Skip to content

Commit 20779a0

Browse files
authored
feat: add reference property to context.report() for external docs links (#2734)
1 parent 0778679 commit 20779a0

12 files changed

Lines changed: 177 additions & 1 deletion

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@redocly/openapi-core": minor
3+
---
4+
5+
Added a `reference` property to `context.report()` so that custom rules can link to external documentation.
6+
When set, the URL is rendered beneath the message in both stylish and `github-actions` output formats.
7+
Configurable rules also accept a top-level `reference` field.

packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,9 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
488488
"message": {
489489
"type": "string",
490490
},
491+
"reference": {
492+
"type": "string",
493+
},
491494
"severity": {
492495
"enum": [
493496
"error",

packages/core/src/__tests__/format.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,64 @@ describe('format', () => {
210210
`);
211211
});
212212

213+
it('should include reference URL in github-actions format', () => {
214+
const problems: NormalizedProblem[] = [
215+
{
216+
ruleId: 'struct',
217+
message: 'Operation must include a request body',
218+
severity: 'error' as const,
219+
location: [
220+
{
221+
source: { absoluteRef: 'openapi.yaml' } as Source,
222+
start: { line: 1, col: 2 },
223+
end: { line: 3, col: 4 },
224+
} as LocationObject,
225+
],
226+
suggest: [],
227+
reference: 'https://wiki.example.com/api-guidelines#request-bodies',
228+
},
229+
];
230+
231+
formatProblems(problems, {
232+
format: 'github-actions',
233+
version: '1.0.0',
234+
totals: getTotals(problems),
235+
});
236+
237+
expect(output).toEqual(
238+
'::error title=struct,file=openapi.yaml,line=1,col=2,endLine=3,endColumn=4::Operation must include a request body%0A%0AReference: https://wiki.example.com/api-guidelines#request-bodies%0A%0A\n'
239+
);
240+
});
241+
242+
it('should render both suggestions and reference with single separator in github-actions format', () => {
243+
const problems: NormalizedProblem[] = [
244+
{
245+
ruleId: 'operation-id',
246+
message: 'Operation ID is required',
247+
severity: 'error' as const,
248+
location: [
249+
{
250+
source: { absoluteRef: 'openapi.yaml' } as Source,
251+
start: { line: 1, col: 2 },
252+
end: { line: 3, col: 4 },
253+
} as LocationObject,
254+
],
255+
suggest: ['addOperation'],
256+
reference: 'https://wiki.example.com/api-guidelines#operation-id',
257+
},
258+
];
259+
260+
formatProblems(problems, {
261+
format: 'github-actions',
262+
version: '1.0.0',
263+
totals: getTotals(problems),
264+
});
265+
266+
expect(output).toEqual(
267+
'::error title=operation-id,file=openapi.yaml,line=1,col=2,endLine=3,endColumn=4::Operation ID is required%0A%0ADid you mean: addOperation ?%0A%0AReference: https://wiki.example.com/api-guidelines#operation-id%0A%0A\n'
268+
);
269+
});
270+
213271
it('should limit suggestions based on REDOCLY_CLI_LINT_MAX_SUGGESTIONS constant', () => {
214272
const problems: NormalizedProblem[] = [
215273
{

packages/core/src/__tests__/lint.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,41 @@ describe('lint', () => {
390390
expect(replaceSourceWithRef(results_with_createConfig)).toEqual(replaceSourceWithRef(results));
391391
});
392392

393+
it('lintFromString should propagate reference from a configurable rule to the resulting problem', async () => {
394+
const source = outdent`
395+
openapi: 3.0.2
396+
info:
397+
title: Example
398+
version: '1.0'
399+
paths:
400+
/user:
401+
get:
402+
responses:
403+
'200':
404+
description: OK
405+
`;
406+
const results = await lintFromString({
407+
absoluteRef: '/test/spec.yaml',
408+
source,
409+
config: await createConfig(outdent`
410+
rules:
411+
rule/operation-summary-required:
412+
subject:
413+
type: Operation
414+
property: summary
415+
assertions:
416+
defined: true
417+
message: Operation summary is required
418+
severity: error
419+
reference: https://docs.example.com/style-guide#operation-summary
420+
`),
421+
});
422+
423+
const problem = results.find((r) => r.ruleId === 'rule/operation-summary-required');
424+
expect(problem).toBeDefined();
425+
expect(problem?.reference).toEqual('https://docs.example.com/style-guide#operation-summary');
426+
});
427+
393428
it('lint should work', async () => {
394429
const results = await lint({
395430
ref: path.join(__dirname, 'fixtures/lint/openapi.yaml'),

packages/core/src/format/format.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ export function formatProblems(
284284
`[${idx + 1}] ${bgColor(fileWithLoc)} ${atPointer}\n\n` +
285285
`${problem.message}\n\n` +
286286
formatDidYouMean(problem) +
287+
(problem.reference ? `Reference: ${colorize.blue(problem.reference)}\n\n` : '') +
287288
getCodeframe(loc, color) +
288289
'\n\n' +
289290
formatFrom(cwd, problem.from) +
@@ -440,7 +441,9 @@ function outputForGithubActions(problems: NormalizedProblem[], cwd: string): voi
440441
break;
441442
}
442443
const suggest = formatDidYouMean(problem);
443-
const message = suggest !== '' ? problem.message + '\n\n' + suggest : problem.message;
444+
const reference = problem.reference ? `Reference: ${problem.reference}\n\n` : '';
445+
const message =
446+
problem.message + (suggest !== '' || reference !== '' ? '\n\n' : '') + suggest + reference;
444447
const properties = {
445448
title: problem.ruleId,
446449
file: isAbsoluteUrl(location.source.absoluteRef)

packages/core/src/rules/common/assertions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type RawAssertion = AssertionDefinition & {
3030
where?: AssertionDefinition[];
3131
message?: string;
3232
suggest?: string[];
33+
reference?: string;
3334
severity?: RuleSeverity;
3435
};
3536

packages/core/src/rules/common/assertions/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export function buildSubjectVisitor(assertId: string, assertion: Assertion): Vis
238238
location: getProblemsLocation(problemGroup) || ctx.location,
239239
forceSeverity: assertion.severity || 'error',
240240
suggest: assertion.suggest || [],
241+
...(assertion.reference && { reference: assertion.reference }),
241242
ruleId: assertId,
242243
});
243244
}

packages/core/src/types/redocly-yaml.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@ const ConfigurableRule: NodeType = {
594594
where: listOf('Where'),
595595
message: { type: 'string' },
596596
suggest: { type: 'array', items: { type: 'string' } },
597+
reference: { type: 'string' },
597598
severity: { enum: ['error', 'warn', 'off'] },
598599
},
599600
required: ['subject', 'assertions'],

packages/core/src/walk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export type Problem = {
7979
from?: LocationObject;
8080
forceSeverity?: RuleSeverity;
8181
ruleId?: string;
82+
reference?: string;
8283
};
8384

8485
export type NormalizedProblem = {
@@ -89,6 +90,7 @@ export type NormalizedProblem = {
8990
from?: LocationObject;
9091
suggest: string[];
9192
ignored?: boolean;
93+
reference?: string;
9294
};
9395

9496
export type WalkContext = {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
openapi: 3.1.0
2+
info:
3+
version: 1.0.0
4+
title: Reference field test
5+
description: Test that configurable rule reference is rendered.
6+
license:
7+
name: MIT
8+
paths: {}
9+
components:
10+
examples:
11+
snake_case_example:
12+
value: { hello: 'world' }
13+
anotherBadExample:
14+
value: { foo: 'bar' }

0 commit comments

Comments
 (0)