/** * Diff risk-floor — deterministic review-need classifier. * * Given the set of changed files in a diff, derive a *minimum* review * requirement ("floor") from the change surface. This is the mechanical half * of the agent reflection loop (design §6): risky surfaces (auth, data, infra) * trip a review requirement regardless of what the agent self-reports. * * Precedence (authoritative ordering, see design §5): * CI/tests > human merge > reviewer verdict > self-reflection * This module sits at the *floor*. It NEVER overrides CI or a human; a * `needs_review: false` verdict means "no surface tripped the floor", not * "safe to merge". Consumers MUST keep CI/tests authoritative above it. * * Pure and deterministic: no IO, no clock, no randomness. Same input → same * verdict. Safe to call from a Stop hook via `node -e` or to port inline. */ /** Review surfaces, ordered most- to least-sensitive. */ export type ReviewSurface = 'auth' | 'data' | 'infra' | 'build' | 'ui' | 'test' | 'docs' | 'none'; export interface RiskFloorInput { /** Paths of changed files, repo-relative. Order-insensitive. */ filesChanged: string[]; /** Optional diff size signals; reserved for future weighting. */ insertions?: number; deletions?: number; } export interface RiskFloorVerdict { /** True when the change surface meets/exceeds the review threshold. */ needs_review: boolean; /** Aggregate risk score in [0, 1] — the max surface weight across files. */ score: number; /** The dominant (highest-weight) surface across all changed files. */ surface: ReviewSurface; /** Human-readable explanation naming the surface and tripping files. */ reason: string; } /** Default review threshold; `score >= THRESHOLD` ⇒ `needs_review`. */ export const DEFAULT_RISK_THRESHOLD = 0.5; interface SurfaceRule { surface: ReviewSurface; weight: number; /** Case-insensitive regex matched against the file path. */ pattern: RegExp; } /** * Surface classification rules, evaluated highest-weight first. The first * rule whose pattern matches a path classifies that file; the file's surface * is the highest-risk surface it matches (rules are pre-sorted by weight). */ const SURFACE_RULES: readonly SurfaceRule[] = [ { surface: 'auth', weight: 1.0, pattern: /auth|login|session|token|permission|rbac|credential|secret/i, }, { surface: 'data', weight: 0.9, pattern: /migration|prisma|schema|\.sql|entity|repository|seed/i, }, { surface: 'infra', weight: 0.85, pattern: /docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform/i, }, { surface: 'build', weight: 0.6, pattern: /package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite/i, }, { surface: 'ui', weight: 0.4, pattern: /\.tsx|\.css|components\/|apps\/web\// }, { surface: 'test', weight: 0.2, pattern: /\.spec\.|\.test\.|__tests__\// }, { surface: 'docs', weight: 0.1, pattern: /\.md$|docs\// }, ]; const NONE_WEIGHT = 0.0; /** Classify a single path to its highest-risk surface and weight. */ function classify(path: string): { surface: ReviewSurface; weight: number } { for (const rule of SURFACE_RULES) { if (rule.pattern.test(path)) { return { surface: rule.surface, weight: rule.weight }; } } return { surface: 'none', weight: NONE_WEIGHT }; } /** * Evaluate the review risk-floor for a diff. * * @param input changed files (+ optional size signals) * @param threshold review cutoff; defaults to {@link DEFAULT_RISK_THRESHOLD} */ export function evaluateRiskFloor( input: RiskFloorInput, threshold: number = DEFAULT_RISK_THRESHOLD, ): RiskFloorVerdict { const files = (input.filesChanged ?? []).filter((f) => typeof f === 'string' && f.length > 0); if (files.length === 0) { return { needs_review: false, score: 0, surface: 'none', reason: 'no files changed', }; } let topSurface: ReviewSurface = 'none'; let topWeight = NONE_WEIGHT; const tripping: string[] = []; for (const file of files) { const { surface, weight } = classify(file); if (weight > topWeight) { topWeight = weight; topSurface = surface; tripping.length = 0; tripping.push(file); } else if (weight === topWeight && surface === topSurface && surface !== 'none') { tripping.push(file); } } const needs_review = topWeight >= threshold; const reason = topSurface === 'none' ? `no sensitive surface in ${files.length} changed file(s)` : `${topSurface} surface (weight ${topWeight}) in: ${tripping.join(', ')}`; return { needs_review, score: topWeight, surface: topSurface, reason }; }