Skip to content

fix(deps): guard against Windows→Linux lockfile drift, and close the TODO - #74

Merged
chriskehayias merged 2 commits into
mainfrom
fix/lockfile-drift-guard
Aug 21, 2026
Merged

fix(deps): guard against Windows→Linux lockfile drift, and close the TODO#74
chriskehayias merged 2 commits into
mainfrom
fix/lockfile-drift-guard

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

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, and both times it was found a merge later:

Date Trigger Damage
2026-05-17 npm dedupe on Windows @emnapi/* subtree pruned; fixed by hand
2026-08-21 64f18f0 ajv hoisted; main red ~45 min; fixed in #72

Why npm ci --dry-run is not the guard

This is the crux, and it's why the previous two incidents were only ever caught downstream. Measured against a known-broken lockfile:

Command Windows Linux
npm ci --dry-run exit 0 exit 1
npm ci --dry-run --os=linux --cpu=x64 exit 0

npm's lock/manifest sync check ignores --os/--cpu, so the drift is undetectable with npm ci from a Windows machine. A pre-commit hook built on it would pass every time and still break CI.

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.

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:

✗ package-lock.json does not match Linux resolution.
  Missing 4 entries that Linux needs:
    + node_modules/@eslint/eslintrc/node_modules/ajv
    + node_modules/eslint/node_modules/ajv
    …
  Has 2 entries Linux resolution does not produce:
    - node_modules/ajv
  Fix:  npm run deps:relock
npm run deps:verify    # check
npm run deps:relock    # regenerate canonically

--package-lock-only never touches node_modules, so both are safe mid-session.

Enforced in two places

  • .githooks/pre-commit — runs only when package-lock.json is staged, so a normal commit costs nothing. 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.
  • lockfile CI 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. 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-equal devOptionaldev), 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 (/bin/sh^M: bad interpreter).
  • 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 isn't fixable upstream.

Answering the TODO's open items

# Item Resolution
1 npm install --include=optional Moot — superseded by the confirmed recipe
2 --os=linux --cpu=x64 ✅ Confirmed, 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
4 Pre-commit hook / CI guard This PR
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

The TODO is removed; its content now lives in deps-known-issues.md, which is where state that outlives a single fix belongs.

Test plan

  • Guard fails on the real broken lockfile, naming all 6 entries — from Windows
  • Guard passes on the fixed lockfile; relock is idempotent
  • Hook blocks a commit staging a broken lockfile; allows a clean one
  • Hook stored as mode 755 POSIX script with LF endings
  • tsc --noEmit and eslint . clean
  • lockfile CI job green on this PR

🤖 Generated with Claude Code

… 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

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

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>
@chriskehayias
chriskehayias merged commit 7b851f8 into main Aug 21, 2026
2 checks passed
@chriskehayias
chriskehayias deleted the fix/lockfile-drift-guard branch August 21, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant