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:
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.
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user