Purpose
Find code nothing has touched in a long time, the category a rotated tidying lane and a
diff-scoped simplification pass structurally cannot see. A run answers what in this repository is
no longer reachable, and how confident are we? Every candidate is adjudicated against the
dynamic-usage patterns static analyzers are blind to, so the report is a list of decisions a human
can act on rather than an analyzer dump the reader must re-verify.
The headline is four lanes of unequal strength, each labelled, not four peer detectors. A
report that presents them as equals is wrong even when every finding in it is right.
The four lanes are not peers
| Lane |
Covers |
Measured character |
How it fails |
| knip |
TS/JS: unused files, exports, types, enum members. Not class members. Knip 6 rejects --include classMembers outright |
60% precision / 100% recall on trap fixtures |
Unrestored, it manufactures false positives (a failed config load produced 2 phantom "unused files"). Its ERROR: line goes to stderr, which the JSON reporter discards |
| vulture |
Python: unused function / class / method / variable / attribute, plus unreachable code |
16.7% precision / 100% recall. All five trap classes false-positived at 100% |
High recall, low precision by construction. Read its output as a worklist, never as a verdict |
| gopls |
Go: unexported symbols only (gopls check -severity=hint). That is the lane's declared coverage, not a defect |
Correct on every measured symbol; 2.1s |
An unresolved module graph suppresses hints. False NEGATIVES, the opposite of knip. Never describe the two degradations with one shared phrase |
| grep |
Shell and other symbol languages: function definitions with no reference anywhere |
4/4 true positives, 0 false positives over 546 .sh / 177,793 lines; shellcheck found 0 of the same 4 |
High precision, acknowledged low recall. $, -, . are non-word characters, so an adjacent hit reads as a reference and quietly saves a symbol that may be dead |
Every figure in the Measured character column comes from this plugin's own trap fixtures under
evals/fixtures/, as recorded on 2026-08-23. Recheck trigger: a major version bump in any lane's
detector, or a change to the fixture corpus. Re-measure before quoting one to a user.
Orphaned-file coverage is TS/JS only. Rust and .NET are permanently out of scope: their
detectors build the project. Per-lane invocation, flags, and degradation detail live in
context/lanes.md "Lane reference".
Candidate shapes and default tiers
The detector emits candidates, not verdicts. A shape's tier is the candidate's prior, taken
from the measured precision of the lane that produced it.
| Shape |
Lane |
Default tier |
ts-unused-file |
knip |
2 |
ts-unused-export |
knip |
2 |
ts-unused-type |
knip |
2 |
ts-unused-enum-member |
knip |
2 |
py-unused-symbol |
vulture |
2 |
py-unreachable |
vulture |
1 |
go-unused-unexported |
gopls |
1 |
unreferenced-symbol |
grep |
1 |
detector-drift |
any |
3 |
Consumers with their own conventions can refine these defaults in their repo's CLAUDE.md /
rules; the tiers above are the skill's built-in baseline.
Running the detector
${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh # whole repo, every lane
${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh --max 20 # cap the adjudication set
${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh --lane grep src/ # one lane, scoped candidates
${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh --help # usage and exit codes
Exit is 0 on every scan path and 2 only on a usage error: a read-only audit never fails its
caller, and no detector's own exit code is ever propagated. Present the Lane: lines with the
findings. They are what makes a clean report a claim rather than an absence.
Candidate scope and reference-search scope are separate. A target narrows which files
produce candidates; the reference search stays repository-wide, because a reference from anywhere
still saves a symbol.
Run states. Four, not two
| State |
Meaning |
ran |
the lane resolved a binary, invoked it, and read trustworthy output |
skipped |
no resolvable local binary, or a located binary that failed to invoke. Nothing was fetched. No package runner is ever called |
degraded |
the lane ran but its output is not trustworthy. It emits no records, and its line says why |
scanned-zero-files |
the lane had zero in-scope input files it owns (a nested project root's files belong to that root). Both detectors otherwise report this as exit 0 with no output. Indistinguishable from clean |
A run where no lane reached ran is a scan of nothing, not a clean bill, and the script says so.
Adjudication
Bounded by design. Full evidence catalogue in
context/adjudication.md "Evidence patterns".
- Consent gate. Report the candidate count and the
--max cap from
Summary candidates: and get a go-ahead before adjudicating. Never quote a time or token
estimate. There is no measurement behind one.
- Order is git recency, oldest-untouched first. The script already emits them that way. There
is no confidence key to order by: vulture pins every symbol-level finding at exactly 60 and knip
has no confidence field at all.
- Adjudicate each candidate to
dead, uncertain, or alive, checking the dynamic-usage
patterns the detectors cannot see. String-name dispatch, DI/serialization, reflection, decorator
and route registration, test-only entry points, public API surface, generated code.
- Every
alive cites the specific evidence that saved it. An unevidenced alive is a guess.
- Fan out to fresh-context subagents in batches when the set is large; if spawn depth is
exhausted, say so and adjudicate inline at the same cap rather than silently shrinking the set.
- Optional LSP assist, never required and never a lane: when Claude Code's
LSP tool is
available, findReferences on one candidate is one more evidence source. includeDeclaration is
hard-coded true, so the dead threshold is resultCount == 1. Imports count as references, so a
re-export still reads alive.
Hard rules
- Read-only. No
Edit, no Write, no mutating Bash. The skill writes no file, not even
a findings file. Every deletion is the human's.
- Tier semantics. The detector's tiers are candidate priors: T1 = high-confidence candidate,
T2 = uncertain candidate, T3 = detector drift (output no parser recognized). In the
adjudicated report the same tiers carry verdicts:
dead → T1, uncertain → T2. alive is
never emitted as a record. It is the adjudication saving a candidate, and emitting it would
make Summary total: mean two different things at once.
- T3 is the drift bucket. An unrecognized detector line is recorded, never silently dropped and
never counted as a finding. A fourth verdict would land here; that is the alarm, not the answer.
- Exit codes are never run health. knip exits 1 for findings and for hard errors; vulture
exits 3 for findings, and 1 for a lone unparsable input; gopls always exits 0.
- Presence is proven by invocation. A locator hit is not a presence proof. Measured,
command -v rust-analyzer succeeds while invocation fails.
- Never fetch. Detectors run from a resolvable local binary (PATH, the repo-local
node_modules/.bin walk, or .venv/bin) or the lane is skipped. No package runner is invoked.
- knip evaluates repo-controlled config through jiti. Disclosed on every run that loads one,
never hidden. It is narrower than a build; vulture, measured, is genuinely pure.
**/evals/fixtures/** is never scanning input, matching the policy ruff.toml already sets.
A detector's planted-defect corpus is not the consumer's dead code.
Output schema
The script emits flat records; the adjudicated report is what the human reads.
Lane: knip | root=. | state=ran | files=19 | detail=19 candidate(s); 269 finding(s) this root does not own dropped ...
Note: knip evaluated knip.config.ts through jiti — a DISCLOSED exception ...
File: src/legacy/format.ts
Finding tier: 2
Finding shape: ts-unused-export
Finding line: 13
Finding excerpt: formatLegacyRow
---
Summary file: src/legacy/format.ts | T1=0 T2=1 T3=0
Summary lanes: ran=3 skipped=1 degraded=6 scanned-zero-files=2
Summary candidates: total=370 emitted=15 dropped-by-cap=355 cap=15
Summary total: files=15 T1=0 T2=15 T3=0
Present per file: verdict, shape, line, the evidence checked, and for alive what saved it.
Close with the lane roster, the candidate count against the cap, and n dropped by cap.
Convergence loop
The skill writes nothing, so the memory has to live in the repository:
- Adjudicate a bounded batch.
- Paste the emitted suppression entries into each detector's native config. Knip
ignore
entries, a vulture whitelist. Formats in context/adjudication.md
"Suppression formats".
- The next run is cleaner, and the batch after it reaches new code.
Only the knip and vulture lanes converge. The Go and shell lanes have no native suppression: measured, gopls check -severity=hint reports through every candidate directive (//lint:ignore is
staticcheck's and is not honored), so those verdicts live in the report and in a comment at the
declaration, and the same candidates return. Say so rather than emitting a directive that does
nothing.
dead and uncertain verdicts are session-scoped: nothing persists them, so an un-suppressed
uncertain returns as a candidate on the next run. A committed vulture whitelist raises F821
under a consumer's ruff config. Say so when you emit one.
What this skill is NOT
- Not
/code-tidying:tidy. tidy APPLIES Beck's Dead Code tidying inside one rotated lane;
this hunts candidates across the whole repository and reports. Bring dead verdicts to tidy.
- Not
/code-tidying:batch-simplify. That sweeps recently changed files; this deliberately
targets the long-untouched ones a recency window excludes.
- Not a dependency, asset, or feature-flag auditor, and not coverage-based runtime detection.
Gotchas
- A
degraded lane is not a quiet lane. knip degraded means invented findings were withheld;
gopls degraded means real findings were never produced. Report which one happened.
grep -w -F is the floor and -F is mandatory, without it core.ts matches coreXts. A
hit adjacent to $, -, or . needs model inspection; it is never an automatic alive.
- knip runs per project root, discovered via
package.json. One run per root, never one run
for the whole repository. Each root carries its own state, and each root reports only the files it
owns: paths under it and under no nested root. A nested workspace's file is reported by that
workspace's own run and only when that run is healthy, so one degraded workspace neither condemns
the others nor has its withheld findings re-manufactured by an outer root's run.
- vulture is handed only
*.py. Given anything else it logs a parse error to stderr and skips
that file; that is an input note, not a degraded lane.
- A cap can truncate a file's block.
--max counts candidates, not files, so Summary file:
reflects what was emitted, not what exists.
1---2name: audit-dead-code3description: Hunt dead code across a whole repository through four labelled lanes of unequal confidence. Knip (TS/JS unused files, exports, types, enum members), vulture (Python symbols), gopls (Go unexported symbols), and a portable grep lane (shell and other symbol languages), then adjudicate every candidate against the dynamic-usage evidence static analyzers are blind to, emitting Tier 1 (dead) and Tier 2 (uncertain) findings plus paste-ready native suppressions; read-only, no edits applied. Use when: 'find dead code', 'audit dead code', 'what is unused in this repo', 'unused exports', 'unreferenced functions', 'orphaned files', 'is anything here still called', 'dead code sweep', or when long-untouched code needs a deliberate hunt a rotated tidying lane never reaches, not for applying the deletion or Beck's Dead Code tidying (use /code-tidying:tidy), diff-scoped simplification of recently changed files (use /code-tidying:batch-simplify), comment residue (use /code-tidying:audit-comment-residue), or unused dependencies,4---56## Purpose78Find code nothing has touched in a long time, the category a rotated tidying lane and a9diff-scoped simplification pass structurally cannot see. A run answers *what in this repository is10no longer reachable, and how confident are we?* Every candidate is adjudicated against the11dynamic-usage patterns static analyzers are blind to, so the report is a list of decisions a human12can act on rather than an analyzer dump the reader must re-verify.1314The headline is **four lanes of unequal strength, each labelled**, not four peer detectors. A15report that presents them as equals is wrong even when every finding in it is right.1617## The four lanes are not peers1819| Lane | Covers | Measured character | How it fails |20|---|---|---|---|21| **knip** | TS/JS: unused files, exports, types, enum members. **Not** class members. Knip 6 rejects `--include classMembers` outright | **60% precision / 100% recall** on trap fixtures | Unrestored, it **manufactures false positives** (a failed config load produced 2 phantom "unused files"). Its `ERROR:` line goes to stderr, which the JSON reporter discards |22| **vulture** | Python: unused function / class / method / variable / attribute, plus unreachable code | **16.7% precision / 100% recall**. All five trap classes false-positived at 100% | High recall, low precision **by construction**. Read its output as a worklist, never as a verdict |23| **gopls** | Go: **unexported symbols only** (`gopls check -severity=hint`). That is the lane's declared coverage, not a defect | Correct on every measured symbol; 2.1s | An unresolved module graph **suppresses hints**. False **NEGATIVES**, the opposite of knip. Never describe the two degradations with one shared phrase |24| **grep** | Shell and other symbol languages: function definitions with no reference anywhere | **4/4 true positives, 0 false positives** over 546 `.sh` / 177,793 lines; shellcheck found 0 of the same 4 | High precision, **acknowledged low recall**. `$`, `-`, `.` are non-word characters, so an adjacent hit reads as a reference and quietly saves a symbol that may be dead |2526Every figure in the Measured character column comes from this plugin's own trap fixtures under27`evals/fixtures/`, as recorded on 2026-08-23. Recheck trigger: a major version bump in any lane's28detector, or a change to the fixture corpus. Re-measure before quoting one to a user.2930Orphaned-**file** coverage is **TS/JS only**. Rust and .NET are permanently out of scope: their31detectors build the project. Per-lane invocation, flags, and degradation detail live in32[context/lanes.md](context/lanes.md) "Lane reference".3334## Candidate shapes and default tiers3536The detector emits **candidates**, not verdicts. A shape's tier is the candidate's prior, taken37from the measured precision of the lane that produced it.3839| Shape | Lane | Default tier |40|---|---|---|41| `ts-unused-file` | knip | 2 |42| `ts-unused-export` | knip | 2 |43| `ts-unused-type` | knip | 2 |44| `ts-unused-enum-member` | knip | 2 |45| `py-unused-symbol` | vulture | 2 |46| `py-unreachable` | vulture | 1 |47| `go-unused-unexported` | gopls | 1 |48| `unreferenced-symbol` | grep | 1 |49| `detector-drift` | any | 3 |5051Consumers with their own conventions can refine these defaults in their repo's `CLAUDE.md` /52rules; the tiers above are the skill's built-in baseline.5354## Running the detector5556```bash57${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh # whole repo, every lane58${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh --max 20 # cap the adjudication set59${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh --lane grep src/ # one lane, scoped candidates60${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh --help # usage and exit codes61```6263Exit is **0 on every scan path** and **2 only on a usage error**: a read-only audit never fails its64caller, and no detector's own exit code is ever propagated. Present the `Lane:` lines with the65findings. They are what makes a clean report a claim rather than an absence.6667**Candidate scope and reference-search scope are separate.** A `target` narrows which files68*produce* candidates; the reference search stays repository-wide, because a reference from anywhere69still saves a symbol.7071## Run states. Four, not two7273| State | Meaning |74|---|---|75| `ran` | the lane resolved a binary, invoked it, and read trustworthy output |76| `skipped` | no resolvable local binary, or a located binary that failed to invoke. Nothing was fetched. No package runner is ever called |77| `degraded` | the lane ran but its output is not trustworthy. **It emits no records**, and its line says why |78| `scanned-zero-files` | the lane had zero in-scope input files it **owns** (a nested project root's files belong to that root). Both detectors otherwise report this as exit 0 with no output. Indistinguishable from clean |7980A run where no lane reached `ran` is **a scan of nothing, not a clean bill**, and the script says so.8182## Adjudication8384Bounded by design. Full evidence catalogue in85[context/adjudication.md](context/adjudication.md) "Evidence patterns".86871. **Consent gate.** Report the candidate **count** and the `--max` cap from88 `Summary candidates:` and get a go-ahead before adjudicating. Never quote a time or token89 estimate. There is no measurement behind one.902. **Order is git recency, oldest-untouched first.** The script already emits them that way. There91 is no confidence key to order by: vulture pins every symbol-level finding at exactly 60 and knip92 has no confidence field at all.933. **Adjudicate each candidate to `dead`, `uncertain`, or `alive`**, checking the dynamic-usage94 patterns the detectors cannot see. String-name dispatch, DI/serialization, reflection, decorator95 and route registration, test-only entry points, public API surface, generated code.964. **Every `alive` cites the specific evidence that saved it.** An unevidenced `alive` is a guess.975. Fan out to fresh-context subagents in batches when the set is large; if spawn depth is98 exhausted, say so and adjudicate inline at the same cap rather than silently shrinking the set.996. **Optional LSP assist**, never required and never a lane: when Claude Code's `LSP` tool is100 available, `findReferences` on one candidate is one more evidence source. `includeDeclaration` is101 hard-coded true, so the dead threshold is `resultCount == 1`. Imports count as references, so a102 re-export still reads alive.103104## Hard rules105106- **Read-only.** No `Edit`, no `Write`, no mutating `Bash`. The skill writes **no file**, not even107 a findings file. Every deletion is the human's.108- **Tier semantics.** The detector's tiers are candidate priors: T1 = high-confidence candidate,109 T2 = uncertain candidate, T3 = **detector drift** (output no parser recognized). In the110 adjudicated report the same tiers carry verdicts: `dead` → T1, `uncertain` → T2. **`alive` is111 never emitted as a record**. It is the adjudication saving a candidate, and emitting it would112 make `Summary total:` mean two different things at once.113- **T3 is the drift bucket.** An unrecognized detector line is recorded, never silently dropped and114 never counted as a finding. A fourth verdict would land here; that is the alarm, not the answer.115- **Exit codes are never run health.** knip exits 1 for findings *and* for hard errors; vulture116 exits 3 for findings, and 1 for a lone unparsable input; gopls always exits 0.117- **Presence is proven by invocation.** A locator hit is not a presence proof. Measured,118 `command -v rust-analyzer` succeeds while invocation fails.119- **Never fetch.** Detectors run from a resolvable local binary (PATH, the repo-local120 `node_modules/.bin` walk, or `.venv/bin`) or the lane is `skipped`. No package runner is invoked.121- **knip evaluates repo-controlled config through jiti.** Disclosed on every run that loads one,122 never hidden. It is narrower than a build; vulture, measured, is genuinely pure.123- **`**/evals/fixtures/**` is never scanning input**, matching the policy `ruff.toml` already sets.124 A detector's planted-defect corpus is not the consumer's dead code.125126## Output schema127128The script emits flat records; the adjudicated report is what the human reads.129130```text131Lane: knip | root=. | state=ran | files=19 | detail=19 candidate(s); 269 finding(s) this root does not own dropped ...132Note: knip evaluated knip.config.ts through jiti — a DISCLOSED exception ...133File: src/legacy/format.ts134Finding tier: 2135Finding shape: ts-unused-export136Finding line: 13137Finding excerpt: formatLegacyRow138---139Summary file: src/legacy/format.ts | T1=0 T2=1 T3=0140Summary lanes: ran=3 skipped=1 degraded=6 scanned-zero-files=2141Summary candidates: total=370 emitted=15 dropped-by-cap=355 cap=15142Summary total: files=15 T1=0 T2=15 T3=0143```144145Present per file: verdict, shape, line, the evidence checked, and for `alive` what saved it.146Close with the lane roster, the candidate count against the cap, and `n dropped by cap`.147148## Convergence loop149150The skill writes nothing, so the memory has to live in the repository:1511521. Adjudicate a bounded batch.1532. Paste the emitted suppression entries into each detector's **native** config. Knip `ignore`154 entries, a vulture whitelist. Formats in [context/adjudication.md](context/adjudication.md)155 "Suppression formats".1563. The next run is cleaner, and the batch after it reaches new code.157158Only the knip and vulture lanes converge. **The Go and shell lanes have no native suppression**: measured, `gopls check -severity=hint` reports through every candidate directive (`//lint:ignore` is159staticcheck's and is not honored), so those verdicts live in the report and in a comment at the160declaration, and the same candidates return. Say so rather than emitting a directive that does161nothing.162163`dead` and `uncertain` verdicts are **session-scoped**: nothing persists them, so an un-suppressed164`uncertain` returns as a candidate on the next run. A committed vulture whitelist raises `F821`165under a consumer's ruff config. Say so when you emit one.166167## What this skill is NOT168169- **Not `/code-tidying:tidy`.** `tidy` APPLIES Beck's Dead Code tidying inside one rotated lane;170 this hunts candidates across the whole repository and reports. Bring `dead` verdicts to `tidy`.171- **Not `/code-tidying:batch-simplify`.** That sweeps recently changed files; this deliberately172 targets the long-untouched ones a recency window excludes.173- **Not a dependency, asset, or feature-flag auditor**, and not coverage-based runtime detection.174175## Gotchas176177- **A `degraded` lane is not a quiet lane.** knip degraded means invented findings were withheld;178 gopls degraded means real findings were never produced. Report which one happened.179- **`grep -w -F` is the floor and `-F` is mandatory**, without it `core.ts` matches `coreXts`. A180 hit adjacent to `$`, `-`, or `.` needs model inspection; it is never an automatic `alive`.181- **knip runs per project root**, discovered via `package.json`. One run per root, never one run182 for the whole repository. Each root carries its own state, and each root reports only the files it183 **owns**: paths under it and under no nested root. A nested workspace's file is reported by that184 workspace's own run and only when that run is healthy, so one degraded workspace neither condemns185 the others nor has its withheld findings re-manufactured by an outer root's run.186- **vulture is handed only `*.py`.** Given anything else it logs a parse error to stderr and skips187 that file; that is an input note, not a degraded lane.188- **A cap can truncate a file's block.** `--max` counts candidates, not files, so `Summary file:`189 reflects what was emitted, not what exists.