Skip to content

Commit 29ab905

Browse files
authored
fix: preserve catalog version range policy on update (#12416)
A named catalog whose name parses as a version (e.g. catalog:express4-21) had its range policy overridden by pnpm update because whichVersionIsPinned misread the catalog: reference in the previous specifier as a pinned version. The catalog reference carries no pinning of its own, so the prefix from the catalog entry is now preserved. Closes #10321
1 parent a8c4704 commit 29ab905

4 files changed

Lines changed: 65 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/resolving.npm-resolver": patch
3+
"pnpm": patch
4+
---
5+
6+
Fixed `pnpm update` overriding the version range policy of a named catalog whose name parses as a version (e.g. `catalog:express4-21`). The `catalog:` reference carries no pinning of its own, so the prefix from the catalog entry (such as `~`) is now preserved instead of being widened to `^` [#10321](https://github.com/pnpm/pnpm/issues/10321).

installing/deps-installer/test/catalogs.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,6 +2042,55 @@ describe('update', () => {
20422042
})
20432043
})
20442044

2045+
// A named catalog whose name parses as a version (e.g. "express4-21") must not
2046+
// have its update policy overridden. The "catalog:express4-21" reference in the
2047+
// manifest carries no pinning of its own, so the "~" prefix from the catalog
2048+
// entry must be preserved instead of being widened to "^" (issue #10321).
2049+
test('update via install mutation preserves the ~ range of a version-like named catalog (issue #10321)', async () => {
2050+
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
2051+
name: 'project1',
2052+
dependencies: {
2053+
'@pnpm.e2e/foo': 'catalog:foo1-0',
2054+
},
2055+
}])
2056+
2057+
const mutateOpts = {
2058+
...options,
2059+
lockfileOnly: true,
2060+
catalogs: {
2061+
'foo1-0': { '@pnpm.e2e/foo': '~1.0.0' },
2062+
},
2063+
}
2064+
2065+
await mutateModules(installProjects(projects), mutateOpts)
2066+
2067+
expect(readLockfile().catalogs['foo1-0']).toEqual({
2068+
'@pnpm.e2e/foo': { specifier: '~1.0.0', version: '1.0.0' },
2069+
})
2070+
2071+
// Simulate `pnpm update` via the "install" mutation with update=true.
2072+
const { updatedCatalogs } = await mutateModules(
2073+
installProjects(projects).map((project) => ({
2074+
...project,
2075+
mutation: 'install' as const,
2076+
update: true,
2077+
updatePackageManifest: true,
2078+
})),
2079+
mutateOpts
2080+
)
2081+
2082+
// The "~" prefix must be preserved, not widened to "^".
2083+
expect(updatedCatalogs).toEqual({
2084+
'foo1-0': {
2085+
'@pnpm.e2e/foo': '~1.0.0',
2086+
},
2087+
})
2088+
2089+
expect(readLockfile().catalogs['foo1-0']).toEqual({
2090+
'@pnpm.e2e/foo': { specifier: '~1.0.0', version: '1.0.0' },
2091+
})
2092+
})
2093+
20452094
// Similar to above but with updateToLatest (simulating `pnpm upgrade -r --latest`)
20462095
test('update via install mutation with updateToLatest preserves catalog: in manifest (issue #11658)', async () => {
20472096
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })

resolving/npm-resolver/src/whichVersionIsPinned.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import type { PinnedVersion } from '@pnpm/types'
22
import { parseRange } from 'semver-utils'
33

44
export function whichVersionIsPinned (spec: string): PinnedVersion | undefined {
5+
// A catalog reference carries no version pinning of its own; the pinning is
6+
// defined by the catalog entry it points to. Bail out so a catalog name that
7+
// happens to look like a version (e.g. "catalog:express4-21") isn't misread
8+
// as a pinned version.
9+
if (spec.startsWith('catalog:')) return undefined
510
const colonIndex = spec.indexOf(':')
611
if (colonIndex !== -1) {
712
spec = spec.substring(colonIndex + 1)

resolving/npm-resolver/test/whichVersionIsPinned.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ test.each([
1515
['npm:@pnpm.e2e/qar@100.0.0', 'patch'],
1616
['jsr:@foo/foo@1.0.0', 'patch'],
1717
['jsr:foo@^1.0.0', 'major'],
18+
['catalog:', undefined],
19+
['catalog:default', undefined],
20+
['catalog:foo', undefined],
21+
// A catalog name that parses as a version must not be treated as a pin.
22+
['catalog:express4-21', undefined],
1823
])('whichVersionIsPinned()', (spec, expectedResult) => {
1924
expect(whichVersionIsPinned(spec)).toEqual(expectedResult)
2025
})

0 commit comments

Comments
 (0)