/** * XD-002 / XD-003 — the rule and the skill it names as its remedy agree. * * Support report 6819314a0164: on a corpus written in the client's French * vocabulary, with two-value statuses, both rules warned and neither could be * satisfied without damaging the corpus. * - XD-003 demanded a SmartKanban for ANY state field, while /ba-create-screen * makes the board mandatory from 3 states on; * - XD-002 required the entity's TECHNICAL name (`Task`) in the use-case prose, * which says « rappel ». * The client went green by adding two-column kanbans and by writing « (mémo) » * into its use-case titles. The first describe block is that corpus, BEFORE * the contortion — synthetic, no client file is committed. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { KANBAN_MIN_STATES } from '../../../../../lib/page-spec-kanban.js' import { loadCorpus } from '../corpus/model.js' import { runRules } from '../engine.js' import { mergeJudgments } from '../judgments.js' import { citesStateValue, citedStateValues } from '../rules/state.js' import { XD_RULES } from '../rules/xd.js' import type { Finding, JudgmentItem } from '../types.js' const roots: string[] = [] afterEach(() => { for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true }) }) function audit(files: Record, ids: string[] = ['XD-002', 'XD-003']): { findings: Finding[]; judgments: JudgmentItem[] } { const root = mkdtempSync(join(tmpdir(), 'audit-ba-xd-')) roots.push(root) for (const [rel, content] of Object.entries(files)) { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } return runRules(XD_RULES.filter((r) => ids.includes(r.id)), loadCorpus(root), { strict: false }) } const of = (r: { findings: Finding[] }, id: string): Finding => r.findings.find((f) => f.ruleId === id)! // ─── Builders ────────────────────────────────────────────────────────────── function entity(n: number, name: string, status: string, trace: string[]): string { return [ `### ENT-00${n} — ${name} (agrégat racine)`, '- **Préfixe table** : `tk_`', `- **Traçabilité** : ${trace.join(', ')}`, '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Label | string(200) | requis | — |', `| Status | enum | ${status} | — |`, '', ].join('\n') } interface UcOpts { pre?: string post?: string flow?: string[] } function uc(code: string, title: string, o: UcOpts = {}): string { return [ `### ${code} — ${title} (manual · medium · user-goal)`, '- **Acteur principal** : BA-001-AC-001 (Collaborateur)', `- **Préconditions** : ${o.pre ?? 'le collaborateur est connecté.'}`, '- **Flux principal** :', ...(o.flow ?? ['Le collaborateur ouvre la page.', 'Le système répond.']).map((s, i) => ` ${i + 1}. ${s}`), `- **Postconditions** : ${o.post ?? 'la page est affichée.'}`, '', ].join('\n') } const useCases = (section: string, ...blocks: string[]): string => [``, `# Cas d'usage — GEST / RAPPELS / ${section}`, '', ...blocks].join('\n') const kanban = (code: string, entityName: string): string => [ `### ${code} — Tableau (SmartKanban)`, `- **Entité** : ${entityName}`, '- **Champ statut** : Status', '- **Colonnes** : draft (Brouillon, gray), open (Ouvert, blue), closed (Clos, green)', '', ].join('\n') // ─── The client shape ────────────────────────────────────────────────────── /** Two entities, English technical names, two-value statuses. The prose says * « rappel » and « correspondant » — never `Task`, never `Partner`. */ const CLIENT_SHAPE: Record = { 'GEST/RAPPELS/entité.md': [ entity(1, 'Task', 'A_FAIRE/TRAITE, défaut A_FAIRE', ['UC-GEST-RAPPELS-LIST-001', 'UC-GEST-RAPPELS-LIST-002', 'BR-001']), entity(2, 'Partner', 'ACTIF/RETIRE, défaut ACTIF', ['UC-GEST-RAPPELS-CARNET-001', 'UC-GEST-RAPPELS-CARNET-002']), ].join('\n'), 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-001', 'Noter un rappel', { post: 'le rappel est enregistré au statut `A_FAIRE`.' }), uc('UC-GEST-RAPPELS-LIST-002', 'Marquer un rappel comme traité', { pre: 'le rappel appartient au collaborateur et se trouve au statut `A_FAIRE`.', flow: ['Le collaborateur indique que la chose est faite.', 'Le système passe au statut `TRAITE` le rappel concerné.'], post: 'le rappel est au statut `TRAITE` avec son jour de traitement.', }), ), 'GEST/RAPPELS/carnet/use-case.md': useCases( 'carnet', uc('UC-GEST-RAPPELS-CARNET-001', 'Inscrire un correspondant dans son carnet'), uc('UC-GEST-RAPPELS-CARNET-002', 'Retirer un correspondant du carnet', { pre: 'le correspondant se trouve au statut `ACTIF`.', flow: ['Le collaborateur demande le retrait.', 'Le système passe le correspondant au statut `RETIRE`.'], post: 'le correspondant est au statut `RETIRE` ; il n’est plus proposé.', }), ), } describe('the client shape — French prose, two-value statuses, no board', () => { it('pin — the corpus carries what the old matcher could not read', () => { const titles = Object.values(CLIENT_SHAPE).join('\n') expect(titles).not.toMatch(/###\s+UC-[^\n]*\b(Task|Partner)\b/i) expect(titles).not.toMatch(/###\s+UC-[^\n]*\b(chang|modifi|valid|annul|archiv|activ)/i) expect(titles).not.toContain('SmartKanban') }) it('XD-002 ok, nothing to arbitrate — the traceability links, pre → post proves the move', () => { const r = audit(CLIENT_SHAPE) expect(of(r, 'XD-002')).toMatchObject({ severity: 'ok', message: 'Chaque champ d’état a son UC de changement (ou aucun champ d’état).' }) expect(r.judgments).toEqual([]) }) it('XD-003 ok — no entity reaches the threshold, no board is demanded', () => { const f = of(audit(CLIENT_SHAPE), 'XD-003') expect(f.severity).toBe('ok') expect(f.message).toBe('Aucune entité à cycle de vie (≥ 3 états) — aucun board exigé.') }) }) // ─── XD-003 — the threshold ──────────────────────────────────────────────── describe('XD-003 — a board is required from KANBAN_MIN_STATES states on', () => { it('the threshold is 3 — the number the authoring docs state', () => { expect(KANBAN_MIN_STATES).toBe(3) }) it('exactly 3 states, no board → warn, the entity named', () => { const f = of(audit({ 'GEST/RAPPELS/entité.md': entity(1, 'Ticket', 'draft/open/closed, requis', ['BR-001']) }), 'XD-003') expect(f.severity).toBe('warn') expect(f.evidence).toEqual(['Ticket']) expect(f.message).toContain('≥ 3 états') }) it('a board bound to the entity closes it', () => { const f = of( audit({ 'GEST/RAPPELS/entité.md': entity(1, 'Ticket', 'draft/open/closed, requis', ['BR-001']), 'GEST/RAPPELS/tickets/screen.md': kanban('SCR-GEST-RAPPELS-TICKETS-002', 'Ticket'), }), 'XD-003', ) expect(f).toMatchObject({ severity: 'ok', message: '1 entité(s) à cycle de vie (≥ 3 états), chacune avec sa vue workflow.' }) }) it('only the entities past the threshold are named — a two-value sibling is never listed', () => { const f = of( audit({ 'GEST/RAPPELS/entité.md': [ entity(1, 'Ticket', 'draft/open/closed/archived', ['BR-001']), entity(2, 'Partner', 'ACTIF/RETIRE', ['BR-002']), ].join('\n'), }), 'XD-003', ) expect(f.evidence).toEqual(['Ticket']) }) it('a facet is not a value — `A/B, requis` stays two states', () => { const f = of(audit({ 'GEST/RAPPELS/entité.md': entity(1, 'Partner', 'ACTIF/RETIRE, requis, défaut ACTIF', ['BR-001']) }), 'XD-003') expect(f.severity).toBe('ok') }) it('a board on a two-value status stays ALLOWED — the rule requires, it never forbids', () => { const f = of( audit({ 'GEST/RAPPELS/entité.md': entity(1, 'Partner', 'ACTIF/RETIRE', ['BR-001']), 'GEST/RAPPELS/carnet/screen.md': kanban('SCR-GEST-RAPPELS-CARNET-002', 'Partner'), }), 'XD-003', ) expect(f.severity).toBe('ok') }) }) // ─── XD-002 — three outcomes ─────────────────────────────────────────────── const TASK = entity(1, 'Task', 'A_FAIRE/TRAITE, défaut A_FAIRE', ['UC-GEST-RAPPELS-LIST-001']) describe('XD-002 — covered / judgment / uncovered', () => { it('uncovered: the linked use case never cites a state → warn', () => { const r = audit({ 'GEST/RAPPELS/entité.md': TASK, 'GEST/RAPPELS/list/use-case.md': useCases('list', uc('UC-GEST-RAPPELS-LIST-001', 'Retrouver ses rappels')), }) expect(of(r, 'XD-002')).toMatchObject({ severity: 'warn', evidence: ['Task'] }) expect(r.judgments).toEqual([]) }) it('uncovered: a use case that moves the state but is NOT linked to the entity proves nothing', () => { const r = audit({ 'GEST/RAPPELS/entité.md': entity(1, 'Task', 'A_FAIRE/TRAITE', ['BR-001']), 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-009', 'Marquer un rappel comme traité', { pre: 'statut `A_FAIRE`.', post: 'statut `TRAITE`.' }), ), }) expect(of(r, 'XD-002')).toMatchObject({ severity: 'warn', evidence: ['Task'] }) }) it('judgment: a consultation cites a status — the engine asks, it does not guess', () => { const r = audit({ 'GEST/RAPPELS/entité.md': TASK, 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-001', 'Retrouver ses rappels', { flow: ['Le système affiche les rappels au statut `A_FAIRE`.'] }), ), }) expect(of(r, 'XD-002')).toMatchObject({ severity: 'ok', message: 'Aucun champ d’état sans cas d’usage candidat. 1 entité(s) à arbitrer (jugement).' }) expect(r.judgments).toHaveLength(1) expect(r.judgments[0]).toMatchObject({ ruleId: 'XD-002', dimension: 'cross-dimension', maxSeverity: 'warn', scope: { app: 'GEST', module: 'RAPPELS' } }) expect(r.judgments[0]!.question).toContain('Task') expect(r.judgments[0]!.excerpts).toEqual(['Task.Status ← UC-GEST-RAPPELS-LIST-001 — Retrouver ses rappels (manual · medium · user-goal) (cite : A_FAIRE)']) }) it('judgment: a creation SETS a status, it does not move one (no starting state)', () => { const r = audit({ 'GEST/RAPPELS/entité.md': TASK, 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-001', 'Noter un rappel', { post: 'le rappel est enregistré au statut `A_FAIRE`.' }), ), }) expect(of(r, 'XD-002').severity).toBe('ok') expect(r.judgments).toHaveLength(1) }) it('judgment: the same value before and after is not a move', () => { const r = audit({ 'GEST/RAPPELS/entité.md': TASK, 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-001', 'Reporter un rappel', { pre: 'le rappel est au statut `A_FAIRE`.', post: 'le rappel reste au statut `A_FAIRE`.' }), ), }) expect(r.judgments).toHaveLength(1) }) it('ONE pending item per module, every entity inside — a decision is matched on (rule, scope)', () => { const r = audit({ 'GEST/RAPPELS/entité.md': [ entity(1, 'Task', 'A_FAIRE/TRAITE', ['UC-GEST-RAPPELS-LIST-001']), entity(2, 'Partner', 'ACTIF/RETIRE', ['UC-GEST-RAPPELS-LIST-002']), ].join('\n'), 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-001', 'Retrouver ses rappels', { flow: ['Le système liste les rappels `TRAITE`.'] }), uc('UC-GEST-RAPPELS-LIST-002', 'Parcourir le carnet', { flow: ['Le système liste les correspondants `ACTIF`.'] }), ), }) expect(r.judgments).toHaveLength(1) expect(r.judgments[0]!.excerpts.map((x) => x.split(' ← ')[0])).toEqual(['Task.Status', 'Partner.Status']) expect(of(r, 'XD-002').message).toContain('2 entité(s) à arbitrer') }) it('an arbitration consumes the pending item and lands as a finding, capped at warn', () => { const r = audit({ 'GEST/RAPPELS/entité.md': TASK, 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-001', 'Retrouver ses rappels', { flow: ['Le système affiche les rappels au statut `A_FAIRE`.'] }), ), }) const merged = mergeJudgments(r.judgments, [ { ruleId: 'XD-002', dimension: 'cross-dimension', scope: { app: 'GEST', module: 'RAPPELS' }, resolution: 'err', message: 'Aucun cas d’usage ne fait changer le statut.', evidence: ['Task'] }, ]) expect(merged.remaining).toEqual([]) expect(merged.findings).toHaveLength(1) expect(merged.findings[0]).toMatchObject({ ruleId: 'XD-002', severity: 'warn', evidence: ['Task'] }) }) it('mixed: one entity uncovered, one to arbitrate → warn names the first, the item carries the second', () => { const r = audit({ 'GEST/RAPPELS/entité.md': [ entity(1, 'Task', 'A_FAIRE/TRAITE', ['UC-GEST-RAPPELS-LIST-001']), entity(2, 'Partner', 'ACTIF/RETIRE', ['BR-009']), ].join('\n'), 'GEST/RAPPELS/list/use-case.md': useCases( 'list', uc('UC-GEST-RAPPELS-LIST-001', 'Retrouver ses rappels', { flow: ['Le système liste les rappels `TRAITE`.'] }), ), }) expect(of(r, 'XD-002')).toMatchObject({ severity: 'warn', evidence: ['Partner'] }) expect(of(r, 'XD-002').message).toContain('1 entité(s) à arbitrer') expect(r.judgments[0]!.excerpts.every((x) => x.startsWith('Task.Status'))).toBe(true) }) it('pin — the historical path still covers: status action in the title + entity name in the text', () => { const r = audit({ 'GEST/RAPPELS/entité.md': entity(1, 'Ticket', 'draft/open/closed', ['BR-001']), 'GEST/RAPPELS/tickets/use-case.md': useCases('tickets', uc('UC-GEST-RAPPELS-TICKETS-001', 'Valider un ticket')), }) expect(of(r, 'XD-002').severity).toBe('ok') expect(r.judgments).toEqual([]) }) it('no state field at all → ok, nothing asked', () => { const md = [ '### ENT-001 — Note (agrégat racine)', '- **Préfixe table** : `nt_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Label | string(200) | requis | — |', '', ].join('\n') const r = audit({ 'GEST/RAPPELS/entité.md': md }) expect(of(r, 'XD-002').severity).toBe('ok') expect(of(r, 'XD-003').severity).toBe('ok') expect(r.judgments).toEqual([]) }) }) // ─── The value matcher (shared with DM-026) ──────────────────────────────── describe('citesStateValue — whole token, folded', () => { const text = (s: string): string => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() it('finds the value in backticks, in prose, at the edges', () => { expect(citesStateValue(text('au statut `TRAITE` le rappel'), 'TRAITE')).toBe(true) expect(citesStateValue(text('TRAITE'), 'TRAITE')).toBe(true) expect(citesStateValue(text('statut = a_faire;'), 'A_FAIRE')).toBe(true) }) it('a value inside a longer word is not a citation', () => { expect(citesStateValue(text('le départ à la retraite'), 'TRAITE')).toBe(false) expect(citesStateValue(text('les rappels traités'), 'TRAITE')).toBe(false) expect(citesStateValue(text('inactif'), 'ACTIF')).toBe(false) }) it('the accent fold is two-way: « traité » cites TRAITE — which is why a citation alone is a judgment, never a proof', () => { expect(citesStateValue(text('marqué traité par erreur'), 'TRAITE')).toBe(true) }) it('a value carrying regex characters is matched literally', () => { expect(citesStateValue(text('statut a.b'), 'A.B')).toBe(true) expect(citesStateValue(text('statut axb'), 'A.B')).toBe(false) }) it('citedStateValues keeps the declaration order', () => { expect(citedStateValues(text('de `TRAITE` vers `A_FAIRE`'), ['A_FAIRE', 'TRAITE', 'ARCHIVE'])).toEqual(['A_FAIRE', 'TRAITE']) }) })