/** * Fail-closed BELOW the heading — the engine leg. * * lib/ba-entities now says out loud what it cannot read (an attribute row it * must refuse, an **Index** with no group, a **Relations** entry the grammar * cannot parse). This suite asserts the two things the lib tests cannot: * - those warnings REACH the report (`model.parseWarnings`). They used to * land in `ModuleModel.entityWarnings` and be read by nobody — the * entité.md heading near-miss, the one warning the parser did have, died * that way; * - DM-007 SEES a refused row. For as long as the parser dropped every * accented/hyphenated name before the rule ran, DM-007 could only ever * fire on an underscore, and its `ok` described the survivors. * * And the sharpest one: an AUTHORED model that keeps one leftover placeholder * line is read whole — every relation with it. Before, `parseEntities` tested * the marker against the entire file and discarded the document: DM-004 and * DM-013 went green on an empty graph while DM-009 warned that everything was * isolated. */ 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, type CorpusModel } from '../corpus/model.js' import { runRules } from '../engine.js' import { reconcileParse } from '../control-counts.js' import { DM_RULES } from '../rules/dm.js' import type { Finding } from '../types.js' const ENTITE = [ '### ENT-001 — Order (agrégat racine)', '- **Préfixe table** : `ord_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Name | string(150) | requis | — |', '| Montant (HT) | decimal(18,2) | ≥ 0 | — |', '', '- **Relations** :', ' - Order *→1 Client — FK ClientId, scope same-module, onDelete restrict.', ' - Order *→1 Owner FK OwnerId', '- **Index** : Name unique', '', '### ENT-002 — Client (agrégat racine)', '- **Préfixe table** : `ord_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Name | string(150) | requis | — |', '', '## Écrans', '', '_À définir lors de la phase « écrans »._', '', ].join('\n') let root: string let model: CorpusModel let findings: Finding[] beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'audit-ba-fail-closed-')) const p = join(root, 'SALES', 'ORDERS', 'entité.md') mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, ENTITE, 'utf8') model = loadCorpus(root) findings = runRules(DM_RULES, model, { strict: false }).findings }) afterAll(() => { rmSync(root, { recursive: true, force: true }) }) const byRule = (id: string): Finding => findings.find((f) => f.ruleId === id)! describe('the parser near-misses reach the report', () => { it('forwards the refused attribute row, the empty Index and the Relations loss into model.parseWarnings', () => { const w = model.parseWarnings.filter((x) => x.startsWith('SALES/ORDERS/entité.md: ')) expect(w.some((x) => x.includes('attribute row « Montant (HT) » dropped'))).toBe(true) expect(w.some((x) => x.includes('**Index** declares « Name unique »'))).toBe(true) expect(w.some((x) => x.includes('carries 2 cardinality token(s) but only 1 entry(ies) parse'))).toBe(true) }) }) describe('DM-007 sees its own subject again', () => { it('warns with the refused row in evidence', () => { const f = byRule('DM-007') expect(f.severity).toBe('warn') expect(f.evidence!.some((e) => e.includes('Montant (HT)'))).toBe(true) }) }) describe('a leftover placeholder line no longer erases the model', () => { it('keeps every entity AND its relations — DM-004 resolves, DM-009 does not cry isolation', () => { const m = model.modules.find((x) => x.module === 'ORDERS')! expect(m.entities.map((e) => e.name)).toEqual(['Order', 'Client']) expect(m.entities[0]!.relations).toHaveLength(1) expect(model.graph.relations).toHaveLength(1) expect(byRule('DM-004').severity).toBe('ok') // Order → Client exists, so neither entity is isolated. expect(byRule('DM-009').severity).toBe('ok') }) }) describe('control counts BELOW the heading (attributes / relations / indexes)', () => { it('a LOUD loss counts as seen — the corpus reconciles ok, and the totals say what was declared', () => { const control = reconcileParse(model) expect(control.status).toBe('ok') // 3 rows on Order (one refused, out loud) + 2 on Client. expect(control.totals['attributes']).toEqual({ control: 5, parsed: 5 }) // 2 cardinality tokens, 1 parsed + 1 reported lost. expect(control.totals['relations']).toEqual({ control: 2, parsed: 2 }) // 1 Index bullet, unreadable — warned, hence seen. expect(control.totals['indexes']).toEqual({ control: 1, parsed: 1 }) }) it('a relation the grammar cannot read, even OUTSIDE a **Relations** bullet, is LOUD — seen, never fatal', () => { // The loose legacy style: no dash, no `scope` — the grammar cannot read it. // The loss probe is BLOCK-scoped (ba-relations owns it), so the token is // still counted, the warning names the entity, and the control count sees // a loud loss: seen, status ok, never a mute exit 3. const dir = mkdtempSync(join(tmpdir(), 'audit-ba-bare-relation-')) try { const p = join(dir, 'APP', 'MOD', 'entité.md') mkdirSync(join(p, '..'), { recursive: true }) writeFileSync( p, [ '### ENT-001 — Order (agrégat racine)', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '', 'Order *→1 Client (FK ClientId, cross-module vers CRM/CLIENTS)', '', ].join('\n'), 'utf8', ) const model = loadCorpus(dir) const control = reconcileParse(model) expect(control.status).toBe('ok') expect(control.totals['relations']).toEqual({ control: 1, parsed: 1 }) expect(model.parseWarnings.some((w) => w.includes('carries 1 cardinality token(s) but only 0 entry(ies) parse'))).toBe(true) } finally { rmSync(dir, { recursive: true, force: true }) } }) })