/** * cli:derive-lookup-grants — block.ts * * The machine-owned block of rbac.md: deterministic render + idempotent * whole-line splice between the BEGIN/END markers. The block is ALWAYS * rewritten in full (never patched row by row) so its content is a pure * function of the data model — same inputs → byte-identical block. */ import type { DriftReport, LookupGrantRow, SkippedGrant, UnresolvedProducer, } from './types.js' import { DERIVED_BLOCK_BEGIN, DERIVED_BLOCK_END } from '../../../../lib/ba-rbac-rows.js' // Markers + stripDerivedBlock moved to lib/ba-rbac-rows.ts (shared with // documentation/extract-doc); re-exported here so import sites stay untouched. export { DERIVED_BLOCK_BEGIN, DERIVED_BLOCK_END, stripDerivedBlock } from '../../../../lib/ba-rbac-rows.js' /** One rendered table row — also the drift-comparison unit for mode=check. */ export function renderRowLine(row: LookupGrantRow): string { const actor = row.actorLabel ? `${row.actorCode} (${row.actorLabel})` : row.actorCode return `| ${actor} | \`${row.path}\` | ${row.portee} | ${row.justification} |` } /** * Render the full machine-owned block. Deterministic — rows are expected * already deduped + stably sorted by the caller (derive.ts). */ export function renderDerivedBlock( rows: LookupGrantRow[], needsResolution: UnresolvedProducer[], skipped: SkippedGrant[] = [], ): string { const lines: string[] = [ DERIVED_BLOCK_BEGIN, '### Grants `lookup` dérivés — bloc machine (ne pas éditer à la main)', '', '> Généré par `derive-lookup-grants` depuis les FK du modèle de données', '> (relations `*→1` / `1→1`, scope ≠ core). Chemins **app-qualifiés**', '> (4 segments — exception assumée à la prohibition n°10 : la Phase 0 les', "> transcrit tels quels, sans re-préfixer). Relancer le CLI après toute", "> modification d'`entité.md`.", '', ] if (rows.length === 0) { // The empty case has TWO causes and they are not the same news. Saying // "aucune FK" when every candidate was suppressed as redundant is what // forced a client to reverse-engineer the CLI's JSON to understand an // empty block (Demo-GestionFlotte, 2026-09-04). const covered = skipped.length === 1 ? 'l’unique candidat est déjà couvert' : `les ${skipped.length} candidats sont déjà couverts` lines.push( skipped.length > 0 ? `_Aucun grant \`lookup\` dérivé — ${covered} par un \`read\`/\`lookup\` ` + 'détenu sur la section productrice (détail ci-dessous)._' : '_Aucun grant `lookup` dérivé — aucune FK vers une autre section._', ) } else { lines.push( '| Acteur | Permission (`app.module.section.lookup`) | Portée | Justification |', '|--------|-------------------------------------------|--------|---------------|', ...rows.map(renderRowLine), ) } // Suppressions used to exist ONLY in the CLI's stdout JSON: the document // gave no clue why an actor was absent. Blockquote lines never start with // `|`, so `extractBlockRowLines` (the drift unit) cannot see them. if (skipped.length > 0) { lines.push( '', '> ℹ Grants non émis — l’acteur détient déjà `read`/`lookup` au grain section', '> chez le producteur, ce qui passe déjà le double portail', '> `[RequirePermission(x.lookup, x.read)]` (ANY) : un grant dérivé serait redondant.', ...skipped.map((sk) => `> - ${sk.actorCode} → \`${sk.path}\` (détient \`${sk.reason === 'holds-lookup' ? 'lookup' : 'read'}\`)`), ) } if (needsResolution.length > 0) { lines.push( '', '> ⚠ À résoudre (section producteur introuvable — compléter `screen.md` ou le menu, puis relancer) :', ...needsResolution.map( (n) => `> - FK ${n.consumerEntity}.${n.fk} → ${n.targetEntity} (${n.producerModule}) : ${n.detail}`, ), ) } lines.push(DERIVED_BLOCK_END) return lines.join('\n') } /** * Replace the whole-line span between the markers with `block`. Returns the * new source, `null` when the block is already byte-identical (no rewrite * needed), or appends the block at EOF when no markers exist yet. */ export function spliceDerivedBlock(source: string, block: string): string | null { const beginIdx = source.indexOf(DERIVED_BLOCK_BEGIN) const endIdx = source.indexOf(DERIVED_BLOCK_END) if (beginIdx >= 0 && endIdx > beginIdx) { const lineStart = source.lastIndexOf('\n', beginIdx) + 1 const lineEnd = source.indexOf('\n', endIdx) const before = source.slice(0, lineStart) const after = lineEnd >= 0 ? source.slice(lineEnd + 1) : '' const next = before + block + '\n' + after return next === source ? null : next } // No block yet — append at EOF, separated by one blank line. const sep = source.endsWith('\n\n') ? '' : source.endsWith('\n') ? '\n' : '\n\n' return source + sep + block + '\n' } /** The raw text between the markers (markers included), or null when absent. */ export function extractDerivedBlock(source: string): string | null { const beginIdx = source.indexOf(DERIVED_BLOCK_BEGIN) const endIdx = source.indexOf(DERIVED_BLOCK_END) if (beginIdx < 0 || endIdx <= beginIdx) return null return source.slice(beginIdx, endIdx + DERIVED_BLOCK_END.length) } /** * DATA row lines inside a rendered/extracted block — identified by the actor * code prefix (`| BA-… |`), which excludes the table header (whose Permission * column title also contains a backticked `.lookup` token). */ export function extractBlockRowLines(block: string): string[] { return block .split('\n') .map((l) => l.trim()) .filter((l) => /^\|\s*BA-.*`[^`]+\.lookup`.*\|$/.test(l)) } /** * THE drift comparison — shared by the CLI's `mode=check` and by audit-ba's * RBAC-008. It used to be the same seven lines copied into both, pinned by no * test, and they had already drifted on `missingBlock`. * * `missingBlock` follows the RULE's (documented) semantics: a block is missing * only when the derivation had something to say. A module with no FK candidate * legitimately carries no block yet — that is not drift. */ export function checkLookupDrift( source: string, core: { rows: LookupGrantRow[]; totals: { fkCandidates: number } }, ): DriftReport { const current = extractDerivedBlock(source) const freshLines = core.rows.map(renderRowLine) const fileLines = current ? extractBlockRowLines(current) : [] const freshSet = new Set(freshLines) const fileSet = new Set(fileLines) const staleRows = fileLines.filter((l) => !freshSet.has(l)) const missingRows = freshLines.filter((l) => !fileSet.has(l)) const missingBlock = current === null && core.totals.fkCandidates > 0 return { missingBlock, staleRows, missingRows, upToDate: !missingBlock && staleRows.length === 0 && missingRows.length === 0, } }