From 2c1321dc167ccd7f61e4dfc47619629f5d81c298 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:40:40 -0600 Subject: [PATCH 01/12] feat: add summary for fixable replacements in analysis output --- src/analyze/replacements.ts | 16 +++-- src/commands/analyze.ts | 57 +++++++++++++++-- src/test/__snapshots__/cli.test.ts.snap | 64 +++++++++++-------- src/test/cli.test.ts | 13 ++++ src/types.ts | 4 ++ .../node_modules/chalk/package.json | 1 + test/fixtures/basic-chalk/package-lock.json | 18 ++++++ 7 files changed, 137 insertions(+), 36 deletions(-) create mode 100644 test/fixtures/basic-chalk/node_modules/chalk/package.json create mode 100644 test/fixtures/basic-chalk/package-lock.json diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 56c8172..4c4bc47 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -108,6 +108,8 @@ export async function runReplacements( ...replacements.all.moduleReplacements ]; + const fixableByMigrate = context.options?.fixableByMigrate; + for (const name of Object.keys(packageJson.dependencies)) { // Find replacement (custom replacements take precedence due to order) const replacement = allReplacements.find( @@ -118,18 +120,22 @@ export async function runReplacements( continue; } + const fixableBy = fixableByMigrate?.includes(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 +162,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 +172,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 4043437..d0fa217 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -3,6 +3,7 @@ import {promises as fsp, type Stats} from 'node:fs'; import * as prompts from '@clack/prompts'; import {styleText} from 'node:util'; import {meta} from './analyze.meta.js'; +import {fixableReplacements} from './fixable-replacements.js'; import {report} from '../index.js'; import {enableDebug} from '../logger.js'; @@ -19,6 +20,37 @@ function formatBytes(bytes: number) { return `${size.toFixed(1)} ${units[unitIndex]}`; } +const BULLET_INDENT = 4; // " • " = 4 visible chars before message +const CONTINUATION_INDENT = ' '; + +function wrapMessage( + text: string, + bulletPrefix: string, + width: number = process.stdout?.columns ?? 80 +): string { + const maxContentWidth = Math.max(20, width - BULLET_INDENT); + const words = text.split(/\s+/); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (next.length <= maxContentWidth) { + current = next; + } else { + if (current) lines.push(current); + current = word; + } + } + if (current) lines.push(current); + + return lines + .map((line, i) => + i === 0 ? `${bulletPrefix}${line}` : `${CONTINUATION_INDENT}${line}` + ) + .join('\n'); +} + export async function run(ctx: CommandContext) { const [_commandName, providedPath] = ctx.positionals; const logLevel = ctx.values['log-level']; @@ -53,7 +85,8 @@ export async function run(ctx: CommandContext) { const {stats, messages} = await report({ root, - manifest: customManifests + manifest: customManifests, + fixableByMigrate: fixableReplacements.map((r) => r.from) }); prompts.log.info('Summary'); @@ -118,7 +151,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(wrapMessage(msg.message, ` ${bullet} `), { spacing: 0 }); } @@ -129,7 +163,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(wrapMessage(msg.message, ` ${bullet} `), { spacing: 0 }); } @@ -140,12 +175,26 @@ 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(wrapMessage(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/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index bd6e3f4..d76b67b 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -3,43 +3,51 @@ exports[`CLI > should display package report 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should display package report 2`] = `""`; +exports[`CLI > should display package report 2`] = ` +"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use \`node --trace-warnings ...\` to show where the warning was created) +" +`; exports[`CLI > should run successfully with default options 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should run successfully with default options 2`] = `""`; +exports[`CLI > should run successfully with default options 2`] = ` +"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use \`node --trace-warnings ...\` to show where the warning was created) +" +`; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index e1e930a..28e6c41 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -99,6 +99,19 @@ const basicChalkFixture = path.join( '../../test/fixtures/basic-chalk' ); +describe('analyze fixable summary', () => { + it('includes fixable-by-migrate summary when project has fixable replacement', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['analyze'], + basicChalkFixture + ); + const output = stdout + stderr; + expect(code).toBe(0); + expect(output).toContain('fixable by'); + expect(output).toContain('npx @e18e/cli migrate'); + }); +}); + describe('migrate --all', () => { it('should migrate all fixable replacements with --all --dry-run when project has fixable deps', async () => { const {stdout, stderr, code} = await runCliProcess( diff --git a/src/types.ts b/src/types.ts index 3c1e88d..d388775 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,8 @@ import type {ParsedLockFile} from 'lockparse'; export interface Options { root?: string; manifest?: string[]; + /** Package names that have a codemod (fixable by migrate). */ + fixableByMigrate?: string[]; } export interface StatLike { @@ -30,6 +32,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 { diff --git a/test/fixtures/basic-chalk/node_modules/chalk/package.json b/test/fixtures/basic-chalk/node_modules/chalk/package.json new file mode 100644 index 0000000..b38e5c7 --- /dev/null +++ b/test/fixtures/basic-chalk/node_modules/chalk/package.json @@ -0,0 +1 @@ +{"name":"chalk","version":"4.1.2","type":"module"} diff --git a/test/fixtures/basic-chalk/package-lock.json b/test/fixtures/basic-chalk/package-lock.json new file mode 100644 index 0000000..5daa36e --- /dev/null +++ b/test/fixtures/basic-chalk/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "foo", + "version": "0.0.1", + "lockfileVersion": 3, + "packages": { + "": { + "name": "foo", + "version": "0.0.1", + "dependencies": { + "chalk": "^4.0.0" + } + }, + "node_modules/chalk": { + "name": "chalk", + "version": "4.1.2" + } + } +} From c4ea6c5e19d8953158254c5e58af50ae1ae7beb2 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:42:51 -0600 Subject: [PATCH 02/12] format --- src/commands/analyze.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index d0fa217..6fee4de 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -186,11 +186,18 @@ export async function run(ctx: CommandContext) { const errorCount = errorMessages.length; const warningCount = warningMessages.length; const suggestionCount = suggestionMessages.length; - const fixableCount = messages.filter((m) => m.fixableBy === 'migrate').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'}`); + 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\`)`; From 95af9ee2004b74c170182c145d63c01a0d47e9e0 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:44:00 -0600 Subject: [PATCH 03/12] update: snapshots --- src/test/__snapshots__/cli.test.ts.snap | 64 +++++++++++-------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index d76b67b..bd6e3f4 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -3,51 +3,43 @@ exports[`CLI > should display package report 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should display package report 2`] = ` -"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. -(Use \`node --trace-warnings ...\` to show where the warning was created) -" -`; +exports[`CLI > should display package report 2`] = `""`; exports[`CLI > should run successfully with default options 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should run successfully with default options 2`] = ` -"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. -(Use \`node --trace-warnings ...\` to show where the warning was created) -" -`; +exports[`CLI > should run successfully with default options 2`] = `""`; From ea8ead4dc5a4166b105ebf90e327d959cfecdce6 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:04:36 -0600 Subject: [PATCH 04/12] refactor: use fast-wrap-ansi for message wrapping --- package-lock.json | 25 ++++++++++ package.json | 1 + src/commands/analyze.ts | 47 +++++------------- src/test/__snapshots__/cli.test.ts.snap | 64 ++++++++++++++----------- 4 files changed, 75 insertions(+), 62 deletions(-) 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/commands/analyze.ts b/src/commands/analyze.ts index 6fee4de..37f3345 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -6,6 +6,7 @@ import {meta} from './analyze.meta.js'; import {fixableReplacements} from './fixable-replacements.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']; @@ -20,37 +21,6 @@ function formatBytes(bytes: number) { return `${size.toFixed(1)} ${units[unitIndex]}`; } -const BULLET_INDENT = 4; // " • " = 4 visible chars before message -const CONTINUATION_INDENT = ' '; - -function wrapMessage( - text: string, - bulletPrefix: string, - width: number = process.stdout?.columns ?? 80 -): string { - const maxContentWidth = Math.max(20, width - BULLET_INDENT); - const words = text.split(/\s+/); - const lines: string[] = []; - let current = ''; - - for (const word of words) { - const next = current ? `${current} ${word}` : word; - if (next.length <= maxContentWidth) { - current = next; - } else { - if (current) lines.push(current); - current = word; - } - } - if (current) lines.push(current); - - return lines - .map((line, i) => - i === 0 ? `${bulletPrefix}${line}` : `${CONTINUATION_INDENT}${line}` - ) - .join('\n'); -} - export async function run(ctx: CommandContext) { const [_commandName, providedPath] = ctx.positionals; const logLevel = ctx.values['log-level']; @@ -141,6 +111,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( @@ -152,7 +131,7 @@ export async function run(ctx: CommandContext) { prompts.log.message(styleText('red', 'Errors:'), {spacing: 0}); for (const msg of errorMessages) { const bullet = styleText('red', '•'); - prompts.log.message(wrapMessage(msg.message, ` ${bullet} `), { + prompts.log.message(formatBulletMessage(msg.message, bullet), { spacing: 0 }); } @@ -164,7 +143,7 @@ export async function run(ctx: CommandContext) { prompts.log.message(styleText('yellow', 'Warnings:'), {spacing: 0}); for (const msg of warningMessages) { const bullet = styleText('yellow', '•'); - prompts.log.message(wrapMessage(msg.message, ` ${bullet} `), { + prompts.log.message(formatBulletMessage(msg.message, bullet), { spacing: 0 }); } @@ -176,7 +155,7 @@ export async function run(ctx: CommandContext) { prompts.log.message(styleText('blue', 'Suggestions:'), {spacing: 0}); for (const msg of suggestionMessages) { const bullet = styleText('blue', '•'); - prompts.log.message(wrapMessage(msg.message, ` ${bullet} `), { + prompts.log.message(formatBulletMessage(msg.message, bullet), { spacing: 0 }); } diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index bd6e3f4..d76b67b 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -3,43 +3,51 @@ exports[`CLI > should display package report 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should display package report 2`] = `""`; +exports[`CLI > should display package report 2`] = ` +"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use \`node --trace-warnings ...\` to show where the warning was created) +" +`; exports[`CLI > should run successfully with default options 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should run successfully with default options 2`] = `""`; +exports[`CLI > should run successfully with default options 2`] = ` +"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. +(Use \`node --trace-warnings ...\` to show where the warning was created) +" +`; From df1449cfc41468d82077fc8c48167b987f2cef02 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:06:22 -0600 Subject: [PATCH 05/12] update: snapshots --- src/test/__snapshots__/cli.test.ts.snap | 64 +++++++++++-------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index d76b67b..bd6e3f4 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -3,51 +3,43 @@ exports[`CLI > should display package report 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should display package report 2`] = ` -"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. -(Use \`node --trace-warnings ...\` to show where the warning was created) -" -`; +exports[`CLI > should display package report 2`] = `""`; exports[`CLI > should run successfully with default options 1`] = ` "e18e (cli ) -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; -exports[`CLI > should run successfully with default options 2`] = ` -"(node:) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set. -(Use \`node --trace-warnings ...\` to show where the warning was created) -" -`; +exports[`CLI > should run successfully with default options 2`] = `""`; From 05f694380ef116655edf99a96a85a0fc364aefcd Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:51:33 -0600 Subject: [PATCH 06/12] refactor: import fixableReplacements instead of passing via options --- src/analyze/replacements.ts | 7 +++++-- src/commands/analyze.ts | 4 +--- src/types.ts | 2 -- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 4c4bc47..536a259 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,7 +109,9 @@ export async function runReplacements( ...replacements.all.moduleReplacements ]; - const fixableByMigrate = context.options?.fixableByMigrate; + 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) @@ -120,7 +123,7 @@ export async function runReplacements( continue; } - const fixableBy = fixableByMigrate?.includes(name) ? 'migrate' : undefined; + 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') { diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 37f3345..59e2ba2 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -3,7 +3,6 @@ import {promises as fsp, type Stats} from 'node:fs'; import * as prompts from '@clack/prompts'; import {styleText} from 'node:util'; import {meta} from './analyze.meta.js'; -import {fixableReplacements} from './fixable-replacements.js'; import {report} from '../index.js'; import {enableDebug} from '../logger.js'; import {wrapAnsi} from 'fast-wrap-ansi'; @@ -55,8 +54,7 @@ export async function run(ctx: CommandContext) { const {stats, messages} = await report({ root, - manifest: customManifests, - fixableByMigrate: fixableReplacements.map((r) => r.from) + manifest: customManifests }); prompts.log.info('Summary'); diff --git a/src/types.ts b/src/types.ts index d388775..ac96d5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,8 +5,6 @@ import type {ParsedLockFile} from 'lockparse'; export interface Options { root?: string; manifest?: string[]; - /** Package names that have a codemod (fixable by migrate). */ - fixableByMigrate?: string[]; } export interface StatLike { From 33601896ecf225e113a1a26e8223181c458aa974 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:52:38 -0600 Subject: [PATCH 07/12] format --- src/analyze/replacements.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 536a259..a3e5e44 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -109,9 +109,7 @@ export async function runReplacements( ...replacements.all.moduleReplacements ]; - const fixableByMigrate = new Set( - fixableReplacements.map((r) => r.from) - ); + 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) From 831844870e619dd4f4a58f95a681a4a1385f29a5 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:19:44 -0600 Subject: [PATCH 08/12] revert: use tempDir for fixable summary test --- src/test/cli.test.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 28e6c41..84f8c83 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -2,7 +2,12 @@ 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 { + createTempDir, + cleanupTempDir, + createTestPackage, + createTestPackageWithDependencies +} from './utils.js'; let tempDir: string; const stripVersion = (str: string): string => @@ -94,16 +99,36 @@ describe('CLI', () => { }); }); +let fixableTempDir: string; const basicChalkFixture = path.join( __dirname, '../../test/fixtures/basic-chalk' ); describe('analyze fixable summary', () => { + beforeAll(async () => { + 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(fixableTempDir); + }); + it('includes fixable-by-migrate summary when project has fixable replacement', async () => { const {stdout, stderr, code} = await runCliProcess( ['analyze'], - basicChalkFixture + fixableTempDir ); const output = stdout + stderr; expect(code).toBe(0); From 4593001399d060c1bdbfed714ba47574a1890394 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:49:07 -0600 Subject: [PATCH 09/12] format --- src/test/cli.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index c75c5a1..b1d553a 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -153,10 +153,7 @@ describe('analyze fixable summary', () => { }); it('includes fixable-by-migrate summary when project has fixable replacement', async () => { - const {stdout, stderr, code} = await runCliProcess( - ['analyze'], - tempDir - ); + const {stdout, stderr, code} = await runCliProcess(['analyze'], tempDir); const output = stdout + stderr; expect(code).toBe(0); expect(output).toContain('fixable by'); From a52fab242fd970b099d0c9cac7a08f46757cd462 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:04:36 -0600 Subject: [PATCH 10/12] fix: update analyze command to handle optional path and improve CLI test logging --- src/commands/analyze.ts | 4 +- src/test/__snapshots__/cli.test.ts.snap | 60 ++++++++--------- src/test/cli.test.ts | 85 +++++++++++++++---------- 3 files changed, 85 insertions(+), 64 deletions(-) diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 57c5d6a..9c4d272 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -33,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; diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index bd6e3f4..5718d6d 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -1,21 +1,21 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`CLI > should display package report 1`] = ` -"e18e (cli ) - -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +"e18e (cli ) + +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; @@ -23,21 +23,21 @@ exports[`CLI > should display package report 1`] = ` exports[`CLI > should display package report 2`] = `""`; exports[`CLI > should run successfully with default options 1`] = ` -"e18e (cli ) - -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +"e18e (cli ) + +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index b1d553a..cce8ba3 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -2,6 +2,8 @@ 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 {existsSync} from 'node:fs'; +import {stripVTControlCharacters} from 'node:util'; import { createTempDir, cleanupTempDir, @@ -10,12 +12,14 @@ import { } 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'), '(cli )' ); +const stripAnsi = (str: string): string => stripVTControlCharacters(str); const normalizeStderr = (str: string): string => str.replace(/\(node:\d+\)/g, '(node:)'); @@ -25,10 +29,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', @@ -38,14 +40,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}); @@ -57,10 +55,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( @@ -87,24 +100,38 @@ 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); } expect(code).toBe(0); - expect(stripVersion(stdout)).toMatchSnapshot(); - expect(normalizeStderr(stderr)).toMatchSnapshot(); + expect(stripAnsi(stripVersion(stdout))).toMatchSnapshot(); + expect(stripAnsi(normalizeStderr(stderr))).toMatchSnapshot(); }); 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(); + expect(stripAnsi(stripVersion(stdout))).toMatchSnapshot(); + expect(stripAnsi(normalizeStderr(stderr))).toMatchSnapshot(); }); }); describe('analyze exit codes', () => { + beforeAll(async () => { + const nodeModules = path.join(basicChalkFixture, 'node_modules'); + if (!existsSync(nodeModules)) { + const {execSync} = await import('node:child_process'); + 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); @@ -133,27 +160,11 @@ describe('analyze exit codes', () => { }); describe('analyze fixable summary', () => { - beforeAll(async () => { - tempDir = await createTempDir(); - await createTestPackageWithDependencies( - tempDir, - { - 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); - }); - it('includes fixable-by-migrate summary when project has fixable replacement', async () => { - const {stdout, stderr, code} = await runCliProcess(['analyze'], tempDir); + const {stdout, stderr, code} = await runCliProcess( + ['analyze', '--log-level=debug'], + fixableTempDir + ); const output = stdout + stderr; expect(code).toBe(0); expect(output).toContain('fixable by'); @@ -162,6 +173,14 @@ describe('analyze fixable summary', () => { }); describe('migrate --all', () => { + beforeAll(async () => { + const nodeModules = path.join(basicChalkFixture, 'node_modules'); + if (!existsSync(nodeModules)) { + const {execSync} = await import('node:child_process'); + 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'], From ee785f5715916b0b99f2c0d55fcb07c423d150d3 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:58:36 -0600 Subject: [PATCH 11/12] test: preserve ANSI colors in CLI snapshots --- src/test/__snapshots__/cli.test.ts.snap | 60 ++++++++++++------------- src/test/cli.test.ts | 10 ++--- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/test/__snapshots__/cli.test.ts.snap b/src/test/__snapshots__/cli.test.ts.snap index 5718d6d..bd6e3f4 100644 --- a/src/test/__snapshots__/cli.test.ts.snap +++ b/src/test/__snapshots__/cli.test.ts.snap @@ -1,21 +1,21 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`CLI > should display package report 1`] = ` -"e18e (cli ) - -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +"e18e (cli ) + +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; @@ -23,21 +23,21 @@ exports[`CLI > should display package report 1`] = ` exports[`CLI > should display package report 2`] = `""`; exports[`CLI > should run successfully with default options 1`] = ` -"e18e (cli ) - -┌ Analyzing... -│ -● Summary -│ Package Name mock-package -│ Version 1.0.0 -│ Install Size 53.0 B -│ Dependencies 1 (1 production, 0 development) -│ Duplicate Dependency Count 0 -│ -● Results: -│ -│ -└ Done! +"e18e (cli ) + +┌ Analyzing... +│ +● Summary +│ Package Name mock-package +│ Version 1.0.0 +│ Install Size 53.0 B +│ Dependencies 1 (1 production, 0 development) +│ Duplicate Dependency Count 0 +│ +● Results: +│ +│ +└ Done! " `; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index cce8ba3..d59dea8 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -3,7 +3,6 @@ import {spawn} from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs/promises'; import {existsSync} from 'node:fs'; -import {stripVTControlCharacters} from 'node:util'; import { createTempDir, cleanupTempDir, @@ -19,7 +18,6 @@ const stripVersion = (str: string): string => '(cli )' ); -const stripAnsi = (str: string): string => stripVTControlCharacters(str); const normalizeStderr = (str: string): string => str.replace(/\(node:\d+\)/g, '(node:)'); @@ -108,8 +106,8 @@ describe('CLI', () => { console.error('CLI Error:', stderr); } expect(code).toBe(0); - expect(stripAnsi(stripVersion(stdout))).toMatchSnapshot(); - expect(stripAnsi(normalizeStderr(stderr))).toMatchSnapshot(); + expect(stripVersion(stdout)).toMatchSnapshot(); + expect(normalizeStderr(stderr)).toMatchSnapshot(); }); it('should display package report', async () => { @@ -118,8 +116,8 @@ describe('CLI', () => { tempDir ); expect(code).toBe(0); - expect(stripAnsi(stripVersion(stdout))).toMatchSnapshot(); - expect(stripAnsi(normalizeStderr(stderr))).toMatchSnapshot(); + expect(stripVersion(stdout)).toMatchSnapshot(); + expect(normalizeStderr(stderr)).toMatchSnapshot(); }); }); From d5d9bacb4e4fdb8a4c9a4924248604ff9ec805fe Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:12:58 -0600 Subject: [PATCH 12/12] cleanup --- src/test/cli.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index d59dea8..efaec2c 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -3,6 +3,7 @@ import {spawn} from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs/promises'; import {existsSync} from 'node:fs'; +import {execSync} from 'node:child_process'; import { createTempDir, cleanupTempDir, @@ -125,7 +126,6 @@ describe('analyze exit codes', () => { beforeAll(async () => { const nodeModules = path.join(basicChalkFixture, 'node_modules'); if (!existsSync(nodeModules)) { - const {execSync} = await import('node:child_process'); execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'}); } }); @@ -174,7 +174,6 @@ describe('migrate --all', () => { beforeAll(async () => { const nodeModules = path.join(basicChalkFixture, 'node_modules'); if (!existsSync(nodeModules)) { - const {execSync} = await import('node:child_process'); execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'}); } });