// lib/review/reviewed-commit.mjs
// R6 — is the review of the commit being merged? (fix round 4-5a)
//
// A review verdict opens the merge into main only for the history it judged.
// `/trekexecute` commits the review lessons after the review, so the commit
// being merged is rarely the reviewed one itself; what may come after it is
// exactly what /trekreview writes and nothing else:
//
// - the reviewed commit is HEAD, or an ancestor of HEAD;
// - every commit between it and HEAD (side branches included, each diffed
// against its first parent, `--no-relative --ignore-submodules=none`) changes only
// `{prefix}.claude/rules/voyage-review-lessons.md` and `{prefix}CLAUDE.md`,
// where {prefix} is the project's place in the repo (`git rev-parse
// --show-prefix`);
// - in each such commit the lessons file is unchanged outside its managed
// block, and CLAUDE.md is unchanged or gains exactly the pointer block
// (`withPointer` in lib/review/recurrence.mjs);
// - a commit with no parent in that range fails closed, and so does a
// symlinked CLAUDE.md (the pointer write goes through the link, so its
// target shows as a changed path).
//
// No CLI of its own: the command is `lib/review/verdict-gate.mjs --repo
`,
// 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.
import { spawnSync } from 'node:child_process';
import { LESSONS_FILE_NAME, MARK_START, MARK_END, withPointer } from './recurrence.mjs';
const SHA_RE = /^[0-9a-f]{7,40}$/i;
const LESSONS_REL = `.claude/rules/${LESSONS_FILE_NAME}`; // posix, as `git diff` prints it
const CLAUDE_REL = 'CLAUDE.md';
const SYMLINK_MODE = '120000';
/**
* The review's `scope_sha_end` as written. Read from the frontmatter text, not
* through the YAML reader, which turns an all-digit value into a number and
* drops a leading zero (`0123456` → 123456; release review of 6.3.0, round 3).
*/
export function reviewedSha(reviewText) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? ''));
const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]);
// A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it
// points at now, and coverage would pass whatever was reviewed (round 4).
return m && SHA_RE.test(m[2]) ? m[2] : '';
}
function git(repo, args) {
return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
}
function stale(reason) {
return { ok: false, reason: `review-stale: ${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. */
function gitFailed(what, r) {
return stale(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`);
}
const short = (c) => c.slice(0, 12);
/** The lessons text with its managed block (start marker to end marker, plus its newline) taken out. */
function outsideBlock(text) {
const s = text.indexOf(MARK_START);
if (s === -1) return text;
const e = text.indexOf(MARK_END, s + MARK_START.length);
if (e === -1) return text;
let end = e + MARK_END.length;
if (text.startsWith('\r\n', end)) end += 2;
else if (text[end] === '\n') end += 1;
return text.slice(0, s) + text.slice(end);
}
/**
* One file in one tree: `{ ok, mode, text }`; absent reads as mode null, text ''.
* `--full-tree`: the path is from the repo root, as `git diff` prints it.
*/
function blobAt(repo, rev, path) {
const ls = git(repo, ['ls-tree', '--full-tree', '-z', rev, '--', path]);
if (ls.status !== 0) return { ok: false, fail: gitFailed(`ls-tree ${rev}`, ls) };
const entry = ls.stdout.split('\0').find(Boolean);
if (!entry) return { ok: true, mode: null, text: '' };
const mode = entry.split(' ')[0];
const blob = git(repo, ['cat-file', 'blob', `${rev}:${path}`]);
if (blob.status !== 0) return { ok: false, fail: gitFailed(`cat-file ${rev}:${path}`, blob) };
return { ok: true, mode, text: blob.stdout };
}
/**
* 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:`
*/
export function checkReviewedCommit({ repo, sha } = {}) {
if (typeof repo !== 'string' || repo === '') return stale('no repository given');
const given = String(sha ?? '');
const notCommit = `reviewed commit ${given} is not a commit in this repository`;
// 1. Hex only, so a value like `--output=/x` never reaches git as an option.
if (!SHA_RE.test(given)) return stale(notCommit);
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${given}^{commit}`]);
if (resolved.status !== 0) {
const note = gitNote(resolved);
return stale(note ? `${notCommit} (${note})` : notCommit);
}
const full = resolved.stdout.trim();
// 2. The review is of HEAD itself.
const head = git(repo, ['rev-parse', '--verify', 'HEAD']);
if (head.status !== 0) return gitFailed('rev-parse HEAD', head);
if (head.stdout.trim() === full) return { ok: true };
// 3. Ancestor of HEAD (side branches merged in count; an unmerged one does not).
const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']);
if (anc.status === 1) return stale('the reviewed commit is not an ancestor of HEAD');
if (anc.status !== 0) return gitFailed('merge-base --is-ancestor', anc);
// 4. Every commit after the review, paths and content.
const pre = git(repo, ['rev-parse', '--show-prefix']);
if (pre.status !== 0) return gitFailed('rev-parse --show-prefix', pre);
const prefix = pre.stdout.replace(/\r?\n$/, '');
const lessons = `${prefix}${LESSONS_REL}`;
const claude = `${prefix}${CLAUDE_REL}`;
// One line per commit, ` ...`; a line with one field is a root.
const list = git(repo, ['rev-list', '--parents', `${full}..HEAD`]);
if (list.status !== 0) return gitFailed('rev-list --parents', list);
const lines = list.stdout.split('\n').filter(Boolean).map((l) => l.split(' '));
const root = lines.find((f) => f.length === 1);
if (root) return stale(`commit ${short(root[0])} has no parent`);
for (const [c] of lines) {
// `--no-relative` and `--ignore-submodules=none`: neither `diff.relative`
// nor an `ignore =` setting (config or .gitmodules) may hide a changed path.
const names = git(repo, ['diff', '--no-relative', '--ignore-submodules=none', '--name-only', '-z', '--no-renames', `${c}^1`, c]);
if (names.status !== 0) return gitFailed(`diff ${short(c)}`, names);
const paths = names.stdout.split('\0').filter(Boolean);
const other = paths.find((p) => p !== lessons && p !== claude);
if (other !== undefined) return stale(`${other} changed after the review (commit ${short(c)})`);
for (const path of paths) {
const before = blobAt(repo, `${c}^1`, path);
if (!before.ok) return before.fail;
const after = blobAt(repo, c, path);
if (!after.ok) return after.fail;
if (before.mode === SYMLINK_MODE || after.mode === SYMLINK_MODE) {
return stale(`${path} is a symlink (commit ${short(c)}) — re-run /trekreview on this commit`);
}
if (path === lessons) {
// 5. Only the managed block may change.
if (outsideBlock(before.text).trimEnd() !== outsideBlock(after.text).trimEnd()) {
return stale(`${path} changed outside the managed block (commit ${short(c)})`);
}
} else if (after.text !== before.text && after.text !== withPointer(before.text)) {
// 6. CLAUDE.md: unchanged, or exactly the pointer block added.
return stale(`${path} changed other than the review-lessons pointer (commit ${short(c)})`);
}
}
}
return { ok: true };
}