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:
parent
57e7eba0a0
commit
a09e6c96a1
7 changed files with 494 additions and 9 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue