Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/yarn-catalogs-yarnrc.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 8 additions & 5 deletions packages/bumpy/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
parseCatalogs,
diffCatalogMaps,
isCatalogRefAffected,
catalogYamlFile,
CATALOG_FILES,
} from '../utils/package-manager.ts';
import { readText, exists } from '../utils/fs.ts';
Expand Down Expand Up @@ -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);

Expand Down
42 changes: 30 additions & 12 deletions packages/bumpy/src/utils/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,20 @@ async function getWorkspaceGlobs(rootDir: string, pm: PackageManager): Promise<s
/**
* Files that may contain catalog definitions, in the order they're applied.
* Later entries override earlier ones (matching loadCatalogs behavior).
* `.yarnrc.yml` is where Yarn (>=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.
Expand All @@ -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<string, Record<string, string>>): void => {
Expand All @@ -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<string, string>;
catalogs?: Record<string, Record<string, string>>;
} | null;
Expand Down Expand Up @@ -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<CatalogMap> {
// 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);
}
}

Expand All @@ -164,7 +182,7 @@ async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<Catalo
pkgJsonText = await readText(pkgJsonPath);
}

return parseCatalogs(pnpmYaml, pkgJsonText);
return parseCatalogs(workspaceYaml, pkgJsonText);
}

/** Extract the catalog name from a `catalog:` / `catalog:<name>` range, normalizing the default alias */
Expand Down
86 changes: 86 additions & 0 deletions packages/bumpy/test/utils/package-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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:
Expand Down Expand Up @@ -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<void>): Promise<void> {
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' });
});
});
});
Loading