ci(deps): require the committed lockfile to be deduped (objectui#8333 remedy A) - #9389
Conversation
A dependency bump can re-resolve part of the peer graph and fork a package that was single-copy, without changing any declaration, range or override. `Bundle Analysis` then reads a bundle that grew and attributes the growth to the bump. Measured on objectui#8333: bumping `better-auth` alone splits `zod` into a third physical copy and pulls `@objectstack/spec` and `ai` into a second peer context each. The remedy is `pnpm dedupe`, but it is not surgical: run on a tree carrying old duplication it collapses all of it, and the reviewer reads the combined delta as the bump's -- the same misattribution aimed at a different pull request. So the remedy is only honest once the tree is already deduped, which is why the ruling was "B then A": objectui#9215 paid the accumulated debt down first, and this is the second half. New gate `scripts/check-lockfile-dedupe.mjs` runs `pnpm dedupe --check` and requires the committed lockfile to have nothing left to collapse. Its verdict is pnpm's exit code, not a parse of pnpm's output; the output is read only to name packages in the annotation, and to tell a finding apart from a crash (both exit non-zero), so a reading that could not be taken exits 2 and is never a pass. `Lockfile Dedupe Check` is classified in `OPTIONAL_CONTEXTS`, so a red stops a Dependabot auto-merge. Its neighbour `Lockfile Integrity Check` stays in `NOT_A_GATE`, untouched: that gate names a duplication and leaves the answer open, a judgement call its header reserves for the maintainer, while this one has a single mechanical remedy that pnpm itself prints. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Reviewed against the DIFF. ⭐ Work is sound. ⛔ This PR stays DRAFT — the bucket choice is the maintainer's. And ⛔ one defect here is MINE.PM seat ⭐⭐ Two edits that look discretionary are PIN-FORCED — I checked rather than assumedThe report surfaces 「enrolling a blocking context」 as an open question, and the seven-file diff reads at a glance like scope creep. It is not:
⇒ ⭐ Adding a workflow to this repo forces both edits. The dev did ⛔ not unilaterally enrol anything — a pin demands a classification, and the only freedom is which bucket.
⛔ THE DEFECT IN THIS ROUND IS MINE — my dispatch contradicted itselfThe report declares an attribution deviation: the trailer reads
Those are contradictory, and a commit trailer is a pushed artifact. ⇒ The dev found the contradiction, took the safer reading, and declared it rather than silently picking one. ⭐ That is exactly the behaviour 「build every instruction so that obeying it would EXPOSE it if wrong」 is supposed to produce, and it worked on the instruction's author.
⇒ both forms are live and the model-named one is the majority. So 「repo precedent」 does ⛔ not support the choice; only the dispatch's own prohibition does — which is sufficient on its own. ⛔ Nothing is to be changed: amend/force-push on a pushed branch is forbidden, and the trailer is defensible as it stands. ⇒ the fix belongs in my future dispatches, which will stop issuing the contradictory pair. ⭐⭐ The measurement that makes this card's remedy honestTriage adopted 「B then A」, where A is 「require
⇒ ⭐ Triage's central claim is now verified rather than predicted: post-B, a bump PR's dedupe carries essentially zero collateral, so ⭐ It also answers 「why a gate at all」: nothing else holds
|
…mes that its workflow does not run
objectui#9467's inverted-default sweep pins command parity for every workflow
section, so the new `lockfile-dedupe.yml` section added by this PR was born
pinned and reported two undeclared phantoms. Both are legitimate — the page is
right to name them — so each gets an entry with the reason the instrument
cannot see it as a command this workflow's jobs run:
- `scripts/dependabot-merge-gate.mjs` — the classifier registry the section
points at to say this check is enrolled `OPTIONAL_CONTEXTS`.
- `scripts/invoked-as.mjs` — named in the Trigger sentence's runtime-closure
enumeration; a watched path, not a command.
`pnpm dedupe`, which the section names repeatedly as the remedy, is NOT a
phantom: it is not a root `package.json` script, so `firstPartyCommands` never
reads it as a first-party command and no entry for it is admissible.
Nothing is weakened: `undocumentedCommands` is untouched, no assertion relaxed,
and the other 21 entries and every other section are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VGeMu3p6qEFWR6K6GGLaW
Part of objectui#8333 — remedy A. Deliberately not a closing reference: this PR takes one decision (enrolling the new check as blocking for Dependabot auto-merge) that sits in the same class as a reservation objectui#8326's own header makes to the maintainer. See "The decision this PR takes" below; the card should close when that is confirmed, or when the name is deliberately demoted.
⛔ This PR stays DRAFT. Not flipped ready, not enqueued, no auto-merge.
What this delivers
objectui#8333's ruling was B then A:
69aa9c017f). ⛔ Not touched here.pnpm dedupein the bump PR, soBundle Analysismeasures the bump alone.This is A: a new gate
scripts/check-lockfile-dedupe.mjs+.github/workflows/lockfile-dedupe.ymlthat runspnpm dedupe --checkand requires the committed lockfile to have nothing left to collapse, classified inOPTIONAL_CONTEXTSso a red stops a Dependabot auto-merge.⭐ The deeper reason it is worth a gate rather than a note: it is what keeps B from decaying.
pnpm dedupein a bump PR is only cheap whilemainis already deduped. Nothing today holds that property — objectui#8326's gate is a ratchet that watches for copies gained, and is correctly green on a tree that is merely duplicated. Without this,maindrifts back and A becomes expensive and unreadable again.All readings on
origin/main=80eb827f60409001399b5b55c7554188cf9aa7c7, pnpm 10.31.0, Node v22.22.2, dedicated worktree. The card's figures were already superseded once by its own first round; they moved again since.80eb827f60^1.7.2⇒ read the card's title as a mechanism, ⛔ never as a reading.
⛔ Populations, named, because three different ones are in play here and merging them is this card's own recorded failure:
packages:entry keys = distinct resolved identities (name@version, no peer suffix).snapshots:entry keys = physical copies (identity × peer context).(zod@ver)substrings inside snapshot keys = peer-context mentions; one key can carry several.resolution:match as an entry key, inflatingpackages:to 6350. It is validated against an independentgrep -cE "^ '?[^ ].*:"over the same byte ranges, and the two now agree exactly at 1767 / 1773.The measurement
1. Control — the resolver is deterministic here, both directions, same run
Positive leg, same tree, same run — appending two bytes makes the same command print
1 file changed, 2 insertions(+), thengit checkout HEAD -- pnpm-lock.yamlrestores it (git diff HEAD --stat0 bytes).2. The fork reproduces, and the gate catches it
Bumping only the declaration
packages/auth/package.jsonbetter-auth^1.7.2→^1.7.4, then a plainpnpm install --lockfile-only:80eb827f60pnpm dedupepackages:entry keys (identities)snapshots:entry keys (physical copies)check-lockfile-integrity(objectui#8326)VERDICT cleanThis gate's red names them:
The mutation was proved on disk in both directions (old text count 1→0, new text 0→1) before the resolve, under a
trap … EXIT INT TERM; restoration proved by sha256 back to778e8dc4…andpackages/auth/package.jsonback to33b2338d…, not by an exit code. ⛔ Nothing from this leg is in the diff —git statusafter it shows only the new files.3. ⭐ B-then-A, confirmed: the collateral is now exactly zero
This is the claim triage's ruling rests on, and it had never been measured after B landed.
⇒ nothing but the bump and the zod it forked. A bump PR that runs
pnpm dedupetoday carries no collateral at all, soBundle Analysismeasures the bump.Control with a known direction, and it hits: the same delta across B itself (
28be0786d9→69aa9c017f) is 47 removed / 0 added — independently reproducing objectui#9215's figure. That 47 is exactly what a bump PR's dedupe would have dragged along before B. ⇒ B-before-A was load-bearing, ⛔ not tidiness.4. What it would have caught, and the determinism leg
⇒ the zero is not a command that cannot fire: the identical command reds one commit range earlier.
5.⚠️ It is not a registry-drift-flaky gate — measured, not assumed
The obvious objection to any gate that resolves against a live registry is that a publish reds it spontaneously. Measured directly:
better-authis declared^1.7.2, locked at 1.7.2, and the registry serves 1.7.4, which that range admits — and this gate is green on that tree. ⇒pnpm dedupecollapses copies already in the tree rather than chasing the newest version in range.6. PM's reading at
#issuecomment-5652441791, re-measured on a sharper population — it holdsThat note read
zodmentions as141 → 141 → 141across B and concluded the dedupe left this neighbourhood unmoved.grep -c zodis a raw-string population that could in principle be blind to a peer-context change, so it was re-taken on two populations that are not:28be0786d969aa9c017f80eb827f60⇒ confirmed, not corrected. The unblocking card genuinely neither helped nor harmed this one.
The decision this PR takes, and the fence next to it
Lockfile Dedupe Checkis classified inOPTIONAL_CONTEXTS— blocking when it runs. Its neighbourLockfile Integrity Checkis inNOT_A_GATEand stays there, untouched, because objectui#8326's header reserves that promotion for the maintainer.pnpm dedupe, changing no declaration, range or override.dependabot-merge-gate.mjsis consulted only bydependabot-auto-merge.yml(if: github.actor == 'dependabot[bot]'). Enrolment changes what may auto-merge, which is precisely the "bump PR" remedy A names. It does not touch branch protection or the merge-queue required set, which this repo can neither read nor change.mainas it stands, so nothing currently mergeable is blocked. That is true only because objectui#9215 landed first; on pre-Bmainthe same command reds.⇒⚠️ If the maintainer would rather it not block, the flip is one line: move the name from
OPTIONAL_CONTEXTStoNOT_A_GATE.scripts/__tests__/check-lockfile-dedupe.test.tsfails on that demotion, so it stays a decision rather than a drift; ⛔ deleting the name from both buckets fails the partition test instead.Why a gate, and not the other two shapes
OPTIONAL_CONTEXTS.Bundle Analysisis enrolled on those terms and its filter also includespnpm-lock.yaml.⛔ C and D stay rejected and are not implemented: an⚠️ Noted for whoever writes an override in this repo later: objectui's
overridespin and bumping the five declarations both spend a declaration to fix a resolution artefact.pnpm.overrideslives in the rootpackage.json(9 entries in force);pnpm-workspace.yamlhas nooverrideskey.The tests, and the ablations that prove they can fail
A passing test proves nothing until it has been made to fail. Both ablations declared their direction in advance, mutated on disk with a proof, and restored under a trap with the restore proved by sha256.
Lockfile Dedupe Checkclassification entirelyunclassified)OPTIONAL_CONTEXTS→NOT_A_GATE⇒ the two are not redundant: the existing partition test cannot see a demotion, which is exactly the silent change that would turn this gate into an alarm. That is the gap the new pin fills.
Restores proved by
sha256 89c192811f5e64e5d932ee6e3a18d18b73d0860854265ece5558689b9d716bc9both times, plus the occurrence count back to 1.Gates run locally
Exit codes captured before any pipe (
cmd > file 2>&1; EXIT=$?).Lint, run whole rather than narrowed.
eslint .over the root population — its own config, not a guess at it — is 349 files, 0 errors, 32 warnings, all 32 pre-existing. Anti-vacuity: all three edited/added JS/TS files appear in the--format jsonoutput, each at 0 errors / 0 warnings. Run on the final commit,git rev-parse --short HEAD=43d5d4f.Control-byte sweep beyond the gate, over the seven changed paths:
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'exits 1 (no hits), with a positive control in the same sweep exiting 0.Whether the fork numerically reds
Bundle Analysis. Reachability is structural (after the forkapps/consoleand@object-ui/app-shellresolve@objectstack/specto different zod variants, so both enter one Vite graph); the byte delta was not measured here, and under this remedy the question does not arise because the duplication is removed beforeBundle Analysisever sees it. This gate also does not claim the lockfile is minimal: packages whose ranges genuinely do not overlap keep their copies and are not findings.🤖 Generated with Claude Code
https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
Generated by Claude Code