Files
gstack/lib/ship-patch.ts
T
Garry Tanandcapy-ai[bot] 549b9a8660 fix(ship): finish takes a landed stack parent as the reviewed base, fail-closed (GST-1399) (#4021)
* fix(ship): finish takes a landed stack parent as the reviewed base, fail-closed on metadata and never bounded (GST-1399)

Ported from #4013 onto #4020's route: the stacked-parent path judges every
shipped path's metadata class against main, counts stamp_paths and
release_outputs only while stamp-only, and never feeds the bounded route.
The receipt carry's landed-parent search finds a squash main edited since.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): a loop exception starts at the landed parent; a conflicted base commit never holds a parent (GST-1399)

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: v1/outside-voice#1 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): a metadata-only conflict still holds a landed parent; a post-merge loop head starts at its own fork (GST-1399)

heldBy counts a merge as conflicted only when a conflicted path is inside its pathspec, so a train's VERSION restamp
in the squash no longer hides the landed parent. The review-loop exception's atHead uses patchBase, so a fix-check head
after the child merged main starts at its own merge base, not at the parent.

Review-Finding: p2/specialist-batched#1 reader=specialist-batched
Review-Finding: p2/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ship): stacked-parent loop-exception matrix over merge order, parent state and fix-check head (GST-1399)

28 cells: the child merged main before the parent landed, after, or never; the parent landed by squash, by squash
with a later main edit of its lines, with a release fold, or not at all; the fix-check head is before any merge, the
merge, or after it. Every cell runs the postinstall and stamp_paths probes (parent changes main never took, edited
after the fix-check) and a control run (the child's own file and a main-only file edited after the fix-check).

Red at 3f4c47c (4): merged before or after the landing × parent edited × fix-check head merge or post; the
exception accepts package.json and src/policy.ts. The other 24 cells pass.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): a loop exception's fix-check paths are the head's own patch from both forks; heldBy reads merge-tree with -z (GST-1399)

The "touched at the fix-check head" set is the intersection of the head's patch from its merge base and from the
landed parent, so neither a parent change main never took nor a main-only file counts as reviewed, whatever order
the child merged main in. heldBy reads conflicted names NUL-separated, so a C-quoted name still counts as a conflict.
The stacked-parent matrix (28 cells) is green.

Review-Finding: fc1/fix-check#1 reader=fix-check
Review-Finding: fc1/fix-check#3 reader=fix-check
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-05 06:30:15 +00:00

