mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 18:35:18 -04:00
fix: infer missing platform fields of optional dependencies from the package name (#12312)
* fix: infer missing platform fields of optional deps from the package name Some registries strip the os/cpu/libc fields (or just libc) from the version objects of the packuments they serve. Resolution then saw every platform-specific optional dependency as platform-unrestricted, so pnpm downloaded and installed the binaries of every platform regardless of supportedArchitectures, and wrote lockfile entries without the platform fields, which broke installs from that lockfile on every machine. Platform-specific binary packages encode their platform in the package name (e.g. @nx/nx-win32-arm64-msvc), so packageIsInstallable now fills the missing platform fields of an optional dependency from the name's tokens. Since every install path decides installability through that check before fetching, foreign-platform binaries are skipped without even downloading them, in fresh resolution and in headless installs with both node linkers alike. A package that declares no platform fields at all is treated as platform-specific only when an operating system is recognized in its name, so a generic name segment (such as 'arm' on its own) never gets a package skipped. Fixes https://github.com/pnpm/pnpm/issues/11702 Fixes https://github.com/pnpm/pnpm/issues/9940 * chore: add platform name tokens to the cspell dictionary * fix(package-is-installable): infer missing platform fields of optional deps from the package name Port of pnpm commit https://github.com/pnpm/pnpm/commit/34875b2d7c (PR https://github.com/pnpm/pnpm/pull/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 https://github.com/pnpm/pnpm/issues/11702 Fixes https://github.com/pnpm/pnpm/issues/9940 * test: shut the metadata-stripping proxy down cleanly and forward the request method
This commit is contained in:
6
.changeset/spicy-pots-wonder.md
Normal file
6
.changeset/spicy-pots-wonder.md
Normal file
@@ -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).
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, string>): string[] | undefined {
|
||||
const values = new Set<string>()
|
||||
for (const token of tokens) {
|
||||
const value = valueByToken.get(token)
|
||||
if (value != null) {
|
||||
values.add(value)
|
||||
}
|
||||
}
|
||||
return values.size > 0 ? Array.from(values) : undefined
|
||||
}
|
||||
@@ -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<typeof DetectLibc>('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)
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void>((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<void>((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<string, Record<string, unknown>> }
|
||||
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 })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -290,6 +290,7 @@ fn is_compatible<Reporter: self::Reporter>(
|
||||
return true;
|
||||
}
|
||||
let manifest = PackageInstallabilityManifest {
|
||||
name: subdep.name.clone(),
|
||||
engines: None,
|
||||
cpu: subdep.cpu.clone(),
|
||||
os: subdep.os.clone(),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Port of `inferPlatformFromPackageName.ts` from
|
||||
//! <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/src/inferPlatformFromPackageName.ts>.
|
||||
|
||||
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<WantedPlatform> {
|
||||
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<Vec<String>> {
|
||||
let mut values: Vec<String> = 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
|
||||
/// <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/src/index.ts#L70-L96>.
|
||||
/// See <https://github.com/pnpm/pnpm/issues/11702>.
|
||||
pub fn inferred_platform(name: &str, declared: WantedPlatformRef<'_>) -> Option<WantedPlatform> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WantedEngine>,
|
||||
pub cpu: Option<Vec<String>>,
|
||||
pub os: Option<Vec<String>>,
|
||||
@@ -202,6 +208,31 @@ pub fn package_is_installable(
|
||||
manifest: &PackageInstallabilityManifest,
|
||||
options: &InstallabilityOptions<'_>,
|
||||
) -> Result<InstallabilityVerdict, Box<InstallabilityError>> {
|
||||
// Mirrors upstream's `effectivePlatform(pkg, options.optional)` at
|
||||
// <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/src/index.ts#L41>:
|
||||
// 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) => {
|
||||
|
||||
@@ -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
|
||||
//! <https://github.com/pnpm/pnpm/tree/94240bc046/config/package-is-installable/test>.
|
||||
//! All live under
|
||||
//! <https://github.com/pnpm/pnpm/tree/34875b2d7c/config/package-is-installable/test>.
|
||||
|
||||
mod check_engine;
|
||||
mod check_platform;
|
||||
mod infer_platform_from_package_name;
|
||||
mod package_is_installable;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Port of `config/package-is-installable/test/inferPlatformFromPackageName.ts`
|
||||
//! from
|
||||
//! <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/test/inferPlatformFromPackageName.ts>.
|
||||
|
||||
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<WantedPlatform> {
|
||||
Some(WantedPlatform { os: owned(os), cpu: owned(cpu), libc: owned(libc) })
|
||||
}
|
||||
|
||||
fn owned(values: Option<&[&str]>) -> Option<Vec<String>> {
|
||||
values.map(|values| values.iter().map(|value| (*value).to_string()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_platform_from_real_world_names() {
|
||||
let cases: &[(&str, Option<WantedPlatform>)] = &[
|
||||
("@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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Reporter: self::Reporter>(
|
||||
// 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<Reporter: self::Reporter>(
|
||||
// `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<PackageKey, Option<InstallabilityError>> = 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<InstallabilityError>> = 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<Reporter: self::Reporter>(
|
||||
// (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
|
||||
// <https://github.com/pnpm/pnpm/blob/34875b2d7c/config/package-is-installable/src/index.ts#L41>:
|
||||
// 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<Reporter: self::Reporter>(
|
||||
|
||||
/// 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<PackageKey, PackageMetadata>) -> bool {
|
||||
pub fn any_installability_constraint(
|
||||
snapshots: &HashMap<PackageKey, SnapshotEntry>,
|
||||
packages: &HashMap<PackageKey, PackageMetadata>,
|
||||
) -> 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(),
|
||||
|
||||
@@ -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::<RecordingReporter>(
|
||||
&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::<RecordingReporter>(
|
||||
&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::<RecordingReporter>(
|
||||
&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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user