/** * 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. */ 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({ 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 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) { const idx = 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.scope.app ?? 'project'}${d.scope.module ? `/${d.scope.module}` : ''} » sans JudgmentItem en attente — fichier de jugements périmé ? Décision ignorée.`, ) continue } const item = remaining[idx]! remaining.splice(idx, 1) // The arbitration can never EXCEED the rule's declared ceiling. const capped: Finding['severity'] = d.resolution === 'err' && item.maxSeverity === 'warn' ? 'warn' : d.resolution if (capped !== d.resolution) { warnings.push(`${d.ruleId} : résolution « err » plafonnée à « warn » (maxSeverity de la règle).`) } findings.push({ ruleId: d.ruleId, dimension: d.dimension, severity: capped, scope: d.scope, message: `${d.message} _(arbitrage)_`, ...(d.evidence !== undefined && d.evidence.length > 0 ? { evidence: d.evidence } : {}), }) } return { findings, remaining, warnings } }