/** * cli:derive-rule-links — execute.ts * * Pure cores over injected sources (fs-free — the index does the I/O): * - mapping ladder: rule-doc SECTION folder → linked-UC sections → [] (needs-judgment) * - backfill: write the code into every pagespec of the mapped section(s) * - check: PRD-129 (required rules linked) + PRD-130 (cited codes resolve) * * Never a guess: an unmappable rule is DATA (`needs-judgment`), never an * invented link; an exemption always names its real enforcement channel. */ import { basename, dirname } from 'node:path' import { enforceableRules, ruleExemption, type BaRule } from '../../../../lib/ba-rules-rows.js' import type { CitedCodeFinding, DeriveRuleLinksReport, RuleLinkOutcome } from './types.js' // Shared exemption doctrine (moved to lib — audit-dev-tests DEV-TEST-009 // applies the SAME one; re-exported so this CLI's surface stays stable). export { ruleExemption } from '../../../../lib/ba-rules-rows.js' /** Matches the FIRST fenced ```json … ``` block (the pagespec machine block). */ export const JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ export interface PagespecSource { /** File name (e.g. `Opportunite.list.md`) — the report key. */ name: string md: string } export interface ParsedPagespec { name: string md: string block: Record | null raw: RegExpExecArray | null section?: string linked: string[] } export function parsePagespec(source: PagespecSource): ParsedPagespec { const m = JSON_BLOCK_RE.exec(source.md) if (!m) return { name: source.name, md: source.md, block: null, raw: null, linked: [] } try { const block = JSON.parse(m[1]!) as Record const linked = Array.isArray(block.linkedBusinessRules) ? block.linkedBusinessRules.filter((v): v is string => typeof v === 'string') : [] return { name: source.name, md: source.md, block, raw: m, ...(typeof block.section === 'string' ? { section: block.section } : {}), linked, } } catch { return { name: source.name, md: source.md, block: null, raw: null, linked: [] } } } /** kebab section of a UC code (4-seg → seg[3], 5-seg → seg[3] too). */ function ucSection(ucCode: string): string | null { const parts = ucCode.split('-') if (parts.length < 5) return null return parts[3]!.toLowerCase().replace(/_/g, '-') } /** * Deterministic mapping ladder: * 1. the rule's doc lives in a SECTION folder → that section; * 2. module-level doc → the sections of its linked UCs (deduped); * 3. neither → [] (needs-judgment — the human decides, never a guess). */ export function mapRuleSections(rule: BaRule, moduleDirName: string): string[] { const parent = basename(dirname(rule.docPath)) if (parent !== '' && parent !== moduleDirName && /^[a-z]/.test(parent)) return [parent] const fromUcs = [...new Set(rule.linkedUcs.map(ucSection).filter((s): s is string => s !== null))] return fromUcs.sort() } export interface DeriveInput { app: string module: string mode: 'backfill' | 'check' rules: BaRule[] ruleWarnings: string[] pagespecs: PagespecSource[] /** Module folder NAME (docPath parent of module-level rules). */ moduleDirName: string } export interface DeriveOutput { report: DeriveRuleLinksReport /** name → rewritten md (backfill mode only). */ rewrites: Map } export function deriveRuleLinks(input: DeriveInput): DeriveOutput { const warnings = [...input.ruleWarnings] const parsed = input.pagespecs.map(parsePagespec) for (const p of parsed) { if (p.block === null) warnings.push(`${p.name}: no parseable \`\`\`json machine block — skipped.`) } const pages = parsed.filter((p) => p.block !== null) // Codes cited anywhere (PRD-130 input) + where each rule is already linked. const linkedAnywhere = new Set() for (const p of pages) for (const code of p.linked) linkedAnywhere.add(code) const byCode = new Map() for (const rule of input.rules) { byCode.set(rule.code, [...(byCode.get(rule.code) ?? []), rule]) } const citedCodeFindings: CitedCodeFinding[] = [] for (const p of pages) { for (const code of p.linked) { const holders = byCode.get(code) if (!holders || holders.length === 0) { citedCodeFindings.push({ page: p.name, code, kind: 'unresolved-code', detail: `\`${code}\` is cited by ${p.name} but exists in NO règles-métier.md of ${input.app}/${input.module} — a dead reference satisfies no gate (mirror of PRD-097 for UCs).`, }) } else if (holders.length > 1) { citedCodeFindings.push({ page: p.name, code, kind: 'ambiguous-code', detail: `\`${code}\` exists in ${holders.length} docs of the module (BR codes are doc-scoped) — the citation is ambiguous; renumber one doc.`, }) } } } const enforceable = enforceableRules(input.rules) const enforceableCodes = new Set(enforceable.map((r) => r.code)) const rules: RuleLinkOutcome[] = [] const rewrites = new Map() // name → mutable pending block (several rules may land on the same page). const pending = new Map() for (const rule of input.rules) { const exemption = ruleExemption(rule) if (exemption !== null) { rules.push({ code: rule.code, docPath: rule.docPath, sections: [], status: 'exempt', exemption }) continue } if (!enforceableCodes.has(rule.code)) { // Not enforceable, yet NOT exempt: a NON-CANONICAL severity (typo like // « bloquant »). It used to be mislabeled `exempt/severity-info` — a // spelling mistake silently disarmed BOTH gates while wearing a // legitimate exemption. It is the author's call to make, loudly. rules.push({ code: rule.code, docPath: rule.docPath, sections: [], status: 'needs-judgment', detail: `${rule.code}: Sévérité "${rule.fields['severite'] ?? '(absente)'}" is not canonical (err|warn|info) — ` + `the rule sits OUTSIDE every gate until fixed. Correct the severity in ${rule.docPath}.`, }) continue } if (linkedAnywhere.has(rule.code)) { rules.push({ code: rule.code, docPath: rule.docPath, sections: [], status: 'linked' }) continue } const sections = mapRuleSections(rule, input.moduleDirName) if (sections.length === 0) { rules.push({ code: rule.code, docPath: rule.docPath, sections: [], status: 'needs-judgment', detail: `${rule.code} (${rule.title}) is a module-level rule with no linked UC — no section is derivable ` + `mechanically. Author linkedBusinessRules on the owning pagespec(s), link a UC in règles-métier.md, ` + `or exempt it (- **Enforcement** : plateforme|manuel).`, }) continue } const targets = pages.filter((p) => p.section !== undefined && sections.includes(p.section)) if (targets.length === 0) { rules.push({ code: rule.code, docPath: rule.docPath, sections, status: 'missing-link', detail: `mapped to section(s) [${sections.join(', ')}] but no pagespec carries that section — verify the PRD covers the section (PRD-070).`, }) continue } if (input.mode === 'check') { rules.push({ code: rule.code, docPath: rule.docPath, sections, status: 'missing-link' }) continue } for (const target of targets) { const live = pending.get(target.name) ?? target const set = new Set( Array.isArray(live.block!.linkedBusinessRules) ? (live.block!.linkedBusinessRules as unknown[]).filter((v): v is string => typeof v === 'string') : [], ) set.add(rule.code) live.block!.linkedBusinessRules = [...set].sort() pending.set(target.name, live) } rules.push({ code: rule.code, docPath: rule.docPath, sections, status: 'backfilled' }) } for (const [name, page] of pending) { const newJson = JSON.stringify(page.block, null, 2) const raw = page.raw! const md2 = page.md.slice(0, raw.index) + '```json\n' + newJson + '\n```' + page.md.slice(raw.index + raw[0].length) rewrites.set(name, md2) } const totals = { rules: input.rules.length, required: rules.filter((r) => r.status !== 'exempt').length, linked: rules.filter((r) => r.status === 'linked').length, backfilled: rules.filter((r) => r.status === 'backfilled').length, missingLinks: rules.filter((r) => r.status === 'missing-link').length, needsJudgment: rules.filter((r) => r.status === 'needs-judgment').length, exempt: rules.filter((r) => r.status === 'exempt').length, citedCodeFindings: citedCodeFindings.length, } return { report: { mode: input.mode, app: input.app, module: input.module, rules, citedCodeFindings, totals, filesModified: [...rewrites.keys()].sort(), warnings, }, rewrites, } }