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:
Kjell Tore Guttormsen 2026-10-06 20:03:16 +02:00
commit 40f6ae1177
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
17 changed files with 581 additions and 78 deletions

View file

@ -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"

View file

@ -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);

View file

@ -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';

View file

@ -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,
}),
]);

View file

@ -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;

View file

@ -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)) {