/** * CODE-008 — the extension request (doctrine 2026-10-03). A module declaring, * on an entity ANOTHER application owns, attributes that application does not * carry: err until `/demande-extension.md` carries the ask; warn while a * request of the application is pending/refused (development frozen, Statut * owned by the Studio); ok once covered/validated. The doc is a corpus doc: * it is hashed (a Statut flip stales the verdicts) and control-counted. */ 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 { reconcileParse } from '../control-counts.js' import { runRules } from '../engine.js' import { CODE_RULES } from '../rules/code.js' import { resetDomainScanCache } from '../../../../../lib/domain-scan.js' import type { Finding } from '../types.js' const entity = (code: string, name: string, attrs: string[]): string => [`### ${code} — ${name} (agrégat racine)`, '- **Description** : x.', '- **Périmètre** : y.', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', ...attrs.map((a) => `| ${a} | string(50) | requis | — |`), ''].join('\n') let dir: string const w = (rel: string, content: string): void => { const p = join(dir, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } const run = (): Finding[] => { resetDomainScanCache() const model = loadCorpus(join(dir, 'ba')) return runRules(CODE_RULES, model, { strict: false, projectRoot: dir }).findings.filter((f) => f.ruleId === 'CODE-008' && f.scope.module === 'MEMOS') } beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'ss-code-008-')) w('ba/GESTION_FLOTTE/index.md', '# GESTION_FLOTTE\n') w('ba/GESTION_FLOTTE/VEHICULES/index.md', '# VEHICULES\n') w('ba/GESTION_FLOTTE/VEHICULES/entité.md', entity('ENT-001', 'Vehicule', ['Plaque'])) w('ba/NOTES/index.md', '# NOTES\n') w('ba/NOTES/MEMOS/index.md', '# MEMOS\n') w('ba/NOTES/MEMOS/entité.md', [entity('ENT-001', 'Memo', ['Titre']), entity('ENT-002', 'Vehicule', ['Plaque', 'Immatriculation'])].join('\n')) w('src/Demo.Domain/Entities/GestionFlotte/Vehicules/Vehicule.cs', 'public class Vehicule : ExtensionBaseEntity\n{\n public string Plaque { get; set; } = "";\n}\n') w('src/Demo.Domain/Entities/Notes/Memos/Memo.cs', 'public class Memo : ExtensionBaseEntity\n{\n public string Titre { get; set; } = "";\n}\n') }) afterAll(() => rmSync(dir, { recursive: true, force: true })) describe('CODE-008 — extension request', () => { it('extra attributes on another application\'s entity, no doc → err naming the pair, the columns and the class', () => { const f = run() expect(f).toHaveLength(1) expect(f[0]!.severity).toBe('err') expect(f[0]!.evidence).toEqual(['ENT-002 Vehicule → GESTION_FLOTTE/VEHICULES : Immatriculation (classe src/Demo.Domain/Entities/GestionFlotte/Vehicules/Vehicule.cs) — AUCUN bloc REQ ne la porte']) expect(f[0]!.file).toBe('NOTES/demande-extension.md') expect(f[0]!.relatedTo).toBe('CODE-001') }) it('a pending block covering the ask → no err, a warn « application gelée » (Statut owned by the Studio)', () => { w( 'ba/NOTES/demande-extension.md', [ '', "# Demandes d'extension — NOTES", '', '### REQ-001 — Vehicule → GESTION_FLOTTE/VEHICULES', '- **Statut** : en attente', '- **Demandeurs** : MEMOS', '- **Classe** : `src/Demo.Domain/Entities/GestionFlotte/Vehicules/Vehicule.cs`', '- **Motif** : x.', '- **Date** : 2026-10-03', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Immatriculation | string(50) | requis | — |', '', ].join('\n'), ) const f = run() expect(f.map((x) => x.severity)).toEqual(['warn']) expect(f[0]!.message).toContain('GELÉ') expect(f[0]!.evidence).toEqual(['REQ-001 Vehicule → GESTION_FLOTTE/VEHICULES (en attente) — demandeurs : MEMOS']) }) it('a pending block LACKING a newer column → err (stale), the block named', () => { w('ba/NOTES/MEMOS/entité.md', [entity('ENT-001', 'Memo', ['Titre']), entity('ENT-002', 'Vehicule', ['Plaque', 'Immatriculation', 'Couleur'])].join('\n')) const f = run() const err = f.find((x) => x.severity === 'err')! expect(err).toBeDefined() expect(err.evidence![0]).toContain('REQ-001 (en attente) ne porte pas : Couleur') w('ba/NOTES/MEMOS/entité.md', [entity('ENT-001', 'Memo', ['Titre']), entity('ENT-002', 'Vehicule', ['Plaque', 'Immatriculation'])].join('\n')) }) it('validated → ok, covered; the doc is a corpus doc (hashed, control-counted)', () => { const before = loadCorpus(join(dir, 'ba')).sourcesHash w('ba/NOTES/demande-extension.md', requestDoc().replace('- **Statut** : en attente', '- **Statut** : validée')) const f = run() expect(f.map((x) => x.severity)).toEqual(['ok']) expect(f[0]!.evidence).toEqual(['REQ-001 Vehicule → GESTION_FLOTTE/VEHICULES (validée)']) const model = loadCorpus(join(dir, 'ba')) expect(model.sourcesHash).not.toBe(before) expect(model.docs.some((d) => d.kind === 'demande-extension' && d.app === 'NOTES')).toBe(true) // The control counter (`^### REQ-`) reconciles with the parser — a healthy doc has no perDoc row. const control = reconcileParse(model) expect(control.totals['extensionRequests']).toEqual({ control: 1, parsed: 1 }) expect(control.perDoc.some((d) => d.relPath === 'NOTES/demande-extension.md')).toBe(false) expect(control.status).not.toBe('fatal') }) }) function requestDoc(): string { return [ '', "# Demandes d'extension — NOTES", '', '### REQ-001 — Vehicule → GESTION_FLOTTE/VEHICULES', '- **Statut** : en attente', '- **Demandeurs** : MEMOS', '- **Classe** : `src/Demo.Domain/Entities/GestionFlotte/Vehicules/Vehicule.cs`', '- **Motif** : x.', '- **Date** : 2026-10-03', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Immatriculation | string(50) | requis | — |', '', ].join('\n') }