voyage/lib/review/verdict-gate.mjs
Kjell Tore Guttormsen a10018f9d3
fix: round 8 of the release review of 6.3.0, for 6.3.1
Round 8 (0f400d3..29b4ac9, BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR). Each fix
began with a test that failed on 29b4ac9 (13 tests, all red there); suite
2221 (2219/0/2).

- BLOCKER: a backticked check whose trailing comment holds a quote, $,
  backtick or backslash is refused, not dropped. The comment was never
  screened, while the executor hands the Verify span as written to red
  proof, the test lock and the done line inside --cmd "...", where a $(...)
  runs and a quote ends the argument. trekplan's text and the reviewer's
  NOT RUN table say so.
- A span that is only a comment is not-a-command, not a placeholder. The
  dropComment text says the allowlist still refuses a word with a # in it.
- The criteria runner, comparison-gate and end-state-gate use isMain: they
  did nothing and exited 0 under a symlinked path. The test now refuses any
  process.argv[1] comparison outside lib/util/is-main.mjs.
- Files: in a field with backticked paths, a plain word counts only before
  the entry's description (a dash, or a colon after the path); a name with
  no slash only first in its entry or after and/or/+/&. "mocks
  fs.readFileSync", "runs on Node.js 22.x" or "no change to package.json"
  no longer make a tests-only step look like it builds something.
  Lowercase makefile, rakefile and justfile are build files. Measured on the
  520 Files: fields in 48 plans of the marketplace and the test repo:
  3 fields read differently (two notes no longer read as files, one
  duplicate name dropped), tests-only changed in 0.
- The test lock compares --cmd without its trailing comment, and the test
  lock and the done line read a stop with stoppedBy, the one reading.
  red-proof drops its second reading, spawnError.
- release-check reads a relative --review in --repo or, when only that one
  exists, from the current directory as 6.3.0 did; two different files are
  exit 2, never guessed. The runbook says so.
- One trailing-json reader: a closing fence may be longer than three
  backticks, in the block reader and the verdict gate alike, and the review
  validator and the synthesis digest use it, so three backticks inside a
  JSON string no longer hide a BLOCKER from the remediation-step check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:56 +02:00

195 lines
8.7 KiB
JavaScript

// lib/review/verdict-gate.mjs
// K7 — the review verdict gate (ordre 5, P11).
//
// One command decides on the verdict written in review.md, so whatever stops a
// merge or a release reads a file and an exit code, never a model's summary:
//
// node lib/review/verdict-gate.mjs [--json] <review.md>
// 0 = ALLOW or WARN
// 1 = BLOCK
// 2 = could not decide: missing/unreadable file, not a review, no verdict,
// an unknown verdict, a file that contradicts its own verdict, or a
// WARN because a reviewer or engine did not run (`missing-reviewer:` /
// `unattributable-payload` in allow_blocked_by — operator decision
// 2026-10-06: a review not performed in full stops a merge).
// Exit 2 is NEVER green.
//
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
// that is GREENER than the file's own findings (ALLOW/WARN over a BLOCKER, or a
// frontmatter verdict the trailing JSON block disagrees with) is exit 2 as well
// — the file cannot be trusted for the direction that would let work through.
// BLOCK always exits 1: a stricter-than-supported verdict is still a stop.
//
// Borrowed: the verdict goes to a file and a command check gates on it
// (Kiro `feature-pipeline`, https://kiro.dev/docs/workflows/patterns); the
// executor may read the verdict but never write it (Spec Kit
// `templates/checklist-template.md:8-9`, MIT). The blocking itself is Voyage's
// own design — neither source blocks on its own.
import { readFileSync, existsSync } from 'node:fs';
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
import { extractFindingsBlock } from './findings-schema.mjs';
import { isMain } from '../util/is-main.mjs';
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
function undecided(reason, verdict = null) {
return { exit: EXIT.UNDECIDED, verdict, reason };
}
/**
* Decide on review.md text. Pure: no I/O.
* @param {string} text
* @returns {{ exit: 0|1|2, verdict: string|null, reason: string }}
*/
export function decideVerdict(text) {
const r = validateReviewContent(text, { strict: false });
const fm = r.parsed?.frontmatter ?? {};
if (!r.valid) {
const codes = (r.errors ?? []).map((e) => e.code).join(', ');
return undecided(`not a readable review.md (${codes || 'parse failure'})`);
}
const verdict = fm.verdict;
if (verdict === undefined || verdict === null || verdict === '') {
return undecided('review.md carries no verdict (K7: required) — never read as ALLOW');
}
if (!VERDICT_VALUES.includes(verdict)) {
return undecided(`unknown verdict ${JSON.stringify(verdict)}`);
}
if (verdict === 'BLOCK') {
return { exit: EXIT.BLOCK, verdict, reason: 'verdict BLOCK' };
}
// 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);
}
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 = 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);
}
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);
}
// A review in which a reviewer or engine never reported was not performed in
// full: that is "could not decide", not a passable warning (operator
// decision 2026-10-06, ordre 5). A 1.1 review must carry the list.
if (atLeastVersion(fm.review_version, 1, 1) && !Array.isArray(payload.allow_blocked_by)) {
return undecided('trailing JSON block lacks allow_blocked_by (required from review_version 1.1)', verdict);
}
const warnReasons = Array.isArray(payload.allow_blocked_by) ? payload.allow_blocked_by.map(String) : [];
const notRun = warnReasons.filter((w) => w.startsWith('missing-reviewer:') || w.startsWith('unattributable-payload'));
if (notRun.length > 0) {
return undecided(`a control did not run: ${notRun.join(', ')}`, verdict);
}
return { exit: EXIT.PASS, verdict, reason: `verdict ${verdict}`, warn_reasons: warnReasons };
}
function atLeastVersion(v, major, minor) {
const m = typeof v === 'string' ? v.match(/^(\d+)\.(\d+)$/) : null;
return Boolean(m) && (Number(m[1]) > major || (Number(m[1]) === major && Number(m[2]) >= minor));
}
// A fence as lib/review/findings-schema.mjs reads one: on a line of its own,
// indented or not, and a closing fence of three backticks or more (round 8).
const FENCE_RE = /^[ \t]*```[ \t]*([A-Za-z0-9_+-]*)[^\n]*\n[\s\S]*?^[ \t]*`{3,}[ \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;
}
/**
* Decide on a review.md path.
* @param {string} filePath
*/
export function decideVerdictFile(filePath) {
if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`);
let text;
try {
text = readFileSync(filePath, 'utf-8');
} catch (e) {
return undecided(`cannot read ${filePath}: ${e.message}`);
}
return decideVerdict(text);
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
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;
try {
result = decideVerdictFile(filePath);
} catch (e) {
result = undecided(`gate crashed: ${e.message}`);
}
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ path: filePath, ...result }) + '\n');
} else {
const label = result.exit === EXIT.UNDECIDED ? 'UNDECIDED (not green)' : result.verdict;
process.stdout.write(`verdict-gate: ${label} — ${result.reason} — ${filePath}\n`);
}
process.exit(result.exit);
}