diff --git a/package-lock.json b/package-lock.json index ea9619d..de8c525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@clack/prompts": "^1.0.0", "@publint/pack": "^0.1.3", + "fast-wrap-ansi": "^0.2.0", "fdir": "^6.5.0", "gunshi": "^0.27.5", "lockparse": "^0.5.0", @@ -3362,6 +3363,30 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", diff --git a/package.json b/package.json index 706fd4e..42da504 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 56c8172..a3e5e44 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -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 { @@ -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( @@ -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; @@ -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); @@ -165,7 +173,8 @@ export async function runReplacements( result.messages.push({ severity: 'warning', score: 0, - message: fullMessage + message: fullMessage, + ...(fixableBy && {fixableBy}) }); } } diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index a8f6247..9c4d272 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -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']; @@ -32,7 +33,9 @@ const FAIL_THRESHOLD_RANK: Record = { }; export async function run(ctx: CommandContext) { - 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; @@ -120,6 +123,15 @@ export async function run(ctx: CommandContext) { // 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( @@ -130,7 +142,8 @@ export async function run(ctx: CommandContext) { 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 }); } @@ -141,7 +154,8 @@ export async function run(ctx: CommandContext) { 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 }); } @@ -152,12 +166,33 @@ export async function run(ctx: CommandContext) { 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!'); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index b81a7e8..efaec2c 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -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'), @@ -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', @@ -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}); @@ -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( @@ -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); } @@ -92,7 +112,10 @@ 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(); @@ -100,6 +123,13 @@ describe('CLI', () => { }); 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); @@ -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'], diff --git a/src/types.ts b/src/types.ts index 3c1e88d..ac96d5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 {