Skip to content

Commit

Permalink
feat(formatters): Add markdown formatter
Browse files Browse the repository at this point in the history
- Format results as a markdown table
- New utility funciton getDocumentationUrl.ts that build the documentation url from the rule or the ruleset
  • Loading branch information
jb.muscat committed Jul 30, 2024
1 parent 3797272 commit 624ba22
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/formatters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ console.error(output);
- html
- text
- teamcity
- markdown (example: [src/\_\_tests\_\_/markdown.md](src/__tests__/markdown.md))

### Node.js only

Expand Down
3 changes: 3 additions & 0 deletions packages/formatters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@
"@stoplight/spectral-core": "^1.15.1",
"@stoplight/spectral-runtime": "^1.1.0",
"@stoplight/types": "^13.15.0",
"@types/markdown-escape": "^1.1.3",
"chalk": "4.1.2",
"cliui": "7.0.4",
"lodash": "^4.17.21",
"markdown-escape": "^2.0.0",
"markdown-table-ts": "^1.0.3",
"node-sarif-builder": "^2.0.3",
"strip-ansi": "6.0",
"text-table": "^0.2.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/formatters/src/__tests__/markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
| Code | Path | Message | Severity | Start | End | Source |
| ---------------------------------------------------------------------- | ---------------------------- | -------------------------------------------- | -------- | ----- | ---- | --------------------------------------------------- |
| [operation-description](https://rule-documentation-url.com) | paths.\/pets.get.description | paths.\/pets.get.description is not truthy | Error | 1:0 | 10:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml |
| [operation-tags](https://ruleset-documentation-url.com#operation-tags) | paths.\/pets.get.tags | paths.\/pets.get.tags is not truthy | Warning | 11:0 | 20:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml |
| rule-from-other-ruleset | paths | i should not have any documentation url link | Warning | 21:0 | 30:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml |
111 changes: 111 additions & 0 deletions packages/formatters/src/__tests__/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { DiagnosticSeverity } from '@stoplight/types';
import type { IRuleResult } from '@stoplight/spectral-core';
import { FormatterContext } from '../types';
import { markdown } from '../markdown';
import path from 'path';
import fs from 'fs';

const results: IRuleResult[] = [
{
code: 'operation-description',
message: 'paths./pets.get.description is not truthy',
path: ['paths', '/pets', 'get', 'description'],
severity: DiagnosticSeverity.Error,
source: './src/__tests__/fixtures/petstore.oas2.yaml',
range: {
start: {
line: 1,
character: 0,
},
end: {
line: 10,
character: 1,
},
},
},
{
code: 'operation-tags',
message: 'paths./pets.get.tags is not truthy',
path: ['paths', '/pets', 'get', 'tags'],
severity: DiagnosticSeverity.Warning,
source: './src/__tests__/fixtures/petstore.oas2.yaml',
range: {
start: {
line: 11,
character: 0,
},
end: {
line: 20,
character: 1,
},
},
},
{
code: 'rule-from-other-ruleset',
message: 'i should not have any documentation url link',
path: ['paths'],
severity: DiagnosticSeverity.Warning,
source: './src/__tests__/fixtures/petstore.oas2.yaml',
range: {
start: {
line: 21,
character: 0,
},
end: {
line: 30,
character: 1,
},
},
},
];

const context = {
ruleset: {
rules: {
'operation-description': {
documentationUrl: 'https://rule-documentation-url.com',
owner: {
definition: {
documentationUrl: 'https://ruleset-documentation-url.com',
},
},
},
'operation-tags': {
documentationUrl: '', //nothing
owner: {
definition: {
documentationUrl: 'https://ruleset-documentation-url.com',
},
},
},
'rule-from-other-ruleset': {
documentationUrl: '', //nothing
owner: {
definition: {
documentationUrl: '', //nothing
},
},
},
},
},
} as unknown as FormatterContext;

describe('Markdown formatter', () => {
test('should format as markdown table', () => {
const CRLF = '\r\n';
let md = markdown(results, { failSeverity: DiagnosticSeverity.Warning }, context);
let expectedMd = loadMarkdownFile('markdown.md');

// We normalize the line-breaks and trailing whitespaces because the expected markdown file is can be created on a Windows machine
// and prettier instert a line break automatically
md = md.replace(new RegExp(CRLF, 'g'), '\n').trimEnd();
expectedMd = expectedMd.replace(new RegExp(CRLF, 'g'), '\n').trimEnd();

expect(md).toEqual(expectedMd);
});
});

function loadMarkdownFile(fileName: string): string {
const file = path.join(__dirname, './', fileName);
return fs.readFileSync(file, 'utf8').toString();
}
1 change: 1 addition & 0 deletions packages/formatters/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './junit';
export * from './html';
export * from './text';
export * from './teamcity';
export * from './markdown';
import type { Formatter } from './types';
export type { Formatter, FormatterOptions } from './types';

Expand Down
44 changes: 44 additions & 0 deletions packages/formatters/src/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { printPath, PrintStyle } from '@stoplight/spectral-runtime';
import { Formatter, FormatterContext } from './types';
import { groupBySource } from './utils';
import { DiagnosticSeverity } from '@stoplight/types';
import { getMarkdownTable } from 'markdown-table-ts';
import markdownEscape from 'markdown-escape';
import { getRuleDocumentationUrl } from './utils/getDocumentationUrl';

export const markdown: Formatter = (results, { failSeverity }, ctx?: FormatterContext) => {
const groupedResults = groupBySource(results);

const body: string[][] = [];
for (const [source, validationResults] of Object.entries(groupedResults)) {
validationResults.sort((a, b) => a.range.start.line - b.range.start.line);

if (validationResults.length > 0) {
const filteredValidationResults = validationResults.filter(result => result.severity <= failSeverity);

for (const result of filteredValidationResults) {
const ruleDocumentationUrl = getRuleDocumentationUrl(result.code, ctx);
const codeWithOptionalLink =
ruleDocumentationUrl != null
? `[${result.code.toString()}](${ruleDocumentationUrl})`
: result.code.toString();
const escapedPath = markdownEscape(printPath(result.path, PrintStyle.Dot));
const escapedMessage = markdownEscape(result.message);
const severityString = DiagnosticSeverity[result.severity];
const start = `${result.range.start.line}:${result.range.start.character}`;
const end = `${result.range.end.line}:${result.range.end.character}`;
const escapedSource = markdownEscape(source);
body.push([codeWithOptionalLink, escapedPath, escapedMessage, severityString, start, end, escapedSource]);
}
}
}

const table = getMarkdownTable({
table: {
head: ['Code', 'Path', 'Message', 'Severity', 'Start', 'End', 'Source'],
body: body,
},
});

return table;
};
22 changes: 22 additions & 0 deletions packages/formatters/src/utils/getDocumentationUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FormatterContext } from '../types';

/// Returns the documentation URL, either directly from the rule or by combining the ruleset documentation URL with the rule code.
export function getRuleDocumentationUrl(ruleCode: string | number, ctx?: FormatterContext): string | undefined {
if (!ctx?.ruleset) {
return undefined;
}

const rule = ctx.ruleset.rules[ruleCode.toString()];
//if rule.documentationUrl is not null and not empty and not undefined, return it
if (rule.documentationUrl != null && rule.documentationUrl) {
return rule.documentationUrl;
}

//otherwise use the ruleset documentationUrl and append the rulecode as an anchor
const rulesetDocumentationUrl = rule.owner?.definition.documentationUrl;
if (rulesetDocumentationUrl != null && rulesetDocumentationUrl) {
return `${rulesetDocumentationUrl}#${ruleCode}`;
}

return undefined;
}
24 changes: 24 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2728,12 +2728,15 @@ __metadata:
"@stoplight/spectral-core": ^1.15.1
"@stoplight/spectral-runtime": ^1.1.0
"@stoplight/types": ^13.15.0
"@types/markdown-escape": ^1.1.3
ast-types: ^0.14.2
astring: ^1.8.4
chalk: 4.1.2
cliui: 7.0.4
eol: 0.9.1
lodash: ^4.17.21
markdown-escape: ^2.0.0
markdown-table-ts: ^1.0.3
node-html-parser: ^4.1.5
node-sarif-builder: ^2.0.3
strip-ansi: 6.0
Expand Down Expand Up @@ -3305,6 +3308,13 @@ __metadata:
languageName: node
linkType: hard

"@types/markdown-escape@npm:^1.1.3":
version: 1.1.3
resolution: "@types/markdown-escape@npm:1.1.3"
checksum: cb2e410993271f0ccc526190391a08344f4f602be69e06fee989d36d5886866ba9ba2184054895d0ad2a12d57b02f3ccf86d7a1fe8904be48bcc1ee61b98e32f
languageName: node
linkType: hard

"@types/minimatch@npm:*, @types/minimatch@npm:^3.0.5":
version: 3.0.5
resolution: "@types/minimatch@npm:3.0.5"
Expand Down Expand Up @@ -9326,6 +9336,20 @@ __metadata:
languageName: node
linkType: hard

"markdown-escape@npm:^2.0.0":
version: 2.0.0
resolution: "markdown-escape@npm:2.0.0"
checksum: 74c66d817636ac5f6a275fdc79ecb1e208d907ca85289d660b515256fbc3e380eb18d29b6bbbd6a77968ee4fb5872d40ecf31e52bc9f17855bb01bb723569fa0
languageName: node
linkType: hard

"markdown-table-ts@npm:^1.0.3":
version: 1.0.3
resolution: "markdown-table-ts@npm:1.0.3"
checksum: af684c664f14d628cec0b554d3accc12309be02d25c7fc455bfd962e04484d2d2781c4d58dd1bfe07f5e895962b137ddbdae43e05f95ed3190f31f4035174840
languageName: node
linkType: hard

"marked-terminal@npm:^5.0.0":
version: 5.2.0
resolution: "marked-terminal@npm:5.2.0"
Expand Down

0 comments on commit 624ba22

Please sign in to comment.