Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

feat(real-hoist): single-pass parent-wins hoist (#438)#452

Merged
zkochan merged 6 commits into
mainfrom
feat/438-slice-3b
May 13, 2026
Merged

feat(real-hoist): single-pass parent-wins hoist (#438)#452
zkochan merged 6 commits into
mainfrom
feat/438-slice-3b

Conversation

@zkochan

@zkochan zkochan commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the stub nm_hoist (landed in #448) with a working hoister and the surrounding guardrails: BFS over the result graph pulling every eligible descendant up to the root, plus upfront refusal of inputs the algorithm doesn't yet model.

What the algorithm models

  • Free hoist. A transitive dep with no name collision at the root surfaces at the root.
  • Identity dedup. A dep reachable through multiple parents shares one Rc thanks to the wrapper's cache; the hoist preserves that identity and strips the duplicate reference at the other parent path.
  • Parent-wins on version conflict. When two distinct deps share an alias but resolve to different snapshot keys, the first BFS visitor takes the root slot and the other stays under its declaring parent. Visit order matches the wrapper's alias-sorted insertion order, so the outcome is deterministic.
  • Deep chains flatten in one pass. root → a → b → c → d becomes root → {a, b, c, d} — each node, once hoisted, is queued for further descent; its own children evaluate against root's slots rather than against the (now-empty) intermediate parent.
  • O(1) root-slot lookup. A side HashMap<String, RcByPtr<HoisterResult>> mirrors root's direct deps, so the per-edge "is this name taken?" check doesn't degrade to O(N²) IndexSet scans on flat graphs.

Fail-fast guards

The algorithm doesn't yet model peers, hoisting limits, or multi-importer (workspace) hoist trees. Rather than emit a layout pnpm would reject, the wrapper refuses these inputs upfront with three new HoistError variants:

  • UnsupportedPeerDependency { ident, peers } — fires when scanning the constructed HoisterTree finds any node with non-empty peer_names (either peerDependencies from the packages: map or transitive_peer_dependencies on a snapshots: entry).
  • UnsupportedHoistingLimits { len } — non-empty opts.hoisting_limits.
  • UnsupportedWorkspace { extra_importers } — any importer beyond the root ..

Each carries enough context for an operator to identify what triggered it. The UnsupportedWorkspace help points at SymlinkDirectDependencies — workspaces do work in pacquet's wider install path (workspace support landed in #443 for the isolated linker); only the hoister is restricted.

Rebase pickup

collect_importer_deps carries the Link-variant skip introduced by #443 (workspace support) — spec.version.as_regular() extracts the snapshot key for Regular deps and continues past Link deps, since workspace siblings are direct symlinks materialised by SymlinkDirectDependencies and have no snapshot to hoist.

Performance

Nothing in this PR is reachable from pacquet install today (crates/package-manager/src/install*.rs doesn't import pacquet-real-hoist — the crate is dead code from the install pipeline's perspective until the hoisted-linker wiring lands in later slices). The benchmark results bear that out: cold Frozen Lockfile is within CI variance (+3.2% mean, +3.5% median, driven by one outlier), Hot Cache is 6% faster (same stat → lstat improvement that shipped in slice 1's import_indexed_dir), micro is identical. No code path explains a real regression.

What's deferred

  • Peer-dependency-aware hoisting (peer_names constraints, peer-promise satisfaction).
  • Multi-round convergence — the BFS handles deep chains in one pass, so the cases requiring true multi-round are limited to peer interactions.
  • hoistingLimits runtime enforcement.
  • dependencyKind distinctions for workspaces and external soft links (today only ExternalSoftLink placeholders are added by the wrapper and stripped post-hoist; Workspace-kind nodes are blocked by the UnsupportedWorkspace guard upfront).

Upstream reference

Test plan

  • one_transitive_dep_hoists_to_rootroot → a → b flattens.
  • diamond_dep_hoists_once_to_rootroot → {a, c} → b keeps one shared b at root; a DFS over the post-hoist graph asserts exactly one allocation with name == "b" (proves identity dedup observably, not via a tautological find).
  • version_conflict_keeps_loser_at_parenta → b@1, c → b@2 puts the first-visited at root and leaves the other under its parent. Reference assertions use .contains() + explicit .len(), not iter().next().
  • deep_chain_flattens_in_one_pass — depth-4 chain in one BFS pass.
  • transitive_npm_alias_resolves_target_snapshot — aliased hoist with the resolved target's identity intact.
  • peer_dependency_in_lockfile_surfaces_unsupportedpackages.peerDependencies triggers UnsupportedPeerDependency.
  • non_empty_hoisting_limits_surfaces_unsupportedopts.hoisting_limits triggers UnsupportedHoistingLimits.
  • multi_importer_lockfile_surfaces_unsupported_workspace — extra importers trigger UnsupportedWorkspace.
  • All 3 wrapper tests from feat(real-hoist): crate skeleton + lockfile-to-HoisterTree wrapper (#438 slice 3a) #448 still pass (broken-lockfile error, empty lockfile, externalDependencies stripped).
  • just ready, cargo doc --document-private-items, taplo format --check, just dylint all clean.
  • All Copilot review threads addressed and resolved (order-fragile assertions, tautological identity check, references iter ordering, O(1) root-slot lookup, doc-comment misplacement, fail-fast guards from CodeRabbit).

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

Copilot AI review requested due to automatic review settings May 13, 2026 12:50
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Replaces a stubbed nm_hoist with conversion to HoisterResult and a single-pass BFS hoist_into_root; adds input guards for hoisting limits, workspace importers, and peer-constrained nodes; updates tests for deterministic, identity-robust assertions.

Changes

Real-hoist root hoisting algorithm

Layer / File(s) Summary
Hoisting contracts and imports
crates/real-hoist/src/lib.rs
Adds HashSet and VecDeque imports; extends HoistError with UnsupportedPeerDependency, UnsupportedHoistingLimits, and UnsupportedWorkspace; defines AbsorbDecision.
Hoist entry validation and peer scan
crates/real-hoist/src/lib.rs
hoist now rejects non-empty opts.hoisting_limits, fails on multi-importer lockfiles, and scans the constructed HoisterTree to early-return UnsupportedPeerDependency for any node with non-empty peer_names. Includes find_first_peer_constrained.
nm_hoist conversion and cloning
crates/real-hoist/src/lib.rs
nm_hoist converts HoisterTreeHoisterResult, calls hoist_into_root, and returns an owned cloned outer HoisterResult to avoid later mutations of shared inner graph nodes.
Single-pass BFS hoist_into_root
crates/real-hoist/src/lib.rs
Implements BFS hoisting: builds root_index for O(1) root checks, tracks visited nodes by Rc identity, snapshots children before mutating, applies per-child AbsorbDecision (move to root, dedupe same Rc, or keep on conflict), and enqueues children so deep chains flatten in the same pass.
Deterministic hoisting test updates
crates/real-hoist/src/tests.rs
Updated tests: broken-lockfile pattern-match; transitive, diamond, version-conflict, deep-chain, npm-alias tests assert sorted root membership, child-stripping, and use result-graph traversals to validate pointer-identity deduplication and correct reference sets.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • pnpm/pacquet#448: Provides the earlier crate skeleton and lockfile-to-HoisterTree wrapper that this PR implements the hoisting algorithm atop.

Poem

🐰 I hopped the tree to lift each name,
Breadth-first flattening, one-pass game.
No duplicate paws left to confound,
Rooted friends now neatly found.
A joyful hop — neat graph unbound.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(real-hoist): single-pass parent-wins hoist' accurately and specifically describes the main change: replacing the stub with a working single-pass hoister using a parent-wins strategy.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering the algorithm's design, guardrails, test plan, and deferred work.

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

✨ 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/438-slice-3b

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

@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.00     16.0±0.49ms   271.2 KB/sec    1.00     15.9±0.29ms   272.6 KB/sec

@codecov

codecov Bot commented May 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.75000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.35%. Comparing base (8b35794) to head (b467ea7).

Files with missing lines Patch % Lines
crates/real-hoist/src/lib.rs 98.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #452      +/-   ##
==========================================
+ Coverage   87.15%   87.35%   +0.19%     
==========================================
  Files         113      113              
  Lines        9187     9266      +79     
==========================================
+ Hits         8007     8094      +87     
+ Misses       1180     1172       -8     

☔ View full report in Codecov by Sentry.
📢 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Implements a working nm_hoist for real-hoist using a single-pass BFS “parent-wins” strategy and updates/expands tests to validate the new hoisting behavior (flattening, diamond dedup behavior, version conflicts, deep chains, npm alias hoisting).

Changes:

  • Replaces the previous nm_hoist stub with a BFS-based hoisting implementation that hoists eligible descendants to the root.
  • Adds/updates tests covering basic hoisting, diamond graphs, version conflicts, deep-chain flattening, and npm alias behavior.
  • Expands inline documentation describing the modeled subset vs. unsupported behaviors (peers, multi-round convergence, etc.).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
crates/real-hoist/src/lib.rs Replaces nm_hoist stub with BFS hoist implementation and adds algorithm documentation.
crates/real-hoist/src/tests.rs Updates existing tests and adds new ones to assert the new hoist semantics (flattening, conflicts, alias behavior).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/real-hoist/src/tests.rs Outdated
Comment thread crates/real-hoist/src/tests.rs Outdated
Comment thread crates/real-hoist/src/tests.rs Outdated
Comment thread crates/real-hoist/src/tests.rs Outdated
Comment thread crates/real-hoist/src/lib.rs Outdated
@zkochan

zkochan commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown

Integrated-Benchmark Report (Linux)

Scenario: Frozen Lockfile

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.618 ± 0.138 2.458 2.945 1.03 ± 0.07
pacquet@main 2.537 ± 0.096 2.441 2.704 1.00
pnpm 6.205 ± 0.046 6.131 6.296 2.45 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.61792450212,
      "stddev": 0.13787773114226792,
      "median": 2.60904313392,
      "user": 2.5419263599999997,
      "system": 3.56707946,
      "min": 2.45833193442,
      "max": 2.94474279442,
      "times": [
        2.64055692242,
        2.63226891342,
        2.45833193442,
        2.94474279442,
        2.65012241842,
        2.69998207742,
        2.48994459342,
        2.58581735442,
        2.5250663444200003,
        2.55241166842
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.5371054135200004,
      "stddev": 0.09645354577859676,
      "median": 2.5214388969200003,
      "user": 2.50623446,
      "system": 3.5664678600000004,
      "min": 2.4410170784200003,
      "max": 2.70400957242,
      "times": [
        2.57775772542,
        2.49461902242,
        2.68082414842,
        2.45421845642,
        2.70400957242,
        2.54825877142,
        2.4410170784200003,
        2.56716414742,
        2.45021774342,
        2.45296746942
      ]
    },
    {
      "command": "pnpm",
      "mean": 6.20502918742,
      "stddev": 0.04551263776150798,
      "median": 6.20737685492,
      "user": 9.16048616,
      "system": 4.52630516,
      "min": 6.130870338419999,
      "max": 6.296380378419999,
      "times": [
        6.158180196419999,
        6.205919252419999,
        6.20859447142,
        6.24600402742,
        6.17614138042,
        6.20976337642,
        6.21227921442,
        6.296380378419999,
        6.130870338419999,
        6.20615923842
      ]
    }
  ]
}

Scenario: Frozen Lockfile (Hot Cache)

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 690.4 ± 35.9 657.5 782.4 1.00
pacquet@main 734.9 ± 38.5 663.6 813.7 1.06 ± 0.08
pnpm 2610.7 ± 98.2 2469.9 2798.4 3.78 ± 0.24
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6904430900200001,
      "stddev": 0.035878959722302525,
      "median": 0.67780183622,
      "user": 0.37167354,
      "system": 1.48106662,
      "min": 0.65751207972,
      "max": 0.7823607587200001,
      "times": [
        0.7823607587200001,
        0.6889169177200001,
        0.6728825177200001,
        0.6825418937200001,
        0.6730617787200001,
        0.69386290372,
        0.6726438517200001,
        0.6674263877200001,
        0.7132218107200001,
        0.65751207972
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.73493688172,
      "stddev": 0.03854008581828193,
      "median": 0.73956899222,
      "user": 0.35631344,
      "system": 1.4963200200000002,
      "min": 0.6636145667200001,
      "max": 0.8137401007200001,
      "times": [
        0.74865217372,
        0.73954100172,
        0.74217581872,
        0.69732580472,
        0.7395969827200001,
        0.7196245397200001,
        0.8137401007200001,
        0.6636145667200001,
        0.7391687457200001,
        0.7459290827200001
      ]
    },
    {
      "command": "pnpm",
      "mean": 2.61073640472,
      "stddev": 0.09821045384016339,
      "median": 2.57795209622,
      "user": 3.1371889399999997,
      "system": 2.23682992,
      "min": 2.46991800772,
      "max": 2.79844711072,
      "times": [
        2.68083266472,
        2.5625772877200004,
        2.72142979372,
        2.59332690472,
        2.46991800772,
        2.54969227772,
        2.5487688367200003,
        2.63374595972,
        2.5486252037200003,
        2.79844711072
      ]
    }
  ]
}

zkochan added a commit that referenced this pull request May 13, 2026
…review)

