Skip to content

fix: converge incremental install by refreshing stale transitive pins (alternative to #11502)#12472

Merged
zkochan merged 2 commits into
mainfrom
feat/refresh-stale-lockfile-pins
Jun 18, 2026
Merged

fix: converge incremental install by refreshing stale transitive pins (alternative to #11502)#12472
zkochan merged 2 commits into
mainfrom
feat/refresh-stale-lockfile-pins

Conversation

@zkochan

@zkochan zkochan commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

A prototype alternative to the post-resolution dedupe pass in #11502. Instead of resolving a lower version, fetching it (and its subtree), then rewriting the edge and pruning the orphan, this dedupes during resolution so the lower version is never resolved or fetched.

Background

On a fresh resolve pnpm already dedupes: every level of the tree walk seeds preferredVersions with the versions resolved in ancestor/sibling packages, and direct deps additionally get DIRECT_DEP_SELECTOR_WEIGHT. So a transitive foo@^1.0.0 already prefers a foo@1.1.0 that a direct dependency resolved to.

The duplicate that #11502 targets is purely a lockfile-stickiness artifact: a package that isn't being re-resolved (its manifest didn't change) has its child edges taken verbatim from the lockfile, bypassing the preferred-versions walk. That keeps a stale lower version pinned even though a direct dependency now resolves to a higher version in the same range — and because it's a normally-resolved package, its tarball gets fetched.

Approach

In getDepsToResolve, when a child edge is about to be reused from its lockfile pin, check whether a direct dependency (the deterministic, resolved-first anchor, marked with DIRECT_DEP_SELECTOR_WEIGHT) already resolved to a higher version that satisfies the edge's range. If so, force the edge onto that version via preferredVersion (singular), which overrides the EXISTING_VERSION_SELECTOR_WEIGHT stability bias that would otherwise re-pick the lower version. The lower version is never resolved and never fetched — no wasted download, no post-resolution pruning.

Comparison with #11502

#11502 (post-pass) This PR (resolution-time)
Lower version fetched then discarded yes no — never resolved
Mechanism new O(E) graph rewrite + prune existing preferredVersion lever
Reachability-flag (optional/prod/dev) staleness possible n/a — normal resolution computes flags
New config / pacquet surface a new setting none — pure resolver behavior
Scope direct↔transitive and transitive↔transitive direct↔transitive only

Scope / limitations

By design this only dedupes a transitive against a direct-dependency version — the deterministic anchor, and the shape of every motivating case in #11502. It deliberately does not touch the transitive-vs-transitive case, which is the order-dependent one that motivated the original removal of automatic deduping (#11110).

This is a behavior change from the post-#11110 "keep already-installed versions" default, so it is opened as a draft for discussion: whether it should be the default or gated behind a setting, plus a wider installer sweep before shipping.

Pacquet port (included)

The matching pacquet (Rust) port is in this PR. pacquet's full-subtree lockfile reuse (resolve_reused_node) is coarser than pnpm's per-edge reuse — it reuses a parent's children straight from the snapshot's exact pins and carries neither the resolved direct-dep versions nor the parent manifest's ranges the refresh needs. Three coordinated changes bridge that, mirroring pnpm's phase structure:

  • Record, per importer, which direct deps changed vs. the prior lockfile (race-free) and the resolved direct-dep versions (after the direct-dep level barrier).
  • Decline full-subtree reuse for a parent that depends on a changed direct dep, so it resolves fresh and walks its children against their real manifest ranges — where the range is available.
  • In walk_node_children, force a child edge onto a higher direct-dep version when the recorded pin is below it and it still satisfies the edge's range. The lower version is never resolved or fetched.

Both maps are keyed by importer to match pnpm's per-importer preferredVersions. New pacquet tests (stale_pin_dedupe.rs) cover the exact-version and caret-range (pnpm add) cases; the resolver (143), package-manager (537), and reuse/dedupe/add/update/parity CLI suites pass, as do fmt/clippy/doc/dylint/taplo.

Tests

  • New refreshes a stale transitive pin to a higher direct-dependency version at resolution time (the exact scenario feat: smarter install dedupe behavior #11502 targeted; 100.0.0 is absent from the lockfile because it is never resolved).
  • All existing pass locally: 117 deps-resolver tests, full dedupe.ts, dedupeDirectDeps/dedupeInWorkspace/autoInstallPeers, update.ts, peerDependencies.ts. The one lockfile.ts failure here is a pre-existing offline failure (it fetches a real registry.npmjs.org tarball).

🤖 This PR was prepared by Claude Code (model: Opus 4.8) on behalf of @zkochan.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a lockfile non-convergence issue during incremental installs where stale transitive pins could remain after direct dependency upgrades. Incremental installs now refresh those pins to match the newer direct-dependency resolution and prune the outdated entries.
    • Ensured aliased transitive dependencies are not refreshed incorrectly.
  • Tests
    • Added Jest and Rust integration/regression tests covering exact and caret direct updates, multi-importer convergence, idempotency, and prerelease-aware higher-version selection.
  • Chores
    • Published a patch changeset documenting and shipping the fix.

Squash-merge commit message

fix: converge incremental install by refreshing stale transitive pins

When a package is reused from the lockfile, its child edges are taken
verbatim and bypass the preferred-versions walk, so a transitive
dependency can stay pinned to an older version even after a direct
dependency resolved to a higher version that satisfies the same range —
leaving the lockfile non-convergent (an incremental install keeps a
duplicate that a fresh install would not).

The resolver now refreshes such a stale pin to the higher
direct-dependency version during resolution, via `preferredVersion`
(singular), which overrides the EXISTING_VERSION_SELECTOR_WEIGHT stability
bias. The older version is never resolved or fetched, and the incremental
result converges to what a fresh install produces. The pick is anchored to
direct dependencies (which resolve first), so it restores the automatic
dedupe removed in pnpm/pnpm#11110 without reintroducing its
non-determinism, and unlike the post-pass in pnpm/pnpm#11502 it does not
over-fetch.

pacquet is ported in the same change. Its full-subtree lockfile reuse is
coarser than pnpm's per-edge reuse, so it records per importer which direct
deps changed and their resolved versions, declines full-subtree reuse for a
parent that depends on a changed direct dep, and forces the higher version
in the child walk. Range satisfaction uses plain semver (not
prerelease-inclusive), matching pnpm's semver.satisfies(.., true).

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0422622b-0293-4557-881d-919ccef5088a

📥 Commits

Reviewing files that changed from the base of the PR and between 4a27779 and a738dc9.

📒 Files selected for processing (7)
  • .changeset/refresh-stale-lockfile-pins.md
  • installing/deps-installer/test/install/dedupe.ts
  • installing/deps-installer/test/install/multipleImporters.ts
  • installing/deps-resolver/src/resolveDependencies.ts
  • pacquet/crates/cli/tests/stale_pin_dedupe.rs
  • pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs
  • pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree/tests.rs
✅ Files skipped from review due to trivial changes (1)
  • .changeset/refresh-stale-lockfile-pins.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree/tests.rs
  • pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs

📝 Walkthrough

Walkthrough

Fixes a lockfile non-convergence bug in incremental installs: when a reused lockfile entry contains stale transitive pins, the resolver now detects a higher direct-dependency version and overrides the pin. The fix is implemented in both the pnpm TypeScript resolver (resolveDependencies.ts) and the pacquet Rust resolver (resolve_dependency_tree.rs), with integration and unit tests for each.

Changes

pnpm TypeScript Resolver – Stale Pin Override

Layer / File(s) Summary
Alias pin override logic in getDepsToResolve
installing/deps-resolver/src/resolveDependencies.ts
resolveDependencies now passes preferredVersions into getDepsToResolve. The options type gains an optional preferredVersions field. The alias/pinned-reference branch is refactored: two new helpers (getPinnedNameVer, findHigherDirectDepVersion) detect whether a higher DIRECT_DEP_SELECTOR_WEIGHT version satisfies the pinned dep's range; if so, proceed is set to true and preferredVersion is assigned, overriding the lockfile pin. The preferredVersion field is now populated in each extendedWantedDeps entry.
Regression tests and changeset
installing/deps-installer/test/install/dedupe.ts, installing/deps-installer/test/install/multipleImporters.ts, .changeset/refresh-stale-lockfile-pins.md
Adds three Jest regression tests to dedupe.ts: one upgrading a direct dep from 100.0.0 to 100.1.0 and asserting lockfile adoption, another confirming no refresh for aliased transitive dependencies, and a third verifying idempotency. A multi-importer test in multipleImporters.ts checks lockfile convergence across projects when one bumps a direct dep. The changeset entry documents the patch releases for @pnpm/installing.deps-resolver and pnpm.

pacquet Rust Resolver – Stale Pin Refresh Gate

Layer / File(s) Summary
WorkspaceTreeCtx state and helper functions
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs
WorkspaceTreeCtx gains two mutex-protected maps: changed_direct_deps (per-importer aliases whose specifier changed vs. prior lockfile) and direct_dep_versions (per-importer resolved versions by name). A DirectDepVersions type alias maps direct dependency name to parsed resolved versions. Default initializes both maps to empty. Four helper functions are added: computing changed direct dep specifiers from prior lockfile, recording resolved versions per importer, checking if a prior snapshot depends on a changed dep, and finding the highest higher direct-dep version satisfying a child range. Imports are reorganized accordingly.
extend_tree, resolve_node_seed, and walk_node_children wiring
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs
extend_tree calls compute_changed_direct_deps before the level barrier and records resolved direct versions after it, switching children_overlay to use direct_versions. resolve_node_seed adds a gate that declines lockfile reuse when the prior snapshot depended on a changed direct dep. walk_node_children derives a forced_version for each child edge and, when one exists, overwrites the child bare_specifier and clears its prior snapshot key.
Rust integration and unit tests
pacquet/crates/cli/tests/stale_pin_dedupe.rs, pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree/tests.rs
Adds integration test helpers (pacquet_at, write_manifest) and three integration tests: one for exact-version upgrade (100.0.0100.1.0), one for caret-range upgrade (^100.1.0), and one verifying no refresh for aliased transitive dependencies. Adds unit tests for higher_direct_dep_version covering semver range selection, prerelease handling, and edge cases.

Sequence Diagram(s)

sequenceDiagram
  participant Installer
  participant resolveDependencies
  participant getDepsToResolve
  participant findHigherDirectDepVersion
  rect rgba(100, 149, 237, 0.5)
    Note over Installer,findHigherDirectDepVersion: Incremental install with bumped direct dep
  end
  Installer->>resolveDependencies: resolve deps with preferredVersions
  resolveDependencies->>getDepsToResolve: { wantedDeps, preferredVersions }
  getDepsToResolve->>getDepsToResolve: alias found with lockfile pin
  getDepsToResolve->>findHigherDirectDepVersion: (pinnedRange, preferredVersions, DIRECT_DEP_SELECTOR_WEIGHT)
  alt higher direct version satisfies range
    findHigherDirectDepVersion-->>getDepsToResolve: higherVersion (e.g. 100.1.0)
    getDepsToResolve->>getDepsToResolve: proceed=true, preferredVersion=higherVersion, skip pin reuse
    getDepsToResolve-->>resolveDependencies: extendedWantedDeps with preferredVersion
    resolveDependencies-->>Installer: lockfile updated to 100.1.0, stale 100.0.0 removed
  else no higher version
    findHigherDirectDepVersion-->>getDepsToResolve: undefined
    getDepsToResolve->>getDepsToResolve: reuse pinned lockfile reference
    getDepsToResolve-->>resolveDependencies: extendedWantedDeps (pin reused)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/refresh-stale-lockfile-pins

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


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.

@zkochan zkochan changed the title feat: dedupe stale transitive pins at resolution time (alternative to #11502) fix: converge incremental install by refreshing stale transitive pins (alternative to #11502) Jun 17, 2026
zkochan added a commit that referenced this pull request Jun 17, 2026
…igher direct deps

Port of the pnpm fix in #12472. When a package is reused from
the prior lockfile, its child edges are taken verbatim and bypass the
preferred-versions walk, so a transitive dependency can stay pinned to an
older version even after a direct dependency resolved to a higher version
that satisfies the same range — leaving the lockfile non-convergent (an
incremental install keeps a duplicate a fresh install would not).

pacquet's full-subtree reuse (resolve_reused_node) is coarser than pnpm's
per-edge reuse and carries neither the direct-dep versions nor the parent
manifest's ranges that the refresh needs. Three coordinated changes bridge
that, mirroring pnpm's phase structure:

- Record, per importer, which direct deps changed vs. the prior lockfile
  (race-free, from the manifest + lockfile) and the resolved direct-dep
  versions (after the direct-dep level barrier).
- Decline full-subtree reuse for a parent that depends on a changed direct
  dep, so it resolves fresh and walks its children against their real
  manifest ranges (extract_children), where the range is available.
- In walk_node_children, when a child's recorded pin is below a higher
  direct-dep version that still satisfies the edge's range, force the edge
  onto that version. The lower version is never resolved or fetched — no
  wasted download, no post-resolution pruning.

Both maps are keyed by importer to match pnpm's per-importer
preferredVersions, so one importer's direct dep never refreshes another's
transitive pins.
@codecov-commenter

codecov-commenter commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12472      +/-   ##
==========================================
+ Coverage   88.12%   88.15%   +0.02%     
==========================================
  Files         311      312       +1     
  Lines       41924    42280     +356     
==========================================
+ Hits        36947    37270     +323     
- Misses       4977     5010      +33     

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

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.03      7.8±0.21ms   554.8 KB/sec    1.00      7.6±0.11ms   568.8 KB/sec

@github-actions

github-actions Bot commented Jun 17, 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.164 ± 0.206 3.893 4.600 1.96 ± 0.18
pacquet@main 4.013 ± 0.104 3.864 4.207 1.89 ± 0.15
pnpr@HEAD 2.120 ± 0.161 1.973 2.395 1.00
pnpr@main 2.130 ± 0.104 2.029 2.323 1.00 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.1637677125799994,
      "stddev": 0.20566732189226825,
      "median": 4.194706975879999,
      "user": 3.8255669999999995,
      "system": 3.4893428999999996,
      "min": 3.8930023738800004,
      "max": 4.60024773688,
      "times": [
        4.60024773688,
        3.91268854788,
        4.220648080879999,
        3.99671901688,
        4.25305756788,
        4.113151011879999,
        3.8930023738800004,
        4.16876587088,
        4.256884042879999,
        4.22251287588
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.01253042788,
      "stddev": 0.10350203632747954,
      "median": 4.00858593538,
      "user": 3.7918341999999994,
      "system": 3.4883604999999998,
      "min": 3.86390471588,
      "max": 4.20738191388,
      "times": [
        3.98155194288,
        4.05196772588,
        4.114781853879999,
        4.20738191388,
        4.04962075988,
        3.9988949118800003,
        3.86390471588,
        3.96673128188,
        3.87219221388,
        4.01827695888
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.11974514218,
      "stddev": 0.16113225691220906,
      "median": 2.05185921688,
      "user": 2.6486172999999993,
      "system": 3.0094448999999996,
      "min": 1.9731397428800002,
      "max": 2.39458935688,
      "times": [
        1.98098356788,
        1.9731397428800002,
        2.08224701988,
        2.28883260088,
        2.34973468988,
        2.05120471688,
        2.04452054788,
        1.97968546188,
        2.39458935688,
        2.05251371688
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.13018747338,
      "stddev": 0.10420941402413554,
      "median": 2.08596321488,
      "user": 2.6216266,
      "system": 3.0038533,
      "min": 2.02868996088,
      "max": 2.3231801288800003,
      "times": [
        2.3231801288800003,
        2.05926046688,
        2.02868996088,
        2.04638866688,
        2.04904072488,
        2.07511371888,
        2.14412859688,
        2.27996723188,
        2.09681271088,
        2.19929252688
      ]
    }
  ]
}

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

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 644.4 ± 18.4 615.5 682.0 1.00 ± 0.07
pacquet@main 643.3 ± 40.3 612.7 754.4 1.00
pnpr@HEAD 682.5 ± 20.6 657.7 718.2 1.06 ± 0.07
pnpr@main 679.3 ± 18.7 660.3 726.6 1.06 ± 0.07
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6443794112000001,
      "stddev": 0.018358153490603567,
      "median": 0.6428904009,
      "user": 0.3783556199999999,
      "system": 1.3514309199999999,
      "min": 0.6154683984,
      "max": 0.6819879624,
      "times": [
        0.6501417494,
        0.6819879624,
        0.6342251284,
        0.6411791304000001,
        0.6572276124,
        0.6553359234,
        0.6360206344,
        0.6154683984,
        0.6276059014,
        0.6446016714
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.643269458,
      "stddev": 0.040267204907542535,
      "median": 0.6327400219,
      "user": 0.37266282,
      "system": 1.32589732,
      "min": 0.6126741124,
      "max": 0.7543832574,
      "times": [
        0.6288184714,
        0.6347975914,
        0.6353155794,
        0.6182926554,
        0.6126741124,
        0.6306824524,
        0.6468400004,
        0.6304777574,
        0.6404127024,
        0.7543832574
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6825074429000001,
      "stddev": 0.020606763117955788,
      "median": 0.6798505094,
      "user": 0.38365141999999997,
      "system": 1.3545862199999998,
      "min": 0.6576791644000001,
      "max": 0.7182370444,
      "times": [
        0.6871006364000001,
        0.7043928414,
        0.7031708544,
        0.6800364494000001,
        0.6796645694000001,
        0.6651392924,
        0.6703315224,
        0.6576791644000001,
        0.6593220544,
        0.7182370444
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6793169695000001,
      "stddev": 0.018738623726595183,
      "median": 0.6734562949,
      "user": 0.38851362,
      "system": 1.34009322,
      "min": 0.6602529654,
      "max": 0.7265908194,
      "times": [
        0.7265908194,
        0.6602529654,
        0.6749830324,
        0.6824034804,
        0.6852275304000001,
        0.6635514174,
        0.6714053004,
        0.6865174454,
        0.6719295574,
        0.6703081464
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.189 ± 0.040 4.129 4.255 2.01 ± 0.06
pacquet@main 4.205 ± 0.032 4.146 4.253 2.02 ± 0.06
pnpr@HEAD 2.205 ± 0.118 2.090 2.386 1.06 ± 0.06
pnpr@main 2.079 ± 0.058 1.991 2.205 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.189390962459999,
      "stddev": 0.04037970474761205,
      "median": 4.18451054146,
      "user": 3.70666784,
      "system": 3.39222052,
      "min": 4.129217188959999,
      "max": 4.25471624596,
      "times": [
        4.15266768396,
        4.218089883959999,
        4.25471624596,
        4.129217188959999,
        4.16444662096,
        4.2331193229599995,
        4.19728141596,
        4.2153593129599995,
        4.17173966696,
        4.15727228196
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.20498347596,
      "stddev": 0.03220728111195031,
      "median": 4.20671100096,
      "user": 3.7400558399999992,
      "system": 3.3630308199999996,
      "min": 4.14555442296,
      "max": 4.25316612196,
      "times": [
        4.20681098596,
        4.16881239396,
        4.25316612196,
        4.2127882759599995,
        4.18857621896,
        4.24677636596,
        4.20628975196,
        4.214449205959999,
        4.14555442296,
        4.20661101596
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.2048705389600003,
      "stddev": 0.11752930883307397,
      "median": 2.17824382096,
      "user": 2.4690428399999997,
      "system": 2.9189795199999997,
      "min": 2.0903707539600003,
      "max": 2.3857174439600004,
      "times": [
        2.1003565279600003,
        2.29694528596,
        2.09696468896,
        2.3857174439600004,
        2.35286513496,
        2.24745126496,
        2.26734859096,
        2.0903707539600003,
        2.1090363769600002,
        2.10164932096
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.07945257286,
      "stddev": 0.05789267550814077,
      "median": 2.08105860446,
      "user": 2.49665504,
      "system": 2.9123775199999993,
      "min": 1.9914712649600002,
      "max": 2.20519596496,
      "times": [
        2.1014151779600003,
        2.20519596496,
        2.0427176299600003,
        2.10684137396,
        2.1047532489600003,
        2.03575525196,
        2.04425860696,
        1.9914712649600002,
        2.0919359489600002,
        2.07018125996
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.334 ± 0.009 1.325 1.352 2.02 ± 0.22
pacquet@main 1.366 ± 0.067 1.337 1.556 2.06 ± 0.24
pnpr@HEAD 0.675 ± 0.038 0.650 0.770 1.02 ± 0.12
pnpr@main 0.661 ± 0.071 0.624 0.863 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.3338492079999997,
      "stddev": 0.00900207041590481,
      "median": 1.3314057251,
      "user": 1.33496096,
      "system": 1.7179035200000001,
      "min": 1.3252460271,
      "max": 1.3522091041,
      "times": [
        1.3371094541,
        1.3522091041,
        1.3252460271,
        1.3264440420999999,
        1.3413259091,
        1.3263370240999999,
        1.3307876581,
        1.3414886581,
        1.3255204110999999,
        1.3320237921
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.3658591376,
      "stddev": 0.0670171336056993,
      "median": 1.3438307081,
      "user": 1.3456552600000002,
      "system": 1.74732442,
      "min": 1.3365080971,
      "max": 1.5555113190999998,
      "times": [
        1.3589251961,
        1.3423660681,
        1.5555113190999998,
        1.3445611341,
        1.3365080971,
        1.3382074631,
        1.3431002821,
        1.3518221731,
        1.3372626561,
        1.3503269870999999
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6750680781,
      "stddev": 0.037900312446568475,
      "median": 0.6602906331,
      "user": 0.33506746,
      "system": 1.28937882,
      "min": 0.6500334491,
      "max": 0.7698644271,
      "times": [
        0.6644498731,
        0.6535946781,
        0.7132019461,
        0.6549708651,
        0.6682558741,
        0.6500334491,
        0.7698644271,
        0.6567056141000001,
        0.6638756521,
        0.6557284021
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6614773942000001,
      "stddev": 0.07122117747513218,
      "median": 0.6408672260999999,
      "user": 0.32198086,
      "system": 1.2855231200000001,
      "min": 0.6243799381,
      "max": 0.8628904991,
      "times": [
        0.6272390511,
        0.6464703301,
        0.6395923841,
        0.6375931541000001,
        0.6243799381,
        0.6471772321,
        0.6485126391,
        0.6387766461,
        0.6421420681,
        0.8628904991
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.034 ± 0.037 2.989 3.109 4.56 ± 0.09
pacquet@main 2.981 ± 0.033 2.944 3.038 4.48 ± 0.08
pnpr@HEAD 0.665 ± 0.010 0.653 0.685 1.00
pnpr@main 0.668 ± 0.092 0.628 0.929 1.00 ± 0.14
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.03409544062,
      "stddev": 0.03704096670187535,
      "median": 3.02878118032,
      "user": 1.75992428,
      "system": 2.04440856,
      "min": 2.9891762233200003,
      "max": 3.10940944732,
      "times": [
        3.02962814232,
        2.9891762233200003,
        3.0041256453200003,
        3.03400750632,
        3.07018898832,
        3.02793421832,
        3.02579639232,
        3.10940944732,
        3.05833977632,
        2.99234806632
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.98077254822,
      "stddev": 0.03289622010110773,
      "median": 2.9754310633200003,
      "user": 1.7319284799999999,
      "system": 1.99604976,
      "min": 2.9442145013200003,
      "max": 3.03785210232,
      "times": [
        3.02873308232,
        2.9557491053200002,
        2.99707776732,
        2.96859630932,
        2.9442145013200003,
        2.94859935632,
        2.9899836623200002,
        3.03785210232,
        2.95465377832,
        2.98226581732
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6654972853200001,
      "stddev": 0.010215356385779075,
      "median": 0.66480716482,
      "user": 0.32273708,
      "system": 1.3009758599999999,
      "min": 0.6531256953200001,
      "max": 0.6852055313200001,
      "times": [
        0.6531256953200001,
        0.6573161353200001,
        0.6696318573200001,
        0.6572921453200001,
        0.6567259313200001,
        0.6604478623200001,
        0.6852055313200001,
        0.6701184123200001,
        0.6691664673200001,
        0.6759428153200001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6684518783200002,
      "stddev": 0.09184982961472532,
      "median": 0.6407623373200001,
      "user": 0.32827158,
      "system": 1.2792172599999998,
      "min": 0.6276712823200001,
      "max": 0.9291175633200001,
      "times": [
        0.6480940673200001,
        0.6463573703200001,
        0.6373534003200001,
        0.6397666213200001,
        0.64789507432,
        0.6417580533200001,
        0.6307841603200001,
        0.6276712823200001,
        0.6357211903200001,
        0.9291175633200001
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12472
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,189.39 ms
(-0.72%)Baseline: 4,219.83 ms
5,063.80 ms
(82.73%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,034.10 ms
(+0.92%)Baseline: 3,006.30 ms
3,607.56 ms
(84.10%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,333.85 ms
(+0.13%)Baseline: 1,332.16 ms
1,598.59 ms
(83.44%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,163.77 ms
(-0.28%)Baseline: 4,175.48 ms
5,010.57 ms
(83.10%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
644.38 ms
(+4.36%)Baseline: 617.43 ms
740.92 ms
(86.97%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12472
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,204.87 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
665.50 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
675.07 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,119.75 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
682.51 ms
🐰 View full continuous benchmarking report in Bencher

zkochan added a commit that referenced this pull request Jun 17, 2026
…igher direct deps

Port of #12472. pacquet's full-subtree lockfile reuse
(resolve_reused_node) is coarser than pnpm's per-edge reuse and carries
neither the resolved direct-dep versions nor the parent manifest's ranges
the refresh needs. Three coordinated changes bridge that, mirroring pnpm's
phase structure:

- Record, per importer, which direct deps changed vs. the prior lockfile
  (race-free) and the resolved direct-dep versions (after the direct-dep
  level barrier).
- Decline full-subtree reuse for a parent that depends on a changed direct
  dep, so it resolves fresh and walks its children against their real
  manifest ranges.
- In walk_node_children, force a child edge onto a higher direct-dep
  version when the recorded pin is below it and it still satisfies the
  edge's range. The lower version is never resolved or fetched.

Both maps are keyed by importer to match pnpm's per-importer
preferredVersions.
@zkochan zkochan force-pushed the feat/refresh-stale-lockfile-pins branch from 751bf30 to 2aa4fa2 Compare June 17, 2026 21:37
@zkochan zkochan marked this pull request as ready for review June 17, 2026 21:44
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix incremental install by refreshing stale transitive lockfile pins
🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

Description

• Refresh stale transitive lockfile pins to higher direct-dependency versions during resolution.
• Prevent resolving/fetching obsolete versions so incremental installs converge to fresh installs.
• Add pnpm + pacquet regression tests covering exact and caret-range direct-dependency bumps.
Diagram

graph TD
  A([Installer]) --> B["deps-resolver"] --> C["getDepsToResolve"] --> D[("Lockfile pins")]
  C --> E["preferredVersions (direct)" ] --> F["preferredVersion override"]
  G(["pacquet CLI"]) --> H["resolve_dependency_tree"] --> I["walk_node_children"] --> J[("pnpm-lock.yaml")]
  H --> K["record direct-dep versions"] --> I
  subgraph Legend
    direction LR
    _svc([Component]) ~~~ _fn["Step/function"] ~~~ _db[(Lockfile)]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Post-resolution dedupe rewrite + prune (as in #11502)
  • ➕ Can address transitive↔transitive duplicates as well as direct↔transitive
  • ➕ Keeps resolution behavior unchanged; operates on a computed graph
  • ➖ Still resolves/fetches the soon-to-be-pruned lower version
  • ➖ Adds a new graph pass and pruning complexity; can risk flag/metadata staleness
2. Gate the refresh behind a config flag
3. Broader in-resolution dedupe (include transitive↔transitive)
  • ➕ More complete dedupe, potentially matching fresh-resolve behavior in more cases
  • ➖ Harder to keep deterministic; can reintroduce order-dependent behavior
  • ➖ Higher risk of unexpected changes in incremental installs

Recommendation: The in-resolution direct↔transitive refresh is a strong approach for the motivating non-convergence bug: it prevents wasted resolution/fetch work and avoids a separate rewrite/prune phase. Given it is still a behavior change, consider either (a) shipping with careful release notes and broad installer coverage, or (b) temporarily gating behind a setting until ecosystem feedback confirms the new default is acceptable. Keeping scope limited to direct↔transitive (deterministic anchors) is the right trade-off versus reintroducing transitive↔transitive order sensitivity.

Files changed (5) +347 / -5

Bug fix (2) +209 / -5
resolveDependencies.tsRefresh reused lockfile child pins to higher in-range direct-dep versions +69/-1

Refresh reused lockfile child pins to higher in-range direct-dep versions

• Extends getDepsToResolve to detect when a reused lockfile reference would keep a stale lower version. If a higher direct-dependency version exists in preferredVersions and satisfies the range, it forces selection via preferredVersion (singular), overriding stability bias so the lower version is never resolved/fetched.

installing/deps-resolver/src/resolveDependencies.ts

resolve_dependency_tree.rsPort stale transitive pin refresh into pacquet resolver and reuse logic +140/-4

Port stale transitive pin refresh into pacquet resolver and reuse logic

• Tracks (per importer) changed direct deps and resolved direct-dep versions after the direct-dep barrier. Declines full-subtree reuse when a reused node depends on a changed direct dep, and during child walking redirects a pinned edge to the highest in-range direct-dep version to avoid resolving/fetching the stale version.

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

Tests (2) +132 / -0
dedupe.tsAdd regression test for refreshing stale transitive lockfile pin +28/-0

Add regression test for refreshing stale transitive lockfile pin

• Introduces a test that reproduces a stale transitive pin after bumping a direct dependency. Asserts the lockfile converges to the higher version and the older version is absent.

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

stale_pin_dedupe.rsAdd pacquet CLI tests for stale pin refresh (exact and caret ranges) +104/-0

Add pacquet CLI tests for stale pin refresh (exact and caret ranges)

• Adds two end-to-end tests verifying that an incremental install refreshes a stale transitive pin to the bumped direct-dependency resolved version. Covers both exact version bumps and caret-range specs.

pacquet/crates/cli/tests/stale_pin_dedupe.rs

Documentation (1) +6 / -0
refresh-stale-lockfile-pins.mdDocument patch release for stale transitive pin convergence fix +6/-0

Document patch release for stale transitive pin convergence fix

• Adds a changeset describing the lockfile non-convergence scenario and the new resolution-time refresh behavior. Notes that the stale lower version is no longer resolved or fetched.

.changeset/refresh-stale-lockfile-pins.md

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

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

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Refresh loses lockfile context 🐞 Bug ≡ Correctness
Description
When a stale lockfile pin is refreshed via preferredVersion, getDepsToResolve() does not set
reference, so infoFromLockfile becomes undefined. Downstream, this is treated as “not in
lockfile” and forces update=true, which can trigger broader re-resolution than intended and
potentially ignore the direct-dep version anchor.
Code

installing/deps-resolver/src/resolveDependencies.ts[R1453-1462]

+        if (higherDirectVersion != null) {
+          // `preferredVersion` (singular) overrides the
+          // EXISTING_VERSION_SELECTOR_WEIGHT stability bias that would
+          // otherwise re-pick the lower version. The lower version is then
+          // never resolved or fetched.
+          proceed = true
+          preferredVersion = higherDirectVersion
+        } else {
+          reference = pinnedRef
+        }
Evidence
The refresh branch sets preferredVersion and forces proceed, but does not set reference, so
getInfoFromLockfile() receives reference=undefined and returns undefined. The resolver then
computes update based on infoFromLockfile?.dependencyLockfile being null/undefined, which forces
update=true for this edge and changes resolution semantics beyond the intended targeted pin
refresh.

installing/deps-resolver/src/resolveDependencies.ts[1434-1502]
installing/deps-resolver/src/resolveDependencies.ts[1607-1615]
installing/deps-resolver/src/resolveDependencies.ts[883-921]
installing/deps-resolver/src/resolveDependencies.ts[1745-1751]

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

## Issue description
When `higherDirectVersion` is found, `getDepsToResolve()` sets `preferredVersion` but leaves `reference` unset. This makes `getInfoFromLockfile()` return `undefined`, and downstream `resolveDependenciesOfDependency()` treats the dependency as missing from the lockfile and forces `update=true`, which is not the intended behavior for a targeted “refresh pin to higher direct-dep version”.
### Issue Context
We still want the higher direct-dep version override, but we should keep the old lockfile entry as `currentPkg`/`infoFromLockfile` so the dependency is not treated as a brand-new dep (and doesn’t automatically enter update logic).
### Fix Focus Areas
- installing/deps-resolver/src/resolveDependencies.ts[1443-1462]
- installing/deps-resolver/src/resolveDependencies.ts[1478-1502]
### Proposed change
In the `higherDirectVersion != null` branch, also set `reference = pinnedRef` (or otherwise ensure `infoFromLockfile` is populated from the pinned lockfile ref) while keeping `proceed = true` and `preferredVersion = higherDirectVersion`. This preserves lockfile context but still forces the pick to the higher direct-dep version via `preferredVersion`.

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


2. Prerelease range mismatch 🐞 Bug ≡ Correctness
Description
pacquet’s stale-pin refresh treats prerelease versions as satisfying ranges via
satisfies_including_prerelease, so a prerelease direct dep (e.g. 1.2.0-beta) can be forced onto
a transitive edge whose range would not accept that prerelease under pnpm’s `semver.satisfies(...,
true)` semantics. This can unexpectedly pull prereleases into the resolved tree/lockfile and break
parity with the pnpm fix this port is meant to mirror.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2052-2057]

+    direct_versions?
+        .get(name)?
+        .iter()
+        .filter(|&version| version > pinned && satisfies_including_prerelease(range, version))
+        .max()
+        .cloned()
Evidence
The refresh decision in pacquet explicitly uses prerelease-inclusive satisfaction, and the helper is
documented/implemented to accept prereleases by stripping their prerelease tag; the pnpm side of
this PR uses semver.satisfies without includePrerelease behavior for the same decision.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2041-2058]
pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs[189-211]
installing/deps-resolver/src/resolveDependencies.ts[1544-1574]

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

## Issue description
In pacquet, `higher_direct_dep_version()` uses `satisfies_including_prerelease(range, version)` when deciding whether to refresh a stale transitive pin to a higher direct-dependency version.
`satisfies_including_prerelease` implements `includePrerelease: true`-style behavior by stripping prerelease tags and re-testing, which can treat prerelease versions as satisfying ranges that would normally exclude them. This diverges from the pnpm TypeScript implementation in this PR, which uses `semver.satisfies(candidate, range, true)` (loose only; does **not** include prereleases by default).
### Issue Context
The stale-pin refresh is intended to mirror pnpm’s behavior. Using prerelease-inclusive satisfaction can redirect transitive edges to prerelease versions unexpectedly.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2041-2058]
### Suggested fix
Replace `satisfies_including_prerelease(range, version)` in `higher_direct_dep_version` with the resolver’s normal “does this version satisfy this range?” predicate (likely `range.satisfies(version)` in node_semver), so prereleases are only considered satisfying when the range itself permits them.
Add/adjust a unit test for the prerelease case (direct dep prerelease + transitive stable range) to ensure pacquet matches pnpm’s behavior.

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



Remediation recommended

3. Injected toggle not detected 🐞 Bug ≡ Correctness ⭐ New
Description
In pacquet, record_changed_direct_deps() ignores the per-dependency injected flag when deciding
whether a direct dependency “changed” vs the prior lockfile. If only
dependenciesMeta[alias].injected flips, the dep won’t be marked changed, so
node_depends_on_changed_direct_dep() may still allow lockfile subtree reuse even though injected
affects file: vs link: resolution behavior.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R1977-1981]

+    for (alias, spec, _optional, _injected) in wanted {
+        let unchanged = prior
+            .and_then(|importer| importer_dep_specifier(importer, alias))
+            .is_some_and(|recorded| recorded == spec);
+        if !unchanged && let Ok(name) = alias.parse::<PkgName>() {
Evidence
The resolver explicitly models injected as an input that changes resolution results (it is
collected from dependenciesMeta and included in wanted/cache keys), but the new “changed direct
deps” tracker drops _injected on the floor, so injected-only changes are invisible to the
reuse-decline mechanism.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[316-337]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[425-431]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1966-1984]
pacquet/crates/lockfile/src/project_snapshot.rs[6-40]
pacquet/crates/lockfile/src/resolved_dependency.rs[40-46]

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

### Issue description
`record_changed_direct_deps()` records changed direct deps by comparing only the direct dependency **specifier string** against the prior lockfile, but it ignores the direct dep’s `injected` flag (`dependenciesMeta[alias].injected`). This can miss injected-only changes, which are semantically significant (workspace deps can resolve to `link:` vs `file:`).

### Issue Context
- `WantedSpec` includes `injected` and it is treated as a first-class resolution signal.
- The changed-direct-deps set is used to decline lockfile subtree reuse (`node_depends_on_changed_direct_dep`), so missing a “changed” direct dep can keep reuse enabled when it should be disabled.

### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1969-1996]
- pacquet/crates/lockfile/src/project_snapshot.rs[6-40]

### Proposed fix
1. Extend the “unchanged” check to include injected parity:
  - Add a helper like `importer_dep_injected(importer: &ProjectSnapshot, alias: &str) -> bool` that reads `importer.dependencies_meta` and returns `dependenciesMeta[alias].injected ?? false`.
  - In `record_changed_direct_deps`, treat a dep as unchanged only when **both** the prior `specifier` matches and the prior injected flag matches the current `_injected` value.
2. If the lockfile has no `dependencies_meta`, default prior injected to `false`.
3. Add/extend a test that toggles only `dependenciesMeta[alias].injected` (same specifier) and asserts the reuse-decline gate triggers as expected.

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


4. Selector scan on reuse 🐞 Bug ➹ Performance
Description
In pnpm, the new stale-pin refresh path linearly scans all selectors in
preferredVersions[pinnedName] (with multiple semver checks) for each lockfile-reused edge, which
can add measurable CPU overhead in incremental installs that reuse many pins. This is especially
relevant because preferredVersions is also populated from all unique lockfile snapshot
(name,version) pairs, growing selector maps for popular packages.
Code

installing/deps-resolver/src/resolveDependencies.ts[R1558-1573]

+  const selectors = preferredVersions[pinnedName]
+  if (selectors == null) return undefined
+  let best: string | undefined
+  for (const [candidate, selector] of Object.entries(selectors)) {
+    if (
+      typeof selector === 'object' &&
+      selector.selectorType === 'version' &&
+      selector.weight === DIRECT_DEP_SELECTOR_WEIGHT &&
+      semver.valid(candidate) &&
+      semver.gt(candidate, pinnedVersion) &&
+      semver.satisfies(candidate, bareSpecifier, true) &&
+      (best == null || semver.gt(candidate, best))
+    ) {
+      best = candidate
+    }
+  }
Evidence
The new code adds a full scan of the selector map and does multiple semver operations per entry.
Preferred versions are also populated from lockfile snapshots, which directly increases the number
of entries that might be scanned for a given package name.

installing/deps-resolver/src/resolveDependencies.ts[1432-1462]
installing/deps-resolver/src/resolveDependencies.ts[1544-1574]
lockfile/preferred-versions/src/index.ts[35-75]

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

## Issue description
`findHigherDirectDepVersion()` iterates `Object.entries(preferredVersions[pinnedName])` and runs `semver.valid/gt/satisfies` for every selector entry each time a lockfile pin is about to be reused.
In incremental installs, many edges may hit this reuse path; additionally, `preferredVersions` is seeded from lockfile snapshots (unique (name,version) pairs), which can make selector maps non-trivial in size.
### Issue Context
This PR introduces the scan as part of the new stale-pin refresh behavior.
### Fix Focus Areas
- installing/deps-resolver/src/resolveDependencies.ts[1432-1503]
- installing/deps-resolver/src/resolveDependencies.ts[1544-1575]
- lockfile/preferred-versions/src/index.ts[35-75]
### Suggested fix
Reduce per-edge work by precomputing (once per `getDepsToResolve` call) a compact structure of only the direct-dependency resolved versions per package name (those with `selectorType: 'version'` and `weight === DIRECT_DEP_SELECTOR_WEIGHT`).
Then, for each reuse check:
- Parse the wanted range once (e.g. `new semver.Range(bareSpecifier, true)`) and test candidates via `range.test(...)`, or use `semver.maxSatisfying(directCandidates, bareSpecifier, true)`.
- Avoid iterating over non-version selectors and lockfile-seeded versions.
Keep the behavior identical (highest direct-dep version > pinnedVersion and satisfying the edge range).

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


5. Mutex in child walk 🐞 Bug ➹ Performance
Description
In pacquet, the stale-pin refresh calls higher_direct_dep_version() during walk_node_children() for
each child edge with a prior pin, and higher_direct_dep_version() takes a global std::sync::Mutex
and parses version strings on every call. This introduces extra lock contention and CPU work in a
hot resolution path and may slow incremental installs on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2031-2037]

+    lock_recoverable(&ctx.workspace.direct_dep_versions)
+        .get(&ctx.importer_id)?
+        .get(name)?
+        .iter()
+        .filter_map(|version| version.parse::<node_semver::Version>().ok())
+        .filter(|version| version > pinned && satisfies_including_prerelease(range, version))
+        .max()
Evidence
The per-child forced_version computation calls higher_direct_dep_version() while walking children,
and higher_direct_dep_version() locks a std::sync::Mutex and parses versions each call, so the extra
work is on the resolution hot path.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1574-1612]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2025-2038]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[26-35]

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

## Issue description
`walk_node_children` computes a `forced_version` per child and calls `higher_direct_dep_version`, which locks `ctx.workspace.direct_dep_versions` (a `std::sync::Mutex`) and parses stored version strings on every invocation. This adds repeated mutex acquisition and parsing work inside the child-edge fanout.
### Issue Context
This runs during resolution-time walking (incremental install path) and can be executed many times per node across large graphs.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1574-1612]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2025-2038]
### Suggested fix
- Avoid locking `direct_dep_versions` per child edge. For example, lock once per `walk_node_children` call and capture the importer’s direct-dep version map into a local variable (clone or otherwise snapshot a read-only view), then pass that to the helper.
- Avoid parsing the same version strings repeatedly by storing parsed `node_semver::Version` values when recording direct dep versions (or by caching the parsed list in the snapshot), so `higher_direct_dep_version` can do pure comparisons without per-call parsing.

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


View more (1)
6. Reuse gate allocates strings 🐞 Bug ➹ Performance
Description
In pacquet, the new lockfile reuse-decline check allocates a new String for each dependency key via
name.to_string() while scanning reused snapshots, and runs on the lockfile-reuse hot path before
try_reuse_node. This adds avoidable CPU/allocations and holds the changed_direct_deps mutex
during the scan, which can regress incremental install performance on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2006-2008]

+    let depends_on = |map: Option<&HashMap<PkgName, SnapshotDepRef>>| {
+        map.is_some_and(|deps| deps.keys().any(|name| importer_changed.contains(&name.to_string())))
+    };
Evidence
The reuse-decline gate is executed before lockfile subtree reuse and uses an allocating membership
test while holding the changed_direct_deps mutex, so it adds per-edge overhead on the reuse hot
path.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[602-612]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2000-2011]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1264-1290]

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

## Issue description
`reused_parent_has_changed_direct_child` checks whether a reused snapshot depends on a changed direct dependency by iterating `deps.keys()` and doing `importer_changed.contains(&name.to_string())`. This allocates a fresh `String` for every key examined and holds the `changed_direct_deps` mutex while scanning.
### Issue Context
This check runs in the lockfile-reuse gate (`resolve_node_seed`) before attempting subtree reuse, so it can execute many times in incremental installs.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[602-612]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1947-2011]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1264-1290]
### Suggested fix
- Change `changed_direct_deps` to store parsed `PkgName` (e.g., `HashSet<PkgName>`) rather than `String`.
- Update `record_changed_direct_deps` to parse `alias` into `PkgName` and insert that.
- Update `reused_parent_has_changed_direct_child` to compare without allocation (e.g., `deps.keys().any(|name| importer_changed.contains(name))`).
- Optionally: clone/copy the small `importer_changed` set out of the mutex guard and drop the lock before scanning the snapshot dependency maps to reduce lock-hold time.

ⓘ 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 a738dc9

Results up to commit 4a27779


🐞 Bugs (5) 📘 Rule violations (0) 📜 Skill insights (0)


Action required
1. Refresh loses lockfile context 🐞 Bug ≡ Correctness ⭐ New
Description
When a stale lockfile pin is refreshed via preferredVersion, getDepsToResolve() does not set
reference, so infoFromLockfile becomes undefined. Downstream, this is treated as “not in
lockfile” and forces update=true, which can trigger broader re-resolution than intended and
potentially ignore the direct-dep version anchor.
Code

installing/deps-resolver/src/resolveDependencies.ts[R1453-1462]

+        if (higherDirectVersion != null) {
+          // `preferredVersion` (singular) overrides the
+          // EXISTING_VERSION_SELECTOR_WEIGHT stability bias that would
+          // otherwise re-pick the lower version. The lower version is then
+          // never resolved or fetched.
+          proceed = true
+          preferredVersion = higherDirectVersion
+        } else {
+          reference = pinnedRef
+        }
Evidence
The refresh branch sets preferredVersion and forces proceed, but does not set reference, so
getInfoFromLockfile() receives reference=undefined and returns undefined. The resolver then
computes update based on infoFromLockfile?.dependencyLockfile being null/undefined, which forces
update=true for this edge and changes resolution semantics beyond the intended targeted pin
refresh.

installing/deps-resolver/src/resolveDependencies.ts[1434-1502]
installing/deps-resolver/src/resolveDependencies.ts[1607-1615]
installing/deps-resolver/src/resolveDependencies.ts[883-921]
installing/deps-resolver/src/resolveDependencies.ts[1745-1751]

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

### Issue description
When `higherDirectVersion` is found, `getDepsToResolve()` sets `preferredVersion` but leaves `reference` unset. This makes `getInfoFromLockfile()` return `undefined`, and downstream `resolveDependenciesOfDependency()` treats the dependency as missing from the lockfile and forces `update=true`, which is not the intended behavior for a targeted “refresh pin to higher direct-dep version”.

### Issue Context
We still want the higher direct-dep version override, but we should keep the old lockfile entry as `currentPkg`/`infoFromLockfile` so the dependency is not treated as a brand-new dep (and doesn’t automatically enter update logic).

### Fix Focus Areas
- installing/deps-resolver/src/resolveDependencies.ts[1443-1462]
- installing/deps-resolver/src/resolveDependencies.ts[1478-1502]

### Proposed change
In the `higherDirectVersion != null` branch, also set `reference = pinnedRef` (or otherwise ensure `infoFromLockfile` is populated from the pinned lockfile ref) while keeping `proceed = true` and `preferredVersion = higherDirectVersion`. This preserves lockfile context but still forces the pick to the higher direct-dep version via `preferredVersion`.

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


2. Prerelease range mismatch 🐞 Bug ≡ Correctness
Description
pacquet’s stale-pin refresh treats prerelease versions as satisfying ranges via
satisfies_including_prerelease, so a prerelease direct dep (e.g. 1.2.0-beta) can be forced onto
a transitive edge whose range would not accept that prerelease under pnpm’s `semver.satisfies(...,
true)` semantics. This can unexpectedly pull prereleases into the resolved tree/lockfile and break
parity with the pnpm fix this port is meant to mirror.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2052-2057]

+    direct_versions?
+        .get(name)?
+        .iter()
+        .filter(|&version| version > pinned && satisfies_including_prerelease(range, version))
+        .max()
+        .cloned()
Evidence
The refresh decision in pacquet explicitly uses prerelease-inclusive satisfaction, and the helper is
documented/implemented to accept prereleases by stripping their prerelease tag; the pnpm side of
this PR uses semver.satisfies without includePrerelease behavior for the same decision.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2041-2058]
pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs[189-211]
installing/deps-resolver/src/resolveDependencies.ts[1544-1574]

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

## Issue description
In pacquet, `higher_direct_dep_version()` uses `satisfies_including_prerelease(range, version)` when deciding whether to refresh a stale transitive pin to a higher direct-dependency version.
`satisfies_including_prerelease` implements `includePrerelease: true`-style behavior by stripping prerelease tags and re-testing, which can treat prerelease versions as satisfying ranges that would normally exclude them. This diverges from the pnpm TypeScript implementation in this PR, which uses `semver.satisfies(candidate, range, true)` (loose only; does **not** include prereleases by default).
### Issue Context
The stale-pin refresh is intended to mirror pnpm’s behavior. Using prerelease-inclusive satisfaction can redirect transitive edges to prerelease versions unexpectedly.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2041-2058]
### Suggested fix
Replace `satisfies_including_prerelease(range, version)` in `higher_direct_dep_version` with the resolver’s normal “does this version satisfy this range?” predicate (likely `range.satisfies(version)` in node_semver), so prereleases are only considered satisfying when the range itself permits them.
Add/adjust a unit test for the prerelease case (direct dep prerelease + transitive stable range) to ensure pacquet matches pnpm’s behavior.

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



Remediation recommended
3. Selector scan on reuse 🐞 Bug ➹ Performance
Description
In pnpm, the new stale-pin refresh path linearly scans all selectors in
preferredVersions[pinnedName] (with multiple semver checks) for each lockfile-reused edge, which
can add measurable CPU overhead in incremental installs that reuse many pins. This is especially
relevant because preferredVersions is also populated from all unique lockfile snapshot
(name,version) pairs, growing selector maps for popular packages.
Code

installing/deps-resolver/src/resolveDependencies.ts[R1558-1573]

+  const selectors = preferredVersions[pinnedName]
+  if (selectors == null) return undefined
+  let best: string | undefined
+  for (const [candidate, selector] of Object.entries(selectors)) {
+    if (
+      typeof selector === 'object' &&
+      selector.selectorType === 'version' &&
+      selector.weight === DIRECT_DEP_SELECTOR_WEIGHT &&
+      semver.valid(candidate) &&
+      semver.gt(candidate, pinnedVersion) &&
+      semver.satisfies(candidate, bareSpecifier, true) &&
+      (best == null || semver.gt(candidate, best))
+    ) {
+      best = candidate
+    }
+  }
Evidence
The new code adds a full scan of the selector map and does multiple semver operations per entry.
Preferred versions are also populated from lockfile snapshots, which directly increases the number
of entries that might be scanned for a given package name.

installing/deps-resolver/src/resolveDependencies.ts[1432-1462]
installing/deps-resolver/src/resolveDependencies.ts[1544-1574]
lockfile/preferred-versions/src/index.ts[35-75]

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

## Issue description
`findHigherDirectDepVersion()` iterates `Object.entries(preferredVersions[pinnedName])` and runs `semver.valid/gt/satisfies` for every selector entry each time a lockfile pin is about to be reused.
In incremental installs, many edges may hit this reuse path; additionally, `preferredVersions` is seeded from lockfile snapshots (unique (name,version) pairs), which can make selector maps non-trivial in size.
### Issue Context
This PR introduces the scan as part of the new stale-pin refresh behavior.
### Fix Focus Areas
- installing/deps-resolver/src/resolveDependencies.ts[1432-1503]
- installing/deps-resolver/src/resolveDependencies.ts[1544-1575]
- lockfile/preferred-versions/src/index.ts[35-75]
### Suggested fix
Reduce per-edge work by precomputing (once per `getDepsToResolve` call) a compact structure of only the direct-dependency resolved versions per package name (those with `selectorType: 'version'` and `weight === DIRECT_DEP_SELECTOR_WEIGHT`).
Then, for each reuse check:
- Parse the wanted range once (e.g. `new semver.Range(bareSpecifier, true)`) and test candidates via `range.test(...)`, or use `semver.maxSatisfying(directCandidates, bareSpecifier, true)`.
- Avoid iterating over non-version selectors and lockfile-seeded versions.
Keep the behavior identical (highest direct-dep version > pinnedVersion and satisfying the edge range).

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


4. Mutex in child walk 🐞 Bug ➹ Performance
Description
In pacquet, the stale-pin refresh calls higher_direct_dep_version() during walk_node_children() for
each child edge with a prior pin, and higher_direct_dep_version() takes a global std::sync::Mutex
and parses version strings on every call. This introduces extra lock contention and CPU work in a
hot resolution path and may slow incremental installs on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2031-2037]

+    lock_recoverable(&ctx.workspace.direct_dep_versions)
+        .get(&ctx.importer_id)?
+        .get(name)?
+        .iter()
+        .filter_map(|version| version.parse::<node_semver::Version>().ok())
+        .filter(|version| version > pinned && satisfies_including_prerelease(range, version))
+        .max()
Evidence
The per-child forced_version computation calls higher_direct_dep_version() while walking children,
and higher_direct_dep_version() locks a std::sync::Mutex and parses versions each call, so the extra
work is on the resolution hot path.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1574-1612]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2025-2038]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[26-35]

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

## Issue description
`walk_node_children` computes a `forced_version` per child and calls `higher_direct_dep_version`, which locks `ctx.workspace.direct_dep_versions` (a `std::sync::Mutex`) and parses stored version strings on every invocation. This adds repeated mutex acquisition and parsing work inside the child-edge fanout.
### Issue Context
This runs during resolution-time walking (incremental install path) and can be executed many times per node across large graphs.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1574-1612]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2025-2038]
### Suggested fix
- Avoid locking `direct_dep_versions` per child edge. For example, lock once per `walk_node_children` call and capture the importer’s direct-dep version map into a local variable (clone or otherwise snapshot a read-only view), then pass that to the helper.
- Avoid parsing the same version strings repeatedly by storing parsed `node_semver::Version` values when recording direct dep versions (or by caching the parsed list in the snapshot), so `higher_direct_dep_version` can do pure comparisons without per-call parsing.

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


5. Reuse gate allocates strings 🐞 Bug ➹ Performance
Description
In pacquet, the new lockfile reuse-decline check allocates a new String for each dependency key via
name.to_string() while scanning reused snapshots, and runs on the lockfile-reuse hot path before
try_reuse_node. This adds avoidable CPU/allocations and holds the changed_direct_deps mutex
during the scan, which can regress incremental install performance on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2006-2008]

+    let depends_on = |map: Option<&HashMap<PkgName, SnapshotDepRef>>| {
+        map.is_some_and(|deps| deps.keys().any(|name| importer_changed.contains(&name.to_string())))
+    };
Evidence
The reuse-decline gate is executed before lockfile subtree reuse and uses an allocating membership
test while holding the changed_direct_deps mutex, so it adds per-edge overhead on the reuse hot
path.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[602-612]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2000-2011]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1264-1290]

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

## Issue description
`reused_parent_has_changed_direct_child` checks whether a reused snapshot depends on a changed direct dependency by iterating `deps.keys()` and doing `importer_changed.contains(&name.to_string())`. This allocates a fresh `String` for every key examined and holds the `changed_direct_deps` mutex while scanning.
### Issue Context
This check runs in the lockfile-reuse gate (`resolve_node_seed`) before attempting subtree reuse, so it can execute many times in incremental installs.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[602-612]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1947-2011]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1264-1290]
### Suggested fix
- Change `changed_direct_deps` to store parsed `PkgName` (e.g., `HashSet<PkgName>`) rather than `String`.
- Update `record_changed_direct_deps` to parse `alias` into `PkgName` and insert that.
- Update `reused_parent_has_changed_direct_child` to compare without allocation (e.g., `deps.keys().any(|name| importer_changed.contains(name))`).
- Optionally: clone/copy the small `importer_changed` set out of the mutex guard and drop the lock before scanning the snapshot dependency maps to reduce lock-hold time.

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


Results up to commit 72cc9f1


🐞 Bugs (4) 📘 Rule violations (0) 📜 Skill insights (0)


Action required
1. Prerelease range mismatch 🐞 Bug ≡ Correctness ⭐ New
Description
pacquet’s stale-pin refresh treats prerelease versions as satisfying ranges via
satisfies_including_prerelease, so a prerelease direct dep (e.g. 1.2.0-beta) can be forced onto
a transitive edge whose range would not accept that prerelease under pnpm’s `semver.satisfies(...,
true)` semantics. This can unexpectedly pull prereleases into the resolved tree/lockfile and break
parity with the pnpm fix this port is meant to mirror.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2052-2057]

+    direct_versions?
+        .get(name)?
+        .iter()
+        .filter(|&version| version > pinned && satisfies_including_prerelease(range, version))
+        .max()
+        .cloned()
Evidence
The refresh decision in pacquet explicitly uses prerelease-inclusive satisfaction, and the helper is
documented/implemented to accept prereleases by stripping their prerelease tag; the pnpm side of
this PR uses semver.satisfies without includePrerelease behavior for the same decision.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2041-2058]
pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs[189-211]
installing/deps-resolver/src/resolveDependencies.ts[1544-1574]

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

### Issue description
In pacquet, `higher_direct_dep_version()` uses `satisfies_including_prerelease(range, version)` when deciding whether to refresh a stale transitive pin to a higher direct-dependency version.

`satisfies_including_prerelease` implements `includePrerelease: true`-style behavior by stripping prerelease tags and re-testing, which can treat prerelease versions as satisfying ranges that would normally exclude them. This diverges from the pnpm TypeScript implementation in this PR, which uses `semver.satisfies(candidate, range, true)` (loose only; does **not** include prereleases by default).

### Issue Context
The stale-pin refresh is intended to mirror pnpm’s behavior. Using prerelease-inclusive satisfaction can redirect transitive edges to prerelease versions unexpectedly.

### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2041-2058]

### Suggested fix
Replace `satisfies_including_prerelease(range, version)` in `higher_direct_dep_version` with the resolver’s normal “does this version satisfy this range?” predicate (likely `range.satisfies(version)` in node_semver), so prereleases are only considered satisfying when the range itself permits them.

Add/adjust a unit test for the prerelease case (direct dep prerelease + transitive stable range) to ensure pacquet matches pnpm’s behavior.

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



Remediation recommended
2. Selector scan on reuse 🐞 Bug ➹ Performance ⭐ New
Description
In pnpm, the new stale-pin refresh path linearly scans all selectors in
preferredVersions[pinnedName] (with multiple semver checks) for each lockfile-reused edge, which
can add measurable CPU overhead in incremental installs that reuse many pins. This is especially
relevant because preferredVersions is also populated from all unique lockfile snapshot
(name,version) pairs, growing selector maps for popular packages.
Code

installing/deps-resolver/src/resolveDependencies.ts[R1558-1573]

+  const selectors = preferredVersions[pinnedName]
+  if (selectors == null) return undefined
+  let best: string | undefined
+  for (const [candidate, selector] of Object.entries(selectors)) {
+    if (
+      typeof selector === 'object' &&
+      selector.selectorType === 'version' &&
+      selector.weight === DIRECT_DEP_SELECTOR_WEIGHT &&
+      semver.valid(candidate) &&
+      semver.gt(candidate, pinnedVersion) &&
+      semver.satisfies(candidate, bareSpecifier, true) &&
+      (best == null || semver.gt(candidate, best))
+    ) {
+      best = candidate
+    }
+  }
Evidence
The new code adds a full scan of the selector map and does multiple semver operations per entry.
Preferred versions are also populated from lockfile snapshots, which directly increases the number
of entries that might be scanned for a given package name.

installing/deps-resolver/src/resolveDependencies.ts[1432-1462]
installing/deps-resolver/src/resolveDependencies.ts[1544-1574]
lockfile/preferred-versions/src/index.ts[35-75]

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

### Issue description
`findHigherDirectDepVersion()` iterates `Object.entries(preferredVersions[pinnedName])` and runs `semver.valid/gt/satisfies` for every selector entry each time a lockfile pin is about to be reused.

In incremental installs, many edges may hit this reuse path; additionally, `preferredVersions` is seeded from lockfile snapshots (unique (name,version) pairs), which can make selector maps non-trivial in size.

### Issue Context
This PR introduces the scan as part of the new stale-pin refresh behavior.

### Fix Focus Areas
- installing/deps-resolver/src/resolveDependencies.ts[1432-1503]
- installing/deps-resolver/src/resolveDependencies.ts[1544-1575]
- lockfile/preferred-versions/src/index.ts[35-75]

### Suggested fix
Reduce per-edge work by precomputing (once per `getDepsToResolve` call) a compact structure of only the direct-dependency resolved versions per package name (those with `selectorType: 'version'` and `weight === DIRECT_DEP_SELECTOR_WEIGHT`).

Then, for each reuse check:
- Parse the wanted range once (e.g. `new semver.Range(bareSpecifier, true)`) and test candidates via `range.test(...)`, or use `semver.maxSatisfying(directCandidates, bareSpecifier, true)`.
- Avoid iterating over non-version selectors and lockfile-seeded versions.

Keep the behavior identical (highest direct-dep version > pinnedVersion and satisfying the edge range).

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


3. Mutex in child walk 🐞 Bug ➹ Performance
Description
In pacquet, the stale-pin refresh calls higher_direct_dep_version() during walk_node_children() for
each child edge with a prior pin, and higher_direct_dep_version() takes a global std::sync::Mutex
and parses version strings on every call. This introduces extra lock contention and CPU work in a
hot resolution path and may slow incremental installs on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2031-2037]

+    lock_recoverable(&ctx.workspace.direct_dep_versions)
+        .get(&ctx.importer_id)?
+        .get(name)?
+        .iter()
+        .filter_map(|version| version.parse::<node_semver::Version>().ok())
+        .filter(|version| version > pinned && satisfies_including_prerelease(range, version))
+        .max()
Evidence
The per-child forced_version computation calls higher_direct_dep_version() while walking children,
and higher_direct_dep_version() locks a std::sync::Mutex and parses versions each call, so the extra
work is on the resolution hot path.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1574-1612]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2025-2038]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[26-35]

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

## Issue description
`walk_node_children` computes a `forced_version` per child and calls `higher_direct_dep_version`, which locks `ctx.workspace.direct_dep_versions` (a `std::sync::Mutex`) and parses stored version strings on every invocation. This adds repeated mutex acquisition and parsing work inside the child-edge fanout.
### Issue Context
This runs during resolution-time walking (incremental install path) and can be executed many times per node across large graphs.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1574-1612]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2025-2038]
### Suggested fix
- Avoid locking `direct_dep_versions` per child edge. For example, lock once per `walk_node_children` call and capture the importer’s direct-dep version map into a local variable (clone or otherwise snapshot a read-only view), then pass that to the helper.
- Avoid parsing the same version strings repeatedly by storing parsed `node_semver::Version` values when recording direct dep versions (or by caching the parsed list in the snapshot), so `higher_direct_dep_version` can do pure comparisons without per-call parsing.

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


