Summary
A pacquet vs pnpm lockfile-parity gap in peer resolution node-sharing / same-package shadowing inside dependency cycles. This is the root cause behind the @babel/core peer-suffix over-propagation (~178 lines on the pnpm monorepo) and a related @types/node divergence, and it blocks a clean fix for both. Surfaced while working #12266.
Two observable symptoms
1. @babel/core over-propagation. babel-jest's required @babel/core peer (satisfied by the importer's direct @babel/core) is carried by babel-jest in both pnpm and pacquet, but pacquet bubbles it up into the suffixes of jest-config / @jest/transform / jest-runtime / jest, while pnpm keeps those bare. pnpm absorbs @babel/core at every node that lists it as a regular dependency (e.g. jest-snapshot depends on @babel/core), via its name-keyed bubble gate unknownResolvedPeersOfChildren = allResolvedPeers where !children[alias] (resolvePeers.ts:1065).
2. @types/node node-sharing. @jest/core has a regular dep @types/node. jest-config is a child of both @jest/core and jest-cli. pnpm resolves jest-config once (carrying @types/node from @jest/core's scope) and shares that one node under both parents. pacquet resolves two variants — jest-config(@types/node) under @jest/core and bare jest-config under jest-cli (where @types/node isn't in the ancestor scope) — and jest-cli picks the bare one.
Repro
{ "dependencies": { "jest": "30.4.2", "@babel/core": "7.29.7" } }
# pnpm-workspace.yaml
autoInstallPeers: true
pacquet install --lockfile-only vs pnpm install --lockfile-only (pnpm 11.5.2) → ~101 line diff dominated by spurious (@babel/core@7.29.7) peer suffixes on jest/@jest/*. (On the pnpm monorepo this is ~178 lines.)
Root cause
Both symptoms trace to the same gap in pacquet-resolving-deps-resolver's resolve_peers.rs:
- Bubble gate is NodeId-keyed, not name-keyed. pacquet's
external_from_children / external_to_report filters drop a child's resolved peer only when it matches one of the node's children by NodeId. The babel plugins resolve @babel/core to the importer's @babel/core (same package as jest-snapshot's child ⇒ no shadow swap ⇒ a different NodeId), so the NodeId check misses and the peer over-bubbles. pnpm's gate is name-keyed (!children[alias]), so a node that lists the peer as a regular dependency absorbs it regardless.
- No node sharing across parents. pnpm resolves a node once and reuses its depPath via
pathsByNodeId; find_hit/parentPackagesMatch let it share jest-config(@types/node) across @jest/core and jest-cli. pacquet resolves per-occurrence and creates a bare second variant.
- Same-package shadowing inside cycles. The
@babel/core ↔ @babel/helper-module-transforms cycle is what makes the plugins resolve @babel/core to the importer rather than the closer jest-snapshot child in the first place. A non-cyclic minimal repro (mid → {p, plugin(peer p)}, importer also has p) does not reproduce — pacquet resolves p to mid's own child and absorbs it correctly. So the trigger is cycle-specific.
Why a naive fix isn't enough
Adding the name-keyed (children_map.contains_key(alias)) gate fixes @babel/core on the monorepo (178 → 3 lines; total 2191 → 1866) without a monorepo @types/node regression (there @types/node is a catalog dep, in scope everywhere). But:
- It can't be unit-tested in isolation — the trigger needs the babel cycle structure, not reproducible minimally.
- It regresses the pure-transitive case (jest + autoInstallPeers, no direct
@types/node): pacquet emits a bare jest-config where pnpm shares the @types/node variant.
So the name-keyed gate is a surface patch on the deeper node-sharing/cycle mismatch. The proper fix is to align pacquet's node sharing (find_hit / depPath reuse across parents) and same-package shadowing in cycles with pnpm — after which @babel/core containment falls out naturally and becomes testable.
Scope
pacquet-resolving-deps-resolver: resolve_peers.rs (find_hit, parent_packages_match, the external_from_children / external_to_report bubble filters, and the same-package shadow logic in bump_occurrence_on_shadow).
- Upstream references:
resolvePeers.ts resolvePeersOfChildren / unknownResolvedPeersOfChildren (L1065), findHit (L848), parentPkgsMatch (L768), getPreviouslyResolvedChildren (L930), pathsByNodeId/pathsByNodeIdPromises.
Tracked under the lockfile-parity umbrella #12266. The two already-landed peer fixes (walk-ancestor suffix propagation; optional-peer over-hoist) are in #12267.
Written by an agent (Claude Code, claude-opus-4-8).
Summary
A pacquet vs pnpm lockfile-parity gap in peer resolution node-sharing / same-package shadowing inside dependency cycles. This is the root cause behind the
@babel/corepeer-suffix over-propagation (~178 lines on the pnpm monorepo) and a related@types/nodedivergence, and it blocks a clean fix for both. Surfaced while working #12266.Two observable symptoms
1.
@babel/coreover-propagation.babel-jest's required@babel/corepeer (satisfied by the importer's direct@babel/core) is carried bybabel-jestin both pnpm and pacquet, but pacquet bubbles it up into the suffixes ofjest-config/@jest/transform/jest-runtime/jest, while pnpm keeps those bare. pnpm absorbs@babel/coreat every node that lists it as a regular dependency (e.g.jest-snapshotdepends on@babel/core), via its name-keyed bubble gateunknownResolvedPeersOfChildren = allResolvedPeers where !children[alias](resolvePeers.ts:1065).2.
@types/nodenode-sharing.@jest/corehas a regular dep@types/node.jest-configis a child of both@jest/coreandjest-cli. pnpm resolvesjest-configonce (carrying@types/nodefrom@jest/core's scope) and shares that one node under both parents. pacquet resolves two variants —jest-config(@types/node)under@jest/coreand barejest-configunderjest-cli(where@types/nodeisn't in the ancestor scope) — andjest-clipicks the bare one.Repro
{ "dependencies": { "jest": "30.4.2", "@babel/core": "7.29.7" } }pacquet install --lockfile-onlyvspnpm install --lockfile-only(pnpm 11.5.2) → ~101 line diff dominated by spurious(@babel/core@7.29.7)peer suffixes onjest/@jest/*. (On the pnpm monorepo this is ~178 lines.)Root cause
Both symptoms trace to the same gap in
pacquet-resolving-deps-resolver'sresolve_peers.rs:external_from_children/external_to_reportfilters drop a child's resolved peer only when it matches one of the node's children by NodeId. The babel plugins resolve@babel/coreto the importer's@babel/core(same package asjest-snapshot's child ⇒ no shadow swap ⇒ a different NodeId), so the NodeId check misses and the peer over-bubbles. pnpm's gate is name-keyed (!children[alias]), so a node that lists the peer as a regular dependency absorbs it regardless.pathsByNodeId;find_hit/parentPackagesMatchlet it sharejest-config(@types/node)across@jest/coreandjest-cli. pacquet resolves per-occurrence and creates a bare second variant.@babel/core↔@babel/helper-module-transformscycle is what makes the plugins resolve@babel/coreto the importer rather than the closerjest-snapshotchild in the first place. A non-cyclic minimal repro (mid→{p, plugin(peer p)}, importer also hasp) does not reproduce — pacquet resolvesptomid's own child and absorbs it correctly. So the trigger is cycle-specific.Why a naive fix isn't enough
Adding the name-keyed (
children_map.contains_key(alias)) gate fixes@babel/coreon the monorepo (178 → 3 lines; total 2191 → 1866) without a monorepo@types/noderegression (there@types/nodeis a catalog dep, in scope everywhere). But:@types/node): pacquet emits a barejest-configwhere pnpm shares the@types/nodevariant.So the name-keyed gate is a surface patch on the deeper node-sharing/cycle mismatch. The proper fix is to align pacquet's node sharing (
find_hit/ depPath reuse across parents) and same-package shadowing in cycles with pnpm — after which@babel/corecontainment falls out naturally and becomes testable.Scope
pacquet-resolving-deps-resolver:resolve_peers.rs(find_hit,parent_packages_match, theexternal_from_children/external_to_reportbubble filters, and the same-package shadow logic inbump_occurrence_on_shadow).resolvePeers.tsresolvePeersOfChildren/unknownResolvedPeersOfChildren(L1065),findHit(L848),parentPkgsMatch(L768),getPreviouslyResolvedChildren(L930),pathsByNodeId/pathsByNodeIdPromises.Tracked under the lockfile-parity umbrella #12266. The two already-landed peer fixes (walk-ancestor suffix propagation; optional-peer over-hoist) are in #12267.
Written by an agent (Claude Code, claude-opus-4-8).