@precisionutilityguild/culprits
v0.2.1
Published
Spectrum-based fault localization for Vitest and Jest — ranks the lines most likely to be your bug.
Downloads
218
Maintainers
Readme
culprits
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/culpritstop 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;That is spectrum-based fault localization (SBFL): DStar(*=2) primary, Ochiai as the first tie-break. No config, no daemon, no index, no API key, no LLM calls. Same input, same output, every time.
Usage
Run it from any repo that uses Vitest or Jest:
culprits # human-readable ranked list (default top 10)
culprits --json # machine format
culprits -n 20 # show the top 20
culprits --all # every line with a nonzero score
culprits --runner jest # override automatic runner detection
culprits -- test/foo.js # everything after -- is passed through to the runnerThe 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 are byte-stable.
Lines covered by all failing tests (nf = 0) carry an [all-fail] badge in the human output and
"coveredByAllFailing": true in JSON. It is a display marker only, never a sort key.
Runner support
| Runner | Versions | Injection | Project config | |--------|----------|-----------|----------------| | Vitest | 4+ | Custom runner through a merged synthetic config | Inherited | | Jest | 30+ | jest-circus environment via CLI; serialized in-band | Inherited |
Both are optional peers: your project needs only the runner it already uses. Detection prefers Jest
when a Jest config or jest field exists, then a sole declared Jest dependency; otherwise it keeps
the Vitest default. Override with --runner vitest|jest.
Exit codes
| Code | Meaning | |------|---------| | 0 | Ranking produced (≥1 failing test, coverage collected) | | 1 | Runtime/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 |
What to expect from the ranking
How sharp the answer gets depends on your failing tests. Discrimination scales with failing-test
diversity and falls with coverage breadth. Measured on real repos with seeded bugs (receipts in the
repo's notes/):
- Several failing unit tests → the right line (benchmark fixture: bug ranked #1).
- 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; innocent lines covered only
by the failing integration tests float up. Scope the run to the affected area
(
culprits -- test/that-area) for line-level signal. On a 1275-test repo, scoping moved the seeded bug from rank 19 to inside the top 10, with all suspects in the right function.
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.
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.
How it works
A runner adapter snapshots node:inspector precise coverage around every test. Vitest uses a custom
runner injected through a merged synthetic config. Jest uses a custom jest-circus environment
selected through CLI flags and forces --runInBand plus --maxConcurrency=1; your config,
transforms, module mappings, and test match rules stay active. Captured executed sources and their
source maps are converted back to original lines with ast-v8-to-istanbul. Per line, culprits counts
the failing and passing tests that cover it, ranks by DStar2, and breaks ties with Ochiai.
If any test file fails to collect (a bad import, an unresolved alias, a syntax error), culprits refuses rather than ranking a partial run — run the selected runner directly to see why.
The technique is 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.
Limitations
- Vitest 4+ and Jest 30+ only.
- Module-scope (top-level) code is not ranked — it evaluates once at import time and is attributed to no single test.
test.concurrenttests are serialized (maxConcurrency: 1) so their coverage can be attributed; any residual overlap is flagged and excluded.- Coexisting with project-owned coverage conflicts with per-test collection, so culprits refuses
Vitest
coverage.enabled, JestcollectCoverage, and--coverage. - Vitest workspace /
test.projectsconfigs are not supported — the coverage runner is injected at the root and vitest does not propagate it into sub-projects, so culprits refuses. Run it inside an individual project directory instead. - Jest
projectsconfigs are not supported; run inside one project directory. - A transformed Jest file must expose a usable source map. Unverifiable files are excluded rather than mapped to guessed lines.
- Jest requires jest-circus and the default Node environment. Custom or per-file environments and retry attempts are refused when they prevent one coverage record per reported test.
Known limitations (Jest)
Two accepted risks in the Jest runner:
- The incomplete-run guard is count-based (
numTotalTestsvs recorded coverage entries), so an exactly offsetting pair of anomalies could in principle mask a partially-instrumented run. - Coverage of source modules shared across test files relies on V8's
Debugger.enablescriptParsedreplay — standard behavior, but not yet pinned by a cross-file fixture.
Part of a family
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.
