Skip to content

Commit e2c9c03

Browse files
authored
feat: add summary for fixable replacements in analysis output (#164)
* feat: add summary for fixable replacements in analysis output * format * update: snapshots * refactor: use fast-wrap-ansi for message wrapping * update: snapshots * refactor: import fixableReplacements instead of passing via options * format * revert: use tempDir for fixable summary test * format * fix: update analyze command to handle optional path and improve CLI test logging * test: preserve ANSI colors in CLI snapshots * cleanup
1 parent 4ae0903 commit e2c9c03

6 files changed

Lines changed: 140 additions & 18 deletions

File tree

package-lock.json

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"homepage": "https://github.com/e18e/cli#readme",
4848
"dependencies": {
4949
"@clack/prompts": "^1.0.0",
50+
"fast-wrap-ansi": "^0.2.0",
5051
"@publint/pack": "^0.1.3",
5152
"fdir": "^6.5.0",
5253
"gunshi": "^0.27.5",

src/analyze/replacements.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as replacements from 'module-replacements';
22
import type {ManifestModule, ModuleReplacement} from 'module-replacements';
33
import type {ReportPluginResult, AnalysisContext} from '../types.js';
4+
import {fixableReplacements} from '../commands/fixable-replacements.js';
45
import {getPackageJson} from '../utils/package-json.js';
56
import {resolve, dirname, basename} from 'node:path';
67
import {
@@ -108,6 +109,8 @@ export async function runReplacements(
108109
...replacements.all.moduleReplacements
109110
];
110111

112+
const fixableByMigrate = new Set(fixableReplacements.map((r) => r.from));
113+
111114
for (const name of Object.keys(packageJson.dependencies)) {
112115
// Find replacement (custom replacements take precedence due to order)
113116
const replacement = allReplacements.find(
@@ -118,18 +121,22 @@ export async function runReplacements(
118121
continue;
119122
}
120123

124+
const fixableBy = fixableByMigrate.has(name) ? 'migrate' : undefined;
125+
121126
// Handle each replacement type using the same logic for both custom and built-in
122127
if (replacement.type === 'none') {
123128
result.messages.push({
124129
severity: 'warning',
125130
score: 0,
126-
message: `Module "${name}" can be removed, and native functionality used instead`
131+
message: `Module "${name}" can be removed, and native functionality used instead`,
132+
...(fixableBy && {fixableBy})
127133
});
128134
} else if (replacement.type === 'simple') {
129135
result.messages.push({
130136
severity: 'warning',
131137
score: 0,
132-
message: `Module "${name}" can be replaced. ${replacement.replacement}.`
138+
message: `Module "${name}" can be replaced. ${replacement.replacement}.`,
139+
...(fixableBy && {fixableBy})
133140
});
134141
} else if (replacement.type === 'native') {
135142
const enginesNode = packageJson.engines?.node;
@@ -156,7 +163,8 @@ export async function runReplacements(
156163
result.messages.push({
157164
severity: 'warning',
158165
score: 0,
159-
message: fullMessage
166+
message: fullMessage,
167+
...(fixableBy && {fixableBy})
160168
});
161169
} else if (replacement.type === 'documented') {
162170
const docUrl = getDocsUrl(replacement.docPath);
@@ -165,7 +173,8 @@ export async function runReplacements(
165173
result.messages.push({
166174
severity: 'warning',
167175
score: 0,
168-
message: fullMessage
176+
message: fullMessage,
177+
...(fixableBy && {fixableBy})
169178
});
170179
}
171180
}

src/commands/analyze.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {styleText} from 'node:util';
55
import {meta} from './analyze.meta.js';
66
import {report} from '../index.js';
77
import {enableDebug} from '../logger.js';
8+
import {wrapAnsi} from 'fast-wrap-ansi';
89

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

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

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

121124
// Display tool analysis results
122125
if (messages.length > 0) {
126+
const width = process.stdout?.columns ?? 80;
127+
const maxContentWidth = Math.max(20, width - 4);
128+
129+
const formatBulletMessage = (text: string, bullet: string) =>
130+
wrapAnsi(text, maxContentWidth)
131+
.split('\n')
132+
.map((line, i) => (i === 0 ? ` ${bullet} ${line}` : ` ${line}`))
133+
.join('\n');
134+
123135
const errorMessages = messages.filter((m) => m.severity === 'error');
124136
const warningMessages = messages.filter((m) => m.severity === 'warning');
125137
const suggestionMessages = messages.filter(
@@ -130,7 +142,8 @@ export async function run(ctx: CommandContext<typeof meta>) {
130142
if (errorMessages.length > 0) {
131143
prompts.log.message(styleText('red', 'Errors:'), {spacing: 0});
132144
for (const msg of errorMessages) {
133-
prompts.log.message(` ${styleText('red', '•')} ${msg.message}`, {
145+
const bullet = styleText('red', '•');
146+
prompts.log.message(formatBulletMessage(msg.message, bullet), {
134147
spacing: 0
135148
});
136149
}
@@ -141,7 +154,8 @@ export async function run(ctx: CommandContext<typeof meta>) {
141154
if (warningMessages.length > 0) {
142155
prompts.log.message(styleText('yellow', 'Warnings:'), {spacing: 0});
143156
for (const msg of warningMessages) {
144-
prompts.log.message(` ${styleText('yellow', '•')} ${msg.message}`, {
157+
const bullet = styleText('yellow', '•');
158+
prompts.log.message(formatBulletMessage(msg.message, bullet), {
145159
spacing: 0
146160
});
147161
}
@@ -152,12 +166,33 @@ export async function run(ctx: CommandContext<typeof meta>) {
152166
if (suggestionMessages.length > 0) {
153167
prompts.log.message(styleText('blue', 'Suggestions:'), {spacing: 0});
154168
for (const msg of suggestionMessages) {
155-
prompts.log.message(` ${styleText('blue', '•')} ${msg.message}`, {
169+
const bullet = styleText('blue', '•');
170+
prompts.log.message(formatBulletMessage(msg.message, bullet), {
156171
spacing: 0
157172
});
158173
}
159174
prompts.log.message('', {spacing: 0});
160175
}
176+
177+
const errorCount = errorMessages.length;
178+
const warningCount = warningMessages.length;
179+
const suggestionCount = suggestionMessages.length;
180+
const fixableCount = messages.filter(
181+
(m) => m.fixableBy === 'migrate'
182+
).length;
183+
const parts: string[] = [];
184+
if (errorCount > 0)
185+
parts.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`);
186+
if (warningCount > 0)
187+
parts.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`);
188+
if (suggestionCount > 0)
189+
parts.push(
190+
`${suggestionCount} suggestion${suggestionCount === 1 ? '' : 's'}`
191+
);
192+
let summary = parts.join(', ');
193+
if (fixableCount > 0)
194+
summary += ` (${fixableCount} fixable by \`npx @e18e/cli migrate\`)`;
195+
prompts.log.message(styleText('dim', summary), {spacing: 0});
161196
}
162197
prompts.outro('Done!');
163198

src/test/cli.test.ts

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest';
22
import {spawn} from 'node:child_process';
33
import path from 'node:path';
44
import fs from 'node:fs/promises';
5-
import {createTempDir, cleanupTempDir, createTestPackage} from './utils.js';
5+
import {existsSync} from 'node:fs';
6+
import {execSync} from 'node:child_process';
7+
import {
8+
createTempDir,
9+
cleanupTempDir,
10+
createTestPackage,
11+
createTestPackageWithDependencies
12+
} from './utils.js';
613

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

2230
beforeAll(async () => {
23-
// Create a temporary directory for the test package
31+
// Create temp dir for mock package (no fixable replacements)
2432
tempDir = await createTempDir();
25-
26-
// Create a test package with some files
2733
await createTestPackage(tempDir, {
2834
name: 'mock-package',
2935
version: '1.0.0',
@@ -33,14 +39,10 @@ beforeAll(async () => {
3339
'some-dep': '1.0.0'
3440
}
3541
});
36-
37-
// Create a simple index.js file
3842
await fs.writeFile(
3943
path.join(tempDir, 'index.js'),
4044
'console.log("Hello, world!");'
4145
);
42-
43-
// Create node_modules with a dependency
4446
const nodeModules = path.join(tempDir, 'node_modules');
4547
await fs.mkdir(nodeModules, {recursive: true});
4648
await fs.mkdir(path.join(nodeModules, 'some-dep'), {recursive: true});
@@ -52,10 +54,25 @@ beforeAll(async () => {
5254
type: 'module'
5355
})
5456
);
57+
58+
// Create temp dir for fixable replacements (chalk)
59+
fixableTempDir = await createTempDir();
60+
await createTestPackageWithDependencies(
61+
fixableTempDir,
62+
{
63+
name: 'foo',
64+
version: '0.0.1',
65+
type: 'module',
66+
main: 'lib/main.js',
67+
dependencies: {chalk: '^4.0.0'}
68+
},
69+
[{name: 'chalk', version: '4.1.2', type: 'module'}]
70+
);
5571
});
5672

5773
afterAll(async () => {
5874
await cleanupTempDir(tempDir);
75+
await cleanupTempDir(fixableTempDir);
5976
});
6077

6178
function runCliProcess(
@@ -82,7 +99,10 @@ function runCliProcess(
8299

83100
describe('CLI', () => {
84101
it('should run successfully with default options', async () => {
85-
const {stdout, stderr, code} = await runCliProcess(['analyze'], tempDir);
102+
const {stdout, stderr, code} = await runCliProcess(
103+
['analyze', '--log-level=debug'],
104+
tempDir
105+
);
86106
if (code !== 0) {
87107
console.error('CLI Error:', stderr);
88108
}
@@ -92,14 +112,24 @@ describe('CLI', () => {
92112
});
93113

94114
it('should display package report', async () => {
95-
const {stdout, stderr, code} = await runCliProcess(['analyze'], tempDir);
115+
const {stdout, stderr, code} = await runCliProcess(
116+
['analyze', '--log-level=debug'],
117+
tempDir
118+
);
96119
expect(code).toBe(0);
97120
expect(stripVersion(stdout)).toMatchSnapshot();
98121
expect(normalizeStderr(stderr)).toMatchSnapshot();
99122
});
100123
});
101124

102125
describe('analyze exit codes', () => {
126+
beforeAll(async () => {
127+
const nodeModules = path.join(basicChalkFixture, 'node_modules');
128+
if (!existsSync(nodeModules)) {
129+
execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'});
130+
}
131+
});
132+
103133
it('exits 1 when path is not a directory', async () => {
104134
const {code} = await runCliProcess(['analyze', '/nonexistent-path']);
105135
expect(code).toBe(1);
@@ -127,7 +157,27 @@ describe('analyze exit codes', () => {
127157
});
128158
});
129159

160+
describe('analyze fixable summary', () => {
161+
it('includes fixable-by-migrate summary when project has fixable replacement', async () => {
162+
const {stdout, stderr, code} = await runCliProcess(
163+
['analyze', '--log-level=debug'],
164+
fixableTempDir
165+
);
166+
const output = stdout + stderr;
167+
expect(code).toBe(0);
168+
expect(output).toContain('fixable by');
169+
expect(output).toContain('npx @e18e/cli migrate');
170+
});
171+
});
172+
130173
describe('migrate --all', () => {
174+
beforeAll(async () => {
175+
const nodeModules = path.join(basicChalkFixture, 'node_modules');
176+
if (!existsSync(nodeModules)) {
177+
execSync('npm install', {cwd: basicChalkFixture, stdio: 'pipe'});
178+
}
179+
});
180+
131181
it('should migrate all fixable replacements with --all --dry-run when project has fixable deps', async () => {
132182
const {stdout, stderr, code} = await runCliProcess(
133183
['migrate', '--all', '--dry-run'],

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface Message {
3030
severity: 'error' | 'warning' | 'suggestion';
3131
score: number;
3232
message: string;
33+
/** Command that can fix this message (e.g. 'migrate'). */
34+
fixableBy?: string;
3335
}
3436

3537
export interface PackageJsonLike {

0 commit comments

Comments
 (0)