Skip to content
Open
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
10 changes: 7 additions & 3 deletions src/analyze/replacements.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as replacements from 'module-replacements';
import type {
ManifestModule,
ModuleReplacement,
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, ModuleReplacement> = {
...replacements.all.replacements,
...baseManifest.replacements,
...customManifest.replacements
};

Expand Down
84 changes: 84 additions & 0 deletions src/categories.ts
Original file line number Diff line number Diff line change
@@ -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<Category, 'all'>;

export type ParsedCategories = 'all' | Set<CategoryKey>;

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<CategoryKey>();

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<CategoryKey, ManifestModule> = {
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;
}
6 changes: 6 additions & 0 deletions src/commands/analyze.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 17 additions & 2 deletions src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -49,6 +50,20 @@ export async function run(ctx: CommandContext<typeof meta>) {
prompts.intro('Analyzing...');
}

let parsedCategories: ReturnType<typeof parseCategories>;
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;
Expand All @@ -70,12 +85,12 @@ export async function run(ctx: CommandContext<typeof meta>) {
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;
Expand Down
6 changes: 6 additions & 0 deletions src/commands/migrate.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 21 additions & 4 deletions src/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof meta>) {
const [_commandName, ...targetModules] = ctx.positionals;
Expand All @@ -20,11 +21,27 @@ export async function run(ctx: CommandContext<typeof meta>) {

prompts.intro(`Migrating packages...`);

let parsedCategories: ReturnType<typeof parseCategories>;
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 = {
Expand All @@ -33,7 +50,7 @@ export async function run(ctx: CommandContext<typeof meta>) {
};

const fixableReplacementsTargets = new Set(
fixableReplacements
inScopeFixableReplacements
.filter((rep) => Object.hasOwn(dependencies, rep.from))
.map((rep) => rep.from)
);
Expand Down Expand Up @@ -64,7 +81,7 @@ export async function run(ctx: CommandContext<typeof meta>) {
let selectedReplacements: Replacement[];

if (all) {
selectedReplacements = fixableReplacements.filter((rep) =>
selectedReplacements = inScopeFixableReplacements.filter((rep) =>
fixableReplacementsTargets.has(rep.from)
);
} else {
Expand All @@ -85,7 +102,7 @@ export async function run(ctx: CommandContext<typeof meta>) {
return;
}

const replacement = fixableReplacements.find(
const replacement = inScopeFixableReplacements.find(
(rep) => rep.from === targetModule
);
if (!replacement) {
Expand Down
101 changes: 101 additions & 0 deletions src/test/categories.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
Loading
Loading