Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit dcabb78

Browse files
aqeelatzkochanm2na7
authored
fix(resolver): targeted updates resolve exactly like a fresh install (#12558)
Contract: `pnpm up <pkg>` produces the same lockfile a fresh install of the same manifests would (equivalently: delete the target's lockfile entries and run `pnpm install`). pnpm (TypeScript): - Thread a per-wanted-dependency `updateRequested` flag (true only for packages matching the user's update target) from the deps-resolver through requestPackage to the npm picker. - At the picker, `stripLockfileVersionPins` subtracts EXISTING_VERSION_SELECTOR_WEIGHT from the target's version-type selectors (the lockfile seed's contribution, which is added onto manifest entries pinning the same version) and drops a selector only when nothing remains. Manifest pins, down-chain propagated versions, and range/tag selectors (audit-fix penalties) keep applying. - Always seed `preferredVersions` from the lockfile, also during update mutations (#10662); caller-supplied preferred versions (audit-fix penalties) merge over the seed per package name on a null-prototype target. - New held-back-update warning at both npm picker entry points: when `updateRequested` and a kept preferred selector steers the pick below what the non-pin selectors alone would choose, warn once per (name, picked, preferred) recommending a pnpm.overrides entry. - `pnpm update <dep>@<version>` for a transitive-only <dep> warns that the version part is ignored and recommends an override (#12744). pacquet (same behavior): - The picker strips nothing: UpdateSeedPolicy::DropOnly already withholds the target's lockfile pins from the preferred-versions seed. `update_requested` (derived per wanted dependency via `is_update_target`, matching npm-alias and jsr real names) gates the held-back-update warning emitted by `warn_once_on_held_back_update`, with the same message as the TypeScript side. - `pacquet update <pkg>@<version>` on a transitive-only package warns that the version part is ignored, mirroring the TypeScript wording. Coverage: resolver-level tests in both stacks assert that the lockfile-pin strip lets the target move, that manifest pins, propagated versions, combined manifest+lockfile entries (manifest weight preserved), and audit penalties keep steering, and e2e tests assert the same-chain and importer-pin topologies dedupe under update exactly as a fresh install does (fresh-install behavior verified empirically against the registry mock). The transitive-version update resolves to highest-in-range with the override recommendation, and crafted package names (`[email protected]`) cannot pollute Object.prototype. Closes #10662. Closes #12744. Co-authored-by: Minha Kang <[email protected]> --------- Co-authored-by: Zoltan Kochan <[email protected]> Co-authored-by: Minha Kang <[email protected]>
1 parent 07c9787 commit dcabb78

32 files changed

Lines changed: 1613 additions & 92 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@pnpm/installing.deps-installer": patch
3+
"pnpm": patch
4+
---
5+
6+
Fixed `pnpm up -r <pkg>` bumping unrelated packages that have open semver ranges. Previously, any update mutation nullified the lockfile-derived `preferredVersions` globally, so packages with `^x.y.z` ranges could re-resolve to newer compatible versions even though the user only asked to update a specific package. The install layer now always seeds `preferredVersions` from the lockfile, and caller-supplied preferred versions (such as the vulnerability penalties of `pnpm audit --fix`) layer on top of the seed instead of replacing it. The targeted package still bumps: the per-resolve `updateRequested` flag makes the resolver ignore the target's own lockfile pins.
7+
8+
Closes pnpm/pnpm#10662.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@pnpm/lockfile.preferred-versions": patch
3+
"@pnpm/installing.deps-resolver": patch
4+
"pnpm": patch
5+
---
6+
7+
Fixed a prototype-pollution hazard when seeding preferred versions: a dependency named `__proto__` in a manifest or in `pnpm-lock.yaml` could write through `Object.prototype` (or crash the install) while the preferred-versions map was being built. The maps are now null-prototype objects, so crafted package names land as plain keys.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@pnpm/resolving.npm-resolver": patch
3+
"@pnpm/resolving.resolver-base": patch
4+
"@pnpm/store.controller-types": patch
5+
"@pnpm/installing.deps-resolver": patch
6+
"pnpm": patch
7+
---
8+
9+
Fixed `pnpm up <pkg>` producing a different result than a fresh install of the same manifests would. The resolver now distinguishes `updateRequested` (true only for packages that match the user's update target) from the broader `update` flag, and for the targeted package ignores only its own lockfile-derived preferred-version pins — so the target re-resolves exactly as if its lockfile entries were deleted and `pnpm install` ran. Preferred versions a fresh install applies (manifest pins, versions propagated down the dependency chain, and the vulnerability-avoidance penalties of `pnpm audit --fix`) stay in effect, so an update never installs duplicate versions that a reinstall from scratch would not reproduce. When a preferred version holds the update target below the newest version its range admits, pnpm now prints a warning explaining that reaching the newer version everywhere requires an override.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/installing.commands": patch
3+
"pnpm": patch
4+
---
5+
6+
`pnpm update <dep>@<version>` now prints a warning when `<dep>` is only present as a transitive dependency: the requested version cannot be applied there (updates resolve the target the way a fresh install would), and the warning recommends adding the version to `pnpm.overrides` instead, which is the mechanism that does pin transitive dependencies. Closes pnpm/pnpm#12744.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ tsconfig.tsbuildinfo
2121
tsconfig.lint.tsbuildinfo
2222
test.lib/
2323

24+
# Generated by `pnpm --filter pnpm run compile` (tsgo typecheck and the
25+
# @pnpm/exe shim layout). Neither has any source content.
26+
__typecheck__/
27+
/pnpm/
28+
2429
# Visual Studio Code configs
2530
.vscode/
2631

pacquet/crates/cli/tests/update.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,48 @@ fn update_transitive_glob_mixed_with_direct_selector() {
205205
drop((root, anchor));
206206
}
207207

208+
/// `pacquet update <pkg>@<version>` on a package that is only present
209+
/// as a transitive dependency ignores the version part: there is no
210+
/// manifest entry to write it into, and an update resolves the target
211+
/// the way a fresh install would. The version part triggers a warning
212+
/// recommending a `pnpm.overrides` entry — the mechanism that does pin
213+
/// transitive dependencies.
214+
#[test]
215+
fn update_transitive_ignores_requested_version() {
216+
let (root, workspace, anchor) = setup();
217+
218+
// Pin the transitive dep-of-pkg-with-1-dep at 100.0.0 (via a direct
219+
// exact entry), then drop it to a pure transitive of pkg-with-1-dep.
220+
write_manifest(&workspace, &format!(r#"{{ "{PARENT}": "100.0.0", "{DEP}": "100.0.0" }}"#));
221+
pacquet(&workspace, ["install"]).assert().success();
222+
eprintln!("virtual store contents: {:?}", list_virtual_store(&workspace));
223+
assert!(virtual_store_has(&workspace, "@[email protected]"));
224+
225+
write_manifest(&workspace, &format!(r#"{{ "{PARENT}": "100.0.0" }}"#));
226+
227+
// The update requests 100.0.0, but the version part of a
228+
// transitive-only selector is ignored: the target re-resolves to the
229+
// highest version in pkg-with-1-dep's ^100.0.0 range (100.1.0),
230+
// exactly as a fresh install with the target's lockfile entries
231+
// deleted would.
232+
pacquet(&workspace, ["update", &format!("{DEP}@100.0.0")]).assert().success();
233+
234+
eprintln!("virtual store contents: {:?}", list_virtual_store(&workspace));
235+
// Only presence is asserted: the update does not prune the previous
236+
// version's now-orphaned virtual-store directory.
237+
assert!(
238+
virtual_store_has(&workspace, "@[email protected]"),
239+
"the target should re-resolve to highest-in-range, like a fresh install",
240+
);
241+
let lock = fs::read_to_string(workspace.join("pnpm-lock.yaml")).expect("read pnpm-lock.yaml");
242+
assert!(
243+
!lock.contains("[email protected]"),
244+
"the ignored requested version must not pin the target in the lockfile",
245+
);
246+
247+
drop((root, anchor));
248+
}
249+
208250
/// `pacquet update --latest` ignores the manifest range, bumps to the
209251
/// `latest` dist-tag, and rewrites `package.json`.
210252
#[test]

pacquet/crates/package-manager/src/update.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,27 @@ impl Update<'_> {
372372
}
373373
}
374374
}
375+
// With no manifest entry to write the version into, and
376+
// update results always matching what a fresh install
377+
// would resolve, the selector's version part cannot take
378+
// effect — say so and point at the mechanism that does
379+
// pin transitive dependencies. The recommended override
380+
// is scoped to the dependents' declared range so it
381+
// cannot violate any consumer's range; the range itself
382+
// is not known at this layer (it lives in the
383+
// dependents' manifests), hence the placeholder.
384+
for sel in &selectors {
385+
let Some(version) = sel.version.as_deref() else { continue };
386+
tracing::warn!(
387+
target: "pacquet_package_manager::update",
388+
pattern = sel.pattern,
389+
version,
390+
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}" }}"#,
391+
sel.pattern,
392+
sel.pattern,
393+
sel.pattern,
394+
);
395+
}
375396
UpdateSeedPolicy::DropOnly(drop_names)
376397
} else {
377398
for (name, group, prev) in &matched_direct {

pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,9 +1698,10 @@ fn landed_on_prior_entry(prior_key: &PkgNameVerPeer, resolved_pkg_id: &str) -> b
16981698
}
16991699

17001700
/// The package names the npm picker may consult the preferred-versions
1701-
/// overlay under for one wanted edge: the alias itself, plus the inner
1702-
/// target of an `npm:` alias and the folded `@jsr/...` name of a
1703-
/// `jsr:` specifier — mirroring the name derivation in the npm
1701+
/// overlay under for one wanted edge: the alias itself, plus the real
1702+
/// package name from [`real_package_name_of`] when it differs (the
1703+
/// inner target of an `npm:` alias or the folded `@jsr/...` name of a
1704+
/// `jsr:` specifier) — mirroring the name derivation in the npm
17041705
/// resolver's `parse_bare_specifier`, which keys its overlay merge by
17051706
/// the resolved `spec.name` rather than the outer alias.
17061707
fn overlay_lookup_names(wanted: &WantedDependency) -> Vec<String> {
@@ -1710,31 +1711,10 @@ fn overlay_lookup_names(wanted: &WantedDependency) -> Vec<String> {
17101711
{
17111712
names.push(alias.to_string());
17121713
}
1713-
let Some(bare) = wanted.bare_specifier.as_deref() else { return names };
1714-
if let Some(rest) = bare.strip_prefix("npm:") {
1715-
let alias_keeps_name = wanted
1716-
.alias
1717-
.as_deref()
1718-
.is_some_and(|alias| !alias.is_empty() && rest.parse::<node_semver::Range>().is_ok());
1719-
if !alias_keeps_name {
1720-
let last_at =
1721-
rest.bytes().enumerate().rev().find_map(|(i, b)| (b == b'@').then_some(i));
1722-
let inner = match last_at {
1723-
Some(idx) if idx >= 1 => &rest[..idx],
1724-
_ => rest,
1725-
};
1726-
if !inner.is_empty() && !names.iter().any(|name| name == inner) {
1727-
names.push(inner.to_string());
1728-
}
1729-
}
1730-
} else if bare.starts_with("jsr:")
1731-
&& let Ok(Some(spec)) = pacquet_resolving_jsr_specifier_parser::parse_jsr_specifier(
1732-
bare,
1733-
wanted.alias.as_deref(),
1734-
)
1735-
&& !names.contains(&spec.npm_pkg_name)
1714+
if let Some(real_name) = real_package_name_of(wanted)
1715+
&& !names.iter().any(|name| name == real_name.as_ref())
17361716
{
1737-
names.push(spec.npm_pkg_name);
1717+
names.push(real_name.into_owned());
17381718
}
17391719
names
17401720
}
@@ -1761,14 +1741,28 @@ where
17611741
if let Some(result) = cached {
17621742
return Ok(result);
17631743
}
1764-
let overlay_opts;
1765-
let opts = if cache_key.8.is_empty() {
1766-
opts
1767-
} else {
1744+
// Combine two per-package opts adjustments into one clone. The
1745+
// `update_requested` flag is scoped per wanted-dependency — true only
1746+
// when the package's real name (parsed from `bare_specifier` for
1747+
// npm-aliases, folded from the jsr specifier for jsr deps) is in the
1748+
// update target list — so the picker's held-back-update warning fires
1749+
// only for the packages the user actually asked to update.
1750+
let needs_overlay = !cache_key.8.is_empty();
1751+
let update_target = is_update_target(&ctx.workspace.update_reuse_scope, wanted);
1752+
let needs_update = update_target != opts.update_requested;
1753+
let owned_opts;
1754+
let opts = if needs_overlay || needs_update {
17681755
let mut owned = opts.clone();
1769-
owned.preferred_versions_overlay = pick_overlay.map(Arc::clone);
1770-
overlay_opts = owned;
1771-
&overlay_opts
1756+
if needs_overlay {
1757+
owned.preferred_versions_overlay = pick_overlay.map(Arc::clone);
1758+
}
1759+
if needs_update {
1760+
owned.update_requested = update_target;
1761+
}
1762+
owned_opts = owned;
1763+
&owned_opts
1764+
} else {
1765+
opts
17721766
};
17731767
let mut result = resolver
17741768
.resolve(wanted, opts)
@@ -2105,13 +2099,88 @@ fn try_reuse_node(
21052099
}
21062100

21072101
/// `true` when `name` is a `pacquet update` target excluded from reuse.
2108-
fn update_excludes(scope: &UpdateReuseScope, name: &pacquet_lockfile::PkgName) -> bool {
2102+
fn update_excludes(scope: &UpdateReuseScope, name: &str) -> bool {
21092103
match scope {
21102104
UpdateReuseScope::All => false,
21112105
// `None` is handled earlier in `try_reuse_node`; treat it the
21122106
// same here for completeness.
21132107
UpdateReuseScope::None => true,
2114-
UpdateReuseScope::Except(names) => names.contains(&name.to_string()),
2108+
UpdateReuseScope::Except(names) => names.contains(name),
2109+
}
2110+
}
2111+
2112+
/// Resolve the *real* package name a [`WantedDependency`] targets —
2113+
/// i.e. the name update targeting matches against, not the local
2114+
/// install alias. For a plain dep (`foo@^1`) or a workspace dep, this is just
2115+
/// `wanted.alias`. For an npm-alias dep (`foo@npm:bar@^4`), this is
2116+
/// `bar` — parsed out of `bare_specifier` because `wanted.alias` keeps
2117+
/// the local install name `foo`. For a jsr dep (`foo@jsr:@bar/baz@^1`
2118+
/// or just `jsr:@bar/baz`), this is the folded npm registry name
2119+
/// (`@jsr/bar__baz`) that the picker and lockfile snapshots key on.
2120+
/// [`overlay_lookup_names`] builds its candidate set from this name.
2121+
///
2122+
/// Returns `None` when no name can be recovered (no alias, malformed
2123+
/// npm-alias target, unparsable jsr specifier). The caller treats
2124+
/// `None` as "not a targeted update" since update targets are keyed
2125+
/// by package name.
2126+
fn real_package_name_of(wanted: &WantedDependency) -> Option<Cow<'_, str>> {
2127+
let bare = wanted.bare_specifier.as_deref()?;
2128+
if let Some(rest) = bare.strip_prefix("npm:") {
2129+
// `npm:<range>` form: the body is a semver range, not a name,
2130+
// so the install alias IS the real package name.
2131+
let alias_keeps_name = wanted
2132+
.alias
2133+
.as_deref()
2134+
.is_some_and(|alias| !alias.is_empty() && rest.parse::<node_semver::Range>().is_ok());
2135+
if !alias_keeps_name {
2136+
// `npm:<name>@<range>` or `npm:<name>`: parse the real name
2137+
// out of `bare_specifier` because `wanted.alias` keeps the
2138+
// local install name. Split at the last `@` to separate the
2139+
// real name from the version range. When there is no `@`,
2140+
// the whole `rest` is the name (default-tag form `npm:bar`).
2141+
let last_at =
2142+
rest.bytes().enumerate().rev().find_map(|(i, b)| (b == b'@').then_some(i));
2143+
let name = match last_at {
2144+
Some(idx) if idx >= 1 => &rest[..idx],
2145+
_ => rest,
2146+
};
2147+
return (!name.is_empty()).then_some(Cow::Borrowed(name));
2148+
}
2149+
}
2150+
if bare.starts_with("jsr:") {
2151+
// An unparsable `jsr:` specifier carries no recoverable real
2152+
// name — return `None` rather than falling back to the install
2153+
// alias, which could accidentally match an update target and
2154+
// mark the broken dep as a targeted update.
2155+
let spec = pacquet_resolving_jsr_specifier_parser::parse_jsr_specifier(
2156+
bare,
2157+
wanted.alias.as_deref(),
2158+
)
2159+
.ok()
2160+
.flatten()?;
2161+
return Some(Cow::Owned(spec.npm_pkg_name));
2162+
}
2163+
// Plain dep, workspace dep, or `npm:<range>` form: the install
2164+
// alias is the real name.
2165+
wanted.alias.as_deref().map(Cow::Borrowed)
2166+
}
2167+
2168+
/// Whether `wanted` is one of the packages the user asked to update,
2169+
/// given the install's [`UpdateReuseScope`]. Feeds the per-resolve
2170+
/// `ResolveOptions::update_requested` flag, which gates the npm
2171+
/// picker's held-back-update warning.
2172+
///
2173+
/// Returns `false` for `All`/`None` scopes (no individually targeted
2174+
/// packages) and for `Except` scopes where the wanted package's real
2175+
/// name is not in the target list. Returns `true` only when the real
2176+
/// name is in the `Except` set.
2177+
#[inline]
2178+
fn is_update_target(scope: &UpdateReuseScope, wanted: &WantedDependency) -> bool {
2179+
match scope {
2180+
UpdateReuseScope::All | UpdateReuseScope::None => false,
2181+
UpdateReuseScope::Except(_) => {
2182+
real_package_name_of(wanted).is_some_and(|n| update_excludes(scope, n.as_ref()))
2183+
}
21152184
}
21162185
}
21172186

@@ -2145,8 +2214,9 @@ fn subtree_fully_reusable(
21452214
// A `pacquet update` target anywhere in the subtree forces the whole
21462215
// subtree to re-resolve so the bump's new transitive deps are picked
21472216
// up — update names match at any depth.
2148-
let reusable = !update_excludes(&ctx.workspace.update_reuse_scope, &key.name)
2149-
&& synthesize_reused_result(lockfile, key, &key.name.to_string()).is_some()
2217+
let name = key.name.to_string();
2218+
let reusable = !update_excludes(&ctx.workspace.update_reuse_scope, &name)
2219+
&& synthesize_reused_result(lockfile, key, &name).is_some()
21502220
&& subtree_children_reusable(ctx, lockfile, key);
21512221
lock_recoverable(&ctx.workspace.subtree_reusable).insert(key.clone(), reusable);
21522222
reusable

0 commit comments

Comments
 (0)