diff --git a/src/analyze/core-js.ts b/src/analyze/core-js.ts index 06831fa..d2a072a 100644 --- a/src/analyze/core-js.ts +++ b/src/analyze/core-js.ts @@ -65,7 +65,8 @@ export async function runCoreJsAnalysis( messages.push({ severity: 'warning', score: 0, - message: `Broad core-js import "${specifier}" in ${filePath} loads all polyfills at once. Import only the specific modules you need.` + file: filePath, + message: `Broad core-js import "${specifier}" loads all polyfills at once. Import only the specific modules you need.` }); } else if (specifier.startsWith('core-js/modules/')) { const moduleName = specifier.slice('core-js/modules/'.length); @@ -73,7 +74,8 @@ export async function runCoreJsAnalysis( messages.push({ severity: 'suggestion', score: 0, - message: `core-js polyfill "${moduleName}" imported in ${filePath} is unnecessary - your target engines already support this natively.` + file: filePath, + message: `core-js polyfill "${moduleName}" is unnecessary - your target engines already support this natively.` }); } } diff --git a/src/analyze/publint.ts b/src/analyze/publint.ts index 5425b4f..c1b722f 100644 --- a/src/analyze/publint.ts +++ b/src/analyze/publint.ts @@ -15,6 +15,7 @@ export async function runPublint( result.messages.push({ severity: problem.type, score: 0, + file: 'package.json', message: formatMessage(problem, publintResult.pkg) ?? '' }); } diff --git a/src/analyze/replacements.ts b/src/analyze/replacements.ts index 36916af..9ad7960 100644 --- a/src/analyze/replacements.ts +++ b/src/analyze/replacements.ts @@ -175,6 +175,7 @@ export async function runReplacements( result.messages.push({ severity: 'warning', score: 0, + file: 'package.json', message, ...(fixableBy && {fixableBy}) }); diff --git a/src/analyze/web-features-codemods.ts b/src/analyze/web-features-codemods.ts index c0c8ae3..39a1be3 100644 --- a/src/analyze/web-features-codemods.ts +++ b/src/analyze/web-features-codemods.ts @@ -60,7 +60,8 @@ export async function runWebFeaturesCodemodsAnalysis( messages.push({ severity: 'suggestion', score: 0, - message: `File "${filePath}" can use newer web features: ${matches.join(', ')}.` + file: filePath, + message: `Can use newer web features: ${matches.join(', ')}.` }); } } diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 09ad0f5..cdcf8ad 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -197,11 +197,31 @@ export async function run(ctx: CommandContext) { const width = process.stdout?.columns ?? 80; const maxContentWidth = Math.max(20, width - 4); - const formatBulletMessage = (text: string, bullet: string) => - wrapAnsi(text, maxContentWidth) + const labels = { + error: {text: 'error', color: 'red'}, + warning: {text: 'warning', color: 'yellow'}, + suggestion: {text: 'suggestion', color: 'blue'} + } as const; + + const labelWidth = Math.max( + ...Object.values(labels).map((l) => l.text.length) + ); + // Two leading spaces, then the severity column, then two spaces before text. + const gutter = 2 + labelWidth + 2; + + const formatBulletMessage = ( + text: string, + label: (typeof labels)[keyof typeof labels] + ) => { + const severity = styleText(label.color, label.text.padEnd(labelWidth)); + const indent = ' '.repeat(gutter); + return wrapAnsi(text, maxContentWidth) .split('\n') - .map((line, i) => (i === 0 ? ` ${bullet} ${line}` : ` ${line}`)) + .map((line, i) => + i === 0 ? ` ${severity} ${line}` : `${indent}${line}` + ) .join('\n'); + }; const errorMessages = visibleMessages.filter((m) => m.severity === 'error'); const warningMessages = visibleMessages.filter( @@ -211,38 +231,35 @@ export async function run(ctx: CommandContext) { (m) => m.severity === 'suggestion' ); - // Display errors - if (errorMessages.length > 0) { - prompts.log.message(styleText('red', 'Errors:'), {spacing: 0}); - for (const msg of errorMessages) { - const bullet = styleText('red', '•'); - prompts.log.message(formatBulletMessage(msg.message, bullet), { - spacing: 0 - }); - } - prompts.log.message('', {spacing: 0}); - } - - // Display warnings - if (warningMessages.length > 0) { - prompts.log.message(styleText('yellow', 'Warnings:'), {spacing: 0}); - for (const msg of warningMessages) { - const bullet = styleText('yellow', '•'); - prompts.log.message(formatBulletMessage(msg.message, bullet), { - spacing: 0 - }); + const groups = new Map(); + for (const msg of visibleMessages) { + const existing = groups.get(msg.file); + if (existing) { + existing.push(msg); + } else { + groups.set(msg.file, [msg]); } - prompts.log.message('', {spacing: 0}); } - - // Display suggestions - if (suggestionMessages.length > 0) { - prompts.log.message(styleText('blue', 'Suggestions:'), {spacing: 0}); - for (const msg of suggestionMessages) { - const bullet = styleText('blue', '•'); - prompts.log.message(formatBulletMessage(msg.message, bullet), { - spacing: 0 - }); + const sortedGroups = [...groups.entries()].sort(([a], [b]) => { + if (a === undefined) return 1; + if (b === undefined) return -1; + return a.localeCompare(b); + }); + + const severityOrder = {error: 0, warning: 1, suggestion: 2} as const; + + for (const [file, fileMessages] of sortedGroups) { + const group = fileMessages + .slice() + .sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + prompts.log.message(styleText('underline', file ?? 'general'), { + spacing: 0 + }); + for (const msg of group) { + prompts.log.message( + formatBulletMessage(msg.message, labels[msg.severity]), + {spacing: 0} + ); } prompts.log.message('', {spacing: 0}); } diff --git a/src/test/__snapshots__/custom-manifests.test.ts.snap b/src/test/__snapshots__/custom-manifests.test.ts.snap index f59f8c9..a6e999d 100644 --- a/src/test/__snapshots__/custom-manifests.test.ts.snap +++ b/src/test/__snapshots__/custom-manifests.test.ts.snap @@ -5,26 +5,31 @@ exports[`Custom Manifests > should handle invalid manifest files gracefully 1`] exports[`Custom Manifests > should load and use custom manifest files 1`] = ` [ { + "file": "package.json", "message": "Module "@e18e/fake-0" can be replaced with inline native syntax. Use picocolors, kleur, or native console styling. See more at https://github.com/e18e/module-replacements/blob/main/docs/modules/fake-0.md.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-1" can be replaced with inline native syntax. Use native JavaScript methods or specific lodash functions.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-2" can be replaced with native functionality. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See more at https://github.com/e18e/module-replacements/blob/main/docs/modules/request-alternatives.md.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-4" can be removed, and native functionality used instead", "score": 0, "severity": "warning", @@ -35,36 +40,43 @@ exports[`Custom Manifests > should load and use custom manifest files 1`] = ` exports[`Custom Manifests > should load multiple manifest files 1`] = ` [ { + "file": "package.json", "message": "Module "@e18e/fake-0" can be replaced with inline native syntax. Use picocolors, kleur, or native console styling. See more at https://github.com/e18e/module-replacements/blob/main/docs/modules/fake-0.md.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-1" can be replaced with inline native syntax. Use native JavaScript methods or specific lodash functions.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-2" can be replaced with native functionality. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See more at https://github.com/e18e/module-replacements/blob/main/docs/modules/request-alternatives.md.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-4" can be removed, and native functionality used instead", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-5" can be replaced with inline native syntax. Use Fastify, Koa, or native Node.js http module.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-6" can be removed, and native functionality used instead", "score": 0, "severity": "warning", @@ -76,26 +88,31 @@ exports[`Custom Manifests > should prioritize custom replacements over built-in { "withCustom": [ { + "file": "package.json", "message": "Module "@e18e/fake-0" can be replaced with inline native syntax. Use picocolors, kleur, or native console styling. See more at https://github.com/e18e/module-replacements/blob/main/docs/modules/fake-0.md.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-1" can be replaced with inline native syntax. Use native JavaScript methods or specific lodash functions.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-2" can be replaced with native functionality. Required Node >= 12.0.0. You can read more at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-3" can be replaced with a more performant alternative. See more at https://github.com/e18e/module-replacements/blob/main/docs/modules/request-alternatives.md.", "score": 0, "severity": "warning", }, { + "file": "package.json", "message": "Module "@e18e/fake-4" can be removed, and native functionality used instead", "score": 0, "severity": "warning", diff --git a/src/test/analyze/__snapshots__/web-features-codemods.test.ts.snap b/src/test/analyze/__snapshots__/web-features-codemods.test.ts.snap index 1dc544a..e11d21c 100644 --- a/src/test/analyze/__snapshots__/web-features-codemods.test.ts.snap +++ b/src/test/analyze/__snapshots__/web-features-codemods.test.ts.snap @@ -3,7 +3,8 @@ exports[`runWebFeaturesCodemodsAnalysis > handles a file with multiple matches 1`] = ` [ { - "message": "File "index.js" can use newer web features: arrayAt, exponentiation.", + "file": "index.js", + "message": "Can use newer web features: arrayAt, exponentiation.", "score": 0, "severity": "suggestion", }, @@ -15,7 +16,8 @@ exports[`runWebFeaturesCodemodsAnalysis > handles a file with no matches 1`] = ` exports[`runWebFeaturesCodemodsAnalysis > handles a file with one match 1`] = ` [ { - "message": "File "index.js" can use newer web features: arrayAt.", + "file": "index.js", + "message": "Can use newer web features: arrayAt.", "score": 0, "severity": "suggestion", }, @@ -25,7 +27,8 @@ exports[`runWebFeaturesCodemodsAnalysis > handles a file with one match 1`] = ` exports[`runWebFeaturesCodemodsAnalysis > handles multiple occurrences of the same match in one file 1`] = ` [ { - "message": "File "index.js" can use newer web features: arrayAt.", + "file": "index.js", + "message": "Can use newer web features: arrayAt.", "score": 0, "severity": "suggestion", }, @@ -37,7 +40,8 @@ exports[`runWebFeaturesCodemodsAnalysis > ignores a file path outside of the roo exports[`runWebFeaturesCodemodsAnalysis > respects the src option 1`] = ` [ { - "message": "File "src/index.js" can use newer web features: arrayAt.", + "file": "src/index.js", + "message": "Can use newer web features: arrayAt.", "score": 0, "severity": "suggestion", }, diff --git a/src/test/analyze/core-js.test.ts b/src/test/analyze/core-js.test.ts index c01b994..a6593d3 100644 --- a/src/test/analyze/core-js.test.ts +++ b/src/test/analyze/core-js.test.ts @@ -312,7 +312,7 @@ describe('runCoreJsAnalysis', () => { const result = await runCoreJsAnalysis(context); expect(result.messages).toHaveLength(1); - expect(result.messages[0]?.message).toContain('src'); + expect(result.messages[0]?.file).toContain('src'); }); it('scans multiple src globs when options.src has more than one entry', async () => { diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index f11f47d..902d2de 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -163,7 +163,7 @@ describe('analyze exit codes', () => { ); expect(code).toBe(0); const output = stdout + stderr; - expect(output).not.toContain('Warnings:'); + expect(output).not.toContain('warning'); expect(output).toMatch(/below --report-level error/); }); @@ -174,8 +174,8 @@ describe('analyze exit codes', () => { ); expect(code).toBe(1); const output = stdout + stderr; - expect(output).toContain('Warnings:'); - expect(output).not.toContain('Suggestions:'); + expect(output).toContain('warning'); + expect(output).not.toContain('suggestion'); }); it('--quiet hides non-errors like ESLint (default log-level still fails on warnings)', async () => { @@ -185,7 +185,7 @@ describe('analyze exit codes', () => { ); expect(code).toBe(1); const output = stdout + stderr; - expect(output).not.toContain('Warnings:'); + expect(output).not.toContain('warning'); expect(output).toContain('hidden by --quiet'); }); }); diff --git a/src/types.ts b/src/types.ts index a39af0c..7d0fbe9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,8 @@ export interface Message { severity: 'error' | 'warning' | 'suggestion'; score: number; message: string; + /** File the message relates to, relative to the project root. */ + file?: string; /** Command that can fix this message (e.g. 'migrate'). */ fixableBy?: string; }