fix(installing.deps-resolver): deterministically order cyclic peer suffixes#11826
Conversation
…ffixes (#8155) `resolveDependencies` was pushing onto `pkgAddresses`, `postponedResolutionsQueue`, and `postponedPeersResolutionQueue` from inside `Promise.all`-spawned callbacks, so the order of items in those arrays reflected completion timing rather than the order of `extendedWantedDeps`. That ordering then flowed downstream into `resolvePeers` and the cyclic-peer suffix assignment, so two packages with transitive peer dependencies on each other (e.g. `@aws-sdk/client-sts` and `@aws-sdk/client-sso-oidc`) flipped between two equally-valid lockfile forms across consecutive installs. The fix awaits `Promise.all` to a temporary array and drains it with `for…of` so the per-edge results land in input order. This matches the existing pattern 200 lines earlier in `resolveDependenciesOfImporters`. End-to-end repro from the issue (`pnpm add @aws-sdk/client-s3@3.588.0` then loop `pnpm dedupe --check`): 33/50 failures without the fix → 0/100 with it. --- Written by an agent (Claude Code, claude-opus-4-7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
💖 Thanks for opening this pull request! 💖 |
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📜 Recent review details⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
🧰 Additional context used📓 Path-based instructions (1)**/*.{ts,tsx}📄 CodeRabbit inference engine (AGENTS.md)
Files:
🧠 Learnings (1)📚 Learning: 2026-05-14T09:04:00.133ZApplied to files:
🔇 Additional comments (1)
📝 WalkthroughWalkthroughReorders accumulation in resolveDependencies to collect parallel dependency resolutions and drain them in deterministic input order; adds a regression test exercising cyclic transitive peer dependencies and a changeset noting the fix (addresses issue ChangesCyclic Peer Resolution Determinism Fix
Sequence Diagram(s)sequenceDiagram
participant Client as Installer
participant Resolver as resolveDependencies
participant Child as resolveDependenciesOfDependency
participant Accumulators as pkgAddresses/postponedQueues
Client->>Resolver: request dependency resolution
Resolver->>Child: spawn parallel resolveDependenciesOfDependency per dep
Child-->>Resolver: return resolution result
Resolver->>Accumulators: drain collected results in input order to update pkgAddresses/postponedQueues
Resolver-->>Client: resolved dependency graph (deterministic)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@installing/deps-installer/test/install/cyclicPeerDeterminism.ts`:
- Line 109: The test currently masks ordering by sorting snapshot keys—replace
the sorted retrieval with a raw key list to preserve insertion/completion order:
change the use of snapshotKeys (currently assigned via
Object.keys(lockfile.snapshots ?? {}).sort()) to use
Object.keys(lockfile.snapshots ?? {}) without .sort() so the determinism test
can detect completion-order regressions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ea86d746-ccff-44ca-9c79-5726b9e75bca
📒 Files selected for processing (3)
.changeset/fix-cyclic-peer-determinism.mdinstalling/deps-installer/test/install/cyclicPeerDeterminism.tsinstalling/deps-resolver/src/resolveDependencies.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Compile & Lint
- GitHub Check: Analyze (javascript)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Use trailing commas in TypeScript code
Prefer functions over classes in TypeScript code
Declare functions after they are used, relying on function hoisting in TypeScript
Functions should have no more than two or three arguments; use a single options object instead for multiple parameters in TypeScript
Follow import order in TypeScript: 1) standard libraries, 2) external dependencies (alphabetically sorted), 3) relative imports
Write self-explanatory code in TypeScript; avoid comments that restate what the code already says. Use comments only for non-obvious reasons, hidden invariants, or workarounds
Use JSDoc for function contracts (preconditions, postconditions, edge cases), not for re-narrating the function body in TypeScript
Files:
installing/deps-installer/test/install/cyclicPeerDeterminism.tsinstalling/deps-resolver/src/resolveDependencies.ts
🧠 Learnings (1)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.
Applied to files:
installing/deps-installer/test/install/cyclicPeerDeterminism.tsinstalling/deps-resolver/src/resolveDependencies.ts
🪛 GitHub Check: CodeQL
installing/deps-installer/test/install/cyclicPeerDeterminism.ts
[failure] 88-88: Incomplete string escaping or encoding
This replaces only the first occurrence of '/'.
🔇 Additional comments (3)
installing/deps-resolver/src/resolveDependencies.ts (1)
686-708: LGTM!installing/deps-installer/test/install/cyclicPeerDeterminism.ts (1)
1-108: LGTM!Also applies to: 110-120
.changeset/fix-cyclic-peer-determinism.md (1)
1-7: LGTM!
… path
Addresses CodeQL incomplete-string-escaping finding: `replace('/', '%2F')`
only swaps the first occurrence. Scoped names in this test only have one
slash so the behavior is unchanged, but switching to `replaceAll` clears
the warning and is more defensible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
installing/deps-installer/test/install/cyclicPeerDeterminism.ts (1)
109-109:⚠️ Potential issue | 🟠 Major | ⚡ Quick winSorting snapshot keys weakens the determinism signal.
.sort()collapses any insertion-order differences into a single canonical sequence, so a regression that reintroduces completion-order leakage (without changing which keys exist) would still pass this test. Drop the.sort()to retain the ordering signal this test is designed to catch.Proposed fix
- const snapshotKeys = Object.keys(lockfile.snapshots ?? {}).sort() + const snapshotKeys = Object.keys(lockfile.snapshots ?? {})🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@installing/deps-installer/test/install/cyclicPeerDeterminism.ts` at line 109, The test currently masks insertion-order differences by sorting snapshot keys; locate where snapshotKeys is computed (the const snapshotKeys = Object.keys(lockfile.snapshots ?? {}).sort() assignment) and remove the .sort() call so snapshotKeys = Object.keys(lockfile.snapshots ?? {}), preserving original ordering and allowing the test to detect completion-order leakage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@installing/deps-installer/test/install/cyclicPeerDeterminism.ts`:
- Line 109: The test currently masks insertion-order differences by sorting
snapshot keys; locate where snapshotKeys is computed (the const snapshotKeys =
Object.keys(lockfile.snapshots ?? {}).sort() assignment) and remove the .sort()
call so snapshotKeys = Object.keys(lockfile.snapshots ?? {}), preserving
original ordering and allowing the test to detect completion-order leakage.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 506481f6-0719-4588-96e7-da4f47065bc0
📒 Files selected for processing (1)
installing/deps-installer/test/install/cyclicPeerDeterminism.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Compile & Lint
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Use trailing commas in TypeScript code
Prefer functions over classes in TypeScript code
Declare functions after they are used, relying on function hoisting in TypeScript
Functions should have no more than two or three arguments; use a single options object instead for multiple parameters in TypeScript
Follow import order in TypeScript: 1) standard libraries, 2) external dependencies (alphabetically sorted), 3) relative imports
Write self-explanatory code in TypeScript; avoid comments that restate what the code already says. Use comments only for non-obvious reasons, hidden invariants, or workarounds
Use JSDoc for function contracts (preconditions, postconditions, edge cases), not for re-narrating the function body in TypeScript
Files:
installing/deps-installer/test/install/cyclicPeerDeterminism.ts
🧠 Learnings (1)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.
Applied to files:
installing/deps-installer/test/install/cyclicPeerDeterminism.ts
Removed the .sort() applied to the lockfile snapshot keys in the cyclic peer determinism test so the comparison reflects the actual order emitted by the lockfile writer. The deterministic ordering guaranteed by 7577d47 makes the sorted view and the raw view identical today; dropping the sort lets the test fail on any future regression that keeps the key set stable but shuffles the order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ect literal MutatedProject does not carry a manifest field; it is conveyed via allProjects in MutateModulesOptions. Passing it inside the install project literal triggered TS2353 against the InstallDepsMutation shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clearer name for the map keyed by package name, and avoids tripping cspell on the abbreviation "metas". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ubuntu Node.js 22.13.0 hit a transient 404 from Verdaccio's proxy-to-npm while resolving a transitive peer of @medusajs/medusa-js@6.1.7 in the pre-existing "install should not hang on circular peer dependencies" test (installing/deps-installer/test/install/misc.ts:1247). Ubuntu Node 24 and Node 26 ran the same code green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Congrats on merging your first pull request! 🎉🎉🎉 |
Resolved two conflicts against upstream changes in `perf(pacquet): close the warm-cache resolve gap to pnpm CLI` (#11837) and `fix(installing.deps-resolver): deterministically order cyclic peer suffixes` (#11826): - `package-manager/src/install_with_fresh_lockfile.rs`: main dropped the `enable_global_virtual_store` conditional and now builds the lockfile unconditionally (renamed `layout_lockfile` → `built_lockfile`, added a tracing span). The clippy `if_then_some_else_none` rewrite this branch added to that block is no longer applicable — took main's version. - `resolving-deps-resolver/src/resolve_peers.rs` (two sites): main changed `NodeId` from a `Copy` newtype to an `enum { Counter(u64), Leaf(Arc<str>) }`, so `Some(node_id)` had to become `Some(node_id.clone())`. Combined with this branch's `if_then_some_else_none` rewrite of the sibling `alias` field. The new `node_id.clone()` does not trip `clippy::clone_on_ref_ptr` because `NodeId` is an enum, not an `Arc`/`Rc`/`Weak`. `engine-runtime-node-resolver/src/node_resolver.rs` and `resolving-npm-resolver/src/resolve_from_workspace.rs` auto-merged; the clippy fixes on those lines survived intact. Verified locally: `cargo clippy --locked --workspace --all-targets -- --deny warnings`, `cargo fmt --check`, `taplo format --check`.
* Upgrade pnpm to 11.3.0 to unblock dedupe --check in CI pnpm 9.1.1–11.2.x had a non-deterministic dedupe pass that failed ~50% of the time on identical input, so the workspace was carrying a commented-out `pnpm dedupe --check` step. 11.3.0 ships the fix (pnpm/pnpm#11826, pnpm/pnpm#8155), so this restores the check and brings every project onto a single version via corepack. Also performs the v11 config migration the release demands: settings other than auth/registry move out of .npmrc and into pnpm-workspace.yaml, and package.json#pnpm overrides move there too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Upgrade deps * Upgrade deps * Restore eslint/prettier public-hoist after pnpm 10 default change pnpm 10 changed the default `publicHoistPattern` from `['*eslint*', '*prettier*']` to `[]`. That broke `apps/mcp-server/eslint.config.ts`, which imports `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, and `eslint-plugin-react-hooks` directly from a sibling workspace package's transitive deps — it was relying on the old hoist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Bump claude container memory to 8g The 4g cap was OOM-killing parallel `tsc` workers during `pnpm typecheck`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Format pnpm-workspace.yaml with prettier Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Enable injectWorkspacePackages for pnpm v10+ deploy pnpm v10+ refuses `pnpm deploy` from a non-injected workspace, breaking the flex-ui Docker builds. Enable injectWorkspacePackages so the deploy step in flex-ui/Dockerfile works without falling back to legacy mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Upgrade deps * Pin admin react-day-picker to v8 to match @flex/ui injectWorkspacePackages (enabled for pnpm deploy) installs @flex/ui as file: instead of link:, so its peer deps now resolve from the consumer. Admin declared react-day-picker@^9.14.0, which made @flex/ui/calendar.tsx type-check against v9 and fail on IconLeft/IconRight (renamed to Chevron in v9). Admin only uses the DateRange type, so downgrading to ^8.10.1 to match dashboard and @flex/ui is the smallest correct fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert injectWorkspacePackages; pass --legacy to pnpm deploy Enabling injectWorkspacePackages hard-copies internal workspace packages into node_modules. Every package under flex-ui/packages/ exports raw .ts(x) source, so once injected those .ts files live inside node_modules and the toolchain rejects them: - Node's native type stripping fails with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING (breaks vitest config loading and Playwright via pirates) - Turbopack errors with "Unknown module type" for every .ts(x) - tsc / next build OOM after traversing the injected sources Revert injectWorkspacePackages from pnpm-workspace.yaml and pass --legacy to pnpm deploy in the Dockerfile to preserve the v10+ deploy behavior the injection setting was meant to enable. Tracked in CON-701 to migrate workspace packages to compiled dist output and re-enable injection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Raise Node heap on Check - Flex UI to fix dedupe OOM `pnpm dedupe --check` hits the V8 ~2 GB old-space ceiling while resolving the workspace on blacksmith-2vcpu runners. Set NODE_OPTIONS at the job level so typecheck and lint get the same headroom. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Disable pnpm 11 deps verify in flex-ui Docker builder `pnpm build` aborted with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY because pnpm 11's verify-deps-before-run saw the workspace-protocol package.json copied in after `pnpm deploy --legacy` and tried to purge an already-correct node_modules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Disable pnpm 11 deps verify in flex-ui Docker test stage The Playwright test image copies only /opt/flex/flex-ui from the deps stage, so the `../flex-ai` entry in pnpm-workspace.yaml is unresolvable at container start. pnpm 11's verify-deps-before-run fires on `pnpm exec playwright test` and aborts with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for `@flex/ai@workspace:*`. Mirror the builder stage and skip the check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update Woo deps * Bump pnpm 11.3.0 -> 11.4.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Bump pnpm 11.4.0 -> 11.5.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Upgrade Woo deps * Run prettier on pnpm-workspace.yaml Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix wasm * Fix dedupe * Bump pnpm to 11.5.1 Picks up the v11.5.1 patch fixes — most notably preserving `integrity:` on remote-tarball lockfile entries when entries are rebuilt (the v11.4.0 hardening regression), normalized repository field for publishes, faster `pnpm audit`, and peer-resolution / workspace-state-cache fixes. Release notes: https://github.com/pnpm/pnpm/releases/tag/v11.5.1 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Dedupe * Upgrade deps --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Fixes #8155 —
pnpm dedupeandpnpm installproduce a different lockfile on consecutive runs when two transitive dependencies declare each other as peer dependencies (e.g.@aws-sdk/client-sts↔@aws-sdk/client-sso-oidc) andauto-install-peersis enabled.The bug
Run the reproduction from the original issue —
pnpm add @aws-sdk/client-s3@3.588.0, then looppnpm dedupe --check— and the lockfile flip-flops between two equally-valid forms:On a CI pipeline that runs
pnpm dedupe --check, this fails roughly half the time. The issue has been open since May 2024 and reproduces on every released version from 9.1.1 through 11.2.2 (reporter's own confirmation timeline and most recent independent confirmation on 10.17.1).A commenter on the issue traced the regression to #8058 (perf change to peers caching, shipped in 9.1.1), and another commenter noted that
auto-install-peers = falsemakes the issue go away.The earlier non-determinism fix in #11110 addresses a different scenario (#10626: newly-published registry versions affecting dedupe), not this one.
Root cause
installing/deps-resolver/src/resolveDependencies.ts:686-708was using aPromise.allwithpushinside the callback:Promise.allruns the callbacks concurrently; pushing the result from inside each callback means the order of items inpkgAddresses/postponedResolutionsQueue/postponedPeersResolutionQueueis the order ofresolveDependenciesOfDependencycompletion, not the order ofextendedWantedDeps. (MDN reference forPromise.allorder semantics — the result array preserves input order, but side-effects inside the callbacks do not.)That completion-order ordering then flows downstream:
pkgAddressesbecomes the seed forcurrentParentPkgAliasesandparentPkgAliases(line 711).resolvePeersviastartResolvingPeers, which builds themissingPeersset whose iteration order then drives the hoist loop inresolveRootDependencies.resolvePeersOfChildrenwalks children in that order, andcalculateDepPathininstalling/deps-resolver/src/resolvePeers.ts:553-587breaks the cycle by collapsing the second cyclic peer it visits to a suffix-lessname@version. Whichever side of the cycle is processed first gets the full(other@version)suffix; the other side gets the bare form. So when timing jitter flips the processing order, the lockfile flips.The existing two-circular-peers test at
peerDependencies.ts:1832doesn't exhibit the bug because both packages there are direct deps of the root project, so the hoist loop never re-runs and the cycle never travels through the racy code path. The bug specifically requires the cycle members to be discovered via auto-install-peers hoisting.The exact "await
Promise.allto a temporary, thenfor…ofover it" pattern is already used 200 lines earlier inresolveDependenciesOfImporters—resolveDependencieswas the one place that diverged from it.The fix
One structural change: drain the
Promise.allresults in a separatefor…ofso they land in input order.That's it — no behavioral change in resolution, just removing the order leak.
Promise.all's contract guarantees the result array is in input order;for…ofpreserves that.Empirical validation
End-to-end reproduction from the issue, bundled with
pnpm/dist/pnpm.mjsbuilt from this branch:pnpm dedupe --checkfailurespnpm@11.2.2(unfixed)Regression test
Added
installing/deps-installer/test/install/cyclicPeerDeterminism.ts. It uses@pnpm/testing.mock-agent(same pattern as theprereleaseWeights.tsregression test for #10626) to mock three fake packages — a wrapper that pulls in two transitive deps that peer each other — and runsinstall30 times, asserting the lockfile snapshot keys are byte-identical across runs.Why pacquet does not have the same problem
I audited the pacquet port to check whether the same fix needs to land there. It doesn't — pacquet's analogous code already preserves input order by construction:
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs:270-283(extend_tree) andL395-L421(resolve_node) parallelise children viafuture::try_join_all, whose contract is "preserves the input order" — the Rust idiom returns values from each future and collects them, rather thanpush-ing from inside the future, which structurally rules out the JS race.pacquet/crates/resolving-deps-resolver/src/resolve_importer.rscollectsmissing_requiredfrom aBTreeMap, and hoisted peers come fromhoist_peerswhich also returns aBTreeMap— both ordered alphabetically by key, per the Rust stdlib contract.So pacquet's order is twice-canonical (input-preserving + lexicographic on hoist) where pnpm's was completion-order. No pacquet-side change needed.
Test plan
'2 circular peers','3 circular peers','3 circular peers in workspace root', and'resolves complex circular deps'tests inpeerDependencies.ts— they should still pass.cyclicPeerDeterminism.tsregression test — should be green.pnpm add @aws-sdk/client-s3@3.588.0, then looppnpm dedupe --check) succeeds every iteration on a build from this branch.Written by an agent (Claude Code, claude-opus-4-7).
Summary by CodeRabbit