Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 0 additions & 80 deletions .claude/TODO/investigate-emnapi-lockfile-drift.md

This file was deleted.

82 changes: 82 additions & 0 deletions .claude/references/deps-known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,88 @@ Last audit: **2026-08-21 (run 2)** — report at `.claude/reports/deps-audit-202
| `chalk` | `^5.6.2` → `^6.0.0` | `npm run setup:check` renders colored output, all 8 checks run | 2026-08-21 |
| `@testing-library/jest-dom` | `^6.9.1` → `^7.0.1` | 279/279 tests pass; `@testing-library/dom@^10.4.1` promoted transitive → explicit `devDependency` as v7 requires | 2026-08-21 |

## Lockfile platform drift (Windows -> Linux CI)

**Resolved 2026-08-21 with a guard. Read this before touching `package-lock.json`.**

`package-lock.json` is authored on Windows and installed by CI on Linux. npm resolves
optional and bundled subtrees per platform, so a lockfile written on Windows can omit
entries `npm ci` on Linux requires. CI then dies at the install step with a cryptic
`Missing: … from lock file`, before any test runs.

It happened twice, and both times reached `main` and were found a merge later:

| Date | Trigger | Damage |
|---|---|---|
| 2026-05-17 | `npm dedupe` on Windows | `@emnapi/*` subtree under `@tailwindcss/oxide-wasm32-wasi` pruned; fixed by hand |
| 2026-08-21 | `64f18f0` "Package Update Cleanup" | `ajv` hoisted to top level; `main` red for ~45 min; fixed in PR #72 |

### The rule

```bash
npm run deps:relock # the ONLY supported way to regenerate the lockfile
npm run deps:verify # check it (runs in CI and in the pre-commit hook)
```

`deps:relock` is `npm install --package-lock-only --os=linux --cpu=x64`. Verified
2026-08-21: it restores every missing nested/bundled entry, prunes nothing, and does not
narrow the lockfile to one platform — platform entry counts were byte-identical before and
after (win32 76, darwin 75, linux-x64 46, android 40). It is idempotent, and
`--package-lock-only` never touches `node_modules`, so it is safe to run mid-session.

**Never** regenerate with a bare `npm install` or `npm dedupe` on Windows. Measured against
the fixed lockfile: a plain `npm install --package-lock-only` is harmless (2 metadata lines),
but `npm dedupe --package-lock-only` re-breaks it in one command — 114 lines, stripping the
nested `eslint/node_modules/ajv` subtrees and re-hoisting `ajv@6.15.0`, reproducing the exact
`64f18f0` failure.

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

Measured 2026-08-21 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 hook built on it would pass every time and still break CI.

`scripts/check-lockfile.mjs` instead asserts an invariant that holds on any platform: *the
lockfile must already be what Linux resolution produces.* It relocks a throwaway copy and
compares. Against the real broken lockfile it names all six drifted entries, from Windows.

The comparison is **semantic, not byte-for-byte** — it compares the tree shape (which
`node_modules/...` entries exist, and at which version) and ignores npm metadata flags.
That matters: CI's node 22 ships npm 10.x while developers here run npm 11.x, and the two
write flags like `dev` vs `devOptional` differently. A byte comparison fails on differences
that cannot break an install — verified 2026-08-21, when a lockfile differing only in one
`fast-deep-equal` flag installed cleanly on CI (`test` job green) while a byte-diff rejected
it. Reporting harmless diffs as failures is how a check gets ignored.

The semantic comparison tracks npm's own validation closely. On the broken lockfile it
reports `ajv: 6.15.0 -> 8.20.0` and a missing `fast-uri`, which is what `npm ci` itself says
(`Invalid: lock file's ajv@6.15.0 does not satisfy ajv@8.20.0`, `Missing: fast-uri@3.1.5`).

### Where it runs

- **pre-commit** — `.githooks/pre-commit`, only when `package-lock.json` is staged.
Auto-installed by the `prepare` script (`git config core.hooksPath .githooks`), so a fresh
clone gets it on first `npm install`. Bypass with `git commit --no-verify`.
- **CI** — the `lockfile` job in `.github/workflows/test.yml`, on every push and PR. This is
the authoritative check; it runs on Linux and cannot be skipped.

Offline behavior: the check needs the registry. Locally it warns and passes when npm is
unreachable (so an offline commit is not blocked); in CI (`process.env.CI`) it fails instead.

### Not fixable upstream

`@tailwindcss/oxide-wasm32-wasi` and `@unrs/resolver-binding-wasm32-wasi` are
`optionalDependencies` of `@tailwindcss/oxide` and `unrs-resolver` respectively, both with
`cpu: ["wasm32"]`. They are transitive and not ours to remove — the only way to exclude them is
`--omit=optional`, which would also drop every platform's native binary. The WASM-fallback
entanglement is inherent to those upstream packages, so the guard is the fix, not removal.

## Open items awaiting a decision (not blockers)

| Item | Detail | Raised |
Expand Down
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Git hooks must keep LF endings. This repo is developed on Windows with
# core.autocrlf=true, which would otherwise check the hook out with CRLF — and a
# shell script with CRLF line endings fails to execute on macOS and Linux
# (`/bin/sh^M: bad interpreter`).
.githooks/** text eol=lf
23 changes: 23 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
#
# Blocks commits that would land a lockfile CI cannot install.
#
# Only runs when package-lock.json is actually staged, so it costs nothing on a
# normal commit. See scripts/check-lockfile.mjs for why `npm ci --dry-run` is
# not sufficient here (it exits 0 on Windows against a lockfile that breaks
# Linux CI).
#
# Installed by the `prepare` script in package.json, which points
# core.hooksPath at this directory. To bypass once: git commit --no-verify

if git diff --cached --name-only --diff-filter=ACM | grep -q '^package-lock\.json$'; then
echo "package-lock.json is staged — checking it against Linux resolution..."
if ! node scripts/check-lockfile.mjs; then
echo ""
echo "Commit blocked. Run 'npm run deps:relock', stage package-lock.json, and retry."
echo "To commit anyway: git commit --no-verify"
exit 1
fi
fi

exit 0
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ on:
branches: [main]

jobs:
# Fails fast and legibly when package-lock.json drifts from what Linux
# resolution produces. The `test` job's `npm ci` would also die on this, but
# with npm's cryptic "Missing: … from lock file" and no remediation. Drift
# generated on Windows is invisible to `npm ci --dry-run` there, so this is the
# authoritative check — see scripts/check-lockfile.mjs.
lockfile:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Check package-lock.json for platform drift
run: node scripts/check-lockfile.mjs

test:
runs-on: ubuntu-latest

Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ Ministry Platform is a shared production database containing real church member
- **Generate MP Types**: `npm run mp:generate:models` (generates TypeScript types + Zod schemas from Ministry Platform API, cleans output directory first)
- **Tests**: `npm test` (Vitest in watch mode), `npm run test:run` (single run), `npm run test:coverage` (with coverage)
- **Setup**: `npm run setup` (interactive project setup wizard), `npm run setup:check` (validate setup without changes)
- **Dependencies**: `npm run deps:relock` (regenerate `package-lock.json` — the only supported way), `npm run deps:verify` (check it for platform drift)

### Dependency Rule — MANDATORY

**Never regenerate `package-lock.json` with a bare `npm install` or `npm dedupe` on Windows.** Use `npm run deps:relock`.

This repo's lockfile is authored on Windows and installed by CI on Linux. npm resolves optional and bundled subtrees per platform, so a Windows-generated lockfile can omit entries `npm ci` on Linux requires — CI then dies at the install step before any test runs. This broke `main` twice (2026-05-17 `@emnapi/*`, 2026-08-21 `ajv`). One `npm dedupe` on Windows is enough to reproduce it.

Critically, **`npm ci --dry-run` cannot detect this on Windows** — it exits 0 there against a lockfile that fails on Linux, and `--os`/`--cpu` do not change that. So a green local check proves nothing; run `npm run deps:verify`, which asserts the lockfile already matches Linux resolution.

A pre-commit hook (`.githooks/pre-commit`, auto-installed via the `prepare` script) and a `lockfile` CI job both enforce this. Full detail: **[Dependency Known Issues](.claude/references/deps-known-issues.md)** § Lockfile platform drift.

Also: do not run `npm ci` while `next dev` is running — it deletes `node_modules` first, then aborts on a locked native `.node` file, leaving the tree half-installed. Stop the dev server first.

### Type Generation Notes

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
"mp:generate:models": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean",
"mp:generate:storedprocs": "tsx src/lib/providers/ministry-platform/scripts/generate-storedprocs.ts",
"setup": "tsx scripts/setup.ts",
"setup:check": "tsx scripts/setup.ts --check"
"setup:check": "tsx scripts/setup.ts --check",
"deps:verify": "node scripts/check-lockfile.mjs",
"deps:relock": "node scripts/check-lockfile.mjs --fix",
"prepare": "git config core.hooksPath .githooks || exit 0"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
Expand Down
Loading
Loading