/** * DM-029..032 — the business test dataset (jeu-de-test.md) audited on a * two-module corpus on disk, THROUGH the corpus loader (the audit scope is one * module, the cited module is outside it — the full-tree universe must serve). * * Also the fail-closed leg: a refused row reaches `model.parseWarnings` AND the * control counts reconcile (`testDataSets`, `testDataRows`) — a loud loss * counts as seen, never « 0 rows ». */ 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 } from '../corpus/model.js' import { runRules } from '../engine.js' import { reconcileParse } from '../control-counts.js' import { DM_RULES } from '../rules/dm.js' import type { Finding } from '../types.js' const CONFIG_ENTITE = [ '', '- **Portée** : strict', '### ENT-001 — TypeClient (lookup)', '- **Préfixe table** : `cfg_`', '- **Affichage** : Label', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Label | string(100) | requis, unique | — |', '', '- **Valeurs initiales** : clé `Label` — les types fixés par le métier :', '', '| Label |', '|---|', '| Grand compte |', '| PME |', '', ].join('\n') const ANNUAIRE_ENTITE = [ '', '- **Portée** : strict', '### ENT-001 — Client (agrégat racine)', '- **Préfixe table** : `cli_`', '- **Affichage** : Nom', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Nom | string(200) | requis | — |', '| Statut | enum | Actif/Archivé, requis | — |', '', '- **Relations** :', ' - Client *→1 TypeClient — FK TypeClientId, scope cross-module (CLIENT/CONFIGURATION), onDelete restrict', '', ].join('\n') const ANNUAIRE_RULES = [ '', '### BR-004 — Archivage réversible', '- **Type** : state-transition', '- **Sévérité** : err', '- **Flow** :', ' - Actif → Archivé (by: BA-001-AC-001)', ' - Archivé → Actif (by: BA-001-AC-001)', "- **Code d'erreur** : `client-archive`", '', ].join('\n') const ANNUAIRE_TEST_DATA = [ '', '- **Date de référence** : 2026-09-15', '', '### JT-001 — Client (ENT-001)', '- **Clé** : `Nom`', '', '| Nom | TypeClient | Statut |', '|---|---|---|', '| Direction Marketing | Grand compte | Actif |', '| Atelier Lausanne | PME | Archivé |', '| Trop court | PME |', '', ].join('\n') let root: string function write(rel: string, content: string): void { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } const byRule = (findings: Finding[], id: string): Finding => findings.find((f) => f.ruleId === id && f.scope.module === 'ANNUAIRE')! beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'audit-ba-test-data-')) write('CLIENT/CONFIGURATION/entité.md', CONFIG_ENTITE) write('CLIENT/ANNUAIRE/entité.md', ANNUAIRE_ENTITE) write('CLIENT/ANNUAIRE/règles-métier.md', ANNUAIRE_RULES) write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA) }) afterAll(() => rmSync(root, { recursive: true, force: true })) describe('DM-029..032 through the corpus — scope one module, the cited one outside it', () => { it('resolves the cross-module citation against the Valeurs initiales of a module OUTSIDE the scope, every rule ok', () => { const model = loadCorpus(root, { app: 'CLIENT', module: 'ANNUAIRE' }) expect(model.modules.map((m) => m.module)).toEqual(['ANNUAIRE']) expect(model.testDataUniverse.size).toBe(2) const findings = runRules(DM_RULES, model, { strict: false }).findings expect(byRule(findings, 'DM-029').severity).toBe('ok') expect(byRule(findings, 'DM-030').severity).toBe('ok') expect(byRule(findings, 'DM-031').severity).toBe('ok') expect(byRule(findings, 'DM-032').severity).toBe('ok') expect(byRule(findings, 'DM-030').file).toBe('CLIENT/ANNUAIRE/jeu-de-test.md') }) it('the refused short row reaches parseWarnings and the control counts reconcile (loud loss = seen)', () => { const model = loadCorpus(root, { app: 'CLIENT', module: 'ANNUAIRE' }) expect(model.parseWarnings.some((w) => w.startsWith('CLIENT/ANNUAIRE/jeu-de-test.md: ') && w.includes('row dropped: 2 cell(s) for 3 column(s)'))).toBe(true) const control = reconcileParse(model) expect(control.status).toBe('ok') expect(control.totals['testDataSets']).toEqual({ control: 1, parsed: 1 }) // 3 data lines authored, 2 kept + 1 refused out loud. expect(control.totals['testDataRows']).toEqual({ control: 3, parsed: 3 }) }) it('DM-029 warns the module that has business entities and no dataset; DM-030..032 stay ok « non fourni »', () => { rmSync(join(root, 'CLIENT', 'ANNUAIRE', 'jeu-de-test.md')) try { const model = loadCorpus(root, { app: 'CLIENT', module: 'ANNUAIRE' }) const findings = runRules(DM_RULES, model, { strict: false }).findings const f = byRule(findings, 'DM-029') expect(f.severity).toBe('warn') expect(f.evidence).toEqual(['Client']) expect(f.message).toContain('/ba-create-test-data') for (const id of ['DM-030', 'DM-031', 'DM-032']) expect(byRule(findings, id).severity).toBe('ok') } finally { write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA) } }) it('DM-032 warns when a Flow status has no row; DM-030 names the OWNER of an unresolved citation', () => { write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA.replace('| Atelier Lausanne | PME | Archivé |', '| Atelier Lausanne | Artisan | Actif |')) try { const model = loadCorpus(root, { app: 'CLIENT', module: 'ANNUAIRE' }) const findings = runRules(DM_RULES, model, { strict: false }).findings const dm30 = byRule(findings, 'DM-030') expect(dm30.severity).toBe('warn') expect(dm30.evidence!.some((e) => e.includes('CLIENT/CONFIGURATION/TypeClient « Artisan »') && e.includes('CLIENT/CONFIGURATION provides no such row'))).toBe(true) const dm32 = byRule(findings, 'DM-032') expect(dm32.severity).toBe('warn') expect(dm32.evidence![0]).toContain('« Archivé »') } finally { write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA) } }) it('a module made of reference tables only is « sans objet » for DM-029', () => { const model = loadCorpus(root, { app: 'CLIENT', module: 'CONFIGURATION' }) const findings = runRules(DM_RULES, model, { strict: false }).findings const f = findings.find((x) => x.ruleId === 'DM-029' && x.scope.module === 'CONFIGURATION')! expect(f.severity).toBe('ok') expect(f.message).toContain('sans objet') }) })