import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { loadCorpus, type CorpusModel } from '../corpus/model.js' import { detectConventions } from '../corpus/conventions.js' import { reconcileParse } from '../control-counts.js' import { runRules, selfContradictionWarnings, selfContradictions } from '../engine.js' import { ACTOR_RULES } from '../rules/actors.js' import { RBAC_RULES } from '../rules/rbac.js' import { DM_RULES } from '../rules/dm.js' import { CODE_RULES } from '../rules/code.js' import { UC_RULES } from '../rules/uc.js' import { BR_RULES } from '../rules/br.js' import { SCREEN_RULES } from '../rules/screens.js' import { parseScreenBlocks } from '../rules/screen-blocks.js' import { MENU_RULES } from '../rules/menu.js' import { SECTION_RULES } from '../rules/sections.js' import { stateAttrsOf, XD_RULES } from '../rules/xd.js' import { parseEntityDoc } from '../../../../../lib/ba-entities.js' import { SOURCES_RULES } from '../rules/sources.js' import { buildReport, conventionFindings, exitCodeOf } from '../render/report-json.js' import type { Finding, JudgmentItem } from '../types.js' // ─── Mini-corpus (recette) — 2 apps × 3 modules, totaux CONNUS ───────────── // Attendus déterministes sur le registre COMPLET (10 dimensions) : // errs : DM-013@BUDGET (OwnerId sans relation), RBAC-008@PIPELINE (bloc // lookup absent, 1 FK candidate), RBAC-009 ×3 (miroir floor absent). // warns notables : DM-009@BUDGET (isolée), MENU-006@FIN (module BUDGET = // section budget), SCR-006@CRM (2 modules sans SmartAppHome), // CONV-001 + CONV-002 (conventions projet). // jugements : UC-011 (périmètre) ×3 modules, MENU-001 ×2 apps, XAPP-002/003. function writeMiniCorpus(root: string): void { const w = (rel: string, content: string): void => { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } const uc = (code: string, title: string, entity: string, extra = ''): string => [ `### ${code} — ${title}`, '- **Acteur principal** : BA-001-AC-001 (Commercial)', `- **Préconditions** : le contexte ${entity} existe.`, '- **Flux principal** :', ` 1. L'utilisateur ouvre ${entity} pour « ${title} ».`, ` 2. Il renseigne les informations propres à « ${title} ».`, ` 3. Il confirme « ${title} » et vérifie le résultat.`, '- **Exceptions** :', ' - EXC-1 : données invalides → refus avec message.', `- **Postconditions** : ${entity} à jour.`, '- **Acceptance Criteria** :', ' - [ ] AC-01 — La requête valide renvoie 200.', ' - [ ] AC-02 — Une donnée invalide renvoie 400.', extra, '', ].join('\n') const rule = (code: string, title: string, entity: string, errCode: string, ucs: string): string => [ `### ${code} — ${title}`, '- **Type** : validation', '- **Sévérité** : err', `- **Condition** : à la création d'un ${entity}`, `- **Expression** : \`${entity} valide\``, `- **Code d'erreur** : \`${errCode}\``, '- **Cas valides** : valeur renseignée, longueur max 150 (borne)', `- **Cas invalides** : champ vide → 400 \`${errCode}\` ; doublon insensible à la casse refusé`, `- **Cas d'usage liés** : ${ucs}`, '', ].join('\n') const rbac = (mod: string, sec: string): string => [ `# RBAC — ${mod}`, '', '| Acteur | Permission | Portée | Justification |', '|--------|------------|--------|---------------|', `| BA-001-AC-001 (Commercial) | \`${mod}.${sec}.access\` | toutes | accès |`, `| BA-001-AC-001 (Commercial) | \`${mod}.${sec}.read\` | toutes | lecture |`, '', ].join('\n') const screen = (code: string, title: string, entity: string, perm: string, ucs: string): string => [ `### ${code} — ${title} (SmartListView)`, `- **Entité** : ${entity} (ENT-001)`, `- **Permission** : ${perm}`, `- **Cas d'usage liés** : ${ucs}`, '', ].join('\n') w('CRM/index.md', '# CRM\n\n## Contexte\nGestion de la relation client.\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n') w( 'CRM/acteur.md', ['# Acteurs — CRM', '', '### BA-001-AC-001 — Commercial', '- **Type** : internal', '', '### BA-001-AC-002 — Manager', '- **Type** : internal', ''].join('\n'), ) w('CRM/PIPELINE/index.md', '# Pipeline commercial\n\n## Contexte\nSuivi des opportunités commerciales.\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n') w( 'CRM/PIPELINE/entité.md', [ '### ENT-001 — Opportunity (agrégat racine)', '- **Préfixe table** : `pipe_`', '- **Portée** : strict', '- **Traçabilité** : UC-CRM-PIPELINE-opportunites-001', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Name | string(150) | requis | — |', '| Amount | decimal(18,2) | ≥ 0 | — |', '| ClientId | Guid | requis | — |', '', '- **Relations** : Opportunity *→1 Client — FK ClientId, scope cross-module (CRM/CLIENTS), onDelete restrict.', '- **Index** : (Name).', '', ].join('\n'), ) w('CRM/PIPELINE/règles-métier.md', rule('BR-001', 'Montant positif', 'Opportunity', 'montant-positif', 'UC-CRM-PIPELINE-opportunites-001, UC-CRM-PIPELINE-opportunites-002')) w('CRM/PIPELINE/rbac.md', rbac('pipeline', 'opportunites')) w( 'CRM/PIPELINE/opportunites/use-case.md', [ uc('UC-CRM-PIPELINE-opportunites-001', 'Créer une opportunité', 'Opportunity'), '---', uc('UC-CRM-PIPELINE-opportunites-002', 'Modifier une opportunité', 'Opportunity'), ].join('\n'), ) w( 'CRM/PIPELINE/opportunites/screen.md', screen('SCR-CRM-PIPELINE-opportunites-001', 'Liste des opportunités', 'Opportunity', 'pipeline.opportunites.read', 'UC-CRM-PIPELINE-opportunites-001, UC-CRM-PIPELINE-opportunites-002'), ) w('CRM/CLIENTS/index.md', '# Clients\n\n## Contexte\nAnnuaire des clients.\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n') w( 'CRM/CLIENTS/entité.md', [ '### ENT-001 — Client (agrégat racine)', '- **Préfixe table** : `cli_`', '- **Portée** : strict', '- **Traçabilité** : UC-CRM-CLIENTS-clients-001', '- **Personne** : none — décision client : annuaire de sociétés clientes, pas de comptes.', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Name | string(100) | requis | — |', '', ].join('\n'), ) w('CRM/CLIENTS/règles-métier.md', rule('BR-001', 'Nom requis', 'Client', 'client-nom-requis', 'UC-CRM-CLIENTS-clients-001')) w('CRM/CLIENTS/rbac.md', rbac('clients', 'clients')) w('CRM/CLIENTS/clients/use-case.md', uc('UC-CRM-CLIENTS-clients-001', 'Lister les clients', 'Client')) w('CRM/CLIENTS/clients/screen.md', screen('SCR-CRM-CLIENTS-clients-001', 'Liste des clients', 'Client', 'clients.clients.read', 'UC-CRM-CLIENTS-clients-001')) w('FIN/index.md', '# FIN\n\n## Contexte\nPilotage financier.\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n') w('FIN/acteur.md', ['# Acteurs — FIN', '', '### BA-001-AC-001 — Commercial', '- **Type** : internal', ''].join('\n')) w('FIN/BUDGET/index.md', '# Budget\n\n## Contexte\nSuivi budgétaire.\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n') w( 'FIN/BUDGET/entité.md', [ '### ENT-001 — Budget (agrégat racine)', '- **Préfixe table** : `bud_`', '- **Portée** : strict', '- **Traçabilité** : UC-FIN-BUDGET-budget-001', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Label | string(80) | requis | — |', '| OwnerId | Guid | requis | — |', '', ].join('\n'), ) w('FIN/BUDGET/règles-métier.md', rule('BR-001', 'Libellé requis', 'Budget', 'budget-libelle-requis', 'UC-FIN-BUDGET-budget-001')) w('FIN/BUDGET/rbac.md', rbac('budget', 'budget')) w('FIN/BUDGET/budget/use-case.md', uc('UC-FIN-BUDGET-budget-001', 'Consulter le budget', 'Budget')) w('FIN/BUDGET/budget/screen.md', screen('SCR-FIN-BUDGET-budget-001', 'Liste des budgets', 'Budget', 'budget.budget.read', 'UC-FIN-BUDGET-budget-001')) } const ALL_RULES = [ ...MENU_RULES, ...SECTION_RULES, ...ACTOR_RULES, ...UC_RULES, ...BR_RULES, ...RBAC_RULES, ...DM_RULES, ...SCREEN_RULES, ...XD_RULES, ...CODE_RULES, ] let root: string let model: CorpusModel let findings: Finding[] let judgments: JudgmentItem[] beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'ss-audit-ba-')) writeMiniCorpus(root) model = loadCorpus(root) const out = runRules(ALL_RULES, model, { strict: false, projectRoot: root /* no src/ → greenfield vérifié */ }) findings = [...conventionFindings(model, false), ...out.findings] judgments = out.judgments }) afterAll(() => { rmSync(root, { recursive: true, force: true }) }) describe('audit-ba / corpus model — les totaux de parsing sont PUBLIÉS et exacts', () => { it('recette : retrouve les totaux connus du mini-corpus en une passe', () => { expect(model.totals).toMatchObject({ apps: 2, modules: 3, sections: 3, actors: 3, ucs: 4, acs: 8, rules: 3, errorCodes: 3, distinctErrorCodes: 3, entities: 3, screens: 3, rbacRows: 6, }) }) it('perModule est publié pour chaque module', () => { const pipeline = model.perModule.find((m) => m.module === 'PIPELINE')! expect(pipeline).toMatchObject({ ucs: 2, acs: 4, entities: 1, screens: 1, rbacRows: 2, rules: 1 }) }) it('le graphe projet résout la FK cross-module Opportunity → Client', () => { expect(model.graph.relations.some((r) => r.sourceEntity === 'Opportunity' && r.targetEntity === 'Client')).toBe(true) }) }) describe('audit-ba / fail-closed — contrôle indépendant du parsing', () => { it('corpus sain → status ok, totaux contrôle = totaux parsés', () => { const control = reconcileParse(model) expect(control.status).toBe('ok') expect(control.totals['ucs']).toEqual({ control: 4, parsed: 4 }) expect(control.totals['acs']).toEqual({ control: 8, parsed: 8 }) expect(control.totals['entities']).toEqual({ control: 3, parsed: 3 }) expect(control.totals['screens']).toEqual({ control: 3, parsed: 3 }) expect(control.totals['rules']).toEqual({ control: 3, parsed: 3 }) }) it('un doc dont le contrôle voit des items que le parseur ne rend pas → FATAL (exit 3)', () => { const broken = mkdtempSync(join(tmpdir(), 'ss-audit-ba-broken-')) try { mkdirSync(join(broken, 'APP', 'MOD'), { recursive: true }) writeFileSync(join(broken, 'APP', 'index.md'), '# APP\n') writeFileSync(join(broken, 'APP', 'MOD', 'index.md'), '# MOD\n') writeFileSync(join(broken, 'APP', 'MOD', 'entité.md'), '### ENT-A1 — Chose\n| Attribut | Type | Contraintes | Calculé |\n| Id | Guid | PK | — |\n', 'utf8') const m = loadCorpus(broken) const control = reconcileParse(m) expect(control.status).toBe('fatal') const report = buildReport({ model: m, findings: [], judgments: [], parseControl: control, skippedDimensions: [], warnings: [] }) expect(report.exitClass).toBe('parse-suspect') expect(exitCodeOf(report.exitClass)).toBe(3) } finally { rmSync(broken, { recursive: true, force: true }) } }) }) describe('audit-ba / conventions projet (exigence 8)', () => { it('détecte les conventions du corpus (minuscules 100 %, kebab plat 100 %)', () => { expect(model.conventions.ucSectionCase).toBe('lower') expect(model.conventions.errorCodeShape).toBe('kebab') }) it('émet exactement UN warn CONV-001 et UN warn CONV-002 de portée projet', () => { const conv = findings.filter((f) => f.ruleId.startsWith('CONV-')) expect(conv).toHaveLength(2) expect(conv.every((f) => f.severity === 'warn' && Object.keys(f.scope).length === 0)).toBe(true) }) it('BR-011 est PORTÉ par CONV-002 (ok de suppression), jamais 3 erreurs par occurrence', () => { const br11 = findings.filter((f) => f.ruleId === 'BR-011') expect(br11.every((f) => f.severity === 'ok' && f.message.includes('CONV-002'))).toBe(true) }) it('--strict supprime la tolérance et BR-011 redevient err par occurrence', () => { expect(conventionFindings(model, true)).toEqual([]) const strictOut = runRules(BR_RULES, model, { strict: true }) expect(strictOut.findings.filter((f) => f.ruleId === 'BR-011').some((f) => f.severity === 'err')).toBe(true) }) it('un corpus majoritairement mixte est dit « mixed »', () => { const fake = [ { ucs: [{ ucCode: 'UC-A-B-sec-001' }, { ucCode: 'UC-A-B-SEC-002' }], rules: [] }, { ucs: [{ ucCode: 'UC-A-B-sec-003' }, { ucCode: 'UC-A-B-SEC-004' }], rules: [] }, ] expect(detectConventions(fake as never).ucSectionCase).toBe('mixed') }) }) describe('audit-ba / règles mécaniques — les erreurs ATTENDUES, ni plus ni moins', () => { it('la liste EXACTE des erreurs du mini-corpus', () => { const errs = findings.filter((f) => f.severity === 'err') const ids = errs.map((f) => `${f.ruleId}@${f.scope.module ?? f.scope.app ?? 'project'}`).sort() expect(ids).toEqual(['DM-013@BUDGET', 'RBAC-008@PIPELINE', 'RBAC-009@BUDGET', 'RBAC-009@CLIENTS', 'RBAC-009@PIPELINE']) }) it('DM-013 err : FIN/BUDGET OwnerId:guid sans relation déclarée', () => { const f = findings.find((x) => x.ruleId === 'DM-013' && x.scope.module === 'BUDGET')! expect(f.evidence).toContain('Budget.OwnerId') }) it('warns notables attendus : DM-009@BUDGET, MENU-006@FIN (BUDGET=budget), SCR-006@CRM', () => { expect(findings.find((f) => f.ruleId === 'DM-009' && f.scope.module === 'BUDGET')!.severity).toBe('warn') const menu6 = findings.find((f) => f.ruleId === 'MENU-006' && f.scope.app === 'FIN')! expect(menu6.severity).toBe('warn') expect(menu6.evidence?.[0]).toContain('BUDGET') expect(findings.find((f) => f.ruleId === 'SCR-006' && f.scope.app === 'CRM')!.severity).toBe('warn') }) it('la couverture UC → surface (SCR-024, moteur deriveUcCoverage) est complète', () => { expect(findings.filter((f) => f.ruleId === 'SCR-024').every((f) => f.severity === 'ok')).toBe(true) }) it('BR-007 : chaque UC est couvert par ≥ 1 règle liée', () => { expect(findings.filter((f) => f.ruleId === 'BR-007').every((f) => f.severity === 'ok')).toBe(true) }) it('UC-022 : la parité EXC ↔ AC passe (AC-02 négatif couvre EXC-1)', () => { expect(findings.filter((f) => f.ruleId === 'UC-022').every((f) => f.severity === 'ok')).toBe(true) }) it('SEC-007 : tous les codes aval résolvent vers une section existante', () => { expect(findings.filter((f) => f.ruleId === 'SEC-007').every((f) => f.severity === 'ok')).toBe(true) }) it('CODE-001..004 : greenfield VÉRIFIÉ (projectRoot fourni) → ok ; sans projectRoot → warn fail-closed', () => { expect(findings.filter((f) => f.ruleId === 'CODE-001').every((f) => f.severity === 'ok' && f.message.includes('greenfield'))).toBe(true) const out = runRules(CODE_RULES, model, { strict: false }) expect(out.findings.filter((f) => f.ruleId === 'CODE-004').every((f) => f.severity === 'warn' && f.message.includes('projectRoot absent'))).toBe(true) }) }) describe('audit-ba / jugement — extraits compacts, jamais un doc entier', () => { it('UC-011 (périmètre métier) émet UN jugement par module avec le Contexte + les titres', () => { const uc11 = judgments.filter((j) => j.ruleId === 'UC-011') expect(uc11).toHaveLength(3) expect(uc11[0]!.maxSeverity).toBe('err') expect(uc11[0]!.excerpts.join('\n')).toContain('## Contexte') }) it('MENU-001 émet un jugement par application ; XAPP-002/003 un chacun au projet', () => { expect(judgments.filter((j) => j.ruleId === 'MENU-001')).toHaveLength(2) expect(judgments.filter((j) => j.ruleId === 'XAPP-002')).toHaveLength(1) expect(judgments.filter((j) => j.ruleId === 'XAPP-003')).toHaveLength(1) }) it('chaque extrait de jugement reste COMPACT (≤ 2 Ko)', () => { for (const j of judgments) { for (const e of j.excerpts) expect(e.length).toBeLessThanOrEqual(2048) } }) it('les règles de jugement pur n’émettent JAMAIS d’err mécanique', () => { const judgmentRuleIds = new Set(['UC-011', 'UC-023', 'BR-012', 'MENU-001', 'MENU-005', 'SEC-003', 'SEC-004', 'XAPP-002', 'XAPP-003']) const mechanical = findings.filter((f) => judgmentRuleIds.has(f.ruleId)) expect(mechanical.every((f) => f.severity !== 'err')).toBe(true) }) }) describe('audit-ba / report — classes de sortie', () => { it('le rapport porte exitClass err → exit 2, ruleset + hash publiés, jugements comptés', () => { const control = reconcileParse(model) const report = buildReport({ model, findings, judgments, parseControl: control, skippedDimensions: [], warnings: [] }) expect(report.exitClass).toBe('err') expect(exitCodeOf(report.exitClass)).toBe(2) expect(report.rulesetVersion).toMatch(/^\d+\.\d+\.\d+$/) expect(report.sourcesHash).toBeTruthy() expect(report.counts.judgment).toBe(judgments.length) }) it('un corpus sans err ni warn sort en 0 ; warn-only en 1', () => { expect(exitCodeOf('ok')).toBe(0) expect(exitCodeOf('warn')).toBe(1) }) }) describe('audit-ba / collision Core (fixture dédiée)', () => { it('une entité « Organisation » collisionne Core TenantOrganisation → DM-018 err + CODE-005 err (dedup)', () => { const dir = mkdtempSync(join(tmpdir(), 'ss-audit-ba-core-')) try { mkdirSync(join(dir, 'APP', 'MOD'), { recursive: true }) writeFileSync(join(dir, 'APP', 'index.md'), '# APP\n') writeFileSync(join(dir, 'APP', 'MOD', 'index.md'), '# MOD\n') writeFileSync( join(dir, 'APP', 'MOD', 'entité.md'), '### ENT-001 — Organisation (agrégat racine)\n- **Préfixe table** : `org_`\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n| Name | string(100) | requis | — |\n', 'utf8', ) const m = loadCorpus(dir) const out = runRules([...DM_RULES, ...CODE_RULES], m, { strict: false }) const dm = out.findings.find((f) => f.ruleId === 'DM-018')! const code = out.findings.find((f) => f.ruleId === 'CODE-005')! expect(dm.severity).toBe('err') expect(code.severity).toBe('err') expect(code.dedupOf).toBe('DM-018') expect(dm.evidence?.[0]).toContain('TenantOrganisation') } finally { rmSync(dir, { recursive: true, force: true }) } }) }) describe('audit-ba / DM-021 — code-like classification (near-miss + synonym attributes)', () => { let root21: string beforeAll(() => { root21 = mkdtempSync(join(tmpdir(), 'audit-ba-dm021-')) const mod = join(root21, 'APP', 'MOD') mkdirSync(mod, { recursive: true }) writeFileSync(join(root21, 'index.md'), '# Projet\n') writeFileSync(join(root21, 'APP', 'index.md'), '# APP\n') writeFileSync(join(mod, 'index.md'), '# MOD\n') writeFileSync(join(mod, 'entité.md'), `# Modèle de données — APP / MOD ### ENT-001 — Catalogue (agrégat racine) - **Préfixe table** : \`cat_\` | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Reference | string/50 | unique | — | | Code pattern | \`CAT-{SEQ:4}\` — scope tenant | ### ENT-002 — Client (agrégat racine) - **Préfixe table** : \`cli_\` | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Matricule | string/20 | unique | — | | NumeroTva | string/30 | unique | — | - **Code saisi** : NumeroTva — identifiant fiscal fourni par le client. `) }) afterAll(() => rmSync(root21, { recursive: true, force: true })) it('err on the table-cell near-miss + the co-signalled attribute; warn on the lone unclassified one; the marked one is silent', () => { const m21 = loadCorpus(root21) const { findings } = runRules(DM_RULES, m21, { strict: false }) const dm21 = findings.filter((f) => f.ruleId === 'DM-021') const err = dm21.find((f) => f.severity === 'err')! const warn = dm21.find((f) => f.severity === 'warn')! expect(err.evidence!.join(' ')).toContain('Catalogue:near-miss:table-cell') expect(err.evidence!.join(' ')).toContain('Catalogue.Reference:code-like-near-miss') expect(warn.evidence!.join(' ')).toContain('Client.Matricule:code-like-unclassified') expect(dm21.flatMap((f) => f.evidence ?? []).join(' ')).not.toContain('NumeroTva') }) it('ok (≥1 finding per rule, DM-021 included) on a clean module', () => { const clean = mkdtempSync(join(tmpdir(), 'audit-ba-dm021ok-')) const mod = join(clean, 'APP', 'MOD') mkdirSync(mod, { recursive: true }) writeFileSync(join(mod, 'entité.md'), '### ENT-001 — Facture (agrégat racine)\n- **Code pattern** : `FAC-{SEQ:4}` — scope tenant.\n') const mClean = loadCorpus(clean) const { findings } = runRules(DM_RULES, mClean, { strict: false }) const dm21 = findings.filter((f) => f.ruleId === 'DM-021') expect(dm21).toHaveLength(1) expect(dm21[0]!.severity).toBe('ok') rmSync(clean, { recursive: true, force: true }) }) }) describe('audit-ba / DM-022 — a reference value does not carry a code', () => { const REF_MODULE = `# Modèle de données — APP / MOD ### ENT-001 — MotifRetrait (lookup) - **Préfixe table** : \`cat_\` | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string/50 | unique | — | | Label | string/150 | requis | — | - **Index** : (Code) unique. - **Valeurs initiales** : clé \`Code\` — les motifs : | Code | Label | |------|-------| | REMPLACEMENT | Remplacement | ### ENT-002 — UniteVente (lookup) - **Code décidé** : l'unité imprimée sur le bon de livraison — décision utilisateur du 2026-09-01 | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string/10 | unique | — | | Label | string/80 | requis | — | - **Valeurs initiales** : clé \`Code\` — les unités : | Code | Label | |------|-------| | VTE_KILO | Kilogramme | ### ENT-003 — TypeExpression (lookup) - **Code décidé** : le code du type, sans date | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string/50 | unique | — | | Label | string/150 | requis | — | ### ENT-004 — StatutArticle (lookup) - **Code pattern** : \`ST-{SEQ:3}\` — scope tenant, reset none, gapless. | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string/50 | unique | — | | Label | string/150 | requis | — | ### ENT-005 — NatureGrille (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Label | string/150 | requis | — | | IsActive | bool | défaut true | — | ` function corpusWith(entite: string, extra?: (mod: string) => void): string { const root = mkdtempSync(join(tmpdir(), 'audit-ba-dm022-')) const mod = join(root, 'APP', 'MOD') mkdirSync(mod, { recursive: true }) writeFileSync(join(root, 'index.md'), '# Projet\n') writeFileSync(join(root, 'APP', 'index.md'), '# APP\n') writeFileSync(join(mod, 'index.md'), '# MOD\n') writeFileSync(join(mod, 'entité.md'), entite) extra?.(mod) return root } const dm022 = (root: string) => { const { findings } = runRules(DM_RULES, loadCorpus(root), { strict: false }) return findings.filter((f) => f.ruleId === 'DM-022') } it('errs on the undecided code, the allocated code and the undated decision — and stays SILENT on the dated one', () => { const root = corpusWith(REF_MODULE) const found = dm022(root) const err = found.find((f) => f.severity === 'err')! const ev = err.evidence!.join(' ') expect(ev).toContain('MotifRetrait.Code:code-without-decision') expect(ev).toContain('StatutArticle:allocated-code-on-reference') expect(ev).toContain('TypeExpression:decided-near-miss:missing-date') // The dated decision overrides the rule — no consolation warn, nothing. expect(found.flatMap((f) => f.evidence ?? []).join(' ')).not.toContain('UniteVente') // A reference table with no code at all is simply not mentioned. expect(ev).not.toContain('NatureGrille') rmSync(root, { recursive: true, force: true }) }) it('the undecided code carries its citation inventory as evidence', () => { const root = corpusWith(REF_MODULE, (mod) => { writeFileSync( join(mod, 'prd.api.md'), '# Phase: API\n- POST /articles/{id}/retirer — payload `{ ReasonCode: "REMPLACEMENT" }`\n', ) }) const err = dm022(root).find((f) => f.severity === 'err')! expect(err.evidence!.join(' ')).toContain('1 citation(s) : prd.api.md') rmSync(root, { recursive: true, force: true }) }) it('reads the PRD — the third source nothing else covers', () => { // Same module WITHOUT the PRD: the very same code is reported as searched // and uncited. That difference is the whole reason the source exists. const root = corpusWith(REF_MODULE) const err = dm022(root).find((f) => f.severity === 'err')! expect(err.evidence!.join(' ')).toContain('valeur(s) de code cherchée(s)') expect(err.evidence!.join(' ')).toContain('aucune citée') rmSync(root, { recursive: true, force: true }) }) it('never claims « nothing depends on this code » when nothing was SEARCHED', () => { // A reference table whose rows the BA left in prose: no **Valeurs // initiales**, so no code value to look for. The finding must not read as // a clearance, and the backfill must not strip on that basis. const noSeed = `# Modèle de données — APP / MOD ### ENT-001 — SansValeursInitiales (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string/50 | unique | — | | Label | string/150 | requis | — | ` const root = corpusWith(noSeed) const ev = dm022(root).find((f) => f.severity === 'err')!.evidence!.join(' ') expect(ev).toContain('0 valeur(s) de code cherchée(s)') rmSync(root, { recursive: true, force: true }) }) it('the « dead decision » warn stays MUTE while no PRD exists, and says the inventory is partial', () => { // /ba-audit-data-model legitimately runs BEFORE /ba-create-prd: without // this guard the rule would cry « dead decision » on every normal module. const decidedOnly = `# Modèle de données — APP / MOD ### ENT-001 — UniteVente (lookup) - **Code décidé** : l'unité imprimée — décision utilisateur du 2026-09-01 | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string/10 | unique | — | | Label | string/80 | requis | — | - **Valeurs initiales** : clé \`Code\` — les unités : | Code | Label | |------|-------| | VTE_KILO | Kilogramme | ` const root = corpusWith(decidedOnly) const found = dm022(root) expect(found).toHaveLength(1) expect(found[0]!.severity).toBe('ok') expect(found[0]!.message).toContain('PARTIEL') rmSync(root, { recursive: true, force: true }) // With a PRD that cites nothing, the decision IS reported as uncited. const withPrd = corpusWith(decidedOnly, (mod) => { writeFileSync(join(mod, 'prd.api.md'), '# Phase: API\n- GET /unites\n') }) const warn = dm022(withPrd).find((f) => f.severity === 'warn')! expect(warn.evidence!.join(' ')).toContain('UniteVente:decided-code-uncited') rmSync(withPrd, { recursive: true, force: true }) }) it('DM-015 no longer asks a reference table for a code, and DM-012 goes silent where DM-022 errs', () => { const root = corpusWith(REF_MODULE) const { findings } = runRules(DM_RULES, loadCorpus(root), { strict: false }) // DM-015 must never list `code` as a missing convention field again. const dm15 = findings.filter((f) => f.ruleId === 'DM-015') expect(dm15.flatMap((f) => f.evidence ?? []).join(' ')).not.toMatch(/:\s*code\b/) // MotifRetrait HAS its (Code) unique index, so DM-012 would be silent on it // anyway; StatutArticle has a code and NO index — DM-022 errs on it, so // DM-012 must not propose hardening the column being removed. const dm12 = findings.filter((f) => f.ruleId === 'DM-012') expect(dm12.flatMap((f) => f.evidence ?? []).join(' ')).not.toContain('StatutArticle') rmSync(root, { recursive: true, force: true }) }) it('emits exactly one ok finding on a module with no reference table', () => { const root = corpusWith( '### ENT-001 — Facture (agrégat racine)\n- **Code pattern** : `FAC-{SEQ:4}` — scope tenant.\n', ) const found = dm022(root) expect(found).toHaveLength(1) expect(found[0]!.severity).toBe('ok') rmSync(root, { recursive: true, force: true }) }) }) describe('audit-ba / SCR-015 — supplied-on-create relaxes the CREATE surface only', () => { let root15: string function corpus(codeLine: string): string { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-scr015-')) const mod = join(dir, 'APP', 'MOD') mkdirSync(join(mod, 'factures'), { recursive: true }) writeFileSync(join(mod, 'entité.md'), [ '### ENT-001 — Facture (agrégat racine)', codeLine, '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Label | string(100) | requis | — |', '', ].join('\n')) writeFileSync(join(mod, 'factures', 'screen.md'), [ '### SCR-APP-MOD-factures-002 — Créer une facture (SmartForm)', '- **Entité** : Facture (ENT-001)', '- **Mode** : create', '- **Permission** : `mod.factures.create`', '- **Champs** : code (text, optionnel), Label (text, requis)', '', '### SCR-APP-MOD-factures-003 — Modifier une facture (SmartForm)', '- **Entité** : Facture (ENT-001)', '- **Mode** : edit', '- **Permission** : `mod.factures.update`', '- **Champs** : code (text), Label (text, requis)', '', ].join('\n')) return dir } afterAll(() => { if (root15) rmSync(root15, { recursive: true, force: true }) }) it('facette déclarée : le create passe, l’edit éditable reste un err', () => { root15 = corpus('- **Code pattern** : `FAC-{SEQ:4}` — scope tenant, surchargeable à la création.') const m15 = loadCorpus(root15) const { findings } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-015'), m15, { strict: false }) const errs = findings.filter((f) => f.ruleId === 'SCR-015' && f.severity === 'err') expect(errs).toHaveLength(1) expect(errs[0]!.evidence!.join(' ')).toContain('factures-003') expect(errs[0]!.evidence!.join(' ')).not.toContain('factures-002') rmSync(root15, { recursive: true, force: true }) }) it('sans la facette, le create reste un err (comportement historique)', () => { root15 = corpus('- **Code pattern** : `FAC-{SEQ:4}` — scope tenant.') const m15 = loadCorpus(root15) const { findings } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-015'), m15, { strict: false }) const errs = findings.filter((f) => f.ruleId === 'SCR-015' && f.severity === 'err') expect(errs).toHaveLength(1) expect(errs[0]!.evidence!.join(' ')).toContain('factures-002') }) }) // ─── Signalement DemoGestionFlotte (2026-09-02) — la grammaire de référence met // des virgules DANS les parenthèses (`code (text, requis)`) : le compteur ne // double plus (SCR-017/018), les preuves restent entières (SCR-019), readonly // redevient lisible (SCR-015). Moteur : lib/ba-list-split. ────────────────── describe('audit-ba / SCR-017 — les virgules parenthésées ne gonflent plus les listes', () => { function corpus17(indicateurs: string, filtres: string): string { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-scr017-')) const mod = join(dir, 'APP', 'MOD') mkdirSync(join(mod, 'parc'), { recursive: true }) writeFileSync(join(mod, 'parc', 'screen.md'), [ '### SCR-APP-MOD-parc-002 — Liste des véhicules (SmartListView)', '- **Entité** : Vehicule (ENT-001)', '- **Permission** : `mod.parc.read`', '- **Colonnes** : plate (text, tri), model (text, tri), status (badge: active/inactive), canton (text), mileage (number)', `- **Filtres** : ${filtres}`, `- **Indicateurs** : ${indicateurs}`, '', ].join('\n')) return dir } it('6 filtres « (lookup, entity X) » restent 6 — plus de faux « > 8 »', () => { const dir = corpus17( 'total (count), actifs (count, status = active)', 'vehicleTypeId (lookup, entity VehicleType), siteId (lookup, entity Site), status (select), canton (select), dateFrom (date), dateTo (date)', ) const m = loadCorpus(dir) const { findings: f17 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-017'), m, { strict: false }) expect(f17.filter((f) => f.severity === 'warn')).toEqual([]) rmSync(dir, { recursive: true, force: true }) }) it('le contrôle « indicateur scopé » revit : scope hors Filtres déclarés → warn', () => { const dir = corpus17('total (count), enRetard (count, overdue = true)', 'status (select)') const m = loadCorpus(dir) const { findings: f17 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-017'), m, { strict: false }) const warn = f17.find((f) => f.severity === 'warn') expect(warn).toBeDefined() expect(warn!.evidence!.join(' ')).toContain('overdue') rmSync(dir, { recursive: true, force: true }) }) }) describe('audit-ba / SCR-018 — le mur de champs compte les VRAIS champs', () => { function corpus18(champsLine: string): string { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-scr018-')) const mod = join(dir, 'APP', 'MOD') mkdirSync(join(mod, 'cantons'), { recursive: true }) writeFileSync(join(mod, 'cantons', 'screen.md'), [ '### SCR-APP-MOD-cantons-002 — Créer un canton (SmartForm)', '- **Entité** : Canton (ENT-001)', '- **Mode** : create', '- **Permission** : `mod.cantons.create`', `- **Champs** : ${champsLine}`, '', ].join('\n')) return dir } it('les 7 champs du signalement (cantons) ne sont plus un mur de 13', () => { const dir = corpus18( 'Code (text, requis), Label (text, requis), VehicleRegistryName (text, requis), ' + 'MaxPlateDepositMonths (number, requis), MonthlyDepositFee (number, requis), ' + 'IsActive (boolean), SortOrder (number, requis)', ) const m = loadCorpus(dir) const { findings: f18 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-018'), m, { strict: false }) expect(f18.filter((f) => f.severity === 'warn')).toEqual([]) rmSync(dir, { recursive: true, force: true }) }) it("un « typoté ne mute pas le mur : la garde d'équilibre re-split bruyamment", () => { const dir = corpus18( 'label («titre incomplet, ' + Array.from({ length: 7 }, (_, i) => `Field${i + 1} (text, requis)`).join(', '), ) const m = loadCorpus(dir) const { findings: f18 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-018'), m, { strict: false }) expect(f18.find((f) => f.severity === 'warn')).toBeDefined() rmSync(dir, { recursive: true, force: true }) }) it('un vrai mur (11 champs) reste détecté, avec le bon chiffre', () => { const dir = corpus18( Array.from({ length: 11 }, (_, i) => `Field${i + 1} (text, requis)`).join(', '), ) const m = loadCorpus(dir) const { findings: f18 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-018'), m, { strict: false }) const warn = f18.find((f) => f.severity === 'warn') expect(warn).toBeDefined() expect(warn!.evidence!.join(' ')).toContain('11 champs à plat') rmSync(dir, { recursive: true, force: true }) }) }) describe('audit-ba / SCR-019 — preuves entières, readonly respecté', () => { function corpus19(champsLine: string): string { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-scr019-')) const mod = join(dir, 'APP', 'MOD') mkdirSync(join(mod, 'echeances'), { recursive: true }) writeFileSync(join(mod, 'entité.md'), [ '### ENT-001 — Echeance (agrégat racine)', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| RecurrenceRegime | enum | Durée / Kilométrage / Sans récurrence, requis | — |', '| Status | enum | draft/active, requis | — |', '| Label | string(100) | requis | — |', '', ].join('\n')) writeFileSync(join(mod, 'echeances', 'screen.md'), [ '### SCR-APP-MOD-echeances-002 — Créer une échéance (SmartForm)', '- **Entité** : Echeance (ENT-001)', '- **Mode** : create', '- **Permission** : `mod.echeances.create`', `- **Champs** : ${champsLine}`, '', ].join('\n')) return dir } it("le champ d'état est cité en ENTIER — parenthèse fermante comprise", () => { const dir = corpus19( 'RecurrenceRegime (select : Durée / Kilométrage / Sans récurrence, requis), Label (text, requis)', ) const m = loadCorpus(dir) const { findings: f19 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-019'), m, { strict: false }) const warn = f19.find((f) => f.severity === 'warn') expect(warn).toBeDefined() expect(warn!.evidence!.join(' ')).toContain('Sans récurrence, requis)') rmSync(dir, { recursive: true, force: true }) }) it("un champ d'état readonly ne warne plus (le fragment fantôme a disparu)", () => { const dir = corpus19('Status (enum, readonly), Label (text, requis)') const m = loadCorpus(dir) const { findings: f19 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-019'), m, { strict: false }) expect(f19.filter((f) => f.severity === 'warn')).toEqual([]) rmSync(dir, { recursive: true, force: true }) }) }) describe('audit-ba / SCR-015 — readonly traverse la virgule parenthésée', () => { it('code (text, readonly) en edit sur entité codée : plus de faux err', () => { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-scr015ro-')) const mod = join(dir, 'APP', 'MOD') mkdirSync(join(mod, 'factures'), { recursive: true }) writeFileSync(join(mod, 'entité.md'), [ '### ENT-001 — Facture (agrégat racine)', '- **Code pattern** : `FAC-{SEQ:4}` — scope tenant.', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Label | string(100) | requis | — |', '', ].join('\n')) writeFileSync(join(mod, 'factures', 'screen.md'), [ '### SCR-APP-MOD-factures-003 — Modifier une facture (SmartForm)', '- **Entité** : Facture (ENT-001)', '- **Mode** : edit', '- **Permission** : `mod.factures.update`', '- **Champs** : code (text, readonly), Label (text, requis)', '', ].join('\n')) const m = loadCorpus(dir) const { findings: f15 } = runRules(SCREEN_RULES.filter((r) => r.id === 'SCR-015'), m, { strict: false }) expect(f15.filter((f) => f.severity === 'err')).toEqual([]) rmSync(dir, { recursive: true, force: true }) }) }) describe('audit-ba / screen-blocks + xd — parseurs conscients des parenthèses', () => { it('parseActionPairs préserve un label « a, b » et un payloadParameters multi-entrées', () => { const blocks = parseScreenBlocks( [ '### SCR-APP-MOD-x-001 — Liste (SmartListView)', '- **Actions custom** :', ' - `veh.assign` — kind: api, scope: row, endpoint: `assign`, httpMethod: POST, label: « Assigner, libérer », payloadParameters: vehicleId (lookup, entity Vehicle), notes (textarea), UC: UC-APP-MOD-x-001', '', ].join('\n'), 'x/screen.md', ) expect(blocks[0]!.actions[0]).toMatchObject({ code: 'veh.assign', kind: 'api', scope: 'row', endpoint: 'assign', httpMethod: 'POST', label: 'Assigner, libérer', uc: 'UC-APP-MOD-x-001', }) }) it('stateAttrsOf ne fabrique plus de valeur fantôme depuis une facette', () => { const { entities } = parseEntityDoc( [ '### ENT-001 — Dossier (agrégat racine)', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Status | enum | draft/submitted/approved, requis | — |', '', ].join('\n'), 'APP/MOD', ) expect(stateAttrsOf(entities[0]!)[0]!.enumValues).toEqual(['draft', 'submitted', 'approved']) }) it('une énum écrite à la virgule (hors grammaire) ne désengage PAS le leg XD', () => { const { entities } = parseEntityDoc( [ '### ENT-001 — Dossier (agrégat racine)', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Status | enum | brouillon, actif, clos, requis | — |', '', ].join('\n'), 'APP/MOD', ) const values = stateAttrsOf(entities[0]!)[0]!.enumValues expect(values.length).toBeGreaterThan(0) expect(values).toContain('brouillon') }) }) describe('audit-ba / DM-017 check 7 — supplied + {SEQ} = deferred-collision warn', () => { it('warns on the sequential corridor, stays silent on a derived (no-{SEQ}) supplied format', () => { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-dm017c7-')) const mod = join(dir, 'APP', 'MOD') mkdirSync(mod, { recursive: true }) writeFileSync(join(mod, 'entité.md'), [ '### ENT-001 — Facture (agrégat racine)', '- **Code pattern** : `FAC-{SEQ:4}` — scope tenant, surchargeable à la création.', '', '### ENT-002 — Slug (agrégat racine)', '- **Code pattern** : `S-{SLUG:Name}` — scope tenant, surchargeable à la création.', '', ].join('\n')) const m17 = loadCorpus(dir) const { findings } = runRules(DM_RULES.filter((r) => r.id === 'DM-017'), m17, { strict: false }) const warn = findings.find((f) => f.ruleId === 'DM-017' && f.severity === 'warn')! expect(warn.evidence!.join(' ')).toContain('Facture:supplied-sequential-corridor') expect(warn.evidence!.join(' ')).not.toContain('Slug:supplied-sequential-corridor') rmSync(dir, { recursive: true, force: true }) }) }) describe('audit-ba / report — remédiation attachée (Inc 2, sur le VRAI mini-corpus)', () => { it('chaque finding sort avec un remède nommé — aucune impasse silencieuse', () => { const report = buildReport({ model, findings, judgments, parseControl: reconcileParse(model), skippedDimensions: [], warnings: [], }) expect(report.findings.length).toBeGreaterThan(0) expect(report.findings.every((f) => f.remedy !== undefined)).toBe(true) expect(report.findings.every((f) => (f.remedy?.solution.length ?? 0) > 0)).toBe(true) }) it('RBAC-008/009 sortent en voie derive, avec le CLI et son mode d écriture', () => { const report = buildReport({ model, findings, judgments, parseControl: reconcileParse(model), skippedDimensions: [], warnings: [], }) const rbac = report.findings.filter( (f) => (f.ruleId === 'RBAC-008' || f.ruleId === 'RBAC-009') && f.severity === 'err', ) expect(rbac.length).toBeGreaterThan(0) for (const f of rbac) { expect(f.remedy?.lane).toBe('derive') expect(f.remedy?.cliPath).toContain('/cli/derive-') expect(f.remedy?.writeMode).toBe('derive') // Le doc à corriger est le rbac.md du module, jamais le verdict _audit/. expect(f.file).toMatch(/\/rbac\.md$/) expect(f.file).not.toContain('_audit/') } }) it('un finding de modèle de données pointe entité.md et route vers son skill d authoring', () => { const report = buildReport({ model, findings, judgments, parseControl: reconcileParse(model), skippedDimensions: [], warnings: [], }) const dm = report.findings.find((f) => f.ruleId === 'DM-013' && f.severity === 'err') expect(dm).toBeDefined() expect(dm!.file).toMatch(/entité\.md$/) expect(dm!.remedy?.lane).toBe('authoring') expect(dm!.remedy?.target).toBe('ba-create-data-model') }) }) describe('audit-ba / MENU-007 + MENU-008 — Contexte substantiel, Hors-périmètre déclaré', () => { function menuFindings(appIndex: string, moduleIndex: string, sectionIndex?: string): Finding[] { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-menu78-')) try { mkdirSync(join(dir, 'APP', 'MOD', 'sec'), { recursive: true }) writeFileSync(join(dir, 'index.md'), '# Projet\n', 'utf8') writeFileSync(join(dir, 'APP', 'index.md'), appIndex, 'utf8') writeFileSync(join(dir, 'APP', 'MOD', 'index.md'), moduleIndex, 'utf8') writeFileSync(join(dir, 'APP', 'MOD', 'sec', 'index.md'), sectionIndex ?? '# Sec\n\n## Contexte\nListe des éléments de la section.\n', 'utf8') return runRules(MENU_RULES, loadCorpus(dir), { strict: false }).findings } finally { rmSync(dir, { recursive: true, force: true }) } } const RICH_CTX = '## Contexte\nApplication de pilotage des ventes pour les commerciaux et leurs managers. ' + 'Elle couvre la prospection et le devis, et s’arrête à la signature du contrat.\n' const HP_OK = '## Hors-périmètre\n- La facturation est couverte ailleurs.\n' const HP_MARKER = '## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n' it('MENU-007 err quand un Contexte requis manque, warn quand il est maigre', () => { const missing = menuFindings('# APP\n\n' + HP_OK, '# MOD\n\n' + HP_MARKER) const err = missing.find((f) => f.ruleId === 'MENU-007' && f.severity === 'err')! expect(err.evidence!.join(' ')).toContain('Application APP') expect(err.evidence!.join(' ')).toContain('Module APP/MOD') const thin = menuFindings('# APP\n\n## Contexte\nVentes.\n' + HP_OK, '# MOD\n\n' + RICH_CTX + HP_MARKER) const warn = thin.find((f) => f.ruleId === 'MENU-007' && f.severity === 'warn')! expect(warn.evidence!.join(' ')).toContain('caractères') expect(thin.some((f) => f.ruleId === 'MENU-007' && f.severity === 'err')).toBe(false) }) it('MENU-007 ok quand chaque nœud requis porte un Contexte substantiel', () => { const ok = menuFindings('# APP\n\n' + RICH_CTX + HP_OK, '# MOD\n\n' + RICH_CTX + HP_MARKER) expect(ok.filter((f) => f.ruleId === 'MENU-007')).toEqual([ expect.objectContaining({ severity: 'ok' }), ]) }) it('MENU-008 err quand app/module ne déclarent pas leur Hors-périmètre (absent OU vide)', () => { const absent = menuFindings('# APP\n\n' + RICH_CTX, '# MOD\n\n' + RICH_CTX) const err = absent.find((f) => f.ruleId === 'MENU-008' && f.severity === 'err')! expect(err.evidence).toHaveLength(2) expect(err.evidence!.join(' ')).toContain('ABSENT') const empty = menuFindings('# APP\n\n' + RICH_CTX + '## Hors-périmètre\n\n', '# MOD\n\n' + RICH_CTX + HP_MARKER) const err2 = empty.find((f) => f.ruleId === 'MENU-008' && f.severity === 'err')! expect(err2.evidence!.join(' ')).toContain('vide') }) it('MENU-008 : le marqueur vide explicite vaut déclaration, et MENU-005 le traite comme « rien à confronter »', () => { const ok = menuFindings( '# APP\n\n' + RICH_CTX + HP_MARKER, '# MOD\n\n' + RICH_CTX + HP_MARKER, '# Sec\n\n## Contexte\nListe des éléments de la section.\n' + HP_MARKER, ) expect(ok.some((f) => f.ruleId === 'MENU-008' && f.severity !== 'ok')).toBe(false) // marker-only declarations leave MENU-005 « sans objet » — no judgment emitted expect(ok.find((f) => f.ruleId === 'MENU-005')!.message).toContain('sans objet') }) it('MENU-008 warn (jamais err) quand seule une SECTION omet le Hors-périmètre', () => { const f = menuFindings( '# APP\n\n' + RICH_CTX + HP_OK, '# MOD\n\n' + RICH_CTX + HP_MARKER, '# Sec\n\n## Contexte\nListe des éléments de la section.\n', ) const m8 = f.filter((x) => x.ruleId === 'MENU-008') expect(m8.some((x) => x.severity === 'err')).toBe(false) expect(m8.find((x) => x.severity === 'warn')!.evidence!.join(' ')).toContain('Section APP/MOD/sec') }) }) describe('audit-ba / SRC-001..007 — le registre de sources et ses citations', () => { const SOURCE_MD = [ '', '# SRC-001 — Cahier des charges', '', '## Métadonnées', '- **Origine** : `docs/cdc.pdf` (copie : `raw/cdc.pdf`)', '- **Format** : pdf · **Ingéré le** : 2026-09-02', '- **Tags** : regles', '- **Portée pressentie** : APP/MOD', '', '## Résumé', 'Le client fixe la politique de remise et son circuit de validation.', '', '## Points saillants', '### §1 — Processus [processus]', 'Cycle en 4 étapes.', '### §2 — Plafond de remise [regles] — extrait verbatim', '> « Toute remise supérieure à 20 % requiert la validation du manager. »', '', '## Ce que cette source ne couvre PAS', '- Rien sur les permissions.', '', ].join('\n') function entry(over: Record = {}): Record { return { code: 'SRC-001', kind: 'file', title: 'Cahier des charges', fingerprint: 'a1b2c3d4e5f6', origin: { path: 'docs/cdc.pdf' }, format: 'pdf', ingestedAt: '2026-09-02', updatedAt: '2026-09-02', status: 'ingested', tags: ['regles'], scopes: ['APP/MOD'], summary: 'Politique de remise.', sections: 2, extracts: 1, ...over, } } /** ba/ + sources/ SIBLINGS under one tmp root — the exact deployed layout. */ function srcCorpus(opts: { registry?: { sources: Record; nextSeq?: number } | null sourceMd?: Record ucSources?: string }): { root: string; model: CorpusModel } { const root = mkdtempSync(join(tmpdir(), 'audit-ba-src-')) const ba = join(root, 'ba') mkdirSync(join(ba, 'APP', 'MOD', 'sec'), { recursive: true }) const HP = '\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n' writeFileSync(join(ba, 'index.md'), '# Projet\n', 'utf8') writeFileSync(join(ba, 'APP', 'index.md'), '# APP\n\n## Contexte\nPilotage des ventes pour les équipes commerciales, de la prospection au devis signé.\n' + HP, 'utf8') writeFileSync(join(ba, 'APP', 'MOD', 'index.md'), '# MOD\n\n## Contexte\nSuivi des remises et validations commerciales du module.\n' + HP, 'utf8') writeFileSync(join(ba, 'APP', 'MOD', 'sec', 'index.md'), '# Sec\n\n## Contexte\nListe des remises.\n', 'utf8') writeFileSync( join(ba, 'APP', 'MOD', 'sec', 'use-case.md'), '\n# Cas d\u2019usage\n\n### UC-APP-MOD-SEC-001 — Valider une remise\n- **Acteur principal** : BA-001-AC-001\n- **Postconditions** : remise validée.\n' + (opts.ucSources ?? '') + '- **Acceptance Criteria** :\n - [ ] AC-01 — POST valide renvoie 201.\n', 'utf8', ) if (opts.registry !== null) { const reg = opts.registry ?? { sources: { 'SRC-001': entry() } } const srcRoot = join(root, 'sources') const docs = opts.sourceMd ?? { 'SRC-001': SOURCE_MD } mkdirSync(srcRoot, { recursive: true }) writeFileSync(join(srcRoot, 'index.json'), JSON.stringify({ version: 1, nextSeq: reg.nextSeq ?? 2, sources: reg.sources }), 'utf8') for (const [code, md] of Object.entries(docs)) { mkdirSync(join(srcRoot, code), { recursive: true }) writeFileSync(join(srcRoot, code, 'source.md'), md, 'utf8') } } return { root, model: loadCorpus(ba) } } const srcFindings = (model: CorpusModel): Finding[] => runRules(SOURCES_RULES, model, { strict: false }).findings const byRule = (fs: Finding[], id: string): Finding[] => fs.filter((f) => f.ruleId === id) it('registre sain + citation résolue : tout est ok et les COMPTES sont dits', () => { const { root, model } = srcCorpus({ ucSources: '- **Sources** : SRC-001 §2\n' }) try { const fs = srcFindings(model) expect(fs.some((f) => f.severity === 'err')).toBe(false) expect(byRule(fs, 'SRC-001')[0]!.message).toContain('1 source(s)') const src4 = byRule(fs, 'SRC-004').find((f) => f.scope.app === 'APP')! expect(src4.severity).toBe('ok') expect(src4.message).toContain('1 citation(s) vérifiée(s)') expect(byRule(fs, 'SRC-005').find((f) => f.scope.module === 'MOD')!.severity).toBe('ok') expect(byRule(fs, 'SRC-006')[0]!.severity).toBe('ok') } finally { rmSync(root, { recursive: true, force: true }) } }) it('AUCUN registre : chaque règle rend ok « sans objet » — jamais un err d\u2019absence', () => { const { root, model } = srcCorpus({ registry: null }) try { const fs = srcFindings(model) expect(fs.some((f) => f.severity !== 'ok')).toBe(false) expect(byRule(fs, 'SRC-005').find((f) => f.scope.module === 'MOD')!.message).toContain('sans citation acceptable') } finally { rmSync(root, { recursive: true, force: true }) } }) it('citation cassée (code inconnu OU ancre inconnue) = SRC-004 err avec les comptes', () => { const { root, model } = srcCorpus({ ucSources: '- **Sources** : SRC-999, SRC-001 §9\n' }) try { const src4 = byRule(srcFindings(model), 'SRC-004').find((f) => f.scope.app === 'APP')! expect(src4.severity).toBe('err') expect(src4.message).toContain('2 citation(s) vérifiée(s), 2 irrésolvable(s)') expect(src4.evidence!.join(' ')).toContain('SRC-999') expect(src4.evidence!.join(' ')).toContain('§9') } finally { rmSync(root, { recursive: true, force: true }) } }) it('source web ingérée sans extrait verbatim = SRC-003 err (le filet des éditions manuelles)', () => { const webMd = SOURCE_MD .replace('kind=file', 'kind=web') .replace('### §2 — Plafond de remise [regles] — extrait verbatim\n> « Toute remise supérieure à 20 % requiert la validation du manager. »', '### §2 — Plafond [regles]\nParaphrase sans citation.') const { root, model } = srcCorpus({ registry: { sources: { 'SRC-001': entry({ kind: 'web', origin: { url: 'https://x.ch', fetchedAt: '2026-09-02' }, format: 'web', extracts: 0 }) } }, sourceMd: { 'SRC-001': webMd }, ucSources: '- **Sources** : SRC-001\n', }) try { expect(byRule(srcFindings(model), 'SRC-003')[0]!.severity).toBe('err') } finally { rmSync(root, { recursive: true, force: true }) } }) it('source en portée APP/MOD jamais citée = SRC-005 err ; portée APP seule = warn', () => { const { root, model } = srcCorpus({}) try { const src5 = byRule(srcFindings(model), 'SRC-005').find((f) => f.scope.module === 'MOD')! expect(src5.severity).toBe('err') expect(src5.evidence!.join(' ')).toContain('SRC-001') } finally { rmSync(root, { recursive: true, force: true }) } const appScoped = srcCorpus({ registry: { sources: { 'SRC-001': entry({ scopes: ['APP'] }) } } }) try { expect(byRule(srcFindings(appScoped.model), 'SRC-005').find((f) => f.scope.module === 'MOD')!.severity).toBe('warn') } finally { rmSync(appScoped.root, { recursive: true, force: true }) } }) it('registre incohérent (doc indexé absent du disque) = SRC-001 err', () => { const { root, model } = srcCorpus({ sourceMd: {} }) try { const src1 = byRule(srcFindings(model), 'SRC-001')[0]! expect(src1.severity).toBe('err') expect(src1.evidence!.join(' ')).toContain('SRC-001') } finally { rmSync(root, { recursive: true, force: true }) } }) it('ancre ba:source qui ne parse pas = contrôle FATAL (exit 3) — jamais vert par mutisme', () => { const { root, model } = srcCorpus({ sourceMd: { 'SRC-001': SOURCE_MD.replace('code=SRC-001', 'code=BROKEN') } }) try { const control = reconcileParse(model) expect(control.status).toBe('fatal') expect(control.perDoc.find((d) => d.counter === 'sources')).toMatchObject({ control: 1, parsed: 0, status: 'fatal' }) } finally { rmSync(root, { recursive: true, force: true }) } }) it('portée pressentie orpheline (menu renommé) = SRC-002 warn — le trou reconcile-menu est DIT', () => { const { root, model } = srcCorpus({ registry: { sources: { 'SRC-001': entry({ scopes: ['GHOST/MOD'] }) } }, ucSources: '- **Sources** : SRC-001 §2\n', }) try { const src2 = byRule(srcFindings(model), 'SRC-002').find((f) => f.severity === 'warn')! expect(src2.evidence!.join(' ')).toContain('GHOST/MOD') expect(src2.evidence!.join(' ')).toContain('reconcile-menu') } finally { rmSync(root, { recursive: true, force: true }) } }) it('source remplacée encore citée = SRC-007 warn ; ingérée jamais citée = SRC-006 warn', () => { const { root, model } = srcCorpus({ registry: { sources: { 'SRC-001': entry({ status: 'superseded', supersededBy: 'SRC-002' }), 'SRC-002': entry({ code: 'SRC-002', fingerprint: 'bbbbbbbbbbbb' }) }, nextSeq: 3 }, sourceMd: { 'SRC-001': SOURCE_MD.replace('status=ingested', 'status=superseded'), 'SRC-002': SOURCE_MD.replace(/SRC-001/g, 'SRC-002').replace('a1b2c3d4e5f6', 'bbbbbbbbbbbb') }, ucSources: '- **Sources** : SRC-001 §2\n', }) try { const fs = srcFindings(model) expect(byRule(fs, 'SRC-007')[0]!.severity).toBe('warn') expect(byRule(fs, 'SRC-006')[0]!.severity).toBe('warn') expect(byRule(fs, 'SRC-006')[0]!.evidence!.join(' ')).toContain('SRC-002') } finally { rmSync(root, { recursive: true, force: true }) } }) }) // --------------------------------------------------------------------------- // SCR-003 ⇔ XD-005 — one predicate, never two verdicts (DemoGestionFlotte // 2026-09-04: XD-005 err on 9 hub screens SCR-003 declared ok — a re-implemented // filter without the hub exemption). The corpus below is the client's exact // shape: the create-screen template's `— (…)` placeholder on a module home and // on a dashboard, next to a properly bound list. // --------------------------------------------------------------------------- function hubCorpus(listEntity: string): { root: string; model: CorpusModel } { const root = mkdtempSync(join(tmpdir(), 'ss-audit-ba-hub-')) const w = (rel: string, body: string): void => { mkdirSync(join(root, rel, '..'), { recursive: true }) writeFileSync(join(root, rel), body, 'utf8') } w('FLOTTE/index.md', '# FLOTTE\n') w('FLOTTE/PARC/index.md', '# Parc véhicules\n') w('FLOTTE/PARC/vehicules/index.md', '# Véhicules\n') w( 'FLOTTE/PARC/entité.md', [ '### ENT-001 — Vehicle (agrégat racine)', '- **Préfixe table** : `veh_`', '- **Traçabilité** : UC-FLOTTE-PARC-vehicules-001', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Plate | string(20) | requis | — |', '', ].join('\n'), ) w( 'FLOTTE/PARC/vehicules/screen.md', [ '### SCR-FLOTTE-PARC-VEHICULES-001 — Accueil Parc véhicules (SmartModuleHome)', '_Portée module — page de lancement du module, hébergée dans la section d’atterrissage._', '- **Entité** : — (agrégation multi-entités par les widgets)', '- **Permission** : `parc.vehicules.access`', '', '### SCR-FLOTTE-PARC-VEHICULES-002 — Liste des véhicules (SmartListView)', `- **Entité** : ${listEntity} (ENT-001)`, '- **Permission** : `parc.vehicules.read`', '- **Cas d’usage liés** : UC-FLOTTE-PARC-vehicules-001', '', '### SCR-FLOTTE-PARC-VEHICULES-004 — Tableau de bord échéances (SmartDashboard)', '- **Entité** : — (agrégation sur DueDate et Vehicle)', '- **Permission** : `parc.vehicules.read`', '', ].join('\n'), ) return { root, model: loadCorpus(root) } } const MIRROR_RULES = [...SCREEN_RULES, ...XD_RULES].filter((r) => r.id === 'SCR-003' || r.id === 'XD-005') describe('audit-ba / SCR-003 ⇔ XD-005 — même prédicat, jamais deux verdicts', () => { it('la forme du template (hub `— (…)`) est ok pour LES DEUX règles', () => { const { root, model } = hubCorpus('Vehicle') try { const { findings } = runRules(MIRROR_RULES, model, { strict: false }) const scr = findings.find((f) => f.ruleId === 'SCR-003')! const xd = findings.find((f) => f.ruleId === 'XD-005')! expect(scr.severity).toBe('ok') expect(xd.severity).toBe('ok') expect(xd.dedupOf).toBeUndefined() // ok findings carry no mirror tag } finally { rmSync(root, { recursive: true, force: true }) } }) it('une vraie référence cassée est err pour LES DEUX règles, avec la même evidence', () => { const { root, model } = hubCorpus('Fantome') try { const { findings } = runRules(MIRROR_RULES, model, { strict: false }) const scr = findings.find((f) => f.ruleId === 'SCR-003')! const xd = findings.find((f) => f.ruleId === 'XD-005')! expect(scr.severity).toBe('err') expect(xd.severity).toBe('err') expect(xd.dedupOf).toBe('SCR-003') expect(xd.evidence).toEqual(scr.evidence) expect(scr.evidence).toEqual(['SCR-FLOTTE-PARC-VEHICULES-002 → Fantome']) } finally { rmSync(root, { recursive: true, force: true }) } }) it('invariant de parité : le moteur ne se contredit sur AUCUN corpus (même détecteur que /support-report)', () => { const { root, model } = hubCorpus('Fantome') try { const extra = runRules(ALL_RULES, model, { strict: false, projectRoot: root }).findings for (const all of [findings, extra]) { expect(selfContradictions(all)).toEqual([]) expect(selfContradictionWarnings(all)).toEqual([]) } } finally { rmSync(root, { recursive: true, force: true }) } }) it('le moteur DIT sa propre contradiction : un miroir err dont le primaire est ok ⇒ warning `audit.rule-contradiction`', () => { const scope = { app: 'FLOTTE', module: 'PARC' } const divergent: Finding[] = [ { ruleId: 'SCR-003', dimension: 'screens', severity: 'ok', scope, message: 'Toutes les références entité des écrans résolvent.' }, { ruleId: 'XD-005', dimension: 'cross-dimension', severity: 'err', scope, message: 'Écran(s) liant une entité absente du MCD (miroir de SCR-003).', evidence: ['SCR-X → —'], dedupOf: 'SCR-003' }, ] const warnings = selfContradictionWarnings(divergent) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('audit.rule-contradiction') expect(warnings[0]).toContain('XD-005→SCR-003') expect(warnings[0]).toContain('FLOTTE / PARC') expect(warnings[0]).toContain('/support-report') // The primary agreeing (err too) is not a contradiction — no warning. expect(selfContradictionWarnings([{ ...divergent[0]!, severity: 'err' }, divergent[1]!])).toEqual([]) }) it('`dedupOf` ne porte que les vrais miroirs ; les jumeaux sont `relatedTo`', () => { const pairs = new Set(findings.filter((f) => f.dedupOf !== undefined).map((f) => `${f.ruleId}->${f.dedupOf}`)) const mirrors = new Set(['XD-005->SCR-003', 'CODE-005->DM-018', 'BR-009->UC-003']) for (const p of pairs) expect(mirrors.has(p), `paire dedupOf non-miroir : ${p}`).toBe(true) for (const f of findings) { expect(f.dedupOf !== undefined && f.relatedTo !== undefined, `${f.ruleId} porte dedupOf ET relatedTo`).toBe(false) } }) })