/** * cli:derive-referential-codes — execute.ts * * PURE core — zero `node:fs`. `index.ts` loads the documents and writes the * result; everything here is a function of its inputs. * * The read half (decision line, citations, verdict) lives in * `lib/ba-referential-codes.ts` because DM-022 shares it. What lives HERE is * the write half: the surgical, idempotent rewriting of one `entité.md`. * * The backfill is deliberately narrow. It edits ONE file, block by block, and * only on entities the shared verdict calls `reprise` — a reference table with * a code, no user decision, and nothing citing its values. Everything else is * reported and left untouched: a CITED code stops on THAT entity and the run * continues on the others (never a global refusal that leaves the module half * done). */ import { citationsFor, classifyReferentialCode, fold, initialValuesSources, isReferentialCodeError, type CitationSource, } from '../../../../lib/ba-referential-codes.js' import { parseEntityDoc, type BaEntity } from '../../../../lib/ba-entities.js' import type { DeriveReferentialCodesReport, EntityReport, SourceCoverage, } from './types.js' /** `### ENT-001 — Opportunity (lookup)` — mirrors ba-entities' own heading RE. */ const ENT_HEADING_RE = /^###\s+(ENT-\d+)\s*[—-]\s*([A-Za-z][A-Za-z0-9_]*)\s*(?:\(([^)]+)\))?\s*$/ const ANY_HEADING_RE = /^#{1,3}\s/ const NL = String.fromCharCode(10) const TABLE_LINE_RE = /^(\s*)\|(.*)\|\s*$/ const splitCells = (line: string): string[] => { const m = TABLE_LINE_RE.exec(line) return m ? m[2]!.split('|').map((c) => c.trim()) : [] } const indentOf = (line: string): string => TABLE_LINE_RE.exec(line)?.[1] ?? '' const isSeparatorRow = (cells: string[]): boolean => cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '') /** A separator row keeps its tight `|---|---|` form — reformatting it would * be pure churn in the diff the user reviews to undo the backfill. */ const renderRow = (indent: string, cells: string[]): string => isSeparatorRow(cells) ? indent + '|' + cells.join('|') + '|' : indent + '| ' + cells.join(' | ') + ' |' // --------------------------------------------------------------------------- // Block surgery // --------------------------------------------------------------------------- export interface BlockEdit { lines: string[] applied: string[] } /** Index of the attribute table's header line inside a block, or -1. */ function attributeHeaderIndex(block: readonly string[]): number { return block.findIndex((l) => { const cells = splitCells(l) return cells.length > 0 && fold(cells[0] ?? '') === 'attribut' }) } /** The contiguous run of table lines starting at `from` (inclusive). */ function tableRange(block: readonly string[], from: number): [number, number] { let end = from while (end + 1 < block.length && TABLE_LINE_RE.test(block[end + 1] ?? '')) end += 1 return [from, end] } /** Drop the `| Code | … |` row of the attribute table. */ function dropCodeAttributeRow(block: string[], applied: string[]): string[] { const header = attributeHeaderIndex(block) if (header === -1) return block const [, end] = tableRange(block, header) const out = block.filter((l, i) => { if (i <= header || i > end) return true const cells = splitCells(l) if (isSeparatorRow(cells)) return true if (fold(cells[0] ?? '') !== 'code') return true applied.push("attribut `code` retiré de la table d'attributs") return false }) return out } /** * Remove a MONO-column `(Code) unique` group from the `**Index**` bullet, with * its separating comma whichever side it sits on. A COMPOSITE index is never * touched — the shared verdict blocks that entity upstream. */ function dropCodeIndex(block: string[], applied: string[]): string[] { const i = block.findIndex((l) => /^-\s*\*\*Index\*\*\s*:/.test(l)) if (i === -1) return block const line = block[i]! const head = /^(-\s*\*\*Index\*\*\s*:\s*)/.exec(line)?.[1] ?? '' let rhs = line.slice(head.length) const before = rhs const GROUP = String.raw`\(\s*code\s*\)(?:\s*unique)?` rhs = rhs.replace(new RegExp(String.raw`\s*,\s*${GROUP}`, 'i'), '') if (rhs === before) rhs = rhs.replace(new RegExp(`${GROUP}\\s*,\\s*`, 'i'), '') if (rhs === before) rhs = rhs.replace(new RegExp(GROUP, 'i'), '') if (rhs === before) return block applied.push('index unique sur `code` retiré') const out = [...block] if (!rhs.includes('(')) { out.splice(i, 1) // nothing left to declare return out } out[i] = `${head}${rhs.replace(/^\s*[,.]\s*/, '').trimEnd()}` return out } /** Move the `**Valeurs initiales**` natural key from `Code` onto the label. */ function switchSeedKey(block: string[], label: string, applied: string[]): string[] { const i = block.findIndex((l) => /^-\s*\*\*Valeurs initiales\*\*\s*:/.test(l)) if (i === -1) return block const out = [...block] const replaced = out[i]!.replace( /(cl[eé]\s+`?)([A-Za-z0-9_]+)(`?)/i, (whole, pre: string, key: string, post: string) => fold(key) === 'code' ? `${pre}${label}${post}` : whole, ) if (replaced !== out[i]) { out[i] = replaced applied.push(`clé de semis \`Code\` → \`${label}\``) } return out } /** Drop the `Code` column from the seeded-rows table. */ function dropSeedCodeColumn(block: string[], applied: string[]): string[] { const bullet = block.findIndex((l) => /^-\s*\*\*Valeurs initiales\*\*\s*:/.test(l)) if (bullet === -1) return block const start = block.findIndex((l, i) => i > bullet && TABLE_LINE_RE.test(l)) if (start === -1) return block const [, end] = tableRange(block, start) const col = splitCells(block[start]!).findIndex((c) => fold(c) === 'code') if (col === -1) return block const out = [...block] for (let i = start; i <= end; i++) { const cells = splitCells(out[i]!) if (cells.length <= col) continue out[i] = renderRow(indentOf(out[i]!), cells.filter((_, c) => c !== col)) } applied.push('colonne `Code` retirée de la table des valeurs initiales') return out } /** * The display anchor moves onto the label — this is what makes the generator * fix hold end to end: `scaffold-business` reads `**Affichage**` before any * cascade, so a reference row is named by its label even on a re-scaffold. */ function anchorDisplayOnLabel(block: string[], label: string, applied: string[]): string[] { const i = block.findIndex((l) => /^-\s*\*\*Affichage\*\*\s*:/.test(l)) if (i !== -1) { const out = [...block] const replaced = out[i]!.replace( /(^-\s*\*\*Affichage\*\*\s*:\s*)([A-Za-z][A-Za-z0-9]*)/, (whole, pre: string, attr: string) => (fold(attr) === 'code' ? `${pre}${label}` : whole), ) if (replaced !== out[i]) { out[i] = replaced applied.push(`\`**Affichage**\` : Code → ${label}`) } return out } const bullet = `- **Affichage** : ${label} — le libellé nomme la ligne.` let last = -1 for (let k = 0; k < block.length; k++) if (/^-\s*\*\*/.test(block[k]!)) last = k const out = [...block] if (last === -1) { let end = out.length while (end > 0 && (out[end - 1] ?? '').trim() === '') end -= 1 out.splice(end, 0, bullet) } else { // After the last tail bullet AND anything indented under it (a seeded table). let after = last + 1 while (after < out.length && ((out[after] ?? '').trim() === '' || /^\s/.test(out[after] ?? ''))) { after += 1 } out.splice(after, 0, bullet) } applied.push(`\`**Affichage** : ${label}\` ajouté`) return out } /** Apply the full reprise to one entity block. */ export function repriseBlock(block: readonly string[], label: string): BlockEdit { const applied: string[] = [] let lines = [...block] lines = dropCodeAttributeRow(lines, applied) lines = dropCodeIndex(lines, applied) lines = switchSeedKey(lines, label, applied) lines = dropSeedCodeColumn(lines, applied) lines = anchorDisplayOnLabel(lines, label, applied) return { lines, applied } } /** `entityName → [startLine, endLineExclusive)` over the whole document. */ export function entityBlockRanges(lines: readonly string[]): Map { const out = new Map() let openName: string | null = null let openAt = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i]! const heading = ENT_HEADING_RE.exec(line) if (heading) { if (openName !== null) out.set(openName, [openAt, i]) openName = heading[2]! openAt = i continue } if (ANY_HEADING_RE.test(line) && openName !== null) { out.set(openName, [openAt, i]) openName = null } } if (openName !== null) out.set(openName, [openAt, lines.length]) return out } // --------------------------------------------------------------------------- // The run // --------------------------------------------------------------------------- export interface DeriveInput { app: string module: string mode: 'check' | 'backfill' /** Raw `entité.md` content. */ entiteMd: string /** Citation sources ALREADY loaded (rules, ACs, PRD) — fs stays in index. */ sources: readonly CitationSource[] coverage: SourceCoverage warnings?: string[] } export interface DeriveOutput { report: DeriveReferentialCodesReport /** Rewritten `entité.md`, or null when nothing changed (idempotence). */ rewritten: string | null } /** Said out loud on every report — never inferred by the reader. */ export function scopeNotesFor(coverage: SourceCoverage): string[] { const notes = [ "L'inventaire lit la BA, PAS le C#. Une machine d'états qui compare un code sans qu'aucune " + "règle ne le cite lui échappe (périmètre DEV-API) : l'absence de citation n'est donc pas " + "une preuve d'absence.", 'Une clé de semis posée sur le LIBELLÉ se casse si quelqu\'un renomme la ligne — le semis ' + 'recrée alors la ligne au lieu de la retrouver. C\'est le prix du retrait du code, et il ' + 'se pèse AVANT de décider, pas six mois après.', ] if (!coverage.prdPresent) { notes.push( 'prd*.md / pagespecs ABSENTS — inventaire PARTIEL : la 3ᵉ source (un payload qui désigne ' + "la ligne par son code sans qu'aucune règle ne la cite) n'a pas pu être lue. Relancer " + 'après /ba-create-prd avant tout backfill.', ) } return notes } export function deriveReferentialCodes(input: DeriveInput): DeriveOutput { const warnings = [...(input.warnings ?? [])] const parsed = parseEntityDoc(input.entiteMd, `${input.app}/${input.module}`) warnings.push(...parsed.warnings) const lines = input.entiteMd.split(/\r?\n/) const ranges = entityBlockRanges(lines) const entities: EntityReport[] = [] /** entityName → rewritten block (backfill only). */ const rewrites = new Map() for (const e of parsed.entities) { const citations = citationsFor(e, [ ...input.sources, ...initialValuesSources(parsed.entities, e.name), ]) const verdict = classifyReferentialCode(e, citations) const applied: string[] = [] if (input.mode === 'backfill' && verdict.status === 'reprise') { const range = ranges.get(e.name) if (!range) { warnings.push(`${e.name}: bloc introuvable dans le markdown — non repris.`) } else if (verdict.labelAttribute === null) { warnings.push(`${e.name}: pas d'attribut libellé — non repris.`) // classify guards this } else { const edit = repriseBlock(lines.slice(range[0], range[1]), verdict.labelAttribute) if (edit.applied.length > 0) { rewrites.set(e.name, edit.lines) applied.push(...edit.applied) } } } entities.push({ ...verdict, applied }) } // Splice the rewritten blocks back, from the END so earlier ranges hold. let rewritten: string | null = null if (rewrites.size > 0) { const ordered = [...rewrites.entries()] .map(([name, block]) => ({ range: ranges.get(name)!, block })) .sort((a, b) => b.range[0] - a.range[0]) const next = [...lines] for (const { range, block } of ordered) next.splice(range[0], range[1] - range[0], ...block) // Never strip the document's trailing newline — an inserted tail bullet // lands after the final empty element and would silently eat it. let joined = next.join('\n') if (input.entiteMd.endsWith(NL) && !joined.endsWith(NL)) joined += NL rewritten = joined === input.entiteMd ? null : joined } const references = entities.filter((e) => e.status !== 'not-reference') const count = (s: EntityReport['status']): number => references.filter((e) => e.status === s).length const report: DeriveReferentialCodesReport = { mode: input.mode, app: input.app, module: input.module, entities, coverage: input.coverage, scopeNotes: scopeNotesFor(input.coverage), totals: { entities: entities.length, references: references.length, clean: count('clean'), decided: count('decided'), nearMiss: count('near-miss'), allocated: count('allocated'), blocked: count('blocked'), reprise: count('reprise'), backfilled: entities.filter((e) => e.applied.length > 0).length, citations: entities.reduce((n, e) => n + e.citations.length, 0), errors: references.filter((e) => isReferentialCodeError(e.status)).length, }, filesModified: [], warnings, } return { report, rewritten } } export type { BaEntity }