Spec 20. Cross-language. Deterministic, offline, no API key. Candidates, never deletion authority.
find_dead_code answers three graph-reachability questions grep can't (it sees text, not reach)
and the model burns tokens guessing at:
- "What code is unreachable / dead?" — mark-and-sweep from roots; the unreached remainder.
- "Is anything calling X?" — reachability from every entry point.
- "What becomes dead if I delete X?" — the set reachable only through X.
Prior art is knip / ts-prune (mark-and-sweep from entry points) — but TypeScript/JavaScript-only. This is the cross-language version over the unified tree-sitter graph (15+ languages).
Static reachability cannot see dynamic entry points, framework magic (routes, DI, plugin
registries), reflection, or public API consumed outside the repo. All of these produce false
"dead" positives. So find_dead_code:
- treats tests, imported symbols, route handlers, and
mainas roots, - returns confidence-tagged candidates with a reason, never a verdict,
- and never auto-deletes.
Every response carries soundness.posture: "candidates-not-authority" and explicit caveats. Treat
it as a lead generator for a human/agent to verify — not a delete list.
A node is a root (assumed live) if it is a test, is imported by name from another file, is a
detected HTTP route handler, or is main-like. Reachability is a forward BFS from those roots.
Confidence is deliberately conservative — the bias is toward false-live over false-dead:
| Confidence | When |
|---|---|
high |
static language · no internal caller · not imported by name · and its module is not imported anywhere |
medium |
reachable only from other dead code, or no dependency-graph signal available |
low |
dynamic language (Python/Ruby/PHP/…), or its module is imported elsewhere (namespace/default/re-export usage the named-import scan can't resolve) |
That last rule matters: on a real repo it cut high-confidence candidates from ~470 to ~35 — a
symbol living in a module something else imports is never flagged high, because the specific
usage may be a namespace or default import this static scan doesn't resolve.
A reachability conclusion is only as sound as the call graph is complete. The TS/JS import resolver
follows re-export chains — export { x } from './impl', export * from './x' (and TypeScript's ESM
.js specifiers) — through any depth of barrel to a symbol's true definition, and that resolution
runs on every cross-file call edge (not just base classes). A call imported through a barrel resolves
to the real target (labelled re_export) instead of stalling at the barrel and falling through to the
ambiguous first-same-named-candidate (name_only). Concretely, dogfooding this repo moved 29 symbols
off the false-dead / false-entry-point list — e.g. EdgeStore.open went from a reported zero
callers to its real 22 — because a method/static call through an imported receiver now binds to its
definition. See openspec/changes/add-call-resolution-recall/.
Report output:
{
"stats": { "analyzed": 1455, "roots": 399, "reachable": 790, "candidateDead": 665 },
"rootKinds": { "tests": 0, "imported": 393, "httpHandlers": 0 },
"byConfidence": { "high": 35, "medium": 35, "low": 595 },
"candidateDead": [
{ "name": "isValidEmail", "file": "src/utils/validation.ts", "language": "TypeScript",
"fanIn": 0, "confidence": "high",
"reason": "no internal caller; not imported by name from any other file; not a test, route handler, or main entry" }
],
"coverage": { "languages": ["TypeScript", "Python", "Go"], "exportSignal": "dependency-graph" },
"soundness": { "posture": "candidates-not-authority", "caveats": ["These are CANDIDATES…", "…"] }
}Delete-impact output:
{
"target": "handler",
"becomesDeadIfDeleted": [{ "name": "helper", "file": "src/app.ts", "language": "TypeScript", "fanIn": 1 }],
"count": 1,
"note": "These nodes are reachable only through the target. Deleting it orphans them — verify before removing."
}Pure read over the existing graph — no schema change:
- Reachability — forward BFS over
buildAdjacency's forward map from the root set; candidate-dead = code nodes not reached. External and infrastructure (IaC) nodes are excluded. - Liveness signals — tests + HTTP route handlers +
main, plus the dependency graph's imported names (symbol-level) and imported files (module-level) for the cross-language "used elsewhere" signal. - Delete-impact — recompute reachability with the target removed from both seeds and the graph, and diff against the baseline reached set.
Implementation: reachability.ts. Tested over
a two-language fixture with known live regions, a dead orphan, a dead cluster, and a
delete-impact diff in
reachability.test.ts.
Accuracy depends on a current
analyze_codebasethat includes tests and produces the dependency graph. Without test nodes as roots, test-only code is flagged; without the dependency graph, confidence is reduced and the response says so.
Index integrity. When the persisted index does not reconcile against its build-time attestation — materially smaller than the build committed (
degraded) or built at a different schema (mismatched) — the response carries that verdict inconfidenceBoundary.integrityand is not markedcomplete. A "dead" conclusion over a half-built index is the most dangerous false negative, so "looks dead to a broken index" is labeled, never asserted. Re-runanalyze_codebaseto rebuild.