feat(package-manager): hoisted dep-graph type skeleton (#438 slice 4a)#478
Conversation
Defines the directory-keyed dependency graph types used by the hoisted-linker install path. Types only — the walker that fills them from a lockfile lands in a follow-up. Ported from upstream: - `DependenciesGraphNode` mirrors deps/graph-builder `DependenciesGraphNode` minus the `fetching` / `files_index_file` fields that only the store-controller-bound walker can populate. - `DependenciesGraph` = `BTreeMap<PathBuf, DependenciesGraphNode>`, keyed by absolute directory path (unlike pacquet's existing depPath-keyed `deps_graph` module — hoisted nodes can occupy several directories when a name conflict forces nesting). - `DepHierarchy` is the recursive directory tree the linker walks to decide population order and which `<dir>/node_modules/.bin` to wire up. Newtype-wrapped because Rust doesn't allow recursive type aliases. - `DirectDependenciesByImporterId` is the per-importer alias-to-directory table the linker hands to the bin pass. - `LockfileToDepGraphResult` bundles everything the walker returns to the install pipeline, including `prevGraph` for the orphan-diff pass and `injectionTargetsByDepPath` for the injected-workspace re-mirror step. - `LockfileToHoistedDepGraphOptions` carries the subset of upstream's options that the to-be-ported walker actually reads today; fields tied to the store controller, fetch concurrency, and workspace project list will be added when their consumers land. Smoke tests cover empty-result default construction, node insertion by `dir`, recursive hierarchy nesting, and default-options shape. Upstream: - <https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts> - <https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-builder/src/lockfileToDepGraph.ts>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📜 Recent 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)
📝 WalkthroughWalkthroughAdds a new types-only module ChangesHoisted Dependency Graph Types and Integration
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related issues
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 |
There was a problem hiding this comment.
Pull request overview
Introduces a new types-only module in package-manager that defines the directory-keyed dependency graph shapes needed for the future nodeLinker: 'hoisted' install path, mirroring pnpm’s lockfileToHoistedDepGraph result structure.
Changes:
- Added
crates/package-manager/src/hoisted_dep_graph.rswith hoisted dep-graph node/result/options types plus basic smoke tests. - Wired the new module into
crates/package-manager/src/lib.rs(module declaration + re-export).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| crates/package-manager/src/lib.rs | Exposes the new hoisted_dep_graph module via mod and pub use. |
| crates/package-manager/src/hoisted_dep_graph.rs | Adds directory-keyed hoisted dep-graph types (DependenciesGraphNode, DependenciesGraph, DepHierarchy, result/options structs) and tests. |
Comments suppressed due to low confidence (1)
crates/package-manager/src/hoisted_dep_graph.rs:161
LockfileToHoistedDepGraphOptions.skippedis described as a set of depPaths, but it’s currentlyBTreeSet<String>. Consider making thisBTreeSet<DepPath>(matchingDependenciesGraphNode.dep_path) to keep the depPath concept type-safe and avoid accidentally inserting non-depPath strings.
/// Packages the previous install decided not to fetch
/// (installability check failed; the package was added here).
/// The walker skips any depPath in this set without consulting
/// the snapshot. Cloned + extended on the way out.
pub skipped: BTreeSet<String>,
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/package-manager/src/hoisted_dep_graph.rs`:
- Around line 51-55: Replace plain String fields with dedicated newtype
wrappers: declare branded newtypes (e.g., struct PkgIdWithPatchHash(String);
struct DepPath(String);) and change the HoistedDepGraph struct fields
pkg_id_with_patch_hash: PkgIdWithPatchHash and skipped: Option<DepPath> (or
Vec<DepPath> depending on existing shape). Implement or derive common traits
used across the crate (Clone, Debug, Eq, Hash, PartialEq, Serialize/Deserialize,
AsRef<str>, From<String>) to preserve behavior, update all usages of
pkg_id_with_patch_hash and skipped accordingly, and adjust any serde attributes
or conversions to serialize/deserialize as plain strings so external format
stays unchanged.
- Around line 4-6: Update the module doc comment in hoisted_dep_graph.rs that
references pnpm files pinned to a historical SHA: change the two URLs in the
top-level doc comment (the links to
installing/deps-restorer/src/lockfileToHoistedDepGraph.ts and
deps/graph-builder/src/lockfileToDepGraph.ts) to use
https://github.com/pnpm/pnpm/blob/main/... instead of the current SHA-permalink
so the rustdoc references follow the pnpm-porting guideline; locate the
module-level comment at the top of hoisted_dep_graph.rs and replace the two
blob/94240bc046 links with blob/main equivalents.
🪄 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: f20c9adc-c3c6-4b00-91c3-3095b2080c32
📒 Files selected for processing (2)
crates/package-manager/src/hoisted_dep_graph.rscrates/package-manager/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). (7)
- GitHub Check: Code Coverage
- GitHub Check: Lint and Test (ubuntu-latest)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Lint and Test (macos-latest)
- GitHub Check: Lint and Test (windows-latest)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Agent
🧰 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/package-manager/src/lib.rscrates/package-manager/src/hoisted_dep_graph.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/package-manager/src/lib.rscrates/package-manager/src/hoisted_dep_graph.rs
🔇 Additional comments (2)
crates/package-manager/src/hoisted_dep_graph.rs (1)
163-263: LGTM!crates/package-manager/src/lib.rs (1)
11-11: LGTM!Also applies to: 38-38
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #478 +/- ##
==========================================
+ Coverage 89.07% 90.16% +1.09%
==========================================
Files 116 120 +4
Lines 10537 11190 +653
==========================================
+ Hits 9386 10090 +704
+ Misses 1151 1100 -51 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Micro-Benchmark ResultsLinux |
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.54298726562,
"stddev": 0.0988009517369913,
"median": 2.53583516942,
"user": 2.62217738,
"system": 3.55876476,
"min": 2.3754539764200002,
"max": 2.6883421314200002,
"times": [
2.55197400942,
2.51969632942,
2.6883421314200002,
2.68595170142,
2.5993628024200004,
2.5772607834200003,
2.4857345524200003,
2.46347073642,
2.48262563342,
2.3754539764200002
]
},
{
"command": "pacquet@main",
"mean": 2.59159664262,
"stddev": 0.09113541510934174,
"median": 2.56235451442,
"user": 2.6006301799999996,
"system": 3.5319191599999997,
"min": 2.48834877542,
"max": 2.77790607242,
"times": [
2.69517858842,
2.51135268342,
2.53640362942,
2.77790607242,
2.48834877542,
2.57111424442,
2.53082892242,
2.60018718442,
2.55359478442,
2.65105154142
]
},
{
"command": "pnpm",
"mean": 5.951930542919999,
"stddev": 0.08747994144381466,
"median": 5.945351586419999,
"user": 8.779565380000001,
"system": 4.328316060000001,
"min": 5.83033964642,
"max": 6.09062137842,
"times": [
5.889903611419999,
6.002673166419999,
6.09062137842,
6.06319456442,
5.91030641042,
5.9983306144199995,
5.83033964642,
5.98039676242,
5.87133149642,
5.88220777842
]
}
]
}Scenario: Frozen Lockfile (Hot Cache)
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.7610469290800002,
"stddev": 0.03318120273592099,
"median": 0.75630045158,
"user": 0.38207409999999997,
"system": 1.64145072,
"min": 0.72351142758,
"max": 0.83103234758,
"times": [
0.83103234758,
0.77891187358,
0.76850358758,
0.73180079158,
0.74224290758,
0.7310728145800001,
0.7907926375800001,
0.72351142758,
0.76604875058,
0.74655215258
]
},
{
"command": "pacquet@main",
"mean": 0.8286955598799999,
"stddev": 0.041912958968324476,
"median": 0.81300396408,
"user": 0.37940970000000007,
"system": 1.66425612,
"min": 0.78616306958,
"max": 0.90372159358,
"times": [
0.89329378358,
0.80129011958,
0.82340047858,
0.90372159358,
0.79603295758,
0.79616168758,
0.78616306958,
0.83089931258,
0.85338514658,
0.80260744958
]
},
{
"command": "pnpm",
"mean": 2.54120520338,
"stddev": 0.10529132250452168,
"median": 2.51994132508,
"user": 2.9451355,
"system": 2.22479902,
"min": 2.41769332258,
"max": 2.72656625158,
"times": [
2.51073329958,
2.4671339465799997,
2.65993191258,
2.60941122358,
2.52914935058,
2.44212749658,
2.41769332258,
2.60976891158,
2.43953631858,
2.72656625158
]
}
]
} |
…478 review) - Replace v5-era `/accepts/1.3.7` depPath strings in the smoke test with v9-shape `accepts@1.3.7` (the format pacquet's `PkgNameVerPeer` produces and the lockfile snapshots use). The legacy `/name/version` shape is only kept for read-side `hoistedAliases` compatibility. Caught by Copilot. - Tighten doc-comments on `hoisted_locations`, `injection_targets_by_dep_path`, and `skipped` to spell out *why* the keys are plain `String` (not `DepPath`): upstream types those fields with raw `string` in the hoisted-specific interface, even though the values are populated from depPaths. Mirrors upstream's literal typing.
Sub-slice 4b of #438. Implements `lockfile_to_hoisted_dep_graph(lockfile, opts) -> LockfileToDepGraphResult` — the walker that consumes the Slice 3 hoister output and produces the directory-keyed graph + hierarchy + hoisted_locations + direct-deps-by-importer-id + injection-targets-by-dep-path that 4a (#478) pinned the type shape of. Single-importer only (multi-importer lockfiles surface as `HoistError::UnsupportedWorkspace` via the underlying hoister). ## What's deferred Three things are intentionally left to follow-ups so 4b stays focused: - **Store fetch wiring** — `fetching` / `files_index_file` on the graph node are still defaulted. 4c will wire `StoreController::fetchPackage` (and the skip-fetch optimization that consults `currentHoistedLocations`). - **Installability check** — the walker honors `opts.skipped` as input but doesn't run `packageIsInstallable` to add new entries. 4c will plumb `package_is_installable` from `pacquet-package-is-installable` (already used by the isolated linker path). - **`prev_graph` diff** — `prev_graph` is `None`. 4d will walk the *current* lockfile alongside the wanted one (using `force: true` + empty `skipped` per upstream) and produce the orphan-removal input Slice 5's linker consumes. ## Two-pass design Upstream's `fetchDeps` walks asynchronously via `await Promise.all(deps.map(async (dep) => { ... }))`. The key invariant: each sibling's async function runs its sync prologue (insert + push to `pkgLocationsByDepPath`) before any continuation fires, because `Promise.all` sync-invokes each function and the inner `await fetchDeps(...)` only yields after the prologue completes. So by the time any node's post-recursion `graph[dir].children = getChildren(...)` line runs, every node in the entire tree is already in `pkgLocationsByDepPath`. Pacquet runs synchronously. To preserve that invariant cleanly: 1. **Pass 1 (recursive walk).** Insert each node into the graph with `children: BTreeMap::new()` and push its dir to `pkg_locations_by_dep_path`. Recursion order matches upstream's outer body (insert → push → recurse → push to `hoistedLocations`). 2. **Pass 2 (`fill_children`).** Iterate every graph node and resolve `children: alias → dir` from the snapshot's `dependencies` + `optionalDependencies` via `SnapshotDepRef::resolve`, consulting the now-complete `pkg_locations_by_dep_path`. A single-pass walker that computed children inline produced incorrect `children` maps for hoisted transitive deps — the canonical case `root → a → b` with `b` flattened to root left `a.children["b"]` empty because `b`'s pkg_locations entry hadn't been recorded yet. Caught by `walker_transitive_dep_flattens_under_root` before the refactor. ## Tests - `walker_empty_lockfile_produces_empty_result` — empty importer → empty graph, with the `"."` root importer key still present. - `walker_single_root_dep_emits_one_node` — `root → a`, node at `<lockfile_dir>/node_modules/a`. - `walker_transitive_dep_flattens_under_root` — `root → a → b`: `b` hoists to root, `a.children["b"]` points at the root-level dir. - `walker_version_conflict_keeps_loser_nested` — `root → {a@1, c}` with `c → a@2`: `a@1` at root, `a@2` nested under `c`; `hoisted_locations` records both directories; `c.children["a"]` points at the nested copy. - `walker_honors_pre_skipped_dep_path` — depPaths in `opts.skipped` are dropped from the walk entirely (and not recorded in `hoisted_locations`). - `walker_records_directory_resolution_as_injection_target` — `directory:` resolutions populate `injection_targets_by_dep_path` for the post-install re-mirror pass.
…ph nodes (#481) (#504) * feat(lockfile): PkgIdWithPatchHash newtype, replace `String` in dep-graph nodes (#481) Port upstream's [`PkgIdWithPatchHash`](https://github.com/pnpm/pnpm/blob/94240bc046/core/types/src/misc.ts) branded string (`string & { __brand: 'PkgIdWithPatchHash' }`) as a non-validating newtype in `pacquet-lockfile`, matching `CLAUDE.md`'s rule 3 for non-validating brands: `From<String>` / `From<&str>` via `derive_more`, `#[serde(transparent)]` for wire-format identity with `String`, no validating constructor. Modeled on `pacquet_modules_yaml::DepPath` — the sibling brand from the same upstream `misc.ts` file under the same rules. Replaces the plain `String` field/parameter in the two pacquet consumers CodeRabbit flagged on #478: - `DependenciesGraphNode.pkg_id_with_patch_hash` in `package-manager/src/hoisted_dep_graph.rs`. - `create_full_pkg_id`'s first parameter and the `lockfile_to_dep_graph` local in `package-manager/src/virtual_store_layout.rs`. A workspace audit (`grep "pkg_id_with_patch_hash"`) turns up no other slots typed as plain `String`. Two new tests in `pkg_id_with_patch_hash.rs` pin the contract: a serde round-trip (`#[serde(transparent)]` keeps the on-disk shape a raw string) and the non-validating-construction property (empty string and `From<&str>`/`From<String>` both work). * fix(lockfile): replace intra-doc links to pacquet-modules-yaml with plain text CI \`Doc\` job runs with \`RUSTDOCFLAGS=-D warnings\`, so the two \`[\`pacquet_modules_yaml::DepPath\`]\` intra-doc links in \`pkg_id_with_patch_hash.rs\` failed \`rustdoc::broken-intra-doc-links\`: \`pacquet-lockfile\` doesn't depend on \`pacquet-modules-yaml\` (the dependency would go the wrong way), so the rustdoc resolver can't follow the link. Switched both references to bare \`pacquet_modules_yaml::DepPath\` prose, with a note that the bare reference is intentional. The docstring still tells a reader where to find the sibling brand; it just stops resolving as a clickable link, which costs nothing practical since the type name is searchable in any IDE. Adding \`pacquet-modules-yaml\` as a dev-dep purely for a doc link would invert the natural crate ordering (lockfile is upstream of modules-yaml in the build graph) — rejected as a cosmetic fix that introduces a real architectural smell.
Summary
Sub-slice 4a of #438 (the
nodeLinker: 'hoisted'umbrella). Types-only skeleton for the directory-keyed dependency graph that the hoisted-linker install path will produce.Following the same shape as Sub-slice 3a (
pacquet-real-hoistcrate skeleton): pin the types first, fill in the walker / store-fetch / installability-check / prev-graph diff in follow-up sub-slices.What lands
New module
crates/package-manager/src/hoisted_dep_graph.rswith:DependenciesGraphNode— mirrors upstream'sDependenciesGraphNodeminusfetchingandfiles_index_file(only the store-controller-bound walker can populate those; deferred to 4c).DependenciesGraph=BTreeMap<PathBuf, DependenciesGraphNode>, keyed by absolute directory path. Unlike pacquet's existing depPath-keyeddeps_graphmodule (a side-effects-cache adapter), this graph keys by directory because hoisted nodes can occupy several directories when a name conflict forces nesting.DepHierarchy— recursive directory tree the linker walks to decide population order and which<dir>/node_modules/.binto wire up. Newtype-wrapped because Rust doesn't allow recursive type aliases.DirectDependenciesByImporterId— per-importeralias → dirtable the bin pass consumes.LockfileToDepGraphResult— bundle of everything the walker returns, includingprev_graphfor the orphan-diff pass (Slice 5) andinjection_targets_by_dep_pathfor the injected-workspace re-mirror step.LockfileToHoistedDepGraphOptions— subset of upstream's options that the to-be-ported walker actually reads today (lockfile dir,autoInstallPeers,skippedset). Store-controller and workspace project list will be added when their consumers land.Decisions worth flagging
crates/package-manager/src/as a sibling to existingdeps_graph.rsandhoist.rsrather than a new crate. Can extract later if the walker grows enough to warrant it; for now, factoring it out would just addpacquet-plumbing without any callers in or out of the workspace.PartialEqonly, notEq—LockfileResolutionisPartialEqonly (the lockfile-types convention; some inner variants don't qualify), so propagatingEqhere would force a divergence. Matches the existing lockfile convention.pkg_id_with_patch_hash: String— kept as a plainStringto match the conventionvirtual_store_layoutalready uses; the upstream brand is non-validating so the type-safety win is small.Test plan
default_result_is_empty— empty-result smoke, the walker's no-importer case.graph_node_inserts_by_dir— types compose, a node round-trips throughBTreeMapinsertion.hierarchy_nests_recursively— the recursiveDepHierarchynewtype nests, supportsDefault, and survives equality.options_default_is_empty—LockfileToHoistedDepGraphOptions::default()yields the empty options shape.just ready(859 → 863 tests, all green).cargo doc --document-private-items,taplo format --check,just dylintall clean.What's next
walk_hoist_result: given apacquet_real_hoist::HoisterResult+ the lockfile, build the graph / hierarchy / hoisted_locations. No store I/O, no installability check.fetching/files_index_fileand respect theskippedset.prev_graphdiff via the current-lockfile fall-back loop, unblocking Slice 5's orphan removal.Written by an agent (Claude Code, claude-opus-4-7).
Summary by CodeRabbit
Refactor
Tests