DEV Community

yureki_lab
yureki_lab

Posted on

How I Deleted 26,000 Lines of Dead Code With Claude Code in One Sprint

TL;DR

I pointed Claude Code at a 5-year-old TypeScript monorepo and deleted 26,412 lines across 214 files in one sprint β€” without a single rollback. The trick wasn't a clever prompt. It was refusing to let the agent delete anything it couldn't prove was dead, using two independent evidence sources. Here's the setup, the one thing it still got wrong, and 5 lessons. 🧹

The Problem

Our main service is about 180k lines of TypeScript. It's five years old. It works.

It's also full of code nobody has executed since 2023.

Feature flags that shipped at 100% and never got cleaned up. Two generations of an HTTP client, both still imported somewhere. An entire legacy/ directory that a comment described as "temporary." Three date-formatting helpers with slightly different rounding behavior.

For a long time I filed this under "annoying but harmless." Disk is cheap, right?

What changed my mind was watching an AI agent work in that repo.

I'd ask Claude Code to fix a bug in our billing rounding logic. It would grep, find three rounding helpers, and pick one β€” sometimes the dead one. I'd ask it to add a retry to an API call, and it would patch the deprecated HTTP client because that's what the top grep hit pointed at. Every wrong answer was locally reasonable. The repo was lying to it.

That's the actual cost of dead code in 2026: it's not disk, it's not even build time. It's that your codebase is the context window for every human and every agent that touches it, and dead code is confidently-formatted misinformation.

So I decided to clean it up. And that's where I hit the second problem.

AI agents are additive by default

Ask an agent to fix something and you get more code. Ask it to improve something and you get more code. Ask it to delete something and it gets nervous, hedges, and gives you a list of "candidates you may want to consider removing" β€” which is a to-do list, not a change.

This isn't a personality quirk you can prompt away with "be more aggressive." It's structural. Adding code is a local decision: the model can see the function it's writing and reason about it. Deleting code is a global claim β€” "nothing anywhere calls this, including things that call it by a string at runtime" β€” and the model can't see the whole program. When it's honest about that uncertainty, it stalls. When it isn't, it deletes your cron job.

The fix is not a better prompt. The fix is giving the agent evidence it doesn't have to guess about.

How I Solved It

I built a three-stage pipeline. Stages 1 and 2 are boring tools that produce facts. Stage 3 is the agent, and its job is deliberately narrow.

flowchart TD
    A[Static reachability scan<br/>knip + tsc] --> C{Candidate list}
    B[Runtime evidence<br/>30d import counters] --> C
    C --> D[Agent: prove or refute<br/>one candidate at a time]
    D -->|refuted| E[Drop, log reason]
    D -->|proven dead| F[Delete + one commit each]
    F --> G[CI: build, test, typecheck]
Enter fullscreen mode Exit fullscreen mode

Stage 1: static reachability

I used knip (v5.x) for unused exports and files, plus tsc --noUnusedLocals for the in-file stuff. One config, no agent involved:

// knip.json
{
  "entry": ["src/server.ts", "src/workers/*.ts", "scripts/*.ts"],
  "project": ["src/**/*.ts"],
  "ignore": ["**/*.test.ts", "src/generated/**"],
  "ignoreDependencies": ["@types/*"]
}
Enter fullscreen mode Exit fullscreen mode

That produced 300-ish candidate files and a few hundred unused exports. Not one line got deleted based on this alone. Static analysis over a language with dynamic imports is a heuristic, not a proof.

Stage 2: runtime evidence

This is the part people skip, and it's the part that made the whole thing safe.

I added a tiny module-load counter behind an env flag and shipped it to production for 30 days. Every top-level module import fires once and reports the module path:

// src/instrumentation/module-usage.ts
// Node.js 22.x β€” registered before app code loads
import { register } from 'node:module'

const seen = new Set<string>()

export function recordModuleLoad(specifier: string): void {
  if (seen.has(specifier)) return
  seen.add(specifier)
  // fire-and-forget to the metrics sink; never block startup
  metrics.increment('module.loaded', { module: specifier })
}
Enter fullscreen mode Exit fullscreen mode

After 30 days I had a set of every module path that had actually been loaded in production β€” including the ones static analysis missed because they were pulled in by a string:

const handler = await import(`./handlers/${event.type}`)  // knip sees nothing here
Enter fullscreen mode Exit fullscreen mode

The intersection of "knip says unreachable" and "never loaded in 30 days of production" is a much stronger claim than either one alone. That intersection was my real candidate list: 231 files.

Stage 3: the agent's actual job

Here's the important reframe. I did not ask Claude Code to "find and remove dead code." I gave it a candidate list and made its job adversarial: try to prove this candidate is still alive.

The task prompt, roughly:

For candidate: `src/legacy/rate-calculator.ts`

Your default answer is ALIVE. Only conclude DEAD if all four checks pass.

1. Grep the repo for the module's basename as a STRING literal
   (dynamic imports, DI containers, route tables, config files, feature flags).
