Dead code with passing tests: what coverage won't tell you
6m read time

Dead code with passing tests: what coverage won't tell you

I rescanned 29 of my own repositories for dead code. Of 296 dead symbols, 138 are only ever called by tests. Coverage counts them as covered, and an agent reading the repo sees a tested function that looks safe to reuse.

evidence138 dead symbols only tests still callknossos 0.44.0, vitest 5.0.02 sourcesNovember 11, 2026
own measurement
Knossos scan of 29 own repositories, 327 candidates checked, on 11 November 2026
tested on
knossos 0.44.0, vitest 5.0.0
checked
November 11, 2026

In mcpobservatory, the project behind MCP Observatory, there is a function called parseSemgrepJson. It turns Semgrep's JSON output into findings. If the JSON doesn't parse, it returns an empty list.

That last part was a bug. A report cut off halfway because the buffer filled up became "Semgrep looked and found nothing", and the scan went into the database as clean. I fixed it on 11 August. Production has called parseSemgrepJsonStrict ever since, which returns null when the report can't be read.

The old function stayed. It is still exported and still does exactly what the bug did. Nine tests call it, 15 times per run, and coverage dutifully counts it in the 95.1% of functions the suite reaches. Nothing in production uses it.

To an agent looking for "the Semgrep parser", this is the best candidate in the repository: the shortest name, a docblock, and tests showing how to call it. It is also the one version that brings the bug back.

Only the test still calls parseSemgrepJson, production uses parseSemgrepJsonStrict

What I counted ​

In September I ran Knossos, the architecture scanner I'm building, over all my repositories. Today I did it again with Knossos 0.44.0, on the 29 of my own repositories that are still on this machine. Code I write for work stayed out.

Knossos reports dead code as candidates, each with a confidence level. I took only the probable ones. Of those, 166 were settled by the simplest check there is: the name appears nowhere else in the repository, or for test-only code, nowhere outside the tests. The other 161 were checked by hand against the source, first by three agents and then by me: the receiver's type, whether a call sits in a comment, whether a script is one I run by hand.

Test helpers that say so in their name, such as __resetStatusRegionForTests, I left out up front. They are test-only on purpose.

WhatCount
probable candidates327
confirmed dead296
of which only called by tests138
false positives31 (9.5%)
repositories with dead code18 of 29

The false positives were nearly all code the scanner can't see being loaded: scripts I run by hand, fixture files Semgrep reads as data, callbacks handed to a library.

One repository I dropped entirely. Knossos 0.44.0 mistook its Python imports for external modules, so every service looked dead. A scanner that tells its own author the whole backend is dead is at least not short on confidence. The bug is now on Knossos's own list.

Why tested dead code is worse ​

Untested dead code stands out. Coverage paints it red, and whoever runs into it wonders whether it's still needed. Tested dead code looks like part of the system: it has a test, it counts towards coverage, and the test shows you how to call it.

The makers of knip, a tool that finds unused exports in JavaScript and TypeScript, describe the problem in their production mode documentation: "Test files usually import production files. This prevents production files or their exports from being reported as unused, while sometimes both of them can be deleted."

To a human that is clutter. To an agent it is documentation. A coding agent reads the repository to decide what it can reuse, and an exported function with a passing test is the strongest signal it gets. Nothing in the code records that the function was replaced in August, but a dependency graph sees it: outside the tests, nobody calls it.

GitClear measures the same pattern from the other side. Their 2026 report finds that "long-term legacy maintenance is down 74% vs 2022 levels", and describes the default AI workflow as incentivised to deliver "a happy-path, a passing test, a closed ticket". A new function with a test closes a ticket. Removing the old one closes nothing.

Seven weeks after the list ​

The most uncomfortable number is mine. On 24 September I had the full list, per repository and per symbol. Those same repositories held 369 pieces of dead production code then. Today, 168 of them are still there and still dead: 46%.

133 were deleted. The rest are used by now, or Knossos stopped counting them after the false-positive fixes of the past weeks. Tests made little difference: 48% of the test-only symbols survived, against 43% of the fully unreferenced ones.

So a list of dead code cleans up nothing. It is a resolution, and my resolutions last about as long as everyone else's do in January.

Does it matter when the repository started? ​

I expected repositories I started after Knossos existed to score better, and newer models to leave less clutter behind. The data says less than I hoped.

First commitRepositoriesDead symbols per 1,000
before 18 July 2026 (Knossos began)1311.3
from 18 July162.3
from 18 July, without Knossos158.1

Nearly all of the gap is Knossos itself: 15,113 symbols, none dead. It is also the repository whose CI fails once the number of unreferenced candidates passes a budget. I'm aware the tool is marking its own homework here.

Without Knossos the gap depends on how you count. Pooled, it is 8.1 against 11.3. Per repository, the average sits at about 13 per 1,000 in both groups, and the newer repositories are younger and smaller, so they've simply had less time to collect dead code. What I will say: the repository with a gate has zero, and the rest have a list.

On models I can't say anything. My transcripts go back to September, and nearly everything in them runs on Opus 5.5. With this data I can't separate a model update from age and size.

What to do about it ​

  • Run your dead-code check without the tests. A symbol only tests call then counts as dead. knip does this with knip --production, Knossos reports it as test_only. With a tool like vulture, pass it your source and leave out the test directory.
  • Delete a function together with its tests. A test that only tests dead code protects nothing. Move whatever behaviour still matters to the function that actually runs, and throw the rest away.
  • Make removing the old version part of the replacement. Whoever replaces a function removes the old one in the same commit. A line in your CLAUDE.md helps sometimes. A check that can fail helps every time: turn the gate red when the number of test-only symbols grows.

A green suite already says little about whether your tests would catch a bug. About whether the tested code still serves any purpose, it says nothing at all.

parseSemgrepJson goes today. This is the prompt the agent in mcpobservatory got for it:

text
parseSemgrepJson in src/enrich/semgrep.ts has had no production caller since
6ec7481b (11 Aug): runSemgrep uses parseSemgrepJsonStrict. The wrapper still
turns a parse failure into [], which is the S2 bug, so nothing may reuse it.

1. Find every caller with `git grep -w parseSemgrepJson`. Expect only
   tests/enrich/semgrep.test.ts.
2. Move each test that still checks real behaviour onto
   parseSemgrepJsonStrict: [] for a clean report, null for an unparseable one.
   Delete tests that only exercise the wrapper.
3. Delete parseSemgrepJson and update the comments that name it in
   src/enrich/semgrep.ts. Leave migration 064 alone, it is history.
4. Run the full suite with the coverage floors. Report the diff stat and the
   test count before and after.

Do not change the behaviour of parseSemgrepJsonStrict.

If coverage drops a fraction afterwards, it will be the most honest drop I've seen all year.

(11 of 11)