From f31c7ca00a695db19d71229620eb3c673b01af6a Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 6 Aug 2026 12:40:27 +0200 Subject: [PATCH] perf: prune newly ignored optional dependencies without resolution (#13482) Changing ignoredOptionalDependencies previously forced dependency resolution even when the change only added ignore patterns. Detect monotonic additions, remove matching optional importer edges, and prune package snapshots that are no longer reachable. Preserve packages that remain reachable through another dependency path. Use the same optimization in the TypeScript CLI and pacquet. Keep removals and new exclusion patterns on the safe resolution path, and prevent pacquet from using a pruned lockfile as the seed when ignored-optional settings differ. Make current-version package-manager pin tests use a static local registry so release commits can be tested before their version is published. Related to pnpm/pnpm#13474. --- .changeset/quick-oranges-ignore-optionals.md | 9 ++ pnpm-lock.yaml | 3 + .../cli/tests/lockfile_resolution_reuse.rs | 130 ++++++++++++++++ .../crates/cli/tests/package_manager_check.rs | 10 +- pnpm/crates/cli/tests/version.rs | 2 +- ...st_update_ignored_optional_dependencies.rs | 92 +++++++++++ .../tests.rs | 143 ++++++++++++++++++ .../src/fast_update_lockfile.rs | 58 +++++++ .../src/fast_update_overrides.rs | 60 +------- pnpm/crates/package-manager/src/install.rs | 2 +- .../src/install/lockfile_freshness.rs | 20 ++- .../crates/package-manager/src/install/run.rs | 29 ++-- .../src/install_with_fresh_lockfile.rs | 8 +- .../install_with_fresh_lockfile/resolve.rs | 4 + pnpm/crates/package-manager/src/lib.rs | 2 + pnpm/crates/testing-utils/src/bin.rs | 27 +++- pnpm/crates/testing-utils/src/registry.rs | 24 ++- pnpm11/deps/status/src/checkDepsStatus.ts | 1 + .../deps-installer/src/install/index.ts | 11 ++ ...ryFastUpdateIgnoredOptionalDependencies.ts | 62 ++++++++ .../install/ignoredOptionalDependencies.ts | 127 +++++++++++++++- pnpm11/lockfile/verification/package.json | 1 + .../src/allProjectsAreUpToDate.ts | 2 + .../src/satisfiesPackageManifest.ts | 28 +++- .../test/satisfiesPackageManifest.ts | 27 ++++ pnpm11/lockfile/verification/tsconfig.json | 3 + .../packages/@pnpm/exe/0.0.0/package.json | 4 + .../packages/pnpm/0.0.0/package.json | 4 + 28 files changed, 797 insertions(+), 96 deletions(-) create mode 100644 .changeset/quick-oranges-ignore-optionals.md create mode 100644 pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies.rs create mode 100644 pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies/tests.rs create mode 100644 pnpm/crates/package-manager/src/fast_update_lockfile.rs create mode 100644 pnpm11/installing/deps-installer/src/install/tryFastUpdateIgnoredOptionalDependencies.ts create mode 100644 pnpr/.fixtures/packages/@pnpm/exe/0.0.0/package.json create mode 100644 pnpr/.fixtures/packages/pnpm/0.0.0/package.json diff --git a/.changeset/quick-oranges-ignore-optionals.md b/.changeset/quick-oranges-ignore-optionals.md new file mode 100644 index 0000000000..1b82ee7130 --- /dev/null +++ b/.changeset/quick-oranges-ignore-optionals.md @@ -0,0 +1,9 @@ +--- +"@pnpm/deps.status": patch +"@pnpm/installing.deps-installer": patch +"@pnpm/lockfile.verification": patch +"pnpm": patch +"pacquet": patch +--- + +Speed up installs after adding `ignoredOptionalDependencies` patterns by removing newly ignored optional dependencies and pruning packages that are no longer reachable without resolving the dependency graph again. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2177f97a4..47851109a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7141,6 +7141,9 @@ importers: '@pnpm/catalogs.types': specifier: workspace:* version: link:../../catalogs/types + '@pnpm/config.matcher': + specifier: workspace:* + version: link:../../config/matcher '@pnpm/crypto.hash': specifier: workspace:* version: link:../../crypto/hash diff --git a/pnpm/crates/cli/tests/lockfile_resolution_reuse.rs b/pnpm/crates/cli/tests/lockfile_resolution_reuse.rs index dccf5cc8a1..43c7ff58b4 100644 --- a/pnpm/crates/cli/tests/lockfile_resolution_reuse.rs +++ b/pnpm/crates/cli/tests/lockfile_resolution_reuse.rs @@ -308,6 +308,136 @@ fn dependency_removal_override_prunes_the_locked_subtree_without_resolving() { drop((root, mock_instance)); } +#[test] +fn adding_and_removing_an_ignored_optional_dependency_uses_the_safe_path() { + let CommandTempCwd { workspace, root, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let AddMockedRegistry { mock_instance, npmrc_path, .. } = npmrc_info; + let manifest_path = workspace.join("package.json"); + let workspace_yaml_path = workspace.join("pnpm-workspace.yaml"); + fs::write( + &manifest_path, + serde_json::json!({ + "dependencies": { + "@pnpm.e2e/pkg-with-good-optional": "1.0.0" + } + }) + .to_string(), + ) + .expect("write package.json"); + let workspace_yaml = + fs::read_to_string(&workspace_yaml_path).expect("read pnpm-workspace.yaml"); + fs::write(&workspace_yaml_path, format!("{workspace_yaml}trustLockfile: true\n")) + .expect("enable trusted lockfile"); + pacquet_at(&workspace).with_arg("install").assert().success(); + + let workspace_yaml = + fs::read_to_string(&workspace_yaml_path).expect("read pnpm-workspace.yaml"); + fs::write( + &workspace_yaml_path, + format!("{workspace_yaml}ignoredOptionalDependencies:\n - is-positive\n"), + ) + .expect("add ignored optional dependency"); + let dead_registry = dead_registry_url(); + let live_npmrc = fs::read_to_string(&npmrc_path).expect("read .npmrc"); + let dead_npmrc = live_npmrc + .lines() + .filter(|line| !line.trim_start().starts_with("registry=")) + .collect::>() + .join("\n"); + fs::write(&npmrc_path, format!("registry={dead_registry}\n{dead_npmrc}\n")) + .expect("rewrite .npmrc with a dead registry"); + + let assert = pacquet_at(&workspace).with_arg("install").assert().success(); + assert!( + String::from_utf8_lossy(&assert.get_output().stdout) + .contains("Lockfile is up to date, resolution step is skipped"), + ); + + let wanted = pacquet_lockfile::Lockfile::load_wanted_from_dir(&workspace) + .expect("load updated wanted lockfile") + .expect("updated wanted lockfile"); + let current = pacquet_lockfile::Lockfile::load_current_from_virtual_store_dir( + &workspace.join("node_modules/.pnpm"), + ) + .expect("load current lockfile") + .expect("current lockfile"); + let parent_key = "@pnpm.e2e/pkg-with-good-optional@1.0.0".parse().expect("parent package key"); + let removed_key = "is-positive@1.0.0".parse().expect("removed package key"); + let removed_name = "is-positive".parse().expect("removed package name"); + for lockfile in [&wanted, ¤t] { + assert_eq!( + lockfile.ignored_optional_dependencies.as_deref(), + Some(["is-positive".to_string()].as_slice()), + ); + assert!( + lockfile + .snapshots + .as_ref() + .and_then(|snapshots| snapshots.get(&parent_key)) + .and_then(|snapshot| snapshot.optional_dependencies.as_ref()) + .is_none_or(|dependencies| !dependencies.contains_key(&removed_name)), + ); + assert!( + lockfile + .snapshots + .as_ref() + .is_none_or(|snapshots| !snapshots.contains_key(&removed_key)), + ); + assert!( + lockfile.packages.as_ref().is_none_or(|packages| !packages.contains_key(&removed_key)), + ); + } + assert!( + !workspace + .join( + "node_modules/.pnpm/@pnpm.e2e+pkg-with-good-optional@1.0.0/node_modules/is-positive", + ) + .exists(), + ); + + fs::write(&npmrc_path, live_npmrc).expect("restore live registry"); + let workspace_yaml = + fs::read_to_string(&workspace_yaml_path).expect("read pnpm-workspace.yaml"); + fs::write( + &workspace_yaml_path, + workspace_yaml.replace("ignoredOptionalDependencies:\n - is-positive\n", ""), + ) + .expect("remove ignored optional dependency"); + let assert = pacquet_at(&workspace).with_arg("install").assert().success(); + assert!( + !String::from_utf8_lossy(&assert.get_output().stdout) + .contains("Lockfile is up to date, resolution step is skipped"), + ); + + let wanted = pacquet_lockfile::Lockfile::load_wanted_from_dir(&workspace) + .expect("load updated wanted lockfile") + .expect("updated wanted lockfile"); + let parent_key = "@pnpm.e2e/pkg-with-good-optional@1.0.0".parse().expect("parent package key"); + let restored_key = "is-positive@1.0.0".parse().expect("restored package key"); + let restored_name = "is-positive".parse().expect("restored package name"); + assert!( + wanted + .snapshots + .as_ref() + .and_then(|snapshots| snapshots.get(&parent_key)) + .and_then(|snapshot| snapshot.optional_dependencies.as_ref()) + .is_some_and(|dependencies| dependencies.contains_key(&restored_name)), + ); + assert!( + wanted.snapshots.as_ref().is_some_and(|snapshots| snapshots.contains_key(&restored_key)), + ); + assert!( + workspace + .join( + "node_modules/.pnpm/@pnpm.e2e+pkg-with-good-optional@1.0.0/node_modules/is-positive", + ) + .exists(), + ); + + drop((root, mock_instance)); +} + #[test] fn reuses_unchanged_subtree_without_re_resolving_from_the_registry() { let CommandTempCwd { workspace, root, npmrc_info, .. } = diff --git a/pnpm/crates/cli/tests/package_manager_check.rs b/pnpm/crates/cli/tests/package_manager_check.rs index 2e88bb1b24..7d94e27810 100644 --- a/pnpm/crates/cli/tests/package_manager_check.rs +++ b/pnpm/crates/cli/tests/package_manager_check.rs @@ -57,13 +57,16 @@ fn pm_on_fail_ignore_bypasses_the_package_manager_version_mismatch() { #[test] fn a_package_manager_field_with_an_integrity_hash_matches_the_running_version() { - let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + let CommandTempCwd { mut pacquet, root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_pnpm_version(pacquet_config::PNPM_VERSION); let pinned = format!("pnpm@{}+sha256.123456789", pacquet_config::PNPM_VERSION); write_manifest(&workspace, &serde_json::json!({ "packageManager": pinned })); + pacquet.env("PNPM_CONFIG_REGISTRY", npmrc_info.mock_instance.url()); let output = run(pacquet, root.path(), &["install"]); assert_success(&output); + drop((root, npmrc_info)); } #[test] @@ -192,8 +195,10 @@ fn dev_engines_package_manager_array_defaults_on_fail_to_ignore_before_the_last_ /// must not leave `packageManagerDependencies` unwritten. #[test] fn a_command_outside_the_install_family_records_the_pinned_package_manager() { - let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + let CommandTempCwd { mut pacquet, root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_pnpm_version(pacquet_config::PNPM_VERSION); write_dev_engines_package_manager(&workspace, "pnpm", pacquet_config::PNPM_VERSION, None); + pacquet.env("PNPM_CONFIG_REGISTRY", npmrc_info.mock_instance.url()); let output = run(pacquet, root.path(), &EXEC_NODE_VERSION); @@ -202,6 +207,7 @@ fn a_command_outside_the_install_family_records_the_pinned_package_manager() { fs::read_to_string(workspace.join("pnpm-lock.yaml")).expect("read the written lockfile"); assert_contains(&lockfile, "packageManagerDependencies:"); assert_contains(&lockfile, &format!("pnpm@{}", pacquet_config::PNPM_VERSION)); + drop((root, npmrc_info)); } #[test] diff --git a/pnpm/crates/cli/tests/version.rs b/pnpm/crates/cli/tests/version.rs index 99ddc4f601..e4d24d273a 100644 --- a/pnpm/crates/cli/tests/version.rs +++ b/pnpm/crates/cli/tests/version.rs @@ -65,7 +65,7 @@ fn version_flag_switches_to_project_package_manager_version() { #[test] fn version_flag_records_a_pinned_package_manager_it_does_not_need_to_switch_to() { let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } = - CommandTempCwd::init().add_mocked_registry(); + CommandTempCwd::init().add_mocked_registry_with_pnpm_version(pacquet_config::PNPM_VERSION); let AddMockedRegistry { mock_instance, .. } = npmrc_info; let pinned = pacquet_config::PNPM_VERSION; fs::write( diff --git a/pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies.rs b/pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies.rs new file mode 100644 index 0000000000..64750f37ed --- /dev/null +++ b/pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies.rs @@ -0,0 +1,92 @@ +use pacquet_config::matcher::create_matcher; +use pacquet_lockfile::{Lockfile, PkgName}; +use std::collections::{BTreeSet, HashMap, HashSet}; + +pub(crate) fn try_fast_update_ignored_optional_dependencies( + lockfile: &Lockfile, + ignored_optional_dependencies: &[String], +) -> Option { + let previous: BTreeSet<_> = lockfile + .ignored_optional_dependencies + .as_deref() + .unwrap_or_default() + .iter() + .cloned() + .collect(); + let current: BTreeSet<_> = ignored_optional_dependencies.iter().cloned().collect(); + let added_exclusion = current.difference(&previous).any(|pattern| pattern.starts_with('!')); + let previous_ignores_by_default = + !previous.is_empty() && previous.iter().all(|pattern| pattern.starts_with('!')); + if previous == current + || !previous.is_subset(¤t) + || added_exclusion + || previous_ignores_by_default + { + return None; + } + + let matcher = create_matcher(ignored_optional_dependencies); + let mut candidate = lockfile.clone(); + for importer in candidate.importers.values_mut() { + let removed = remove_ignored_optional_dependencies( + &mut importer.optional_dependencies, + &mut importer.dependencies, + &matcher, + ); + if let Some(specifiers) = importer.specifiers.as_mut() { + let removed_specifiers: HashSet<_> = + removed.into_iter().map(|name| name.to_string()).collect(); + specifiers.retain(|name, _| !removed_specifiers.contains(name)); + } + } + if let Some(snapshots) = candidate.snapshots.as_mut() { + for snapshot in snapshots.values_mut() { + remove_ignored_optional_dependencies( + &mut snapshot.optional_dependencies, + &mut snapshot.dependencies, + &matcher, + ); + } + } + candidate.ignored_optional_dependencies = Some(current.into_iter().collect()); + crate::fast_update_lockfile::prune_unreachable_packages(&mut candidate); + Some(candidate) +} + +fn remove_ignored_optional_dependencies( + optional_dependencies: &mut Option>, + dependencies: &mut Option>, + matcher: &pacquet_config::matcher::Matcher, +) -> HashSet { + let removed: HashSet<_> = optional_dependencies + .as_ref() + .into_iter() + .flatten() + .filter(|(name, _)| matches_package_name(matcher, name)) + .map(|(name, _)| name.clone()) + .collect(); + if let Some(optional_dependencies) = optional_dependencies.as_mut() { + optional_dependencies.retain(|name, _| !removed.contains(name)); + } + if optional_dependencies.as_ref().is_some_and(HashMap::is_empty) { + *optional_dependencies = None; + } + if let Some(dependencies) = dependencies.as_mut() { + dependencies.retain(|name, _| !removed.contains(name)); + } + if dependencies.as_ref().is_some_and(HashMap::is_empty) { + *dependencies = None; + } + removed +} + +fn matches_package_name(matcher: &pacquet_config::matcher::Matcher, name: &PkgName) -> bool { + if name.scope.is_some() { + matcher.matches(&name.to_string()) + } else { + matcher.matches(&name.bare) + } +} + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies/tests.rs b/pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies/tests.rs new file mode 100644 index 0000000000..9150893271 --- /dev/null +++ b/pnpm/crates/package-manager/src/fast_update_ignored_optional_dependencies/tests.rs @@ -0,0 +1,143 @@ +use super::try_fast_update_ignored_optional_dependencies; +use pacquet_lockfile::Lockfile; + +fn lockfile(source: &str) -> Lockfile { + serde_saphyr::from_str(source).expect("parse lockfile") +} + +#[test] +fn removes_matching_optional_edges_and_prunes_only_unreachable_packages() { + let lockfile = lockfile( + r" +lockfileVersion: '9.0' +importers: + .: + dependencies: + carrier: + specifier: 1.0.0 + version: 1.0.0 + parent: + specifier: 1.0.0 + version: 1.0.0 + optionalDependencies: + root-only: + specifier: 1.0.0 + version: 1.0.0 +packages: + carrier@1.0.0: + resolution: {integrity: sha512-carrier} + parent@1.0.0: + resolution: {integrity: sha512-parent} + root-only@1.0.0: + resolution: {integrity: sha512-root} + shared@1.0.0: + resolution: {integrity: sha512-shared} + unique@1.0.0: + resolution: {integrity: sha512-unique} +snapshots: + carrier@1.0.0: + dependencies: + shared: 1.0.0 + parent@1.0.0: + optionalDependencies: + shared: 1.0.0 + unique: 1.0.0 + root-only@1.0.0: {} + shared@1.0.0: {} + unique@1.0.0: {} +", + ); + + let updated = try_fast_update_ignored_optional_dependencies( + &lockfile, + &["root-only".to_string(), "shared".to_string(), "unique".to_string()], + ) + .expect("additions should update"); + + assert!(updated.importers["."].optional_dependencies.is_none()); + let parent_key = "parent@1.0.0".parse().expect("parent key"); + assert!( + updated.snapshots.as_ref().expect("snapshots")[&parent_key].optional_dependencies.is_none(), + ); + let snapshots = updated.snapshots.as_ref().expect("snapshots"); + assert!(snapshots.contains_key(&"shared@1.0.0".parse().expect("shared key"))); + assert!(!snapshots.contains_key(&"root-only@1.0.0".parse().expect("root key"))); + assert!(!snapshots.contains_key(&"unique@1.0.0".parse().expect("unique key"))); + let packages = updated.packages.as_ref().expect("packages"); + assert!(packages.contains_key(&"shared@1.0.0".parse().expect("shared key"))); + assert!(!packages.contains_key(&"root-only@1.0.0".parse().expect("root key"))); + assert!(!packages.contains_key(&"unique@1.0.0".parse().expect("unique key"))); +} + +#[test] +fn rejects_removing_an_ignored_pattern() { + let lockfile = lockfile( + r" +lockfileVersion: '9.0' +ignoredOptionalDependencies: + - foo +importers: {} +", + ); + + assert!( + try_fast_update_ignored_optional_dependencies(&lockfile, &["bar".to_string()]).is_none(), + ); +} + +#[test] +fn rejects_an_added_exclusion_pattern() { + let lockfile = lockfile( + r" +lockfileVersion: '9.0' +ignoredOptionalDependencies: + - '*' +importers: {} +", + ); + + assert!( + try_fast_update_ignored_optional_dependencies( + &lockfile, + &["*".to_string(), "!is-positive".to_string()], + ) + .is_none(), + ); +} + +#[test] +fn rejects_adding_an_include_to_exclusion_only_patterns() { + let lockfile = lockfile( + r" +lockfileVersion: '9.0' +ignoredOptionalDependencies: + - '!foo' +importers: {} +", + ); + + assert!( + try_fast_update_ignored_optional_dependencies( + &lockfile, + &["!foo".to_string(), "bar".to_string()], + ) + .is_none(), + ); +} + +#[test] +fn records_an_added_pattern_even_when_it_matches_no_edge() { + let lockfile = lockfile( + r" +lockfileVersion: '9.0' +importers: {} +", + ); + + let updated = try_fast_update_ignored_optional_dependencies(&lockfile, &["unused".to_string()]) + .expect("setting-only addition should update"); + assert_eq!( + updated.ignored_optional_dependencies.as_deref(), + Some(["unused".to_string()].as_slice()), + ); +} diff --git a/pnpm/crates/package-manager/src/fast_update_lockfile.rs b/pnpm/crates/package-manager/src/fast_update_lockfile.rs new file mode 100644 index 0000000000..597098cb83 --- /dev/null +++ b/pnpm/crates/package-manager/src/fast_update_lockfile.rs @@ -0,0 +1,58 @@ +use pacquet_lockfile::{Lockfile, PkgNameVerPeer}; +use std::collections::{HashSet, VecDeque}; + +pub(crate) fn prune_unreachable_packages(lockfile: &mut Lockfile) { + let reachable = { + let Some(snapshots) = lockfile.snapshots.as_ref() else { return }; + let mut reachable = HashSet::new(); + let mut queue = VecDeque::new(); + for importer in lockfile.importers.values() { + for dependencies in [ + importer.dependencies.as_ref(), + importer.dev_dependencies.as_ref(), + importer.optional_dependencies.as_ref(), + ] + .into_iter() + .flatten() + { + for (alias, spec) in dependencies { + if let Some(key) = spec.version.resolved_key(alias) { + queue.push_back(key); + } + } + } + } + while let Some(key) = queue.pop_front() { + if !reachable.insert(key.clone()) { + continue; + } + let Some(snapshot) = snapshots.get(&key) else { continue }; + for dependencies in + [snapshot.dependencies.as_ref(), snapshot.optional_dependencies.as_ref()] + .into_iter() + .flatten() + { + for (alias, dep_ref) in dependencies { + if let Some(key) = dep_ref.resolve(alias) { + queue.push_back(key); + } + } + } + } + reachable + }; + let reachable_metadata: HashSet<_> = + reachable.iter().map(PkgNameVerPeer::without_peer).collect(); + if let Some(snapshots) = lockfile.snapshots.as_mut() { + snapshots.retain(|key, _| reachable.contains(key)); + if snapshots.is_empty() { + lockfile.snapshots = None; + } + } + if let Some(packages) = lockfile.packages.as_mut() { + packages.retain(|key, _| reachable_metadata.contains(key)); + if packages.is_empty() { + lockfile.packages = None; + } + } +} diff --git a/pnpm/crates/package-manager/src/fast_update_overrides.rs b/pnpm/crates/package-manager/src/fast_update_overrides.rs index 1b4f189a0e..dac50ae4f2 100644 --- a/pnpm/crates/package-manager/src/fast_update_overrides.rs +++ b/pnpm/crates/package-manager/src/fast_update_overrides.rs @@ -11,7 +11,7 @@ use pacquet_resolving_deps_resolver::ManifestHook; use pacquet_resolving_resolver_base::{ResolveOptions, ResolveResult, Resolver, WantedDependency}; use serde_json::Value; use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{HashMap, HashSet}, sync::Arc, }; @@ -358,7 +358,7 @@ fn rewrite_lockfile( updated.snapshots = Some(snapshots); updated.packages = Some(packages); updated.overrides = Some(opts.resolved_overrides.clone()); - prune_unreachable_packages(&mut updated); + crate::fast_update_lockfile::prune_unreachable_packages(&mut updated); Some(updated) } @@ -426,62 +426,6 @@ fn should_remove_dependency( }) } -fn prune_unreachable_packages(lockfile: &mut Lockfile) { - let reachable = { - let Some(snapshots) = lockfile.snapshots.as_ref() else { return }; - let mut reachable = HashSet::new(); - let mut queue = VecDeque::new(); - for importer in lockfile.importers.values() { - for dependencies in [ - importer.dependencies.as_ref(), - importer.dev_dependencies.as_ref(), - importer.optional_dependencies.as_ref(), - ] - .into_iter() - .flatten() - { - for (alias, spec) in dependencies { - if let Some(key) = spec.version.resolved_key(alias) { - queue.push_back(key); - } - } - } - } - while let Some(key) = queue.pop_front() { - if !reachable.insert(key.clone()) { - continue; - } - let Some(snapshot) = snapshots.get(&key) else { continue }; - for dependencies in - [snapshot.dependencies.as_ref(), snapshot.optional_dependencies.as_ref()] - .into_iter() - .flatten() - { - for (alias, dep_ref) in dependencies { - if let Some(key) = dep_ref.resolve(alias) { - queue.push_back(key); - } - } - } - } - reachable - }; - let reachable_metadata: HashSet<_> = - reachable.iter().map(PkgNameVerPeer::without_peer).collect(); - if let Some(snapshots) = lockfile.snapshots.as_mut() { - snapshots.retain(|key, _| reachable.contains(key)); - if snapshots.is_empty() { - lockfile.snapshots = None; - } - } - if let Some(packages) = lockfile.packages.as_mut() { - packages.retain(|key, _| reachable_metadata.contains(key)); - if packages.is_empty() { - lockfile.packages = None; - } - } -} - fn validate_dependencies( manifest_dependencies: HashMap, locked_dependencies: Option<&HashMap>, diff --git a/pnpm/crates/package-manager/src/install.rs b/pnpm/crates/package-manager/src/install.rs index f89f423c66..8db24ba44f 100644 --- a/pnpm/crates/package-manager/src/install.rs +++ b/pnpm/crates/package-manager/src/install.rs @@ -70,7 +70,7 @@ pub(crate) use lockfile_freshness::{ check_lockfile_settings_drift, parse_config_overrides, }; use lockfile_freshness::{ - FastUpdateImporterLockfileOptions, check_lockfile_freshness, try_fast_update_importer_lockfile, + FastUpdateLockfileOptions, check_lockfile_freshness, try_fast_update_lockfile, }; use materialize::{MaterializationInputs, MaterializationOutput, materialize}; use modules_state::{ diff --git a/pnpm/crates/package-manager/src/install/lockfile_freshness.rs b/pnpm/crates/package-manager/src/install/lockfile_freshness.rs index 598caaf592..ed89d5c909 100644 --- a/pnpm/crates/package-manager/src/install/lockfile_freshness.rs +++ b/pnpm/crates/package-manager/src/install/lockfile_freshness.rs @@ -4,7 +4,7 @@ use super::{ StalenessReason, satisfies_package_manifest, }; -pub(super) struct FastUpdateImporterLockfileOptions<'a, 'manifest> { +pub(super) struct FastUpdateLockfileOptions<'a, 'manifest> { pub(super) lockfile: Option<&'a Lockfile>, pub(super) manifests: &'a [(String, &'manifest PackageManifest)], pub(super) config: &'a Config, @@ -13,12 +13,22 @@ pub(super) struct FastUpdateImporterLockfileOptions<'a, 'manifest> { pub(super) ignore_manifest_check: bool, } -pub(super) async fn try_fast_update_importer_lockfile( - opts: FastUpdateImporterLockfileOptions<'_, '_>, +pub(super) async fn try_fast_update_lockfile( + opts: FastUpdateLockfileOptions<'_, '_>, ) -> Option { let lockfile = opts.lockfile?; - let candidate = - crate::fast_update_importers::try_fast_update_importers(lockfile, opts.manifests)?; + let importer_candidate = + crate::fast_update_importers::try_fast_update_importers(lockfile, opts.manifests); + let ignored_optional_candidate = + crate::fast_update_ignored_optional_dependencies::try_fast_update_ignored_optional_dependencies( + lockfile, + opts.config.ignored_optional_dependencies.as_deref().unwrap_or_default(), + ); + let ((Some(candidate), None) | (None, Some(candidate))) = + (importer_candidate, ignored_optional_candidate) + else { + return None; + }; check_lockfile_freshness( &candidate, opts.manifests, diff --git a/pnpm/crates/package-manager/src/install/run.rs b/pnpm/crates/package-manager/src/install/run.rs index cd6a7244df..e1c413f3ac 100644 --- a/pnpm/crates/package-manager/src/install/run.rs +++ b/pnpm/crates/package-manager/src/install/run.rs @@ -1,19 +1,18 @@ use super::{ ApplyMaterializationInputs, Arc, AtomicU8, ContextLog, DependencyGroup, - FastUpdateImporterLockfileOptions, FreshnessCheckError, HashSet, Host, - InMemoryPackageMetaCache, IncludedDependencies, Install, InstallError, InstallRunOptions, - IsTerminal, Lockfile, LogEvent, LogLevel, MaterializationInputs, MaterializationOutput, - OptimisticRepeatInstallCheck, OptimisticRepeatInstallDecision, Path, PnpmLog, - PrepareModulesStateInputs, PreparedModulesState, Reporter, ScopeLog, Stage, StageLog, - SummaryLog, UpdateSeedPolicy, apply_materialization_result, build_project_manifests_list, - build_resolution_verifiers, build_root_importer_project_manifests_list, - build_selected_project_manifests_list, check_lockfile_freshness, - check_optimistic_repeat_install, configured_or_discovered_workspace_dir, - dev_preinstall_already_ran, emit_initial_package_manifest, - get_catalogs_from_workspace_manifest, gvs_build_marker_present, + FastUpdateLockfileOptions, FreshnessCheckError, HashSet, Host, InMemoryPackageMetaCache, + IncludedDependencies, Install, InstallError, InstallRunOptions, IsTerminal, Lockfile, LogEvent, + LogLevel, MaterializationInputs, MaterializationOutput, OptimisticRepeatInstallCheck, + OptimisticRepeatInstallDecision, Path, PnpmLog, PrepareModulesStateInputs, + PreparedModulesState, Reporter, ScopeLog, Stage, StageLog, SummaryLog, UpdateSeedPolicy, + apply_materialization_result, build_project_manifests_list, build_resolution_verifiers, + build_root_importer_project_manifests_list, build_selected_project_manifests_list, + check_lockfile_freshness, check_optimistic_repeat_install, + configured_or_discovered_workspace_dir, dev_preinstall_already_ran, + emit_initial_package_manifest, get_catalogs_from_workspace_manifest, gvs_build_marker_present, gvs_build_markers_may_require_recovery, load_workspace_projects, map_frozen_lockfile_error, materialize, prepare_modules_state, run_dev_preinstall, selected_manifest_freshness_inputs, - try_fast_update_importer_lockfile, unapproved_recorded_ignored_builds, verify_lockfile_eagerly, + try_fast_update_lockfile, unapproved_recorded_ignored_builds, verify_lockfile_eagerly, }; use pacquet_executor::DEV_PREINSTALL_STAGE; @@ -497,10 +496,10 @@ where // would hide the change of a real install creating `pnpm-lock.yaml`. let existing_wanted_lockfile = lockfile; let lockfile = lockfile.or(synthesized_lockfile.as_ref()); - let can_fast_update_importers = + let can_fast_update_lockfile = !frozen_lockfile && !dry_run && prefer_frozen_lockfile && mutation.is_full_install(); - let fast_updated_lockfile = if can_fast_update_importers { - try_fast_update_importer_lockfile(FastUpdateImporterLockfileOptions { + let fast_updated_lockfile = if can_fast_update_lockfile { + try_fast_update_lockfile(FastUpdateLockfileOptions { lockfile, manifests: &manifest_freshness_inputs, config, 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 3ad4080f7a..6e1ce5bca6 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -26,7 +26,7 @@ use pacquet_resolving_npm_resolver::{InMemoryPackageMetaCache, MergeNamedRegistr use pacquet_store_dir::SharedVerifiedFilesCache; use pacquet_tarball::{MemCache, SharedReportedProgressKeys}; use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, path::Path, sync::{Arc, atomic::AtomicU8}, }; @@ -1924,6 +1924,12 @@ fn overrides_match( } } +fn ignored_optional_dependencies_match(left: Option<&[String]>, right: Option<&[String]>) -> bool { + let left: HashSet<_> = left.unwrap_or_default().iter().collect(); + let right: HashSet<_> = right.unwrap_or_default().iter().collect(); + left == right +} + fn compose_manifest_hooks( first: Option, second: Option, diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs index 479eb386e2..c0e451a207 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs @@ -258,6 +258,10 @@ pub(super) async fn lockfile_reuse_seed(inputs: ReuseSeedInputs<'_>) -> Option { /// pnpm 11 reads those from the workspace YAML rather than `.npmrc`. #[must_use] pub fn add_mocked_registry(self) -> CommandTempCwd { - self.add_mocked_registry_with_substitutions(&[]) + self.add_mocked_registry_with_substitutions_and_mode(&[], false) + } + + #[must_use] + pub fn add_mocked_registry_with_pnpm_version( + self, + version: &str, + ) -> CommandTempCwd { + self.add_mocked_registry_with_substitutions_and_mode( + &[("0.0.0-test-current-pnpm", version)], + true, + ) } /// Create a mock registry whose generated fixture manifests have exact @@ -73,6 +84,14 @@ impl CommandTempCwd<()> { pub fn add_mocked_registry_with_substitutions( self, substitutions: &[(&str, &str)], + ) -> CommandTempCwd { + self.add_mocked_registry_with_substitutions_and_mode(substitutions, false) + } + + fn add_mocked_registry_with_substitutions_and_mode( + self, + substitutions: &[(&str, &str)], + static_registry: bool, ) -> CommandTempCwd { let store_dir = self.root.path().join("pacquet-store"); let cache_dir = self.root.path().join("pacquet-cache"); @@ -90,7 +109,11 @@ impl CommandTempCwd<()> { ®istry_storage, substitutions, ); - TestRegistry::start_with_storage(®istry_storage) + if static_registry { + TestRegistry::start_static_with_storage(®istry_storage) + } else { + TestRegistry::start_with_storage(®istry_storage) + } }; let mocked_registry = mock_instance.url(); let npmrc_text = format!("registry={mocked_registry}\n{npmrc_text}"); diff --git a/pnpm/crates/testing-utils/src/registry.rs b/pnpm/crates/testing-utils/src/registry.rs index c214cfd867..7031580bf4 100644 --- a/pnpm/crates/testing-utils/src/registry.rs +++ b/pnpm/crates/testing-utils/src/registry.rs @@ -18,7 +18,11 @@ impl TestRegistry { } pub fn start_with_storage(storage: &Path) -> Self { - Self { url: TestRegistryInstance::start(storage.to_path_buf()).url } + Self { url: TestRegistryInstance::start(storage.to_path_buf(), RegistryMode::Proxy).url } + } + + pub fn start_static_with_storage(storage: &Path) -> Self { + Self { url: TestRegistryInstance::start(storage.to_path_buf(), RegistryMode::Static).url } } #[must_use] @@ -32,15 +36,24 @@ struct TestRegistryInstance { url: String, } +#[derive(Clone, Copy)] +enum RegistryMode { + Proxy, + Static, +} + impl TestRegistryInstance { fn get() -> &'static Self { static INSTANCE: LazyLock = LazyLock::new(|| { - TestRegistryInstance::start(pnpr_fixtures::ensure_storage().to_path_buf()) + TestRegistryInstance::start( + pnpr_fixtures::ensure_storage().to_path_buf(), + RegistryMode::Proxy, + ) }); &INSTANCE } - fn start(storage: PathBuf) -> Self { + fn start(storage: PathBuf, mode: RegistryMode) -> Self { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) .expect("bind test registry to an unused localhost port"); listener.set_nonblocking(true).expect("set test registry listener to nonblocking"); @@ -50,7 +63,10 @@ impl TestRegistryInstance { // Proxy mode: `@pnpm.e2e` fixtures are served from local storage, while // real npm packages (`is-positive`, `is-negative`, etc.) fall through to // the npm upstream — matching how registry-mock served pacquet's tests. - let mut config = Config::proxy(listen, storage); + let mut config = match mode { + RegistryMode::Proxy => Config::proxy(listen, storage), + RegistryMode::Static => Config::static_serve(listen, storage), + }; config.public_url = url.trim_end_matches('/').to_string(); // Registration is opt-in; tests that forward credentials create // accounts via adduser against this registry. diff --git a/pnpm11/deps/status/src/checkDepsStatus.ts b/pnpm11/deps/status/src/checkDepsStatus.ts index 98122ba883..7ff70f1232 100644 --- a/pnpm11/deps/status/src/checkDepsStatus.ts +++ b/pnpm11/deps/status/src/checkDepsStatus.ts @@ -701,6 +701,7 @@ async function assertWantedLockfileUpToDate ( { autoInstallPeers, excludeLinksFromLockfile, + ignoredOptionalDependencies: config.ignoredOptionalDependencies, }, wantedLockfile.importers[projectId], projectManifest diff --git a/pnpm11/installing/deps-installer/src/install/index.ts b/pnpm11/installing/deps-installer/src/install/index.ts index 3f60401e47..3a6182c75b 100644 --- a/pnpm11/installing/deps-installer/src/install/index.ts +++ b/pnpm11/installing/deps-installer/src/install/index.ts @@ -108,6 +108,7 @@ import { import { linkPackages } from './link.js' import { reportPeerDependencyIssues } from './reportPeerDependencyIssues.js' import { tryFastUpdateCatalogs } from './tryFastUpdateCatalogs.js' +import { tryFastUpdateIgnoredOptionalDependencies } from './tryFastUpdateIgnoredOptionalDependencies.js' import { hasChangedProjectSpecifiers, tryFastUpdateImporters } from './tryFastUpdateImporters.js' import { tryFastUpdateLockfile } from './tryFastUpdateLockfile.js' import { tryFastUpdateOverrides } from './tryFastUpdateOverrides.js' @@ -698,6 +699,7 @@ export async function mutateModules ( hasChangedProjectSpecifiers(ctx.wantedLockfile, contextProjects) const canTryFastUpdateLockfile = (outdatedLockfileSettingName === 'catalogs' || + outdatedLockfileSettingName === 'ignoredOptionalDependencies' || outdatedLockfileSettingName === 'overrides' || hasChangedSpecifiers) && !frozenLockfile && @@ -736,12 +738,16 @@ export async function mutateModules ( const onlyChangedSetting = getOutdatedLockfileSetting(ctx.wantedLockfile, { ...lockfileSettings, catalogs: changedSetting === 'catalogs' ? lockfileCatalogs : opts.catalogs, + ignoredOptionalDependencies: changedSetting === 'ignoredOptionalDependencies' + ? ctx.wantedLockfile.ignoredOptionalDependencies + : lockfileSettings.ignoredOptionalDependencies, overrides: changedSetting === 'overrides' ? ctx.wantedLockfile.overrides : overridesMap, }) == null const isLockfileUpToDate = (lockfile: LockfileObject) => allProjectsAreUpToDate(Object.values(ctx.projects), { catalogs: opts.catalogs, autoInstallPeers: opts.autoInstallPeers, excludeLinksFromLockfile: opts.excludeLinksFromLockfile, + ignoredOptionalDependencies: opts.ignoredOptionalDependencies, linkWorkspacePackages: opts.linkWorkspacePackagesDepth >= 0, wantedLockfile: lockfile, workspacePackages: ctx.workspacePackages, @@ -758,6 +764,9 @@ export async function mutateModules ( overrides: opts.overrides, }) } + if (changedSetting === 'ignoredOptionalDependencies') { + return tryFastUpdateIgnoredOptionalDependencies(candidate, opts.ignoredOptionalDependencies) + } if (changedSetting == null) { return tryFastUpdateImporters(candidate, contextProjects) } @@ -1163,6 +1172,7 @@ export async function mutateModules ( catalogs: opts.catalogs, autoInstallPeers: opts.autoInstallPeers, excludeLinksFromLockfile: opts.excludeLinksFromLockfile, + ignoredOptionalDependencies: opts.ignoredOptionalDependencies, linkWorkspacePackages: opts.linkWorkspacePackagesDepth >= 0, wantedLockfile: ctx.wantedLockfile, workspacePackages: ctx.workspacePackages, @@ -1201,6 +1211,7 @@ Note that in CI environments, this setting is enabled by default.`, const _satisfiesPackageManifest = satisfiesPackageManifest.bind(null, { autoInstallPeers: opts.autoInstallPeers, excludeLinksFromLockfile: opts.excludeLinksFromLockfile, + ignoredOptionalDependencies: opts.ignoredOptionalDependencies, }) for (const { id, manifest, rootDir } of Object.values(ctx.projects)) { const { satisfies, detailedReason } = _satisfiesPackageManifest(ctx.wantedLockfile.importers[id], manifest) diff --git a/pnpm11/installing/deps-installer/src/install/tryFastUpdateIgnoredOptionalDependencies.ts b/pnpm11/installing/deps-installer/src/install/tryFastUpdateIgnoredOptionalDependencies.ts new file mode 100644 index 0000000000..c961b06cb5 --- /dev/null +++ b/pnpm11/installing/deps-installer/src/install/tryFastUpdateIgnoredOptionalDependencies.ts @@ -0,0 +1,62 @@ +import { createMatcher } from '@pnpm/config.matcher' +import { pruneSharedLockfile } from '@pnpm/lockfile.pruner' +import type { + LockfileObject, + ProjectSnapshot, + ResolvedDependencies, +} from '@pnpm/lockfile.types' + +export function tryFastUpdateIgnoredOptionalDependencies ( + lockfile: LockfileObject, + ignoredOptionalDependencies: string[] +): boolean { + const previous = new Set(lockfile.ignoredOptionalDependencies ?? []) + const current = new Set(ignoredOptionalDependencies) + const addedPatterns = [...current].filter((pattern) => !previous.has(pattern)) + const previousIgnoresByDefault = previous.size > 0 && [...previous].every((pattern) => pattern.startsWith('!')) + if ( + previous.size === current.size || + [...previous].some((pattern) => !current.has(pattern)) || + addedPatterns.some((pattern) => pattern.startsWith('!')) || + previousIgnoresByDefault + ) { + return false + } + + const isIgnored = createMatcher(ignoredOptionalDependencies) + for (const importer of Object.values(lockfile.importers)) { + removeIgnoredOptionalDependencies(importer, isIgnored) + } + for (const snapshot of Object.values(lockfile.packages ?? {})) { + removeIgnoredOptionalDependencies(snapshot, isIgnored) + } + lockfile.ignoredOptionalDependencies = [...current].sort() + + const pruned = pruneSharedLockfile(lockfile) + if (pruned.packages == null) { + delete lockfile.packages + } else { + lockfile.packages = pruned.packages + } + return true +} + +function removeIgnoredOptionalDependencies ( + snapshot: Pick & { + specifiers?: ResolvedDependencies + }, + isIgnored: (dependency: string) => boolean +): void { + const removed = Object.keys(snapshot.optionalDependencies ?? {}).filter(isIgnored) + for (const dependency of removed) { + delete snapshot.optionalDependencies![dependency] + delete snapshot.dependencies?.[dependency] + delete snapshot.specifiers?.[dependency] + } + if (snapshot.optionalDependencies != null && Object.keys(snapshot.optionalDependencies).length === 0) { + delete snapshot.optionalDependencies + } + if (snapshot.dependencies != null && Object.keys(snapshot.dependencies).length === 0) { + delete snapshot.dependencies + } +} diff --git a/pnpm11/installing/deps-installer/test/install/ignoredOptionalDependencies.ts b/pnpm11/installing/deps-installer/test/install/ignoredOptionalDependencies.ts index e9c795be04..1f25ba279d 100644 --- a/pnpm11/installing/deps-installer/test/install/ignoredOptionalDependencies.ts +++ b/pnpm11/installing/deps-installer/test/install/ignoredOptionalDependencies.ts @@ -1,8 +1,11 @@ import { expect, test } from '@jest/globals' import { addDependenciesToPackage, install } from '@pnpm/installing.deps-installer' +import type { LockfileObject } from '@pnpm/lockfile.types' import { prepareEmpty } from '@pnpm/prepare' -import type { ProjectManifest } from '@pnpm/types' +import type { StoreController } from '@pnpm/store.controller-types' +import type { DepPath, ProjectId, ProjectManifest } from '@pnpm/types' +import { tryFastUpdateIgnoredOptionalDependencies } from '../../src/install/tryFastUpdateIgnoredOptionalDependencies.js' import { testDefaults, } from '../utils/index.js' @@ -57,8 +60,9 @@ test('adding or changing manifest.pnpm.ignoredOptionalDependencies should change }, }) const project = prepareEmpty() + const options = testDefaults() - await install(manifest, testDefaults()) + await install(manifest, options) { const lockfile = project.readLockfile() expect(lockfile).not.toHaveProperty(['ignoredOptionalDependencies']) @@ -66,13 +70,126 @@ test('adding or changing manifest.pnpm.ignoredOptionalDependencies should change expect(lockfile.packages).toHaveProperty(['is-positive@1.0.0']) } - await install(manifest, testDefaults({ - ignoredOptionalDependencies: ['is-positive'], - })) + const requestedPackages = trackRequestedPackages(options.storeController) + options.ignoredOptionalDependencies = ['is-positive'] + await install(manifest, options) { const lockfile = project.readLockfile() + expect(requestedPackages).toStrictEqual([]) expect(lockfile.ignoredOptionalDependencies).toStrictEqual(['is-positive']) expect(lockfile.packages).toHaveProperty(['@pnpm.e2e/pkg-with-good-optional@1.0.0']) expect(lockfile.packages).not.toHaveProperty(['is-positive@1.0.0']) } }) + +test('removing an ignored optional dependency falls back to resolution', async () => { + const manifest: ProjectManifest = { + dependencies: { + '@pnpm.e2e/pkg-with-good-optional': '1.0.0', + }, + } + const project = prepareEmpty() + const options = testDefaults({ + ignoredOptionalDependencies: ['is-positive'], + }) + + await install(manifest, options) + + const requestedPackages = trackRequestedPackages(options.storeController) + options.ignoredOptionalDependencies = [] + await install(manifest, options) + + expect(requestedPackages).toContain('is-positive') + expect(project.readLockfile().packages).toHaveProperty(['is-positive@1.0.0']) +}) + +test('fast update prunes only optional packages that become unreachable', () => { + const lockfile = { + importers: { + '.': { + dependencies: { + carrier: '1.0.0', + parent: '1.0.0', + }, + optionalDependencies: { + 'root-only': '1.0.0', + }, + specifiers: { + carrier: '1.0.0', + parent: '1.0.0', + 'root-only': '1.0.0', + }, + }, + }, + lockfileVersion: '9.0', + packages: { + 'carrier@1.0.0': { + dependencies: { + shared: '1.0.0', + }, + resolution: { integrity: 'sha512-carrier' }, + }, + 'parent@1.0.0': { + optionalDependencies: { + shared: '1.0.0', + unique: '1.0.0', + }, + resolution: { integrity: 'sha512-parent' }, + }, + 'root-only@1.0.0': { + resolution: { integrity: 'sha512-root' }, + }, + 'shared@1.0.0': { + resolution: { integrity: 'sha512-shared' }, + }, + 'unique@1.0.0': { + resolution: { integrity: 'sha512-unique' }, + }, + }, + } as LockfileObject + + expect(tryFastUpdateIgnoredOptionalDependencies(lockfile, [ + 'root-only', + 'shared', + 'unique', + ])).toBe(true) + + const importer = lockfile.importers['.' as ProjectId] + const parent = lockfile.packages?.['parent@1.0.0' as DepPath] + expect(importer.optionalDependencies).toBeUndefined() + expect(importer.specifiers).not.toHaveProperty('root-only') + expect(parent?.optionalDependencies).toBeUndefined() + expect(lockfile.packages).toHaveProperty(['shared@1.0.0']) + expect(lockfile.packages).not.toHaveProperty(['root-only@1.0.0']) + expect(lockfile.packages).not.toHaveProperty(['unique@1.0.0']) +}) + +test('fast update rejects a new exclusion pattern', () => { + const lockfile = { + ignoredOptionalDependencies: ['*'], + importers: {}, + lockfileVersion: '9.0', + } as LockfileObject + + expect(tryFastUpdateIgnoredOptionalDependencies(lockfile, ['*', '!is-positive'])).toBe(false) +}) + +test('fast update rejects adding an include to exclusion-only patterns', () => { + const lockfile = { + ignoredOptionalDependencies: ['!foo'], + importers: {}, + lockfileVersion: '9.0', + } as LockfileObject + + expect(tryFastUpdateIgnoredOptionalDependencies(lockfile, ['!foo', 'bar'])).toBe(false) +}) + +function trackRequestedPackages (storeController: StoreController): string[] { + const requestedPackages: string[] = [] + const requestPackage = storeController.requestPackage + storeController.requestPackage = async (wantedDependency, requestOptions) => { + requestedPackages.push(wantedDependency.alias!) + return requestPackage(wantedDependency, requestOptions) + } + return requestedPackages +} diff --git a/pnpm11/lockfile/verification/package.json b/pnpm11/lockfile/verification/package.json index b5cb80eaa1..4d776acd3a 100644 --- a/pnpm11/lockfile/verification/package.json +++ b/pnpm11/lockfile/verification/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@pnpm/catalogs.types": "workspace:*", + "@pnpm/config.matcher": "workspace:*", "@pnpm/crypto.hash": "workspace:*", "@pnpm/deps.path": "workspace:*", "@pnpm/installing.context": "workspace:*", diff --git a/pnpm11/lockfile/verification/src/allProjectsAreUpToDate.ts b/pnpm11/lockfile/verification/src/allProjectsAreUpToDate.ts index ddca84b859..a9466f05da 100644 --- a/pnpm11/lockfile/verification/src/allProjectsAreUpToDate.ts +++ b/pnpm11/lockfile/verification/src/allProjectsAreUpToDate.ts @@ -20,6 +20,7 @@ export async function allProjectsAreUpToDate ( catalogs: Catalogs autoInstallPeers: boolean excludeLinksFromLockfile: boolean + ignoredOptionalDependencies?: string[] linkWorkspacePackages: boolean wantedLockfile: LockfileObject workspacePackages: WorkspacePackages @@ -37,6 +38,7 @@ export async function allProjectsAreUpToDate ( const _satisfiesPackageManifest = satisfiesPackageManifest.bind(null, { autoInstallPeers: opts.autoInstallPeers, excludeLinksFromLockfile: opts.excludeLinksFromLockfile, + ignoredOptionalDependencies: opts.ignoredOptionalDependencies, }) const _linkedPackagesAreUpToDate = linkedPackagesAreUpToDate.bind(null, { linkWorkspacePackages: opts.linkWorkspacePackages, diff --git a/pnpm11/lockfile/verification/src/satisfiesPackageManifest.ts b/pnpm11/lockfile/verification/src/satisfiesPackageManifest.ts index e04a19caf2..9a642ff04b 100644 --- a/pnpm11/lockfile/verification/src/satisfiesPackageManifest.ts +++ b/pnpm11/lockfile/verification/src/satisfiesPackageManifest.ts @@ -1,3 +1,4 @@ +import { createMatcher } from '@pnpm/config.matcher' import * as dp from '@pnpm/deps.path' import type { ProjectSnapshot } from '@pnpm/lockfile.types' import { @@ -14,12 +15,22 @@ export function satisfiesPackageManifest ( opts: { autoInstallPeers?: boolean excludeLinksFromLockfile?: boolean + ignoredOptionalDependencies?: string[] }, importer: ProjectSnapshot | undefined, pkg: ProjectManifest ): { satisfies: boolean, detailedReason?: string } { if (!importer) return { satisfies: false, detailedReason: 'no importer' } - let existingDeps: Record = { ...pkg.devDependencies, ...pkg.dependencies, ...pkg.optionalDependencies } + const ignoredOptionalDependencies = new Set( + opts.ignoredOptionalDependencies?.length + ? Object.keys(pkg.optionalDependencies ?? {}) + .filter(createMatcher(opts.ignoredOptionalDependencies)) + : [] + ) + let existingDeps = omitIgnoredDependencies( + { ...pkg.devDependencies, ...pkg.dependencies, ...pkg.optionalDependencies }, + ignoredOptionalDependencies + ) if (opts?.autoInstallPeers) { pkg = { ...pkg, @@ -60,7 +71,9 @@ export function satisfiesPackageManifest ( } for (const depField of DEPENDENCIES_FIELDS) { const importerDeps = importer[depField] ?? {} - let pkgDeps: Record = pkg[depField] ?? {} + let pkgDeps = depField === 'devDependencies' + ? pkg[depField] ?? {} + : omitIgnoredDependencies(pkg[depField], ignoredOptionalDependencies) if (opts?.excludeLinksFromLockfile) { pkgDeps = pickNonLinkedDeps(pkgDeps) } @@ -110,6 +123,17 @@ export function satisfiesPackageManifest ( return { satisfies: true } } +function omitIgnoredDependencies ( + dependencies: Record | undefined, + ignoredDependencies: Set +): Record { + const filteredDependencies = { ...dependencies } + for (const dependency of ignoredDependencies) { + delete filteredDependencies[dependency] + } + return filteredDependencies +} + function countOfNonLinkedDeps (lockfileDeps: { [depName: string]: string }): number { return Object.values(lockfileDeps).filter((ref) => !ref.includes('link:') && !ref.includes('file:')).length } diff --git a/pnpm11/lockfile/verification/test/satisfiesPackageManifest.ts b/pnpm11/lockfile/verification/test/satisfiesPackageManifest.ts index 53c3cdc637..f81a832403 100644 --- a/pnpm11/lockfile/verification/test/satisfiesPackageManifest.ts +++ b/pnpm11/lockfile/verification/test/satisfiesPackageManifest.ts @@ -430,3 +430,30 @@ test('satisfiesPackageManifest()', () => { `, }) }) + +test('satisfiesPackageManifest() ignores configured optional dependencies', () => { + expect(satisfiesPackageManifest( + { + ignoredOptionalDependencies: ['@ignored/*', 'foo'], + }, + { + dependencies: { + required: '1.0.0', + }, + specifiers: { + required: '1.0.0', + }, + }, + { + ...DEFAULT_PKG_FIELDS, + dependencies: { + foo: '1.0.0', + required: '1.0.0', + }, + optionalDependencies: { + '@ignored/pkg': '1.0.0', + foo: '1.0.0', + }, + } + )).toStrictEqual({ satisfies: true }) +}) diff --git a/pnpm11/lockfile/verification/tsconfig.json b/pnpm11/lockfile/verification/tsconfig.json index c0b91beb29..799bf93f1c 100644 --- a/pnpm11/lockfile/verification/tsconfig.json +++ b/pnpm11/lockfile/verification/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../catalogs/types" }, + { + "path": "../../config/matcher" + }, { "path": "../../core/constants" }, diff --git a/pnpr/.fixtures/packages/@pnpm/exe/0.0.0/package.json b/pnpr/.fixtures/packages/@pnpm/exe/0.0.0/package.json new file mode 100644 index 0000000000..8702cf357f --- /dev/null +++ b/pnpr/.fixtures/packages/@pnpm/exe/0.0.0/package.json @@ -0,0 +1,4 @@ +{ + "name": "@pnpm/exe", + "version": "0.0.0-test-current-pnpm" +} diff --git a/pnpr/.fixtures/packages/pnpm/0.0.0/package.json b/pnpr/.fixtures/packages/pnpm/0.0.0/package.json new file mode 100644 index 0000000000..30fd9e094f --- /dev/null +++ b/pnpr/.fixtures/packages/pnpm/0.0.0/package.json @@ -0,0 +1,4 @@ +{ + "name": "pnpm", + "version": "0.0.0-test-current-pnpm" +}