2. Grep for every exported symbol name across src/, scripts/, and infra/.
3. Check package.json scripts, Dockerfiles, and CI workflow files for direct
   references to the file path.
4. Read the file. If it registers a side effect on import (event listeners,
   process handlers, prototype patching), it is ALIVE regardless of exports.

Return JSON only:
{ "verdict": "DEAD" | "ALIVE", "evidence": "...", "checked": ["..."] }
Enter fullscreen mode Exit fullscreen mode

Four things matter about that prompt:

  • The default is ALIVE. Uncertainty resolves toward keeping the code. Flip that default and you get a fun weekend.
  • Check #1 exists because of a real incident (see below).
  • Check #4 catches import-for-side-effect modules, which have no used exports and are extremely alive.
  • JSON output, because I ran this over 231 candidates and needed to sort the results, not read 231 essays.

Then deletion, one commit per file:

# one commit per removal β€” this matters, see Lesson 3
while read -r file; do
  git rm -r "$file"
  git commit -q -m "chore: remove dead module $(basename "$file")"
done < verified-dead.txt

npm run build && npm run test && npx tsc --noEmit
Enter fullscreen mode Exit fullscreen mode

The results

Metric Before After
Lines of TypeScript 181,004 154,592
Files in src/ 1,893 1,679
tsc --noEmit 48s 31s
Full CI pipeline 11m 20s 7m 05s
Rollbacks β€” 0

Of the 231 candidates, the agent marked 214 dead and 17 alive. Every one of those 17 was a genuine catch β€” mostly DI-container registrations and side-effect imports that static analysis had happily flagged as garbage.

The one it got wrong

It marked src/reports/quarterly-reconciliation.ts as dead. Knip agreed. Thirty days of production telemetry agreed β€” no import, ever.

It runs once a quarter, invoked by a scheduled job that resolves the module by name from a config table.

My 30-day observation window was shorter than the thing's period. Both my evidence sources were structurally blind to it, and the agent dutifully concluded what the evidence said.

What saved me was check #1 β€” grepping for the basename as a string literal β€” which hit the config table and flipped the verdict to ALIVE with the comment "referenced by string in config/scheduled-jobs.yml." I'd added that check on instinct after a near-miss earlier in the week. It was the single highest-value line in the prompt.

If your codebase resolves anything by string at runtime, static analysis is not a proof and telemetry is only a proof over a window longer than your longest schedule.

Lessons Learned

1. Agents are additive by default β€” you have to make deletion the default verb.
"Consider removing" is not a change. Give the agent a concrete candidate and a binary verdict to return, and the hedging disappears. The output format does more work here than any amount of tone-setting in the system prompt.

2. Evidence beats reasoning, every time.
The model reasoning about whether code is reachable is a vibe. The model reading knip output and 30 days of production counters is a fact-checker. Spend your effort on producing facts to hand it, not on prompting it to think harder. This generalizes way past dead code.

3. One deletion per commit, or you can't bisect.
I almost batched them β€” 214 commits felt absurd. Then something broke in staging on day three and I found the cause with git bisect in four minutes. In a single squashed "remove dead code" commit, that's an afternoon of manual splitting. Cheap insurance.

4. Set the default to "keep" and make the agent argue its way out.
Framing the task as prove this is still alive rather than find dead code changed the error profile completely. Under the first framing, mistakes cost you leftover files. Under the second, mistakes cost you an outage. Pick which direction you want to be wrong in β€” you don't get to be right every time.

5. The bottleneck was never writing the deletions β€” it was trusting them.
Claude Code produced the 214 deletions in under an hour. Building the evidence pipeline took two days, and the 30-day telemetry window took, well, 30 days. That ratio is the actual story of agent-assisted work on legacy systems: the model isn't the slow part, and speeding up the model wouldn't have helped at all.

What's Next

Two follow-ups I'm working on:

  • A ratchet in CI. The reachability scan now runs on every PR and fails the build if the unused-export count goes up. Cleanups don't stick without a ratchet; the repo re-accumulates in about a year.
  • A longer telemetry window before the next pass. 90 days minimum, so quarterly jobs stop being invisible. The next sweep targets unused exports inside live files, which is a harder problem β€” the blast radius per mistake is smaller, but there are thousands of them.

I'm also curious whether the "prove it's alive" framing works for other global claims agents are bad at: unused database columns, dead feature flags, stale IAM permissions. Same shape of problem, same missing evidence.

Wrap-up

Dead code isn't a tidiness problem anymore. It's a context problem β€” for your teammates and for every agent you point at the repo. And AI agents won't fix it on their own, because deleting code requires a kind of global certainty they structurally don't have.

Give them the evidence, make the default "keep," and commit one thing at a time. That's the whole method.

If you try this, I'd genuinely like to know what your static analyzer missed β€” drop it in the comments, the blind spots are the interesting part. And if you want more write-ups like this one, follow me here on Dev.to. I post build logs from working on real legacy systems with AI agents β€” what worked, and the parts that broke. πŸš€

Stack for this one: Claude Code (v2.x), Node.js 22.x, TypeScript 5.7, knip 5.x.

Top comments (0)