/** * cli:audit-ba — judgments.ts * * Merge of the SKILL's arbitration decisions into the run: the LLM returns * DECISIONS (ruleId + scope + résolution + message), never markdown — the CLI * converts them into findings, consumes the matching JudgmentItems, and * rewrites the verdicts itself. A decision that matches no pending item is a * loud warning (stale judgments file), never a silent drop. * * Also the single source of which judgments GATE (cross-application) and * which are an advisory review (internal) — see CROSS_APP_JUDGMENT_RULES. */ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { z } from 'zod' import type { Finding, JudgmentItem } from './types.js' import { AUDIT_BA_DIMENSIONS } from './types.js' export const JudgmentDecisionSchema = z.object({ /** The item's `id` — settles exactly that item. Absent = legacy match on * (ruleId, dimension, scope), first pending item in report order. */ id: z.string().min(1).optional(), ruleId: z.string().min(1), dimension: z.enum(AUDIT_BA_DIMENSIONS), scope: z .object({ app: z.string().min(1).optional(), module: z.string().min(1).optional(), section: z.string().min(1).optional(), }) .strict() .default({}), /** The arbitration outcome — capped by the item's maxSeverity. */ resolution: z.enum(['ok', 'warn', 'err']), /** Business wording of the finding (goes in the verdict bullet). */ message: z.string().min(1), evidence: z.array(z.string().min(1)).optional(), }) export const JudgmentsFileSchema = z.object({ decisions: z.array(JudgmentDecisionSchema), }) export type JudgmentDecision = z.infer /** * The judgments that GATE — the only ones a human is asked to settle. * * Doctrine (06.10.2026): a validation is requested on what puts ANOTHER * application's element in the balance — a module code, a section, a rule, a * use case or an entity that another application of the project already * carries, or two applications covering one function. Never on what stays * inside one application. None of them emits on a one-application project: * five only emit on a concrete pair spanning two applications, XAPP-003 asks * one project-level question as soon as the project holds ≥ 2 applications. * * Every other judgment is INTERNAL (a module fits its application, an * out-of-scope contradiction, a missing domain application, a vague verb, a * UC's module scope, an AC conjunction, a state variant…): it is an ADVISORY * review — `judgmentAdvisory[]`, never `judgmentNeeded[]` — never counted * « à arbitrer », never turns a dimension « non audité » for * /ba-audit-pre-dev, and when a skill does arbitrate it the outcome is capped * at `warn`: an internal judgment informs, it never blocks. * * Opt-in by construction: a new judgment rule is advisory until it is listed * here, and it may only be listed when its candidates span two applications. */ export const CROSS_APP_JUDGMENT_RULES: ReadonlySet = new Set([ 'MENU-002', // same module code in ≥ 2 applications 'SEC-004', // same section name in ≥ 2 applications 'XAPP-003', // modules of different applications covering one function 'BR-012', // same business-rule title in another application 'UC-023', // same use-case title in another application 'DM-034', // entity equivalence with another application (reuse / extension request) ]) /** * Stable fingerprint of what a human is shown — rule, dimension, scope, * question, excerpts (never the ceiling). A decision carrying it settles * exactly that item, in any order: DM-034, BR-012, UC-023 and SEC-004 emit * ONE item per pair, so several items share (rule, scope) and a decision * matched on that pair alone depended on report order. When the content * changes, the id changes, a stored decision goes stale (loud warning) and the * question is asked again — a decision is valid for what was seen. */ export function judgmentId(item: Pick): string { const basis = JSON.stringify([item.ruleId, item.dimension, scopeKey(item.scope), item.question, item.excerpts]) return `J-${createHash('sha256').update(basis).digest('hex').slice(0, 12)}` } /** The engine stamps every emitted item (rules never compute it themselves). */ export function withJudgmentId(item: JudgmentItem): JudgmentItem { return item.id !== undefined ? item : { id: judgmentId(item), ...item } } export function isGatingJudgment(item: Pick): boolean { return CROSS_APP_JUDGMENT_RULES.has(item.ruleId) } /** Split pending items into the gating (cross-application) pool and the * advisory (internal) one — the latter carries its effective `warn` ceiling. */ export function splitJudgments(items: JudgmentItem[]): { gating: JudgmentItem[]; advisory: JudgmentItem[] } { const gating: JudgmentItem[] = [] const advisory: JudgmentItem[] = [] for (const item of items) { if (isGatingJudgment(item)) gating.push(item) else advisory.push({ ...item, maxSeverity: 'warn' }) } return { gating, advisory } } export interface MergeResult { /** Findings produced by the decisions (severity capped at maxSeverity). */ findings: Finding[] /** Pending items NOT consumed by any decision (stay in the verdicts). */ remaining: JudgmentItem[] warnings: string[] } function scopeKey(s: { app?: string; module?: string; section?: string }): string { return `${s.app ?? ''}|${s.module ?? ''}|${s.section ?? ''}`.toLowerCase() } export function loadJudgments(path: string): { decisions: JudgmentDecision[]; errors: string[] } { let raw: unknown try { raw = JSON.parse(readFileSync(path, 'utf8')) } catch (e) { return { decisions: [], errors: [`Cannot read judgments file ${path}: ${(e as Error).message}`] } } const parsed = JudgmentsFileSchema.safeParse(raw) if (!parsed.success) { return { decisions: [], errors: parsed.error.issues.map((i) => `[judgments ${i.path.join('.')}] ${i.message}`) } } return { decisions: parsed.data.decisions, errors: [] } } export function mergeJudgments(pending: JudgmentItem[], decisions: JudgmentDecision[]): MergeResult { const warnings: string[] = [] const findings: Finding[] = [] const remaining = [...pending] for (const d of decisions) { // By `id` when the decision carries one (exact item, order-free); else the // legacy (rule, dimension, scope) match — first pending item in order. const idx = d.id !== undefined ? remaining.findIndex((p) => p.id === d.id && p.ruleId === d.ruleId) : remaining.findIndex( (p) => p.ruleId === d.ruleId && p.dimension === d.dimension && scopeKey(p.scope) === scopeKey(d.scope), ) if (idx === -1) { warnings.push( `Décision « ${d.ruleId}${d.id !== undefined ? ` ${d.id}` : ''} @ ${d.scope.app ?? 'project'}${d.scope.module ? `/${d.scope.module}` : ''} » sans JudgmentItem en attente — fichier de jugements périmé (contenu changé depuis la décision ?) — décision ignorée, la question est reposée.`, ) continue } const item = remaining[idx]! remaining.splice(idx, 1) // The arbitration can never EXCEED the rule's declared ceiling — and an // internal (advisory) judgment's ceiling is `warn` whatever the rule says. const gating = isGatingJudgment(item) const ceiling: 'warn' | 'err' = gating ? item.maxSeverity : 'warn' const capped: Finding['severity'] = d.resolution === 'err' && ceiling === 'warn' ? 'warn' : d.resolution if (capped !== d.resolution) { warnings.push( gating ? `${d.ruleId} : résolution « err » plafonnée à « warn » (maxSeverity de la règle).` : `${d.ruleId} : résolution « err » plafonnée à « warn » (jugement interne à l'application — revue conseillée, jamais bloquante).`, ) } findings.push({ ruleId: item.ruleId, dimension: item.dimension, severity: capped, scope: item.scope, message: `${d.message} _(arbitrage)_`, ...(d.evidence !== undefined && d.evidence.length > 0 ? { evidence: d.evidence } : {}), }) } return { findings, remaining, warnings } }