Skip to content

fix(resolver): stabilize transitivePeerDependencies in dependency cycles#12286

Merged
zkochan merged 18 commits into
pnpm:mainfrom
aqeelat:fix/transitive-peer-deps-proceedall-cascade
Jun 16, 2026
Merged

fix(resolver): stabilize transitivePeerDependencies in dependency cycles#12286
zkochan merged 18 commits into
pnpm:mainfrom
aqeelat:fix/transitive-peer-deps-proceedall-cascade

Conversation

@aqeelat

@aqeelat aqeelat commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes lockfile churn where a package's transitivePeerDependencies (e.g. supports-color via debug) could be dropped — and shift between packages — when the package participates in a dependency cycle. Which packages carry a given transitive peer depended on resolution order, so upgrading an unrelated dependency churned the lockfile.

Root cause

When peer resolution walks into a cycle, the cycle is broken by dropping the repeated package's subtree, so the re-entry occurrence resolves against truncated children and looks peer-free. That occurrence was then recorded as "pure" in purePkgs — a verdict keyed by package id, not by context. A later occurrence of the same package, reached through a different parent that can see the full subtree, hit the purePkgs short-circuit and returned an empty peer set, dropping the transitive peers it should have surfaced. Because the outcome depends on which occurrence is walked first, it was order-dependent.

Fix

Don't record a cycle re-entry's resolution in purePkgs / peersCache (a re-entry is detected when the package id already appears in the ancestor chain). Its truncated peer sets aren't authoritative for the package as a whole, so leaving the caches untouched lets later occurrences resolve correctly — or reuse the package's authoritative, non-truncated entry via findHit. This is a minimal guard at the cache-population site: it adds no post-resolution pass and does not change transitivePeerDependencies for packages that aren't in cycles.

This PR also includes an independent fix: when collecting peer providers from a node's children, match each child's resolved package name in addition to its alias, so pnpm add my-alias@npm:real-pkg is visible to peer resolution when real-pkg is a peer dependency name.

Both the TypeScript pnpm CLI and the Rust (pacquet) port are updated in parity.

Related issues

This addresses the transitivePeerDependencies-churn class of bug. It is relevant to the churn reported in #5108 (which also shows separate peer-suffix-hash churn that this PR does not address) and to the transitivePeerDependencies-instability aspect of #5552 and #5794. It does not address the out-of-scope version drift in #9992, which is a separate problem. These issues are referenced rather than auto-closed, since the fix targets the underlying mechanism rather than each issue's specific reproduction.

Test plan

  • New integration reproduction in installing/deps-installer/test/install/peerDependencies.ts, backed by a cyclic fixture: fails without the fix (a sibling package loses its optional transitive peer), passes with it.
  • Resolver-level regression guards on both stacks. The pacquet unit test fails 8/8 without the fix and passes 12/12 with it (the bug is order-dependent).
  • Full resolver suites pass: 113/113 (TypeScript @pnpm/installing.deps-resolver), 143/143 (pacquet resolving-deps-resolver); cargo clippy and rustfmt clean.

Written by an agent (Claude Code, claude-opus-4-8).

@aqeelat aqeelat requested a review from zkochan as a code owner June 9, 2026 12:38
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (9) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Grey Divider


Action required

1. Aliased deps bypass TPD filter 🐞 Bug ≡ Correctness
Description
In resolvePeers(), propagation skips adding a transitive peer only when parentEntry.children[tpd]
exists, but children is keyed by dependency alias, not necessarily the peer/package name. With npm
aliases (alias != resolvedPackage.name), this can incorrectly propagate an unnecessary
transitivePeerDependencies entry into parents, impacting lockfile contents and downstream
resolution/hoisting decisions.
Code

installing/deps-resolver/src/resolvePeers.ts[R254-257]

