import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { loadCorpus } from '../corpus/model.js' import { reconcileParse } from '../control-counts.js' import { runRules } 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 { MENU_RULES } from '../rules/menu.js' import { SECTION_RULES } from '../rules/sections.js' import { XD_RULES } from '../rules/xd.js' import { SOURCES_RULES } from '../rules/sources.js' import { buildReport } from '../render/report-json.js' import { groupVerdicts, renderVerdict } from '../render/verdict-md.js' import { CROSS_APP_JUDGMENT_RULES, isGatingJudgment, judgmentId, mergeJudgments, splitJudgments, withJudgmentId } from '../judgments.js' import type { AuditBaReport, JudgmentItem } from '../types.js' // Doctrine 06.10.2026 — a validation is asked on what crosses an application, // never on what stays inside one. The incident: a one-application project // (TEST, 4 modules, no section yet) showed MENU-001, MENU-005 and XAPP-002 // « à trancher » in the Studio and nothing could move until a human answered // three questions about the application's own menu. const ALL_RULES = [ ...MENU_RULES, ...SECTION_RULES, ...ACTOR_RULES, ...UC_RULES, ...BR_RULES, ...RBAC_RULES, ...DM_RULES, ...SCREEN_RULES, ...XD_RULES, ...CODE_RULES, ...SOURCES_RULES, ] const HERE = dirname(fileURLToPath(import.meta.url)) const OPTS = { date: '2026-10-06', rulesetVersion: '1.5.0', sourcesHash: 'abc123' } const CTX = '## Contexte\nTraitement des notes de frais déposées par les collaborateurs : enregistrement, classement et analyse.\n\n' let root: string | null = null function corpus(files: Record): string { root = mkdtempSync(join(tmpdir(), 'ss-audit-ba-gating-')) for (const [rel, content] of Object.entries(files)) { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } return root } function audit(dir: string): AuditBaReport { const model = loadCorpus(dir) const out = runRules(ALL_RULES, model, { strict: false }) return buildReport({ model, findings: out.findings, judgments: out.judgments, parseControl: reconcileParse(model), skippedDimensions: [], warnings: [], }) } afterEach(() => { if (root) rmSync(root, { recursive: true, force: true }) root = null }) describe('judgments — seuls les jugements INTER-APPLICATIONS sont à trancher', () => { it('la liste des règles bloquantes ne nomme que des règles de jugement enregistrées', () => { const byId = new Map(ALL_RULES.map((r) => [r.id, r])) for (const id of CROSS_APP_JUDGMENT_RULES) { expect(byId.has(id), `${id} absent du registre`).toBe(true) expect(['judgment', 'hybrid']).toContain(byId.get(id)!.kind) } }) it('le tableau « Which judgments block » du SKILL.md nomme EXACTEMENT les règles bloquantes', () => { const skill = readFileSync(join(HERE, '..', '..', '..', 'SKILL.md'), 'utf8') const row = skill.split('\n').find((l) => l.startsWith('| `judgmentNeeded[]`')) expect(row, 'ligne judgmentNeeded[] absente du SKILL.md').toBeDefined() const ids = row!.split('|')[2]!.match(/[A-Z]+-\d{3}/g) ?? [] expect(new Set(ids)).toEqual(new Set(CROSS_APP_JUDGMENT_RULES)) }) it('le cas du rapport : une application seule → AUCUN jugement à trancher, MENU-001/005 et XAPP-002 en revue conseillée', () => { const dir = corpus({ 'index.md': "# Projet\n\n## Hors-périmètre\n- Centre d'aide public ou FAQ externe — application plateforme `support`.\n", 'TEST/index.md': `# TEST — Gestion des notes de frais\n\n${CTX}## Hors-périmètre\n- La paie.\n- Le report.\n- Le partage.\n`, 'TEST/ANALYSE/index.md': `# Analyse automatique\n\n${CTX}`, 'TEST/CLASSEMENT/index.md': `# Classement\n\n${CTX}`, 'TEST/FRAIS/index.md': `# Notes de frais\n\n${CTX}`, 'TEST/PARAMETRES/index.md': `# Paramètres\n\n${CTX}`, }) const report = audit(dir) expect(report.judgmentNeeded).toEqual([]) expect(report.counts.judgment).toBe(0) const advisoryIds = report.judgmentAdvisory.map((j) => j.ruleId) expect(advisoryIds).toEqual(expect.arrayContaining(['MENU-001', 'MENU-005', 'XAPP-002'])) expect(report.counts.advisory).toBe(report.judgmentAdvisory.length) // An internal review can never be arbitrated into a blocker. expect(report.judgmentAdvisory.every((j) => j.maxSeverity === 'warn')).toBe(true) // The app's menu verdict carries no « à arbitrer » — the counter // /ba-audit-pre-dev turns into « non audité » — and no ba:judgment anchor. const menu = groupVerdicts(report.findings, [...report.judgmentNeeded, ...report.judgmentAdvisory]).find( (t) => t.relPath === 'TEST/_audit/menu.md', )! const md = renderVerdict(menu, OPTS) expect(md.split('\n')[2]).not.toContain('à arbitrer') expect(md).not.toContain('ba:judgment') expect(md).not.toContain('## 🧠 Jugement requis') expect(md).toContain('') expect(md).toContain('## 💡 Revue conseillée (non bloquante)') }) it('deux applications portant le même module → MENU-002 et XAPP-003 restent à trancher', () => { const dir = corpus({ 'CRM/index.md': `# CRM\n\n${CTX}`, 'CRM/FACTURES/index.md': `# Factures\n\n${CTX}`, 'FIN/index.md': `# FIN\n\n${CTX}`, 'FIN/FACTURES/index.md': `# Factures\n\n${CTX}`, }) const report = audit(dir) const needed = report.judgmentNeeded.map((j) => j.ruleId) expect(needed).toEqual(expect.arrayContaining(['MENU-002', 'XAPP-003'])) expect(report.judgmentNeeded.every(isGatingJudgment)).toBe(true) expect(report.judgmentAdvisory.some(isGatingJudgment)).toBe(false) expect(report.counts.judgment).toBe(report.judgmentNeeded.length) }) }) describe('judgments — fusion : un jugement interne arbitré ne bloque jamais', () => { const item = (ruleId: string, maxSeverity: 'warn' | 'err'): JudgmentItem => ({ ruleId, dimension: 'use-cases', scope: { app: 'CRM', module: 'PIPELINE' }, maxSeverity, question: 'Q', excerpts: [], }) it('UC-011 (interne, maxSeverity err à l’émission) arbitré « err » → plafonné à warn, avec la raison', () => { const r = mergeJudgments([item('UC-011', 'err')], [ { ruleId: 'UC-011', dimension: 'use-cases', scope: { app: 'CRM', module: 'PIPELINE' }, resolution: 'err', message: 'UC hors périmètre.' }, ]) expect(r.findings[0]!.severity).toBe('warn') expect(r.warnings.some((w) => w.includes('jugement interne'))).toBe(true) }) it('splitJudgments range chaque item d’un seul côté et porte le plafond warn de l’interne', () => { const { gating, advisory } = splitJudgments([item('UC-023', 'warn'), item('UC-011', 'err'), item('UC-007', 'err')]) expect(gating.map((j) => j.ruleId)).toEqual(['UC-023']) expect(advisory.map((j) => [j.ruleId, j.maxSeverity])).toEqual([ ['UC-011', 'warn'], ['UC-007', 'warn'], ]) }) }) describe('judgments — identifiant stable : une décision vise UN jugement précis', () => { const pair = (other: string): JudgmentItem => ({ ruleId: 'DM-034', dimension: 'data-model', scope: { app: 'CRM', module: 'CLIENTS' }, maxSeverity: 'warn', question: `Même définition métier ? ENT-001 Client vs ${other} ENT-004 Client [même nom]`, excerpts: ['CRM/CLIENTS ENT-001 Client — Description : la personne morale facturée.', `${other} ENT-004 Client — Description : idem.`], }) it('le moteur tamponne chaque jugement d’un id J-<12 hex>, identique d’un run à l’autre', () => { const files = { 'CRM/index.md': `# CRM\n\n${CTX}`, 'CRM/FACTURES/index.md': `# Factures\n\n${CTX}`, 'FIN/index.md': `# FIN\n\n${CTX}`, 'FIN/FACTURES/index.md': `# Factures\n\n${CTX}`, } const first = audit(corpus(files)) const ids = [...first.judgmentNeeded, ...first.judgmentAdvisory].map((j) => j.id) expect(ids.length).toBeGreaterThan(0) expect(ids.every((id) => /^J-[0-9a-f]{12}$/.test(id ?? ''))).toBe(true) rmSync(root!, { recursive: true, force: true }) const second = audit(corpus(files)) expect([...second.judgmentNeeded, ...second.judgmentAdvisory].map((j) => j.id)).toEqual(ids) }) it('l’id change quand le contenu montré change (la décision devient périmée)', () => { const a = pair('FIN/BUDGET') expect(judgmentId(a)).toBe(judgmentId({ ...a })) expect(judgmentId({ ...a, excerpts: [a.excerpts[0]!, 'FIN/BUDGET ENT-004 Client — Description : autre chose.'] })).not.toBe(judgmentId(a)) }) it('deux paires DM-034 du même module : la décision par id règle la BONNE, quel que soit l’ordre', () => { const a = withJudgmentId(pair('FIN/BUDGET')) const b = withJudgmentId(pair('RH/PAIE')) const r = mergeJudgments([a, b], [ { id: b.id, ruleId: 'DM-034', dimension: 'data-model', scope: { app: 'CRM', module: 'CLIENTS' }, resolution: 'ok', message: 'Périmètres différents.' }, ]) expect(r.remaining.map((j) => j.id)).toEqual([a.id]) expect(r.warnings).toEqual([]) }) it('une décision dont l’id n’existe plus est signalée périmée, jamais appliquée à un autre jugement', () => { const a = withJudgmentId(pair('FIN/BUDGET')) const r = mergeJudgments([a], [ { id: 'J-000000000000', ruleId: 'DM-034', dimension: 'data-model', scope: { app: 'CRM', module: 'CLIENTS' }, resolution: 'ok', message: 'x' }, ]) expect(r.findings).toEqual([]) expect(r.remaining).toHaveLength(1) expect(r.warnings.some((w) => w.includes('périmé'))).toBe(true) }) })