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.jsthat 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.jsonteaches it the workspace graph (it readspnpm-workspace.yamlnatively) and the entry points static analysis cannot see: Cloudflare Pages Functions infunctions/, DVC pipeline stages inml-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 rootlintaggregate.- 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/typesissue 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
| 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: 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'scloudflare:workers/cloudflare:workflowsvirtual modules were reported as an unlistedcloudflaredependency 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.jsondocuments 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.