Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

apd

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.

How it gets there

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.

Usage

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 support

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.

Exit codes

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.

For coding agents

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.

What to expect

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.

Limitations

  • 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 -t filter; 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.yml is refused (exit 2). apd redirects the recorder by writing that file, so it cannot point appmap_dir anywhere 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.yml at 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.

Background

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.

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

Algorithmic debugging for Vitest and Jest — divide-and-query one failing test's call tree with an LLM oracle to name the function that returns the wrong value.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages