mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 07:47:27 +01:00
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
242 lines
15 KiB
TypeScript
242 lines
15 KiB
TypeScript
/**
|
|
* Derived PR-lane dependencies (scripts/pr-dependencies.ts) and the
|
|
* fail-closed full-gate list (PR_FULL_GATE_FILES): unmapped files select the
|
|
* gate cases whose reference closure reaches them, underivable files and
|
|
* global inputs restore the full gate, and every fallback names its fix.
|
|
*/
|
|
import { 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 { spawnSync } from 'node:child_process';
|
|
import { deriveDependencies, derivedDependencies, referenceText } from '../scripts/pr-dependencies';
|
|
import {
|
|
DERIVABLE_PREFIXES, PERIODIC_ONLY_PR_FILES, PR_FULL_GATE_FILES, PR_PROFILE_MAPS, selectPrProfile, unknownFileLabel,
|
|
} from '../scripts/test-pr-profile';
|
|
import { buildRunManifest, computePaidCaseSelection } from '../scripts/test-paid-shards';
|
|
import { E2E_TIERS } from './helpers/touchfiles-data';
|
|
import { matchGlob } from './helpers/test-selection';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const real = derivedDependencies(PR_PROFILE_MAPS, ROOT);
|
|
const gate = Object.keys(E2E_TIERS).filter(id => E2E_TIERS[id] === 'gate');
|
|
const FIX = 'docs/TESTING_INTERNALS.md#pr-paid-lane-fallback';
|
|
|
|
function fixtureRepo(files: Record<string, string>): string {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-deps-'));
|
|
for (const [file, text] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, file), text);
|
|
}
|
|
for (const args of [['init', '-q'], ['add', '-A']]) {
|
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 10_000 });
|
|
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
describe('reference derivation on a fixture tree', () => {
|
|
const root = fixtureRepo({
|
|
'test/skill-e2e-alpha.test.ts': "import { help } from './helpers/alpha-helper';\nimport { other } from './skill-e2e-beta.test';\n",
|
|
'test/skill-e2e-beta.test.ts': "export const other = 'lib/beta-only.ts';\n",
|
|
'test/helpers/alpha-helper.ts': "import { x } from '../../lib/alpha';\n// prose about lib/commented.ts is not a reference\nexport const help = x;\n",
|
|
'test/helpers/touchfiles-data.ts': "export const MAP = { a: ['lib/listed-only.ts'] };\n",
|
|
'test/helpers/joined.ts': "export const p = path.join(ROOT, 'lib', 'joined.ts');\n", 'lib/joined.ts': '',
|
|
'lib/alpha.ts': "import './../test/helpers/joined';\nexport const x = 1;\n", 'lib/beta-only.ts': '', 'lib/commented.ts': '', 'lib/listed-only.ts': '', 'lib/bin-lib.ts': '', 'lib/unused.ts': '',
|
|
'alpha/SKILL.md.tmpl': '{{GREETING}}\nRun `gstack-tool` then open design/dist/design.\n',
|
|
'bin/gstack-tool': "#!/bin/sh\n# mentions lib/commented.ts only in a comment\nbun run \"$ROOT/lib/bin-lib.ts\"\n",
|
|
'scripts/build.sh': 'bun build --compile design/src/cli.ts --outfile design/dist/design\n',
|
|
'design/src/cli.ts': "import './render';\n", 'design/src/render.ts': '',
|
|
'scripts/resolvers/index.ts': "import { greet } from './greeting';\nexport const RESOLVERS = {\n GREETING: greet,\n};\n",
|
|
'scripts/resolvers/greeting.ts': "import { tone } from './tone';\nexport const greet = () => tone;\n", 'scripts/resolvers/tone.ts': 'export const tone = 1;\n',
|
|
'hosts/acme.ts': '', 'hosts/zeta.ts': '',
|
|
'package.json': '{"scripts":{}}\n',
|
|
});
|
|
const maps = { e2eTouchfiles: { alpha: ['test/skill-e2e-alpha.test.ts', 'alpha/**'], beta: ['test/skill-e2e-beta.test.ts'] }, judgeTouchfiles: {}, globalTouchfiles: [] };
|
|
const derived = deriveDependencies(maps, root);
|
|
const consumers = (file: string) => [...derived.e2e.get(file) ?? []].sort();
|
|
|
|
test('imports, bin invocations, compiled binaries and template placeholders all reach their files', () => {
|
|
expect(consumers('lib/alpha.ts')).toEqual(['alpha']);
|
|
expect(consumers('lib/joined.ts')).toEqual(['alpha']);
|
|
expect(consumers('bin/gstack-tool')).toEqual(['alpha']);
|
|
expect(consumers('lib/bin-lib.ts')).toEqual(['alpha']);
|
|
expect(consumers('design/src/render.ts')).toEqual(['alpha']);
|
|
expect(consumers('scripts/resolvers/tone.ts')).toEqual(['alpha']);
|
|
expect(derived.trace('alpha', 'scripts/resolvers/tone.ts')).toEqual(['alpha/SKILL.md.tmpl', 'scripts/resolvers/greeting.ts', 'scripts/resolvers/tone.ts']);
|
|
});
|
|
|
|
test('comments, selection data and other paid test files are not references', () => {
|
|
expect(consumers('lib/commented.ts')).toEqual([]);
|
|
expect(consumers('lib/listed-only.ts')).toEqual([]);
|
|
expect(consumers('lib/beta-only.ts')).toEqual(['beta']);
|
|
expect(consumers('lib/unused.ts')).toEqual([]);
|
|
expect(referenceText('a.ts', ' // lib/x.ts\nconst y = "lib/z.ts"; // trailing lib/w.ts\n')).toContain('lib/z.ts');
|
|
expect(referenceText('a.ts', ' // lib/x.ts\n')).not.toContain('lib/x.ts');
|
|
expect(referenceText('bin/x', '# lib/x.ts\necho lib/y.ts\n')).toBe('\necho lib/y.ts\n');
|
|
expect(referenceText('data.json', '{"a":"lib/x.ts"}')).toBe('');
|
|
});
|
|
|
|
test('trace names the shortest chain: a template that invokes a bin directly wins over a long import path to it', () => {
|
|
const repo = fixtureRepo({
|
|
'test/skill-e2e-gamma.test.ts': '', 'gamma/SKILL.md.tmpl': 'Run bin/gstack-target.\n', 'gamma/sections/a.md.tmpl': 'See lib/one.ts.\n',
|
|
'lib/one.ts': "import './two';\n", 'lib/two.ts': "import './three';\n", 'lib/three.ts': "export const x = 'bin/gstack-target';\n",
|
|
'bin/gstack-target': '#!/bin/sh\n', 'package.json': '{}',
|
|
});
|
|
const traced = deriveDependencies({ e2eTouchfiles: { gamma: ['test/skill-e2e-gamma.test.ts', 'gamma/**'] }, judgeTouchfiles: {}, globalTouchfiles: [] }, repo);
|
|
expect(traced.trace('gamma', 'bin/gstack-target')).toEqual(['gamma/SKILL.md.tmpl', 'bin/gstack-target']);
|
|
expect(traced.trace('gamma', 'lib/three.ts')).toEqual(['gamma/sections/a.md.tmpl', 'lib/one.ts', 'lib/two.ts', 'lib/three.ts']);
|
|
});
|
|
|
|
test('a host config feeds only cases whose test names the host', () => {
|
|
const named = deriveDependencies({ ...maps, e2eTouchfiles: { ...maps.e2eTouchfiles, beta: ['test/skill-e2e-beta.test.ts'] } },
|
|
fixtureRepo({ 'test/skill-e2e-beta.test.ts': "run({ host: 'acme' });\n", 'hosts/acme.ts': '', 'hosts/zeta.ts': '', 'scripts/build.sh': '', 'scripts/resolvers/index.ts': '', 'package.json': '{}' }));
|
|
expect([...named.e2e.get('hosts/acme.ts') ?? []]).toEqual(['beta']);
|
|
expect(named.e2e.get('hosts/zeta.ts')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('derived dependencies of this checkout', () => {
|
|
test('a bin script a skill template invokes selects that skill\'s cases', () => {
|
|
const consumers = real.e2e.get('bin/gstack-next-version') ?? new Set();
|
|
expect(consumers.has('review-sql-injection')).toBe(true);
|
|
expect(real.trace('review-sql-injection', 'bin/gstack-next-version')?.[0]).toBe('review/SKILL.md.tmpl');
|
|
});
|
|
|
|
test('a compiled design source reaches exactly the cases whose closure names the design binary', () => {
|
|
const consumers = [...real.e2e.get('design/src/evolve.ts') ?? []];
|
|
expect(consumers.length).toBeGreaterThan(0);
|
|
expect(consumers.length).toBeLessThan(Object.keys(E2E_TIERS).length);
|
|
for (const id of consumers.slice(0, 5)) expect(real.trace(id, 'design/src/evolve.ts')?.some(file => file === 'design/src/cli.ts' || file.startsWith('design/src/'))).toBe(true);
|
|
});
|
|
|
|
test('the paid runner\'s import closure is global, and only that', () => {
|
|
for (const file of ['scripts/test-paid-shards.ts', 'scripts/e2e-shard-reuse.ts', 'test/helpers/session-runner.ts', 'test/helpers/eval-store.ts', 'scripts/lib/shard-engine.ts']) {
|
|
expect(real.global.has(file), file).toBe(true);
|
|
}
|
|
expect(real.global.has('bin/gstack-next-version')).toBe(false);
|
|
expect(real.global.size).toBeLessThan(120);
|
|
});
|
|
});
|
|
|
|
describe('PR selection with derived dependencies', () => {
|
|
const select = (changedFiles: string[]) => computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles, packageVersionOnly: true });
|
|
|
|
test('an unmapped file with derived consumers runs every dependent gate case and nothing else', () => {
|
|
const result = select(['design/src/evolve.ts']);
|
|
expect(result.coverage?.mode).toBe('dependents');
|
|
const expected = gate.filter(id => real.e2e.get('design/src/evolve.ts')?.has(id)).sort();
|
|
expect(result.selection.e2e).toEqual(expected);
|
|
expect(result.coverage?.derivedFiles).toEqual([{ file: 'design/src/evolve.ts', e2e: real.e2e.get('design/src/evolve.ts')!.size, judges: real.judges.get('design/src/evolve.ts')?.size ?? 0 }]);
|
|
const manifest = buildRunManifest({ tier: 'gate', profile: 'pr', sliceCount: 2, evalsAll: false, env: {}, changedFiles: ['design/src/evolve.ts'] });
|
|
expect(manifest.prCoverage?.mode).toBe('dependents');
|
|
expect(manifest.entries.some(entry => entry.status === 'planned')).toBe(true);
|
|
});
|
|
|
|
test('a tracked file no paid case reaches is exempt; periodic-only workflows never run the PR gate', () => {
|
|
const result = select(['scripts/typecheck-test.ts', ...PERIODIC_ONLY_PR_FILES]);
|
|
expect(result.coverage?.mode).toBe('pr');
|
|
expect(result.coverage?.noConsumerFiles).toEqual(['scripts/typecheck-test.ts']);
|
|
expect(result.selection).toEqual({ e2e: [], judges: [] });
|
|
});
|
|
|
|
test('a paid-runner import selects every case, as a global touchfile does', () => {
|
|
const viaClosure = select(['scripts/e2e-shard-reuse.ts']);
|
|
const viaGlobal = select(['scripts/test-paid-shards.ts']);
|
|
expect(viaClosure.coverage?.mode).toBe('pr');
|
|
expect(viaClosure.selection).toEqual(viaGlobal.selection);
|
|
});
|
|
|
|
test('derivation is skipped for fixture maps unless passed explicitly', () => {
|
|
const result = selectPrProfile({ maps: { ...PR_PROFILE_MAPS, e2eTouchfiles: { ...PR_PROFILE_MAPS.e2eTouchfiles } }, selectedE2E: [], selectedJudges: [], changedFiles: ['design/src/evolve.ts'] });
|
|
expect(result.mode).toBe('full-fallback');
|
|
});
|
|
});
|
|
|
|
describe('the fail-closed full-gate list', () => {
|
|
const tracked = [...real.tracked];
|
|
|
|
test('every entry matches a tracked file', () => {
|
|
const stale = PR_FULL_GATE_FILES.filter(pattern => !tracked.some(file => matchGlob(file, pattern)));
|
|
expect(stale.map(pattern => `${pattern}: matches no tracked file. Fix: delete it from PR_FULL_GATE_FILES in scripts/test-pr-profile.ts (${FIX})`)).toEqual([]);
|
|
});
|
|
|
|
test('every entry restores the full gate even when a touchfile or a derived reference also names it', () => {
|
|
for (const pattern of PR_FULL_GATE_FILES) {
|
|
const file = tracked.find(candidate => matchGlob(candidate, pattern))!;
|
|
const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: [file], packageVersionOnly: false });
|
|
expect({ file, mode: result.coverage?.mode }).toEqual({ file, mode: 'full-fallback' });
|
|
expect(result.selection.e2e).toEqual(gate.sort());
|
|
}
|
|
});
|
|
|
|
test('the setup, preamble, eval policy and PR workflow inputs are on it', () => {
|
|
for (const file of ['setup', 'scripts/resolvers/preamble.ts', 'test/helpers/periodic-exclude-data.ts', '.github/workflows/evals.yml', 'bun.lock']) {
|
|
expect(PR_FULL_GATE_FILES.some(pattern => matchGlob(file, pattern)), file).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('a file the derivation cannot place restores the full gate with its fix', () => {
|
|
for (const file of ['ETHOS.md', 'conductor.json']) {
|
|
const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: [file], packageVersionOnly: true });
|
|
expect(result.coverage?.mode, file).toBe('full-fallback');
|
|
const label = result.coverage!.unknownFileLabels[0]!;
|
|
expect(label.file).toBe(file);
|
|
expect(label.fix).toContain(file);
|
|
expect(label.fix).toContain('touchfiles-data.ts');
|
|
expect(label.fix).toContain('DERIVABLE_PREFIXES');
|
|
}
|
|
expect(DERIVABLE_PREFIXES).not.toContain('test/fixtures/');
|
|
});
|
|
|
|
});
|
|
|
|
describe('deleted files (paths absent from the head tree)', () => {
|
|
const without = (file: string) => ({ ...real, tracked: new Set([...real.tracked].filter(other => other !== file)) });
|
|
const pick = (file: string, derived = without(file)) => selectPrProfile({ selectedE2E: [], selectedJudges: [], changedFiles: [file], derived });
|
|
|
|
test('a deleted file nothing references and no touchfile matches has no dependents', () => {
|
|
const result = pick('scripts/retired-tool-nobody-calls.ts', real);
|
|
expect(result.mode).toBe('pr');
|
|
expect(result.noConsumerFiles).toEqual(['scripts/retired-tool-nobody-calls.ts']);
|
|
expect(result.e2e).toEqual([]);
|
|
});
|
|
|
|
test('a deleted file a touchfile matches selects that touchfile\'s cases', () => {
|
|
const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: ['browse/src/retired-module.ts'], packageVersionOnly: true });
|
|
expect(result.coverage?.unknownFiles).toEqual([]);
|
|
expect(result.coverage?.mode).toBe('pr');
|
|
expect(result.selection.e2e).toContain('browse-basic');
|
|
});
|
|
|
|
test('a deleted file live code still references selects the referencing files\' dependents', () => {
|
|
const result = pick('bin/gstack-next-version');
|
|
expect(result.mode).toBe('dependents');
|
|
expect(result.e2e).toContain('review-sql-injection');
|
|
expect(result.derivedFiles[0]?.file).toBe('bin/gstack-next-version');
|
|
expect(real.referencers('bin/gstack-next-version')).toContain('review/SKILL.md.tmpl');
|
|
});
|
|
|
|
test('a deleted file a full-gate input still references, or a deleted full-gate entry, restores the full gate', () => {
|
|
expect(real.referencers('bin/gstack-install-registry.sh')).toContain('setup');
|
|
const referenced = pick('bin/gstack-install-registry.sh');
|
|
expect(referenced.mode).toBe('full-fallback');
|
|
expect(referenced.unknownFileLabels[0]).toMatchObject({ file: 'bin/gstack-install-registry.sh', label: 'deleted but still referenced' });
|
|
expect(referenced.unknownFileLabels[0]!.fix).toContain('setup');
|
|
expect(pick('bunfig.toml').mode).toBe('full-fallback');
|
|
});
|
|
|
|
test('comments and data files never count as references to a deleted path', () => {
|
|
const root = fixtureRepo({
|
|
'test/skill-e2e-alpha.test.ts': "// lib/gone.ts used to live here\nimport './helpers/h';\n",
|
|
'test/helpers/h.ts': "export const data = 'x';\n", 'data/list.json': '["lib/gone.ts"]',
|
|
'scripts/build.sh': '', 'scripts/resolvers/index.ts': '', 'package.json': '{}',
|
|
});
|
|
const derived = deriveDependencies({ e2eTouchfiles: { alpha: ['test/skill-e2e-alpha.test.ts'] }, judgeTouchfiles: {}, globalTouchfiles: [] }, root);
|
|
expect(derived.referencers('lib/gone.ts')).toEqual([]);
|
|
const relative = fixtureRepo({ 'test/skill-e2e-alpha.test.ts': "import './helpers/gone';\n", 'scripts/build.sh': '', 'scripts/resolvers/index.ts': '', 'package.json': '{}' });
|
|
expect(deriveDependencies({ e2eTouchfiles: { alpha: ['test/skill-e2e-alpha.test.ts'] }, judgeTouchfiles: {}, globalTouchfiles: [] }, relative)
|
|
.referencers('test/helpers/gone.ts')).toEqual(['test/skill-e2e-alpha.test.ts']);
|
|
});
|
|
});
|