From 624ba229ca3848b165731f6d60cd2cbcbe1b6435 Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Tue, 30 Jul 2024 18:17:34 +0200 Subject: [PATCH] feat(formatters): Add markdown formatter - Format results as a markdown table - New utility funciton getDocumentationUrl.ts that build the documentation url from the rule or the ruleset --- packages/formatters/README.md | 1 + packages/formatters/package.json | 3 + packages/formatters/src/__tests__/markdown.md | 5 + .../formatters/src/__tests__/markdown.test.ts | 111 ++++++++++++++++++ packages/formatters/src/index.ts | 1 + packages/formatters/src/markdown.ts | 44 +++++++ .../src/utils/getDocumentationUrl.ts | 22 ++++ yarn.lock | 24 ++++ 8 files changed, 211 insertions(+) create mode 100644 packages/formatters/src/__tests__/markdown.md create mode 100644 packages/formatters/src/__tests__/markdown.test.ts create mode 100644 packages/formatters/src/markdown.ts create mode 100644 packages/formatters/src/utils/getDocumentationUrl.ts diff --git a/packages/formatters/README.md b/packages/formatters/README.md index 809d02e99..bb1e7ccf5 100644 --- a/packages/formatters/README.md +++ b/packages/formatters/README.md @@ -28,6 +28,7 @@ console.error(output); - html - text - teamcity +- markdown (example: [src/\_\_tests\_\_/markdown.md](src/__tests__/markdown.md)) ### Node.js only diff --git a/packages/formatters/package.json b/packages/formatters/package.json index 9339c07c0..dcc1f64c6 100644 --- a/packages/formatters/package.json +++ b/packages/formatters/package.json @@ -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", diff --git a/packages/formatters/src/__tests__/markdown.md b/packages/formatters/src/__tests__/markdown.md new file mode 100644 index 000000000..0e96a12e6 --- /dev/null +++ b/packages/formatters/src/__tests__/markdown.md @@ -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 | diff --git a/packages/formatters/src/__tests__/markdown.test.ts b/packages/formatters/src/__tests__/markdown.test.ts new file mode 100644 index 000000000..85f66780f --- /dev/null +++ b/packages/formatters/src/__tests__/markdown.test.ts @@ -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(); +} diff --git a/packages/formatters/src/index.ts b/packages/formatters/src/index.ts index 23f612c6d..14567521a 100644 --- a/packages/formatters/src/index.ts +++ b/packages/formatters/src/index.ts @@ -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'; diff --git a/packages/formatters/src/markdown.ts b/packages/formatters/src/markdown.ts new file mode 100644 index 000000000..8a7b969ec --- /dev/null +++ b/packages/formatters/src/markdown.ts @@ -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; +}; diff --git a/packages/formatters/src/utils/getDocumentationUrl.ts b/packages/formatters/src/utils/getDocumentationUrl.ts new file mode 100644 index 000000000..85f2190e3 --- /dev/null +++ b/packages/formatters/src/utils/getDocumentationUrl.ts @@ -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; +} diff --git a/yarn.lock b/yarn.lock index 40a769a4e..6c7d12ac9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -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" @@ -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"