fix(deps): guard against Windows→Linux lockfile drift, and close the TODO - #74
Merged
Conversation
… TODO
Closes the long-running lockfile drift TODO by adding the guard it was actually
asking for, and answering the three items it left open.
Background: package-lock.json is authored on Windows and installed by CI on
Linux. npm resolves optional/bundled subtrees per platform, so a Windows-written
lockfile can omit entries `npm ci` on Linux requires — CI then dies at the
install step before any test runs. It broke main twice (2026-05-17 @emnapi/*,
2026-08-21 ajv/64f18f0), and both times it was found a merge later.
Why `npm ci --dry-run` is not the guard
Measured against a known-broken lockfile:
npm ci --dry-run Windows: exit 0 Linux: exit 1
npm ci --dry-run --os=linux --cpu=x64 Windows: exit 0
npm's lock/manifest sync check ignores --os/--cpu, so the drift is undetectable
with `npm ci` from a Windows machine. A hook built on it would pass every time
and still break CI. This is the reason the previous two incidents were only ever
caught downstream.
The check
scripts/check-lockfile.mjs asserts a platform-independent invariant instead: the
lockfile must already be what Linux resolution produces. It relocks a throwaway
copy with `npm install --package-lock-only --os=linux --cpu=x64` and diffs,
reporting the exact node_modules/... keys that moved. Verified idempotent, so a
correct lockfile gives a zero diff. Against the real broken lockfile it names all
six drifted entries — from Windows, where `npm ci --dry-run` reported success.
npm run deps:verify # check
npm run deps:relock # regenerate canonically (also --fix)
Enforced in two places
- .githooks/pre-commit — runs only when package-lock.json is staged. Installed by
the `prepare` script (git config core.hooksPath .githooks), so a fresh clone
picks it up on first npm install. Bypass: git commit --no-verify.
- .github/workflows/test.yml — new `lockfile` job, every push and PR, on Linux.
Authoritative and unskippable. The test job's `npm ci` would also fail on drift,
but with npm's cryptic "Missing: … from lock file" and no remediation.
Verified end-to-end: staging the deduped/broken lockfile blocks the commit and
names the entries; staging the good one passes.
Offline behavior: the check needs the registry. Locally it warns and exits 0 when
npm is unreachable, so an offline commit is not blocked; in CI it fails instead.
Also in this commit
- package-lock.json: one metadata line (fast-deep-equal devOptional -> dev), a
leftover of the bogus hoisted ajv. Makes the lockfile exactly canonical, so the
zero-diff invariant holds.
- .gitattributes: forces LF on .githooks/**. With core.autocrlf=true a Mac/Linux
clone would otherwise get a CRLF shell script, which fails to execute.
- CLAUDE.md: the rule, why a green local `npm ci --dry-run` proves nothing, and a
warning not to run `npm ci` while next dev holds a native .node file open.
- deps-known-issues.md: the durable writeup — both incidents, the measurements,
and why this is not fixable upstream.
Answering the TODO's open items
1. `npm install --include=optional` — moot; superseded by the confirmed recipe.
2. `--os=linux --cpu=x64` — confirmed, and now wrapped in `deps:relock`.
3. Move dep work to Linux/WSL — not needed. The flags make Windows produce a
correct lockfile, and the guard proves it before the commit lands.
5. Are the wasm32-wasi packages removable? No. @tailwindcss/oxide-wasm32-wasi and
@unrs/resolver-binding-wasm32-wasi are optionalDependencies of
@tailwindcss/oxide and unrs-resolver, both cpu:["wasm32"] — transitive, not
ours. Excluding them needs --omit=optional, which would also drop every
platform's native binary. The guard is the fix, not removal.
TODO removed; its content now lives in deps-known-issues.md, which is where state
that outlives a single fix belongs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The `lockfile` CI job failed on its first run while `test` passed on the same
commit — proof the byte-for-byte comparison was wrong, not the lockfile.
CI's node 22 ships npm 10.x; developers here run npm 11.x. The two write
metadata flags (`dev` vs `devOptional`, …) differently, so a byte diff reports
drift that cannot break an install. Concretely: the committed lockfile differs
from npm 10's output by one `fast-deep-equal` flag, and `npm ci` installed it
cleanly in the same workflow run that the byte check rejected. A check that
cries wolf is a check everyone learns to skip.
Now compares tree shape only — which `node_modules/...` entries exist and at
which version — and ignores metadata. That is exactly what `npm ci` validates,
and it is stable across npm versions and platforms.
Verified four ways:
committed lockfile, Windows (npm 11.16) -> pass
committed lockfile, Linux (npm 10.8) -> pass, notes the metadata-only gap
broken lockfile, Windows -> fail, names all 6 entries
broken lockfile, Linux -> fail, "ajv: 6.15.0 -> 8.20.0"
plus missing fast-uri
That last line is npm's own complaint (`Invalid: lock file's ajv@6.15.0 does not
satisfy ajv@8.20.0`, `Missing: fast-uri@3.1.5`) derived independently, which is
good evidence the semantic check tracks what npm actually enforces.
Also adds version-mismatch reporting, which the byte comparison could not produce.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the long-running lockfile drift TODO by adding the guard it was actually asking for, and answering the three items it left open.
Background
package-lock.jsonis authored on Windows and installed by CI on Linux. npm resolves optional/bundled subtrees per platform, so a Windows-written lockfile can omit entriesnpm cion Linux requires — CI then dies at the install step before any test runs.It broke
maintwice, and both times it was found a merge later:npm dedupeon Windows@emnapi/*subtree pruned; fixed by hand64f18f0ajvhoisted;mainred ~45 min; fixed in #72Why
npm ci --dry-runis not the guardThis is the crux, and it's why the previous two incidents were only ever caught downstream. Measured against a known-broken lockfile:
npm ci --dry-runnpm ci --dry-run --os=linux --cpu=x64npm's lock/manifest sync check ignores
--os/--cpu, so the drift is undetectable withnpm cifrom a Windows machine. A pre-commit hook built on it would pass every time and still break CI.The check
scripts/check-lockfile.mjsasserts a platform-independent invariant instead: the lockfile must already be what Linux resolution produces. It relocks a throwaway copy withnpm install --package-lock-only --os=linux --cpu=x64and diffs.Verified idempotent, so a correct lockfile gives a zero diff. Against the real broken lockfile it names all six drifted entries — from Windows, where
npm ci --dry-runreported success:--package-lock-onlynever touchesnode_modules, so both are safe mid-session.Enforced in two places
.githooks/pre-commit— runs only whenpackage-lock.jsonis staged, so a normal commit costs nothing. Installed by thepreparescript (git config core.hooksPath .githooks), so a fresh clone picks it up on firstnpm install. Bypass:git commit --no-verify.lockfileCI job — every push and PR, on Linux. Authoritative and unskippable. Thetestjob'snpm ciwould also fail on drift, but with npm's crypticMissing: … from lock fileand no remediation.Verified end-to-end: staging the deduped/broken lockfile blocks the commit and names the entries; staging the good one passes. This PR's own commit was validated by the hook.
Offline: the check needs the registry. Locally it warns and exits 0 when npm is unreachable (an offline commit isn't blocked); in CI it fails instead.
Also here
package-lock.json— one metadata line (fast-deep-equaldevOptional→dev), a leftover of the bogus hoistedajv. Makes the lockfile exactly canonical so the zero-diff invariant holds..gitattributes— forces LF on.githooks/**. Withcore.autocrlf=truea Mac/Linux clone would otherwise get a CRLF shell script, which fails to execute (/bin/sh^M: bad interpreter).CLAUDE.md— the rule, why a green localnpm ci --dry-runproves nothing, and a warning not to runnpm ciwhilenext devholds a native.nodefile open.deps-known-issues.md— the durable writeup: both incidents, the measurements, and why this isn't fixable upstream.Answering the TODO's open items
npm install --include=optional--os=linux --cpu=x64deps:relock@tailwindcss/oxide-wasm32-wasiand@unrs/resolver-binding-wasm32-wasiareoptionalDependenciesof@tailwindcss/oxideandunrs-resolver, bothcpu:["wasm32"]— transitive, not ours. Excluding them needs--omit=optional, which would also drop every platform's native binary. The guard is the fix, not removalThe TODO is removed; its content now lives in
deps-known-issues.md, which is where state that outlives a single fix belongs.Test plan
tsc --noEmitandeslint .cleanlockfileCI job green on this PR🤖 Generated with Claude Code