/** * cli:audit-ba — control-counts.ts (the FAIL-CLOSED heart) * * Independent, ultra-permissive control counts per doc, reconciled against * the structured parsers' totals. The failure mode this closes: a pattern * stops matching, the parser yields 0 items, exit 0, and the audit concludes * « 0 erreur » on an unread corpus. Here: * - a doc whose control count > 0 while the parser saw 0 → FATAL (exit 3); * - an aggregated gap > max(10 items, 2%) on any counter → FATAL; * - any non-zero per-doc gap → a `parse-suspect` warn finding. * The control regexes are deliberately DUMB (anchored prefixes, no grammar) — * they over-approximate, so `parsed > control` is possible and only the * control>parsed direction is loss-suspicious; equality is the healthy state. */ import { isAttributeRowLoss, isIndexLoss, relationLossOf } from '../../../../lib/ba-entities.js' import { countTestDataRows } from '../../../../lib/ba-test-data.js' import type { CorpusDoc, CorpusModel } from './corpus/model.js' import type { DocReconciliation, ParseControlReport } from './types.js' const FATAL_ABS_GAP = 10 const FATAL_REL_GAP = 0.02 interface CounterSpec { counter: string kind: CorpusDoc['kind'] control: (raw: string) => number /** Structured count for the SAME doc (from the model). */ parsed: (model: CorpusModel, doc: CorpusDoc) => number } const count = (raw: string, re: RegExp): number => raw.match(re)?.length ?? 0 /** Strip the machine-owned blocks an rbac.md carries (independent slicing — * markers only, no parser reuse). */ function humanRbacRegion(raw: string): string { return raw.replace(/[\s\S]*?/g, '') } function moduleOf(model: CorpusModel, doc: CorpusDoc) { return model.modules.find((m) => m.app === doc.app && m.module === doc.module) } /** * Data rows of the `| Attribut | … |` table(s) only — a deliberately dumb * state machine (a header cell folding to « attribut » opens a table, any * non-`|` line closes it, separator rows skipped). A `**Valeurs initiales**` * table must NOT count, or every seeded lookup would cry wolf. */ function countAttributeRows(raw: string): number { let inTable = false let n = 0 for (const line of raw.split(/\r?\n/)) { const isRow = /^\s*\|/.test(line) if (!isRow) { inTable = false; continue } const first = (line.match(/^\s*\|\s*([^|]*)/)?.[1] ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toLowerCase() if (first === 'attribut') { inTable = true; continue } if (!inTable) continue if (/^:?-{2,}:?$/.test(first) || first === '') continue n++ } return n } const SPECS: CounterSpec[] = [ { counter: 'ucs', kind: 'use-case', control: (raw) => count(raw, /^###\s+UC-/gim), parsed: (model, doc) => { const m = moduleOf(model, doc) if (!m) return 0 const rel = doc.relPath.split('/').slice(2).join('/') const ucDoc = m.ucDocs.find((d) => d.relPath === rel) // Near-misses are parser-visible losses (counted in lost) — they are // NOT silent, so count them as "seen" for the reconciliation. return ucDoc ? ucDoc.ucs.length + ucDoc.lost.filter((l) => l.includes('does not parse')).length : 0 }, }, { counter: 'acs', kind: 'use-case', control: (raw) => count(raw, /^\s*-\s*\[[ xX]?\]\s*AC-\d/gim), parsed: (model, doc) => { const m = moduleOf(model, doc) if (!m) return 0 const rel = doc.relPath.split('/').slice(2).join('/') const ucDoc = m.ucDocs.find((d) => d.relPath === rel) if (!ucDoc) return 0 // Malformed/duplicate bullets are in `lost` (loud) — count them as seen. const lostAcs = ucDoc.lost.filter((l) => !l.includes('does not parse')).length return ucDoc.ucs.reduce((n, u) => n + u.acs.length, 0) + lostAcs }, }, { counter: 'entities', kind: 'entite', control: (raw) => count(raw, /^###\s+ENT-/gim), parsed: (model, doc) => moduleOf(model, doc)?.entities.length ?? 0, }, // The three counters BELOW the heading. The reconciler used to stop at // `### ENT-`: an attribute table, a Relations bullet or an Index bullet // that stopped matching yielded 0 items while `entities` stayed equal — // green on a graph with no foreign keys. A LOUD loss (the parser warned) // counts as seen, exactly as the acs counter treats `lost`. { counter: 'attributes', kind: 'entite', control: (raw) => countAttributeRows(raw), parsed: (model, doc) => { const m = moduleOf(model, doc) if (!m) return 0 const kept = m.entities.reduce((n, e) => n + e.attributes.length, 0) const refused = m.entityWarnings.filter(isAttributeRowLoss).length return kept + refused }, }, { counter: 'relations', kind: 'entite', control: (raw) => count(raw, /\*(?:→|->)1|1(?:→|->)1|1(?:→|->)\*|\*(?:→|->)\*/g), parsed: (model, doc) => { const m = moduleOf(model, doc) if (!m) return 0 const parsedRel = m.entities.reduce((n, e) => n + e.relations.length, 0) const lost = m.entityWarnings.reduce((n, w) => n + relationLossOf(w), 0) return parsedRel + lost }, }, { counter: 'indexes', kind: 'entite', control: (raw) => count(raw, /^-\s*\*\*Index\*\*/gim), parsed: (model, doc) => { const m = moduleOf(model, doc) if (!m) return 0 const withIndex = m.entities.filter((e) => e.indexes.length > 0).length const unreadable = m.entityWarnings.filter(isIndexLoss).length return withIndex + unreadable }, }, { // jeu-de-test.md — heading grain. A near-miss heading is WARNED by the // parser and counted in `headingsSeen`, so a loud loss reconciles. counter: 'testDataSets', kind: 'jeu-de-test', control: (raw) => count(raw, /^###\s+JT-/gim), parsed: (model, doc) => moduleOf(model, doc)?.testData?.headingsSeen ?? 0, }, { // Row grain: a refused row (short, empty, second table, dropped block) is // WARNED and counted in `rowsLost`, so a loud loss reconciles — never « 0 rows ». counter: 'testDataRows', kind: 'jeu-de-test', control: (raw) => countTestDataRows(raw), parsed: (model, doc) => { const d = moduleOf(model, doc)?.testData if (!d) return 0 return d.sets.reduce((n, s) => n + s.rows.length, 0) + d.rowsLost }, }, { counter: 'rules', kind: 'regles', control: (raw) => count(raw, /^###\s+BR-\d/gim), // Rules aggregate module+sections into one list — reconcile at doc // granularity by docPath suffix match. parsed: (model, doc) => { const m = moduleOf(model, doc) if (!m) return 0 const suffix = doc.relPath.split('/').slice(2).join('/') return m.rules.filter((r) => r.docPath.replace(/\\/g, '/').endsWith(suffix)).length }, }, { counter: 'screens', kind: 'screen', control: (raw) => count(raw, /^###\s+SCR-/gim), parsed: (model, doc) => { const m = moduleOf(model, doc) if (!m) return 0 const rel = doc.relPath return m.screens.filter((s) => s.file.replace(/\\/g, '/') === rel).length }, }, { counter: 'actors', kind: 'acteur', control: (raw) => count(raw, /^###\s+BA-.*-AC-\d+/gim), parsed: (model, doc) => model.apps.find((a) => a.app === doc.app)?.actors.length ?? 0, }, { counter: 'rbacRows', kind: 'rbac', control: (raw) => count(humanRbacRegion(raw), /^\|\s*BA-/gim), parsed: (model, doc) => moduleOf(model, doc)?.rbacRows.length ?? 0, }, ] export function reconcileParse(model: CorpusModel): ParseControlReport { const perDoc: DocReconciliation[] = [] const totals: Record = {} for (const spec of SPECS) { totals[spec.counter] = { control: 0, parsed: 0 } for (const doc of model.docs) { if (doc.kind !== spec.kind) continue const control = spec.control(doc.raw) const parsed = spec.parsed(model, doc) totals[spec.counter]!.control += control totals[spec.counter]!.parsed += parsed if (control === parsed) continue const status: DocReconciliation['status'] = control > 0 && parsed === 0 ? 'fatal' : 'suspect' perDoc.push({ relPath: doc.relPath, counter: spec.counter, control, parsed, status }) } } // Client-sources registry (sibling root): `ba:source` anchors on disk vs // docs the parser actually read — the same mute-parser guard, at registry // granularity (per-doc misses inside the registry are SRC-001's issues). const reg = model.sources if (reg.exists) { totals['sources'] = { control: reg.controls.anchors, parsed: reg.controls.parsed } if (reg.controls.anchors > reg.controls.parsed) { perDoc.push({ relPath: '(sources)', counter: 'sources', control: reg.controls.anchors, parsed: reg.controls.parsed, status: reg.controls.parsed === 0 ? 'fatal' : 'suspect', }) } } let status: ParseControlReport['status'] = 'ok' if (perDoc.some((d) => d.status === 'suspect')) status = 'suspect' for (const [counter, t] of Object.entries(totals)) { const gap = t.control - t.parsed if (gap > Math.max(FATAL_ABS_GAP, Math.ceil(t.control * FATAL_REL_GAP))) { status = 'fatal' perDoc.push({ relPath: '(aggregate)', counter, control: t.control, parsed: t.parsed, status: 'fatal' }) } } if (perDoc.some((d) => d.status === 'fatal')) status = 'fatal' return { status, perDoc, totals } }