Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/empty-cities-sink.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 1 addition & 5 deletions docs/@v1/commands/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 1 addition & 5 deletions docs/@v2/commands/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
39 changes: 37 additions & 2 deletions packages/cli/src/__tests__/commands/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('handleLint', () => {
return {
...actual,
lint: vi.fn(async (): Promise<NormalizedProblem[]> => []),
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(),
Expand Down Expand Up @@ -217,13 +217,48 @@ 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 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);
Expand Down
115 changes: 62 additions & 53 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,64 +53,73 @@ export async function handleLint({

const totals: Totals = { errors: 0, warnings: 0, ignored: 0 };
let totalIgnored = 0;
const allLintRes: Awaited<ReturnType<typeof lint>> = [];
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);
const results = await lint({
ref: path,
config: aliasConfig,
collectSpecData,
});

aliasConfig.skipRules(argv['skip-rule']);
aliasConfig.skipPreprocessors(argv['skip-preprocessor']);
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',
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Markdown format shows misleading combined totals per file

Medium Severity

When multiple APIs are linted, the markdown format in formatProblems prints totals.errors and totals.warnings inside the per-file-group loop. Previously, formatProblems was called per-file with per-file fileTotals, so each section showed that file's counts. Now that it's called once with combined totals, every file section displays the same combined error/warning counts, making it look like each individual file has that many issues. This produces misleading markdown output.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1799891. Configure here.

});

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 {
formatProblems(results, {
format: argv.format,
maxProblems: argv['max-problems'],
totals: fileTotals,
version,
command: 'lint',
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

const elapsed = getExecutionTime(startedAt);
logger.info(gray(`${formatPath(path)}: validated in ${elapsed}\n\n`));
} catch (e) {
handleError(e, path);
}
}

Expand Down
Loading