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
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"homepage": "https://github.com/e18e/cli#readme",
"dependencies": {
"@clack/prompts": "^1.0.0",
"fast-wrap-ansi": "^0.2.0",
"@publint/pack": "^0.1.3",
"fdir": "^6.5.0",
"gunshi": "^0.27.5",
Expand Down
17 changes: 13 additions & 4 deletions src/analyze/replacements.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as replacements from 'module-replacements';
import type {ManifestModule, ModuleReplacement} from 'module-replacements';
import type {ReportPluginResult, AnalysisContext} from '../types.js';
import {fixableReplacements} from '../commands/fixable-replacements.js';
import {getPackageJson} from '../utils/package-json.js';
import {resolve, dirname, basename} from 'node:path';
import {
Expand Down Expand Up @@ -108,6 +109,8 @@ export async function runReplacements(
...replacements.all.moduleReplacements
];

const fixableByMigrate = new Set(fixableReplacements.map((r) => r.from));

for (const name of Object.keys(packageJson.dependencies)) {
// Find replacement (custom replacements take precedence due to order)
const replacement = allReplacements.find(
Expand All @@ -118,18 +121,22 @@ export async function runReplacements(
continue;
}

const fixableBy = fixableByMigrate.has(name) ? 'migrate' : undefined;

// Handle each replacement type using the same logic for both custom and built-in
if (replacement.type === 'none') {
result.messages.push({
severity: 'warning',
score: 0,
message: `Module "${name}" can be removed, and native functionality used instead`
message: `Module "${name}" can be removed, and native functionality used instead`,
...(fixableBy && {fixableBy})
});
} else if (replacement.type === 'simple') {
result.messages.push({
severity: 'warning',
score: 0,
message: `Module "${name}" can be replaced. ${replacement.replacement}.`
message: `Module "${name}" can be replaced. ${replacement.replacement}.`,
...(fixableBy && {fixableBy})
});
} else if (replacement.type === 'native') {
const enginesNode = packageJson.engines?.node;
Expand All @@ -156,7 +163,8 @@ export async function runReplacements(
result.messages.push({
severity: 'warning',
score: 0,
message: fullMessage
message: fullMessage,
...(fixableBy && {fixableBy})
});
} else if (replacement.type === 'documented') {
const docUrl = getDocsUrl(replacement.docPath);
Expand All @@ -165,7 +173,8 @@ export async function runReplacements(
result.messages.push({
severity: 'warning',
score: 0,
message: fullMessage
message: fullMessage,
...(fixableBy && {fixableBy})
});
}
}
Expand Down
43 changes: 39 additions & 4 deletions src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {styleText} from 'node:util';
import {meta} from './analyze.meta.js';
import {report} from '../index.js';
import {enableDebug} from '../logger.js';
import {wrapAnsi} from 'fast-wrap-ansi';

function formatBytes(bytes: number) {
const units = ['B', 'KB', 'MB', 'GB'];
Expand Down Expand Up @@ -32,7 +33,9 @@ const FAIL_THRESHOLD_RANK: Record<string, number> = {
};

export async function run(ctx: CommandContext<typeof meta>) {
const [_commandName, providedPath] = ctx.positionals;
// Gunshi passes subcommand name as first positional; path is optional second
const providedPath =
ctx.positionals.length > 1 ? ctx.positionals[1] : undefined;
const logLevel = ctx.values['log-level'];
let root: string | undefined = undefined;

Expand Down Expand Up @@ -120,6 +123,15 @@ export async function run(ctx: CommandContext<typeof meta>) {

// Display tool analysis results
if (messages.length > 0) {
const width = process.stdout?.columns ?? 80;
const maxContentWidth = Math.max(20, width - 4);

const formatBulletMessage = (text: string, bullet: string) =>
wrapAnsi(text, maxContentWidth)
.split('\n')
.map((line, i) => (i === 0 ? ` ${bullet} ${line}` : ` ${line}`))
.join('\n');

const errorMessages = messages.filter((m) => m.severity === 'error');
const warningMessages = messages.filter((m) => m.severity === 'warning');
const suggestionMessages = messages.filter(
Expand All @@ -130,7 +142,8 @@ export async function run(ctx: CommandContext<typeof meta>) {
if (errorMessages.length > 0) {
prompts.log.message(styleText('red', 'Errors:'), {spacing: 0});
for (const msg of errorMessages) {
prompts.log.message(` ${styleText('red', '•')} ${msg.message}`, {
const bullet = styleText('red', '•');
prompts.log.message(formatBulletMessage(msg.message, bullet), {
spacing: 0
});
}
Expand All @@ -141,7 +154,8 @@ export async function run(ctx: CommandContext<typeof meta>) {
if (warningMessages.length > 0) {
prompts.log.message(styleText('yellow', 'Warnings:'), {spacing: 0});
for (const msg of warningMessages) {
prompts.log.message(` ${styleText('yellow', '•')} ${msg.message}`, {
const bullet = styleText('yellow', '•');
prompts.log.message(formatBulletMessage(msg.message, bullet), {
spacing: 0
});
}
Expand All @@ -152,12 +166,33 @@ export async function run(ctx: CommandContext<typeof meta>) {
if (suggestionMessages.length > 0) {
prompts.log.message(styleText('blue', 'Suggestions:'), {spacing: 0});
for (const msg of suggestionMessages) {
prompts.log.message(` ${styleText('blue', '•')} ${msg.message}`, {
const bullet = styleText('blue', '•');
prompts.log.message(formatBulletMessage(msg.message, bullet), {
spacing: 0
});
}
prompts.log.message('', {spacing: 0});
}

const errorCount = errorMessages.length;
const warningCount = warningMessages.length;
const suggestionCount = suggestionMessages.length;
const fixableCount = messages.filter(
(m) => m.fixableBy === 'migrate'
).length;
const parts: string[] = [];
if (errorCount > 0)
parts.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`);
if (warningCount > 0)
parts.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`);
if (suggestionCount > 0)
parts.push(
`${suggestionCount} suggestion${suggestionCount === 1 ? '' : 's'}`
);
let summary = parts.join(', ');
if (fixableCount > 0)
summary += ` (${fixableCount} fixable by \`npx @e18e/cli migrate\`)`;
prompts.log.message(styleText('dim', summary), {spacing: 0});
}
prompts.outro('Done!');

Expand Down
70 changes: 60 additions & 10 deletions src/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest';
import {spawn} from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs/promises';
import {createTempDir, cleanupTempDir, createTestPackage} from './utils.js';
import {existsSync} from 'node:fs';
import {execSync} from 'node:child_process';
import {
createTempDir,
cleanupTempDir,
createTestPackage,
createTestPackageWithDependencies
} from './utils.js';

let tempDir: string;
let fixableTempDir: string;
const stripVersion = (str: string): string =>
str.replace(
new RegExp(/\(cli v\d+\.\d+\.\d+(?:-\S+)?\)/, 'g'),
Expand All @@ -20,10 +28,8 @@ const basicChalkFixture = path.join(
);

beforeAll(async () => {
// Create a temporary directory for the test package
// Create temp dir for mock package (no fixable replacements)
tempDir = await createTempDir();

// Create a test package with some files
await createTestPackage(tempDir, {
name: 'mock-package',
version: '1.0.0',
Expand All @@ -33,14 +39,10 @@ beforeAll(async () => {
'some-dep': '1.0.0'
}
});

// Create a simple index.js file
await fs.writeFile(
path.join(tempDir, 'index.js'),
'console.log("Hello, world!");'
);

// Create node_modules with a dependency
const nodeModules = path.join(tempDir, 'node_modules');
await fs.mkdir(nodeModules, {recursive: true});
await fs.mkdir(path.join(nodeModules, 'some-dep'), {recursive: true});
Expand All @@ -52,10 +54,25 @@ beforeAll(async () => {
type: 'module'
})
);

// Create temp dir for fixable replacements (chalk)
fixableTempDir = await createTempDir();
await createTestPackageWithDependencies(
fixableTempDir,
{
name: 'foo',
version: '0.0.1',
type: 'module',
main: 'lib/main.js',
dependencies: {chalk: '^4.0.0'}
},
[{name: 'chalk', version: '4.1.2', type: 'module'}]
);
});

afterAll(async () => {
await cleanupTempDir(tempDir);
await cleanupTempDir(fixableTempDir);
});

function runCliProcess(
Expand All @@ -82,7 +99,10 @@ function runCliProcess(

describe('CLI', () => {
it('should run successfully with default options', async () => {
const {stdout, stderr, code} = await runCliProcess(['analyze'], tempDir);
const {stdout, stderr, code} = await runCliProcess(
['analyze', '--log-level=debug'],
tempDir
);
if (code !== 0) {
console.error('CLI Error:', stderr);
}
Expand All @@ -92,14 +112,24 @@ describe('CLI', () => {
});

it('should display package report', async () => {
const {stdout, stderr, code} = await runCliProcess(['analyze'], tempDir);
const {stdout, stderr, code} = await runCliProcess(
['analyze', '--log-level=debug'],
tempDir
);
expect(code).toBe(0);
expect(stripVersion(stdout)).toMatchSnapshot();
expect(normalizeStderr(stderr)).toMatchSnapshot();
});
});

describe('analyze exit codes', () => {
beforeAll(async () => {
const nodeModules = path.join(basicChalkFixture, 'node_modules');
if (!existsSync(nodeModules)) {
execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'});
}
});

it('exits 1 when path is not a directory', async () => {
const {code} = await runCliProcess(['analyze', '/nonexistent-path']);
expect(code).toBe(1);
Expand Down Expand Up @@ -127,7 +157,27 @@ describe('analyze exit codes', () => {
});
});

describe('analyze fixable summary', () => {
it('includes fixable-by-migrate summary when project has fixable replacement', async () => {
const {stdout, stderr, code} = await runCliProcess(
['analyze', '--log-level=debug'],
fixableTempDir
);
const output = stdout + stderr;
expect(code).toBe(0);
expect(output).toContain('fixable by');
expect(output).toContain('npx @e18e/cli migrate');
});
});

describe('migrate --all', () => {
beforeAll(async () => {
const nodeModules = path.join(basicChalkFixture, 'node_modules');
if (!existsSync(nodeModules)) {
execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'});
}
});

it('should migrate all fixable replacements with --all --dry-run when project has fixable deps', async () => {
const {stdout, stderr, code} = await runCliProcess(
['migrate', '--all', '--dry-run'],
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface Message {
severity: 'error' | 'warning' | 'suggestion';
score: number;
message: string;
/** Command that can fix this message (e.g. 'migrate'). */
fixableBy?: string;
}

export interface PackageJsonLike {
Expand Down
Loading