fix(review): receipt write gate-scans whenever the base declares review_exclude_paths; collectLive never re-qualifies origin/<base> (#3852 /ship red-team#1, outside-voice#1)

This commit is contained in:
garrytan committed 2026-11-02 13:16:09 +00:00
1 parent 557e5c4c5f
commit d7ad64eae4
4 files changed
+31 -4

No files matched your search

+3 -2
View File
@@ -255,10 +255,11 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings });
}
/** The working tree against the merge base with origin/<base> (or <base>), untracked files counted per path; the registry is the base ref's commit. */
/** The working tree against the merge base with origin/<base> (or <base>; a remote-qualified base reads refs/remotes/<base> only), untracked files counted per path; the registry is the base ref's commit. */
export function collectLive(root: string, base: string): Collected {
const errors: DepthError[] = [];
const ref = [`refs/remotes/origin/${base}`, `refs/heads/${base}`].find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base;
const qualified = base.startsWith('origin/') || git(root, ['remote']).out.split('\n').some(r => r && base.startsWith(`${r}/`));
const ref = (qualified ? [`refs/remotes/${base}`] : [`refs/remotes/origin/${base}`, `refs/heads/${base}`]).find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base;
const registry = loadRegistryAt(root, ref);
const mb = git(root, ['merge-base', ref, 'HEAD']);
if (!mb.ok) {
+1 -1
View File
@@ -96,7 +96,7 @@ export function receiptReviewAtHead(repoRoot: string, base: string, binDir: stri
const declares = declaresExclude(repoRoot, ref, sha);
let tagged: number | null = 0;
let paths: Set<string> | null = declares ? new Set() : null;
if (gateScanNeeded(c.paths, c.root)) {
if (declares || gateScanNeeded(c.paths, c.root)) {
const atHead = git(repoRoot, ['rev-parse', 'HEAD']).out === head && git(repoRoot, ['diff', '--quiet', 'HEAD', '--']).ok;
const g = atHead ? runScriptSync(path.join(binDir, 'gstack-gate-diff'), [baseRev, ...(declares ? ['--format', 'jsonl'] : [])], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 }) : null;
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g?.stdout ?? '');
+12
View File
@@ -112,6 +112,18 @@ describe('collectLive: the working tree', () => {
expect(c.paths).toEqual(['docs/a.md', 'src/a.ts', 'src/new.ts']);
expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 20 + 10 });
});
// Value: protects=a remote-qualified base reads that remote's tracking branch by its full name, so a fetched branch called
// origin/main cannot become the merge base (an empty diff) or the registry and policy source; fails_when=collectLive tries
// refs/remotes/origin/<base> first for origin/main (/ship review of #3852, outside-voice#1); why_new=every other case names a
// bare base; seam=none; no_claim=remotes other than origin
test('a fetched branch named origin/main never stands in for origin/main', () => {
const r = repo({});
r.git('update-ref', 'refs/remotes/origin/main', r.baseSha);
const head = r.commit({ 'src/a.ts': lines(3) });
r.git('update-ref', 'refs/remotes/origin/origin/main', head);
const c = collectLive(r.dir, 'origin/main');
expect([c.mergeBase, c.registry.sha, c.paths]).toEqual([r.baseSha, r.baseSha, ['src/a.ts']]);
});
test('no merge base is a collector error the CLI refuses on', () => {
const r = repo({});
expect(collectLive(r.dir, 'no-such-branch').errors.map(e => e.code)).toEqual(['numstat_failed']);
+15 -1
View File
@@ -11,7 +11,7 @@ import { EXIT } from '../lib/headless-artifacts';
import { KEEP_ARG_MAX_BYTES, readSelection, reviewExclude, tooLargeLine, unreadableLine } from '../lib/review-exclude';
import { collectCommit, collectLive } from '../lib/review-collect';
import { extractReceipt } from '../lib/ship-receipt';
import { bindShippedReceipt } from '../lib/ship-receipt-depth';
import { bindShippedReceipt, receiptReviewAtHead } from '../lib/ship-receipt-depth';
import { REVIEW_EXCLUDE_KEY_VERSION, isUnavailable } from '../lib/ship-policy';
import { loadPolicyAt } from '../lib/ship-policy-at';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
@@ -253,6 +253,20 @@ describe('the receipt binds the selection the reviewers read', () => {
const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
const writeReceipt = (repo: FixtureRepo, body: string, extra: string[]) =>
runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]);
// Value: protects=a docs-only range whose only file sits under a base pattern and carries a gate tag binds the same selection at
// plan and receipt write, so the receipt can be written; fails_when=receiptReviewAtHead skips the gate scan when the paths are
// docs-class and excludes the file the plan kept as gate-tagged (/ship review of #3852, red-team#1: digests 652d0aa8 vs
// 6f33bd56, RECEIPT_INVALID with no fix); why_new=every other receipt case has a code path in its range; seam=gstack-review-plan
// --json and receiptReviewAtHead on one head; no_claim=the gate scan's own tag rules
test('a docs-only range with a gate-tagged file under a pattern binds the plan\'s digest at receipt write', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-docs-', baseFiles: { '.gstack/ship-policy.json': policyText(['testdata/**']) } });
repos.push(repo);
const head = commit(repo, { 'testdata/expected.txt': 'expected output\n' });
const planned = JSON.parse(runBin(repo, 'gstack-review-plan', ['--base', 'main', '--json']).out).review_exclude;
expect(planned.kept_files).toEqual([{ path: 'testdata/expected.txt', reason: 'gate-tagged' }]);
const { exclude } = receiptReviewAtHead(repo.work, 'main', path.join(import.meta.dir, '..', 'bin'), head);
expect(exclude.state === 'active' ? exclude.record.digest : exclude.state).toBe(planned.digest);
});
// Value: protects=a receipt names the selection the rendered briefs read, a base merge that leaves the policy alone still
// carries, and a policy change on the base (adding or removing a pattern) needs a new review, not a write; fails_when=write
// records a digest the briefs never named, carry compares the base sha, or a removed exclusion clears by writing the old