Skip to content
Back to skills

Audit Dead Code

ASecurity

Hunt dead code in four lanes (Knip, vulture, gopls, portable grep). Read-only. 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'. Not for applying the deletion (/code-tidying:tidy), diff-scoped simplification (/code-tidying:batch-simplify), or comment residue (/code-tidying:audit-comment-residue).

  • 13 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 2, 2026
ai-agentspythonrustgoshellbashnodegitapi

Works with

  • claude code
  • cli
  • api

Security analysis

A100/100

Pro scans all 20 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add melodic-software/claude-code-plugins --skill audit-dead-code --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Audit Dead Code?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Audit Dead Code
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/melodic-software-audit-dead-code/badge)](https://www.skillsdirectory.com/skills/melodic-software-audit-dead-code)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
description: "Hunt dead code in four lanes (Knip, vulture, gopls, portable grep). Read-only. 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'. Not for applying the deletion (/code-tidying:tidy), diff-scoped simplification (/code-tidying:batch-simplify), or comment residue (/code-tidying:audit-comment-residue)."
argument-hint: "[--max N] [--lane knip|vulture|gopls|grep] [target]"
user-invocable: true
disable-model-invocation: false
allowed-tools: ["Bash(${CLAUDE_SKILL_DIR}/scripts/dead-code-scan.sh:*)", "Bash(git grep:*)", "Bash(git log:*)", "Bash(grep:*)"]
metadata:
  workflow-stage: anytime
  summary: Whole-repo dead-code hunt across four labeled lanes with adjudicated candidates
---

## 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 labeled**, 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 PowerShell function definitions; `function`, `class`, `const`, `let` and `var` declarations in JS/TS that no `package.json` root owns (`ts-unreferenced-symbol`, tier 2); plus unreferenced source files (`unreferenced-file`, tier 2) | Shell symbol extractor: **4/4 true positives, 0 false positives** over 546 `.sh` / 177,793 lines; shellcheck found 0 of the same 4. JS/TS symbol extractor: **66.7% precision (2 of 3) / 66.7% recall (2 of 3)** on the trap corpus plus `standalone.mjs`, as recorded on 2026-09-29. `unreferenced-file` precision is unmeasured | High precision on shell symbols, **acknowledged low recall**. `$`, `-`, `.` are non-word characters, so an adjacent hit reads as a reference and quietly saves a symbol that may be dead. A JS/TS export that only an entry point or an outside consumer calls is a false candidate (`main` in the corpus), and a name mentioned in a comment saves a dead symbol (`formatLegacyRow`). A file miss is tier 2 because a computed path or glob is invisible |

Every figure in the Measured character column comes from this plugin's own trap fixtures under
`evals/fixtures/`, as recorded on 2026-08-23 (the JS/TS symbol figures: 2026-09-29). 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 spans source files with a recognized extension, plus extensionless files with a line-1 shebang. The extension list is `DC_NOLANE_EXTS` in `scripts/lib/dead-code-shapes.sh`; add a lowercase extension there to classify it. An extensionless file is classified by its shebang interpreter: a shell (`sh`, `bash`, `dash`, `zsh`, `ksh`, including `env` forms) joins the shell lane, any other interpreter is a source file with no lane, and no shebang means not source. The grep lane emits `unreferenced-file` at tier 2
when a source file's basename and its repo-relative path (plus its stem, for a language with no
lane) have no literal reference in any other tracked file. That set is shell, PowerShell, Python entry points (a line-1 shebang, a
`__name__` guard, or `__main__.py`), JS/TS that no `package.json` root owns, and source files in languages with no
lane. Knip still reports unused TS/JS files inside a `package.json` root. A reference in a CI
workflow, settings file, manifest, or doc saves the file. A computed path or a glob does not, so
the candidate is **uncertain, not dead**. Rust and .NET stay out of the build-based detectors; an
unreferenced file there is still this candidate, because those builds do not spell every path.
Per-lane invocation, flags, and degradation detail live in
[context/lanes.md](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 |
| `ts-unreferenced-symbol` | grep | 2 |
| `unreferenced-file` | grep | 2 |
| `detector-drift` | any | 3 |

`ts-unreferenced-symbol` is tier 2, not the tier 1 of the shell `unreferenced-symbol`: its measured
precision is 2 of 3 on the trap corpus, and an entry-point or public export nothing in the repository
calls reads as unreferenced.

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

```bash
${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. Five, 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. Its files are uncovered (`lane degraded`) |
| `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 |
| `no-manifest` | the lane's language is in scope, but no project manifest root exists (`package.json` for knip, `go.mod` for gopls). `files=` is the real in-scope count for that language. Those files are uncovered unless another lane took them: the grep lane takes JS/TS that no `package.json` root owns. This is not a missing binary |

`files=` on a `Lane:` line is the number of files that lane took as input, including `skipped` and `no-manifest`. It is not the number of findings.

A run where no lane reached `ran` and no source file is uncovered is **a scan of nothing, not a clean bill**, and the script says so. A run whose only lanes are `degraded` leaves their files uncovered and lists them instead. A run that leaves any in-scope source file uncovered is also not a clean bill, even when some lane ran. The clean-result note is printed only when `Summary coverage:` reports `uncovered=0`.

## Adjudication

Bounded by design. Full evidence catalogue in
[context/adjudication.md](context/adjudication.md) "Evidence patterns".

1. **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. Done when the operator has explicitly approved
   adjudication or declined it (report-only).
2. **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.
3. **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. An
   `unreferenced-file` loaded by a computed path or a glob is `uncertain`, not `dead`.
4. **Every `alive` cites the specific evidence that saved it.** An unevidenced `alive` is a guess.
5. 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.
6. **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. Done when every emitted candidate has a `dead`, `uncertain`, or
   `alive` verdict with evidence, or the cap stopped the batch and that stop is stated.

## 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. The one exception is installing or fetching a
  detector, and it happens only through the consent path in "When coverage is incomplete" step 3,
  after an explicit yes. The scan script itself never fetches and invokes no package runner, so
  there is no silent network or code execution.
- **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`. The scan script never fetches
  and invokes no package runner. An install or fetch of a detector happens only through the consent
  path in "When coverage is incomplete" step 3, after an explicit yes: no silent network or code
  execution.
- **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.

## When coverage is incomplete

A `skipped` lane, a `degraded` lane, a `no-manifest` lane, a `scanned-zero-files` lane, or source files with no lane at
all are gaps, not a clean bill. After presenting the lane roster:

1. **Name each gap** from `Summary coverage:` and each `Note: uncovered` line: the path and the
   reason (`no lane for the language`, `no manifest root`, `tool not installed`, `lane degraded`,
   `tool could not parse it`, or `lane not selected`). Rust and .NET stay a policy exclusion: they are `no lane
   for the language`, not a detector this run forgot to invoke.
2. **Offer to file an issue** against this plugin with the file count and language, pre-filled for
   the operator to edit and submit. Do nothing unless they agree.
3. **Offer research and install**: with consent, run `/discovery:research` to pick a detector for the
   language, show the choice and its install command, and install or invoke it only after an
   explicit yes. Record its precision as **unmeasured** until trap fixtures cover it.

The default stays read-only: no package runner, no network fetch, and no build without consent.
Consent-gated lanes that may compile or execute project code remain out of scope here.

## Output schema

The script emits flat records; the adjudicated report is what the human reads.

```text
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=0 scanned-zero-files=2 no-manifest=0
Summary coverage: covered=19 uncovered=2
Summary candidates: total=370 emitted=15 dropped-by-cap=355 cap=15
Summary total: files-with-findings=15 T1=0 T2=15 T3=0
Note: uncovered cmd/tool/main.go — no manifest root
Note: uncovered src/main.rs — no lane for the language
```

`Summary total: files-with-findings=` counts files that emitted at least one candidate. `Summary coverage:` counts every in-scope source file: covered by a lane in `ran`, or uncovered. Uncovered reasons are `no lane for the language`, `no manifest root`, `tool not installed`, `lane degraded`, `tool could not parse it`, and `lane not selected`. The grep lane covers files with no symbol lane, since it checks them for references; `no lane for the language` remains when that lane is not selected. Markdown, JSON, YAML, and other non-source files are not in that total. When `uncovered` is greater than zero the script lists each file and does not print the clean-result note or the scan-of-nothing note.

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:

1. Adjudicate a bounded batch. Done when the batch is adjudicated or explicitly deferred.
2. Paste the emitted suppression entries into each detector's **native** config. Knip `ignore`
   entries, a vulture whitelist. Formats in [context/adjudication.md](context/adjudication.md)
   "Suppression formats". Done when the operator has the suppression text or has declined to apply it.
3. The next run is cleaner, and the batch after it reaches new code. Done when a follow-up scan is
   scheduled or the operator stops after one batch.

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.

## Next

`/code-tidying:tidy`

Adjudicated `dead` verdicts are applied there. This skill only reports.

## Gotchas

- **No `package.json` means knip does not scan `.js`/`.mjs`/`.cjs`.** Those extensions are routed to
  knip, so with no manifest root its lane is `no-manifest` and `files=` is the real count. The grep
  lane takes those files, and any JS/TS file outside every `package.json` root, for
  `ts-unreferenced-symbol` and `unreferenced-file`, so a full run lists them covered. A symbol used
  only inside its own file counts as referenced and stays unreported.
- **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. Either way the
  lane's files are listed uncovered (`lane degraded`), so the clean-result note cannot print.
- **`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.

Files in this skill

  • SKILL.md12.7 KB
  • context/adjudication.md7 KB
  • context/lanes.md10.1 KB
  • evals/evals.json9.9 KB
  • evals/fixtures/dead-and-dynamic.go602 B
  • evals/fixtures/dead-and-dynamic.py1022 B
  • evals/fixtures/dead-and-dynamic.sh1.2 KB
  • evals/fixtures/dead-and-dynamic.ts674 B
  • evals/fixtures/gopls-hints.txt77 B
  • evals/fixtures/knip-degraded.stderr.txt142 B
  • evals/fixtures/knip-report.json676 B
  • evals/fixtures/py-entry.py352 B
  • evals/fixtures/ts-entry.ts591 B
  • evals/fixtures/ts-orphan.ts347 B
  • evals/fixtures/ts-used.ts234 B
  • evals/fixtures/vulture-parse-error-input.toml449 B
  • evals/fixtures/vulture-parse-error.txt128 B
  • evals/fixtures/vulture-report.txt276 B
  • scripts/dead-code-scan.sh30.5 KB
  • scripts/dead-code-scan.test.sh39.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.