/** * lib/rule-contradictions.ts — an audit envelope disagreeing with ITSELF. * * audit-ba reserves `dedupOf` for the SAME evaluator serving two rule ids * (XD-005→SCR-003, CODE-005→DM-018, BR-009→UC-003 — a twin on a different * predicate is `relatedTo`). So a `dedupOf` mirror finding in err while every * covering finding of its primary is ok is a CLI defect BY CONSTRUCTION — the * DemoGestionFlotte XD-005 incident (2026-09-04): 9 hub screens err under * XD-005, ok under SCR-003, gate blocked on a valid corpus, and no channel to * say so. * * ONE detector, two readers: the audit-ba engine runs it on its own findings * at the end of every run (`audit.rule-contradiction` warning in the * envelope), and /support-report runs it on a captured envelope's * `report.findings[]` (the mechanical `rule-contradiction` class). Pure, * structural over the finding shape, no I/O — lives in lib/ because audit-ba * may only import cross-skill from the enumerated installer allowlist. */ export type FindingSeverity = 'ok' | 'warn' | 'err' /** Where an audit finding lands (absent fields = project scope) — mirror of * audit-ba's FindingScope, kept structural so any audit envelope qualifies. */ export interface EnvelopeFindingScope { app?: string module?: string section?: string } /** The subset of an audit finding the detector reads (audit-ba `Finding`). */ export interface EnvelopeFinding { ruleId: string severity: FindingSeverity scope: EnvelopeFindingScope message: string evidence: string[] /** The primary rule this finding MIRRORS (same evaluator, two ids). */ dedupOf?: string } /** One proven self-contradiction of an audit envelope. */ export interface RuleContradiction { mirrorRuleId: string primaryRuleId: string scope: EnvelopeFindingScope mirrorSeverity: 'warn' | 'err' mirrorMessage: string mirrorEvidence: string[] primaryMessage: string } const SEVERITY_RANK: Record = { ok: 0, warn: 1, err: 2 } /** Only `err` mirrors are mechanical proof: every `dedupOf` pair of the * registry is err-level; a warn twin on a different predicate is `relatedTo`. */ export const CONTRADICTION_MIN_SEVERITY: 'warn' | 'err' = 'err' /** `p` (a primary's scope) covers `f` when every key it pins is equal on `f` * — an unpinned key is wider (a project-level primary covers a module mirror). */ export function scopeCovers(p: EnvelopeFindingScope, f: EnvelopeFindingScope): boolean { return (p.app === undefined || p.app === f.app) && (p.module === undefined || p.module === f.module) && (p.section === undefined || p.section === f.section) } export function scopeKey(s: EnvelopeFindingScope): string { return `${s.app ?? ''}|${s.module ?? ''}|${s.section ?? ''}` } /** `FLOTTE / PARC[ / section]`, or `(projet)` at project scope. */ export function scopeLabel(s: EnvelopeFindingScope): string { return [s.app, s.module, s.section].filter((x): x is string => x !== undefined).join(' / ') || '(projet)' } /** Identity of one contradiction (mirror, primary, scope) — the dedup key. */ export function contradictionKey(c: RuleContradiction): string { return `${c.mirrorRuleId}|${c.primaryRuleId}|${scopeKey(c.scope)}` } /** Union by key, sorted — the same output whatever the input order. */ export function mergeContradictions(...lists: RuleContradiction[][]): RuleContradiction[] { const byKey = new Map() for (const list of lists) for (const c of list) if (!byKey.has(contradictionKey(c))) byKey.set(contradictionKey(c), c) return [...byKey.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, c]) => c) } /** * The mechanical proof: a finding `m` carrying `dedupOf: Y` at severity ≥ * `minSeverity` while EVERY covering finding of `Y` is `ok`. A primary that is * absent (dimension not run) or disagrees somewhere on the scope is no proof — * fail-closed, the contradiction must be visible in the findings themselves. */ export function detectRuleContradictions(findings: EnvelopeFinding[], minSeverity: 'warn' | 'err' = CONTRADICTION_MIN_SEVERITY): RuleContradiction[] { const byRule = new Map() for (const f of findings) { if (!byRule.has(f.ruleId)) byRule.set(f.ruleId, []) byRule.get(f.ruleId)!.push(f) } const out: RuleContradiction[] = [] for (const m of findings) { if (m.dedupOf === undefined || SEVERITY_RANK[m.severity] < SEVERITY_RANK[minSeverity]) continue const covering = (byRule.get(m.dedupOf) ?? []).filter((p) => scopeCovers(p.scope, m.scope)) if (covering.length === 0) continue if (covering.some((p) => p.severity !== 'ok')) continue out.push({ mirrorRuleId: m.ruleId, primaryRuleId: m.dedupOf, scope: { ...m.scope }, mirrorSeverity: m.severity as 'warn' | 'err', mirrorMessage: m.message, mirrorEvidence: [...m.evidence], primaryMessage: covering[0]!.message, }) } return mergeContradictions(out) } /** The defect is the RULE PAIR, not the client's module: scopes stay out of * the signature so ten modules hit by one divergent mirror make ONE report * (scopes are merged into the record on re-invocation). */ export function contradictionSignature(contradictions: RuleContradiction[]): string { const pairs = [...new Set(contradictions.map((c) => `${c.mirrorRuleId}->${c.primaryRuleId}`))].sort() return `rule-contradiction:${pairs.join(',')}` } /** The report excerpt for a contradiction: ONLY the findings involved, as JSON * — the head of a whole-project audit stdout says nothing about the defect. */ export function contradictionExcerpt(findings: EnvelopeFinding[], contradictions: RuleContradiction[]): string { const involved: EnvelopeFinding[] = [] for (const c of contradictions) { for (const f of findings) { if (involved.includes(f)) continue const isMirror = f.ruleId === c.mirrorRuleId && scopeKey(f.scope) === scopeKey(c.scope) const isPrimary = f.ruleId === c.primaryRuleId && scopeCovers(f.scope, c.scope) if (isMirror || isPrimary) involved.push(f) } } return JSON.stringify(involved, null, 2) }