fix(review): scope_sha_start is bound to the fork point from main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-03 11:23:21 +01:00
commit a09e6c96a1
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 494 additions and 9 deletions

View file

@ -28,17 +28,39 @@
// the file from passing as pre-existing. Any git failure here also means
// nothing is pre-existing.
//
// Known limit: `scope_sha_start` is read from review.md and is not bound to
// anything the gate checks (`scope_sha_end` is, by `checkReviewedCommit` in
// lib/review/reviewed-commit.mjs). A forged narrower start, for example a
// later commit of the same run, shrinks the range, so a line the run added can
// look pre-existing for a MAJOR finding. BLOCKER and security findings are
// never affected: they count in full whatever the mark.
// The binding (fix round 4.5b2 part B, Step 14): `scope_sha_start` is read from
// review.md, so it counts only when it is bound to the fork point from main
// (`forkPoint`: `refs/heads/main`, else `refs/heads/master`, the rule of
// `/trekexecute` Phase 8). Exactly two starts are bound (`startBound`):
// - the fork point itself;
// - a later start reached through process files only: the tree diff from the
// fork point to the start names only the approval log
// (`.claude/voyage/approvals.jsonl`, LOG_RELPATH) and paths under the
// top-level `.claude/projects/`, the commits `/trekbrief` and `/trekplan`
// make on the branch (lib/util/artifact-commit.mjs).
// Every other start, and a range that ends on the main line itself, is
// `{ok:false}`, so nothing is pre-existing. A finding on the approval log or on
// a `.claude/projects/` file is never pre-existing, whatever the start. Limits:
// - a range that ends on the main line (a run committed straight to main, or
// a fast-forward merge), a start older than the fork point (the line sets
// are a tree diff, so a line main changed between that start and the fork
// point and the branch put back would not show), and a repo with neither
// branch have nothing pre-existing; a subdirectory project's own
// `{prefix}.claude/projects/` is not a process path (the stricter side);
// - trust limit: the fork point is read from `refs/heads/main` (or
// `master`), a ref the session that writes review.md can also move (`git
// update-ref`, `git branch -f`, or a plain commit on main before the branch
// is made), and the Bash guard does not ask about that. The binding narrows
// the forged start to a forged ref move as well. It is a trace, not a
// signature, like the K2 log;
// - BLOCKER and security findings are unaffected, as before: they count in
// full whatever the mark.
//
// No CLI of its own.
import { spawnSync } from 'node:child_process';
import { FULL_SHA_RE, reviewedSha } from './reviewed-commit.mjs';
import { LOG_RELPATH } from '../util/approval-log.mjs';
const MAX_BUFFER = 64 * 1024 * 1024;
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/;
@ -62,6 +84,74 @@ function gitFailed(what, r) {
return notMeasured(`git ${what} failed (${note || `exit ${r.status}`})`);
}
/** A reason without the `notMeasured` prefix, to be wrapped once more. */
function bare(reason) {
return String(reason ?? '').replace(/^pre-existing not measured: /, '');
}
/** True for the process files `/trekbrief` and `/trekplan` commit: the approval log and the top-level project folder. */
function processPath(p) {
return p === LOG_RELPATH || p.startsWith('.claude/projects/');
}
/**
* The fork point of `end` from the main branch (`refs/heads/main`, else
* `refs/heads/master`): the main line's commit when it holds nothing `end`
* lacks, else the first parent of the oldest first-parent commit of the main
* line that `end` lacks.
* @param {string} repo
* @param {string} end - a full commit id
* @returns {{ok: true, sha: string, ref: string} | {ok: false, reason: string}}
*/
export function forkPoint(repo, end) {
if (!repo) return notMeasured('no repo');
if (!FULL_SHA_RE.test(String(end ?? ''))) return notMeasured('scope_sha_end must be a full commit id');
let ref = null;
let tip = null;
for (const b of ['main', 'master']) {
const r = git(repo, ['rev-parse', '-q', '--verify', `refs/heads/${b}`]);
if (r.error || (r.status !== 0 && r.status !== 1)) return gitFailed('rev-parse --verify', r);
if (r.status === 0) { ref = `refs/heads/${b}`; tip = r.stdout.trim(); break; }
}
if (!ref) return { ok: false, reason: 'no main or master branch' };
const list = git(repo, ['rev-list', '--first-parent', ref, `^${end}`]);
if (list.error || list.status !== 0) return gitFailed('rev-list --first-parent', list);
const shas = list.stdout.split('\n').filter((s) => s !== '');
if (shas.length === 0) return { ok: true, sha: tip, ref };
const last = shas.at(-1);
const parent = git(repo, ['rev-parse', '-q', '--verify', `${last}^1`]);
if (parent.error || (parent.status !== 0 && parent.status !== 1)) return gitFailed('rev-parse --verify', parent);
if (parent.status === 1) return { ok: false, reason: `${last.slice(0, 12)} on ${ref} has no parent: no fork point` };
return { ok: true, sha: parent.stdout.trim(), ref };
}
/**
* Is `start` bound to the fork point `fork`? Only the fork point itself, or a
* later start whose tree diff from the fork point names only process files.
* @param {string} repo
* @param {string} start - scope_sha_start
* @param {string} fork - forkPoint(...).sha
* @returns {{ok: true} | {ok: false, reason: string}}
*/
export function startBound(repo, start, fork) {
if (!repo) return notMeasured('no repo');
if (!FULL_SHA_RE.test(String(start ?? '')) || !FULL_SHA_RE.test(String(fork ?? ''))) {
return { ok: false, reason: 'the start and the fork point must be full commit ids' };
}
if (start.toLowerCase() === fork.toLowerCase()) return { ok: true };
const anc = git(repo, ['merge-base', '--is-ancestor', fork, start]);
if (anc.error || (anc.status !== 0 && anc.status !== 1)) return gitFailed('merge-base --is-ancestor', anc);
if (anc.status === 1) return { ok: false, reason: 'the start is not after the fork point (an older or unrelated start)' };
const r = git(repo, ['diff', '--no-color', '--no-ext-diff', '--no-relative', '--ignore-submodules=none',
'--name-only', '-z', '--no-renames', fork, start]);
if (r.error || r.status !== 0) return gitFailed('diff --name-only', r);
const other = r.stdout.split('\0').find((p) => p !== '' && !processPath(p));
if (other !== undefined) {
return { ok: false, reason: `the fork point..start changes ${JSON.stringify(other.slice(0, 120))}, not a process file` };
}
return { ok: true };
}
/** A frontmatter sha field as written (the `reviewedSha` reading, for any key): a full sha, else ''. */
function frontmatterSha(text, key) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text ?? ''));
@ -223,6 +313,15 @@ export function changedLineSets(repo, start, end) {
const anc = git(repo, ['merge-base', '--is-ancestor', start, end]);
if (anc.error || (anc.status !== 0 && anc.status !== 1)) return gitFailed('merge-base --is-ancestor', anc);
if (anc.status === 1) return notMeasured(`${start.slice(0, 12)} is not an ancestor of ${end.slice(0, 12)}`);
const fp = forkPoint(repo, end);
if (!fp.ok) return notMeasured(bare(fp.reason));
const onMain = git(repo, ['merge-base', '--is-ancestor', end, fp.sha]);
if (onMain.error || (onMain.status !== 0 && onMain.status !== 1)) return gitFailed('merge-base --is-ancestor', onMain);
if (onMain.status === 0) return notMeasured(`the range ends on ${fp.ref}; scope_sha_start cannot be bound`);
const bound = startBound(repo, start, fp.sha);
if (!bound.ok) {
return notMeasured(`scope_sha_start ${start.slice(0, 12)} is not bound to the fork point ${fp.sha.slice(0, 12)} from ${fp.ref}: ${bare(bound.reason)}`);
}
const r = git(repo, ['-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-textconv',
'--no-relative', '--find-renames', '--src-prefix=a/', '--dst-prefix=b/', '--ignore-submodules=none', start, end]);
if (r.error || r.status !== 0) return gitFailed('diff', r);
@ -257,6 +356,8 @@ function plainPath(p) {
export function isPreExisting(f, sets) {
if (!sets || sets.ok !== true) return false;
if (!f || !Number.isInteger(f.line) || f.line < 1 || !plainPath(f.file)) return false;
// A process file can be changed by a bound start's own commits: never pre-existing.
if (processPath(f.file)) return false;
if (sets.renamed.has(f.file)) return false;
const entry = sets.files.get(f.file);
if (entry === 'all') return false;