458 lines
24 KiB
JavaScript
458 lines
24 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> [--repo <dir> | --repo=<dir>]
|
|
// 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 <dir>`, 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 <dir>`, 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`, or an ALLOW/WARN review with a counting MAJOR
|
|
// that is not answered (reason `review-unanswered: <n> MAJOR without
|
|
// an answer`, or `review-unanswered: fixed-pending <n>` when every
|
|
// open one is answered `fixed`; `decideOpenFindings`, read against
|
|
// the top-level approval log of `--repo`, else of the review file's
|
|
// git work tree, else no records). The `--json` output carries
|
|
// `unanswered` and `fixed_pending` id arrays. A round 2+ review that
|
|
// review-finalize.mjs did not finalize is exit 2 too (reason
|
|
// `review-not-measured: round 2+ review not finalized`): it lacks
|
|
// `finalized: true` while an archive `review-round-<run>-<n>.md` of its
|
|
// own run (<run> = the first 12 hex of `scope_sha_start`) sits beside
|
|
// it; the previous findings reach a later round only through
|
|
// finalize's carry. A review whose `declined_to_judge` holds a
|
|
// BLOCKER, a MAJOR or a security entry under a noted reason
|
|
// (`policy:skip`, `policy:cap`, `instruction-out-of-set`;
|
|
// coordinator-contract `NOTED_REASONS`) is exit 2 as well (reason
|
|
// `a policy or filter moved a BLOCKER, MAJOR or security finding`;
|
|
// `movedSerious`): a policy or filter only ever moves MINOR and
|
|
// SUGGESTION findings. An ALLOW/WARN review is exit 2 too when an
|
|
// engine finding at MAJOR or BLOCKER in a Phase 4.7 payload beside it
|
|
// (`.review-engine-payload-*.json`, read by review-finalize.mjs
|
|
// `roundEvidence`) is missing from it (reason `review-not-measured:
|
|
// engine finding not in review: <severity> <rule_key> at
|
|
// <file>:<line>[, …]`; `droppedEngineFindings`): finalize's put-back
|
|
// did not run, or `finalized: true` was written by hand.
|
|
// An ALLOW/WARN review that holds one finding id twice, in the
|
|
// frontmatter `findings:` list or in the trailing JSON block, is exit
|
|
// 2 (reason `duplicate finding id <id> in <where>`).
|
|
// Exit 2 is NEVER green.
|
|
//
|
|
// The pure `decideVerdict` stays text-only: it never reads the answers. The
|
|
// two callers that add the open-findings check are `decideVerdictFile` (this
|
|
// CLI) and scripts/release-check.mjs `checkReview`. The check never keys on
|
|
// the verdict label: an ALLOW over a MAJOR is counted as a WARN is.
|
|
//
|
|
// 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, readdirSync } from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { dirname } from 'node:path';
|
|
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
|
|
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
|
|
import { illegalBriefLowering, NOTED_REASONS } from './coordinator-contract.mjs';
|
|
import { reviewedSha, checkReviewedCommit } from './reviewed-commit.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
import { resolveRepo } from '../util/approval-log.mjs';
|
|
import { answerRecords, archivedReader, decideAnswers, isSecurityFinding, reviewSubject } from './review-answers.mjs';
|
|
import { preExistingCheck, scopeShas } from './pre-existing.mjs';
|
|
import { frontmatterField, runId } from './review-rounds.mjs';
|
|
// No cycle: neither module, nor any module they import, imports this gate.
|
|
import { roundEvidence } from './review-finalize.mjs';
|
|
import { putBack, normTitle } from './engine-compare.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);
|
|
}
|
|
// One id twice would let one bound answer clear two MAJORs: not measured.
|
|
const fmList = Array.isArray(fm.findings) ? fm.findings : [];
|
|
const fmDup = fmList.find((id, i) => fmList.indexOf(id) !== i);
|
|
if (fmDup !== undefined) return undecided(`duplicate finding id ${fmDup} in the frontmatter findings: list`, verdict);
|
|
const jsonList = payload.findings.map((f) => f.id);
|
|
const jsonDup = jsonList.find((id, i) => jsonList.indexOf(id) !== i);
|
|
if (jsonDup !== undefined) return undecided(`duplicate finding id ${jsonDup} in the trailing JSON block`, verdict);
|
|
const fmIds = new Set(fmList);
|
|
const jsonIds = new Set(jsonList);
|
|
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;
|
|
}
|
|
|
|
const NOT_FINALIZED = 'review-not-measured: round 2+ review not finalized';
|
|
const MOVED_SERIOUS = 'a policy or filter moved a BLOCKER, MAJOR or security finding';
|
|
const DROPPED_ENGINE = 'review-not-measured: engine finding not in review';
|
|
const NOTED_SEVERITIES = new Set(['MINOR', 'SUGGESTION']);
|
|
|
|
/** One field of a declined entry for the reason line: one line, at most 120 chars. */
|
|
const field = (v) => (v === null || v === undefined ? null : String(v).replace(/\s+/g, ' ').slice(0, 120));
|
|
|
|
/**
|
|
* The `declined_to_judge` entries a policy or filter may not have written: a
|
|
* reason in `NOTED_REASONS` (coordinator-contract) on an entry that is not a
|
|
* MINOR or SUGGESTION, or that is a security finding (review-answers
|
|
* `isSecurityFinding`). An unreadable block gives none (decideVerdict and
|
|
* decideAnswers judge it).
|
|
* @param {string} text - review.md
|
|
* @returns {Array<{severity, rule_key, file, line, reason}>}
|
|
*/
|
|
export function movedSerious(text) {
|
|
const last = fencedBlocks(String(text ?? '')).at(-1);
|
|
if (!last || !last.closed || !isJsonBlock(last)) return [];
|
|
let payload;
|
|
try { payload = JSON.parse(last.body); } catch { return []; }
|
|
const declined = Array.isArray(payload?.declined_to_judge) ? payload.declined_to_judge : [];
|
|
return declined
|
|
.filter((d) => d !== null && typeof d === 'object' && NOTED_REASONS.has(d.reason)
|
|
&& (!NOTED_SEVERITIES.has(String(d.severity ?? '').toUpperCase()) || isSecurityFinding(d)))
|
|
.map((d) => ({ severity: field(d.severity), rule_key: field(d.rule_key), file: field(d.file), line: field(d.line), reason: d.reason }));
|
|
}
|
|
|
|
/**
|
|
* A round 2+ review that finalize did not finalize, or null. From round 2 the
|
|
* previous findings reach review.md only through finalize's carry, so such a
|
|
* review is never read: it lacks `finalized: true` while an archive
|
|
* `review-round-<run>-<n>.md` of its own run (lib/review/review-rounds.mjs
|
|
* `runId` of `scope_sha_start`) sits beside it. A folder that cannot be
|
|
* listed is not measured either.
|
|
* @param {string} text - review.md
|
|
* @param {string|undefined} reviewPath
|
|
* @returns {string|null}
|
|
*/
|
|
export function unfinalizedRound(text, reviewPath) {
|
|
if (frontmatterField(text, 'finalized') === 'true') return null;
|
|
const { start } = scopeShas(text);
|
|
if (!reviewPath || !start) return null;
|
|
const archive = new RegExp(`^review-round-${runId(start)}-\\d+\\.md$`);
|
|
let names;
|
|
try { names = readdirSync(dirname(reviewPath)); } catch (e) {
|
|
return `review-not-measured: cannot list the review folder (${e.message})`;
|
|
}
|
|
return names.some((n) => archive.test(n)) ? NOT_FINALIZED : null;
|
|
}
|
|
|
|
/** The trailing JSON block as decideVerdict reads it (the last fenced block of the body), or null. */
|
|
function trailingPayload(text) {
|
|
const body = validateReviewContent(text, { strict: false }).parsed?.body ?? '';
|
|
const last = fencedBlocks(body).at(-1);
|
|
if (!last || !last.closed || !isJsonBlock(last)) return null;
|
|
try { return JSON.parse(last.body); } catch { return null; }
|
|
}
|
|
|
|
/** Same place and title: the fields a carry fold keeps from the current finding. */
|
|
const samePlace = (c, e) => typeof c.title === 'string' && typeof e.title === 'string'
|
|
&& c.file === e.file && String(c.line) === String(e.line) && normTitle(c) === normTitle(e);
|
|
|
|
/**
|
|
* The engine findings at MAJOR or BLOCKER in the Phase 4.7 payloads beside the
|
|
* review (review-finalize.mjs `roundEvidence`: each regular
|
|
* `.review-engine-payload-*.json` whose `reviewer` maps back through ENGINES)
|
|
* that review.md does not hold: engine-compare.mjs `putBack` against the
|
|
* trailing JSON block (an unreadable block holds nothing, so each such engine
|
|
* finding is missing: the stricter side), without each one a
|
|
* finding with `carried_from` matches on file, line and `normTitle` (a carry
|
|
* fold can swap in a previous security `rule_key`, review-rounds.mjs `fold`,
|
|
* so the row alone would miss it). A finalized review holds every put-back
|
|
* on its row, so it gives none. An engine finding returned on its own row at
|
|
* a lower severity is not caught: only a missing one is.
|
|
* @param {string} text - review.md
|
|
* @param {string|undefined} reviewPath
|
|
* @returns {object[]} the missing engine findings, as putBack copies them
|
|
*/
|
|
export function droppedEngineFindings(text, reviewPath) {
|
|
if (!reviewPath) return [];
|
|
const { payloads } = roundEvidence(dirname(reviewPath));
|
|
if (payloads.length === 0) return [];
|
|
const review = trailingPayload(String(text ?? ''));
|
|
const carried = (Array.isArray(review?.findings) ? review.findings : [])
|
|
.filter((f) => f !== null && typeof f === 'object' && typeof f.carried_from === 'string' && f.carried_from !== '');
|
|
return putBack(review, payloads).added.filter((e) => !carried.some((c) => samePlace(c, e)));
|
|
}
|
|
|
|
/**
|
|
* The open-findings check: every counting MAJOR of an ALLOW/WARN review must be
|
|
* answered (lib/review/review-answers.mjs `decideAnswers`). K2 records come
|
|
* from the top-level approval log of `repo`, else of the review's directory;
|
|
* a broken, diverged or unreadable log, or no work tree, means no records.
|
|
* A MAJOR leaves the answer set as pre-existing only when it is marked
|
|
* `pre_existing: true` and git agrees over `scope_sha_start..scope_sha_end`
|
|
* in `repo` (lib/review/pre-existing.mjs `preExistingCheck`); with no repo, or
|
|
* no full `scope_sha_start`, no finding is pre-existing. A BLOCKER or a
|
|
* security finding counts whatever the mark. A carried answer row is read
|
|
* against its archive beside the review file (`archivedReader`); with no
|
|
* review path no carried row answers. A finding with `carried_from` is never
|
|
* pre-existing: it keeps its line from the previous round's end. A round 2+
|
|
* review that was not finalized (`unfinalizedRound`) is exit 2 first, then an
|
|
* engine MAJOR or BLOCKER missing from the review (`droppedEngineFindings`),
|
|
* then a BLOCKER, MAJOR or security finding moved by a policy or filter (`movedSerious`).
|
|
* @param {string} text - review.md
|
|
* @param {{ repo?: string, reviewPath: string }} opts
|
|
* @returns {{ exit: 0|2, reason: string, unanswered: string[], fixed_pending: string[] }}
|
|
*/
|
|
export function decideOpenFindings(text, { repo, reviewPath } = {}) {
|
|
const notFinal = unfinalizedRound(text, reviewPath);
|
|
if (notFinal) return { exit: EXIT.UNDECIDED, reason: notFinal, unanswered: [], fixed_pending: [] };
|
|
const dropped = droppedEngineFindings(text, reviewPath);
|
|
if (dropped.length > 0) {
|
|
return {
|
|
exit: EXIT.UNDECIDED,
|
|
reason: `${DROPPED_ENGINE}: ${dropped.map((f) => `${field(f.severity) ?? '(no severity)'} ${field(f.rule_key) ?? '(no rule_key)'} at ${field(f.file) ?? '?'}:${field(f.line) ?? '?'}`).join(', ')}`,
|
|
unanswered: [],
|
|
fixed_pending: [],
|
|
};
|
|
}
|
|
const hidden = movedSerious(text);
|
|
if (hidden.length > 0) {
|
|
return {
|
|
exit: EXIT.UNDECIDED,
|
|
reason: `${MOVED_SERIOUS}: declined_to_judge ${hidden.map((d) => `${d.severity ?? '(no severity)'} ${d.rule_key ?? '(no rule_key)'} at ${d.file ?? '?'}:${d.line ?? '?'} (${d.reason})`).join(', ')}`,
|
|
unanswered: [],
|
|
fixed_pending: [],
|
|
};
|
|
}
|
|
const { records, top, problem } = answerRecords(repo ?? (reviewPath ? dirname(reviewPath) : undefined));
|
|
const subject = top && reviewPath ? reviewSubject(top, reviewPath) : undefined;
|
|
// A carried `## Answers` row (Carried from review-round-<run>-<n>.md#<id>)
|
|
// is checked against that archive, read beside the review file.
|
|
const archived = reviewPath ? archivedReader(dirname(reviewPath)) : undefined;
|
|
const pre = preExistingCheck(text, repo);
|
|
const d = decideAnswers(text, { records, reviewSubject: subject, preExisting: (f) => !f.carried_from && pre(f), archived });
|
|
if (d.ok) return { exit: EXIT.PASS, reason: 'every counting MAJOR is answered', unanswered: [], fixed_pending: [] };
|
|
return {
|
|
exit: EXIT.UNDECIDED,
|
|
reason: problem ? `${d.reason} (${problem})` : d.reason,
|
|
unanswered: d.unanswered,
|
|
fixed_pending: d.fixedPending,
|
|
};
|
|
}
|
|
|
|
function insideWorkTree(dir) {
|
|
const r = spawnSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
|
|
return r.status === 0 && r.stdout.trim() === 'true';
|
|
}
|
|
|
|
/**
|
|
* 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. Then every counting MAJOR must be answered (`decideOpenFindings`),
|
|
* read against `repo`, else the review's git work tree, else no records.
|
|
* @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 (decided.exit !== EXIT.PASS) return decided;
|
|
if (repo !== undefined) {
|
|
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);
|
|
}
|
|
const here = dirname(filePath);
|
|
const openRepo = repo !== undefined ? repo : (insideWorkTree(here) ? resolveRepo(undefined, here) : undefined);
|
|
const open = decideOpenFindings(text, { repo: openRepo, reviewPath: filePath });
|
|
if (open.exit !== EXIT.PASS) {
|
|
return { ...undecided(open.reason, decided.verdict), unanswered: open.unanswered, fixed_pending: open.fixed_pending };
|
|
}
|
|
return { ...decided, unanswered: [], fixed_pending: [] };
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const usage = 'Usage: verdict-gate.mjs [--json] <review.md> [--repo <dir> | --repo=<dir>] (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=<dir> 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);
|
|
}
|