Five review findings from Copilot, all legitimate:

1. `hoist_into_root` did `root_deps.iter().find(...)` per visited
   edge — O(root_degree) per edge, O(N²) on a flat graph. Mirror
   root's direct deps into a `HashMap<String, RcByPtr<…>>` index
   maintained alongside the `IndexSet`, so the per-edge "is this
   name taken?" check stays O(1). The IndexSet remains
   authoritative for insertion order; the index is a side cache
   updated whenever a child is hoisted in.

2. `one_transitive_dep_hoists_to_root`'s `assert_eq!(names,
   ["a","b"])` depended on BFS insertion order. Sort first so the
   test pins membership, not traversal order.

3. `diamond_dep_hoists_once_to_root`'s `all_bs.len() == 1` check
   was tautological — `find` returns at most one. The intended
   property (identity dedup across the full result graph) wasn't
   actually being checked. Replace with a DFS that collects every
   distinct `Rc` allocation whose `name == "b"` and asserts the
   set has exactly one entry.

4. `version_conflict_keeps_loser_at_parent` and
   `transitive_npm_alias_resolves_target_snapshot` asserted on
   `references.borrow().iter().next()`. That's order-dependent
   even when there's only one entry today; once the algorithm
   starts accumulating references (peer-dependency work), it
   could regress to silent ordering bugs. Switch to
   `.contains(expected)` plus an explicit `.len()` check.

All 8 tests pass. `just ready`, `cargo doc
--document-private-items`, `taplo format --check`, `just dylint`
clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@crates/real-hoist/src/lib.rs`:
- Around line 530-633: The hoist_into_root/nm_hoist logic only considers name
collisions and therefore can produce layouts that ignore peer constraints,
hoisting_limits, and dependency_kind; change nm_hoist to detect unsupported
graph features up-front (e.g. any HoisterTree node with non-empty peer_names,
non-default hoisting_limits in HoistOpts, or any non-default dependency_kind on
edges) and fail fast (return an error/result signaling unsupported input)
instead of running hoist_into_root, or alternatively propagate those constraints
into the hoist state so hoist_into_root consults them before moving children.
Locate checks in nm_hoist (before calling convert/hoist_into_root) and reference
HoisterTree.peek/fields peer_names, HoistOpts.hoisting_limits and any
edge/dependency field named dependency_kind to implement the validation or to
thread the constraints into the existing root_index/AbsorbDecision logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b4b2c819-74aa-4153-a029-8e02ee6a9ef4

