diff --git a/lib/config/index.spec.ts b/lib/config/index.spec.ts index cc6c587ae23..61a67b095d4 100644 --- a/lib/config/index.spec.ts +++ b/lib/config/index.spec.ts @@ -127,7 +127,11 @@ describe('config/index', () => { expect(config).toContainEntries([ [ 'managerFilePatterns', - ['/(^|/)package\\.json$/', '/(^|/)pnpm-workspace\\.yaml$/'], + [ + '/(^|/)package\\.json$/', + '/(^|/)pnpm-workspace\\.yaml$/', + '/(^|/)\\.yarnrc\\.yml$/', + ], ], ]); expect(getManagerConfig(parentConfig, 'html')).toContainEntries([ diff --git a/lib/modules/manager/npm/extract/common/package-file.spec.ts b/lib/modules/manager/npm/extract/common/package-file.spec.ts new file mode 100644 index 00000000000..dce90a206f2 --- /dev/null +++ b/lib/modules/manager/npm/extract/common/package-file.spec.ts @@ -0,0 +1,66 @@ +import { GlobalConfig } from '../../../../../config/global'; +import { logger } from '../../../../../logger'; +import { hasPackageManager } from './package-file'; +import { Fixtures } from '~test/fixtures'; + +vi.mock('fs-extra', async () => + ( + await vi.importActual('~test/fixtures') + ).fsExtra(), +); + +describe('modules/manager/npm/extract/common/package-file', () => { + beforeEach(() => { + Fixtures.reset(); + GlobalConfig.set({ localDir: '/', cacheDir: '/tmp/cache' }); + }); + + it('returns true for a valid packageManager with name@version(e.g. pnpm@8.15.4)', async () => { + Fixtures.mock({ + '/repo/package.json': JSON.stringify({ packageManager: 'pnpm@8.15.4' }), + }); + await expect(hasPackageManager('/repo')).resolves.toBe(true); + + expect(logger.trace).toHaveBeenCalledWith( + 'npm.hasPackageManager from package.json', + ); + }); + + it('returns true for a valid range like npm@^9', async () => { + Fixtures.mock({ + '/repo/package.json': JSON.stringify({ packageManager: 'npm@^9' }), + }); + await expect(hasPackageManager('/repo')).resolves.toBe(true); + }); + + it('returns true for yarn classic pin yarn@1.22.19', async () => { + Fixtures.mock({ + '/repo/package.json': JSON.stringify({ packageManager: 'yarn@1.22.19' }), + }); + await expect(hasPackageManager('/repo')).resolves.toBe(true); + }); + + it("returns false when packageManager does not contain '@' (e.g. 'npm')", async () => { + Fixtures.mock({ + '/repo/package.json': JSON.stringify({ packageManager: 'npm' }), + }); + await expect(hasPackageManager('/repo')).resolves.toBe(false); + }); + + it('returns false when packageManager is missing', async () => { + Fixtures.mock({ '/repo/package.json': JSON.stringify({ name: 'demo' }) }); + await expect(hasPackageManager('/repo')).resolves.toBe(false); + }); + + it('returns false when package.json is invalid', async () => { + Fixtures.mock({ '/repo/package.json': '{ not: valid json' }); + await expect(hasPackageManager('/repo')).resolves.toBe(false); + }); + + it('returns false if packageManager is an empty string', async () => { + Fixtures.mock({ + '/repo/package.json': JSON.stringify({ packageManager: '' }), + }); + await expect(hasPackageManager('/repo')).resolves.toBe(false); + }); +}); diff --git a/lib/modules/manager/npm/extract/common/package-file.ts b/lib/modules/manager/npm/extract/common/package-file.ts index 3ee062016ba..a42241e6e81 100644 --- a/lib/modules/manager/npm/extract/common/package-file.ts +++ b/lib/modules/manager/npm/extract/common/package-file.ts @@ -5,6 +5,7 @@ import { logger } from '../../../../../logger'; import { regEx } from '../../../../../util/regex'; import type { PackageDependency, PackageFileContent } from '../../../types'; import type { NpmManagerData } from '../../types'; +import { loadPackageJson } from '../../utils'; import type { NpmPackage, NpmPackageDependency } from '../types'; import { extractDependency, @@ -147,3 +148,16 @@ export function extractPackageJson( }, }; } + +export async function hasPackageManager( + packageJsonDir: string, +): Promise { + logger.trace(`npm.hasPackageManager from package.json`); + + const packageJsonResult = await loadPackageJson(packageJsonDir); + + return ( + is.nonEmptyString(packageJsonResult?.packageManager?.name) && + is.nonEmptyString(packageJsonResult?.packageManager?.version) + ); +} diff --git a/lib/modules/manager/npm/extract/index.spec.ts b/lib/modules/manager/npm/extract/index.spec.ts index b5db377eeda..64b13be6f01 100644 --- a/lib/modules/manager/npm/extract/index.spec.ts +++ b/lib/modules/manager/npm/extract/index.spec.ts @@ -15,6 +15,10 @@ const defaultExtractConfig = { const input01Content = Fixtures.get('inputs/01.json', '..'); const input02Content = Fixtures.get('inputs/02.json', '..'); +const input01PackageManager = Fixtures.get( + 'inputs/01-package-manager.json', + '..', +); const input01GlobContent = Fixtures.get('inputs/01-glob.json', '..'); const workspacesContent = Fixtures.get('inputs/workspaces.json', '..'); const vendorisedContent = Fixtures.get('is-object.json', '..'); @@ -1268,6 +1272,125 @@ describe('modules/manager/npm/extract/index', () => { }, ]); }); + + it('extracts yarnrc.yml and adds it as packageFile', async () => { + const yarnrc = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + is-positive: 1.0.0 + `; + fs.readLocalFile.mockResolvedValueOnce(yarnrc); + + fs.readLocalFile.mockResolvedValueOnce(input02Content); + + const res = await extractAllPackageFiles(defaultExtractConfig, [ + '.yarnrc.yml', + ]); + + expect(res).toEqual([ + { + deps: [ + { + currentValue: '1.0.0', + datasource: 'npm', + depName: 'is-positive', + depType: 'yarn.catalog.default', + prettyDepType: 'yarn.catalog.default', + }, + ], + managerData: { + hasPackageManager: false, + }, + packageFile: '.yarnrc.yml', + }, + ]); + }); + + it('extracts yarnrc.yml and adds it as packageFile and packageManager to true', async () => { + const yarnrc = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + is-positive: 1.0.0 + `; + + fs.readLocalFile.mockResolvedValueOnce(yarnrc); + fs.readLocalFile.mockResolvedValueOnce(input01PackageManager); + + const res = await extractAllPackageFiles(defaultExtractConfig, [ + '.yarnrc.yml', + ]); + + expect(res[0]).toEqual({ + deps: [ + { + currentValue: '1.0.0', + datasource: 'npm', + depName: 'is-positive', + depType: 'yarn.catalog.default', + prettyDepType: 'yarn.catalog.default', + }, + ], + managerData: { + hasPackageManager: true, + }, + packageFile: '.yarnrc.yml', + }); + }); + + it('extracts yarnrc.yml and adds it as packageFile and packageManager to false if no deps', async () => { + const yarnrc = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + is-positive: 1.0.0 + `; + + fs.readLocalFile.mockResolvedValueOnce(yarnrc); + + fs.readLocalFile.mockResolvedValueOnce( + '{"name": "simulate deps to be null", brokenJsonHere: }', + ); + + const res = await extractAllPackageFiles(defaultExtractConfig, [ + '.yarnrc.yml', + ]); + + expect(res[0]).toEqual({ + deps: [ + { + currentValue: '1.0.0', + datasource: 'npm', + depName: 'is-positive', + depType: 'yarn.catalog.default', + prettyDepType: 'yarn.catalog.default', + }, + ], + managerData: { + hasPackageManager: false, + }, + packageFile: '.yarnrc.yml', + }); + }); }); describe('.postExtract()', () => { diff --git a/lib/modules/manager/npm/extract/index.ts b/lib/modules/manager/npm/extract/index.ts index af385863147..06391fc77fc 100644 --- a/lib/modules/manager/npm/extract/index.ts +++ b/lib/modules/manager/npm/extract/index.ts @@ -1,4 +1,5 @@ import is from '@sindresorhus/is'; +import upath from 'upath'; import { GlobalConfig } from '../../../../config/global'; import { logger } from '../../../../logger'; import { @@ -17,11 +18,11 @@ import type { import type { YarnConfig } from '../schema'; import type { NpmLockFiles, NpmManagerData } from '../types'; import { getExtractedConstraints } from './common/dependency'; -import { extractPackageJson } from './common/package-file'; +import { extractPackageJson, hasPackageManager } from './common/package-file'; import { extractPnpmWorkspaceFile, tryParsePnpmWorkspaceYaml } from './pnpm'; import { postExtract } from './post'; import type { NpmPackage } from './types'; -import { isZeroInstall } from './yarn'; +import { extractYarnCatalogs, isZeroInstall } from './yarn'; import { loadConfigFromLegacyYarnrc, loadConfigFromYarnrcYml, @@ -254,13 +255,37 @@ export async function extractAllPackageFiles( }); } } else { - logger.trace({ packageFile }, `Extracting as a package.json file`); - const deps = await extractPackageFile(content, packageFile, config); - if (deps) { - npmFiles.push({ - ...deps, - packageFile, - }); + if (packageFile.endsWith('json')) { + logger.trace({ packageFile }, `Extracting as a package.json file`); + + const deps = await extractPackageFile(content, packageFile, config); + if (deps) { + npmFiles.push({ + ...deps, + packageFile, + }); + } + } else { + logger.trace({ packageFile }, `Extracting as a .yarnrc.yml file`); + + const yarnConfig = loadConfigFromYarnrcYml(content); + + if (yarnConfig?.catalogs) { + const hasPackageManagerResult = await hasPackageManager( + upath.dirname(packageFile), + ); + const catalogsDeps = await extractYarnCatalogs( + yarnConfig.catalogs, + packageFile, + hasPackageManagerResult, + ); + if (catalogsDeps) { + npmFiles.push({ + ...catalogsDeps, + packageFile, + }); + } + } } } } else { diff --git a/lib/modules/manager/npm/extract/yarn.spec.ts b/lib/modules/manager/npm/extract/yarn.spec.ts index 047790f1649..3eb20837ba8 100644 --- a/lib/modules/manager/npm/extract/yarn.spec.ts +++ b/lib/modules/manager/npm/extract/yarn.spec.ts @@ -1,4 +1,8 @@ -import { getYarnLock, getYarnVersionFromLock } from './yarn'; +import { + extractYarnCatalogs, + getYarnLock, + getYarnVersionFromLock, +} from './yarn'; import { Fixtures } from '~test/fixtures'; import { fs } from '~test/util'; @@ -74,4 +78,75 @@ describe('modules/manager/npm/extract/yarn', () => { '^2.0.0', ); }); + + describe('.extractYarnCatalogs()', () => { + it('handles empty catalog entries', async () => { + expect( + await extractYarnCatalogs(undefined, 'package.json', false), + ).toMatchObject({ + deps: [], + }); + }); + + it('parses valid .yarnrc.yml file', async () => { + fs.localPathExists.mockResolvedValueOnce(true); + fs.getSiblingFileName.mockReturnValueOnce('yarn.lock'); + expect( + await extractYarnCatalogs( + { + list: { + react: '18.3.0', + react17: { + react: '17.0.2', + }, + }, + }, + 'package.json', + true, + ), + ).toMatchObject({ + deps: [ + { + currentValue: '18.3.0', + datasource: 'npm', + depName: 'react', + depType: 'yarn.catalog.default', + prettyDepType: 'yarn.catalog.default', + }, + { + currentValue: '17.0.2', + datasource: 'npm', + depName: 'react', + depType: 'yarn.catalog.react17', + prettyDepType: 'yarn.catalog.react17', + }, + ], + managerData: { + yarnLock: 'yarn.lock', + hasPackageManager: true, + }, + }); + }); + + it('finds relevant lockfile', async () => { + fs.localPathExists.mockResolvedValueOnce(true); + fs.getSiblingFileName.mockReturnValueOnce('yarn.lock'); + expect( + await extractYarnCatalogs( + { + list: { + react: '18.3.1', + }, + }, + 'package.json', + false, + ), + ).toMatchObject({ + managerData: { + yarnLock: 'yarn.lock', + hasPackageManager: false, + }, + }); + }); + }); }); diff --git a/lib/modules/manager/npm/extract/yarn.ts b/lib/modules/manager/npm/extract/yarn.ts index 2646efac9b2..77180eabad5 100644 --- a/lib/modules/manager/npm/extract/yarn.ts +++ b/lib/modules/manager/npm/extract/yarn.ts @@ -7,7 +7,11 @@ import { localPathExists, readLocalFile, } from '../../../../util/fs'; -import type { LockFile } from './types'; +import type { PackageFileContent } from '../../types'; +import type { YarnCatalogs } from '../schema'; +import type { NpmManagerData } from '../types'; +import { extractCatalogDeps } from './common/catalogs'; +import type { Catalog, LockFile } from './types'; export async function getYarnLock(filePath: string): Promise { // TODO #22198 @@ -121,3 +125,55 @@ export function getYarnVersionFromLock(lockfile: LockFile): string { return '^2.0.0'; } + +export async function extractYarnCatalogs( + catalogs: YarnCatalogs | undefined, + packageFile: string, + hasPackageManager: boolean, +): Promise> { + logger.trace(`yarn.extractYarnCatalogs(${packageFile})`); + + const yarnCatalogs = yarnCatalogsToArray(catalogs); + + const deps = extractCatalogDeps(yarnCatalogs, 'yarn'); + + let yarnLock: string | undefined; + const filePath = getSiblingFileName(packageFile, 'yarn.lock'); + + if (await localPathExists(filePath)) { + yarnLock = filePath; + } + + return { + deps, + managerData: { + yarnLock, + hasPackageManager, + }, + }; +} + +function yarnCatalogsToArray(catalogs: YarnCatalogs | undefined): Catalog[] { + const result: Catalog[] = []; + + if (catalogs?.list !== undefined) { + for (const [ + depNameOrCatalogName, + depsVersionOrNamedCatalog, + ] of Object.entries(catalogs.list)) { + if (is.object(depsVersionOrNamedCatalog)) { + result.push({ + name: depNameOrCatalogName, + dependencies: depsVersionOrNamedCatalog, + }); + } else { + result.push({ + name: 'default', + dependencies: { [depNameOrCatalogName]: depsVersionOrNamedCatalog }, + }); + } + } + } + + return result; +} diff --git a/lib/modules/manager/npm/index.ts b/lib/modules/manager/npm/index.ts index be20d58cf40..7d9d0dff410 100644 --- a/lib/modules/manager/npm/index.ts +++ b/lib/modules/manager/npm/index.ts @@ -23,6 +23,7 @@ export const defaultConfig = { managerFilePatterns: [ '/(^|/)package\\.json$/', '/(^|/)pnpm-workspace\\.yaml$/', + '/(^|/)\\.yarnrc\\.yml$/', ], digest: { prBodyDefinitions: { diff --git a/lib/modules/manager/npm/post-update/index.ts b/lib/modules/manager/npm/post-update/index.ts index 6b028536c2e..884ea12079c 100644 --- a/lib/modules/manager/npm/post-update/index.ts +++ b/lib/modules/manager/npm/post-update/index.ts @@ -260,7 +260,8 @@ export async function writeUpdatedPackageFiles( if ( !( packageFile.path.endsWith('package.json') || - packageFile.path.endsWith('pnpm-workspace.yaml') + packageFile.path.endsWith('pnpm-workspace.yaml') || + packageFile.path.endsWith('.yarnrc.yml') ) ) { continue; diff --git a/lib/modules/manager/npm/readme.md b/lib/modules/manager/npm/readme.md index ff1624488ab..ad86f709416 100644 --- a/lib/modules/manager/npm/readme.md +++ b/lib/modules/manager/npm/readme.md @@ -11,6 +11,7 @@ The following `depTypes` are currently supported by the npm manager : - `resolutions` - `pnpm.overrides` - `pnpm.catalog.`, such as `pnpm.catalog.default` and `pnpm.catalog.myCatalog`. [Matches any default and named pnpm catalogs](https://pnpm.io/catalogs#defining-catalogs). +- `yarn.catalogs.list.` if you are using the [yarn-plugin-catalogs](https://github.com/toss/yarn-plugin-catalogs) ### npm problems and workarounds diff --git a/lib/modules/manager/npm/schema.ts b/lib/modules/manager/npm/schema.ts index faa5c3efe71..df24912f3f8 100644 --- a/lib/modules/manager/npm/schema.ts +++ b/lib/modules/manager/npm/schema.ts @@ -6,6 +6,12 @@ export const PnpmCatalogs = z.object({ catalogs: z.optional(z.record(z.record(z.string()))), }); +export const YarnCatalogs = z.object({ + options: z.optional(z.union([z.string(), z.array(z.string())])), + list: z.record(z.union([z.string(), z.record(z.string())])), +}); +export type YarnCatalogs = z.infer; + export const YarnConfig = Yaml.pipe( z.object({ npmRegistryServer: z.string().optional(), @@ -16,9 +22,9 @@ export const YarnConfig = Yaml.pipe( }), ) .optional(), + catalogs: YarnCatalogs.optional().catch(undefined), }), ); - export type YarnConfig = z.infer; export const PnpmWorkspaceFile = z diff --git a/lib/modules/manager/npm/update/dependency/index.spec.ts b/lib/modules/manager/npm/update/dependency/index.spec.ts index 3acc4888857..da09b85bb3a 100644 --- a/lib/modules/manager/npm/update/dependency/index.spec.ts +++ b/lib/modules/manager/npm/update/dependency/index.spec.ts @@ -420,5 +420,42 @@ describe('modules/manager/npm/update/dependency/index', () => { }); expect(testContent).toEqual(expected); }); + it('handles yarn.catalogs dependencies', () => { + const upgrade = { + depType: 'yarn.catalogs.default', + depName: 'typescript', + newValue: '0.60.0', + }; + + const overrideDependencies = ` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + typescript: 0.0.5 + `; + const expected = `nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + typescript: 0.60.0 +`; + + const testContent = npmUpdater.updateDependency({ + fileContent: overrideDependencies, + upgrade, + }); + expect(testContent).toEqual(expected); + }); }); }); diff --git a/lib/modules/manager/npm/update/dependency/index.ts b/lib/modules/manager/npm/update/dependency/index.ts index f89c749c9e7..feefb346bb1 100644 --- a/lib/modules/manager/npm/update/dependency/index.ts +++ b/lib/modules/manager/npm/update/dependency/index.ts @@ -13,6 +13,7 @@ import type { import type { NpmDepType, NpmManagerData } from '../../types'; import { getNewGitValue, getNewNpmAliasValue } from './common'; import { updatePnpmCatalogDependency } from './pnpm'; +import { updateYarnrcCatalogDependency } from './yarn'; function renameObjKey( oldObj: DependenciesMeta, @@ -120,6 +121,9 @@ export function updateDependency({ if (upgrade.depType?.startsWith('pnpm.catalog')) { return updatePnpmCatalogDependency({ fileContent, upgrade }); } + if (upgrade.depType?.startsWith('yarn.catalog')) { + return updateYarnrcCatalogDependency({ fileContent, upgrade }); + } const { depType, managerData } = upgrade; const depName: string = managerData?.key ?? upgrade.depName; diff --git a/lib/modules/manager/npm/update/dependency/yarn.spec.ts b/lib/modules/manager/npm/update/dependency/yarn.spec.ts new file mode 100644 index 00000000000..c8517f9a9b7 --- /dev/null +++ b/lib/modules/manager/npm/update/dependency/yarn.spec.ts @@ -0,0 +1,864 @@ +import { codeBlock } from 'common-tags'; +import * as npmUpdater from '../..'; +import { updateYarnrcCatalogDependency } from './yarn'; +import { logger } from '~test/util'; + +describe('modules/manager/npm/update/dependency/yarn', () => { + describe('updateYarnrcCatalogDependency', () => { + it('returns null if catalogName is missing and logs error', () => { + const upgrade = { + depType: undefined, + depName: 'react', + newValue: '19.0.0', + }; + + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 18.3.1 + `; + const testContent = updateYarnrcCatalogDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + + expect(logger.logger.error).toHaveBeenCalledWith( + 'No catalogName was found; this is likely an extraction error.', + ); + expect(testContent).toBeNull(); + }); + + it('ensure continuation even if catalog list and update does not match', () => { + const upgrade = { + depType: 'yarn.catalog.react17', + depName: 'react', + newValue: '19.0.0', + }; + + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react18: + react: 18.3.1 + `; + const testContent = updateYarnrcCatalogDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toBeNull(); + }); + + it('ensure continuation even if dependency and update does not match', () => { + const upgrade = { + depType: 'yarn.catalog.react18', + depName: 'react', + newValue: '19.0.0', + }; + + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react18: + react-dom: 18.3.1 + `; + + const testContent = updateYarnrcCatalogDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toBeNull(); + }); + + it('ensure trace logging', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 18.3.1 + `; + updateYarnrcCatalogDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + + expect(logger.logger.trace).toHaveBeenCalledWith( + 'npm.updateYarnrcCatalogDependency(): yarn.catalog.default::default.react = 19.0.0', + ); + }); + }); + describe('updateDependency', () => { + it(`returns null if catalogName is missing`, () => { + const upgrade = { + depName: 'react', + newValue: '19.0.0', + }; + + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 18.3.1 + `; + + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + + expect(testContent).toBeNull(); + }); + + it('handles implicit default catalog dependency', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 18.3.1 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 19.0.0 + `); + }); + + it('handles explicit named catalog dependency', () => { + const upgrade = { + depType: 'yarn.catalog.react17', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react17: + react: 17.0.0 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react17: + react: 19.0.0 + `); + }); + + it('does nothing if the new and old values match', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 19.0.0 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(yarnrcYaml); + }); + + it('replaces package', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'config', + newName: 'abc', + newValue: '2.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + config: 1.21.0 + `; + + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + abc: 2.0.0 + `); + }); + + it('replaces a github dependency value', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'gulp', + currentValue: 'v4.0.0-alpha.2', + currentRawValue: 'gulpjs/gulp#v4.0.0-alpha.2', + newValue: 'v4.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + gulp: gulpjs/gulp#v4.0.0-alpha.2 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + gulp: gulpjs/gulp#v4.0.0 + `); + }); + + it('replaces a npm package alias', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'hapi', + npmPackageAlias: true, + packageName: '@hapi/hapi', + currentValue: '18.3.0', + newValue: '18.3.1', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + hapi: npm:@hapi/hapi@18.3.0 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + hapi: npm:@hapi/hapi@18.3.1 + `); + }); + + it('replaces a github short hash', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'gulp', + currentDigest: 'abcdef7', + currentRawValue: 'gulpjs/gulp#abcdef7', + newDigest: '0000000000111111111122222222223333333333', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + gulp: gulpjs/gulp#abcdef7 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + gulp: gulpjs/gulp#0000000 + `); + }); + + it('replaces a github fully specified version', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'n', + currentValue: 'v1.0.0', + currentRawValue: 'git+https://github.com/owner/n#v1.0.0', + newValue: 'v1.1.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + n: git+https://github.com/owner/n#v1.0.0 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + n: git+https://github.com/owner/n#v1.1.0 + `); + }); + + it('returns null if the dependency is not present in the target catalog', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react-not', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + + catalog: + react: 18.3.1 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toBeNull(); + }); + + it('returns null if catalogs are missing', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toBeNull(); + }); + + it('returns null if empty file', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const testContent = npmUpdater.updateDependency({ + fileContent: null as never, + upgrade, + }); + expect(testContent).toBeNull(); + }); + + it('preserves literal whitespace', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 18.3.1 + + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 19.0.0 + `); + }); + + it('preserves single quote style', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: '18.3.1' + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: '19.0.0' + `); + }); + + it('preserves comments', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 18.3.1 # This is a comment + # This is another comment + react-dom: 18.3.1 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: 19.0.0 # This is a comment + # This is another comment + react-dom: 18.3.1 + `); + }); + + it('preserves double quote style', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: "18.3.1" + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: "19.0.0" + `); + }); + + it('preserves anchors, replacing only the value', () => { + // At the time of writing, this pattern is the recommended way to sync + // dependencies in catalogs. + // @see https://github.com/pnpm/pnpm/issues/8245#issuecomment-2371335323 + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: &react 18.3.1 + react-dom: *react + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: &react 19.0.0 + react-dom: *react + `); + }); + + it('preserves whitespace with anchors', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: &react 18.3.1 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: &react 19.0.0 + `); + }); + + it('preserves quotation style with anchors', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: &react "18.3.1" + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: &react "19.0.0" + `); + }); + + it('preserves formatting in flow style syntax', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + const yarnrcYaml = codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: { + # This is a comment + "react": "18.3.1" + } + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toEqual(codeBlock` + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: { + # This is a comment + "react": "19.0.0" + } + `); + }); + + it('does not replace aliases in the value position', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newValue: '19.0.0', + }; + // In the general case, we do not know whether we should replace the anchor + // that an alias is resolved from. We leave this up to the user, e.g. via a + // Regex custom manager. + const yarnrcYaml = codeBlock` + __deps: + react: &react 18.3.1 + + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + react: *react + react-dom: *react + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toBeNull(); + }); + + it('does not replace aliases in the key position', () => { + const upgrade = { + depType: 'yarn.catalog.default', + depName: 'react', + newName: 'react-x', + }; + const yarnrcYaml = codeBlock` + __vars: + &r react: "" + + nodeLinker: node-modules + + plugins: + - checksum: 4cb9601cfc0c71e5b0ffd0a85b78e37430b62257040714c2558298ce1fc058f4e918903f0d1747a4fef3f58e15722c35bd76d27492d9d08aa5b04e235bf43b22 + path: .yarn/plugins/@yarnpkg/plugin-catalogs.cjs + spec: 'https://raw.githubusercontent.com/toss/yarn-plugin-catalogs/main/bundles/%40yarnpkg/plugin-catalogs.js' + + catalogs: + list: + *r: 18.0.0 + `; + const testContent = npmUpdater.updateDependency({ + fileContent: yarnrcYaml, + upgrade, + }); + expect(testContent).toBeNull(); + }); + }); +}); diff --git a/lib/modules/manager/npm/update/dependency/yarn.ts b/lib/modules/manager/npm/update/dependency/yarn.ts new file mode 100644 index 00000000000..3f5eb000c70 --- /dev/null +++ b/lib/modules/manager/npm/update/dependency/yarn.ts @@ -0,0 +1,142 @@ +import is from '@sindresorhus/is'; +import type { Document } from 'yaml'; +import { CST, isCollection, isPair, isScalar, parseDocument } from 'yaml'; +import { logger } from '../../../../../logger'; +import type { UpdateDependencyConfig } from '../../../types'; +import { YarnConfig } from '../../schema'; +import { getNewGitValue, getNewNpmAliasValue } from './common'; + +export function updateYarnrcCatalogDependency({ + fileContent, + upgrade, +}: UpdateDependencyConfig): string | null { + const { depType, depName } = upgrade; + + const catalogName = depType?.split('.').at(-1); + + if (!is.string(catalogName)) { + logger.error( + 'No catalogName was found; this is likely an extraction error.', + ); + return null; + } + + let { newValue } = upgrade; + + newValue = getNewGitValue(upgrade) ?? newValue; + newValue = getNewNpmAliasValue(newValue, upgrade) ?? newValue; + + logger.trace( + `npm.updateYarnrcCatalogDependency(): ${depType}::${catalogName}.${depName} = ${newValue}`, + ); + + let document: ReturnType; + let parsedContents: YarnConfig; + + try { + // In order to preserve the original formatting as much as possible, we want + // manipulate the CST directly. Using the AST (the result of parseDocument) + // does not guarantee that formatting would be the same after + // stringification. However, the CST is more annoying to query for certain + // values. Thus, we use both an annotated AST and a JS representation; the + // former for manipulation, and the latter for querying/validation. + document = parseDocument(fileContent, { keepSourceTokens: true }); + parsedContents = YarnConfig.parse(document.toString()); + } catch (err) { + logger.debug({ err }, 'Could not parse yarnrc YAML file.'); + return null; + } + + const oldVersion = + catalogName === 'default' + ? parsedContents.catalogs?.list?.[depName!] + : is.object(parsedContents.catalogs?.list?.[catalogName]) && + is.string(depName) + ? parsedContents.catalogs?.list?.[catalogName][depName] + : undefined; + + if (oldVersion === newValue) { + logger.trace('Version is already updated'); + return fileContent; + } + + // Update the value + const path = getDepPath({ + depName: depName!, + catalogName, + }); + + const modifiedDocument = changeDependencyIn(document, path, { + newValue, + newName: upgrade.newName, + }); + + if (!modifiedDocument) { + // Case where we are explicitly unable to substitute the key/value, for + // example if the value was an alias. + return null; + } + + return CST.stringify(modifiedDocument.contents!.srcToken!); +} + +/** + * Change the scalar name and/or value of a collection item in a YAML document, + * while keeping formatting consistent. Mutates the given document. + */ +function changeDependencyIn( + document: Document, + path: string[], + { newName, newValue }: { newName?: string; newValue?: string }, +): Document | null { + const parentPath = path.slice(0, -1); + const relevantItemKey = path.at(-1); + + const parentNode = document.getIn(parentPath); + + if (!parentNode || !isCollection(parentNode)) { + return null; + } + + const relevantNode = parentNode.items.find( + (item) => + isPair(item) && isScalar(item.key) && item.key.value === relevantItemKey, + ); + + if (!relevantNode || !isPair(relevantNode)) { + return null; + } + + if (newName) { + /* the try..catch block above already throws if a key is an alias */ + CST.setScalarValue(relevantNode.srcToken!.key!, newName); + } + + if (newValue) { + // We only support scalar values when substituting. This explicitly avoids + // substituting aliases, since those can be resolved from a shared location, + // and replacing either the referrent anchor or the alias would be wrong in + // the general case. We leave this up to the user, e.g. via a Regex custom + // manager. + if (!CST.isScalar(relevantNode.srcToken?.value)) { + return null; + } + CST.setScalarValue(relevantNode.srcToken.value, newValue); + } + + return document; +} + +function getDepPath({ + catalogName, + depName, +}: { + catalogName: string; + depName: string; +}): string[] { + if (catalogName === 'default') { + return ['catalogs', 'list', depName]; + } else { + return ['catalogs', 'list', catalogName, depName]; + } +} diff --git a/lib/workers/repository/extract/extract-fingerprint-config.spec.ts b/lib/workers/repository/extract/extract-fingerprint-config.spec.ts index 9158384b9f9..05501bc9b3b 100644 --- a/lib/workers/repository/extract/extract-fingerprint-config.spec.ts +++ b/lib/workers/repository/extract/extract-fingerprint-config.spec.ts @@ -42,6 +42,7 @@ describe('workers/repository/extract/extract-fingerprint-config', () => { managerFilePatterns: [ '/(^|/)package\\.json$/', '/(^|/)pnpm-workspace\\.yaml$/', + '/(^|/)\\.yarnrc\\.yml$/', '/hero.json/', ], ignorePaths: ['ignore-path-2'], @@ -92,6 +93,7 @@ describe('workers/repository/extract/extract-fingerprint-config', () => { managerFilePatterns: [ '/(^|/)package\\.json$/', '/(^|/)pnpm-workspace\\.yaml$/', + '/(^|/)\\.yarnrc\\.yml$/', '/hero.json/', ], ignorePaths: ['**/node_modules/**', '**/bower_components/**'],