Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/commands/analyze.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this should just be severity?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think report-level makes sense in this case

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',
Expand All @@ -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;
52 changes: 44 additions & 8 deletions src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -33,11 +34,25 @@ const FAIL_THRESHOLD_RANK: Record<string, number> = {
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<typeof meta>) {
// 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;

Expand Down Expand Up @@ -94,12 +109,24 @@ export async function run(ctx: CommandContext<typeof meta>) {
});

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);
}
Expand Down Expand Up @@ -156,8 +183,15 @@ export async function run(ctx: CommandContext<typeof meta>) {
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);

Expand All @@ -167,9 +201,11 @@ export async function run(ctx: CommandContext<typeof meta>) {
.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'
);

Expand Down Expand Up @@ -212,7 +248,7 @@ export async function run(ctx: CommandContext<typeof meta>) {
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[] = [];
Expand Down
102 changes: 102 additions & 0 deletions src/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading