fix(update): reject a version a transitive-only selector cannot record (#14090)

`pnpm update <name>@<version>` treated the requested version three different
ways. A direct dependency records it (in the manifest, or against the range
`--no-save` keeps). A transitive-only target in a single project ignored it
and warned. But the workspace-recursive path seeded preferred versions from
every pinned selector, so the same command silently honored the version in a
workspace and ignored it outside one, while the warning said the opposite.

Drop `createPreferredVersionsFromPinnedUpdateSpecs`. It only ever reached
transitive targets: `parseWantedDependencies` already applies (or explicitly
supersedes) a direct dependency's requested version through the kept-range
path, so nothing else depended on it.

Warning and carrying on was itself the wrong answer. No other package manager
takes that position: npm refuses any version on `update` outright
(`EUPDATEARGS`), and Yarn Berry refuses too; Yarn Classic and Bun accept it
and make it durable by promoting the package to a direct dependency. pnpm was
alone in accepting the argument, doing something else, and exiting 0.

Fail with ERR_PNPM_UPDATE_VERSION_ON_INDIRECT_DEP instead, naming the
selectors and showing the override that does pin a transitive dependency.
Scoped to selectors that name an exact version and that no selected project
declares directly: a range or a tag names no single version to record, so
updating within the dependents' ranges is a reasonable reading of it and those
keep their warning. `--depth 0` still reports NO_PACKAGE_IN_DEPENDENCIES, and
`--latest` still rejects versioned selectors on its own.

pacquet's copy of the warning went through `tracing::warn!`, which is dropped
unless `TRACE` is set, so its users never saw it; what survives emits through
the reporter now.

Version-line scoping of update targets (pnpm/pnpm#14053) is unchanged.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-08-23 12:42:26 +02:00
1 parent d5219db37a
commit 10782479e3
10 files changed
+696 -142

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@pnpm/installing.commands": patch
"pnpm": patch
"pacquet": patch
---
`pnpm update <name>@<version>` 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.
+24 -22
View File
@@ -361,14 +361,14 @@ fn update_transitive_glob_mixed_with_direct_selector() {
drop((root, anchor));
}
/// `pacquet update <pkg>@<version>` 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 <pkg>@<version>` 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));
@@ -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}@<declared range>: 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));
}
+122 -12
View File
@@ -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::<Vec<_>>();
reject_versions_of_indirect_update_specs::<Reporter>(
&selectors,
&[manifest],
&include_direct,
&package_manifest_prefix(manifest),
)?;
}
let mut latest_chain = None;
let Some(prepared) = prepare_manifest::<Reporter>(
manifest,
@@ -912,18 +932,6 @@ async fn prepare_manifest<Reporter: self::Reporter>(
}
}
}
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: {{ "{}@<declared range>": "{version}" }}"#,
selector.pattern,
selector.pattern,
selector.pattern,
);
}
} else {
if latest && !save {
emit_latest_ignored::<Reporter>(rewrite_ctx.manifest);
@@ -1125,6 +1133,23 @@ async fn prepare_selected_manifests<Reporter: self::Reporter>(
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::<Vec<_>>();
let manifests =
selected_indices.iter().map(|&index| &projects[index].manifest).collect::<Vec<_>>();
reject_versions_of_indirect_update_specs::<Reporter>(
&selectors,
&manifests,
include_direct,
&workspace_root.to_string_lossy(),
)?;
}
for &index in selected_indices {
let Some(prepared) = prepare_manifest::<Reporter>(
&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 <dep>@<version>` where `<dep>` 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<Reporter: self::Reporter>(
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::<Vec<_>>()
.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}@<declared range>: {version}"))
.collect::<Vec<_>>()
.join("\n");
let names = pinned.iter().map(|(pattern, _)| pattern.as_str()).collect::<Vec<_>>().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
@@ -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::<Vec<_>>();
reject_versions_of_indirect_update_specs::<SilentReporter>(
&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::<SilentReporter>(
&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}"));
}
}
+9 -22
View File
@@ -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 <dep>@<version>` where `<dep>` 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}@<declared range>": "${versionSpec}" }`)
}
}
/**
* The `updateMatching` predicate of `pnpm audit --fix`: a package is an
* update target when its resolved version is vulnerable. The resolver calls
+69 -37
View File
@@ -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<Config, 'bin'>
& { 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 <dep>@<version>` where `<dep>` 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}@<declared range>: ${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
@@ -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@<declared range>: 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()
}
})
@@ -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@<declared range>: 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<string, unknown>)['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 () => {
+110 -6
View File
@@ -190,14 +190,115 @@ test('recursive update <pkg>@<version> --lockfile-only --no-save does not leak a
const lockfileBefore = readYamlFileSync<any>('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<any>('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 <pkg>@<version> 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@<declared range>: 100.1.0')
// The lockfile is left exactly as the install wrote it.
const lockfile = readYamlFileSync<any>('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 <pkg>@<version> 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 <pkg> 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<any>('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:<pkg>@<version> --lockfile-only --no-save scopes by version line', async () => {
@@ -226,17 +327,20 @@ test('recursive update alias@npm:<pkg>@<version> --lockfile-only --no-save scope
const lockfileBefore = readYamlFileSync<any>('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<any>('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')