// 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] [--repo | --repo=] // 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, a // finding lowered or marked against the brief outside the one legal // shape (coordinator-contract `illegalBriefLowering`), 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), a // `--repo` or `--repo=` with no value, any other option (M6), or, // with `--repo `, an ALLOW/WARN // review of another commit than the one being merged (reason // `review-stale: …`: its `scope_sha_end` is not HEAD, or an ancestor // of HEAD whose later commits change only the review lessons and the // CLAUDE.md pointer; lib/review/reviewed-commit.mjs, R6), or, with // `--repo `, a check that could not look (reason // `review-not-measured: …`: the path is missing, it is no git // repository, or a git call failed; M4) — printed as it is, never // as `review-stale`. // 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 { fencedBlocks, isJsonBlock } from './findings-schema.mjs'; import { illegalBriefLowering } from './coordinator-contract.mjs'; import { reviewedSha, checkReviewedCommit } from './reviewed-commit.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); } // The last fenced block, its info string and its body from one reading // (findings-schema `fencedBlocks`): a gate that took the language from one // reader and the body from another passed a BLOCKER in a last block opened // as ```json {x} (release review of 6.3.0, round 9). const last = fencedBlocks(body).at(-1); if (last === undefined) { return undecided(`verdict ${verdict} has no trailing JSON block to back it`, verdict); } if (!last.closed) { return undecided(`the last fenced block ("${last.info || '(no language)'}") is never closed, so it runs to the end of the file`, verdict); } if (!isJsonBlock(last)) { return undecided(`the last fenced block is "${last.info || '(no language)'}", not the trailing \`json\` block`, verdict); } let payload; try { payload = JSON.parse(last.body); } 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); } // The brief-conflict rule (coordinator-contract `illegalBriefLowering`, the // one place it lives): a finding lowered or marked outside the one legal // shape means the verdict may be greener than the findings — never green. const illegal = payload.findings.filter((f) => illegalBriefLowering(f)).map((f) => f.id); if (illegal.length > 0) { return undecided(`verdict ${verdict} over a brief-conflict lowering outside the one legal shape (finding ${illegal.join(', ')})`, 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)); } /** 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. With `repo`, an ALLOW/WARN review must also be * of the commit being merged there (R6); BLOCK and exit 2 are returned as they are. * @param {string} filePath * @param {{ repo?: string }} [opts] */ export function decideVerdictFile(filePath, { repo } = {}) { 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}`); } const decided = decideVerdict(text); if (repo === undefined || decided.exit !== EXIT.PASS) return decided; const sha = reviewedSha(text); if (!sha) return undecided('review-stale: review.md has no scope_sha_end that is a commit sha', decided.verdict); const commit = checkReviewedCommit({ repo, sha }); if (!commit.ok) return undecided(commit.reason, decided.verdict); return decided; } if (isMain(import.meta.url)) { const args = process.argv.slice(2); const usage = 'Usage: verdict-gate.mjs [--json] [--repo | --repo=] (exactly one file)\n'; // --repo consumes its value before the file argument is collected, so the // value is never read as a second file. Any other option is refused (M6): a // dropped --repo= or a misspelt flag would otherwise pass the gate // without the check it asked for. const files = []; let repo; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--repo') { const v = args[++i]; if (v === undefined || v.startsWith('--')) { process.stderr.write(`verdict-gate: --repo needs a value\n${usage}`); process.exit(EXIT.UNDECIDED); } repo = v; } else if (a.startsWith('--repo=')) { const v = a.slice('--repo='.length); if (v === '') { process.stderr.write(`verdict-gate: --repo= needs a value\n${usage}`); process.exit(EXIT.UNDECIDED); } repo = v; } else if (a === '--json') { // read below } else if (a.startsWith('--')) { process.stderr.write(`verdict-gate: unknown option ${a}\n${usage}`); process.exit(EXIT.UNDECIDED); } else { files.push(a); } } 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); process.exit(EXIT.UNDECIDED); } let result; try { result = decideVerdictFile(filePath, { repo }); } 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); }