From df3a09a05be9ee3fbaa595edf96d5a3ffdc1732c Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 4 Aug 2026 21:09:33 +0200 Subject: [PATCH] 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. --- .changeset/headless-hoisted-workspace-bins.md | 5 + ...e-modules-entry-no-longer-fails-install.md | 5 + pnpm/crates/cli/tests/hoist.rs | 121 ++++ pnpm/crates/cli/tests/hoisted_node_linker.rs | 48 ++ pnpm/crates/cmd-shim/src/link_bins.rs | 21 +- .../src/install_frozen_lockfile.rs | 611 ++++-------------- pnpm/crates/deps-restorer/src/lib.rs | 2 + pnpm/crates/deps-restorer/src/linking.rs | 501 ++++++++++++++ .../deps-restorer/src/materialization_plan.rs | 217 +++++++ .../src/install_with_fresh_lockfile.rs | 145 +++-- .../install_with_fresh_lockfile/linking.rs | 468 -------------- .../materialization_plan.rs | 216 ------- 12 files changed, 1137 insertions(+), 1223 deletions(-) create mode 100644 .changeset/headless-hoisted-workspace-bins.md create mode 100644 .changeset/stray-node-modules-entry-no-longer-fails-install.md create mode 100644 pnpm/crates/deps-restorer/src/linking.rs create mode 100644 pnpm/crates/deps-restorer/src/materialization_plan.rs delete mode 100644 pnpm/crates/package-manager/src/install_with_fresh_lockfile/linking.rs delete mode 100644 pnpm/crates/package-manager/src/install_with_fresh_lockfile/materialization_plan.rs diff --git a/.changeset/headless-hoisted-workspace-bins.md b/.changeset/headless-hoisted-workspace-bins.md new file mode 100644 index 0000000000..004f21c9e3 --- /dev/null +++ b/.changeset/headless-hoisted-workspace-bins.md @@ -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. diff --git a/.changeset/stray-node-modules-entry-no-longer-fails-install.md b/.changeset/stray-node-modules-entry-no-longer-fails-install.md new file mode 100644 index 0000000000..d389fa2d50 --- /dev/null +++ b/.changeset/stray-node-modules-entry-no-longer-fails-install.md @@ -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. diff --git a/pnpm/crates/cli/tests/hoist.rs b/pnpm/crates/cli/tests/hoist.rs index 0ed6d450f0..3e48e9e502 100644 --- a/pnpm/crates/cli/tests/hoist.rs +++ b/pnpm/crates/cli/tests/hoist.rs @@ -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)); +} diff --git a/pnpm/crates/cli/tests/hoisted_node_linker.rs b/pnpm/crates/cli/tests/hoisted_node_linker.rs index 2f453702c3..d7478a5b96 100644 --- a/pnpm/crates/cli/tests/hoisted_node_linker.rs +++ b/pnpm/crates/cli/tests/hoisted_node_linker.rs @@ -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 `/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)); +} diff --git a/pnpm/crates/cmd-shim/src/link_bins.rs b/pnpm/crates/cmd-shim/src/link_bins.rs index b4cea88eba..4b79c470f5 100644 --- a/pnpm/crates/cmd-shim/src/link_bins.rs +++ b/pnpm/crates/cmd-shim/src/link_bins.rs @@ -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( 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) diff --git a/pnpm/crates/deps-restorer/src/install_frozen_lockfile.rs b/pnpm/crates/deps-restorer/src/install_frozen_lockfile.rs index 05e3f40d8d..756f80b63e 100644 --- a/pnpm/crates/deps-restorer/src/install_frozen_lockfile.rs +++ b/pnpm/crates/deps-restorer/src/install_frozen_lockfile.rs @@ -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>`). - if let Some(supp) = supported_architectures { - host.supported_architectures = Some(supp.clone()); - } - let skipped = compute_skipped_snapshots::( - 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 = - 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 = + importers.keys().cloned().collect(); + let mut skipped = crate::materialization_plan::compute_skip_set::( + 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, - Option>>, - ) = 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> = - 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::() - .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 = 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::() - .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 = 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 `/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::( - 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 `/node_modules` - // (private hoist) and/or `/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 `/node_modules/` - // where it competes for the same `/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 = 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 - // `/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 → `/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 = 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 = 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::( + 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 diff --git a/pnpm/crates/deps-restorer/src/lib.rs b/pnpm/crates/deps-restorer/src/lib.rs index 740de91195..00dbba0542 100644 --- a/pnpm/crates/deps-restorer/src/lib.rs +++ b/pnpm/crates/deps-restorer/src/lib.rs @@ -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; diff --git a/pnpm/crates/deps-restorer/src/linking.rs b/pnpm/crates/deps-restorer/src/linking.rs new file mode 100644 index 0000000000..ffe298530c --- /dev/null +++ b/pnpm/crates/deps-restorer/src/linking.rs @@ -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 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>, + pub packages: Option<&'a HashMap>, + pub importers: &'a HashMap, + 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, + 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, + /// Importers declaring `installConfig.hoistingLimits: "workspaces"`. + pub root_component_importers: &'a std::collections::HashSet, + /// 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>, + pub hoisted_pkg_roots_by_key: Option>>, + pub publicly_hoisted_for_post_build: Vec, +} + +/// 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( + inputs: LinkPhaseInputs<'_>, + skipped: &mut SkippedSnapshots, +) -> Result { + 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> = 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::() + .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::() + .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 `/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::( + 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 `/node_modules` + // (private hoist) and/or `/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 `/node_modules/` + // where it competes for the same `/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 = 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 + // `/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 → `/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::( + &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, + }) +} diff --git a/pnpm/crates/deps-restorer/src/materialization_plan.rs b/pnpm/crates/deps-restorer/src/materialization_plan.rs new file mode 100644 index 0000000000..0c2e52a9c6 --- /dev/null +++ b/pnpm/crates/deps-restorer/src/materialization_plan.rs @@ -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, + supported_architectures: Option<&SupportedArchitectures>, +) -> Option { + 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, + /// 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>, + pub packages: Option<&'a HashMap>, + /// `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, + 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( + inputs: SkipSetInputs<'_>, +) -> Result> { + 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::( + 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>, + host_node: Option<&(bool, String)>, +) -> (Option, Option>>) { + fn probe() -> Option { + 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))), + } +} diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs index f754af07b1..404cb59766 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -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 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 InstallWithFreshLockfile<'_, DependencyGroupList> { ); } - let mut skipped = materialization_plan::compute_skip_set::( - &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 = + built_lockfile.importers.keys().cloned().collect(); + let mut skipped = + pacquet_deps_restorer::materialization_plan::compute_skip_set::( + 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 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 = 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::( - linking::LinkPhaseInputs { + } = pacquet_deps_restorer::linking::run_link_phase::( + 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 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 diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/linking.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/linking.rs deleted file mode 100644 index ba0dd09bc2..0000000000 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/linking.rs +++ /dev/null @@ -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, - pub dependency_groups: &'a [DependencyGroup], - /// Importers whose own `node_modules` this install anchors links in. - pub project_anchor_importer_ids: &'a HashSet, - /// Importers inside the materialization closure — the hoist plan's - /// workspace-package candidates are drawn from these. - pub materialization_importer_ids: &'a HashSet, - 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, - 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>, - /// Lets `BuildModules` `cd` into the hoisted on-disk dir. `None` - /// under the isolated linker. - pub hoisted_pkg_roots_by_key: Option>>, - /// 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, -} - -/// 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 `//`, -/// which for the root importer (`.`) collapses to -/// `/`. 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 == -/// /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( - mut inputs: LinkPhaseInputs<'_>, - skipped: &mut SkippedSnapshots, -) -> Result { - 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::() - .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::(&inputs, cas_paths_by_pkg_id, skipped); - } - link_isolated::(&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( - inputs: &LinkPhaseInputs<'_>, - cas_paths_by_pkg_id: Option, - skipped: &mut SkippedSnapshots, -) -> Result { - 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::>(); - let package_map_project_manifests = inputs - .importer_manifests - .iter() - .map(|(id, manifest)| (inputs.lockfile_dir.join(id), *manifest)) - .collect::>(); - // 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::( - 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( - inputs: &LinkPhaseInputs<'_>, - skipped: &SkippedSnapshots, -) -> Result { - 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::>() - }); - 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> = 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::() - .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 = 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 `/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::(&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, - project_anchor_importer_ids: &HashSet, - 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::>(); - 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(()) -} diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/materialization_plan.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/materialization_plan.rs deleted file mode 100644 index 07ad0e3e81..0000000000 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/materialization_plan.rs +++ /dev/null @@ -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, - supported_architectures: Option<&SupportedArchitectures>, -) -> Option { - 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, Option>>) { - fn probe() -> Option { - 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( - inputs: &SkipSetInputs<'_>, -) -> Result { - 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::( - &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 = - built_lockfile.importers.keys().cloned().collect(); - crate::extend_skipped_with_dependency_closure( - &mut skipped, - built_lockfile, - lockfile_dir, - &importer_ids, - included, - ); - - Ok(skipped) -}