fix(deps-restorer): shim publicly hoisted workspace-package bins on a headless install (#13644)

Share the materialization plan and the link phase between the two
install paths, in `pacquet-deps-restorer`. The skip set decides what
`node_modules` contains and the link phase decides how it is wired, so
the paths diverging in either is a correctness bug rather than a style
difference. `InstallFrozenLockfile::run` drops 875 -> 485.

Sharing the link phase left one behavioural flag no test pinned, and
tracing it found a real bug. A workspace package matching
`publicHoistPattern` is recorded in the hoist result's
`hoisted_workspace_aliases`, not `publicly_hoisted_aliases_with_bins`,
which is all the post-build top-level bin link reads. Its bin therefore
reached the root `.bin` only via the link phase's `node_modules`
re-walk, which only a non-frozen install performed, so
`--frozen-lockfile` left the shim missing. pnpm's headless path
re-walks, so this was pacquet diverging. Both paths now re-walk, which
fixes the gap and removes the flag.

That surfaced a second latent bug: `link_bins::read_package` treated
anything but a missing manifest as fatal, so a stray file in
`node_modules` failed the install with `NotADirectory`. Non-directory
entries now mean "not a package".

Seven genuine differences stay as caller-side inputs, including the
frozen path's non-empty-snapshots guard, which is load-bearing because
`any_installability_constraint` short-circuits on `packages` before
reading snapshots.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-08-04 21:09:33 +02:00
1 parent 74bf5dd8fd
commit df3a09a05b
12 files changed
+1137 -1223

No files matched your search

@@ -0,0 +1,5 @@
---
"pacquet": patch
---
A headless install (`--frozen-lockfile`) now creates the command shims for a publicly hoisted workspace package's `bin`, matching what a normal install already did and what pnpm's own headless install does. Previously those shims were missing until the next non-frozen install.
@@ -0,0 +1,5 @@
---
"pacquet": patch
---
A stray non-directory entry in `node_modules` no longer fails an install. Files placed next to the installed dependencies are skipped rather than reported as an unreadable manifest.
+121
View File
@@ -1422,3 +1422,124 @@ fn should_not_add_extra_node_paths_when_extend_node_path_false() {
drop((root, npmrc_info)); // cleanup
}
/// A workspace package that is *publicly* hoisted lands in the root
/// `node_modules`, but it is recorded in the hoist result's
/// `hoisted_workspace_aliases` rather than
/// `publicly_hoisted_aliases_with_bins`. The post-build top-level bin
/// link only sees the latter, so a bin declared by such a package
/// reaches the root `.bin` solely through the link phase's re-walk of
/// `node_modules`.
#[test]
fn publicly_hoisted_workspace_package_bins_reach_the_root_bin_dir() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
fs::write(
workspace.join("package.json"),
serde_json::json!({ "name": "root", "private": true }).to_string(),
)
.expect("write root package.json");
write_workspace_yaml(&workspace, "packages:\n - 'packages/*'\npublicHoistPattern:\n - '*'\n");
// The root deliberately does not depend on this project, so its bin
// can only arrive via hoisting.
let pkg_dir = workspace.join("packages/foo");
fs::create_dir_all(&pkg_dir).expect("mkdir packages/foo");
fs::write(
pkg_dir.join("package.json"),
serde_json::json!({
"name": "local-foo",
"version": "1.0.0",
"private": true,
"bin": { "local-foo-cli": "index.js" },
"dependencies": { "@pnpm.e2e/hello-world-js-bin-parent": "1.0.0" },
})
.to_string(),
)
.expect("write packages/foo/package.json");
fs::write(pkg_dir.join("index.js"), "#!/usr/bin/env node\n").expect("write index.js");
let shim = workspace.join("node_modules/.bin/local-foo-cli");
pacquet.with_arg("install").assert().success();
assert!(shim.exists(), "fresh: the publicly hoisted workspace bin must be shimmed at {shim:?}");
fs::remove_dir_all(workspace.join("node_modules")).expect("remove node_modules");
pacquet_in(&workspace).with_args(["install", "--frozen-lockfile"]).assert().success();
assert!(
shim.exists(),
"frozen: the publicly hoisted workspace bin must be shimmed at {shim:?}",
);
drop((root, mock_instance));
}
/// A direct dependency's bin must win over a publicly hoisted
/// *workspace* package declaring the same bin name. The post-build
/// top-level link resolves precedence by [`BinOrigin`] tier, but it
/// never sees workspace-hoisted aliases, so the link phase's re-walk is
/// the only thing that shims them — and that scan treats every
/// candidate as direct. This pins which one ends up in the root `.bin`.
#[test]
fn direct_dep_bin_wins_over_a_publicly_hoisted_workspace_package() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
// The direct dependency ships a `hello-world-js-bin` bin.
fs::write(
workspace.join("package.json"),
serde_json::json!({
"name": "root",
"private": true,
"dependencies": { "@pnpm.e2e/hello-world-js-bin": "1.0.0" },
})
.to_string(),
)
.expect("write root package.json");
write_workspace_yaml(&workspace, "packages:\n - 'packages/*'\npublicHoistPattern:\n - '*'\n");
// A workspace package claiming the same bin name.
let pkg_dir = workspace.join("packages/collide");
fs::create_dir_all(&pkg_dir).expect("mkdir packages/collide");
fs::write(
pkg_dir.join("package.json"),
serde_json::json!({
"name": "collide",
"version": "1.0.0",
"private": true,
"bin": { "hello-world-js-bin": "index.js" },
"dependencies": { "@pnpm.e2e/hello-world-js-bin-parent": "1.0.0" },
})
.to_string(),
)
.expect("write packages/collide/package.json");
fs::write(pkg_dir.join("index.js"), "#!/usr/bin/env node\n").expect("write index.js");
let assert_direct_wins = |stage: &str| {
let shim = fs::read_to_string(workspace.join("node_modules/.bin/hello-world-js-bin"))
.expect("read shim");
assert!(
!shim.contains("packages/collide") && !shim.contains(r"packages\\collide"),
"{stage}: the direct dependency must win over the hoisted workspace package:\n{shim}",
);
};
pacquet.with_arg("install").assert().success();
// Guard the guard: the hoisted workspace package must actually be
// present, or the collision below is not being exercised at all.
assert!(
workspace.join("node_modules/collide").exists(),
"the workspace package must be publicly hoisted for this to test anything",
);
assert_direct_wins("fresh");
fs::remove_dir_all(workspace.join("node_modules")).expect("remove node_modules");
pacquet_in(&workspace).with_args(["install", "--frozen-lockfile"]).assert().success();
assert!(workspace.join("node_modules/collide").exists(), "hoisted after frozen replay too");
assert_direct_wins("frozen");
drop((root, mock_instance));
}
@@ -929,3 +929,51 @@ fn lifecycle_scripts_do_not_fail_on_repeat_hoisted_install() {
drop((root, mock_instance));
}
/// A hoist pattern is inert under `nodeLinker: hoisted`. The isolated
/// hoist writes symlinks into `<virtual_store>/node_modules`, and the
/// hoisted linker builds no virtual store to point them at, so the plan
/// is suppressed rather than producing links to nowhere.
///
/// Both paths must agree: the frozen path suppresses the plan by passing
/// `is_hoisted` into `compute_hoist_plan`, while the fresh path only
/// reaches that call inside its isolated branch. This pins the shared
/// outcome so the two cannot drift apart.
#[test]
fn hoist_patterns_are_inert_under_the_hoisted_linker() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
write_manifest(&workspace, serde_json::json!({ "send": "0.17.2" }));
write_workspace_yaml(
&workspace,
"nodeLinker: hoisted\npublicHoistPattern:\n - '*'\nhoistPattern:\n - '*'\n",
);
let assert_no_isolated_hoist = |stage: &str| {
assert!(
is_real_dir(&workspace, "node_modules/send"),
"{stage}: the hoisted linker still lands real directories",
);
assert!(
!workspace.join("node_modules/.pnpm/node_modules").exists(),
"{stage}: no private-hoist dir, because there is no virtual store to hoist into",
);
let modules_yaml = fs::read_to_string(workspace.join("node_modules/.modules.yaml"))
.expect("read .modules.yaml");
assert!(
modules_yaml.contains(r#""hoistedDependencies": {}"#),
"{stage}: the isolated hoist recorded nothing; got:\n{modules_yaml}",
);
};
pacquet.with_args(["install"]).assert().success();
assert_no_isolated_hoist("fresh");
fs_remove_dir_all(&workspace.join("node_modules"));
pacquet_at(&workspace).with_arg("install").with_arg("--frozen-lockfile").assert().success();
assert_no_isolated_hoist("frozen");
drop((root, mock_instance));
}
+19 -2
View File
@@ -246,7 +246,16 @@ where
// uses.
let scope_entries = match Sys::read_dir(&path) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
// Same reasoning as `read_package`: a `@`-prefixed file
// is not a scope directory, so it holds no packages.
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory,
) =>
{
continue;
}
Err(error) => {
return Err(LinkBinsError::ReadModulesDir { dir: path.clone(), error });
}
@@ -273,7 +282,15 @@ fn read_package<Sys: FsReadFile>(
let manifest_path = location.join("package.json");
let bytes = match Sys::read_file(&manifest_path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
// A missing manifest and a non-directory entry both mean the
// same thing: this is not a package. Users do drop stray files
// into `node_modules`, and one of them must not fail the
// install.
Err(error)
if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) =>
{
return Ok(None);
}
Err(error) => return Err(LinkBinsError::ReadManifest { path: manifest_path, error }),
};
let manifest: Value = parse_manifest_bytes(&bytes)
@@ -1,14 +1,12 @@
use crate::{
AllowBuildPolicy, BuildModules, BuildModulesError, CreateVirtualStore, CreateVirtualStoreError,
CreateVirtualStoreOutput, HoistedDepGraphError, HoistedDependencies, InstallabilityHost,
LinkHoistedModulesError, LinkHoistedModulesOpts, LinkRootComponentMembersError,
LinkVirtualStoreBins, LinkVirtualStoreBinsError, LockfileToHoistedDepGraphOptions,
SkippedSnapshots, SymlinkDirectDependencies, SymlinkDirectDependenciesError,
SymlinkPackageError, VersionPolicyError, VirtualStoreLayout, any_installability_constraint,
build_direct_deps_by_importer, compute_skipped_snapshots, direct_dep_names_for_importer,
get_hoisted_dependencies, link_direct_dep_bins_resolved, link_hoisted_modules,
link_root_component_members, link_top_level_bins, lockfile_to_hoisted_dep_graph,
symlink_direct_dependencies::importer_root_dir, symlink_hoisted_dependencies,
CreateVirtualStoreOutput, HoistedDepGraphError, HoistedDependencies, LinkHoistedModulesError,
LinkHoistedModulesOpts, LinkRootComponentMembersError, LinkVirtualStoreBinsError,
LockfileToHoistedDepGraphOptions, SkippedSnapshots, SymlinkDirectDependencies,
SymlinkDirectDependenciesError, SymlinkPackageError, VersionPolicyError, VirtualStoreLayout,
any_installability_constraint, build_direct_deps_by_importer, direct_dep_names_for_importer,
get_hoisted_dependencies, link_hoisted_modules, link_top_level_bins,
lockfile_to_hoisted_dep_graph, symlink_direct_dependencies::importer_root_dir,
};
use derive_more::{Display, Error};
use miette::Diagnostic;
@@ -27,9 +25,7 @@ use pacquet_package_manifest::DependencyGroup;
use pacquet_patching::{
ExtendedPatchInfo, PatchKeyConflictError, ResolvePatchedDependenciesError, get_patch_info,
};
use pacquet_reporter::{
IgnoredScriptsLog, LogEvent, LogLevel, Reporter, Stage, StageLog, StatsLog, StatsMessage,
};
use pacquet_reporter::{IgnoredScriptsLog, LogEvent, LogLevel, Reporter, Stage, StageLog};
use pacquet_resolving_resolver_base::ResolutionVerifier;
use pacquet_store_dir::StoreIndexWriter;
use pacquet_tarball::{MemCache, SharedReportedProgressKeys};
@@ -137,7 +133,7 @@ where
pub requester: &'a str,
/// CLI-merged `supportedArchitectures` from
/// `pnpm-workspace.yaml` plus `--cpu` / `--os` / `--libc`
/// overrides. Threaded into [`InstallabilityHost`] so the
/// overrides. Threaded into [`crate::InstallabilityHost`] so the
/// platform-tagged optional-dependency filter respects user-
/// supplied architecture overrides.
pub supported_architectures: Option<&'a pacquet_package_is_installable::SupportedArchitectures>,
@@ -224,6 +220,9 @@ pub enum InstallFrozenLockfileError {
#[diagnostic(transparent)]
PruneStaleModules(#[error(source)] crate::PruneDirectDepsError),
#[diagnostic(transparent)]
LinkPhase(#[error(source)] crate::linking::LinkPhaseError),
/// Surfaces a failure to cross-link a Bit root component's injected
/// members into one another's virtual-store slot. Only reachable
/// when a project manifest declares
@@ -666,30 +665,6 @@ where
StoreIndexWriter::spawn(&config.store_dir)
};
// Caller-side fast-path for the installability check. The
// common case (no lockfile metadata row declares an
// `engines` / `cpu` / `os` / `libc` constraint) lets us skip
// both [`InstallabilityHost::detect`] and
// [`compute_skipped_snapshots`] entirely. Spawning
// `node --version` here would otherwise serialize the
// node-binary startup with `CreateVirtualStore::run` (the
// dominant cost of a cold install), giving up the overlap.
//
// When constraints DO exist, the host is needed before
// extraction (so `CreateVirtualStore` can suppress slots for
// skipped snapshots), and the spawn cost is unavoidable.
// `--force` bypasses the check outright: every snapshot in the
// lockfile is materialized regardless of platform / engine
// constraints, mirroring pnpm's `!opts.force &&
// packageIsInstallable(...)` gate.
let needs_installability_check = !config.force
&& match (snapshots, packages) {
(Some(snaps), Some(pkgs)) if !snaps.is_empty() => {
any_installability_constraint(snaps, pkgs)
}
_ => false,
};
// Seed the skip set from the previous install's
// `.modules.yaml.skipped`. Each entry there is a depPath
// string a previous run wrote out; on this run we treat each
@@ -722,116 +697,61 @@ where
}
};
// Build the per-install [`SkippedSnapshots`] set. For every
// lockfile snapshot, run the installability check against
// the host triple; optional+incompatible entries land in
// the set and fire `pnpm:skipped-optional-dependency`.
//
// `host` is built only when needed. The detection path runs
// `node --version` on the blocking pool so it doesn't stall
// the reactor thread.
let (mut skipped, host_node) = if needs_installability_check {
let engine_strict = config.engine_strict;
let mut host = match node_version {
// An explicit `nodeVersion` needs no `node --version` probe, so
// build the host directly off the reactor thread.
node_version @ Some(_) => {
InstallabilityHost::detect_with(engine_strict, node_version)
}
None => tokio::task::spawn_blocking(move || {
InstallabilityHost::detect_with(engine_strict, None)
})
.await
.unwrap_or_else(|_| InstallabilityHost {
node_version: "99999.0.0".to_string(),
node_detected: false,
os: pacquet_graph_hasher::host_platform(),
cpu: pacquet_graph_hasher::host_arch(),
libc: pacquet_graph_hasher::host_libc(),
supported_architectures: None,
engine_strict,
}),
};
// Plant the CLI-merged `supportedArchitectures` (yaml +
// `--cpu`/`--os`/`--libc`) onto the host context so
// `check_platform`'s `dedupe_current` substitution picks
// up user-supplied OS/CPU/libc accept lists instead of
// only the host triple. Clone is cheap (three short
// `Option<Vec<String>>`).
if let Some(supp) = supported_architectures {
host.supported_architectures = Some(supp.clone());
}
let skipped = compute_skipped_snapshots::<Reporter>(
importers,
snapshots.expect("guarded by needs_installability_check"),
packages.expect("guarded by needs_installability_check"),
&host,
requester,
seed,
)
.map_err(InstallFrozenLockfileError::Installability)?;
// Preserve `node_detected` + `node_version` for the
// engine-name derivation below. Dropping the rest of the
// host struct frees the allocations early.
(skipped, Some((host.node_detected, host.node_version)))
} else {
// Constraint-free lockfile: keep the seed verbatim so a
// snapshot recorded as skipped on the previous install
// survives the constraint having been removed from the
// lockfile.
(seed, None)
};
// `--no-optional` enforcement (umbrella slice 5).
// When `include.optionalDependencies` is false, every
// snapshot whose `optional` flag is true gets dropped from
// the install graph. The lockfile's
// [`SnapshotEntry::optional`] is set by the resolver when
// the snapshot is reachable **only** through optional
// edges; a snapshot reachable through any non-optional
// edge carries `optional: false` and survives the filter
// (a dependency that is both optional and non-optional is
// installed). The exclusions land in the transient
// `optional_excluded` subset of [`SkippedSnapshots`] so
// they propagate to every downstream filter
// (`CreateVirtualStore`, `SymlinkDirectDependencies`,
// `BuildModules`, hoist) through the same gate
// installability skips use — and stay out of
// `.modules.yaml.skipped` so a future install without
// `--no-optional` brings them back.
let include_optional = dependency_groups.contains(&DependencyGroup::Optional);
if !include_optional && let Some(snaps) = snapshots {
for (key, snap) in snaps {
if snap.optional {
skipped.add_optional_excluded(key.clone());
// Detecting the host is what costs a `node --version`, so it is
// skipped entirely for the common constraint-free lockfile —
// otherwise the probe serializes against the extraction that
// dominates a cold install.
// `any_installability_constraint` short-circuits on `packages`
// alone, so the empty-snapshots guard is load-bearing: without
// it a lockfile with constrained metadata but no snapshots would
// pay for a `node --version` it has nothing to check.
let needs_installability_check = !config.force
&& match (snapshots, packages) {
(Some(snaps), Some(pkgs)) if !snaps.is_empty() => {
any_installability_constraint(snaps, pkgs)
}
}
}
_ => false,
};
let installability_host = crate::materialization_plan::detect_installability_host(
needs_installability_check,
config.engine_strict,
node_version,
supported_architectures,
)
.await;
// Keep `node_detected` + the version for the engine-name
// derivation below; the rest of the host is dropped here.
let host_node = installability_host
.as_ref()
.map(|host| (host.node_detected, host.node_version.clone()));
if skip_runtimes && let Some(pkgs) = packages {
crate::add_direct_runtime_skips(&mut skipped, importers, pkgs);
}
// The recorded skip set must be the reachability closure of the
// direct skips (see
// [`crate::extend_skipped_with_dependency_closure`]); extend it
// before `CreateVirtualStore`, the hoist pass, and the symlink /
// bin passes consume it.
{
let importer_ids: std::collections::HashSet<String> =
importers.keys().cloned().collect();
crate::extend_skipped_with_dependency_closure(
&mut skipped,
lockfile,
workspace_root,
&importer_ids,
pacquet_modules_yaml::IncludedDependencies {
let closure_importer_ids: std::collections::HashSet<String> =
importers.keys().cloned().collect();
let mut skipped = crate::materialization_plan::compute_skip_set::<Reporter>(
crate::materialization_plan::SkipSetInputs {
requester,
importers,
snapshots,
packages,
installability_host: installability_host.as_ref(),
seed,
// The frozen path always installs the groups it was
// given, so `--no-optional` needs no further
// qualification here.
exclude_optional: !include_optional,
skip_runtimes,
closure_lockfile: lockfile,
closure_root: workspace_root,
closure_importer_ids: &closure_importer_ids,
included: pacquet_modules_yaml::IncludedDependencies {
dependencies: dependency_groups.contains(&DependencyGroup::Prod),
dev_dependencies: dependency_groups.contains(&DependencyGroup::Dev),
optional_dependencies: include_optional,
},
);
}
},
)
.map_err(InstallFrozenLockfileError::Installability)?;
// `engine_name` feeds two sites:
//
@@ -869,41 +789,13 @@ where
// `node --version` returns from the shell, splitting the
// shared store between pinned and non-pinned installs on the
// same host.
let runtime_pinned_major = find_runtime_node_major(snapshots);
let (initial_engine_name, deferred_engine_handle): (
Option<String>,
Option<tokio::task::JoinHandle<Option<String>>>,
) = if let Some(major) = runtime_pinned_major {
// Lockfile-driven major wins outright; skip the host
// probe / `node --version` spawn entirely.
(Some(pacquet_graph_hasher::engine_name(major, None, None)), None)
} else {
match &host_node {
Some((true, ver)) => (
parse_major_from_version(ver)
.map(|major| pacquet_graph_hasher::engine_name(major, None, None)),
None,
),
Some((false, _)) => (None, None),
None if config.enable_global_virtual_store => (
tokio::task::spawn_blocking(|| {
pacquet_graph_hasher::detect_node_major()
.map(|major| pacquet_graph_hasher::engine_name(major, None, None))
})
.await
.ok()
.flatten(),
None,
),
None => (
None,
Some(tokio::task::spawn_blocking(|| {
pacquet_graph_hasher::detect_node_major()
.map(|major| pacquet_graph_hasher::engine_name(major, None, None))
})),
),
}
};
let (initial_engine_name, deferred_engine_handle) =
crate::materialization_plan::resolve_engine_name(
config.enable_global_virtual_store,
snapshots,
host_node.as_ref(),
)
.await;
let engine_name = initial_engine_name;
// Build the install-scoped slot-directory layout. When
@@ -1043,323 +935,70 @@ where
skipped.add_fetch_failed(key);
}
// Pre-compute the hoist plan so the dedupe pass inside
// `SymlinkDirectDependencies` can fold publicly-hoisted aliases
// into root's target map — pacquet runs hoist *after*
// `SymlinkDirectDependencies`, so without this the dedupe map
// only sees root's direct deps and a non-root importer's
// direct dep that would land at root via public-hoist stays
// un-deduped. The full `HoistResult` is also threaded to the
// on-disk hoist pass below so the traversal isn't run twice.
// `hoist-workspace-packages`: named non-root projects become
// hoist candidates whose links point at the project dirs.
let hoisted_workspace_packages = config
.hoist_workspace_packages
.then(|| workspace_packages_for_hoist(workspace_root, project_manifests));
let pre_hoist = compute_hoist_plan(
config,
snapshots,
packages,
importers,
&dependency_groups,
&skipped,
is_hoisted,
hoisted_workspace_packages.as_ref(),
);
let public_hoist_targets: Option<BTreeMap<String, PathBuf>> =
pre_hoist.as_ref().map(|plan| {
collect_public_hoist_targets(&plan.result, &plan.graph, &layout, &plan.skipped)
});
// Reconcile before linking: stale direct-dep links and
// orphaned hoist links must vacate their slots so the relink +
// rehoist below can claim them. The hoisted linker is excluded
// — its previous-graph diff removes orphans and emits the
// `pnpm:stats` `removed` event itself (see
// [`crate::link_hoisted_modules()`]); on the isolated linker
// the event fires here, so every install carries exactly one,
// pairing the `added` emitted in `CreateVirtualStore`.
//
// `virtual_store_only` skips reconciliation for the same reason
// it skips linking below: it never creates importer or hoist
// links, so there is nothing of its own to reconcile.
if !is_hoisted && !config.virtual_store_only {
let removed_count = match current_lockfile {
Some(current) => crate::PruneStaleModules {
config,
workspace_root,
wanted_lockfile: lockfile,
current_lockfile: current,
prior_hoisted_dependencies,
included_groups: &dependency_groups,
prune_orphans,
}
.run::<Reporter>()
.map_err(InstallFrozenLockfileError::PruneStaleModules)?,
None => 0,
};
Reporter::emit(&LogEvent::Stats(StatsLog {
level: LogLevel::Debug,
message: StatsMessage::Removed {
prefix: requester.to_owned(),
removed: removed_count,
},
}));
}
// `virtual_store_only` stops here: the virtual store is
// populated, but nothing downstream of it — importer symlinks,
// per-slot bins, root components — gets linked.
if !is_hoisted && !config.virtual_store_only {
// Importer ids backed by the install's own declared
// projects. These may legitimately live outside the
// lockfile dir (Bit's capsule installs pass such
// projects), so they bypass the malformed-lockfile
// importer-key rejection.
let trusted_importer_ids: std::collections::HashSet<String> = project_manifests
.iter()
.map(|(project_dir, _)| {
pacquet_workspace::importer_id_from_root_dir(workspace_root, project_dir)
})
.collect();
SymlinkDirectDependencies {
config,
layout: &layout,
importers,
packages,
dependency_groups: dependency_groups.iter().copied(),
workspace_root,
skipped: &skipped,
link_only: false,
public_hoist_targets: public_hoist_targets.as_ref(),
trusted_importer_ids: Some(&trusted_importer_ids),
extra_node_paths: &extra_node_paths,
}
.run::<Reporter>()
.map_err(InstallFrozenLockfileError::SymlinkDirectDependencies)?;
// Bit "root components": make each root's injected members
// mutually reachable. Gated on
// `installConfig.hoistingLimits: "workspaces"`, so it is a
// no-op for every non-Bit install. See
// [`link_root_component_members`]. `project_manifests` keys
// are project directories; map each back to its lockfile
// importer id so the set lines up with `importers`.
let root_component_importers: std::collections::HashSet<String> = project_manifests
.iter()
.filter(|(_, manifest)| {
manifest.install_config_hoisting_limits()
== Some(crate::HOISTING_LIMITS_WORKSPACES)
})
.map(|(project_dir, _)| {
pacquet_workspace::importer_id_from_root_dir(workspace_root, project_dir)
})
.collect();
link_root_component_members(
&layout,
importers,
&root_component_importers,
&dependency_groups,
&skipped,
)
.map_err(InstallFrozenLockfileError::LinkRootComponentMembers)?;
// Link the bins of each virtual-store slot's children into the
// slot's own `node_modules/.bin`.
// Done before `importing_done` so reporters see the import phase
// close only after every link (including per-slot bins) is in
// place. The manifest map threaded from `CreateVirtualStore`
// lets the linker hit `pkgFilesIndex.manifest` directly instead
// of re-reading every child's `package.json` from disk.
//
// Both passes are gated by `!is_hoisted`: under
// `nodeLinker: hoisted` there is no virtual store
// (`CreateVirtualStore` skipped slot writes), and the
// bin links go into `<parent>/node_modules/.bin` for
// every hoist location instead. The hoisted linker
// ([`crate::link_hoisted_modules()`], called below) does
// its own per-`node_modules` bin pass while walking the
// hierarchy, routing both link phases through the hoisted
// linker.
LinkVirtualStoreBins {
layout: &layout,
snapshots,
packages,
package_manifests: &package_manifests,
skipped: &skipped,
extra_node_paths: &extra_node_paths,
}
.run()
.map_err(InstallFrozenLockfileError::LinkVirtualStoreBins)?;
}
// Hoisted-linker materialization. Replaces the isolated
// [`crate::SymlinkDirectDependencies`] +
// [`crate::LinkVirtualStoreBins`] pair when
// `nodeLinker: hoisted` is in effect: the dep-graph walker
// computes per-package directories (with conflict-aware
// nesting), and the linker imports CAS files into those
// directories from
// [`CreateVirtualStoreOutput::cas_paths_by_pkg_id`] which
// was populated above with `node_linker = Hoisted`.
//
// `hoisted_locations` is the per-depPath list of
// lockfile-relative directories the walker emits. Threaded
// through [`InstallFrozenLockfileOutput`] so
// [`crate::Install::run`] can persist it into
// `.modules.yaml.hoisted_locations` (rebuild reads it back
// and surfaces `MISSING_HOISTED_LOCATIONS` if it's gone).
//
// `pkg_roots_by_key` is a per-snapshot override for
// `BuildModules`'s `pkgRoot` lookup. Populated from the
// walker's [`crate::DependenciesGraphNode::dir`] values so
// the build phase can `cd` into the on-disk hoisted
// directory instead of computing a virtual-store slot path
// that doesn't exist under hoisted. `None` (and an empty
// `hoisted_locations`) for the isolated linker. See
// [`crate::BuildModules::pkg_roots_by_key`] for why a snapshot
// can map to more than one directory and which writes have to
// reach all of them.
let HoistedLinkerOutput { hoisted_locations, hoisted_pkg_roots_by_key } =
if is_hoisted && !config.virtual_store_only {
run_hoisted_linker::<Reporter>(
HoistedLinkerInputs {
config,
lockfile,
current_lockfile,
layout: &layout,
importers,
dependency_groups: &dependency_groups,
project_manifests,
package_map_project_manifests,
walker_lockfile_dir: workspace_root,
symlink_workspace_root: workspace_root,
host_node: host_node.as_ref(),
supported_architectures,
cas_paths_by_pkg_id,
logged_methods,
requester,
},
&mut skipped,
)
.map_err(InstallFrozenLockfileError::from)?
} else {
HoistedLinkerOutput::default()
};
// Hoist transitive deps into `<virtual_store>/node_modules`
// (private hoist) and/or `<root>/node_modules` (public hoist).
//
// The guard is `hoistPattern != null || publicHoistPattern != null`
// — `Some(empty)` is a valid disabled state for one side but
// not the other, so the guard checks `is_some()` on the field
// (not `Vec` length). With pacquet's defaults both sides are
// `Some(non-empty)`, so the pass runs by default.
// Stashed across the hoist pass for the post-`BuildModules`
// top-level bin link. Isolated-linker public-hoist promotes
// a transitive dep alias to `<root>/node_modules/<alias>`
// where it competes for the same `<root>/node_modules/.bin`
// slot as the root importer's direct deps. Per
// pnpm/pacquet#342 the direct dep's bin must win. The post-build pass below
// takes both direct + hoisted candidate lists so
// `pacquet_cmd_shim::pick_winner` (private)'s [`BinOrigin`] tier
// resolves the conflict in one call. Empty means there's
// no public-hoist (no patterns set, hoisted linker, or
// `Some(empty)`-vs-`None` short-circuit).
let mut publicly_hoisted_for_post_build: Vec<String> = Vec::new();
// Isolated-linker hoist pass: shamefully-hoist + private
// hoist into the virtual store. Skipped under hoisted —
// the hoisted linker materialized the project tree above
// and there's no virtual store to point hoist symlinks at,
// so no new isolated-hoist results are produced when no
// `hoistPattern` / `publicHoistPattern` is configured.
//
// The traversal itself ran upthread (`pre_hoist`) so the dedupe
// pass in `SymlinkDirectDependencies` could see public-hoist
// targets; here we consume the same plan to write the
// symlinks on disk and emit the per-side bin shims.
let hoisted_dependencies = if let Some(plan) = pre_hoist {
let HoistPlan { graph, result, skipped: hoist_skipped, .. } = plan;
// Public-hoist target is the project's root
// `node_modules` (= `config.modules_dir`).
// Private-hoist target is the project-local
// `<root>/node_modules/.pnpm/node_modules` —
// pacquet's `config.virtual_store_dir` always
// resolves there even with GVS enabled: pacquet keeps
// `virtual_store_dir` project-local and
// routes the GVS-shared root through
// `global_virtual_store_dir` instead — see
// [`Config::apply_global_virtual_store_derivation`].
// The symlink *target* (under the slot dir)
// does need to be GVS-aware, which the
// `VirtualStoreLayout` handle below provides.
let private_dir = config.virtual_store_dir.join("node_modules");
let public_dir = config.modules_dir.clone();
symlink_hoisted_dependencies(
&result.hoisted_dependencies_by_node_id,
&result.hoisted_workspace_aliases,
&graph,
&layout,
&private_dir,
&public_dir,
&hoist_skipped,
)
.map_err(InstallFrozenLockfileError::HoistSymlink)?;
// Private-side bins → `<vs>/node_modules/.bin`.
// Reuses the rayon-parallel `link_direct_dep_bins`
// shape (read each location's `package.json`, fan out
// to `link_bins_of_packages`).
link_direct_dep_bins_resolved(
&private_dir,
&crate::resolve_hoisted_bin_deps(&layout, &result.hoisted_aliases_with_bins),
&extra_node_paths,
)
.map_err(InstallFrozenLockfileError::HoistLinkBins)?;
// Stash the public-hoist alias list for the
// post-`BuildModules` top-level bin link, which re-links
// with the [`BinOrigin`] tier so a direct dep's bin wins
// outright over a publicly-hoisted bin with a lexically
// smaller name. The re-link runs after `buildModules`.
publicly_hoisted_for_post_build = result.publicly_hoisted_aliases_with_bins;
result.hoisted_dependencies
} else {
crate::HoistedDependencies::new()
};
let included = IncludedDependencies {
// Importer ids backed by the install's own declared projects.
// These may legitimately live outside the lockfile dir (Bit's
// capsule installs), so they bypass the malformed-lockfile
// importer-key rejection.
let trusted_importer_ids: std::collections::HashSet<String> = project_manifests
.iter()
.map(|(project_dir, _)| {
pacquet_workspace::importer_id_from_root_dir(workspace_root, project_dir)
})
.collect();
let root_component_importers: std::collections::HashSet<String> = project_manifests
.iter()
.filter(|(_, manifest)| {
manifest.install_config_hoisting_limits() == Some(crate::HOISTING_LIMITS_WORKSPACES)
})
.map(|(project_dir, _)| {
pacquet_workspace::importer_id_from_root_dir(workspace_root, project_dir)
})
.collect();
let sidecar_included = IncludedDependencies {
dependencies: dependency_groups.contains(&DependencyGroup::Prod),
dev_dependencies: dependency_groups.contains(&DependencyGroup::Dev),
optional_dependencies: dependency_groups.contains(&DependencyGroup::Optional),
};
if crate::should_write_package_map(config, node_linker) {
let filtered_lockfile =
crate::filter_lockfile_for_current(lockfile, included, &skipped);
crate::package_map::write_package_map(
&filtered_lockfile,
&crate::package_map::PackageMapOptions {
lockfile_dir: workspace_root,
modules_dir: &config.modules_dir,
package_map_type: config.node_package_map_type,
layout: &layout,
project_manifests,
},
)
.map_err(InstallFrozenLockfileError::WritePackageMap)?;
}
// See `install_with_fresh_lockfile::linking` for why
// `virtual_store_only` suppresses the loader.
if matches!(node_linker, NodeLinker::Pnp) && !config.virtual_store_only {
let filtered_lockfile =
crate::filter_lockfile_for_current(lockfile, included, &skipped);
crate::write_pnp_file(
&filtered_lockfile,
workspace_root,
let sidecar_lockfile =
crate::filter_lockfile_for_current(lockfile, sidecar_included, &skipped);
let crate::linking::LinkPhaseOutput {
hoisted_dependencies,
hoisted_locations,
hoisted_pkg_roots_by_key,
publicly_hoisted_for_post_build,
} = crate::linking::run_link_phase::<Reporter>(
crate::linking::LinkPhaseInputs {
symlink_root: workspace_root,
trusted_importer_ids: &trusted_importer_ids,
root_component_importers: &root_component_importers,
sidecar_lockfile: &sidecar_lockfile,
config,
&layout,
layout: &layout,
lockfile,
current_lockfile,
snapshots,
packages,
importers,
project_manifests,
)
.map_err(InstallFrozenLockfileError::WritePnpFile)?;
}
package_map_project_manifests,
dependency_groups: &dependency_groups,
package_manifests: &package_manifests,
cas_paths_by_pkg_id,
extra_node_paths: &extra_node_paths,
workspace_root,
requester,
node_linker,
is_hoisted,
prune_orphans,
prior_hoisted_dependencies,
host_node: host_node.as_ref(),
supported_architectures,
logged_methods,
},
&mut skipped,
)
.map_err(InstallFrozenLockfileError::LinkPhase)?;
// `importing_done` fires once extraction and symlink linking
// are complete, before any build phase. Reporters use it to
+2
View File
@@ -18,6 +18,8 @@ pub mod link_bins;
pub mod link_file;
pub mod link_hoisted_modules;
pub mod link_root_component_members;
pub mod linking;
pub mod materialization_plan;
pub mod package_map;
pub mod pnp;
pub mod prune_direct_deps;
+501
View File
@@ -0,0 +1,501 @@
//! The install link phase, shared by both paths: reconcile what the previous install
//! left behind, materialize the importer-visible tree — either the
//! hoisted `node_modules` hierarchy or the isolated symlink layout with
//! its hoist and bin passes — and write the module-resolution sidecars.
//!
//! Runs once the virtual store is populated and before the build phase,
//! which needs both the linked tree and the hoisted package roots this
//! reports.
use crate::{
CasPathsByPkgId, LinkVirtualStoreBins, PackageManifests, SkippedSnapshots,
SymlinkDirectDependencies, VirtualStoreLayout,
install_frozen_lockfile::{
HoistPlan, HoistedLinkerError, HoistedLinkerInputs, HoistedLinkerOutput,
collect_public_hoist_targets, compute_hoist_plan, run_hoisted_linker,
workspace_packages_for_hoist,
},
link_direct_dep_bins_resolved, link_root_component_members, symlink_hoisted_dependencies,
};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_config::{Config, NodeLinker};
use pacquet_lockfile::{Lockfile, PackageKey, PackageMetadata, ProjectSnapshot, SnapshotEntry};
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_reporter::{LogEvent, LogLevel, Reporter, StatsLog, StatsMessage};
use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
sync::atomic::AtomicU8,
};
/// Error type of [`run_link_phase`].
///
/// The wrapping variants are `#[diagnostic(transparent)]`, so the
/// surfaced `ERR_PNPM_*` code is the inner error's and a link failure
/// reports identically whichever install path ran it. The two sidecar
/// writes are the exception: they own
/// `ERR_PNPM_PACKAGE_MANAGER_WRITE_PACKAGE_MAP` and
/// `ERR_PNPM_PACKAGE_MANAGER_WRITE_PNP_FILE` because the underlying I/O
/// error carries no pnpm code of its own.
#[derive(Debug, Display, Error, Diagnostic)]
pub enum LinkPhaseError {
#[diagnostic(transparent)]
PruneStaleModules(#[error(source)] crate::PruneDirectDepsError),
#[diagnostic(transparent)]
SymlinkDirectDependencies(#[error(source)] crate::SymlinkDirectDependenciesError),
#[diagnostic(transparent)]
LinkRootComponentMembers(#[error(source)] crate::LinkRootComponentMembersError),
#[diagnostic(transparent)]
LinkVirtualStoreBins(#[error(source)] crate::LinkVirtualStoreBinsError),
#[diagnostic(transparent)]
HoistSymlink(#[error(source)] crate::SymlinkPackageError),
#[diagnostic(transparent)]
HoistLinkBins(#[error(source)] pacquet_cmd_shim::LinkBinsError),
#[diagnostic(transparent)]
LinkBins(#[error(source)] pacquet_cmd_shim::LinkBinsError),
#[diagnostic(transparent)]
HoistedDepGraph(#[error(source)] crate::HoistedDepGraphError),
#[diagnostic(transparent)]
LinkHoistedModules(#[error(source)] crate::LinkHoistedModulesError),
#[display("failed to write package map: {_0}")]
#[diagnostic(code(ERR_PNPM_PACKAGE_MANAGER_WRITE_PACKAGE_MAP))]
WritePackageMap(#[error(source)] crate::WritePackageMapError),
#[display("failed to write PnP loader: {_0}")]
#[diagnostic(code(ERR_PNPM_PACKAGE_MANAGER_WRITE_PNP_FILE))]
WritePnpFile(#[error(source)] crate::WritePnpFileError),
}
impl From<HoistedLinkerError> for LinkPhaseError {
fn from(error: HoistedLinkerError) -> Self {
match error {
HoistedLinkerError::HoistedDepGraph(error) => LinkPhaseError::HoistedDepGraph(error),
HoistedLinkerError::LinkHoistedModules(error) => {
LinkPhaseError::LinkHoistedModules(error)
}
HoistedLinkerError::SymlinkDirectDependencies(error) => {
LinkPhaseError::SymlinkDirectDependencies(error)
}
HoistedLinkerError::WritePackageMap(error) => LinkPhaseError::WritePackageMap(error),
}
}
}
/// Everything the link phase reads.
///
/// Both install paths supply this, and the fields that differ between
/// them are inputs rather than branches: [`Self::symlink_root`],
/// [`Self::trusted_importer_ids`], [`Self::root_component_importers`]
/// and [`Self::sidecar_lockfile`] each carry a per-path value whose
/// reason is documented on the field.
pub struct LinkPhaseInputs<'a> {
pub config: &'static Config,
pub layout: &'a VirtualStoreLayout,
pub lockfile: &'a Lockfile,
pub current_lockfile: Option<&'a Lockfile>,
pub snapshots: Option<&'a HashMap<PackageKey, SnapshotEntry>>,
pub packages: Option<&'a HashMap<PackageKey, PackageMetadata>>,
pub importers: &'a HashMap<String, ProjectSnapshot>,
pub project_manifests: &'a [(PathBuf, &'a PackageManifest)],
pub package_map_project_manifests: &'a [(PathBuf, &'a PackageManifest)],
pub dependency_groups: &'a [DependencyGroup],
pub package_manifests: &'a PackageManifests,
pub cas_paths_by_pkg_id: Option<CasPathsByPkgId>,
pub extra_node_paths: &'a [String],
/// Anchor for each importer's `node_modules`. The frozen path uses
/// `workspace_root`; the fresh path uses `modules_dir.parent()`,
/// because its tests relocate `modules_dir` away from the manifest.
pub symlink_root: &'a Path,
/// Lockfile dir, for the sidecars and the hoisted walker.
pub workspace_root: &'a Path,
/// Importer ids allowed to live outside the lockfile dir (Bit's
/// capsule installs). Derived differently per path, so it is an
/// input rather than something this module recomputes.
pub trusted_importer_ids: &'a std::collections::HashSet<String>,
/// Importers declaring `installConfig.hoistingLimits: "workspaces"`.
pub root_component_importers: &'a std::collections::HashSet<String>,
/// The lockfile the module-resolution sidecars describe. The frozen
/// path filters to the current install first; the fresh path already
/// holds a materialization closure.
pub sidecar_lockfile: &'a Lockfile,
pub requester: &'a str,
pub node_linker: NodeLinker,
pub is_hoisted: bool,
pub prune_orphans: bool,
pub prior_hoisted_dependencies: Option<&'a crate::HoistedDependencies>,
pub host_node: Option<&'a (bool, String)>,
pub supported_architectures: Option<&'a pacquet_package_is_installable::SupportedArchitectures>,
pub logged_methods: &'a AtomicU8,
}
/// What the link phase hands to the build phase and the caller's
/// `.modules.yaml` writer.
pub struct LinkPhaseOutput {
pub hoisted_dependencies: crate::HoistedDependencies,
pub hoisted_locations: BTreeMap<String, Vec<String>>,
pub hoisted_pkg_roots_by_key: Option<HashMap<PackageKey, Vec<PathBuf>>>,
pub publicly_hoisted_for_post_build: Vec<String>,
}
/// Reconcile what the previous install left behind, then materialize
/// the importer-visible tree and the module-resolution sidecars.
///
/// **Precondition:** the virtual store is already populated. This
/// creates links into it and never fetches, so a snapshot missing from
/// the store yields a dangling link rather than an error.
///
/// `skipped` is taken by `&mut` because the hoisted linker adds to it:
/// a package the walker cannot place is recorded so the build phase and
/// `.modules.yaml` observe the same skip set this phase acted on.
///
/// Returns what the build phase and the caller's `.modules.yaml` writer
/// need — see [`LinkPhaseOutput`]. Under `virtual_store_only` nothing
/// below the reconciliation runs and every output is empty: that mode
/// populates the store without touching the project.
pub fn run_link_phase<Reporter: self::Reporter>(
inputs: LinkPhaseInputs<'_>,
skipped: &mut SkippedSnapshots,
) -> Result<LinkPhaseOutput, LinkPhaseError> {
let LinkPhaseInputs {
symlink_root,
trusted_importer_ids,
root_component_importers,
sidecar_lockfile,
config,
layout,
lockfile,
current_lockfile,
snapshots,
packages,
importers,
project_manifests,
package_map_project_manifests,
dependency_groups,
package_manifests,
cas_paths_by_pkg_id,
extra_node_paths,
workspace_root,
requester,
node_linker,
is_hoisted,
prune_orphans,
prior_hoisted_dependencies,
host_node,
supported_architectures,
logged_methods,
} = inputs;
// Pre-compute the hoist plan so the dedupe pass inside
// `SymlinkDirectDependencies` can fold publicly-hoisted aliases
// into root's target map — pacquet runs hoist *after*
// `SymlinkDirectDependencies`, so without this the dedupe map
// only sees root's direct deps and a non-root importer's
// direct dep that would land at root via public-hoist stays
// un-deduped. The full `HoistResult` is also threaded to the
// on-disk hoist pass below so the traversal isn't run twice.
// `hoist-workspace-packages`: named non-root projects become
// hoist candidates whose links point at the project dirs.
let hoisted_workspace_packages = config
.hoist_workspace_packages
.then(|| workspace_packages_for_hoist(workspace_root, project_manifests));
let pre_hoist = compute_hoist_plan(
config,
snapshots,
packages,
importers,
dependency_groups,
skipped,
is_hoisted,
hoisted_workspace_packages.as_ref(),
);
let public_hoist_targets: Option<BTreeMap<String, PathBuf>> = pre_hoist
.as_ref()
.map(|plan| collect_public_hoist_targets(&plan.result, &plan.graph, layout, &plan.skipped));
// Reconcile before linking: stale direct-dep links and
// orphaned hoist links must vacate their slots so the relink +
// rehoist below can claim them. The hoisted linker is excluded
// — its previous-graph diff removes orphans and emits the
// `pnpm:stats` `removed` event itself (see
// [`crate::link_hoisted_modules()`]); on the isolated linker
// the event fires here, so every install carries exactly one,
// pairing the `added` emitted in `CreateVirtualStore`.
// Nothing below this point runs under `virtual_store_only`: it
// creates no importer or hoist links, so it has neither anything to
// reconcile nor anything to link. Returning here rather than gating
// each pass keeps that a single decision — and keeps the hoist pass,
// which writes into `config.modules_dir`, from touching a project
// this mode is meant to leave alone.
if config.virtual_store_only {
return Ok(LinkPhaseOutput {
hoisted_dependencies: crate::HoistedDependencies::new(),
hoisted_locations: BTreeMap::new(),
hoisted_pkg_roots_by_key: None,
publicly_hoisted_for_post_build: Vec::new(),
});
}
//
if !is_hoisted {
let removed_count = match current_lockfile {
Some(current) => crate::PruneStaleModules {
config,
workspace_root: symlink_root,
wanted_lockfile: lockfile,
current_lockfile: current,
prior_hoisted_dependencies,
included_groups: dependency_groups,
prune_orphans,
}
.run::<Reporter>()
.map_err(LinkPhaseError::PruneStaleModules)?,
None => 0,
};
Reporter::emit(&LogEvent::Stats(StatsLog {
level: LogLevel::Debug,
message: StatsMessage::Removed { prefix: requester.to_owned(), removed: removed_count },
}));
}
if !is_hoisted {
SymlinkDirectDependencies {
config,
layout,
importers,
packages,
dependency_groups: dependency_groups.iter().copied(),
workspace_root: symlink_root,
skipped,
link_only: false,
public_hoist_targets: public_hoist_targets.as_ref(),
trusted_importer_ids: Some(trusted_importer_ids),
extra_node_paths,
}
.run::<Reporter>()
.map_err(LinkPhaseError::SymlinkDirectDependencies)?;
// Bit "root components": make each root's injected members
// mutually reachable. Gated on
// `installConfig.hoistingLimits: "workspaces"`, so it is a
// no-op for every non-Bit install. See
// [`link_root_component_members`]. `project_manifests` keys
// are project directories; map each back to its lockfile
// importer id so the set lines up with `importers`.
link_root_component_members(
layout,
importers,
root_component_importers,
dependency_groups,
skipped,
)
.map_err(LinkPhaseError::LinkRootComponentMembers)?;
// Link the bins of each virtual-store slot's children into the
// slot's own `node_modules/.bin`.
// Done before `importing_done` so reporters see the import phase
// close only after every link (including per-slot bins) is in
// place. The manifest map threaded from `CreateVirtualStore`
// lets the linker hit `pkgFilesIndex.manifest` directly instead
// of re-reading every child's `package.json` from disk.
//
// Both passes are gated by `!is_hoisted`: under
// `nodeLinker: hoisted` there is no virtual store
// (`CreateVirtualStore` skipped slot writes), and the
// bin links go into `<parent>/node_modules/.bin` for
// every hoist location instead. The hoisted linker
// ([`crate::link_hoisted_modules()`], called below) does
// its own per-`node_modules` bin pass while walking the
// hierarchy, routing both link phases through the hoisted
// linker.
LinkVirtualStoreBins {
layout,
snapshots,
packages,
package_manifests,
skipped,
extra_node_paths,
}
.run()
.map_err(LinkPhaseError::LinkVirtualStoreBins)?;
}
// Hoisted-linker materialization. Replaces the isolated
// [`crate::SymlinkDirectDependencies`] +
// [`crate::LinkVirtualStoreBins`] pair when
// `nodeLinker: hoisted` is in effect: the dep-graph walker
// computes per-package directories (with conflict-aware
// nesting), and the linker imports CAS files into those
// directories from
// [`CreateVirtualStoreOutput::cas_paths_by_pkg_id`] which
// was populated above with `node_linker = Hoisted`.
//
// `hoisted_locations` is the per-depPath list of
// lockfile-relative directories the walker emits. Threaded
// through [`InstallFrozenLockfileOutput`] so
// [`crate::Install::run`] can persist it into
// `.modules.yaml.hoisted_locations` (rebuild reads it back
// and surfaces `MISSING_HOISTED_LOCATIONS` if it's gone).
//
// `pkg_roots_by_key` is a per-snapshot override for
// `BuildModules`'s `pkgRoot` lookup. Populated from the
// walker's [`crate::DependenciesGraphNode::dir`] values so
// the build phase can `cd` into the on-disk hoisted
// directory instead of computing a virtual-store slot path
// that doesn't exist under hoisted. `None` (and an empty
// `hoisted_locations`) for the isolated linker. See
// [`crate::BuildModules::pkg_roots_by_key`] for why a snapshot
// can map to more than one directory and which writes have to
// reach all of them.
let HoistedLinkerOutput { hoisted_locations, hoisted_pkg_roots_by_key } = if is_hoisted {
run_hoisted_linker::<Reporter>(
HoistedLinkerInputs {
config,
lockfile,
current_lockfile,
layout,
importers,
dependency_groups,
project_manifests,
package_map_project_manifests,
walker_lockfile_dir: workspace_root,
symlink_workspace_root: symlink_root,
host_node,
supported_architectures,
cas_paths_by_pkg_id,
logged_methods,
requester,
},
skipped,
)
.map_err(LinkPhaseError::from)?
} else {
HoistedLinkerOutput::default()
};
// Hoist transitive deps into `<virtual_store>/node_modules`
// (private hoist) and/or `<root>/node_modules` (public hoist).
//
// The guard is `hoistPattern != null || publicHoistPattern != null`
// — `Some(empty)` is a valid disabled state for one side but
// not the other, so the guard checks `is_some()` on the field
// (not `Vec` length). With pacquet's defaults both sides are
// `Some(non-empty)`, so the pass runs by default.
// Stashed across the hoist pass for the post-`BuildModules`
// top-level bin link. Isolated-linker public-hoist promotes
// a transitive dep alias to `<root>/node_modules/<alias>`
// where it competes for the same `<root>/node_modules/.bin`
// slot as the root importer's direct deps. Per
// pnpm/pacquet#342 the direct dep's bin must win. The post-build pass below
// takes both direct + hoisted candidate lists so
// `pacquet_cmd_shim::pick_winner` (private)'s [`BinOrigin`] tier
// resolves the conflict in one call. Empty means there's
// no public-hoist (no patterns set, hoisted linker, or
// `Some(empty)`-vs-`None` short-circuit).
let mut publicly_hoisted_for_post_build: Vec<String> = Vec::new();
// Isolated-linker hoist pass: shamefully-hoist + private
// hoist into the virtual store. Skipped under hoisted —
// the hoisted linker materialized the project tree above
// and there's no virtual store to point hoist symlinks at,
// so no new isolated-hoist results are produced when no
// `hoistPattern` / `publicHoistPattern` is configured.
//
// The traversal itself ran upthread (`pre_hoist`) so the dedupe
// pass in `SymlinkDirectDependencies` could see public-hoist
// targets; here we consume the same plan to write the
// symlinks on disk and emit the per-side bin shims.
let hoisted_dependencies = if let Some(plan) = pre_hoist {
let HoistPlan { graph, result, skipped: hoist_skipped, .. } = plan;
// Public-hoist target is the project's root
// `node_modules` (= `config.modules_dir`).
// Private-hoist target is the project-local
// `<root>/node_modules/.pnpm/node_modules` —
// pacquet's `config.virtual_store_dir` always
// resolves there even with GVS enabled: pacquet keeps
// `virtual_store_dir` project-local and
// routes the GVS-shared root through
// `global_virtual_store_dir` instead — see
// [`Config::apply_global_virtual_store_derivation`].
// The symlink *target* (under the slot dir)
// does need to be GVS-aware, which the
// `VirtualStoreLayout` handle below provides.
let private_dir = config.virtual_store_dir.join("node_modules");
let public_dir = config.modules_dir.clone();
symlink_hoisted_dependencies(
&result.hoisted_dependencies_by_node_id,
&result.hoisted_workspace_aliases,
&graph,
layout,
&private_dir,
&public_dir,
&hoist_skipped,
)
.map_err(LinkPhaseError::HoistSymlink)?;
// Private-side bins → `<vs>/node_modules/.bin`.
// Reuses the rayon-parallel `link_direct_dep_bins`
// shape (read each location's `package.json`, fan out
// to `link_bins_of_packages`).
link_direct_dep_bins_resolved(
&private_dir,
&crate::resolve_hoisted_bin_deps(layout, &result.hoisted_aliases_with_bins),
extra_node_paths,
)
.map_err(LinkPhaseError::HoistLinkBins)?;
// Stash the public-hoist alias list for the
// post-`BuildModules` top-level bin link, which re-links
// with the [`BinOrigin`] tier so a direct dep's bin wins
// outright over a publicly-hoisted bin with a lexically
// smaller name. The re-link runs after `buildModules`.
publicly_hoisted_for_post_build = result.publicly_hoisted_aliases_with_bins;
result.hoisted_dependencies
} else {
crate::HoistedDependencies::new()
};
if crate::should_write_package_map(config, node_linker) {
crate::package_map::write_package_map(
sidecar_lockfile,
&crate::package_map::PackageMapOptions {
lockfile_dir: workspace_root,
modules_dir: &config.modules_dir,
package_map_type: config.node_package_map_type,
layout,
project_manifests,
},
)
.map_err(LinkPhaseError::WritePackageMap)?;
}
if matches!(node_linker, NodeLinker::Pnp) {
crate::write_pnp_file(sidecar_lockfile, workspace_root, config, layout, project_manifests)
.map_err(LinkPhaseError::WritePnpFile)?;
}
// `SymlinkDirectDependencies` already linked direct-dep bins, but
// hoisting runs after it. Re-walking each importer's `node_modules`
// is what shims a publicly-hoisted *workspace package*'s bin: those
// are recorded in the hoist result's `hoisted_workspace_aliases`,
// which the post-build top-level link never sees.
{
let modules_basename = config.modules_dir.file_name().map_or_else(
|| std::ffi::OsString::from("node_modules"),
std::ffi::OsStr::to_os_string,
);
for importer_id in trusted_importer_ids {
let modules_dir =
crate::symlink_direct_dependencies::importer_root_dir(symlink_root, importer_id)
.join(&modules_basename);
let bins_dir = modules_dir.join(".bin");
pacquet_cmd_shim::link_bins::<pacquet_cmd_shim::Host>(
&modules_dir,
&bins_dir,
extra_node_paths,
)
.map_err(LinkPhaseError::LinkBins)?;
}
}
Ok(LinkPhaseOutput {
hoisted_dependencies,
hoisted_locations,
hoisted_pkg_roots_by_key,
publicly_hoisted_for_post_build,
})
}
@@ -0,0 +1,217 @@
//! What an install is allowed to put on disk: the host its constraints
//! are checked against, the set of snapshots it must not materialize,
//! and the engine name its store slots are keyed by.
//!
//! Shared by both install paths. They agree on every step here — the
//! skip set decides what lands in `node_modules`, so the two paths
//! producing it differently would be a correctness bug rather than a
//! stylistic difference. The two places they legitimately differ are
//! parameters: [`SkipSetInputs::seed`] and
//! [`SkipSetInputs::exclude_optional`].
use crate::{
InstallabilityHost, SkippedSnapshots, add_direct_runtime_skips, compute_skipped_snapshots,
extend_skipped_with_dependency_closure,
install_frozen_lockfile::{find_runtime_node_major, parse_major_from_version},
};
use pacquet_lockfile::{Lockfile, PackageKey, PackageMetadata, ProjectSnapshot, SnapshotEntry};
use pacquet_modules_yaml::IncludedDependencies;
use pacquet_package_is_installable::{InstallabilityError, SupportedArchitectures};
use std::{
collections::{HashMap, HashSet},
path::Path,
};
/// Detect the host an install's `os` / `cpu` / `libc` / `engines`
/// constraints are checked against.
///
/// Whether detection is `needed` stays with the caller: the two install
/// paths qualify it differently, and getting it wrong is not obvious
/// from here. Passing `false` returns `None`, which is what keeps `node
/// --version` off the critical path of the common unconstrained install
/// — the probe would otherwise serialize against the extraction that
/// dominates a cold install.
pub async fn detect_installability_host(
needed: bool,
engine_strict: bool,
node_version: Option<String>,
supported_architectures: Option<&SupportedArchitectures>,
) -> Option<InstallabilityHost> {
if !needed {
return None;
}
let mut host = match node_version {
// An explicit `nodeVersion` needs no `node --version` probe, so
// build the host directly off the reactor thread.
node_version @ Some(_) => InstallabilityHost::detect_with(engine_strict, node_version),
None => tokio::task::spawn_blocking(move || {
InstallabilityHost::detect_with(engine_strict, None)
})
.await
.unwrap_or_else(|_| InstallabilityHost {
node_version: "99999.0.0".to_string(),
node_detected: false,
os: pacquet_graph_hasher::host_platform(),
cpu: pacquet_graph_hasher::host_arch(),
libc: pacquet_graph_hasher::host_libc(),
supported_architectures: None,
engine_strict,
}),
};
// Plant the CLI-merged `supportedArchitectures` (yaml +
// `--cpu`/`--os`/`--libc`) onto the host so `check_platform`'s
// `dedupe_current` substitution picks up user-supplied accept lists
// rather than only the host triple.
if let Some(supported) = supported_architectures {
host.supported_architectures = Some(supported.clone());
}
Some(host)
}
pub struct SkipSetInputs<'a> {
pub requester: &'a str,
/// Importers the installability pass evaluates against.
pub importers: &'a HashMap<String, ProjectSnapshot>,
/// The snapshots and metadata being evaluated. Under a filtered
/// install these are the materialization closure's, not the whole
/// lockfile's.
pub snapshots: Option<&'a HashMap<PackageKey, SnapshotEntry>>,
pub packages: Option<&'a HashMap<PackageKey, PackageMetadata>>,
/// `None` when installability checks are bypassed — see
/// [`detect_installability_host`].
pub installability_host: Option<&'a InstallabilityHost>,
/// Skips carried in from the previous install's
/// `.modules.yaml.skipped`, so a package already known to be
/// incompatible is not re-checked and does not re-emit
/// `pnpm:skipped-optional-dependency`.
///
/// The frozen path seeds this; the fresh path starts empty, because
/// it has just re-resolved the graph and cannot assume the previous
/// run's verdicts still apply.
pub seed: SkippedSnapshots,
/// Whether to drop every `optional: true` snapshot.
///
/// Both paths mean `--no-optional` by this, but they qualify it
/// differently: only a *full* install's dependency groups carry that
/// intent, so the fresh path additionally requires
/// `is_full_install`. Deciding it in the caller keeps that
/// distinction visible where the reason for it lives.
pub exclude_optional: bool,
pub skip_runtimes: bool,
/// The lockfile whose importers anchor the reachability closure —
/// the full one, even under a filtered install.
pub closure_lockfile: &'a Lockfile,
pub closure_root: &'a Path,
pub closure_importer_ids: &'a HashSet<String>,
pub included: IncludedDependencies,
}
/// Compute the snapshots this install must not materialize: the
/// installability skips, the `--no-optional` exclusions, the
/// `--no-runtime` direct-runtime skips, and the reachability closure
/// over all of them.
///
/// The closure runs last and is not optional: every downstream consumer
/// (`CreateVirtualStore`, the hoist pass, the symlink and bin passes)
/// reads the finished set, and a skip that is not closed over leaves
/// dangling links to a package that was never placed.
pub fn compute_skip_set<Reporter: pacquet_reporter::Reporter>(
inputs: SkipSetInputs<'_>,
) -> Result<SkippedSnapshots, Box<InstallabilityError>> {
let SkipSetInputs {
requester,
importers,
snapshots,
packages,
installability_host,
seed,
exclude_optional,
skip_runtimes,
closure_lockfile,
closure_root,
closure_importer_ids,
included,
} = inputs;
let mut skipped = match (snapshots, packages, installability_host) {
(Some(snapshots), Some(packages), Some(host)) => compute_skipped_snapshots::<Reporter>(
importers, snapshots, packages, host, requester, seed,
)?,
// Constraint-free lockfile: keep the seed verbatim, so a
// snapshot recorded as skipped previously survives the
// constraint having since been removed from the lockfile.
_ => seed,
};
// The lockfile's `optional` flag is set only when a snapshot is
// reachable *exclusively* through optional edges, so a dependency
// that is both optional and required still survives this filter.
// These land in the transient `optional_excluded` subset: excluded
// from materialization, but kept out of `.modules.yaml.skipped` so a
// later install without the flag brings them back.
if exclude_optional && let Some(snapshots) = snapshots {
for (key, snapshot) in snapshots {
if snapshot.optional {
skipped.add_optional_excluded(key.clone());
}
}
}
if skip_runtimes && let Some(packages) = packages {
add_direct_runtime_skips(&mut skipped, importers, packages);
}
extend_skipped_with_dependency_closure(
&mut skipped,
closure_lockfile,
closure_root,
closure_importer_ids,
included,
);
Ok(skipped)
}
/// Resolve the engine name that keys the install's store slots and the
/// side-effects-cache prefix.
///
/// A `node@runtime:` pin in the lockfile wins outright, so pinned and
/// non-pinned installs on the same host share one store rather than
/// splitting it under whatever `node --version` the shell reports. Then
/// the already-detected host, then a probe.
///
/// The probe comes back as a still-running
/// [`JoinHandle`][tokio::task::JoinHandle] so it overlaps the
/// virtual-store I/O — except under the global virtual store, whose
/// layout needs the name synchronously.
///
/// A synthetic host (`node_detected == false`) yields `None` rather than
/// a `99999.0.0`-derived key, which would poison both the cache and the
/// GVS hash.
pub async fn resolve_engine_name(
enable_global_virtual_store: bool,
snapshots: Option<&HashMap<PackageKey, SnapshotEntry>>,
host_node: Option<&(bool, String)>,
) -> (Option<String>, Option<tokio::task::JoinHandle<Option<String>>>) {
fn probe() -> Option<String> {
pacquet_graph_hasher::detect_node_major()
.map(|major| pacquet_graph_hasher::engine_name(major, None, None))
}
if let Some(major) = find_runtime_node_major(snapshots) {
return (Some(pacquet_graph_hasher::engine_name(major, None, None)), None);
}
match host_node {
Some((true, version)) => (
parse_major_from_version(version)
.map(|major| pacquet_graph_hasher::engine_name(major, None, None)),
None,
),
Some((false, _)) => (None, None),
None if enable_global_virtual_store => {
(tokio::task::spawn_blocking(probe).await.ok().flatten(), None)
}
None => (None, Some(tokio::task::spawn_blocking(probe))),
}
}
@@ -32,9 +32,7 @@ use std::{
};
use tokio::sync::watch;
mod linking;
mod manifest_transforms;
mod materialization_plan;
mod resolve;
mod resolver_setup;
@@ -354,6 +352,9 @@ pub enum InstallWithFreshLockfileError {
#[diagnostic(transparent)]
PruneStaleModules(#[error(source)] crate::PruneDirectDepsError),
#[diagnostic(transparent)]
LinkPhase(#[error(source)] pacquet_deps_restorer::linking::LinkPhaseError),
/// Surfaces a failure to cross-link a Bit root component's injected
/// members into one another's virtual-store slot. Only reachable
/// when an importer manifest declares
@@ -1243,25 +1244,33 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
});
let initial_materialization_lockfile =
initial_materialization.as_ref().map_or(&built_lockfile, |closure| &closure.lockfile);
let installability_host = materialization_plan::detect_installability_host(
config,
initial_materialization_lockfile,
node_version,
supported_architectures,
)
.await;
let needs_installability_check = !config.force
&& initial_materialization_lockfile.packages.as_ref().is_some_and(|packages| {
initial_materialization_lockfile.snapshots.as_ref().is_some_and(|snapshots| {
crate::any_installability_constraint(snapshots, packages)
})
});
let installability_host =
pacquet_deps_restorer::materialization_plan::detect_installability_host(
needs_installability_check,
config.engine_strict,
node_version,
supported_architectures,
)
.await;
// Detect the host node once and reuse it for both the engine-name
// cache key and installability check, mirroring the frozen path.
let host_node: Option<(bool, String)> = installability_host
.as_ref()
.map(|host| (host.node_detected, host.node_version.clone()));
let (engine_name, deferred_engine_handle) = materialization_plan::resolve_engine_name(
config,
initial_materialization_lockfile,
host_node.as_ref(),
)
.await;
let (engine_name, deferred_engine_handle) =
pacquet_deps_restorer::materialization_plan::resolve_engine_name(
config.enable_global_virtual_store,
initial_materialization_lockfile.snapshots.as_ref(),
host_node.as_ref(),
)
.await;
let layout_engine_name =
if config.enable_global_virtual_store { engine_name.as_deref() } else { None };
let phase_start = std::time::Instant::now();
@@ -1282,19 +1291,34 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
);
}
let mut skipped = materialization_plan::compute_skip_set::<Reporter>(
&materialization_plan::SkipSetInputs {
requester,
materialization_lockfile: initial_materialization_lockfile,
built_lockfile: &built_lockfile,
lockfile_dir,
installability_host: installability_host.as_ref(),
included,
dependency_groups: &dependency_groups,
is_full_install,
skip_runtimes,
},
)?;
let closure_importer_ids: std::collections::HashSet<String> =
built_lockfile.importers.keys().cloned().collect();
let mut skipped =
pacquet_deps_restorer::materialization_plan::compute_skip_set::<Reporter>(
pacquet_deps_restorer::materialization_plan::SkipSetInputs {
requester,
importers: &initial_materialization_lockfile.importers,
snapshots: initial_materialization_lockfile.snapshots.as_ref(),
packages: initial_materialization_lockfile.packages.as_ref(),
installability_host: installability_host.as_ref(),
// The fresh path has just re-resolved the graph, so the
// previous run's verdicts may no longer hold.
seed: SkippedSnapshots::new(),
// Only a full install's `dependency_groups` carries a
// `--no-optional` intent: a partial run either passes
// every direct group (`add`, `remove`, `update`) or
// narrows them for its own reasons (`fetch --dev`,
// `rebuild`) and must keep its transitive optionals.
exclude_optional: is_full_install
&& !dependency_groups.contains(&DependencyGroup::Optional),
skip_runtimes,
closure_lockfile: &built_lockfile,
closure_root: lockfile_dir,
closure_importer_ids: &closure_importer_ids,
included,
},
)
.map_err(InstallWithFreshLockfileError::Installability)?;
let final_materialization = initial_materialization_ids.as_ref().map(|importer_ids| {
crate::materialization_closure(
@@ -1395,36 +1419,64 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
// `modules_dir.parent()` rather than `lockfile_dir`.
let symlink_root: &Path = config.modules_dir.parent().unwrap_or(lockfile_dir);
let linking::LinkPhaseOutput {
let project_manifests_for_link: Vec<(std::path::PathBuf, &PackageManifest)> =
importer_manifests
.iter()
.filter(|(id, _)| project_anchor_importer_ids.contains(id.as_str()))
.map(|(id, manifest)| (lockfile_dir.join(id), *manifest))
.collect();
let package_map_project_manifests: Vec<(std::path::PathBuf, &PackageManifest)> =
importer_manifests
.iter()
.map(|(id, manifest)| (lockfile_dir.join(id), *manifest))
.collect();
let root_component_importers: std::collections::HashSet<String> = importer_manifests
.iter()
.filter(|(id, _)| project_anchor_importer_ids.contains(id.as_str()))
.filter(|(_, manifest)| {
manifest.install_config_hoisting_limits()
== Some(pacquet_deps_restorer::HOISTING_LIMITS_WORKSPACES)
})
.map(|(id, _)| id.clone())
.collect();
let pacquet_deps_restorer::linking::LinkPhaseOutput {
hoisted_dependencies,
hoisted_locations,
hoisted_pkg_roots_by_key,
publicly_hoisted_for_post_build,
} = linking::run_link_phase::<Reporter>(
linking::LinkPhaseInputs {
} = pacquet_deps_restorer::linking::run_link_phase::<Reporter>(
pacquet_deps_restorer::linking::LinkPhaseInputs {
symlink_root,
trusted_importer_ids: &project_anchor_importer_ids,
root_component_importers: &root_component_importers,
sidecar_lockfile: materialization_lockfile,
config,
layout: &layout,
materialization_lockfile,
lockfile: materialization_lockfile,
current_lockfile,
prior_hoisted_dependencies,
importer_manifests: &importer_manifests,
snapshots: materialization_lockfile.snapshots.as_ref(),
packages: materialization_lockfile.packages.as_ref(),
importers: &materialization_lockfile.importers,
project_manifests: &project_manifests_for_link,
package_map_project_manifests: &package_map_project_manifests,
dependency_groups: &dependency_groups,
project_anchor_importer_ids: &project_anchor_importer_ids,
materialization_importer_ids: &materialization_importer_ids,
lockfile_dir,
symlink_root,
package_manifests: &package_manifests,
cas_paths_by_pkg_id,
host_node: host_node.as_ref(),
supported_architectures,
extra_node_paths: &extra_node_paths,
logged_methods,
workspace_root: lockfile_dir,
requester,
node_linker,
is_hoisted,
prune_orphans,
prior_hoisted_dependencies,
host_node: host_node.as_ref(),
supported_architectures,
logged_methods,
},
&mut skipped,
)?;
)
.map_err(InstallWithFreshLockfileError::LinkPhase)?;
// `importing_done` fires once extraction and symlink linking
// are complete, before the build phase. Reporters use it to
@@ -1444,15 +1496,6 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
None => engine_name,
};
linking::write_module_resolution_sidecars(
config,
node_linker,
materialization_lockfile,
&layout,
&importer_manifests,
&project_anchor_importer_ids,
lockfile_dir,
)?;
let build_extra_env = build_extra_env(config, node_linker);
// `CreateVirtualStore` keeps skipped snapshots out of this map, so
@@ -1,468 +0,0 @@
//! The fresh install's link phase: reconcile what the previous install
//! left behind, then materialize the importer-visible tree — either the
//! hoisted `node_modules` hierarchy or the isolated symlink layout with
//! its hoist and bin passes.
//!
//! Runs once the virtual store is populated and before the build
//! phase, which needs both the linked tree and the hoisted package
//! roots this reports.
use super::InstallWithFreshLockfileError;
use crate::{
HoistedDependencies, LinkVirtualStoreBins, SkippedSnapshots, SymlinkDirectDependencies,
VirtualStoreLayout, link_root_component_members,
};
use pacquet_cmd_shim::{Host, link_bins};
use pacquet_config::Config;
use pacquet_lockfile::Lockfile;
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_reporter::{LogEvent, LogLevel, StatsLog, StatsMessage};
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::{Path, PathBuf},
};
pub(super) struct LinkPhaseInputs<'a> {
pub config: &'static Config,
pub layout: &'a VirtualStoreLayout,
/// The lockfile narrowed to what this install materializes.
pub materialization_lockfile: &'a Lockfile,
pub current_lockfile: Option<&'a Lockfile>,
pub prior_hoisted_dependencies: Option<&'a HoistedDependencies>,
pub importer_manifests: &'a BTreeMap<String, &'a PackageManifest>,
pub dependency_groups: &'a [DependencyGroup],
/// Importers whose own `node_modules` this install anchors links in.
pub project_anchor_importer_ids: &'a HashSet<String>,
/// Importers inside the materialization closure — the hoist plan's
/// workspace-package candidates are drawn from these.
pub materialization_importer_ids: &'a HashSet<String>,
pub lockfile_dir: &'a Path,
/// Anchor for every importer's `node_modules`. Derived from
/// `config.modules_dir.parent()` rather than `lockfile_dir` — see
/// [`run_link_phase`].
pub symlink_root: &'a Path,
pub package_manifests: &'a crate::PackageManifests,
pub cas_paths_by_pkg_id: Option<crate::CasPathsByPkgId>,
pub host_node: Option<&'a (bool, String)>,
pub supported_architectures: Option<&'a pacquet_package_is_installable::SupportedArchitectures>,
pub extra_node_paths: &'a [String],
pub logged_methods: &'a std::sync::atomic::AtomicU8,
pub requester: &'a str,
pub is_hoisted: bool,
/// See [`crate::PruneStaleModules::prune_orphans`].
pub prune_orphans: bool,
}
/// What the link phase hands to the build phase and the caller's
/// `.modules.yaml` writer.
pub(super) struct LinkPhaseOutput {
/// The publicly/privately-hoisted alias map under the isolated
/// linker; empty under `nodeLinker: hoisted`, which writes the
/// on-disk tree directly and reports through
/// [`Self::hoisted_locations`] instead.
pub hoisted_dependencies: HoistedDependencies,
pub hoisted_locations: BTreeMap<String, Vec<String>>,
/// Lets `BuildModules` `cd` into the hoisted on-disk dir. `None`
/// under the isolated linker.
pub hoisted_pkg_roots_by_key: Option<HashMap<pacquet_lockfile::PackageKey, Vec<PathBuf>>>,
/// The public-hoist alias list the post-build top-level bin link
/// consults so a direct dep's bin wins over a hoisted one.
pub publicly_hoisted_for_post_build: Vec<String>,
}
/// Reconcile the previous install's links, then materialize the tree.
///
/// **The link passes anchor on `config.modules_dir.parent()`, not
/// `lockfile_dir`.** `SymlinkDirectDependencies` resolves each importer's
/// modules dir as `<workspace_root>/<importer_id>/<modules_basename>`,
/// which for the root importer (`.`) collapses to
/// `<workspace_root>/<modules_basename>`. The fresh-lockfile path's tests
/// parameterise `config.modules_dir` at a path that doesn't always live
/// under the manifest's directory, so anchoring on the
/// lockfile-dir-derived `workspace_root` would land symlinks at the wrong
/// path on those configurations. Where `config.modules_dir ==
/// <lockfile_dir>/node_modules` the two coincide; for an
/// explicitly-relocated `modules_dir` the symlinks land where the rest of
/// pacquet's install code (`.modules.yaml`, `LinkVirtualStoreBins`, ...)
/// already writes.
///
/// `virtual_store_only` stops after the virtual store is populated:
/// neither linker arm runs, so nothing is hoisted and no importer
/// symlinks or bins are created — and nothing of its own needs
/// reconciling either.
pub(super) fn run_link_phase<Reporter: pacquet_reporter::Reporter>(
mut inputs: LinkPhaseInputs<'_>,
skipped: &mut SkippedSnapshots,
) -> Result<LinkPhaseOutput, InstallWithFreshLockfileError> {
let &LinkPhaseInputs {
config,
materialization_lockfile,
current_lockfile,
prior_hoisted_dependencies,
dependency_groups,
symlink_root,
requester,
is_hoisted,
prune_orphans,
..
} = &inputs;
// Nothing below this point runs under `virtual_store_only`: it
// creates no importer or hoist links, so it has neither anything to
// reconcile nor anything to link. Returning here rather than gating
// each pass keeps that a single decision.
if config.virtual_store_only {
return Ok(LinkPhaseOutput {
hoisted_dependencies: HoistedDependencies::new(),
hoisted_locations: BTreeMap::new(),
hoisted_pkg_roots_by_key: None,
publicly_hoisted_for_post_build: Vec::new(),
});
}
// Reconcile before linking: stale direct-dep links and orphaned
// hoist links must vacate their slots so the relink + rehoist below
// can claim them. The hoisted linker is excluded — its previous-graph
// diff removes orphans and emits the `pnpm:stats` `removed` event
// itself (see [`crate::link_hoisted_modules()`]); on the isolated
// linker the event fires here, so every linking install carries
// exactly one, pairing the `added` emitted in `CreateVirtualStore`.
if !is_hoisted {
let removed_count = match current_lockfile {
Some(current) => crate::PruneStaleModules {
config,
workspace_root: symlink_root,
wanted_lockfile: materialization_lockfile,
current_lockfile: current,
prior_hoisted_dependencies,
included_groups: dependency_groups,
prune_orphans,
}
.run::<Reporter>()
.map_err(InstallWithFreshLockfileError::PruneStaleModules)?,
None => 0,
};
Reporter::emit(&LogEvent::Stats(StatsLog {
level: LogLevel::Debug,
message: StatsMessage::Removed { prefix: requester.to_owned(), removed: removed_count },
}));
}
if is_hoisted {
let cas_paths_by_pkg_id = inputs.cas_paths_by_pkg_id.take();
return link_hoisted::<Reporter>(&inputs, cas_paths_by_pkg_id, skipped);
}
link_isolated::<Reporter>(&inputs, skipped)
}
/// Under `nodeLinker: hoisted` the regular deps live as real directories
/// materialized by the hoisted linker, not as symlinks into the virtual
/// store. Route through the same walker + linker + `link_only` symlink
/// pass the frozen path uses, then skip the isolated-linker
/// public/private hoist and `LinkVirtualStoreBins` passes entirely — the
/// hoisted linker writes per-`node_modules` bins while walking the
/// hierarchy.
fn link_hoisted<Reporter: pacquet_reporter::Reporter>(
inputs: &LinkPhaseInputs<'_>,
cas_paths_by_pkg_id: Option<crate::CasPathsByPkgId>,
skipped: &mut SkippedSnapshots,
) -> Result<LinkPhaseOutput, InstallWithFreshLockfileError> {
let project_manifests = inputs
.importer_manifests
.iter()
.filter(|(id, _)| inputs.project_anchor_importer_ids.contains(id.as_str()))
.map(|(id, manifest)| (inputs.lockfile_dir.join(id), *manifest))
.collect::<Vec<_>>();
let package_map_project_manifests = inputs
.importer_manifests
.iter()
.map(|(id, manifest)| (inputs.lockfile_dir.join(id), *manifest))
.collect::<Vec<_>>();
// Reuse the host probed once for the engine-name key, so a hoisted
// install with installability constraints spawns `node --version`
// only once. `None` when nothing constrained it (the engine check
// then resolves against the host inside `run_hoisted_linker`,
// matching the frozen path).
let output = crate::install_frozen_lockfile::run_hoisted_linker::<Reporter>(
crate::install_frozen_lockfile::HoistedLinkerInputs {
config: inputs.config,
lockfile: inputs.materialization_lockfile,
current_lockfile: inputs.current_lockfile,
layout: inputs.layout,
importers: &inputs.materialization_lockfile.importers,
dependency_groups: inputs.dependency_groups,
project_manifests: &project_manifests,
package_map_project_manifests: &package_map_project_manifests,
walker_lockfile_dir: inputs.lockfile_dir,
symlink_workspace_root: inputs.symlink_root,
host_node: inputs.host_node,
supported_architectures: inputs.supported_architectures,
cas_paths_by_pkg_id,
logged_methods: inputs.logged_methods,
requester: inputs.requester,
},
skipped,
)
.map_err(InstallWithFreshLockfileError::from)?;
Ok(LinkPhaseOutput {
// The hoisted linker has no isolated-mode alias→kind adapter
// shape, so it reports its placements through
// `hoisted_locations` instead.
hoisted_dependencies: HoistedDependencies::new(),
hoisted_locations: output.hoisted_locations,
hoisted_pkg_roots_by_key: output.hoisted_pkg_roots_by_key,
publicly_hoisted_for_post_build: Vec::new(),
})
}
/// The isolated linker: importer symlinks into the virtual store, the
/// public/private hoist, and the two bin passes.
fn link_isolated<Reporter: pacquet_reporter::Reporter>(
inputs: &LinkPhaseInputs<'_>,
skipped: &SkippedSnapshots,
) -> Result<LinkPhaseOutput, InstallWithFreshLockfileError> {
let &LinkPhaseInputs {
config,
layout,
materialization_lockfile,
importer_manifests,
dependency_groups,
project_anchor_importer_ids,
materialization_importer_ids,
lockfile_dir,
symlink_root,
package_manifests,
extra_node_paths,
..
} = inputs;
// Pre-compute the hoist plan so the dedupe pass in
// `SymlinkDirectDependencies` can fold publicly-hoisted aliases into
// root's target map — same shape as the frozen-lockfile path. The
// `HoistResult` is reused for the on-disk hoist phase below, so the
// traversal runs once. Under `hoist-workspace-packages`, named
// non-root projects become hoist candidates whose links point at the
// project dirs (`importer_manifests` is keyed by importer id).
let hoisted_workspace_packages = config.hoist_workspace_packages.then(|| {
importer_manifests
.iter()
.filter(|(id, _)| materialization_importer_ids.contains(id.as_str()))
.filter(|(id, _)| id.as_str() != ".")
.filter_map(|(id, manifest)| {
let name = manifest.value().get("name")?.as_str()?;
Some((
name.to_string(),
crate::symlink_direct_dependencies::importer_root_dir(lockfile_dir, id),
))
})
.collect::<indexmap::IndexMap<_, _>>()
});
let pre_hoist = crate::install_frozen_lockfile::compute_hoist_plan(
config,
materialization_lockfile.snapshots.as_ref(),
materialization_lockfile.packages.as_ref(),
&materialization_lockfile.importers,
dependency_groups,
skipped,
false,
hoisted_workspace_packages.as_ref(),
);
let public_hoist_targets: Option<BTreeMap<String, PathBuf>> = pre_hoist.as_ref().map(|plan| {
crate::install_frozen_lockfile::collect_public_hoist_targets(
&plan.result,
&plan.graph,
layout,
&plan.skipped,
)
});
// Importer ids backed by the install's own declared projects
// (`importer_manifests` is keyed by importer id). These may
// legitimately live outside the lockfile dir (Bit's capsule installs
// pass such projects), so they bypass the malformed-lockfile
// importer-key rejection.
SymlinkDirectDependencies {
config,
layout,
importers: &materialization_lockfile.importers,
packages: materialization_lockfile.packages.as_ref(),
dependency_groups: dependency_groups.iter().copied(),
workspace_root: symlink_root,
skipped,
link_only: false,
public_hoist_targets: public_hoist_targets.as_ref(),
trusted_importer_ids: Some(project_anchor_importer_ids),
extra_node_paths,
}
.run::<Reporter>()
.map_err(InstallWithFreshLockfileError::SymlinkDirectDependencies)?;
// Bit "root components": make each root's injected members mutually
// reachable. Gated on `installConfig.hoistingLimits: "workspaces"`,
// so it is a no-op for every non-Bit install. See
// [`link_root_component_members`].
let root_component_importers: HashSet<String> = importer_manifests
.iter()
.filter(|(id, _)| project_anchor_importer_ids.contains(id.as_str()))
.filter(|(_, manifest)| {
manifest.install_config_hoisting_limits() == Some(crate::HOISTING_LIMITS_WORKSPACES)
})
.map(|(id, _)| id.clone())
.collect();
link_root_component_members(
layout,
&materialization_lockfile.importers,
&root_component_importers,
dependency_groups,
skipped,
)
.map_err(InstallWithFreshLockfileError::LinkRootComponentMembers)?;
// On-disk hoist phase. Mirrors the frozen-install block in
// `install_frozen_lockfile.rs`: symlink the publicly + privately
// hoisted aliases into their target dirs, then link private-side bins
// into `<vs>/node_modules/.bin`. Public-side bin precedence is
// handled implicitly by the per-importer `link_bins` pass below,
// which walks both direct-dep and public-hoist symlinks in root's
// `node_modules/`.
let mut publicly_hoisted_for_post_build = Vec::new();
let hoisted_dependencies = if let Some(plan) = pre_hoist {
let crate::install_frozen_lockfile::HoistPlan {
graph, result, skipped: hoist_skipped, ..
} = plan;
let private_dir = config.virtual_store_dir.join("node_modules");
let public_dir = config.modules_dir.clone();
crate::symlink_hoisted_dependencies(
&result.hoisted_dependencies_by_node_id,
&result.hoisted_workspace_aliases,
&graph,
layout,
&private_dir,
&public_dir,
&hoist_skipped,
)
.map_err(InstallWithFreshLockfileError::HoistSymlink)?;
crate::link_direct_dep_bins_resolved(
&private_dir,
&crate::resolve_hoisted_bin_deps(layout, &result.hoisted_aliases_with_bins),
extra_node_paths,
)
.map_err(InstallWithFreshLockfileError::HoistLinkBins)?;
// Stash the public-hoist alias list so the post-build top-level
// bin link resolves direct-over-hoisted precedence
// (pnpm/pacquet#342) instead of leaving it to the per-importer
// `link_bins` walk below.
publicly_hoisted_for_post_build = result.publicly_hoisted_aliases_with_bins;
result.hoisted_dependencies
} else {
HoistedDependencies::new()
};
// Link bins. Direct dependencies first (each importer's
// `node_modules/.bin`) and then per-slot children inside the virtual
// store, using the same two-call shape as
// `install_frozen_lockfile.rs`. One pass per importer so sibling
// workspace projects get their own `.bin/` populated.
let modules_basename = config
.modules_dir
.file_name()
.map_or_else(|| std::ffi::OsString::from("node_modules"), std::ffi::OsStr::to_os_string);
for importer_id in project_anchor_importer_ids {
let project_dir =
crate::symlink_direct_dependencies::importer_root_dir(symlink_root, importer_id);
let modules_dir = project_dir.join(&modules_basename);
let bins_dir = modules_dir.join(".bin");
link_bins::<Host>(&modules_dir, &bins_dir, extra_node_paths)
.map_err(InstallWithFreshLockfileError::LinkBins)?;
}
// Drive the lockfile-driven `LinkVirtualStoreBins` path: the bin
// linker iterates `snapshots:` (no per-slot `read_dir`) and reads
// each child's manifest from `package_manifests` (no per-child
// `package.json` disk read on warm hits).
//
// The freshly-built `packages:` rows carry the same `hasBin` the
// on-disk lockfile gets (the resolver's picked manifest keeps `bin`
// through its flatten catch-all, and `dependencies_graph_to_lockfile`
// reads it off), so the bin linker's `has_bin_set` short-circuit —
// the one the frozen path trusts on the very same rows after a
// save/load round-trip — is just as sound here.
LinkVirtualStoreBins {
layout,
snapshots: materialization_lockfile.snapshots.as_ref(),
packages: materialization_lockfile.packages.as_ref(),
package_manifests,
skipped,
extra_node_paths,
}
.run()
.map_err(InstallWithFreshLockfileError::LinkVirtualStoreBins)?;
Ok(LinkPhaseOutput {
hoisted_dependencies,
hoisted_locations: BTreeMap::new(),
hoisted_pkg_roots_by_key: None,
publicly_hoisted_for_post_build,
})
}
/// Write the module-resolution sidecars the lifecycle scripts read:
/// `node_modules/.package-map.json` and, under `nodeLinker: pnp`, the
/// `PnP` loader.
///
/// Both run before the build phase, since [`super::build_extra_env`] points
/// lifecycle scripts' `NODE_OPTIONS` at the package map. `layout`
/// already resolves each snapshot to its real on-disk slot (flat or
/// global-virtual-store). Reached only after materialization, mirroring
/// the frozen path's write in `InstallFrozenLockfile::run`, so the
/// `lockfile_only` early return never writes a map for an unlinked tree.
pub(super) fn write_module_resolution_sidecars(
config: &Config,
node_linker: pacquet_config::NodeLinker,
materialization_lockfile: &Lockfile,
layout: &VirtualStoreLayout,
importer_manifests: &BTreeMap<String, &PackageManifest>,
project_anchor_importer_ids: &HashSet<String>,
lockfile_dir: &Path,
) -> Result<(), InstallWithFreshLockfileError> {
let write_package_map = crate::should_write_package_map(config, node_linker);
// `.pnp.cjs` is how a PnP project resolves, which makes it a
// project-level artifact like the importer links and the package
// map. `virtual_store_only` — how `pnpm fetch` warms a store without
// touching the project — must not write it.
let write_pnp =
matches!(node_linker, pacquet_config::NodeLinker::Pnp) && !config.virtual_store_only;
if !write_package_map && !write_pnp {
return Ok(());
}
let project_manifests = importer_manifests
.iter()
.filter(|(id, _)| project_anchor_importer_ids.contains(id.as_str()))
.map(|(id, manifest)| (lockfile_dir.join(id), *manifest))
.collect::<Vec<_>>();
if write_package_map {
crate::package_map::write_package_map(
materialization_lockfile,
&crate::package_map::PackageMapOptions {
lockfile_dir,
modules_dir: &config.modules_dir,
package_map_type: config.node_package_map_type,
layout,
project_manifests: &project_manifests,
},
)
.map_err(InstallWithFreshLockfileError::WritePackageMap)?;
}
if write_pnp {
crate::write_pnp_file(
materialization_lockfile,
lockfile_dir,
config,
layout,
&project_manifests,
)
.map_err(InstallWithFreshLockfileError::WritePnpFile)?;
}
Ok(())
}
@@ -1,216 +0,0 @@
//! What the fresh install is allowed to put on disk: the host it is
//! installing for, the engine name that keys its store slots, and the
//! set of snapshots it must skip.
//!
//! Reads the freshly-built lockfile and runs before
//! `CreateVirtualStore`, which materializes only what the skip set
//! leaves behind and addresses its slots by the engine name decided
//! here.
use super::InstallWithFreshLockfileError;
use crate::SkippedSnapshots;
use pacquet_config::Config;
use pacquet_lockfile::Lockfile;
use pacquet_modules_yaml::IncludedDependencies;
use pacquet_package_is_installable::SupportedArchitectures;
use pacquet_package_manifest::DependencyGroup;
use std::path::Path;
/// Detect the host the install's `os` / `cpu` / `libc` / `engines`
/// constraints are checked against.
///
/// `None` when nothing in the lockfile constrains installability, or
/// when `--force` bypasses the checks outright (see [`Config::force`]):
/// no skip set is computed and the hoisted walker emits every dep, so no
/// host detection is needed either.
pub(super) async fn detect_installability_host(
config: &Config,
lockfile: &Lockfile,
node_version: Option<String>,
supported_architectures: Option<&SupportedArchitectures>,
) -> Option<crate::InstallabilityHost> {
let constrained = lockfile.packages.as_ref().is_some_and(|packages| {
lockfile
.snapshots
.as_ref()
.is_some_and(|snapshots| crate::any_installability_constraint(snapshots, packages))
});
if config.force || !constrained {
return None;
}
let engine_strict = config.engine_strict;
let mut host = match node_version {
// An explicit `nodeVersion` needs no `node --version` probe, so
// build the host directly off the reactor thread.
node_version @ Some(_) => {
crate::InstallabilityHost::detect_with(engine_strict, node_version)
}
None => tokio::task::spawn_blocking(move || {
crate::InstallabilityHost::detect_with(engine_strict, None)
})
.await
.ok()
.unwrap_or_else(|| crate::InstallabilityHost {
node_version: "99999.0.0".to_string(),
node_detected: false,
os: pacquet_graph_hasher::host_platform(),
cpu: pacquet_graph_hasher::host_arch(),
libc: pacquet_graph_hasher::host_libc(),
supported_architectures: None,
engine_strict,
}),
};
if let Some(supported) = supported_architectures {
host.supported_architectures = Some(supported.clone());
}
Some(host)
}
/// Resolve the engine name that keys the install's store slots.
///
/// Priority mirrors the frozen path: a `node@runtime:` pin in the
/// lockfile wins outright (so pinned and non-pinned installs on the same
/// host share the store), then the already-detected host, then a `node
/// --version` probe. The probe is returned as a still-running
/// [`JoinHandle`][tokio::task::JoinHandle] so it overlaps
/// `CreateVirtualStore`'s I/O — except under the global virtual store,
/// whose layout needs the name synchronously.
pub(super) async fn resolve_engine_name(
config: &Config,
lockfile: &Lockfile,
host_node: Option<&(bool, String)>,
) -> (Option<String>, Option<tokio::task::JoinHandle<Option<String>>>) {
fn probe() -> Option<String> {
pacquet_graph_hasher::detect_node_major()
.map(|major| pacquet_graph_hasher::engine_name(major, None, None))
}
if let Some(major) =
crate::install_frozen_lockfile::find_runtime_node_major(lockfile.snapshots.as_ref())
{
return (Some(pacquet_graph_hasher::engine_name(major, None, None)), None);
}
match host_node {
Some((true, version)) => (
crate::install_frozen_lockfile::parse_major_from_version(version)
.map(|major| pacquet_graph_hasher::engine_name(major, None, None)),
None,
),
Some((false, _)) => (None, None),
None if config.enable_global_virtual_store => {
(tokio::task::spawn_blocking(probe).await.ok().flatten(), None)
}
None => (None, Some(tokio::task::spawn_blocking(probe))),
}
}
pub(super) struct SkipSetInputs<'a> {
pub requester: &'a str,
/// The lockfile the installability pass evaluates: the
/// materialization closure under a filtered install, the full built
/// lockfile otherwise.
pub materialization_lockfile: &'a Lockfile,
/// The full built lockfile, whose importers anchor the reachability
/// closure.
pub built_lockfile: &'a Lockfile,
pub lockfile_dir: &'a Path,
/// `None` when installability checks are bypassed — see
/// [`detect_installability_host`].
pub installability_host: Option<&'a crate::InstallabilityHost>,
pub included: IncludedDependencies,
pub dependency_groups: &'a [DependencyGroup],
/// See [`super::InstallWithFreshLockfile::is_full_install`].
pub is_full_install: bool,
/// See [`super::InstallWithFreshLockfile::skip_runtimes`].
pub skip_runtimes: bool,
}
/// Compute the snapshots this install must not materialize: the
/// installability skips, the `--no-optional` exclusions, the
/// `--no-runtime` direct-runtime skips, and the reachability closure over
/// all of them.
pub(super) fn compute_skip_set<Reporter: pacquet_reporter::Reporter>(
inputs: &SkipSetInputs<'_>,
) -> Result<SkippedSnapshots, InstallWithFreshLockfileError> {
let &SkipSetInputs {
requester,
materialization_lockfile,
built_lockfile,
lockfile_dir,
installability_host,
included,
dependency_groups,
is_full_install,
skip_runtimes,
} = inputs;
let mut skipped = match (
materialization_lockfile.snapshots.as_ref(),
materialization_lockfile.packages.as_ref(),
installability_host,
) {
(Some(snapshots), Some(packages), Some(host)) => {
crate::compute_skipped_snapshots::<Reporter>(
&materialization_lockfile.importers,
snapshots,
packages,
host,
requester,
SkippedSnapshots::new(),
)
.map_err(InstallWithFreshLockfileError::Installability)?
}
_ => SkippedSnapshots::new(),
};
// `--no-optional` excludes the Optional group. `dependency_groups`
// already drops the root importer's own optional direct deps, but a
// transitive optional (an `optionalDependencies` entry of a resolved
// package) is still in the graph and would be materialized. Exclude
// every optional-only snapshot the same way the frozen-lockfile path
// does — via the transient `optional_excluded` skip set, which keeps
// it out of materialization and `.modules.yaml.skipped` yet leaves it
// in the lockfile, so a later install without the flag restores it.
//
// Gated on `is_full_install`: only a full install's
// `dependency_groups` carries a `--no-optional` intent. Partial runs
// either pass every direct group (`add`, `remove`, `update`) or
// narrow the groups for reasons of their own (`fetch --dev`,
// `rebuild`), and must keep their transitive optionals (e.g.
// `@pnpm/exe`'s platform binary on the engine install).
if is_full_install
&& !dependency_groups.contains(&DependencyGroup::Optional)
&& let Some(snapshots) = materialization_lockfile.snapshots.as_ref()
{
for (key, snapshot) in snapshots {
if snapshot.optional {
skipped.add_optional_excluded(key.clone());
}
}
}
if skip_runtimes && let Some(packages) = materialization_lockfile.packages.as_ref() {
crate::add_direct_runtime_skips(
&mut skipped,
&materialization_lockfile.importers,
packages,
);
}
// The recorded skip set must be the reachability closure of the
// direct skips (see [`crate::extend_skipped_with_dependency_closure`]);
// extend it before the materialization closure, hoist, symlink, and
// bin passes consume it.
let importer_ids: std::collections::HashSet<String> =
built_lockfile.importers.keys().cloned().collect();
crate::extend_skipped_with_dependency_closure(
&mut skipped,
built_lockfile,
lockfile_dir,
&importer_ids,
included,
);
Ok(skipped)
}