/** * DM-025 — an attribute nothing describes (the dead column), and the helper * behind it. The exemption list is where the bugs would live, so every * exemption gets a fixture attribute that MUST NOT be reported, and the two * genuinely dead ones MUST. */ 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 { attributeEvidence, DM_RULES } from '../rules/dm.js' import type { Finding } from '../types.js' const ENTITE = [ '### ENT-001 — Order (agrégat racine)', '- **Préfixe table** : `ord_`', '- **Portée** : strict', '- **Affichage** : Reference — le champ qui NOMME une ligne', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Reference | string(50) | requis | — |', '| Name | string(150) | requis | — |', // cited by a UC '| Amount | decimal(18,2) | ≥ 0 | — |', // cited by a rule expression '| Notes | string(2000) | — | — |', // cited only by a screen column '| Priority | int | — | — |', // cited only by an index '| Status | enum | draft/open/closed, requis | — |', // state → exempt '| Weighted | decimal(18,2) | — | `Amount * Probability` |', // computed → exempt '| Probability | decimal(5,4) | — | — |', // cited only by a sibling formula '| ApprovedByUserId | Guid | — | — |', // fk-allowlist → exempt '| Rank | int | — | — |', // DEAD '| Legacy | string(50) | — | — |', // DEAD '', '- **Relations** : Order *→1 Client — FK ClientId, scope same-module, onDelete restrict.', '- **Index** : (Priority).', '', '### ENT-002 — Client (agrégat racine)', '- **Préfixe table** : `ord_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Name | string(150) | requis | — |', '', '### ENT-003 — OrderType (lookup)', '- **Préfixe table** : `ord_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Label | string(100) | requis | — |', // DM-015 set → exempt '| IsActive | bool | requis | — |', // DM-015 set → exempt '| Color | string(20) | — | — |', // DEAD on a lookup — must be reported '', '### ENT-004 — OrderAuditLog (technical)', '- **Préfixe table** : `ord_`', '- **Isolation** : by-design — journal', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Payload | string(4000) | — | — |', // technical entity → exempt '', ].join('\n') const UC = [ '### UC-SALES-ORDERS-orders-001 — Créer une commande', '- **Acteur principal** : BA-SALES-AC-001', '- **Scénario nominal** :', ' 1. L’utilisateur saisit le Name de la commande.', '', ].join('\n') const RULES = [ '### BR-001 — Montant positif', '- **Type** : validation', '- **Sévérité** : err', '- **Expression** : `Amount > 0`', '', ].join('\n') const SCREEN = [ '### SCR-SALES-ORDERS-orders-001 — Liste des commandes (SmartListView)', '- **Entité** : Order', '- **Colonnes** : Reference, Name, Notes', '', ].join('\n') function corpus(files: Record): { root: string; f: Finding[] } { const root = mkdtempSync(join(tmpdir(), 'audit-ba-dm-025-')) for (const [rel, content] of Object.entries(files)) { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } const f = runRules(DM_RULES.filter((r) => r.id === 'DM-025'), loadCorpus(root), { strict: false }).findings return { root, f } } describe('DM-025 — dead columns', () => { it('reports the genuinely dead attributes and NONE of the exempt/described ones', () => { const { root, f } = corpus({ 'SALES/ORDERS/entité.md': ENTITE, 'SALES/ORDERS/règles-métier.md': RULES, 'SALES/ORDERS/orders/use-case.md': UC, 'SALES/ORDERS/orders/screen.md': SCREEN, }) try { expect(f).toHaveLength(1) expect(f[0]!.severity).toBe('warn') expect(f[0]!.evidence!.sort()).toEqual(['Order.Legacy', 'Order.Rank', 'OrderType.Color']) } finally { rmSync(root, { recursive: true, force: true }) } }) it('stays silent (inventaire PARTIEL) when no screen exists yet — phase 6 before phase 7', () => { const { root, f } = corpus({ 'SALES/ORDERS/entité.md': ENTITE, 'SALES/ORDERS/règles-métier.md': RULES, 'SALES/ORDERS/orders/use-case.md': UC, }) try { expect(f[0]!.severity).toBe('ok') expect(f[0]!.message).toContain('PARTIEL') } finally { rmSync(root, { recursive: true, force: true }) } }) it('attributeEvidence gathers the structural references (FK, index, formula tokens, Affichage)', () => { const root = mkdtempSync(join(tmpdir(), 'audit-ba-dm-025-ev-')) try { const p = join(root, 'SALES', 'ORDERS', 'entité.md') mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, ENTITE, 'utf8') const model = loadCorpus(root) const m = model.modules.find((x) => x.module === 'ORDERS')! const { names } = attributeEvidence(model, m) for (const n of ['clientid', 'priority', 'amount', 'probability', 'reference']) expect(names.has(n)).toBe(true) expect(names.has('rank')).toBe(false) } finally { rmSync(root, { recursive: true, force: true }) } }) })