diff --git a/.bumpy/catalog-change-detection.md b/.bumpy/catalog-change-detection.md new file mode 100644 index 0000000..9939577 --- /dev/null +++ b/.bumpy/catalog-change-detection.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Detect catalog entry changes as package changes. When a catalog version in `pnpm-workspace.yaml` (pnpm) or root `package.json` (bun/yarn `catalog`/`catalogs`, plus `workspaces.catalog`/`workspaces.catalogs`) is modified, `bumpy add` and `bumpy check` now flag every package that references the changed entry via `catalog:` / `catalog:` as changed. Closes #92. diff --git a/packages/bumpy/src/commands/add.ts b/packages/bumpy/src/commands/add.ts index af22433..ccbf9d1 100644 --- a/packages/bumpy/src/commands/add.ts +++ b/packages/bumpy/src/commands/add.ts @@ -1,13 +1,12 @@ -import { relative, resolve } from 'node:path'; +import { resolve } from 'node:path'; import pc from 'picocolors'; import { log } from '../utils/logger.ts'; import { p, unwrap } from '../utils/clack.ts'; import { ensureDir, exists } from '../utils/fs.ts'; import { randomName, slugify } from '../utils/names.ts'; import { writeBumpFile, readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts'; -import picomatch from 'picomatch'; -import { getBumpyDir, loadConfig, loadPackageConfig } from '../core/config.ts'; -import { discoverPackages, discoverWorkspace } from '../core/workspace.ts'; +import { getBumpyDir, loadConfig } from '../core/config.ts'; +import { discoverWorkspace } from '../core/workspace.ts'; import { findChangedPackages } from './check.ts'; import { getChangedFiles } from '../core/git.ts'; import { bumpSelectPrompt } from '../prompts/bump-select.ts'; @@ -78,35 +77,15 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise(); - for (const [name, pkg] of pkgs) { - const pkgConfig = await loadPackageConfig(pkg.dir, config, name); - const patterns = pkgConfig.changedFilePatterns ?? config.changedFilePatterns; - matchers.set(name, picomatch(patterns)); - } - - const changedPackageNames = new Set(); - for (const file of changedFiles) { - for (const [name, pkg] of pkgs) { - const pkgRelDir = relative(rootDir, pkg.dir); - if (file.startsWith(pkgRelDir + '/')) { - const relToPackage = file.slice(pkgRelDir.length + 1); - if (matchers.get(name)!(relToPackage)) { - changedPackageNames.add(name); - } - } - } - } + const changedFiles = getChangedFiles(rootDir, config.baseBranch); + const changedPackageNames = new Set(await findChangedPackages(changedFiles, pkgs, rootDir, config)); // Load existing bump files on this branch to avoid re-defaulting already-covered packages const { bumpFiles: allBumpFiles } = await readBumpFiles(rootDir); diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index 57ec127..3a91b7b 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -1,10 +1,19 @@ -import { relative } from 'node:path'; +import { relative, resolve } from 'node:path'; import picomatch from 'picomatch'; import { log, colorize } from '../utils/logger.ts'; import { loadConfig, loadPackageConfig, getBumpyDir } from '../core/config.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts'; -import { getChangedFiles, getFileStatuses } from '../core/git.ts'; +import { getChangedFiles, getFileStatuses, getBaseCompareRef, readFileAtRef } from '../core/git.ts'; +import { + detectPackageManager, + parseCatalogs, + diffCatalogMaps, + isCatalogRefAffected, + CATALOG_FILES, +} from '../utils/package-manager.ts'; +import { readText, exists } from '../utils/fs.ts'; +import { DEP_TYPES } from '../types.ts'; import type { BumpyConfig, WorkspacePackage } from '../types.ts'; export type HookContext = 'pre-commit' | 'pre-push'; @@ -238,5 +247,60 @@ export async function findChangedPackages( } } + // Catalog change detection: if a catalog file changed, find packages whose + // catalog: dep references resolve to a changed catalog entry + const catalogChanges = await getChangedCatalogEntries(rootDir, config.baseBranch, changedFiles); + if (catalogChanges.size > 0) { + for (const [name, pkg] of packages) { + if (changed.has(name)) continue; + for (const depType of DEP_TYPES) { + const deps = pkg[depType]; + let matched = false; + for (const [depName, range] of Object.entries(deps)) { + if (isCatalogRefAffected(range, depName, catalogChanges)) { + changed.add(name); + matched = true; + break; + } + } + if (matched) break; + } + } + } + return [...changed]; } + +/** + * Compute which catalog entries changed between the base ref and HEAD. + * Returns Map>. Empty if no catalog files changed. + */ +async function getChangedCatalogEntries( + rootDir: string, + baseBranch: string, + changedFiles: string[], +): Promise>> { + const catalogFileChanged = changedFiles.some((f) => (CATALOG_FILES as readonly string[]).includes(f)); + if (!catalogFileChanged) return new Map(); + + const baseRef = getBaseCompareRef(rootDir, baseBranch); + + const pm = await detectPackageManager(rootDir); + + // 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; + 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; + const beforePkgJson = readFileAtRef(rootDir, baseRef, 'package.json'); + const beforeCatalogs = parseCatalogs(beforeYaml, beforePkgJson); + + return diffCatalogMaps(beforeCatalogs, afterCatalogs); +} diff --git a/packages/bumpy/src/core/git.ts b/packages/bumpy/src/core/git.ts index f00201b..ffb6849 100644 --- a/packages/bumpy/src/core/git.ts +++ b/packages/bumpy/src/core/git.ts @@ -113,21 +113,34 @@ export function tagExists(tag: string, opts?: { cwd?: string }): boolean { return tryRunArgs(['git', 'tag', '-l', tag], opts) === tag; } -/** Get files changed on this branch compared to a base branch */ -export function getChangedFiles(rootDir: string, baseBranch: string): string[] { - // Ensure we have the base branch ref (may need fetching in shallow CI clones) +/** + * Resolve the ref to use as the comparison base for "this branch vs main". + * Prefers the merge-base, falls back to `origin/`. + */ +export function getBaseCompareRef(rootDir: string, baseBranch: string): string { if (!tryRunArgs(['git', 'rev-parse', '--verify', `origin/${baseBranch}`], { cwd: rootDir })) { tryRunArgs(['git', 'fetch', 'origin', baseBranch, '--depth=1'], { cwd: rootDir }); } - - // Try merge-base for the most accurate comparison const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir }); - const ref = mergeBase || `origin/${baseBranch}`; + return mergeBase || `origin/${baseBranch}`; +} + +/** Get files changed on this branch compared to a base branch */ +export function getChangedFiles(rootDir: string, baseBranch: string): string[] { + const ref = getBaseCompareRef(rootDir, baseBranch); const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir }); if (!diff) return []; return diff.split('\n').filter(Boolean); } +/** + * Read a file's contents at a given git ref. + * Returns null if the file does not exist at that ref. + */ +export function readFileAtRef(rootDir: string, ref: string, path: string): string | null { + return tryRunArgs(['git', 'show', `${ref}:${path}`], { cwd: rootDir }); +} + /** Get commits on the current branch since it diverged from baseBranch */ export function getBranchCommits( rootDir: string, diff --git a/packages/bumpy/src/utils/package-manager.ts b/packages/bumpy/src/utils/package-manager.ts index 0b2b4dc..22c4c5c 100644 --- a/packages/bumpy/src/utils/package-manager.ts +++ b/packages/bumpy/src/utils/package-manager.ts @@ -72,16 +72,35 @@ async function getWorkspaceGlobs(rootDir: string, pm: PackageManager): Promise { +/** + * Files that may contain catalog definitions, in the order they're applied. + * Later entries override earlier ones (matching loadCatalogs behavior). + */ +export const CATALOG_FILES = ['pnpm-workspace.yaml', 'package.json'] as const; + +/** + * Normalize a catalog name to its canonical form. + * pnpm/bun treat "default" and the unnamed top-level catalog interchangeably, + * so we store and look up the default catalog under "" regardless of which alias + * the user wrote. + */ +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 { const catalogs: CatalogMap = new Map(); - if (pm === 'pnpm') { - // pnpm: catalogs live in pnpm-workspace.yaml - const wsFile = resolve(rootDir, 'pnpm-workspace.yaml'); - if (await exists(wsFile)) { - const content = await readText(wsFile); - const parsed = yaml.load(content) as { + const addNamed = (raw: Record>): void => { + for (const [name, deps] of Object.entries(raw)) { + catalogs.set(normalizeCatalogName(name), deps); + } + }; + + if (pnpmWorkspaceYaml) { + try { + const parsed = yaml.load(pnpmWorkspaceYaml) as { catalog?: Record; catalogs?: Record>; } | null; @@ -90,52 +109,113 @@ async function loadCatalogs(rootDir: string, pm: PackageManager): Promise>(resolve(rootDir, 'package.json')); + if (rootPackageJson) { + try { + const pkg = JSON.parse(rootPackageJson) as Record; - // Check top-level catalog/catalogs - if (pkg.catalog && typeof pkg.catalog === 'object') { - catalogs.set('', pkg.catalog as Record); - } - if (pkg.catalogs && typeof pkg.catalogs === 'object') { - for (const [name, deps] of Object.entries(pkg.catalogs as Record>)) { - catalogs.set(name, deps); + // Top-level catalog/catalogs (used by bun, yarn, and proposed npm) + if (pkg.catalog && typeof pkg.catalog === 'object') { + catalogs.set('', pkg.catalog as Record); } - } - - // Also check inside workspaces object (bun style) - const workspaces = pkg.workspaces; - if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) { - const ws = workspaces as Record; - if (ws.catalog && typeof ws.catalog === 'object') { - catalogs.set('', ws.catalog as Record); + if (pkg.catalogs && typeof pkg.catalogs === 'object') { + addNamed(pkg.catalogs as Record>); } - if (ws.catalogs && typeof ws.catalogs === 'object') { - for (const [name, deps] of Object.entries(ws.catalogs as Record>)) { - catalogs.set(name, deps); + + // Inside workspaces object (bun style) + const workspaces = pkg.workspaces; + if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) { + const ws = workspaces as Record; + if (ws.catalog && typeof ws.catalog === 'object') { + catalogs.set('', ws.catalog as Record); + } + if (ws.catalogs && typeof ws.catalogs === 'object') { + addNamed(ws.catalogs as Record>); } } + } catch { + // ignore malformed json } - } catch { - // ignore } return catalogs; } +/** Load catalog definitions from pnpm-workspace.yaml 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'); + if (await exists(wsFile)) { + pnpmYaml = await readText(wsFile); + } + } + + let pkgJsonText: string | null = null; + const pkgJsonPath = resolve(rootDir, 'package.json'); + if (await exists(pkgJsonPath)) { + pkgJsonText = await readText(pkgJsonPath); + } + + return parseCatalogs(pnpmYaml, pkgJsonText); +} + +/** Extract the catalog name from a `catalog:` / `catalog:` range, normalizing the default alias */ +function catalogNameFromRange(range: string): string { + return normalizeCatalogName(range.slice('catalog:'.length).trim()); +} + /** Resolve a specific dependency's catalog: reference */ export function resolveCatalogDep(depName: string, range: string, catalogs: CatalogMap): string | null { if (!range.startsWith('catalog:')) return null; - const catalogName = range.slice('catalog:'.length).trim() || ''; - const catalog = catalogs.get(catalogName); + const catalog = catalogs.get(catalogNameFromRange(range)); if (!catalog) return null; return catalog[depName] ?? null; } + +/** + * Diff two catalog states and return the set of (catalogName → changed depNames). + * Includes added, removed, and version-changed entries. + */ +export function diffCatalogMaps(before: CatalogMap, after: CatalogMap): Map> { + const changes = new Map>(); + const catalogNames = new Set([...before.keys(), ...after.keys()]); + + for (const catalogName of catalogNames) { + const beforeDeps = before.get(catalogName) ?? {}; + const afterDeps = after.get(catalogName) ?? {}; + const depNames = new Set([...Object.keys(beforeDeps), ...Object.keys(afterDeps)]); + const changedDeps = new Set(); + for (const depName of depNames) { + if (beforeDeps[depName] !== afterDeps[depName]) { + changedDeps.add(depName); + } + } + if (changedDeps.size > 0) { + changes.set(catalogName, changedDeps); + } + } + + return changes; +} + +/** + * Given a set of catalog entries that have changed, return the set of catalog + * references (e.g. "catalog:" or "catalog:testing") that affect those entries. + * Used to match package.json dep ranges against changed catalog entries. + */ +export function isCatalogRefAffected( + range: string, + depName: string, + catalogChanges: Map>, +): boolean { + if (!range.startsWith('catalog:')) return false; + return catalogChanges.get(catalogNameFromRange(range))?.has(depName) ?? false; +} diff --git a/packages/bumpy/test/core/check-catalog.test.ts b/packages/bumpy/test/core/check-catalog.test.ts new file mode 100644 index 0000000..feb700c --- /dev/null +++ b/packages/bumpy/test/core/check-catalog.test.ts @@ -0,0 +1,226 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { findChangedPackages } from '../../src/commands/check.ts'; +import { discoverWorkspace } from '../../src/core/workspace.ts'; +import { getChangedFiles } from '../../src/core/git.ts'; +import { loadConfig } from '../../src/core/config.ts'; +import { createTempGitRepo, cleanupTempDir, gitInDir } from '../helpers.ts'; + +/** + * Integration tests for catalog-aware change detection in findChangedPackages. + * Each test sets up a temp git repo with a base "main" branch, creates a feature + * branch, modifies catalog entries, and verifies which packages are flagged. + */ +describe('findChangedPackages — catalog change detection', () => { + let tmpDir: string; + const teardown: Array<() => Promise> = []; + + beforeEach(async () => { + tmpDir = await createTempGitRepo(); + gitInDir(['branch', '-M', 'main'], tmpDir); + gitInDir(['config', 'user.email', 'test@example.com'], tmpDir); + gitInDir(['config', 'user.name', 'Test'], tmpDir); + }); + + afterEach(async () => { + for (const fn of teardown) await fn(); + teardown.length = 0; + await cleanupTempDir(tmpDir); + }); + + /** Set up a bare remote so getBaseCompareRef can resolve origin/main */ + async function setupOriginMain(): Promise { + const { mkdtemp, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const remote = await mkdtemp(resolve(tmpdir(), 'bumpy-remote-')); + gitInDir(['init', '--bare'], remote); + gitInDir(['remote', 'add', 'origin', remote], tmpDir); + gitInDir(['push', '-u', 'origin', 'main'], tmpDir); + teardown.push(() => rm(remote, { recursive: true, force: true })); + } + + async function writeJson(path: string, data: unknown): Promise { + await mkdir(resolve(tmpDir, path, '..'), { recursive: true }); + await writeFile(resolve(tmpDir, path), JSON.stringify(data, null, 2)); + } + async function writeFileAt(path: string, content: string): Promise { + await mkdir(resolve(tmpDir, path, '..'), { recursive: true }); + await writeFile(resolve(tmpDir, path), content); + } + + test('pnpm-workspace.yaml catalog change flags consuming packages', async () => { + // --- base state on main --- + await writeJson('package.json', { name: 'root', private: true }); + await writeFileAt('pnpm-workspace.yaml', "packages:\n - 'packages/*'\ncatalog:\n '@somepkg': ^0.10.0\n"); + await writeFileAt('pnpm-lock.yaml', ''); // marker for pnpm detection + await writeJson('packages/uses-catalog/package.json', { + name: 'uses-catalog', + version: '1.0.0', + dependencies: { '@somepkg': 'catalog:' }, + }); + await writeJson('packages/no-catalog/package.json', { + name: 'no-catalog', + version: '1.0.0', + dependencies: { lodash: '^4.0.0' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init monorepo'], tmpDir); + await setupOriginMain(); + + // --- branch off main, bump catalog entry --- + gitInDir(['checkout', '-b', 'feature'], tmpDir); + await writeFileAt('pnpm-workspace.yaml', "packages:\n - 'packages/*'\ncatalog:\n '@somepkg': ^0.11.0\n"); + gitInDir(['commit', '-am', 'bump catalog'], tmpDir); + + // --- assert --- + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const changedFiles = getChangedFiles(tmpDir, config.baseBranch); + const changed = await findChangedPackages(changedFiles, packages, tmpDir, config); + + expect(changed).toContain('uses-catalog'); + expect(changed).not.toContain('no-catalog'); + }); + + test('package.json catalog change (bun style) flags consuming packages', async () => { + await writeJson('package.json', { + name: 'root', + private: true, + workspaces: ['packages/*'], + catalog: { react: '^19.0.0' }, + }); + await writeFileAt('bun.lock', ''); // bun detection + await writeJson('packages/web/package.json', { + name: 'web', + version: '1.0.0', + dependencies: { react: 'catalog:' }, + }); + await writeJson('packages/cli/package.json', { + name: 'cli', + version: '1.0.0', + dependencies: { chalk: '^5.0.0' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init'], tmpDir); + await setupOriginMain(); + + gitInDir(['checkout', '-b', 'feature'], tmpDir); + await writeJson('package.json', { + name: 'root', + private: true, + workspaces: ['packages/*'], + catalog: { react: '^19.1.0' }, + }); + gitInDir(['commit', '-am', 'bump react in catalog'], tmpDir); + + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const changedFiles = getChangedFiles(tmpDir, config.baseBranch); + const changed = await findChangedPackages(changedFiles, packages, tmpDir, config); + + expect(changed).toContain('web'); + expect(changed).not.toContain('cli'); + }); + + test('named catalog change only affects packages referencing that named catalog', async () => { + await writeJson('package.json', { name: 'root', private: true }); + await writeFileAt( + 'pnpm-workspace.yaml', + "packages:\n - 'packages/*'\ncatalogs:\n testing:\n jest: ^30.0.0\n build:\n typescript: ^5.0.0\n", + ); + await writeFileAt('pnpm-lock.yaml', ''); + await writeJson('packages/uses-testing/package.json', { + name: 'uses-testing', + version: '1.0.0', + devDependencies: { jest: 'catalog:testing' }, + }); + await writeJson('packages/uses-build/package.json', { + name: 'uses-build', + version: '1.0.0', + devDependencies: { typescript: 'catalog:build' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init'], tmpDir); + await setupOriginMain(); + + gitInDir(['checkout', '-b', 'feature'], tmpDir); + await writeFileAt( + 'pnpm-workspace.yaml', + "packages:\n - 'packages/*'\ncatalogs:\n testing:\n jest: ^30.1.0\n build:\n typescript: ^5.0.0\n", + ); + gitInDir(['commit', '-am', 'bump jest in testing catalog'], tmpDir); + + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const changedFiles = getChangedFiles(tmpDir, config.baseBranch); + const changed = await findChangedPackages(changedFiles, packages, tmpDir, config); + + expect(changed).toContain('uses-testing'); + expect(changed).not.toContain('uses-build'); + }); + + test('catalog: in devDependencies / peerDependencies also flags the package', async () => { + await writeJson('package.json', { name: 'root', private: true }); + await writeFileAt( + 'pnpm-workspace.yaml', + "packages:\n - 'packages/*'\ncatalog:\n typescript: ^5.0.0\n react: ^19.0.0\n", + ); + await writeFileAt('pnpm-lock.yaml', ''); + await writeJson('packages/uses-dev/package.json', { + name: 'uses-dev', + version: '1.0.0', + devDependencies: { typescript: 'catalog:' }, + }); + await writeJson('packages/uses-peer/package.json', { + name: 'uses-peer', + version: '1.0.0', + peerDependencies: { react: 'catalog:' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init'], tmpDir); + await setupOriginMain(); + + gitInDir(['checkout', '-b', 'feature'], tmpDir); + await writeFileAt( + 'pnpm-workspace.yaml', + "packages:\n - 'packages/*'\ncatalog:\n typescript: ^5.1.0\n react: ^19.1.0\n", + ); + gitInDir(['commit', '-am', 'bump catalog'], tmpDir); + + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const changedFiles = getChangedFiles(tmpDir, config.baseBranch); + const changed = await findChangedPackages(changedFiles, packages, tmpDir, config); + + expect(changed).toContain('uses-dev'); + expect(changed).toContain('uses-peer'); + }); + + test('non-catalog dep updates do not trigger detection', async () => { + await writeJson('package.json', { name: 'root', private: true }); + await writeFileAt('pnpm-workspace.yaml', "packages:\n - 'packages/*'\ncatalog:\n react: ^19.0.0\n"); + await writeFileAt('pnpm-lock.yaml', ''); + // pkg-a depends on react with an explicit (non-catalog) range + await writeJson('packages/pkg-a/package.json', { + name: 'pkg-a', + version: '1.0.0', + dependencies: { react: '^19.0.0' }, + }); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init'], tmpDir); + await setupOriginMain(); + + gitInDir(['checkout', '-b', 'feature'], tmpDir); + await writeFileAt('pnpm-workspace.yaml', "packages:\n - 'packages/*'\ncatalog:\n react: ^19.1.0\n"); + gitInDir(['commit', '-am', 'bump catalog'], tmpDir); + + const config = await loadConfig(tmpDir); + const { packages } = await discoverWorkspace(tmpDir, config); + const changedFiles = getChangedFiles(tmpDir, config.baseBranch); + const changed = await findChangedPackages(changedFiles, packages, tmpDir, config); + + // pkg-a uses an explicit version, not catalog:, so the catalog change doesn't affect it + expect(changed).not.toContain('pkg-a'); + }); +}); diff --git a/packages/bumpy/test/utils/package-manager.test.ts b/packages/bumpy/test/utils/package-manager.test.ts new file mode 100644 index 0000000..fd615e5 --- /dev/null +++ b/packages/bumpy/test/utils/package-manager.test.ts @@ -0,0 +1,208 @@ +import { test, expect, describe } from 'bun:test'; +import { + parseCatalogs, + diffCatalogMaps, + isCatalogRefAffected, + resolveCatalogDep, + type CatalogMap, +} from '../../src/utils/package-manager.ts'; + +describe('parseCatalogs', () => { + test('returns empty map when no content', () => { + expect(parseCatalogs(null, null).size).toBe(0); + }); + + test('parses default catalog from pnpm-workspace.yaml', () => { + const yaml = ` +packages: + - 'packages/*' +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 pnpm-workspace.yaml', () => { + const yaml = ` +catalogs: + testing: + jest: ^30.0.0 + build: + typescript: ^5.0.0 +`; + const catalogs = parseCatalogs(yaml, null); + expect(catalogs.get('testing')).toEqual({ jest: '^30.0.0' }); + expect(catalogs.get('build')).toEqual({ typescript: '^5.0.0' }); + }); + + test('parses top-level catalog from package.json (bun/yarn style)', () => { + const pkg = JSON.stringify({ + name: 'root', + catalog: { react: '^19.0.0' }, + catalogs: { testing: { jest: '^30.0.0' } }, + }); + const catalogs = parseCatalogs(null, pkg); + expect(catalogs.get('')).toEqual({ react: '^19.0.0' }); + expect(catalogs.get('testing')).toEqual({ jest: '^30.0.0' }); + }); + + test('parses workspaces.catalog from package.json (bun nested style)', () => { + const pkg = JSON.stringify({ + name: 'root', + workspaces: { + packages: ['packages/*'], + catalog: { react: '^19.0.0' }, + catalogs: { testing: { jest: '^30.0.0' } }, + }, + }); + const catalogs = parseCatalogs(null, pkg); + expect(catalogs.get('')).toEqual({ react: '^19.0.0' }); + expect(catalogs.get('testing')).toEqual({ jest: '^30.0.0' }); + }); + + test('package.json catalog overrides pnpm yaml when both present', () => { + const yaml = `catalog:\n react: ^18.0.0\n`; + const pkg = JSON.stringify({ catalog: { react: '^19.0.0' } }); + const catalogs = parseCatalogs(yaml, pkg); + expect(catalogs.get('')).toEqual({ react: '^19.0.0' }); + }); + + test('tolerates malformed yaml', () => { + expect(() => parseCatalogs('not: valid: yaml: at all', null)).not.toThrow(); + }); + + test('tolerates malformed json', () => { + expect(() => parseCatalogs(null, '{not valid json')).not.toThrow(); + }); + + test('catalogs.default is stored under "" so it merges with the top-level catalog', () => { + // pnpm treats top-level `catalog` and `catalogs.default` as aliases of the same default catalog + const yaml = ` +catalogs: + default: + react: ^19.0.0 +`; + const catalogs = parseCatalogs(yaml, null); + expect(catalogs.get('')).toEqual({ react: '^19.0.0' }); + expect(catalogs.has('default')).toBe(false); + }); + + test('catalogs.default in package.json also normalizes to ""', () => { + const pkg = JSON.stringify({ catalogs: { default: { react: '^19.0.0' }, testing: { jest: '^30.0.0' } } }); + const catalogs = parseCatalogs(null, pkg); + expect(catalogs.get('')).toEqual({ react: '^19.0.0' }); + expect(catalogs.get('testing')).toEqual({ jest: '^30.0.0' }); + expect(catalogs.has('default')).toBe(false); + }); +}); + +describe('diffCatalogMaps', () => { + function mapOf(obj: Record>): CatalogMap { + return new Map(Object.entries(obj)); + } + + test('returns empty when catalogs are identical', () => { + const a = mapOf({ '': { react: '^19.0.0' } }); + const b = mapOf({ '': { react: '^19.0.0' } }); + expect(diffCatalogMaps(a, b).size).toBe(0); + }); + + test('detects version change in default catalog', () => { + const before = mapOf({ '': { react: '^19.0.0' } }); + const after = mapOf({ '': { react: '^19.1.0' } }); + const diff = diffCatalogMaps(before, after); + expect(diff.get('')).toEqual(new Set(['react'])); + }); + + test('detects added entry', () => { + const before = mapOf({ '': { react: '^19.0.0' } }); + const after = mapOf({ '': { react: '^19.0.0', lodash: '^4.0.0' } }); + expect(diffCatalogMaps(before, after).get('')).toEqual(new Set(['lodash'])); + }); + + test('detects removed entry', () => { + const before = mapOf({ '': { react: '^19.0.0', lodash: '^4.0.0' } }); + const after = mapOf({ '': { react: '^19.0.0' } }); + expect(diffCatalogMaps(before, after).get('')).toEqual(new Set(['lodash'])); + }); + + test('tracks changes in named catalogs separately', () => { + const before = mapOf({ '': { react: '^19.0.0' }, testing: { jest: '^30.0.0' } }); + const after = mapOf({ '': { react: '^19.0.0' }, testing: { jest: '^30.1.0' } }); + const diff = diffCatalogMaps(before, after); + expect(diff.has('')).toBe(false); + expect(diff.get('testing')).toEqual(new Set(['jest'])); + }); + + test('handles entirely new catalog', () => { + const before = mapOf({}); + const after = mapOf({ '': { react: '^19.0.0' } }); + expect(diffCatalogMaps(before, after).get('')).toEqual(new Set(['react'])); + }); +}); + +describe('isCatalogRefAffected', () => { + const changes = new Map>([ + ['', new Set(['react'])], + ['testing', new Set(['jest'])], + ]); + + test('returns false for non-catalog ranges', () => { + expect(isCatalogRefAffected('^19.0.0', 'react', changes)).toBe(false); + expect(isCatalogRefAffected('workspace:*', 'react', changes)).toBe(false); + }); + + test('matches default catalog ref for changed entry', () => { + expect(isCatalogRefAffected('catalog:', 'react', changes)).toBe(true); + }); + + test('default catalog ref does not match named catalog change', () => { + expect(isCatalogRefAffected('catalog:', 'jest', changes)).toBe(false); + }); + + test('matches named catalog ref for changed entry', () => { + expect(isCatalogRefAffected('catalog:testing', 'jest', changes)).toBe(true); + }); + + test('named catalog ref does not match different named catalog', () => { + expect(isCatalogRefAffected('catalog:build', 'react', changes)).toBe(false); + }); + + test('returns false when depName is not in changes', () => { + expect(isCatalogRefAffected('catalog:', 'lodash', changes)).toBe(false); + }); + + test('catalog:default is an alias for catalog: (default catalog)', () => { + expect(isCatalogRefAffected('catalog:default', 'react', changes)).toBe(true); + expect(isCatalogRefAffected('catalog:default', 'jest', changes)).toBe(false); + }); +}); + +describe('resolveCatalogDep (sanity check after refactor)', () => { + test('resolves default catalog reference', () => { + const catalogs: CatalogMap = new Map([['', { react: '^19.0.0' }]]); + expect(resolveCatalogDep('react', 'catalog:', catalogs)).toBe('^19.0.0'); + }); + + test('resolves named catalog reference', () => { + const catalogs: CatalogMap = new Map([['testing', { jest: '^30.0.0' }]]); + expect(resolveCatalogDep('jest', 'catalog:testing', catalogs)).toBe('^30.0.0'); + }); + + test('returns null for non-catalog range', () => { + const catalogs: CatalogMap = new Map([['', { react: '^19.0.0' }]]); + expect(resolveCatalogDep('react', '^19.0.0', catalogs)).toBeNull(); + }); + + test('returns null for missing entry', () => { + const catalogs: CatalogMap = new Map([['', {}]]); + expect(resolveCatalogDep('react', 'catalog:', catalogs)).toBeNull(); + }); + + test('resolves catalog:default to the default catalog', () => { + const catalogs: CatalogMap = new Map([['', { react: '^19.0.0' }]]); + expect(resolveCatalogDep('react', 'catalog:default', catalogs)).toBe('^19.0.0'); + }); +});