345 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ship-patch: whether the branch's own change to a path is the one a review saw, when the base
* moved under it (GST-407, GST-384). /ship's finish route and the receipt carry both ask it.
*
* Each side is a branch patch: reviewedBase -> reviewed (what the review read) and
* currentBase -> current (what ships), where each base is that side's merge base with the
* target branch. A path is
* - same: the 3-line-context patch-ids match and every hunk sits where the base's change puts it (a
* base merge only shifted line numbers; patch-ids ignore positions, so a block moved to an identical
* context elsewhere is changed);
* - context: the +/- lines match hunk for hunk, and each hunk sits where the base's own
* change (reviewedBase -> currentBase) puts it, so only context lines the base edited differ.
* The merge may still have spliced the two (a JSON entry where the branch changed one key and
* the base the next), so a context path needs a scoped review of its hunks, never a silent pass;
* - changed: anything else, including the same lines moved to another place, a file-mode change, and any path whose
* patches cannot be read (an empty patch on both sides of a path the branch touches). A binary, empty or mode-only
* patch has no hunks to place, so it is same when its patch-id matches and changed otherwise.
* Diffs skip external drivers and textconv, so a configured filter cannot hide a line.
* Paths reach git as literal pathspecs from NUL-separated listings, so quoted names and glob
* characters cannot widen or empty a comparison.
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EMPTY_PROJECT_CONFIG, scanCandidate } from './gate-diff/index';
import { parseUnifiedDiff } from './gate-diff/git';
import { CARRY_TEST_CODE_GLOBS } from './ship-receipt';
import { matchesAny } from './ship-policy';
import { isGatePath } from './review-depth';
import { TARGETED_BRIEF_FILE, TARGETED_CLASSES, targetedBrief } from './review-targeted';
import { parseConfig, PREGATE_CONFIG_PATH } from './pregate/config';
import { headPolicyDiff } from './pregate/head-policy';
import { CHECK_IDS } from './pregate/index';
export interface PatchSides { reviewedBase: string; reviewed: string; currentBase: string; current: string }
export type PathVerdict = 'same' | 'context' | 'changed';
function git(repoRoot: string, args: string[], input?: string): string | null {
const r = spawnSync('git', ['-c', 'core.quotepath=off', ...args], { cwd: repoRoot, encoding: 'utf8', input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return r.status === 0 ? r.stdout : null;
}
/** Every path the diff from..to touches (renames as a delete and an add); null when git cannot diff them. */
export function touchedPaths(repoRoot: string, from: string, to: string): Set<string> | null {
const out = git(repoRoot, ['diff', '-z', '--name-only', '--no-renames', from, to]);
return out === null ? null : new Set(out.split('\0').filter(Boolean));
}
function pathDiff(repoRoot: string, from: string, to: string, rel: string, context: number): string | null {
return git(repoRoot, ['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--binary', '--full-index', `-U${context}`, from, to, '--', rel]);
}
function patchId(repoRoot: string, diff: string): string | null {
if (!diff.trim()) return '';
const out = git(repoRoot, ['patch-id', '--verbatim'], diff);
return out === null ? null : out.trim().split(/\s+/)[0] ?? '';
}
interface ZeroHunk { oldStart: number; oldCount: number; newStart: number; newCount: number; body: string }
/** The hunks of a zero-context diff; null when it has none to read (binary, mode-only) but is not empty. */
function zeroHunks(diff: string): ZeroHunk[] | null {
const hunks: ZeroHunk[] = [];
const lines = diff.split('\n');
for (let i = 0; i < lines.length; i++) {
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(lines[i]!);
if (!m) continue;
const h: ZeroHunk = { oldStart: Number(m[1]), oldCount: m[2] === undefined ? 1 : Number(m[2]), newStart: Number(m[3]), newCount: m[4] === undefined ? 1 : Number(m[4]), body: '' };
const body: string[] = [];
let left = h.oldCount + h.newCount;
while (left > 0 || lines[i + 1]?.startsWith('\\')) {
const line = lines[++i];
if (line === undefined) return null;
if (!line.startsWith('\\')) left--;
body.push(line);
}
h.body = body.join('\n');
hunks.push(h);
}
return hunks.length || !diff.trim() ? hunks : null;
}
/** Where a line of the reviewed base sits in the current base, or null when the base changed it. */
function mapper(base: ZeroHunk[]): (line: number) => number | null {
return line => {
let shift = 0;
for (const h of base) {
if (h.oldCount > 0 && line >= h.oldStart && line < h.oldStart + h.oldCount) return null;
const before = h.oldCount > 0 ? h.oldStart + h.oldCount - 1 < line : h.oldStart < line;
if (before) shift += h.newCount - h.oldCount;
}
return line + shift;
};
}
/** Whether a branch hunk at the reviewed base's position lands where the current branch hunk sits. */
function sameSpot(r: ZeroHunk, c: ZeroHunk, map: (line: number) => number | null): boolean {
if (r.oldCount > 0) {
for (let k = 0; k < r.oldCount; k++) if (map(r.oldStart + k) !== c.oldStart + k) return false;
return true;
}
let below = r.oldStart;
while (below > 0 && map(below) === null) below--;
let above = r.oldStart + 1;
for (let guard = 0; map(above) === null && guard < 100_000; guard++) above++;
const low = below > 0 ? map(below)! : 0;
return c.oldStart >= low && c.oldStart <= map(above)! - 1;
}
/** The verdict for one path the branch touches on either side (module header). */
export function comparePath(repoRoot: string, s: PatchSides, rel: string): PathVerdict {
const r3 = pathDiff(repoRoot, s.reviewedBase, s.reviewed, rel, 3);
const c3 = pathDiff(repoRoot, s.currentBase, s.current, rel, 3);
if (r3 === null || c3 === null || (!r3.trim() && !c3.trim())) return 'changed';
const modes = (d: string) => d.split('\n').filter(l => /^(old mode|new mode|new file mode|deleted file mode) /.test(l)).join('\n');
if (modes(r3) !== modes(c3)) return 'changed';
const ids = [patchId(repoRoot, r3), patchId(repoRoot, c3)];
if (ids[0] === null || ids[1] === null) return 'changed';
const r0 = zeroHunks(pathDiff(repoRoot, s.reviewedBase, s.reviewed, rel, 0) ?? '');
const c0 = zeroHunks(pathDiff(repoRoot, s.currentBase, s.current, rel, 0) ?? '');
if (ids[0] === ids[1] && r0 === null) return 'same';
const b0 = zeroHunks(pathDiff(repoRoot, s.reviewedBase, s.currentBase, rel, 0) ?? '');
if (!r0?.length || !c0 || !b0 || r0.length !== c0.length || r0.some((h, i) => h.body !== c0[i]!.body)) return 'changed';
const map = mapper(b0);
if (!r0.every((h, i) => sameSpot(h, c0[i]!, map))) return 'changed';
return ids[0] === ids[1] ? 'same' : 'context';
}
/**
* Whether the current base already holds the reviewed change to a path the branch no longer changes (GST-1399): a
* three-way merge of reviewedBase -> reviewed into currentBase leaves currentBase's bytes, as when the base absorbed the
* child's hunk. A post-review revert to the base's content leaves the reviewed change out of the base, so it is not held,
* and a reviewed mode change (executable bit, symlink) is held only when the base has the reviewed mode. A path missing on
* any side is not held.
*/
export function baseHolds(repoRoot: string, s: PatchSides, rel: string): boolean {
const blobs = [s.currentBase, s.reviewedBase, s.reviewed].map(ref => {
const r = spawnSync('git', ['cat-file', 'blob', `${ref}:${rel}`], { cwd: repoRoot, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return r.status === 0 ? r.stdout : null;
});
if (blobs.some(b => b === null)) return false;
const [mc, mb, mr] = [s.currentBase, s.reviewedBase, s.reviewed].map(ref => git(repoRoot, ['ls-tree', '--full-tree', ref, '--', rel])?.split(' ')[0]);
if (!mc || !mr || (mr !== mb && mr !== mc)) return false;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-patch-'));
try {
const files = blobs.map((b, i) => { const f = path.join(dir, String(i)); fs.writeFileSync(f, b!); return f; });
const merged = spawnSync('git', ['merge-file', '-p', '-q', ...files], { cwd: repoRoot, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return merged.status === 0 && merged.stdout.equals(blobs[0]!);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function hunkTexts(diff: string): string[] {
return diff.split(/^(?=@@ )/m).slice(1).map(h => h.replace(/^@@ [^@]*@@/, '@@'));
}
/** The most lines one packet section may hold; a path with a longer section takes the full loop instead. */
export const PACKET_SECTION_CAP = 200;
/** The most lines the base's change section may hold before its uncounted base lines count again (GST-1226). */
export const BASE_SECTION_CEILING = 2000;
/**
* The scoped-review packet: per context path, the hunks that differ between the two branch patches and the base's own
* change; per test path, its test hunks. `over` names every path with a section past PACKET_SECTION_CAP: one reviewer
* cannot be shown all of it, so the caller sends it to the full loop. The base's +/- lines, already merged at base, are
* shown but do not count toward the cap (GST-1226) until that section passes BASE_SECTION_CEILING lines.
*/
export function contextPacket(repoRoot: string, s: PatchSides, paths: readonly string[], tests: readonly string[] = [], bounded: Readonly<Record<string, BoundedKind>> = {}): { text: string; over: string[]; sha256: string } {
const over = new Set<string>();
let current = '';
const fence = (label: string, body: string, counts: (line: string) => boolean = () => true) => {
if (body.split('\n').filter(counts).length > PACKET_SECTION_CAP) over.add(current);
return `${label}\n\`\`\`diff\n${body.trimEnd()}\n\`\`\`\n`;
};
const late = Object.keys(bounded).sort();
const sides = `reviewed ${s.reviewed.slice(0, 12)} against ${s.reviewedBase.slice(0, 12)}; current ${s.current.slice(0, 12)} against ${s.currentBase.slice(0, 12)}.`;
const parts = [paths.length || !late.length
? `# Scoped review: base context moved next to the reviewed hunks\n\n${sides} The branch's +/- lines are unchanged; read whether the merge kept their meaning next to the base's edit. Report clean, or findings at the blocking floor.\n`
: `# Scoped review: edits that landed after the review bound\n\n${sides}\n`];
if (late.length) parts.push(`Bounded edits since review in ${late.map(p => `${p} (${bounded[p]})`).join(', ')}: each landed after the review bound, and its kind was proven from the diff; this one read replaces another review round (docs/review-policy.md#bounded-post-review-edits). Read every line under "Changed since review" as gate code. Audit prose is policy text agents follow: flag any change to what a gate, cap, reviewer, approval or exception does. A lanes edit maps files to tests: flag a glob that marks files covered by tests that do not exercise them. A bookkeeping row is an exemption or a limit: flag a row that moves an exemption onto code it should not cover, or raises a limit without a reason in the diff. Read them through the targeted brief at the end for money, gate, auth and secret wording. Report clean, or findings at the blocking floor.\n`);
if (tests.length) parts.push(`Test code changed since review in ${tests.join(', ')}: read whether each test still checks what it did (no weakened or bypassed assertion, early return, new exemption, skip or mock). Report clean, or findings at the blocking floor.\n`);
for (const rel of [...paths, ...tests, ...late]) {
current = rel;
const r = hunkTexts(pathDiff(repoRoot, s.reviewedBase, s.reviewed, rel, 3) ?? '');
const c = hunkTexts(pathDiff(repoRoot, s.currentBase, s.current, rel, 3) ?? '');
const b = hunkTexts(pathDiff(repoRoot, s.reviewedBase, s.currentBase, rel, 3) ?? '').join('');
parts.push(`## ${rel}\n`, ...(rel in bounded ? [fence('Changed since review', pathDiff(repoRoot, s.reviewed, s.current, rel, 3) ?? '')] : []), fence('Reviewed hunks', r.filter(h => !c.includes(h)).join('')), fence('Current hunks', c.filter(h => !r.includes(h)).join('')),
fence("The base's change", b, b.split('\n').length > BASE_SECTION_CEILING ? undefined : l => !/^[+-]/.test(l)));
}
if (late.length) parts.push(`## Targeted brief (${TARGETED_CLASSES.join(', ')})\n\n${packetBrief()}\n`);
const text = parts.join('\n');
return { text, over: [...over], sha256: createHash('sha256').update(text).digest('hex') };
}
/** The targeted brief with every class's block, as a bounded packet carries it; receipt write checks a packet holds it. */
export function packetBrief(): string {
return targetedBrief(fs.readFileSync(TARGETED_BRIEF_FILE, 'utf8'), TARGETED_CLASSES);
}
/**
* A test code file outside shared helpers and fixtures: `*.test.*`, `*.spec.*` and `*_test.go` anywhere, and the
* name-only conventions (`test_*.py`, `*Test.java`, `*Tests.swift`, …) only under a test directory, since product
* files can carry those names (`src/main/java/ABTest.java`). A change to one gets the scoped review, not the full loop.
*/
export function isTestCode(rel: string): boolean {
if (!matchesAny(CARRY_TEST_CODE_GLOBS, rel) || /(^|\/)(helpers|fixtures|__fixtures__|__snapshots__)\//.test(rel)) return false;
return /\.(test|spec)\.[^/]+$|_test\.go$/.test(rel) || /(^|\/)(test|tests|spec|__tests__|src\/test|[^/]+Tests)\//.test(rel);
}
/**
* Test paths whose change from..to needs review: any removed or changed line (a weakened assertion or a changed
* expected value carries no gate tag) except a literal-only rewrite, a gate edit lib/gate-diff tags (a skipped test, a
* snapshot, a relaxed gate value), or a diff the scan cannot read. The rest still get the scoped reader of their test
* hunks (lib/ship-release.ts route).
*/
export function gateEditedPaths(repoRoot: string, from: string, to: string, paths: readonly string[]): Set<string> | null {
const tagged = gateTaggedPaths(repoRoot, from, to, paths);
if (!tagged) return null;
const removed = paths.filter(p => {
const d = pathDiff(repoRoot, from, to, p, 0);
if (d === null || /^(Binary files |GIT binary patch)/m.test(d)) return true;
const at = d.search(/^@@ /m);
return at >= 0 && d.slice(at).split('\n').some(l => l.startsWith('-')) && !literalOnly(d, git(repoRoot, ['show', `${to}:${p}`]));
});
return new Set([...tagged, ...removed]);
}
/** Paths with a hunk lib/gate-diff tags, or a diff its scan cannot read; null when git cannot diff them. */
function gateTaggedPaths(repoRoot: string, from: string, to: string, paths: readonly string[]): Set<string> | null {
if (!paths.length) return new Set();
const diff = git(repoRoot, ['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', from, to, '--', ...paths]);
if (diff === null) return null;
const scan = scanCandidate({ files: parseUnifiedDiff(diff), commits: [], project: EMPTY_PROJECT_CONFIG, specIsTestPath: true, candidate: 'route', artifact: '' });
return new Set([...scan.hunks.filter(h => h.tags.length > 0).map(h => h.path), ...scan.unsupportedPaths.map(u => u.path)]);
}
const CHECK_CALL = /\b(expect|assert\w*|should|skip|only|todo|each|test|it|describe|bench)\b|\.to[A-Z]/;
const QUOTED = /(['"])(?:\\.|(?!\1)[^\\\n])*\1/g;
const CODE_IN_STRING = /#\{|\$\{|\b[fFrRbB]{1,2}['"]/;
const STATEMENT_END = /(;|\}|=>\s*\{|\)\s*\{)\s*$/;
/**
* A zero-context diff whose every hunk swaps lines one for one, each pair the same once quoted strings are emptied, in
* statements with no assertion or test call (the changed line and the lines above it back to the previous statement's end in
* `after`, blank lines included, outside any template literal): a test literal changed (a fixture value the secrets scan flagged, GST-1276 instance 5), never a check or an
* expected value. Template literals, interpolated strings and prefixed (f, r, b) strings do not count, since they can hold code.
*/
function literalOnly(diff: string, after: string | null): boolean {
const hunks = zeroHunks(diff);
if (!hunks?.length || after === null) return false;
const text = after.split('\n');
const statement = (n: number) => { const out = [text[n - 1] ?? '']; for (let k = n - 2; k >= 0 && !STATEMENT_END.test(text[k]!); k--) out.push(text[k]!); return out; };
const inTemplate = (n: number) => (text.slice(0, n - 1).join('\n').match(/`/g)?.length ?? 0) % 2 === 1;
return hunks.every(h => {
const lines = h.body.split('\n');
const minus = lines.filter(l => l.startsWith('-')).map(l => l.slice(1));
const plus = lines.filter(l => l.startsWith('+')).map(l => l.slice(1));
const bare = (l: string) => l.replace(QUOTED, '$1$1');
return minus.length === plus.length && minus.every((m, i) => m !== plus[i] && !CODE_IN_STRING.test(m + plus[i]) && bare(m) === bare(plus[i]!)
&& !CHECK_CALL.test(bare(m)) && !inTemplate(h.newStart + i) && !statement(h.newStart + i).some(l => CHECK_CALL.test(l.replace(QUOTED, '$1$1'))));
});
}
export type BoundedKind = 'audit prose' | 'lanes' | 'bookkeeping';
/** The most lines (added plus removed) one bounded path may change since review. */
export const BOUNDED_LINES = 20;
export interface BoundedInput {
sides?: PatchSides; reviewed: string; now: string; base: string; candidates: readonly string[];
/** The Step 14.5 post-audit snapshot's `hashes` (path -> blob), or null when finish could not read it. */
audited: Readonly<Record<string, string | null>> | null;
/** origin/<base>'s ship-policy `bookkeeping` globs. */
bookkeeping: readonly string[];
}
export interface BoundedRefusal { path: string; reason: string; fix: string }
/** Policy prose an agent reads that a docs audit may correct: docs/*.md and the root AGENTS.md or CLAUDE.md, never a gate path. */
export const auditProse = (p: string) => (/^docs\/[^/]+\.md$/.test(p) || /^(AGENTS|CLAUDE)\.md$/.test(p)) && !isGatePath(p);
/**
* The post-review edits finish may send to one scoped reader instead of Steps 5–11.5 (GST-1276, GST-1311), each kind proven
* from the diff: audit prose whose bytes now are the ones the post-audit snapshot recorded; a .gstack/pregate.json change that
* only adds lanes over the reviewed copy (pregate's own headPolicyDiff); a file the base policy declares bookkeeping. Every
* kind needs both base sides, at most BOUNDED_LINES changed lines, readable text and no gate-scan tag. A candidate of a kind
* that fails a proof is refused with the reason and the fix; any other path is not a candidate.
*/
export function boundedPaths(repoRoot: string, i: BoundedInput): { kinds: Record<string, BoundedKind>; refused: BoundedRefusal[] } {
const kinds: Record<string, BoundedKind> = {};
const refused: BoundedRefusal[] = [];
const full = 'take Steps 5–11.5';
const kindOf = (p: string): BoundedKind | null => auditProse(p) ? 'audit prose' : p === PREGATE_CONFIG_PATH ? 'lanes' : matchesAny([...i.bookkeeping], p) ? 'bookkeeping' : null;
const candidates = i.candidates.filter(p => kindOf(p));
const tagged = candidates.length ? gateTaggedPaths(repoRoot, i.reviewed, i.now, candidates) : new Set<string>();
const blob = (tree: string, p: string) => git(repoRoot, ['rev-parse', '--verify', '--quiet', `${tree}:${p}`])?.trim() ?? null;
for (const p of candidates) {
const kind = kindOf(p)!;
const refuse = (reason: string, fix: string) => refused.push({ path: p, reason, fix });
if (!i.sides) { refuse('needs --reviewed-base', 'rerun finish with --reviewed-base <the merge base saved with the reviewed tree>'); continue; }
const stat = git(repoRoot, ['--literal-pathspecs', 'diff', '--numstat', '--no-renames', i.reviewed, i.now, '--', p])?.trim().split(/\s+/) ?? [];
const lines = Number(stat[0]) + Number(stat[1]);
if (!Number.isFinite(lines)) { refuse('is binary or unreadable', full); continue; }
if (lines > BOUNDED_LINES) { refuse(`changed ${lines} lines since review (at most ${BOUNDED_LINES})`, full); continue; }
if (!tagged || tagged.has(p)) { refuse(tagged ? 'carries a hunk the gate scan tagged' : 'could not be gate-scanned', full); continue; }
if (kind === 'bookkeeping' && (isGatePath(p) || /^\.(gstack|github)\//.test(p))) { refuse('is a gate path or under .gstack/ or .github/, never bookkeeping', full); continue; }
if (kind === 'audit prose') {
const recorded = i.audited?.[p];
if (!recorded) { refuse('is not a doc the Step 14.5 audit snapshot recorded', 'include it in the audit (--docs), or ' + full); continue; }
if (blob(i.now, p) !== recorded) { refuse('is not the bytes the post-audit snapshot recorded (edited after the audit)', 'restore the audited bytes, or ' + full); continue; }
}
if (kind === 'lanes') {
const [refText, headText] = [i.reviewed, i.now].map(tree => git(repoRoot, ['show', `${tree}:${p}`]));
const [ref, head] = [refText, headText].map(t => t === null ? null : parseConfig(t, CHECK_IDS));
if (!ref || !head || ref.errors.length || head.errors.length) { refuse('does not parse at both the reviewed and the current tree', full); continue; }
const canon = (v: unknown): string => Array.isArray(v) ? `[${v.map(canon)}]` : v && typeof v === 'object' ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canon((v as Record<string, unknown>)[k])}`)}}` : JSON.stringify(v);
const [was, now] = [refText!, headText!].map(t => JSON.parse(t) as Record<string, unknown>);
const moved = [...new Set([...Object.keys(was), ...Object.keys(now)])].find(k => k !== 'dependencies' && canon(was[k]) !== canon(now[k]));
if (moved) { refuse(`changes ${moved}; a lanes edit changes only dependencies`, full); continue; }
const d = headPolicyDiff(ref.config, head.config, i.base);
if (d.loosens.length) { refuse(`loosens the reviewed policy: ${d.loosens[0]}`, 'restore what it removed (a [] declaration lands on the base first), or ' + full); continue; }
if (!Object.keys(d.adds).length) { refuse('adds no lane', full); continue; }
}
kinds[p] = kind;
}
return { kinds, refused };
}
export interface ScopedReview { paths: string[]; reviewer: string; verdict: 'clean'; head?: string; tree?: string; packet_sha256?: string }
/** A clean scoped review that covers every context path at this head or tree, or why it does not stand. */
export function readScopedReview(file: string, bind: { head?: string; tree?: string }, paths: readonly string[]): { ok: true; review: ScopedReview } | { ok: false; message: string } {
let v: any;
try { v = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return { ok: false, message: `${file} is not readable JSON (${(e as Error).message.split('\n')[0]})` }; }
if (!v || typeof v !== 'object' || Array.isArray(v)) return { ok: false, message: `${file} must be { paths, reviewer, verdict, head|tree }` };
if (v.verdict !== 'clean') return { ok: false, message: `the scoped review is ${JSON.stringify(v.verdict)}, not clean: fix its findings and run the full loop` };
if (typeof v.reviewer !== 'string' || !v.reviewer.trim()) return { ok: false, message: 'the scoped review names no reviewer' };
for (const key of ['head', 'tree'] as const) {
if (bind[key] && v[key] !== bind[key]) return { ok: false, message: `the scoped review is for ${key} ${String(v[key] ?? 'none').slice(0, 12)}, not ${bind[key]!.slice(0, 12)}` };
}
const covered = Array.isArray(v.paths) ? v.paths.filter((p: unknown) => typeof p === 'string') : [];
const missing = paths.filter(p => !covered.includes(p));
if (missing.length) return { ok: false, message: `the scoped review does not cover ${missing.slice(0, 5).join(', ')}` };
return { ok: true, review: { paths: covered, reviewer: v.reviewer.trim(), verdict: 'clean', ...(v.head ? { head: v.head } : {}), ...(v.tree ? { tree: v.tree } : {}), ...(typeof v.packet_sha256 === 'string' ? { packet_sha256: v.packet_sha256 } : {}) } };
}