Merge main bf2bcf14 into gsta258-l4-pr-ci-reuse (rule 19 grant)

docs/pregate.md keeps the ci-reuse-pr subsection and main's Owner-granted flaky
exception section (#3911), in that order.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 11:19:03 +00:00
commit b9a428721f
52 files changed
+2445 -132

No files matched your search

+22
View File
@@ -223,6 +223,28 @@ describe('gstack-diff-scope', () => {
expect(scope.SCOPE_AUTH).toBe('true');
});
// Value: protects=auth matches as a word or path segment, so docs about authors stay out of the gate class;
// fails_when=the auth match is a bare substring again (author-spec.md, AUTHORS, authority.ts raise SCOPE_AUTH);
// why_new=GST-1065 (author-spec.md raised a docs bundle to gate class with four full readers); seam=bin/gstack-diff-scope
for (const file of ['docs/author-spec.md', 'AUTHORS', 'lib/authority.ts', 'docs/authored-by.md', 'src/coauthors.ts', 'AUTHORS.md']) {
test(`${file} is not an auth path`, () => {
expect(runScope(createRepo([file])).SCOPE_AUTH).toBe('false');
});
}
// Value: protects=real auth paths still raise SCOPE_AUTH after the word-boundary fix (negative controls for GST-1065);
// fails_when=the boundary drops a segment, a separator, a camelCase name, an authn/authz/oauth spelling or a glued token (auth0, basicauth, nextauth);
// why_new=GST-1065 and its review (the first word-only matcher dropped glued tokens the old glob caught); seam=bin/gstack-diff-scope
for (const file of ['src/auth.ts', 'app/auth/login.rb', 'lib/oauth.ts', 'src/oauth2/token.ts', 'lib/authn/check.go', 'src/authz.py', 'src/authService.ts',
'src/useAuth.ts', 'lib/OAuthClient.ts', 'lib/authentication.rb', 'lib/authorization_policy.rb', 'src/Authorize.tsx', 'config/AUTH_KEYS.md', 'pkg/auth-middleware.go',
'lib/auth0.ts', 'src/basicauth.go', 'src/authguard.ts', 'lib/reauth.ts', 'pages/api/nextauth.ts', 'src/authtoken.ts', 'lib/preauth.rb', 'src/HTTPAuth.ts',
'src/unauthenticated.ts', 'lib/reauthentication.ts', 'src/deauthorize.ts', 'app/oauth2client.py', 'api/HTTPAuthorization.ts',
'src/nextauthmiddleware.ts', 'src/reauthn.ts', 'src/noauthrequired.ts']) {
test(`${file} is an auth path`, () => {
expect(runScope(createRepo([file])).SCOPE_AUTH).toBe('true');
});
}
test('detects config via bun.lock (Bun v1.2+ text lockfile)', () => {
const dir = createRepo(['bun.lock']);
const scope = runScope(dir);
+28
View File
@@ -935,6 +935,34 @@ describe('scan', () => {
expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: main (not a repository)`);
expect(r.code).toBe(1); // a base that cannot be diffed is a failed target, never a silent clean scan
});
test.skipIf(!POSIX)('--changed main reads origin/main over a stale local main (GST-1036)', () => {
// Value: protects=design review scans only the branch's frontend files; fails_when=a bare --changed base diffs against the stale local main
// and scans a stylesheet only upstream changed; why_new=GST-1036 (ship review-army passes a bare <base>); seam=bin/gstack-design-detect.ts
const bare = path.join(SANDBOX, 'stale-origin.git');
const work = path.join(SANDBOX, 'stale-work');
const other = path.join(SANDBOX, 'stale-other');
const log = path.join(SANDBOX, 'argv-stale.log');
try {
git(SANDBOX, 'clone', '-q', '--bare', REPO, bare);
git(SANDBOX, 'clone', '-q', bare, work);
git(SANDBOX, 'clone', '-q', bare, other);
for (const d of [work, other]) { git(d, 'config', 'user.email', 't@example.com'); git(d, 'config', 'user.name', 't'); }
fs.writeFileSync(path.join(other, 'src', 'styles.css'), '.hero { color: blue }\n');
git(other, 'commit', '-qam', 'upstream');
git(other, 'push', '-q', 'origin', 'HEAD:main');
git(work, 'fetch', '-q', 'origin');
git(work, 'checkout', '-q', '-b', 'feature', 'origin/main');
fs.writeFileSync(path.join(work, 'src', 'components', 'Card.tsx'), 'export const Card = () => <p />;\n');
git(work, 'commit', '-qam', 'change');
const r = run(['scan', '--changed', 'main', '--format', 'gstack'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log }, cwd: work });
const argv = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]).argv as string[];
expect(argv.slice(2).map(a => path.relative(fs.realpathSync(work), a)).sort()).toEqual(['src/components/Card.tsx']);
expect(r.err).toContain('gstack-design-detect: local main is behind origin/main; base is origin/main');
} finally {
for (const p of [bare, work, other, log]) fs.rmSync(p, { recursive: true, force: true });
}
});
});
describe('design-review REPORT_DIR agrees with the allow-list', () => {
+317
View File
@@ -0,0 +1,317 @@
/**
* gstack-pregate clear --flaky-exception (GST-1096): a red GitHub Actions run clears a requires-remote obligation only
* when every failure is named from the run's own shard results and logs, each named file is outside the PR's diff and
* its import closure, at most three cases, none paid or a security scan, and an owner's gstack-approvals row binds the
* repository, PR, head, tree, lane, run, attempt and cases. verify prints pass-with-exception, ready prints
* FLAKY_EXCEPTION and still needs green CI (pregate only), and the receipt records it. The run API is a fake; trees
* come from the fixture repository's git.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, type PregateFile } from '../lib/headless-artifacts';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, clearObligation, runPregate, verifyPregate, type ClearDecision, type RunCheck } from '../lib/pregate/index';
import { exceptionBinding, type FailureDeps, type GrantRow } from '../lib/pregate/flaky-exception';
import type { ApiJob, ApiRun, RunDeps, RunProfile } from '../lib/run-evidence';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
import { cleanup as cleanupQueue, makeFixtureRepo as makeQueueRepo, makePr, runBin as runQueueBin } from './helpers/restamp-fixture';
import { ghQueueShim } from './helpers/ship-queue-fixture';
import { shippedBody } from './helpers/ship-queue-train-fixture';
const repos: FixtureRepo[] = [];
const queueRepos: Array<ReturnType<typeof makeQueueRepo>> = [];
afterEach(() => {
while (repos.length) cleanup(repos.pop()!);
while (queueRepos.length) cleanupQueue(queueRepos.pop()!);
});
const LANE = 'pregate.json/remote_gate';
const NOW = new Date('2026-10-29T12:00:00Z');
const PROFILE_NAME = 'ci-reuse/free-tests';
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-plan', 'typecheck', 'free-tests'], artifact: 'tested-tree' };
const GATE_RUNS = { profiles: [PROFILE_NAME], inventory: 'true' };
const FLAKE = 'test/retry.test.js::retries three times';
const WORDS = 'yes, accept the retry timing flake for this head';
function fixture(): FixtureRepo {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js' } });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'the real change');
return repo;
}
async function pregateAt(repo: FixtureRepo): Promise<PregateFile> {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]);
return file;
}
interface Shard { files: string[]; failures?: Array<[string | null, string]>; status?: 'passed' | 'failed' | 'timed-out'; unattributed?: number; retry?: { files: string[]; failures: Array<[string, string]> }; log?: string | null }
interface RunOver { conclusion?: string; jobs?: (jobs: ApiJob[]) => ApiJob[]; missing?: string[]; pull?: { head_sha: string; state: string; head_repo: string | null }; newer?: boolean; attemptAfter?: number; api?: Partial<ApiRun> }
const DEFAULT_SHARDS: Shard[] = [
{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times']] },
{ files: ['test/b.test.ts', 'test/util.test.js', 'test/config-read.test.js', 'test/tool-cli.test.js'] },
];
function logFor(s: Shard, names: Array<[string | null, string]>): string {
const lines: string[] = [];
for (const f of s.files) {
lines.push(`${f}:`);
for (const [file, name] of names) if (file === f) lines.push(`(fail) ${name} [5005.00ms]`);
}
lines.push(`Ran ${s.files.length * 3} tests across ${s.files.length} files. [12.00s]`);
return lines.join('\n') + '\n';
}
/** Run 400: a red workflow_dispatch Free Tests run on `head` whose shard artifacts and logs say what failed. */
function redRun(repo: FixtureRepo, head: string, shards: Shard[] = DEFAULT_SHARDS, over: RunOver = {}): RunCheck & { failures: FailureDeps } {
const plan = { version: 1, revision: head, id: 'plan-1', shards: shards.map((s, i) => ({ shard: i + 1, files: s.files, predictedMs: 1000 })) };
const statusOf = (s: Shard) => s.status ?? (s.failures?.length || s.unattributed ? 'failed' : 'passed');
const finalFailed = (s: Shard) => statusOf(s) !== 'passed' && !(s.retry && s.retry.failures.length === 0);
const outcome = (s: Shard, i: number, files: string[], failures: Array<[string | null, string]>, status: string) => ({
shard: i + 1, files, status, exitCode: status === 'passed' ? 0 : 1, elapsedMs: 1000, groupPid: null,
summary: { testsRan: files.length * 3, filesRan: files.length, sawTerminalSummary: status !== 'timed-out' },
failingFiles: [...new Set(failures.map(([f]) => f).filter((f): f is string => !!f))], unattributedFailures: s.unattributed ?? 0,
failures: failures.map(([file, testName]) => ({ file, testName })),
});
const artifacts: Record<string, Record<string, string>> = { 'free-plan': { 'free-plan.json': JSON.stringify(plan) } };
shards.forEach((s, i) => {
const retry = s.retry ? outcome({ files: s.retry.files }, i, s.retry.files, s.retry.failures, s.retry.failures.length ? 'failed' : 'passed') : null;
artifacts[`free-result-${i + 1}`] = { [`shard-${i + 1}.json`]: JSON.stringify({ planId: 'plan-1', revision: head, outcome: outcome(s, i, s.files, s.failures ?? [], statusOf(s)), retry }) };
if (statusOf(s) !== 'passed' && s.log !== null) artifacts[`free-test-shard-logs-${i + 1}`] = { [`gstack-free-test-${i + 1}.log`]: s.log ?? logFor(s, [...(s.failures ?? []), ...(s.retry?.failures ?? [])]) };
});
for (const m of over.missing ?? []) delete artifacts[m];
const anyFailed = shards.some(finalFailed);
const at = '2026-10-29T10:30:00Z';
const baseJobs: ApiJob[] = [
{ name: 'reuse', conclusion: 'skipped', completed_at: at },
{ name: 'free-plan', conclusion: 'success', completed_at: at },
{ name: 'typecheck', conclusion: 'success', completed_at: at },
...shards.map((s, i) => ({ name: `free-suite (${i + 1})`, conclusion: finalFailed(s) ? 'failure' : 'success', completed_at: at })),
{ name: 'free-tests', conclusion: anyFailed ? 'failure' : 'success', completed_at: at },
];
const jobs = over.jobs ? over.jobs(baseJobs) : baseJobs;
let reads = 0;
const api = (): ApiRun => ({
id: 400, run_attempt: reads++ > 0 && over.attemptAfter ? over.attemptAfter : 1, status: 'completed', conclusion: over.conclusion ?? (anyFailed ? 'failure' : 'success'), event: 'workflow_dispatch', head_sha: head,
path: '.github/workflows/free-tests.yml', created_at: '2026-10-29T10:00:00Z', updated_at: at, repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' }, ...over.api,
});
const deps: RunDeps = {
run: id => { if (id !== 400) throw new Error('HTTP 404'); return api(); },
runsForHead: () => [{ ...api(), run_attempt: 1 }, ...(over.newer ? [{ ...api(), id: 401, run_attempt: 1, created_at: '2026-10-29T11:00:00Z', run_started_at: '2026-10-29T11:00:00Z' }] : [])],
jobs: () => jobs,
artifact: () => { throw new Error('run 400 has 0 unexpired artifacts named tested-tree'); },
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
};
const failures: FailureDeps = {
artifactFiles: (_id, name) => { const a = artifacts[name]; if (!a) throw new Error(`run 400 has 0 unexpired artifacts named ${name}`); return a; },
pull: () => over.pull ?? { head_sha: head, state: 'open', head_repo: 'fx/fx' },
};
return { repo: 'fx/fx', deps, profiles: { [PROFILE_NAME]: PROFILE }, gateRuns: GATE_RUNS, now: NOW, inventory: () => ['test/a.test.ts', 'test/b.test.ts'], failures };
}
const msg = (d: ClearDecision) => ('refused' in d ? `${d.refused}: ${d.message}${d.fix ? `; fix: ${d.fix}` : ''}` : `cleared ${d.receipt}`);
/** The scope the grant-needed refusal names, as an approvals row the owner granted. */
function grantFor(d: ClearDecision, over: Partial<GrantRow> = {}): (id: string) => GrantRow | null {
const scope = /--scope "(flaky-exception:[0-9a-f]{64})"/.exec(msg(d))?.[1];
if (!scope) throw new Error(`no grant scope in: ${msg(d)}`);
return id => (id === 'a-1' ? { id, outcome: 'approved', scope, words: WORDS, by: 'Garry', ...over } : null);
}
describe.skipIf(process.platform === 'win32')('clear --flaky-exception: an owner-granted exception for named flaky cases', () => {
test('a red run whose only failure is the named case clears the gate once the bound grant exists; verify reports pass-with-exception', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const check = redRun(repo, head);
const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work);
expect(msg(asked)).toMatch(/^PREGATE_RECEIPT_INVALID: FLAKY_EXCEPTION: refused reason=grant_needed — .*test\/retry\.test\.js::retries three times.*; fix: .*gstack-approvals add --ask '.*' --words "<the owner's exact words>" --by "<owner>" --scope "flaky-exception:[0-9a-f]{64}" --source pregate-flaky-exception/);
const d = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7, grant: 'a-1', grantRow: grantFor(asked) } }, repo.work);
if ('refused' in d) throw new Error(d.message);
expect(d.receipt).toBe(`remote-gate tree=${file.tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`);
expect(d.exception).toMatchObject({ lane: LANE, repo: 'fx/fx', pr: 7, head, tree: file.tree, runs: ['400/1'], cases: [FLAKE], grant: 'a-1', by: 'Garry', words: WORDS });
const cleared = clearObligation(file, LANE, d.receipt, 'test', d.substitution, d.exception);
expect(cleared.verdict).toBe('pass-with-exception');
expect(cleared.exceptions).toEqual([d.exception!]);
const v = verifyPregate(repo.work, cleared, { tree: file.tree } as any);
expect(v).toMatchObject({ ok: true });
expect(v.message).toContain(`flaky exception ${FLAKE} (grant a-1, by Garry, run 400/1)`);
const tampered = (e: Partial<NonNullable<PregateFile['exceptions']>[number]>) => verifyPregate(repo.work, { ...cleared, exceptions: [{ ...cleared.exceptions![0]!, ...e }] }, { tree: file.tree } as any);
expect(tampered({ head: 'f'.repeat(40) })).toMatchObject({ ok: false, code: 'PREGATE_REMOTE_UNCLEARED' });
expect(tampered({ cases: [FLAKE, 'test/b.test.ts::other'] }).message).toContain('binding does not match');
expect(verifyPregate(repo.work, { ...cleared, exceptions: [] }, { tree: file.tree } as any).message).toContain('no exceptions entry');
const plain = clearObligation(structuredClone(cleared), LANE, `remote-gate tree=${file.tree.slice(0, 12)} tests=18 runs=401/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`, 'test');
expect([plain.exceptions, plain.verdict]).toEqual([undefined, 'requires-remote cleared']);
}, 120_000);
test('the grant binds the head, PR, run and cases: another scope, an unapproved row, an agent\'s words or an unknown id refuse', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const check = redRun(repo, head);
const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work);
const run = (grantRow: (id: string) => GrantRow | null, grant = 'a-1') => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7, grant, grantRow } }, repo.work));
const otherHead = exceptionBinding({ lane: LANE, repo: 'fx/fx', pr: 7, head: 'e'.repeat(40), tree: file.tree, runs: ['400/1'], cases: [FLAKE] });
expect(run(grantFor(asked, { scope: otherHead }))).toContain('reason=grant_mismatch');
expect(run(grantFor(asked, { outcome: 'pending' }))).toContain('reason=grant_not_approved');
expect(run(grantFor(asked, { by: 'agent:jam_123' }))).toContain('reason=grant_not_owner');
expect(run(grantFor(asked), 'a-2')).toContain('reason=grant_missing');
}, 120_000);
test('every failure must be named: an extra failure, a case that did not fail, more than three cases, an unattributed failure refuse', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const clear = (check: RunCheck, cases = [FLAKE]) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases, pr: 7 } }, repo.work));
const two: Shard[] = [{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'other']] }, DEFAULT_SHARDS[1]!];
expect(clear(redRun(repo, head, two))).toContain('reason=unnamed_failure — run 400 attempt 1 failed test/a.test.ts::other, which no --flaky-exception names');
expect(clear(redRun(repo, head), [FLAKE, 'test/a.test.ts::passed fine'])).toContain('reason=case_not_failed — test/a.test.ts::passed fine did not fail in run 400');
expect(clear(redRun(repo, head), ['a.ts::1', 'b.ts::2', 'c.ts::3', 'd.ts::4'])).toBe('usage: --flaky-exception names at most 3 cases; got 4');
expect(clear(redRun(repo, head), ['no-separator'])).toContain('usage: --flaky-exception takes "<test file>::<case>"');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, unattributed: 1 }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1: 1 failure(s) the runner could not attribute to a test');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, failures: [[null, 'retries three times']] }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure');
expect(clear(redRun(repo, head, [{ files: DEFAULT_SHARDS[0]!.files }, DEFAULT_SHARDS[1]!]))).toContain('reason=run_green');
}, 120_000);
test('every job, shard and log is reconciled: a failed non-shard job, a missing result or log, a timed-out shard, a log failure the result omits refuse', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const clear = (check: RunCheck) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work));
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'failure' } : j) }))).toContain('reason=unnamed_failure — run 400 attempt 1\'s typecheck job concluded failure; only test-shard failures can be named');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => j.name === 'reuse' ? { ...j, conclusion: 'cancelled' } : j) }))).toContain('reason=unnamed_failure — run 400 attempt 1\'s reuse job concluded cancelled');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.filter(j => j.name !== 'free-suite (2)') }))).toContain('reason=shard_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { missing: ['free-result-2'] }))).toContain('reason=shard_incomplete — shard 2 has no readable free-result-2');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: null }, DEFAULT_SHARDS[1]!]))).toContain('reason=logs_missing — shard 1 failed and has no readable free-test-shard-logs-1');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, status: 'timed-out' }, DEFAULT_SHARDS[1]!]))).toContain('reason=shard_incomplete — shard 1 timed out');
const extraInLog = logFor(DEFAULT_SHARDS[0]!, [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'hidden']]);
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: extraInLog }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1\'s log names test/a.test.ts::hidden, which its result does not');
expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: logFor(DEFAULT_SHARDS[0]!, [['test/retry.test.js', 'retries three times']]) + '# Unhandled error between tests\n' }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1\'s log shows 1 unhandled error(s) between tests');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { conclusion: 'cancelled' }))).toContain('reason=run_not_failure');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { newer: true }))).toContain('reason=newer_run');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { attemptAfter: 2 }))).toContain('reason=run_changed');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { repository: { full_name: 'other/fx' } } }))).toContain('reason=other_repository');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { head_repository: { full_name: 'fork/fx' } } }))).toContain('reason=fork_run');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { path: '.github/workflows/evals.yml' } }))).toContain('reason=workflow_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { event: 'push' } }))).toContain('reason=event_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { status: 'in_progress' } }))).toContain('reason=run_in_progress');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => ({ ...j, completed_at: '2026-10-27T10:30:00Z' })) }))).toContain('reason=run_stale');
}, 120_000);
test('a red run that moves while the clear is assembled refuses, and the printed grant command quotes case names for the shell', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const check = redRun(repo, head);
const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work);
let rerun = false;
const run = check.deps.run;
const moving: RunCheck = { ...check, deps: { ...check.deps, run: id => (rerun ? { ...run(id), run_attempt: 2, status: 'in_progress' } : run(id)) }, inventory: () => { rerun = true; return ['test/a.test.ts', 'test/b.test.ts'] } };
expect(msg(clearDecision(file, LANE, { runs: ['400'], evidence: moving, flaky: { cases: [FLAKE], pr: 7, grant: 'a-1', grantRow: grantFor(asked) } }, repo.work))).toContain('reason=run_changed — run 400 moved to attempt 2 (in_progress)');
const name = "times out $(touch pwned) `id` it's";
const shard: Shard[] = [{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', name]] }, DEFAULT_SHARDS[1]!];
const fix = msg(clearDecision(file, LANE, { runs: ['400'], evidence: redRun(repo, head, shard), flaky: { cases: [`test/retry.test.js::${name}`], pr: 7 } }, repo.work));
const ask = /--ask ('(?:[^']|'\\'')*') --words/.exec(fix)?.[1];
expect(ask).toBeDefined();
const echoed = Bun.spawnSync(['bash', '-c', `printf %s ${ask}`], { cwd: repo.work, timeout: 10_000 });
expect(echoed.stdout.toString()).toContain(`test/retry.test.js::${name}`);
expect(fs.existsSync(path.join(repo.work, 'pwned'))).toBe(false);
}, 120_000);
test('a retry that passed is a recovered flake; a retry that failed again keeps only its own failures', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const shard1: Shard = { files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'recovered']], retry: { files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times']] } };
expect(msg(clearDecision(file, LANE, { runs: ['400'], evidence: redRun(repo, head, [shard1, DEFAULT_SHARDS[1]!]), flaky: { cases: [FLAKE], pr: 7 } }, repo.work))).toContain('reason=grant_needed');
}, 120_000);
test('a named file inside the PR diff or its import closure, a paid file or a security scan test refuses', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const one = (fileName: string, name: string) => {
const check = redRun(repo, head, [{ files: [fileName, 'test/retry.test.js', 'test/a.test.ts'].filter((f, i, a) => a.indexOf(f) === i), failures: [[fileName, name]] }, { files: DEFAULT_SHARDS[1]!.files.filter(f => f !== fileName) }]);
return msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [`${fileName}::${name}`], pr: 7 } }, repo.work));
};
expect(one('test/util.test.js', 'adds')).toContain('reason=case_in_closure — test/util.test.js depends on a file the PR changes (lib/util.js)');
expect(one('test/skill-e2e-flaky.test.ts', 'judge')).toContain('reason=case_paid — test/skill-e2e-flaky.test.ts is a paid test file');
expect(one('test/cso-docker-policy.test.ts', 'times out')).toContain('reason=case_security — test/cso-docker-policy.test.ts tests a security scan');
expect(one('test/ship-template-redaction.test.ts', 'blocks')).toContain('reason=case_security');
expect(one('browse/test/content-security.test.ts', 'blocks')).toContain('reason=case_security');
const touched = fixture();
commit(touched, { 'test/retry.test.js': "const test = require('node:test');\ntest('retries three times', () => {});\n" }, 'touch the flaky test');
const touchedHead = touched.git('rev-parse', 'HEAD');
const touchedFile = await pregateAt(touched);
expect(msg(clearDecision(touchedFile, LANE, { runs: ['400'], evidence: redRun(touched, touchedHead), flaky: { cases: [FLAKE], pr: 7 } }, touched.work))).toContain('reason=case_in_diff — test/retry.test.js is in the PR\'s diff');
}, 120_000);
test('the run must be at pregate.json\'s own head and tree, and the PR head must be that head: no rebinding', async () => {
const repo = fixture();
const older = repo.git('rev-parse', 'HEAD~1');
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const clear = (check: RunCheck) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work));
expect(clear(redRun(repo, older))).toContain('reason=head_mismatch');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { pull: { head_sha: older, state: 'open', head_repo: 'fx/fx' } }))).toContain('reason=pr_mismatch — PR #7\'s head is');
expect(clear(redRun(repo, head, DEFAULT_SHARDS, { pull: { head_sha: head, state: 'closed', head_repo: 'fx/fx' } }))).toContain('reason=pr_mismatch — PR #7 is closed');
}, 120_000);
test('the bins: clear refuses --flaky-exception without --run or --pr; verify prints the PREGATE pass-with-exception line; the receipt records it', async () => {
const repo = fixture();
const head = repo.git('rev-parse', 'HEAD');
const file = await pregateAt(repo);
const out = path.join(repo.work, '.gstack/tmp/pregate.json');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, JSON.stringify(file));
const noRun = runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, '--flaky-exception', FLAKE, '--pr', '7', '--out', out]);
expect([noRun.status, noRun.err]).toEqual([EXIT.usage, expect.stringContaining('--flaky-exception needs --run <id|url>')]);
const noPr = runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, '--run', '400', '--flaky-exception', FLAKE, '--out', out]);
expect([noPr.status, noPr.err]).toEqual([EXIT.usage, expect.stringContaining('--flaky-exception needs --pr <n>')]);
const exception = { lane: LANE, repo: 'fx/fx', pr: 7, head, tree: file.tree, runs: ['400/1'], cases: [FLAKE] };
const recorded = { ...exception, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(exception) };
const cleared = clearObligation(file, LANE, `remote-gate tree=${file.tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`, 'test', undefined, recorded);
fs.writeFileSync(out, JSON.stringify(cleared));
const v = runBin(repo, 'gstack-pregate', ['verify', '--out', out]);
expect([v.status, v.err]).toEqual([EXIT.ok, '']);
expect(v.out).toContain(`PREGATE: pass-with-exception (${FLAKE}; grant a-1)\n`);
const bound = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'interactive', '--pregate', out, '--json']);
expect([bound.status, bound.err]).toEqual([EXIT.ok, expect.any(String)]);
expect(bound.out).toContain('"flaky_exception":[{"lane":"pregate.json/remote_gate","runs":["400/1"],"cases":["test/retry.test.js::retries three times"],"grant":"a-1","by":"Garry"}]');
}, 180_000);
});
describe.skipIf(process.platform === 'win32')('ready with a flaky exception', () => {
test('ready accepts pass-with-exception, prints FLAKY_EXCEPTION, and still refuses a red required check (pregate only)', () => {
const repo = makeQueueRepo();
queueRepos.push(repo);
const head = makePr(repo, 'pr-a');
const tree = runQueueBin(repo, 'gstack-wtree', []).out.trim();
const exception = { lane: LANE, repo: 'o/r', pr: 1, head, tree, runs: ['400/1'], cases: [FLAKE] };
const pregate = path.join(repo.root, 'pregate.json');
fs.writeFileSync(pregate, JSON.stringify({
schema_version: 1, tree, head, verdict: 'pass-with-exception', checks: [{ id: 'lanes', status: 'requires-remote' }],
requires_remote: [{ lane: LANE, platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: `remote-gate tree=${tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z` }],
exceptions: [{ ...exception, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(exception) }],
}));
const queue = (checks: unknown) => runQueueBin(repo, 'gstack-ship-queue', ['ready', '--pr', '1', '--pregate', pregate, '--base', 'main'], ghQueueShim(repo, { prs: { '1': { headRefOid: head, url: 'https://github.com/o/r/pull/1', body: shippedBody('1', head) } }, checks: { [head]: checks } }));
const ok = queue({ check_runs: [{ status: 'completed', conclusion: 'success' }] });
expect([ok.status, ok.err]).toEqual([EXIT.ok, '']);
expect(ok.out).toMatch(/^PREGATE: pass-with-exception tree=[0-9a-f]{12} /m);
expect(ok.out).toContain(`FLAKY_EXCEPTION: ${LANE} cases=${FLAKE} run=400/1 grant=a-1 by=Garry (pregate only; every required CI check still needs its own green run at this head)\n`);
const red = queue({ check_runs: [{ status: 'completed', conclusion: 'failure' }] });
expect(red.status).toBe(EXIT.refused);
expect(red.err).toMatch(/CI is not green at .*\(QUEUE_NOT_READY\)/);
const other = { ...exception, pr: 2 };
fs.writeFileSync(pregate, JSON.stringify({ ...JSON.parse(fs.readFileSync(pregate, 'utf8')), exceptions: [{ ...other, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(other) }] }));
const moved = queue({ check_runs: [{ status: 'completed', conclusion: 'success' }] });
expect(moved.status).toBe(EXIT.refused);
expect(moved.err).toContain('flaky exception grant a-1 is bound to PR #2');
}, 120_000);
});
+104 -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, receiptReviewAtHead } from '../lib/ship-receipt-depth';
import { bindShippedReceipt, excludeMismatch, 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';
@@ -313,6 +313,31 @@ describe('the receipt binds the selection the reviewers read', () => {
expect(removed.status).toBe(EXIT.fail);
expect(removed.err).toContain(`field=review_exclude expected=none recorded=${digest}`);
}, 240_000);
// Value: protects=every place that describes write --review-exclude says what it checks: the digest HEAD has under the base
// policy, not that a review read that selection; fails_when=the help, troubleshooting, ship-policy or the queue's refusal
// line claims a receipt write alone cannot clear field=review_exclude while a write with the refusal's own expected= digest
// clears it with no review run; why_new=GST-972 item 2 (the #3852 cross-family trail review); seam=gstack-ship-receipt write
// and --help, excludeMismatch, the two docs; no_claim=binding the digest to the review ledger (write never reads it)
test('write binds --review-exclude to the base policy, not to a review, and no description claims more', () => {
const repo = census();
const body = path.join(repo.root, 'body.md');
const refused = writeReceipt(repo, body, []);
const expected = refused.err.match(/field=review_exclude expected=([0-9a-f]{64}) /)?.[1];
expect(expected).toBeDefined();
expect(writeReceipt(repo, body, ['--review-exclude', expected!, ...targetedAtHead(repo)]).status).toBe(EXIT.ok);
const doc = (rel: string) => fs.readFileSync(path.join(import.meta.dir, '..', rel), 'utf8').replace(/\s+/g, ' ');
const claims = {
help: runBin(repo, 'gstack-ship-receipt', ['--help']).out.replace(/\s+/g, ' '),
troubleshooting: doc('docs/troubleshooting.md'),
shipPolicy: doc('docs/ship-policy.md'),
queue: excludeMismatch({ state: 'none' }, { review_exclude: { digest: expected } } as any, repo.git('rev-parse', 'HEAD'), 'origin/main')!,
};
for (const [where, text] of Object.entries(claims)) {
expect({ where, overClaim: /write alone (cannot|does not) clear|never a receipt write alone/i.test(text) }).toEqual({ where, overClaim: false });
expect({ where, says: text.includes('does not read the review ledger') }).toEqual({ where, says: true });
}
}, 240_000);
});
describe('a branch cannot hide code under a base pattern', () => {
@@ -348,6 +373,84 @@ describe('a branch cannot hide code under a base pattern', () => {
expect(p).toMatchObject({ depth: 'full', errors: expect.arrayContaining([expect.objectContaining({ code: 'binary_code_path', input: 'assets/loader.js' })]) });
}, 120_000);
// Value: protects=a compiled binary under a base pattern stays in review, because it is code; fails_when=a .node, .so, .so.N,
// .dylib or .dll file, or an ELF, Mach-O or PE image under a content extension (.bin, .dat), is excluded as binary content;
// why_new=GST-972 item 3; seam=bin/gstack-review-plan; no_claim=what a reader can see inside a binary
test('native binaries are code: .node, .so, .dylib, .dll and executable images under a content extension stay kept', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo);
fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true });
const elf = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), Buffer.alloc(24)]);
const macho = Buffer.concat([Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), Buffer.alloc(28)]);
const pe = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(30)]);
const files = { 'assets/addon.node': elf, 'assets/libx.dylib': macho, 'assets/libx.so': elf, 'assets/libx.so.1': elf, 'assets/setup.dat': pe, 'assets/tool.bin': elf, 'assets/x.dll': pe, 'assets/real.gz': GZ };
for (const [rel, bytes] of Object.entries(files)) fs.writeFileSync(path.join(repo.work, rel), bytes);
commit(repo, {});
const p = JSON.parse(plan(repo, ['--json']).out);
expect(p.review_exclude.kept_files).toEqual(Object.keys(files).filter(f => f !== 'assets/real.gz').map(f => ({ path: f, reason: 'code' })));
expect(p.review_exclude).toMatchObject({ excluded: 1, content: { binary: 1 } });
}, 120_000);
// Value: protects=a branch's .gitattributes cannot get an executable image excluded by making git read it as text, and a
// 64-bit universal Mach-O is an executable image; fails_when=only files git reports as binary under the branch's own
// attributes get the magic check, so an ELF named .png under '*.png diff -binary' is excluded as data, or 0xcafebabf
// is not executable magic; why_new=GST-972 item 3 follow-up (#3910, #3902 outside voice); seam=bin/gstack-review-plan and
// reviewExclude in head mode; no_claim=a NUL-free script under a content extension
test('a branch .gitattributes that makes git read an ELF as text cannot get it excluded; fat64 Mach-O is native', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo);
fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'assets/native.png'), Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), Buffer.alloc(24)]));
fs.writeFileSync(path.join(repo.work, 'assets/fat64.dat'), Buffer.concat([Buffer.from([0xca, 0xfe, 0xba, 0xbf]), Buffer.alloc(28)]));
fs.writeFileSync(path.join(repo.work, 'assets/real.gz'), GZ);
commit(repo, { '.gitattributes': '*.png diff -binary\n' });
const p = JSON.parse(plan(repo, ['--json']).out);
const kept = [{ path: 'assets/fat64.dat', reason: 'code' as const }, { path: 'assets/native.png', reason: 'code' as const }];
expect(p.review_exclude.kept_files).toEqual(kept);
expect(p.review_exclude).toMatchObject({ excluded: 1, content: { binary: 1 } });
const head = repo.git('rev-parse', 'HEAD');
const baseSha = repo.git('rev-parse', 'origin/main');
const c = collectCommit(repo.work, baseSha, head);
const x = reviewExclude({ root: repo.work, baseSha, ref: 'origin/main', mergeBase: c.mergeBase!, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged: new Set(), context: 'test' });
expect(x.state === 'active' && x.kept).toEqual(kept);
expect(x.state === 'active' && x.record.digest).toBe(p.review_exclude.digest);
}, 120_000);
// Value: protects=a NUL byte cannot turn code or a key file into excludable content; fails_when=a NUL-bearing file with a
// source extension outside the old CODE_EXT list (.gradle, .sql, .tf, .groovy), a key store (.p12) or an extensionless key
// is excluded as binary; why_new=GST-972 item 6 (#3852 targeted gate reader at 61803d9); seam=bin/gstack-review-plan;
// no_claim=the list of binary content extensions is complete (an unknown one is kept, never excluded)
test('a NUL-bearing file is binary content only under a known content extension; code and key files stay kept', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo);
fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true });
const nul = (code: string) => Buffer.from(`/* \0 */ ${code}\n`);
const kept = { 'assets/Build.groovy': nul('println "x"'), 'assets/build.gradle': nul('apply plugin: "java"'), 'assets/id_ed25519': nul('openssh-key-v1'), 'assets/main.tf': nul('resource "x" "y" {}'), 'assets/query.sql': nul('DROP TABLE users;'), 'assets/server.p12': Buffer.from([0x30, 0x82, 0, 1, 2, 3]) };
const content = { 'assets/report.pdf': GZ, 'assets/shot.png': GZ };
for (const [rel, bytes] of Object.entries({ ...kept, ...content })) fs.writeFileSync(path.join(repo.work, rel), bytes);
commit(repo, {});
const p = JSON.parse(plan(repo, ['--json']).out);
expect(p.review_exclude.kept_files).toEqual(Object.keys(kept).map(f => ({ path: f, reason: 'code' })));
expect(p.review_exclude).toMatchObject({ excluded: 2, content: { binary: 2 } });
}, 120_000);
// Value: protects=a branch's .gitattributes cannot get a matched file with a credential line excluded by making git call it
// binary, at the plan (live tree) and at the receipt (head blobs); fails_when=the secret-line rule reads only the
// collector's added lines, which git leaves empty for an attribute-binary file, so the file is excluded; why_new=GST-972
// item 5 (#3852 round 3, outside-voice#2); seam=bin/gstack-review-plan and reviewExclude in head mode; no_claim=pregate's gitleaks scan, what readers' own diffs show (they still follow the attributes)
test('a branch .gitattributes marking receipts binary cannot get a credential-line file excluded: the keep rule reads the text', () => {
const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['receipts/**']) } }); repos.push(repo);
const secret = '{"cmd": "curl -H $OPENAI_API_KEY"}\n';
commit(repo, { '.gitattributes': 'receipts/** binary\n', 'receipts/s.json': secret, 'receipts/a.b/c.json': secret, 'receipts/a.json': '{"ok": true}\n', 'receipts/a/t.json': '{"ok": true}\n' });
const p = JSON.parse(plan(repo, ['--json']).out);
const kept = [{ path: 'receipts/a.b/c.json', reason: 'secret-line' as const }, { path: 'receipts/s.json', reason: 'secret-line' as const }];
expect(p.review_exclude.kept_files).toEqual(kept);
expect(p.review_exclude).toMatchObject({ excluded: 2, content: { data: 2 } });
const head = repo.git('rev-parse', 'HEAD');
const baseSha = repo.git('rev-parse', 'origin/main');
const c = collectCommit(repo.work, baseSha, head);
const x = reviewExclude({ root: repo.work, baseSha, ref: 'origin/main', mergeBase: c.mergeBase!, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged: new Set(), context: 'test' });
expect(x.state === 'active' && x.kept).toEqual(kept);
expect(x.state === 'active' && x.record.digest).toBe(p.review_exclude.digest);
}, 120_000);
test('an unavailable gate scan keeps every candidate instead of excluding it', () => {
const repo = census();
const head = repo.git('rev-parse', 'HEAD');
+17
View File
@@ -191,6 +191,23 @@ describe('lib: deriveFromFindings, bindReview order and reviewFreshness', () =>
expect(reviewFreshness(bound, 'd'.repeat(64))?.status).toBe('STALE');
});
// Value: protects=a fix-check row is written with the fields review-read verifies (GEVA-218: the row lacked them, so it read
// UNVERIFIED and finish could not count it); fails_when=the logger accepts a review-fix-check row without completed and
// converged, or consumes the start token while refusing it; seam=bindReview
test('a review-fix-check row without completed and converged is refused and keeps its start token (GST-1130)', () => {
const dir = path.join(home, 'starts', '.review-starts');
fs.mkdirSync(dir, { recursive: true });
const token = '77777777-2222-3333-4444-555555555555';
const w = 'e'.repeat(64);
fs.writeFileSync(path.join(dir, `${token}.json`), JSON.stringify({ skill: 'review-fix-check', repo: '/r', branch: 'b', wtree: w, started_at: 't' }));
const env = { GSTACK_REVIEW_DIR: path.join(home, 'starts'), GSTACK_REVIEW_REPO: '/r', GSTACK_REVIEW_BRANCH: 'b', GSTACK_STAMP_WTREE: w };
for (const row of [{}, { completed: true }, { converged: true }, { completed: 'yes', converged: true }]) {
expect(() => bindReview({ skill: 'review-fix-check', status: 'clean', ...row }, token, env)).toThrow('review-log: a review-fix-check row must carry completed and converged as true or false');
}
expect(fs.existsSync(path.join(dir, `${token}.json`))).toBe(true);
expect(bindReview({ skill: 'review-fix-check', status: 'clean', completed: true, converged: true }, token, env).review_binding.state).toBe('verified');
});
test('reviewFreshness reads findings_open: resolved criticals are CURRENT, claimed issues_found stays conservative', () => {
const w = 'b'.repeat(64);
const base = { skill: 'review', completed: true, converged: true, wtree: w, review_binding: { state: 'verified', start_wtree: w, end_wtree: w } };
+2 -1
View File
@@ -74,6 +74,7 @@ const NO_LOSS_REPLAYS = {
'GST-967': "GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged",
'GST-508+GST-464+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: ship/sections/review-army.md and adversarial.md are main's GST-967 bytes plus exactly the GST-508+GST-464 lines (the merged file minus main equals the wave minus fc292e22f, line for line), so GST-967's dispatch-superset no-loss check and GST-508's no-loss replay both hold; merged-head checks in GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md",
'GST-508+R51+GST-911+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: lib/review-depth.ts is main's GST-967 bytes plus exactly the GST-508+R51+GST-911 lines (the merged file minus main equals the wave minus fc292e22f, line for line); GST-967's dispatch-superset no-loss check (test/review-depth.test.ts) passes on the merged file, and the GST-508+R51+GST-911 checks are rerun at the merged head (GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md)",
'GST-940': "GST-940 verdict no-loss check: svgDrawing reads a style element's opening tag as quoted pairs with ASCII whitespace, walks its body over the raw text, refuses any & in it, reads comments as CSS and refuses a non-ASCII element or attribute name; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the GST-940 cases in test/review-planner-misreads.test.ts; rerun on the merge with main at 8f08cb76a (GST-967 merged), with the same results; rerun on the merge with main at 5f705f367 (#3852 and #3864 merged), with the same results",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
@@ -95,7 +96,7 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
'lib/review-depth.ts': { sha: 'd76a9afcfadfe0a95b15428c04e6f7a88c6b0fa75591201c45f023fec2bb90db', replay: 'GST-508+R51+GST-911+GST-967' },
'lib/review-depth.ts': { sha: '25050a7a61f81df649d1df0d0237dbf961671356395317c6a527741f5bb56877', replay: 'GST-940' },
'lib/review-risk.ts': { sha: '08464b78a723ee463ca3a28d0abbc4eb8f4f512192061c703743707b66b5a311', replay: 'GST-942' },
};
+21 -1
View File
@@ -125,7 +125,9 @@ describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is se
// Value: protects=only an SVG made of drawing elements is data: anything that loads, links or declares outside that set stays code;
// fails_when=an external image or stylesheet, an entity declaration, an external DTD, an animation or a CSS url() to another document reads as docs,
// including through a CSS escape or a zero-padded character reference (review pass 1 findings) or behind a stray quote that
// pairs with a later one (review pass 2 finding), or any CSS beyond declarations and url(#id) (Step 11 finding: image-set());
// pairs with a later one (review pass 2 finding), or any CSS beyond declarations and url(#id) (Step 11 finding: image-set()),
// including CSS behind a character-reference </style> or comment, an HTML-only comment end, a quoted > on the style tag, a
// non-ASCII space before > in </style>, or a tag or attribute under a non-ASCII namespace prefix (GST-940);
// seam=collectCommit + depthOfCommitInput
test.each([
['an external image', ' <image href="https://x.test/pixel.png" width="1" height="1"/>'],
@@ -153,11 +155,29 @@ describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is se
['a style attribute cross-fade()', ' <rect style="fill:cross-fade(url(https://x.test/a.png), url(#m0), 50%)"/>'],
['a style element whose </style> hides in a comment', ' <style>svg{fill:<!-- </style> -->image-set("https://x.test/p.png" 1x)}</style>'],
['a style element whose </style> hides in CDATA', ' <style><![CDATA[ /* </style> */ svg{fill:image-set("https://x.test/p.png" 1x)} ]]></style>'],
['a character-reference </style> in a style element', ' <style>&#60;/style>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a hex character-reference </style> in a style element', ' <style>&#x3C;/style>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a character-reference comment in a style element', ' <style>&#60;!-- rect{fill:image-set("https://x.test/p.png" 1x)} --></style>'],
['CSS after an HTML empty comment in a style element', ' <style><!-->rect{fill:image-set("https://x.test/p.png" 1x)}--></style>'],
['CSS after an HTML --!> comment end in a style element', ' <style><!-- --!>rect{fill:image-set("https://x.test/p.png" 1x)} --></style>'],
['a </style> inside a quoted style attribute', ' <style title="></style>">rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['an escaped ampersand in a style element', ' <style>text{font-family:"A &amp; B"}</style>'],
['a no-break space before > in </style>', ' <style>a{}</style\u00a0>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a vertical tab before > in </style>', ' <style>a{}</style\u000b>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
['a script under a non-ASCII prefix', ' <g\u00e9:script xmlns:g\u00e9="http://www.w3.org/2000/svg">fetch(1)</g\u00e9:script>'],
['a style under a non-ASCII prefix', ' <g\u00e9:style xmlns:g\u00e9="http://www.w3.org/2000/svg">rect{fill:image-set("https://x.test/p.png" 1x)}</g\u00e9:style>'],
['an external href under a non-ASCII prefix', ' <image x\u00e9:href="https://x.test/p.png" xmlns:x\u00e9="http://www.w3.org/1999/xlink"/>'],
])('%s keeps the SVG code', (_name, line) => {
const fx = fixture();
expect(plan(fx, fx.commit({ 'results/chart.svg': chart(5, line) })).class).toBe('code');
}, 60_000);
// Value: protects=the GST-940 style walker still reads a plain chart style as a drawing: a comment, CDATA and a quoted attribute
// without markup; fails_when=the walker over-refuses them, so chart SVGs lose the light review; seam=svgDrawing
test('a style comment, CDATA and quoted attribute keep a chart a drawing (GST-940)', () => {
expect(svgDrawing(chart(5, ' <style type="text/css" media="all"><!-- plot --><![CDATA[ rect{fill:#000} ]]></style>'))).toBe(true);
});
// Value: protects=a passive edit to an SVG that already carries a script is still code, because the whole file decides, not the
// changed lines; and an SVG code names stays code like any consumed data file; fails_when=either plans as docs; seam=collectCommit
test('an edit inside a scripted SVG, and an SVG code names, stay code', () => {
+2 -1
View File
@@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECURITY_LINE, matchRisk } from '../lib/review-risk';
const ROOT = path.join(import.meta.dir, '..');
@@ -51,7 +52,7 @@ describe('the rules hashes cover every new pattern', () => {
fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts'));
fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n");
const hash = () => {
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
return r.stdout.trim();
};
+77 -3
View File
@@ -8,7 +8,7 @@ import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
import { extractReceipt, landedStackParent, renderReceiptBlock } from '../lib/ship-receipt';
import { bindShippedReceipt } from '../lib/ship-receipt-depth';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
@@ -21,7 +21,7 @@ const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n";
const RETRY4 = "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n";
/** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */
function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/util.js': SUB }, childFiles: Record<string, string> = { 'lib/retry.js': RETRY4 }): { parent: string; child: string; body: string } {
function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/util.js': SUB }, childFiles: Record<string, string> = { 'lib/retry.js': RETRY4 }, base = 'parent'): { parent: string; child: string; body: string } {
repo.git('checkout', '-q', '-b', 'parent');
const parent = commit(repo, parentFiles, 'parent: sub');
repo.git('push', '-q', 'origin', 'parent');
@@ -29,7 +29,7 @@ function stack(repo: FixtureRepo, parentFiles: Record<string, string> = { 'lib/u
repo.git('reset', '-q', '--hard', parent);
const child = commit(repo, childFiles, 'child: four retries');
const body = path.join(repo.root, 'body.md');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'parent', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', base, '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
expect(w.status).toBe(EXIT.ok);
return { parent, child, body };
}
@@ -166,3 +166,77 @@ describe('carry after a stack parent squash-merges (#3570)', () => {
expect(receiptIn(body).head).toBe(child);
}, 90_000);
});
const CONFIG = ['a = 1', 'b = 2', 'c = 3', 'd = 4', 'e = 5', 'f = 6', 'g = 7', 'h = 8', ''].join('\n');
const configWith = (edits: Record<string, string>) => CONFIG.split('\n').map(l => edits[l] ?? l).join('\n');
describe('carry after a stack parent squash-merges, receipt written against main (GST-1199)', () => {
// Value: protects=a child PR stacked on its parent but targeting main keeps its receipt once the parent squash-merges and
// main is merged in (gstack-evals#178 faced a 45-60M-token gate re-review for it); fails_when=carry compares the child's
// whole range from the old fork point, parent hunks included, with HEAD's diff against a base that now holds them;
// seam=none; no_claim=gh pr edit (--body stands in)
test('the child carries when its range from the landed parent is HEAD\'s whole product diff against main', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { parent, child, body } = stack(repo, undefined, undefined, 'main');
landOnMain(repo, { 'lib/util.js': SUB });
const head = repo.git('rev-parse', 'HEAD');
const out = carry(repo, body);
expect(out.err).not.toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(out.status).toBe(EXIT.ok);
expect(out.out).toContain(`CARRY: carried from ${child.slice(0, 12)} to ${head.slice(0, 12)}`);
const r = receiptIn(body);
expect(r.head).toBe(head);
expect(r.base).toBe('main');
expect(r.carry!.reason).toBe(`stack parent ${parent.slice(0, 12)} landed + base merge`);
expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '8', [head]).ok).toBe(true);
}, 90_000);
// Value: protects=fail closed: a product edit after the receipt, or a parent that never landed and whose change the
// child's merge dropped, takes a fresh /ship; fails_when=any reviewed ancestor counts as a landed parent, or the edit
// hides behind the shorter range; seam=none; no_claim=-
test('a product edit after the receipt, or a dropped parent that never landed, refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => {
const repo = makeFixtureRepo(); repos.push(repo);
const { child, body } = stack(repo, undefined, undefined, 'main');
landOnMain(repo, { 'lib/util.js': SUB });
commit(repo, { 'lib/retry.js': RETRY4.replace('4', '5') }, 'child: five');
const edited = carry(repo, body);
expect(edited.status).toBe(EXIT.refused);
expect(edited.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(body).head).toBe(child);
const other = makeFixtureRepo(); repos.push(other);
const s = stack(other, undefined, undefined, 'main');
landOnMain(other, {});
commit(other, { 'lib/util.js': other.git('show', 'origin/main:lib/util.js') + '\n' }, 'drop the parent change');
const dropped = carry(other, s.body);
expect(dropped.status).toBe(EXIT.refused);
expect(dropped.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
expect(receiptIn(s.body).head).toBe(s.child);
}, 120_000);
// Value: protects=a base change next to the child's reviewed hunks gets one fix-check reviewer, not a carry and not a full
// /ship; fails_when=the landed-parent comparison ignores base context or refuses it outright; seam=none; no_claim=-
test('a base change next to the child\'s reviewed hunks routes to RECEIPT_CARRY_SCOPED_REVIEW', () => {
const repo = makeFixtureRepo({ baseFiles: { 'lib/config.js': CONFIG } }); repos.push(repo);
const { child, body } = stack(repo, undefined, { 'lib/config.js': configWith({ 'd = 4': 'd = 40' }) }, 'main');
landOnMain(repo, { 'lib/util.js': SUB, 'lib/config.js': configWith({ 'f = 6': 'f = 60' }) });
const out = carry(repo, body);
expect(out.status).toBe(EXIT.refused);
expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)');
expect(out.err).toContain('lib/config.js');
expect(receiptIn(body).head).toBe(child);
}, 90_000);
// Value: protects=the receipt never names a stack parent that does not exist; fails_when=a test-first commit with no
// product change counts as landed (any base holds it), so the reason claims a parent and the test range shrinks; seam=none;
// no_claim=-
test('a test-only commit in a branch that was never stacked is no landed parent', () => {
const repo = makeFixtureRepo({ baseFiles: { 'lib/config.js': CONFIG } }); repos.push(repo);
const fork = repo.git('rev-parse', 'HEAD');
commit(repo, { 'test/config.test.js': "const test = require('node:test');\ntest('d', () => {});\n" }, 'test first');
const reviewed = commit(repo, { 'lib/config.js': configWith({ 'd = 4': 'd = 40' }) }, 'fix d');
repo.git('checkout', '-q', 'main');
const mainTip = commit(repo, { 'docs/README.md': '# main moves on\n' }, 'main moves on');
expect(landedStackParent(repo.work, { rangeBase: fork, reviewed, currentBase: mainTip }, ['.', ':(exclude,glob)test/**'])).toBeNull();
}, 60_000);
});
+80
View File
@@ -757,3 +757,83 @@ describe('finish with an owner-accepted edit after a fix-check (GST-965, GBRA-27
});
});
// Value: protects=a granted fix-check's clean read clears the paths it reviewed at Step 16, a gate-edited test included, so
// finish does not send them back through Steps 5–11.5 (GEVA-218, gstack-evals#168); fails_when=finish routes a path inside
// the fix-check's range as behavior, counts a path edited after the fix-check, or counts a row that is not a completed clean
// fix-check with a range base; seam=finish's route reading the review log; no_claim=Step 11.5's binding
describe('finish after a completed fix-check (GST-1130, GEVA-218)', () => {
const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); });\n";
const TEST_V2 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); }, 1_000);\ntest('b finishes fast', () => { expect(1 + 1).toBe(2); }, 1_000);\n";
const logFixCheck = (f: ShipFx, row: Record<string, unknown>) => {
const start = f.run(path.join(BIN, 'gstack-review-log'), ['--start', 'review-fix-check']);
expect(start.status).toBe(0);
return f.run(path.join(BIN, 'gstack-review-log'), [JSON.stringify({ skill: 'review-fix-check', timestamp: '2026-11-02T00:00:00Z', via: 'ship', review_scope: 'frozen', status: 'clean', issues_found: 0, critical: 0, ...row }), '--finish', start.stdout.trim()]);
};
const grant = (f: ShipFx, stopHead: string) => {
const a = f.run(path.join(BIN, 'gstack-approvals'), ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${stopHead.slice(0, 12)})`, '--words', 'one scoped fix-check after round 2', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { GSTACK_APPROVALS_MIRROR: '0' });
expect(a.status).toBe(0);
return JSON.parse(a.stdout).added[0] as string;
};
const afterFixCheck = () => {
const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': TEST_V1 });
const passHead = f.git('rev-parse', 'HEAD').stdout.trim();
const reviewed = wtree(f);
f.write({ 'src/x.ts': 'export const x = 3;\n', 'test/x.test.ts': TEST_V2 }); f.commit('fix the round-2 blocker and bound its case');
return { f, passHead, fixHead: f.git('rev-parse', 'HEAD').stdout.trim(), reviewed, post: snapshot(f) };
};
test('paths inside a completed clean fix-check range count as reviewed, a gate-edited test included; a later edit still stops', () => {
const { f, passHead, fixHead, reviewed, post } = afterFixCheck();
const plain = finish(f, reviewed, post);
expect([plain.status, plain.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]);
const id = grant(f, passHead);
expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0);
const r = finish(f, reviewed, post);
expect(lineOf(r.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)}, grant ${id})`);
expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=unknown context=0 fix_checked=2$/);
expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]);
f.write({ 'src/x.ts': 'export const x = 4;\n' }); f.commit('an edit after the fix-check');
const late = finish(f, reviewed, post);
expect(lineOf(late.stdout, 'FIX_CHECK')).toStartWith('FIX_CHECK: reviewed test/x.test.ts (');
expect([late.status, late.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts)')]).toEqual([3, true]);
});
test('a clean fix-check row with no recorded review_loop_grant for a stop head in its range is not counted', () => {
const { f, passHead, reviewed, post } = afterFixCheck();
expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0);
const ungranted = finish(f, reviewed, post);
expect(lineOf(ungranted.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit)');
expect([ungranted.status, ungranted.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]);
grant(f, mergeBase(f));
const elsewhere = finish(f, reviewed, post);
expect([elsewhere.status, lineOf(elsewhere.stdout, 'FIX_CHECK')]).toEqual([3, 'FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit)']);
});
test('a fix-check row that did not finish clean, or names no range base, is not counted', () => {
const { f, passHead, reviewed, post } = afterFixCheck();
expect(logFixCheck(f, { completed: false, converged: false, range_base: passHead }).status).toBe(0);
const incomplete = finish(f, reviewed, post);
expect(lineOf(incomplete.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (missing start capture or incomplete/nonconverged pass)');
expect([incomplete.status, incomplete.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]);
expect(logFixCheck(f, { completed: true, converged: true }).status).toBe(0);
const unranged = finish(f, reviewed, post);
expect(lineOf(unranged.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (the row names no range_base commit on this branch)');
expect(unranged.status).toBe(3);
const fork = mergeBase(f);
expect(logFixCheck(f, { completed: true, converged: true, range_base: fork }).status).toBe(0);
const early = finish(f, reviewed, post);
expect(lineOf(early.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: not counted (range_base ${fork.slice(0, 12)} does not hold the reviewed tree)`);
expect(early.status).toBe(3);
});
test('a fix-check logged on uncommitted content is not counted', () => {
const { f, passHead, reviewed, post } = afterFixCheck();
f.write({ 'src/x.ts': 'export const x = 9;\n' });
expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0);
f.git('checkout', '--', 'src/x.ts');
const r = finish(f, reviewed, post);
expect(lineOf(r.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (the row was logged on content no branch commit holds)');
expect(r.status).toBe(3);
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* A bare --base <branch> reads origin's tracking branch, not a stale local branch (GST-1036). Capy clones and linked
* worktrees never move the local base branch, so `--base main` there merged against an old local main: review and ship
* bins then counted upstream commits the branch only inherited (a docs diff planned at gate depth, banned terms and
* prose findings in lines the branch never wrote). Every such bin resolves the base through resolveBase
* (lib/gate-diff/git.ts) and names the ref it used.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { BANNED_TERMS_SCHEMA } from '../lib/banned-terms';
import { resolveCommit } from '../lib/pr-shots';
const BIN = path.join(import.meta.dir, '..', 'bin');
const dirs: string[] = [];
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
function fixture() {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'stale-base-')));
dirs.push(root);
const work = path.join(root, 'work');
const env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
const sh = (cwd: string, c: string) => { const r = spawnSync('bash', ['-c', c], { cwd, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(`${c}: ${r.stderr}`); return r.stdout.trim(); };
sh(root, 'git init -q --bare -b main origin.git && git clone -q origin.git work 2>/dev/null');
fs.mkdirSync(path.join(work, '.gstack'));
fs.writeFileSync(path.join(work, '.gstack', 'banned-terms.json'), JSON.stringify({ $schema: BANNED_TERMS_SCHEMA, terms: ['frobnicate'] }) + '\n');
fs.writeFileSync(path.join(work, 'README.md'), '# t\n');
sh(work, 'git add -A && git commit -qm base && git push -q origin main');
sh(root, "git clone -q origin.git other && cd other && mkdir docs && printf 'Upstream notes frobnicate the parser.\\n\\nShowcasing vibrant features.\\n' > docs/up.md && git add -A && git commit -qm upstream && git push -q origin main");
sh(work, "git fetch -q origin && git checkout -qb feat origin/main && mkdir src && echo 'export const v = 1;' > src/v.ts && git add -A && git commit -qm feat");
const origin = sh(work, 'git rev-parse refs/remotes/origin/main');
expect(sh(work, 'git rev-parse refs/heads/main')).not.toBe(origin);
const run = (bin: string, args: string[]) => { const r = spawnSync(path.join(BIN, bin), args, { cwd: work, env, encoding: 'utf8', timeout: 180_000 }); return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; };
return { root, work, sh, run, origin };
}
const NOTE = 'local main is behind origin/main; base is origin/main';
describe('a bare --base reads origin/<base> over a stale local branch (GST-1036)', () => {
// Value: protects=review-plan names the base ref its plan was computed against, so a stale-base plan shows in its output;
// fails_when=review-plan prints no REVIEW_BASE line, or names the local main while origin/main is ahead, or re-qualifies origin/main;
// why_new=GST-1036 (#3882 planned a docs diff at full depth against local main 319ef500); seam=bin/gstack-review-plan, lib/review-collect.ts collectLive
test('gstack-review-plan prints the ref it used and why', () => {
const { run, origin } = fixture();
const bare = run('gstack-review-plan', ['--base', 'main']);
expect(bare.status, bare.out + bare.err).toBe(0);
expect(bare.out).toContain(`REVIEW_BASE: origin/main ${origin.slice(0, 12)}\n`);
expect(bare.err).toContain(`gstack-review-plan: ${NOTE}`);
const json = run('gstack-review-plan', ['--base', 'main', '--json']);
expect(JSON.parse(json.out)).toMatchObject({ paths: ['src/v.ts'], base: { ref: 'origin/main', sha: origin, local: 'behind' } });
const qualified = run('gstack-review-plan', ['--base', 'origin/main']);
expect(qualified.out).toContain(`REVIEW_BASE: origin/main ${origin.slice(0, 12)}\n`);
expect(qualified.err).not.toContain('base is');
}, 240_000);
// Value: protects=banned-terms scans only the lines the branch adds; fails_when=it merges against the stale local main and reports a term an upstream commit added;
// why_new=GST-1036 sibling bin (/review passes a bare <base>); seam=bin/gstack-banned-terms
test('gstack-banned-terms check ignores inherited upstream lines', () => {
const { run } = fixture();
for (const base of ['main', 'origin/main']) {
const r = run('gstack-banned-terms', ['check', '--base', base]);
expect(r.status, r.out + r.err).toBe(0);
expect(r.out).toContain('BANNED_TERMS: ok');
expect(r.err).toContain(`gstack-banned-terms: ${NOTE}`);
}
}, 120_000);
// Value: protects=without a remote-tracking twin the local base still stands, so the origin preference never hides lines;
// fails_when=the fix drops the local fallback; why_new=negative control for GST-1036; seam=bin/gstack-banned-terms
test('no origin: gstack-banned-terms keeps the local main and reports what the branch carries over it', () => {
const { sh, work, run } = fixture();
sh(work, 'git remote remove origin');
const r = run('gstack-banned-terms', ['check', '--base', 'main']);
expect(r.status, r.out + r.err).toBe(1);
expect(r.out).toContain('frobnicate');
expect(r.err).not.toContain('base is');
}, 120_000);
// Value: protects=prose-lint --base lints only the lines the branch adds; fails_when=a bare base merges against the stale local main;
// why_new=GST-1036 sibling bin; seam=lib/prose-lint.ts addedOnly
test('gstack-prose-lint --base main skips a file only upstream changed; origin/main is read as given', () => {
const { run } = fixture();
for (const base of ['main', 'origin/main']) {
const r = run('gstack-prose-lint', ['docs/up.md', '--base', base, '--gate']);
expect(r.status, r.out + r.err).toBe(0);
expect(r.out).not.toContain('PROSE:');
}
}, 120_000);
// Value: protects=the docs-audit snapshot selects only the branch's files; fails_when=--base main resolves to the stale local main and selects upstream docs;
// why_new=GST-1036 sibling bin; seam=bin/gstack-docs-candidate
test('gstack-docs-candidate snapshot --base main records origin/main and selects only the branch', () => {
const { root, run, origin } = fixture();
const r = run('gstack-docs-candidate', ['snapshot', '--out', path.join(root, 'c.json'), '--audit-id', 'a1', '--mode', 'read-only', '--base', 'main']);
expect(r.status, r.out + r.err).toBe(0);
expect(r.out).toContain(`base=${origin} `);
expect(r.out).toContain('committed: "src/v.ts"\n');
expect(r.err).toContain(`gstack-docs-candidate: ${NOTE}`);
}, 120_000);
// Value: protects=the PR screenshot's before side is origin's base, not a stale local copy; fails_when=resolveCommit reads refs/heads/main first;
// why_new=GST-1036 sibling bin; seam=lib/pr-shots.ts resolveCommit
test('gstack-pr-shots --base main reads origin/main; a sha resolves as given', () => {
const { work, sh, origin } = fixture();
expect(resolveCommit(work, 'main')).toBe(origin);
const local = sh(work, 'git rev-parse refs/heads/main');
expect(resolveCommit(work, local)).toBe(local);
}, 120_000);
});
+1 -1
View File
@@ -148,7 +148,7 @@ test('every windows-smoke test is Windows-safe, seeded fast, and says why it is
test('no Windows job restores or saves the bun install cache', () => {
// GSTA-203 PR 1: a cold install (16-17 s) beat restoring the ~450 MB
// windows-bun entry (33-42 s), and PR merge-ref saves filled the 10 GB quota.
for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml']) {
for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml', 'windows-scoped-tests.yml']) {
for (const [name, job] of Object.entries<any>(load(file).jobs)) {
const caches = (job.steps ?? []).filter((step: any) => step.uses?.startsWith('actions/cache'));
expect({ file, job: name, caches }).toEqual({ file, job: name, caches: [] });
+311
View File
@@ -0,0 +1,311 @@
/**
* Windows checks by path (ship-90-90 plan D, GST-1135). Pins three things:
*
* 1. windows-smoke.yml stays the unfiltered Windows check on every pull
* request.
* 2. windows-setup-e2e.yml starts only on install paths, and its
* windows-scope job keeps the two Windows jobs off a pull request whose
* only install change is package.json's version line (the merge-train
* stamp).
* 3. windows-scoped-tests.yml runs the changed Windows-safe test files when
* a pull request touches path, file-name or process code. Its filter must
* catch #3758 (/freeze's check-freeze.sh, a hook path) and #3829 (a test
* that spawned Bun with --config=/dev/null), and pass over a docs-only
* and a pure-logic change. Its aggregate passes when nothing is in scope,
* so a skipped run still reports success.
*
* The filter and scope steps run here as the runner would run them: bash in a
* two-commit git repository, with HEAD^1 as the pull request's base.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseWorkflow } from '../lib/pregate/workflows';
import { collectFreeTestFiles, curateWindowsSafe } from '../scripts/test-free-shards';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const SCOPED_FILE = 'windows-scoped-tests.yml';
const SETUP_FILE = 'windows-setup-e2e.yml';
const scopedText = read(`.github/workflows/${SCOPED_FILE}`);
const setupText = read(`.github/workflows/${SETUP_FILE}`);
const scoped = Bun.YAML.parse(scopedText) as any;
const setupE2e = Bun.YAML.parse(setupText) as any;
const smoke = Bun.YAML.parse(read('.github/workflows/windows-smoke.yml')) as any;
const toShell = (p: string) => p.split(path.sep).join('/');
const CAN_RUN = !!Bun.which('bash') && !!Bun.which('git');
const PKG = (version: string, deps = '"a": "1.0.0"') => `{\n "name": "gstack",\n "version": "${version}",\n "dependencies": {\n ${deps}\n }\n}\n`;
function git(cwd: string, args: string[]): string {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout;
}
/** A repository with a base commit and one change on top: HEAD^1 plays the pull request's base, as on the merge ref. */
function repo(base: Record<string, string>, change: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-'));
const write = (files: Record<string, string>) => {
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);
}
};
git(dir, ['init', '-q']);
write(base);
git(dir, ['add', '-A']);
git(dir, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', 'base']);
write(change);
git(dir, ['add', '-A']);
git(dir, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', 'change']);
return dir;
}
/** GITHUB_OUTPUT lines, including name<<DELIMITER blocks. */
function parseOutputs(text: string): Record<string, string> {
const out: Record<string, string> = {};
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const block = /^([\w-]+)<<(.+)$/.exec(lines[i]!);
if (block) {
const end = lines.indexOf(block[2]!, i + 1);
out[block[1]!] = lines.slice(i + 1, end).join('\n');
i = end;
} else if (lines[i]!.includes('=')) {
const at = lines[i]!.indexOf('=');
out[lines[i]!.slice(0, at)] = lines[i]!.slice(at + 1);
}
}
return out;
}
interface StepRun { status: number | null; stderr: string; outputs: Record<string, string>; summary: string; temp: string }
const TEMPS: string[] = [];
afterAll(() => { for (const dir of TEMPS) fs.rmSync(dir, { recursive: true, force: true }); });
const newTemp = () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-temp-')); TEMPS.push(dir); return dir; };
/** Run one workflow step's script under bash with a fresh GITHUB_OUTPUT and summary; temp is RUNNER_TEMP, kept for a later step. */
function runStep(script: string, cwd: string, env: Record<string, string> = {}, temp = newTemp()): StepRun {
const output = path.join(temp, 'github-output');
const summary = path.join(temp, 'github-summary');
fs.writeFileSync(output, '');
fs.writeFileSync(summary, '');
const r = spawnSync('bash', ['-c', script], {
cwd, encoding: 'utf8', timeout: 30_000,
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? temp, GITHUB_EVENT_NAME: 'pull_request', RUNNER_TEMP: toShell(temp), GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(summary), ...env },
});
return { status: r.status, stderr: r.stderr, outputs: parseOutputs(fs.readFileSync(output, 'utf8')), summary: fs.readFileSync(summary, 'utf8'), temp };
}
const step = (job: any, id: string) => job.steps.find((s: any) => s.id === id);
const filterScript: string = step(scoped.jobs['windows-scope'], 'filter').run;
const selectScript: string = step(scoped.jobs['windows-scope'], 'select').run;
const setupScopeScript: string = step(setupE2e.jobs['windows-scope'], 'scope').run;
// #3758 (v1.92.9.0, /freeze checks where an edit really lands): its changed files. Its Windows failure was in
// test/hook-scripts.test.ts, from check-freeze.sh's path handling (run 41243040943).
const PR_3758 = ['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'careful/bin/hook-extract.sh', 'freeze/SKILL.md', 'freeze/SKILL.md.tmpl',
'freeze/bin/check-freeze.sh', 'package.json', 'test/hook-scripts.test.ts'];
// #3829 (v1.92.61.0, risk classes cover child_process): its changed files, none named for paths or processes. Its
// Windows failure (nightly run 41420490350) was test/review-risk-linear.test.ts, a file it added, spawning Bun with
// --config=/dev/null: EINVAL on native Windows.
const PR_3829 = ['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'docs/review-policy.md', 'lib/review-collect.ts', 'lib/review-depth.ts',
'lib/review-risk.ts', 'lib/review-targeted.ts', 'package.json', 'test/fixtures/review-risk/gbrain-evals-289.json', 'test/helpers/free-fixtures-data.ts',
'test/review-packets-removed.test.ts', 'test/review-risk-linear.test.ts', 'test/review-risk-routing.test.ts'];
const LINE_3829 = " const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });";
/** Every file of a pull request with a neutral one-line edit, package.json as a version bump, plus the given lines. */
function prRepo(files: string[], lines: Record<string, string> = {}) {
const base: Record<string, string> = {};
const change: Record<string, string> = {};
for (const file of files) {
base[file] = file === 'package.json' ? PKG('1.0.0') : 'unchanged\n';
change[file] = file === 'package.json' ? PKG('1.0.1') : `unchanged\nedited\n${lines[file] ?? ''}\n`;
}
return repo(base, change);
}
describe('windows-smoke stays on every pull request', () => {
test('no paths filter, no job condition, one job', () => {
expect(smoke.on.pull_request).toEqual({ branches: ['main'] });
expect(smoke.on.push).toEqual({ branches: ['main'] });
expect(Object.keys(smoke.jobs)).toEqual(['windows-smoke']);
expect(smoke.jobs['windows-smoke'].if).toBeUndefined();
expect(smoke.jobs['windows-smoke'].needs).toBeUndefined();
});
test('no Windows job that can skip is a required check, and the scoped aggregate always reports', () => {
const required = JSON.parse(read('.gstack/ship-policy.json')).changelog_fragments.required_checks as string[];
expect(required).toContain('windows-smoke');
for (const name of ['windows-scope', 'windows-setup', 'held-install-journey', 'windows-scoped-shard', 'Windows Setup E2E']) expect(required).not.toContain(name);
expect(scoped.jobs['windows-scoped-tests'].if).toBe('always()');
});
});
describe('Windows Setup E2E runs by install path', () => {
const paths: string[] = setupE2e.on.pull_request.paths;
test('pull requests on install paths and dispatch only; every Windows job waits on windows-scope', () => {
expect(Object.keys(setupE2e.on).sort()).toEqual(['pull_request', 'workflow_dispatch']);
expect(setupE2e.on.pull_request.branches).toEqual(['main']);
for (const p of ['setup', 'setup.ps1', 'package.json', 'bun.lock', 'gstack-upgrade/**', 'bin/gstack-config', 'bin/gstack-patch-names',
'bin/gstack-state-root.sh', 'bin/gstack-migrate-claude-code', 'hosts/claude/hooks/**', 'docs/troubleshooting.md', `.github/workflows/${SETUP_FILE}`]) expect(paths).toContain(p);
expect(new Set(paths).size).toBe(paths.length);
for (const p of paths.filter(p => !p.includes('*'))) expect([p, fs.existsSync(path.join(ROOT, p))]).toEqual([p, true]);
const scope = setupE2e.jobs['windows-scope'];
expect(scope['runs-on']).toBe('ubuntu-24.04');
expect(scope.steps[0].with).toEqual({ 'fetch-depth': 2, 'persist-credentials': false });
const windowsJobs = Object.keys(setupE2e.jobs).filter(name => name !== 'windows-scope');
expect(windowsJobs).toEqual(['windows-setup', 'held-install-journey']);
for (const name of windowsJobs) {
expect([name, setupE2e.jobs[name]['runs-on'], setupE2e.jobs[name].needs, setupE2e.jobs[name].if])
.toEqual([name, 'windows-latest', 'windows-scope', "needs.windows-scope.outputs.install == 'true'"]);
}
});
test("windows-scope's install list is the paths list", () => {
const list = setupScopeScript.slice(setupScopeScript.indexOf("<<'PATHS'\n") + 10, setupScopeScript.indexOf('\nPATHS\n')).split('\n');
expect(list).toEqual(paths);
});
test('pregate still reads windows-setup as a Windows lane obligation (no condition keys on a dispatch input)', () => {
const lanes = parseWorkflow(SETUP_FILE, setupText, 'linux').filter(l => l.runsTests);
expect(lanes.map(l => [l.job, l.manual, l.tier])).toContainEqual(['windows-setup', false, 'platform']);
});
test.skipIf(!CAN_RUN)('a version-only package.json change starts no Windows job; any other install change does', () => {
const scope = (base: Record<string, string>, change: Record<string, string>) => {
const dir = repo(base, change);
try {
const r = runStep(setupScopeScript, dir);
expect(r.status, r.stderr).toBe(0);
return r.outputs.install;
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
};
const base = { 'package.json': PKG('1.0.0'), setup: '#!/bin/bash\n', 'lib/x.ts': 'export const x = 1;\n', 'hosts/claude/hooks/a/b.ts': 'a\n' };
expect(scope(base, { 'package.json': PKG('1.0.1'), 'lib/x.ts': 'export const x = 2;\n' })).toBe('false');
expect(scope(base, { 'package.json': PKG('1.0.1', '"a": "1.0.1"') })).toBe('true');
expect(scope(base, { 'package.json': PKG('1.0.1'), setup: '#!/bin/bash\necho changed\n' })).toBe('true');
expect(scope(base, { 'hosts/claude/hooks/a/b.ts': 'b\n' })).toBe('true');
expect(scope({ 'package.json': PKG('1.0.0', '"version": "1.0.0"') }, { 'package.json': PKG('1.0.0', '"version": "1.0.1"') })).toBe('true');
const dispatched = runStep(setupScopeScript, ROOT, { GITHUB_EVENT_NAME: 'workflow_dispatch' });
expect([dispatched.status, dispatched.outputs.install]).toEqual([0, 'true']);
});
});
describe('the path-scoped Windows shard on pull requests', () => {
test('every pull request runs the cheap scope job; a dispatch names its files; read-only and secretless', () => {
expect(Object.keys(scoped.on).sort()).toEqual(['pull_request', 'workflow_dispatch']);
expect(scoped.on.pull_request).toEqual({ branches: ['main'] });
expect(scoped.on.workflow_dispatch.inputs.files).toMatchObject({ type: 'string', required: true });
expect(scoped.permissions).toEqual({ contents: 'read' });
expect(scopedText).not.toContain('secrets.');
expect(scopedText).not.toContain('pull_request_target');
expect(scoped.concurrency.group).toBe('windows-scoped-tests-${{ github.event.pull_request.number || github.run_id }}');
const scope = scoped.jobs['windows-scope'];
expect(scope['runs-on']).toBe('ubuntu-24.04');
expect(scope.steps[0].with).toEqual({ 'fetch-depth': 2, 'persist-credentials': false });
const shard = scoped.jobs['windows-scoped-shard'];
expect(shard['runs-on']).toBe('windows-latest');
expect(shard.needs).toBe('windows-scope');
expect(shard.steps[0].with).toEqual({ 'persist-credentials': false });
const run = shard.steps.find((s: any) => s.name === 'Run the scoped Windows-safe test files');
expect(run.env.FILES).toBe('${{ inputs.files || needs.windows-scope.outputs.files }}');
expect(run.env.GSTACK_FREE_RETRY_FLAKY).toBe('1');
expect(run.run).toContain('bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-scoped-files.txt"');
expect(scoped.jobs['windows-scoped-tests'].needs).toEqual(['windows-scope', 'windows-scoped-shard']);
});
test('pregate reads the scoped shard as manual, so it adds no remote-clear obligation', () => {
const lanes = parseWorkflow(SCOPED_FILE, scopedText, 'linux').filter(l => l.runsTests);
expect(lanes.map(l => [l.job, l.manual])).toEqual([['windows-scoped-shard', true]]);
});
test("#3758's and #3829's failing test files are in the curated Windows-safe subset", () => {
const { safe } = curateWindowsSafe(collectFreeTestFiles(ROOT), ROOT);
expect(safe).toContain('test/hook-scripts.test.ts');
expect(safe).toContain('test/review-risk-linear.test.ts');
});
const CAN_SELECT = CAN_RUN && !!Bun.which('jq');
/** Filter, then select with a stub bun whose curated plan holds the given files. */
function scopeJob(dir: string, curated: string[]) {
const filter = runStep(filterScript, dir);
expect(filter.status, filter.stderr).toBe(0);
const scope = fs.readFileSync(path.join(filter.temp, 'windows-scope'), 'utf8');
if (filter.outputs.windows !== 'true') return { windows: filter.outputs.windows, scope, files: undefined as string | undefined };
const bin = path.join(filter.temp, 'bin');
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = --ci-plan ] && printf \'%s\' "$PLAN_JSON" > "$2"; shift; done\necho \'{"shard":[1]}\'\n', { mode: 0o755 });
const plan = JSON.stringify({ version: 1, revision: 'r', shards: [{ shard: 1, predictedMs: 1, files: curated }], id: 'x' });
const select = runStep(selectScript, dir, { PATH: `${toShell(bin)}:${process.env.PATH}`, PLAN_JSON: plan }, filter.temp);
expect(select.status, select.stderr).toBe(0);
return { windows: filter.outputs.windows, scope, files: select.outputs.files };
}
const CURATED = ['test/hook-scripts.test.ts', 'test/review-risk-linear.test.ts', 'test/review-risk-routing.test.ts'];
test.skipIf(!CAN_SELECT)('the select step reads the real curated plan: a changed Windows-safe test is picked, a POSIX-only one is not', () => {
const temp = newTemp();
fs.writeFileSync(path.join(temp, 'changed-files'), 'freeze/bin/check-freeze.sh\ntest/hook-scripts.test.ts\ntest/windows-path-scope-workflow.test.ts\n');
const select = runStep(selectScript, ROOT, { PATH: `${toShell(path.dirname(process.execPath))}:${process.env.PATH}` }, temp);
expect(select.status, select.stderr).toBe(0);
expect(select.outputs.files).toBe('test/hook-scripts.test.ts');
}, 60_000);
test.skipIf(!CAN_SELECT)('#3758: the hook paths put it in scope and the Windows job runs test/hook-scripts.test.ts', () => {
const dir = prRepo(PR_3758);
try {
const job = scopeJob(dir, CURATED);
expect(job.windows).toBe('true');
expect(job.scope).toContain('path: careful/bin/hook-extract.sh');
expect(job.scope).toContain('path: freeze/bin/check-freeze.sh');
expect(job.files).toBe('test/hook-scripts.test.ts');
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test.skipIf(!CAN_SELECT)('#3829: no path names it; the spawn line it added puts it in scope and the job runs its changed tests', () => {
const dir = prRepo(PR_3829, { 'test/review-risk-linear.test.ts': LINE_3829 });
try {
const job = scopeJob(dir, CURATED);
expect(job.windows).toBe('true');
expect(job.scope.trim().split('\n')).toEqual([`content: test/review-risk-linear.test.ts: +${LINE_3829}`]);
expect(job.files).toBe('test/review-risk-linear.test.ts\ntest/review-risk-routing.test.ts');
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test.skipIf(!CAN_SELECT)('negative controls: a docs-and-stamp change and a pure-logic change start no Windows job', () => {
for (const [files, lines] of [
[['CHANGELOG.md', 'VERSION', 'package.json', 'docs/support-tiers.md', 'changelog.d/x.md'], {}],
[['lib/review-depth.ts', 'test/review-depth.test.ts'], { 'lib/review-depth.ts': 'export const LIMIT = 3;', 'test/review-depth.test.ts': ' expect(LIMIT).toBe(3);' }],
] as Array<[string[], Record<string, string>]>) {
const dir = prRepo(files, lines);
try {
const job = scopeJob(dir, [...CURATED, 'test/review-depth.test.ts']);
expect([files, job.windows, job.scope]).toEqual([files, 'false', '']);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
});
test.skipIf(!CAN_SELECT)('in scope but no changed Windows-safe test file: no files, so no Windows job', () => {
const dir = prRepo(['bin/gstack-example', 'test/posix-only.test.ts']);
try {
const job = scopeJob(dir, CURATED);
expect([job.windows, job.files]).toEqual(['true', undefined]);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test.skipIf(!Bun.which('bash'))('the aggregate passes when nothing is in scope and otherwise requires the Windows job', () => {
const aggregate = scoped.jobs['windows-scoped-tests'].steps[0];
expect(aggregate.env).toEqual({ SCOPE_RESULT: '${{ needs.windows-scope.result }}', SHARD_RESULT: '${{ needs.windows-scoped-shard.result }}', FILES: '${{ inputs.files || needs.windows-scope.outputs.files }}' });
const status = (env: Record<string, string>) => runStep(aggregate.run, ROOT, env).status;
expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'skipped', FILES: '' })).toBe(0);
expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'success', FILES: 'test/a.test.ts' })).toBe(0);
expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'failure', FILES: 'test/a.test.ts' })).not.toBe(0);
expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'cancelled', FILES: 'test/a.test.ts' })).not.toBe(0);
expect(status({ SCOPE_RESULT: 'failure', SHARD_RESULT: 'skipped', FILES: '' })).not.toBe(0);
});
});