ADR 022: Knip (Dead Code & Dependency Hygiene)

Accepted
2026-07-20

This legacy URL now resolves to the canonical ADR.

Context

The quality stack watches code that runs: SonarQube scores health within files (ADR 048), CodeQL traces dataflow (ADR 032), Trivy gates dependency CVEs (ADR 047). 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: 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 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

ToolRoleProsConsVerdict
Knip (accepted)Files + exports + depsMonorepo-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-pruneUnused exportsSimple.Archived by its author, who recommends Knip.Rejected: unmaintained.
depcheckUnused deps onlyLong-standing.Deps only. No files/exports; weak monorepo support; largely dormant.Rejected: subset of Knip.
dependency-cruiserDependency rulesEnforces architecture constraints (cycles, layering).Different problem: constrains allowed imports rather than finding unused code.Not a substitute; a possible future complement.
SonarQube dead-code rulesWithin-fileAlready running.Cannot see across modules or into package.json.Insufficient alone: the gap is cross-module.
Status quon/aNothing 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: 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 trade, accepted because the alternatives are unmaintained rather than merely older.

Relation To Building 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. 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. 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. The gate covers only issue classes with a green baseline; the exports backlog records staged work. It must not become a number gamed 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.