Files
gstack/test/pregate-always-run.test.ts
T
Garry Tanandcapy-ai[bot] a60c99dbd7 test-impact: tracer and reuse keys for free tests; pregate keeps main's always-run list (GST-167 PR 2, GST-233) (#3801)
* test-impact: tracer and reuse keys for free tests; pregate keeps main's always-run list

GST-167 PR 2: lib/test-impact/{trace,key}.ts and scripts/test-impact.ts trace a free test file under strace and key it at a tree over its read set, import closure, lockfile, configs, runner and key code and runtime; anything uncertain is ineligible. Nothing in CI changes yet.

GST-233: pregate unions the merge base's .gstack/always-run.json with the branch's, so a branch cannot drop main's whole-tree tests.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (untracked reads ineligible, tool bytes and runner imports in the key)

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

* fix: adversarial review fixes (creation proof, PATH reads, bound loopback endpoints, unix sockets, env and runtime binding, future traces)

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

* fix: graph checkout ignores the tree's export attributes; the tracer runs the free runner's own command and sandbox

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

* fix: write the graph's tree copy blob by blob so .gitattributes can't rewrite or hide files

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

* fix: refuse unsafe tree paths before materializeTree writes; trace the CLI key test on the real runtime

materializeTree now checks every path before writing: a .., . or .git segment, an empty segment, a backslash, a duplicate path or an entry under a symlink entry refuses the tree, and symlinks are written last. Failing-first tests on 89d4728 showed a '..' tree writing into os.tmpdir() and a symlink-then-blob tree writing through the link.

The key --base CLI test traced under a hard-coded bun 1.4.4 while the CLI keys on the real runtime (CI runs bun 1.4.2), so the runtime check added in 6b4e6c5 made it ineligible; it now traces on the real runtime at the current time.

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

---------

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

97 lines
5.3 KiB
TypeScript

/**
* GST-167 PR 1 (GST-98): whole-tree tests named in .gstack/always-run.json join
* every selection that runs a test, because no touched file maps to a scanner
* that reads the whole tree. #3592 and #3593 passed their selected gate and
* then failed CI on exactly these tests.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { ALWAYS_RUN_PATH, alwaysRunTests } from '../lib/pregate/lane-selection';
import { listRepoFiles, isTestFile } from '../lib/pregate/import-graph';
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const SCANNER = "const test = require('node:test');\ntest('scans the tree', () => {});\n";
const repos: FixtureRepo[] = [];
const fixture = (alwaysRun: unknown) => {
const r = makeFixtureRepo({ baseFiles: { 'test/tree-tripwire.test.js': SCANNER, ...(alwaysRun === undefined ? {} : { [ALWAYS_RUN_PATH]: typeof alwaysRun === 'string' ? alwaysRun : JSON.stringify(alwaysRun) }) } });
repos.push(r);
return r;
};
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const lanes = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--only', 'lanes', '--explain']);
describe('always_run selection', () => {
test('a code change adds the listed scanner the import graph never selects; without the file it is not added', () => {
const repo = fixture({ tests: ['test/*-tripwire.test.js', 'test/missing-*.test.js'] });
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
const r = lanes(repo);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('test/util.test.js ← lib/util.js');
expect(r.out).toContain(`test/tree-tripwire.test.js (always_run, ${ALWAYS_RUN_PATH})`);
expect(r.out).toContain(`${ALWAYS_RUN_PATH}: test/missing-*.test.js matches no test file`);
const bare = fixture(undefined);
commit(bare, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
const b = lanes(bare);
expect(b.status).toBe(EXIT.ok);
expect(b.out).toContain('test/util.test.js ← lib/util.js');
expect(b.out).not.toContain('always_run');
});
test('a docs-only branch adds nothing; editing the list itself runs it and owes no lane', () => {
const docs = fixture({ tests: ['test/*-tripwire.test.js'] });
commit(docs, { 'docs/README.md': '# docs\n\nupdated\n' });
const d = lanes(docs);
expect(d.status).toBe(EXIT.ok);
expect(d.out).not.toContain('always_run');
const list = fixture({ tests: ['test/*-tripwire.test.js'] });
commit(list, { [ALWAYS_RUN_PATH]: JSON.stringify({ tests: ['test/tree-*.test.js'] }) });
const l = lanes(list);
expect(l.status).toBe(EXIT.ok);
expect(l.out).toContain(`${ALWAYS_RUN_PATH}: pin file validated by pregate`);
expect(l.out).toContain(`test/tree-tripwire.test.js (always_run, ${ALWAYS_RUN_PATH})`);
expect(l.err).not.toContain('PREGATE_NO_LANE');
});
test('GST-233: a branch that drops a glob from the list still runs the merge base\'s entry', () => {
const repo = fixture({ tests: ['test/*-tripwire.test.js'] });
commit(repo, { [ALWAYS_RUN_PATH]: JSON.stringify({ tests: [] }), 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
const r = lanes(repo);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain(`test/tree-tripwire.test.js (always_run, merge base ${ALWAYS_RUN_PATH})`);
const gone = fixture({ tests: ['test/*-tripwire.test.js'] });
commit(gone, { [ALWAYS_RUN_PATH]: null, 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
expect(lanes(gone).out).toContain(`test/tree-tripwire.test.js (always_run, merge base ${ALWAYS_RUN_PATH})`);
});
test('a malformed list stops with a message naming the file and the fix', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-always-run-'));
try {
fs.mkdirSync(path.join(dir, '.gstack'));
fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), '{ not json');
expect(() => alwaysRunTests(dir, [])).toThrow(`${ALWAYS_RUN_PATH}: not valid JSON`);
fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), JSON.stringify({ tests: ['ok', ''] }));
expect(() => alwaysRunTests(dir, [])).toThrow('"tests" must be a list of non-empty glob strings');
fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), JSON.stringify({ tests: 'test/*.ts' }));
expect(() => alwaysRunTests(dir, [])).toThrow('"tests" must be a list of non-empty glob strings');
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test("gstack's own list: every glob names a tracked test, and the GST-98 scanners are on it", () => {
const tracked = listRepoFiles(ROOT).filter(isTestFile);
const own = alwaysRunTests(ROOT, tracked);
expect(own.unmatched).toEqual([]);
for (const t of ['test/spawnsync-timeout-tripwire.test.ts', 'test/script-invocation-tripwire.test.ts', 'test/egress-receipt-wiring.test.ts',
'test/env-host-fence-conformance.test.ts', 'test/free-fixtures.test.ts', 'test/bun-entry-isolation.test.ts', 'test/module-size-ratchet.test.ts']) {
expect(own.tests).toContain(t);
}
expect(own.tests).not.toContain('test/host-conformance.test.ts');
});
});