Skip to content

Commit c5b192f

Browse files
committed
fix: normalize 'default' as alias for the unnamed default catalog
pnpm/bun treat top-level `catalog` and `catalogs.default` as the same default catalog, with `catalog:` and `catalog:default` both pointing at it. Previously `catalogs.default` was stored under key "default" and `catalog:default` looked up "default", so users who used the alias form got no catalog resolution. Now both forms normalize to the canonical "" key.
1 parent 5e6acec commit c5b192f

2 files changed

Lines changed: 56 additions & 13 deletions

File tree

packages/bumpy/src/utils/package-manager.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,26 @@ async function getWorkspaceGlobs(rootDir: string, pm: PackageManager): Promise<s
7878
*/
7979
export const CATALOG_FILES = ['pnpm-workspace.yaml', 'package.json'] as const;
8080

81+
/**
82+
* Normalize a catalog name to its canonical form.
83+
* pnpm/bun treat "default" and the unnamed top-level catalog interchangeably,
84+
* so we store and look up the default catalog under "" regardless of which alias
85+
* the user wrote.
86+
*/
87+
function normalizeCatalogName(name: string): string {
88+
return name === 'default' ? '' : name;
89+
}
90+
8191
/** Parse catalog definitions from the raw contents of pnpm-workspace.yaml and root package.json */
8292
export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson: string | null): CatalogMap {
8393
const catalogs: CatalogMap = new Map();
8494

95+
const addNamed = (raw: Record<string, Record<string, string>>): void => {
96+
for (const [name, deps] of Object.entries(raw)) {
97+
catalogs.set(normalizeCatalogName(name), deps);
98+
}
99+
};
100+
85101
if (pnpmWorkspaceYaml) {
86102
try {
87103
const parsed = yaml.load(pnpmWorkspaceYaml) as {
@@ -93,9 +109,7 @@ export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson:
93109
catalogs.set('', parsed.catalog); // default catalog
94110
}
95111
if (parsed?.catalogs) {
96-
for (const [name, deps] of Object.entries(parsed.catalogs)) {
97-
catalogs.set(name, deps);
98-
}
112+
addNamed(parsed.catalogs);
99113
}
100114
} catch {
101115
// ignore malformed yaml
@@ -111,9 +125,7 @@ export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson:
111125
catalogs.set('', pkg.catalog as Record<string, string>);
112126
}
113127
if (pkg.catalogs && typeof pkg.catalogs === 'object') {
114-
for (const [name, deps] of Object.entries(pkg.catalogs as Record<string, Record<string, string>>)) {
115-
catalogs.set(name, deps);
116-
}
128+
addNamed(pkg.catalogs as Record<string, Record<string, string>>);
117129
}
118130

119131
// Inside workspaces object (bun style)
@@ -124,9 +136,7 @@ export function parseCatalogs(pnpmWorkspaceYaml: string | null, rootPackageJson:
124136
catalogs.set('', ws.catalog as Record<string, string>);
125137
}
126138
if (ws.catalogs && typeof ws.catalogs === 'object') {
127-
for (const [name, deps] of Object.entries(ws.catalogs as Record<string, Record<string, string>>)) {
128-
catalogs.set(name, deps);
129-
}
139+
addNamed(ws.catalogs as Record<string, Record<string, string>>);
130140
}
131141
}
132142
} catch {
@@ -157,11 +167,15 @@ async function loadCatalogs(rootDir: string, pm: PackageManager): Promise<Catalo
157167
return parseCatalogs(pnpmYaml, pkgJsonText);
158168
}
159169

170+
/** Extract the catalog name from a `catalog:` / `catalog:<name>` range, normalizing the default alias */
171+
function catalogNameFromRange(range: string): string {
172+
return normalizeCatalogName(range.slice('catalog:'.length).trim());
173+
}
174+
160175
/** Resolve a specific dependency's catalog: reference */
161176
export function resolveCatalogDep(depName: string, range: string, catalogs: CatalogMap): string | null {
162177
if (!range.startsWith('catalog:')) return null;
163-
const catalogName = range.slice('catalog:'.length).trim() || '';
164-
const catalog = catalogs.get(catalogName);
178+
const catalog = catalogs.get(catalogNameFromRange(range));
165179
if (!catalog) return null;
166180
return catalog[depName] ?? null;
167181
}
@@ -203,6 +217,5 @@ export function isCatalogRefAffected(
203217
catalogChanges: Map<string, Set<string>>,
204218
): boolean {
205219
if (!range.startsWith('catalog:')) return false;
206-
const catalogName = range.slice('catalog:'.length).trim() || '';
207-
return catalogChanges.get(catalogName)?.has(depName) ?? false;
220+
return catalogChanges.get(catalogNameFromRange(range))?.has(depName) ?? false;
208221
}

packages/bumpy/test/utils/package-manager.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ catalogs:
7676
test('tolerates malformed json', () => {
7777
expect(() => parseCatalogs(null, '{not valid json')).not.toThrow();
7878
});
79+
80+
test('catalogs.default is stored under "" so it merges with the top-level catalog', () => {
81+
// pnpm treats top-level `catalog` and `catalogs.default` as aliases of the same default catalog
82+
const yaml = `
83+
catalogs:
84+
default:
85+
react: ^19.0.0
86+
`;
87+
const catalogs = parseCatalogs(yaml, null);
88+
expect(catalogs.get('')).toEqual({ react: '^19.0.0' });
89+
expect(catalogs.has('default')).toBe(false);
90+
});
91+
92+
test('catalogs.default in package.json also normalizes to ""', () => {
93+
const pkg = JSON.stringify({ catalogs: { default: { react: '^19.0.0' }, testing: { jest: '^30.0.0' } } });
94+
const catalogs = parseCatalogs(null, pkg);
95+
expect(catalogs.get('')).toEqual({ react: '^19.0.0' });
96+
expect(catalogs.get('testing')).toEqual({ jest: '^30.0.0' });
97+
expect(catalogs.has('default')).toBe(false);
98+
});
7999
});
80100

81101
describe('diffCatalogMaps', () => {
@@ -153,6 +173,11 @@ describe('isCatalogRefAffected', () => {
153173
test('returns false when depName is not in changes', () => {
154174
expect(isCatalogRefAffected('catalog:', 'lodash', changes)).toBe(false);
155175
});
176+
177+
test('catalog:default is an alias for catalog: (default catalog)', () => {
178+
expect(isCatalogRefAffected('catalog:default', 'react', changes)).toBe(true);
179+
expect(isCatalogRefAffected('catalog:default', 'jest', changes)).toBe(false);
180+
});
156181
});
157182

158183
describe('resolveCatalogDep (sanity check after refactor)', () => {
@@ -175,4 +200,9 @@ describe('resolveCatalogDep (sanity check after refactor)', () => {
175200
const catalogs: CatalogMap = new Map([['', {}]]);
176201
expect(resolveCatalogDep('react', 'catalog:', catalogs)).toBeNull();
177202
});
203+
204+
test('resolves catalog:default to the default catalog', () => {
205+
const catalogs: CatalogMap = new Map([['', { react: '^19.0.0' }]]);
206+
expect(resolveCatalogDep('react', 'catalog:default', catalogs)).toBe('^19.0.0');
207+
});
178208
});

0 commit comments

Comments
 (0)