fix(review): close the gate's fail-open paths an independent review found
An adversarial read of d88245c reproduced 15 problems; all fixed here, each
with a test that fails on the old code.
- verdict-gate: a greener verdict must be backed by the LAST fence being a
`json` block of the right shape, with no BLOCKER in the JSON (any case),
in `## Findings (BLOCKER)`, or a differing finding-id list; two verdict
lines and more than one file argument are exit 2. ({}, null, lowercase
"blocker", a trailing ```jsonc and a later json fence all gave exit 0.)
- engine-findings: a transcribed "narrower" key can no longer lower an
engine tier; absolute paths under --repo-root match the scope; "12" lines
parse; an empty scope is an error; out-of-scope engine findings are
unverified (`engine:out-of-scope`) and forbid ALLOW.
- recurrence: the catch-all engine keys and keys normalised from unknown
ones are never proposed; the same unfixed finding in two iterations is not
a recurrence; proposals are validated before they are offered; \r/U+2028
and damaged markers are refused; writes go through a symlinked CLAUDE.md.
Prose points --repo at the session directory, not the git toplevel.
- Workflow substrate: engines are counted from what arrived, and the
verdict's floor is recomputed from the coordinator's findings; now tested
by running the script with stubbed Workflow globals. Two literal NUL bytes
written as \u0000 (git saw the file as binary).
- Trimming keeps Pass 1's "Also flagged by" provenance; `## Declined to
Judge` and remediation steps are required from review_version 1.1 only
(N-1); the zero-diff review now carries the JSON block the gate needs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d88245c06e
commit
40f6ae1177
17 changed files with 581 additions and 78 deletions
|
|
@ -77,6 +77,7 @@ export const REFUTING_REASONS = Object.freeze(new Set([
|
|||
*/
|
||||
export const UNVERIFIED_REASONS = Object.freeze([
|
||||
'actionability:empty',
|
||||
'engine:out-of-scope',
|
||||
'unknown-rule_key',
|
||||
'file-existence:indeterminate',
|
||||
]);
|
||||
|
|
@ -214,6 +215,21 @@ function trimText(text, max) {
|
|||
return text.slice(0, max - TRIM_MARKER.length) + TRIM_MARKER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a detail but keep Pass 1's "Also flagged by …" provenance lines: the
|
||||
* reviewer's own text is what gets cut. When the provenance alone is too long
|
||||
* it is cut too — `raised_by` still names every reviewer.
|
||||
*/
|
||||
function trimDetail(detail, max) {
|
||||
const at = detail.indexOf('\nAlso flagged by ');
|
||||
if (at === -1) return trimText(detail, max);
|
||||
const head = detail.slice(0, at);
|
||||
const tail = detail.slice(at);
|
||||
const room = max - tail.length - TRIM_MARKER.length;
|
||||
if (room < 40) return trimText(detail, max);
|
||||
return head.slice(0, room) + TRIM_MARKER + tail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass 2 — HubSpot Judge (deterministic subset): succinctness and
|
||||
* actionability (recommended_action, when present, must be a non-empty
|
||||
|
|
@ -250,7 +266,7 @@ export function judgeFilter(findings) {
|
|||
const out = { ...f };
|
||||
const fields = [];
|
||||
if (longTitle) { out.title = trimText(title, JUDGE_TITLE_MAX); fields.push('title'); }
|
||||
if (longDetail) { out.detail = trimText(detail, JUDGE_DETAIL_MAX); fields.push('detail'); }
|
||||
if (longDetail) { out.detail = trimDetail(detail, JUDGE_DETAIL_MAX); fields.push('detail'); }
|
||||
out.trimmed_fields = fields;
|
||||
kept.push(out);
|
||||
trimmed.push(out);
|
||||
|
|
@ -357,7 +373,7 @@ export function computeVerdict(findings, options = {}) {
|
|||
* forbids ALLOW. Do not iterate both and count twice.
|
||||
*
|
||||
* @param {Array<{reviewer?: string, findings: object[]}>} reviewerPayloads
|
||||
* @param {{ expectedReviewers?: string[] }} [options]
|
||||
* @param {{ expectedReviewers?: string[], engineOutOfScope?: object[] }} [options]
|
||||
* `declined_to_judge` lists every unverified finding with its reason, and
|
||||
* `trimmed_count` counts the MINOR/SUGGESTION findings whose text was trimmed
|
||||
* (kept, judged, and counted — never removed for length).
|
||||
|
|
@ -369,7 +385,11 @@ export function runContract(reviewerPayloads, options = {}) {
|
|||
const deduped = dedupByTriplet(ingested);
|
||||
const judged = judgeFilter(deduped);
|
||||
const reasoned = reasonablenessFilter(judged.kept);
|
||||
const unverified = [...judged.unverified, ...reasoned.unverified];
|
||||
// Engine findings on files outside the review's SHA range (engine-findings.mjs
|
||||
// `out_of_scope`): real claims nobody here judged, so they forbid ALLOW.
|
||||
const outOfScope = (options.engineOutOfScope ?? [])
|
||||
.map((f) => ({ ...f, suppressed_reason: 'engine:out-of-scope' }));
|
||||
const unverified = [...judged.unverified, ...reasoned.unverified, ...outOfScope];
|
||||
// What the coordinator set aside without ruling on it, one line each, so a
|
||||
// reader sees every finding that did not reach a verdict tier. Borrowed:
|
||||
// "Declined to judge … nothing you set aside is dropped silently"
|
||||
|
|
|
|||
|
|
@ -17,20 +17,26 @@
|
|||
// "rule_key": "<optional narrower catalogue key>" } ] }
|
||||
//
|
||||
// Output: { reviewer, findings: [...catalogue-conformant...], out_of_scope: [...] }
|
||||
// - rule_key: the given one when it is in the catalogue, else
|
||||
// CODE_REVIEW_DEFECT (MAJOR) / SECURITY_REVIEW_VULNERABILITY (BLOCKER).
|
||||
// - rule_key: the given one when it is in the catalogue AND at least as
|
||||
// severe as the engine's own key, else CODE_REVIEW_DEFECT (MAJOR) /
|
||||
// SECURITY_REVIEW_VULNERABILITY (BLOCKER). A "narrower" key can never
|
||||
// lower an engine finding's tier.
|
||||
// - File paths are made repo-relative (an absolute path under `repoRoot`
|
||||
// loses that prefix) before they are compared with the scope.
|
||||
// - A finding on a file outside the review scope is NOT dropped silently: it
|
||||
// lands in `out_of_scope` for the Coverage section (`/security-review`
|
||||
// diffs against origin's default branch, which can be wider than the
|
||||
// review's SHA range).
|
||||
// lands in `out_of_scope`, which the coordinator counts as unverified
|
||||
// (`engine:out-of-scope`) — it forbids ALLOW and is listed under
|
||||
// Declined to Judge (`/security-review` diffs against origin's default
|
||||
// branch, which can be wider than the review's SHA range).
|
||||
// - An empty scope list is an error, never "everything is out of scope".
|
||||
//
|
||||
// CLI: node lib/review/engine-findings.mjs [--scope <file-list.txt>] <engine-findings.json>
|
||||
// CLI: node lib/review/engine-findings.mjs [--scope <file-list.txt>] [--repo-root <dir>] <engine-findings.json>
|
||||
// exit 0 = payload on stdout; 1 = input invalid (errors on stderr); 2 = could not run.
|
||||
|
||||
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { computeFindingId } from '../parsers/finding-id.mjs';
|
||||
import { RULE_KEYS, getRule } from './rule-catalogue.mjs';
|
||||
import { RULE_KEYS, getRule, SEVERITY_VALUES } from './rule-catalogue.mjs';
|
||||
import { validateFindings } from './findings-schema.mjs';
|
||||
|
||||
export const ENGINES = Object.freeze({
|
||||
|
|
@ -46,14 +52,27 @@ export const ENGINES = Object.freeze({
|
|||
}),
|
||||
});
|
||||
|
||||
function normPath(p) {
|
||||
return String(p ?? '').replace(/^\.\//, '').trim();
|
||||
function normPath(p, repoRoot) {
|
||||
let out = String(p ?? '').trim().replace(/\\/g, '/');
|
||||
if (repoRoot) {
|
||||
const root = String(repoRoot).replace(/\\/g, '/').replace(/\/+$/, '') + '/';
|
||||
if (out.startsWith(root)) out = out.slice(root.length);
|
||||
}
|
||||
return out.replace(/^(\.\/)+/, '');
|
||||
}
|
||||
|
||||
function parseLine(v) {
|
||||
if (Number.isInteger(v) && v >= 0) return v;
|
||||
if (typeof v === 'string' && /^\d+$/.test(v.trim())) return Number(v.trim());
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tier = (key) => SEVERITY_VALUES.indexOf(getRule(key).severity);
|
||||
|
||||
/**
|
||||
* Convert one engine's transcribed findings into a coordinator payload.
|
||||
* @param {{ engine: string, findings: object[] }} input
|
||||
* @param {{ scopeFiles?: string[] }} [opts]
|
||||
* @param {{ scopeFiles?: string[], repoRoot?: string }} [opts]
|
||||
* @returns {{ ok: boolean, errors: string[], payload?: { reviewer: string, findings: object[] }, out_of_scope?: object[] }}
|
||||
*/
|
||||
export function toReviewerPayload(input, opts = {}) {
|
||||
|
|
@ -61,18 +80,23 @@ export function toReviewerPayload(input, opts = {}) {
|
|||
const spec = ENGINES[input?.engine];
|
||||
if (!spec) return { ok: false, errors: [`engine must be one of ${Object.keys(ENGINES).join('|')}, got ${JSON.stringify(input?.engine)}`] };
|
||||
if (!Array.isArray(input.findings)) return { ok: false, errors: ['findings must be an array'] };
|
||||
const scope = opts.scopeFiles ? new Set(opts.scopeFiles.map(normPath)) : null;
|
||||
if (opts.scopeFiles && opts.scopeFiles.length === 0) {
|
||||
return { ok: false, errors: ['scope list is empty — the review scope is unknown, so no engine finding can be placed'] };
|
||||
}
|
||||
const scope = opts.scopeFiles ? new Set(opts.scopeFiles.map((f) => normPath(f, opts.repoRoot))) : null;
|
||||
|
||||
const findings = [];
|
||||
const out_of_scope = [];
|
||||
input.findings.forEach((f, i) => {
|
||||
const file = normPath(f?.file);
|
||||
const file = normPath(f?.file, opts.repoRoot);
|
||||
const summary = String(f?.summary ?? '').trim();
|
||||
if (!file) errors.push(`findings[${i}].file is empty`);
|
||||
if (!summary) errors.push(`findings[${i}].summary is empty`);
|
||||
if (!file || !summary) return;
|
||||
const line = Number.isInteger(f.line) && f.line >= 0 ? f.line : 0;
|
||||
const rule_key = typeof f.rule_key === 'string' && RULE_KEYS.has(f.rule_key) ? f.rule_key : spec.rule_key;
|
||||
const line = parseLine(f.line);
|
||||
const narrower = typeof f.rule_key === 'string' && RULE_KEYS.has(f.rule_key)
|
||||
&& tier(f.rule_key) <= tier(spec.rule_key);
|
||||
const rule_key = narrower ? f.rule_key : spec.rule_key;
|
||||
const out = {
|
||||
id: computeFindingId(file, line, rule_key),
|
||||
severity: getRule(rule_key).severity,
|
||||
|
|
@ -107,11 +131,13 @@ function isMain() {
|
|||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
const scopePath = scopeIdx === -1 ? null : args[scopeIdx + 1];
|
||||
const filePath = args.find((a, i) => !a.startsWith('--') && (scopeIdx === -1 || i !== scopeIdx + 1));
|
||||
const valueOf = (name) => { const i = args.indexOf(name); return i === -1 ? null : args[i + 1]; };
|
||||
const scopePath = valueOf('--scope');
|
||||
const repoRoot = valueOf('--repo-root');
|
||||
const flagValues = new Set([scopePath, repoRoot].filter(Boolean));
|
||||
const filePath = args.find((a) => !a.startsWith('--') && !flagValues.has(a));
|
||||
if (!filePath || !existsSync(filePath) || (scopePath && !existsSync(scopePath))) {
|
||||
process.stderr.write('Usage: engine-findings.mjs [--scope <file-list.txt>] <engine-findings.json>\n');
|
||||
process.stderr.write('Usage: engine-findings.mjs [--scope <file-list.txt>] [--repo-root <dir>] <engine-findings.json>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
let input;
|
||||
|
|
@ -123,7 +149,7 @@ if (isMain()) {
|
|||
process.stderr.write(`engine-findings: could not read input: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const r = toReviewerPayload(input, { scopeFiles });
|
||||
const r = toReviewerPayload(input, { scopeFiles, repoRoot: repoRoot ?? undefined });
|
||||
if (!r.ok) {
|
||||
for (const e of r.errors) process.stderr.write(` ERROR ${e}\n`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
// P5 — the project's memory, fed by review (ordre 5).
|
||||
//
|
||||
// A finding class (the same catalogue `rule_key`) that shows up in TWO review.md
|
||||
// files in the target repo becomes ONE proposal; one sighting is no proposal.
|
||||
// files in the target repo, as two DIFFERENT findings (distinct finding IDs),
|
||||
// becomes ONE proposal; one sighting is no proposal, and one unfixed finding
|
||||
// seen again in the next iteration is a persisting defect, not a recurrence.
|
||||
// Catch-all keys (route null) and findings whose key was normalised from an
|
||||
// unknown one (`original_rule_key`) are never counted.
|
||||
// The history is the review.md files the repo already has — the current
|
||||
// `.claude/projects/*/review.md` plus, where git tracks them, their earlier
|
||||
// committed versions. No new store is created.
|
||||
|
|
@ -14,7 +18,7 @@
|
|||
//
|
||||
// Writes: only between Voyage's own markers, under a size budget; past the
|
||||
// budget the write is refused and the lesson belongs in a skill (operator's
|
||||
// call). Every line is approved by the operator first (/trekreview Phase 9.5,
|
||||
// call). Every line is approved by the operator first (/trekreview Phase 8.7,
|
||||
// AskUserQuestion) — this module never decides that on its own.
|
||||
//
|
||||
// Borrowed (credited, MIT where it applies):
|
||||
|
|
@ -33,7 +37,7 @@
|
|||
// → {"written":true|false, "reason":…} on stdout; exit 0 written, 1 refused, 2 could not run
|
||||
|
||||
import {
|
||||
readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync,
|
||||
readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync, lstatSync,
|
||||
} from 'node:fs';
|
||||
import { join, relative, dirname } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
|
@ -142,10 +146,18 @@ function lessonsPath(repo) {
|
|||
return join(repo, LESSONS_DIR, LESSONS_FILE_NAME);
|
||||
}
|
||||
|
||||
function count(text, needle) {
|
||||
return text.split(needle).length - 1;
|
||||
}
|
||||
|
||||
/** The managed block, null when absent, or { damaged: true } when the markers are not exactly one START before one END. */
|
||||
function readBlock(text) {
|
||||
const starts = count(text, MARK_START);
|
||||
const ends = count(text, MARK_END);
|
||||
if (starts === 0 && ends === 0) return null;
|
||||
const start = text.indexOf(MARK_START);
|
||||
const end = text.indexOf(MARK_END);
|
||||
if (start === -1 || end === -1 || end < start) return null;
|
||||
if (starts !== 1 || ends !== 1 || end < start) return { damaged: true };
|
||||
return { start, end, inner: text.slice(start + MARK_START.length, end) };
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +170,7 @@ export function writtenClasses(repo) {
|
|||
const p = lessonsPath(repo);
|
||||
if (!existsSync(p)) return new Set();
|
||||
const block = readBlock(readFileSync(p, 'utf-8'));
|
||||
if (!block) return new Set();
|
||||
if (!block || block.damaged) return new Set();
|
||||
const keys = new Set();
|
||||
for (const line of blockEntries(block.inner)) {
|
||||
const m = line.match(/^- \[([A-Z_]+)\]/);
|
||||
|
|
@ -170,7 +182,7 @@ export function writtenClasses(repo) {
|
|||
// ---- proposals --------------------------------------------------------------
|
||||
|
||||
function clip(s, n) {
|
||||
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
||||
const t = String(s ?? '').replace(/<!--|-->/g, '').replace(/[\u0000-\u001f\u007f\u2028\u2029\s]+/g, ' ').trim();
|
||||
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
||||
}
|
||||
|
||||
|
|
@ -182,9 +194,13 @@ function composeLine(rule, evidence) {
|
|||
const how = rule.memory_route === 'check'
|
||||
? CHECK_HINTS[rule.rule_key] ?? `a deterministic check for ${rule.rule_key}.`
|
||||
: `/trekreview flags it as ${rule.rule_key}; read the diff for it before the step is called done.`;
|
||||
const ids = evidence.map((e) => String(e.id ?? '').slice(0, 8)).join(', ');
|
||||
const ids = evidence.map((e) => clip(String(e.id ?? '').slice(0, 8), 8)).join(', ');
|
||||
const body = `Mistake: ${clip(rule.description, 160)} Seen: ${seen}. · Fix: ${fix} · How to check: ${how} (evidence: ${ids})`;
|
||||
return `- [${rule.rule_key}] (${rule.memory_route}) ${body}`;
|
||||
const line = `- [${rule.rule_key}] (${rule.memory_route}) ${body}`;
|
||||
if (line.length <= LINE_MAX) return line;
|
||||
// Shorten the free-text parts, never the three labels.
|
||||
const short = evidence.map((e) => `"${clip(e.title ?? e.rule_key, 40)}" (${clip(e.file, 40)})`).join(', ');
|
||||
return `- [${rule.rule_key}] (${rule.memory_route}) Mistake: ${clip(rule.description, 120)} Seen: ${short}. · Fix: ${clip(fix, 120)} · How to check: ${clip(how, 160)} (evidence: ${ids})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -195,7 +211,7 @@ function composeLine(rule, evidence) {
|
|||
*/
|
||||
export function validateLessonLine(line) {
|
||||
if (typeof line !== 'string' || line.trim().length === 0) return { valid: false, reason: 'empty' };
|
||||
if (line.includes('\n')) return { valid: false, reason: 'multi-line' };
|
||||
if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(line)) return { valid: false, reason: 'control or line-break character' };
|
||||
if (line.length > LINE_MAX) return { valid: false, reason: `longer than ${LINE_MAX}` };
|
||||
if (line.includes('<!--') || line.includes('-->')) return { valid: false, reason: 'marker text' };
|
||||
const a = line.indexOf('Mistake:');
|
||||
|
|
@ -217,27 +233,41 @@ export function proposeLessons(repo) {
|
|||
const already = writtenClasses(repo);
|
||||
const byKey = new Map();
|
||||
for (const review of reviews) {
|
||||
const firstPerKey = new Map();
|
||||
const perKey = new Map();
|
||||
for (const f of review.findings) {
|
||||
if (!firstPerKey.has(f.rule_key)) firstPerKey.set(f.rule_key, f);
|
||||
if (f.original_rule_key !== undefined) continue; // normalised from an unknown key: not a class
|
||||
if (!perKey.has(f.rule_key)) perKey.set(f.rule_key, []);
|
||||
perKey.get(f.rule_key).push(f);
|
||||
}
|
||||
for (const [key, f] of firstPerKey) {
|
||||
for (const [key, fs] of perKey) {
|
||||
if (!byKey.has(key)) byKey.set(key, []);
|
||||
byKey.get(key).push({ ...f, review: review.identity, source: review.source });
|
||||
byKey.get(key).push({ review: review.identity, source: review.source, findings: fs });
|
||||
}
|
||||
}
|
||||
const proposals = [];
|
||||
for (const [key, evidence] of [...byKey].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
for (const [key, sightings] of [...byKey].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
// Two reviews, and two DIFFERENT findings: pick, per review, a finding whose
|
||||
// id has not been used yet. The same unfixed finding twice is one defect.
|
||||
const usedIds = new Set();
|
||||
const evidence = [];
|
||||
for (const s of sightings) {
|
||||
const f = s.findings.find((x) => !usedIds.has(x.id ?? `${x.file}:${x.line}`));
|
||||
if (!f) continue;
|
||||
usedIds.add(f.id ?? `${f.file}:${f.line}`);
|
||||
evidence.push({ ...f, review: s.review, source: s.source });
|
||||
}
|
||||
if (evidence.length < RECURRENCE_THRESHOLD) continue;
|
||||
const rule = getRule(key);
|
||||
if (!rule || rule.memory_route === null || rule.memory_route === undefined) continue;
|
||||
if (already.has(key)) continue;
|
||||
const ev = evidence.slice(0, RECURRENCE_THRESHOLD);
|
||||
const line = composeLine(rule, ev);
|
||||
if (!validateLessonLine(line).valid) continue; // never propose a line apply would refuse
|
||||
proposals.push({
|
||||
rule_key: key,
|
||||
route: rule.memory_route,
|
||||
target: `${LESSONS_DIR}/${LESSONS_FILE_NAME}`,
|
||||
line: composeLine(rule, ev),
|
||||
line,
|
||||
evidence: evidence.map((e) => ({ id: e.id ?? null, review: e.review, source: e.source, file: e.file ?? null, title: e.title ?? null })),
|
||||
});
|
||||
}
|
||||
|
|
@ -247,6 +277,9 @@ export function proposeLessons(repo) {
|
|||
// ---- writing an approved lesson ---------------------------------------------
|
||||
|
||||
function atomicWriteText(path, text) {
|
||||
// Write THROUGH a symlink (CLAUDE.md -> AGENTS.md is common): rename onto the
|
||||
// real target, so the link survives and the target gets the change.
|
||||
if (existsSync(path) && lstatSync(path).isSymbolicLink()) path = realpathSync(path);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, text);
|
||||
|
|
@ -254,7 +287,7 @@ function atomicWriteText(path, text) {
|
|||
}
|
||||
|
||||
function withPointer(claudeText) {
|
||||
if (claudeText.includes(POINTER_START)) return null;
|
||||
if (claudeText.includes(POINTER_START) || claudeText.includes(POINTER_END)) return null;
|
||||
const sep = claudeText.endsWith('\n') ? '\n' : '\n\n';
|
||||
return `${claudeText}${sep}${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
|
||||
}
|
||||
|
|
@ -285,6 +318,9 @@ export function applyLesson(repo, ruleKey, opts = {}) {
|
|||
const path = lessonsPath(repo);
|
||||
const current = existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
||||
let block = readBlock(current);
|
||||
if (block && block.damaged) {
|
||||
return { written: false, reason: 'markers-damaged', detail: `${LESSONS_DIR}/${LESSONS_FILE_NAME} must hold exactly one start marker before one end marker — fix it by hand` };
|
||||
}
|
||||
let base = current;
|
||||
if (!block) {
|
||||
const sep = current.length === 0 || current.endsWith('\n') ? '' : '\n';
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@
|
|||
// review.md files is proposed as project memory (P5, lib/review/recurrence.mjs):
|
||||
// 'check' - mechanical: a deterministic check can catch it;
|
||||
// 'rule' - judgement: a line in the target repo's `.claude/rules/` file;
|
||||
// null - review hygiene (about the review itself, not the target code):
|
||||
// never proposed.
|
||||
// null - never proposed: review hygiene (about the review itself, not the
|
||||
// target code), or a catch-all key (the two engine keys) whose
|
||||
// members share nothing but their source.
|
||||
// Routing borrowed from Pocock `skills/in-progress/retro/SKILL.md:19` (MIT).
|
||||
|
||||
export const SEVERITY_VALUES = Object.freeze(['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION']);
|
||||
|
|
@ -117,14 +118,14 @@ export const RULE_CATALOGUE = Object.freeze([
|
|||
severity: 'MAJOR',
|
||||
category: 'correctness',
|
||||
description: "A defect reported by Claude Code's /code-review that no narrower rule_key covers.",
|
||||
memory_route: 'rule',
|
||||
memory_route: null,
|
||||
}),
|
||||
Object.freeze({
|
||||
rule_key: 'SECURITY_REVIEW_VULNERABILITY',
|
||||
severity: 'BLOCKER',
|
||||
category: 'security',
|
||||
description: "A vulnerability reported by Claude Code's /security-review that no narrower rule_key covers.",
|
||||
memory_route: 'rule',
|
||||
memory_route: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -57,28 +57,82 @@ export function decideVerdict(text) {
|
|||
return { exit: EXIT.BLOCK, verdict, reason: 'verdict BLOCK' };
|
||||
}
|
||||
|
||||
// ALLOW / WARN: the file must back the greener direction.
|
||||
const block = extractFindingsBlock(r.parsed.body ?? '');
|
||||
if (block === null) {
|
||||
// ALLOW / WARN: the file must back the greener direction, from every place a
|
||||
// BLOCKER can be written. A shape the gate cannot read is exit 2, never 0.
|
||||
const body = r.parsed.body ?? '';
|
||||
const fmText = text.slice(0, Math.max(0, text.indexOf('\n---', 3)));
|
||||
if ((fmText.match(/^verdict\s*:/gm) ?? []).length > 1) {
|
||||
return undecided('frontmatter carries more than one verdict line', verdict);
|
||||
}
|
||||
const lastFence = lastFenceLanguage(body);
|
||||
if (lastFence === null) {
|
||||
return undecided(`verdict ${verdict} has no trailing JSON block to back it`, verdict);
|
||||
}
|
||||
if (lastFence !== 'json') {
|
||||
return undecided(`the last fenced block is "${lastFence || '(no language)'}", not the trailing \`json\` block`, verdict);
|
||||
}
|
||||
const block = extractFindingsBlock(body);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(block);
|
||||
} catch (e) {
|
||||
return undecided(`trailing JSON block does not parse: ${e.message}`, verdict);
|
||||
}
|
||||
if (payload?.verdict !== undefined && payload.verdict !== verdict) {
|
||||
const shape = checkPayloadShape(payload);
|
||||
if (shape) return undecided(`trailing JSON block: ${shape}`, verdict);
|
||||
if (payload.verdict !== verdict) {
|
||||
return undecided(`frontmatter verdict ${verdict} disagrees with the JSON block verdict ${payload.verdict}`, verdict);
|
||||
}
|
||||
const blockerFindings = Array.isArray(payload?.findings)
|
||||
? payload.findings.filter((f) => f?.severity === 'BLOCKER').length
|
||||
: 0;
|
||||
const blockerCount = Number(payload?.counts?.BLOCKER ?? 0);
|
||||
if (blockerFindings > 0 || blockerCount > 0) {
|
||||
return undecided(`verdict ${verdict} over ${Math.max(blockerFindings, blockerCount)} BLOCKER finding(s)`, verdict);
|
||||
const blockerFindings = payload.findings.filter((f) => String(f.severity).toUpperCase() === 'BLOCKER').length;
|
||||
const blockerCount = Object.entries(payload.counts)
|
||||
.filter(([k]) => k.toUpperCase() === 'BLOCKER')
|
||||
.reduce((n, [, v]) => n + Number(v || 0), 0);
|
||||
const blockerSection = blockerSectionEntries(body);
|
||||
if (blockerFindings > 0 || blockerCount > 0 || blockerSection > 0) {
|
||||
return undecided(`verdict ${verdict} over BLOCKER finding(s) (json: ${blockerFindings}, counts: ${blockerCount}, ## Findings (BLOCKER): ${blockerSection})`, verdict);
|
||||
}
|
||||
return { exit: EXIT.PASS, verdict, reason: `verdict ${verdict}` };
|
||||
const fmIds = new Set(Array.isArray(fm.findings) ? fm.findings : []);
|
||||
const jsonIds = new Set(payload.findings.map((f) => f.id));
|
||||
if (fmIds.size !== jsonIds.size || [...fmIds].some((id) => !jsonIds.has(id))) {
|
||||
return undecided('frontmatter findings: list and the JSON block finding ids differ', verdict);
|
||||
}
|
||||
const warnReasons = Array.isArray(payload.allow_blocked_by) ? payload.allow_blocked_by : [];
|
||||
return { exit: EXIT.PASS, verdict, reason: `verdict ${verdict}`, warn_reasons: warnReasons };
|
||||
}
|
||||
|
||||
const FENCE_RE = /^```[ \t]*([A-Za-z0-9_+-]*)[^\n]*\n[\s\S]*?^```[ \t]*$/gm;
|
||||
|
||||
/** Language tag of the LAST fenced block in `body` (lower-cased), '' for none, null when no fence. */
|
||||
function lastFenceLanguage(body) {
|
||||
let last = null;
|
||||
let m;
|
||||
FENCE_RE.lastIndex = 0;
|
||||
while ((m = FENCE_RE.exec(body)) !== null) last = m[1].toLowerCase();
|
||||
return last;
|
||||
}
|
||||
|
||||
/** The JSON block shape the gate needs; returns an error string or null. */
|
||||
function checkPayloadShape(p) {
|
||||
if (p === null || typeof p !== 'object' || Array.isArray(p)) return 'not an object';
|
||||
if (!VERDICT_VALUES.includes(p.verdict)) return `verdict must be one of ${VERDICT_VALUES.join('|')}`;
|
||||
if (p.counts === null || typeof p.counts !== 'object' || Array.isArray(p.counts)) return 'counts must be an object';
|
||||
if (!Array.isArray(p.findings)) return 'findings must be an array';
|
||||
for (const [i, f] of p.findings.entries()) {
|
||||
if (f === null || typeof f !== 'object' || typeof f.severity !== 'string' || typeof f.id !== 'string') {
|
||||
return `findings[${i}] needs a string id and severity`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Number of `### ` entries under `## Findings (BLOCKER)`. */
|
||||
function blockerSectionEntries(body) {
|
||||
const start = body.search(/^##\s+Findings\s*\(BLOCKER\)/im);
|
||||
if (start === -1) return 0;
|
||||
const rest = body.slice(start).replace(/^[^\n]*\n/, '');
|
||||
const next = rest.search(/^##\s+/m);
|
||||
const section = next === -1 ? rest : rest.slice(0, next);
|
||||
return (section.match(/^###\s+\S/gm) ?? []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -106,9 +160,11 @@ function isMain() {
|
|||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const filePath = args.find((a) => !a.startsWith('--'));
|
||||
if (!filePath) {
|
||||
process.stderr.write('Usage: verdict-gate.mjs [--json] <review.md>\n');
|
||||
const files = args.filter((a) => !a.startsWith('--'));
|
||||
const filePath = files[0];
|
||||
if (files.length !== 1) {
|
||||
// Exactly one file: a glob would otherwise gate on its first match only.
|
||||
process.stderr.write('Usage: verdict-gate.mjs [--json] <review.md> (exactly one file)\n');
|
||||
process.exit(EXIT.UNDECIDED);
|
||||
}
|
||||
let result;
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ export function validateReviewContent(text, opts = {}) {
|
|||
// append to a plan unchanged: `### Step R<n>: …` with `Source finding: <id>`.
|
||||
// Borrowed: review gaps become appended, numbered tasks with a source-ref,
|
||||
// never rewriting earlier ones (Spec Kit `templates/commands/converge.md:156-163`, MIT).
|
||||
const blockerIds = blockerIdsFromBody(body);
|
||||
// 1.1+ only (N-1 window): a 1.0 review never carried the section.
|
||||
const v = typeof fm.review_version === 'string' ? fm.review_version.match(/^(\d+)\.(\d+)$/) : null;
|
||||
const atLeast11 = v && (Number(v[1]) > 1 || (Number(v[1]) === 1 && Number(v[2]) >= 1));
|
||||
const blockerIds = atLeast11 ? blockerIdsFromBody(body) : [];
|
||||
if (blockerIds.length > 0) {
|
||||
const steps = remediationStepSources(body);
|
||||
for (const id of blockerIds) {
|
||||
|
|
@ -142,6 +145,13 @@ export function validateReviewContent(text, opts = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// 1.1+: what the coordinator set aside is listed, never left out (ordre 5).
|
||||
if (atLeast11 && !/^##\s+Declined to Judge\b/m.test(body)) {
|
||||
const issueObj = issue('REVIEW_MISSING_SECTION', 'Required body section missing: ## Declined to Judge (review_version ≥ 1.1)');
|
||||
if (strict) errors.push(issueObj);
|
||||
else warnings.push(issueObj);
|
||||
}
|
||||
|
||||
for (const section of REVIEW_BODY_SECTIONS) {
|
||||
const re = new RegExp(`^##\\s+${section}\\b`, 'm');
|
||||
if (!re.test(body)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue