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" +}