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
314 lines
10 KiB
TypeScript
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')
|
|
})
|