fix(review): the evidence writer takes a verdict only for the branch head

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-04 10:27:19 +01:00
commit 5b9cbeba28
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 296 additions and 5 deletions

View file

@ -14,10 +14,18 @@
// - verification: progress.json `plan_verification.summary` passed/total;
// - review: the verdict gate (`decideVerdictFile` with the repo, R6 first)
// on {project}/review.md, and its `scope_sha_end` (`reviewedSha`); a review
// the gate does not decide (exit 2) has a null verdict;
// the gate does not decide (exit 2) has a null verdict. The gate is asked
// only when `git rev-parse --verify HEAD` in --repo is the branch head (R6
// is HEAD-based); from any other HEAD the verdict is null ("not measured"
// on the question) and `review.sha` is still written;
// - door: the plan's `## Doors` list, else the CHANGELOG's first section when
// it holds exactly one door line, else null.
// A field that cannot be derived is null, and the guard prints "not measured".
// A branch name git would read as something else is refused before the head
// is read ({"written": false}, "branch name <b> is ambiguous ..."): when
// `refs/tags/<b>` or `refs/<b>` resolves, or an entry of any kind
// `<git dir>/<b>` or `<git common dir>/<b>` exists (so `voyage`, the evidence
// directory's parent, is refused). The guard's reader keeps the same rule.
// Before writing, evidence files in that directory older than 30 days are
// removed (regular files named `<hex>.json` only). The file is written
// atomically (a temp file in the same directory, then rename).
@ -100,6 +108,26 @@ function git(repo, args) {
return spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
}
/**
* True when git would read `branch` as something other than refs/heads/<branch>:
* `refs/tags/<b>` or `refs/<b>` resolves (argv only, never a shell), or an
* entry of any kind (file, directory, link) `<git dir>/<b>` or
* `<git common dir>/<b>` exists. The guard's reader
* (hooks/lib/merge-evidence.mjs) shows no evidence for the same names.
*/
function ambiguousName(repoDir, gitDir, common, branch) {
for (const ref of [`refs/tags/${branch}`, `refs/${branch}`]) {
if (git(repoDir, ['rev-parse', '--verify', '--quiet', ref]).status === 0) return true;
}
for (const dir of [gitDir, common]) {
try { lstatSync(join(dir, branch)); return true; } catch (e) {
// No entry is ENOENT/ENOTDIR; any other error cannot tell, so it counts as ambiguous.
if (e?.code !== 'ENOENT' && e?.code !== 'ENOTDIR') return true;
}
}
return false;
}
const readText = (p) => { try { return existsSync(p) ? readFileSync(p, 'utf8') : null; } catch { return null; } };
/** Remove evidence files older than MAX_AGE_MS: regular files named `<hex>.json` only. */
@ -131,13 +159,24 @@ export function writeEvidence({ repo, branch, projectDir, now = Date.now() } = {
const repoDir = resolve(repo);
const project = resolve(projectDir);
const head = git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
const sha = head.status === 0 ? head.stdout.trim() : '';
if (!SHA_RE.test(sha)) return { written: false, reason: `no branch ${branch} in ${repoDir}` };
const cd = git(repoDir, ['rev-parse', '--git-common-dir']);
const rawCommon = cd.status === 0 ? cd.stdout.trim() : '';
if (!rawCommon) return { written: false, reason: `git rev-parse --git-common-dir failed in ${repoDir}` };
const common = isAbsolute(rawCommon) ? rawCommon : resolve(repoDir, rawCommon);
const gd = git(repoDir, ['rev-parse', '--git-dir']);
const rawGitDir = gd.status === 0 ? gd.stdout.trim() : '';
if (!rawGitDir) return { written: false, reason: `git rev-parse --git-dir failed in ${repoDir}` };
const gitDir = isAbsolute(rawGitDir) ? rawGitDir : resolve(repoDir, rawGitDir);
if (ambiguousName(repoDir, gitDir, common, branch)) {
return { written: false, reason: `branch name ${branch} is ambiguous (a tag or another ref has that name)` };
}
const head = git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
const sha = head.status === 0 ? head.stdout.trim() : '';
if (!SHA_RE.test(sha)) return { written: false, reason: `no branch ${branch} in ${repoDir}` };
// The gate's R6 check is HEAD-based: it is asked only when HEAD is the branch head.
const at = git(repoDir, ['rev-parse', '--verify', 'HEAD']);
const headIsBranch = at.status === 0 && at.stdout.trim() === sha;
const top = git(repoDir, ['rev-parse', '--show-toplevel']);
const topDir = top.status === 0 && top.stdout.trim() ? top.stdout.trim() : repoDir;
@ -152,7 +191,7 @@ export function writeEvidence({ repo, branch, projectDir, now = Date.now() } = {
const reviewPath = join(project, 'review.md');
const reviewText = readText(reviewPath);
let verdict = null;
if (reviewText !== null) {
if (reviewText !== null && headIsBranch) {
try {
const gate = decideVerdictFile(reviewPath, { repo: repoDir });
if (gate.exit === EXIT.PASS) verdict = gate.verdict ?? null;