Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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/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.

Usage

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 support

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.

Exit codes

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

What to expect from the ranking

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.

For coding agents

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.

How it works

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.

Limitations

  • Vitest 4+ and Jest 30+ only. Use --runner vitest|jest when 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.projects and Jest projects configs are refused. Run inside an individual project directory instead.
  • Project-owned coverage conflicts with per-test collection, so Vitest coverage.enabled, Jest collectCoverage, and --coverage are 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.concurrent tests 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.

Repo layout

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.

About

Test fails, 40 lines changed — these 3 lines are the culprits. Spectrum-based fault localization (SBFL) for vitest: one command, no config, no LLM.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages