diff --git a/.bumpy/yarn-catalogs-yarnrc.md b/.bumpy/yarn-catalogs-yarnrc.md new file mode 100644 index 0000000..4067130 --- /dev/null +++ b/.bumpy/yarn-catalogs-yarnrc.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +Load Yarn catalogs from `.yarnrc.yml`. Yarn (>=4.10) stores catalog definitions in `.yarnrc.yml` under the `catalog`/`catalogs` keys (the same shape pnpm uses in `pnpm-workspace.yaml`), but catalog loading only read `pnpm-workspace.yaml` and `package.json`, so Yarn workspaces always saw an empty catalog map. Catalog loading and the check command's catalog diff now consult the package manager's own YAML file (`.yarnrc.yml` for Yarn). diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index ab8646f..5a97cf9 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -10,6 +10,7 @@ import { parseCatalogs, diffCatalogMaps, isCatalogRefAffected, + catalogYamlFile, CATALOG_FILES, } from '../utils/package-manager.ts'; import { readText, exists } from '../utils/fs.ts'; @@ -421,18 +422,20 @@ async function getChangedCatalogEntries( const pm = await detectPackageManager(rootDir); + // The workspace YAML that may hold catalogs: pnpm-workspace.yaml for pnpm, + // .yarnrc.yml for Yarn, and none for npm/bun (which use package.json). + const yamlFile = catalogYamlFile(pm); + // Load "after" (current working tree state) const afterYaml = - pm === 'pnpm' && (await exists(resolve(rootDir, 'pnpm-workspace.yaml'))) - ? await readText(resolve(rootDir, 'pnpm-workspace.yaml')) - : null; + yamlFile && (await exists(resolve(rootDir, yamlFile))) ? await readText(resolve(rootDir, yamlFile)) : null; const afterPkgJson = (await exists(resolve(rootDir, 'package.json'))) ? await readText(resolve(rootDir, 'package.json')) : null; const afterCatalogs = parseCatalogs(afterYaml, afterPkgJson); - // Load "before" (state at base ref). pnpm-workspace.yaml is only relevant for pnpm. - const beforeYaml = pm === 'pnpm' ? readFileAtRef(rootDir, baseRef, 'pnpm-workspace.yaml') : null; + // Load "before" (state at base ref). + const beforeYaml = yamlFile ? readFileAtRef(rootDir, baseRef, yamlFile) : null; const beforePkgJson = readFileAtRef(rootDir, baseRef, 'package.json'); const beforeCatalogs = parseCatalogs(beforeYaml, beforePkgJson); diff --git a/packages/bumpy/src/utils/package-manager.ts b/packages/bumpy/src/utils/package-manager.ts index 22c4c5c..0488f23 100644 --- a/packages/bumpy/src/utils/package-manager.ts +++ b/packages/bumpy/src/utils/package-manager.ts @@ -75,8 +75,20 @@ async function getWorkspaceGlobs(rootDir: string, pm: PackageManager): Promise=4.10) stores catalogs. */ -export const CATALOG_FILES = ['pnpm-workspace.yaml', 'package.json'] as const; +export const CATALOG_FILES = ['pnpm-workspace.yaml', '.yarnrc.yml', 'package.json'] as const; + +/** + * The YAML file a given package manager stores catalog definitions in, if any. + * pnpm uses pnpm-workspace.yaml; Yarn (>=4.10) uses .yarnrc.yml. Both share the + * same `catalog` / `catalogs` shape. npm and bun keep catalogs in package.json. + */ +export function catalogYamlFile(pm: PackageManager): string | null { + if (pm === 'pnpm') return 'pnpm-workspace.yaml'; + if (pm === 'yarn') return '.yarnrc.yml'; + return null; +} /** * Normalize a catalog name to its canonical form. @@ -88,8 +100,12 @@ function normalizeCatalogName(name: string): string { return name === 'default' ? '' : name; } -/** Parse catalog definitions from the raw contents of pnpm-workspace.yaml and root package.json */ -export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson: string | null): CatalogMap { +/** + * Parse catalog definitions from the raw contents of a workspace YAML file and + * the root package.json. `workspaceYaml` is pnpm-workspace.yaml for pnpm or + * .yarnrc.yml for Yarn — both use the same `catalog` / `catalogs` shape. + */ +export function parseCatalogs(workspaceYaml: string | null, rootPackageJson: string | null): CatalogMap { const catalogs: CatalogMap = new Map(); const addNamed = (raw: Record>): void => { @@ -98,9 +114,9 @@ export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson: } }; - if (pnpmWorkspaceYaml) { + if (workspaceYaml) { try { - const parsed = yaml.load(pnpmWorkspaceYaml) as { + const parsed = yaml.load(workspaceYaml) as { catalog?: Record; catalogs?: Record>; } | null; @@ -147,14 +163,16 @@ export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson: return catalogs; } -/** Load catalog definitions from pnpm-workspace.yaml or root package.json */ +/** Load catalog definitions from the package manager's workspace YAML and/or root package.json */ async function loadCatalogs(rootDir: string, pm: PackageManager): Promise { - // pnpm-workspace.yaml is only read for pnpm — other PMs don't recognize it - let pnpmYaml: string | null = null; - if (pm === 'pnpm') { - const wsFile = resolve(rootDir, 'pnpm-workspace.yaml'); + // Only the package manager's own YAML file is consulted (pnpm-workspace.yaml + // for pnpm, .yarnrc.yml for Yarn) — other PMs don't recognize it. + let workspaceYaml: string | null = null; + const yamlFile = catalogYamlFile(pm); + if (yamlFile) { + const wsFile = resolve(rootDir, yamlFile); if (await exists(wsFile)) { - pnpmYaml = await readText(wsFile); + workspaceYaml = await readText(wsFile); } } @@ -164,7 +182,7 @@ async function loadCatalogs(rootDir: string, pm: PackageManager): Promise` range, normalizing the default alias */ diff --git a/packages/bumpy/test/utils/package-manager.test.ts b/packages/bumpy/test/utils/package-manager.test.ts index fd615e5..cf72b01 100644 --- a/packages/bumpy/test/utils/package-manager.test.ts +++ b/packages/bumpy/test/utils/package-manager.test.ts @@ -1,9 +1,14 @@ import { test, expect, describe } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; import { parseCatalogs, diffCatalogMaps, isCatalogRefAffected, resolveCatalogDep, + catalogYamlFile, + detectWorkspaces, type CatalogMap, } from '../../src/utils/package-manager.ts'; @@ -24,6 +29,31 @@ catalog: expect(catalogs.get('')).toEqual({ react: '^19.0.0', lodash: '^4.17.21' }); }); + test('parses default catalog from .yarnrc.yml (same shape as pnpm)', () => { + // Yarn (>=4.10) stores catalogs in .yarnrc.yml using the same catalog/catalogs keys + const yaml = ` +catalog: + react: ^19.0.0 + lodash: ^4.17.21 +`; + const catalogs = parseCatalogs(yaml, null); + expect(catalogs.get('')).toEqual({ react: '^19.0.0', lodash: '^4.17.21' }); + }); + + test('parses named catalogs from .yarnrc.yml', () => { + const yaml = ` +catalog: + lodash: ^4.17.21 +catalogs: + react18: + react: ^18.3.1 + react-dom: ^18.3.1 +`; + const catalogs = parseCatalogs(yaml, null); + expect(catalogs.get('')).toEqual({ lodash: '^4.17.21' }); + expect(catalogs.get('react18')).toEqual({ react: '^18.3.1', 'react-dom': '^18.3.1' }); + }); + test('parses named catalogs from pnpm-workspace.yaml', () => { const yaml = ` catalogs: @@ -206,3 +236,59 @@ describe('resolveCatalogDep (sanity check after refactor)', () => { expect(resolveCatalogDep('react', 'catalog:default', catalogs)).toBe('^19.0.0'); }); }); + +describe('catalogYamlFile', () => { + test('pnpm uses pnpm-workspace.yaml', () => { + expect(catalogYamlFile('pnpm')).toBe('pnpm-workspace.yaml'); + }); + + test('yarn uses .yarnrc.yml', () => { + expect(catalogYamlFile('yarn')).toBe('.yarnrc.yml'); + }); + + test('npm and bun have no workspace catalog yaml', () => { + expect(catalogYamlFile('npm')).toBeNull(); + expect(catalogYamlFile('bun')).toBeNull(); + }); +}); + +describe('detectWorkspaces catalog loading', () => { + async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(resolve(tmpdir(), 'bumpy-pm-')); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + + test('loads Yarn catalogs from .yarnrc.yml (issue #148)', async () => { + await withTempDir(async (dir) => { + await writeFile(resolve(dir, 'yarn.lock'), ''); + await writeFile(resolve(dir, 'package.json'), JSON.stringify({ name: 'root', workspaces: ['packages/*'] })); + await writeFile( + resolve(dir, '.yarnrc.yml'), + `catalog:\n react: ^19.0.0\ncatalogs:\n testing:\n jest: ^30.0.0\n`, + ); + + const info = await detectWorkspaces(dir); + expect(info.packageManager).toBe('yarn'); + expect(info.catalogs.get('')).toEqual({ react: '^19.0.0' }); + expect(info.catalogs.get('testing')).toEqual({ jest: '^30.0.0' }); + }); + }); + + test('does not read .yarnrc.yml for a pnpm workspace', async () => { + await withTempDir(async (dir) => { + await writeFile(resolve(dir, 'pnpm-lock.yaml'), ''); + await writeFile(resolve(dir, 'package.json'), JSON.stringify({ name: 'root' })); + // A stray .yarnrc.yml should be ignored when the PM is pnpm + await writeFile(resolve(dir, '.yarnrc.yml'), `catalog:\n react: ^18.0.0\n`); + await writeFile(resolve(dir, 'pnpm-workspace.yaml'), `catalog:\n react: ^19.0.0\n`); + + const info = await detectWorkspaces(dir); + expect(info.packageManager).toBe('pnpm'); + expect(info.catalogs.get('')).toEqual({ react: '^19.0.0' }); + }); + }); +});