diff --git a/changelog.d/gst-167-tracer-and-keys.md b/changelog.d/gst-167-tracer-and-keys.md new file mode 100644 index 000000000..2ebcc0f94 --- /dev/null +++ b/changelog.d/gst-167-tracer-and-keys.md @@ -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. diff --git a/docs/pregate.md b/docs/pregate.md index e6d45e4da..0e6229522 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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 ` (always_run, .gstack/always-run.json)`, and a glob that matches no test as ` 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 ` (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` diff --git a/docs/test-impact.md b/docs/test-impact.md new file mode 100644 index 000000000..1d65d466b --- /dev/null +++ b/docs/test-impact.md @@ -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 ] ...` runs each file with `bun test` under strace and writes its read set to `` (default `.context/test-impact/traces`). It needs Linux with strace installed. +- `key --tree [--base ] [--traces ] [--json] ...` prints each file's key at ``, or why it has none. +- `explain [--tree ] [--base ] [--traces ] ` 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. diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 5ca134179..271602743 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -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): { 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, 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(); + const ownTests = new Set(); 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 = {}; diff --git a/lib/test-impact/key.ts b/lib/test-impact/key.ts new file mode 100644 index 000000000..f2648b509 --- /dev/null +++ b/lib/test-impact/key.ts @@ -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; children: Map } +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; env?: Record; +} + +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(); + const children = new Map(); + 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): Map { + const want = [...new Set(oids)]; + const out = new Map(); + 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 { + 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(); + 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([[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 = {}; + 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 }; +} diff --git a/lib/test-impact/trace.ts b/lib/test-impact/trace.ts new file mode 100644 index 000000000..263bc0196 --- /dev/null +++ b/lib/test-impact/trace.ts @@ -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 ` 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; + /** 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; +} + +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)[e] ?? e; + }); +} + +/** Complete calls in order: `` halves joined with their `<... resumed>` tails; signals and exits skipped. */ +export function straceCalls(text: string): Call[] { + const pending = new Map(); + 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('')) { pending.set(pid, body.slice(0, -''.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` or `3` 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([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(); + const paths = new Set(); + const dirs = new Map>(); + const reasons = new Set(); + const tools = new Set(); + const calls = straceCalls(text); + const ownPorts = new Set(); + 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(); + 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; } +} diff --git a/scripts/test-impact.ts b/scripts/test-impact.ts new file mode 100755 index 000000000..f5bf3d487 --- /dev/null +++ b/scripts/test-impact.ts @@ -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 ] ... + * Run each file with `bun test` under strace (Linux) and write its read set to + * (default .context/test-impact/traces). + * scripts/test-impact.ts key --tree [--base ] [--traces ] [--json] ... + * Print each file's key at , 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 ] [--base ] [--traces ] + * 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 ] ... | key --tree [--base ] [--traces ] [--json] ... | explain [--tree ] [--base ] [--traces ] '; +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; files: string[] } { + const opts: Record = {}; + 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): 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 { + 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 { + 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); }); +} diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index ad2cf1081..6d0298710 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -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)" }, diff --git a/test/fixtures/test-impact/capture.strace b/test/fixtures/test-impact/capture.strace new file mode 100644 index 000000000..8a49b21bb --- /dev/null +++ b/test/fixtures/test-impact/capture.strace @@ -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, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libm.so.6", O_RDONLY|O_CLOEXEC) = 3 +31600 open("/proc/sys/vm/overcommit_memory", O_RDONLY) = 3 +31600 open("/sys/kernel/mm/transparent_hugepage/enabled", O_RDONLY) = 3 +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, "/proc/self/maps", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/dev/null", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/proc/sys/vm/mmap_min_addr", O_RDONLY) = 3 +31600 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/sys/devices/system/cpu/online", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/proc/self/cgroup", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/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, "/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, "/sys/fs/cgroup/system.slice/capy-machine-server.service/cpu.max", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/cpu.max", O_RDONLY|O_CLOEXEC) = 3 +31600 openat(AT_FDCWD, "/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, "/", 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/", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 4 +31600 getdents64(4, [{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, [], 8192) = 0 +31600 openat(AT_FDCWD, "/tmp/ti-capture/", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5 +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} +31600 getdents64(5, [{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, +31601 <... clone3 resumed> => {parent_tid=[31602]}, 88) = 31602 +31600 <... getdents64 resumed>[], 8192) = 0 +31600 openat(AT_FDCWD, "/tmp/ti-capture/repo", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 6 +31600 getdents64(6, [{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, [], 8192) = 0 +31600 openat(6, "package.json", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 8 +31601 openat(AT_FDCWD, "/etc/localtime", O_RDONLY|O_CLOEXEC) = 8 +31600 openat(AT_FDCWD, "/proc/self/cgroup", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/30ccabc8-01c2-49f2-b7a8-dbc950e07067/memory.max", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/30ccabc8-01c2-49f2-b7a8-dbc950e07067/memory.high", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/memory.max", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/capy-machine-server.service/work/memory.high", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/capy-machine-server.service/memory.max", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/capy-machine-server.service/memory.high", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/memory.max", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/system.slice/memory.high", O_RDONLY|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/sys/fs/cgroup/memory.max", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) +31600 openat(AT_FDCWD, "/sys/fs/cgroup/memory.high", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) +31600 openat(AT_FDCWD, "/proc/self/statm", O_RDONLY) = 5 +31600 openat(AT_FDCWD, "/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/test", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5 +31600 getdents64(5, [{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, [], 8192) = 0 +31600 openat(AT_FDCWD, "/tmp/ti-capture/repo/test", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5 +31600 openat(AT_FDCWD, "/tmp/ti-capture/repo/test/cap.test.ts", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/proc/self/statm", O_RDONLY) = 5 +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, "/proc/self/statm", O_RDONLY) = 5 +31600 openat(AT_FDCWD, "/tmp/ti-capture/repo/lib/data.txt", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 5 +31600 openat(AT_FDCWD, "/tmp/ti-capture/repo/test/data", O_RDONLY|O_LARGEFILE|O_CLOEXEC|O_DIRECTORY) = 5 +31600 getdents64(5, [{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, [], 8192) = 0 +31600 vfork( +31605 openat(AT_FDCWD, "/dev/null", O_RDONLY|O_CREAT, 0664) = 13 +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"] +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, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 +31605 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libtinfo.so.6", O_RDONLY|O_CLOEXEC) = 3 +31605 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 +31605 openat(AT_FDCWD, "/dev/tty", O_RDWR|O_NONBLOCK) = -1 ENXIO (No such device or address) +31605 connect(3, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory) +31605 connect(3, {sa_family=AF_UNIX, sun_path="/var/run/nscd/socket"}, 110) = -1 ENOENT (No such file or directory) +31605 newfstatat(AT_FDCWD, "/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, "/", {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, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3 +31605 openat(AT_FDCWD, "/etc/passwd", O_RDONLY|O_CLOEXEC) = 3 +31605 openat(AT_FDCWD, "/tmp/ti-capture/repo/bin/tool2", O_RDONLY) = 3 +31605 newfstatat(AT_FDCWD, "/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, ".", {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, "/usr/local/bin/dirname", 0x7ffca3f03460, 0) = -1 ENOENT (No such file or directory) +31606 newfstatat(AT_FDCWD, "/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, "/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, "/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, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 +31606 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 +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", {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", {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", {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/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, "./tool2-lib.sh", O_RDONLY) = 3 +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/home/.gitconfig", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 10 +31600 openat(AT_FDCWD, "/tmp/ti-shared-state", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 10 +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} +31600 openat(AT_FDCWD, "/tmp/ti-capture/repo/.git/HEAD", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 10 +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, "/proc/self/status", O_RDONLY|O_NOCTTY|O_LARGEFILE|O_CLOEXEC) = 8 +31600 bind(8, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("127.0.0.1")}, 16) = 0 +31600 getsockname(8, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0 +31600 getsockname(8, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0 +31600 getsockname(8, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0 +31600 getsockname(8, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0 +31600 getsockname(8, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0 +31600 connect(10, {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, {sa_family=AF_INET, sin_port=htons(34391), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0 +31600 connect(8, {sa_family=AF_INET, sin_port=htons(443), sin_addr=inet_addr("192.0.2.1")}, 16) = -1 EINPROGRESS (Operation now in progress) diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 043f2ca2c..8c9c72cf7 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -229,6 +229,7 @@ export const FREE_FIXTURES: Readonly> = { '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'], diff --git a/test/pregate-always-run.test.ts b/test/pregate-always-run.test.ts index 4d45043e9..44fd26c35 100644 --- a/test/pregate-always-run.test.ts +++ b/test/pregate-always-run.test.ts @@ -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 { diff --git a/test/test-impact-key.test.ts b/test/test-impact-key.test.ts new file mode 100644 index 000000000..931d92f27 --- /dev/null +++ b/test/test-impact-key.test.ts @@ -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 = {}): 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; 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, {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); + }); +}); diff --git a/test/test-impact-trace.test.ts b/test/test-impact-trace.test.ts new file mode 100644 index 000000000..b2f9de9f0 --- /dev/null +++ b/test/test-impact-trace.test.ts @@ -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, ', + '8 clone3({flags=CLONE_VM}, 88) = 9', + '7 <... openat resumed>"a\\x20b.txt", O_RDONLY) = 3', + '9 chdir("sub") = 0', + '9 stat("x.json", {...}) = -1 ENOENT (No such file or directory)', + '9 openat(AT_FDCWD, "/dev/null", O_RDONLY) = 3', + ].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']); + }); +});