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.mjseslint.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 cache | 20s |
pnpm install, cold store, 210 packages downloaded, 0 reused | 22s |
The install step’s share of the frontend-ci job | 5s of ~215s — about 2% |
| The job’s actual dominant cost | cargo 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
-
apps/vtt-frontend-clientinstalls with pnpm and with nothing else.package-lock.jsonis deleted;pnpm-lock.yamlis the lockfile and is committed. -
The version is declared exactly once, in the
packageManagerfield (pnpm@11.20.0).corepackprovisions that version locally andpnpm/action-setupreads the same field in CI viapackage_json_file. No workflow names a version, because two declarations are two things that can disagree. -
CI installs with
pnpm install --frozen-lockfile— pnpm’snpm 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. -
pnpm@11.20.0, notpnpm@12. 12.0.0 is atrc.1; §5 question 5 — “is it stable, or is it new?” — andCLAUDE.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 thelatesttag. -
An install driven by anything else is refused, by a dependency-free
preinstallcheck. 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/jsinto 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 ascheck-optimize-deps.mjswalks 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 isP3verbatim. -
npm with
--install-strategy=nested. Reproduces part of the isolation, is not the documented default, and is a flag a singlenpm installforgets. 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 --testharness 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.
corepackmakes 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/jswas 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_modulesis now a symlink farm. Tools that walk it naively can be surprised. Verified as part of this change: the aliasedtypescript-native→typescript@7.0.2path thatpnpm run typecheckinvokes asnode node_modules/typescript-native/bin/tscresolves correctly through the symlink.npm installrun against a pnpm tree does not merely get refused — it crashes withCannot 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 saypnpm. Historical quotations were deliberately not rewritten: two comments infrontend-ci.ymlquote a false claim they are correcting, and that claim really did namenpm 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-IDis 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’spreinstallscript. It readsnpm_config_user_agentand 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, wherenpm installis refused with an actionable message andpnpm install --frozen-lockfilesucceeds. 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” —
packageManagerin the client’spackage.json, read bycorepacklocally and bypnpm/action-setup’spackage_json_filein CI. The enforcement is the absence of a second declaration; aversion:added to that action is the defect to look for. -
“CI installs from the lockfile” —
pnpm install --frozen-lockfileinfrontend-ci.yml, which fails whenpackage.jsonandpnpm-lock.yamldisagree. -
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.0rather than 12 has no automated enforcement, and that is stated rather than implied. Nothing prevents someone editing thepackageManagerfield to a release candidate. The control is the row intech-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.