4. Reuse gate allocates strings 🐞 Bug ➹ Performance
Description
In pacquet, the new lockfile reuse-decline check allocates a new String for each dependency key via
name.to_string() while scanning reused snapshots, and runs on the lockfile-reuse hot path before
try_reuse_node. This adds avoidable CPU/allocations and holds the changed_direct_deps mutex
during the scan, which can regress incremental install performance on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2006-2008]

+    let depends_on = |map: Option<&HashMap<PkgName, SnapshotDepRef>>| {
+        map.is_some_and(|deps| deps.keys().any(|name| importer_changed.contains(&name.to_string())))
+    };
Evidence
The reuse-decline gate is executed before lockfile subtree reuse and uses an allocating membership
test while holding the changed_direct_deps mutex, so it adds per-edge overhead on the reuse hot
path.

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[602-612]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[2000-2011]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1264-1290]

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

## Issue description
`reused_parent_has_changed_direct_child` checks whether a reused snapshot depends on a changed direct dependency by iterating `deps.keys()` and doing `importer_changed.contains(&name.to_string())`. This allocates a fresh `String` for every key examined and holds the `changed_direct_deps` mutex while scanning.
### Issue Context
This check runs in the lockfile-reuse gate (`resolve_node_seed`) before attempting subtree reuse, so it can execute many times in incremental installs.
### Fix Focus Areas
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[602-612]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1947-2011]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1264-1290]
### Suggested fix
- Change `changed_direct_deps` to store parsed `PkgName` (e.g., `HashSet<PkgName>`) rather than `String`.
- Update `record_changed_direct_deps` to parse `alias` into `PkgName` and insert that.
- Update `reused_parent_has_changed_direct_child` to compare without allocation (e.g., `deps.keys().any(|name| importer_changed.contains(name))`).
- Optionally: clone/copy the small `importer_changed` set out of the mutex guard and drop the lock before scanning the snapshot dependency maps to reduce lock-hold time.

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


Results up to commit 2aa4fa2


🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)


Remediation recommended
1. Mutex in child walk 🐞 Bug ➹ Performance ⭐ New
Description
In pacquet, the stale-pin refresh calls higher_direct_dep_version() during walk_node_children() for
each child edge with a prior pin, and higher_direct_dep_version() takes a global std::sync::Mutex
and parses version strings on every call. This introduces extra lock contention and CPU work in a
hot resolution path and may slow incremental installs on large dependency graphs.
Code

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[R2031-2037]

+    lock_recoverable(&ctx.workspace.direct_dep_versions)
+        .get(&ctx.importer_id)?
+        .get(name)?
+        .iter()
+        .filter_map(|version| version.parse::<node_semver::Version>().ok())
+        .filter(|version| version > pinned && satisfies_including_prerelease(range, version))
+        .max()
Evidence
The per-child forced_version computation calls higher_direct_dep_version() while walking children,
and higher_direct_dep_version() locks a std::sync::Mutex and parses versions each call, so the extra
work is on the resolution hot path.

[pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1574-1612]](https://github.com/pnpm/pnpm/blob/2aa4fa23280fee968dcdd067c1d865c183f8253d/pacquet/crates...

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

🧹 Nitpick comments (1)
installing/deps-resolver/src/resolveDependencies.ts (1)

1450-1453: ⚡ Quick win

Use an options object for findHigherDirectDepVersion parameters.

Line 1549 introduces 4 positional parameters in a new helper; this is easy to misorder in resolver hot-path call sites. Please convert it to a single options object.

Proposed refactor
-        const higherDirectVersion = pinned == null
-          ? undefined
-          : findHigherDirectDepVersion(options.preferredVersions, pinned.name, pinned.version, wantedDependency.bareSpecifier)
+        const higherDirectVersion = pinned == null
+          ? undefined
+          : findHigherDirectDepVersion({
+            preferredVersions: options.preferredVersions,
+            pinnedName: pinned.name,
+            pinnedVersion: pinned.version,
+            bareSpecifier: wantedDependency.bareSpecifier,
+          })

-function findHigherDirectDepVersion (
-  preferredVersions: PreferredVersions | undefined,
-  pinnedName: string,
-  pinnedVersion: string,
-  bareSpecifier: string
-): string | undefined {
+function findHigherDirectDepVersion (
+  opts: {
+    preferredVersions: PreferredVersions | undefined
+    pinnedName: string
+    pinnedVersion: string
+    bareSpecifier: string
+  }
+): string | undefined {
-  if (preferredVersions == null) return undefined
-  if (!semver.valid(pinnedVersion)) return undefined
-  if (semver.validRange(bareSpecifier) === null) return undefined
-  const selectors = preferredVersions[pinnedName]
+  if (opts.preferredVersions == null) return undefined
+  if (!semver.valid(opts.pinnedVersion)) return undefined
+  if (semver.validRange(opts.bareSpecifier) === null) return undefined
+  const selectors = opts.preferredVersions[opts.pinnedName]
   if (selectors == null) return undefined
   let best: string | undefined
   for (const [candidate, selector] of Object.entries(selectors)) {
     if (
       typeof selector === 'object' &&
       selector.selectorType === 'version' &&
       selector.weight === DIRECT_DEP_SELECTOR_WEIGHT &&
       semver.valid(candidate) &&
-      semver.gt(candidate, pinnedVersion) &&
-      semver.satisfies(candidate, bareSpecifier, true) &&
+      semver.gt(candidate, opts.pinnedVersion) &&
+      semver.satisfies(candidate, opts.bareSpecifier, true) &&
       (best == null || semver.gt(candidate, best))
     ) {
       best = candidate
     }
   }
   return best
 }

As per coding guidelines, **/*.{ts,tsx} functions should have ≤2-3 arguments and use an options object for more.

Also applies to: 1549-1575

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@installing/deps-resolver/src/resolveDependencies.ts` around lines 1450 -
1453, The function findHigherDirectDepVersion currently accepts 4 positional
parameters which violates the coding guideline of keeping functions to 2-3
arguments. Refactor this function (defined around line 1549-1575) to accept a
single options object parameter containing preferredVersions, name, version, and
bareSpecifier properties instead of four separate arguments. Then update all
call sites of findHigherDirectDepVersion, including the one at line 1450-1453,
to pass an options object with these properties rather than individual
positional arguments.

Source: Coding guidelines

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

Nitpick comments:
In `@installing/deps-resolver/src/resolveDependencies.ts`:
- Around line 1450-1453: The function findHigherDirectDepVersion currently
accepts 4 positional parameters which violates the coding guideline of keeping
functions to 2-3 arguments. Refactor this function (defined around line
1549-1575) to accept a single options object parameter containing
preferredVersions, name, version, and bareSpecifier properties instead of four
separate arguments. Then update all call sites of findHigherDirectDepVersion,
including the one at line 1450-1453, to pass an options object with these
properties rather than individual positional arguments.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0d7e8d8f-c55d-42b4-9847-c7a255179575

📥 Commits

Reviewing files that changed from the base of the PR and between 223d060 and 2aa4fa2.

📒 Files selected for processing (5)
  • .changeset/refresh-stale-lockfile-pins.md
  • installing/deps-installer/test/install/dedupe.ts
  • installing/deps-resolver/src/resolveDependencies.ts
  • pacquet/crates/cli/tests/stale_pin_dedupe.rs
  • pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs

Comment thread pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2aa4fa2

@github-actions github-actions Bot added the reviewed: coderabbit CodeRabbit submitted an approving review label Jun 17, 2026
zkochan added a commit that referenced this pull request Jun 17, 2026
…igher direct deps

Port of #12472. pacquet's full-subtree lockfile reuse
(resolve_reused_node) is coarser than pnpm's per-edge reuse and carries
neither the resolved direct-dep versions nor the parent manifest's ranges
the refresh needs. Three coordinated changes bridge that, mirroring pnpm's
phase structure:

- Record, per importer, which direct deps changed vs. the prior lockfile
  (race-free) and the resolved direct-dep versions (after the direct-dep
  level barrier).
- Decline full-subtree reuse for a parent that depends on a changed direct
  dep, so it resolves fresh and walks its children against their real
  manifest ranges.
- In walk_node_children, force a child edge onto a higher direct-dep
  version when the recorded pin is below it and it still satisfies the
  edge's range. The lower version is never resolved or fetched.

Both maps are keyed by importer to match pnpm's per-importer
preferredVersions. The direct-dep version map stores parsed versions
behind an Arc so the hot child walk snapshots it once per node instead of
locking and parsing per edge.
@zkochan zkochan force-pushed the feat/refresh-stale-lockfile-pins branch from 2aa4fa2 to 72cc9f1 Compare June 17, 2026 22:11
Comment thread installing/deps-resolver/src/resolveDependencies.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 72cc9f1

zkochan added a commit that referenced this pull request Jun 17, 2026
…igher direct deps

Port of #12472. pacquet's full-subtree lockfile reuse
(resolve_reused_node) is coarser than pnpm's per-edge reuse and carries
neither the resolved direct-dep versions nor the parent manifest's ranges
the refresh needs. Three coordinated changes bridge that, mirroring pnpm's
phase structure:

- Record, per importer, which direct deps changed vs. the prior lockfile
  (race-free) and the resolved direct-dep versions (after the direct-dep
  level barrier).
- Decline full-subtree reuse for a parent that depends on a changed direct
  dep, so it resolves fresh and walks its children against their real
  manifest ranges.
- In walk_node_children, force a child edge onto a higher direct-dep
  version when the recorded pin is below it and it still satisfies the
  edge's range. The lower version is never resolved or fetched.

Both maps are keyed by importer to match pnpm's per-importer
preferredVersions. The direct-dep version map stores parsed versions
behind an Arc so the hot child walk snapshots it once per node instead of
locking and parsing per edge. Range satisfaction uses plain semver (not
prerelease-inclusive), matching pnpm's semver.satisfies(.., true).
@zkochan zkochan force-pushed the feat/refresh-stale-lockfile-pins branch from 72cc9f1 to 4a27779 Compare June 17, 2026 22:58
Comment thread installing/deps-resolver/src/resolveDependencies.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 4a27779

When a package is reused from the lockfile, its child edges are taken
verbatim and bypass the preferred-versions walk, so a transitive
dependency can stay pinned to an older version even after a direct
dependency resolved to a higher version that satisfies the same range —
leaving the lockfile non-convergent (an incremental install keeps a
duplicate a fresh install would not).

getDepsToResolve now refreshes such a stale pin to the higher
direct-dependency version during resolution, via `preferredVersion`
(singular), which overrides the EXISTING_VERSION_SELECTOR_WEIGHT stability
bias. The older version is never resolved or fetched, and the incremental
result converges to the fresh one.

Tests cover the core scenario, caret-range and aliased (npm:) specifiers,
idempotency, and a multi-importer workspace (where all importers' direct
deps resolve before any transitive, so the refresh converges across
importers to match a fresh install).
…igher direct deps

Port of #12472. pacquet's full-subtree lockfile reuse
(resolve_reused_node) is coarser than pnpm's per-edge reuse and carries
neither the resolved direct-dep versions nor the parent manifest's ranges
the refresh needs. Three coordinated changes bridge that, mirroring pnpm's
phase structure:

- Record, per importer, which direct deps changed vs. the prior lockfile
  (race-free) and the resolved direct-dep versions (after the direct-dep
  level barrier).
- Decline full-subtree reuse for a parent that depends on a changed direct
  dep, so it resolves fresh and walks its children against their real
  manifest ranges.
- In walk_node_children, force a child edge onto a higher direct-dep
  version when the recorded pin is below it and it still satisfies the
  edge's range. The lower version is never resolved or fetched.

Both maps are keyed by importer. Unlike pnpm — which resolves every
importer's direct deps before any transitive and so converges
cross-importer — this crate resolves importers sequentially, so a shared
map would refresh one importer's edges from another's direct deps
order-dependently; per-importer keying keeps it deterministic (the
multi-importer convergence pnpm does is a known parity gap).

The direct-dep version map stores parsed versions behind an Arc so the hot
child walk snapshots it once per node instead of locking and parsing per
edge. Range satisfaction uses plain semver (not prerelease-inclusive),
matching pnpm's semver.satisfies(.., true).
@zkochan zkochan force-pushed the feat/refresh-stale-lockfile-pins branch from 4a27779 to a738dc9 Compare June 18, 2026 00:11
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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

@zkochan zkochan merged commit fe66535 into main Jun 18, 2026
37 checks passed
@zkochan zkochan deleted the feat/refresh-stale-lockfile-pins branch June 18, 2026 08:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

product: pacquet product: pnpm@11 reviewed: coderabbit CodeRabbit submitted an approving review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants