NxtSoftLabs
← All writing

Blastline: graph-backed test impact analysis for CI

August 19, 2026·5 min read

Blastline is a new open-source tool that answers two questions about any diff — which tests does this change actually reach? and what is its transitive blast radius? — by querying a CGraph code graph instead of guessing from file paths or coverage runs. It ships as a CLI, a GitHub Action that comments real dependents on every PR, and an MCP server so coding agents can pre-flight their own edits. It is MIT-licensed and on npm today as blastline.

The gap it fills

Test-impact selection has been stuck in the same place for years: every real implementation is locked to one build system (Bazel's bazel-diff, Nx and Turborepo's affected), one language via coverage instrumentation (pytest-testmon, Skippy), or a closed SaaS (Meta's predictive selection, Launchable, Gradle Develocity). There is no maintained, cross-language, build-system-agnostic, statically-derived selector in open source — a gap Martin Fowler's test-impact-analysis writeup described in 2017 that is still open in 2026.

But selection is fundamentally a graph-reachability question: changed code → transitive dependents → the test files among them. CGraph already builds that graph deterministically, across 13 languages, with ~10ms impact queries — and had deliberately left the judgment layer on top of impact unbuilt. Blastline is that layer.

Run at least these — never "safe to skip"

Static graphs miss dynamic dispatch, so Blastline's contract is deliberately conservative: the selection is a safe superset — "run at least these" — and anything the graph cannot vouch for fails open to the full suite, with machine-readable reasons:

blastline tests main..HEAD | xargs vitest run
blastline blast main..HEAD    # every transitive dependent, with file:line

Fail-open triggers include changed files with no graph node (configs, lockfiles), a graph older than the head commit, oversized diffs, and — the one we're proudest of — a sparse-graph guard: if the graph averages fewer than 3 edges per file, Blastline refuses to produce a subset at all, because an under-extracted graph yields subsets that look impressively small and are actually blind. Selection is deterministic: same repo state, same diff, byte-identical output.

The benchmark that found a bug in our own engine

Before launch we replayed 80 historical commits across two repos — a production Next.js app and es-toolkit (1,508 files, ~688 test files) — scoring every selection against the tests the commit's author co-changed. On the Next.js app: 19/20 commits got a subset averaging a quarter of the suite, with every genuinely-related co-changed test selected.

On es-toolkit, the replay caught something better than a good number: it caught our own engine silently deleting 650 of 1,508 files from the graph. The root cause (CGraph #39/#40, fixed in #42) was an id collision: an import specifier that spells the source extension (from './chunkBy.ts') produced an import stub with exactly the real file node's id; spec files merge before their implementations, so the stub squatted the id and the real file — and its function, and every edge — was deleted. After the fix, the identical replay went from 6/20 subsets with 7 of 8 co-changed tests missed, to 18/20 subsets with 41/41 co-changed tests selected, at a mean 4.7% of the suite. The full methodology and both before/after tables are in the repo's bench results.

That loop — tool finds engine bug, engine fix makes tool trustworthy — is the reason Blastline exists as a separate project consuming CGraph's public surfaces rather than a feature inside it.

Three surfaces, one pipeline

  • CLIblastline tests / blastline blast, list or --json, with --ignore for paths you declare irrelevant and --min-density to tune the guard.
  • GitHub Action — comments the blast radius and impacted tests on every PR (uses: Nxtsoft/blastline@main), and exposes kind/tests outputs so a downstream job can run only the selected tests.
  • MCP serverblastline mcp serves blastline_tests and blastline_blast over stdio, so an agent can check what its edit reaches before opening the PR — turning "check the blast radius" from a meeting into a tool call.

Try it

npm install -g blastline
cgraph --root ./src --out cgraph-out     # build the graph once (CGraph)
blastline tests main..HEAD | xargs vitest run

Update, later the same day (v0.2): Python and Go landed, and the honesty loop ran twice more. The pytest and *_test.go detectors worked immediately, but replaying real repos showed both languages' graphs were blind — Go receiver-method calls resolved to zero cross-file edges on gorilla/mux (tests could reach 11% of the code), and Python graphs had no import edges at all on pallets/itsdangerous (7%). Blastline grew a second structural guard for exactly this shape — disconnected-tests, which refuses to produce a subset when tests can't reach the code — and the extraction gaps were fixed upstream in CGraph#46. After the fix: itsdangerous reachability hit 1.00 and its replay selected every surviving author-co-changed test (3/3) across 18/20 subset commits; mux initially reached 0.50 with 7/11 selected — the remaining misses were interface-dispatch bindings (mux declares Match on eight types because they satisfy one matcher interface). So CGraph grew interface-dispatch resolution (#47): implements and dispatches_to edges plus a member-call rescue that binds an ambiguous call to the single interface method promising that name. After that, mux's replay selected 10 of 10 semantically selectable co-changed tests (the eleventh was an empty TODO test body that calls nothing), at a mean 37.7% of the suite — Go's dispatch fan-out trades selectivity for safety.

Scope as of v0.2.1: all three languages — TypeScript (41/41), Python (3/3), Go (10/10) — are replay-verified on their benchmarks.

v0.3: freshness pinning. The last roadmap gap was trust in the graph itself: an index that's out of date doesn't fail, it answers. CGraph one-shot builds now embed the same sha256-merkle-v1 content root the daemon publishes, and Blastline uses it three ways: every subset carries the root as provenance (in the JSON output, the MCP payload, and a footer on the PR comment — the selection names the exact tree it was computed from), --expect-root pins a selection to a specific tree, and --daemon-verify asks the repo's live CGraph daemon for its current root and refuses to select from a graph that doesn't match. Verified against a running daemon: matching roots produce a subset with provenance; edit one file and the daemon's root moves, so selection fails open naming both roots — the stale index can no longer answer politely and wrongly. The mtime heuristic stays as the fallback when no root exists. The code, the proposal, and every benchmark number are at github.com/Nxtsoft/blastline.