fix(resolver): stabilize transitivePeerDependencies in dependency cycles#12286
Conversation
Code Review by Qodo
Context used 1. Aliased deps bypass TPD filter
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis 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. ChangesTransitive Peer Dependencies Propagation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Summary by QodoFix lockfile churn by propagating transitivePeerDependencies via worklist pass WalkthroughsUser DescriptionSummaryFixes lockfile churn where Root cause: The Fix: Post-processing worklist-based propagation loop that runs after all peer resolutions complete. Seeds a worklist with nodes that have non-empty Both the TypeScript pnpm CLI and the Rust (pacquet) port are updated in parity. Fixes #5108 Test plan
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. Diagramgraph 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
High-Level AssessmentThe following are alternative approaches to this PR: 1. Fix purePkgs caching to retain missing peers/TPD in cached results
2. Disable/limit purePkgs fast-path when cycles are detected
3. Compute transitivePeerDependencies at lockfile emit time by traversing snapshots
Recommendation: The PR’s approach (a worklist-based propagation pass after child edges are finalized) is the most robust: it makes File ChangesBug fix (2)
Tests (9)
Documentation (1)
Other (1)
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@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
📒 Files selected for processing (13)
.changeset/tpd-propagation-fix.mdcspell.jsoninstalling/deps-installer/test/install/cyclicPeerDeterminism.tsinstalling/deps-installer/test/install/peerDependencies.tsinstalling/deps-resolver/src/resolvePeers.tsinstalling/deps-resolver/test/resolvePeers.tspacquet/crates/resolving-deps-resolver/src/resolve_peers.rspacquet/crates/resolving-deps-resolver/src/tests.rspnpr/.fixtures/packages/@pnpm.e2e/has-optional-peer/1.0.0/package.jsonpnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-a/1.0.0/package.jsonpnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-carrier-b/1.0.0/package.jsonpnpr/.fixtures/packages/@pnpm.e2e/transitive-peer-parent/1.0.0/package.jsonpnpr/.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.tsinstalling/deps-resolver/test/resolvePeers.tsinstalling/deps-installer/test/install/peerDependencies.tsinstalling/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 firespnpm:<channel>events throughglobalLogger,logger.debug(...), orstreamParser.write(...), mirror the call site, payload, and ordering so@pnpm/cli.default-reporterparses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing the brand into a plainStringor&strin Rust
If upstream TypeScript always validates before construction of a branded string, validate in the Rust wrapper too viaTryFrom<String>and/orFromStrand 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 infallibleFrom<String>constructor
If upstream TypeScript occasionally constructs a branded string without validation, exposefrom_str_uncheckedin 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 handwritingimplblocks; use manualimplonly when conversion needs custom logic
Model TypeScript string literal unions (like'auto' | 'always' | 'never') as Rustenums 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 inCODE_STYLE_GUIDE.md— imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching,pipe-trait, error handling, test layout, and cloning ofArcandRc
Choose owned vs. borrowed parameters to minimize copies; widen to t...
Files:
pacquet/crates/resolving-deps-resolver/src/resolve_peers.rspacquet/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.tsinstalling/deps-resolver/test/resolvePeers.tsinstalling/deps-installer/test/install/peerDependencies.tsinstalling/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.tsinstalling/deps-resolver/test/resolvePeers.tsinstalling/deps-installer/test/install/peerDependencies.tsinstalling/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.tsinstalling/deps-installer/test/install/peerDependencies.tsinstalling/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.rspacquet/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.rspacquet/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.rspacquet/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.rspacquet/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.rspacquet/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.rspacquet/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-resolvermatches thenameininstalling/deps-resolver/package.json.pacquet/crates/resolving-deps-resolver/Cargo.tomldefines Rust cratepacquet-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!
93fef55 to
cc98312
Compare
|
Code review by qodo was updated up to the latest commit cc98312 |
|
Code review by qodo was updated up to the latest commit db14a31 |
|
Code review by qodo was updated up to the latest commit 77ca095 |
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
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
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
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
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
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
]
}
]
} |
|
| Branch | pr/12286 |
| Testbed | pacquet |
Click to view all benchmark results
| Benchmark | Latency | Benchmark 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%) |
|
Code review by qodo was updated up to the latest commit c5135ce |
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
Looking for bugs?Check back in a few minutes. An AI review agent is analyzing this pull request. |
|
Code review by qodo was updated up to the latest commit bfb0930 |
|
Code review by qodo was updated up to the latest commit 3af5991 |
|
@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:
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). |
|
@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. |
|
Code review by qodo was updated up to the latest commit b31e7dd |
|
@astegmaier sorry for the false hope. This PR does not solve your issue. I suggest you open your PR. |
|
@coderabbitai resume full review |
|
✅ Action performedFull review finished. |
|
| Branch | pr/12286 |
| Testbed | pnpr |
⚠️ 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-thresholdsflag.
Click to view all benchmark results
| Benchmark | Latency | milliseconds (ms) |
|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot | 2,119.66 ms |
| isolated-linker.fresh-install.cold-cache.hot-store | 📈 view plot | 660.16 ms |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot | 668.39 ms |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot | 2,161.84 ms |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot | 694.03 ms |
Thanks for looking into it - will do. |
|
@zkochan thank you 🙏🏼 |
…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.
b31e7dd to
e08e630
Compare
|
Code review by qodo was updated up to the latest commit e08e630 |
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
|
Code review by qodo was updated up to the latest commit f10973e |
Benchmark Results
Run 27616656607 · 10 runs per scenario · triggered by @zkochan |
Summary
Fixes lockfile churn where a package's
transitivePeerDependencies(e.g.supports-colorviadebug) 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 thepurePkgsshort-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 viafindHit. This is a minimal guard at the cache-population site: it adds no post-resolution pass and does not changetransitivePeerDependenciesfor 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-pkgis visible to peer resolution whenreal-pkgis 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 thetransitivePeerDependencies-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
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.@pnpm/installing.deps-resolver), 143/143 (pacquetresolving-deps-resolver);cargo clippyandrustfmtclean.Written by an agent (Claude Code, claude-opus-4-8).