Files
pnpm/pnpm11/resolving/npm-resolver/test/pickVersionByVersionRange.test.ts
Zoltan Kochan cfae1ffae7 fix(resolver): let a lockfile pin break ties instead of outranking the graph (#17090)
The version picker ranks the candidates for a range by the summed weight
of their preferences. The lockfile pin weighed 1,000,000, a manifest
specifier 1,000, and a version resolved higher in the dependency chain 1.
A fresh install has no lockfile, so it picks the version the chain
already resolved, while a reinstall kept the pinned version and wrote a
second copy of the package. Replacing a dependency therefore left a
lockfile that a fresh install of the same manifests does not write.

The pin is now the lowest tier: a version named on the command line,
then a manifest specifier, then a version resolved higher in the chain,
then the lockfile pin. Each tier is a digit of a base-1000 number, and
`pinned_by_lockfile` / `pinnedByLockfile` reads the lowest digit where
the code used to compare against the old lockfile weight. A pin that no
other preference competes with still wins against the newest version, so
a re-resolved dependency keeps the locked versions of its own
dependencies (pnpm/pnpm#2191). v12 gets a port of that test.

The pins `pnpm import` reads from another package manager's lockfile and
the version a command names move to the top tier, so they keep
outranking every manifest range. The v11 `audit --fix update` penalties
are derived from the manifest weight, so they keep their place relative
to it. The `dedupe` option of the v11 preferred-versions seed no longer
changes anything and is removed.

The peer convergence harness no longer tracks a divergence for
pnpm/pnpm#17084.

Also fixes an order-dependent v11 test that read a dist tag other tests
move.

Closes pnpm/pnpm#17084
2026-10-18 16:39:03 +02:00

314 lines
10 KiB
TypeScript

import { expect, test } from '@jest/globals'
import type { PackageMeta } from '@pnpm/resolving.registry.types'
import { DIRECT_DEP_SELECTOR_WEIGHT, EXISTING_VERSION_SELECTOR_WEIGHT, RESOLVED_VERSION_SELECTOR_WEIGHT } from '@pnpm/resolving.resolver-base'
import semver from 'semver'
import {
pickLowestVersionByVersionRange,
pickStableCachedRangeVersion,
pickVersionByVersionRange,
} from '../src/pickPackageFromMeta.js'
// Deliberately mixes prereleases, build metadata, an unparseable entry, and a
// version only a loose parse accepts, because the pickers select over whatever
// keys a registry puts in `versions`.
const VERSIONS = [
'0.0.1',
'1.0.0-alpha.1',
'1.0.0-beta.2',
'1.0.0',
'1.0.10',
'1.2.0',
'1.2.3+build.1',
'1.10.0',
'2.0.0-rc.1',
'2.0.0',
'3.0.0',
'v4.0.0',
'not-a-version',
]
const RANGES = [
'*',
'^1.0.0',
'~1.2.0',
'1.x',
'>=1.0.0 <2.0.0',
'1.0.0 || 3.0.0',
'>=1.0.0-alpha.1 <2.0.0',
'2.0.0-rc.1',
'<=1.2',
'4.0.0',
'^9.0.0',
'not-a-range',
]
// No `latest` tag, so the pickers can't take their dist-tag shortcut and every
// range goes through the version scan.
function metaWithoutLatest (versions: string[]): PackageMeta {
const meta: PackageMeta = {
name: 'pick-version',
'dist-tags': {},
versions: {},
}
for (const version of versions) {
meta.versions[version] = {
name: 'pick-version',
version,
dist: { tarball: `https://registry.npmjs.org/pick-version/-/pick-version-${version}.tgz`, shasum: '' },
}
}
return meta
}
function metaWithDeprecation (versions: Record<string, boolean>, latest?: string): PackageMeta {
const meta: PackageMeta = {
name: 'pick-version',
'dist-tags': latest == null ? {} : { latest },
versions: {},
}
for (const [version, deprecated] of Object.entries(versions)) {
meta.versions[version] = {
name: 'pick-version',
version,
...(deprecated ? { deprecated: 'do not use' } : {}),
dist: { tarball: `https://registry.npmjs.org/pick-version/-/pick-version-${version}.tgz`, shasum: '' },
}
}
return meta
}
test.each(RANGES)('the highest satisfying version of %s matches semver.maxSatisfying', (versionRange) => {
const meta = metaWithoutLatest(VERSIONS)
expect(pickVersionByVersionRange({ meta, versionRange })).toBe(semver.maxSatisfying(VERSIONS, versionRange, true))
})
test.each(RANGES.filter((versionRange) => versionRange !== '*'))(
'the lowest satisfying version of %s matches semver.minSatisfying',
(versionRange) => {
const meta = metaWithoutLatest(VERSIONS)
expect(pickLowestVersionByVersionRange({ meta, versionRange })).toBe(semver.minSatisfying(VERSIONS, versionRange, true))
}
)
test('a range satisfied by equal versions keeps the first of them, as semver does', () => {
// `1.2.3+build.1` and `1.2.3+build.2` compare equal (build metadata is
// ignored), so the winner is decided by which one is seen first.
const versions = ['1.2.3+build.1', '1.2.3+build.2']
const meta = metaWithoutLatest(versions)
expect(pickVersionByVersionRange({ meta, versionRange: '1.2.3' }))
.toBe(semver.maxSatisfying(versions, '1.2.3', true))
expect(pickLowestVersionByVersionRange({ meta, versionRange: '1.2.3' }))
.toBe(semver.minSatisfying(versions, '1.2.3', true))
})
test('a packument of only unparseable versions satisfies nothing', () => {
const meta = metaWithoutLatest(['not-a-version', 'also-not-a-version'])
expect(pickVersionByVersionRange({ meta, versionRange: '^1.0.0' })).toBeNull()
expect(pickLowestVersionByVersionRange({ meta, versionRange: '^1.0.0' })).toBeNull()
})
test('a single dominant lockfile version makes a cached range stable', () => {
const meta = metaWithoutLatest(['1.0.0', '1.1.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
'^1.0.0': { selectorType: 'range', weight: 1000 },
},
versionRange: '^1.0.0',
})
expect(result).toBe('1.0.0')
})
test('a version resolved higher in the chain makes a cached range stable over the lockfile pin', () => {
const meta = metaWithoutLatest(['1.0.0', '1.1.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
'1.1.0': { selectorType: 'version', weight: RESOLVED_VERSION_SELECTOR_WEIGHT },
},
versionRange: '^1.0.0',
})
expect(result).toBe('1.1.0')
})
test('a cache missing the lockfile version is not stable', () => {
const meta = metaWithoutLatest(['1.0.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.1.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('multiple satisfying lockfile versions are not treated as stable', () => {
const meta = metaWithoutLatest(['1.0.0', '1.1.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
'1.1.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('a competing selector that can tie the lockfile pin is not stable', () => {
const meta = metaWithoutLatest(['1.0.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
'>=1.1.0': { selectorType: 'range', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('a negative preferred selector disables cached range reuse', () => {
const meta = metaWithoutLatest(['1.0.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
'>=1.1.0': { selectorType: 'range', weight: -1 },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('a pin a movable dist-tag outweighs is not stable', () => {
const meta = metaWithoutLatest(['1.0.0', '1.1.0'])
meta['dist-tags'].next = '1.1.0'
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
next: { selectorType: 'tag', weight: DIRECT_DEP_SELECTOR_WEIGHT },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('a pin that outweighs every movable dist-tag is stable', () => {
const meta = metaWithoutLatest(['1.0.0', '1.1.0'])
meta['dist-tags'].next = '1.1.0'
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: DIRECT_DEP_SELECTOR_WEIGHT + EXISTING_VERSION_SELECTOR_WEIGHT },
next: { selectorType: 'tag', weight: RESOLVED_VERSION_SELECTOR_WEIGHT },
},
versionRange: '^1.0.0',
})
expect(result).toBe('1.0.0')
})
test('a fractional selector weight disables cached range reuse', () => {
const meta = metaWithoutLatest(['1.0.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
'>=1.1.0': { selectorType: 'range', weight: 0.5 },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('a non-finite selector weight disables cached range reuse', () => {
const meta = metaWithoutLatest(['1.0.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
'>=1.1.0': { selectorType: 'range', weight: Number.POSITIVE_INFINITY },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('selector weights whose sum is unsafe disable cached range reuse', () => {
const meta = metaWithoutLatest(['1.0.0'])
const result = pickStableCachedRangeVersion({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: Number.MAX_SAFE_INTEGER },
'>=1.0.0': { selectorType: 'range', weight: 1 },
},
versionRange: '^1.0.0',
})
expect(result).toBeNull()
})
test('a deprecated prerelease latest under * falls back to a non-deprecated prerelease', () => {
const meta = metaWithDeprecation({ '2.0.0-beta.1': true, '2.0.0-beta.2': false }, '2.0.0-beta.1')
expect(pickVersionByVersionRange({ meta, versionRange: '*' })).toBe('2.0.0-beta.2')
})
test('a deprecated prerelease latest under * does not fall back to a different prerelease line', () => {
const meta = metaWithDeprecation({ '2.0.0-beta.1': true, '3.0.0-alpha.1': false }, '2.0.0-beta.1')
expect(pickVersionByVersionRange({ meta, versionRange: '*' })).toBe('2.0.0-beta.1')
})
test('a deprecated prerelease latest under * prefers a non-deprecated prerelease from the same release over a lower stable version', () => {
const meta = metaWithDeprecation(
{ '1.0.0': false, '2.0.0-beta.1': true, '2.0.0-beta.2': false },
'2.0.0-beta.1'
)
expect(pickVersionByVersionRange({ meta, versionRange: '*' })).toBe('2.0.0-beta.2')
})
test('a deprecated stable latest under * does not fall back to a prerelease', () => {
const meta = metaWithDeprecation({ '0.9.0': false, '1.0.0': true, '2.0.0-beta.1': false }, '1.0.0')
expect(pickVersionByVersionRange({ meta, versionRange: '*' })).toBe('0.9.0')
})
test('a deprecated preferred-selector winner falls back inside its group', () => {
const meta = metaWithDeprecation({ '2.0.0': true, '2.0.0-beta.1': false }, '2.0.0')
const result = pickVersionByVersionRange({
meta,
preferredVersionSelectors: {
'^2.0.0-beta.0': { selectorType: 'range', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
},
versionRange: '^2.0.0-beta.0',
})
expect(result).toBe('2.0.0-beta.1')
})
test('a preferred group holding one pinned version keeps it even when deprecated', () => {
const meta = metaWithDeprecation({ '1.0.0': true, '2.0.0': false }, '1.0.0')
const result = pickVersionByVersionRange({
meta,
preferredVersionSelectors: {
'1.0.0': { selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT },
},
versionRange: '^1.0.0',
})
expect(result).toBe('1.0.0')
})