Files
gstack/lib/pregate/import-graph.ts
T
Garry Tanandcapy-ai[bot] 8584d6ff49 v1.91.77.0 feat(ship): two-stage gstack-pregate, gstack-regen generated-file registry, intent-based issue links (multi-agent wave C-pregate) (#3121)
* v1.91.77.0 feat(ship): two-stage gstack-pregate, gstack-regen registry, intent-based issue links (multi-agent wave C-pregate)

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

* test(pregate): keep the bin-driving pregate tests off the Windows subset; pin files are free-only for eval selection

The three C-pregate tests that spawn bin/gstack-regen, bin/gstack-pregate,
bin/gstack-issue-links and bin/gstack-ship-receipt do so through
test/helpers/pregate-fixture.ts runBin, so the Windows curation's source
scan of the test file never saw a bin/ spawn and shipped them to
windows-latest, where CreateProcess cannot exec a shebang (14 failures in
windows-free-shard 2 and 5). Each is now a KNOWN_WINDOWS_INCOMPATIBLE entry
with the precise reason. .gstack/generated.json and .gstack/pregate.json
join FREE_ONLY_PR_FILES: no paid case reads gstack's own pin files (the
tools read origin/<base> of the repo they run in, and paid cases run in
fixture repos), so touching them no longer restores every gate case.

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

* test(harness-version): record the FREE_ONLY_PR_FILES pin-file entries as non-behavioral

scripts/test-pr-profile.ts is a pinned shared-harness file; the two
.gstack pin-file entries change selection only, never a case verdict.

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-10-11 01:57:16 +00:00

166 lines
9.5 KiB
TypeScript

/**
* pregate/import-graph — which test files depend on which repo files (plan
* C8 `lanes`). The reverse graph unions four sources, each kept as provenance
* for `--explain`:
* 1. static imports and requires (Bun.Transpiler.scanImports);
* 2. repo-path string literals, including `join('a', 'b')` segment runs
* (a test that spawns `bin/gstack-doctor` names it in a string, never an
* import) and literal directories (`test/fixtures/x` → `x/**`);
* 3. the `bin/<name>` → `test/<name>*.test.*` convention (with and without
* the `gstack-` prefix);
* 4. explicit `.gstack/pregate.json` `dependencies` declarations.
* Uncertainty selects more tests, never fewer: a dependency on a directory
* selects every test under it; a touched file with no path to a test is the
* caller's `no lane` failure, not a pass. test/helpers/touchfile-closure.ts
* (helpers and fixtures only, a lower bound by its own words) is the precedent.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { matchesAny } from '../ship-policy';
export const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|sh|bash|go|rs)$/;
export const TEST_FILE_RE = /(?:^|\/)(?:test|tests|spec|__tests__)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb)$|_spec\.rb$/;
export interface Edge { from: string; via: 'import' | 'literal' | 'convention' | 'declared' | 'generated' }
export interface Graph {
files: Set<string>;
tests: Set<string>;
/** dependency → dependents with the kind of edge. */
reverse: Map<string, Edge[]>;
}
const scanner = new Bun.Transpiler({ loader: 'tsx' });
const QUOTED = /['"`]([A-Za-z0-9_@][A-Za-z0-9_./@-]*)['"`]/g;
const JOIN = /\bjoin\(([^()]*)\)/g;
const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.mjs', '.cjs', '/index.ts', '/index.js'];
export function isTestFile(rel: string): boolean { return TEST_FILE_RE.test(rel); }
export function listRepoFiles(repoRoot: string): string[] {
const r = spawnSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
return (r.stdout ?? '').split('\0').filter(p => p && !p.startsWith('node_modules/'));
}
function normalize(p: string): string { return path.posix.normalize(p.replace(/\\/g, '/')).replace(/^\.\//, ''); }
/** A module specifier or literal → the repo file it names, or a directory → `dir/**`, or null. */
function resolveRef(files: Set<string>, dirs: Set<string>, fromFile: string, ref: string, literal = false): string | null {
if (!ref || ref.startsWith('node:') || ref.startsWith('bun:') || ref.startsWith('@') && !ref.includes('/')) return null;
const candidates: string[] = [];
if (ref.startsWith('.')) candidates.push(normalize(path.posix.join(path.posix.dirname(fromFile), ref)));
else candidates.push(normalize(ref));
for (const c of candidates) {
if (c.startsWith('../') || c === '..' || c === '.' || c === '') continue;
for (const ext of RESOLVE_EXT) if (files.has(c + ext)) return c + ext;
// A directory literal needs two segments (`test/fixtures/x`): a bare word is a name, not a path.
if (dirs.has(c) && (!literal || c.includes('/'))) return `${c}/**`;
// A bare bin name in a test (spawned through PATH or a helper's bin dir) names bin/<name>.
if (literal && !c.includes('/') && files.has(`bin/${c}`)) return `bin/${c}`;
}
return null;
}
function addEdge(graph: Graph, dependency: string, dependent: string, via: Edge['via']): void {
if (dependency === dependent) return;
const list = graph.reverse.get(dependency) ?? [];
if (!list.some(e => e.from === dependent && e.via === via)) { list.push({ from: dependent, via }); graph.reverse.set(dependency, list); }
}
/** Every dependency reference one source file makes: imports, requires, quoted repo paths and join() segment runs. */
export function referencesIn(files: Set<string>, dirs: Set<string>, rel: string, source: string): Array<{ target: string; via: Edge['via'] }> {
const out: Array<{ target: string; via: Edge['via'] }> = [];
if (/\.[cm]?[jt]sx?$/.test(rel)) {
let imports: Array<{ path: string }> = [];
try { imports = scanner.scanImports(source); } catch { imports = []; }
for (const { path: spec } of imports) { const t = resolveRef(files, dirs, rel, spec); if (t) out.push({ target: t, via: 'import' }); }
// The transpiler erases type-only imports; a types module still binds its importers' contracts.
for (const m of source.matchAll(/^\s*(?:import|export)\s+type\b[^;'"]*?\bfrom\s+['"]([^'"]+)['"]/gm)) { const t = resolveRef(files, dirs, rel, m[1]!); if (t) out.push({ target: t, via: 'import' }); }
}
// Literals carry dependencies only from the test tree and from bin/ and shell
// sources (a test spawns a bin; a bash bin calls a sibling). Prose and
// manifests name everything and would make every file a hub.
if (!scansLiterals(rel)) return out;
for (const m of source.matchAll(QUOTED)) { const t = resolveRef(files, dirs, rel, m[1]!, true); if (t) out.push({ target: t, via: 'literal' }); }
for (const m of source.matchAll(JOIN)) {
const segments = [...m[1]!.matchAll(/['"`]([^'"`]+)['"`]/g)].map(s => s[1]!);
if (segments.length < 2) continue;
const joined = segments.join('/');
const t = resolveRef(files, dirs, rel, joined, true) ?? resolveRef(files, dirs, rel, './' + joined, true);
if (t) out.push({ target: t, via: 'literal' });
}
return out;
}
/** Test-tree files (tests, helpers, fixtures), bin/ entrypoints and shell sources carry literal dependencies. */
export function scansLiterals(rel: string): boolean {
return /(?:^|\/)(?:test|tests|spec|__tests__)\//.test(rel) || isTestFile(rel) || rel.startsWith('bin/') || /\.(?:sh|bash)$/.test(rel);
}
export interface BuildOptions {
declarations?: Record<string, string[]>; maxBytes?: number;
/** Paths that no longer exist (deleted or renamed-from) but that tests may still name; they resolve as dependencies so their consumers are found. */
extraFiles?: string[];
}
/** The whole-repo reverse graph. Files over maxBytes (default 2 MiB) are skipped: generated bundles, not tests. */
export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph {
const all = listRepoFiles(repoRoot);
const files = new Set([...all, ...(opts.extraFiles ?? [])]);
const dirs = new Set<string>();
for (const f of all) { let d = path.posix.dirname(f); while (d && d !== '.') { dirs.add(d); d = path.posix.dirname(d); } }
const graph: Graph = { files, tests: new Set(all.filter(isTestFile)), reverse: new Map() };
const maxBytes = opts.maxBytes ?? 2 * 1024 * 1024;
for (const rel of all) {
if (!SOURCE_EXT.test(rel) && !rel.startsWith('bin/')) continue;
let source: string;
try { const st = fs.statSync(path.join(repoRoot, rel)); if (!st.isFile() || st.size > maxBytes) continue; source = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
for (const { target, via } of referencesIn(files, dirs, rel, source)) addEdge(graph, target, rel, via);
}
for (const test of graph.tests) {
const m = /^(?:[\w.-]+\/)*?test\/([\w-]+)\.(?:test|spec)\./.exec(test);
if (!m) continue;
// test/gstack-ship-receipt-census.test.ts names bin/gstack-ship-receipt-census, then bin/gstack-ship-receipt, then bin/gstack-ship, …
const parts = m[1]!.split('-');
for (let n = parts.length; n >= 1; n--) {
const name = parts.slice(0, n).join('-');
for (const bin of [`bin/${name}`, `bin/gstack-${name}`, `bin/${name}.ts`, `bin/gstack-${name}.ts`]) if (files.has(bin)) addEdge(graph, bin, test, 'convention');
}
}
for (const [glob, tests] of Object.entries(opts.declarations ?? {})) {
for (const f of files) if (matchesAny([glob], f)) for (const t of tests) addEdge(graph, f, t, 'declared');
}
return graph;
}
export interface LaneMapping { file: string; tests: string[]; provenance: string[]; declaredNone: boolean }
/**
* Tests that depend on `file`, transitively through non-test files and
* through `dir/**` edges. `declarations` with an empty list declare "no lane
* needed" and win when nothing else maps.
*/
export function testsFor(graph: Graph, file: string, declarations: Record<string, string[]> = {}, maxDepth = 12): LaneMapping {
const tests = new Set<string>();
const provenance: string[] = [];
const seen = new Set<string>([file]);
const queue: Array<{ node: string; chain: string[] }> = [{ node: file, chain: [file] }];
const dirEdges = [...graph.reverse.keys()].filter(k => k.endsWith('/**'));
while (queue.length) {
const { node, chain } = queue.shift()!;
if (chain.length > maxDepth) continue;
const edges = [...(graph.reverse.get(node) ?? [])];
for (const dir of dirEdges) if (node.startsWith(dir.slice(0, -3) + '/')) edges.push(...(graph.reverse.get(dir) ?? []).map(e => ({ ...e, via: e.via })));
for (const e of edges) {
if (seen.has(e.from)) continue;
seen.add(e.from);
if (graph.tests.has(e.from)) { tests.add(e.from); provenance.push(`${e.from} ← ${[...chain].reverse().join(' ← ')} (${e.via})`); }
else queue.push({ node: e.from, chain: [...chain, e.from] });
}
}
if (graph.tests.has(file)) { tests.add(file); provenance.push(`${file} (itself a test)`); }
const declaredNone = tests.size === 0 && Object.entries(declarations).some(([glob, list]) => list.length === 0 && matchesAny([glob], file));
if (declaredNone) provenance.push(`${file}: declared no lane needed (.gstack/pregate.json dependencies)`);
return { file, tests: [...tests].sort(), provenance, declaredNone };
}