diff --git a/.changeset/spicy-pots-wonder.md b/.changeset/spicy-pots-wonder.md new file mode 100644 index 0000000000..1bd9c2ca7c --- /dev/null +++ b/.changeset/spicy-pots-wonder.md @@ -0,0 +1,6 @@ +--- +"@pnpm/config.package-is-installable": patch +"pnpm": patch +--- + +Platform-specific optional dependencies are now skipped even when their `os`/`cpu`/`libc` fields are missing from the registry metadata or the lockfile. Some registries strip these fields from the package metadata, which made pnpm download and install the binaries of every platform regardless of `supportedArchitectures`. The missing platform fields of an optional dependency are now inferred from its name (e.g. `@nx/nx-win32-arm64-msvc` → `os: win32`, `cpu: arm64`), so foreign-platform binaries are skipped without even downloading them [#11702](https://github.com/pnpm/pnpm/issues/11702). diff --git a/config/package-is-installable/src/index.ts b/config/package-is-installable/src/index.ts index 59310aa6ba..1c62ef617b 100644 --- a/config/package-is-installable/src/index.ts +++ b/config/package-is-installable/src/index.ts @@ -7,9 +7,11 @@ import type { SupportedArchitectures } from '@pnpm/types' import { checkEngine, UnsupportedEngineError, type WantedEngine } from './checkEngine.js' import { checkPlatform, UnsupportedPlatformError } from './checkPlatform.js' +import { inferPlatformFromPackageName } from './inferPlatformFromPackageName.js' export type { Engine } from './checkEngine.js' export type { Platform, WantedPlatform } from './checkPlatform.js' +export { inferPlatformFromPackageName } from './inferPlatformFromPackageName.js' export { UnsupportedEngineError, @@ -36,7 +38,7 @@ export function packageIsInstallable ( supportedArchitectures?: SupportedArchitectures } ): boolean | null { - const warn = checkPackage(pkgId, pkg, options) + const warn = checkPackage(pkgId, { engines: pkg.engines, ...effectivePlatform(pkg, options.optional) }, options) if (warn == null) return true @@ -65,6 +67,36 @@ export function packageIsInstallable ( return null } +interface PlatformFields { + cpu?: string[] + os?: string[] + libc?: string[] +} + +/** + * The platform fields of an optional dependency may be incomplete: some + * registries strip os/cpu/libc (or just libc) from the metadata they serve, + * and lockfile entries written from such metadata lack them too. For a + * platform-specific binary the package name carries the same information, so + * each missing field is filled from the name's tokens. A package that + * declares no platform fields at all is treated as platform-specific only + * when an operating system is recognized in its name — a generic name + * segment (e.g. `arm` on its own) never marks it as such. + * https://github.com/pnpm/pnpm/issues/11702 + */ +function effectivePlatform (pkg: PlatformFields & { name: string }, optional: boolean): PlatformFields { + if (!optional || (pkg.os != null && pkg.cpu != null && pkg.libc != null)) return pkg + const inferred = inferPlatformFromPackageName(pkg.name) + if (inferred == null) return pkg + const pkgDeclaresPlatform = pkg.os != null || pkg.cpu != null || pkg.libc != null + if (!pkgDeclaresPlatform && inferred.os == null) return pkg + return { + os: pkg.os ?? inferred.os, + cpu: pkg.cpu ?? inferred.cpu, + libc: pkg.libc ?? inferred.libc, + } +} + export function checkPackage ( pkgId: string, manifest: { diff --git a/config/package-is-installable/src/inferPlatformFromPackageName.ts b/config/package-is-installable/src/inferPlatformFromPackageName.ts new file mode 100644 index 0000000000..ea551b3acb --- /dev/null +++ b/config/package-is-installable/src/inferPlatformFromPackageName.ts @@ -0,0 +1,79 @@ +const OS_BY_TOKEN = new Map([ + ['aix', 'aix'], + ['android', 'android'], + ['darwin', 'darwin'], + ['macos', 'darwin'], + ['osx', 'darwin'], + ['freebsd', 'freebsd'], + ['linux', 'linux'], + ['netbsd', 'netbsd'], + ['openbsd', 'openbsd'], + ['openharmony', 'openharmony'], + ['sunos', 'sunos'], + ['win32', 'win32'], + ['windows', 'win32'], +]) + +const CPU_BY_TOKEN = new Map([ + ['arm', 'arm'], + ['armv6', 'arm'], + ['armv7', 'arm'], + ['arm64', 'arm64'], + ['aarch64', 'arm64'], + ['ia32', 'ia32'], + ['loong64', 'loong64'], + ['mips64el', 'mips64el'], + ['ppc64', 'ppc64'], + ['ppc64le', 'ppc64'], + ['riscv64', 'riscv64'], + ['s390x', 's390x'], + ['x64', 'x64'], + ['amd64', 'x64'], + ['wasm32', 'wasm32'], +]) + +const LIBC_BY_TOKEN = new Map([ + ['glibc', 'glibc'], + ['gnu', 'glibc'], + ['gnueabihf', 'glibc'], + ['musl', 'musl'], + ['musleabihf', 'musl'], +]) + +export interface PlatformInferredFromPackageName { + os?: string[] + cpu?: string[] + libc?: string[] +} + +/** + * Infers the supported platforms of a package from the tokens of its name, + * e.g. `@nx/nx-win32-arm64-msvc` → `{ os: ['win32'], cpu: ['arm64'] }`. + * Platform-specific binary packages follow this naming convention, which is + * the only platform signal left when their os/cpu/libc manifest fields are + * absent. Returns null when no platform token is recognized in the name. + */ +export function inferPlatformFromPackageName (name: string): PlatformInferredFromPackageName | null { + const nameWithoutScope = name.includes('/') ? name.slice(name.indexOf('/') + 1) : name + const tokens = nameWithoutScope.toLowerCase().split(/[-_.]/) + const os = pickTokenValues(tokens, OS_BY_TOKEN) + const cpu = pickTokenValues(tokens, CPU_BY_TOKEN) + const libc = pickTokenValues(tokens, LIBC_BY_TOKEN) + if (os == null && cpu == null && libc == null) return null + return { + ...(os != null ? { os } : {}), + ...(cpu != null ? { cpu } : {}), + ...(libc != null ? { libc } : {}), + } +} + +function pickTokenValues (tokens: string[], valueByToken: Map): string[] | undefined { + const values = new Set() + for (const token of tokens) { + const value = valueByToken.get(token) + if (value != null) { + values.add(value) + } + } + return values.size > 0 ? Array.from(values) : undefined +} diff --git a/config/package-is-installable/test/inferPlatformFromPackageName.ts b/config/package-is-installable/test/inferPlatformFromPackageName.ts new file mode 100644 index 0000000000..a540b0c717 --- /dev/null +++ b/config/package-is-installable/test/inferPlatformFromPackageName.ts @@ -0,0 +1,112 @@ +import { expect, jest, test } from '@jest/globals' +import type * as DetectLibc from 'detect-libc' + +jest.mock('detect-libc', () => { + const original = jest.requireActual('detect-libc') + return { + ...original, + familySync: () => 'glibc', + } +}) + +const { inferPlatformFromPackageName } = await import('../lib/inferPlatformFromPackageName.js') +const { packageIsInstallable } = await import('../lib/index.js') + +test.each([ + ['@nx/nx-win32-arm64-msvc', { os: ['win32'], cpu: ['arm64'] }], + ['@nx/nx-linux-arm-gnueabihf', { os: ['linux'], cpu: ['arm'], libc: ['glibc'] }], + ['@nx/nx-linux-x64-gnu', { os: ['linux'], cpu: ['x64'], libc: ['glibc'] }], + ['@esbuild/aix-ppc64', { os: ['aix'], cpu: ['ppc64'] }], + ['@esbuild/openharmony-arm64', { os: ['openharmony'], cpu: ['arm64'] }], + ['@biomejs/cli-linux-x64-musl', { os: ['linux'], cpu: ['x64'], libc: ['musl'] }], + ['@typescript/native-preview-darwin-arm64', { os: ['darwin'], cpu: ['arm64'] }], + ['turbo-windows-64', { os: ['win32'] }], + ['esbuild-darwin-64', { os: ['darwin'] }], + ['bun-linux-aarch64', { os: ['linux'], cpu: ['arm64'] }], + ['sharp-linux-armv7', { os: ['linux'], cpu: ['arm'] }], + ['is-arm', { cpu: ['arm'] }], + ['fsevents', null], + ['lodash', null], + ['@pnpm.e2e/not-compatible-with-any-os', null], +])('inferPlatformFromPackageName(%s)', (name, inferred) => { + expect(inferPlatformFromPackageName(name)).toStrictEqual(inferred) +}) + +test('an optional dependency without platform fields is not installable when its name declares an unsupported platform', () => { + expect(packageIsInstallable('@nx/nx-win32-arm64-msvc@1.0.0', { + name: '@nx/nx-win32-arm64-msvc', + version: '1.0.0', + }, { + optional: true, + lockfileDir: process.cwd(), + supportedArchitectures: { os: ['linux'], cpu: ['x64'] }, + })).toBe(false) +}) + +test('a missing libc field is taken from the package name even when the other platform fields are declared', () => { + const options = { + optional: true, + lockfileDir: process.cwd(), + supportedArchitectures: { os: ['linux'], cpu: ['x64'], libc: ['glibc'] }, + } + expect(packageIsInstallable('@nx/nx-linux-x64-musl@1.0.0', { + name: '@nx/nx-linux-x64-musl', + version: '1.0.0', + os: ['linux'], + cpu: ['x64'], + }, options)).toBe(false) + expect(packageIsInstallable('@nx/nx-linux-x64-gnu@1.0.0', { + name: '@nx/nx-linux-x64-gnu', + version: '1.0.0', + os: ['linux'], + cpu: ['x64'], + }, options)).toBe(true) +}) + +test('a missing cpu field is taken from the name of a package that declares its platform', () => { + expect(packageIsInstallable('@pnpm.e2e/some-pkg-arm64@1.0.0', { + name: '@pnpm.e2e/some-pkg-arm64', + version: '1.0.0', + os: ['linux'], + }, { + optional: true, + lockfileDir: process.cwd(), + supportedArchitectures: { os: ['linux'], cpu: ['x64'] }, + })).toBe(false) +}) + +test('the platform fields of the manifest take precedence over the package name', () => { + expect(packageIsInstallable('@pnpm.e2e/win32-binary@1.0.0', { + name: '@pnpm.e2e/win32-binary', + version: '1.0.0', + os: ['linux'], + cpu: ['x64'], + libc: ['glibc'], + }, { + optional: true, + lockfileDir: process.cwd(), + supportedArchitectures: { os: ['linux'], cpu: ['x64'], libc: ['glibc'] }, + })).toBe(true) +}) + +test('a package without any declared platform field is not skipped when its name has no operating system token', () => { + expect(packageIsInstallable('is-arm@1.0.0', { + name: 'is-arm', + version: '1.0.0', + }, { + optional: true, + lockfileDir: process.cwd(), + supportedArchitectures: { os: ['linux'], cpu: ['x64'] }, + })).toBe(true) +}) + +test('the platform is not inferred from the name of a non-optional dependency', () => { + expect(packageIsInstallable('@nx/nx-win32-arm64-msvc@1.0.0', { + name: '@nx/nx-win32-arm64-msvc', + version: '1.0.0', + }, { + optional: false, + lockfileDir: process.cwd(), + supportedArchitectures: { os: ['linux'], cpu: ['x64'] }, + })).toBe(true) +}) diff --git a/cspell.json b/cspell.json index 78aa92ea84..72eeea4c17 100644 --- a/cspell.json +++ b/cspell.json @@ -123,6 +123,7 @@ "gitea", "globalconfig", "globstar", + "gnueabihf", "gpgsign", "grault", "gruntfile", @@ -178,6 +179,7 @@ "logstream", "longlink", "longpaths", + "loong", "luca", "martensson", "maxtimeout", @@ -195,6 +197,7 @@ "msgpackr", "msvc", "msys", + "musleabihf", "mycomp", "mycompany", "myorg", @@ -223,6 +226,7 @@ "ofjergrg", "onclickoutside", "oomol", + "openharmony", "openpgp", "ossl", "outfile", @@ -309,6 +313,7 @@ "rescopes", "rescoping", "rimrafed", + "riscv", "rmgr", "rpmdevtools", "rpmlint", diff --git a/installing/deps-installer/test/install/optionalDependencies.ts b/installing/deps-installer/test/install/optionalDependencies.ts index b08b7677e3..f1cd4eb0b6 100644 --- a/installing/deps-installer/test/install/optionalDependencies.ts +++ b/installing/deps-installer/test/install/optionalDependencies.ts @@ -1,7 +1,10 @@ import fs from 'node:fs' +import http from 'node:http' +import type { AddressInfo } from 'node:net' import path from 'node:path' import { describe, expect, jest, test } from '@jest/globals' +import { WANTED_LOCKFILE } from '@pnpm/constants' import { addDependenciesToPackage, install, @@ -11,10 +14,12 @@ import { } from '@pnpm/installing.deps-installer' import type { LockfileFile } from '@pnpm/lockfile.fs' import { prepareEmpty, preparePackages } from '@pnpm/prepare' +import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock' import type { ProjectRootDir } from '@pnpm/types' import { rimrafSync } from '@zkochan/rimraf' import deepRequireCwd from 'deep-require-cwd' import { readYamlFileSync } from 'read-yaml-file' +import { writeYamlFileSync } from 'write-yaml-file' import { testDefaults } from '../utils/index.js' @@ -140,6 +145,80 @@ test('skip optional dependency that does not support the current OS', async () = } }) +// Test case for https://github.com/pnpm/pnpm/issues/11702 +test('skip optional dependencies whose names declare unsupported platforms when the registry metadata has no platform fields', async () => { + const project = prepareEmpty() + const server = createMetadataStrippingRegistryProxy() + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const registryProxy = `http://localhost:${(server.address() as AddressInfo).port}/` + try { + await install({ + dependencies: { + '@pnpm.e2e/has-many-optional-deps': '1.0.0', + }, + }, testDefaults({ + registries: { default: registryProxy }, + supportedArchitectures: { os: ['darwin'], cpu: ['arm64'] }, + })) + } finally { + server.closeAllConnections() + await new Promise((resolve, reject) => { + server.close((err) => { + if (err == null) { + resolve() + } else { + reject(err) + } + }) + }) + } + + expect(deepRequireCwd(['@pnpm.e2e/has-many-optional-deps', '@pnpm.e2e/darwin-arm64', './package.json']).version).toBe('1.0.0') + + // The platforms of the other binaries are inferred from their names, so + // they are skipped without even downloading their tarballs. + project.storeHasNot('@pnpm.e2e/linux-x64', '1.0.0') + project.storeHasNot('@pnpm.e2e/windows-x64', '1.0.0') + expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+linux-x64@1.0.0'))).toBeFalsy() + expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+windows-x64@1.0.0'))).toBeFalsy() + + const modulesInfo = readYamlFileSync<{ skipped: string[] }>(path.join('node_modules', '.modules.yaml')) + expect(modulesInfo.skipped).toContain('@pnpm.e2e/linux-x64@1.0.0') + expect(modulesInfo.skipped).toContain('@pnpm.e2e/windows-x64@1.0.0') +}) + +// Simulates a registry that strips os/cpu/libc from packument version objects +// (some registry proxies do this), forwarding everything else to the registry mock. +function createMetadataStrippingRegistryProxy (): http.Server { + return http.createServer((req, res) => { + (async () => { + const upstream = await fetch(`http://localhost:${REGISTRY_MOCK_PORT}${req.url}`, { + method: req.method, + headers: { accept: req.headers.accept ?? '*/*' }, + }) + const contentType = upstream.headers.get('content-type') ?? '' + if (contentType.includes('json')) { + const doc = await upstream.json() as { versions?: Record> } + for (const versionMeta of Object.values(doc.versions ?? {})) { + delete versionMeta.os + delete versionMeta.cpu + delete versionMeta.libc + } + res.writeHead(upstream.status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(doc)) + } else { + res.writeHead(upstream.status, { 'content-type': contentType }) + res.end(Buffer.from(await upstream.arrayBuffer())) + } + })().catch((err) => { + res.writeHead(500) + res.end(String(err)) + }) + }) +} + test('skip optional dependency that does not support the current Node version', async () => { const project = prepareEmpty() const reporter = jest.fn() @@ -615,6 +694,43 @@ describe('supported architectures', () => { }) expect(deepRequireCwd(['@pnpm.e2e/has-many-optional-deps', '@pnpm.e2e/linux-x64', './package.json']).version).toBe('1.0.0') }) + // Test case for https://github.com/pnpm/pnpm/issues/11702 + test.each(['isolated', 'hoisted'])('skip optional dependencies that do not support the target architecture when their lockfile entries have no platform fields (nodeLinker=%s)', async (nodeLinker) => { + const project = prepareEmpty() + const supportedArchitectures = { os: ['darwin'], cpu: ['arm64'] } + + const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['@pnpm.e2e/has-many-optional-deps@1.0.0'], { + ...testDefaults({ nodeLinker }), + supportedArchitectures, + }) + + // Simulate a lockfile resolved from registry metadata that lacks + // the platform fields. + const lockfile = project.readLockfile() + for (const pkgSnapshot of Object.values(lockfile.packages)) { + delete pkgSnapshot.os + delete pkgSnapshot.cpu + delete pkgSnapshot.libc + } + writeYamlFileSync(WANTED_LOCKFILE, lockfile, { lineWidth: 1000 }) + rimrafSync('node_modules') + + await install(manifest, { + ...testDefaults({ nodeLinker }), + frozenLockfile: true, + supportedArchitectures, + }) + + if (nodeLinker === 'hoisted') { + expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/darwin-arm64'))).toBeTruthy() + expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/darwin-x64'))).toBeFalsy() + expect(fs.existsSync(path.resolve('node_modules/@pnpm.e2e/linux-x64'))).toBeFalsy() + } else { + expect(deepRequireCwd(['@pnpm.e2e/has-many-optional-deps', '@pnpm.e2e/darwin-arm64', './package.json']).version).toBe('1.0.0') + expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+darwin-x64@1.0.0'))).toBeFalsy() + expect(fs.existsSync(path.resolve('node_modules/.pnpm/@pnpm.e2e+linux-x64@1.0.0'))).toBeFalsy() + } + }) test('remove optional dependencies that are not used', async () => { prepareEmpty() const opts = testDefaults({ modulesCacheMaxAge: 0 }) diff --git a/installing/package-requester/test/index.ts b/installing/package-requester/test/index.ts index 600c6ea4f4..26173a44e3 100644 --- a/installing/package-requester/test/index.ts +++ b/installing/package-requester/test/index.ts @@ -931,6 +931,43 @@ test('do not fetch an optional package that is not installable', async () => { expect(pkgResponse.fetching).toBeFalsy() }) +// Test case for https://github.com/pnpm/pnpm/issues/11702 +test('do not fetch an optional package whose name declares an unsupported platform when the registry metadata has no platform fields', async () => { + const storeDir = temporaryDirectory() + const cafs = createCafsStore(storeDir) + const resolveWithoutPlatformFields: typeof resolve = async (wantedDependency, opts) => { + const result = await resolve(wantedDependency, opts) + if (result.manifest != null) { + delete result.manifest.os + delete result.manifest.cpu + delete result.manifest.libc + } + return result + } + const requestPackage = createPackageRequester({ + resolve: resolveWithoutPlatformFields, + fetchers, + cafs, + networkConcurrency: 1, + storeDir, + verifyStoreIntegrity: true, + virtualStoreDirMaxLength: 120, + }) + + const projectDir = temporaryDirectory() + const pkgResponse = await requestPackage({ alias: '@pnpm.e2e/linux-x64', optional: true, bareSpecifier: '*' }, { + downloadPriority: 0, + lockfileDir: projectDir, + preferredVersions: {}, + projectDir, + supportedArchitectures: { os: ['darwin'], cpu: ['arm64'] }, + }) + + expect(pkgResponse.body.isInstallable).toBe(false) + expect(pkgResponse.body.id).toBe('@pnpm.e2e/linux-x64@1.0.0') + expect(pkgResponse.fetching).toBeFalsy() +}) + // Test case for https://github.com/pnpm/pnpm/issues/1866 test('fetch a git package without a package.json', async () => { // a small Deno library with a 'denolib.json' instead of a 'package.json' diff --git a/pacquet/crates/env-installer/src/install_config_deps.rs b/pacquet/crates/env-installer/src/install_config_deps.rs index 5664e8dbff..de0b872e4a 100644 --- a/pacquet/crates/env-installer/src/install_config_deps.rs +++ b/pacquet/crates/env-installer/src/install_config_deps.rs @@ -290,6 +290,7 @@ fn is_compatible( return true; } let manifest = PackageInstallabilityManifest { + name: subdep.name.clone(), engines: None, cpu: subdep.cpu.clone(), os: subdep.os.clone(), diff --git a/pacquet/crates/package-is-installable/src/infer_platform_from_package_name.rs b/pacquet/crates/package-is-installable/src/infer_platform_from_package_name.rs new file mode 100644 index 0000000000..2321eec737 --- /dev/null +++ b/pacquet/crates/package-is-installable/src/infer_platform_from_package_name.rs @@ -0,0 +1,107 @@ +//! Port of `inferPlatformFromPackageName.ts` from +//! . + +use crate::check_platform::{WantedPlatform, WantedPlatformRef}; + +fn os_for_token(token: &str) -> Option<&'static str> { + match token { + "aix" => Some("aix"), + "android" => Some("android"), + "darwin" | "macos" | "osx" => Some("darwin"), + "freebsd" => Some("freebsd"), + "linux" => Some("linux"), + "netbsd" => Some("netbsd"), + "openbsd" => Some("openbsd"), + "openharmony" => Some("openharmony"), + "sunos" => Some("sunos"), + "win32" | "windows" => Some("win32"), + _ => None, + } +} + +fn cpu_for_token(token: &str) -> Option<&'static str> { + match token { + "arm" | "armv6" | "armv7" => Some("arm"), + "arm64" | "aarch64" => Some("arm64"), + "ia32" => Some("ia32"), + "loong64" => Some("loong64"), + "mips64el" => Some("mips64el"), + "ppc64" | "ppc64le" => Some("ppc64"), + "riscv64" => Some("riscv64"), + "s390x" => Some("s390x"), + "x64" | "amd64" => Some("x64"), + "wasm32" => Some("wasm32"), + _ => None, + } +} + +fn libc_for_token(token: &str) -> Option<&'static str> { + match token { + "glibc" | "gnu" | "gnueabihf" => Some("glibc"), + "musl" | "musleabihf" => Some("musl"), + _ => None, + } +} + +/// Infers the supported platforms of a package from the tokens of its name, +/// e.g. `@nx/nx-win32-arm64-msvc` produces `os: ["win32"], cpu: ["arm64"]`. +/// Platform-specific binary packages follow this naming convention, which is +/// the only platform signal left when their os/cpu/libc manifest fields are +/// absent. Returns `None` when no platform token is recognized in the name. +pub fn infer_platform_from_package_name(name: &str) -> Option { + let name_without_scope = name.find('/').map_or(name, |idx| &name[idx + 1..]); + let lowercase = name_without_scope.to_lowercase(); + let tokens: Vec<&str> = lowercase.split(['-', '_', '.']).collect(); + let os = pick_token_values(&tokens, os_for_token); + let cpu = pick_token_values(&tokens, cpu_for_token); + let libc = pick_token_values(&tokens, libc_for_token); + if os.is_none() && cpu.is_none() && libc.is_none() { + return None; + } + Some(WantedPlatform { os, cpu, libc }) +} + +fn pick_token_values( + tokens: &[&str], + value_for_token: fn(&str) -> Option<&'static str>, +) -> Option> { + let mut values: Vec = Vec::new(); + for token in tokens { + if let Some(value) = value_for_token(token) + && !values.iter().any(|seen| seen == value) + { + values.push(value.to_string()); + } + } + (!values.is_empty()).then_some(values) +} + +/// The platform fields of an optional dependency may be incomplete: some +/// registries strip os/cpu/libc (or just libc) from the metadata they serve, +/// and lockfile entries written from such metadata lack them too. For a +/// platform-specific binary the package name carries the same information, so +/// each missing field is filled from the name's tokens. A package that +/// declares no platform fields at all is treated as platform-specific only +/// when an operating system is recognized in its name — a generic name +/// segment (e.g. `arm` on its own) never marks it as such. +/// +/// Returns `None` when the declared fields stand as-is. The `optional` gate +/// stays at the call site, mirroring upstream's `effectivePlatform` at +/// . +/// See . +pub fn inferred_platform(name: &str, declared: WantedPlatformRef<'_>) -> Option { + if declared.os.is_some() && declared.cpu.is_some() && declared.libc.is_some() { + return None; + } + let inferred = infer_platform_from_package_name(name)?; + let declares_platform = + declared.os.is_some() || declared.cpu.is_some() || declared.libc.is_some(); + if !declares_platform && inferred.os.is_none() { + return None; + } + Some(WantedPlatform { + os: declared.os.map(<[String]>::to_vec).or(inferred.os), + cpu: declared.cpu.map(<[String]>::to_vec).or(inferred.cpu), + libc: declared.libc.map(<[String]>::to_vec).or(inferred.libc), + }) +} diff --git a/pacquet/crates/package-is-installable/src/lib.rs b/pacquet/crates/package-is-installable/src/lib.rs index cef5e3b1e5..725ae94e0b 100644 --- a/pacquet/crates/package-is-installable/src/lib.rs +++ b/pacquet/crates/package-is-installable/src/lib.rs @@ -18,6 +18,7 @@ mod check_engine; mod check_platform; +mod infer_platform_from_package_name; mod package_is_installable; #[cfg(test)] @@ -30,6 +31,7 @@ pub use check_platform::{ Platform, SupportedArchitectures, UnsupportedPlatformError, WantedPlatform, WantedPlatformRef, check_platform, }; +pub use infer_platform_from_package_name::{infer_platform_from_package_name, inferred_platform}; pub use package_is_installable::{ InstallabilityError, InstallabilityOptions, InstallabilityVerdict, PackageInstallabilityManifest, SkipReason, check_package, package_is_installable, diff --git a/pacquet/crates/package-is-installable/src/package_is_installable.rs b/pacquet/crates/package-is-installable/src/package_is_installable.rs index 9b12871d5c..ac7df2b972 100644 --- a/pacquet/crates/package-is-installable/src/package_is_installable.rs +++ b/pacquet/crates/package-is-installable/src/package_is_installable.rs @@ -8,6 +8,7 @@ use crate::{ check_platform::{ SupportedArchitectures, UnsupportedPlatformError, WantedPlatformRef, check_platform, }, + infer_platform_from_package_name::inferred_platform, }; use derive_more::{Display, Error}; use miette::Diagnostic; @@ -15,8 +16,13 @@ use serde::Serialize; /// Inputs from a package manifest (or lockfile metadata row) that /// drive the installability check. +/// +/// `name` feeds the platform-from-name inference for optional +/// dependencies (see [`inferred_platform`]); an empty name disables +/// the inference and leaves only the declared fields. #[derive(Debug, Default, Clone)] pub struct PackageInstallabilityManifest { + pub name: String, pub engines: Option, pub cpu: Option>, pub os: Option>, @@ -202,6 +208,31 @@ pub fn package_is_installable( manifest: &PackageInstallabilityManifest, options: &InstallabilityOptions<'_>, ) -> Result> { + // Mirrors upstream's `effectivePlatform(pkg, options.optional)` at + // : + // an optional dependency with incomplete platform fields gets the + // missing ones filled from its name before the check runs. + let effective: PackageInstallabilityManifest; + let manifest = if options.optional + && let Some(platform) = inferred_platform( + &manifest.name, + WantedPlatformRef { + os: manifest.os.as_deref(), + cpu: manifest.cpu.as_deref(), + libc: manifest.libc.as_deref(), + }, + ) { + effective = PackageInstallabilityManifest { + name: manifest.name.clone(), + engines: manifest.engines.clone(), + os: platform.os, + cpu: platform.cpu, + libc: platform.libc, + }; + &effective + } else { + manifest + }; let warn = match check_package(package_id, manifest, options) { Ok(maybe) => maybe, Err(invalid_node) => { diff --git a/pacquet/crates/package-is-installable/src/tests.rs b/pacquet/crates/package-is-installable/src/tests.rs index 24638d5099..a5389a8af8 100644 --- a/pacquet/crates/package-is-installable/src/tests.rs +++ b/pacquet/crates/package-is-installable/src/tests.rs @@ -1,10 +1,12 @@ //! Ports of upstream's unit tests: //! - `config/package-is-installable/test/checkPlatform.ts` //! - `config/package-is-installable/test/checkEngine.ts` +//! - `config/package-is-installable/test/inferPlatformFromPackageName.ts` //! -//! Both live under -//! . +//! All live under +//! . mod check_engine; mod check_platform; +mod infer_platform_from_package_name; mod package_is_installable; diff --git a/pacquet/crates/package-is-installable/src/tests/infer_platform_from_package_name.rs b/pacquet/crates/package-is-installable/src/tests/infer_platform_from_package_name.rs new file mode 100644 index 0000000000..daa8ece6aa --- /dev/null +++ b/pacquet/crates/package-is-installable/src/tests/infer_platform_from_package_name.rs @@ -0,0 +1,171 @@ +//! Port of `config/package-is-installable/test/inferPlatformFromPackageName.ts` +//! from +//! . + +use crate::{ + InstallabilityOptions, InstallabilityVerdict, PackageInstallabilityManifest, + SupportedArchitectures, WantedPlatform, infer_platform_from_package_name, + package_is_installable, +}; +use pretty_assertions::assert_eq; + +fn platform( + os: Option<&[&str]>, + cpu: Option<&[&str]>, + libc: Option<&[&str]>, +) -> Option { + Some(WantedPlatform { os: owned(os), cpu: owned(cpu), libc: owned(libc) }) +} + +fn owned(values: Option<&[&str]>) -> Option> { + values.map(|values| values.iter().map(|value| (*value).to_string()).collect()) +} + +#[test] +fn infers_platform_from_real_world_names() { + let cases: &[(&str, Option)] = &[ + ("@nx/nx-win32-arm64-msvc", platform(Some(&["win32"]), Some(&["arm64"]), None)), + ( + "@nx/nx-linux-arm-gnueabihf", + platform(Some(&["linux"]), Some(&["arm"]), Some(&["glibc"])), + ), + ("@nx/nx-linux-x64-gnu", platform(Some(&["linux"]), Some(&["x64"]), Some(&["glibc"]))), + ("@esbuild/aix-ppc64", platform(Some(&["aix"]), Some(&["ppc64"]), None)), + ("@esbuild/openharmony-arm64", platform(Some(&["openharmony"]), Some(&["arm64"]), None)), + ( + "@biomejs/cli-linux-x64-musl", + platform(Some(&["linux"]), Some(&["x64"]), Some(&["musl"])), + ), + ( + "@typescript/native-preview-darwin-arm64", + platform(Some(&["darwin"]), Some(&["arm64"]), None), + ), + ("turbo-windows-64", platform(Some(&["win32"]), None, None)), + ("esbuild-darwin-64", platform(Some(&["darwin"]), None, None)), + ("bun-linux-aarch64", platform(Some(&["linux"]), Some(&["arm64"]), None)), + ("sharp-linux-armv7", platform(Some(&["linux"]), Some(&["arm"]), None)), + ("is-arm", platform(None, Some(&["arm"]), None)), + ("fsevents", None), + ("lodash", None), + ("@pnpm.e2e/not-compatible-with-any-os", None), + ]; + for (name, expected) in cases { + assert_eq!(&infer_platform_from_package_name(name), expected, "name: {name}"); + } +} + +fn optional_on_linux_x64(supported: Option<&SupportedArchitectures>) -> InstallabilityOptions<'_> { + InstallabilityOptions { + engine_strict: false, + optional: true, + current_node_version: "20.10.0", + pnpm_version: None, + current_os: "linux", + current_cpu: "x64", + current_libc: "glibc", + supported_architectures: supported, + } +} + +fn supported_linux_x64_glibc() -> SupportedArchitectures { + SupportedArchitectures { + os: Some(vec!["linux".to_string()]), + cpu: Some(vec!["x64".to_string()]), + libc: Some(vec!["glibc".to_string()]), + } +} + +#[test] +fn optional_dependency_without_platform_fields_is_skipped_by_name() { + let manifest = PackageInstallabilityManifest { + name: "@nx/nx-win32-arm64-msvc".to_string(), + ..Default::default() + }; + let verdict = package_is_installable( + "@nx/nx-win32-arm64-msvc@1.0.0", + &manifest, + &optional_on_linux_x64(None), + ) + .unwrap(); + assert!(matches!(verdict, InstallabilityVerdict::SkipOptional { .. }), "got {verdict:?}"); +} + +#[test] +fn missing_libc_is_taken_from_the_name_when_other_fields_are_declared() { + let supported = supported_linux_x64_glibc(); + let options = optional_on_linux_x64(Some(&supported)); + let musl = PackageInstallabilityManifest { + name: "@nx/nx-linux-x64-musl".to_string(), + os: Some(vec!["linux".to_string()]), + cpu: Some(vec!["x64".to_string()]), + ..Default::default() + }; + let verdict = package_is_installable("@nx/nx-linux-x64-musl@1.0.0", &musl, &options).unwrap(); + assert!(matches!(verdict, InstallabilityVerdict::SkipOptional { .. }), "got {verdict:?}"); + + let gnu = PackageInstallabilityManifest { + name: "@nx/nx-linux-x64-gnu".to_string(), + os: Some(vec!["linux".to_string()]), + cpu: Some(vec!["x64".to_string()]), + ..Default::default() + }; + let verdict = package_is_installable("@nx/nx-linux-x64-gnu@1.0.0", &gnu, &options).unwrap(); + assert_eq!(verdict, InstallabilityVerdict::Installable); +} + +#[test] +fn missing_cpu_is_taken_from_the_name_of_a_package_that_declares_its_platform() { + let manifest = PackageInstallabilityManifest { + name: "@pnpm.e2e/some-pkg-arm64".to_string(), + os: Some(vec!["linux".to_string()]), + ..Default::default() + }; + let verdict = package_is_installable( + "@pnpm.e2e/some-pkg-arm64@1.0.0", + &manifest, + &optional_on_linux_x64(None), + ) + .unwrap(); + assert!(matches!(verdict, InstallabilityVerdict::SkipOptional { .. }), "got {verdict:?}"); +} + +#[test] +fn declared_platform_fields_take_precedence_over_the_name() { + let manifest = PackageInstallabilityManifest { + name: "@pnpm.e2e/win32-binary".to_string(), + os: Some(vec!["linux".to_string()]), + cpu: Some(vec!["x64".to_string()]), + libc: Some(vec!["glibc".to_string()]), + ..Default::default() + }; + let supported = supported_linux_x64_glibc(); + let verdict = package_is_installable( + "@pnpm.e2e/win32-binary@1.0.0", + &manifest, + &optional_on_linux_x64(Some(&supported)), + ) + .unwrap(); + assert_eq!(verdict, InstallabilityVerdict::Installable); +} + +#[test] +fn package_without_declared_fields_is_not_skipped_without_an_os_token() { + let manifest = + PackageInstallabilityManifest { name: "is-arm".to_string(), ..Default::default() }; + let verdict = + package_is_installable("is-arm@1.0.0", &manifest, &optional_on_linux_x64(None)).unwrap(); + assert_eq!(verdict, InstallabilityVerdict::Installable); +} + +#[test] +fn platform_is_not_inferred_for_a_non_optional_dependency() { + let manifest = PackageInstallabilityManifest { + name: "@nx/nx-win32-arm64-msvc".to_string(), + ..Default::default() + }; + let mut options = optional_on_linux_x64(None); + options.optional = false; + let verdict = + package_is_installable("@nx/nx-win32-arm64-msvc@1.0.0", &manifest, &options).unwrap(); + assert_eq!(verdict, InstallabilityVerdict::Installable); +} diff --git a/pacquet/crates/package-manager/src/hoisted_dep_graph.rs b/pacquet/crates/package-manager/src/hoisted_dep_graph.rs index 8770d6356c..308cfc2dfc 100644 --- a/pacquet/crates/package-manager/src/hoisted_dep_graph.rs +++ b/pacquet/crates/package-manager/src/hoisted_dep_graph.rs @@ -664,7 +664,7 @@ fn walk_deps( // an unsupported platform is silently added to `skipped`; // a required dep takes the error path. if !state.opts.force { - let manifest = manifest_for_installability(metadata); + let manifest = manifest_for_installability(&pkg_key, metadata); let optional = snapshot.map(|s| s.optional).unwrap_or(false); let install_opts = InstallabilityOptions { engine_strict: state.opts.engine_strict, @@ -771,6 +771,7 @@ fn lookup_package_metadata<'a>( /// [lockfileToHoistedDepGraph.ts:192-199](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L192-L199); /// extracted here so the walker body stays small. fn manifest_for_installability( + pkg_key: &PackageKey, metadata: &pacquet_lockfile::PackageMetadata, ) -> PackageInstallabilityManifest { let engines = metadata.engines.as_ref().map(|engines| WantedEngine { @@ -778,6 +779,7 @@ fn manifest_for_installability( pnpm: engines.get("pnpm").cloned(), }); PackageInstallabilityManifest { + name: pkg_key.name.to_string(), engines, cpu: metadata.cpu.clone(), os: metadata.os.clone(), diff --git a/pacquet/crates/package-manager/src/install_frozen_lockfile.rs b/pacquet/crates/package-manager/src/install_frozen_lockfile.rs index aba37cdb2d..74cf480d74 100644 --- a/pacquet/crates/package-manager/src/install_frozen_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_frozen_lockfile.rs @@ -329,7 +329,9 @@ where // extraction (so `CreateVirtualStore` can suppress slots for // skipped snapshots), and the spawn cost is unavoidable. let needs_installability_check = match (snapshots, packages) { - (Some(snaps), Some(pkgs)) if !snaps.is_empty() => any_installability_constraint(pkgs), + (Some(snaps), Some(pkgs)) if !snaps.is_empty() => { + any_installability_constraint(snaps, pkgs) + } _ => false, }; diff --git a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs index 1affa45c87..2fd822960a 100644 --- a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -1465,11 +1465,11 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList> // constraint, so the engine check resolves against a real version // instead of erroring on an empty one. Skip the `node --version` // probe entirely when nothing constrains it. - let host_node = if built_lockfile - .packages - .as_ref() - .is_some_and(crate::any_installability_constraint) - { + let host_node = if built_lockfile.packages.as_ref().is_some_and(|packages| { + built_lockfile.snapshots.as_ref().is_some_and(|snapshots| { + crate::any_installability_constraint(snapshots, packages) + }) + }) { tokio::task::spawn_blocking(crate::InstallabilityHost::detect) .await .ok() diff --git a/pacquet/crates/package-manager/src/installability.rs b/pacquet/crates/package-manager/src/installability.rs index 0d914f2c6a..8c56dd85f0 100644 --- a/pacquet/crates/package-manager/src/installability.rs +++ b/pacquet/crates/package-manager/src/installability.rs @@ -25,7 +25,7 @@ use std::collections::{HashMap, HashSet}; use pacquet_lockfile::{PackageKey, PackageMetadata, SnapshotEntry}; use pacquet_package_is_installable::{ InstallabilityError, InstallabilityOptions, PackageInstallabilityManifest, SkipReason, - SupportedArchitectures, WantedEngine, check_package, + SupportedArchitectures, WantedEngine, WantedPlatformRef, check_package, inferred_platform, }; use pacquet_reporter::{ LogEvent, LogLevel, Reporter, SkippedOptionalDependencyLog, SkippedOptionalPackage, @@ -316,7 +316,7 @@ pub fn compute_skipped_snapshots( // shape as the loop it short-circuits — but does at most four // `Option::is_some` checks per row and short-circuits on the // first declared constraint. - if !any_installability_constraint(packages) { + if !any_installability_constraint(snapshots, packages) { return Ok(seed); } @@ -355,7 +355,12 @@ pub fn compute_skipped_snapshots( // `SkipOptional` details payload and the `ProceedWithWarning` // message body, matching upstream's `warn.toString()` / `warn.message` // at `index.ts:50` / `:44`). - let mut check_cache: HashMap> = HashMap::new(); + // + // The key carries the snapshot's `optional` flag because the + // platform-from-name inference only runs for optional snapshots, + // so the verdict of an optional and a non-optional snapshot of + // the same metadata row can differ. + let mut check_cache: HashMap<(PackageKey, bool), Option> = HashMap::new(); for (snapshot_key, snapshot) in snapshots { // Seeded entries short-circuit the per-snapshot re-check. @@ -378,14 +383,33 @@ pub fn compute_skipped_snapshots( // (small) and only happens on the first peer-variant of each // package. Subsequent peer-variants land in the `else` arm // and read back the cached verdict. - let warn = if let Some(cached) = check_cache.get(&metadata_key) { + let cache_key = (metadata_key.clone(), snapshot.optional); + let warn = if let Some(cached) = check_cache.get(&cache_key) { cached.clone() } else { - let manifest = manifest_from_metadata(metadata); + let mut manifest = manifest_from_metadata(&metadata_key, metadata); + // Mirrors upstream's `effectivePlatform(pkg, options.optional)` at + // : + // an optional snapshot with incomplete platform fields gets + // the missing ones filled from the package name. + if snapshot.optional + && let Some(platform) = inferred_platform( + &manifest.name, + WantedPlatformRef { + os: manifest.os.as_deref(), + cpu: manifest.cpu.as_deref(), + libc: manifest.libc.as_deref(), + }, + ) + { + manifest.os = platform.os; + manifest.cpu = platform.cpu; + manifest.libc = platform.libc; + } let pkg_id = metadata_key.to_string(); let result = check_package(&pkg_id, &manifest, &base_options) .map_err(|invalid| Box::new(InstallabilityError::InvalidNodeVersion(invalid)))?; - check_cache.insert(metadata_key.clone(), result.clone()); + check_cache.insert(cache_key, result.clone()); result }; @@ -427,18 +451,38 @@ pub fn compute_skipped_snapshots( /// True if any package metadata row in the lockfile declares an /// `engines` / `cpu` / `os` / `libc` constraint pacquet would need -/// to evaluate. Short-circuits on the first hit. When this returns -/// false, both [`compute_skipped_snapshots`] and the caller can -/// short-circuit: no need to spawn `node --version` or build the -/// host context, because the verdict is unconditionally an empty -/// skip set. +/// to evaluate, or any optional snapshot's package name infers a +/// platform constraint its metadata row doesn't declare. +/// Short-circuits on the first hit. When this returns false, both +/// [`compute_skipped_snapshots`] and the caller can short-circuit: +/// no need to spawn `node --version` or build the host context, +/// because the verdict is unconditionally an empty skip set. /// /// `pub` so `install_frozen_lockfile` can gate the host detection /// on it — the spawn is otherwise on the critical path of /// `CreateVirtualStore::run` and serializes ~100ms of node-binary /// startup with extraction it used to overlap with. -pub fn any_installability_constraint(packages: &HashMap) -> bool { +pub fn any_installability_constraint( + snapshots: &HashMap, + packages: &HashMap, +) -> bool { packages.values().any(metadata_has_meaningful_constraint) + || snapshots.iter().any(|(snapshot_key, snapshot)| { + snapshot.optional && { + let metadata_key = snapshot_key.without_peer(); + packages.get(&metadata_key).is_some_and(|metadata| { + inferred_platform( + &metadata_key.name.to_string(), + WantedPlatformRef { + os: metadata.os.as_deref(), + cpu: metadata.cpu.as_deref(), + libc: metadata.libc.as_deref(), + }, + ) + .is_some() + }) + } + }) } /// True if a single metadata row carries a constraint pacquet would @@ -473,8 +517,12 @@ fn platform_axis_meaningful(axis: Option<&[String]>) -> bool { } } -fn manifest_from_metadata(metadata: &PackageMetadata) -> PackageInstallabilityManifest { +fn manifest_from_metadata( + metadata_key: &PackageKey, + metadata: &PackageMetadata, +) -> PackageInstallabilityManifest { PackageInstallabilityManifest { + name: metadata_key.name.to_string(), engines: metadata.engines.as_ref().map(|map| WantedEngine { node: map.get("node").cloned(), pnpm: map.get("pnpm").cloned(), diff --git a/pacquet/crates/package-manager/src/installability/tests.rs b/pacquet/crates/package-manager/src/installability/tests.rs index cbcf2098e8..38d8822208 100644 --- a/pacquet/crates/package-manager/src/installability/tests.rs +++ b/pacquet/crates/package-manager/src/installability/tests.rs @@ -263,7 +263,7 @@ fn engines_without_node_or_pnpm_does_not_count_as_constraint() { let mut packages = HashMap::new(); packages.insert(key, synthetic_metadata(Some(&[("npm", ">=8")]), None, None, None)); assert!( - !any_installability_constraint(&packages), + !any_installability_constraint(&HashMap::new(), &packages), "engines.npm alone should not block the fast path", ); } @@ -277,7 +277,7 @@ fn platform_any_sentinel_does_not_count_as_constraint() { let mut packages = HashMap::new(); packages.insert(key, synthetic_metadata(None, Some(&["any"]), Some(&["any"]), Some(&["any"]))); assert!( - !any_installability_constraint(&packages), + !any_installability_constraint(&HashMap::new(), &packages), r#"cpu/os/libc = ["any"] should not block the fast path"#, ); } @@ -291,7 +291,7 @@ fn empty_platform_lists_do_not_count_as_constraint() { let mut packages = HashMap::new(); packages.insert(key, synthetic_metadata(None, Some(&[]), Some(&[]), Some(&[]))); assert!( - !any_installability_constraint(&packages), + !any_installability_constraint(&HashMap::new(), &packages), "empty platform lists should not block the fast path", ); } @@ -303,7 +303,10 @@ fn meaningful_engines_node_triggers_slow_path() { let key = snapshot_key("for-legacy-node@1.0.0"); let mut packages = HashMap::new(); packages.insert(key, synthetic_metadata(Some(&[("node", "0.10")]), None, None, None)); - assert!(any_installability_constraint(&packages), "engines.node must trigger the slow path"); + assert!( + any_installability_constraint(&HashMap::new(), &packages), + "engines.node must trigger the slow path", + ); } /// A meaningful non-`any` platform value triggers the slow path. @@ -312,7 +315,10 @@ fn meaningful_platform_value_triggers_slow_path() { let key = snapshot_key("not-compatible-with-any-os@1.0.0"); let mut packages = HashMap::new(); packages.insert(key, synthetic_metadata(None, None, Some(&["this-os-does-not-exist"]), None)); - assert!(any_installability_constraint(&packages), "non-any os must trigger the slow path"); + assert!( + any_installability_constraint(&HashMap::new(), &packages), + "non-any os must trigger the slow path", + ); } /// Peer-resolved variants of the same metadata row (e.g. @@ -595,3 +601,139 @@ fn fetch_failed_and_optional_excluded_are_symmetric() { assert_eq!(skipped_b.iter().count(), 1); assert!(skipped_b.contains(&key)); } + +/// An optional snapshot whose metadata row carries no platform +/// fields (some registries strip os/cpu/libc from the metadata they +/// serve, and lockfile entries written from such metadata lack them +/// too) is still skipped when its package name declares an +/// unsupported platform. Ports the headless half of upstream's +/// `optionalDependencies.ts` `skip optional dependencies that do not +/// support the target architecture when their lockfile entries have +/// no platform fields`. +#[test] +fn skip_optional_with_platform_inferred_from_name() { + reset_events(); + let foreign = snapshot_key("@nx/nx-win32-arm64-msvc@1.0.0"); + let matching = snapshot_key("@nx/nx-linux-x64-gnu@1.0.0"); + let mut snapshots = HashMap::new(); + snapshots.insert(foreign.clone(), SnapshotEntry { optional: true, ..Default::default() }); + snapshots.insert(matching.clone(), SnapshotEntry { optional: true, ..Default::default() }); + let mut packages = HashMap::new(); + packages.insert(foreign.clone(), synthetic_metadata(None, None, None, None)); + packages.insert(matching.clone(), synthetic_metadata(None, None, None, None)); + + let skipped = compute_skipped_snapshots::( + &snapshots, + &packages, + &host("20.10.0", "linux", "x64"), + "/proj", + SkippedSnapshots::new(), + ) + .unwrap(); + + assert_eq!(skipped.len(), 1); + assert!(skipped.contains(&foreign)); + assert!(!skipped.contains(&matching)); + + let events = take_events(); + let skipped_events_count = events + .iter() + .filter(|event| matches!(event, LogEvent::SkippedOptionalDependency(_))) + .count(); + assert_eq!(skipped_events_count, 1); +} + +/// The platform is not inferred from the name of a non-optional +/// snapshot: a regular dependency that happens to carry a platform +/// token in its name installs everywhere unless its metadata says +/// otherwise. +#[test] +fn name_inference_does_not_apply_to_non_optional_snapshots() { + reset_events(); + let key = snapshot_key("@nx/nx-win32-arm64-msvc@1.0.0"); + let mut snapshots = HashMap::new(); + snapshots.insert(key.clone(), SnapshotEntry { optional: false, ..Default::default() }); + let mut packages = HashMap::new(); + packages.insert(key, synthetic_metadata(None, None, None, None)); + + let skipped = compute_skipped_snapshots::( + &snapshots, + &packages, + &host("20.10.0", "linux", "x64"), + "/proj", + SkippedSnapshots::new(), + ) + .unwrap(); + + assert!(skipped.is_empty()); +} + +/// A missing libc field alone is taken from the package name: with +/// `supportedArchitectures.libc = ['glibc']`, the `-musl` variant is +/// skipped and the `-gnu` variant stays, even though neither +/// metadata row declares `libc`. +#[test] +fn missing_libc_is_inferred_from_name() { + reset_events(); + let musl = snapshot_key("@nx/nx-linux-x64-musl@1.0.0"); + let gnu = snapshot_key("@nx/nx-linux-x64-gnu@1.0.0"); + let mut snapshots = HashMap::new(); + snapshots.insert(musl.clone(), SnapshotEntry { optional: true, ..Default::default() }); + snapshots.insert(gnu.clone(), SnapshotEntry { optional: true, ..Default::default() }); + let mut packages = HashMap::new(); + packages.insert(musl.clone(), synthetic_metadata(None, Some(&["x64"]), Some(&["linux"]), None)); + packages.insert(gnu.clone(), synthetic_metadata(None, Some(&["x64"]), Some(&["linux"]), None)); + + let mut host = host("20.10.0", "linux", "x64"); + host.libc = "glibc"; + host.supported_architectures = Some(pacquet_package_is_installable::SupportedArchitectures { + os: Some(vec!["linux".to_string()]), + cpu: Some(vec!["x64".to_string()]), + libc: Some(vec!["glibc".to_string()]), + }); + + let skipped = compute_skipped_snapshots::( + &snapshots, + &packages, + &host, + "/proj", + SkippedSnapshots::new(), + ) + .unwrap(); + + assert_eq!(skipped.len(), 1); + assert!(skipped.contains(&musl)); + assert!(!skipped.contains(&gnu)); +} + +/// An optional snapshot whose name carries a platform token while +/// its metadata row declares no platform fields must block the +/// fast path — otherwise the inference never gets a chance to run. +#[test] +fn name_inferable_optional_snapshot_triggers_slow_path() { + let key = snapshot_key("@nx/nx-win32-arm64-msvc@1.0.0"); + let mut snapshots = HashMap::new(); + snapshots.insert(key.clone(), SnapshotEntry { optional: true, ..Default::default() }); + let mut packages = HashMap::new(); + packages.insert(key, synthetic_metadata(None, None, None, None)); + assert!( + any_installability_constraint(&snapshots, &packages), + "a platform-named optional snapshot must trigger the slow path", + ); +} + +/// A name without an operating-system token never marks a package +/// that declares no platform fields as platform-specific, so it +/// must not block the fast path either. +#[test] +fn generic_name_does_not_trigger_slow_path() { + let key = snapshot_key("is-arm@1.0.0"); + let mut snapshots = HashMap::new(); + snapshots.insert(key.clone(), SnapshotEntry { optional: true, ..Default::default() }); + let mut packages = HashMap::new(); + packages.insert(key, synthetic_metadata(None, None, None, None)); + assert!( + !any_installability_constraint(&snapshots, &packages), + "a generic name segment must not block the fast path", + ); +}