Skip to content

Commit fe44450

Browse files
committed
fix(package-is-installable): infer missing platform fields of optional deps from the package name
Port of pnpm commit 34875b2d7c (PR #12312). Some registries strip the os/cpu/libc fields (or just libc) from the version objects of the packuments they serve, and lockfile entries written from such metadata lack the fields too, so every platform's binaries were installed regardless of supportedArchitectures. Platform-specific binary packages encode their platform in the package name (e.g. @nx/nx-win32-arm64-msvc), so the installability check now fills the missing platform fields of an optional dependency from the name's tokens: infer_platform_from_package_name + inferred_platform in pacquet-package-is-installable, applied inside package_is_installable (hoisted linker) and in compute_skipped_snapshots (isolated linker, with the check cache keyed by the snapshot's optional flag since the verdicts can differ). The any_installability_constraint fast path now also considers optional snapshots whose names infer a platform their metadata row does not declare, so the inference is reachable on lockfiles without any declared constraint. Same guard rails as upstream: declared fields always win (each field is filled only when missing — a missing libc alone is inferred, disambiguating -gnu vs -musl), and a package declaring no platform fields at all engages the inference only when an operating-system token is recognized in its name, so a generic name segment such as 'arm' on its own never gets a package skipped. Fixes #11702 Fixes #9940
1 parent 23f55eb commit fe44450

11 files changed

Lines changed: 535 additions & 27 deletions

File tree

pacquet/crates/env-installer/src/install_config_deps.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ fn is_compatible<Reporter: self::Reporter>(
290290
return true;
291291
}
292292
let manifest = PackageInstallabilityManifest {
293+
name: subdep.name.clone(),
293294
engines: None,
294295
cpu: subdep.cpu.clone(),
295296
os: subdep.os.clone(),
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! Port of `inferPlatformFromPackageName.ts` from
2+
//! <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/src/inferPlatformFromPackageName.ts>.
3+
4+
use crate::check_platform::{WantedPlatform, WantedPlatformRef};
5+
6+
fn os_for_token(token: &str) -> Option<&'static str> {
7+
match token {
8+
"aix" => Some("aix"),
9+
"android" => Some("android"),
10+
"darwin" | "macos" | "osx" => Some("darwin"),
11+
"freebsd" => Some("freebsd"),
12+
"linux" => Some("linux"),
13+
"netbsd" => Some("netbsd"),
14+
"openbsd" => Some("openbsd"),
15+
"openharmony" => Some("openharmony"),
16+
"sunos" => Some("sunos"),
17+
"win32" | "windows" => Some("win32"),
18+
_ => None,
19+
}
20+
}
21+
22+
fn cpu_for_token(token: &str) -> Option<&'static str> {
23+
match token {
24+
"arm" | "armv6" | "armv7" => Some("arm"),
25+
"arm64" | "aarch64" => Some("arm64"),
26+
"ia32" => Some("ia32"),
27+
"loong64" => Some("loong64"),
28+
"mips64el" => Some("mips64el"),
29+
"ppc64" | "ppc64le" => Some("ppc64"),
30+
"riscv64" => Some("riscv64"),
31+
"s390x" => Some("s390x"),
32+
"x64" | "amd64" => Some("x64"),
33+
"wasm32" => Some("wasm32"),
34+
_ => None,
35+
}
36+
}
37+
38+
fn libc_for_token(token: &str) -> Option<&'static str> {
39+
match token {
40+
"glibc" | "gnu" | "gnueabihf" => Some("glibc"),
41+
"musl" | "musleabihf" => Some("musl"),
42+
_ => None,
43+
}
44+
}
45+
46+
/// Infers the supported platforms of a package from the tokens of its name,
47+
/// e.g. `@nx/nx-win32-arm64-msvc` produces `os: ["win32"], cpu: ["arm64"]`.
48+
/// Platform-specific binary packages follow this naming convention, which is
49+
/// the only platform signal left when their os/cpu/libc manifest fields are
50+
/// absent. Returns `None` when no platform token is recognized in the name.
51+
pub fn infer_platform_from_package_name(name: &str) -> Option<WantedPlatform> {
52+
let name_without_scope = name.find('/').map_or(name, |idx| &name[idx + 1..]);
53+
let lowercase = name_without_scope.to_lowercase();
54+
let tokens: Vec<&str> = lowercase.split(['-', '_', '.']).collect();
55+
let os = pick_token_values(&tokens, os_for_token);
56+
let cpu = pick_token_values(&tokens, cpu_for_token);
57+
let libc = pick_token_values(&tokens, libc_for_token);
58+
if os.is_none() && cpu.is_none() && libc.is_none() {
59+
return None;
60+
}
61+
Some(WantedPlatform { os, cpu, libc })
62+
}
63+
64+
fn pick_token_values(
65+
tokens: &[&str],
66+
value_for_token: fn(&str) -> Option<&'static str>,
67+
) -> Option<Vec<String>> {
68+
let mut values: Vec<String> = Vec::new();
69+
for token in tokens {
70+
if let Some(value) = value_for_token(token)
71+
&& !values.iter().any(|seen| seen == value)
72+
{
73+
values.push(value.to_string());
74+
}
75+
}
76+
(!values.is_empty()).then_some(values)
77+
}
78+
79+
/// The platform fields of an optional dependency may be incomplete: some
80+
/// registries strip os/cpu/libc (or just libc) from the metadata they serve,
81+
/// and lockfile entries written from such metadata lack them too. For a
82+
/// platform-specific binary the package name carries the same information, so
83+
/// each missing field is filled from the name's tokens. A package that
84+
/// declares no platform fields at all is treated as platform-specific only
85+
/// when an operating system is recognized in its name — a generic name
86+
/// segment (e.g. `arm` on its own) never marks it as such.
87+
///
88+
/// Returns `None` when the declared fields stand as-is. The `optional` gate
89+
/// stays at the call site, mirroring upstream's `effectivePlatform` at
90+
/// <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/src/index.ts#L70-L96>.
91+
/// See <https://github.com/pnpm/pnpm/issues/11702>.
92+
pub fn inferred_platform(name: &str, declared: WantedPlatformRef<'_>) -> Option<WantedPlatform> {
93+
if declared.os.is_some() && declared.cpu.is_some() && declared.libc.is_some() {
94+
return None;
95+
}
96+
let inferred = infer_platform_from_package_name(name)?;
97+
let declares_platform =
98+
declared.os.is_some() || declared.cpu.is_some() || declared.libc.is_some();
99+
if !declares_platform && inferred.os.is_none() {
100+
return None;
101+
}
102+
Some(WantedPlatform {
103+
os: declared.os.map(<[String]>::to_vec).or(inferred.os),
104+
cpu: declared.cpu.map(<[String]>::to_vec).or(inferred.cpu),
105+
libc: declared.libc.map(<[String]>::to_vec).or(inferred.libc),
106+
})
107+
}

