feat(real-hoist): single-pass parent-wins hoist (#438)#452
Conversation
📝 WalkthroughWalkthroughReplaces 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. ChangesReal-hoist root hoisting algorithm
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Micro-Benchmark ResultsLinux |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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_hoiststub 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.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
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)
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
]
}
]
} |
…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.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@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
📒 Files selected for processing (2)
crates/real-hoist/src/lib.rscrates/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 andpipe-traitchains; do not break them into intermediateletbindings 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 (&Pathover&PathBuf,&strover&String) when it does not force extra copies.
PreferArc::clone(&x)andRc::clone(&x)overx.clone()for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Writeuse super::{Foo, bar}instead ofuse 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::*;inlib.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 plainStringor&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 viaTryFrom<String>and/orFromStr; 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 infallibleFrom<String>andFrom<&str>constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bareasassertion), add afrom_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.rscrates/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.rscrates/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 winInclude 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 isassert_eq!."
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.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@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
📒 Files selected for processing (2)
crates/real-hoist/src/lib.rscrates/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 andpipe-traitchains; do not break them into intermediateletbindings 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 (&Pathover&PathBuf,&strover&String) when it does not force extra copies.
PreferArc::clone(&x)andRc::clone(&x)overx.clone()for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Writeuse super::{Foo, bar}instead ofuse 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::*;inlib.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 plainStringor&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 viaTryFrom<String>and/orFromStr; 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 infallibleFrom<String>andFrom<&str>constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bareasassertion), add afrom_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!
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.
…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.
6b93245 to
a27d0ab
Compare
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.
a27d0ab to
b467ea7
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/real-hoist/src/tests.rs (1)
123-124: ⚡ Quick winUse
Rc::clone(&...)instead of.clone()forRchandles.Please switch these
Rcclone sites toRc::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)andRc::clone(&x)overx.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
📒 Files selected for processing (2)
crates/real-hoist/src/lib.rscrates/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 andpipe-traitchains; do not break them into intermediateletbindings 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 (&Pathover&PathBuf,&strover&String) when it does not force extra copies.
PreferArc::clone(&x)andRc::clone(&x)overx.clone()for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Writeuse super::{Foo, bar}instead ofuse 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::*;inlib.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 plainStringor&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 viaTryFrom<String>and/orFromStr; 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 infallibleFrom<String>andFrom<&str>constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bareasassertion), add afrom_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
## 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.
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.
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
Rcthanks to the wrapper's cache; the hoist preserves that identity and strips the duplicate reference at the other parent path.root → a → b → c → dbecomesroot → {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.HashMap<String, RcByPtr<HoisterResult>>mirrors root's direct deps, so the per-edge "is this name taken?" check doesn't degrade to O(N²)IndexSetscans 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
HoistErrorvariants:UnsupportedPeerDependency { ident, peers }— fires when scanning the constructedHoisterTreefinds any node with non-emptypeer_names(eitherpeerDependenciesfrom thepackages:map ortransitive_peer_dependencieson asnapshots:entry).UnsupportedHoistingLimits { len }— non-emptyopts.hoisting_limits.UnsupportedWorkspace { extra_importers }— any importer beyond the root..Each carries enough context for an operator to identify what triggered it. The
UnsupportedWorkspacehelp points atSymlinkDirectDependencies— 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_depscarries theLink-variant skip introduced by #443 (workspace support) —spec.version.as_regular()extracts the snapshot key forRegulardeps andcontinues pastLinkdeps, since workspace siblings are direct symlinks materialised bySymlinkDirectDependenciesand have no snapshot to hoist.Performance
Nothing in this PR is reachable from
pacquet installtoday (crates/package-manager/src/install*.rsdoesn't importpacquet-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 (samestat → lstatimprovement that shipped in slice 1'simport_indexed_dir), micro is identical. No code path explains a real regression.What's deferred
peer_namesconstraints, peer-promise satisfaction).hoistingLimitsruntime enforcement.dependencyKinddistinctions for workspaces and external soft links (today onlyExternalSoftLinkplaceholders are added by the wrapper and stripped post-hoist;Workspace-kind nodes are blocked by theUnsupportedWorkspaceguard upfront).Upstream reference
hoist.tsalgorithm overview — the recipe pacquet's single-pass BFS approximates.hoistTomain loop — the structural intent the port mirrors for the subset above.Test plan
one_transitive_dep_hoists_to_root—root → a → bflattens.diamond_dep_hoists_once_to_root—root → {a, c} → bkeeps one sharedbat root; a DFS over the post-hoist graph asserts exactly one allocation withname == "b"(proves identity dedup observably, not via a tautologicalfind).version_conflict_keeps_loser_at_parent—a → b@1, c → b@2puts the first-visited at root and leaves the other under its parent. Reference assertions use.contains()+ explicit.len(), notiter().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_unsupported—packages.peerDependenciestriggersUnsupportedPeerDependency.non_empty_hoisting_limits_surfaces_unsupported—opts.hoisting_limitstriggersUnsupportedHoistingLimits.multi_importer_lockfile_surfaces_unsupported_workspace— extra importers triggerUnsupportedWorkspace.just ready,cargo doc --document-private-items,taplo format --check,just dylintall clean.Written by an agent (Claude Code, claude-opus-4-7).