Skip to content

The Frontend Package Manager — ADR-110

The Frontend Package Manager — ADR-110

One ruling. It changes how the client’s dependencies are installed, and nothing about what they are. No runtime code is touched, no wire format moves, and no quantity changes.

It is recorded as an ADR rather than a chore(deps) because it changes a boundary: which imports resolve. tech-stack-currency.md §1.5 says most upgrades are ordinary chores and to not manufacture an ADR for a patch bump — this is not that. A tool whose misuse pattern changes is a decision, and the property it buys is one a future contributor can delete by running one command.


ADR-110 — The Frontend Client Installs With pnpm, and With Nothing Else

Status: Accepted · Date: 2026-08-08 · No ADR is amended or superseded

Context

apps/vtt-frontend-client installed with npm, whose node_modules is hoisted: a package that no package.json declares is nonetheless importable, because some transitive dependency pulled it to the top level. An import of it compiles, type-checks, lints, bundles and ships. It breaks later, when the dependency that happened to hoist it changes its own tree — at which point the failure is in a file nobody edited, blamed on a package nobody added.

That is a phantom dependency, and this repository already has a name for the shape of it: correct, silent, and invisible to every existing check.

tech-stack-currency.md §5 lists the five questions a technology choice is judged against here. Question 2 is the one that decides this:

Can the boundary it participates in be made structural? Prefer a tool whose misuse is a missing import or a compile error over one whose misuse is a review comment.

Under npm, “do not import what you have not declared” is a review comment. Under pnpm’s strict resolution it is a ERR_MODULE_NOT_FOUND. That is the whole argument, and it is the same argument check-network-boundary.mjs, check-optimize-deps.mjs and Descent.ArchitectureTests are each an instance of — P3’s ordering, applied to the installer.

The problem was real here, not hypothetical. The first build after the migration failed:

Cannot find package '@eslint/js' imported from .../eslint.config.mjs

eslint.config.mjs — the file that configures the linter that gates every frontend build — imported a package the client never declared. It resolved for as long as it did purely because eslint depends on it and npm hoisted it. It is now an explicit devDependency.

On CI speed, which was offered as a second driver: the measurement does not support it, and the honest number is recorded rather than the expected one. P5 and P7 both apply — a figure nobody measured is not a reason.

Measured 2026-08-08
npm ci, cold node_modules, warm npm cache20s
pnpm install, cold store, 210 packages downloaded, 0 reused22s
The install step’s share of the frontend-ci job5s of ~215s — about 2%
The job’s actual dominant costcargo install wasm-pack, 119s

pnpm’s local number is its worst case (an empty content-addressable store); a warm store is substantially faster, and CI caches it. But even a free install would take ~2% off this pipeline. Anyone optimising frontend-ci should be looking at the 119 seconds spent compiling wasm-pack from source, not at the installer. The migration is justified by question 2 and not by wall-clock.

Decision

  1. apps/vtt-frontend-client installs with pnpm and with nothing else. package-lock.json is deleted; pnpm-lock.yaml is the lockfile and is committed.

  2. The version is declared exactly once, in the packageManager field (pnpm@11.20.0). corepack provisions that version locally and pnpm/action-setup reads the same field in CI via package_json_file. No workflow names a version, because two declarations are two things that can disagree.

  3. CI installs with pnpm install --frozen-lockfile — pnpm’s npm ci. It is the default in CI and is passed explicitly anyway, so the guarantee is visible in the step rather than inherited from an environment variable a future runner image might not set.

  4. pnpm@11.20.0, not pnpm@12. 12.0.0 is at rc.1; §5 question 5 — “is it stable, or is it new?” — and CLAUDE.md §6 rule 7’s latest ≠ newest both refuse a release candidate for a tool the whole build depends on. Re-evaluate when 12 reaches the latest tag.

  5. An install driven by anything else is refused, by a dependency-free preinstall check. See Enforcement.

Alternatives Considered and Why Rejected

  • Stay on npm and rely on review. This is the status quo, and the status quo shipped an undeclared @eslint/js into the file that configures the build’s own linter. Rejected by question 2: it leaves the rule as a review comment.

  • Stay on npm, add a dedicated phantom-dependency lint. Buildable — walk the import graph and compare against package.json, much as check-optimize-deps.mjs walks it for a different purpose. Rejected because it is a second mechanism that has to be maintained, can drift from the resolver’s real behaviour, and would be strictly weaker than making the resolver itself refuse. Preferring the structural version over the checked version is P3 verbatim.

  • npm with --install-strategy=nested. Reproduces part of the isolation, is not the documented default, and is a flag a single npm install forgets. A property that depends on everyone remembering a flag is the review comment again, wearing a different hat.

  • Yarn (Berry) with PnP. Stricter still, and a larger blast radius: PnP changes module resolution itself, which this client’s Vite/Rolldown, wasm-pack output and node --test harness would all have to be re-validated against. The isolation benefit is the same one pnpm gives; the migration cost is not. Rejected on cost, not on merit.

  • pnpm 12.0.0-rc.1. Refused — see Decision 4.

Consequences (including negative)

  • Every contributor and every CI job now needs pnpm. corepack makes this one command and removes the “which version do you have” class of problem entirely, but it is a new step in a fresh environment and the README now leads with it.

  • A hoisted import that used to work will now fail, and that is the point. @eslint/js was the one in the tree today. There may be others in code not yet written, and they will fail at install-adjacent time rather than in production — which is the trade being bought.

  • node_modules is now a symlink farm. Tools that walk it naively can be surprised. Verified as part of this change: the aliased typescript-nativetypescript@7.0.2 path that pnpm run typecheck invokes as node node_modules/typescript-native/bin/tsc resolves correctly through the symlink. npm install run against a pnpm tree does not merely get refused — it crashes with Cannot read properties of null (reading 'matches') before it reaches any lifecycle script. That is incidental protection and is not what the guard in Enforcement relies on; the guard was tested on a clean tree, where npm does reach it.

  • The lockfile format changed, so this pull request’s diff cannot be read as a dependency review. The dependency set is unchanged except for the one addition (@eslint/js), which is called out separately for exactly that reason.

  • docs/, .claude-rules/ and both READMEs now say pnpm. Historical quotations were deliberately not rewritten: two comments in frontend-ci.yml quote a false claim they are correcting, and that claim really did name npm run e2e:baseline. Rewriting quoted text to match a new tool would falsify a recorded correction (P1).

  • Nothing about the backend, the geometry crate or the wire format changes. No Q-ID is introduced, consumed or invalidated.

Rights-holders (ADR-079)

None. This decision retains no data, adds no processor, and changes no party who handles anything. It is a build-time tool.

Enforcement

Per ADR-045, each normative sentence above names where it is enforced:

  • “Installs with pnpm and with nothing else”tools/check-package-manager.mjs, wired as the client’s preinstall script. It reads npm_config_user_agent and exits non-zero for any tool but pnpm; an absent agent means the script is not in an install and passes. Verified in four states (npm, pnpm, yarn, no agent) and on a clean checkout, where npm install is refused with an actionable message and pnpm install --frozen-lockfile succeeds. This is the load-bearing one, because the property it protects can be destroyed by one command in a working tree with nothing else reporting it.

  • “The version is declared exactly once”packageManager in the client’s package.json, read by corepack locally and by pnpm/action-setup’s package_json_file in CI. The enforcement is the absence of a second declaration; a version: added to that action is the defect to look for.

  • “CI installs from the lockfile”pnpm install --frozen-lockfile in frontend-ci.yml, which fails when package.json and pnpm-lock.yaml disagree.

  • The phantom-dependency property itself needs no separate check: it is enforced by the resolver, which is the entire reason for the change. A missing declaration is a failed install or a failed build, not a lint anyone has to run.

  • pnpm@11.20.0 rather than 12 has no automated enforcement, and that is stated rather than implied. Nothing prevents someone editing the packageManager field to a release candidate. The control is the row in tech-stack-currency.md, which records the version, the reason, and the trigger to re-evaluate — description backed by review, which ADR-045 clause 2 requires be said out loud rather than dressed as a requirement.