📥 Commits

Reviewing files that changed from the base of the PR and between 43e5db9 and 9ac2a61.

📒 Files selected for processing (2)
  • crates/real-hoist/src/lib.rs
  • crates/real-hoist/src/tests.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Doc
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Code Coverage
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Preserve existing method chains and pipe-trait chains; do not break them into intermediate let bindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Path over &PathBuf, &str over &String) when it does not force extra copies.
Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Write use super::{Foo, bar} instead of use super::*; for any glob whose target is a module you control. External-crate preludes (e.g., use rayon::prelude::*;) and root-of-module re-exports (e.g., pub use submodule::*; in lib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plain String or &str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only via TryFrom<String> and/or FromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallible From<String> and From<&str> constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bare as assertion), add a from_str_unchecked (or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...

Files:

  • crates/real-hoist/src/lib.rs
  • crates/real-hoist/src/tests.rs
🧠 Learnings (3)
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.

Applied to files:

  • crates/real-hoist/src/lib.rs
  • crates/real-hoist/src/tests.rs
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.

Applied to files:

  • crates/real-hoist/src/tests.rs
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In this repo’s Rust tests (run via `cargo nextest`), stdout/stderr from passing tests is captured and is only shown when a test fails. As a result, `eprintln!` (and similar stderr logging) in these tests should generally not be flagged as CI “noise” on the happy path; CI output should only appear for failing tests.

Applied to files:

  • crates/real-hoist/src/tests.rs
🔇 Additional comments (1)
crates/real-hoist/src/tests.rs (1)

315-317: ⚡ Quick win

Include the leaked child list in this assertion.

If this fails today, we only learn which root child still has nested deps, not what actually leaked. Printing the remaining child names here would make the failure self-diagnosing under cargo nextest.

[Suggest_recommended_refactor]

💡 Example tweak
     for entry in root_children.iter() {
+        let leaked: Vec<_> =
+            entry.0.dependencies.borrow().iter().map(|d| d.0.name.as_str()).collect();
         assert!(
-            entry.0.dependencies.borrow().is_empty(),
-            "{} has no nested deps",
-            entry.0.name
+            leaked.is_empty(),
+            "{} still has nested deps: {:?}",
+            entry.0.name,
+            leaked
         );
     }

Based on learnings, "In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., eprintln!/eprintln!(...)) so that useful diagnostic values are printed when a test fails, unless the assertion is assert_eq!."

Comment thread crates/real-hoist/src/lib.rs
zkochan added a commit that referenced this pull request May 13, 2026
Coderabbit flagged that the algorithm silently accepts inputs it
doesn't model — peer-constrained packages, non-empty
`hoisting_limits`, and multi-importer (workspace) lockfiles — and
returns a layout that looks plausible but doesn't match what pnpm
would produce. A future caller wiring this into the install
pipeline before the peer / limits / workspace sub-slices land
would silently emit wrong `node_modules`.

Add three new `HoistError` variants and check them upfront:

- `UnsupportedPeerDependency { ident, peers }` — scan the
  constructed `HoisterTree` after the wrapper builds it; if any
  node has non-empty `peer_names` (either declared
  `peerDependencies` from the `packages:` map or
  `transitive_peer_dependencies` on a `snapshots:` entry), surface
  the offending package's snapshot key and the peer set.
- `UnsupportedHoistingLimits { len }` — refuse non-empty
  `opts.hoisting_limits`.
- `UnsupportedWorkspace { extra_importers }` — refuse lockfiles
  with any importer beyond the root `.`.

Each variant carries enough context for the operator to identify
what triggered it and points at the umbrella's sub-slices that
will lift the restriction.

Three new tests pin each guard. Existing 8 tests still pass — none
of them use peers, limits, or extra importers.
Copilot AI review requested due to automatic review settings May 13, 2026 13:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@crates/real-hoist/src/lib.rs`:
- Around line 575-636: The long doc comment describing the BFS hoisting
algorithm is currently above find_first_peer_constrained; move that entire
nm_hoist doc block (the multi-line comment explaining free hoist, identity
dedup, parent-wins, not-modeled items, and the upstream link) so it sits
immediately above the nm_hoist function, and leave only the short one-line
comment about scanning for peer constraints above find_first_peer_constrained;
ensure the comment for nm_hoist references the function name nm_hoist and the
peer-scan comment remains directly above find_first_peer_constrained.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a966c086-1517-46e8-8834-7dff6608fbf1

📥 Commits

Reviewing files that changed from the base of the PR and between 9ac2a61 and 4fb512d.

📒 Files selected for processing (2)
  • crates/real-hoist/src/lib.rs
  • crates/real-hoist/src/tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/real-hoist/src/tests.rs
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Preserve existing method chains and pipe-trait chains; do not break them into intermediate let bindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Path over &PathBuf, &str over &String) when it does not force extra copies.
Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Write use super::{Foo, bar} instead of use super::*; for any glob whose target is a module you control. External-crate preludes (e.g., use rayon::prelude::*;) and root-of-module re-exports (e.g., pub use submodule::*; in lib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plain String or &str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only via TryFrom<String> and/or FromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallible From<String> and From<&str> constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bare as assertion), add a from_str_unchecked (or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...

Files:

  • crates/real-hoist/src/lib.rs
🧠 Learnings (1)
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.

Applied to files:

  • crates/real-hoist/src/lib.rs
🔇 Additional comments (8)
crates/real-hoist/src/lib.rs (8)

19-19: LGTM!


163-221: LGTM!


285-299: LGTM!


367-374: LGTM!


616-634: LGTM!


637-647: LGTM!


649-662: LGTM!


664-738: LGTM!

Comment thread crates/real-hoist/src/lib.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

zkochan added a commit that referenced this pull request May 13, 2026
Adding `find_first_peer_constrained` between the doc block and
`nm_hoist` accidentally orphaned the algorithm's documentation
onto the wrong function: the long "what this models / doesn't
model" block ended up describing `find_first_peer_constrained`
while `nm_hoist` itself was undocumented. Split them so each
function carries its own docs, and update the "what this does not
model yet" section to note that peer-constrained inputs are now
refused by the wrapper before they reach this function.
zkochan added a commit that referenced this pull request May 13, 2026
…review)

Five review findings from Copilot, all legitimate:

1. `hoist_into_root` did `root_deps.iter().find(...)` per visited
   edge — O(root_degree) per edge, O(N²) on a flat graph. Mirror
   root's direct deps into a `HashMap<String, RcByPtr<…>>` index
   maintained alongside the `IndexSet`, so the per-edge "is this
   name taken?" check stays O(1). The IndexSet remains
   authoritative for insertion order; the index is a side cache
   updated whenever a child is hoisted in.

2. `one_transitive_dep_hoists_to_root`'s `assert_eq!(names,
   ["a","b"])` depended on BFS insertion order. Sort first so the
   test pins membership, not traversal order.

3. `diamond_dep_hoists_once_to_root`'s `all_bs.len() == 1` check
   was tautological — `find` returns at most one. The intended
   property (identity dedup across the full result graph) wasn't
   actually being checked. Replace with a DFS that collects every
   distinct `Rc` allocation whose `name == "b"` and asserts the
   set has exactly one entry.

4. `version_conflict_keeps_loser_at_parent` and
   `transitive_npm_alias_resolves_target_snapshot` asserted on
   `references.borrow().iter().next()`. That's order-dependent
   even when there's only one entry today; once the algorithm
   starts accumulating references (peer-dependency work), it
   could regress to silent ordering bugs. Switch to
   `.contains(expected)` plus an explicit `.len()` check.

All 8 tests pass. `just ready`, `cargo doc
--document-private-items`, `taplo format --check`, `just dylint`
clean.
zkochan added a commit that referenced this pull request May 13, 2026
Coderabbit flagged that the algorithm silently accepts inputs it
doesn't model — peer-constrained packages, non-empty
`hoisting_limits`, and multi-importer (workspace) lockfiles — and
returns a layout that looks plausible but doesn't match what pnpm
would produce. A future caller wiring this into the install
pipeline before the peer / limits / workspace sub-slices land
would silently emit wrong `node_modules`.

Add three new `HoistError` variants and check them upfront:

- `UnsupportedPeerDependency { ident, peers }` — scan the
  constructed `HoisterTree` after the wrapper builds it; if any
  node has non-empty `peer_names` (either declared
  `peerDependencies` from the `packages:` map or
  `transitive_peer_dependencies` on a `snapshots:` entry), surface
  the offending package's snapshot key and the peer set.
- `UnsupportedHoistingLimits { len }` — refuse non-empty
  `opts.hoisting_limits`.
- `UnsupportedWorkspace { extra_importers }` — refuse lockfiles
  with any importer beyond the root `.`.

Each variant carries enough context for the operator to identify
what triggered it and points at the umbrella's sub-slices that
will lift the restriction.

Three new tests pin each guard. Existing 8 tests still pass — none
of them use peers, limits, or extra importers.
zkochan added a commit that referenced this pull request May 13, 2026
Adding `find_first_peer_constrained` between the doc block and
`nm_hoist` accidentally orphaned the algorithm's documentation
onto the wrong function: the long "what this models / doesn't
model" block ended up describing `find_first_peer_constrained`
while `nm_hoist` itself was undocumented. Split them so each
function carries its own docs, and update the "what this does not
model yet" section to note that peer-constrained inputs are now
refused by the wrapper before they reach this function.
Copilot AI review requested due to automatic review settings May 13, 2026 13:49
@zkochan zkochan force-pushed the feat/438-slice-3b branch from 6b93245 to a27d0ab Compare May 13, 2026 13:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

zkochan added 5 commits May 13, 2026 15:55
Replaces the stub `nm_hoist` with a working hoister: walks the
result graph breadth-first and pulls every eligible descendant
up to the root.

What it models:

- Free hoist — a transitive dep with no name collision at the root
  surfaces at the root.
- Identity dedup — a dep reachable through multiple parents shares
  one Rc thanks to the wrapper's cache, and the hoist preserves
  that identity instead of allocating a second copy. The duplicate
  reference at the other parent path is stripped.
- Parent-wins on version conflict — when two distinct deps share an
  alias but resolve to different snapshot keys, the first BFS
  visitor takes the root slot and the other stays under its
  declaring parent. Visit order matches the wrapper's
  alias-sorted insertion order, so the outcome is deterministic.
- Deep chains flatten in one pass — `root → a → b → c → d` becomes
  `root → {a, b, c, d}` because each node, once hoisted, is queued
  for further descent; its own children evaluate against root's
  slots rather than against the (now-empty) intermediate parent.

What it doesn't model yet (each gated on later work in the slice
sequence because they require either additional graph metadata
or peer-aware traversal):

- Peer-dependency constraints (`peer_names`).
- Multi-round convergence — the BFS handles deep chains in one
  pass, so the remaining cases requiring true multi-round are
  limited to peer interactions.
- `hoistingLimits` / `externalDependencies` knobs.
- `dependencyKind` distinctions for workspaces and external soft
  links.

Tests:

- `one_transitive_dep_hoists_to_root` — `root → a → b` flattens.
- `diamond_dep_hoists_once_to_root` — `root → {a, c} → b` keeps
  one shared `b` at root with identity preserved.
- `version_conflict_keeps_loser_at_parent` — `a → b@1, c → b@2`
  puts the first-visited at root and leaves the other under its
  parent.
- `deep_chain_flattens_in_one_pass` — depth-4 chain.
- `transitive_npm_alias_resolves_target_snapshot` — updated to
  assert the alias hoists to root with the resolved target's
  identity intact.
- All wrapper tests from 3a still pass.

Upstream reference: `yarnpkg/berry@4287909fa6` `hoist.ts` and the
pnpm wrapper at `pnpm/pnpm@94240bc046`.
…review)

Five review findings from Copilot, all legitimate:

1. `hoist_into_root` did `root_deps.iter().find(...)` per visited
   edge — O(root_degree) per edge, O(N²) on a flat graph. Mirror
   root's direct deps into a `HashMap<String, RcByPtr<…>>` index
   maintained alongside the `IndexSet`, so the per-edge "is this
   name taken?" check stays O(1). The IndexSet remains
   authoritative for insertion order; the index is a side cache
   updated whenever a child is hoisted in.

2. `one_transitive_dep_hoists_to_root`'s `assert_eq!(names,
   ["a","b"])` depended on BFS insertion order. Sort first so the
   test pins membership, not traversal order.

3. `diamond_dep_hoists_once_to_root`'s `all_bs.len() == 1` check
   was tautological — `find` returns at most one. The intended
   property (identity dedup across the full result graph) wasn't
   actually being checked. Replace with a DFS that collects every
   distinct `Rc` allocation whose `name == "b"` and asserts the
   set has exactly one entry.

4. `version_conflict_keeps_loser_at_parent` and
   `transitive_npm_alias_resolves_target_snapshot` asserted on
   `references.borrow().iter().next()`. That's order-dependent
   even when there's only one entry today; once the algorithm
   starts accumulating references (peer-dependency work), it
   could regress to silent ordering bugs. Switch to
   `.contains(expected)` plus an explicit `.len()` check.

All 8 tests pass. `just ready`, `cargo doc
--document-private-items`, `taplo format --check`, `just dylint`
clean.
Coderabbit flagged that the algorithm silently accepts inputs it
doesn't model — peer-constrained packages, non-empty
`hoisting_limits`, and multi-importer (workspace) lockfiles — and
returns a layout that looks plausible but doesn't match what pnpm
would produce. A future caller wiring this into the install
pipeline before the peer / limits / workspace sub-slices land
would silently emit wrong `node_modules`.

Add three new `HoistError` variants and check them upfront:

- `UnsupportedPeerDependency { ident, peers }` — scan the
  constructed `HoisterTree` after the wrapper builds it; if any
  node has non-empty `peer_names` (either declared
  `peerDependencies` from the `packages:` map or
  `transitive_peer_dependencies` on a `snapshots:` entry), surface
  the offending package's snapshot key and the peer set.
- `UnsupportedHoistingLimits { len }` — refuse non-empty
  `opts.hoisting_limits`.
- `UnsupportedWorkspace { extra_importers }` — refuse lockfiles
  with any importer beyond the root `.`.

Each variant carries enough context for the operator to identify
what triggered it and points at the umbrella's sub-slices that
will lift the restriction.

Three new tests pin each guard. Existing 8 tests still pass — none
of them use peers, limits, or extra importers.
Adding `find_first_peer_constrained` between the doc block and
`nm_hoist` accidentally orphaned the algorithm's documentation
onto the wrong function: the long "what this models / doesn't
model" block ended up describing `find_first_peer_constrained`
while `nm_hoist` itself was undocumented. Split them so each
function carries its own docs, and update the "what this does not
model yet" section to note that peer-constrained inputs are now
refused by the wrapper before they reach this function.
Origin/main grew a `path: Option<String>` field on
`TarballResolution` (PR #451, `feat(git-fetcher): install
git-hosted tarballs via preparePackage + packlist`). That PR
landed after #439 (`feat(package-is-installable): platform +
engine check`) and missed updating the `TarballResolution`
literal in `crates/package-manager/src/installability/tests.rs`,
so any PR opened against current main inherits a build break in
the `package-is-installable` test build (#445 hit the same
failure). My own `peer_dependency_in_lockfile_surfaces_unsupported`
test in `crates/real-hoist/src/tests.rs` had the same gap.

Add `path: None` to both literals so the test builds compile
against the post-#451 `TarballResolution` shape.

Includes the rebase of feat/438-slice-3b onto current origin/main.
`UnsupportedPeerDependency`, `UnsupportedHoistingLimits`, and
`UnsupportedWorkspace` all said "staged for a later sub-slice of
#438", which is exactly the PR-organisation scaffolding that
rots once the referenced slices land or get renumbered. Rewrite
each help message to describe what works today rather than what's
coming, with the `UnsupportedWorkspace` case additionally pointing
at the existing isolated-linker workspace path
(`SymlinkDirectDependencies`) so an operator sees where workspace
support does live in pacquet today.

No behaviour change.
@zkochan zkochan force-pushed the feat/438-slice-3b branch from a27d0ab to b467ea7 Compare May 13, 2026 14:01

@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)
crates/real-hoist/src/tests.rs (1)

123-124: ⚡ Quick win

Use Rc::clone(&...) instead of .clone() for Rc handles.

Please switch these Rc clone sites to Rc::clone(&x) to match repo Rust style and make refcounting explicit.

Suggested pattern updates
- let a = root_children.iter().find(|d| d.0.name == "a").unwrap().0.clone();
+ let a = Rc::clone(&root_children.iter().find(|d| d.0.name == "a").unwrap().0);

- let mut stack: Vec<Rc<HoisterResult>> = root_children.iter().map(|d| d.0.clone()).collect();
+ let mut stack: Vec<Rc<HoisterResult>> = root_children.iter().map(|d| Rc::clone(&d.0)).collect();

- stack.push(d.0.clone());
+ stack.push(Rc::clone(&d.0));

As per coding guidelines: “Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.”

Also applies to: 173-174, 186-187, 197-198, 248-249, 259-260, 410-416, 424-425

🤖 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 `@crates/real-hoist/src/tests.rs` around lines 123 - 124, The test currently
uses .clone() on Rc handles (e.g., let a = root_children.iter().find(|d|
d.0.name == "a").unwrap().0.clone();) which violates repo style; replace these
with Rc::clone(&...) to make refcounting explicit — locate the Rc clones in
tests.rs (examples: the binding for a and other spots noted: around lines where
root_children, child bindings, or nodes are cloned) and change .clone() calls on
Rc-wrapped values to Rc::clone(&value) (and similarly use Rc::clone(&some_rc)
wherever you see .clone() on Rc handles such as the other instances mentioned in
the review).
🤖 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 `@crates/real-hoist/src/tests.rs`:
- Around line 123-124: The test currently uses .clone() on Rc handles (e.g., let
a = root_children.iter().find(|d| d.0.name == "a").unwrap().0.clone();) which
violates repo style; replace these with Rc::clone(&...) to make refcounting
explicit — locate the Rc clones in tests.rs (examples: the binding for a and
other spots noted: around lines where root_children, child bindings, or nodes
are cloned) and change .clone() calls on Rc-wrapped values to Rc::clone(&value)
(and similarly use Rc::clone(&some_rc) wherever you see .clone() on Rc handles
such as the other instances mentioned in the review).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7e929f26-c3de-4524-a6ea-bd4edc6f4b0b

📥 Commits

Reviewing files that changed from the base of the PR and between 6b93245 and b467ea7.

📒 Files selected for processing (2)
  • crates/real-hoist/src/lib.rs
  • crates/real-hoist/src/tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/real-hoist/src/lib.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Code Coverage
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Preserve existing method chains and pipe-trait chains; do not break them into intermediate let bindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Path over &PathBuf, &str over &String) when it does not force extra copies.
Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Write use super::{Foo, bar} instead of use super::*; for any glob whose target is a module you control. External-crate preludes (e.g., use rayon::prelude::*;) and root-of-module re-exports (e.g., pub use submodule::*; in lib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plain String or &str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only via TryFrom<String> and/or FromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallible From<String> and From<&str> constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bare as assertion), add a from_str_unchecked (or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...

Files:

  • crates/real-hoist/src/tests.rs
🧠 Learnings (3)
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.

Applied to files:

  • crates/real-hoist/src/tests.rs
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In this repo’s Rust tests (run via `cargo nextest`), stdout/stderr from passing tests is captured and is only shown when a test fails. As a result, `eprintln!` (and similar stderr logging) in these tests should generally not be flagged as CI “noise” on the happy path; CI output should only appear for failing tests.

Applied to files:

  • crates/real-hoist/src/tests.rs
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.

Applied to files:

  • crates/real-hoist/src/tests.rs
🔇 Additional comments (1)
crates/real-hoist/src/tests.rs (1)

67-70: LGTM!

Also applies to: 85-122, 127-172, 175-185, 188-201, 203-247, 250-258, 261-265, 267-320, 401-409, 417-423, 426-426, 428-538

@zkochan zkochan merged commit 477d240 into main May 13, 2026
16 checks passed
@zkochan zkochan deleted the feat/438-slice-3b branch May 13, 2026 14:58
zkochan added a commit that referenced this pull request May 13, 2026
## Summary

Lifts the previous `UnsupportedPeerDependency` upfront guard and replaces it with the real peer check from upstream's `getNodeHoistInfo`. The hoister now accepts lockfiles whose packages declare peer dependencies and produces the layout pnpm would: a peer-constrained dep only floats up to the root when doing so doesn't change which package its peers resolve to.

## How it works

- `HoisterResult` carries the input `peer_names` set forward. Upstream's `HoisterResult` doesn't (peer info lives on its intermediate `HoisterWorkTree`); pacquet runs the algorithm on `HoisterResult` directly, so the peer set rides along.

- The hoist driver is a recursive `hoist_subtree` that walks the result graph depth-first. Each recursion receives the candidate parent's *current* ancestor path (`Vec<Rc<HoisterResult>>` exclusive of the parent). After a child's hoist decision is applied, the path passed into the child's recursion reflects its *new* position — a freshly-hoisted child recurses with `[root]`, a child that stayed nested recurses with `parent_path + [parent]`. (An earlier version of this PR used BFS with paths captured at queue time; that path went stale whenever a node was hoisted between being queued and dequeued, leading to over-refused hoists. The recursive form was Copilot's recommendation in code review; the bug regression is pinned by `peer_check_uses_post_hoist_ancestor_path_not_queue_time_path`.)

- `would_shadow_peer` walks that path to decide whether a candidate hoist would change its own peer resolution.

- New `AbsorbDecision::PeerShadow` variant alongside `Free` / `SameNode` / `Conflict`. Fires when:
  1. Root declares the candidate's own name as a peer (root-shadow guard — vacuous today since the wrapper's `.` root has empty `peer_names`, kept for parity).
  2. For each peer name `P` the candidate declares, the closest ancestor providing `P` does so with a different ident than the root's `P`. Promoting the candidate would silently re-resolve the peer.

- The previous `UnsupportedPeerDependency` error variant and `find_first_peer_constrained` upfront scan are gone. The `#[non_exhaustive]` tag stays so future variants can be added without breaking callers.

## DAG-vs-tree caveat

Upstream's `cloneTree` duplicates the work tree into a strict tree per parent path, so each candidate visit has a unique ancestor chain. Pacquet preserves the DAG (its identity-dedup is a feature) and records only the path DFS actually used to reach the candidate. In the rare case where the same `Rc` is reachable through both a peer-compatible and a peer-shadowing path, pacquet refuses to hoist — the layout ends up at most over-nested, never under-nested. The trade-off is documented on `would_shadow_peer`.

## Upstream reference

- [`hoist.ts:414`](https://github.com/yarnpkg/berry/blob/4287909fa6a0a1ec976a55776bff606864b31990/packages/yarnpkg-nm/sources/hoist.ts#L414) — root-shadow guard.
- [`hoist.ts:454-479`](https://github.com/yarnpkg/berry/blob/4287909fa6a0a1ec976a55776bff606864b31990/packages/yarnpkg-nm/sources/hoist.ts#L454-L479) — ancestor-path peer check.
- [`hoist.ts:670`](https://github.com/yarnpkg/berry/blob/4287909fa6a0a1ec976a55776bff606864b31990/packages/yarnpkg-nm/sources/hoist.ts#L670) — `cloneTree`, the per-path-tree shape pacquet skips.

## Test plan

- [x] `peer_constrained_node_stays_under_parent_when_root_provides_different_ident` — `app → widget (peer: react) + react@17`, `root → react@18`. widget cannot hoist because hoisting would change its peer to react@18.
- [x] `peer_constrained_node_hoists_when_ancestor_and_root_agree` — same shape but `app → react@18` matches root's `react@18` (shared `Rc` via wrapper dedup), so widget hoists freely.
- [x] `peer_check_uses_post_hoist_ancestor_path_not_queue_time_path` — regression for the BFS-stale-path bug Copilot caught. `root → app → mid → terminal` (peer: `react`) where `mid` hoists past a conflicting `app.react@17`. Under the previous BFS the stale path `[root, app, mid]` made `terminal`'s peer check trip on `app.react@17 ≠ root.react@18` and refuse the hoist; under DFS the actual post-hoist path `[root, mid]` finds no provider mismatch and `terminal` correctly flattens to root.
- [x] All 10 pre-existing tests still pass (`one_transitive_dep_hoists_to_root`, `diamond_dep_hoists_once_to_root`, `version_conflict_keeps_loser_at_parent`, `deep_chain_flattens_in_one_pass`, `transitive_npm_alias_resolves_target_snapshot`, `non_empty_hoisting_limits_surfaces_unsupported`, `multi_importer_lockfile_surfaces_unsupported_workspace`, `external_dependencies_are_stripped_from_the_result`, `empty_lockfile_yields_empty_root`, `hoist_throws_on_broken_lockfile`).
- [x] The previously-failing `peer_dependency_in_lockfile_surfaces_unsupported` test from #452 is replaced (peers are no longer an unsupported input).
- [x] All Copilot review threads addressed and resolved (stale ancestor path, misleading comment).
- [x] `just ready` (836 tests pass), `cargo doc --document-private-items`, `taplo format --check`, `just dylint` all clean.
KSXGitHub pushed a commit that referenced this pull request May 13, 2026
Pull in 28 commits from upstream main, including the
`pacquet-npmrc` → `pacquet-config` rename (#420) plus features:
- supportedArchitectures + --cpu/--os/--libc (#456)
- frozen-lockfile (#442, #443, #447, #450)
- git-fetcher (#436 / #446, #451, #454)
- side-effects cache (#421 / #422, #423, #424)
- real-hoist + global-virtual-store (#432 / #438 / #444, #449, #452)
- patchedDependencies + allow-builds (#425, #427, #428)
- engine/platform installability (#434 / #439)

Conflicts resolved:
- `crates/npmrc/` files migrated under the renamed
  `crates/config/` directory; `Npmrc` → `Config` everywhere
  except `NpmrcAuth` (which keeps the `.npmrc`-domain name).
- `Config::current` reads the env-var DI generic `Api: EnvVar`
  for ${VAR}-substitution in `.npmrc`. Production turbofish in
  `cli_args.rs` is `Config::current::<RealApi, _, _, _, _>(...)`.
- Two-phase `NpmrcAuth::apply_*` retained so default-registry
  creds key at the yaml-resolved registry URL.
- New `Config::auth_headers` field plumbed through
  `install_package_by_snapshot`'s `DownloadTarballToStore`.
- Tests under `crates/config/src/workspace_yaml/tests.rs`
  pick up the new ParseYaml unit test added on this branch.
@zkochan zkochan mentioned this pull request May 14, 2026
59 tasks
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants