Test fails, 40 lines changed — these 3 lines are the culprits.
culprits runs your project's existing Vitest or Jest
suite once with
per-test coverage and prints a small ranked list of the source lines most likely to be
the bug — the lines covered disproportionately by failing tests versus passing ones.
Every suspect comes with evidence: its scores and the failing tests that hit it.
No config, no daemon, no index, no API key, no LLM. Fully deterministic.
npx @precisionutilityguild/culprits
A four-test suite where clamp high fails:
culprits · runner: vitest · tests: 4 (3 passed, 1 failed)
failing tests
F1 test/math.test.js > clamp high
top 2 suspects (all lines with nonzero score; ranked by dstar2)
src/math.js
#1 L6 ochiai 0.707 dstar2 1.000 ef 1/1 (F1) ep 1/3 [all-fail]
if (x > hi) return lo; // bug: should return hi
#2 L5 ochiai 0.577 dstar2 0.500 ef 1/1 (F1) ep 2/3 [all-fail]
if (x < lo) return lo;
The bug is ranked #1, with the failing test that proves it.
culprits # human-readable ranked list (default top 10)
culprits --json # machine format, byte-stable on stdout
culprits -n 20 # show the top 20
culprits --all # every line with a nonzero score
culprits --runner jest # override automatic runner detection
culprits -- test/area # everything after -- is passed through to the runner
The runner's own reporter goes to stderr; the culprits report is the only thing on stdout,
so culprits --json > out.json and piping into an agent just work. Exit codes are the
contract: 0 ranking produced, 3 suite is green, 2 usage error/refusal, 1 runtime
failure. Full contract and flag details: pkg/README.md.
| Runner | Versions | Configuration | Multi-project |
|---|---|---|---|
| Vitest | 4+ | Existing config is merged; aliases/plugins/includes survive | Refused |
| Jest | 30+ | Existing config stays active; environment is injected by CLI | Refused |
Detection prefers an explicit Jest config or package.json jest field, then a sole
declared Jest dependency; otherwise it keeps the Vitest default. Override with
--runner vitest|jest.
Measured on real repos with seeded bugs (receipts in notes/):
- Several failing unit tests → the right line.
- One failing unit test → the right function: the suspects fill the culprit's neighborhood, but a single failing test cannot discriminate between lines on its own always-executed path.
- Integration-heavy full-suite runs → module-level signal at best; scope the run to
the affected area (
culprits -- test/that-area) for line-level signal.
The tool is honest about its ceiling: a wide tie means "the coverage evidence cannot narrow this further," and it says so rather than faking confidence.
The second consumer is a coding agent mid-debug: instead of re-reading a 40-line diff to
guess, it runs culprits --json and jumps to the top suspects. The JSON gives
failingTests and suspects[] with per-line scores, counters, and covering-test IDs.
This repo ships a Claude Code skill (skills/culprits/) that teaches the agent when to reach for the tool, how to scope runs, and how to read ties — including the measured cases where scoping hurts the ranking.
A runner adapter snapshots V8 precise coverage around every test. Vitest uses a custom
runner injected through a merged synthetic config; Jest uses a custom jest-circus test
environment selected on the command line with --runInBand --maxConcurrency=1. Both
paths inherit the project's own transforms, aliases, and test matching. Per line,
culprits counts failing/passing tests covering it and ranks by DStar2, breaking ties
with Ochiai. If any test file fails to collect, it refuses rather than ranking a
partial run.
The technique is spectrum-based fault localization — a 1997-lineage method (Reps/Ball/Das/Larus FSE '97; Tarantula, Jones et al.; Ochiai, Abreu et al.; DStar, Wong et al.) that is standard tooling in Java and was simply never shipped for JS/TS. DStar2-as-default is a measured choice, not a preference: on a 24-seeded-bug benchmark across 7 repos it was never worse than Ochiai and strictly better in 7 trials. Scoring derivation: spec/output-samples/DERIVATION.md.
- Vitest 4+ and Jest 30+ only. Use
--runner vitest|jestwhen detection is ambiguous. - Module-scope (top-level) code is not ranked — it runs once at import time and belongs to no single test.
- Vitest workspace /
test.projectsand Jestprojectsconfigs are refused — run inside an individual project directory instead. - Jest transforms must provide a usable source map; files whose executed code cannot be mapped safely are excluded rather than assigned guessed line numbers.
- Jest currently requires jest-circus with its default Node environment. Custom/per-file environments and test retries are refused when they break one-record-per-test attribution.
test.concurrenttests are serialized so their coverage can be attributed.
- pkg/ — the npm package (
npx @precisionutilityguild/culprits) - skills/culprits/ — Claude Code skill wrapper
- spec/ — input fixture and golden outputs
- notes/ — the receipts: research & competitor sweep, ranking benchmark, real-repo dogfood, original product spec
Three tools, one idea: answers grounded in what your tests actually execute — evidence an agent can't hallucinate. Pick by the question you're holding:
| Your question | Tool |
|---|---|
| "A test is failing — which line is the bug?" | culprits — spectrum fault localization (npm) |
| "Where is feature X implemented?" | recon — feature location by coverage diff (npm) |
| "My uncommitted diff broke the tests — which hunks?" | diffbisect — delta debugging below commit granularity (npm) |
MIT.