diff --git a/.changeset/tidy-pugs-smile.md b/.changeset/tidy-pugs-smile.md new file mode 100644 index 0000000000..d4c79d2ea2 --- /dev/null +++ b/.changeset/tidy-pugs-smile.md @@ -0,0 +1,7 @@ +--- +"@pnpm/installing.commands": patch +"pnpm": patch +"pacquet": patch +--- + +`pnpm update @` now fails with `ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP` when the package is not a direct dependency of any selected project, instead of quietly updating it to whatever a fresh install would resolve. There is nowhere to record the version in that case, so the request cannot be honored, and the error points at the `overrides` entry that does pin a transitive dependency. Ranges and tags are unaffected, and a package that any selected project declares directly still takes its version as before. diff --git a/pnpm/crates/cli/tests/suite/update.rs b/pnpm/crates/cli/tests/suite/update.rs index 938397b3ff..7c20763c22 100644 --- a/pnpm/crates/cli/tests/suite/update.rs +++ b/pnpm/crates/cli/tests/suite/update.rs @@ -361,14 +361,14 @@ fn update_transitive_glob_mixed_with_direct_selector() { drop((root, anchor)); } -/// `pacquet update @` on a package that is only present -/// as a transitive dependency ignores the version part: there is no -/// manifest entry to write it into, and an update resolves the target -/// the way a fresh install would. The version part triggers a warning -/// recommending a `pnpm.overrides` entry — the mechanism that does pin -/// transitive dependencies. +/// `pacquet update @` on a package that is only present as a +/// transitive dependency has no manifest entry to write the version into, and +/// an update resolves the target the way a fresh install would — so the +/// version could only reach the lockfile as an entry nothing backs. The +/// command fails and points at `overrides`, the mechanism that does pin a +/// transitive dependency. #[test] -fn update_transitive_ignores_requested_version() { +fn update_transitive_rejects_a_requested_version() { let (root, workspace, anchor) = setup(); // Pin the transitive dep-of-pkg-with-1-dep at 100.0.0 (via a direct @@ -380,24 +380,26 @@ fn update_transitive_ignores_requested_version() { write_manifest(&workspace, &format!(r#"{{ "{PARENT}": "100.0.0" }}"#)); - // The update requests 100.0.0, but the version part of a - // transitive-only selector is ignored: the target re-resolves to the - // highest version in pkg-with-1-dep's ^100.0.0 range (100.1.0), - // exactly as a fresh install with the target's lockfile entries - // deleted would. - pacquet(&workspace, ["update", &format!("{DEP}@100.0.0")]).assert().success(); + let output = pacquet(&workspace, ["update", &format!("{DEP}@100.0.0")]) + .output() + .expect("run pacquet update"); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + eprintln!("STATUS: {}\nOUTPUT:\n{rendered}", output.status); + + assert!(!output.status.success(), "a version that cannot be recorded should fail"); + assert!( + rendered.contains("ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP"), + "the failure must carry the UPDATE_VERSION_ON_INDIRECT_DEP code", + ); eprintln!("virtual store contents: {:?}", list_virtual_store(&workspace)); - // Only presence is asserted: the update does not prune the previous - // version's now-orphaned virtual-store directory. assert!( - virtual_store_has(&workspace, "@pnpm.e2e+dep-of-pkg-with-1-dep@100.1.0"), - "the target should re-resolve to highest-in-range, like a fresh install", - ); - let lock = fs::read_to_string(workspace.join("pnpm-lock.yaml")).expect("read pnpm-lock.yaml"); - assert!( - !lock.contains("dep-of-pkg-with-1-dep@100.0.0"), - "the ignored requested version must not pin the target in the lockfile", + !virtual_store_has(&workspace, "@pnpm.e2e+dep-of-pkg-with-1-dep@100.1.0"), + "a rejected update must not have resolved anything", ); drop((root, anchor)); diff --git a/pnpm/crates/cli/tests/suite/update_recursive.rs b/pnpm/crates/cli/tests/suite/update_recursive.rs index d367461f58..587fd20c63 100644 --- a/pnpm/crates/cli/tests/suite/update_recursive.rs +++ b/pnpm/crates/cli/tests/suite/update_recursive.rs @@ -27,6 +27,8 @@ const PRINT_VERSION: &str = "@pnpm.e2e/print-version"; /// package in the other project that the selectors also name. The /// fixture registry has no copy of that package. const MULTI_VERSION_B: &str = "@pnpm.e2e/multi-version-b"; +/// Depends on `@pnpm.e2e/dep-of-pkg-with-1-dep@^100.0.0`. +const PKG_WITH_DEP: &str = "@pnpm.e2e/pkg-with-1-dep"; fn setup() -> (TempDir, std::path::PathBuf, AddMockedRegistry) { let CommandTempCwd { root, workspace, npmrc_info, .. } = @@ -278,6 +280,128 @@ fn recursive_update_prod_dependencies_only() { drop((root, anchor)); } +/// The rendered stdout+stderr of `pacquet` run in `workspace` with `args`, +/// alongside its exit status. +fn pacquet_output(workspace: &Path, args: &[&str]) -> (std::process::ExitStatus, String) { + let output = pacquet(workspace, args).output().expect("run pacquet"); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + eprintln!("STATUS: {}\nOUTPUT:\n{rendered}", output.status); + (output.status, rendered) +} + +/// A versioned selector that matches no direct dependency reaches its target +/// through the resolver alone, where the version has nowhere to be recorded. +/// Resolving to something else and exiting 0 would leave the caller nothing to +/// read, so this fails. +#[test] +fn recursive_update_rejects_a_version_for_a_transitive_only_selector() { + let (root, workspace, anchor) = setup(); + + write_workspace( + &workspace, + &[( + "project-1", + json!({ "name": "project-1", "version": "1.0.0", + "dependencies": { PKG_WITH_DEP: "100.0.0" } }), + )], + ); + pacquet(&workspace, ["install"]).assert().success(); + + let (status, rendered) = + pacquet_output(&workspace, &["-r", "update", &format!("{DEP}@100.1.0")]); + + assert!(!status.success(), "a version that cannot be recorded should fail the command"); + assert!( + rendered.contains("ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP"), + "the failure must carry the UPDATE_VERSION_ON_INDIRECT_DEP code", + ); + // miette wraps the message, so assert on fragments that survive a line break. + assert!( + rendered.contains(&format!(r#""{DEP}" (requested "100.1.0")"#)), + "the failure must name the selector and the version it could not record", + ); + assert!( + rendered.contains(&format!("{DEP}@: 100.1.0")), + "the failure must show the override that does pin a transitive dependency", + ); + + drop((root, anchor)); +} + +/// A selector the workspace declares directly somewhere is legitimately +/// versioned, even where a sibling project only reaches it transitively. +#[test] +fn recursive_update_accepts_a_version_declared_by_any_project() { + let (root, workspace, anchor) = setup(); + + write_workspace( + &workspace, + &[ + ( + "project-1", + json!({ "name": "project-1", "version": "1.0.0", + "dependencies": { PKG_WITH_DEP: "100.0.0" } }), + ), + ( + "project-2", + json!({ "name": "project-2", "version": "1.0.0", + "dependencies": { DEP: "100.0.0" } }), + ), + ], + ); + pacquet(&workspace, ["install"]).assert().success(); + + let (status, rendered) = + pacquet_output(&workspace, &["-r", "update", &format!("{DEP}@100.1.0")]); + + assert!(status.success(), "project-2 declares it, so the version has somewhere to go"); + assert!( + !rendered.contains("ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP"), + "a selector declared by any project must not be rejected", + ); + assert_eq!( + installed_version(&workspace.join("project-2"), DEP).as_deref(), + Some("100.1.0"), + "the declaring project should have been updated", + ); + + drop((root, anchor)); +} + +/// A selector that names no single version -- a tag or a range -- has nothing +/// to record either, but updating within the dependents' ranges is a +/// reasonable reading of it. Those warn rather than fail. +#[test] +fn recursive_update_allows_a_tag_for_a_transitive_only_selector() { + let (root, workspace, anchor) = setup(); + + write_workspace( + &workspace, + &[( + "project-1", + json!({ "name": "project-1", "version": "1.0.0", + "dependencies": { PKG_WITH_DEP: "100.0.0" } }), + )], + ); + pacquet(&workspace, ["install"]).assert().success(); + + let (status, rendered) = + pacquet_output(&workspace, &["-r", "update", &format!("{DEP}@latest")]); + + assert!(status.success(), "a tag is not a version that has to be recorded"); + assert!( + rendered.contains(&format!(r#""{DEP}" is not a direct dependency"#)) + && rendered.contains(r#"the requested "latest" is ignored"#), + "the user should still be told the tag had no effect", + ); + + drop((root, anchor)); +} + /// Ports `recursive update with pattern`. #[test] fn recursive_update_with_pattern() { @@ -422,6 +546,37 @@ fn recursive_update_latest_only_reaches_the_named_packages() { drop((root, anchor)); } +/// At `--depth 0` a transitive dependency is never traversed, so a selector +/// that names one is out of scope rather than an unrecordable request — even +/// alongside a selector that does match a direct dependency. +#[test] +fn recursive_update_depth_zero_leaves_an_indirect_selector_out_of_scope() { + let (root, workspace, anchor) = setup(); + + write_workspace( + &workspace, + &[( + "project-1", + json!({ "name": "project-1", "version": "1.0.0", + "dependencies": { PKG_WITH_DEP: "100.0.0", FOO: "100.0.0" } }), + )], + ); + pacquet(&workspace, ["install"]).assert().success(); + + let (status, rendered) = pacquet_output( + &workspace, + &["-r", "update", "--depth", "0", &format!("{FOO}@100.0.0"), &format!("{DEP}@100.1.0")], + ); + + assert!(status.success(), "an untraversed selector must not fail the command"); + assert!( + !rendered.contains("ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP"), + "depth 0 leaves the indirect selector out of scope", + ); + + drop((root, anchor)); +} + /// Ports `recursive update --latest foo should only update packages that /// have foo`, over a lockfile per project. #[test] @@ -462,3 +617,35 @@ fn recursive_update_latest_with_dedicated_lockfiles_only_touches_the_declaring_p drop((root, anchor)); } + +/// `--latest` rejects every versioned selector on its own, direct or not, and +/// has to report that ahead of the indirect-version check. +#[test] +fn recursive_update_latest_reports_the_spec_ban_first() { + let (root, workspace, anchor) = setup(); + + write_workspace( + &workspace, + &[( + "project-1", + json!({ "name": "project-1", "version": "1.0.0", + "dependencies": { PKG_WITH_DEP: "100.0.0" } }), + )], + ); + pacquet(&workspace, ["install"]).assert().success(); + + let (status, rendered) = + pacquet_output(&workspace, &["-r", "update", "--latest", &format!("{DEP}@100.1.0")]); + + assert!(!status.success(), "a versioned selector with --latest should fail"); + assert!( + rendered.contains("ERR_PNPM_LATEST_WITH_SPEC"), + "--latest owns this failure: {rendered}", + ); + assert!( + !rendered.contains("ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP"), + "the indirect-version check must not preempt it", + ); + + drop((root, anchor)); +} diff --git a/pnpm/crates/package-manager/src/update.rs b/pnpm/crates/package-manager/src/update.rs index b171c50ac6..341a77aa42 100644 --- a/pnpm/crates/package-manager/src/update.rs +++ b/pnpm/crates/package-manager/src/update.rs @@ -165,6 +165,16 @@ pub enum UpdateError { #[diagnostic(code(ERR_PNPM_NO_PACKAGE_IN_DEPENDENCIES))] NoPackageInDependencies, + /// A versioned selector named a package no selected project declares + /// directly, so there is nowhere to record the requested version. + #[display("{message}")] + #[diagnostic(code(ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP), help("{hint}"))] + UpdateVersionOnIndirectDep { + #[error(not(source))] + message: String, + hint: String, + }, + /// A `--workspace` selector named a dependency that no workspace /// project publishes. #[display(r#""{_0}" not found in the workspace"#)] @@ -306,6 +316,16 @@ impl Update<'_> { } let lockfile_specifier_project_manifests = (!save).then(|| vec![(manifest_dir.clone(), manifest.clone())]); + if !latest && depth > 0 { + let selectors = + packages.iter().map(|input| parse_update_param(input)).collect::>(); + reject_versions_of_indirect_update_specs::( + &selectors, + &[manifest], + &include_direct, + &package_manifest_prefix(manifest), + )?; + } let mut latest_chain = None; let Some(prepared) = prepare_manifest::( manifest, @@ -912,18 +932,6 @@ async fn prepare_manifest( } } } - for selector in &selectors { - let Some(version) = selector.version.as_deref() else { continue }; - tracing::warn!( - target: "pnpm_package_manager::update", - pattern = selector.pattern, - version, - r#""{}" is not a direct dependency, so the requested version "{version}" is ignored — "{}" is updated to what a fresh install would resolve. To force a version of a transitive dependency, add an override scoped to the range its dependents declare to pnpm-workspace.yaml, e.g.: overrides: {{ "{}@": "{version}" }}"#, - selector.pattern, - selector.pattern, - selector.pattern, - ); - } } else { if latest && !save { emit_latest_ignored::(rewrite_ctx.manifest); @@ -1125,6 +1133,23 @@ async fn prepare_selected_manifests( let mut workspace_dir_for_catalogs = None; let mut any_work = false; + // Once per command, across every selected project: a selector that is a + // direct dependency of one project is legitimately versioned even where a + // sibling only reaches it transitively. `--depth 0` reports + // `NoPackageInDependencies` instead, and `--latest` rejects versioned + // selectors outright. + if !latest && depth > 0 { + let selectors = packages.iter().map(|input| parse_update_param(input)).collect::>(); + let manifests = + selected_indices.iter().map(|&index| &projects[index].manifest).collect::>(); + reject_versions_of_indirect_update_specs::( + &selectors, + &manifests, + include_direct, + &workspace_root.to_string_lossy(), + )?; + } + for &index in selected_indices { let Some(prepared) = prepare_manifest::( &mut projects[index].manifest, @@ -1469,6 +1494,91 @@ fn insert_update_target(targets: &mut UpdateTargets, selectors: &[ParsedSelector } } +/// Whether any of `manifests` declares a dependency `selector` names, so the +/// update has a manifest entry to write the requested version into. +fn selector_matches_a_direct_dependency( + selector: &ParsedSelector, + manifests: &[&PackageManifest], + include_direct: &[DependencyGroup], +) -> bool { + let matcher = matcher_one(&selector.pattern); + manifests.iter().any(|manifest| { + manifest.dependencies(include_direct.iter().copied()).any(|(name, _)| matcher.matches(name)) + }) +} + +/// `pacquet update @` where `` matches no direct dependency +/// has nowhere to record the version. An update resolves such a target the way +/// a fresh install would -- which a command-line version cannot influence -- so +/// honoring the request would mean writing a lockfile entry no manifest backs, +/// and the next fresh resolve would undo it. Neither npm nor Yarn accepts a +/// version here either. Fail rather than resolve to something else and leave +/// the caller a zero exit status to read. +/// +/// A range or a tag names no single version to record, so updating within the +/// dependents' ranges is a reasonable reading of it: those only warn. A +/// negated selector excludes names rather than requesting one, so it is not +/// judged here at all. +/// +/// The override the hint recommends is scoped to the dependents' declared +/// range so it cannot violate any consumer's range; that range lives in the +/// dependents' manifests, which this layer does not read, hence the +/// placeholder. +fn reject_versions_of_indirect_update_specs( + selectors: &[ParsedSelector], + manifests: &[&PackageManifest], + include_direct: &[DependencyGroup], + prefix: &str, +) -> Result<(), UpdateError> { + let mut pinned = Vec::new(); + for selector in selectors { + let Some(version) = selector.version.as_deref() else { continue }; + // A negated selector excludes names; a version on one asks for nothing. + if selector.pattern.starts_with('!') + || selector_matches_a_direct_dependency(selector, manifests, include_direct) + { + continue; + } + let pattern = &selector.pattern; + if node_semver::Version::parse(version).is_err() { + Reporter::emit(&LogEvent::Pnpm(PnpmLog { + level: LogLevel::Warn, + message: format!( + r#""{pattern}" is not a direct dependency, so the requested "{version}" is ignored — "{pattern}" is updated to what a fresh install would resolve."#, + ), + prefix: prefix.to_string(), + })); + continue; + } + pinned.push((pattern.clone(), version.to_string())); + } + if pinned.is_empty() { + return Ok(()); + } + let subjects = pinned + .iter() + .map(|(pattern, version)| format!(r#""{pattern}" (requested "{version}")"#)) + .collect::>() + .join(", "); + let tail = if pinned.len() == 1 { + "is not a direct dependency, so the requested version cannot" + } else { + "are not direct dependencies, so the requested versions cannot" + }; + let overrides = pinned + .iter() + .map(|(pattern, version)| format!(" {pattern}@: {version}")) + .collect::>() + .join("\n"); + let names = pinned.iter().map(|(pattern, _)| pattern.as_str()).collect::>().join(" "); + Err(UpdateError::UpdateVersionOnIndirectDep { + message: format!("{subjects} {tail} be recorded."), + hint: format!( + "An update resolves a transitive dependency the way a fresh install would, so a version on the command line has no effect on it. To pin one, add an override scoped to the range its dependents declare to pnpm-workspace.yaml:\n\n overrides:\n{overrides}\n\nTo update it within the range its dependents already declare, drop the version: pnpm update {names}", + ), + }) +} + /// The name an update target for `matched` is keyed by. A manifest keys a /// dependency by its alias, but the resolver matches update targets — and /// [`UpdateSeedPolicy::DropOnly`] keys them — by the package name the edge diff --git a/pnpm/crates/package-manager/src/update/tests.rs b/pnpm/crates/package-manager/src/update/tests.rs index 1703ee362c..4112d4dfa5 100644 --- a/pnpm/crates/package-manager/src/update/tests.rs +++ b/pnpm/crates/package-manager/src/update/tests.rs @@ -2,7 +2,7 @@ use super::{ KeptRangeVerdict, UpdateError, apply_bumped_manifest_specs, expand_update_selectors, insert_update_target, is_workspace_local_path_specifier, judge_against_kept_range, parse_update_param, persist_selected_manifests, prepare_selected_manifests, - selected_project_indices, update_target_name, + reject_versions_of_indirect_update_specs, selected_project_indices, update_target_name, }; use pnpm_config::{CatalogMode, Config}; use pnpm_network::ThrottledClient; @@ -628,3 +628,58 @@ fn keeps_a_negated_alias_selector_negated() { assert_eq!(expanded[1].pattern, "!foo"); } + +/// Run the indirect-version check over `selectors` against a single manifest +/// declaring `foo` directly. +fn reject_indirect(selectors: &[&str]) -> Result<(), super::UpdateError> { + let dir = tempdir().expect("create temp dir"); + let package_json = dir.path().join("package.json"); + std::fs::write( + &package_json, + json!({ "name": "a", "dependencies": { "foo": "^1.0.0" } }).to_string(), + ) + .expect("write package.json"); + let manifest = PackageManifest::from_path(package_json).expect("read package.json"); + let parsed = selectors.iter().map(|input| parse_update_param(input)).collect::>(); + reject_versions_of_indirect_update_specs::( + &parsed, + &[&manifest], + &[DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], + "prefix", + ) +} + +#[test] +fn an_exact_version_nothing_declares_directly_is_rejected() { + let err = reject_indirect(&["bar@1.2.3"]).expect_err("bar is not a direct dependency"); + let rendered = err.to_string(); + assert!(rendered.contains(r#""bar" (requested "1.2.3")"#), "{rendered}"); +} + +#[test] +fn a_version_any_manifest_declares_directly_is_accepted() { + reject_indirect(&["foo@1.2.3"]).expect("foo is a direct dependency"); +} + +#[test] +fn a_negated_selector_is_not_judged() { + // `!bar` excludes a name; the version on it requests nothing. Checked with + // no manifests too: with one, the "everything but bar" matcher happens to + // match some other direct dependency and hides the misclassification. + reject_indirect(&["!bar@1.2.3"]).expect("a negated selector requests no version"); + let parsed = [parse_update_param("!bar@1.2.3")]; + reject_versions_of_indirect_update_specs::( + &parsed, + &[], + &[DependencyGroup::Prod], + "prefix", + ) + .expect("a negated selector requests no version"); +} + +#[test] +fn a_range_or_a_tag_is_not_rejected() { + for selector in ["bar@^1.2.3", "bar@latest"] { + reject_indirect(&[selector]).unwrap_or_else(|err| panic!("{selector}: {err}")); + } +} diff --git a/pnpm11/installing/commands/src/installDeps.ts b/pnpm11/installing/commands/src/installDeps.ts index b909751fa5..8cfba10796 100644 --- a/pnpm11/installing/commands/src/installDeps.ts +++ b/pnpm11/installing/commands/src/installDeps.ts @@ -22,7 +22,7 @@ import { } from '@pnpm/installing.deps-installer' import { writeWantedLockfile } from '@pnpm/lockfile.fs' import type { LockfileObject } from '@pnpm/lockfile.types' -import { globalInfo, globalWarn, logger } from '@pnpm/logger' +import { globalInfo, logger } from '@pnpm/logger' import { applyRuntimeOnFailOverride, filterDependenciesByType } from '@pnpm/pkg-manifest.utils' import { getRangeSpecStyle } from '@pnpm/pkg-manifest.utils' import type { PreferredVersions, VersionSelectors } from '@pnpm/resolving.resolver-base' @@ -48,9 +48,9 @@ import { setupPolicyHandlers } from './policyHandlers.js' import { type CommandFullName, createMatcher, + failOnVersionsOfIndirectUpdateSpecs, makeIgnorePatterns, matchDependencies, - parseUpdateParam, recursive, type RecursiveOptions, type UpdateDepsMatcher, @@ -392,7 +392,13 @@ export async function installDeps ( // Don't update package.json in this case, and limit updates to only matching dependencies updatePackageManifest = false updateMatching = (pkgName: string) => updateMatch!(pkgName) != null - warnAboutIgnoredVersionsOfIndirectUpdateSpecs(updateSpecs) + } + // At `--depth 0` an indirect dependency is never traversed, so a selector + // that names one is simply out of scope rather than a version pnpm has + // nowhere to record. `--latest` rejects every versioned selector on its + // own, direct or not, and has to report that first. + if (!opts.latest && (opts.depth ?? Infinity) > 0) { + failOnVersionsOfIndirectUpdateSpecs(updateSpecs, [manifest], includeDirect) } } @@ -599,25 +605,6 @@ function getVulnerabilityPenalty (severity: VulnerabilitySeverity): number { } } -/** - * `pnpm update @` where `` matches only transitive - * dependencies has no manifest entry to write the version into, and an - * update resolves the target the same way a fresh install would — which a - * command-line version cannot influence. Tell the user the version part is - * ignored, and that an override is the mechanism that does pin a - * transitive dependency. The recommended override is scoped to the - * dependents' declared range so it cannot violate any consumer's range; - * the range itself is not known at this layer (it lives in the dependents' - * manifests), hence the placeholder. - */ -function warnAboutIgnoredVersionsOfIndirectUpdateSpecs (updateSpecs: string[]): void { - for (const spec of updateSpecs) { - const { pattern, versionSpec } = parseUpdateParam(spec) - if (versionSpec == null) continue - globalWarn(`"${pattern}" is not a direct dependency, so the requested version "${versionSpec}" is ignored — "${pattern}" is updated to what a fresh install would resolve. To force a version of a transitive dependency, add an override scoped to the range its dependents declare to pnpm-workspace.yaml, e.g.: overrides: { "${pattern}@": "${versionSpec}" }`) - } -} - /** * The `updateMatching` predicate of `pnpm audit --fix`: a package is an * update target when its resolved version is vulnerable. The resolver calls diff --git a/pnpm11/installing/commands/src/recursive.ts b/pnpm11/installing/commands/src/recursive.ts index 8015e08084..93604af746 100755 --- a/pnpm11/installing/commands/src/recursive.ts +++ b/pnpm11/installing/commands/src/recursive.ts @@ -31,14 +31,10 @@ import { type UpdateMatchingFunction, type WorkspacePackages, } from '@pnpm/installing.deps-installer' -import { logger } from '@pnpm/logger' +import { globalWarn, logger } from '@pnpm/logger' import { filterDependenciesByType } from '@pnpm/pkg-manifest.utils' import { getRangeSpecStyle } from '@pnpm/pkg-manifest.utils' -import { - DIRECT_DEP_SELECTOR_WEIGHT, - type PreferredVersions, - type ResolutionVerifier, -} from '@pnpm/resolving.resolver-base' +import type { PreferredVersions, ResolutionVerifier } from '@pnpm/resolving.resolver-base' import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager' import type { StoreController } from '@pnpm/store.controller' import type { @@ -249,9 +245,14 @@ export async function recursive ( } else { updateMatch = null } - const preferredVersions = cmdFullName === 'update' - ? createPreferredVersionsFromPinnedUpdateSpecs(params.flatMap(expandUpdateSelectorsForMatching), opts.preferredVersions) - : opts.preferredVersions + // At `--depth 0` a selector that matches no direct dependency is already + // `NO_PACKAGE_IN_DEPENDENCIES` below; only a deeper update reaches the + // transitive copy whose version cannot be recorded. `--latest` rejects every + // versioned selector on its own, direct or not, and has to report that + // first. + if (updateMatch != null && !opts.latest && (opts.depth ?? Infinity) > 0) { + failOnVersionsOfIndirectUpdateSpecs(params, pkgs.map(({ manifest }) => manifest), includeDirect) + } // For a workspace with shared lockfile if (opts.lockfileDir && ['add', 'install', 'remove', 'update', 'import'].includes(cmdFullName)) { let importers = getImporters(opts) @@ -356,7 +357,6 @@ export async function recursive ( dryRunResult, } = await mutateModules(mutatedImporters, { ...installOpts, - preferredVersions, storeController: store.ctrl, resolutionVerifiers: store.resolutionVerifiers, }) @@ -434,7 +434,7 @@ export async function recursive ( & OptionsFromRootManifest & Project & Pick - & { preferredVersions?: PreferredVersions, rangeSpecStyle: RangeSpecStyle } + & { rangeSpecStyle: RangeSpecStyle } interface ActionResult { updatedCatalogs?: Catalogs @@ -492,7 +492,6 @@ export async function recursive ( savePrefix: typeof localConfig.savePrefix === 'string' ? localConfig.savePrefix : opts.savePrefix, }), configByUri: installOpts.configByUri, - preferredVersions, storeController: store.ctrl, resolutionVerifiers: store.resolutionVerifiers, } @@ -660,36 +659,69 @@ function parseVersionLine (versionSpec: string): { major: number, minor: number } /** - * A selector that pins an exact version also steers the resolver towards it: - * the pinned version, plus a cap so a dependent whose range excludes it still - * stays below it. Both weigh just above `DIRECT_DEP_SELECTOR_WEIGHT`, so they - * outrank the ranges the manifests declare but stay under the pins the - * lockfile seeds — an edge outside the update keeps its locked version, while - * an edge the update targets has had those pins stripped by then and follows - * the request. Negated and glob patterns name no single package to prefer a - * version for. + * `pnpm update @` where `` matches no direct dependency has + * nowhere to record the version. An update resolves such a target the same way + * a fresh install would — which a command-line version cannot influence — so + * honoring the request would mean writing a lockfile entry no manifest backs, + * and the next fresh resolve would undo it. Neither npm nor Yarn accepts a + * version here either. Fail rather than resolve to something else and leave + * the caller a zero exit status to read. + * + * A range or a tag is not held to the same standard: it names no single + * version to record, and updating within the dependents' ranges is a + * reasonable reading of it. Those keep the warning they have always had. + * + * The override the hint recommends is scoped to the dependents' declared range + * so it cannot violate any consumer's range; that range lives in the + * dependents' manifests, which this layer does not read, hence the + * placeholder. */ -export function createPreferredVersionsFromPinnedUpdateSpecs ( - params: string[], - preferredVersions?: PreferredVersions -): PreferredVersions | undefined { +export function failOnVersionsOfIndirectUpdateSpecs ( + updateSpecs: string[], + manifests: ProjectManifest[], + include: IncludedDependencies +): void { const pinned: Array<{ pattern: string, version: string }> = [] - for (const param of params) { - const { pattern, versionSpec } = parseUpdateParam(param) - if (versionSpec == null || pattern[0] === '!' || pattern.includes('*')) continue + for (const spec of updateSpecs) { + const { pattern, versionSpec } = parseUpdateParam(spec) + // A negated selector excludes names; a version on one asks for nothing. + if (versionSpec == null || pattern[0] === '!') continue + if (matchesADirectDependency(pattern, manifests, include)) continue const version = parseExactVersion(versionSpec) - if (version != null) pinned.push({ pattern, version }) + if (version == null) { + globalWarn(`"${pattern}" is not a direct dependency, so the requested "${versionSpec}" is ignored — "${pattern}" is updated to what a fresh install would resolve.`) + continue + } + pinned.push({ pattern, version }) } - if (pinned.length === 0) return preferredVersions - // A null prototype keeps a package named `__proto__` out of the prototype chain. - const mergedPreferredVersions: PreferredVersions = Object.assign(Object.create(null), preferredVersions) - for (const { pattern, version } of pinned) { - mergedPreferredVersions[pattern] = Object.assign(Object.create(null), mergedPreferredVersions[pattern], { - [version]: { selectorType: 'version', weight: DIRECT_DEP_SELECTOR_WEIGHT + 1 }, - [`<=${version}`]: { selectorType: 'range', weight: DIRECT_DEP_SELECTOR_WEIGHT + 1 }, + if (pinned.length === 0) return + const subjects = pinned.map(({ pattern, version }) => `"${pattern}" (requested "${version}")`) + const overrides = pinned.map(({ pattern, version }) => ` ${pattern}@: ${version}`) + throw new PnpmError('UPDATE_VERSION_ON_INDIRECT_DEP', + `${subjects.join(', ')} ${pinned.length === 1 ? 'is not a direct dependency, so the requested version cannot' : 'are not direct dependencies, so the requested versions cannot'} be recorded.`, + { + hint: `An update resolves a transitive dependency the way a fresh install would, so a version on the command line has no effect on it. To pin one, add an override scoped to the range its dependents declare to pnpm-workspace.yaml: + + overrides: +${overrides.join('\n')} + +To update it within the range its dependents already declare, drop the version: pnpm update ${pinned.map(({ pattern }) => pattern).join(' ')}`, }) - } - return mergedPreferredVersions +} + +/** + * Whether any of `manifests` declares a dependency `pattern` names, so the + * update has a manifest entry to write the requested version into. A pattern + * that matches nothing directly reaches its target only through the resolver, + * which the version cannot steer. + */ +function matchesADirectDependency ( + pattern: string, + manifests: ProjectManifest[], + include: IncludedDependencies +): boolean { + const match = createMatcher([pattern]) + return manifests.some((manifest) => matchDependencies(match, manifest, include).length > 0) } export type UpdateDepsMatcher = (input: string) => string | null diff --git a/pnpm11/installing/commands/test/recursiveHelpers.test.ts b/pnpm11/installing/commands/test/recursiveHelpers.test.ts index 2b9a55bce0..2252841fcf 100644 --- a/pnpm11/installing/commands/test/recursiveHelpers.test.ts +++ b/pnpm11/installing/commands/test/recursiveHelpers.test.ts @@ -1,10 +1,16 @@ import { expect, test } from '@jest/globals' -import { DIRECT_DEP_SELECTOR_WEIGHT } from '@pnpm/resolving.resolver-base' +import type { PnpmError } from '@pnpm/error' +import type { ProjectManifest } from '@pnpm/types' -import { - createPreferredVersionsFromPinnedUpdateSpecs, - createUpdateMatching, -} from '../lib/recursive.js' +import { createUpdateMatching, failOnVersionsOfIndirectUpdateSpecs } from '../lib/recursive.js' + +const INCLUDE_ALL = { + dependencies: true, + devDependencies: true, + optionalDependencies: true, +} + +const MANIFESTS: ProjectManifest[] = [{ dependencies: { foo: '^1.0.0' } }] test('createUpdateMatching() does not match other major versions for pinned selectors', () => { const updateMatching = createUpdateMatching(['js-yaml@3.15.1']) @@ -65,33 +71,38 @@ test('createUpdateMatching() scopes exact alias selectors by version line', () = expect(updateMatching('other-pkg', '1.0.0')).toBeFalsy() }) -test('createPreferredVersionsFromPinnedUpdateSpecs() seeds exact and cap selectors', () => { - const preferredVersions = createPreferredVersionsFromPinnedUpdateSpecs(['js-yaml@3.15.1']) +test('failOnVersionsOfIndirectUpdateSpecs() rejects an exact version nothing declares directly', () => { + let err!: PnpmError + try { + failOnVersionsOfIndirectUpdateSpecs(['bar@1.2.3'], MANIFESTS, INCLUDE_ALL) + } catch (_err: unknown) { + err = _err as PnpmError + } - expect(preferredVersions).toBeTruthy() - expect(preferredVersions?.['js-yaml']?.['3.15.1']).toStrictEqual({ - selectorType: 'version', - weight: DIRECT_DEP_SELECTOR_WEIGHT + 1, - }) - expect(preferredVersions?.['js-yaml']?.['<=3.15.1']).toStrictEqual({ - selectorType: 'range', - weight: DIRECT_DEP_SELECTOR_WEIGHT + 1, - }) + expect(err.code).toBe('ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP') + expect(err.message).toContain('"bar" (requested "1.2.3")') + expect(err.hint).toContain('bar@: 1.2.3') }) -test('createPreferredVersionsFromPinnedUpdateSpecs() normalizes loose exact selectors', () => { - const preferredVersions = createPreferredVersionsFromPinnedUpdateSpecs(['js-yaml@v3.15.1']) - - expect(Object.keys(preferredVersions?.['js-yaml'] ?? {})).toStrictEqual(['3.15.1', '<=3.15.1']) +test('failOnVersionsOfIndirectUpdateSpecs() accepts a version any manifest declares directly', () => { + expect(() => { + failOnVersionsOfIndirectUpdateSpecs(['foo@1.2.3'], MANIFESTS, INCLUDE_ALL) + }).not.toThrow() }) -test('createPreferredVersionsFromPinnedUpdateSpecs() ignores non-exact and negated patterns', () => { - const preferredVersions = createPreferredVersionsFromPinnedUpdateSpecs([ - '!js-yaml@3.15.1', - 'js-yaml@^3.15.1', - 'js-yaml@latest', - 'js-yaml*', - ]) - - expect(preferredVersions).toBeUndefined() +test('failOnVersionsOfIndirectUpdateSpecs() ignores negated selectors', () => { + // `!bar` excludes a name; the version on it requests nothing, and the + // "everything but bar" matcher must not decide whether `bar` is direct. + expect(() => { + failOnVersionsOfIndirectUpdateSpecs(['!bar@1.2.3'], MANIFESTS, INCLUDE_ALL) + failOnVersionsOfIndirectUpdateSpecs(['!bar@1.2.3'], [], INCLUDE_ALL) + }).not.toThrow() +}) + +test('failOnVersionsOfIndirectUpdateSpecs() lets a range or a tag through', () => { + for (const spec of ['bar@^1.2.3', 'bar@latest']) { + expect(() => { + failOnVersionsOfIndirectUpdateSpecs([spec], MANIFESTS, INCLUDE_ALL) + }).not.toThrow() + } }) diff --git a/pnpm11/installing/commands/test/update/update.ts b/pnpm11/installing/commands/test/update/update.ts index cf16b26a27..ac0528774f 100644 --- a/pnpm11/installing/commands/test/update/update.ts +++ b/pnpm11/installing/commands/test/update/update.ts @@ -139,7 +139,7 @@ test('update transitive dependency when mixed with a direct dependency selector' expect(lockfile.packages['@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']).toBeTruthy() }) -test('update of a transitive dependency ignores the requested version and resolves like a fresh install', async () => { +test('update of a transitive dependency rejects the requested version', async () => { // @pnpm.e2e/pkg-with-good-optional depends on @pnpm.e2e/dep-of-pkg-with-1-dep via "*". await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.0.0', distTag: 'latest' }) @@ -156,26 +156,78 @@ test('update of a transitive dependency ignores the requested version and resolv expect(project.readLockfile().packages['@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0']).toBeTruthy() - // The update requests 100.1.0, but a transitive dependency has no manifest - // entry to carry a version, and updates resolve the target the way a fresh - // install would — so the requested version is ignored (with a warning - // recommending an override) and the "*" range resolves to the new latest. + let err!: PnpmError + try { + await update.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }, ['@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + } catch (_err: unknown) { + err = _err as PnpmError + } + + expect(err.code).toBe('ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP') + expect(err.hint).toContain('@pnpm.e2e/dep-of-pkg-with-1-dep@: 100.1.0') + // Nothing was resolved, so the lockfile still holds what the install wrote. + expect(project.readLockfile().packages['@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0']).toBeTruthy() +}) + +test('update --depth 0 leaves an indirect selector out of scope', async () => { + await addDistTag({ package: '@pnpm.e2e/foo', version: '100.0.0', distTag: 'latest' }) + + const project = prepare({ + dependencies: { + '@pnpm.e2e/foo': '100.0.0', + '@pnpm.e2e/pkg-with-good-optional': '1.0.0', + }, + }) + + await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }) + + // One selector matches a direct dependency and one only a transitive copy. + // At depth 0 the transitive one is never traversed, so it is out of scope + // rather than a version pnpm has nowhere to record. + await update.handler({ + ...DEFAULT_OPTS, + depth: 0, + dir: process.cwd(), + }, ['@pnpm.e2e/foo@100.0.0', '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + + expect(project.readLockfile().packages['@pnpm.e2e/foo@100.0.0']).toBeTruthy() +}) + +test('update of a transitive dependency without a version resolves like a fresh install', async () => { + await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.0.0', distTag: 'latest' }) + + const project = prepare({ + dependencies: { + '@pnpm.e2e/pkg-with-good-optional': '1.0.0', + }, + }) + + await install.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }) + await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '101.0.0', distTag: 'latest' }) await update.handler({ ...DEFAULT_OPTS, dir: process.cwd(), - }, ['@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + }, ['@pnpm.e2e/dep-of-pkg-with-1-dep']) const lockfile = project.readLockfile() expect(lockfile.packages['@pnpm.e2e/dep-of-pkg-with-1-dep@101.0.0']).toBeTruthy() expect(lockfile.packages['@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0']).toBeFalsy() - expect(lockfile.packages['@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']).toBeFalsy() }) test('update with a version on a crafted package name does not pollute Object.prototype', async () => { - const project = prepare({ + prepare({ dependencies: { '@pnpm.e2e/foo': '1.0.0', }, @@ -186,13 +238,20 @@ test('update with a version on a crafted package name does not pollute Object.pr dir: process.cwd(), }) - await update.handler({ - ...DEFAULT_OPTS, - dir: process.cwd(), - }, ['__proto__@1.0.0']) + let err!: PnpmError + try { + await update.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + }, ['__proto__@1.0.0']) + } catch (_err: unknown) { + err = _err as PnpmError + } + // `__proto__` names no direct dependency, so the version is rejected — and + // reporting that must not write through the prototype on the way out. + expect(err.code).toBe('ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP') expect(({} as Record)['1.0.0']).toBeUndefined() - expect(project.readLockfile().packages['@pnpm.e2e/foo@1.0.0']).toBeTruthy() }) test('update: fail when both "latest" and "workspace" are true', async () => { diff --git a/pnpm11/pnpm/test/update.ts b/pnpm11/pnpm/test/update.ts index f2d4b6752a..bd34b75d7d 100644 --- a/pnpm11/pnpm/test/update.ts +++ b/pnpm11/pnpm/test/update.ts @@ -190,14 +190,115 @@ test('recursive update @ --lockfile-only --no-save does not leak a const lockfileBefore = readYamlFileSync('pnpm-lock.yaml') // eslint-disable-line const project2VersionBefore = lockfileBefore.importers['project-2'].dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep'].version - await execPnpm(['recursive', 'update', '--lockfile-only', '--no-save', '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + const result = execPnpmSync(['recursive', 'update', '--lockfile-only', '--no-save', '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + expect(result.status).toBe(0) + + // project-2 declares the package at 101.0.0, which `--no-save` keeps, so the + // requested version is rejected there rather than dragging that line down. + expect(result.stdout.toString()).toContain('Skipping "@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0": it doesn\'t satisfy "101.0.0"') const lockfile = readYamlFileSync('pnpm-lock.yaml') // eslint-disable-line const depKeys = Object.keys(lockfile.packages ?? {}).filter((key) => key.startsWith('@pnpm.e2e/dep-of-pkg-with-1-dep@')) expect(lockfile.importers['project-2'].dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep'].version).toBe(project2VersionBefore) expect(depKeys.filter((key) => key.startsWith('@pnpm.e2e/dep-of-pkg-with-1-dep@101.'))).toStrictEqual([`@pnpm.e2e/dep-of-pkg-with-1-dep@${project2VersionBefore}`]) - expect(depKeys).toContain('@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0') + // project-1's transitive copy resolves to what a fresh install would pick + // within `^100.0.0`, and the 101.x line stays out of it. + expect(depKeys).toContain('@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0') +}) + +test('recursive update @ reports that a transitive-only version is ignored', async () => { + await addDistTag('@pnpm.e2e/dep-of-pkg-with-1-dep', '100.0.0', 'latest') + + preparePackages([ + { + name: 'project-1', + version: '1.0.0', + dependencies: { + // Depends on `@pnpm.e2e/dep-of-pkg-with-1-dep@^100.0.0` transitively; + // no project in the workspace declares it directly. + '@pnpm.e2e/pkg-with-1-dep': '100.0.0', + }, + }, + ]) + + writeYamlFileSync('pnpm-workspace.yaml', { packages: ['**', '!store/**'] }) + await execPnpm(['recursive', 'install', '--lockfile-only']) + + const result = execPnpmSync(['recursive', 'update', '--lockfile-only', '--no-save', '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + + expect(result.status).toBe(1) + const output = result.stdout.toString() + result.stderr.toString() + expect(output).toContain('ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP') + expect(output).toContain('"@pnpm.e2e/dep-of-pkg-with-1-dep" (requested "100.1.0") is not a direct dependency') + expect(output).toContain('@pnpm.e2e/dep-of-pkg-with-1-dep@: 100.1.0') + + // The lockfile is left exactly as the install wrote it. + const lockfile = readYamlFileSync('pnpm-lock.yaml') // eslint-disable-line + const depKeys = Object.keys(lockfile.packages ?? {}).filter((key) => key.startsWith('@pnpm.e2e/dep-of-pkg-with-1-dep@')) + expect(depKeys).toStrictEqual(['@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0']) +}) + +test('update @ fails when the package is not a direct dependency', async () => { + await addDistTag('@pnpm.e2e/dep-of-pkg-with-1-dep', '100.0.0', 'latest') + prepare({ + dependencies: { + '@pnpm.e2e/pkg-with-1-dep': '100.0.0', + }, + }) + + await execPnpm(['install', '--lockfile-only']) + + const result = execPnpmSync(['update', '--lockfile-only', '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + + expect(result.status).toBe(1) + const output = result.stdout.toString() + result.stderr.toString() + expect(output).toContain('ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP') + // Dropping the version is the in-range update the message points at. + expect(output).toContain('pnpm update @pnpm.e2e/dep-of-pkg-with-1-dep') +}) + +test('update without a version still updates a transitive dependency', async () => { + await addDistTag('@pnpm.e2e/dep-of-pkg-with-1-dep', '100.0.0', 'latest') + prepare({ + dependencies: { + '@pnpm.e2e/pkg-with-1-dep': '100.0.0', + }, + }) + + await execPnpm(['install', '--lockfile-only']) + await addDistTag('@pnpm.e2e/dep-of-pkg-with-1-dep', '100.1.0', 'latest') + + await execPnpm(['update', '--lockfile-only', '@pnpm.e2e/dep-of-pkg-with-1-dep']) + + const lockfile = readYamlFileSync('pnpm-lock.yaml') // eslint-disable-line + expect(Object.keys(lockfile.packages ?? {})).toContain('@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0') +}) + +test('recursive update --latest reports the spec ban before judging whether a selector is direct', async () => { + await addDistTag('@pnpm.e2e/dep-of-pkg-with-1-dep', '100.0.0', 'latest') + + preparePackages([ + { + name: 'project-1', + version: '1.0.0', + dependencies: { + // Declares `@pnpm.e2e/dep-of-pkg-with-1-dep` transitively only, so the + // selector below would be rejected by the indirect-version check too. + '@pnpm.e2e/pkg-with-1-dep': '100.0.0', + }, + }, + ]) + + writeYamlFileSync('pnpm-workspace.yaml', { packages: ['**', '!store/**'] }) + await execPnpm(['recursive', 'install', '--lockfile-only']) + + const result = execPnpmSync(['recursive', 'update', '--latest', '--lockfile-only', '@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + + expect(result.status).toBe(1) + const output = result.stdout.toString() + result.stderr.toString() + expect(output).toContain('ERR_PNPM_LATEST_WITH_SPEC') + expect(output).not.toContain('ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP') }) test('recursive update alias@npm:@ --lockfile-only --no-save scopes by version line', async () => { @@ -226,17 +327,20 @@ test('recursive update alias@npm:@ --lockfile-only --no-save scope const lockfileBefore = readYamlFileSync('pnpm-lock.yaml') // eslint-disable-line const project2VersionBefore = lockfileBefore.importers['project-2'].dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep'].version - await execPnpm(['recursive', 'update', '--lockfile-only', '--no-save', 'alias@npm:@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + const result = execPnpmSync(['recursive', 'update', '--lockfile-only', '--no-save', 'alias@npm:@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0']) + expect(result.status).toBe(0) + + // `alias` is a direct dependency, so the manifest — which `--no-save` keeps — + // decides: `^100.0.0` supersedes the requested 100.1.0. + expect(result.stdout.toString()).toContain('the manifest keeps "npm:@pnpm.e2e/dep-of-pkg-with-1-dep@^100.0.0" when updating without saving') const lockfile = readYamlFileSync('pnpm-lock.yaml') // eslint-disable-line const depKeys = Object.keys(lockfile.packages ?? {}).filter((key) => key.startsWith('@pnpm.e2e/dep-of-pkg-with-1-dep@')) - expect(lockfile.importers['project-1'].dependencies['alias'].version).toBe('@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0') + expect(lockfile.importers['project-1'].dependencies['alias'].version).toBe('@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0') // project-2's 101.x dependency must remain unchanged expect(lockfile.importers['project-2'].dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep'].version).toBe(project2VersionBefore) expect(depKeys.filter((key) => key.startsWith('@pnpm.e2e/dep-of-pkg-with-1-dep@101.'))).toStrictEqual([`@pnpm.e2e/dep-of-pkg-with-1-dep@${project2VersionBefore}`]) - // The alias expansion must have resolved 100.1.0 within the 100.x line - expect(depKeys).toContain('@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0') const project1Manifest = await readPackageJsonFromDir(path.resolve('project-1')) expect(project1Manifest.dependencies?.['alias']).toBe('npm:@pnpm.e2e/dep-of-pkg-with-1-dep@^100.0.0')