Dead Code Audit
Find code that nothing actually uses, without lying about certainty. The output is
always a report with confidence tiers, never silent deletion.
The one rule that matters
Static analysis cannot prove code is dead. It can only prove nothing statically
references it. Reflection, dependency injection, string-based imports, plugin
registries, ORM magic, and template engines all call code that looks unreferenced.
So every finding gets a confidence tier, and deletion only happens if the user asks
for it after seeing the report.
Step 1: Size the repo and pick a strategy
Run the sizing script first. It counts files and lines per language, flags vendored
and generated directories, and recommends a strategy.
python scripts/size_repo.py /path/to/repo
Strategy thresholds (the script applies these for you):
| Tier |
Repo size |
Strategy |
| SMALL |
under 50k source lines |
Full scan: run every relevant tool across the whole repo in one pass, then manually verify each finding |
| MEDIUM |
50k to 500k lines |
Tool scan: run language tools repo-wide, but only do deep cross-reference verification on the highest-value candidates (largest files, whole modules, exported symbols) |
| LARGE |
over 500k lines or over 5,000 source files |
Chunked scan: see "Large repo protocol" below. Do not attempt a single-pass grep-everything approach, it will blow the time budget and the context window |
Always exclude before scanning, regardless of tier: node_modules, vendor,
dist, build, .git, target, venv/.venv, __pycache__, generated code
(protobuf _pb2 files, *.generated.*, migration folders unless asked), and
test fixtures. The sizing script prints an exclusion list; confirm it looks right
before proceeding.
Step 2: Run language-appropriate tools
Read the reference file for each language the sizing script found. Each one lists
the tools, install commands, invocation, and known false-positive patterns:
references/python.md - vulture, pyflakes, coverage-assisted checks
references/javascript.md - knip, ts-prune, depcheck, ESLint (covers TS too)
references/go-rust-java.md - staticcheck/deadcode, cargo machinery, compiler flags
references/configs-ci-hygiene.md - temp/junk files, stale CI, orphaned configs.
ALWAYS read this one regardless of language; run python scripts/hygiene_scan.py <repo>
alongside the source-level tools, since source tools cannot see this category at all.
references/safe-removal.md - test-only-alive detection, tombstone runtime
verification for Tier 2, the deletion workflow, and prevention (CI ratchets).
Read before reporting and always before deleting anything.
If a tool is unavailable and cannot be installed (no network, unsupported), fall
back to the manual method in references/manual-analysis.md: build a symbol
definition list with grep/AST parsing, then search for references to each symbol.
Step 3: Verify candidates before reporting
For each candidate the tools flag, check the dynamic-usage traps before assigning
a tier:
- Grep for the symbol name as a string (
"symbol_name", 'symbol_name') -
catches reflection, getattr, dynamic imports, config-driven dispatch.
- Check decorators/annotations that register things implicitly (route handlers,
event listeners, pytest fixtures, DI containers, serializers).
- Check whether the symbol is part of a public API surface: exported from a
package
__init__.py or index.ts, listed in __all__, mentioned in docs or
README, or the package is published. Public API that is internally unused is
"unused internally", not dead.
- Check templates and non-code files (HTML templates, YAML configs, SQL) for the
name.
- Check git history:
git log --oneline -3 -- <file>. Code touched in the last
30 days deserves extra suspicion of the tools, not of the code.
- Check whether the only references are from test files (paths matching
test/, tests/, spec/, tests/, _test., .test., .spec.). Production
code kept alive solely by its own tests is dead; label it "test-only" in
Tier 1 and remove code and tests together. See
references/safe-removal.md.
Exception: published-library code may legitimately have only test references
internally; that stays Tier 3.
Step 4: Report format
ALWAYS use this exact structure:
# Dead Code Audit: <repo name>
Scanned: <N files, N lines> | Strategy: <SMALL/MEDIUM/LARGE> | Coverage: <full or which modules>
## Tier 1 - Safe to remove (high confidence)
Nothing references these statically OR dynamically. Private symbols, unreferenced
files, unreachable branches after return/raise, unused imports.
<table: location | symbol | why it is dead | evidence>
## Tier 2 - Probably dead (verify with owner)
Statically unreferenced but matches a dynamic-usage risk pattern, or is old code
in a rarely-touched module. For each item, offer the tombstone technique from
`references/safe-removal.md` (a logged marker shipped for 30-90 days) as the way
to settle it with runtime evidence instead of leaving it in limbo.
<table: location | symbol | risk that it is actually used | suggested verification>
## Tier 3 - Unused but intentional (do not remove without discussion)
Public API surface, feature-flagged code, platform-specific branches.
## Unused dependencies
Packages in the manifest that no source file imports.
## Stale CI, configs, and junk files
Temp/backup files, CI files for retired systems or nonexistent branches, and
configs for tools no longer in the dependency set.
## Not scanned
Anything excluded or skipped due to size limits, so the user knows the blind spots.
Estimate deletable line counts per tier. Note the standing blind spots when
relevant: dead API endpoints need traffic logs and dead database objects need
query logs, both outside a source-only audit; name them as follow-ups rather
than staying silent.
If the user then asks to delete, follow the workflow in
references/safe-removal.md: Tier 1 only, one branch per audit and one commit
per module, tests deleted with the code they tested, a post-delete grep per
symbol, full test suite and build, and a diff summary. After a cleanup, offer
the prevention step from the same file (compiler/linter flags plus a baselined
ratchet job) so dead code stops accumulating between audits.
Large repo protocol
For LARGE repos, work like a search party sweeping a forest grid by grid rather
than one person wandering everywhere:
- Budget first. Tell the user roughly how long a full audit takes and offer
scoping options: whole repo chunked, top N largest modules, or a specific
directory they care about. Default to whole-repo chunked if they do not choose.
- Build a cheap global symbol index once (script provided):
python scripts/symbol_index.py /path/to/repo --out index.json
This is a flat map of defined symbols to files, built with lightweight parsing,
cheap enough to run on millions of lines.
- Chunk by top-level module/package, not by arbitrary file count, so import
relationships mostly stay inside a chunk.
- Per chunk: run the language tools scoped to that chunk, then verify each
candidate against the global index, not just the chunk. This is what prevents
the classic false positive where module A's helper is only called from module B.
- Checkpoint after each chunk by appending findings to a running report file.
If the audit gets interrupted, resume from the last chunk instead of restarting.
- Time budget: if a chunk takes more than ~10 minutes of tool time, note it,
scan its largest files only, and mark the rest under "Not scanned". Partial
honest coverage beats fake complete coverage.
- Entire-file dead checks scale best, so on the first pass through a LARGE repo,
prioritize finding whole dead files and dead modules (biggest wins), then only
descend to function-level analysis in modules the user cares about.
What counts as dead code
- Unreferenced functions, classes, methods, variables, constants
- Production code whose only references come from its own tests ("test-only")
- Unreachable code (after return/raise/break, conditions that are always false)
- Unused imports and unused manifest dependencies
- Orphaned files nothing imports
- Commented-out code blocks larger than ~10 lines (report, never auto-delete)
- Feature-flag branches for flags that are hardcoded off (Tier 2)
- Exported symbols with zero internal references (Tier 3 unless clearly internal)
- Committed temp/backup/junk files (
*.log, *.bak, .DS_Store, editor swaps)
- CI files for retired systems, disabled workflows, workflows triggering on
deleted branches, and CI helper scripts nothing references
- Config files whose consuming tool is absent from every manifest, CI file, and
Makefile (see
references/configs-ci-hygiene.md)
1---2name: dead-code-audit3description: Dead Code Audit4---56# Dead Code Audit78Find code that nothing actually uses, without lying about certainty. The output is9always a report with confidence tiers, never silent deletion.1011## The one rule that matters1213Static analysis cannot prove code is dead. It can only prove nothing *statically*14references it. Reflection, dependency injection, string-based imports, plugin15registries, ORM magic, and template engines all call code that looks unreferenced.16So every finding gets a confidence tier, and deletion only happens if the user asks17for it after seeing the report.1819## Step 1: Size the repo and pick a strategy2021Run the sizing script first. It counts files and lines per language, flags vendored22and generated directories, and recommends a strategy.2324```bash25python scripts/size_repo.py /path/to/repo26```2728Strategy thresholds (the script applies these for you):2930| Tier | Repo size | Strategy |31|------|-----------|----------|32| SMALL | under 50k source lines | Full scan: run every relevant tool across the whole repo in one pass, then manually verify each finding |33| MEDIUM | 50k to 500k lines | Tool scan: run language tools repo-wide, but only do deep cross-reference verification on the highest-value candidates (largest files, whole modules, exported symbols) |34| LARGE | over 500k lines or over 5,000 source files | Chunked scan: see "Large repo protocol" below. Do not attempt a single-pass grep-everything approach, it will blow the time budget and the context window |3536Always exclude before scanning, regardless of tier: `node_modules`, `vendor`,37`dist`, `build`, `.git`, `target`, `venv`/`.venv`, `__pycache__`, generated code38(protobuf `_pb2` files, `*.generated.*`, migration folders unless asked), and39test fixtures. The sizing script prints an exclusion list; confirm it looks right40before proceeding.4142## Step 2: Run language-appropriate tools4344Read the reference file for each language the sizing script found. Each one lists45the tools, install commands, invocation, and known false-positive patterns:4647- `references/python.md` - vulture, pyflakes, coverage-assisted checks48- `references/javascript.md` - knip, ts-prune, depcheck, ESLint (covers TS too)49- `references/go-rust-java.md` - staticcheck/deadcode, cargo machinery, compiler flags50- `references/configs-ci-hygiene.md` - temp/junk files, stale CI, orphaned configs.51 ALWAYS read this one regardless of language; run `python scripts/hygiene_scan.py <repo>`52 alongside the source-level tools, since source tools cannot see this category at all.53- `references/safe-removal.md` - test-only-alive detection, tombstone runtime54 verification for Tier 2, the deletion workflow, and prevention (CI ratchets).55 Read before reporting and always before deleting anything.5657If a tool is unavailable and cannot be installed (no network, unsupported), fall58back to the manual method in `references/manual-analysis.md`: build a symbol59definition list with grep/AST parsing, then search for references to each symbol.6061## Step 3: Verify candidates before reporting6263For each candidate the tools flag, check the dynamic-usage traps before assigning64a tier:65661. Grep for the symbol name as a **string** (`"symbol_name"`, `'symbol_name'`) -67 catches reflection, `getattr`, dynamic imports, config-driven dispatch.682. Check decorators/annotations that register things implicitly (route handlers,69 event listeners, pytest fixtures, DI containers, serializers).703. Check whether the symbol is part of a **public API surface**: exported from a71 package `__init__.py` or `index.ts`, listed in `__all__`, mentioned in docs or72 README, or the package is published. Public API that is internally unused is73 "unused internally", not dead.744. Check templates and non-code files (HTML templates, YAML configs, SQL) for the75 name.765. Check git history: `git log --oneline -3 -- <file>`. Code touched in the last77 30 days deserves extra suspicion of the tools, not of the code.786. Check whether the only references are from **test files** (paths matching79 test/, tests/, spec/, __tests__/, *_test.*, *.test.*, *.spec.*). Production80 code kept alive solely by its own tests is dead; label it "test-only" in81 Tier 1 and remove code and tests together. See `references/safe-removal.md`.82 Exception: published-library code may legitimately have only test references83 internally; that stays Tier 3.8485## Step 4: Report format8687ALWAYS use this exact structure:8889```90# Dead Code Audit: <repo name>91Scanned: <N files, N lines> | Strategy: <SMALL/MEDIUM/LARGE> | Coverage: <full or which modules>9293## Tier 1 - Safe to remove (high confidence)94Nothing references these statically OR dynamically. Private symbols, unreferenced95files, unreachable branches after return/raise, unused imports.96<table: location | symbol | why it is dead | evidence>9798## Tier 2 - Probably dead (verify with owner)99Statically unreferenced but matches a dynamic-usage risk pattern, or is old code100in a rarely-touched module. For each item, offer the tombstone technique from101`references/safe-removal.md` (a logged marker shipped for 30-90 days) as the way102to settle it with runtime evidence instead of leaving it in limbo.103<table: location | symbol | risk that it is actually used | suggested verification>104105## Tier 3 - Unused but intentional (do not remove without discussion)106Public API surface, feature-flagged code, platform-specific branches.107108## Unused dependencies109Packages in the manifest that no source file imports.110111## Stale CI, configs, and junk files112Temp/backup files, CI files for retired systems or nonexistent branches, and113configs for tools no longer in the dependency set.114115## Not scanned116Anything excluded or skipped due to size limits, so the user knows the blind spots.117```118119Estimate deletable line counts per tier. Note the standing blind spots when120relevant: dead API endpoints need traffic logs and dead database objects need121query logs, both outside a source-only audit; name them as follow-ups rather122than staying silent.123124If the user then asks to delete, follow the workflow in125`references/safe-removal.md`: Tier 1 only, one branch per audit and one commit126per module, tests deleted with the code they tested, a post-delete grep per127symbol, full test suite and build, and a diff summary. After a cleanup, offer128the prevention step from the same file (compiler/linter flags plus a baselined129ratchet job) so dead code stops accumulating between audits.130131## Large repo protocol132133For LARGE repos, work like a search party sweeping a forest grid by grid rather134than one person wandering everywhere:1351361. **Budget first.** Tell the user roughly how long a full audit takes and offer137 scoping options: whole repo chunked, top N largest modules, or a specific138 directory they care about. Default to whole-repo chunked if they do not choose.1392. **Build a cheap global symbol index once** (script provided):140 `python scripts/symbol_index.py /path/to/repo --out index.json`141 This is a flat map of defined symbols to files, built with lightweight parsing,142 cheap enough to run on millions of lines.1433. **Chunk by top-level module/package**, not by arbitrary file count, so import144 relationships mostly stay inside a chunk.1454. **Per chunk:** run the language tools scoped to that chunk, then verify each146 candidate against the *global* index, not just the chunk. This is what prevents147 the classic false positive where module A's helper is only called from module B.1485. **Checkpoint after each chunk** by appending findings to a running report file.149 If the audit gets interrupted, resume from the last chunk instead of restarting.1506. **Time budget:** if a chunk takes more than ~10 minutes of tool time, note it,151 scan its largest files only, and mark the rest under "Not scanned". Partial152 honest coverage beats fake complete coverage.1537. Entire-file dead checks scale best, so on the first pass through a LARGE repo,154 prioritize finding whole dead files and dead modules (biggest wins), then only155 descend to function-level analysis in modules the user cares about.156157## What counts as dead code158159- Unreferenced functions, classes, methods, variables, constants160- Production code whose only references come from its own tests ("test-only")161- Unreachable code (after return/raise/break, conditions that are always false)162- Unused imports and unused manifest dependencies163- Orphaned files nothing imports164- Commented-out code blocks larger than ~10 lines (report, never auto-delete)165- Feature-flag branches for flags that are hardcoded off (Tier 2)166- Exported symbols with zero internal references (Tier 3 unless clearly internal)167- Committed temp/backup/junk files (`*.log`, `*.bak`, `.DS_Store`, editor swaps)168- CI files for retired systems, disabled workflows, workflows triggering on169 deleted branches, and CI helper scripts nothing references170- Config files whose consuming tool is absent from every manifest, CI file, and171 Makefile (see `references/configs-ci-hygiene.md`)