mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 17:04:27 +01:00
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:
1 parent
83ae96e086
commit
32c8a85005
13 files changed
+1061
-12
No files matched your search
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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,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[] | '?'> = {};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Executable
+135
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user