// 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] // 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). // 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 { 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. * @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] (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); }