139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|