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.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-08-06 12:40:27 +02:00
1 parent ecd540b324
commit f31c7ca00a
28 files changed
+797 -96

No files matched your search

@@ -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.
+3
View File
@@ -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
@@ -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::<Vec<_>>()
.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, &current] {
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, .. } =
@@ -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]
+1 -1
View File
@@ -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(
@@ -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<Lockfile> {
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(&current)
|| 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<OptionalValue, DependencyValue>(
optional_dependencies: &mut Option<HashMap<PkgName, OptionalValue>>,
dependencies: &mut Option<HashMap<PkgName, DependencyValue>>,
matcher: &pacquet_config::matcher::Matcher,
) -> HashSet<PkgName> {
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;
@@ -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()),
);
}
@@ -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;
}
}
}
@@ -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<PkgName, String>,
locked_dependencies: Option<&HashMap<PkgName, SnapshotDepRef>>,
+1 -1
View File
@@ -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::{
@@ -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<Lockfile> {
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,
+14 -15
View File
@@ -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,
@@ -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<ManifestHook>,
second: Option<ManifestHook>,
@@ -258,6 +258,10 @@ pub(super) async fn lockfile_reuse_seed(inputs: ReuseSeedInputs<'_>) -> Option<A
let reusable_settings_lockfile = wanted_lockfile.filter(|lockfile| {
lockfile.package_extensions_checksum.as_deref() == package_extensions_checksum
&& super::ignored_optional_dependencies_match(
lockfile.ignored_optional_dependencies.as_deref(),
config.ignored_optional_dependencies.as_deref(),
)
});
let override_settings_match = reusable_settings_lockfile.is_some_and(|lockfile| {
super::overrides_match(lockfile.overrides.as_ref(), resolved_overrides)
+2
View File
@@ -7,7 +7,9 @@ mod check_custom_resolver_force_resolve;
mod compat_package_extensions;
mod dependencies_graph_to_lockfile;
mod fast_update_catalogs;
mod fast_update_ignored_optional_dependencies;
mod fast_update_importers;
mod fast_update_lockfile;
mod fast_update_overrides;
mod install;
mod install_with_fresh_lockfile;
+25 -2
View File
@@ -62,7 +62,18 @@ impl CommandTempCwd<()> {
/// pnpm 11 reads those from the workspace YAML rather than `.npmrc`.
#[must_use]
pub fn add_mocked_registry(self) -> CommandTempCwd<AddMockedRegistry> {
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<AddMockedRegistry> {
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<AddMockedRegistry> {
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<AddMockedRegistry> {
let store_dir = self.root.path().join("pacquet-store");
let cache_dir = self.root.path().join("pacquet-cache");
@@ -90,7 +109,11 @@ impl CommandTempCwd<()> {
&registry_storage,
substitutions,
);
TestRegistry::start_with_storage(&registry_storage)
if static_registry {
TestRegistry::start_static_with_storage(&registry_storage)
} else {
TestRegistry::start_with_storage(&registry_storage)
}
};
let mocked_registry = mock_instance.url();
let npmrc_text = format!("registry={mocked_registry}\n{npmrc_text}");
+20 -4
View File
@@ -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<TestRegistryInstance> = 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.
@@ -701,6 +701,7 @@ async function assertWantedLockfileUpToDate (
{
autoInstallPeers,
excludeLinksFromLockfile,
ignoredOptionalDependencies: config.ignoredOptionalDependencies,
},
wantedLockfile.importers[projectId],
projectManifest
@@ -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)
@@ -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<ProjectSnapshot, 'dependencies' | 'optionalDependencies'> & {
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
}
}
@@ -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
}
@@ -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:*",
@@ -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,
@@ -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<string, string> = { ...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<string, string> = 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<string, string> | undefined,
ignoredDependencies: Set<string>
): Record<string, string> {
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
}
@@ -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 })
})
@@ -15,6 +15,9 @@
{
"path": "../../catalogs/types"
},
{
"path": "../../config/matcher"
},
{
"path": "../../core/constants"
},
+4
View File
@@ -0,0 +1,4 @@
{
"name": "@pnpm/exe",
"version": "0.0.0-test-current-pnpm"
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "pnpm",
"version": "0.0.0-test-current-pnpm"
}