diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index cd99fb9..ddba215 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -1,4 +1,3 @@ -import * as replacements from 'module-replacements'; import type { ManifestModule, ModuleReplacement, @@ -8,6 +7,7 @@ import type { import type {ReportPluginResult, AnalysisContext} from '../types.js'; import {fixableReplacements} from '../commands/fixable-replacements.js'; import {getPackageJson} from '../utils/package-json.js'; +import {getManifestForCategories} from '../categories.js'; import {resolve, dirname, basename} from 'node:path'; import { satisfies as semverSatisfies, @@ -131,13 +131,17 @@ export async function runReplacements( ? await loadCustomManifests(context.options.manifest) : {mappings: {}, replacements: {}}; + const baseManifest = getManifestForCategories( + context.options?.categories ?? 'all' + ); + // Custom mappings take precedence over built-in const allMappings = { - ...replacements.all.mappings, + ...baseManifest.mappings, ...customManifest.mappings }; const allReplacementDefs: Record = { - ...replacements.all.replacements, + ...baseManifest.replacements, ...customManifest.replacements }; diff --git a/src/categories.ts b/src/categories.ts new file mode 100644 index 0000000..0bdaeab --- /dev/null +++ b/src/categories.ts @@ -0,0 +1,84 @@ +import type {ManifestModule} from 'module-replacements'; +import { + nativeReplacements, + preferredReplacements, + microUtilsReplacements, + all +} from 'module-replacements'; + +export const VALID_CATEGORIES = [ + 'native', + 'preferred', + 'micro-utilities', + 'all' +] as const; + +export type Category = (typeof VALID_CATEGORIES)[number]; + +export type CategoryKey = Exclude; + +export type ParsedCategories = 'all' | Set; + +export function parseCategories(raw: string | undefined): ParsedCategories { + const normalized = raw?.trim() ?? ''; + if (normalized === '' || normalized === 'all') { + return 'all'; + } + + const segments = normalized + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (segments.length === 0) { + return 'all'; + } + + const invalid: string[] = []; + const parsed = new Set(); + + for (const segment of segments) { + if (segment === 'all') { + return 'all'; + } + if (VALID_CATEGORIES.includes(segment as Category)) { + parsed.add(segment as CategoryKey); + } else { + invalid.push(segment); + } + } + + if (invalid.length > 0) { + throw new Error( + `Invalid categories: ${invalid.join(', ')}. Valid values are: ${VALID_CATEGORIES.join(', ')}.` + ); + } + + return parsed; +} + +const MANIFEST_BY_CATEGORY: Record = { + native: nativeReplacements, + preferred: preferredReplacements, + 'micro-utilities': microUtilsReplacements +}; + +export function getManifestForCategories( + parsed: ParsedCategories +): ManifestModule { + if (parsed === 'all') { + return all; + } + + const manifest: ManifestModule = { + mappings: {}, + replacements: {} + }; + + for (const cat of parsed) { + const m = MANIFEST_BY_CATEGORY[cat]; + Object.assign(manifest.mappings, m.mappings); + Object.assign(manifest.replacements, m.replacements); + } + + return manifest; +} diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 16a033c..8e9717a 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -9,6 +9,12 @@ export const meta = { description: 'Set the log level and the minimum severity that causes a non-zero exit code (debug | info | warn | error)' }, + categories: { + type: 'string', + default: 'all', + description: + 'Manifest categories for replacement analysis: all, native, preferred, micro-utilities, or comma-separated (e.g. native,preferred). Default: all.' + }, manifest: { type: 'string', multiple: true, diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 9dc6eeb..9c9b990 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -6,6 +6,7 @@ import {meta} from './analyze.meta.js'; import {report} from '../index.js'; import {enableDebug} from '../logger.js'; import {wrapAnsi} from 'fast-wrap-ansi'; +import {parseCategories} from '../categories.js'; function formatBytes(bytes: number) { const units = ['B', 'KB', 'MB', 'GB']; @@ -49,6 +50,20 @@ export async function run(ctx: CommandContext) { prompts.intro('Analyzing...'); } + let parsedCategories: ReturnType; + try { + parsedCategories = parseCategories(ctx.values.categories ?? 'all'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const descriptiveMessage = `Invalid --categories: ${message}`; + if (jsonOutput) { + process.stderr.write(`Error: ${descriptiveMessage}\n`); + } else { + prompts.cancel(descriptiveMessage); + } + process.exit(1); + } + // Path can be a directory (analyze project) if (providedPath) { let stat: Stats | null; @@ -70,12 +85,12 @@ export async function run(ctx: CommandContext) { root = providedPath; } - // Then read the manifest const customManifests = ctx.values['manifest']; const {stats, messages} = await report({ root, - manifest: customManifests + manifest: customManifests, + categories: parsedCategories }); const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; diff --git a/src/commands/migrate.meta.ts b/src/commands/migrate.meta.ts index 6efcb7c..1e7f587 100644 --- a/src/commands/migrate.meta.ts +++ b/src/commands/migrate.meta.ts @@ -2,6 +2,12 @@ export const meta = { name: 'migrate', description: 'Migrate from a package to a more performant alternative.', args: { + categories: { + type: 'string', + default: 'all', + description: + 'Manifest categories to consider: all, native, preferred, micro-utilities, or comma-separated. Default: all.' + }, all: { type: 'boolean', default: false, diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 6c62c61..a6d0ebb 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -8,6 +8,7 @@ import {fixableReplacements} from './fixable-replacements.js'; import type {Replacement} from '../types.js'; import {LocalFileSystem} from '../local-file-system.js'; import {getPackageJson} from '../utils/package-json.js'; +import {parseCategories, getManifestForCategories} from '../categories.js'; export async function run(ctx: CommandContext) { const [_commandName, ...targetModules] = ctx.positionals; @@ -20,11 +21,27 @@ export async function run(ctx: CommandContext) { prompts.intro(`Migrating packages...`); + let parsedCategories: ReturnType; + try { + parsedCategories = parseCategories(ctx.values.categories); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + prompts.cancel(`Invalid --categories: ${message}`); + process.exitCode = 1; + return; + } + + const manifest = getManifestForCategories(parsedCategories); + const inScopePackageNames = new Set(Object.keys(manifest.mappings)); + const inScopeFixableReplacements = fixableReplacements.filter((rep) => + inScopePackageNames.has(rep.from) + ); + if (!packageJson) { prompts.cancel( 'Error: No package.json found in the current directory. Please run this command in a project directory.' ); - return; + process.exit(1); } const dependencies = { @@ -33,7 +50,7 @@ export async function run(ctx: CommandContext) { }; const fixableReplacementsTargets = new Set( - fixableReplacements + inScopeFixableReplacements .filter((rep) => Object.hasOwn(dependencies, rep.from)) .map((rep) => rep.from) ); @@ -64,7 +81,7 @@ export async function run(ctx: CommandContext) { let selectedReplacements: Replacement[]; if (all) { - selectedReplacements = fixableReplacements.filter((rep) => + selectedReplacements = inScopeFixableReplacements.filter((rep) => fixableReplacementsTargets.has(rep.from) ); } else { @@ -85,7 +102,7 @@ export async function run(ctx: CommandContext) { return; } - const replacement = fixableReplacements.find( + const replacement = inScopeFixableReplacements.find( (rep) => rep.from === targetModule ); if (!replacement) { diff --git a/src/test/categories.test.ts b/src/test/categories.test.ts new file mode 100644 index 0000000..7eed937 --- /dev/null +++ b/src/test/categories.test.ts @@ -0,0 +1,101 @@ +import {describe, it, expect} from 'vitest'; +import { + parseCategories, + getManifestForCategories, + VALID_CATEGORIES +} from '../categories.js'; + +describe('parseCategories', () => { + it('returns "all" for undefined', () => { + expect(parseCategories(undefined)).toBe('all'); + }); + + it('returns "all" for empty string', () => { + expect(parseCategories('')).toBe('all'); + }); + + it('returns "all" for whitespace-only string', () => { + expect(parseCategories(' ')).toBe('all'); + }); + + it('returns "all" for literal "all"', () => { + expect(parseCategories('all')).toBe('all'); + }); + + it('returns "all" for "all" with whitespace', () => { + expect(parseCategories(' all ')).toBe('all'); + }); + + it('returns single category as Set', () => { + expect(parseCategories('native')).toEqual(new Set(['native'])); + expect(parseCategories('preferred')).toEqual(new Set(['preferred'])); + expect(parseCategories('micro-utilities')).toEqual( + new Set(['micro-utilities']) + ); + }); + + it('returns multiple categories for comma-separated list', () => { + expect(parseCategories('native,preferred')).toEqual( + new Set(['native', 'preferred']) + ); + expect(parseCategories('native, preferred , micro-utilities')).toEqual( + new Set(['native', 'preferred', 'micro-utilities']) + ); + }); + + it('deduplicates categories', () => { + expect(parseCategories('native,native,preferred')).toEqual( + new Set(['native', 'preferred']) + ); + }); + + it('throws for invalid category', () => { + expect(() => parseCategories('foo')).toThrow( + /Invalid categories: foo\. Valid values are:/ + ); + expect(() => parseCategories('foo')).toThrow( + new RegExp(VALID_CATEGORIES.join(', ')) + ); + }); + + it('throws for invalid category in comma-separated list', () => { + expect(() => parseCategories('native,invalid,preferred')).toThrow( + /Invalid categories: invalid\. Valid values are:/ + ); + }); + + it('treats empty segments after split as omitted', () => { + expect(parseCategories('native,,preferred')).toEqual( + new Set(['native', 'preferred']) + ); + }); +}); + +describe('getManifestForCategories', () => { + it('returns merged manifest for "all"', () => { + const manifest = getManifestForCategories('all'); + expect(manifest).toHaveProperty('mappings'); + expect(manifest).toHaveProperty('replacements'); + expect(Object.keys(manifest.mappings).length).toBeGreaterThan(0); + expect(Object.keys(manifest.replacements).length).toBeGreaterThan(0); + }); + + it('returns manifest for single category', () => { + const nativeManifest = getManifestForCategories(new Set(['native'])); + expect(nativeManifest).toHaveProperty('mappings'); + expect(nativeManifest).toHaveProperty('replacements'); + expect(Object.keys(nativeManifest.mappings).length).toBeGreaterThanOrEqual( + 0 + ); + }); + + it('returns merged manifest for multiple categories', () => { + const manifest = getManifestForCategories(new Set(['native', 'preferred'])); + expect(manifest).toHaveProperty('mappings'); + expect(manifest).toHaveProperty('replacements'); + const allManifest = getManifestForCategories('all'); + expect(Object.keys(manifest.mappings).length).toBeLessThanOrEqual( + Object.keys(allManifest.mappings).length + ); + }); +}); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 91119c3..ae3af8d 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -204,6 +204,70 @@ describe('analyze fixable summary', () => { }); }); +describe('analyze --categories', () => { + it('exits 1 with helpful error for invalid --categories', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['analyze', '--categories=invalid'], + tempDir + ); + expect(code).toBe(1); + const output = stdout + stderr; + expect(output).toContain('Invalid categories'); + expect(output).toContain('Valid values are'); + expect(output).toMatch(/native|preferred|micro-utilities|all/); + }); + + it('exits 1 for invalid category in comma-separated list', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['analyze', '--categories=native,foo,preferred'], + tempDir + ); + expect(code).toBe(1); + const output = stdout + stderr; + expect(output).toContain('Invalid categories'); + expect(output).toContain('foo'); + }); + + it('runs successfully with --categories=all', async () => { + const {code} = await runCliProcess( + ['analyze', '--categories=all', '--log-level=error'], + tempDir + ); + expect(code).toBe(0); + }); +}); + +describe('migrate --categories', () => { + beforeAll(async () => { + const nodeModules = path.join(basicChalkFixture, 'node_modules'); + if (!existsSync(nodeModules)) { + execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'}); + } + }); + + it('exits 1 with helpful error for invalid --categories', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['migrate', '--categories=invalid'], + tempDir + ); + expect(code).toBe(1); + const output = stdout + stderr; + expect(output).toContain('Invalid categories'); + }); + + it('--all --dry-run with --categories=native runs to completion and only considers native manifest', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['migrate', '--all', '--dry-run', '--categories=native'], + basicChalkFixture + ); + expect(code).toBe(0); + const output = stdout + stderr; + expect(output).toContain('Migration complete'); + // Chalk is in preferred, not native; so with native-only we get 0 files migrated + expect(output).toContain('0 files migrated'); + }); +}); + describe('migrate --all', () => { beforeAll(async () => { const nodeModules = path.join(basicChalkFixture, 'node_modules'); diff --git a/src/test/custom-manifests.test.ts b/src/test/custom-manifests.test.ts index a233ce5..5d95f8d 100644 --- a/src/test/custom-manifests.test.ts +++ b/src/test/custom-manifests.test.ts @@ -124,4 +124,19 @@ describe('Custom Manifests', () => { expect(result.messages).toMatchSnapshot(); }); + + it('should use categories=all by default and produce same result with explicit --categories=all', async () => { + const customManifestPath = join( + __dirname, + '../../test/fixtures/custom-manifest.json' + ); + + context.options = {manifest: [customManifestPath]}; + const resultDefault = await runReplacements(context); + + context.options = {manifest: [customManifestPath], categories: 'all'}; + const resultExplicitAll = await runReplacements(context); + + expect(resultExplicitAll.messages).toEqual(resultDefault.messages); + }); }); diff --git a/src/types.ts b/src/types.ts index ac96d5c..a02c116 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,12 @@ import type {FileSystem} from './file-system.js'; import type {Codemod, CodemodOptions} from 'module-replacements-codemods'; import type {ParsedLockFile} from 'lockparse'; +import type {ParsedCategories} from './categories.js'; export interface Options { root?: string; manifest?: string[]; + categories?: ParsedCategories; } export interface StatLike {