fix(verdict-gate): a failed git call is reported as not measured, not stale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 14:00:42 +01:00
commit 77544841d7
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 233 additions and 13 deletions

View file

@ -20,11 +20,20 @@
// target shows as a changed path).
//
// No CLI of its own: the command is `lib/review/verdict-gate.mjs --repo <dir>`,
// which exits 2 (never green) with the reason `review-stale: …` when
// `checkReviewedCommit` is not ok. Every git call is an argv array with no
// shell; a git call that fails is a stale review (fails closed), never ok.
// which exits 2 (never green) with the reason when `checkReviewedCommit` is
// not ok. Two kinds of reason (M4):
//
// - `review-stale: …` — the gate looked, and the review is of another
// commit (or something else changed after it);
// - `review-not-measured: …` — the gate could not look: the `--repo` path
// is missing, it is no git repository, or a git call failed. That is not
// a finding about the review, so it is never reported as stale.
//
// Every git call is an argv array with no shell; a failed git call fails
// closed (not measured), never ok.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { LESSONS_FILE_NAME, MARK_START, MARK_END, withPointer } from './recurrence.mjs';
/**
@ -59,14 +68,19 @@ function stale(reason) {
return { ok: false, reason: `review-stale: ${reason}` };
}
/** The gate could not look (M4): exit 2 like a stale review, but never called one. */
function notMeasured(reason) {
return { ok: false, reason: `review-not-measured: ${reason}` };
}
/** What a git call said: its spawn error, else the first line of its stderr. */
function gitNote(r) {
return r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0];
}
/** A failed git call as a stale reason: fails closed. */
/** A failed git call: not measured, fails closed. */
function gitFailed(what, r) {
return stale(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`);
return notMeasured(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`);
}
const short = (c) => c.slice(0, 12);
@ -102,17 +116,26 @@ function blobAt(repo, rev, path) {
* Is the review of `sha` a review of what HEAD holds in `repo`?
* @param {{ repo: string, sha: string }} args
* @returns {{ ok: true } | { ok: false, reason: string }} reason starts `review-stale:`
* (the review is of another commit) or `review-not-measured:` (the gate could not look)
*/
export function checkReviewedCommit({ repo, sha } = {}) {
if (typeof repo !== 'string' || repo === '') return stale('no repository given');
if (!existsSync(repo)) return notMeasured(`--repo ${repo} does not exist`);
const given = String(sha ?? '');
const notCommit = `reviewed commit ${given} is not a commit in this repository`;
// 1. Full hex only, so a value like `--output=/x` never reaches git as an option.
if (!FULL_SHA_RE.test(given)) return stale(notCommit);
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${given}^{commit}`]);
// `--quiet` makes an unknown commit exit 1 with nothing on stderr: that is
// stale. A spawn error, any other exit, or stderr text (`not a git
// repository`, a broken git) means the gate could not look.
if (resolved.status === 1 && !resolved.error && String(resolved.stderr ?? '').trim() === '') {
return stale(notCommit);
}
if (resolved.status !== 0) {
const note = gitNote(resolved);
return stale(note ? `${notCommit} (${note})` : notCommit);
if (/not a git repository/.test(note)) return notMeasured(`${repo} is not a git repository (${note})`);
return gitFailed('rev-parse --verify', resolved);
}
const full = resolved.stdout.trim();
// A ref named like an id (64 hex in a SHA-1 repository) also resolves: the

View file

@ -19,7 +19,11 @@
// 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).
// 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`.
// Exit 2 is NEVER green.
//
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict