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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 14:54:28 +00:00
1 parent 83ae96e086
commit 32c8a85005
13 files changed
+1061 -12

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
- **Test impact: tracer and reuse keys (GST-167, part 2).** New `scripts/test-impact.ts` traces a free test file under strace and computes a key over everything it read, its import graph, `bun.lock`, tsconfig, pregate dependencies, the runner and key code, and the runtime. `explain` shows each input and why a file can or can't be reused. Network, real HOME, shared /tmp and `.git` reads, missing or week-old traces, always-run tests, import chains past 12 hops, and any change to the key code make a file ineligible. Nothing in CI changes yet. See docs/test-impact.md.
- **Pregate keeps main's always-run entries (GST-233).** The `lanes` selection now unions `.gstack/always-run.json` from the merge base with the branch's copy, so a branch can't drop a whole-tree test by deleting its glob.
+2
View File
@@ -154,6 +154,8 @@ Review each entry before committing the file on the base branch.
`{ "tests": [globs] }` names whole-tree tests that scan every file of a kind (tripwires, ratchets, conformance and fixture inventories). A diff-selected run never maps a touched file to them, so they used to pass the selected gate and then fail CI (GST-98). When a branch selects any test, owes a lane, or edits this file, the `lanes` selection adds every tracked test file a glob matches. `--explain` prints each one as `<test> (always_run, .gstack/always-run.json)`, and a glob that matches no test as `<glob> matches no test file`. A docs-only branch adds nothing.
The globs are the union of the working tree's list and the merge base's list (GST-233), so a branch can add entries but never drop one main already has. A test only the merge base's list names prints as `<test> (always_run, merge base .gstack/always-run.json)`. `scripts/test-impact.ts` takes the same union with its `--base` ref and never reuses a result for those tests ([test-impact.md](test-impact.md)).
The list lives in its own file, not in `pregate.json`, because older pregate readers reject unknown keys there. A repository that ignores `.gstack/` (gstack does) tracks the file with `git add -f`, as it does `pregate.json`. A missing file means an empty list. A file that is not JSON, or whose `tests` is not a list of non-empty strings, stops the check with that message. gstack's own list matches 16 test files; the 13 with a seed in `scripts/free-test-durations.json` (recorded 2026-10-19) total about 13 s there, and `bun-entry-isolation`, `launch-env-echo-ratchet` and `review-packets-removed` have no seed yet.
## `gstack-pregate`
+40
View File
@@ -0,0 +1,40 @@
# Test impact: traced reuse keys for free tests
GST-167 is the plan to skip a free test on a pull request only when main already passed that exact test with the exact same inputs. PR 2 adds the pieces that decide what "the same inputs" means. Nothing here changes what CI runs yet: PR 3 records main receipts and reports in shadow, and PR 4 is the first change that would skip anything.
## Commands
`scripts/test-impact.ts` is a Bun CLI:
- `trace [--out <dir>] <test-file>...` runs each file with `bun test` under strace and writes its read set to `<dir>` (default `.context/test-impact/traces`). It needs Linux with strace installed.
- `key --tree <ref> [--base <ref>] [--traces <dir>] [--json] <test-file>...` prints each file's key at `<ref>`, or why it has none.
- `explain [--tree <ref>] [--base <ref>] [--traces <dir>] <test-file>` prints every input of one file's key and why it is or isn't eligible.
With `--base`, the CLI loads `lib/test-impact/key.ts` from that ref and runs it over the tree, so a branch can't change the rules that decide what it skips. Without `--base`, it uses this checkout's code and says so. A base ref with no key code makes every file ineligible.
## What the trace records
The recorder runs `strace -f -y -qq -v` with `openat, open, execve, execveat, getdents64, readlink, readlinkat, stat, lstat, newfstatat, statx, access, faccessat, faccessat2, chdir, fchdir, clone, clone3, fork, vfork, connect, bind, sendto, getsockname` (strace 6.8 on x86_64 Linux accepts the whole set) and gives the run a private TMPDIR. `lib/test-impact/trace.ts` keeps every repo path the test or any child process opened, ran, checked or probed. A failed open counts, because a file that shows up later changes what the test sees. For each repo directory it listed, the trace keeps the sorted names. Paths outside the repo, in /proc and /dev, under node_modules and in the private TMPDIR are dropped.
## What the key covers
`lib/test-impact/key.ts` reads everything at the given tree through git, never from the working tree. The key is a sha256 over:
- each traced path's bytes (read with `git cat-file --batch`), or that it is a directory, or that it is absent;
- the names in each traced directory listing, at the tree;
- the test's forward import-graph closure from `lib/pregate/import-graph.ts`, up to 12 hops;
- `bun.lock`, every `tsconfig*.json` and the `dependencies` in `.gstack/pregate.json`;
- the key code itself: the tracer, the key, the CLI, the free-test runner and planner (`scripts/test-free-shards.ts`), the shard engine, the import graph and `lib/pregate/lane-selection.ts`;
- the runtime (platform, architecture, Bun version) and the values of `CI`, `GITHUB_ACTIONS`, `TZ`, `LANG`, `LC_ALL`, `NODE_ENV`, `EVALS` and `EVALS_TIER`.
## What is never reused
A file is ineligible, so it always runs, when:
- it is on the always-run list in the base branch's `.gstack/always-run.json` or the tree's (the union, so a branch can add entries but never drop main's);
- it has no trace, a trace older than 7 days, or a trace whose run failed;
- its trace shows an inet connect or send to anything other than a loopback port the same run opened, any access under the real HOME other than a PATH tool lookup, shared /tmp state, or a read of the repository's own `.git`;
- its import-graph walk reaches the 12-hop cap. The walk fails closed: a deeper chain makes the test ineligible instead of cutting the closure short;
- the tree changes any key-code file against the base. Then every file runs.
Limits: strace can't see which environment variables a test reads, so only the allowlist above is in the key, and a test that depends on another variable has to read it through a file the trace sees or stay off the reuse path. Timing assertions, flaky history and the shard's execution grouping are for PR 3 and PR 4, which have the receipts to check them against. Windows has no tracer, so the Windows lane stays full.
+23 -12
View File
@@ -23,24 +23,35 @@ import type { Lane } from './workflows';
/** Whole-tree tests every selection that runs a test must include (GST-98). Kept out of pregate.json so older readers, which reject unknown keys, keep working. */
export const ALWAYS_RUN_PATH = '.gstack/always-run.json';
/** The tracked test files the `tests` globs in `.gstack/always-run.json` name, and the globs that name none. No file: none. */
export function alwaysRunTests(repoRoot: string, testFiles: Iterable<string>): { tests: string[]; unmatched: string[] } {
const file = path.join(repoRoot, ALWAYS_RUN_PATH);
if (!fs.existsSync(file)) return { tests: [], unmatched: [] };
/** The `tests` globs of one `.gstack/always-run.json` text; `label` names the copy in errors. */
export function parseAlwaysRun(text: string, label: string = ALWAYS_RUN_PATH): string[] {
let parsed: unknown;
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { throw new Error(`${ALWAYS_RUN_PATH}: not valid JSON (${(error as Error).message})`); }
try { parsed = JSON.parse(text); }
catch (error) { throw new Error(`${label}: not valid JSON (${(error as Error).message})`); }
const globs = (parsed as { tests?: unknown } | null)?.tests;
if (!Array.isArray(globs) || globs.some(g => typeof g !== 'string' || !g)) throw new Error(`${ALWAYS_RUN_PATH}: "tests" must be a list of non-empty glob strings`);
if (!Array.isArray(globs) || globs.some(g => typeof g !== 'string' || !g)) throw new Error(`${label}: "tests" must be a list of non-empty glob strings`);
return globs as string[];
}
/**
* The tracked test files the `tests` globs in `.gstack/always-run.json` name, and the globs that name none. The globs are
* the union of the working tree's list and `baseText`, the base branch's copy (GST-233): a branch can add entries but never
* drop one main has. `baseOnly` lists the tests only the base list names. No file on either side: none.
*/
export function alwaysRunTests(repoRoot: string, testFiles: Iterable<string>, baseText: string | null = null): { tests: string[]; unmatched: string[]; baseOnly: string[] } {
const file = path.join(repoRoot, ALWAYS_RUN_PATH);
const own = fs.existsSync(file) ? parseAlwaysRun(fs.readFileSync(file, 'utf8')) : [];
const base = baseText === null ? [] : parseAlwaysRun(baseText, `${ALWAYS_RUN_PATH} on the base branch`);
const all = [...testFiles];
const tests = new Set<string>();
const ownTests = new Set<string>();
const unmatched: string[] = [];
for (const glob of globs as string[]) {
for (const glob of [...new Set([...own, ...base])]) {
const hits = all.filter(t => matchesAny([glob], t));
if (!hits.length) unmatched.push(glob);
for (const t of hits) tests.add(t);
for (const t of hits) { tests.add(t); if (own.includes(glob)) ownTests.add(t); }
}
return { tests: [...tests].sort(), unmatched };
return { tests: [...tests].sort(), unmatched, baseOnly: [...tests].filter(t => !ownTests.has(t)).sort() };
}
/** A path the branch removed (deleted, or a rename's old name) that owes no lane, with why (`note`) and the files that lost their last test. */
@@ -270,8 +281,8 @@ export function selectLanes(ctx: PregateContext): Selection {
noLane.push(file);
}
if (tests.size || noLane.length || seeds.has(ALWAYS_RUN_PATH)) {
const always = alwaysRunTests(ctx.repoRoot, [...graph.tests].filter(t => !goneSet.has(t)));
for (const t of always.tests) if (!tests.has(t)) { tests.add(t); provenance.push(`${t} (always_run, ${ALWAYS_RUN_PATH})`); }
const always = alwaysRunTests(ctx.repoRoot, [...graph.tests].filter(t => !goneSet.has(t)), readAtMergeBase(ctx, ALWAYS_RUN_PATH, ['100644', '100755']));
for (const t of always.tests) if (!tests.has(t)) { tests.add(t); provenance.push(`${t} (always_run, ${always.baseOnly.includes(t) ? 'merge base ' : ''}${ALWAYS_RUN_PATH})`); }
for (const glob of always.unmatched) provenance.push(`${ALWAYS_RUN_PATH}: ${glob} matches no test file`);
}
const laneSets: Record<string, string[] | '?'> = {};
+219
View File
@@ -0,0 +1,219 @@
/**
* test-impact/key — the cache key and eligibility of one free test file at a tree (GST-167 PR 2, plan d5).
*
* A key is a sha256 over every input that can change what the test does, read
* at the given tree through git (never the working tree):
* - traced: each path the test's strace read set names (lib/test-impact/trace.ts),
* as the sha256 of its bytes from `git cat-file --batch`, `dir`, or `absent`;
* - listed: the sorted entry names of every repo directory the trace listed;
* - graph: the forward import-graph closure of the test (lib/pregate/import-graph.ts),
* capped at MAX_HOPS hops; a closure that reaches the cap is ineligible, never truncated;
* - global: bun.lock, every tsconfig*.json and the pregate `dependencies`;
* - code: KEY_CODE_FILES, the runner, planner, shard engine, graph and key code;
* - runtime and env: the runtime manifest and ENV_ALLOWLIST values.
* A test is ineligible (always executed) when it is always_run in the base or the tree list,
* has no trace or one older than TRACE_MAX_AGE_MS, its traced run failed or touched the
* network, the real HOME, shared /tmp or .git, or its graph walk hit the cap. A tree that
* changes any KEY_CODE_FILES file against the base makes every test ineligible.
* scripts/test-impact.ts loads this module from the base ref, so a branch cannot change the
* rules that decide what it skips.
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { matchesAny } from '../ship-policy';
import { PREGATE_CONFIG_PATH } from '../pregate/config';
import { buildGraph, type Graph } from '../pregate/import-graph';
import { ALWAYS_RUN_PATH, parseAlwaysRun } from '../pregate/lane-selection';
import { readTrace } from './trace';
export const MAX_HOPS = 12;
export const TRACE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** Tracer, key, CLI, runner/planner, shard engine and the graph and always-run code the key reads. PR 4 adds its verifier here. */
export const KEY_CODE_FILES = [
'lib/test-impact/trace.ts', 'lib/test-impact/key.ts', 'scripts/test-impact.ts',
'scripts/test-free-shards.ts', 'scripts/lib/shard-engine.ts', 'lib/pregate/import-graph.ts', 'lib/pregate/lane-selection.ts',
];
/** Environment variables whose values a free test may branch on; anything else a test reads is outside the key. */
export const ENV_ALLOWLIST = ['CI', 'GITHUB_ACTIONS', 'TZ', 'LANG', 'LC_ALL', 'NODE_ENV', 'EVALS', 'EVALS_TIER'];
const TSCONFIG = /(?:^|\/)tsconfig[^/]*\.json$/;
export interface TreeEntry { mode: string; type: string; oid: string }
export interface TreeIndex { treeish: string; entries: Map<string, TreeEntry>; children: Map<string, string[]> }
export type InputKind = 'traced' | 'listed' | 'graph' | 'global' | 'code' | 'runtime' | 'env';
export interface KeyInput { kind: InputKind; path: string; digest: string }
export interface KeyResult { test: string; eligible: boolean; reasons: string[]; key: string | null; inputs: KeyInput[]; tracedAt: string | null }
export interface KeyReport { tree: string; base: string | null; keyCodeEdited: string[]; alwaysRunGlobs: { base: string[]; tree: string[] }; results: KeyResult[] }
export interface KeyOptions {
repoRoot: string; tree: string; base?: string | null; tests: string[]; traceDir: string;
now?: number; runtime?: Record<string, string>; env?: Record<string, string | undefined>;
}
function git(repoRoot: string, args: string[], input?: Buffer | string): Buffer {
const r = spawnSync('git', args, { cwd: repoRoot, input, timeout: 300_000, maxBuffer: 1024 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.error?.message ?? r.stderr?.toString().trim() ?? r.status}`);
return r.stdout;
}
/** Every path at a tree-ish with its mode, type and object id, and each directory's sorted entry names (`.` is the root). */
export function readTreeIndex(repoRoot: string, treeish: string): TreeIndex {
const entries = new Map<string, TreeEntry>();
const children = new Map<string, string[]>();
for (const rec of git(repoRoot, ['ls-tree', '-r', '-t', '-z', '--full-tree', treeish]).toString('utf8').split('\0')) {
const tab = rec.indexOf('\t');
if (tab < 0) continue;
const [mode, type, oid] = rec.slice(0, tab).split(' ');
const rel = rec.slice(tab + 1);
entries.set(rel, { mode: mode!, type: type!, oid: oid! });
const parent = path.posix.dirname(rel);
children.set(parent, [...(children.get(parent) ?? []), path.posix.basename(rel)]);
}
for (const list of children.values()) list.sort();
return { treeish, entries, children };
}
/** sha256 of each blob's bytes, read in one `git cat-file --batch`. */
export function blobDigests(repoRoot: string, oids: Iterable<string>): Map<string, string> {
const want = [...new Set(oids)];
const out = new Map<string, string>();
if (!want.length) return out;
const buf = git(repoRoot, ['cat-file', '--batch'], want.join('\n') + '\n');
for (let at = 0; at < buf.length;) {
const nl = buf.indexOf(10, at);
const [oid, type, size] = buf.subarray(at, nl).toString('utf8').split(' ');
if (type === 'missing' || size === undefined) throw new Error(`git cat-file: ${oid} missing`);
const start = nl + 1;
out.set(oid!, createHash('sha256').update(buf.subarray(start, start + Number(size))).digest('hex'));
at = start + Number(size) + 1;
}
return out;
}
/** A file's text at a tree-ish, or null when the tree has no such blob. */
export function catText(repoRoot: string, treeish: string, rel: string): string | null {
const r = spawnSync('git', ['cat-file', 'blob', `${treeish}:${rel}`], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
return r.status === 0 ? r.stdout : null;
}
/** A tree-ish exported to a fresh temp directory with every file staged, so the import graph (git ls-files) sees exactly that tree. */
export function materializeTree(repoRoot: string, treeish: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-impact-tree-'));
const tar = `${dir}.tar`;
try {
git(repoRoot, ['archive', '--format=tar', '-o', tar, treeish]);
const x = spawnSync('tar', ['-xf', tar, '-C', dir], { timeout: 300_000 });
if (x.status !== 0) throw new Error(`tar -xf failed: ${x.stderr?.toString().trim()}`);
git(dir, ['init', '-q']);
git(dir, ['add', '-A', '-f']);
return dir;
} catch (error) { fs.rmSync(dir, { recursive: true, force: true }); throw error; }
finally { fs.rmSync(tar, { force: true }); }
}
/** The runtime manifest a key binds: platform, architecture and Bun version. */
export function runtimeManifest(): Record<string, string> {
return { platform: process.platform, arch: process.arch, bun: typeof Bun === 'undefined' ? 'none' : Bun.version };
}
/**
* Files the test reaches through forward graph edges (imports, literals, convention, declarations; `dir/**` expands to the
* directory's files), at most `maxHops` edges away. `capped` holds the chain to the first file past the cap; a capped closure
* is incomplete, so its test is ineligible.
*/
export function forwardClosure(graph: Graph, test: string, maxHops = MAX_HOPS): { files: string[]; capped: string[] | null } {
const forward = new Map<string, string[]>();
for (const [dep, edges] of graph.reverse) {
const targets = dep.endsWith('/**') ? [...graph.files].filter(f => f.startsWith(dep.slice(0, -2))) : [dep];
for (const e of edges) forward.set(e.from, [...(forward.get(e.from) ?? []), ...targets]);
}
const parent = new Map<string, string | null>([[test, null]]);
let frontier = [test];
for (let hop = 0; frontier.length; hop++) {
const next: string[] = [];
for (const node of frontier) for (const t of forward.get(node) ?? []) {
if (parent.has(t)) continue;
if (hop === maxHops) {
const chain = [t];
for (let n: string | null = node; n !== null; n = parent.get(n) ?? null) chain.unshift(n);
return { files: [...parent.keys()].sort(), capped: chain };
}
parent.set(t, node);
next.push(t);
}
frontier = next;
}
return { files: [...parent.keys()].sort(), capped: null };
}
const sha = (text: string) => createHash('sha256').update(text).digest('hex');
/** Keys and eligibility for each test at `tree`, with `base` as the trusted reference for always_run and key code. */
export function computeKeys(opts: KeyOptions): KeyReport {
const { repoRoot, tree, tests } = opts;
const base = opts.base ?? null;
const now = opts.now ?? Date.now();
const treeIdx = readTreeIndex(repoRoot, tree);
const baseIdx = base === null ? null : readTreeIndex(repoRoot, base);
const keyCodeEdited = baseIdx ? KEY_CODE_FILES.filter(f => treeIdx.entries.get(f)?.oid !== baseIdx.entries.get(f)?.oid) : [];
const listText = (ref: string | null, label: string) => { const t = ref === null ? null : catText(repoRoot, ref, ALWAYS_RUN_PATH); return t === null ? [] : parseAlwaysRun(t, `${ALWAYS_RUN_PATH} at ${label}`); };
const alwaysRunGlobs = { base: listText(base, base ?? ''), tree: listText(tree, tree) };
const alwaysGlobs = [...new Set([...alwaysRunGlobs.base, ...alwaysRunGlobs.tree])];
const pregate = catText(repoRoot, tree, PREGATE_CONFIG_PATH);
let dependencies: Record<string, string[]> = {};
try { dependencies = pregate === null ? {} : (JSON.parse(pregate).dependencies ?? {}); } catch { throw new Error(`${PREGATE_CONFIG_PATH} at ${tree}: not valid JSON`); }
let graph: Graph | null = null;
if (!keyCodeEdited.length) {
const dir = materializeTree(repoRoot, tree);
try { graph = buildGraph(dir, { declarations: dependencies }); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
const env = opts.env ?? process.env;
const shared: KeyInput[] = [
...[...treeIdx.entries.keys()].filter(p => p === 'bun.lock' || TSCONFIG.test(p)).map(p => ({ kind: 'global' as const, path: p, digest: '' })),
{ kind: 'global', path: `${PREGATE_CONFIG_PATH}#dependencies`, digest: sha(JSON.stringify(dependencies)) },
...KEY_CODE_FILES.map(p => ({ kind: 'code' as const, path: p, digest: '' })),
...Object.entries(opts.runtime ?? runtimeManifest()).sort().map(([k, v]) => ({ kind: 'runtime' as const, path: k, digest: v })),
...ENV_ALLOWLIST.map(k => ({ kind: 'env' as const, path: k, digest: env[k] === undefined ? 'unset' : sha(env[k]!) })),
];
const pending = tests.map(test => {
const reasons: string[] = [];
if (treeIdx.entries.get(test)?.type !== 'blob') reasons.push(`not a file at ${tree}`);
if (keyCodeEdited.length) reasons.push(`the tree edits key code (${keyCodeEdited.join(', ')}); every test runs`);
const listed = [alwaysRunGlobs.base.some(g => matchesAny([g], test)) ? `${base}` : '', alwaysRunGlobs.tree.some(g => matchesAny([g], test)) ? tree : ''].filter(Boolean);
if (alwaysGlobs.some(g => matchesAny([g], test))) reasons.push(`always_run (${ALWAYS_RUN_PATH} at ${listed.join(' and ')})`);
const trace = readTrace(opts.traceDir, test);
if (!trace) reasons.push('no trace');
else {
const age = now - Date.parse(trace.tracedAt);
if (!(age <= TRACE_MAX_AGE_MS)) reasons.push(`stale trace (traced ${trace.tracedAt}, limit ${TRACE_MAX_AGE_MS / 86_400_000} days)`);
if (trace.exitCode !== 0) reasons.push(`the traced run failed (exit ${trace.exitCode})`);
reasons.push(...trace.ineligible);
}
const closure = graph ? forwardClosure(graph, test) : null;
if (closure?.capped) reasons.push(`import graph deeper than ${MAX_HOPS} hops: ${closure.capped.join(' → ')}`);
const traced = new Set(trace?.paths ?? []);
const files: KeyInput[] = [
...[...traced].map(p => ({ kind: 'traced' as const, path: p, digest: '' })),
...(closure?.files ?? []).filter(p => !traced.has(p)).map(p => ({ kind: 'graph' as const, path: p, digest: '' })),
...Object.keys(trace?.dirs ?? {}).map(d => ({ kind: 'listed' as const, path: d, digest: '' })),
];
return { test, reasons, trace, inputs: [...files, ...shared.map(s => ({ ...s }))] };
});
const blobOf = (p: string) => { const e = treeIdx.entries.get(p); return e?.type === 'blob' ? e.oid : null; };
const digests = blobDigests(repoRoot, pending.flatMap(t => t.inputs.filter(i => i.kind !== 'listed' && !i.digest).map(i => blobOf(i.path)).filter((o): o is string => o !== null)));
const results = pending.map(({ test, reasons, trace, inputs }) => {
for (const i of inputs) {
if (i.digest) continue;
const e = i.path === '.' ? { type: 'tree', mode: '040000', oid: '' } : treeIdx.entries.get(i.path);
if (i.kind === 'listed') i.digest = e?.type === 'tree' ? sha((treeIdx.children.get(i.path) ?? []).join('\0')) : 'absent';
else if (!e) i.digest = 'absent';
else i.digest = e.type === 'blob' ? `${e.mode}:${digests.get(e.oid)}` : e.type === 'tree' ? 'dir' : `${e.type}:${e.oid}`;
}
inputs.sort((a, b) => a.kind.localeCompare(b.kind) || a.path.localeCompare(b.path));
const eligible = reasons.length === 0;
const key = eligible ? sha([`test\t${test}`, ...inputs.map(i => `${i.kind}\t${i.path}\t${i.digest}`)].join('\n')) : null;
return { test, eligible, reasons, key, inputs, tracedAt: trace?.tracedAt ?? null };
});
return { tree, base, keyCodeEdited, alwaysRunGlobs, results };
}
+227
View File
@@ -0,0 +1,227 @@
/**
* test-impact/trace — strace output to one test file's read set (GST-167 PR 2).
*
* The recorder runs `bun test <file>` under `strace -f -y` with TRACE_SYSCALLS
* and a private TMPDIR. parseStrace turns the log into repo-relative paths the
* test or any child process opened, executed, stat'ed or probed (a failed open
* counts: a file that appears later changes what the test sees), plus the
* sorted names of every repo directory it listed. Paths outside the repo, in
* /proc and /dev, under node_modules (bun.lock is in the key) and in the
* private TMPDIR are dropped. What makes the result unsound marks the test
* ineligible with a reason instead: an inet connect or send that is not to a
* loopback port the trace itself bound, any access under the real HOME other
* than a PATH tool lookup, shared /tmp state, and reads of the repo's own .git.
* lib/test-impact/key.ts hashes the read set at a tree.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
export const TRACE_VERSION = 1;
/** `-e trace=` set; strace 6.8 on x86_64 Linux accepts every name (stat and open are the legacy calls static binaries still make). */
export const TRACE_SYSCALLS = [
'openat', 'open', 'execve', 'execveat', 'getdents64', 'readlink', 'readlinkat', 'stat', 'lstat', 'newfstatat', 'statx',
'access', 'faccessat', 'faccessat2', 'chdir', 'fchdir', 'clone', 'clone3', 'fork', 'vfork', 'connect', 'bind', 'sendto', 'getsockname',
] as const;
/** strace flags: follow children, decode fd paths (gives every *at call its directory and every pid its cwd), full dirents, long strings. */
export const STRACE_FLAGS = ['-f', '-y', '-qq', '-v', '-s', '65535', '-e', `trace=${TRACE_SYSCALLS.join(',')}`];
export interface TraceContext {
/** Absolute repo root as the traced processes saw it. */
repoRoot: string;
/** The real HOME of the traced run. */
home: string;
/** The private TMPDIR the recorder gave the run; everything under it is the test's own. */
tmpDir: string;
/** PATH entries: a lookup or exec of a file directly in one is a tool resolution, not a HOME read. */
pathDirs?: string[];
/** Shared temp roots (default /tmp and /var/tmp). */
sharedTmp?: string[];
/** cwd of the first process (default repoRoot). */
startCwd?: string;
}
export interface ReadSet {
/** Repo-relative paths accessed (files, directories, probes of absent paths), sorted; `.` is the root. */
paths: string[];
/** Repo-relative directory → sorted entry names seen in its listing. */
dirs: Record<string, string[]>;
/** Reasons the trace cannot prove the test's inputs; empty when eligible. */
ineligible: string[];
/** Executables outside the repo the run started, sorted (for explain; the runtime manifest covers the toolchain). */
tools: string[];
}
export interface TraceRecord extends ReadSet {
version: typeof TRACE_VERSION;
test: string;
tracedAt: string;
/** HEAD when traced; null outside a commit. */
commit: string | null;
exitCode: number | null;
runtime: Record<string, string>;
}
interface Call { pid: number; name: string; args: string; ret: string; retPath: string | null }
const LINE = /^(\d+)\s+(.*)$/s;
const CALL = /^([a-z_0-9]+)\((.*)\)\s+=\s+(-?\d+|\?|0x[0-9a-f]+)(?:<([^>]*)>)?/s;
const STRING = /"((?:[^"\\]|\\.)*)"/;
/** Strace's C string escapes to text. */
export function unescapeStrace(s: string): string {
return s.replace(/\\(x[0-9a-fA-F]{2}|[0-7]{1,3}|.)/g, (_, e: string) => {
if (e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16));
if (/^[0-7]+$/.test(e)) return String.fromCharCode(parseInt(e, 8));
return ({ n: '\n', t: '\t', r: '\r', v: '\v', f: '\f' } as Record<string, string>)[e] ?? e;
});
}
/** Complete calls in order: `<unfinished ...>` halves joined with their `<... resumed>` tails; signals and exits skipped. */
export function straceCalls(text: string): Call[] {
const pending = new Map<number, string>();
const calls: Call[] = [];
for (const raw of text.split('\n')) {
const m = LINE.exec(raw.trimEnd());
if (!m) continue;
const pid = Number(m[1]);
let body = m[2]!;
if (body.startsWith('---') || body.startsWith('+++')) continue;
if (body.endsWith('<unfinished ...>')) { pending.set(pid, body.slice(0, -'<unfinished ...>'.length).trimEnd()); continue; }
const resumed = /^<\.\.\. [a-z_0-9]+ resumed>\s?(.*)$/s.exec(body);
if (resumed) { body = (pending.get(pid) ?? '') + resumed[1]; pending.delete(pid); }
const c = CALL.exec(body);
if (c) calls.push({ pid, name: c[1]!, args: c[2]!, ret: c[3]!, retPath: c[4] ?? null });
}
return calls;
}
/** The leading `AT_FDCWD</dir>` or `3</dir>` argument's directory, then the first quoted string after it. */
function dirfdAndPath(args: string): { dirfd: string | null; file: string | null } {
const fd = /^(?:AT_FDCWD|-?\d+)<([^>]*)>/.exec(args);
const str = STRING.exec(args);
return { dirfd: fd ? fd[1]! : null, file: str ? unescapeStrace(str[1]!) : null };
}
const firstString = (args: string): string | null => { const s = /^"((?:[^"\\]|\\.)*)"/.exec(args); return s ? unescapeStrace(s[1]!) : null; };
const within = (p: string, root: string) => p === root || p.startsWith(root === '/' ? '/' : `${root}/`);
interface Peer { family: 'inet' | 'unix'; host: string; port: number; path?: string }
function sockaddr(args: string): Peer | null {
if (/sa_family=AF_UNIX/.test(args)) { const p = /sun_path=@?"((?:[^"\\]|\\.)*)"/.exec(args); return p ? { family: 'unix', host: '', port: 0, path: unescapeStrace(p[1]!) } : null; }
if (!/sa_family=AF_INET6?\b/.test(args)) return null;
const port = /sin6?_port=htons\((\d+)\)/.exec(args);
const host = /inet_addr\("([^"]+)"\)/.exec(args) ?? /inet_pton\(AF_INET6?,\s*"([^"]+)"/.exec(args);
return { family: 'inet', host: host?.[1] ?? '?', port: port ? Number(port[1]) : 0 };
}
const loopback = (host: string) => /^127\./.test(host) || host === '::1' || host === '::ffff:127.0.0.1';
/** A per-test read set from one strace log. */
export function parseStrace(text: string, ctx: TraceContext): ReadSet {
const repo = path.posix.normalize(ctx.repoRoot);
const home = path.posix.normalize(ctx.home);
const tmp = path.posix.normalize(ctx.tmpDir);
const shared = (ctx.sharedTmp ?? ['/tmp', '/var/tmp']).map(p => path.posix.normalize(p));
const pathDirs = new Set((ctx.pathDirs ?? []).filter(Boolean).map(p => path.posix.normalize(p)));
const ancestors = new Set<string>([home]);
for (const p of [repo, tmp, home]) for (let d = path.posix.dirname(p); ; d = path.posix.dirname(d)) { ancestors.add(d); if (d === '/') break; }
const cwd = new Map<number, string>();
const paths = new Set<string>();
const dirs = new Map<string, Set<string>>();
const reasons = new Set<string>();
const tools = new Set<string>();
const calls = straceCalls(text);
const ownPorts = new Set<number>();
for (const c of calls) {
if ((c.name !== 'bind' && c.name !== 'getsockname') || c.ret !== '0') continue;
const a = sockaddr(c.args);
if (a?.family === 'inet' && a.port && loopback(a.host)) ownPorts.add(a.port);
}
const cwdOf = (pid: number) => cwd.get(pid) ?? ctx.startCwd ?? repo;
const absolute = (pid: number, p: string, dir: string | null) => path.posix.normalize(path.posix.isAbsolute(p) ? p : path.posix.join(dir ?? cwdOf(pid), p));
const record = (raw: string, kind: 'access' | 'exec' | 'content') => {
const abs = raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
if (within(abs, '/proc') || within(abs, '/dev') || within(abs, tmp) || ancestors.has(abs)) return;
if (within(abs, repo)) {
const rel = abs === repo ? '.' : abs.slice(repo.length + 1);
if (/(?:^|\/)node_modules(?:\/|$)/.test(rel)) return;
if (rel === '.git' && kind === 'access') return;
if (rel === '.git' || rel.startsWith('.git/')) { reasons.add(`reads the repository's own git state (${rel})`); return; }
paths.add(rel);
return;
}
if (pathDirs.has(path.posix.dirname(abs))) { if (kind === 'exec') tools.add(abs); return; }
if (within(abs, home)) { reasons.add(`reads the real HOME (${abs})`); return; }
const sharedRoot = shared.find(s => within(abs, s));
if (sharedRoot && abs !== sharedRoot) { reasons.add(`reads shared temp state (${abs})`); return; }
if (kind === 'exec') tools.add(abs);
};
for (const c of calls) {
const ok = !c.ret.startsWith('-');
const { dirfd, file } = dirfdAndPath(c.args);
if (dirfd && c.args.startsWith('AT_FDCWD<')) cwd.set(c.pid, path.posix.normalize(dirfd));
switch (c.name) {
case 'clone': case 'clone3': case 'fork': case 'vfork':
if (ok && Number(c.ret) > 0) cwd.set(Number(c.ret), cwdOf(c.pid));
break;
case 'chdir': {
const p = firstString(c.args);
if (p === null || !ok) break;
const abs = absolute(c.pid, p, null);
cwd.set(c.pid, abs);
record(abs, 'access');
break;
}
case 'fchdir': if (ok && dirfd) cwd.set(c.pid, path.posix.normalize(dirfd)); break;
case 'getdents64': {
if (!dirfd) break;
const abs = path.posix.normalize(dirfd);
record(abs, 'content');
if (!within(abs, repo)) break;
const rel = abs === repo ? '.' : abs.slice(repo.length + 1);
if (/(?:^|\/)(?:node_modules|\.git)(?:\/|$)/.test(rel)) break;
const names = dirs.get(rel) ?? new Set<string>();
for (const n of c.args.matchAll(/d_name="((?:[^"\\]|\\.)*)"/g)) { const name = unescapeStrace(n[1]!); if (name !== '.' && name !== '..') names.add(name); }
dirs.set(rel, names);
break;
}
case 'connect': case 'sendto': {
const peer = sockaddr(c.args);
if (peer?.family === 'unix' && peer.path) record(absolute(c.pid, peer.path, null), 'access');
if (peer?.family === 'inet' && !(loopback(peer.host) && ownPorts.has(peer.port))) reasons.add(`network: ${c.name} ${peer.host}:${peer.port}`);
break;
}
case 'bind': case 'getsockname': break;
case 'execve': case 'open': case 'stat': case 'lstat': case 'access': case 'readlink': {
const p = firstString(c.args);
if (p === null) break;
record(absolute(c.pid, p, null), c.name === 'execve' ? (ok ? 'exec' : 'access') : c.name === 'open' ? 'content' : 'access');
break;
}
default: {
if (file === null) break;
const target = file === '' && dirfd ? path.posix.normalize(dirfd) : absolute(c.pid, file, dirfd);
record(target, c.name === 'execveat' ? (ok ? 'exec' : 'access') : c.name === 'openat' ? 'content' : 'access');
if (ok && c.retPath && c.name === 'openat' && path.posix.isAbsolute(c.retPath)) record(path.posix.normalize(c.retPath), 'content');
}
}
}
return {
paths: [...paths].sort(),
dirs: Object.fromEntries([...dirs].sort(([a], [b]) => a.localeCompare(b)).map(([d, n]) => [d, [...n].sort()])),
ineligible: [...reasons].sort(),
tools: [...tools].sort(),
};
}
/** Where a test file's trace lives under a trace directory. */
export function tracePath(dir: string, test: string): string {
return path.join(dir, `${test.replace(/\//g, '__')}.json`);
}
/** A trace record from disk, or null when missing, unreadable, another version or for another test. */
export function readTrace(dir: string, test: string): TraceRecord | null {
try {
const rec = JSON.parse(fs.readFileSync(tracePath(dir, test), 'utf8')) as TraceRecord;
return rec && rec.version === TRACE_VERSION && rec.test === test && Array.isArray(rec.paths) ? rec : null;
} catch { return null; }
}
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* test-impact — trace free tests and compute their reuse keys (GST-167 PR 2; docs/test-impact.md).
*
* scripts/test-impact.ts trace [--out <dir>] <test-file>...
* Run each file with `bun test` under strace (Linux) and write its read set to <dir>
* (default .context/test-impact/traces).
* scripts/test-impact.ts key --tree <ref> [--base <ref>] [--traces <dir>] [--json] <test-file>...
* Print each file's key at <ref>, or why it is ineligible. With --base, the key code is
* loaded from that ref, not from this checkout (plan d5).
* scripts/test-impact.ts explain [--tree <ref>] [--base <ref>] [--traces <dir>] <test-file>
* Print every input of one file's key and why it is or is not eligible.
*
* Nothing here changes what CI runs. Exit codes: 0 done, 1 a traced run or key failed, 2 usage or setup.
*/
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 * as localKey from '../lib/test-impact/key';
import type { KeyOptions, KeyReport } from '../lib/test-impact/key';
import { STRACE_FLAGS, TRACE_VERSION, parseStrace, tracePath, type TraceRecord } from '../lib/test-impact/trace';
const USAGE = 'usage: test-impact.ts trace [--out <dir>] <test-file>... | key --tree <ref> [--base <ref>] [--traces <dir>] [--json] <test-file>... | explain [--tree <ref>] [--base <ref>] [--traces <dir>] <test-file>';
const DEFAULT_TRACES = path.join('.context', 'test-impact', 'traces');
function repoRoot(): string {
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0) throw new Error('not inside a git repository');
return r.stdout.trim();
}
function parseArgs(argv: string[], flags: string[], booleans: string[] = []): { opts: Record<string, string | true>; files: string[] } {
const opts: Record<string, string | true> = {};
const files: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (booleans.includes(a)) opts[a] = true;
else if (flags.includes(a)) { const v = argv[++i]; if (!v) throw new Error(`${a} needs a value`); opts[a] = v; }
else if (a.startsWith('--')) throw new Error(`unknown flag ${a}`);
else files.push(a);
}
return { opts, files };
}
const relTo = (root: string, file: string) => path.relative(root, path.resolve(file)).split(path.sep).join('/');
function trace(root: string, argv: string[]): number {
const { opts, files } = parseArgs(argv, ['--out']);
if (!files.length) throw new Error(USAGE);
if (spawnSync('strace', ['-V'], { timeout: 30_000 }).status !== 0) { console.error('test-impact: strace not found; tracing needs Linux with strace installed'); return 2; }
const out = path.resolve(root, String(opts['--out'] ?? DEFAULT_TRACES));
fs.mkdirSync(out, { recursive: true });
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 30_000 });
let failed = 0;
for (const file of files.map(f => relTo(root, f))) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-impact-run-'));
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-impact-log-'));
try {
const log = path.join(logDir, 'strace.txt');
const r = spawnSync('strace', [...STRACE_FLAGS, '-o', log, '--', process.execPath, ...bunIsolationArgs(), 'test', path.join(root, file), '--timeout=30000', '--max-concurrency=1'],
{ cwd: root, env: { ...process.env, TMPDIR: tmp, TMP: tmp, TEMP: tmp, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, timeout: 600_000, maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
const set = parseStrace(fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '', { repoRoot: root, home: os.homedir(), tmpDir: tmp, pathDirs: (process.env.PATH ?? '').split(path.delimiter) });
const record: TraceRecord = { version: TRACE_VERSION, test: file, tracedAt: new Date().toISOString(), commit: head.status === 0 ? head.stdout.trim() : null, exitCode: r.status, runtime: localKey.runtimeManifest(), ...set };
fs.writeFileSync(tracePath(out, file), JSON.stringify(record, null, 2) + '\n');
if (r.status !== 0) failed++;
const verdict = set.ineligible.length ? `ineligible: ${set.ineligible.join('; ')}` : 'eligible';
console.log(`traced ${file}: exit ${r.status ?? r.signal}, ${set.paths.length} paths, ${Object.keys(set.dirs).length} listed dirs, ${verdict} → ${path.relative(root, tracePath(out, file))}`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(logDir, { recursive: true, force: true });
}
}
return failed ? 1 : 0;
}
/** computeKeys from the base ref's own key code when --base is given (the trusted path), else from this checkout. */
async function keys(root: string, options: Omit<KeyOptions, 'repoRoot'>): Promise<{ report: KeyReport; source: string }> {
if (!options.base) return { report: localKey.computeKeys({ repoRoot: root, ...options }), source: 'this checkout (untrusted: no --base)' };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-impact-base-'));
try {
const tar = path.join(dir, 'code.tar');
const a = spawnSync('git', ['archive', '--format=tar', '-o', tar, options.base, 'lib', 'scripts'], { cwd: root, timeout: 300_000 });
if (a.status !== 0) throw new Error(`git archive ${options.base} failed: ${a.stderr?.toString().trim()}`);
const x = spawnSync('tar', ['-xf', tar, '-C', dir], { timeout: 300_000 });
if (x.status !== 0) throw new Error(`tar -xf failed: ${x.stderr?.toString().trim()}`);
const entry = path.join(dir, 'lib', 'test-impact', 'key.ts');
const source = `${options.base}:lib/test-impact/key.ts`;
if (!fs.existsSync(entry)) {
const results = options.tests.map(test => ({ test, eligible: false, reasons: [`${options.base} has no lib/test-impact/key.ts; nothing is reusable until the key code lands there`], key: null, inputs: [], tracedAt: null }));
return { report: { tree: options.tree, base: options.base, keyCodeEdited: [], alwaysRunGlobs: { base: [], tree: [] }, results }, source };
}
const trusted = await import(entry) as typeof localKey;
return { report: trusted.computeKeys({ repoRoot: root, ...options, runtime: trusted.runtimeManifest() }), source };
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
async function key(root: string, argv: string[], explain: boolean): Promise<number> {
const { opts, files } = parseArgs(argv, ['--tree', '--base', '--traces'], ['--json']);
if (!files.length || (explain && files.length !== 1) || (!explain && !opts['--tree'])) throw new Error(USAGE);
const { report, source } = await keys(root, {
tree: String(opts['--tree'] ?? 'HEAD'), base: opts['--base'] ? String(opts['--base']) : null,
tests: files.map(f => relTo(root, f)), traceDir: path.resolve(root, String(opts['--traces'] ?? DEFAULT_TRACES)),
});
if (opts['--json']) { console.log(JSON.stringify({ source, ...report }, null, 2)); return 0; }
console.log(`tree ${report.tree}${report.base ? `, base ${report.base}` : ''}; key code from ${source}`);
for (const r of report.results) {
console.log(r.eligible ? `KEY ${r.test} ${r.key!.slice(0, 12)} (${r.inputs.length} inputs)` : `INELIGIBLE ${r.test}: ${r.reasons.join('; ')}`);
if (!explain) continue;
console.log(` traced at: ${r.tracedAt ?? 'never'}${r.key ? `\n key: ${r.key}` : ''}`);
console.log(` always_run globs: base [${report.alwaysRunGlobs.base.join(', ')}], tree [${report.alwaysRunGlobs.tree.join(', ')}]`);
for (const kind of ['traced', 'listed', 'graph', 'global', 'code', 'runtime', 'env'] as const) {
const list = r.inputs.filter(i => i.kind === kind);
if (!list.length) continue;
console.log(` ${kind} (${list.length}):`);
for (const i of list) console.log(` ${i.path} ${kind === 'runtime' ? i.digest : i.digest.slice(-12)}`);
}
}
return 0;
}
async function main(argv: string[]): Promise<number> {
const [cmd, ...rest] = argv;
const root = repoRoot();
if (cmd === 'trace') return trace(root, rest);
if (cmd === 'key') return key(root, rest, false);
if (cmd === 'explain') return key(root, rest, true);
console.error(USAGE);
return 2;
}
if (import.meta.main) {
main(process.argv.slice(2)).then(code => process.exit(code), (error: Error) => { console.error(`test-impact: ${error.message}`); process.exit(2); });
}
+3
View File
@@ -159,6 +159,9 @@
{ "file": "lib/pregate/runtime.ts", "movedFrom": "(new: the verified launch Bun for repo commands, field wave CD1)" },
{ "file": "lib/sealed-guard.ts", "movedFrom": "(new: the sealed-output guard shared by the pre-push hook and gstack-pregate sealed, field wave CD4)" },
{ "file": "lib/pregate/regate.ts", "movedFrom": "(new: the regate verdict from a fresh tree receipt plus migration collisions, field wave CD3)" },
{ "file": "lib/test-impact/trace.ts", "movedFrom": "(new: strace output to per-test read sets, GST-167 PR 2)" },
{ "file": "lib/test-impact/key.ts", "movedFrom": "(new: test-impact cache key and eligibility at a git tree, GST-167 PR 2)" },
{ "file": "scripts/test-impact.ts", "movedFrom": "(new: test-impact trace, key and explain CLI, GST-167 PR 2)" },
{ "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" },
{ "file": "lib/source-root.ts", "movedFrom": "(new: the source checkout behind an install root, from its recorded .source-path, field wave B2e)" },
{ "file": "scripts/resolvers/autoplan-driver.ts", "movedFrom": "(new: renders autoplan/DRIVER.md from the runner's constants, field wave B2e)" },
+129
View File
@@ -0,0 +1,129 @@
# strace 6.8 capture (x86_64 Linux, bun 1.4.4) of test/cap.test.ts under env -i, made with scripts/test-impact.ts flags.
# repo=/tmp/ti-capture/repo home=/tmp/ti-capture/home tmpdir=/tmp/ti-capture/run PATH=/usr/local/bin:/usr/bin:/bin
# The test reads lib/data.txt by a computed path, lists test/data, spawns bin/tool2 (cd to bin, source ./tool2-lib.sh),
# reads $HOME/.gitconfig, /tmp/ti-shared-state and .git/HEAD, probes lib/missing.txt, talks to its own loopback server,
# and connects to 192.0.2.1:443.
31600 execve("/usr/local/bin/bun", ["/usr/local/bin/bun", "--no-env-file", "--config=/dev/null", "test", "/tmp/ti-capture/repo/test/cap.test.ts"], ["PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/tmp/ti-capture/home", "TMPDIR=/tmp/ti-capture/run", "BUN_RUNTIME_TRANSPILER_CACHE_PATH=0"]) = 0
31600 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3</etc/ld.so.cache>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3</usr/lib/x86_64-linux-gnu/libc.so.6>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3</usr/lib/x86_64-linux-gnu/libpthread.so.0>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3</usr/lib/x86_64-linux-gnu/libdl.so.2>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/lib/x86_64-linux-gnu/libm.so.6", O_RDONLY|O_CLOEXEC) = 3</usr/lib/x86_64-linux-gnu/libm.so.6>
31600 open("/proc/sys/vm/overcommit_memory", O_RDONLY) = 3</proc/sys/vm/overcommit_memory>
31600 open("/sys/kernel/mm/transparent_hugepage/enabled", O_RDONLY) = 3</sys/kernel/mm/transparent_hugepage/enabled>
31600 access("/sys/devices/system/node/node1", R_OK) = -1 ENOENT (No such file or directory)
31600 access("/sys/devices/system/node/node2", R_OK) = -1 ENOENT (No such file or directory)
31600 access("/sys/devices/system/node/node3", R_OK) = -1 ENOENT (No such file or directory)
31600 access("/sys/devices/system/node/node4", R_OK) = -1 ENOENT (No such file or directory)
31600 access("/sys/devices/system/node/node5", R_OK) = -1 ENOENT (No such file or directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/maps", O_RDONLY|O_CLOEXEC) = 3</proc/31600/maps>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/dev/null", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 3</dev/null>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/sys/vm/mmap_min_addr", O_RDONLY) = 3</proc/sys/vm/mmap_min_addr>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/maps", O_RDONLY|O_CLOEXEC) = 3</proc/31600/maps>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/devices/system/cpu/online", O_RDONLY|O_CLOEXEC) = 3</sys/devices/system/cpu/online>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/cgroup", O_RDONLY|O_CLOEXEC) = 3</proc/31600/cgroup>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/30ccabc8-01c2-49f2-b7a8-dbc950e07067/cpu.max", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/cpu.max", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/cpu.max", O_RDONLY|O_CLOEXEC) = 3</sys/fs/cgroup/system.slice/capy-machine-server.service/cpu.max>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/cpu.max", O_RDONLY|O_CLOEXEC) = 3</sys/fs/cgroup/system.slice/cpu.max>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/cpu.max", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
31600 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, child_tid=0x7f69b45ad990, parent_tid=0x7f69b45ad990, exit_signal=0, stack=0x7f69b41ad000, stack_size=0x3fa7c0, tls=0x7f69b45ad6c0} => {parent_tid=[31601]}, 88) = 31601
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 3</>
31600 getdents64(3</>, [{d_ino=2, d_off=12, d_reclen=24, d_type=DT_DIR, d_name="."}, {d_ino=2, d_off=24, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=11, d_off=44, d_reclen=32, d_type=DT_DIR, d_name="lost+found"}, {d_ino=12, d_off=56, d_reclen=24, d_type=DT_LNK, d_name="bin"}, {d_ino=13, d_off=68, d_reclen=24, d_type=DT_LNK, d_name="lib"}, {d_ino=14, d_off=84, d_reclen=32, d_type=DT_LNK, d_name="lib64"}, {d_ino=15, d_off=96, d_reclen=24, d_type=DT_LNK, d_name="sbin"}, {d_ino=2730, d_off=108, d_reclen=24, d_type=DT_DIR, d_name="boot"}, {d_ino=3469, d_off=120, d_reclen=24, d_type=DT_DIR, d_name="dev"}, {d_ino=3272, d_off=132, d_reclen=24, d_type=DT_DIR, d_name="etc"}, {d_ino=3263, d_off=144, d_reclen=24, d_type=DT_DIR, d_name="home"}, {d_ino=16, d_off=160, d_reclen=32, d_type=DT_DIR, d_name="media"}, {d_ino=18, d_off=172, d_reclen=24, d_type=DT_DIR, d_name="mnt"}, {d_ino=3468, d_off=184, d_reclen=24, d_type=DT_DIR, d_name="opt"}, {d_ino=2731, d_off=196, d_reclen=24, d_type=DT_DIR, d_name="proc"}, {d_ino=2733, d_off=208, d_reclen=24, d_type=DT_DIR, d_name="root"}, {d_ino=3268, d_off=220, d_reclen=24, d_type=DT_DIR, d_name="run"}, {d_ino=3467, d_off=232, d_reclen=24, d_type=DT_DIR, d_name="srv"}, {d_ino=2732, d_off=244, d_reclen=24, d_type=DT_DIR, d_name="sys"}, {d_ino=17, d_off=256, d_reclen=24, d_type=DT_DIR, d_name="tmp"}, {d_ino=19, d_off=268, d_reclen=24, d_type=DT_DIR, d_name="usr"}, {d_ino=2734, d_off=280, d_reclen=24, d_type=DT_DIR, d_name="var"}, {d_ino=8359937, d_off=300, d_reclen=32, d_type=DT_DIR, d_name="workspace"}, {d_ino=5224961, d_off=316, d_reclen=24, d_type=DT_DIR, d_name="nix"}, {d_ino=7837441, d_off=344, d_reclen=40, d_type=DT_DIR, d_name="lib.usr-is-merged"}, {d_ino=2612481, d_off=372, d_reclen=40, d_type=DT_DIR, d_name="sbin.usr-is-merged"}, {d_ino=6792449, d_off=400, d_reclen=40, d_type=DT_DIR, d_name="bin.usr-is-merged"}, {d_ino=583, d_off=412, d_reclen=24, d_type=DT_REG, d_name=".e2b"}, {d_ino=3657473, d_off=4096, d_reclen=24, d_type=DT_DIR, d_name="code"}], 8192) = 776
31600 getdents64(3</>, [], 8192) = 0
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 4</tmp>
31600 getdents64(4</tmp>, [{d_ino=17, d_off=12, d_reclen=24, d_type=DT_DIR, d_name="."}, {d_ino=2, d_off=24, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=3135000, d_off=44, d_reclen=32, d_type=DT_DIR, d_name=".X11-unix"}, {d_ino=3135002, d_off=64, d_reclen=32, d_type=DT_DIR, d_name=".ICE-unix"}, {d_ino=3135004, d_off=84, d_reclen=32, d_type=DT_DIR, d_name=".XIM-unix"}, {d_ino=3135009, d_off=104, d_reclen=32, d_type=DT_DIR, d_name=".font-unix"}, {d_ino=3135011, d_off=184, d_reclen=96, d_type=DT_DIR, d_name="systemd-private-e2783ba5d46b4e4f9af3db4e72e19395-earlyoom.service-DZrZz7"}, {d_ino=3135023, d_off=272, d_reclen=104, d_type=DT_DIR, d_name="systemd-private-e2783ba5d46b4e4f9af3db4e72e19395-systemd-logind.service-MhEU5S"}, {d_ino=3135028, d_off=352, d_reclen=96, d_type=DT_DIR, d_name="systemd-private-e2783ba5d46b4e4f9af3db4e72e19395-chrony.service-Giee6x"}, {d_ino=14055, d_off=372, d_reclen=32, d_type=DT_REG, d_name="st.txt"}, {d_ino=3359, d_off=396, d_reclen=40, d_type=DT_SOCK, d_name="dbus-omgxS0MsPL"}, {d_ino=3160431, d_off=416, d_reclen=32, d_type=DT_DIR, d_name="ti-capture"}, {d_ino=3485, d_off=432, d_reclen=32, d_type=DT_REG, d_name=".X9-lock"}, {d_ino=14073, d_off=504, d_reclen=40, d_type=DT_REG, d_name="ti-shared-state"}, {d_ino=3135623, d_off=4096, d_reclen=40, d_type=DT_DIR, d_name="hsperfdata_root"}], 8192) = 688
31600 getdents64(4</tmp>, [], 8192) = 0
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5</tmp/ti-capture>
31601 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, child_tid=0x7f69b3dac990, parent_tid=0x7f69b3dac990, exit_signal=0, stack=0x7f69b35ac000, stack_size=0x7fa7c0, tls=0x7f69b3dac6c0} <unfinished ...>
31600 getdents64(5</tmp/ti-capture>, [{d_ino=3160431, d_off=12, d_reclen=24, d_type=DT_DIR, d_name="."}, {d_ino=17, d_off=24, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=3160432, d_off=36, d_reclen=24, d_type=DT_DIR, d_name="repo"}, {d_ino=3160439, d_off=48, d_reclen=24, d_type=DT_DIR, d_name="home"}, {d_ino=3160440, d_off=60, d_reclen=24, d_type=DT_DIR, d_name="run"}, {d_ino=3160477, d_off=80, d_reclen=32, d_type=DT_REG, d_name="raw.txt"}, {d_ino=3160478, d_off=148, d_reclen=32, d_type=DT_REG, d_name="trim.txt"}], 8192) = 184
31600 getdents64(5</tmp/ti-capture>, <unfinished ...>
31601 <... clone3 resumed> => {parent_tid=[31602]}, 88) = 31602
31600 <... getdents64 resumed>[], 8192) = 0
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 6</tmp/ti-capture/repo>
31600 getdents64(6</tmp/ti-capture/repo>, [{d_ino=3160432, d_off=12, d_reclen=24, d_type=DT_DIR, d_name="."}, {d_ino=3160431, d_off=24, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=3160433, d_off=36, d_reclen=24, d_type=DT_DIR, d_name="lib"}, {d_ino=3160434, d_off=48, d_reclen=24, d_type=DT_DIR, d_name="bin"}, {d_ino=3160435, d_off=60, d_reclen=24, d_type=DT_DIR, d_name="test"}, {d_ino=3160437, d_off=80, d_reclen=32, d_type=DT_DIR, d_name="node_modules"}, {d_ino=3160441, d_off=92, d_reclen=24, d_type=DT_DIR, d_name=".git"}, {d_ino=3160472, d_off=148, d_reclen=32, d_type=DT_REG, d_name="package.json"}], 8192) = 208
31600 getdents64(6</tmp/ti-capture/repo>, [], 8192) = 0
31600 openat(6</tmp/ti-capture/repo>, "package.json", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 8</tmp/ti-capture/repo/package.json>
31601 openat(AT_FDCWD</tmp/ti-capture/repo>, "/etc/localtime", O_RDONLY|O_CLOEXEC) = 8</usr/share/zoneinfo/Etc/UTC>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/cgroup", O_RDONLY|O_CLOEXEC) = 5</proc/31600/cgroup>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/30ccabc8-01c2-49f2-b7a8-dbc950e07067/memory.max", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/capy-machine-server.service/work/30ccabc8-01c2-49f2-b7a8-dbc950e07067/memory.max>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/30ccabc8-01c2-49f2-b7a8-dbc950e07067/memory.high", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/capy-machine-server.service/work/30ccabc8-01c2-49f2-b7a8-dbc950e07067/memory.high>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/memory.max", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/capy-machine-server.service/work/memory.max>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/memory.high", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/capy-machine-server.service/work/memory.high>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/memory.max", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/capy-machine-server.service/memory.max>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/capy-machine-server.service/memory.high", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/capy-machine-server.service/memory.high>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/memory.max", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/memory.max>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/system.slice/memory.high", O_RDONLY|O_CLOEXEC) = 5</sys/fs/cgroup/system.slice/memory.high>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/memory.max", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/sys/fs/cgroup/memory.high", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/statm", O_RDONLY) = 5</proc/31600/statm>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/test/cap.test.ts", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = -1 ENOTDIR (Not a directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/test", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5</tmp/ti-capture/repo/test>
31600 getdents64(5</tmp/ti-capture/repo/test>, [{d_ino=3160435, d_off=12, d_reclen=24, d_type=DT_DIR, d_name="."}, {d_ino=3160432, d_off=24, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=3160436, d_off=36, d_reclen=24, d_type=DT_DIR, d_name="data"}, {d_ino=3160476, d_off=80, d_reclen=32, d_type=DT_REG, d_name="cap.test.ts"}], 8192) = 104
31600 getdents64(5</tmp/ti-capture/repo/test>, [], 8192) = 0
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/test", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5</tmp/ti-capture/repo/test>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/test/cap.test.ts", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 5</tmp/ti-capture/repo/test/cap.test.ts>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/statm", O_RDONLY) = 5</proc/31600/statm>
31600 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, child_tid=0x7f69b359b990, parent_tid=0x7f69b359b990, exit_signal=0, stack=0x7f69b351b000, stack_size=0x7a7c0, tls=0x7f69b359b6c0} => {parent_tid=[31603]}, 88) = 31603
31600 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, child_tid=0x7f69b351a990, parent_tid=0x7f69b351a990, exit_signal=0, stack=0x7f69b349a000, stack_size=0x7a7c0, tls=0x7f69b351a6c0} => {parent_tid=[31604]}, 88) = 31604
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/statm", O_RDONLY) = 5</proc/31600/statm>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/lib/data.txt", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 5</tmp/ti-capture/repo/lib/data.txt>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/test/data", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5</tmp/ti-capture/repo/test/data>
31600 getdents64(5</tmp/ti-capture/repo/test/data>, [{d_ino=3160436, d_off=12, d_reclen=24, d_type=DT_DIR, d_name="."}, {d_ino=3160435, d_off=24, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=3160469, d_off=40, d_reclen=32, d_type=DT_REG, d_name="a.txt"}, {d_ino=3160470, d_off=80, d_reclen=32, d_type=DT_REG, d_name="b.txt"}], 8192) = 112
31600 getdents64(5</tmp/ti-capture/repo/test/data>, [], 8192) = 0
31600 vfork( <unfinished ...>
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/dev/null", O_RDONLY|O_CREAT, 0664) = 13</dev/null>
31605 execve("/tmp/ti-capture/repo/bin/tool2", ["/tmp/ti-capture/repo/bin/tool2"], ["NODE_ENV=test", "PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/tmp/ti-capture/home", "TMPDIR=/tmp/ti-capture/run", "BUN_RUNTIME_TRANSPILER_CACHE_PATH=0"] <unfinished ...>
31600 <... vfork resumed>) = 31605
31605 <... execve resumed>) = 0
31605 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3</etc/ld.so.cache>
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/lib/x86_64-linux-gnu/libtinfo.so.6", O_RDONLY|O_CLOEXEC) = 3</usr/lib/x86_64-linux-gnu/libtinfo.so.6.4>
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3</usr/lib/x86_64-linux-gnu/libc.so.6>
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/dev/tty", O_RDWR|O_NONBLOCK) = -1 ENXIO (No such device or address)
31605 connect(3<socket:[40219]>, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory)
31605 connect(3<socket:[40220]>, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory)
31605 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/etc/nsswitch.conf", {st_dev=makedev(0xfe, 0), st_ino=3368, st_mode=S_IFREG|0644, st_nlink=1, st_uid=0, st_gid=0, st_blksize=4096, st_blocks=8, st_size=494, st_atime=1793493227 /* 2026-11-01T00:33:47.073331156+0000 */, st_atime_nsec=73331156, st_mtime=1659454483 /* 2022-08-02T15:34:43+0000 */, st_mtime_nsec=0, st_ctime=1786380947 /* 2026-08-10T16:55:47.139975296+0000 */, st_ctime_nsec=139975296}, 0) = 0
31605 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/", {st_dev=makedev(0xfe, 0), st_ino=2, st_mode=S_IFDIR|0755, st_nlink=24, st_uid=0, st_gid=0, st_blksize=4096, st_blocks=8, st_size=4096, st_atime=1793543355 /* 2026-11-01T14:29:15.539582315+0000 */, st_atime_nsec=539582315, st_mtime=1793493900 /* 2026-11-01T00:45:00.131644750+0000 */, st_mtime_nsec=131644750, st_ctime=1793493900 /* 2026-11-01T00:45:00.131644750+0000 */, st_ctime_nsec=131644750}, 0) = 0
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3</etc/nsswitch.conf>
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/etc/passwd", O_RDONLY|O_CLOEXEC) = 3</etc/passwd>
31605 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/bin/tool2", O_RDONLY) = 3</tmp/ti-capture/repo/bin/tool2>
31605 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/bin/tool2", {st_dev=makedev(0xfe, 0), st_ino=3160473, st_mode=S_IFREG|0755, st_nlink=1, st_uid=1001, st_gid=1001, st_blksize=4096, st_blocks=1, st_size=61, st_atime=1793543948 /* 2026-11-01T14:39:08.523801197+0000 */, st_atime_nsec=523801197, st_mtime=1793543948 /* 2026-11-01T14:39:08.451801164+0000 */, st_mtime_nsec=451801164, st_ctime=1793543948 /* 2026-11-01T14:39:08.455801166+0000 */, st_ctime_nsec=455801166}, 0) = 0
31605 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fb4d2faba10) = 31606
31606 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, ".", {st_dev=makedev(0xfe, 0), st_ino=3160432, st_mode=S_IFDIR|0755, st_nlink=7, st_uid=1001, st_gid=1001, st_blksize=4096, st_blocks=0, st_size=128, st_atime=1793543948 /* 2026-11-01T14:39:08.479801177+0000 */, st_atime_nsec=479801177, st_mtime=1793543948 /* 2026-11-01T14:39:08.447801162+0000 */, st_mtime_nsec=447801162, st_ctime=1793543948 /* 2026-11-01T14:39:08.447801162+0000 */, st_ctime_nsec=447801162}, 0) = 0
31606 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/usr/local/bin/dirname", 0x7ffca3f03460, 0) = -1 ENOENT (No such file or directory)
31606 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/usr/bin/dirname", {st_dev=makedev(0xfe, 0), st_ino=305, st_mode=S_IFREG|0755, st_nlink=1, st_uid=0, st_gid=0, st_blksize=4096, st_blocks=72, st_size=35208, st_atime=1793493227 /* 2026-11-01T00:33:47.549331156+0000 */, st_atime_nsec=549331156, st_mtime=1769175004 /* 2026-01-23T13:30:04+0000 */, st_mtime_nsec=0, st_ctime=1786380947 /* 2026-08-10T16:55:47.151975261+0000 */, st_ctime_nsec=151975261}, 0) = 0
31606 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/usr/bin/dirname", {st_dev=makedev(0xfe, 0), st_ino=305, st_mode=S_IFREG|0755, st_nlink=1, st_uid=0, st_gid=0, st_blksize=4096, st_blocks=72, st_size=35208, st_atime=1793493227 /* 2026-11-01T00:33:47.549331156+0000 */, st_atime_nsec=549331156, st_mtime=1769175004 /* 2026-01-23T13:30:04+0000 */, st_mtime_nsec=0, st_ctime=1786380947 /* 2026-08-10T16:55:47.151975261+0000 */, st_ctime_nsec=151975261}, 0) = 0
31606 access("/usr/bin/dirname", X_OK) = 0
31606 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/usr/bin/dirname", {st_dev=makedev(0xfe, 0), st_ino=305, st_mode=S_IFREG|0755, st_nlink=1, st_uid=0, st_gid=0, st_blksize=4096, st_blocks=72, st_size=35208, st_atime=1793493227 /* 2026-11-01T00:33:47.549331156+0000 */, st_atime_nsec=549331156, st_mtime=1769175004 /* 2026-01-23T13:30:04+0000 */, st_mtime_nsec=0, st_ctime=1786380947 /* 2026-08-10T16:55:47.151975261+0000 */, st_ctime_nsec=151975261}, 0) = 0
31606 access("/usr/bin/dirname", R_OK) = 0
31606 execve("/usr/bin/dirname", ["dirname", "/tmp/ti-capture/repo/bin/tool2"], ["PWD=/tmp/ti-capture/repo", "NODE_ENV=test", "HOME=/tmp/ti-capture/home", "BUN_RUNTIME_TRANSPILER_CACHE_PATH=0", "TMPDIR=/tmp/ti-capture/run", "SHLVL=0", "PATH=/usr/local/bin:/usr/bin:/bin", "_=/usr/bin/dirname"]) = 0
31606 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
31606 openat(AT_FDCWD</tmp/ti-capture/repo>, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3</etc/ld.so.cache>
31606 openat(AT_FDCWD</tmp/ti-capture/repo>, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3</usr/lib/x86_64-linux-gnu/libc.so.6>
31605 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=31606, si_uid=1001, si_status=0, si_utime=0, si_stime=0} ---
31605 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp", {st_dev=makedev(0xfe, 0), st_ino=17, st_mode=S_IFDIR|S_ISVTX|0777, st_nlink=11, st_uid=0, st_gid=0, st_blksize=4096, st_blocks=8, st_size=4096, st_atime=1793543948 /* 2026-11-01T14:39:08.479801177+0000 */, st_atime_nsec=479801177, st_mtime=1793543948 /* 2026-11-01T14:39:08.455801166+0000 */, st_mtime_nsec=455801166, st_ctime=1793543948 /* 2026-11-01T14:39:08.455801166+0000 */, st_ctime_nsec=455801166}, 0) = 0
31605 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture", {st_dev=makedev(0xfe, 0), st_ino=3160431, st_mode=S_IFDIR|0755, st_nlink=5, st_uid=1001, st_gid=1001, st_blksize=4096, st_blocks=0, st_size=128, st_atime=1793543959 /* 2026-11-01T14:39:19.527806224+0000 */, st_atime_nsec=527806224, st_mtime=1793543959 /* 2026-11-01T14:39:19.503806213+0000 */, st_mtime_nsec=503806213, st_ctime=1793543959 /* 2026-11-01T14:39:19.503806213+0000 */, st_ctime_nsec=503806213}, 0) = 0
31605 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo", {st_dev=makedev(0xfe, 0), st_ino=3160432, st_mode=S_IFDIR|0755, st_nlink=7, st_uid=1001, st_gid=1001, st_blksize=4096, st_blocks=0, st_size=128, st_atime=1793543948 /* 2026-11-01T14:39:08.479801177+0000 */, st_atime_nsec=479801177, st_mtime=1793543948 /* 2026-11-01T14:39:08.447801162+0000 */, st_mtime_nsec=447801162, st_ctime=1793543948 /* 2026-11-01T14:39:08.447801162+0000 */, st_ctime_nsec=447801162}, 0) = 0
31605 newfstatat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/bin", {st_dev=makedev(0xfe, 0), st_ino=3160434, st_mode=S_IFDIR|0755, st_nlink=2, st_uid=1001, st_gid=1001, st_blksize=4096, st_blocks=0, st_size=60, st_atime=1793543948 /* 2026-11-01T14:39:08.419801150+0000 */, st_atime_nsec=419801150, st_mtime=1793543948 /* 2026-11-01T14:39:08.451801164+0000 */, st_mtime_nsec=451801164, st_ctime=1793543948 /* 2026-11-01T14:39:08.451801164+0000 */, st_ctime_nsec=451801164}, 0) = 0
31605 chdir("/tmp/ti-capture/repo/bin") = 0
31605 openat(AT_FDCWD</tmp/ti-capture/repo/bin>, "./tool2-lib.sh", O_RDONLY) = 3</tmp/ti-capture/repo/bin/tool2-lib.sh>
31600 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=31605, si_uid=1001, si_status=0, si_utime=0, si_stime=1 /* 0.01 s */} ---
31600 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, child_tid=0x7f69b3499990, parent_tid=0x7f69b3499990, exit_signal=0, stack=0x7f69b3099000, stack_size=0x3fa7c0, tls=0x7f69b34996c0} => {parent_tid=[31607]}, 88) = 31607
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/home/.gitconfig", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 10</tmp/ti-capture/home/.gitconfig>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-shared-state", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 10</tmp/ti-shared-state>
31607 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, child_tid=0x7f69b3098990, parent_tid=0x7f69b3098990, exit_signal=0, stack=0x7f69b2c98000, stack_size=0x3fa7c0, tls=0x7f69b30986c0} <unfinished ...>
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/tmp/ti-capture/repo/.git/HEAD", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 10</tmp/ti-capture/repo/.git/HEAD>
31607 <... clone3 resumed> => {parent_tid=[31608]}, 88) = 31608
31600 access("/tmp/ti-capture/repo/lib/missing.txt", F_OK) = -1 ENOENT (No such file or directory)
31600 openat(AT_FDCWD</tmp/ti-capture/repo>, "/proc/self/status", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 8</proc/31600/status>
31600 bind(8<socket:[40223]>, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
31600 getsockname(8<socket:[40223]>, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
31600 getsockname(8<socket:[40223]>, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
31600 getsockname(8<socket:[40223]>, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
31600 getsockname(8<socket:[40223]>, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
31600 getsockname(8<socket:[40223]>, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
31600 connect(10<socket:[41347]>, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation now in progress)
31600 getsockname(11<socket:[41348]>, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
31600 connect(8<socket:[40225]>, {sa_family=AF_INET, sin_port=htons(443), sin_addr=inet_addr("192.0.2.1")}, 16) = -1 EINPROGRESS (Operation now in progress)
+1
View File
@@ -229,6 +229,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/state-matrix-6792/**': ['test/state-matrix.test.ts'],
'test/fixtures/pregate-timing/**': ['test/pregate-timing.test.ts'],
'test/fixtures/skillify-narration/**': ['test/skill-fixture.test.ts'],
'test/fixtures/test-impact/capture.strace': ['test/test-impact-trace.test.ts'],
'test/fixtures/test-value-seam-evidence.json': ['test/test-value-bar.test.ts'],
'test/fixtures/third-party-actions-manual-wording.json': ['test/third-party-actions.test.ts'],
'test/fixtures/third-party-actions-opus-consent-renders.json': ['test/third-party-actions.test.ts'],
+12
View File
@@ -58,6 +58,18 @@ describe('always_run selection', () => {
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 {
+211
View File
@@ -0,0 +1,211 @@
/**
* GST-167 PR 2: lib/test-impact/key.ts keys a free test file at a git tree from its trace and the static graph, and
* refuses a key whenever it cannot prove the inputs. Every case runs the real functions on a fixture repo
* (test/helpers/pregate-fixture.ts); traces come from strace text through parseStrace, and one Linux case runs real strace.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { ALWAYS_RUN_PATH } from '../lib/pregate/lane-selection';
import { computeKeys, MAX_HOPS, type KeyResult } from '../lib/test-impact/key';
import { TRACE_VERSION, parseStrace, readTrace, tracePath, type TraceRecord } from '../lib/test-impact/trace';
import { cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const CLI = path.join(ROOT, 'scripts', 'test-impact.ts');
const NOW = Date.parse('2026-11-01T12:00:00Z');
const RUNTIME = { platform: 'linux', arch: 'x64', bun: '1.4.4' };
const NODE_TEST = "const test = require('node:test');\n";
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
function fixture(baseFiles: Record<string, string> = {}): FixtureRepo {
const repo = makeFixtureRepo({ prefix: 'gstack-test-impact-', baseFiles });
repos.push(repo);
return repo;
}
/** Write `test`'s trace from strace lines that open `reads`, list `lists` and add `extra` raw calls, through parseStrace. */
function traceFrom(repo: FixtureRepo, test: string, opts: { reads?: string[]; lists?: Record<string, string[]>; extra?: string[]; tracedAt?: string } = {}): string {
const w = repo.work;
const lines = [test, ...(opts.reads ?? [])].map(p => `100 openat(AT_FDCWD<${w}>, "${w}/${p}", O_RDONLY|O_CLOEXEC) = 3<${w}/${p}>`);
for (const [dir, names] of Object.entries(opts.lists ?? {})) lines.push(`100 getdents64(4<${w}/${dir}>, [${['.', '..', ...names].map(n => `{d_ino=1, d_off=1, d_reclen=24, d_type=DT_REG, d_name="${n}"}`).join(', ')}], 32768) = 96`);
lines.push(...(opts.extra ?? []));
const dir = path.join(repo.root, 'traces');
fs.mkdirSync(dir, { recursive: true });
const set = parseStrace(lines.join('\n'), { repoRoot: w, home: repo.home, tmpDir: path.join(repo.root, 'run') });
const record: TraceRecord = { version: TRACE_VERSION, test, tracedAt: opts.tracedAt ?? '2026-11-01T00:00:00Z', commit: null, exitCode: 0, runtime: RUNTIME, ...set };
fs.writeFileSync(tracePath(dir, test), JSON.stringify(record));
return dir;
}
function keyAt(repo: FixtureRepo, tree: string, test: string, base: string | null = 'main'): KeyResult {
return computeKeys({ repoRoot: repo.work, tree, base, tests: [test], traceDir: path.join(repo.root, 'traces'), now: NOW, runtime: RUNTIME, env: {} }).results[0]!;
}
function cli(repo: FixtureRepo, args: string[]) {
const r = spawnSync(process.execPath, [...bunIsolationArgs(), CLI, ...args], { cwd: repo.work, encoding: 'utf8', timeout: 180_000, env: { ...process.env, ...repo.env } });
return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
}
describe('test-impact key: what changes it', () => {
test('a lib file the test reads by a computed path changes the key; an unrelated file does not; without the trace the graph alone misses it', () => {
const t = 'test/data-read.test.js';
const repo = fixture({ 'lib/data.txt': 'one\n', [t]: `${NODE_TEST}test('reads', () => require('fs').readFileSync(require('path').join(__dirname, '..', ['lib', 'data' + '.txt'].join('/'))));\n` });
traceFrom(repo, t, { reads: ['lib/data.txt'] });
const before = keyAt(repo, 'main', t);
expect(before.eligible).toBe(true);
expect(before.inputs).toContainEqual(expect.objectContaining({ kind: 'traced', path: 'lib/data.txt' }));
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
expect(keyAt(repo, 'HEAD', t).key).toBe(before.key);
commit(repo, { 'lib/data.txt': 'two\n' });
expect(keyAt(repo, 'HEAD', t).key).not.toBe(before.key);
traceFrom(repo, t);
expect(keyAt(repo, 'HEAD', t).key).toBe(keyAt(repo, 'main', t).key);
});
test('a tsconfig path alias change changes the key, and the alias target is in the graph closure', () => {
const t = 'test/alias.test.ts';
const tsconfig = (target: string) => JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@lib/*': [target] } } });
const repo = fixture({ 'tsconfig.json': tsconfig('lib/*'), [t]: "import { add } from '@lib/util';\nimport { test } from 'bun:test';\ntest('adds', () => add(1, 2));\n" });
traceFrom(repo, t);
const before = keyAt(repo, 'main', t);
expect(before.eligible).toBe(true);
expect(before.inputs).toContainEqual(expect.objectContaining({ kind: 'graph', path: 'lib/util.js' }));
expect(before.inputs).toContainEqual(expect.objectContaining({ kind: 'global', path: 'tsconfig.json' }));
commit(repo, { 'tsconfig.json': tsconfig('src/*') });
expect(keyAt(repo, 'HEAD', t).key).not.toBe(before.key);
});
test('a file added under a directory the test listed changes the key', () => {
const t = 'test/listing.test.js';
const repo = fixture({ 'test/data/a.txt': 'a\n', 'test/data/b.txt': 'b\n', [t]: `${NODE_TEST}test('lists', () => require('fs').readdirSync(require('path').join(__dirname, 'da' + 'ta')));\n` });
traceFrom(repo, t, { lists: { 'test/data': ['a.txt', 'b.txt'] } });
const before = keyAt(repo, 'main', t);
expect(before.inputs).toContainEqual(expect.objectContaining({ kind: 'listed', path: 'test/data' }));
commit(repo, { 'docs/README.md': '# docs\n\nmore\n' });
expect(keyAt(repo, 'HEAD', t).key).toBe(before.key);
commit(repo, { 'test/data/c.txt': 'c\n' });
expect(keyAt(repo, 'HEAD', t).key).not.toBe(before.key);
});
test('a spawned bin that sources a sibling script: the traced sibling is in the key', () => {
const t = 'test/tool2.test.js';
const repo = fixture({ 'bin/tool2': '#!/bin/bash\nsource "$(dirname "$0")/tool2-lib.sh"\ngreet\n', 'bin/tool2-lib.sh': 'greet() { echo hi; }\n', [t]: `${NODE_TEST}test('greets', () => require('child_process').spawnSync('bin/tool2', { timeout: 5000 }));\n` });
const w = repo.work;
traceFrom(repo, t, { extra: [`100 execve("${w}/bin/tool2", ["bin/tool2"], []) = 0`, '100 chdir("bin") = 0', `100 openat(AT_FDCWD<${w}/bin>, "./tool2-lib.sh", O_RDONLY) = 3<${w}/bin/tool2-lib.sh>`] });
const before = keyAt(repo, 'main', t);
expect(before.inputs).toContainEqual(expect.objectContaining({ kind: 'traced', path: 'bin/tool2-lib.sh' }));
commit(repo, { 'bin/tool2-lib.sh': 'greet() { echo hello; }\n' });
expect(keyAt(repo, 'HEAD', t).key).not.toBe(before.key);
});
});
describe('test-impact key: ineligible', () => {
const chain = (n: number) => Object.fromEntries(Array.from({ length: n }, (_, i) => [`lib/c${i}.js`, i + 1 < n ? `module.exports = require('./c${i + 1}.js');\n` : 'module.exports = 1;\n']));
test(`an import chain deeper than ${MAX_HOPS} hops is ineligible; exactly ${MAX_HOPS} is not`, () => {
const repo = fixture({ ...chain(MAX_HOPS + 1), 'test/deep.test.js': `${NODE_TEST}require('../lib/c0.js');\ntest('deep', () => {});\n`, 'test/edge.test.js': `${NODE_TEST}require('../lib/c1.js');\ntest('edge', () => {});\n` });
traceFrom(repo, 'test/deep.test.js');
traceFrom(repo, 'test/edge.test.js');
const deep = keyAt(repo, 'main', 'test/deep.test.js');
expect(deep.eligible).toBe(false);
expect(deep.key).toBeNull();
expect(deep.reasons.join('\n')).toContain(`import graph deeper than ${MAX_HOPS} hops: test/deep.test.js → lib/c0.js → `);
expect(deep.reasons.join('\n')).toContain(`lib/c${MAX_HOPS}.js`);
const edge = keyAt(repo, 'main', 'test/edge.test.js');
expect(edge.reasons).toEqual([]);
expect(edge.inputs).toContainEqual(expect.objectContaining({ kind: 'graph', path: `lib/c${MAX_HOPS}.js` }));
});
test('a read of the real HOME (an env-dependent file) or a network connect is ineligible', () => {
const repo = fixture();
traceFrom(repo, 'test/util.test.js', { extra: [`100 openat(AT_FDCWD<${repo.work}>, "${repo.home}/.gitconfig", O_RDONLY) = 3<${repo.home}/.gitconfig>`] });
traceFrom(repo, 'test/retry.test.js', { extra: ['100 connect(5<socket:[1]>, {sa_family=AF_INET, sin_port=htons(443), sin_addr=inet_addr("140.82.112.3")}, 16) = -1 EINPROGRESS (Operation now in progress)'] });
traceFrom(repo, 'test/config-read.test.js', { reads: ['lib/config.json'] });
expect(keyAt(repo, 'main', 'test/util.test.js').reasons).toEqual([`reads the real HOME (${repo.home}/.gitconfig)`]);
expect(keyAt(repo, 'main', 'test/retry.test.js').reasons).toEqual(['network: connect 140.82.112.3:443']);
expect(keyAt(repo, 'main', 'test/config-read.test.js').eligible).toBe(true);
});
test('no trace, a stale trace and a failed traced run are ineligible', () => {
const repo = fixture();
traceFrom(repo, 'test/retry.test.js', { tracedAt: '2026-10-20T00:00:00Z' });
const dir = traceFrom(repo, 'test/config-read.test.js');
const failed = { ...readTrace(dir, 'test/config-read.test.js')!, exitCode: 1 };
fs.writeFileSync(tracePath(dir, 'test/config-read.test.js'), JSON.stringify(failed));
expect(keyAt(repo, 'main', 'test/util.test.js').reasons).toEqual(['no trace']);
expect(keyAt(repo, 'main', 'test/retry.test.js').reasons[0]).toStartWith('stale trace (traced 2026-10-20T00:00:00Z');
expect(keyAt(repo, 'main', 'test/config-read.test.js').reasons).toEqual(['the traced run failed (exit 1)']);
});
test('a tree that edits key code makes every test ineligible', () => {
const repo = fixture({ 'lib/test-impact/key.ts': 'export const v = 1;\n' });
traceFrom(repo, 'test/util.test.js');
traceFrom(repo, 'test/retry.test.js');
expect(keyAt(repo, 'main', 'test/util.test.js').eligible).toBe(true);
commit(repo, { 'lib/test-impact/key.ts': 'export const v = 2;\n' });
const report = computeKeys({ repoRoot: repo.work, tree: 'HEAD', base: 'main', tests: ['test/util.test.js', 'test/retry.test.js'], traceDir: path.join(repo.root, 'traces'), now: NOW, runtime: RUNTIME, env: {} });
expect(report.keyCodeEdited).toEqual(['lib/test-impact/key.ts']);
for (const r of report.results) expect(r.reasons).toEqual(['the tree edits key code (lib/test-impact/key.ts); every test runs']);
});
test('GST-233: a branch that deletes a glob from the always-run list still gets the base list', () => {
const repo = fixture({ [ALWAYS_RUN_PATH]: JSON.stringify({ tests: ['test/util.test.js'] }) });
traceFrom(repo, 'test/util.test.js');
commit(repo, { [ALWAYS_RUN_PATH]: JSON.stringify({ tests: [] }) });
expect(keyAt(repo, 'HEAD', 'test/util.test.js').reasons).toEqual([`always_run (${ALWAYS_RUN_PATH} at main)`]);
expect(keyAt(repo, 'HEAD', 'test/util.test.js', null).eligible).toBe(true);
});
});
describe('scripts/test-impact.ts', () => {
test('key --base runs the base ref\'s key code, not this checkout\'s, and refuses when the base has none', () => {
const repo = fixture();
traceFrom(repo, 'test/util.test.js');
const none = cli(repo, ['key', '--tree', 'HEAD', '--base', 'main', '--traces', path.join(repo.root, 'traces'), 'test/util.test.js']);
expect(none.status).toBe(0);
expect(none.out).toContain('INELIGIBLE test/util.test.js: main has no lib/test-impact/key.ts; nothing is reusable until the key code lands there');
const stub = "export const runtimeManifest = () => ({});\nexport function computeKeys(o: { tree: string; base: string; tests: string[] }) {\n return { tree: o.tree, base: o.base, keyCodeEdited: [], alwaysRunGlobs: { base: [], tree: [] }, results: o.tests.map(test => ({ test, eligible: false, reasons: ['stub key code from the base ref'], key: null, inputs: [], tracedAt: null })) };\n}\n";
repo.git('checkout', '-q', 'main');
commit(repo, { 'lib/test-impact/key.ts': stub }, 'base key code');
repo.git('checkout', '-q', 'feature');
const trusted = cli(repo, ['key', '--tree', 'HEAD', '--base', 'main', '--traces', path.join(repo.root, 'traces'), 'test/util.test.js']);
expect(trusted.out).toContain('key code from main:lib/test-impact/key.ts');
expect(trusted.out).toContain('INELIGIBLE test/util.test.js: stub key code from the base ref');
const local = cli(repo, ['explain', '--tree', 'HEAD', '--traces', path.join(repo.root, 'traces'), 'test/util.test.js']);
expect(local.status).toBe(0);
expect(local.out).toMatch(/KEY test\/util\.test\.js [0-9a-f]{12}/);
expect(local.out).toContain('lib/util.js');
});
const hasStrace = process.platform === 'linux' && spawnSync('strace', ['-V'], { timeout: 10_000 }).status === 0;
test.if(hasStrace)('trace (real strace): a spawned bin that sources a sibling records the sibling, and editing it changes the key', () => {
const t = 'test/tool2.test.ts';
const repo = fixture({
'bin/tool2': '#!/bin/bash\ncd "$(dirname "$0")"\nsource ./tool2-lib.sh\ngreet\n',
'bin/tool2-lib.sh': 'greet() { echo hi; }\n',
[t]: "import { expect, test } from 'bun:test';\nimport { spawnSync } from 'node:child_process';\nimport * as path from 'node:path';\ntest('greets', () => expect(spawnSync(path.join(import.meta.dir, '..', 'bin', 'tool2'), { encoding: 'utf8', timeout: 10000 }).stdout.trim()).toBe('hi'));\n",
});
fs.chmodSync(path.join(repo.work, 'bin/tool2'), 0o755);
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'exec bit');
const traces = path.join(repo.root, 'traces');
const r = cli(repo, ['trace', '--out', traces, t]);
expect(r.err).toBe('');
expect(r.status).toBe(0);
expect(r.out).toContain(`traced ${t}: exit 0`);
const rec = readTrace(traces, t)!;
expect(rec.paths).toEqual(expect.arrayContaining([t, 'bin/tool2', 'bin/tool2-lib.sh']));
expect(rec.ineligible).toEqual([]);
const before = keyAt(repo, 'HEAD', t);
expect(before.eligible).toBe(true);
commit(repo, { 'bin/tool2-lib.sh': 'greet() { echo hi; }\n# edited\n' });
expect(keyAt(repo, 'HEAD', t).key).not.toBe(before.key);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* GST-167 PR 2: lib/test-impact/trace.ts turns a captured strace log into a per-test read set.
* test/fixtures/test-impact/capture.strace is a real strace 6.8 capture of a bun test (its header says what the test did).
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parseStrace, straceCalls, unescapeStrace } from '../lib/test-impact/trace';
const CAPTURE = fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'test-impact', 'capture.strace'), 'utf8');
const CTX = { repoRoot: '/tmp/ti-capture/repo', home: '/tmp/ti-capture/home', tmpDir: '/tmp/ti-capture/run', pathDirs: ['/usr/local/bin', '/usr/bin', '/bin'] };
describe('parseStrace on a captured bun test', () => {
const set = parseStrace(CAPTURE, CTX);
test('records repo paths read, probed and executed, including a sourced sibling found through the child cwd', () => {
for (const p of ['test/cap.test.ts', 'lib/data.txt', 'lib/missing.txt', 'bin/tool2', 'bin/tool2-lib.sh', 'package.json', 'test/data']) expect(set.paths).toContain(p);
expect(set.paths.some(p => p.startsWith('.git') || p.startsWith('/') || p.includes('node_modules'))).toBe(false);
expect(set.tools).toContain('/usr/local/bin/bun');
});
test('a directory listing records its sorted names', () => {
expect(set.dirs['test/data']).toEqual(['a.txt', 'b.txt']);
expect(Object.keys(set.dirs).every(d => !d.startsWith('/'))).toBe(true);
});
test('HOME, shared /tmp, .git and an outside connect make it ineligible; its own loopback server does not', () => {
expect(set.ineligible).toEqual([
'network: connect 192.0.2.1:443',
'reads shared temp state (/tmp/ti-shared-state)',
"reads the real HOME (/tmp/ti-capture/home/.gitconfig)",
"reads the repository's own git state (.git/HEAD)",
]);
});
test('negative control: the same capture without those four accesses is eligible', () => {
const clean = CAPTURE.split('\n').filter(l => !/192\.0\.2\.1|ti-shared-state|\.gitconfig|\.git\/HEAD/.test(l)).join('\n');
const control = parseStrace(clean, CTX);
expect(control.ineligible).toEqual([]);
expect(control.paths).toEqual(set.paths);
});
test('joins unfinished calls, resolves dirfd-relative paths and decodes escapes', () => {
const text = [
'7 openat(5</r/lib>, <unfinished ...>',
'8 clone3({flags=CLONE_VM}, 88) = 9',
'7 <... openat resumed>"a\\x20b.txt", O_RDONLY) = 3</r/lib/a b.txt>',
'9 chdir("sub") = 0',
'9 stat("x.json", {...}) = -1 ENOENT (No such file or directory)',
'9 openat(AT_FDCWD</proc>, "/dev/null", O_RDONLY) = 3</dev/null>',
].join('\n');
expect(straceCalls(text).map(c => c.name)).toEqual(['clone3', 'openat', 'chdir', 'stat', 'openat']);
expect(unescapeStrace('a\\x20b\\"\\\\')).toBe('a b"\\');
const r = parseStrace(text, { repoRoot: '/r', home: '/h', tmpDir: '/t' });
expect(r.paths).toEqual(['lib/a b.txt', 'sub', 'sub/x.json']);
});
});