# ADR 052: Knip (Dead Code & Dependency Hygiene)

- HTML version: https://robbiepalmer.me/projects/personal-site/adrs/052-knip
- Project: Personal Site (https://robbiepalmer.me/projects/personal-site.md)
- Status: Accepted
- Date: 2026-07-20

# Context

The quality stack watches code that *runs*: SonarQube scores health within
files ([ADR 048](/projects/personal-site/adrs/048-sonarqube)), CodeQL traces
dataflow ([ADR 032](/projects/personal-site/adrs/032-codeql)), Trivy gates
dependency CVEs ([ADR 047](/projects/personal-site/adrs/047-trivy)). None of
them answer a structural question that matters more as the monorepo grows:
**is this file, export, or dependency referenced at all?**

That blind spot is not hypothetical. The first Knip run over the workspace
found, after configuration to teach it the repo's dynamic entry points:

* **18 dead source files** — a superseded 12-component cluster in the
  recipe-parsing viz tool, an orphaned filter-component trio plus its types
  module in the UI, an unused shadcn accordion, and a dangling MDX helper.
* **8 dependency-manifest errors** — seven unused dependency declarations
  (including two Radix packages and a stale `zod`), and a remark plugin
  referenced by `.remarkrc.js` that only resolved through another package's
  transitive dependencies.

Dead code is not just clutter: it is surface area every reviewer, scanner, AI
agent, and dependency-update PR pays for on every pass. In an agent-heavy
workflow the cost compounds — agents read and preserve code that nothing uses,
because nothing tells them it is unreferenced.

# Decision

Adopt **Knip** as the monorepo's dead-code and dependency-hygiene check:

* A root `knip.json` teaches it the workspace graph (it reads
  `pnpm-workspace.yaml` natively) and the entry points static analysis cannot
  see: Cloudflare Pages Functions in `functions/`, DVC pipeline stages in
  `ml-pipelines/*`, and helper scripts. Content MDX and binaries invoked
  through mise tasks are declared rather than left to guesswork.
* `mise run //:lint:knip`, wired into the root `lint` aggregate.
* A dedicated **blocking** CI workflow on any JS/TS or package-manifest
  change. Blocking is justified the same way actionlint's was in
  [ADR 049](/projects/personal-site/adrs/049-zizmor): the baseline was made
  green first — the 18 files were deleted and the dependency manifests
  corrected in the adopting change, verified by every workspace's typecheck,
  677 unit tests, and a full production build.
* **Staged adoption**: the `exports`/`types` issue categories (\~220 unused
  exports at time of writing) are excluded from the gate for now. Deleting a
  file is safe when nothing imports it; demoting hundreds of exports is a
  refactor to do deliberately, not a lint gate to switch on wholesale.
  Tightening the config is the natural follow-up once that backlog is worked
  through.

# Why It Adds Something New

Reachability analysis across module and workspace boundaries is a different
axis from everything already running. SonarQube's dead-code rules are
file-local (an unused private function), and no existing tool relates
`package.json` declarations to actual imports in both directions — the unused
side caught seven dead declarations in the first run, and the unlisted side
now guards against the inverse accident (importing a package another workspace
happens to provide). On the determinism axis of
[ADR 048](/projects/personal-site/adrs/048-sonarqube) it lands in the
deterministic column: the same orphaned file fails the same check every time,
where an AI reviewer might or might not notice one in a diff's blast radius.

# Alternatives Considered

| Tool                          | Role                   | Pros                                                                                                                                               | Cons                                                                            | Verdict                                         |
| ----------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- |
| **Knip** *(accepted)*         | Files + exports + deps | Monorepo-native (reads pnpm workspaces); plugin ecosystem covers Next.js/Vitest/Tailwind; single tool for three issue classes; actively developed. | Dynamic usage (fs-loaded content, mise-invoked binaries) needs explicit config. | **Accepted.**                                   |
| **ts-prune**                  | Unused exports         | Simple.                                                                                                                                            | Archived by its author, who recommends Knip.                                    | Rejected: unmaintained.                         |
| **depcheck**                  | Unused deps only       | Long-standing.                                                                                                                                     | Deps only — no files/exports; weak monorepo support; largely dormant.           | Rejected: subset of Knip.                       |
| **dependency-cruiser**        | Dependency *rules*     | Enforces architecture constraints (cycles, layering).                                                                                              | Different problem: constrains allowed imports rather than finding unused code.  | Not a substitute; a possible future complement. |
| **SonarQube dead-code rules** | Within-file            | Already running.                                                                                                                                   | Cannot see across modules or into `package.json`.                               | Insufficient alone: the gap is cross-module.    |
| **Status quo**                | n/a                    | Nothing new.                                                                                                                                       | 18 dead files and an undeclared runtime dependency say otherwise.               | Rejected.                                       |

# Where It Sits On The Adoption Curve

**Early majority**, like zizmor in [ADR 049](/projects/personal-site/adrs/049-zizmor):
Knip has displaced ts-prune/depcheck as the community default for this job and
is stable and well-documented, but young enough that its LLM corpus is thinner
than decade-old linters — the usual
[Goldilocks-zone](/projects?tab=philosophy#the-goldilocks-zone) trade, accepted
because the alternatives are unmaintained rather than merely older.

# Relation To Building Philosophy

* [Less Is More](/projects?tab=philosophy#less-is-more). Knip's whole job is
  making the codebase smaller; the tool itself is one devDependency, one JSON
  config, one CI job.
* [Short Feedback Loops](/projects?tab=philosophy#short-feedback-loops). A PR
  that orphans a file or leaves a dependency behind hears about it on that PR,
  not during some future spring-clean.
* [LLM-Optimized](/projects?tab=philosophy#llm-optimized). Less dead code means
  less irrelevant context for agents to read, imitate, and preserve; the
  config file doubles as documentation of the repo's non-obvious entry points.
* [Respect Goodhart's Law](/projects?tab=philosophy#respect-goodharts-and-conways-laws).
  The gate covers only issue classes with a green baseline; the exports
  backlog is staged work, not a number to game with ignores.

# Gaps & Risks

* **Dynamic and runtime-provided modules are invisible.** Anything referenced
  only via filesystem reads, string-built imports, or mise tasks needs
  declaring in `knip.json`; a missed declaration surfaces as a false positive
  to triage. The first run supplied the canonical example: workerd's
  `cloudflare:workers`/`cloudflare:workflows` virtual modules were reported as
  an unlisted `cloudflare` dependency and are now declared in the config.
* **The exports backlog is deferred**, so unused exports keep accumulating
  until that category is enabled — bounded by the follow-up to work through
  the \~220 findings and tighten the gate.
* **Config drift.** New workspaces or dynamic entry points must be added to
  `knip.json`; forgetting one fails CI loudly, which is the safe direction.

# Consequences

## Positive

* Deleted 18 dead files and fixed 8 dependency-manifest errors on adoption.
* Deterministic, blocking reachability check on every JS/TS change; free, no
  platform, mise/CI parity.
* `knip.json` documents the monorepo's real entry-point graph in one place.

## Negative

* One more config file to keep truthful as the workspace graph evolves.
* Unused-export detection deliberately deferred; the backlog remains until a
  dedicated cleanup enables those categories.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
