![]()
Every codebase has a graveyard. Functions nobody calls, DTOs nobody maps, exports nobody imports. You know it’s there. You just never have time to clean it up.
Our project — a Spring Boot 3.4 + React/TypeScript monorepo with 35+ backend modules and 15+ frontend features — had accumulated a lot of it. Instead of setting up SonarQube or installing Knip, I pointed an AI coding agent at the whole thing and said “find the dead code.” Two hours later, 5,156 lines were gone across 143 files, and both builds still passed.
Here’s exactly how it went.
Why Not Just Use [Existing Tool]?
The obvious question: why not use a dedicated static analysis tool?
SonarQube — Needs a server, database, CI integration. We’re pre-production. That’s a sledgehammer for a nail.
ESLint no-unused-vars — Only catches unused local variables. It won’t tell you that formatPhoneNumber() is exported from utils/phone.ts but never imported anywhere in the project.
Knip — Good for JavaScript/TypeScript, but our backend is Java. We’d need two tools with two configs, and Knip still can’t understand Spring’s dependency injection.
IntelliJ “Unused” inspections — Works per-file, not in batch mode across 1,032 Java files. You’d be clicking through dialogs for hours.
What we wanted: zero setup, works across both languages, understands git history, and lets us review before deleting anything. An AI agent with grep turned out to be exactly that.
The Approach: Grep-Based Static Analysis + AI Orchestration
The core idea is simple. For every exported symbol in the codebase, grep the entire project for references to that symbol. Zero references = candidate for deletion.
We formalized this into a 6-phase process:
Phase 1: Frontend scan (542 .ts/.tsx files)
Phase 2: Backend scan (1,032 .java files)
Phase 3: Git history analysis (git log -S for each candidate)
Phase 4: Report (structured tables, before any deletion)
Phase 5: Interactive cleanup (user chooses what to remove)
Phase 6: Build verification (npm run build + gradlew compileJava)
For the frontend, the scan covered three categories:
- 1A: Unused files —
.ts/.tsxfiles never imported by any other file - 1B: Unused named exports — exported symbols with zero importers
- 1C: Stale barrel re-exports —
index.tsre-exports that nobody consumes
For the backend, four categories:
- 2A: Unused DTOs —
*Request.java,*Response.java,*Dto.javawith zero references - 2B: Unused repository methods — custom query methods never called by any Service
- 2C: Unused utility methods — public methods in
util/helper/commonpackages - 2D: Unused classes — non-Spring classes with zero importers
The frontend and backend scans ran in parallel using separate task agents, cutting wall-clock time roughly in half.
Spring’s “Always Alive” Problem
Here’s where Java/Spring makes dead code detection fundamentally different from JavaScript.
In a React app, if nothing imports a file, it’s dead. Simple. But in Spring Boot, a class annotated with @Service is alive even if no .java file explicitly references it — because Spring’s component scan picks it up at runtime and injects it wherever it’s needed.
This means a naive “search for importers” approach would flag nearly every service class as dead code. Catastrophic.
Our solution was a strict exclusion list — what we called the “Always Alive” rule:
Never flag classes annotated with:
@Component, @Service, @Repository, @Controller, @RestController
@Configuration, @Bean
@Entity, @MappedSuperclass, @Embeddable
@Scheduled, @EventListener, @Async
@Aspect, @ControllerAdvice, @RestControllerAdvice
@Converter, @JsonComponent
Any *Initializer class
Anything under src/test/
This is deliberately conservative. We’d rather miss some dead code than accidentally delete a service that Spring wires at startup. The grep-based approach then only targets the data objects — DTOs, utility methods, repository query methods — where the reference chain is fully visible in source code.
The Results
Frontend cleanup (542 files scanned):
| Category | Items Removed |
|---|---|
| Unused files deleted | 3 |
| Unused exports removed | ~105 |
| Stale barrel re-exports cleaned | 33 |
| Lines deleted | 2,070 |
The 3 deleted files: TeacherTaskForm.tsx, TeacherScheduleDialog.tsx, and lib/errorUtils.ts — components from features that were later redesigned, leaving the old implementations behind.
Backend cleanup (1,032 files scanned):
| Category | Items Removed |
|---|---|
| Unused DTOs deleted | 13 |
| Unused utility classes deleted | 3 |
| Unused repository methods removed | ~185 |
| Unused utility methods removed | 11 |
| Lines deleted | 3,086 |
The 3 deleted utility classes: DomainUtils, StringValidationUtils, CampusTimeUtils. Each had been superseded by better implementations months ago but never cleaned up.
Combined totals:
| Metric | Value |
|---|---|
| Files scanned | 1,574 |
| Dead code items found | ~356 |
| Files changed | 143 |
| Lines deleted | 5,156 |
| False positives caught by build | 2 |
| Total elapsed time | ~2 hours |
The False Positives (And Why Build Verification Is Non-Negotiable)
Two items made it through the scan as “dead” but weren’t. Both were caught by the build step, not by the scan itself.
False positive #1: The aliased field name
OrderService had two methods — generateFirstOrder() and generateBetweenOrder(). The grep scan searched for OrderService.generateFirstOrder and found nothing. Dead code, right?
Wrong. TaskTemplateOrderService injects OrderService into a field named orderService (lowercase), then calls orderService.generateFirstOrder(). The grep pattern was searching for the class name, not the field name. The methods were very much alive.
Caught by ./gradlew compileJava -> compilation error -> auto-restored via git checkout.
False positive #2: The missed call syntax
SeatWaitingEntryRepository had 3 methods flagged as unused. They were actually called by SeatWaitingService, but the scan agent’s grep pattern didn’t match the specific call syntax used.
Same story: caught by build, auto-restored.
Bonus: the corrupted import
During the batch removal of repository methods, one of the agent’s edits accidentally broke an import statement — inserting a line break in the middle of a package name. Not a false positive per se, but another thing caught exclusively by the build step.
The takeaway is blunt: without build verification, we would have shipped broken code. The scan is a heuristic. The build is the source of truth.
The Reusable Command
We didn’t want this to be a one-off. The entire process is now a reusable /dead-code slash command in our Claude Code configuration.
The 6-phase flow:
/dead-code
|
+-- Phase 1: Frontend scan
| +-- 1A: Unused files
| +-- 1B: Unused named exports
| +-- 1C: Stale barrel re-exports
|
+-- Phase 2: Backend scan (parallel with Phase 1)
| +-- 2A: Unused DTOs
| +-- 2B: Unused repository methods
| +-- 2C: Unused utility methods
| +-- 2D: Unused classes
|
+-- Phase 3: Git history (classify STALE / RECENT / ORPHANED)
|
+-- Phase 4: Report (tables, no deletions yet)
|
+-- Phase 5: Interactive cleanup
| +-- Clean ALL
| +-- Clean by category
| +-- Review one-by-one
| +-- Export report only
|
+-- Phase 6: Build verification
+-- On failure: auto-restore false positive, retry
+-- On success: suggest commit
Four key design decisions:
Conservative scanning — The command prefers false negatives over false positives. If a symbol name appears in a string literal (reflection, dynamic imports), it’s not flagged.
Git pickaxe classification — git log -S "<symbol>" tells you when the last reference was removed. A symbol whose import was explicitly deleted 8 months ago (ORPHANED) is a much stronger deletion candidate than one that was simply never imported from (might be WIP).
Report before acting — Phase 4 is mandatory. The user sees every candidate, with file paths, last-modified dates, and classification signals, before a single line is deleted.
Auto-restore on build failure — If the build fails after cleanup, the command identifies the broken file, runs git checkout -- <path>, re-runs the build, and notes it as a false positive. No manual intervention needed.
Key Takeaways
1. Conservative scanning beats aggressive scanning. Our 2 false positives (out of ~356 candidates) is a 0.56% rate, and both were caught automatically. Start strict; you can always loosen later.
2. Build verification is the only reliable safety net. Grep-based analysis is a heuristic. It can’t trace field-name aliases, reflection, or dynamic dispatch. The build catches what the scan misses. Always run it after cleanup.
3. Git context separates “probably dead” from “definitely dead.” A function with zero callers that was last touched yesterday is probably work-in-progress. A function whose import was explicitly removed 8 months ago is almost certainly dead. git log -S gives you that signal for free.
4. You don’t need a dedicated tool for this. SonarQube, Knip, and IDE inspections all have their place. But for a project where you want a quick, cross-language sweep with interactive review, an AI agent with grep, git, and a build command covers the gap surprisingly well. The whole run — 1,574 files, 356 candidates, 5,156 lines deleted — took about 2 hours with zero setup.
Dead code is technical debt that accumulates silently. It makes grep results noisier, onboarding harder, and refactoring riskier. The longer you wait, the more it costs to clean up. Two hours and an AI agent got us back to a clean baseline.
Comments