From 152b1820984206c31da03d2d74bc09b8844e825e Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 15 Apr 2026 15:49:21 +0530 Subject: [PATCH 1/4] fix(cli): added logic to accumulate lint results before formatting --- packages/cli/src/commands/lint.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index d0dc4a5c63..d0d82d8e91 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -53,6 +53,7 @@ export async function handleLint({ const totals: Totals = { errors: 0, warnings: 0, ignored: 0 }; let totalIgnored = 0; + const allLintRes: Awaited> = []; // TODO: use shared externalRef resolver, blocked by preprocessors now as they can mutate documents for (const { path, alias } of apis) { @@ -98,13 +99,7 @@ export async function handleLint({ totalIgnored++; } } else { - formatProblems(results, { - format: argv.format, - maxProblems: argv['max-problems'], - totals: fileTotals, - version, - command: 'lint', - }); + allLintRes.push(...results); } const elapsed = getExecutionTime(startedAt); @@ -114,6 +109,16 @@ export async function handleLint({ } } + if (!argv['generate-ignore-file']) { + formatProblems(allLintRes, { + format: argv.format, + maxProblems: argv['max-problems'], + totals, + version, + command: 'lint', + }); + } + if (argv['generate-ignore-file']) { config.saveIgnore(); logger.info(`Explicitly ignored ${totalIgnored} ${pluralize('problem', totalIgnored)}.\n\n`); From 8dcbcfb0322a62b0f70fc93c1d14e5754c90d784 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 15 Apr 2026 17:38:16 +0530 Subject: [PATCH 2/4] fix(cli): updated lint test --- .../cli/src/__tests__/commands/lint.test.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts index 1106f38f0a..0a663b2407 100644 --- a/packages/cli/src/__tests__/commands/lint.test.ts +++ b/packages/cli/src/__tests__/commands/lint.test.ts @@ -52,7 +52,7 @@ describe('handleLint', () => { return { ...actual, lint: vi.fn(async (): Promise => []), - getTotals: vi.fn(() => ({ errors: 0 }) as Totals), + getTotals: vi.fn(() => ({ errors: 0, warnings: 0, ignored: 0 }) as Totals), doesYamlFileExist: vi.fn((path) => path === 'redocly.yaml'), logger: { info: vi.fn(), @@ -217,13 +217,29 @@ describe('handleLint', () => { expect(formatProblems).toHaveBeenCalledWith(['problem'], { format: 'stylish', maxProblems: 2, - totals: { errors: 0 }, + totals: { errors: 0, warnings: 0, ignored: 0 }, version: '2.0.0', command: 'lint', }); expect(getExecutionTime).toHaveBeenCalledWith(42); }); + it('should aggregate problems from multiple APIs into a single formatProblems call', async () => { + vi.mocked(lint) + .mockResolvedValueOnce(['problem-a'] as any) + .mockResolvedValueOnce(['problem-b'] as any); + await commandWrapper(handleLint)({ + ...argvMock, + apis: ['a.yaml', 'b.yaml'], + format: 'checkstyle', + }); + expect(formatProblems).toHaveBeenCalledTimes(1); + expect(formatProblems).toHaveBeenCalledWith( + ['problem-a', 'problem-b'], + expect.objectContaining({ format: 'checkstyle' }) + ); + }); + it('should catch error in handleError if something fails', async () => { vi.mocked(lint).mockRejectedValueOnce('error'); await commandWrapper(handleLint)(argvMock); From 1c823613f9fae3aa0bc27a0d032a6bdd99c1706b Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 15 Apr 2026 17:44:21 +0530 Subject: [PATCH 3/4] fix: updated both lint md and added changset --- .changeset/empty-cities-sink.md | 5 +++++ docs/@v1/commands/lint.md | 6 +----- docs/@v2/commands/lint.md | 6 +----- 3 files changed, 7 insertions(+), 10 deletions(-) create mode 100644 .changeset/empty-cities-sink.md diff --git a/.changeset/empty-cities-sink.md b/.changeset/empty-cities-sink.md new file mode 100644 index 0000000000..883a0e1d09 --- /dev/null +++ b/.changeset/empty-cities-sink.md @@ -0,0 +1,5 @@ +--- +"@redocly/cli": patch +--- + +When multiple APIs are linted in a single command, all structured formats (such as `checkstyle` and `json`) produce a single combined document that groups problems per file. diff --git a/docs/@v1/commands/lint.md b/docs/@v1/commands/lint.md index 55d9c4e2dc..7a022ab50d 100644 --- a/docs/@v1/commands/lint.md +++ b/docs/@v1/commands/lint.md @@ -115,11 +115,7 @@ However, if you have a configuration file, but it doesn't include any rules or e ### Specify output format -The standard `codeframe` output format works well in most situations, but `redocly` can also produce output to integrate with other tools. - -{% admonition type="warning" name="Lint one API at a time" %} -Some formats, such as `checkstyle` or `json`, don't work well when multiple APIs are linted in a single command. Try linting each API separately when you pass the command output to another tool. -{% /admonition %} +The standard `codeframe` output format works well in most situations, but `redocly` can also produce output to integrate with other tools. When multiple APIs are linted in a single command, all structured formats (such as `checkstyle` and `json`) produce a single combined document that groups problems per file. #### Codeframe (default) diff --git a/docs/@v2/commands/lint.md b/docs/@v2/commands/lint.md index 5dd93dc239..cd737c0f1e 100644 --- a/docs/@v2/commands/lint.md +++ b/docs/@v2/commands/lint.md @@ -130,11 +130,7 @@ However, if you have a configuration file, but it doesn't include any rules or e ### Specify output format The standard `codeframe` output format works well in most situations, but `redocly` can also produce output to integrate with other tools. - -{% admonition type="warning" name="Lint one API at a time" %} -Some formats, such as `checkstyle` or `json`, don't work well when multiple APIs are linted in a single command. -Try linting each API separately when you pass the command output to another tool. -{% /admonition %} +When multiple APIs are linted in a single command, all structured formats (such as `checkstyle` and `json`) produce a single combined document that groups problems per file. #### Codeframe (default) From 19092db33ab21f141ce03dc5203bd6d83d18b508 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 15 Apr 2026 18:36:47 +0530 Subject: [PATCH 4/4] fix: address cursor comments --- .../cli/src/__tests__/commands/lint.test.ts | 19 +++ packages/cli/src/commands/lint.ts | 120 +++++++++--------- 2 files changed, 81 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts index 0a663b2407..17e6c16dbe 100644 --- a/packages/cli/src/__tests__/commands/lint.test.ts +++ b/packages/cli/src/__tests__/commands/lint.test.ts @@ -240,6 +240,25 @@ describe('handleLint', () => { ); }); + it('should output accumulated results when a later API fails and handleError throws', async () => { + vi.mocked(lint) + .mockResolvedValueOnce(['problem-a'] as any) + .mockRejectedValueOnce(new Error('boom')); + vi.mocked(handleError).mockImplementationOnce((e) => { + throw e; + }); + await commandWrapper(handleLint)({ + ...argvMock, + apis: ['a.yaml', 'b.yaml'], + format: 'checkstyle', + }); + expect(formatProblems).toHaveBeenCalledTimes(1); + expect(formatProblems).toHaveBeenCalledWith( + ['problem-a'], + expect.objectContaining({ format: 'checkstyle' }) + ); + }); + it('should catch error in handleError if something fails', async () => { vi.mocked(lint).mockRejectedValueOnce('error'); await commandWrapper(handleLint)(argvMock); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index d0d82d8e91..c1ae412d9e 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -54,71 +54,75 @@ export async function handleLint({ const totals: Totals = { errors: 0, warnings: 0, ignored: 0 }; let totalIgnored = 0; const allLintRes: Awaited> = []; + let loopCompleted = false; + + try { + // TODO: use shared externalRef resolver, blocked by preprocessors now as they can mutate documents + for (const { path, alias } of apis) { + try { + const startedAt = performance.now(); + const aliasConfig = config.forAlias(alias); + + checkIfRulesetExist(aliasConfig.rules); + + aliasConfig.skipRules(argv['skip-rule']); + aliasConfig.skipPreprocessors(argv['skip-preprocessor']); + + if (typeof config.document?.parsed === 'undefined' && !argv.extends) { + logger.info( + `No configurations were provided -- using built in ${blue( + 'recommended' + )} configuration by default.\n\n` + ); + } + if (alias === undefined) { + logger.info(gray(`validating ${formatPath(path)}...\n`)); + } else { + logger.info( + gray(`validating ${formatPath(path)} using lint rules for api '${alias}'...\n`) + ); + } - // TODO: use shared externalRef resolver, blocked by preprocessors now as they can mutate documents - for (const { path, alias } of apis) { - try { - const startedAt = performance.now(); - const aliasConfig = config.forAlias(alias); - - checkIfRulesetExist(aliasConfig.rules); - - aliasConfig.skipRules(argv['skip-rule']); - aliasConfig.skipPreprocessors(argv['skip-preprocessor']); + const results = await lint({ + ref: path, + config: aliasConfig, + collectSpecData, + }); + + const fileTotals = getTotals(results); + totals.errors += fileTotals.errors; + totals.warnings += fileTotals.warnings; + totals.ignored += fileTotals.ignored; + + if (argv['generate-ignore-file']) { + config.clearIgnoreForRef(path); + for (const m of results) { + config.addIgnore(m); + totalIgnored++; + } + } else { + allLintRes.push(...results); + } - if (typeof config.document?.parsed === 'undefined' && !argv.extends) { - logger.info( - `No configurations were provided -- using built in ${blue( - 'recommended' - )} configuration by default.\n\n` - ); - } - if (alias === undefined) { - logger.info(gray(`validating ${formatPath(path)}...\n`)); - } else { - logger.info( - gray(`validating ${formatPath(path)} using lint rules for api '${alias}'...\n`) - ); + const elapsed = getExecutionTime(startedAt); + logger.info(gray(`${formatPath(path)}: validated in ${elapsed}\n\n`)); + } catch (e) { + handleError(e, path); } - - const results = await lint({ - ref: path, - config: aliasConfig, - collectSpecData, + } + loopCompleted = true; + } finally { + if (!argv['generate-ignore-file'] && (loopCompleted || allLintRes.length > 0)) { + formatProblems(allLintRes, { + format: argv.format, + maxProblems: argv['max-problems'], + totals, + version, + command: 'lint', }); - - const fileTotals = getTotals(results); - totals.errors += fileTotals.errors; - totals.warnings += fileTotals.warnings; - totals.ignored += fileTotals.ignored; - - if (argv['generate-ignore-file']) { - config.clearIgnoreForRef(path); - for (const m of results) { - config.addIgnore(m); - totalIgnored++; - } - } else { - allLintRes.push(...results); - } - - const elapsed = getExecutionTime(startedAt); - logger.info(gray(`${formatPath(path)}: validated in ${elapsed}\n\n`)); - } catch (e) { - handleError(e, path); } } - if (!argv['generate-ignore-file']) { - formatProblems(allLintRes, { - format: argv.format, - maxProblems: argv['max-problems'], - totals, - version, - command: 'lint', - }); - } - if (argv['generate-ignore-file']) { config.saveIgnore(); logger.info(`Explicitly ignored ${totalIgnored} ${pluralize('problem', totalIgnored)}.\n\n`);