fix: pnpm add incorrectly modifies a catalog entry in pnpm-workspace.yaml to its exact version (#10370)

* refactor: factor out a `getRealNameAndSpec` function

* test: `pnpm add` does not modify existing catalog entries

* fix: resolve preferred version without mutating bare specifier

close #9759
This commit is contained in:
Brandon Cheng
2025-12-28 20:05:54 -05:00
committed by GitHub
parent d404c55ac8
commit 4f3ad2388c
4 changed files with 81 additions and 4 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/resolve-dependencies": patch
"pnpm": patch
---
Fixed a bug ([#9759](https://github.com/pnpm/pnpm/issues/9759)) where `pnpm add` would incorrectly modify a catalog entry in `pnpm-workspace.yaml` to its exact version.

View File

@@ -1193,6 +1193,58 @@ describe('add', () => {
})
})
// Regression test for https://github.com/pnpm/pnpm/issues/9759
test('adding new usage of default catalog does not mutate catalog entries', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
},
{
name: 'project2',
},
])
const catalogs = {
default: { '@pnpm.e2e/foo': '^100.0.0' },
}
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs,
})
await addDependenciesToPackage(
projects['project2' as ProjectId],
['@pnpm.e2e/foo'],
{
...options,
dir: path.join(options.lockfileDir, 'project2'),
lockfileOnly: true,
allowNew: true,
catalogs,
})
const lockfile = readLockfile()
// This is the specific condition we're regression testing for. The
// specifier used in the original catalog entry should not be modified.
expect(lockfile.catalogs.default['@pnpm.e2e/foo'].specifier).toEqual(catalogs.default['@pnpm.e2e/foo'])
// Sanity check that the rest of the lockfile has expected contents.
expect(readLockfile()).toMatchObject({
catalogs: { default: { '@pnpm.e2e/foo': { specifier: '^100.0.0', version: '100.0.0' } } },
importers: {
project1: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.0.0' } } },
project2: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.0.0' } } },
},
packages: { '@pnpm.e2e/foo@100.0.0': expect.any(Object) },
})
})
test('adding specific version equal to catalog version will use catalog if present', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{
name: 'project1',

View File

@@ -0,0 +1,13 @@
import { type PreferredVersions } from '@pnpm/resolver-base'
import { type WantedDependency } from './getWantedDependencies.js'
import { unwrapPackageName } from './unwrapPackageName.js'
/**
* Create a PreferredVersions object with a specific exact version.
*/
export function getExactSinglePreferredVersions (wantedDependency: WantedDependency, version: string): PreferredVersions {
const { pkgName } = unwrapPackageName(wantedDependency.alias, wantedDependency.bareSpecifier)
return {
[pkgName]: { [version]: 'version' },
}
}

View File

@@ -53,6 +53,7 @@ import pDefer from 'p-defer'
import pShare from 'promise-share'
import { pickBy, omit, zipWith } from 'ramda'
import semver from 'semver'
import { getExactSinglePreferredVersions } from './getExactSinglePreferredVersions.js'
import { getNonDevWantedDependencies, type WantedDependency } from './getNonDevWantedDependencies.js'
import { safeIntersect } from './mergePeers.js'
import { type NodeId, nextNodeId } from './nextNodeId.js'
@@ -1306,14 +1307,19 @@ async function resolveDependency (
optional: true,
}
}
// Normalize the `preferredVersion` (singular) and `preferredVersions`
// (plural) options. If the singular option is passed through, it'll be used
// instead of the plural option.
const preferredVersions = !options.updateRequested && options.preferredVersion != null
? getExactSinglePreferredVersions(wantedDependency, options.preferredVersion)
: options.preferredVersions
try {
const calcSpecifier = options.currentDepth === 0
if (!options.update && currentPkg.version && currentPkg.pkgId?.endsWith(`@${currentPkg.version}`) && !calcSpecifier) {
wantedDependency.bareSpecifier = replaceVersionInBareSpecifier(wantedDependency.bareSpecifier, currentPkg.version)
}
if (!options.updateRequested && options.preferredVersion != null) {
wantedDependency.bareSpecifier = replaceVersionInBareSpecifier(wantedDependency.bareSpecifier, options.preferredVersion)
}
pkgResponse = await ctx.storeController.requestPackage(wantedDependency, {
allowBuild: ctx.allowBuild,
alwaysTryWorkspacePackages: ctx.linkWorkspacePackagesDepth >= options.currentDepth,
@@ -1333,7 +1339,7 @@ async function resolveDependency (
pickLowestVersion: options.pickLowestVersion,
downloadPriority: -options.currentDepth,
lockfileDir: ctx.lockfileDir,
preferredVersions: options.preferredVersions,
preferredVersions,
preferWorkspacePackages: ctx.preferWorkspacePackages,
projectDir: (
options.currentDepth > 0 &&