pacquet/crates/package-is-installable/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
1919
mod check_engine;
2020
mod check_platform;
21+
mod infer_platform_from_package_name;
2122
mod package_is_installable;
2223

2324
#[cfg(test)]
@@ -30,6 +31,7 @@ pub use check_platform::{
3031
Platform, SupportedArchitectures, UnsupportedPlatformError, WantedPlatform, WantedPlatformRef,
3132
check_platform,
3233
};
34+
pub use infer_platform_from_package_name::{infer_platform_from_package_name, inferred_platform};
3335
pub use package_is_installable::{
3436
InstallabilityError, InstallabilityOptions, InstallabilityVerdict,
3537
PackageInstallabilityManifest, SkipReason, check_package, package_is_installable,

pacquet/crates/package-is-installable/src/package_is_installable.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ use crate::{
88
check_platform::{
99
SupportedArchitectures, UnsupportedPlatformError, WantedPlatformRef, check_platform,
1010
},
11+
infer_platform_from_package_name::inferred_platform,
1112
};
1213
use derive_more::{Display, Error};
1314
use miette::Diagnostic;
1415
use serde::Serialize;
1516

1617
/// Inputs from a package manifest (or lockfile metadata row) that
1718
/// drive the installability check.
19+
///
20+
/// `name` feeds the platform-from-name inference for optional
21+
/// dependencies (see [`inferred_platform`]); an empty name disables
22+
/// the inference and leaves only the declared fields.
1823
#[derive(Debug, Default, Clone)]
1924
pub struct PackageInstallabilityManifest {
25+
pub name: String,
2026
pub engines: Option<WantedEngine>,
2127
pub cpu: Option<Vec<String>>,
2228
pub os: Option<Vec<String>>,
@@ -202,6 +208,31 @@ pub fn package_is_installable(
202208
manifest: &PackageInstallabilityManifest,
203209
options: &InstallabilityOptions<'_>,
204210
) -> Result<InstallabilityVerdict, Box<InstallabilityError>> {
211+
// Mirrors upstream's `effectivePlatform(pkg, options.optional)` at
212+
// <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/src/index.ts#L41>:
213+
// an optional dependency with incomplete platform fields gets the
214+
// missing ones filled from its name before the check runs.
215+
let effective: PackageInstallabilityManifest;
216+
let manifest = if options.optional
217+
&& let Some(platform) = inferred_platform(
218+
&manifest.name,
219+
WantedPlatformRef {
220+
os: manifest.os.as_deref(),
221+
cpu: manifest.cpu.as_deref(),
222+
libc: manifest.libc.as_deref(),
223+
},
224+
) {
225+
effective = PackageInstallabilityManifest {
226+
name: manifest.name.clone(),
227+
engines: manifest.engines.clone(),
228+
os: platform.os,
229+
cpu: platform.cpu,
230+
libc: platform.libc,
231+
};
232+
&effective
233+
} else {
234+
manifest
235+
};
205236
let warn = match check_package(package_id, manifest, options) {
206237
Ok(maybe) => maybe,
207238
Err(invalid_node) => {
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
//! Ports of upstream's unit tests:
22
//! - `config/package-is-installable/test/checkPlatform.ts`
33
//! - `config/package-is-installable/test/checkEngine.ts`
4+
//! - `config/package-is-installable/test/inferPlatformFromPackageName.ts`
45
//!
5-
//! Both live under
6-
//! <https://github.com/pnpm/pnpm/tree/94240bc046/config/package-is-installable/test>.
6+
//! All live under
7+
//! <https://github.com/pnpm/pnpm/tree/34875b2d7c/config/package-is-installable/test>.
78
89
mod check_engine;
910
mod check_platform;
11+
mod infer_platform_from_package_name;
1012
mod package_is_installable;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//! Port of `config/package-is-installable/test/inferPlatformFromPackageName.ts`
2+
//! from
3+
//! <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/test/inferPlatformFromPackageName.ts>.
4+
5+
use crate::{
6+
InstallabilityOptions, InstallabilityVerdict, PackageInstallabilityManifest,
7+
SupportedArchitectures, WantedPlatform, infer_platform_from_package_name,
8+
package_is_installable,
9+
};
10+
use pretty_assertions::assert_eq;
11+
12+
fn platform(
13+
os: Option<&[&str]>,
14+
cpu: Option<&[&str]>,
15+
libc: Option<&[&str]>,
16+
) -> Option<WantedPlatform> {
17+
Some(WantedPlatform { os: owned(os), cpu: owned(cpu), libc: owned(libc) })
18+
}
19+
20+
fn owned(values: Option<&[&str]>) -> Option<Vec<String>> {
21+
values.map(|values| values.iter().map(|value| (*value).to_string()).collect())
22+
}
23+
24+
#[test]
25+
fn infers_platform_from_real_world_names() {
26+
let cases: &[(&str, Option<WantedPlatform>)] = &[
27+
("@nx/nx-win32-arm64-msvc", platform(Some(&["win32"]), Some(&["arm64"]), None)),
28+
(
29+
"@nx/nx-linux-arm-gnueabihf",
30+
platform(Some(&["linux"]), Some(&["arm"]), Some(&["glibc"])),
31+
),
32+
("@nx/nx-linux-x64-gnu", platform(Some(&["linux"]), Some(&["x64"]), Some(&["glibc"]))),
33+
("@esbuild/aix-ppc64", platform(Some(&["aix"]), Some(&["ppc64"]), None)),
34+
("@esbuild/openharmony-arm64", platform(Some(&["openharmony"]), Some(&["arm64"]), None)),
35+
(
36+
"@biomejs/cli-linux-x64-musl",
37+
platform(Some(&["linux"]), Some(&["x64"]), Some(&["musl"])),
38+
),
39+
(
40+
"@typescript/native-preview-darwin-arm64",
41+
platform(Some(&["darwin"]), Some(&["arm64"]), None),
42+
),
43+
("turbo-windows-64", platform(Some(&["win32"]), None, None)),
44+
("esbuild-darwin-64", platform(Some(&["darwin"]), None, None)),
45+
("bun-linux-aarch64", platform(Some(&["linux"]), Some(&["arm64"]), None)),
46+
("sharp-linux-armv7", platform(Some(&["linux"]), Some(&["arm"]), None)),
47+
("is-arm", platform(None, Some(&["arm"]), None)),
48+
("fsevents", None),
49+
("lodash", None),
50+
("@pnpm.e2e/not-compatible-with-any-os", None),
51+
];
52+
for (name, expected) in cases {
53+
assert_eq!(&infer_platform_from_package_name(name), expected, "name: {name}");
54+
}
55+
}
56+
57+
fn optional_on_linux_x64(supported: Option<&SupportedArchitectures>) -> InstallabilityOptions<'_> {
58+
InstallabilityOptions {
59+
engine_strict: false,
60+
optional: true,
61+
current_node_version: "20.10.0",
62+
pnpm_version: None,
63+
current_os: "linux",
64+
current_cpu: "x64",
65+
current_libc: "glibc",
66+
supported_architectures: supported,
67+
}
68+
}
69+
70+
fn supported_linux_x64_glibc() -> SupportedArchitectures {
71+
SupportedArchitectures {
72+
os: Some(vec!["linux".to_string()]),
73+
cpu: Some(vec!["x64".to_string()]),
74+
libc: Some(vec!["glibc".to_string()]),
75+
}
76+
}
77+
78+
#[test]
79+
fn optional_dependency_without_platform_fields_is_skipped_by_name() {
80+
let manifest = PackageInstallabilityManifest {
81+
name: "@nx/nx-win32-arm64-msvc".to_string(),
82+
..Default::default()
83+
};
84+
let verdict = package_is_installable(
85+
"@nx/nx-win32-arm64-msvc@1.0.0",
86+
&manifest,
87+
&optional_on_linux_x64(None),
88+
)
89+
.unwrap();
90+
assert!(matches!(verdict, InstallabilityVerdict::SkipOptional { .. }), "got {verdict:?}");
91+
}
92+
93+
#[test]
94+
fn missing_libc_is_taken_from_the_name_when_other_fields_are_declared() {
95+
let supported = supported_linux_x64_glibc();
96+
let options = optional_on_linux_x64(Some(&supported));
97+
let musl = PackageInstallabilityManifest {
98+
name: "@nx/nx-linux-x64-musl".to_string(),
99+
os: Some(vec!["linux".to_string()]),
100+
cpu: Some(vec!["x64".to_string()]),
101+
..Default::default()
102+
};
103+
let verdict = package_is_installable("@nx/nx-linux-x64-musl@1.0.0", &musl, &options).unwrap();
104+
assert!(matches!(verdict, InstallabilityVerdict::SkipOptional { .. }), "got {verdict:?}");
105+
106+
let gnu = PackageInstallabilityManifest {
107+
name: "@nx/nx-linux-x64-gnu".to_string(),
108+
os: Some(vec!["linux".to_string()]),
109+
cpu: Some(vec!["x64".to_string()]),
110+
..Default::default()
111+
};
112+
let verdict = package_is_installable("@nx/nx-linux-x64-gnu@1.0.0", &gnu, &options).unwrap();
113+
assert_eq!(verdict, InstallabilityVerdict::Installable);
114+
}
115+
116+
#[test]
117+
fn missing_cpu_is_taken_from_the_name_of_a_package_that_declares_its_platform() {
118+
let manifest = PackageInstallabilityManifest {
119+
name: "@pnpm.e2e/some-pkg-arm64".to_string(),
120+
os: Some(vec!["linux".to_string()]),
121+
..Default::default()
122+
};
123+
let verdict = package_is_installable(
124+
"@pnpm.e2e/some-pkg-arm64@1.0.0",
125+
&manifest,
126+
&optional_on_linux_x64(None),
127+
)
128+
.unwrap();
129+
assert!(matches!(verdict, InstallabilityVerdict::SkipOptional { .. }), "got {verdict:?}");
130+
}
131+
132+
#[test]
133+
fn declared_platform_fields_take_precedence_over_the_name() {
134+
let manifest = PackageInstallabilityManifest {
135+
name: "@pnpm.e2e/win32-binary".to_string(),
136+
os: Some(vec!["linux".to_string()]),
137+
cpu: Some(vec!["x64".to_string()]),
138+
libc: Some(vec!["glibc".to_string()]),
139+
..Default::default()
140+
};
141+
let supported = supported_linux_x64_glibc();
142+
let verdict = package_is_installable(
143+
"@pnpm.e2e/win32-binary@1.0.0",
144+
&manifest,
145+
&optional_on_linux_x64(Some(&supported)),
146+
)
147+
.unwrap();
148+
assert_eq!(verdict, InstallabilityVerdict::Installable);
149+
}
150+
151+
#[test]
152+
fn package_without_declared_fields_is_not_skipped_without_an_os_token() {
153+
let manifest =
154+
PackageInstallabilityManifest { name: "is-arm".to_string(), ..Default::default() };
155+
let verdict =
156+
package_is_installable("is-arm@1.0.0", &manifest, &optional_on_linux_x64(None)).unwrap();
157+
assert_eq!(verdict, InstallabilityVerdict::Installable);
158+
}
159+
160+
#[test]
161+
fn platform_is_not_inferred_for_a_non_optional_dependency() {
162+
let manifest = PackageInstallabilityManifest {
163+
name: "@nx/nx-win32-arm64-msvc".to_string(),
164+
..Default::default()
165+
};
166+
let mut options = optional_on_linux_x64(None);
167+
options.optional = false;
168+
let verdict =
169+
package_is_installable("@nx/nx-win32-arm64-msvc@1.0.0", &manifest, &options).unwrap();
170+
assert_eq!(verdict, InstallabilityVerdict::Installable);
171+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ fn walk_deps(
664664
// an unsupported platform is silently added to `skipped`;
665665
// a required dep takes the error path.
666666
if !state.opts.force {
667-
let manifest = manifest_for_installability(metadata);
667+
let manifest = manifest_for_installability(&pkg_key, metadata);
668668
let optional = snapshot.map(|s| s.optional).unwrap_or(false);
669669
let install_opts = InstallabilityOptions {
670670
engine_strict: state.opts.engine_strict,
@@ -771,13 +771,15 @@ fn lookup_package_metadata<'a>(
771771
/// [lockfileToHoistedDepGraph.ts:192-199](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L192-L199);
772772
/// extracted here so the walker body stays small.
773773
fn manifest_for_installability(
774+
pkg_key: &PackageKey,
774775
metadata: &pacquet_lockfile::PackageMetadata,
775776
) -> PackageInstallabilityManifest {
776777
let engines = metadata.engines.as_ref().map(|engines| WantedEngine {
777778
node: engines.get("node").cloned(),
778779
pnpm: engines.get("pnpm").cloned(),
779780
});
780781
PackageInstallabilityManifest {
782+
name: pkg_key.name.to_string(),
781783
engines,
782784
cpu: metadata.cpu.clone(),
783785
os: metadata.os.clone(),

0 commit comments

Comments
 (0)