From 29ab905c21048ae1fe3f1bdafaee167b35431fef Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Mon, 15 Jun 2026 11:58:29 +0200 Subject: [PATCH] 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 https://github.com/pnpm/pnpm/issues/10321 --- .changeset/catalog-update-policy.md | 6 +++ installing/deps-installer/test/catalogs.ts | 49 +++++++++++++++++++ .../npm-resolver/src/whichVersionIsPinned.ts | 5 ++ .../test/whichVersionIsPinned.test.ts | 5 ++ 4 files changed, 65 insertions(+) create mode 100644 .changeset/catalog-update-policy.md diff --git a/.changeset/catalog-update-policy.md b/.changeset/catalog-update-policy.md new file mode 100644 index 0000000000..4c29828aba --- /dev/null +++ b/.changeset/catalog-update-policy.md @@ -0,0 +1,6 @@ +--- +"@pnpm/resolving.npm-resolver": patch +"pnpm": patch +--- + +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). diff --git a/installing/deps-installer/test/catalogs.ts b/installing/deps-installer/test/catalogs.ts index 3522e828ca..5542377687 100644 --- a/installing/deps-installer/test/catalogs.ts +++ b/installing/deps-installer/test/catalogs.ts @@ -2042,6 +2042,55 @@ describe('update', () => { }) }) + // A named catalog whose name parses as a version (e.g. "express4-21") must not + // have its update policy overridden. The "catalog:express4-21" reference in the + // manifest carries no pinning of its own, so the "~" prefix from the catalog + // entry must be preserved instead of being widened to "^" (issue #10321). + test('update via install mutation preserves the ~ range of a version-like named catalog (issue #10321)', async () => { + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ + name: 'project1', + dependencies: { + '@pnpm.e2e/foo': 'catalog:foo1-0', + }, + }]) + + const mutateOpts = { + ...options, + lockfileOnly: true, + catalogs: { + 'foo1-0': { '@pnpm.e2e/foo': '~1.0.0' }, + }, + } + + await mutateModules(installProjects(projects), mutateOpts) + + expect(readLockfile().catalogs['foo1-0']).toEqual({ + '@pnpm.e2e/foo': { specifier: '~1.0.0', version: '1.0.0' }, + }) + + // Simulate `pnpm update` via the "install" mutation with update=true. + const { updatedCatalogs } = await mutateModules( + installProjects(projects).map((project) => ({ + ...project, + mutation: 'install' as const, + update: true, + updatePackageManifest: true, + })), + mutateOpts + ) + + // The "~" prefix must be preserved, not widened to "^". + expect(updatedCatalogs).toEqual({ + 'foo1-0': { + '@pnpm.e2e/foo': '~1.0.0', + }, + }) + + expect(readLockfile().catalogs['foo1-0']).toEqual({ + '@pnpm.e2e/foo': { specifier: '~1.0.0', version: '1.0.0' }, + }) + }) + // Similar to above but with updateToLatest (simulating `pnpm upgrade -r --latest`) test('update via install mutation with updateToLatest preserves catalog: in manifest (issue #11658)', async () => { await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' }) diff --git a/resolving/npm-resolver/src/whichVersionIsPinned.ts b/resolving/npm-resolver/src/whichVersionIsPinned.ts index 5e278bdb9d..7a3572084b 100644 --- a/resolving/npm-resolver/src/whichVersionIsPinned.ts +++ b/resolving/npm-resolver/src/whichVersionIsPinned.ts @@ -2,6 +2,11 @@ import type { PinnedVersion } from '@pnpm/types' import { parseRange } from 'semver-utils' export function whichVersionIsPinned (spec: string): PinnedVersion | undefined { + // A catalog reference carries no version pinning of its own; the pinning is + // defined by the catalog entry it points to. Bail out so a catalog name that + // happens to look like a version (e.g. "catalog:express4-21") isn't misread + // as a pinned version. + if (spec.startsWith('catalog:')) return undefined const colonIndex = spec.indexOf(':') if (colonIndex !== -1) { spec = spec.substring(colonIndex + 1) diff --git a/resolving/npm-resolver/test/whichVersionIsPinned.test.ts b/resolving/npm-resolver/test/whichVersionIsPinned.test.ts index 499e28872f..43105094fb 100644 --- a/resolving/npm-resolver/test/whichVersionIsPinned.test.ts +++ b/resolving/npm-resolver/test/whichVersionIsPinned.test.ts @@ -15,6 +15,11 @@ test.each([ ['npm:@pnpm.e2e/qar@100.0.0', 'patch'], ['jsr:@foo/foo@1.0.0', 'patch'], ['jsr:foo@^1.0.0', 'major'], + ['catalog:', undefined], + ['catalog:default', undefined], + ['catalog:foo', undefined], + // A catalog name that parses as a version must not be treated as a pin. + ['catalog:express4-21', undefined], ])('whichVersionIsPinned()', (spec, expectedResult) => { expect(whichVersionIsPinned(spec)).toEqual(expectedResult) })