---
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.
