One test is failing. You have a stack trace and forty functions that ran. Which one actually returned the wrong value?
apd answers that. Point it at the failing test and it names the function:
npx @precisionutilityguild/apd -- vitest run
apd · 3 questions · oracle: fixture
Guilty: lineTotal (src/cart.mjs:10)
args: price=5, qty=2
returned: 11
why: it returned a wrong value and called nothing else — the fault is in its own body
Transcript:
✓ taxFor (src/cart.mjs:5) → correct
✗ subtotal (src/cart.mjs:15) → incorrect
✗ lineTotal (src/cart.mjs:10) → incorrect
That's a real run against pkg/test/fixtures/broken-project. The test asserts
checkout(5, 2) === 20 and gets 21. lineTotal is two calls down, and subtotal calls a
correct helper too — so walking to the deepest node would have blamed the wrong function.
Three questions, no guessing.
apd records the failing test's call tree, then plays twenty questions with it. At each step it
asks an oracle one thing: "given these arguments, is this return value correct?" Every answer
halves the suspect region. It stops when one function is left — the one that returned a wrong
value while everything it called returned right ones.
The split that matters:
- The search is deterministic. Which node gets asked about, in what order, and when to stop come from the bisection math alone. Same tree, same answers, same verdict, every time.
- The judgments are model-dependent. The default oracle is an LLM, so you need
ANTHROPIC_API_KEY. But the model never sees the tree, the other candidates, or the search state. It answers one yes/no question about one call.
That boundary is the whole design. Other tools hand the model a debugger and let it roam. Here the algorithm names the function; the model only judges single calls.
apd -- vitest run # debug the one failing test in the suite
apd -- vitest run test/cart.test.mjs # scope to a file
apd -- jest test/cart.test.js # same, with jest as the runner
apd --json -- vitest run # machine format, byte-stable on stdout
apd --model <id> -- vitest run # oracle model (default claude-opus-5)
apd --max-questions 20 -- vitest run # cap oracle questions (default 50)
apd --root ../other-project -- vitest run
apd --help
Everything after -- is your test command, and its first token names the runner: vitest or
jest. There is no --runner flag — apd reads it from the command you already type. Note that
apd -- jest test/cart.test.js -t "adds tax" has no run subcommand; that's vitest-only syntax.
apd never installs a runner for you. It drives the one your project already has, resolved from
your node_modules and never from PATH, so the code recorded is the code your suite runs.
The runner's output and apd's progress go to stderr. The report is the only thing on stdout, so
apd --json > verdict.json and piping into an agent both just work.
Offline oracle. APD_ORACLE=fixture:<path.json> answers from a JSON map keyed by function
name or file:line — useful for testing the flow without a network. A function the map does not
mention is an error, not a silent "correct".
| Runner | Versions | Recording | Notes |
|---|---|---|---|
| Vitest | 4+ | Whole-process AppMap map | No per-test metadata; the test name and failure come from the JSON reporter |
| Vitest | ≤3 | Per-test AppMap map | Carries test_status and the failure message directly |
| Jest | 28 or 29 (>=28 <30), CommonJS only |
Per-test AppMap map | Carries test_status, the exception and real nested call trees; jest 30+ is refused (exit 2) |
Recording is done by appmap-node; apd deliberately
ships no recorder of its own — the discipline over the trace is the product, not the trace. jest
is an optional peer dependency (jest: ">=28 <30"), never a regular one.
Why jest tops out below 30. apd records with appmap-node 2.26.1, the newest release. On
jest 30.x that recorder throws ReferenceError: environment is not defined from jest-runtime's
FileCache constructor before a single test runs — zero tests, zero maps, and --runInBand does
not help. appmap-node's own devDependencies pin jest: ^29.6.2, so 29 is the version it is
tested against. apd refuses jest 30+ up front with exit 2, names appmap-node as the cause, and
points at vitest as the escape hatch, rather than handing you an empty recording.
Jest must be CommonJS. apd runs jest without --experimental-vm-modules, so an ESM jest
project fails to load its test files. apd refuses with exit 2, printing jest's own SyntaxError: Cannot use import statement outside a module, and explains that even with the flag appmap-node's
CommonJS-only recorder writes no call tree. Either way: a CommonJS jest project, or vitest.
Worth knowing that jest 28/29 records better than vitest 4 — real per-test maps instead of one whole-process map.
Branch on these, not on stdout.
| Exit | Meaning |
|---|---|
| 0 | Verdict produced — a function was named. |
| 1 | Runtime failure: oracle/API error, the isolated re-run did not reproduce the failure, or the bisection was inconclusive (every recorded top-level call judged correct). |
| 2 | Usage error or refusal: bad flags, a runner that is neither vitest nor jest, that runner missing from the project, a jest outside >=28 <30, more than one failing test, a test file the runner could not load at all (either runner — a setup error, not a green suite), a project with its own appmap.yml, a multi-thread trace, no recorded call tree, or a fixture map with no answer for a function. |
| 3 | Nothing to do — the suite is green. |
apd --json emits the verdict as structured data. The guilty function's key is function, not
name — reach for .guilty.name and you get undefined:
{
"outcome": "guilty",
"guilty": {
"function": "lineTotal",
"file": "src/cart.mjs",
"line": 10,
"args": [
{ "name": "price", "class": "Number", "value": "5" },
{ "name": "qty", "class": "Number", "value": "2" }
],
"returnValue": { "class": "Number", "value": "11" },
"exception": null
},
"reason": "no-children",
"questionCount": 3,
"transcript": [
{ "nodeId": 5, "function": "taxFor", "file": "src/cart.mjs", "line": 5, "judgment": "correct" }
],
"test": { "name": "checkout totals a two-item line", "failure": "AssertionError: expected 21 to be 20" },
"oracle": { "model": "fixture" }
}outcome is "guilty" or "inconclusive". On inconclusive, guilty is null and the exit
code is 1. This repo ships a Claude Code skill (skills/apd/) that teaches an agent
when to reach for apd instead of culprits.
Dogfooded against a real repo (receipts in notes/): three seeded bugs in a culprits
clone, all three named exactly. Median wall clock 0.95s across nine runs (three per bug, range
0.87–1.05s) — and the runs needing 0, 3, and 6 questions all landed in that same band, because
Node startup and test discovery dominate the bisection itself.
Measured on synthetic trees, worst case over every possible guilty node:
| Tree | Nodes | Questions (worst) | Questions (mean) |
|---|---|---|---|
| Balanced binary | 1023 | 12 | 10.2 |
| Balanced binary | 63 | 8 | 6.2 |
| Linear chain | 63 | 6 | 6.0 |
Chains hit exactly log2(n+1) — textbook binary search. A 1023-node tree resolves in 12
questions where a linear scan would need 1022.
The honest caveat: this is logarithmic in subtree weight, not node count. A flat tree — one parent with 32 leaf children — has no subtree bigger than one, so there is nothing to halve and the search degenerates toward a linear scan, one question per child. Deep call trees are where the mechanism pays; wide shallow ones are where it does not.
- Sync and awaited code only (v1). Traces spanning more than one thread are refused, not guessed at — interleaved events cannot be reconstructed into a call tree from order alone. Full async/concurrent fidelity is out of the first cut.
- One failing test at a time, by design. With several failures apd refuses (exit 2) and lists
them with a ready-to-paste
-tfilter; recording the wrong tree would produce a confident wrong answer. - Vitest 4 records a whole-process map with no per-test metadata (appmap-node's per-test recorder engages only on vitest ≤3). apd handles both, but on vitest 4 the test identity comes from the JSON reporter rather than the map.
- Jest is 28 or 29 (
>=28 <30), CommonJS only. Both are refusals rather than degradations: jest 30+ crashes appmap-node before any test runs, and an ESM jest project cannot load its test files under apd. See Runner support. - A suite that fails to load is refused, not read as green. If a test file can't be loaded at all — an ESM jest project, a vitest file importing a missing module — apd exits 2 with the runner's own error rather than reporting "no failing test". A genuinely green suite still exits 3.
- A project with its own
appmap.ymlis refused (exit 2). apd redirects the recorder by writing that file, so it cannot pointappmap_diranywhere it can collect from while yours is in place. Move it aside for the run. apd's own leftover debris from an interrupted run is the exception — that it still reclaims. - Concurrent apd runs in one project race on the shared
appmap.ymlat the project root. Run one at a time per repo. - The verdict is only as good as the oracle's answers. The search is sound given correct yes/no judgments; a wrong judgment sends it down the wrong subtree and it will name the wrong function just as confidently. The transcript exists so you can check the reasoning.
- The live LLM oracle has never been called end to end. Not once. The request shape (model, schema-forced verdict, no sampling params, refusal/truncation handling) is pinned by tests against an injected stub, and every end-to-end run — including the dogfood — used the offline fixture oracle with authored ground truth. Judgment quality on real code is unmeasured.
Algorithmic (declarative) debugging — Shapiro 1982, ACM Distinguished Dissertation; see the ACM CSUR 2017 survey. Pick the node whose subtree weight is closest to half the suspect region and ask about it. "Incorrect" makes that subtree the new suspect region; "correct" prunes it entirely. Either answer roughly halves the search. Terminate when the known-wrong node has no unjudged children — that node returned a wrong value while everything it called returned right ones, which is the definition of the guilty function.
The technique was validated in 1982 and never productized, for one reason: the oracle was a human
answering dozens of tedious "is f([2,3]) = 5 right?" questions. The LLM is the oracle now, and
the algorithm keeps it confined.
Traces are consumed in AppMap format. Call/return events are paired into a tree; a frame whose return was never recorded is kept as a judgeable node but never credited as the parent of calls made after it died.
- pkg/ — the npm package (
npx @precisionutilityguild/apd), and its README - skills/apd/ — Claude Code skill wrapper
- notes/ — the receipts: real-repo dogfood, family README rollout plan
- main.md — the original product spec and competitor sweep
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.