diff --git a/src/commands/analyze.meta.ts b/src/commands/analyze.meta.ts index 8e9717a..4744a99 100644 --- a/src/commands/analyze.meta.ts +++ b/src/commands/analyze.meta.ts @@ -7,7 +7,20 @@ export const meta = { choices: ['debug', 'info', 'warn', 'error'], default: 'info', description: - 'Set the log level and the minimum severity that causes a non-zero exit code (debug | info | warn | error)' + 'Minimum severity for a non-zero exit code, and enables debug logging when set to debug (debug | info | warn | error)' + }, + quiet: { + type: 'boolean', + default: false, + description: + 'Only show errors in Results and JSON messages (same idea as ESLint --quiet). Overrides --report-level.' + }, + 'report-level': { + type: 'enum', + choices: ['auto', 'debug', 'info', 'warn', 'error'], + default: 'auto', + description: + 'Which severities appear in Results and in JSON messages when not using --quiet. auto: follow --log-level (debug | info | warn | error)' }, categories: { type: 'string', @@ -24,7 +37,8 @@ export const meta = { json: { type: 'boolean', default: false, - description: 'Output results as JSON to stdout' + description: + 'Output results as JSON to stdout (messages follow --quiet or resolved --report-level)' } } } as const; diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 9c9b990..d5a32b9 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -7,6 +7,7 @@ import {report} from '../index.js'; import {enableDebug} from '../logger.js'; import {wrapAnsi} from 'fast-wrap-ansi'; import {parseCategories} from '../categories.js'; +import type {Message} from '../types.js'; function formatBytes(bytes: number) { const units = ['B', 'KB', 'MB', 'GB']; @@ -33,11 +34,25 @@ const FAIL_THRESHOLD_RANK: Record = { debug: 0 }; +/** Unknown severities are treated like warnings so they are not silently dropped. */ +function messageSeverityRank(m: Message): number { + return SEVERITY_RANK[m.severity] ?? 2; +} + +function messagesAtOrAboveSeverityThreshold( + messages: Message[], + thresholdRank: number +): Message[] { + return messages.filter((m) => messageSeverityRank(m) >= thresholdRank); +} + export async function run(ctx: CommandContext) { // 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']; + const quiet = ctx.values['quiet']; + const reportLevel = ctx.values['report-level']; const jsonOutput = ctx.values['json']; let root: string | undefined; @@ -94,12 +109,24 @@ export async function run(ctx: CommandContext) { }); const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; + const effectiveReportLevel = quiet + ? 'error' + : reportLevel === 'auto' + ? logLevel + : reportLevel; + const reportThresholdRank = FAIL_THRESHOLD_RANK[effectiveReportLevel] ?? 0; + const visibleMessages = messagesAtOrAboveSeverityThreshold( + messages, + reportThresholdRank + ); const hasFailingMessages = thresholdRank > 0 && - messages.some((m) => SEVERITY_RANK[m.severity] >= thresholdRank); + messages.some((m) => messageSeverityRank(m) >= thresholdRank); if (jsonOutput) { - process.stdout.write(JSON.stringify({stats, messages}, null, 2) + '\n'); + process.stdout.write( + JSON.stringify({stats, messages: visibleMessages}, null, 2) + '\n' + ); if (hasFailingMessages) { process.exit(1); } @@ -156,8 +183,15 @@ export async function run(ctx: CommandContext) { prompts.log.info('Results:'); prompts.log.message('', {spacing: 0}); - // Display tool analysis results - if (messages.length > 0) { + // Display tool analysis results (severity-filtered by --quiet / --report-level) + if (messages.length > 0 && visibleMessages.length === 0) { + const dimHint = quiet + ? `${messages.length} issue(s) hidden by --quiet (errors only in output). Omit --quiet or lower --report-level to see them.` + : reportLevel === 'auto' + ? `${messages.length} issue(s) below --report-level ${effectiveReportLevel} (--report-level auto, same as --log-level); set --report-level to a lower value to see them.` + : `${messages.length} issue(s) below --report-level ${effectiveReportLevel}; set --report-level to a lower value to see them.`; + prompts.log.message(styleText('dim', dimHint), {spacing: 0}); + } else if (visibleMessages.length > 0) { const width = process.stdout?.columns ?? 80; const maxContentWidth = Math.max(20, width - 4); @@ -167,9 +201,11 @@ export async function run(ctx: CommandContext) { .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( + const errorMessages = visibleMessages.filter((m) => m.severity === 'error'); + const warningMessages = visibleMessages.filter( + (m) => m.severity === 'warning' + ); + const suggestionMessages = visibleMessages.filter( (m) => m.severity === 'suggestion' ); @@ -212,7 +248,7 @@ export async function run(ctx: CommandContext) { const errorCount = errorMessages.length; const warningCount = warningMessages.length; const suggestionCount = suggestionMessages.length; - const fixableCount = messages.filter( + const fixableCount = visibleMessages.filter( (m) => m.fixableBy === 'migrate' ).length; const parts: string[] = []; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index ae3af8d..f11f47d 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -155,6 +155,39 @@ describe('analyze exit codes', () => { ); expect(code).toBe(0); }); + + it('with --log-level=error hides warnings when there are no errors', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['analyze', '--log-level=error'], + basicChalkFixture + ); + expect(code).toBe(0); + const output = stdout + stderr; + expect(output).not.toContain('Warnings:'); + expect(output).toMatch(/below --report-level error/); + }); + + it('with --log-level=warn shows warnings but not suggestions', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['analyze', '--log-level=warn'], + basicChalkFixture + ); + expect(code).toBe(1); + const output = stdout + stderr; + expect(output).toContain('Warnings:'); + expect(output).not.toContain('Suggestions:'); + }); + + it('--quiet hides non-errors like ESLint (default log-level still fails on warnings)', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['analyze', '--quiet'], + basicChalkFixture + ); + expect(code).toBe(1); + const output = stdout + stderr; + expect(output).not.toContain('Warnings:'); + expect(output).toContain('hidden by --quiet'); + }); }); describe('analyze --json', () => { @@ -189,6 +222,75 @@ describe('analyze --json', () => { const parsed = JSON.parse(stdout); expect(parsed.messages.length).toBeGreaterThan(0); }); + + it('filters JSON messages to match --log-level=error', async () => { + const {stdout, code} = await runCliProcess( + ['analyze', '--json', '--log-level=error'], + basicChalkFixture + ); + expect(code).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.messages).toEqual([]); + }); + + it('--report-level=info includes all messages when --log-level=error', async () => { + const {stdout, code} = await runCliProcess( + ['analyze', '--json', '--log-level=error', '--report-level=info'], + basicChalkFixture + ); + expect(code).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.messages.length).toBeGreaterThanOrEqual(2); + expect( + parsed.messages.some((m: {severity: string}) => m.severity === 'warning') + ).toBe(true); + expect( + parsed.messages.some( + (m: {severity: string}) => m.severity === 'suggestion' + ) + ).toBe(true); + }); + + it('--quiet JSON omits warnings when there are no errors', async () => { + const {stdout, code} = await runCliProcess( + ['analyze', '--json', '--quiet', '--log-level=error'], + basicChalkFixture + ); + expect(code).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.messages).toEqual([]); + }); + + it('--quiet overrides --report-level=info for JSON messages', async () => { + const {stdout, code} = await runCliProcess( + [ + 'analyze', + '--json', + '--quiet', + '--log-level=error', + '--report-level=info' + ], + basicChalkFixture + ); + expect(code).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.messages).toEqual([]); + }); + + it('JSON with --log-level=warn omits suggestions', async () => { + const {stdout, code} = await runCliProcess( + ['analyze', '--json', '--log-level=warn'], + basicChalkFixture + ); + expect(code).toBe(1); + const parsed = JSON.parse(stdout); + expect(parsed.messages.length).toBeGreaterThan(0); + expect( + parsed.messages.every( + (m: {severity: string}) => m.severity !== 'suggestion' + ) + ).toBe(true); + }); }); describe('analyze fixable summary', () => {