/** * Inc 2 — the BA report becomes ACTIONABLE. * * Two things are asserted here, and both are about honesty: * - `sourceDocFor` points at the document to CORRECT, never at the `_audit/` * verdict, and returns null rather than a plausible path when the scope * does not pin exactly one document; * - `enrichFindings` gives EVERY finding a remedy (no silent dead end) while * never overwriting what a rule deliberately set. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { loadCorpus } from '../corpus/model.js' import { runRules } from '../engine.js' import { DM_RULES } from '../rules/dm.js' import { sourceDocFor } from '../corpus/doc-paths.js' import { enrichFindings } from '../render/report-json.js' import type { Finding } from '../types.js' const f = (over: Partial): Finding => ({ ruleId: 'DM-001', dimension: 'data-model', severity: 'err', scope: { app: 'CRM', module: 'PIPELINE' }, message: 'peu importe', ...over, }) describe('sourceDocFor — the doc to correct, not the verdict', () => { it('resolves the module-level documents', () => { const scope = { app: 'CRM', module: 'PIPELINE' } expect(sourceDocFor('data-model', scope)).toBe('CRM/PIPELINE/entité.md') expect(sourceDocFor('rules', scope)).toBe('CRM/PIPELINE/règles-métier.md') expect(sourceDocFor('rbac', scope)).toBe('CRM/PIPELINE/rbac.md') }) it('sends a cross-ref-code finding to the data model — the BA side of the comparison', () => { expect(sourceDocFor('cross-ref-code', { app: 'CRM', module: 'PIPELINE' })).toBe( 'CRM/PIPELINE/entité.md', ) }) it('resolves actors per APPLICATION even though their verdict is project-level', () => { expect(sourceDocFor('actors', { app: 'CRM' })).toBe('CRM/acteur.md') expect(sourceDocFor('actors', {})).toBeNull() }) it('resolves menu at app scope and sections at the deepest pinned node', () => { expect(sourceDocFor('menu', { app: 'CRM' })).toBe('CRM/index.md') expect(sourceDocFor('sections', { app: 'CRM', module: 'PIPELINE' })).toBe( 'CRM/PIPELINE/index.md', ) expect(sourceDocFor('sections', { app: 'CRM', module: 'PIPELINE', section: 'DEVIS' })).toBe( 'CRM/PIPELINE/DEVIS/index.md', ) }) it('FAILS CLOSED on section-level docs evaluated at module scope', () => { // use-case.md / screen.md are authored per SECTION but their rules run per // MODULE. Guessing a path here would send a corrector at the wrong file. expect(sourceDocFor('use-cases', { app: 'CRM', module: 'PIPELINE' })).toBeNull() expect(sourceDocFor('screens', { app: 'CRM', module: 'PIPELINE' })).toBeNull() expect(sourceDocFor('use-cases', { app: 'CRM', module: 'PIPELINE', section: 'DEVIS' })).toBe( 'CRM/PIPELINE/DEVIS/use-case.md', ) }) it('never resolves cross-dimension — it spans documents by definition', () => { expect(sourceDocFor('cross-dimension', { app: 'CRM', module: 'PIPELINE' })).toBeNull() }) it('never returns an _audit/ path', () => { const dims = [ 'menu', 'sections', 'actors', 'use-cases', 'rules', 'rbac', 'data-model', 'screens', 'cross-dimension', 'cross-ref-code', ] as const for (const d of dims) { const p = sourceDocFor(d, { app: 'CRM', module: 'PIPELINE', section: 'DEVIS' }) if (p !== null) expect(p).not.toContain('_audit/') } }) }) describe('enrichFindings', () => { it('gives every finding a remedy — no silent dead end', () => { const out = enrichFindings([ f({ ruleId: 'DM-013' }), f({ ruleId: 'RBAC-008', dimension: 'rbac' }), f({ ruleId: 'ZZZ-999', dimension: 'cross-dimension' }), ]) expect(out.every((x) => x.remedy !== undefined)).toBe(true) expect(out[0]?.remedy?.lane).toBe('authoring') expect(out[0]?.remedy?.target).toBe('ba-create-data-model') expect(out[1]?.remedy?.lane).toBe('derive') expect(out[1]?.remedy?.target).toBe('derive-lookup-grants') expect(out[2]?.remedy?.lane).toBe('manual') }) it('fills file from the dimension + scope', () => { expect(enrichFindings([f({})])[0]?.file).toBe('CRM/PIPELINE/entité.md') }) it('leaves file undefined rather than guessing', () => { const out = enrichFindings([f({ dimension: 'use-cases', ruleId: 'UC-007' })]) expect(out[0]?.file).toBeUndefined() expect(out[0]?.remedy?.target).toBe('ba-create-use-case') }) it('never overwrites what a rule deliberately set', () => { const out = enrichFindings([ f({ file: 'CRM/PIPELINE/DEVIS/use-case.md', remedy: { lane: 'manual', target: '', solution: 'décidé par la règle' }, }), ]) expect(out[0]?.file).toBe('CRM/PIPELINE/DEVIS/use-case.md') expect(out[0]?.remedy?.solution).toBe('décidé par la règle') }) it('keeps the corpus conventions OUT of the authoring lane', () => { // Routing CONV-001/002 at a create-* skill would propose exactly the // rename the doctrine forbids (the error codes are a public API contract). const out = enrichFindings([ f({ ruleId: 'CONV-001', dimension: 'use-cases', severity: 'warn', scope: {} }), f({ ruleId: 'CONV-002', dimension: 'rules', severity: 'warn', scope: {} }), ]) expect(out.map((x) => x.remedy?.lane)).toEqual(['manual', 'manual']) }) it('preserves severity, evidence and scope untouched', () => { const src = f({ severity: 'warn', evidence: ['Contact: OwnerId'] }) const out = enrichFindings([src])[0] expect(out?.severity).toBe('warn') expect(out?.evidence).toEqual(['Contact: OwnerId']) expect(out?.scope).toEqual({ app: 'CRM', module: 'PIPELINE' }) }) }) describe('anchors — la forme MACHINE de evidence[]', () => { it('DM-013 rend une ancre par item fautif, alignée sur evidence[]', () => { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-anchors-')) const mod = join(dir, 'CRM', 'PIPELINE') mkdirSync(mod, { recursive: true }) writeFileSync( join(mod, 'entité.md'), [ '### ENT-001 — Devis (agrégat racine)', '- **Préfixe table** : `dev_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| OwnerId | Guid | requis | — |', '| ReviewerId | Guid | requis | — |', '', ].join('\n'), 'utf8', ) const model = loadCorpus(dir) const { findings } = runRules( DM_RULES.filter((r) => r.id === 'DM-013'), model, { strict: false }, ) const err = findings.find((x) => x.ruleId === 'DM-013' && x.severity === 'err') expect(err).toBeDefined() // Une ancre pour un item de evidence — jamais une ancre unique choisie au hasard. expect(err!.anchors?.length).toBe(err!.evidence?.length) expect(err!.anchors).toEqual( expect.arrayContaining([{ kind: 'attribute', id: 'Devis', attribute: 'OwnerId' }]), ) rmSync(dir, { recursive: true, force: true }) }) it('un finding ok ne porte aucune ancre', () => { const dir = mkdtempSync(join(tmpdir(), 'audit-ba-anchors-ok-')) const mod = join(dir, 'CRM', 'PIPELINE') mkdirSync(mod, { recursive: true }) writeFileSync( join(mod, 'entité.md'), [ '### ENT-001 — Devis (agrégat racine)', '- **Préfixe table** : `dev_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Libelle | string(150) | requis | — |', '', ].join('\n'), 'utf8', ) const model = loadCorpus(dir) const { findings } = runRules( DM_RULES.filter((r) => r.id === 'DM-013'), model, { strict: false }, ) expect(findings[0]?.severity).toBe('ok') expect(findings[0]?.anchors).toBeUndefined() rmSync(dir, { recursive: true, force: true }) }) })