A test just went red. You changed 40 lines. Which one is the bug?
culprits runs your existing Vitest or Jest suite once
with per-test coverage, then ranks the source lines covered disproportionately by failing tests
versus passing ones. You get a short list, and every line on it comes with the failing tests that
prove it.
npx @precisionutilityguild/culprits
culprits · runner: vitest · tests: 11 (8 passed, 3 failed)
failing tests
F1 test/median.test.js > median > even length returns average of the two middle elements
F2 test/median.test.js > median > two elements returns their average
F3 test/stats.test.js > stats > summary of six sensor readings
top 3 suspects (of 8 nonzero; ranked by dstar2)
src/stats.js
#1 L12 ochiai 0.866 dstar2 9.000 ef 3/3 (F1 F2 F3) ep 1/8 [all-fail]
return (sorted[mid] + sorted[mid + 1]) / 2;
#2 L9 ochiai 0.775 dstar2 4.500 ef 3/3 (F1 F2 F3) ep 2/8 [all-fail]
const sorted = [...xs].sort((a, b) => a - b);
#3 L10 ochiai 0.775 dstar2 4.500 ef 3/3 (F1 F2 F3) ep 2/8 [all-fail]
const mid = Math.floor(sorted.length / 2);
Line 12 is the bug — the even-length case should average the two middle elements,
(sorted[mid - 1] + sorted[mid]) / 2. All three failing tests hit it, only one passing test does.
ef 3/3 is how many failing tests covered the line, ep 1/8 how many passing ones did.
[all-fail] marks a line every failing test touched — it's a label on the evidence, never a
sort key.
No config, no daemon, no index, no API key, no LLM. Same input, same output, every time.
Run it from any repo that uses Vitest or Jest:
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 and the report is the only thing on stdout, so
culprits --json > out.json and piping into an agent just work.
| 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 a package.json jest field, then a sole declared
Jest dependency; otherwise it keeps the Vitest default. Override with --runner vitest|jest.
| Code | Meaning |
|---|---|
| 0 | Ranking produced (at least one failing test, coverage collected) |
| 1 | Runtime or internal failure (runner crashed, no coverage collected) |
| 2 | Usage error (bad flags, missing selected runner, or project coverage is on) |
| 3 | Suite is green — nothing to localize |
How sharp the answer gets depends on your failing tests. 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, because 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) to get line-level signal back.
When the evidence runs out, you are told. A wide tie means "the coverage evidence cannot narrow this further" — the tool says so rather than faking confidence.
The other 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 carries failingTests and
suspects[] with per-line scores, counters, and covering-test IDs.
This repo ships a Claude Code skill (skills/culprits/) that teaches an 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 gets a custom runner
injected through a merged synthetic config; Jest gets a custom jest-circus test environment
selected on the command line, with --runInBand --maxConcurrency=1 forced. Both paths inherit
your own transforms, aliases, and test matching. Per line, culprits counts the failing and passing
tests covering it, ranks by DStar2, and breaks 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.
The same benchmark found the sharpest limit, so it belongs next to the good news: 5 of those 24 bugs (~20%) never appeared in the ranking at all, because they sat on continuation lines of multi-line statements and V8 attributes a statement to its opening line. If your bug is on a wrapped line, expect the ranking to point at the line the statement starts on — or to miss it.
- 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. - Project-owned coverage conflicts with per-test collection, so Vitest
coverage.enabled, JestcollectCoverage, and--coverageare refused. - 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 or 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.
Two accepted risks in the Jest runner, plus the full limitation list, are in pkg/README.md.
- 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
Four 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) |
| "One test is failing — which function returns the wrong value?" | apd — algorithmic debugging, LLM-as-oracle (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.