feat(package-manager): hoisted dep-graph walker (#438 slice 4b) (#486)

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.
This commit is contained in:
Zoltan Kochan
2026-05-13 22:16:54 +02:00
committed by GitHub
parent 2c513ad89f
commit 4cf66757d6
3 changed files with 654 additions and 7 deletions

2
pacquet/Cargo.lock generated
View File

@@ -2155,6 +2155,7 @@ dependencies = [
"dunce",
"futures-util",
"httpdate",
"indexmap",
"insta",
"miette 7.6.0",
"node-semver",
@@ -2170,6 +2171,7 @@ dependencies = [
"pacquet-package-is-installable",
"pacquet-package-manifest",
"pacquet-patching",
"pacquet-real-hoist",
"pacquet-registry",
"pacquet-registry-mock",
"pacquet-reporter",

View File

@@ -23,6 +23,7 @@ pacquet-graph-hasher = { workspace = true }
pacquet-package-manifest = { workspace = true }
pacquet-package-is-installable = { workspace = true }
pacquet-patching = { workspace = true }
pacquet-real-hoist = { workspace = true }
pacquet-registry = { workspace = true }
pacquet-reporter = { workspace = true }
pacquet-store-dir = { workspace = true }
@@ -34,6 +35,7 @@ dashmap = { workspace = true }
derive_more = { workspace = true }
futures-util = { workspace = true }
httpdate = { workspace = true }
indexmap = { workspace = true }
node-semver = { workspace = true }
pipe-trait = { workspace = true }
rayon = { workspace = true }

View File

@@ -5,11 +5,14 @@
//! and the supporting types factored into
//! [`deps/graph-builder/src/lockfileToDepGraph.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-builder/src/lockfileToDepGraph.ts).
//!
//! This module is types-only. The walker that produces a result
//! from a lockfile and a `pacquet_real_hoist::HoisterResult`
//! lands in a follow-up; the types are pinned here first so the
//! walker, the installability filter, and the eventual linker can
//! all be reviewed against a fixed shape.
//! The walker [`lockfile_to_hoisted_dep_graph`] takes a lockfile
//! and runs `pacquet_real_hoist::hoist` to get the directory shape,
//! then assembles a [`LockfileToDepGraphResult`] keyed by the
//! computed absolute directory of every node. Store I/O
//! (`fetching` / `files_index_file`), the installability check,
//! and the `prev_graph` diff still land in follow-ups; this walker
//! produces a correct graph topology for the eventual store
//! integration to layer fetch results onto.
//!
//! Unlike the depPath-keyed [`crate::deps_graph`] module (which is
//! a hashing-side adapter for the build cache), the graph defined
@@ -19,12 +22,16 @@
//! to nest. Hoisting decisions are made at directory granularity,
//! not depPath granularity.
use pacquet_lockfile::LockfileResolution;
use derive_more::{Display, Error, From};
use indexmap::IndexSet;
use miette::Diagnostic;
use pacquet_lockfile::{Lockfile, LockfileResolution, PackageKey, ParsePkgNameVerPeerError};
use pacquet_modules_yaml::DepPath;
use pacquet_patching::PatchInfo;
use pacquet_real_hoist::{HoistError, HoistOpts, HoisterResult, RcByPtr, hoist};
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
path::{Path, PathBuf},
};
/// One node in a hoisted-linker dependency graph. Keyed in the
@@ -174,6 +181,331 @@ pub struct LockfileToHoistedDepGraphOptions {
pub skipped: BTreeSet<String>,
}
/// Failure modes of [`lockfile_to_hoisted_dep_graph`]. Marked
/// `#[non_exhaustive]` so adding variants in later sub-slices (the
/// installability filter, the store-fetch integration) isn't a
/// breaking API change.
#[derive(Debug, Display, Error, Diagnostic, From)]
#[non_exhaustive]
pub enum HoistedDepGraphError {
/// The hoister refused the lockfile (broken snapshot,
/// unsupported workspace, etc.). Surfaced verbatim so callers
/// see the same error code as upstream.
#[display("{_0}")]
Hoist(#[error(source)] HoistError),
/// A `HoisterResult` node carried a reference string that
/// doesn't parse as a `name@version[(peers)]` package key.
/// Should never happen for hoister output produced from a
/// valid lockfile — the hoister only emits references it
/// already validated — but the conversion is fallible at the
/// type level, so a typed error is the honest surface.
#[display("Unparsable snapshot reference {reference:?} on hoisted node")]
#[diagnostic(code(ERR_PACQUET_HOISTED_GRAPH_BAD_REFERENCE))]
BadReference {
reference: String,
#[error(source)]
source: ParsePkgNameVerPeerError,
},
}
/// Build a directory-keyed [`LockfileToDepGraphResult`] from a
/// lockfile by running the hoist algorithm and walking the
/// resulting tree. Ports upstream's
/// [`lockfileToHoistedDepGraph`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L65-L85)
/// minus the store-controller-bound fetch step (the
/// `DependenciesGraphNode` fields that depend on the store
/// remain default-valued; a follow-up wires the fetch in) and
/// minus the `currentLockfile`-driven `prev_graph` diff (lands in
/// the same follow-up that adds the orphan-removal pass to the
/// linker).
///
/// Single-importer only today — multi-importer (workspace)
/// lockfiles surface as `HoistError::UnsupportedWorkspace` via
/// the hoister.
pub fn lockfile_to_hoisted_dep_graph(
lockfile: &Lockfile,
opts: &LockfileToHoistedDepGraphOptions,
) -> Result<LockfileToDepGraphResult, HoistedDepGraphError> {
let hoist_opts =
HoistOpts { auto_install_peers: opts.auto_install_peers, ..HoistOpts::default() };
let hoister_result = hoist(lockfile, &hoist_opts)?;
let modules_dir = opts.lockfile_dir.join("node_modules");
let mut state = WalkState {
lockfile,
lockfile_dir: &opts.lockfile_dir,
skipped: &opts.skipped,
graph: DependenciesGraph::new(),
pkg_locations_by_dep_path: BTreeMap::new(),
hoisted_locations: BTreeMap::new(),
injection_targets_by_dep_path: BTreeMap::new(),
};
let root_deps = hoister_result.dependencies.borrow();
let root_hierarchy = walk_deps(&mut state, &modules_dir, &root_deps)?;
drop(root_deps);
// Pass 2 — fill in each node's `children` map from the
// now-complete `pkg_locations_by_dep_path`. Mirrors upstream's
// post-await `graph[dir].children = getChildren(...)` line.
//
// The walk above intentionally leaves `children` empty: in
// upstream's parallel-async walker, every sibling and
// descendant of a node has its directory pushed to
// `pkgLocationsByDepPath` during the sync prologue of its
// `async (dep) => { ... }` body, *before* any continuation
// (the post-recursion `getChildren` call) runs. So by the
// time any node computes its children, the location index is
// already complete. Pacquet runs synchronously, so the
// simplest way to preserve that invariant is to insert
// everything first and resolve children second.
let WalkState {
graph,
pkg_locations_by_dep_path,
hoisted_locations,
injection_targets_by_dep_path,
lockfile,
..
} = state;
let mut graph = graph;
fill_children(&mut graph, &pkg_locations_by_dep_path, lockfile)?;
// The hoister produced a children order; the directory keys in
// `root_hierarchy` follow it. `direct_dependencies_by_importer_id["."]`
// mirrors upstream's `directDepsMap` at
// <https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L139-L145>.
let mut direct_deps_root: BTreeMap<String, PathBuf> = BTreeMap::new();
for child_dir in root_hierarchy.0.keys() {
if let Some(alias) = graph.get(child_dir).and_then(|node| node.alias.as_deref()) {
direct_deps_root.insert(alias.to_string(), child_dir.clone());
}
}
let mut direct_dependencies_by_importer_id: DirectDependenciesByImporterId = BTreeMap::new();
direct_dependencies_by_importer_id
.insert(Lockfile::ROOT_IMPORTER_KEY.to_string(), direct_deps_root);
let mut hierarchy = BTreeMap::new();
hierarchy.insert(opts.lockfile_dir.clone(), root_hierarchy);
Ok(LockfileToDepGraphResult {
graph,
direct_dependencies_by_importer_id,
hierarchy,
hoisted_locations,
symlinked_direct_dependencies_by_importer_id: DirectDependenciesByImporterId::new(),
prev_graph: None,
injection_targets_by_dep_path,
})
}
/// Second walker pass: with every node's directory already in
/// `pkg_locations`, resolve each graph node's `children: alias →
/// dir` map by looking up the node's snapshot in the lockfile.
fn fill_children(
graph: &mut DependenciesGraph,
pkg_locations: &BTreeMap<String, Vec<PathBuf>>,
lockfile: &Lockfile,
) -> Result<(), HoistedDepGraphError> {
let dirs: Vec<PathBuf> = graph.keys().cloned().collect();
for dir in dirs {
let reference = graph[&dir].dep_path.as_str().to_string();
let pkg_key: PackageKey = match reference.parse() {
Ok(key) => key,
Err(source) => {
return Err(HoistedDepGraphError::BadReference { reference, source });
}
};
let snapshot = lockfile.snapshots.as_ref().and_then(|m| m.get(&pkg_key));
let children = compute_children(snapshot, pkg_locations);
if let Some(node) = graph.get_mut(&dir) {
node.children = children;
}
}
Ok(())
}
/// Mutable scratch space the recursive walker threads through
/// every level. Borrowing the lockfile + lockfile_dir + skipped
/// up front avoids passing five separate arguments.
struct WalkState<'a> {
lockfile: &'a Lockfile,
lockfile_dir: &'a Path,
skipped: &'a BTreeSet<String>,
graph: DependenciesGraph,
/// Records every directory each depPath landed in, in visit
/// order. The first entry wins for parent → child wiring (see
/// upstream `getChildren`).
pkg_locations_by_dep_path: BTreeMap<String, Vec<PathBuf>>,
hoisted_locations: BTreeMap<String, Vec<String>>,
injection_targets_by_dep_path: BTreeMap<String, Vec<PathBuf>>,
}
/// Recursive walker over `HoisterResult.dependencies`. Mirrors
/// upstream's
/// [`fetchDeps`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L168-L296)
/// minus the store-fetch / installability path; here the walker
/// only computes node identity, location, children, and
/// hoisted-location records.
///
/// No cycle detection — matches upstream's recursion shape and
/// trusts the hoister to produce a DAG. The hoister's own
/// cyclic-input tests pin that property.
fn walk_deps(
state: &mut WalkState<'_>,
modules: &Path,
deps: &IndexSet<RcByPtr<HoisterResult>>,
) -> Result<DepHierarchy, HoistedDepGraphError> {
let mut hierarchy: BTreeMap<PathBuf, DepHierarchy> = BTreeMap::new();
for dep in deps {
// The hoister keeps every absorbed reference; the first
// (alphabetically smallest) is the canonical depPath for
// this node's location. Mirrors upstream's
// `Array.from(dep.references)[0]`.
let reference = match dep.0.references.borrow().iter().next().cloned() {
Some(r) => r,
None => continue,
};
if state.skipped.contains(&reference) || reference.starts_with("workspace:") {
continue;
}
let pkg_key: PackageKey = match reference.parse() {
Ok(key) => key,
Err(source) => {
return Err(HoistedDepGraphError::BadReference { reference, source });
}
};
// `packages[key]` is the metadata source; absent → this is
// a link / external placeholder that the wrapper strips,
// and the walker mirrors upstream's `if (!pkgSnapshot) return`
// by skipping.
let Some(metadata) = lookup_package_metadata(state.lockfile, &pkg_key) else {
continue;
};
let snapshot =
state.lockfile.snapshots.as_ref().and_then(|snapshots| snapshots.get(&pkg_key));
let dir = modules.join(&dep.0.name);
let dep_location = path_relative_to_lockfile_dir(&dir, state.lockfile_dir);
// Insert *before* recursing — mirrors upstream's
// `fetchDeps` body order (insert + push to pkgLocations,
// then `await fetchDeps(...)`). `children` is filled in
// by `fill_children` after the whole walk is done.
let node = DependenciesGraphNode {
alias: Some(dep.0.name.clone()),
dep_path: DepPath::from(reference.clone()),
pkg_id_with_patch_hash: pkg_key.to_string(),
dir: dir.clone(),
modules: modules.to_path_buf(),
children: BTreeMap::new(),
name: pkg_key.name.to_string(),
version: pkg_key.suffix.version().to_string(),
optional: snapshot.map(|s| s.optional).unwrap_or(false),
optional_dependencies: snapshot
.and_then(|s| s.optional_dependencies.as_ref())
.map(|m| m.keys().map(|k| k.to_string()).collect())
.unwrap_or_default(),
has_bin: metadata.has_bin.unwrap_or(false),
has_bundled_dependencies: metadata.bundled_dependencies.is_some(),
patch: None,
resolution: metadata.resolution.clone(),
};
state.graph.insert(dir.clone(), node);
state.pkg_locations_by_dep_path.entry(reference.clone()).or_default().push(dir.clone());
// Directory resolutions are injected workspace packages.
// Upstream records every dir an injected dep lands in for
// the post-install re-mirror step; mirrored here so a
// future re-mirror pass has the same input shape.
if let LockfileResolution::Directory(_) = &metadata.resolution {
state
.injection_targets_by_dep_path
.entry(reference.clone())
.or_default()
.push(dir.clone());
}
// Recurse into the children (records their pkg_locations
// and produces their `DepHierarchy`).
let inner_modules = dir.join("node_modules");
let child_deps = dep.0.dependencies.borrow();
let inner_hierarchy = walk_deps(state, &inner_modules, &child_deps)?;
drop(child_deps);
// `hoistedLocations` is pushed AFTER the recursion, matching
// upstream. The pre-recursion sites that mutate state are
// for graph/index identity; this one is the user-visible
// location list that the linker consumes.
state.hoisted_locations.entry(reference).or_default().push(dep_location);
hierarchy.insert(dir, inner_hierarchy);
}
Ok(DepHierarchy(hierarchy))
}
/// Look up the metadata side of a snapshot. Pacquet stores
/// `packages` and `snapshots` separately; the walker needs the
/// metadata for resolution / has_bin / bundledDependencies (which
/// upstream pulls from `pkgSnapshot`).
fn lookup_package_metadata<'a>(
lockfile: &'a Lockfile,
key: &PackageKey,
) -> Option<&'a pacquet_lockfile::PackageMetadata> {
lockfile.packages.as_ref()?.get(key)
}
/// Lockfile-relative path string, matching upstream's
/// `path.relative(lockfileDir, dir)`. Returns an empty string when
/// `dir == lockfile_dir`.
///
/// Backslashes are normalized to forward slashes so the value is
/// portable across platforms — `.modules.yaml.hoistedLocations`
/// is read on whatever OS the next install runs on, and pnpm's
/// `pnpm-lock.yaml` already uses forward slashes for the same
/// reason. Upstream's `path.relative` produces OS-native
/// separators (so `.modules.yaml` written on Windows technically
/// holds backslashes), but pacquet normalizes here for
/// cross-platform consistency with the rest of pnpm's serialised
/// formats.
fn path_relative_to_lockfile_dir(dir: &Path, lockfile_dir: &Path) -> String {
dir.strip_prefix(lockfile_dir)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| dir.to_string_lossy().replace('\\', "/"))
}
/// Compute the `children: alias → dir` map for a node. Mirrors
/// upstream's
/// [`getChildren`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L320-L334):
/// look up every direct (and optional, with `include` always on
/// here) dep of the snapshot, resolve it to its depPath via
/// `SnapshotDepRef::resolve`, and take the first recorded
/// location.
fn compute_children(
snapshot: Option<&pacquet_lockfile::SnapshotEntry>,
pkg_locations: &BTreeMap<String, Vec<PathBuf>>,
) -> BTreeMap<String, PathBuf> {
let mut children: BTreeMap<String, PathBuf> = BTreeMap::new();
let Some(snapshot) = snapshot else { return children };
let dep_iter = snapshot
.dependencies
.iter()
.flatten()
.chain(snapshot.optional_dependencies.iter().flatten());
for (alias_name, dep_ref) in dep_iter {
let child_key = dep_ref.resolve(alias_name);
let child_dep_path = child_key.to_string();
if let Some(locations) = pkg_locations.get(&child_dep_path)
&& let Some(first) = locations.first()
{
children.insert(alias_name.to_string(), first.clone());
}
}
children
}
#[cfg(test)]
mod tests {
use super::{
@@ -280,4 +612,315 @@ mod tests {
assert!(!opts.auto_install_peers);
assert!(opts.skipped.is_empty());
}
// --- Walker tests ----------------------------------------------------
use super::lockfile_to_hoisted_dep_graph;
use pacquet_lockfile::{
ComVer, Lockfile, LockfileSettings, LockfileVersion, PackageKey, PackageMetadata, PkgName,
PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, ResolvedDependencyMap, ResolvedDependencySpec,
SnapshotDepRef, SnapshotEntry,
};
use std::collections::HashMap;
fn lockfile_version() -> LockfileVersion<9> {
LockfileVersion::<9>::try_from(ComVer::new(9, 0))
.expect("lockfileVersion 9.0 is compatible")
}
fn pkg_name(s: &str) -> PkgName {
PkgName::parse(s).expect("parse PkgName")
}
fn ver_peer(s: &str) -> PkgVerPeer {
s.parse::<PkgVerPeer>().expect("parse PkgVerPeer")
}
fn dep_key(name: &str, version: &str) -> PkgNameVerPeer {
PkgNameVerPeer::new(pkg_name(name), ver_peer(version))
}
fn resolved_dep(version: &str) -> ResolvedDependencySpec {
ResolvedDependencySpec { specifier: version.to_string(), version: ver_peer(version).into() }
}
fn directory_resolution(directory: &str) -> LockfileResolution {
DirectoryResolution { directory: directory.to_string() }.into()
}
/// Build a metadata stub for a package using a synthetic
/// `directory:` resolution. Walker tests don't exercise
/// resolution semantics — they only need *some* resolution so
/// the graph node has a non-default value to inspect.
fn metadata_stub() -> PackageMetadata {
PackageMetadata {
resolution: directory_resolution("/dev/null/stub"),
engines: None,
cpu: None,
os: None,
libc: None,
deprecated: None,
has_bin: None,
prepare: None,
bundled_dependencies: None,
peer_dependencies: None,
peer_dependencies_meta: None,
}
}
fn lockfile_with(
importer_deps: ResolvedDependencyMap,
packages: HashMap<PackageKey, PackageMetadata>,
snapshots: HashMap<PackageKey, SnapshotEntry>,
) -> Lockfile {
let mut importers = HashMap::new();
importers.insert(
Lockfile::ROOT_IMPORTER_KEY.to_string(),
ProjectSnapshot { dependencies: Some(importer_deps), ..ProjectSnapshot::default() },
);
Lockfile {
lockfile_version: lockfile_version(),
settings: Some(LockfileSettings::default()),
overrides: None,
importers,
packages: Some(packages),
snapshots: Some(snapshots),
}
}
/// A lockfile with no importers walks to an empty graph and a
/// hierarchy with no root entry. Mirrors the
/// `empty_lockfile_yields_empty_root` case from the hoister.
#[test]
fn walker_empty_lockfile_produces_empty_result() {
let lockfile = Lockfile {
lockfile_version: lockfile_version(),
settings: Some(LockfileSettings::default()),
overrides: None,
importers: HashMap::new(),
packages: None,
snapshots: None,
};
let opts = LockfileToHoistedDepGraphOptions {
lockfile_dir: PathBuf::from("/repo"),
..LockfileToHoistedDepGraphOptions::default()
};
let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("empty lockfile walks");
assert!(result.graph.is_empty(), "graph should be empty");
assert!(result.hoisted_locations.is_empty(), "no locations recorded");
// `direct_dependencies_by_importer_id["."]` is always
// present (the root importer is implicit), but its inner
// map is empty when there are no children.
assert_eq!(result.direct_dependencies_by_importer_id.len(), 1);
assert!(result.direct_dependencies_by_importer_id[Lockfile::ROOT_IMPORTER_KEY].is_empty());
}
/// `root → a` with `a` having no transitive deps: walker emits
/// a single graph node at `<lockfile_dir>/node_modules/a`,
/// populates `hoisted_locations["a@1.0.0"]`, and records `a` as
/// the root's only direct dep.
#[test]
fn walker_single_root_dep_emits_one_node() {
let mut root_deps = ResolvedDependencyMap::new();
root_deps.insert(pkg_name("a"), resolved_dep("1.0.0"));
let mut packages = HashMap::new();
packages.insert(dep_key("a", "1.0.0"), metadata_stub());
let mut snapshots = HashMap::new();
snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default());
let lockfile = lockfile_with(root_deps, packages, snapshots);
let lockfile_dir = PathBuf::from("/repo");
let opts = LockfileToHoistedDepGraphOptions {
lockfile_dir: lockfile_dir.clone(),
..LockfileToHoistedDepGraphOptions::default()
};
let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds");
let expected_dir = lockfile_dir.join("node_modules").join("a");
assert_eq!(
result.graph.len(),
1,
"one node emitted: {:?}",
result.graph.keys().collect::<Vec<_>>(),
);
let node = result.graph.get(&expected_dir).expect("node keyed by dir");
assert_eq!(node.alias.as_deref(), Some("a"));
assert_eq!(node.dep_path, DepPath::from("a@1.0.0".to_string()));
assert_eq!(node.name, "a");
assert_eq!(node.version, "1.0.0");
assert_eq!(result.hoisted_locations["a@1.0.0"], vec!["node_modules/a".to_string()]);
assert_eq!(
result.direct_dependencies_by_importer_id[Lockfile::ROOT_IMPORTER_KEY]["a"],
expected_dir,
);
}
/// `root → a → b` (no name conflict): the hoister flattens `b`
/// to root, and the walker emits two graph nodes — both under
/// `<lockfile_dir>/node_modules/`. `a`'s `children["b"]` points
/// at `b`'s root-level directory (not `a/node_modules/b`),
/// because the hoister moved it there.
#[test]
fn walker_transitive_dep_flattens_under_root() {
let mut root_deps = ResolvedDependencyMap::new();
root_deps.insert(pkg_name("a"), resolved_dep("1.0.0"));
let mut packages = HashMap::new();
packages.insert(dep_key("a", "1.0.0"), metadata_stub());
packages.insert(dep_key("b", "1.0.0"), metadata_stub());
let mut snapshots = HashMap::new();
let mut a_deps = HashMap::new();
a_deps.insert(pkg_name("b"), SnapshotDepRef::Plain(ver_peer("1.0.0")));
snapshots.insert(
dep_key("a", "1.0.0"),
SnapshotEntry { dependencies: Some(a_deps), ..SnapshotEntry::default() },
);
snapshots.insert(dep_key("b", "1.0.0"), SnapshotEntry::default());
let lockfile = lockfile_with(root_deps, packages, snapshots);
let lockfile_dir = PathBuf::from("/repo");
let opts = LockfileToHoistedDepGraphOptions {
lockfile_dir: lockfile_dir.clone(),
..LockfileToHoistedDepGraphOptions::default()
};
let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds");
let modules = lockfile_dir.join("node_modules");
assert_eq!(
result.graph.keys().cloned().collect::<Vec<_>>(),
vec![modules.join("a"), modules.join("b")],
"both nodes hoisted to root, sorted by dir",
);
let a_node = result.graph.get(&modules.join("a")).expect("a in graph");
assert_eq!(
a_node.children.get("b"),
Some(&modules.join("b")),
"a's `children[\"b\"]` points at the hoisted (root-level) dir",
);
// Both depPaths recorded at the root level only — no
// nesting needed because there's no version conflict.
assert_eq!(result.hoisted_locations["a@1.0.0"], vec!["node_modules/a".to_string()]);
assert_eq!(result.hoisted_locations["b@1.0.0"], vec!["node_modules/b".to_string()]);
}
/// Version conflict: `root → {a@1, c}` plus `c → a@2`. `a@1`
/// gets the root slot; `a@2` stays nested under `c`. The
/// walker should record `a@1.0.0` at root and `a@2.0.0` at
/// `node_modules/c/node_modules/a`.
#[test]
fn walker_version_conflict_keeps_loser_nested() {
let mut root_deps = ResolvedDependencyMap::new();
root_deps.insert(pkg_name("a"), resolved_dep("1.0.0"));
root_deps.insert(pkg_name("c"), resolved_dep("1.0.0"));
let mut packages = HashMap::new();
packages.insert(dep_key("a", "1.0.0"), metadata_stub());
packages.insert(dep_key("a", "2.0.0"), metadata_stub());
packages.insert(dep_key("c", "1.0.0"), metadata_stub());
let mut snapshots = HashMap::new();
snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default());
snapshots.insert(dep_key("a", "2.0.0"), SnapshotEntry::default());
let mut c_deps = HashMap::new();
c_deps.insert(pkg_name("a"), SnapshotDepRef::Plain(ver_peer("2.0.0")));
snapshots.insert(
dep_key("c", "1.0.0"),
SnapshotEntry { dependencies: Some(c_deps), ..SnapshotEntry::default() },
);
let lockfile = lockfile_with(root_deps, packages, snapshots);
let lockfile_dir = PathBuf::from("/repo");
let opts = LockfileToHoistedDepGraphOptions {
lockfile_dir: lockfile_dir.clone(),
..LockfileToHoistedDepGraphOptions::default()
};
let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds");
let modules = lockfile_dir.join("node_modules");
let a1_dir = modules.join("a");
let c_dir = modules.join("c");
let a2_dir = c_dir.join("node_modules").join("a");
assert!(result.graph.contains_key(&a1_dir), "a@1 at root");
assert!(result.graph.contains_key(&c_dir), "c at root");
assert!(result.graph.contains_key(&a2_dir), "a@2 nested under c");
assert_eq!(result.graph[&a1_dir].dep_path, DepPath::from("a@1.0.0".to_string()));
assert_eq!(result.graph[&a2_dir].dep_path, DepPath::from("a@2.0.0".to_string()));
assert_eq!(result.hoisted_locations["a@1.0.0"], vec!["node_modules/a".to_string()]);
assert_eq!(
result.hoisted_locations["a@2.0.0"],
vec!["node_modules/c/node_modules/a".to_string()],
);
// `c`'s `children["a"]` points at the nested `a@2`, not the
// root's `a@1` — because hoisting kept the nested slot.
assert_eq!(result.graph[&c_dir].children.get("a"), Some(&a2_dir));
}
/// Pre-`skipped` packages aren't emitted into the graph at all.
/// Upstream's walker honors the input `skipped` set without
/// re-checking installability; pacquet's walker does the same.
#[test]
fn walker_honors_pre_skipped_dep_path() {
let mut root_deps = ResolvedDependencyMap::new();
root_deps.insert(pkg_name("a"), resolved_dep("1.0.0"));
let mut packages = HashMap::new();
packages.insert(dep_key("a", "1.0.0"), metadata_stub());
let mut snapshots = HashMap::new();
snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default());
let lockfile = lockfile_with(root_deps, packages, snapshots);
let mut skipped = BTreeSet::new();
skipped.insert("a@1.0.0".to_string());
let opts = LockfileToHoistedDepGraphOptions {
lockfile_dir: PathBuf::from("/repo"),
auto_install_peers: false,
skipped,
};
let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds");
assert!(result.graph.is_empty(), "skipped dep not emitted");
assert!(result.hoisted_locations.is_empty());
}
/// A `directory:` resolution gets recorded in
/// `injection_targets_by_dep_path` so the post-install
/// re-mirror step (a later sub-slice) can find it.
#[test]
fn walker_records_directory_resolution_as_injection_target() {
let mut root_deps = ResolvedDependencyMap::new();
root_deps.insert(pkg_name("a"), resolved_dep("1.0.0"));
let mut packages = HashMap::new();
packages.insert(
dep_key("a", "1.0.0"),
PackageMetadata { resolution: directory_resolution("../local-a"), ..metadata_stub() },
);
let mut snapshots = HashMap::new();
snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default());
let lockfile = lockfile_with(root_deps, packages, snapshots);
let lockfile_dir = PathBuf::from("/repo");
let opts = LockfileToHoistedDepGraphOptions {
lockfile_dir: lockfile_dir.clone(),
..LockfileToHoistedDepGraphOptions::default()
};
let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds");
assert_eq!(
result.injection_targets_by_dep_path["a@1.0.0"],
vec![lockfile_dir.join("node_modules").join("a")],
);
}
}