---
name: plan-dead-code
description: >
  Plan-only dead-code audit: Knip baseline for unused files, exports, and
  deps, plus duplication, debug residue, suppressions, orphan assets, env
  drift. Deletes nothing. Use when "find dead code", "is this code used", or
  "unused exports".
license: MIT
effort: high
---

# plan-dead-code — Prove it is dead before anyone deletes it

**Degree of freedom: MIXED.** Tool config authoring and the baseline
command set `[LOW freedom — run exactly]`; judging *reachable vs
proven-dead*, and what must keep working, `[HIGH freedom]`.

**Plan-only. This pass deletes nothing** — not a file, not an export, not
a dependency, not a row. It produces `plan-dead-code.md`: a reviewed
keep/kill list plus a ratchet baseline.

The failure mode this skill exists to prevent: an agent runs `knip` on a
vibe-coded repo, gets 300 findings, and starts deleting. **On a first run,
most findings are misconfiguration, not dead code.** Knip's own docs are
explicit — running `--fix` "before your configuration is fully settled is
dangerous", and `ignore` patterns are "a last resort" because "hiding a
result is not the same as resolving it."

## This skill vs neighbors

| Skill | Owns |
|---|---|
| **plan-dead-code** (this) | *Nothing reaches this* — unreachable files/exports/deps/assets/schema (plan only) |
| `housekeep-dead-code` | Execute this plan — delete by category, install the ratchet |
| `plan-stub-checker` | *This reaches nothing* — dead buttons, unwired handlers |
| `plan-antislop` | *Looks machine-authored* — a taste judgment, not reachability |
| `plan-dependency-provenance` | Package **existence**, integrity, licensing, slopsquatting |
| `audit-code-quality` | Repo-wide smells and consistency drift |
| `workflow-refactor` | Restructuring live code that stays |
| `enhance-arch-boundaries` | Circular deps and layer direction (dependency-cruiser) |
| `audit-db-schema` | Schema design, constraints, naming |
| `housekeep-backlog` | The TODO/skipped-test register |
| `workflow-housekeep` | README, build artifacts, dependency bumps |

Boundary with `plan-dependency-provenance`: it owns *manifest facts* (does
this package exist, is it licensed). This owns the *graph fact* (nothing
imports it). A package that is real, licensed, and unimported is this
skill's finding.

## How to reason (every candidate)

1. **Observe** — which tool reported it, at what path, in which mode.
   Unsure? `npx knip --trace-export <name>` / `--trace-file <path>` shows
   exactly where Knip looked
2. **Interpret** — is it unreferenced, or referenced in a way the tool
   cannot see? (dynamic import, string route, generated types, Deno)
3. **Classify** — `kill` / `keep-config` / `keep-tagged` / `chain` /
   `needs-owner`, per
   [`references/preservation-contract.md`](references/preservation-contract.md)
4. **Severity × effort** — an unused dependency with an open advisory
   outranks an unused type; a barrel explaining forty findings is one
   M-effort fix, not forty S-effort ones

Skipping **Interpret** is what turns a cleanup into an outage. A tool
finding is evidence, not a verdict.

**Report chain heads, not chain members.** One dead file explains its own
exports *and* the packages only it imported. Those are children of that
row, not rows of their own — otherwise a three-item plan reads as
twenty-seven and the phasing is wrong.

## Worked example

> **Observe:** first `npx knip` on a Vite + React + Supabase app: 312
> findings — 47 unused files, 180 unused exports, 61 unused types, 24 deps.
> **Interpret:** `src/routes/**` is loaded via `import.meta.glob`, so no
> file imports it; `src/types/supabase.ts` is generated; every `ui/*.tsx`
> re-exports through a barrel; `supabase/functions/**` is Deno, never in
> the Vite graph.
> **Classify:** config-gap, not dead code. Add `entry` for the route glob,
> `ignoreExportsUsedInFile` for interfaces/types, a separate workspace for
> `supabase/functions`, and tag the design-system barrel `@public`.
> **Re-run:** 41 findings — 9 unused files, 22 exports, 6 types, 4 deps.
> **Baseline:** 41 default / 12 `--production`. That 12 is the highest-
> confidence set: dead in shipped code.
> **Plan:** kill the 9 files (7 are unused shadcn components — re-addable
> with `npx shadcn add`), 4 deps; `@public` the barrel; 2 exports
> `needs-owner` (look like a half-built public API).

---

## Phase 0 — Detect the stack before running anything  [LOW freedom — run exactly]

Config quality decides whether every later number means anything. Read,
don't guess:

- **Bundler / framework** — `vite.config.*`, `next.config.*`, `astro.config.*`,
  `remix.config.*`, `expo`/`metro.config.*`, `nest-cli.json`
- **Workspaces** — `pnpm-workspace.yaml`, `package.json#workspaces`, `nx.json`,
  `turbo.json`. A monorepo configured as one root project reports the whole
  repo as dead.
- **Non-Node graphs** — `supabase/functions/**` (Deno), Cloudflare Workers,
  `*.sql`, native shells. These are never in the app's module graph and must
  be separate workspaces or they report 100% unused.
- **Generated code** — `types/supabase.ts`, `*.gen.ts`, GraphQL codegen,
  Prisma client, route trees
- **Dynamic reference** — `rg "import\.meta\.glob|require\.context|await import\(|lazy\(" `
- **Existing config** — `knip.json`, `knip.jsonc`, `package.json#knip`,
  `.jscpd.json`, `tsconfig` `noUnusedLocals`/`noUnusedParameters`
- **Test/coverage state** — is behavior locked by tests yet? Record it.
  Deleting before coverage exists means deletion has no safety net.

Record: package manager, framework, workspace layout, non-Node arms,
generated paths, dynamic-import mechanisms, whether tests pass today.

> **Sequencing note.** In the plan loop, `plan-test-coverage` (step 3)
> locks behavior in tests. Running this audit is safe any time; **executing
> it before behavior is locked is the risky order.** Say so in the plan.

---

## Phase 1 — Author the config, then trust the output  [LOW freedom — config before findings]

Install as a devDependency (never a global): `npm i -D knip`. Requires the
project's own TypeScript.

**1a. First run, read hints only.**

```
npx knip
```

Configuration hints print at the top. **Resolve those before reading a
single finding.** Hints are Knip telling you your config is wrong.

**1b. Fix hints in `knip.json` — not with `ignore`.**

| Symptom | Correct fix | Not this |
|---|---|---|
| Whole directory unused | Add `entry` pattern for its real entry | `ignore` the directory |
| `vite.config.ts` reported unused | Enable/disable that plugin explicitly | `ignore` the file |
| Flood of unused `interface`/`type` | `ignoreExportsUsedInFile: { interface: true, type: true }` | `--exclude types` |
| Unresolved path-alias imports | Add `paths` (tsconfig semantics) | `ignore` the importer |
| Node builtins as unused deps | `ignoreDependencies` | blanket `ignore` |
| Test files reported | `--production` | negated `project` patterns |
| Generated file's exports unused | `ignore` that one file *(legitimate)* | — |

Knip already respects `.gitignore` — do not re-list `node_modules`,
`dist`, `build`. Do not duplicate entry points that auto-detected plugins
already add.

**1c. Repeat 1a–1b until hints are clean.** This loop is the skill. Config
recipes for Vite+React+Supabase, Next.js App Router, and monorepos are in
[`references/knip-config.md`](references/knip-config.md).

**Gate before Phase 2.** Hints clean, *and* unresolved imports plus unlisted
dependencies both explained. If either is still in the dozens, the config is
not done — the resolver is failing, not the code. Triaging findings from a
misconfigured run is how false positives become deletions.

---

## Phase 2 — Baseline the module graph  [LOW freedom — run exactly]

Run **both** modes; Knip's CI guide suggests considering a default run
alongside a production run.

```
npx knip --production   # shipped code only: no tests, config, stories, devDeps
npx knip                # everything
```

Read findings in Knip's documented order, because it cascades — "getting
the list of unused files right trickles down into the other issue types":

1. **Unused files** — one dead file manufactures phantom unused exports
   *and* phantom unused deps downstream
2. **Unresolved imports** — usually a `paths` gap, not dead code
3. **Unused exports / types**
4. **Unused dependencies**

Scope with `--files`, `--exports`, `--dependencies` to read one class at a
time, and `--max-show-issues <n>` to keep a first pass readable.
`--production` excludes test files, config files, stories and
devDependencies; `--strict` additionally isolates workspaces to direct
dependencies. `--reporter json` is what the baseline numbers come from —
never a hand count.

Delegate a baseline or residue pass to a subagent only when it is sizeable, independent of the others, and its raw output (Knip JSON, grep dumps) has no place in the plan; brief it with the exact commands and the columns to return. It deletes nothing, like this pass. A handful of greps stays in the main context. Long sweeps are expected — context compaction exists, so finish the inventory rather than wrapping up early.

**Build the keep-working list.** For every finding, ask what would break.
Named suspects, each needing a positive reason to keep:

- Glob/dynamic-imported routes and pages
- String-referenced handlers, feature-flag targets, job names
- Generated types (regenerate, don't hand-edit)
- Deliberate public API → tag `@public` (Knip then stops reporting it)
- Test-only exports that production mode flags → tag `@internal`
- Duplicate default+named export pairs → tag `@alias`

Tags beat config here: they live next to the code and Knip reports a **tag
hint** when a tag becomes unnecessary, so the exemption cannot rot.

---

## Phase 3 — The seven surfaces Knip cannot see  [MIXED — greps run exactly]

Knip owns the module graph. These are the classes a vibe-coded repo
accumulates that it will never report. Count each; fix nothing. Exact
commands, false positives, and the fix column are in
[`references/residue-greps.md`](references/residue-greps.md).

> **Run the bundle-leak check even if you skip the rest of this phase:**
> `npm run build && rg -l "service_role|SUPABASE_SERVICE_ROLE" dist`. A hit
> is a live credential in shipped output — stop the pass and go to
> `plan-secrets-audit`.

**3a. Unused variables and imports inside files.** Knip explicitly does
not do this. Baseline with `tsc --noEmit` under `noUnusedLocals` +
`noUnusedParameters`, or your linter's unused rule. Record the count and
whether the compiler flags are even on.

**3b. Copy-paste duplication.** The class AI-assisted repos are worst at,
and no other skill in this pack detects it. `npx jscpd src --min-tokens 50
--reporters json` (50 is the default; raising it cuts boilerplate noise).
Record duplication % and the top clone pairs. **Duplication is a refactor
finding, not a deletion finding** — route to `workflow-refactor`.

**3c. Debug residue.** Mechanical and countable:

```
rg -n "console\.(log|debug|warn)|debugger" --glob '!*test*' --glob '!*spec*'
rg -n "\.(only|skip|todo)\(|xit\(|xdescribe\(|fdescribe\("
```

Plus commented-out code blocks and `utils`/`helpers` dumping grounds.
`.only` in a committed test is worse than dead code — it silently disables
the rest of the file. Flag those **high**.

**3d. Suppression debt.** The count that must only ever shrink. Use
`rg -o … | wc -l` for a repo total — `rg -c` prints per-file counts of
*matching lines*, which is not a number you can ratchet:

```
rg -o "@ts-ignore|@ts-expect-error|eslint-disable|biome-ignore" | wc -l
rg -o ":\s*any\b|as any\b" | wc -l
```

Also catch stale suppressions: a `@ts-expect-error` on a line that no
longer errors is itself dead code, and TypeScript reports it.

**3e. Orphan assets.** Knip reads the module graph, so it cannot see
binaries. Cross-reference every filename under `public/`, `assets/`,
`static/` against source, CSS, and HTML. Beware hashed and templated
references (`` `/img/${name}.png` ``) — those make a name-based scan
report false orphans, so a folder referenced only by template string is
`needs-owner`, not dead.

**3f. Env var drift, both directions.** Referenced-but-undeclared is a
production break; declared-but-unreferenced is dead config:

```
rg -o "import\.meta\.env\.[A-Z0-9_]+|process\.env\.[A-Z0-9_]+" -r '$0' --no-filename | sort -u
```

Diff that set against `.env.example`. Report names only — **never print
values.**

**3g. Dead database schema.**  [read-only this pass]

These three commands only read, so they are safe against any target
including production — and index-scan statistics are *only* meaningful
against real traffic, so prefer production and record the stats window.
The non-production gate applies to anything that writes.

```
supabase db lint                            # plpgsql_check: dead code after RETURN, unused variables
supabase inspect db unused-indexes          # indexes with low scan counts
supabase inspect db seq-scans
```

Then cross-reference client usage to find orphans the database cannot know
about: `rg -o "\.from\('[^']+'\)|\.rpc\('[^']+'\)|functions\.invoke\('[^']+'\)"`
against the table/function/Edge-Function inventory. Also: Edge Functions
deployed but never invoked, migration sprawl, and whether generated types
still match the schema (`supabase gen types` diff).

**Low index scans on a young or read-light database is not evidence of a
dead index.** Record the stats window. Every schema finding lands in the
plan as a proposal — **no `DROP` is authored in this pass**, and destructive
migrations route to `plan-data-integrity` and `db-migrator`.

---

## Phase 4 — Write `plan-dead-code.md`  [LOW freedom on format]

**4a. Baseline table** — the ratchet's starting line. Numbers only ever go down.

| Metric | Command | Today |
|---|---|---|
| Knip issues (production) | `knip --production` | |
| Knip issues (default) | `knip` | |
| Unused files / exports / types / deps | `knip --files` etc. | |
| Unused locals | `tsc --noEmit` | |
| Duplication % | `jscpd` | |
| `console.*` / `debugger` | `rg -o … \| wc -l` | |
| `.only` / `.skip` / `.todo` / `x`-prefixed | `rg -o … \| wc -l` | |
| Suppressions / `any` | `rg -o … \| wc -l` | |
| Orphan assets | asset scan | |
| Env drift (missing / unused) | env diff | |
| Unused indexes / orphan tables / functions | `supabase inspect` | |

**4b. Keep/kill list** — one row per **chain head**:
`path:line | class | evidence | verdict | sev | effort | children | what must keep working`

Verdicts: `kill` · `keep-tagged` (`@public`/`@internal`/`@alias`) ·
`keep-config` (config gap, fix the config) · `chain` (child of another row) ·
`needs-owner` (a human must decide). Never guess `kill` to shrink the list,
and never leave a keep row without naming its remedy — a keep with no remedy
is an `ignore` in disguise, and the next session will delete it.

A filled example table, with the severity and effort rubric, is in
[`references/output-templates.md`](references/output-templates.md).

**4c. Phased burndown** — ordered so each phase's verification is
meaningful, cheapest-to-review first, and the pre-approved file list going
first so later phases do not churn files that are about to be deleted.

**4d. Ratchet proposal** — the `knip` script, the CI job with
`--max-issues` set to today's count, and which aggregator gate it joins.

**4e. Cause-not-count callouts.** If 40 exports come from one barrel
`index.ts`, the finding is *the barrel*, not 40 exports. Name the root
cause so execution fixes one thing instead of forty.

---

## Self-critique before delivering  [LOW freedom — do not skip]

1. **Config settled** — hints resolved; no `ignore` used where `entry`,
   `paths`, `--production`, or a tag was the real fix
2. **Both modes run** — `--production` and default, reported separately
3. **Interpreted, not transcribed** — every `kill` names its evidence;
   dynamic/generated/Deno paths were actively checked
4. **Nothing deleted** — no file, export, dep, asset, or row changed
5. **Non-graph surfaces counted** — all seven of Phase 3, or explicitly
   `not run` with a reason
6. **No secret values** — env findings are names only
7. **Baseline is real** — every number came from an executed command, not
   an estimate
8. **Right owner** — duplication → `workflow-refactor`; unwired UI →
   `plan-stub-checker`; slop taste → `plan-antislop`; package existence →
   `plan-dependency-provenance`; circular deps → `enhance-arch-boundaries`;
   destructive SQL → `plan-data-integrity`
9. **Safety net stated** — whether tests currently lock the behavior being
   deleted

## Definition of Done

- [ ] Stack, workspaces, non-Node arms, generated and dynamic paths recorded
- [ ] `knip.json` authored; configuration hints clean
- [ ] Both `--production` and default baselines captured
- [ ] Findings read files → unresolved → exports → deps
- [ ] Keep-working list built; tags proposed instead of config where they fit
- [ ] Seven non-graph surfaces counted or marked `not run` with a reason
- [ ] `plan-dead-code.md` written: baseline, keep/kill list, phases, ratchet
- [ ] Root causes named (barrels, dumping grounds) — not just item counts
- [ ] Zero deletions this pass; execution handed to `housekeep-dead-code`

## Output format

Full template: [`references/output-templates.md`](references/output-templates.md).

1. **Config diff** — what `knip.json` changed and which hint each resolved
2. **Before/after finding counts** — first run vs configured run (the honest
   measure of how much was never dead)
3. **Baseline table** — 4a, fully populated
4. **Keep/kill list** — 4b, chain heads only, grouped by class
5. **Root causes** — 4e, the barrels and dumping grounds behind the counts
6. **Phased burndown** — 4c with risk and "what must keep working" per phase
7. **Ratchet proposal** — 4d
8. **Handoffs** — per the self-critique owner list

Plan only. End the turn with a standalone recap in chat: the two or three highest-impact chain heads and the first phase to approve; the plan file is the deliverable, write it first. Deletion begins in `housekeep-dead-code`, after approval. The
non-negotiables for this pass are in
[`references/preservation-contract.md`](references/preservation-contract.md).
