/** * DM-033..035 — the definition cartouche and the application-grain duplicate * doctrine (2026-10-03). * DM-033 Description on every entity, Périmètre on business entities, prose * free of cardinality tokens / declaration mentions (warn) * DM-034 cross-APPLICATION equivalence: candidates by name, by definition * (Jaccard), by shared attributes or by the same Core artefact — * settled mechanically when a **Hors périmètre** names the other * side, judged otherwise (hybrid, never an err) * DM-035 the same entity declared in two modules of ONE application (err) */ 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 { DM_RULES } from '../rules/dm.js' import type { Finding, JudgmentItem } from '../types.js' const block = (code: string, name: string, cls: string, bullets: string[], attrs: string[]): string => [ `### ${code} — ${name} (${cls})`, `- **Préfixe table** : \`${name.slice(0, 3).toLowerCase()}_\``, ...bullets, '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', ...attrs.map((a) => `| ${a} | string(50) | requis | — |`), '', ].join('\n') let dir: string let findings: Finding[] let judgments: JudgmentItem[] const of = (rule: string, app: string, mod: string): Finding[] => findings.filter((f) => f.ruleId === rule && f.scope.app === app && f.scope.module === mod) beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'ss-dm-cartouche-')) const w = (rel: string, content: string): void => { const p = join(dir, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } w('FLOTTE/index.md', '# FLOTTE\n') w('FLOTTE/VEHICULES/index.md', '# VEHICULES\n') w( 'FLOTTE/VEHICULES/entité.md', [ // Same name as PROJETS/EQUIPE Conducteur — but its Hors périmètre names the project side → disjoint by declaration. block('ENT-001', 'Conducteur', 'agrégat racine', [ '- **Description** : la personne habilitée à prendre le volant d’un véhicule de la flotte.', '- **Périmètre** : conducteurs salariés et intérimaires au volant des véhicules, permis vérifié.', '- **Hors périmètre** : le conducteur de projet (chef de projet) — autre application.', ], ['Nom', 'NumeroPermis', 'DateValidite']), // No cartouche at all → DM-033 warns twice (Description + Périmètre). block('ENT-002', 'Mission', 'agrégat racine', [], ['Objet', 'DateDebut']), // A lookup needs a Description but no Périmètre. block('ENT-003', 'TypePermis', 'lookup', ['- **Description** : catégorie de permis de conduire.'], ['Label']), // Prose carrying a cardinality token and a declaration mention → DM-033 warns. block('ENT-004', 'Trajet', 'agrégat racine', [ '- **Description** : un déplacement 1→* étapes, numéroté selon un code pattern annuel.', '- **Périmètre** : trajets professionnels.', ], ['Distance']), ].join('\n'), ) w('FLOTTE/PLANNING/index.md', '# PLANNING\n') // Mission declared AGAIN in another module of the same application → DM-035 err on both. w('FLOTTE/PLANNING/entité.md', block('ENT-001', 'Mission', 'agrégat racine', ['- **Description** : une mission planifiée.', '- **Périmètre** : planning hebdomadaire.'], ['Objet', 'Semaine'])) w('PROJETS/index.md', '# PROJETS\n') w('PROJETS/EQUIPE/index.md', '# EQUIPE\n') w( 'PROJETS/EQUIPE/entité.md', [ block('ENT-001', 'Conducteur', 'agrégat racine', [ '- **Description** : le chef de projet qui conduit un projet interne de bout en bout.', '- **Périmètre** : conducteur de projet, responsable du planning et du budget du projet.', ], ['Nom', 'Service']), // Different name, near-identical definition to FLOTTE Conducteur → judgment (definitions proches). block('ENT-002', 'Pilote', 'agrégat racine', [ '- **Description** : la personne habilitée à prendre le volant d’un véhicule de la flotte.', '- **Périmètre** : conducteurs salariés et intérimaires au volant des véhicules, permis vérifié.', ], ['Nom', 'NumeroPermis', 'DateValidite']), ].join('\n'), ) const model = loadCorpus(dir) const out = runRules(DM_RULES, model, { strict: false }) findings = out.findings judgments = out.judgments }) afterAll(() => rmSync(dir, { recursive: true, force: true })) describe('DM-033 — the cartouche is said', () => { it('a business entity without Description nor Périmètre → warn naming both; a lookup needs no Périmètre', () => { const f = of('DM-033', 'FLOTTE', 'VEHICULES') const missing = f.find((x) => x.message.includes('incomplet'))! expect(missing.severity).toBe('warn') expect(missing.evidence).toEqual(['ENT-002 Mission — **Description** absente', 'ENT-002 Mission — **Périmètre** absent (entité métier)']) }) it('prose carrying a cardinality token or a declaration mention → warn (the block scanners would read a declaration)', () => { const f = of('DM-033', 'FLOTTE', 'VEHICULES') const prose = f.find((x) => x.message.includes('prose libre'))! expect(prose.severity).toBe('warn') expect(prose.evidence).toHaveLength(2) expect(prose.evidence![0]).toContain('ENT-004 Trajet — **Description** porte un token de cardinalité') expect(prose.evidence![1]).toContain('ENT-004 Trajet — **Description** mentionne') }) it('a complete cartouche → ok', () => { expect(of('DM-033', 'PROJETS', 'EQUIPE').map((x) => x.severity)).toEqual(['ok']) }) }) describe('DM-034 — cross-application equivalence (hybrid)', () => { it('same name, the other side named by **Hors périmètre** → settled mechanically (ok evidence), no judgment', () => { const f = of('DM-034', 'FLOTTE', 'VEHICULES')[0]! expect(f.severity).toBe('ok') expect(f.evidence?.some((e) => e.startsWith('ENT-001 Conducteur vs PROJETS/EQUIPE ENT-001 Conducteur') && e.includes('périmètres disjoints déclarés'))).toBe(true) const js = judgments.filter((j) => j.ruleId === 'DM-034' && j.scope.module === 'VEHICULES') expect(js.some((j) => j.question.includes('ENT-001 Conducteur vs PROJETS/EQUIPE ENT-001 Conducteur'))).toBe(false) }) it('a different name with a near-identical definition → judgment (never an err), with both cartouches as excerpts', () => { const js = judgments.filter((j) => j.ruleId === 'DM-034' && j.scope.module === 'VEHICULES') const pilote = js.find((j) => j.question.includes('ENT-001 Conducteur vs PROJETS/EQUIPE ENT-002 Pilote'))! expect(pilote).toBeDefined() expect(pilote.maxSeverity).toBe('warn') expect(pilote.question).toContain('définitions proches') expect(pilote.question).toContain('3 attributs communs') expect(pilote.excerpts).toHaveLength(2) expect(pilote.excerpts[0]).toContain('FLOTTE/VEHICULES ENT-001 — Conducteur') expect(pilote.excerpts[1]).toContain('PROJETS/EQUIPE ENT-002 — Pilote') for (const e of pilote.excerpts) expect(e.length).toBeLessThanOrEqual(2048) expect(findings.filter((x) => x.ruleId === 'DM-034').every((x) => x.severity === 'ok')).toBe(true) }) it('the mechanical resolution is symmetric: PROJETS/EQUIPE sees the FLOTTE Conducteur settled too', () => { const f = of('DM-034', 'PROJETS', 'EQUIPE')[0]! expect(f.evidence?.some((e) => e.includes('ENT-001 Conducteur vs FLOTTE/VEHICULES ENT-001 Conducteur'))).toBe(true) }) it('a single-application corpus → ok « sans objet »', () => { const solo = mkdtempSync(join(tmpdir(), 'ss-dm-solo-')) try { mkdirSync(join(solo, 'APP', 'MOD'), { recursive: true }) writeFileSync(join(solo, 'APP', 'index.md'), '# APP\n') writeFileSync(join(solo, 'APP', 'MOD', 'index.md'), '# MOD\n') writeFileSync(join(solo, 'APP', 'MOD', 'entité.md'), block('ENT-001', 'Chose', 'agrégat racine', ['- **Description** : x.', '- **Périmètre** : y.'], ['Nom']), 'utf8') const out = runRules(DM_RULES, loadCorpus(solo), { strict: false }) expect(out.findings.find((x) => x.ruleId === 'DM-034')!.message).toContain('Une seule application') } finally { rmSync(solo, { recursive: true, force: true }) } }) }) describe('DM-035 — one declaring module per application', () => { it('Mission declared in FLOTTE/VEHICULES and FLOTTE/PLANNING → err on both sides, naming the other', () => { const a = of('DM-035', 'FLOTTE', 'VEHICULES')[0]! const b = of('DM-035', 'FLOTTE', 'PLANNING')[0]! expect(a.severity).toBe('err') expect(a.evidence).toEqual(['ENT-002 Mission — aussi déclarée dans FLOTTE/PLANNING (ENT-001)']) expect(b.severity).toBe('err') expect(b.evidence).toEqual(['ENT-001 Mission — aussi déclarée dans FLOTTE/VEHICULES (ENT-002)']) }) it('a same-name entity in ANOTHER application is DM-034\'s business, never DM-035\'s', () => { expect(of('DM-035', 'PROJETS', 'EQUIPE')[0]!.severity).toBe('ok') }) })