diff --git a/.changeset/fix-catalog-strict-mode-write.md b/.changeset/fix-catalog-strict-mode-write.md new file mode 100644 index 0000000000..4e1e0d434d --- /dev/null +++ b/.changeset/fix-catalog-strict-mode-write.md @@ -0,0 +1,6 @@ +--- +"@pnpm/resolve-dependencies": patch +"pnpm": patch +--- + +Fixed a bug where `catalogMode: strict` would write the literal string `"catalog:"` to `pnpm-workspace.yaml` instead of the resolved version specifier when re-adding an existing catalog dependency [#10176](https://github.com/pnpm/pnpm/issues/10176). diff --git a/pkg-manager/core/test/catalogs.ts b/pkg-manager/core/test/catalogs.ts index b234ab97cb..95de2e60f2 100644 --- a/pkg-manager/core/test/catalogs.ts +++ b/pkg-manager/core/test/catalogs.ts @@ -1340,6 +1340,65 @@ describe('add', () => { }) }) + // Regression test for https://github.com/pnpm/pnpm/issues/10176 + // When re-adding a dependency that already exists in the catalog with catalogMode: strict, + // the catalog entry should preserve the original version specifier, not become 'catalog:' + test('re-adding existing catalog dependency with catalogMode: strict preserves catalog specifier', async () => { + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ + name: 'project1', + dependencies: { + 'is-positive': 'catalog:', + }, + }]) + + // First, install the existing dependency with the catalog + const mutateOpts = { + ...options, + lockfileOnly: true, + catalogs: { + default: { 'is-positive': '^1.0.0' }, + }, + catalogMode: 'strict' as const, + } + + await mutateModules(installProjects(projects), mutateOpts) + + // Verify initial state + expect(readLockfile().catalogs?.default?.['is-positive']).toEqual({ + specifier: '^1.0.0', + version: '1.0.0', + }) + + // Now re-add the same dependency (simulating 'pnpm add is-positive' from a subpackage) + const { updatedManifest, updatedCatalogs } = await addDependenciesToPackage( + projects['project1' as ProjectId], + ['is-positive'], + { + ...mutateOpts, + dir: path.join(options.lockfileDir, 'project1'), + allowNew: true, + }) + + // The manifest should still use catalog: + expect(updatedManifest).toEqual({ + name: 'project1', + dependencies: { + 'is-positive': 'catalog:', + }, + }) + + // The catalog should preserve the original specifier, NOT become 'catalog:' + // This is the bug fix - previously it would incorrectly write 'catalog:' to the catalog + if (updatedCatalogs?.default?.['is-positive']) { + expect(updatedCatalogs.default['is-positive']).not.toBe('catalog:') + expect(updatedCatalogs.default['is-positive']).toMatch(/^\^?\d/) + } + + // The lockfile should have the correct catalog specifier + const lockfile = readLockfile() + expect(lockfile.catalogs?.default?.['is-positive']?.specifier).not.toBe('catalog:') + }) + test('adding with catalogMode: prefer will add to or use from catalog', async () => { const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ name: 'project1', diff --git a/pkg-manager/resolve-dependencies/src/index.ts b/pkg-manager/resolve-dependencies/src/index.ts index 93e5f5f44d..ee0343da12 100644 --- a/pkg-manager/resolve-dependencies/src/index.ts +++ b/pkg-manager/resolve-dependencies/src/index.ts @@ -289,9 +289,12 @@ export async function resolveDependencies ( if (!updateSpec) continue const dep = resolvedImporter.directDependencies[i] if (dep.catalogLookup == null) continue + // If normalizedBareSpecifier isn't defined, this catalog entry was resolved from cache. + // Avoid updating the updatedCatalogs map since it is likely unchanged. + if (dep.normalizedBareSpecifier == null) continue updatedCatalogs ??= {} updatedCatalogs[dep.catalogLookup.catalogName] ??= {} - updatedCatalogs[dep.catalogLookup.catalogName][dep.alias] = dep.normalizedBareSpecifier ?? dep.catalogLookup.userSpecifiedBareSpecifier + updatedCatalogs[dep.catalogLookup.catalogName][dep.alias] = dep.normalizedBareSpecifier } }