fix(review): the verdict gate refuses, with --repo, a review of another commit than the one being merged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9f5f6251a
commit
6c0718c8d8
6 changed files with 570 additions and 25 deletions
|
|
@ -286,7 +286,7 @@ function atomicWriteText(path, text) {
|
|||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
function withPointer(claudeText) {
|
||||
export function withPointer(claudeText) {
|
||||
if (claudeText.includes(POINTER_START) || claudeText.includes(POINTER_END)) return null;
|
||||
const sep = claudeText.endsWith('\n') ? '\n' : '\n\n';
|
||||
return `${claudeText}${sep}${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
|
||||
|
|
|
|||
162
lib/review/reviewed-commit.mjs
Normal file
162
lib/review/reviewed-commit.mjs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// 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 <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.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { MARK_START, MARK_END, withPointer } from './recurrence.mjs';
|
||||
|
||||
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
||||
const LESSONS_REL = '.claude/rules/voyage-review-lessons.md';
|
||||
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 && /^[0-9a-f]{7,40}$/i.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}` };
|
||||
}
|
||||
|
||||
/** A failed git call as a stale reason: fails closed. */
|
||||
function gitFailed(what, r) {
|
||||
const note = (r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]) || `exit ${r.status}`;
|
||||
return stale(`git ${what} failed (${note})`);
|
||||
}
|
||||
|
||||
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, absent, mode, text }`; absent reads as ''.
|
||||
* `--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, absent: 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, absent: false, 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 = (resolved.error?.message ?? String(resolved.stderr ?? '').trim().split('\n')[0]);
|
||||
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}`;
|
||||
|
||||
const roots = git(repo, ['rev-list', '--max-parents=0', `${full}..HEAD`]);
|
||||
if (roots.status !== 0) return gitFailed('rev-list --max-parents=0', roots);
|
||||
const root = roots.stdout.split('\n').find(Boolean);
|
||||
if (root) return stale(`commit ${short(root)} has no parent`);
|
||||
|
||||
const list = git(repo, ['rev-list', `${full}..HEAD`]);
|
||||
if (list.status !== 0) return gitFailed('rev-list', list);
|
||||
for (const c of list.stdout.split('\n').filter(Boolean)) {
|
||||
// `--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 };
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
// 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>
|
||||
// node lib/review/verdict-gate.mjs [--json] <review.md> [--repo <dir>]
|
||||
// 0 = ALLOW or WARN
|
||||
// 1 = BLOCK
|
||||
// 2 = could not decide: missing/unreadable file, not a review, no verdict,
|
||||
|
|
@ -13,7 +13,12 @@
|
|||
// 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).
|
||||
// 2026-10-06: a review not performed in full stops a merge), a
|
||||
// `--repo` with no value, 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).
|
||||
// Exit 2 is NEVER green.
|
||||
//
|
||||
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
|
||||
|
|
@ -32,6 +37,7 @@ 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 });
|
||||
|
|
@ -159,10 +165,12 @@ function blockerSectionEntries(body) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Decide on a review.md path.
|
||||
* 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) {
|
||||
export function decideVerdictFile(filePath, { repo } = {}) {
|
||||
if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`);
|
||||
let text;
|
||||
try {
|
||||
|
|
@ -170,21 +178,44 @@ export function decideVerdictFile(filePath) {
|
|||
} catch (e) {
|
||||
return undecided(`cannot read ${filePath}: ${e.message}`);
|
||||
}
|
||||
return decideVerdict(text);
|
||||
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 files = args.filter((a) => !a.startsWith('--'));
|
||||
const usage = 'Usage: verdict-gate.mjs [--json] <review.md> [--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.
|
||||
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('--')) {
|
||||
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: verdict-gate.mjs [--json] <review.md> (exactly one file)\n');
|
||||
process.stderr.write(usage);
|
||||
process.exit(EXIT.UNDECIDED);
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = decideVerdictFile(filePath);
|
||||
result = decideVerdictFile(filePath, { repo });
|
||||
} catch (e) {
|
||||
result = undecided(`gate crashed: ${e.message}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue