- name
- knip-migration
- description
- Dead-code / unused-dependency detection uses `knip`, which requires each package to expose an
explicit, minimal `package.json#exports` (no `./*` wildcard). Read this when adding a new package or migrating an existing one to knip.
# Dead-code detection: explicit exports + knip
Dead-code / unused-dependency detection runs on **`knip`**. The legacy `unimported` tool is gone
and the dependency checks deny it; legacy `libs/*` packages that never migrated have no
dead-code tooling. The root [`knip.json`](../../../knip.json) holds only
the shared rules — a package needs no `workspaces` entry there, since knip derives its entry files
from `package.json` (`main`, `bin`, `exports`) and its own defaults
([entry files](https://knip.dev/explanations/entry-files)).
## Why this isn't just "swap the tool"
knip treats everything listed in a package's `package.json#exports` as an **entry point**.
Today every package exports a `./*` wildcard mapped to `./src/*.ts` (via the `@ledgerhq/source`
custom condition declared in `tsconfig.base.json`):
```jsonc
"./*": {
"@ledgerhq/source": "./src/*.ts",
"import": "./lib-es/*.js",
"require": "./lib/*.js",
"default": "./lib/*.js"
}
```
Because of this wildcard, knip considers **every top-level `src/*.ts` file "used"** and cannot
detect unused ("zombie") top-level source files. So a package needs to **replace the `./*` wildcard with explicit, minimal subpath exports** that enumerate
the package's true public API.
> The few libs already pointed at knip (e.g. `libs/env`, `libs/promise`) still keep the `./*`
> wildcard, so they are only *partially* migrated — **do not** copy them as the template.
## New package = born migrated
A new package has no consumers, so it should start in the target state:
1. **Explicit `exports` only.** Enumerate the real public API; do **not** add a `./*` wildcard.
Mirror the `.` root entry per subpath, keeping the conditions:
```jsonc
"exports": {
".": { "@ledgerhq/source": "./src/index.ts", "import": "./lib-es/index.js", "require": "./lib/index.js", "default": "./lib/index.js" },
"./logic": { "@ledgerhq/source": "./src/logic.ts", "import": "./lib-es/logic.js", "require": "./lib/logic.js", "default": "./lib/logic.js" },
"./lib-es/*": "./lib-es/*.js",
"./lib/*": "./lib/*.js",
"./package.json": "./package.json"
}
```
2. **Use knip** — add a script that runs knip scoped to the workspace:
`pnpm knip --directory <relative-hop-to-root> -W <workspace-path>`
(`--directory` is the hop back to the repo root — `../..` for `libs/<x>`,
`../../..` for `libs/coin-modules/<x>`; `-W` is the workspace path from root).
## Dual-platform packages run knip once per platform
knip has no notion of `moduleSuffixes`. A suffix-less `./Tool` specifier therefore resolves to
nothing when only `Tool.web.tsx` and `Tool.native.tsx` exist, and **both twins are reported as
unused files** (`files` is an `error` rule). Naming the suffix in the barrel fixes it for packages
that may do so, but not under `devtools/`, where `suffix-imports/no-platform-suffix` makes a
suffixed specifier a lint error.
So a package with platform twins gets two passes, through the shared helper
[`knip.config.base.mjs`](../../../knip.config.base.mjs):
```js
// <pkg>/knip.web.config.mjs — and the `native` mirror
import { createDualPlatformKnipConfig } from "../../knip.config.base.mjs";
export default createDualPlatformKnipConfig({
packagePath: "devtools/shell",
platform: "web",
entry: [],
});
```
```jsonc
// <pkg>/package.json — the hop in `--directory` is the depth back to the repo root
"unimported": "pnpm knip --directory ../.. -c devtools/shell/knip.web.config.mjs -W devtools/shell --tsConfig tsconfig.web.json && pnpm knip --directory ../.. -c devtools/shell/knip.native.config.mjs -W devtools/shell --tsConfig tsconfig.native.json"
```
Name the file `knip.<platform>.config.mjs`. The root `ignore` list matches `**/*.config.*`, so that
name keeps knip from reporting its own config as unused. A `.ts` config does not match and needs a
Sonar exclusion to compensate.
**These configs carry no comments.** This section is the single source for why they look as they do;
the three options below are the only reasons a config should differ from the snippet above.
### `entry` is `[]` unless `package.json` cannot yield the entry
knip derives entries from `package.json` (every `exports` condition, `main`, and specifiers in
`scripts`), and reports an `entry` it already has as a *redundant entry pattern*. Name one only
when that derivation misses a real entry point:
- the package declares no `react-native` condition, so only the web barrel is derived
(`devtools/env`, and `devtools/feature-flags` / `devtools/pay-card`, which have no `exports` at
all). Name `src/index.native.ts` in the native config.
- a platform file is reachable only through resolution. `devtools/bindings` pairs
`isMockSessionSupported.ts` with a `.native.ts` override and no `.web` twin, and knip resolves
the unsuffixed sibling first, so the native config names `src/isMockSessionSupported.native.ts`.
Such an entry goes stale when the override is deleted; knip then reports *Refine entry pattern
(no match)*, so treat that hint as "the pair is gone, drop the entry".
### `additionalProjectExcludes` is for an *unsuffixed* barrel
The platform glob the helper applies (`!src/**/*.native.*` on web, `!src/**/*.web.*` on native)
only matches suffixed files. A web barrel named `src/index.ts` is not excluded from the native pass
and becomes an unused file there, so the native config names it. A package whose web barrel is
`src/index.web.ts` (`devtools/transport-panel`) needs nothing.
### `additionalIgnoreDependencies` is a last resort
A dependency reached only from the excluded platform's files reads as unused, and `dependencies` is
an `error` rule. Confirm the dependency is genuinely unreachable in that platform before reaching
for this.
### Two resolution facts that look like bugs
- **An unsuffixed sibling beats the compiler extension.** With `{"web.ts": passThrough}` registered,
`./foo` still resolves to `foo.ts` when both `foo.ts` and `foo.web.ts` exist.
- **Compilers resolve file suffixes, not directory `index` suffixes.** `from "../hooks"` never
reaches `hooks/index.web.ts`. Import the module itself (`../hooks/useDevToolsStorage`).
A `jest/**` helper sits outside the `project` glob, so knip cannot see it importing a barrel export.
Only `typecheck` catches that, which is why it runs before trusting a clean knip report.
## Reviewing
For a **new** package, flag this and point here:
- a **`./*` wildcard** in `package.json#exports` — new packages must enumerate explicit exports
so knip can detect zombie files.
## The `workspaces` block in `knip.json` is a temporary workaround
Everything must work without touching `knip.json`. The `workspaces` entries that remain are
band-aids for packages that don't declare their surface properly yet — deep imports behind a `./*`
wildcard, runtime entries invisible to `package.json` (Electron preloads, web workers), or
dependencies knip can't resolve. Each one hides a package that hasn't finished the migration above.
So: don't grow this file. Fixing the package's `exports` is the real fix, and it lets the
corresponding `workspaces` entry be deleted. If you truly cannot avoid an entry, keep it to the
smallest possible delta and remember a configured `entry` **replaces** knip's default patterns
rather than extending them.
View on GitHub