+      for (const tpd of childEntry.transitivePeerDependencies) {
+        if (!parentEntry.transitivePeerDependencies.has(tpd) && !parentEntry.peerDependencies?.[tpd] && !parentEntry.children?.[tpd]) {
+          parentEntry.transitivePeerDependencies.add(tpd)
+          gained = true
Evidence
The propagation filter uses children[tpd], but children is keyed by alias, and the resolver
explicitly supports alias != resolved package name; therefore direct aliased children won’t be
detected by this guard. Extra TPDs are persisted and later interpreted as peer constraints / as a
signal to force deeper resolution, so incorrect additions have real behavioral impact.

installing/deps-resolver/src/resolvePeers.ts[185-193]
installing/deps-resolver/src/resolvePeers.ts[1245-1261]
installing/deps-resolver/src/resolvePeers.ts[224-260]
installing/deps-resolver/src/resolveDependencies.ts[1156-1166]
installing/linking/real-hoist/src/index.ts[173-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`resolvePeers()` propagates `transitivePeerDependencies` upward using this guard:
- `!parentEntry.children?.[tpd]`
However, `children` is keyed by **alias**, while `tpd` entries are **peer/package names**. When a dependency is installed via npm alias, the alias key differs from the resolved package name, so the guard can fail even if the parent already has a direct child that satisfies that peer.
This can cause extra `transitivePeerDependencies` to be written to the lockfile, and those entries are later treated as peer constraints during hoisting/resolution.
### Issue Context
- `children` map is built from `childrenNodeIds` where keys are aliases.
- The resolver explicitly models that alias may differ from `resolvedPackage.name`.
### Fix Focus Areas
- installing/deps-resolver/src/resolvePeers.ts[224-263]
- installing/deps-resolver/src/resolvePeers.ts[185-193]
- installing/deps-resolver/src/resolvePeers.ts[1245-1261]
### Implementation direction
1. Replace the `!parentEntry.children?.[tpd]` check with a check that the parent already provides `tpd` as either:
- a direct child alias matching `tpd`, **or**
- a direct child whose **resolved package name** matches `tpd`.
Example approach (keep it efficient):
- Precompute for each node a `providedNames: Set<string>` that includes:
 - all child aliases (keys of `children`)
 - all child resolved names (`depGraphWithResolvedChildren[childPath]?.name`)
- Then guard with `!providedNames.has(tpd)`.
2. Add/extend a unit/integration test covering propagation where the parent provides the peer via an npm alias (alias != real name) to ensure TPD is not redundantly propagated.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Test not actually ignored 🐞 Bug ☼ Reliability
Description
compatible_existing_peer_contexts_survive_writable_lockfile_regeneration is documented as
“Ignored” due to a known lockfile-reuse bug, but it is still annotated with #[test] and has no
#[ignore], so it will run in CI and can fail the suite. This contradicts the comment and
undermines test-suite reliability.
Code

pacquet/crates/cli/tests/install.rs[R865-874]

+/// Ignored: the TPD propagation fix (pnpm/pnpm#12286) correctly adds
+/// `@pnpm.e2e/peer-c` to `abc-grand-parent-with-c`'s transitive peer
+/// deps. The lockfile now records this TPD, and on the second install
+/// pacquet's lockfile-reuse path merges the nested `peer-c@1.x`
+/// context into the root `peer-c@2.0.0` context instead of preserving
+/// both. This is a pacquet lockfile-reuse bug exposed by correct TPD,
+/// not a bug in the propagation itself. The fix belongs in the reuse
+/// path, not in the propagation.
#[test]
fn compatible_existing_peer_contexts_survive_writable_lockfile_regeneration() {
Evidence
The test’s doc comment explicitly says it is ignored, but the code only has #[test] (no
#[ignore]). The same file contains another test that is correctly ignored with `#[ignore =
"..."]`, showing the expected mechanism.

pacquet/crates/cli/tests/install.rs[865-874]
pacquet/crates/cli/tests/install.rs[140-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A pacquet CLI integration test is described as ignored (due to a known lockfile-reuse bug), but it is still enabled as a normal `#[test]` with no `#[ignore]` attribute. This will keep executing in CI and may fail, blocking merges.
### Issue Context
The file already demonstrates the intended pattern for ignoring flaky/known-bad tests using `#[ignore = "..."]` above `#[test]`.
### Fix Focus Areas
- pacquet/crates/cli/tests/install.rs[865-874]
- pacquet/crates/cli/tests/install.rs[140-147]
### Expected fix
Add `#[ignore = "…reason…"]` above `#[test]` for `compatible_existing_peer_contexts_survive_writable_lockfile_regeneration`, or remove/update the “Ignored” comment if the test is intended to run and is now passing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Rust TPD hides real deps 🐞 Bug ≡ Correctness
Description
In pacquet, propagate_transitive_peer_dependencies()/resolvePeers() can propagate a child’s
transitive peer dependency alias into a parent even when that alias already exists as a direct child
dependency on the parent, misclassifying a real dependency edge as a transitive peer. Because
lockfile reuse and hoisting treat transitivePeerDependencies specially, this collision can change
peer/hoist constraints and even cause snapshot-based reconstruction to drop the direct dependency
alias.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[R389-399]

+                    let diff: Vec<String> = child
+                        .transitive_peer_dependencies
+                        .iter()
+                        .filter(|tpd| {
+                            !parent.transitive_peer_dependencies.contains(*tpd)
+                                && !parent.peer_dependencies.contains_key(*tpd)
+                        })
+                        .cloned()
+                        .collect();
+                    if diff.is_empty() { None } else { Some((parent_path.clone(), diff)) }
+                })
Evidence
The cited propagation logic only guards against adding aliases already in the parent’s transitive
peer set and/or declared peer_dependencies, but it does not check whether the alias is already
present as a direct child dependency in the parent’s children map. In contrast, earlier
peer-resolution logic explicitly enforces an invariant that excludes aliases that are direct
children when handling “unknown resolved peers,” showing the intended constraint exists elsewhere
but is missing from the new propagation loop. Downstream, snapshot_child_refs() explicitly filters
out any alias listed in peer_dependencies or transitive_peer_dependencies when reconstructing child
edges during lockfile reuse, so if propagation erroneously adds a real direct dependency alias into
transitive_peer_dependencies, reuse can omit that dependency; similarly, the hoister treats
transitivePeerDependencies entries as peer names, so misclassification affects hoisting/peer-name
behavior and lockfile semantics.

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[360-410]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1503-1539]
installing/deps-resolver/src/resolvePeers.ts[185-225]
installing/deps-resolver/src/resolvePeers.ts[1107-1113]
installing/linking/real-hoist/src/index.ts[179-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The child-to-parent propagation of `transitivePeerDependencies` (Rust: `propagate_transitive_peer_dependencies()` in peer resolution; TS: `resolvePeers()` propagation pass) does not skip aliases that are already present as *direct child dependencies* of the parent (`parent.children` / `parentEntry.children`). This can incorrectly classify a real direct dependency alias as a transitive peer, which then affects hoisting/peer-name constraints and can cause lockfile reuse to drop the dependency because the reuse path filters out anything listed in `transitive_peer_dependencies`.
## Issue Context
- The propagation filter/condition currently checks for existing transitive-peer entries and declared `peer_dependencies`, but it does not check whether the alias already exists in the parent’s direct `children` map.
- Elsewhere in peer resolution (e.g., resolving peers of children / “unknown resolved peers”), the logic already avoids bubbling peers that are direct children (`if (!children[alias]) ...`), indicating the invariant that direct children should not be reclassified as transitive peers.
- In the lockfile reuse path, `snapshot_child_refs()` reconstructs dependencies from snapshot maps while excluding any alias in `peer_dependencies` or `transitive_peer_dependencies`, so an incorrect propagation collision can silently remove a real dependency edge during reuse.
## Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[360-410]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1503-1539]
- installing/deps-resolver/src/resolvePeers.ts[185-225]
- installing/deps-resolver/src/resolvePeers.ts[1107-1113]
## Suggested change
- Rust: In the `filter(|tpd| ...)` (or equivalent) predicate that decides whether to propagate a `tpd` into the parent, also ensure the parent does **not** already have that alias in `parent.children` (e.g., add a `!parent.children.contains_key(*tpd)` guard).
- TypeScript: In the propagation condition, also skip propagation when the parent already has that alias as a direct child dependency (use an own-property check like `Object.prototype.hasOwnProperty.call(parentEntry.children, tpd)` rather than truthiness).
- Keep existing guards (do not add if it is already in the parent’s `transitivePeerDependencies` and/or is a declared `peerDependency`), but add the missing `children`-presence check to preserve the invariant and prevent lockfile reuse/hoisting misbehavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Propagation before graph rewrites 🐞 Bug ≡ Correctness
Description
In resolvePeers(), transitive peer dependency propagation runs on the pre-dedupe graph even though
dedupePeerDependents/dedupe_peer_dependents can later rewrite node.children depPaths and prune
variants, so the propagated values may not reflect the final deduped graph. This can leave some
nodes with stale or missing transitivePeerDependencies/transitive_peer_dependencies that are then
written to or consumed from the lockfile and can affect hoisting/peer sets.
Code

installing/deps-resolver/src/resolvePeers.ts[R185-225]

+  const parentsOf: Record<string, Set<DepPath>> = {}
+  for (const [parentPath, entry] of Object.entries(depGraphWithResolvedChildren)) {
+    for (const childPath of Object.values<DepPath>(entry.children)) {
+      if (!parentsOf[childPath]) {
+        parentsOf[childPath] = new Set()
+      }
+      parentsOf[childPath].add(parentPath as DepPath)
+    }
+  }
+
+  const worklist = new Set<DepPath>()
+  for (const [path, entry] of Object.entries(depGraphWithResolvedChildren)) {
+    if (entry.transitivePeerDependencies.size > 0) {
+      worklist.add(path as DepPath)
+    }
+  }
+
+  while (worklist.size > 0) {
+    const childPath = worklist.values().next().value!
+    worklist.delete(childPath)
+    const childEntry = depGraphWithResolvedChildren[childPath]
+    if (!childEntry?.transitivePeerDependencies.size) continue
+
+    const parents = parentsOf[childPath]
+    if (!parents) continue
+
+    for (const parentPath of parents) {
+      const parentEntry = depGraphWithResolvedChildren[parentPath]
+      if (!parentEntry) continue
+      let gained = false
+      for (const tpd of childEntry.transitivePeerDependencies) {
+        if (!parentEntry.transitivePeerDependencies.has(tpd) && !parentEntry.peerDependencies?.[tpd]) {
+          parentEntry.transitivePeerDependencies.add(tpd)
+          gained = true
+        }
+      }
+      if (gained) {
+        worklist.add(parentPath)
+      }
+    }
+  }
Evidence
Both flows perform a propagation pass over the dependency graph before running a dedupe pass that
mutates the graph’s child edges in place (rewriting child depPaths and potentially pruning
variants/unreachable nodes). Because propagation is edge-dependent, running it on the pre-dedupe
topology means any subsequent edge rewrites can invalidate the previously computed transitive peer
sets; since lockfile snapshot generation (and related behaviors like hoisting/peer set calculations)
read the transitive peer dependency data directly from the graph, stale or missing propagated
entries can surface in the lockfile output and downstream resolution behavior.

installing/deps-resolver/src/resolvePeers.ts[183-225]
installing/deps-resolver/src/resolvePeers.ts[245-264]
installing/deps-resolver/src/resolvePeers.ts[277-291]
installing/deps-resolver/src/updateLockfile.ts[119-121]
installing/linking/real-hoist/src/index.ts[179-185]
pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[287-316]
pacquet/crates/resolving-deps-resolver/src/dedupe_peer_dependents.rs[89-104]
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs[665-678]
pacquet/crates/real-hoist/src/lib.rs[432-455]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`transitivePeerDependencies`/`transitive_peer_dependencies` propagation currently runs before the dedupe pass that rewrites `node.children` depPaths (and may prune variants), so the propagated sets can become stale relative to the final, deduped graph that is later used for lockfile snapshots and hoisting/peer-set computations.
## Issue Context
- Propagation executes immediately after resolving children, but before dedupe.
- The dedupe step rewrites `node.children[alias]` / `node.children` depPaths in-place and can prune variants/unreachable nodes.
- Lockfile writing / snapshot generation and hoisting consume `transitivePeerDependencies` / `node.transitive_peer_dependencies` from the graph, so stale or missing propagation can affect output and behavior.
- Propagation relies on parent/edge relationships (e.g., a `parentsOf` structure), which must reflect the post-dedupe `children` edges.
## Fix Focus Areas
- installing/deps-resolver/src/resolvePeers.ts[183-225]
- installing/deps-resolver/src/resolvePeers.ts[245-291]
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[297-317]
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[360-405]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Duplicate parents in propagation 🐞 Bug ➹ Performance
Description
In pacquet's transitive peer propagation, parents_of stores parents in a Vec, so the same parent
can be recorded multiple times for the same child_path when that child depPath is reachable under
multiple aliases (e.g. dependency alias plus resolved-peer alias). This causes repeated propagation
work for the same parent/child relationship and can add avoidable CPU/allocations in a resolver hot
path.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[R454-460]

+    let mut parents_of: HashMap<DepPath, Vec<DepPath>> = HashMap::new();
+    let mut provided_names: HashMap<DepPath, HashSet<String>> = HashMap::new();
+    for (parent_path, entry) in graph.iter() {
+        let mut names = HashSet::new();
+        for (alias, child_path) in &entry.children {
+            parents_of.entry(child_path.clone()).or_default().push(parent_path.clone());
+            names.insert(alias.clone());
Evidence
The propagation pass currently records parent depPaths in a Vec without deduplication. The graph
node children map can contain multiple aliases pointing to the same depPath because it contains
both dependency edges and resolved peer edges, making duplicates feasible and leading to redundant
parent processing.

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[453-468]
pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[1144-1166]
pacquet/crates/resolving-deps-resolver/src/dependencies_graph.rs[32-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`propagate_transitive_peer_dependencies()` builds `parents_of` as `HashMap<DepPath, Vec<DepPath>>` and pushes `parent_path` once per `(alias, child_path)` edge. If multiple aliases point to the same `child_path` (which can happen because graph `children` includes both normal child edges and resolved-peer edges), the same `parent_path` is duplicated and the parent is processed multiple times for the same child.
### Issue Context
This is a propagation post-pass in the resolver; keeping its complexity proportional to unique edges helps avoid regressions on large graphs and alias-heavy dependency trees.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[453-468]
### Suggested fix
- Change `parents_of` to dedupe parents per child, e.g. `HashMap<DepPath, HashSet<DepPath>>` (or keep `Vec` but guard against duplicates).
- Update the iteration accordingly (iterate the `HashSet` of parents).
- Keep the rest of the algorithm unchanged to preserve behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Link aliases inflate TPD 🐞 Bug ≡ Correctness
Description
In resolvePeers(), providedNames only records a child’s real package name when that child has an
entry in depGraphWithResolvedChildren, but linked deps (link:) don’t have graph nodes, so their
real names are omitted. If a linked dependency is installed under an alias different from its real
name, the propagation loop can incorrectly add a transitivePeerDependencies entry for a peer that
is already directly provided by that linked child, producing incorrect/stable-lockfile-breaking
snapshots.
Code

installing/deps-resolver/src/resolvePeers.ts[R228-236]

+    for (const [alias, childPath] of Object.entries<DepPath>(entry.children)) {
+      if (!parentsOf[childPath]) {
+        parentsOf[childPath] = new Set()
+      }
+      parentsOf[childPath].add(parentPath as DepPath)
+      names.add(alias)
+      const childEntry = depGraphWithResolvedChildren[childPath]
+      if (childEntry?.name) names.add(childEntry.name)
+    }
Evidence
Linked deps are inserted into dependenciesTree with depth: -1, and resolvePeersOfNode()
returns early for depth === -1, so no dep-graph entry is created and no pathsByNodeId mapping is
set. resolveChildren() therefore emits link: strings as child depPaths, but providedNames only
adds the child’s real name when it can find a dep-graph entry for that child depPath, which linked
deps lack; the propagation filter then can’t recognize the real-name peer as already provided by the
parent’s direct linked child.

installing/deps-resolver/src/resolveDependencies.ts[932-946]
installing/deps-resolver/src/resolvePeers.ts[483-485]
installing/deps-resolver/src/resolvePeers.ts[185-191]
installing/deps-resolver/src/resolvePeers.ts[224-236]
installing/deps-resolver/src/updateLockfile.ts[175-194]
installing/deps-resolver/src/resolvePeers.ts[505-516]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new worklist propagation builds `providedNames` from `depGraphWithResolvedChildren[childPath]?.name`. For linked children, `childPath` is a `link:` depPath and there is no dep-graph entry, so the child’s real package name is never recorded. This can cause the propagation loop to treat a peer as “not provided by parent” and incorrectly propagate it.
### Issue Context
- Linked deps are represented in the dependencies tree with `depth: -1`, and `resolvePeersOfNode()` early-returns for them, so they never get a dep-graph node/depPath mapping.
- `resolveChildren()` then falls back to using the raw `link:` nodeId as the child depPath.
### Fix Focus Areas
- installing/deps-resolver/src/resolvePeers.ts[224-236]
### Suggested change
While building `providedNames`, when `childEntry` is missing and `childPath` starts with `link:`, look up the corresponding node in `opts.dependenciesTree` (the key is the same `link:...` string) and add `node.resolvedPackage.name` to `names` as well. This mirrors the alias-vs-real-name handling you already do for graph-backed children.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Rust worklist duplicates 🐞 Bug ➹ Performance
Description
In pacquet's propagate_transitive_peer_dependencies(), the VecDeque worklist has no in-queue
deduplication, so the same parent DepPath can be re-enqueued and reprocessed many times as different
children contribute new transitive peers, increasing CPU and allocations on large graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[R350-385]

+    let mut worklist: VecDeque<DepPath> = graph
+        .iter()
+        .filter(|(_, entry)| !entry.transitive_peer_dependencies.is_empty())
+        .map(|(path, _)| path.clone())
+        .collect();
+
+    while let Some(child_path) = worklist.pop_front() {
+        let child_tpd: Vec<String> = graph
+            .get(&child_path)
+            .map(|c| c.transitive_peer_dependencies.iter().cloned().collect())
+            .unwrap_or_default();
+        if child_tpd.is_empty() {
+            continue;
+        }
+        let Some(parents) = parents_of.get(&child_path).cloned() else { continue };
+
+        for parent_path in parents {
+            let mut new_tpd = Vec::new();
+            {
+                let Some(parent) = graph.get(&parent_path) else { continue };
+                for tpd in &child_tpd {
+                    if !parent.transitive_peer_dependencies.contains(tpd)
+                        && !parent.peer_dependencies.contains_key(tpd)
+                    {
+                        new_tpd.push(tpd.clone());
+                    }
+                }
+            }
+            if !new_tpd.is_empty() {
+                let parent = graph.get_mut(&parent_path).unwrap();
+                for tpd in new_tpd {
+                    parent.transitive_peer_dependencies.insert(tpd);
+                }
+                worklist.push_back(parent_path);
+            }
+        }
Evidence
The Rust implementation re-enqueues nodes without any deduplication, while the TS implementation
uses a Set worklist, demonstrating the intended deduped behavior and highlighting the Rust port’s
redundant-queue risk.

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[339-387]
installing/deps-resolver/src/resolvePeers.ts[195-224]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`propagate_transitive_peer_dependencies()` uses a `VecDeque` worklist and blindly `push_back`s `parent_path` whenever a parent gains new TPD entries. This can enqueue the same `DepPath` many times, causing redundant processing and avoidable superlinear behavior in graphs with shared parents / many incremental TPD additions.
### Issue Context
The TypeScript implementation uses a `Set` worklist (deduped). The Rust port should match that behavior to keep propagation cost proportional to actual new information.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[339-387]
### Suggested approach
- Keep `VecDeque<DepPath>` for FIFO order, but add a `HashSet<DepPath>` (e.g. `in_queue`) to prevent duplicates.
- When enqueuing: `if in_queue.insert(parent_path.clone()) { worklist.push_back(parent_path.clone()) }`
- When popping: `in_queue.remove(&child_path)`
- Optionally also dedupe `parents_of` (store `HashSet<DepPath>` instead of `Vec`) to avoid repeated parent processing when the same child edge appears multiple times.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

8. Redundant cycle scan 🐞 Bug ➹ Performance ⭐ New
Description
resolvePeersOfNode() now performs an extra linear Array.includes() scan on parentDepPathsChain to
compute resolvedThroughCycle even though the same membership check was already done earlier when
extending parentDepPathsChain for child resolution. This adds avoidable O(depth) work per visited
node in a recursive resolver hot path.
Code

installing/deps-resolver/src/resolvePeers.ts[R601-606]

+  const resolvedThroughCycle = ctx.parentDepPathsChain.includes(resolvedPackage.pkgIdWithPatchHash)
+  if (resolvedThroughCycle) {
+    // Leave both caches untouched so later occurrences re-resolve (or hit the
+    // authoritative entry of the same package) instead of reusing this partial one.
+  } else if (isPure) {
    ctx.purePkgs.add(resolvedPackage.pkgIdWithPatchHash)
Evidence
The code already does an includes() check to decide whether to extend the chain for child
resolution, and the new resolvedThroughCycle introduces a second includes() scan for the same
value.

installing/deps-resolver/src/resolvePeers.ts[555-563]
installing/deps-resolver/src/resolvePeers.ts[593-606]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`resolvePeersOfNode()` calls `ctx.parentDepPathsChain.includes(resolvedPackage.pkgIdWithPatchHash)` twice: once to decide whether to extend `parentDepPathsChain` for `resolvePeersOfChildren`, and again later to compute `resolvedThroughCycle`.

Because `parentDepPathsChain` is an array and `includes()` is linear, this adds avoidable per-node work in a recursive resolver hot path.

### Issue Context
The first `includes()` result is already sufficient to decide whether the current node is being resolved through a cycle and can be reused for the later cache decision.

### Fix Focus Areas
- installing/deps-resolver/src/resolvePeers.ts[555-563]
- installing/deps-resolver/src/resolvePeers.ts[593-606]

### Suggested fix
Compute a single `const resolvedThroughCycle = ctx.parentDepPathsChain.includes(resolvedPackage.pkgIdWithPatchHash)` once (before the `resolvePeersOfChildren` call), reuse it both:
- when building the `parentDepPathsChain` passed to children, and
- when deciding whether to populate `purePkgs` / `peersCache`.

Optionally, if this becomes more performance-sensitive, consider threading a boolean or using a Set-based structure for cycle membership checks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Extra allocations in propagation 🐞 Bug ➹ Performance
Description
In pacquet, propagate_transitive_peer_dependencies clones the parent list per dequeued node and
builds an intermediate diffs Vec<(DepPath, Vec<String>)> before applying updates, causing avoidable
allocations in a resolver hot path. This is unlikely to break correctness but can be optimized to
reduce CPU/allocations on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[R375-401]

+        let Some(parents) = parents_of.get(&child_path).cloned() else { continue };
+
+        let diffs: Vec<(DepPath, Vec<String>)> = {
+            let child = match graph.get(&child_path) {
+                Some(c) => c,
+                None => continue,
+            };
+            if child.transitive_peer_dependencies.is_empty() {
+                continue;
+            }
+            parents
+                .iter()
+                .filter_map(|parent_path| {
+                    let parent = graph.get(parent_path)?;
+                    let diff: Vec<String> = child
+                        .transitive_peer_dependencies
+                        .iter()
+                        .filter(|tpd| {
+                            !parent.transitive_peer_dependencies.contains(*tpd)
+                                && !parent.peer_dependencies.contains_key(*tpd)
+                        })
+                        .cloned()
+                        .collect();
+                    if diff.is_empty() { None } else { Some((parent_path.clone(), diff)) }
+                })
+                .collect()
+        };
Evidence
The new propagation loop clones the parent list (.cloned()) and constructs an intermediate diffs
vector for each work item, then iterates that vector again to apply mutations, increasing
allocations in a tight loop.

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[360-411]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`propagate_transitive_peer_dependencies()` performs avoidable cloning/allocation in its inner loop:
- clones the `parents` Vec for every `child_path`
- collects an intermediate `diffs: Vec<(DepPath, Vec<String>)>` (and each `diff: Vec<String>`) before mutating the graph
This adds overhead in `resolve_peers()` / `resolve_peers_workspace()` where the resolver runs frequently.
### Issue Context
The function is called right after `build_final_graph()` and before returning the resolved graph, so any extra allocations show up directly in install/resolve time.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[360-411]
### Suggested approach
- Avoid cloning `parents` by iterating over `parents_of.get(&child_path)` by reference.
- Avoid allocating `diffs` by performing the parent updates in a second pass that only clones the child's TPD set once (or iterates it directly), using a `gained` flag per parent.
- If borrow rules block in-place mutation while iterating parents, collect only the parent depPaths that need updates (no per-parent Vec<String>) and then apply updates.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit f10973e

Results up to commit e08e630


🐞 Bugs (8) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Action required
1. Aliased deps bypass TPD filter 🐞 Bug ≡ Correctness
Description
In resolvePeers(), propagation skips adding a transitive peer only when parentEntry.children[tpd]
exists, but children is keyed by dependency alias, not necessarily the peer/package name. With npm
aliases (alias != resolvedPackage.name), this can incorrectly propagate an unnecessary
transitivePeerDependencies entry into parents, impacting lockfile contents and downstream
resolution/hoisting decisions.
Code

installing/deps-resolver/src/resolvePeers.ts[R254-257]

+      for (const tpd of childEntry.transitivePeerDependencies) {
+        if (!parentEntry.transitivePeerDependencies.has(tpd) && !parentEntry.peerDependencies?.[tpd] && !parentEntry.children?.[tpd]) {
+          parentEntry.transitivePeerDependencies.add(tpd)
+          gained = true
Evidence
The propagation filter uses children[tpd], but children is keyed by alias, and the resolver
explicitly supports alias != resolved package name; therefore direct aliased children won’t be
detected by this guard. Extra TPDs are persisted and later interpreted as peer constraints / as a
signal to force deeper resolution, so incorrect additions have real behavioral impact.

installing/deps-resolver/src/resolvePeers.ts[185-193]
installing/deps-resolver/src/resolvePeers.ts[1245-1261]
installing/deps-resolver/src/resolvePeers.ts[224-260]
installing/deps-resolver/src/resolveDependencies.ts[1156-1166]
installing/linking/real-hoist/src/index.ts[173-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`resolvePeers()` propagates `transitivePeerDependencies` upward using this guard:
- `!parentEntry.children?.[tpd]`
However, `children` is keyed by **alias**, while `tpd` entries are **peer/package names**. When a dependency is installed via npm alias, the alias key differs from the resolved package name, so the guard can fail even if the parent already has a direct child that satisfies that peer.
This can cause extra `transitivePeerDependencies` to be written to the lockfile, and those entries are later treated as peer constraints during hoisting/resolution.
### Issue Context
- `children` map is built from `childrenNodeIds` where keys are aliases.
- The resolver explicitly models that alias may differ from `resolvedPackage.name`.
### Fix Focus Areas
- installing/deps-resolver/src/resolvePeers.ts[224-263]
- installing/deps-resolver/src/resolvePeers.ts[185-193]
- installing/deps-resolver/src/resolvePeers.ts[1245-1261]
### Implementation direction
1. Replace the `!parentEntry.children?.[tpd]` check with a check that the parent already provides `tpd` as either:
- a direct child alias matching `tpd`, **or**
- a direct child whose **resolved package name** matches `tpd`.
Example approach (keep it efficient):
- Precompute for each node a `providedNames: Set<string>` that includes:
  - all child aliases (keys of `children`)
  - all child resolved names (`depGraphWithResolvedChildren[childPath]?.name`)
- Then guard with `!providedNames.has(tpd)`.
2. Add/extend a unit/integration test covering propagation where the parent provides the peer via an npm alias (alias != real name) to ensure TPD is not redundantly propagated.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Test not actually ignored 🐞 Bug ☼ Reliability
Description
compatible_existing_peer_contexts_survive_writable_lockfile_regeneration is documented as
“Ignored” due to a known lockfile-reuse bug, but it is still annotated with #[test] and has no
#[ignore], so it will run in CI and can fail the suite. This contradicts the comment and
undermines test-suite reliability.
Code

pacquet/crates/cli/tests/install.rs[R865-874]

+/// Ignored: the TPD propagation fix (pnpm/pnpm#12286) correctly adds
+/// `@pnpm.e2e/peer-c` to `abc-grand-parent-with-c`'s transitive peer
+/// deps. The lockfile now records this TPD, and on the second install
+/// pacquet's lockfile-reuse path merges the nested `peer-c@1.x`
+/// context into the root `peer-c@2.0.0` context instead of preserving
+/// both. This is a pacquet lockfile-reuse bug exposed by correct TPD,
+/// not a bug in the propagation itself. The fix belongs in the reuse
+/// path, not in the propagation.
#[test]
fn compatible_existing_peer_contexts_survive_writable_lockfile_regeneration() {
Evidence
The test’s doc comment explicitly says it is ignored, but the code only has #[test] (no
#[ignore]). The same file contains another test that is correctly ignored with `#[ignore =
"..."]`, showing the expected mechanism.

pacquet/crates/cli/tests/install.rs[865-874]
pacquet/crates/cli/tests/install.rs[140-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A pacquet CLI integration test is described as ignored (due to a known lockfile-reuse bug), but it is still enabled as a normal `#[test]` with no `#[ignore]` attribute. This will keep executing in CI and may fail, blocking merges.
### Issue Context
The file already demonstrates the intended pattern for ignoring flaky/known-bad tests using `#[ignore = "..."]` above `#[test]`.
### Fix Focus Areas
- pacquet/crates/cli/tests/install.rs[865-874]
- pacquet/crates/cli/tests/install.rs[140-147]
### Expected fix
Add `#[ignore = "…reason…"]` above `#[test]` for `compatible_existing_peer_contexts_survive_writable_lockfile_regeneration`, or remove/update the “Ignored” comment if the test is intended to run and is now passing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Rust TPD hides real deps 🐞 Bug ≡ Correctness
Description
In pacquet, propagate_transitive_peer_dependencies()/resolvePeers() can propagate a child’s
transitive peer dependency alias into a parent even when that alias already exists as a direct child
dependency on the parent, misclassifying a real dependency edge as a transitive peer. Because
lockfile reuse and hoisting treat transitivePeerDependencies specially, this collision can change
peer/hoist constraints and even cause snapshot-based reconstruction to drop the direct dependency
alias.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[R389-399]

+                    let diff: Vec<String> = child
+                        .transitive_peer_dependencies
+                        .iter()
+                        .filter(|tpd| {
+                            !parent.transitive_peer_dependencies.contains(*tpd)
+                                && !parent.peer_dependencies.contains_key(*tpd)
+                        })
+                        .cloned()
+                        .collect();
+                    if diff.is_empty() { None } else { Some((parent_path.clone(), diff)) }
+                })
Evidence
The cited propagation logic only guards against adding aliases already in the parent’s transitive
peer set and/or declared peer_dependencies, but it does not check whether the alias is already
present as a direct child dependency in the parent’s children map. In contrast, earlier
peer-resolution logic explicitly enforces an invariant that excludes aliases that are direct
children when handling “unknown resolved peers,” showing the intended constraint exists elsewhere
but is missing from the new propagation loop. Downstream, snapshot_child_refs() explicitly filters
out any alias listed in peer_dependencies or transitive_peer_dependencies when reconstructing child
edges during lockfile reuse, so if propagation erroneously adds a real direct dependency alias into
transitive_peer_dependencies, reuse can omit that dependency; similarly, the hoister treats
transitivePeerDependencies entries as peer names, so misclassification affects hoisting/peer-name
behavior and lockfile semantics.

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[360-410]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1503-1539]
installing/deps-resolver/src/resolvePeers.ts[185-225]
installing/deps-resolver/src/resolvePeers.ts[1107-1113]
installing/linking/real-hoist/src/index.ts[179-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The child-to-parent propagation of `transitivePeerDependencies` (Rust: `propagate_transitive_peer_dependencies()` in peer resolution; TS: `resolvePeers()` propagation pass) does not skip aliases that are already present as *direct child dependencies* of the parent (`parent.children` / `parentEntry.children`). This can incorrectly classify a real direct dependency alias as a transitive peer, which then affects hoisting/peer-name constraints and can cause lockfile reuse to drop the dependency because the reuse path filters out anything listed in `transitive_peer_dependencies`.
## Issue Context
- The propagation filter/condition currently checks for existing transitive-peer entries and declared `peer_dependencies`, but it does not check whether the alias already exists in the parent’s direct `children` map.
- Elsewhere in peer resolution (e.g., resolving peers of children / “unknown resolved peers”), the logic already avoids bubbling peers that are direct children (`if (!children[alias]) ...`), indicating the invariant that direct children should not be reclassified as transitive peers.
- In the lockfile reuse path, `snapshot_child_refs()` reconstructs dependencies from snapshot maps while excluding any alias in `peer_dependencies` or `transitive_peer_dependencies`, so an incorrect propagation collision can silently remove a real dependency edge during reuse.
## Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[360-410]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1503-1539]
- installing/deps-resolver/src/resolvePeers.ts[185-225]
- installing/deps-resolver/src/resolvePeers.ts[1107-1113]
## Suggested change
- Rust: In the `filter(|tpd| ...)` (or equivalent) predicate that decides whether to propagate a `tpd` into the parent, also ensure the parent does **not** already have that alias in `parent.children` (e.g., add a `!parent.children.contains_key(*tpd)` guard).
- TypeScript: In the propagation condition, also skip propagation when the parent already has that alias as a direct child dependency (use an own-property check like `Object.prototype.hasOwnProperty.call(parentEntry.children, tpd)` rather than truthiness).
- Keep existing guards (do not add if it is already in the parent’s `transitivePeerDependencies` and/or is a declared `peerDependency`), but add the missing `children`-presence check to preserve the invariant and prevent lockfile reuse/hoisting misbehavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Propagation before graph rewrites 🐞 Bug ≡ Correctness
Description
In resolvePeers(), transitive peer dependency propagation runs on the pre-dedupe graph even though
dedupePeerDependents/dedupe_peer_dependents can later rewrite node.children depPaths and prune
variants, so the propagated values may not reflect the final deduped graph. This can leave some
nodes with stale or missing transitivePeerDependencies/transitive_peer_dependencies that are then
written to or consumed from the lockfile and can affect hoisting/peer sets.
Code

installing/deps-resolver/src/resolvePeers.ts[R185-225]

+  const parentsOf: Record<string, Set<DepPath>> = {}
+  for (const [parentPath, entry] of Object.entries(depGraphWithResolvedChildren)) {
+    for (const childPath of Object.values<DepPath>(entry.children)) {
+      if (!parentsOf[childPath]) {
+        parentsOf[childPath] = new Set()
+      }
+      parentsOf[childPath].add(parentPath as DepPath)
+    }
+  }
+
+  const worklist = new Set<DepPath>()
+  for (const [path, entry] of Object.entries(depGraphWithResolvedChildren)) {
+    if (entry.transitivePeerDependencies.size > 0) {
+      worklist.add(path as DepPath)
+    }
+  }
+
+  while (worklist.size > 0) {
+    const childPath = worklist.values().next().value!
+    worklist.delete(childPath)
+    const childEntry = depGraphWithResolvedChildren[childPath]
+    if (!childEntry?.transitivePeerDependencies.size) continue
+
+    const parents = parentsOf[childPath]
+    if (!parents) continue
+
+    for (const parentPath of parents) {
+      const parentEntry = depGraphWithResolvedChildren[parentPath]
+      if (!parentEntry) continue
+      let gained = false
+      for (const tpd of childEntry.transitivePeerDependencies) {
+        if (!parentEntry.transitivePeerDependencies.has(tpd) && !parentEntry.peerDependencies?.[tpd]) {
+          parentEntry.transitivePeerDependencies.add(tpd)
+          gained = true
+        }
+      }
+      if (gained) {
+        worklist.add(parentPath)
+      }
+    }
+  }
Evidence
Both flows perform a propagation pass over the dependency graph before running a dedupe pass that
mutates the graph’s child edges in place (rewriting child depPaths and potentially pruning
variants/unreachable nodes). Because propagation is edge-dependent, running it on the pre-dedupe
topology means any subsequent edge rewrites can invalidate the previously computed transitive peer
sets; since lockfile snapshot generation (and related behaviors like hoisting/peer set calculations)
read the transitive peer dependency data directly from the graph, stale or missing propagated
entries can surface in the lockfile output and downstream resolution behavior.

installing/deps-resolver/src/resolvePeers.ts[183-225]
installing/deps-resolver/src/resolvePeers.ts[245-264]
installing/deps-resolver/src/resolvePeers.ts[277-291]
installing/deps-resolver/src/updateLockfile.ts[119-121]
installing/linking/real-hoist/src/index.ts[179-185]
pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[287-316]
pacquet/crates/resolving-deps-resolver/src/dedupe_peer_dependents.rs[89-104]
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs[665-678]
pacquet/crates/real-hoist/src/lib.rs[432-455]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`transitivePeerDependencies`/`transitive_peer_dependencies` propagation currently runs before the dedupe pass that rewrites `node.children` depPaths (and may prune variants), so the propagated sets can become stale relative to the final, deduped graph that is later used for lockfile snapshots and hoisting/peer-set computations.
## Issue Context
- Propagation executes immediately after resolving children, but before dedupe.
- The dedupe step rewrites `node.children[alias]` / `node.children` depPaths in-place and can prune variants/unreachable nodes.
- Lockfile writing / snapshot generation and hoisting consume `transitivePeerDependencies` / `node.transitive_peer_dependencies` from the graph, so stale or missing propagation can affect output and behavior.
- Propagation relies on parent/edge relationships (e.g., a `parentsOf` structure), which must reflect the post-dedupe `children` edges.
## Fix Focus Areas
- installing/deps-resolver/src/resolvePeers.ts[183-225]
- installing/deps-resolver/src/resolvePeers.ts[245-291]
- pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs[297-317]
- p...

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds transitive peer dependency propagation across both TypeScript and Rust dependency resolvers to prevent lockfile churn. When resolving peer dependencies, the algorithm now floods transitive peer names upward through parent nodes after resolution, using a worklist-based approach that skips peers already satisfied by direct declarations or provided aliases.

Changes

Transitive Peer Dependencies Propagation

Layer / File(s) Summary
TypeScript TPD propagation algorithm
installing/deps-resolver/src/resolvePeers.ts
Post-processing traversal builds a parent→child index from the resolved graph and uses a worklist to propagate children's transitivePeerDependencies upward, skipping peers already declared by the parent or provided via aliases. Separately adjusts peer-candidate logic to match both dependency alias and resolved package name against allPeerDepNames.
TypeScript resolver and installer tests
installing/deps-installer/test/install/cyclicPeerDeterminism.ts, installing/deps-installer/test/install/peerDependencies.ts, installing/deps-resolver/test/resolvePeers.ts
Adds cyclic-dependency determinism test that installs twice with cache cleared between runs and verifies snapshot stability; adds peer-dependency stability tests that confirm transitivePeerDependencies remain unchanged across consecutive installs and when unrelated dependencies are added.
E2E package fixtures for TPD scenarios
pnpr/.fixtures/packages/@pnpm.e2e/has-optional-peer/1.0.0/package.json, pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-a/1.0.0/package.json, pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-b/1.0.0/package.json, pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-parent/1.0.0/package.json, pnpr/.fixtures/packages/@pnpm.e2e/uses-optional-peer/1.0.0/package.json
Introduces fixture manifests modeling optional-peer and carrier topologies: has-optional-peer declares peer-c as optional; carriers depend on uses-optional-peer; uses-optional-peer depends on has-optional-peer; parent ties the chain together.
Rust TPD propagation algorithm and integration
pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
Adds propagate_transitive_peer_dependencies using a VecDeque worklist to build parent→child relationships and flood upward transitive peers, suppressing peers already satisfied by parent declarations or aliases. Calls propagation after final graph construction in both resolve_peers_workspace and Walker::walk code paths.
Rust unit tests for propagation
pacquet/crates/resolving-deps-resolver/src/tests.rs
Adds unit test module with make_node helper and tests verifying child→parent propagation, suppression when parent declares the peer directly, multi-level transitive propagation through grandparent chains, suppression when parent already contains peer as child, and suppression when parent provides peer via aliased dependencies.
Release metadata and spellcheck
.changeset/tpd-propagation-fix.md, cspell.json
Changeset declares patch bumps for @pnpm/installing.deps-resolver and pnpm with description of lockfile churn prevention; adds worklist to spellcheck dictionary.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • pnpm/pnpm#5108: Unexpected lockfile churn when upgrading packages manually in monorepo environments due to transitive peer dependency shifts.
  • pnpm/pnpm#5552: Selective pnpm update foo unintentionally updates packages unrelated to the specified package.
  • pnpm/pnpm#5794: Lockfile changes during pnpm install not reflected in pnpm install --frozen-lockfile, indicating lockfile state divergence.
  • pnpm/pnpm#9992: Selective package updates inadvertently fetch unrelated packages into the dependency tree due to transitive peer dependency instability.

Possibly related PRs

  • pnpm/pnpm#11915: Modifies pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs with lazy-walker changes to peer-resolution logic that complements the upward propagation added in this PR.
  • pnpm/pnpm#12273: Also modifies pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs affecting parent-context peer-resolution logic alongside the propagation propagation step introduced here.

Suggested reviewers

  • zkochan

Poem

🐰 A worklist hops up the dependency tree,
Each transitive peer finds where it should be,
No churn in the lock when unrelated things shift,
The graph stays stable—a propagation gift! 📦✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implementation comprehensively addresses all linked issues by preventing lockfile churn through deterministic TPD propagation, ensuring consistent snapshot references across installation orders, and eliminating unintended out-of-scope package updates.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the stated objectives of fixing transitivePeerDependencies propagation, including test fixtures, test cases, core resolver logic updates in both TypeScript and Rust, and documentation - with no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title accurately and specifically describes the main change: fixing and stabilizing transitivePeerDependencies in dependency cycles, which is the core objective of this PR.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix lockfile churn by propagating transitivePeerDependencies via worklist pass
🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Walkthroughs

User Description

Summary

Fixes lockfile churn where transitivePeerDependencies (e.g., supports-color via debug) shift between packages when unrelated dependencies are upgraded.

Root cause: The purePkgs optimization in resolvePeersOfNode marks packages as "pure" deep inside cycles (where cycle-breaking produces empty children). When resolved again at shallower depths through a different parent path, the purePkgs early return yields empty missing peers, causing transitivePeerDependencies inconsistency. Resolution order changes with unrelated upgrades, so the lockfile churns.

Fix: Post-processing worklist-based propagation loop that runs after all peer resolutions complete. Seeds a worklist with nodes that have non-empty transitivePeerDependencies, then propagates tpd from each node to its parents. Parents that gain new tpd entries are added to the worklist. Filters out peers the parent declares in its own peerDependencies. Complexity is O(total_propagations × avg_parents) — proportional to actual work, not graph size.

Both the TypeScript pnpm CLI and the Rust (pacquet) port are updated in parity.

Fixes #5108
Fixes #5552
Fixes #5794
Fixes #9992
Fixes #11834

Test plan

  • 109 resolver unit tests pass (TS)
  • 95 resolver tests pass (Rust)
  • 3 integration tests for lockfile stability + idempotency (TS)
  • Direct graph-construction unit tests for propagation (Rust) — 2 of 3 fail without the propagation call
  • Verified on real-world repro: fresh install + upgrade produces zero tpd churn, second install is idempotent
  • Benchmark shows no measurable performance regression vs main (22.6s ± 0.9s vs 22.8s ± 0.8s, npm registry, frozen lockfile)

Written by an agent (opencode, glm-5.1).

AI Description
• Propagate transitivePeerDependencies upward through the resolved dep graph to avoid lockfile
  churn.
• Add regression coverage for peer cycles and “unrelated upgrade” determinism/idempotency.
• Port the fix to pacquet (Rust) with parity tests and small tooling updates.
Diagram
graph TD
  F["Resolver unit tests"] --> A["Peer resolver (TS/Rust)"] --> B["Resolved depGraph"] --> C(("TPD propagation")) --> D[("Lockfile snapshots")]
  E["Integration tests"] --> D

  subgraph Legend
    direction LR
    _c["Component"] ~~~ _p(("Worklist pass")) ~~~ _d[("Data store")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Fix purePkgs caching to retain missing peers/TPD in cached results
  • ➕ Avoids an extra post-pass over the graph
  • ➕ Keeps the invariant localized to the optimization that caused the bug
  • ➖ Harder to reason about across multiple cache hit paths and cycle-breaking behavior
  • ➖ Risk of reintroducing nondeterminism if cache entries depend on traversal order/context
2. Disable/limit purePkgs fast-path when cycles are detected
  • ➕ Minimal change to overall algorithm; removes the known problematic case
  • ➖ Can regress performance on graphs with cycles
  • ➖ Does not address other scenarios where cached “pure” results can lose derived metadata
3. Compute transitivePeerDependencies at lockfile emit time by traversing snapshots
  • ➕ Decouples TPD derivation from resolution internals
  • ➕ Potentially simpler mental model: derive from final edges
  • ➖ Requires lockfile generator to understand peer/optional-peer semantics
  • ➖ Still needs careful filtering of parent-declared peerDependencies; risks divergence from resolver state

Recommendation: The PR’s approach (a worklist-based propagation pass after child edges are finalized) is the most robust: it makes transitivePeerDependencies a fixed-point property of the final depGraph, eliminating order sensitivity introduced by the purePkgs fast-path. The worklist algorithm keeps cost proportional to actual propagation, and parity across TS/Rust reduces long-term drift.

Grey Divider

File Changes

Bug fix (2)
resolvePeers.ts Propagate transitivePeerDependencies to parents via worklist post-pass +42/-0

Propagate transitivePeerDependencies to parents via worklist post-pass

• Builds a child→parents index after 'resolveChildren', seeds a worklist with nodes that already have TPD, and propagates each child’s TPD to its parents until convergence. Filters out entries that the parent declares in its own 'peerDependencies' to avoid misclassification.

installing/deps-resolver/src/resolvePeers.ts


resolve_peers.rs Port worklist-based TPD propagation to pacquet resolver +53/-1

Port worklist-based TPD propagation to pacquet resolver

• Introduces 'propagate_transitive_peer_dependencies' using a parent index and 'VecDeque' worklist, filtering out parent-declared peers. Invokes the propagation after 'patch_pending_peer_edges' in both workspace and non-workspace resolve flows to ensure children maps are populated before propagation.

pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs


Tests (9)
cyclicPeerDeterminism.ts Add integration test for TPD propagation across dependency cycles +100/-0

Add integration test for TPD propagation across dependency cycles

• Creates a mocked registry topology with an 'a↔b' cycle and a second path to 'a', where an optional peer should surface as a transitive peer dependency. Verifies snapshots contain the expected TPD and that a second run produces identical snapshot keys (idempotency).

installing/deps-installer/test/install/cyclicPeerDeterminism.ts


peerDependencies.ts Add lockfile stability tests for transitivePeerDependencies +66/-0

Add lockfile stability tests for transitivePeerDependencies

• Adds integration coverage ensuring TPD values are stable across repeated installs and do not churn when adding an unrelated dependency. Asserts carrier snapshots consistently record the same transitive peer dependency.

installing/deps-installer/test/install/peerDependencies.ts


resolvePeers.ts Add unit test for TPD propagation after purePkgs early return in cycles +136/-0

Add unit test for TPD propagation after purePkgs early return in cycles

• Constructs a dependency tree where a cycle causes a node to be marked pure and later revisited through a different parent path. Asserts that the post-pass propagation ensures upstream nodes still receive the expected transitive peer dependency.

installing/deps-resolver/test/resolvePeers.ts


tests.rs Add Rust parity and unit tests for TPD propagation +402/-0

Add Rust parity and unit tests for TPD propagation

• Adds topology tests mirroring the TypeScript cycle regression and a multi-parent carrier scenario to ensure TPD reaches all parents deterministically. Adds direct graph-construction unit tests validating propagation, filtering of parent-declared peers, and transitive multi-level propagation.

pacquet/crates/resolving-deps-resolver/src/tests.rs


package.json Add fixture package declaring an optional peer +12/-0

Add fixture package declaring an optional peer

• Defines '@pnpm.e2e/has-optional-peer' with an optional 'peerDependencies' entry used to induce transitive peer dependency recording in tests.

pnpr/.fixtures/packages/@pnpm.e2e/has-optional-peer/1.0.0/package.json


package.json Add carrier A fixture for TPD stability scenarios +7/-0

Add carrier A fixture for TPD stability scenarios

• Adds '@pnpm.e2e/transitive-peer-carrier-a' which depends on the chain that introduces an optional peer, serving as a lockfile snapshot carrier in integration tests.

pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-a/1.0.0/package.json


package.json Add carrier B fixture for TPD stability scenarios +7/-0

Add carrier B fixture for TPD stability scenarios

• Adds '@pnpm.e2e/transitive-peer-carrier-b' mirroring carrier A to validate stability across multiple similar parents.

pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-b/1.0.0/package.json


package.json Add parent fixture depending on both carriers +9/-0

Add parent fixture depending on both carriers

• Adds '@pnpm.e2e/transitive-peer-parent' depending on both carrier packages (plus an unrelated dep) to exercise snapshot determinism under dependency changes.

pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-parent/1.0.0/package.json


package.json Add fixture package that pulls in optional-peer package +7/-0

Add fixture package that pulls in optional-peer package

• Defines '@pnpm.e2e/uses-optional-peer' which depends on '@pnpm.e2e/has-optional-peer', forming the chain that produces a transitive peer dependency in the lockfile.

pnpr/.fixtures/packages/@pnpm.e2e/uses-optional-peer/1.0.0/package.json


Documentation (1)
tpd-propagation-fix.md Add changeset entry for lockfile churn fix +6/-0

Add changeset entry for lockfile churn fix

• Introduces a patch changeset for '@pnpm/installing.deps-resolver' and 'pnpm' documenting the transitivePeerDependencies churn fix.

.changeset/tpd-propagation-fix.md


Other (1)
cspell.json Allow 'worklist' spelling in repo dictionary +1/-0

Allow 'worklist' spelling in repo dictionary

• Adds the term "worklist" to the cspell dictionary to match the new algorithm terminology.

cspell.json


Grey Divider

Qodo Logo

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `@pacquet/crates/resolving-deps-resolver/src/tests.rs`:
- Around line 2261-2377: The test
tpd_propagates_through_graph_after_pure_pkgs_cycle currently lets node "a" pick
up optional peer "e" during its first walk (so it never becomes a pure_pkgs
node), so reshape the fixture so "a" is first visited only through the pure
cycle path a→b→a (with no access to c/d and thus no resolved or missing peers),
and ensure the branch that exposes d→e (the c→d path) is traversed only
afterwards (so the post-walk propagation is required). Concretely, adjust the
fake package graph entries in this test: make "g" (or its initial entry in the
manifest) lead to the cycle path (a→b→a) on the first visit and delay/hide the
c→d→(peer e) branch (for example remove or move "h" from the initial traversal
or make "h" reachable only via a separate root) so that "a" has empty
all_resolved_peers/all_missing_peers on first walk and later the graph post-pass
must propagate the transitive peer "e"; update references to the nodes
"g","a","b","c","d","h" in the test accordingly.
🪄 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: afe49f69-b026-43f1-86e5-327579deea7a

📥 Commits

Reviewing files that changed from the base of the PR and between d188316 and 93fef55.

📒 Files selected for processing (13)
  • .changeset/tpd-propagation-fix.md
  • cspell.json
  • installing/deps-installer/test/install/cyclicPeerDeterminism.ts
  • installing/deps-installer/test/install/peerDependencies.ts
  • installing/deps-resolver/src/resolvePeers.ts
  • installing/deps-resolver/test/resolvePeers.ts
  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
  • pnpr/.fixtures/packages/@pnpm.e2e/has-optional-peer/1.0.0/package.json
  • pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-a/1.0.0/package.json
  • pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-b/1.0.0/package.json
  • pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-parent/1.0.0/package.json
  • pnpr/.fixtures/packages/@pnpm.e2e/uses-optional-peer/1.0.0/package.json
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with trailing commas, prefer functions over classes, declare functions after they are used (relying on hoisting), limit functions to no more than two or three arguments, and use a single options object for functions needing more parameters
Follow import order: standard libraries first, then external dependencies (sorted alphabetically), then relative imports
Do not write comments that restate what the code already says; rename variables, split helpers, or move checks to more obvious places instead
Do not repeat documentation at call sites that already lives on the callee; update the JSDoc once and let every call site benefit
Use JSDoc for the function's contract (preconditions, postconditions, edge cases, why the function exists), not for re-narrating the function body
Do not record past implementation shape, refactor history, or removed code in comments; use git log and git blame for that information instead
Write comments only when the reason for code is non-obvious, a hidden invariant exists, a workaround for a known bug is needed, or an exception to surrounding pattern is deliberate

Files:

  • installing/deps-resolver/src/resolvePeers.ts
  • installing/deps-resolver/test/resolvePeers.ts
  • installing/deps-installer/test/install/peerDependencies.ts
  • installing/deps-installer/test/install/cyclicPeerDeterminism.ts
pacquet/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/*.rs: Log emissions are part of matching pnpm — when porting a function that fires pnpm:<channel> events through globalLogger, logger.debug(...), or streamParser.write(...), mirror the call site, payload, and ordering so @pnpm/cli.default-reporter parses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing the brand into a plain String or &str in Rust
If upstream TypeScript always validates before construction of a branded string, validate in the Rust wrapper too via TryFrom<String> and/or FromStr and do not provide an infallible public constructor
If upstream TypeScript never validates a branded string, just brand for type-safety in Rust by exposing an infallible From<String> constructor
If upstream TypeScript occasionally constructs a branded string without validation, expose from_str_unchecked in Rust as an escape hatch alongside the validating constructor
Match upstream serde behavior for branded strings crossing JSON, YAML, or INI boundaries by using #[serde(try_from = "String")] for deserialization and #[serde(into = "String")] for serialization
Derive simple conversions for branded strings using #[derive(derive_more::From)] and #[derive(derive_more::Into)] instead of handwriting impl blocks; use manual impl only when conversion needs custom logic
Model TypeScript string literal unions (like 'auto' | 'always' | 'never') as Rust enums instead of newtype wrappers, since the set of valid values is closed
Treat TypeScript string template literal types (like `${string}@${string}`) the same as branded string types in Rust, using a newtype wrapper with validation
Follow the code style guide in CODE_STYLE_GUIDE.md — imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching, pipe-trait, error handling, test layout, and cloning of Arc and Rc
Choose owned vs. borrowed parameters to minimize copies; widen to t...

Files:

  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
🧠 Learnings (10)
📚 Learning: 2026-05-26T21:01:06.666Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11966
File: .changeset/require-tarball-integrity.md:6-6
Timestamp: 2026-05-26T21:01:06.666Z
Learning: In pnpm lockfile-related release notes/docs (especially changeset markdown), preserve URL hostnames exactly as they appear in pnpm-lock.yaml tarball resolution entries—keep hosts like `codeload.github.com`, `bitbucket.org`, and `gitlab.com` in lowercase. Do not “correct” them to title-case/preserve brand capitalization (e.g., LanguageTool rules like `GITHUB` capitalization) because these are literal URL fragments, not platform brand names.

Applied to files:

  • .changeset/tpd-propagation-fix.md
📚 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-resolver/src/resolvePeers.ts
  • installing/deps-resolver/test/resolvePeers.ts
  • installing/deps-installer/test/install/peerDependencies.ts
  • installing/deps-installer/test/install/cyclicPeerDeterminism.ts
📚 Learning: 2026-06-05T13:47:26.046Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/src/install/index.ts:2337-2343
Timestamp: 2026-06-05T13:47:26.046Z
Learning: In the pnpm/pnpm codebase, `PnpmError` automatically prefixes `err.code` with `ERR_PNPM_` when you pass a code that does not already start with `ERR_PNPM_` (it normalizes `this.code` via `code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}``). Therefore, during code review you should NOT flag `new PnpmError(...)` call sites for passing a bare error code (e.g., `new PnpmError('FROZEN_STORE_INCOMPATIBLE_WITH_PNPR', ...)`); the resulting `err.code` will still be `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`.

Applied to files:

  • installing/deps-resolver/src/resolvePeers.ts
  • installing/deps-resolver/test/resolvePeers.ts
  • installing/deps-installer/test/install/peerDependencies.ts
  • installing/deps-installer/test/install/cyclicPeerDeterminism.ts
📚 Learning: 2026-06-05T13:47:05.929Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/test/install/frozenStore.ts:2-17
Timestamp: 2026-06-05T13:47:05.929Z
Learning: In the pnpm/pnpm repository, the shared Jest preset keeps `injectGlobals` at its default (`true`), so `test` and `expect` are available as Jest globals. Therefore, reviewers should not flag (or treat as TypeScript/compilation errors) missing `import { test, expect } from 'jest/globals'` when a test file uses `test`/`expect` without importing them. Importing from `jest/globals` may still be used for consistency with sibling files, but it is not required for execution in this repo unless a Jest preset is explicitly configured with `injectGlobals: false`.

Applied to files:

  • installing/deps-resolver/test/resolvePeers.ts
  • installing/deps-installer/test/install/peerDependencies.ts
  • installing/deps-installer/test/install/cyclicPeerDeterminism.ts
📚 Learning: 2026-05-20T19:40:55.051Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11774
File: pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:0-0
Timestamp: 2026-05-20T19:40:55.051Z
Learning: In the pacquet Rust code, ensure the semver implementation uses the `node-semver` crate (not `nodejs-semver`). `node-semver`’s public API does not include a `satisfies_with_prerelease`-style method; prerelease-tolerant matching should be implemented inline by first calling `Range::satisfies`, and when it rejects a prerelease version, retry matching against a stripped `MAJOR.MINOR.PATCH` base of the prerelease version.

Applied to files:

  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
📚 Learning: 2026-05-22T00:08:44.646Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11837
File: pacquet/crates/resolving-npm-resolver/src/pick_package.rs:33-51
Timestamp: 2026-05-22T00:08:44.646Z
Learning: In the pnpm/pnpm repo’s pacquet Rust crates, do not flag Unicode ellipsis characters (U+2026, `…`) in Rust doc comments (`///` / `/** */`) as a lint violation. The pacquet crate’s `dylint.toml` only enables `perfectionist::derive_ordering`, and the Dylint `unicode-ellipsis` rule is not enabled for this project—so `…` in doc comments is an intentional, repo-consistent style.

Applied to files:

  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
📚 Learning: 2026-05-20T23:07:58.444Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:07:58.444Z
Learning: When reviewing code in this pacquet Rust port, follow the upstream pnpm compatibility rule: only match pnpm’s behavior exactly. Do not propose review changes that intentionally deviate from pnpm’s documented/observed behavior, even if pnpm appears buggy. If you identify a real bug in pnpm behavior, the review should prioritize fixing it upstream in pnpm first, and avoid implementing a pnpm-behavior workaround here unless the same fix has already landed upstream.

Applied to files:

  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
📚 Learning: 2026-05-24T21:10:50.292Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11915
File: pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs:553-617
Timestamp: 2026-05-24T21:10:50.292Z
Learning: When implementing dependency “revisit”/optional-folding in pacquet, mirror pnpm’s `resolveDependencies.ts` behavior: on revisit, update the `optional` flag only for the directly visited package, and do not automatically change `optional` for transitive descendants. pnpm corrects stale optional flags later via `copyDependencySubGraph` (a BFS in `lockfile/pruner/src/index.ts`/`copyDependencySubGraph`). Until pacquet has an equivalent pruner/copy-subgraph step, be aware that the raw `node.optional` can flow through to the lockfile/virtual store via `dependencies_graph_to_lockfile.rs` → `create_virtual_store.rs` → `installability.rs`, so review changes that affect optional flag propagation for this mismatch.

Applied to files:

  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
📚 Learning: 2026-05-24T21:10:50.292Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11915
File: pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs:553-617
Timestamp: 2026-05-24T21:10:50.292Z
Learning: In pacquet’s Rust resolver layer, the behavior of `ResolvedPackage.optional` “AND-folding”/revisit updates is intentionally aligned with pnpm: when a package is directly revisited, only that package’s `optional` flag is updated; transitive descendants are not re-walked/rescored in this stage. pnpm later corrects any stale optional flags downstream via its pruner/BFS logic (`copyDependencySubGraph` + `nonOptional` reachability stamping in the lockfile pruner). Since pacquet does not yet have that pruner equivalent, review should NOT flag the downstream/stale optional propagation as a bug in pacquet PRs; treat the resolver-layer optional propagation gap as expected parity until the pruning/stamping step is ported.

Applied to files:

  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
📚 Learning: 2026-06-06T18:58:37.156Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12243
File: pacquet/crates/package-manager/src/install_package_by_snapshot.rs:319-322
Timestamp: 2026-06-06T18:58:37.156Z
Learning: When reviewing Rust code, do not assume `matches!(expr, Pattern(_))` will move out of `expr` if `Pattern(_)` contains no by-value bindings. `matches!` desugars to a `match` that auto-borrows the scrutinee for discrimination, so even if `expr` is a non-`Copy` value behind a shared reference (e.g., `&T`), the macro should not move-out of the borrowed data purely due to `matches!`. Treat `matches!(&expr, Pattern(_))` as a readability/clarity improvement, not a correctness requirement. Only flag potential move-out-of-borrow risks when the pattern includes by-value bindings that would require moving the matched value.

Applied to files:

  • pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs
  • pacquet/crates/resolving-deps-resolver/src/tests.rs
🔇 Additional comments (13)
.changeset/tpd-propagation-fix.md (2)

6-6: LGTM!


2-2: Confirm changeset package target + clarify Rust changeset need

  • @pnpm/installing.deps-resolver matches the name in installing/deps-resolver/package.json.
  • pacquet/crates/resolving-deps-resolver/Cargo.toml defines Rust crate pacquet-resolving-deps-resolver; confirm whether it’s independently versioned/published via changesets. Add a separate changeset only if it’s externally released/versioned on its own; otherwise the pnpm changeset alone is sufficient.
cspell.json (1)

394-394: LGTM!

installing/deps-resolver/src/resolvePeers.ts (1)

185-225: LGTM!

installing/deps-resolver/test/resolvePeers.ts (1)

1332-1466: LGTM!

pnpr/.fixtures/packages/@pnpm.e2e/has-optional-peer/1.0.0/package.json (1)

1-12: LGTM!

pnpr/.fixtures/packages/@pnpm.e2e/uses-optional-peer/1.0.0/package.json (1)

1-7: LGTM!

installing/deps-installer/test/install/cyclicPeerDeterminism.ts (1)

223-321: LGTM!

pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-a/1.0.0/package.json (1)

1-8: LGTM!

pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-b/1.0.0/package.json (1)

1-8: LGTM!

pnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-parent/1.0.0/package.json (1)

1-10: LGTM!

installing/deps-installer/test/install/peerDependencies.ts (2)

2254-2283: LGTM!


2285-2318: LGTM!

Comment thread pacquet/crates/resolving-deps-resolver/src/tests.rs Outdated
@aqeelat aqeelat force-pushed the fix/transitive-peer-deps-proceedall-cascade branch from 93fef55 to cc98312 Compare June 9, 2026 13:00
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit cc98312

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit db14a31

Comment thread installing/deps-resolver/src/resolvePeers.ts Outdated
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 77ca095

Comment thread pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs Outdated
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.218 ± 0.154 4.034 4.478 2.03 ± 0.13
pacquet@main 4.169 ± 0.133 3.998 4.404 2.00 ± 0.13
pnpr@HEAD 2.162 ± 0.097 2.027 2.329 1.04 ± 0.07
pnpr@main 2.080 ± 0.116 1.971 2.357 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.21847123802,
      "stddev": 0.15352885988903103,
      "median": 4.16082590632,
      "user": 3.9698307999999995,
      "system": 3.5750315199999996,
      "min": 4.03426318082,
      "max": 4.47799269382,
      "times": [
        4.33246338182,
        4.47799269382,
        4.35795368182,
        4.03426318082,
        4.150666305820001,
        4.141812899820001,
        4.07981553782,
        4.17098550682,
        4.066981294820001,
        4.37177789682
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.16892320832,
      "stddev": 0.13271005920861012,
      "median": 4.13669317882,
      "user": 3.9231355999999997,
      "system": 3.54306702,
      "min": 3.9978415758200003,
      "max": 4.40437501082,
      "times": [
        4.08875157082,
        4.04513303082,
        3.9978415758200003,
        4.15338244082,
        4.12513776582,
        4.24388161282,
        4.370440228820001,
        4.11204025482,
        4.40437501082,
        4.14824859182
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1618372598200004,
      "stddev": 0.09740851994529498,
      "median": 2.1396826863200005,
      "user": 2.6793608999999994,
      "system": 3.0119287200000002,
      "min": 2.02737047382,
      "max": 2.3290912458200004,
      "times": [
        2.12722645482,
        2.1738631768200003,
        2.0904325648200004,
        2.11403979082,
        2.20784561982,
        2.02737047382,
        2.3290912458200004,
        2.0846875218200003,
        2.3116768318200003,
        2.1521389178200003
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.08049748662,
      "stddev": 0.11556240250518711,
      "median": 2.04101293182,
      "user": 2.6472868,
      "system": 3.0580617199999995,
      "min": 1.97117448482,
      "max": 2.35692964582,
      "times": [
        2.20348162882,
        2.04725770382,
        2.05201872582,
        2.0144604258200003,
        2.0090376998200004,
        2.03476815982,
        1.97117448482,
        2.35692964582,
        2.0247043968200003,
        2.09114199482
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 640.8 ± 11.5 622.2 660.4 1.00
pacquet@main 643.9 ± 54.5 620.1 798.4 1.00 ± 0.09
pnpr@HEAD 694.0 ± 25.6 658.4 729.7 1.08 ± 0.04
pnpr@main 725.6 ± 98.5 655.8 997.9 1.13 ± 0.16
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6408332789000001,
      "stddev": 0.0114926252826564,
      "median": 0.6397796507,
      "user": 0.3807321,
      "system": 1.34125924,
      "min": 0.6221642942,
      "max": 0.6604143932000001,
      "times": [
        0.6483605982,
        0.6364583062,
        0.6495793562,
        0.6221642942,
        0.6363952392,
        0.6402248672,
        0.6491579292,
        0.6262433712,
        0.6393344342,
        0.6604143932000001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6439185584,
      "stddev": 0.05449340237701222,
      "median": 0.6283859422,
      "user": 0.37694609999999995,
      "system": 1.3395242399999998,
      "min": 0.6201445642,
      "max": 0.7983782372,
      "times": [
        0.6364773312,
        0.6225560122,
        0.6278801412,
        0.6220322942000001,
        0.6288917432,
        0.6201445642,
        0.6296888812,
        0.7983782372,
        0.6230714882,
        0.6300648912
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6940341023000001,
      "stddev": 0.025622188984494008,
      "median": 0.6983357202,
      "user": 0.38937009999999994,
      "system": 1.38092544,
      "min": 0.6584148832,
      "max": 0.7296772552,
      "times": [
        0.7296772552,
        0.7087092662,
        0.6762307022,
        0.6973341832000001,
        0.6594026882,
        0.6742275842000001,
        0.7206041632,
        0.6993372572000001,
        0.7164030402,
        0.6584148832
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.7255583092,
      "stddev": 0.0985026115607838,
      "median": 0.6993193447,
      "user": 0.3886728,
      "system": 1.37813454,
      "min": 0.6558058592,
      "max": 0.9979370812,
      "times": [
        0.7083432992,
        0.7021324792,
        0.7436664502,
        0.6890123552,
        0.7030765162,
        0.6965062102,
        0.6558058592,
        0.6889832812,
        0.6701195602,
        0.9979370812
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.267 ± 0.055 4.180 4.353 2.01 ± 0.07
pacquet@main 4.274 ± 0.021 4.237 4.303 2.02 ± 0.07
pnpr@HEAD 2.120 ± 0.069 2.045 2.271 1.00
pnpr@main 2.144 ± 0.104 2.022 2.336 1.01 ± 0.06
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.2665277993199995,
      "stddev": 0.05505124945059123,
      "median": 4.27313935902,
      "user": 3.8201446599999995,
      "system": 3.4221742999999996,
      "min": 4.1796426240199995,
      "max": 4.35261862502,
      "times": [
        4.3181988880199995,
        4.35261862502,
        4.28640928102,
        4.28495552002,
        4.305467839019999,
        4.2613231980199995,
        4.23732691002,
        4.252244151019999,
        4.1796426240199995,
        4.18709095702
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.2742187532199996,
      "stddev": 0.021332292802897976,
      "median": 4.281203421519999,
      "user": 3.73822726,
      "system": 3.4482106999999997,
      "min": 4.23734060702,
      "max": 4.303171993019999,
      "times": [
        4.29239070802,
        4.280149596019999,
        4.26498823702,
        4.29250821902,
        4.28438001802,
        4.25319798102,
        4.303171993019999,
        4.25180292602,
        4.2822572470199995,
        4.23734060702
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.11966153332,
      "stddev": 0.06917993968495775,
      "median": 2.1040412390200003,
      "user": 2.5715984599999997,
      "system": 2.9562056999999995,
      "min": 2.04508234002,
      "max": 2.27096017802,
      "times": [
        2.0852035310200003,
        2.1228789470200002,
        2.07085316102,
        2.06556381802,
        2.04508234002,
        2.13528044402,
        2.27096017802,
        2.0704470570200004,
        2.19071040702,
        2.13963545002
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.14420054752,
      "stddev": 0.10403053165653779,
      "median": 2.1387988835200002,
      "user": 2.49338336,
      "system": 2.9466466999999996,
      "min": 2.02163448902,
      "max": 2.3362046970200003,
      "times": [
        2.27070685702,
        2.1199159830200003,
        2.02575787002,
        2.1660141700200004,
        2.1289070850200003,
        2.1486906820200002,
        2.03431018902,
        2.3362046970200003,
        2.02163448902,
        2.18986345302
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.386 ± 0.014 1.355 1.401 2.07 ± 0.04
pacquet@main 1.395 ± 0.033 1.367 1.483 2.09 ± 0.06
pnpr@HEAD 0.668 ± 0.011 0.651 0.689 1.00
pnpr@main 0.673 ± 0.060 0.645 0.843 1.01 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.3864902373599999,
      "stddev": 0.013606459823146979,
      "median": 1.3907397484600001,
      "user": 1.3798172799999997,
      "system": 1.75901418,
      "min": 1.35534940046,
      "max": 1.40098291846,
      "times": [
        1.40098291846,
        1.39272471946,
        1.39324759146,
        1.38875477746,
        1.39528847246,
        1.3815615054600001,
        1.37677130446,
        1.38077283046,
        1.35534940046,
        1.39944885346
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.39503556096,
      "stddev": 0.03323025945647913,
      "median": 1.38424484596,
      "user": 1.3890277799999997,
      "system": 1.76611558,
      "min": 1.36657896846,
      "max": 1.4826210454600002,
      "times": [
        1.36657896846,
        1.38563165746,
        1.4826210454600002,
        1.4077628204600001,
        1.38438690546,
        1.3755789384600001,
        1.37552830046,
        1.3841027864600002,
        1.4044021954600001,
        1.3837619914600001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.66839017256,
      "stddev": 0.01088036705201163,
      "median": 0.66697539796,
      "user": 0.34634068,
      "system": 1.31083038,
      "min": 0.6505345544600001,
      "max": 0.68884582246,
      "times": [
        0.6647518114600001,
        0.67883588646,
        0.6505345544600001,
        0.67279700046,
        0.68884582246,
        0.66545700746,
        0.6740767794600001,
        0.6618568574600001,
        0.6684937884600001,
        0.6582522174600001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.67268888946,
      "stddev": 0.060308797668547236,
      "median": 0.65262639446,
      "user": 0.3422946800000001,
      "system": 1.30611168,
      "min": 0.64523288546,
      "max": 0.84324470746,
      "times": [
        0.6487323194600001,
        0.65198420946,
        0.65999653546,
        0.64523288546,
        0.6605024634600001,
        0.64750746146,
        0.64959309046,
        0.65326857946,
        0.84324470746,
        0.66682664246
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.084 ± 0.025 3.035 3.129 4.67 ± 0.06
pacquet@main 3.115 ± 0.070 3.069 3.306 4.72 ± 0.11
pnpr@HEAD 0.660 ± 0.006 0.652 0.674 1.00
pnpr@main 0.670 ± 0.053 0.645 0.818 1.02 ± 0.08
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.08371443652,
      "stddev": 0.024639205466127242,
      "median": 3.08682065252,
      "user": 1.82479996,
      "system": 2.0172875200000004,
      "min": 3.0351202975200002,
      "max": 3.1285479025200003,
      "times": [
        3.09009109852,
        3.0351202975200002,
        3.09667224852,
        3.09572420652,
        3.07373116852,
        3.09670351352,
        3.07054258452,
        3.1285479025200003,
        3.0664611385200002,
        3.08355020652
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.11453874482,
      "stddev": 0.06961255893076101,
      "median": 3.09396206902,
      "user": 1.82287606,
      "system": 2.0444323199999994,
      "min": 3.06899638152,
      "max": 3.30573965952,
      "times": [
        3.10509806552,
        3.10399967252,
        3.08652042552,
        3.1014037125200002,
        3.13243934052,
        3.07979303952,
        3.06899638152,
        3.30573965952,
        3.0790379475200003,
        3.0823592035200003
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.66015731932,
      "stddev": 0.005821634635198248,
      "median": 0.6582443340200002,
      "user": 0.33829926,
      "system": 1.3084053199999999,
      "min": 0.6524822075200001,
      "max": 0.67387234652,
      "times": [
        0.6524822075200001,
        0.67387234652,
        0.6578033615200001,
        0.6641545005200001,
        0.6562065135200001,
        0.6586853065200001,
        0.65743233452,
        0.6575788575200001,
        0.6617654175200001,
        0.6615923475200001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.67023482042,
      "stddev": 0.05267206085908093,
      "median": 0.6536663665200001,
      "user": 0.33255696,
      "system": 1.3068773199999997,
      "min": 0.6446392665200001,
      "max": 0.8182350305200001,
      "times": [
        0.6604762485200001,
        0.64510210452,
        0.6573689855200001,
        0.6477548565200001,
        0.6639376075200001,
        0.6446392665200001,
        0.6682500945200001,
        0.64662026252,
        0.6499637475200001,
        0.8182350305200001
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12286
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,266.53 ms
(+2.41%)Baseline: 4,165.92 ms
4,999.11 ms
(85.35%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,083.71 ms
(+3.13%)Baseline: 2,990.12 ms
3,588.15 ms
(85.94%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,386.49 ms
(+5.80%)Baseline: 1,310.44 ms
1,572.53 ms
(88.17%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,218.47 ms
(+6.99%)Baseline: 3,943.00 ms
4,731.60 ms
(89.16%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
640.83 ms
(+3.51%)Baseline: 619.08 ms
742.90 ms
(86.26%)
🐰 View full continuous benchmarking report in Bencher

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c5135ce

@codecov-commenter

codecov-commenter commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.03%. Comparing base (d36b6f8) to head (f10973e).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12286      +/-   ##
==========================================
+ Coverage   88.00%   88.03%   +0.02%     
==========================================
  Files         308      308              
  Lines       41395    41423      +28     
==========================================
+ Hits        36431    36466      +35     
+ Misses       4964     4957       -7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Looking for bugs?

Check back in a few minutes. An AI review agent is analyzing this pull request.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit bfb0930

Comment thread pacquet/crates/cli/tests/install.rs Outdated
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3af5991

Comment thread installing/deps-resolver/src/resolvePeers.ts Outdated
@astegmaier

astegmaier commented Jun 10, 2026

Copy link
Copy Markdown

@aqeelat - I was super excited to see that PR might be a fix to #11834. Unfortunately, I tested it out, and it does not (in its current state).

Here's the steps I used to test:

  1. clone https://github.com/astegmaier/playground-pnpm-circular-peers-bug
  2. Set up pd to run your PR branch (pnpm install / pnpm add ./pnpm/dev -g / pnpm run compile — see CONTRIBUTING.md).
  3. Resolve the playground from scratch:
    cd /path/to/playground-pnpm-circular-peers-bug
    rm -rf node_modules pnpm-lock.yaml
    pd install
  4. Inspect the resulting pnpm-lock.yaml's snapshots: section:
    awk '/^snapshots:/{f=1} f && /^  (webpack|terser-webpack-plugin)@/' pnpm-lock.yaml
    You'll see:
      webpack@5.107.2:
      webpack@5.107.2(esbuild@0.27.7):
      terser-webpack-plugin@5.6.1(esbuild@0.27.7)(webpack@5.107.2(esbuild@0.27.7)):
      terser-webpack-plugin@5.6.1(webpack@5.107.2):
    
    …i.e. two webpack@ snapshots and two terser-webpack-plugin@ snapshots — the exact symptom dedupePeerDependents doesn't deduplicate when there's a cyclic relationship between packages #11834 reports. The fixed state would be one of each (just the (esbuild@0.27.7) variants).

It would be awesome if we could fix #11834, although I'm not sure this PR is the right vehicle. I'm new to pnpm, but I had an agent come up with a fix for it in astegmaier#1 (I didn't want to spam y'all with a PR I don't understand, but if you or your agent finds it a helpful reference point, I wanted to mention it).

@aqeelat

aqeelat commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@astegmaier i don't know why my agent included that issue. I'll have to investigate more. If the issue turns out to not be fully related then I'll ping you so you can make open your PR.

I'm also new to pnpm and I'm not affiliated at all. But I still think you should open the PR regardless.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit b31e7dd

@aqeelat

aqeelat commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@astegmaier sorry for the false hope. This PR does not solve your issue. I suggest you open your PR.

@aqeelat

aqeelat commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume full review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

@aqeelat: I'll kick off a full review of the PR now.

✅ Action performed

Full review finished.

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12286
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,119.66 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
660.16 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
668.39 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,161.84 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
694.03 ms
🐰 View full continuous benchmarking report in Bencher

@astegmaier

Copy link
Copy Markdown

@astegmaier sorry for the false hope. This PR does not solve your issue. I suggest you open your PR.

Thanks for looking into it - will do.

@aqeelat

aqeelat commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

@zkochan
the PR should be ready now

thank you 🙏🏼

aqeelat added 16 commits June 16, 2026 11:52
…to fix lockfile churn

When a cycle in the dependency graph causes a node to be marked pure
via the purePkgs fast-path, its transitive peer deps are lost. Ancestors
that reach the same package through a second path hit the cached pure
result and never see those peers. This causes tpd to shift between
packages when unrelated dependencies are upgraded.

Add a post-walk fixed-point loop that propagates tpd from children to
parents through the depGraph, filtering out peers the parent declares
in its own peerDependencies. Runs after resolveChildren (TS) /
patch_pending_peer_edges (Rust) so the children map is populated.

Both the TypeScript and Rust (pacquet) sides are ported in parity.

Tests:
- TS: unit test in resolvePeers.ts, integration test in cyclicPeerDeterminism.ts
- Rust: direct graph-construction tests in propagate_tpd_unit (3 cases),
  topology tests in tpd_propagation (cycle + 3-parent)
Replace the fixed-point loop that rescans the entire dep graph on
every iteration with a worklist-based approach:

1. Build a parent index (child → parents) in O(E)
2. Seed worklist with nodes that have non-empty tpd
3. For each node in worklist, propagate its tpd to parents
4. Parents that gained new tpd are added to the worklist

Complexity drops from O(iterations × V × avg_children) to
O(total_propagations × avg_parents) — proportional to actual
propagation work, not graph size.
… regression

The dylint perfectionist lint flagged the propagate_tpd_unit module's
imports as not organized by granularity. Merge the separate
use crate::resolve_peers line into the existing use crate:: block.

The test compatible_existing_peer_contexts_survive_writable_lockfile_regeneration
fails because the TPD propagation correctly adds peer-c to
abc-grand-parent-with-c's transitive deps, which changes the lockfile
and exposes a pre-existing pacquet lockfile-reuse bug where distinct
peer contexts get collapsed when TPD is present. Mark the test as
ignored with a tracking reference until the reuse path is fixed.
…edupe

The propagation step that lifts transitivePeerDependencies from children
to parents had two bugs:

1. It did not check whether the parent already has the peer as a direct
   child (parent.children[tpd]).  This caused TPD to be incorrectly
   added to nodes that satisfy the peer through a regular dependency,
   which then caused lockfile-reuse to drop the dep because reuse
   filters out aliases present in TPD.

2. It ran before dedupeInjectedDeps and deduplicateAll, so the parentsOf
   index and TPD sets could become stale relative to the deduped graph.

Fix both issues by adding the children guard to the propagation filter
and moving propagation after both dedupe passes.  Un-ignore the
lockfile-reuse regression test that these fixes resolve.

Applies to both TypeScript (resolvePeers.ts) and Rust (resolve_peers.rs).
Adds a unit test for the children guard on the Rust side.
…ation

The worklist is seeded from graph.iter() and propagation only adds to
TPD sets, never removes entries — so graph.get(child_path) can never
return None and transitive_peer_dependencies can never become empty
after seeding. Replace the match with an expect() and drop the
is_empty() early return.
The children-guard + move-after-dedupe fixes resolved the lockfile-reuse
bug. The test compatible_existing_peer_contexts_survive_writable_lockfile_regeneration
now passes, so the "Ignored" doc comment is misleading.
Borrow the parents slice from the parents_of index instead of cloning
the entire Vec on every worklist dequeue. The borrow lifetime is valid
because the diffs are collected into a separate Vec before mutating
the graph.
…uard

Check resolved package names in addition to aliases when guarding
against transitive peer dependency propagation. Adds Rust unit test
that verifies the guard blocks TPD when parent provides the peer
via npm alias.

Note: this may be addressing a symptom rather than root cause.
The real issue may be at line 505 where allPeerDepNames.has(alias)
filters out aliased deps whose real name IS a peer dep name.
…ckage name

When building parentPkgs from children, also check the child's resolved
package name (not just alias) against allPeerDepNames. Without this,
aliased dependencies (e.g. my-alias@npm:real-pkg) were invisible to peer
resolution, causing peers to be incorrectly reported as missing.

Adds a regression test and aligns TS with the existing pacquet fix.
@zkochan zkochan force-pushed the fix/transitive-peer-deps-proceedall-cascade branch from b31e7dd to e08e630 Compare June 16, 2026 10:43
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e08e630

Comment thread pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs Outdated
zkochan added 2 commits June 16, 2026 14:03
A cycle re-entry resolves against truncated children (the cycle is broken by
dropping the repeated package's subtree), so its empty or partial peer sets are
not authoritative for the package as a whole. Caching that verdict — as pure or
in the peers cache — let it short-circuit other occurrences of the same package
that can see the full subtree, dropping their transitivePeerDependencies
depending on traversal order and churning the lockfile.

Skip the purePkgs/peersCache population for cycle re-entries in both the
TypeScript resolver and the pacquet port, replacing the earlier post-resolution
propagation pass. Make the existing peerDependencies integration test a real
regression guard by giving the fixture an actual cycle, and add a matching
pacquet unit test.

pnpm#5108
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit f10973e

@zkochan zkochan changed the title fix(resolver): propagate transitivePeerDependencies to fix lockfile churn fix(resolver): stabilize transitivePeerDependencies in dependency cycles Jun 16, 2026
@zkochan zkochan enabled auto-merge (squash) June 16, 2026 12:19
@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results

# Scenario main HEAD
1 Isolated linker: fresh restore, hot cache + hot store 2.32s ± 0.068s 2.29s ± 0.028s
2 Isolated linker: fresh add new dep, hot cache + hot store 6.228s ± 0.056s 6.324s ± 0.178s
3 Isolated linker: fresh install, hot cache + hot store 6.263s ± 0.061s 6.346s ± 0.112s
4 Isolated linker: fresh restore, cold cache + cold store 8.01s ± 0.181s 7.982s ± 0.107s
5 Isolated linker: fresh install, cold cache + cold store 13.953s ± 3.091s 12.839s ± 0.923s
6 GVS linker: fresh restore, hot cache + hot store 1.226s ± 0.154s 1.165s ± 0.022s

Run 27616656607 · 10 runs per scenario · triggered by @zkochan

@zkochan zkochan merged commit 817f99d into pnpm:main Jun 16, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants