I Asked the Agent to Pick Tests From My Diff and It Said "None" Nine Times Out of Ten
Change-based test selection returned zero tests for most edits. Measuring how far an import graph actually reaches across three repositories, and rebuilding the selection contract around it.
I was trying to shorten the self-verification loop.
Running the whole suite after every agent edit is slow, and when it fails the signal is buried. Working as an indie developer, that wait comes straight out of my own day. Walking backwards from the changed files to the tests that actually depend on them should have been the obvious fix.
I wired it up, ran a few dozen edits through it, and got the same answer nearly every time: zero tests selected.
The whole measurement finished in 0.4 seconds, so my first suspicion was that the file reads were failing. I counted the reverse edges instead — 3,177 of them. The graph was fine. The zero was the honest answer.
Here is what I measured that afternoon.
What was measured
Three public repositories, chosen because their directory habits differ.
Repository
Files analyzed
Relative import edges
Graph build time
vite
1,540
1,409
303 ms
hono
388
914
240 ms
nest
1,727
3,177
339 ms
One question only. If you change a single source file, what share of the test suite is reachable by walking imports in reverse?
That calculation is exactly what happens inside an agent when you tell it to "run the tests related to this change." A smaller share means a faster loop — and a larger blind spot.
Building the graph
Only relative imports are resolved. Workspace references like @nestjs/common and path aliases are deliberately excluded, because I wanted something an agent could rebuild in a few hundred milliseconds.
import os, re, subprocessfrom collections import defaultdict, dequeEXTS = [".ts", ".tsx", ".mts", ".js", ".mjs", ".jsx"]# catches both `from "..."` forms and bare side-effect importsIMP = re.compile( r'''(?:^|\n)\s*(?:import|export)\b[^;\n]*?from\s*['"]([^'"]+)['"]''' r'''|(?:^|\n)\s*import\s*['"]([^'"]+)['"]''')# an index file made almost entirely of re-exports is treated as a barrelREEXP = re.compile(r'''(?:^|\n)\s*export\s+(?:\*|\{)[^;]*?from\s*['"]''')def resolve(base_dir, spec, fileset): """Resolve a relative specifier to a real file, or None.""" if not spec.startswith("."): return None raw = os.path.normpath(os.path.join(base_dir, spec)) cands = [raw] + [raw + e for e in EXTS] cands += [os.path.join(raw, "index" + e) for e in EXTS] # ESM specifiers often write .js while pointing at .ts if raw.endswith(".js"): cands += [raw[:-3] + e for e in (".ts", ".tsx", ".mts")] for c in cands: if c in fileset: return c return Nonedef build(repo): out = subprocess.run(["git", "-C", repo, "ls-files"], capture_output=True, text=True).stdout paths = [p for p in out.split("\n") if p.strip() and os.path.splitext(p)[1] in EXTS] fileset = set(paths) fwd, barrel = defaultdict(set), set() for p in paths: try: src = open(os.path.join(repo, p), encoding="utf-8", errors="ignore").read() except OSError: continue # submodule placeholders and similar gaps if os.path.basename(p).startswith("index.") and len(REEXP.findall(src)) >= 3: barrel.add(p) d = os.path.dirname(p) for m in IMP.finditer(src): t = resolve(d, m.group(1) or m.group(2), fileset) if t and t != p: fwd[p].add(t) rev = defaultdict(set) for a, bs in fwd.items(): for b in bs: rev[b].add(a) return paths, rev, barrel
The errors="ignore" matters more than it looks. git ls-files lists paths whose contents may not be materialized, and an exception there halts the walk and hands you a sparse graph that looks perfectly valid. That was the first thing I checked when the zeros appeared.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Across vite, hono and nest, the tests reachable from a single changed file sit at a median of 0.0-0.7% but a p90 of 0.3-50.4% — designing against the mean will mislead you
✦In vite, 1,029 of 1,148 source files reach no test at all through the import graph. The dangerous failure is not over-selection, it is a quiet zero
✦Dropping re-export barrels from the traversal moves nest from 4.0% to 1.4% mean reach and 14.5% to 2.5% at p90 — barrels were inflating perceived impact roughly threefold
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Breadth-first in the reverse direction, recording every test file encountered. The skip set exists so barrels can be excluded later.
def is_test(p): return (".spec." in p) or (".test." in p) or ("__tests__/" in p) or ("/e2e/" in p)def closure_tests(start, rev, tests, skip=frozenset()): """Tests reachable from `start` by following importers.""" seen, hit = {start}, set() q = deque([start]) while q: cur = q.popleft() for importer in rev.get(cur, ()): if importer in seen or importer in skip: continue seen.add(importer) if importer in tests: hit.add(importer) q.append(importer) return hit
Reach ratio is len(closure_tests(...)) / len(tests), computed for every non-test source file.
The mean is the wrong summary
Repository
Source
Tests
Median
Mean
p90
Reaches zero tests
vite
1,148
392
0.0%
1.3%
0.3%
89.6%
hono
251
137
0.7%
10.0%
50.4%
27.9%
nest
1,320
407
0.2%
4.0%
14.5%
26.5%
For hono the median is 0.7%, the mean 10.0%, the p90 50.4%. Three summaries of one distribution, each pointing at a different decision.
The median says selection always works. The mean says a tenth of the suite is a fine price. Only the p90 tells you that one change in ten drags in half the suite.
The high end has recognizable faces.
Repository
Highest-reach file
Reach
hono
src/utils/types.ts
76.6%
hono
src/router.ts
68.6%
nest
packages/common/constants.ts
17.0%
vite
packages/vite/src/node/constants.ts
15.3%
Type definitions and constants. Files that change often, usually in small ways — and exactly where the speedup disappears.
If your agent's time budget assumes the mean, that tenth is what breaks the estimate. I moved my budgeting to the p90.
Over-selection was never the risk
In vite, 89.6% of source files reach zero tests. That is 1,029 files out of 1,148 with no test importing them, directly or transitively.
The reason is traceable. Most vite tests spin up a playground and assert on observed behavior rather than importing source modules relatively. To a static import graph, the two halves of the repository look unrelated.
This was the part of the measurement that genuinely unsettled me.
Over-selection costs you time. Under-selection ships something broken while the pipeline stays green — and it reports that outcome as "zero tests selected," which is indistinguishable from success at a glance. Drop that into an agent's self-verification step and an empty run gets recorded as a verified one.
My expectation going in had been the opposite. I was braced for hub files pulling the entire suite and erasing the savings. The real hazard was the runs where nothing happened.
Barrels widen the path
The second surprise was index.ts.
Excluding re-export-only index files from the traversal changes the picture unevenly.
Repository
Barrels
Mean (with → without)
p90 (with → without)
Zero-reach (with → without)
vite
3
1.3% → 1.1%
0.3% → 0.0%
89.6% → 90.9%
hono
5
10.0% → 9.7%
50.4% → 50.4%
27.9% → 31.5%
nest
52
4.0% → 1.4%
14.5% → 2.5%
26.5% → 36.9%
Only nest moves sharply: p90 falls from 14.5% to 2.5%. Much of its apparent blast radius was flowing through re-exports rather than through real coupling. Fifty-two barrel files were reshaping how 1,320 source files appear to a static analyzer.
Removing them is not simply more correct, though. Zero-reach climbs from 26.5% to 36.9%, because some genuine dependencies only connect through a barrel.
I kept the barrels in the traversal. Over-selecting costs wall-clock time; cutting a real edge costs a missed regression. What I added instead was provenance: tests reached only via a barrel are tagged, so a human reading the plan later can see why each one was chosen.
Never let "none" be the answer
The selection step now returns three states, and zero is treated as a signal to escalate rather than a result.
FULL_SUITE_THRESHOLD = 0.35 # above this, selection stops paying for itselfdef select_tests(changed, rev, tests, barrel=frozenset()): """Build a run plan for a changeset. Zero is never a conclusion.""" hit, via_barrel = set(), set() for p in changed: direct = closure_tests(p, rev, tests, skip=barrel) whole = closure_tests(p, rev, tests) hit |= whole via_barrel |= (whole - direct) # reached only through a barrel if not hit: # nothing reachable statically; the coverage may live in e2e only return {"mode": "escalate", "reason": "no_reachable_test", "run": "full", "changed": sorted(changed)} ratio = len(hit) / len(tests) if ratio > FULL_SUITE_THRESHOLD: return {"mode": "full", "reason": f"reach_ratio={ratio:.2f}", "run": sorted(tests)} return {"mode": "selected", "reason": f"reach_ratio={ratio:.2f}", "run": sorted(hit), "via_barrel_only": sorted(via_barrel)}
Separating escalate is the whole point. "Selection returned nothing" and "static analysis cannot decide this" both take zero seconds, and they mean opposite things. Collapsing them was what produced that nine-out-of-ten result.
The 0.35 threshold sits between hono's p90 (50.4%) and nest's p90 (14.5%). Selecting half a suite buys little and complicates reproduction, so I would rather run everything. Measure your own distribution before adopting the number.
What this method cannot see
Worth stating plainly: every figure above rests on static relative imports.
Not captured
Consequence
Workspace references and path aliases
Monorepo dependencies break, inflating zero-reach
Dynamic import() and lazy loading
Runtime-only edges are invisible
DI containers and decorator wiring
Real coupling stays hidden in nest-style code
Fixtures, snapshots, config files
Non-code changes register as zero impact
e2e and playground suites
The main cause of vite's 89.6% zero-reach
So this is not a tool for deciding which tests to run. It is a tool for deciding which tests to run first, ahead of the full suite. Fast feedback reaches the agent sooner, while the verdict still comes from running everything.
Graph construction took 240-339 ms across all three repositories. Cheap enough to rebuild every time, which I prefer over caching a stale dependency map.
What you can measure tomorrow
To check your own repository, in order:
Run build and closure_tests as written and report median, mean and p90 reach
Check the zero-reach share. Above roughly 20%, static analysis alone should not be deciding test selection
Look at the five highest-reach files. Expect constants and type definitions — that is where selective runs stop helping
Compare results with barrels in skip. A large gap means your perceived blast radius is dominated by re-exports
What I came away with was less a speedup than a map: a picture of where in my own repository static analysis simply cannot judge. Knowing which parts an agent cannot verify turned out to be the prerequisite, not the follow-up.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.