Dead code has always been a maintenance problem. It is now also a per-session bill.
A developer who works in a repository for a year learns which directories are abandoned. They stop opening those files. The cost of the dead code mostly disappears because the knowledge that it is dead lives in their head.
A fresh agent session usually does not inherit that tacit knowledge. It searches and reads, and dead code is indistinguishable from live code to a search. It matches the same greps. It appears in the same directory listing. It has plausible names because it was real once. It occupies the same context window as the code that runs.
That cost is often paid again in each new session. We have written before about where an agent's token budget actually goes. Dead code is one of the line items you can remove outright.
We expected the first run to produce a deletion list. It did not. Across three review passes, applied as six small changes, we removed 40 symbols. Only four came directly from the orphan list. Most came from examining public surfaces whose consumers had disappeared.
"No references" is a graph fact, not a deletion instruction
A reachability tool can answer one precise question: does this graph contain a reference to this symbol? It cannot establish that the symbol is safe to delete.
No reference can mean the symbol is unused. It can also mean the caller sits in a template, in another repository, or in a source shape the extractor does not yet recognise. Whether to remove the symbol also depends on why it exists and who owns it.
That middle case is also a lever, and it is the one worth planning around. The strength of a “no references” finding is bounded by what you put in the graph. A backend indexed on its own can only tell you that nothing in that repository calls a handler, which is a weaker statement than it looks. Index the backend together with the clients that call it and the same finding moves much closer to a verdict.
Routes are the sharpest version of this. enola reports unused endpoints only for a multi-repo snapshot, and when client coverage is partial it reports that too, rather than letting a thin graph read as a clean bill of health. It separates how many endpoints matched no client call from how many could be assessed at all, and names the client repositories still missing. Those are different claims, and a reader given only the first will take it as a statement about the repository.
The same applies less visibly to exported symbols. An exported function with no local caller is flagged as exported precisely because it may be used outside the snapshot, and widening the snapshot is the only thing that settles it.
On this 500-file repository, the analysis reduced thousands of symbols to 57 high-confidence candidates. Manual review confirmed 34 and found an extractor gap behind the other 23.
What we found
enola indexes a repository into facts and runs explainers over the resulting graph. One of them is dead-code, backed by the find_orphans tool.
On enola itself, the analyser found 230 orphans, of which 57 were in the high-confidence tier. High confidence means functions, where plain calls are reliably tracked as edges, as opposed to methods and types where dispatch and type usage are not.
Those 57 split cleanly once we read them:
| Finding | Count | Correct? | Action |
|---|---|---|---|
| Vendored tree-sitter grammars | 23 | Yes | Keep — not our code |
examples/ fixtures | 7 | Yes | Keep — written to be violations |
| First-party, genuinely unused | 4 | Yes | Delete |
| First-party, graph was missing an edge | 23 | No | Fix the extractor |
Manual review confirmed 34 of the 57. We deleted four. The other 30 were vendored code or intentional fixtures: genuinely unreferenced by our code, but not ours to remove.
The remaining 23 exposed missing edges in the graph. We fixed the extractor before continuing the cleanup.
When an unreferenced symbol should stay
Several reasons to keep a finding recurred during the review:
| Context | Example from this run |
|---|---|
| Vendored — you did not write it and will not maintain it | tree-sitter grammar sources |
| Intentional fixture — it exists to be an example | examples/, written to violate a rule on purpose |
| Deliberate test seam — a narrower entry point for tests | extractFileAST, beside the whole-repo path production uses |
| Documented API completeness — unused by design | factpath.Base, which exists "for symmetry with Dir" |
| Framework or template dispatch — the caller is not code the analyser reads | {{.MembersAttr}} in the dashboard page |
| Unshipped capability — built, documented, not yet connected | Merge, which unions two machines' history logs |
Template dispatch
MembersAttr is a method on the dashboard's module-graph node. Go's own deadcode tool reported it unreachable, and it was right: no Go code calls it. It is called from page.html.tmpl, as {{.MembersAttr}}, resolved by reflection at render time.
A Go-source-only reachability analysis will not see that call. Before deleting a reported symbol, check the other mechanisms that can invoke it: templates, configuration, generated wiring, DI containers, and plugin registries.
Documented API completeness
factpath.Split had no callers anywhere, not even a test. The finding was correct. Its package documentation names it explicitly as one of the operations the package covers, and says of its siblings that they exist "for symmetry with Dir" so a call site need not mix two path dialects mid-expression.
Deleting it would have broken a documented package contract. A deliberately complete API can have unused members by design, and the package documentation is where that intent was recorded.
Unshipped capability
Merge in our history package unions two machines' logs of the same repository, dropping duplicate revisions by content-addressed ID and renumbering local sequence numbers. It carries twenty lines of comment about ordering across clock offsets on two machines that recorded the same second. Nothing calls it.
The implementation was unused, but its comments preserved clear product intent. A reachability tool cannot distinguish obsolete code from unfinished product work.
We chose to delete it as a product decision rather than an automatic cleanup. The format rules it pinned are covered by tests, and the feature had no current consumer. Another team might choose to finish or retain it; the analyser cannot make that decision.
When the graph is the thing that is wrong
The other 23 findings were not intentional exceptions. The graph was missing real references.
enola's Go extractor walked function bodies looking for call expressions. A function used as a value is not a call expression. So "go.mod": readGoMod in a dispatch table produced no edge, rc.lock(f, "yarn.lock", yarnLock) as an argument produced no edge, and Side: sourceSide as a struct field produced no edge. Package-level initialisers were worse off: nothing walked them at all, so even a plain call in var repoRoot = findRepoRoot() was invisible.
Every one of those functions then appeared in the tier documented as safest to delete. This is the failure mode that matters in a fact graph, and it is worth stating precisely: a missing edge does not merely lose a fact. It manufactures a confident wrong answer about the symbol on the other end.
We fixed the extractor to record functions used as values and to walk package-level var and const initialisers. Package-level references attach to the package fact because the table holding them is usually an unexported variable with no symbol of its own.
Resolution remains deliberately narrow: a bare identifier must name the package's own top-level function. A local redeclaration suppresses the edge rather than inventing a call to the wrong symbol. That trade-off can still produce a false orphan, but it does not hide one behind a fabricated reference.
| Before | After | |
|---|---|---|
| First-party high-confidence findings needing a graph fix | 23 | 0 |
| Call edges in the golden corpus | baseline | +15, none removed |
| Symbols wrongly classed as isolated | 2 | 0 |
After the fix, none of the remaining high-confidence findings failed manual verification.
We checked that this was precision and not blindness, because a fix that suppresses findings looks identical to a fix that breaks the detector if you only count findings. We planted two probes: a function referenced by nothing, and a function referenced only from a package-level dispatch table. The first is still reported. The second is not. Then we removed them both.
The same failure had appeared before. A package's own min helper shadowed the predeclared identifier, and the extractor dropped calls to it. A function called seventeen times was reported as dead. The extractor cache history records that case; both bugs came from valid reference shapes the extractor did not yet recognise.
The deletions reachability could not find
Most of the 40 symbols we removed were never on the orphan list and could not have been. Their own tests made them reachable.
This repository used to have a second binary that wrapped it. That binary was retired, but the extension points built for it remained.
They included chainable setters nothing called, a template overlay with no overlay, a tool-weight registration hook with nothing to register, and re-exports whose documented consumer no longer existed. Their tests were the last thing holding them up.
A different graph query helped find them: who consumes this public surface, and does that consumer still exist? We enumerated the exported surface, checked both repositories for consumers, and compared each symbol's documented purpose with the code that remained.
We ran this on a single-repository snapshot, which is why that step was manual: the consumers lived in a second repository that was not in the graph. Indexing both together would have produced those edges directly. It would not have made the decision, because the fact that settled it, that the consuming binary had been retired, is not something any graph contains.
That check turned up four comments that were not merely stale but false, including two functions whose entire stated justification for being exported was a validator that does not exist in this repository at all.
And it turned up the best find of the exercise, which was not code.
ScanFootprint measures the size of the source enola actually parsed. Our architecture document describes that measurement as the anchor of the entire value model — "Every figure derives from one measurement" — and then names the function. It had no callers. Not one.
The measurement had been superseded some time earlier by a value recorded during extraction and read from the snapshot metadata in a different package. The feature was alive and correct. The implementation the document pointed at was not, and the document had gone on confidently describing it.
A stale comment misleads one reader at a time. A stale architecture document misleads every new joiner and every agent that reads it for orientation, and no test can fail on it. We found it only because we went looking for code nobody calls.
Checking the architectural delta
After choosing what to delete, we still needed to check the architectural effect of each change. We pinned a baseline before the cleanup and graded every delta against it:
enola baseline pin /path/to/repo
# ...delete things...
enola check /path/to/repo
We made the cleanup as six small changes across three review passes and graded each one. Every check reported no new architectural regressions, findings, or coupling, plus an itemised list of the symbols and edges that left the graph.
This does not replace builds or tests; it verifies a different claim: the cleanup changed only the intended structure. What a code graph can check covers the wider subject. The relevant part here is that the gate fails only on rules you chose.
Twice during this work, enola check refused to answer. The first baseline came from a build with a different version string. The second became incompatible when we bumped the extractor's cache version, meaning the two snapshots had been produced by builds that extract differently.
Both times the command declined and explained why, using an exit code distinct from the one for a regression:
DECLINED — refusing to grade: the baseline is not comparable to the current snapshot.
This is NOT a statement about your change.
That is the right answer. A gate that graded anyway would have reported the extractor improvement as hundreds of architectural changes to code nobody touched, and after one such report you would stop believing the next one.
A deletion checklist
- Index as much of the system as you can before you start. A backend on its own cannot tell you an endpoint is unused, only that nothing in that repository calls it.
- Treat findings as candidates. Check templates, configuration, generated wiring, and external consumers before deleting.
- Separate intentional APIs and unfinished product work from genuinely unused code. The graph cannot make that product decision.
- Delete in small batches. Run tests and grade each architectural delta against a pinned baseline.
Looking for code nobody calls is also a good way to find documentation nobody has checked. In our case, the function with no callers was harmless. The architecture document claiming it was load-bearing was the actual defect.
FAQ
Does a dead-code tool tell you which code is safe to delete?
No. A reachability tool answers one precise question: does this graph contain a reference to this symbol? It cannot establish that the symbol is safe to delete. No reference can mean the symbol is unused. It can also mean the caller sits in a template, in another repository, or in a source shape the extractor does not yet recognise. Treat findings as candidates, and check templates, configuration, generated wiring and external consumers before deleting.
When should an unreferenced symbol stay?
Several reasons recur. Vendored code you did not write and will not maintain. Intentional fixtures that exist to be examples. Deliberate test seams that give tests a narrower entry point than production uses. Deliberately complete APIs whose unused members exist by design, with the intent recorded in the package documentation. Symbols dispatched from a template or framework rather than from code the analyser reads. And unshipped capability: built, documented, not yet connected to a consumer.
Why does dead code cost more when an AI agent reads the repository?
A developer who works in a repository for a year learns which directories are abandoned and stops opening those files. A fresh agent session usually does not inherit that tacit knowledge. It searches and reads, and dead code is indistinguishable from live code to a search: it matches the same greps, appears in the same directory listing, has plausible names because it was real once, and occupies the same context window as the code that runs. That cost is often paid again in each new session.
Why can static analysis report code as dead when it is actually in use?
Because a missing edge in the graph does not merely lose a fact. It manufactures a confident wrong answer about the symbol on the other end. An extractor that walks function bodies looking for call expressions will miss a function used as a value: a dispatch-table entry, a callback argument and a struct field each produce no call expression, and package-level initialisers may not be walked at all. Every such function then appears in the tier documented as safest to delete.
How do you verify that deleting code did not change the architecture?
Pin a baseline before the cleanup, delete in small batches, and grade each delta against that baseline. The check reports new architectural regressions, findings and coupling, plus an itemised list of the symbols and edges that left the graph. It does not replace builds or tests; it verifies a different claim, that the cleanup changed only the intended structure.
Does the scope of the snapshot change what counts as dead code?
Yes. A no-references finding is only as strong as the graph behind it. A backend indexed on its own can show that nothing in that repository calls a handler, which is much weaker than showing that no client calls it. Indexing the backend together with its clients turns the same finding into a far stronger claim. enola reports unused endpoints only for a multi-repo snapshot, reports how many endpoints could be assessed at all when client coverage is partial, and flags exported symbols as possibly used outside the snapshot.