Skip to content

pacquet: peer-resolution node-sharing / cycle shadowing mismatch (blocks @babel/core suffix containment) #12272

Description

@zkochan

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions