From 429572868edd430e28fc5c2aaa375e63aefb492b Mon Sep 17 00:00:00 2001 From: Philipp Heuer Date: Tue, 5 Sep 2023 19:02:00 +0200 Subject: [PATCH 1/8] feat(formatters): add sarif formatter --- docs/guides/2-cli.md | 4 +- karma.conf.ts | 2 + packages/cli/src/services/config.ts | 1 + packages/cli/src/services/output.ts | 13 +- packages/formatters/README.md | 1 + packages/formatters/package.json | 1 + ...ns.test.ts => github-actions.jest.test.ts} | 0 .../src/__tests__/sarif.jest.test.ts | 137 ++++++++++++++++++ packages/formatters/src/index.node.ts | 1 + packages/formatters/src/index.ts | 4 + packages/formatters/src/sarif.ts | 67 +++++++++ .../scenarios/help-no-document.scenario | 2 +- .../scenarios/strict-options.scenario | 2 +- yarn.lock | 18 +++ 14 files changed, 248 insertions(+), 5 deletions(-) rename packages/formatters/src/__tests__/{github-actions.test.ts => github-actions.jest.test.ts} (100%) create mode 100644 packages/formatters/src/__tests__/sarif.jest.test.ts create mode 100644 packages/formatters/src/sarif.ts diff --git a/docs/guides/2-cli.md b/docs/guides/2-cli.md index 7bfd2e5ab..79c00f047 100644 --- a/docs/guides/2-cli.md +++ b/docs/guides/2-cli.md @@ -33,8 +33,8 @@ Other options include: [string] [choices: "utf8", "ascii", "utf-8", "utf16le", "ucs2", "ucs-2", "base64", "latin1"] [default: "utf8"] -f, --format formatters to use for outputting results, more than one can be given joining them with a comma - [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions"] [default: - "stylish"] + [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif"] + [default: "stylish"] -o, --output where to output results, can be a single file name, multiple "output." or missing to print to stdout [string] --stdin-filepath path to a file to pretend that stdin comes from [string] diff --git a/karma.conf.ts b/karma.conf.ts index 6bd9cd705..1f04c4589 100644 --- a/karma.conf.ts +++ b/karma.conf.ts @@ -21,6 +21,8 @@ module.exports = (config: Config): void => { exclude: [ 'packages/cli/**', 'packages/formatters/src/pretty.ts', + 'packages/formatters/src/github-actions.ts', + 'packages/formatters/src/sarif.ts', 'packages/formatters/src/index.node.ts', 'packages/ruleset-bundler/src/plugins/commonjs.ts', '**/*.jest.test.ts', diff --git a/packages/cli/src/services/config.ts b/packages/cli/src/services/config.ts index 909a831f0..b0ec0213c 100644 --- a/packages/cli/src/services/config.ts +++ b/packages/cli/src/services/config.ts @@ -12,6 +12,7 @@ export enum OutputFormat { TEAMCITY = 'teamcity', PRETTY = 'pretty', GITHUB_ACTIONS = 'github-actions', + SARIF = 'sarif', } export interface ILintConfig { diff --git a/packages/cli/src/services/output.ts b/packages/cli/src/services/output.ts index bfc2e3ed6..7f0a85a59 100644 --- a/packages/cli/src/services/output.ts +++ b/packages/cli/src/services/output.ts @@ -1,7 +1,17 @@ import * as process from 'process'; import { IRuleResult } from '@stoplight/spectral-core'; import { promises as fs } from 'fs'; -import { html, json, junit, stylish, teamcity, text, pretty, githubActions } from '@stoplight/spectral-formatters'; +import { + html, + json, + junit, + stylish, + teamcity, + text, + pretty, + githubActions, + sarif, +} from '@stoplight/spectral-formatters'; import type { Formatter, FormatterOptions } from '@stoplight/spectral-formatters'; import type { OutputFormat } from './config'; @@ -14,6 +24,7 @@ const formatters: Record = { text, teamcity, 'github-actions': githubActions, + sarif, }; export function formatOutput(results: IRuleResult[], format: OutputFormat, formatOptions: FormatterOptions): string { diff --git a/packages/formatters/README.md b/packages/formatters/README.md index ebade72f6..809d02e99 100644 --- a/packages/formatters/README.md +++ b/packages/formatters/README.md @@ -33,3 +33,4 @@ console.error(output); - pretty - github-actions +- sarif diff --git a/packages/formatters/package.json b/packages/formatters/package.json index 96acb6e0f..b5c84fafd 100644 --- a/packages/formatters/package.json +++ b/packages/formatters/package.json @@ -41,6 +41,7 @@ "chalk": "4.1.2", "cliui": "7.0.4", "lodash": "^4.17.21", + "node-sarif-builder": "^2.0.3", "strip-ansi": "6.0", "text-table": "^0.2.0", "tslib": "^2.5.0" diff --git a/packages/formatters/src/__tests__/github-actions.test.ts b/packages/formatters/src/__tests__/github-actions.jest.test.ts similarity index 100% rename from packages/formatters/src/__tests__/github-actions.test.ts rename to packages/formatters/src/__tests__/github-actions.jest.test.ts diff --git a/packages/formatters/src/__tests__/sarif.jest.test.ts b/packages/formatters/src/__tests__/sarif.jest.test.ts new file mode 100644 index 000000000..9e2b721ee --- /dev/null +++ b/packages/formatters/src/__tests__/sarif.jest.test.ts @@ -0,0 +1,137 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import type { IRuleResult } from '@stoplight/spectral-core'; +import { sarif, sarifToolVersion } from '../sarif'; + +const cwd = process.cwd(); +const results: IRuleResult[] = [ + { + code: 'operation-description', + message: 'paths./pets.get.description is not truthy\nMessage can have\nmultiple lines', + path: ['paths', '/pets', 'get', 'description'], + severity: 1, + source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`, + range: { + start: { + line: 60, + character: 8, + }, + end: { + line: 71, + character: 60, + }, + }, + }, + { + code: 'operation-tags', + message: 'paths./pets.get.tags is not truthy', + path: ['paths', '/pets', 'get', 'tags'], + severity: 0, + source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`, + range: { + start: { + line: 60, + character: 8, + }, + end: { + line: 71, + character: 60, + }, + }, + }, +]; + +describe('Sarif formatter', () => { + test('should be formatted correctly', () => { + const output = sarif(results, { failSeverity: DiagnosticSeverity.Error }); + const outputObject = JSON.parse(output); + const expectedObject = JSON.parse(` + { + "$schema": "http://json.schemastore.org/sarif-2.1.0-rtm.6.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "spectral", + "rules": [ + { + "id": "operation-description", + "shortDescription": { + "text": "paths./pets.get.description is not truthy\\nMessage can have\\nmultiple lines" + } + }, + { + "id": "operation-tags", + "shortDescription": { + "text": "paths./pets.get.tags is not truthy" + } + } + ], + "version": "${sarifToolVersion}", + "informationUri": "https://github.com/stoplightio/spectral" + } + }, + "results": [ + { + "level": "warning", + "message": { + "text": "paths./pets.get.description is not truthy\\nMessage can have\\nmultiple lines" + }, + "ruleId": "operation-description", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "__tests__/fixtures/petstore.oas2.yaml", + "index": 0 + }, + "region": { + "startLine": 61, + "startColumn": 8, + "endLine": 72, + "endColumn": 60 + } + } + } + ], + "ruleIndex": 0 + }, + { + "level": "error", + "message": { + "text": "paths./pets.get.tags is not truthy" + }, + "ruleId": "operation-tags", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "__tests__/fixtures/petstore.oas2.yaml", + "index": 0 + }, + "region": { + "startLine": 61, + "startColumn": 8, + "endLine": 72, + "endColumn": 60 + } + } + } + ], + "ruleIndex": 1 + } + ], + "artifacts": [ + { + "sourceLanguage": "YAML", + "location": { + "uri": "__tests__/fixtures/petstore.oas2.yaml" + } + } + ] + } + ] + }`); + expect(outputObject).toEqual(expectedObject); + }); +}); diff --git a/packages/formatters/src/index.node.ts b/packages/formatters/src/index.node.ts index d9e9b3ce2..e9d7a5b39 100644 --- a/packages/formatters/src/index.node.ts +++ b/packages/formatters/src/index.node.ts @@ -2,3 +2,4 @@ export { html, json, junit, text, stylish, teamcity } from './index'; export type { Formatter, FormatterOptions } from './index'; export { pretty } from './pretty'; export { githubActions } from './github-actions'; +export { sarif } from './sarif'; diff --git a/packages/formatters/src/index.ts b/packages/formatters/src/index.ts index 8caf77087..23f612c6d 100644 --- a/packages/formatters/src/index.ts +++ b/packages/formatters/src/index.ts @@ -14,3 +14,7 @@ export const pretty: Formatter = () => { export const githubActions: Formatter = () => { throw Error('github-actions formatter is available only in Node.js'); }; + +export const sarif: Formatter = () => { + throw Error('sarif formatter is available only in Node.js'); +}; diff --git a/packages/formatters/src/sarif.ts b/packages/formatters/src/sarif.ts new file mode 100644 index 000000000..e4e9b5ae0 --- /dev/null +++ b/packages/formatters/src/sarif.ts @@ -0,0 +1,67 @@ +import { Formatter } from './types'; +import { DiagnosticSeverity, Dictionary } from '@stoplight/types'; +import { relative } from '@stoplight/path'; +import { SarifBuilder, SarifRunBuilder, SarifResultBuilder, SarifRuleBuilder } from 'node-sarif-builder'; +import { Result } from 'sarif'; + +const pkg = require('../../cli/package.json') as PackageJson; + +interface PackageJson { + version: string; +} + +const OUTPUT_TYPES: Dictionary = { + [DiagnosticSeverity.Error]: 'error', + [DiagnosticSeverity.Warning]: 'warning', + [DiagnosticSeverity.Information]: 'note', + [DiagnosticSeverity.Hint]: 'note', +}; + +export const sarif: Formatter = results => { + const sarifBuilder = new SarifBuilder({ + $schema: 'http://json.schemastore.org/sarif-2.1.0-rtm.6.json', + version: '2.1.0', + runs: [], + }); + + const sarifRunBuilder = new SarifRunBuilder().initSimple({ + toolDriverName: 'spectral', + toolDriverVersion: pkg.version, + url: 'https://github.com/stoplightio/spectral', + }); + + const uniqueRuleIds = new Set(); + results.forEach(result => { + const ruleId = result.code.toString(); + + // add to rules + if (!uniqueRuleIds.has(ruleId)) { + uniqueRuleIds.add(ruleId); + const sarifRuleBuilder = new SarifRuleBuilder().initSimple({ + ruleId, + shortDescriptionText: result.message, + }); + sarifRunBuilder.addRule(sarifRuleBuilder); + } + + // add to results + const sarifResultBuilder = new SarifResultBuilder(); + const severity: DiagnosticSeverity = result.severity || DiagnosticSeverity.Error; + sarifResultBuilder.initSimple({ + level: OUTPUT_TYPES[severity] || 'error', + messageText: result.message, + ruleId: ruleId, + fileUri: relative(process.cwd(), result.source ?? '').replace(/\\/g, '/'), + startLine: (result.range.start.line || 1) + 1, + startColumn: result.range.start.character || 1, + endLine: (result.range.end.line || 1) + 1, + endColumn: result.range.end.character || 1, + }); + sarifRunBuilder.addResult(sarifResultBuilder); + }); + + sarifBuilder.addRun(sarifRunBuilder); + return sarifBuilder.buildSarifJsonString({ indent: true }); +}; + +export const sarifToolVersion: string = pkg.version; diff --git a/test-harness/scenarios/help-no-document.scenario b/test-harness/scenarios/help-no-document.scenario index 038861d46..9bfd2f937 100644 --- a/test-harness/scenarios/help-no-document.scenario +++ b/test-harness/scenarios/help-no-document.scenario @@ -20,7 +20,7 @@ Options: --version Show version number [boolean] --help Show help [boolean] -e, --encoding text encoding to use [string] [choices: "utf8", "ascii", "utf-8", "utf16le", "ucs2", "ucs-2", "base64", "latin1"] [default: "utf8"] - -f, --format formatters to use for outputting results, more than one can be given joining them with a comma [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions"] [default: "stylish"] + -f, --format formatters to use for outputting results, more than one can be given joining them with a comma [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif"] [default: "stylish"] -o, --output where to output results, can be a single file name, multiple "output." or missing to print to stdout [string] --stdin-filepath path to a file to pretend that stdin comes from [string] --resolver path to custom json-ref-resolver instance [string] diff --git a/test-harness/scenarios/strict-options.scenario b/test-harness/scenarios/strict-options.scenario index 4fa1203e0..aa820b270 100644 --- a/test-harness/scenarios/strict-options.scenario +++ b/test-harness/scenarios/strict-options.scenario @@ -20,7 +20,7 @@ Options: --version Show version number [boolean] --help Show help [boolean] -e, --encoding text encoding to use [string] [choices: "utf8", "ascii", "utf-8", "utf16le", "ucs2", "ucs-2", "base64", "latin1"] [default: "utf8"] - -f, --format formatters to use for outputting results, more than one can be given joining them with a comma [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions"] [default: "stylish"] + -f, --format formatters to use for outputting results, more than one can be given joining them with a comma [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif"] [default: "stylish"] -o, --output where to output results, can be a single file name, multiple "output." or missing to print to stdout [string] --stdin-filepath path to a file to pretend that stdin comes from [string] --resolver path to custom json-ref-resolver instance [string] diff --git a/yarn.lock b/yarn.lock index 80fa76eda..626cc7257 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2730,6 +2730,7 @@ __metadata: eol: 0.9.1 lodash: ^4.17.21 node-html-parser: ^4.1.5 + node-sarif-builder: ^2.0.3 strip-ansi: 6.0 text-table: ^0.2.0 tslib: ^2.5.0 @@ -3342,6 +3343,13 @@ __metadata: languageName: node linkType: hard +"@types/sarif@npm:^2.1.4": + version: 2.1.4 + resolution: "@types/sarif@npm:2.1.4" + checksum: 1ff924e9ffe468f93c8751d6e8192ca126380a328ba7d8f7abb6d3e7d66080f9d3c93c4db94ddca569b65a2f6d3b82dfe9b79f23500ebb69e0f6d2d12a1dc5c4 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.0 resolution: "@types/stack-utils@npm:2.0.0" @@ -9805,6 +9813,16 @@ __metadata: languageName: node linkType: hard +"node-sarif-builder@npm:^2.0.3": + version: 2.0.3 + resolution: "node-sarif-builder@npm:2.0.3" + dependencies: + "@types/sarif": ^2.1.4 + fs-extra: ^10.0.0 + checksum: 397dd9bfb0780c6753fb47d1fd0465f3c8a935082cb1bbd7ad6232d18b6343d9d499c6bc572ad0415db282efd6058fe8b7a6657020434adef4fbf93a8b95306e + languageName: node + linkType: hard + "nopt@npm:^5.0.0": version: 5.0.0 resolution: "nopt@npm:5.0.0" From 5d8c06e5365be5a7dbd816573e3432b5bad6022b Mon Sep 17 00:00:00 2001 From: Philipp Heuer Date: Thu, 7 Sep 2023 20:49:17 +0200 Subject: [PATCH 2/8] feat(formatters): add ruleset to formatter params for sarif formatter --- .../cli/src/commands/__tests__/lint.test.ts | 4 +- packages/cli/src/commands/lint.ts | 15 +- .../cli/src/services/__tests__/linter.test.ts | 558 ++++++++++-------- packages/cli/src/services/linter/linter.ts | 14 +- packages/cli/src/services/output.ts | 11 +- .../__tests__/__fixtures__/sairf-rules.yml | 19 + .../src/__tests__/github-actions.jest.test.ts | 2 +- .../formatters/src/__tests__/html.test.ts | 2 +- .../formatters/src/__tests__/json.test.ts | 4 +- .../formatters/src/__tests__/junit.test.ts | 8 +- .../src/__tests__/pretty.jest.test.ts | 6 +- .../src/__tests__/sarif.jest.test.ts | 12 +- .../formatters/src/__tests__/stylish.test.ts | 4 +- .../formatters/src/__tests__/teamcity.test.ts | 2 +- .../formatters/src/__tests__/text.test.ts | 2 +- packages/formatters/src/sarif.ts | 24 +- packages/formatters/src/types.ts | 4 +- 17 files changed, 383 insertions(+), 308 deletions(-) create mode 100644 packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml diff --git a/packages/cli/src/commands/__tests__/lint.test.ts b/packages/cli/src/commands/__tests__/lint.test.ts index eb98e7050..69d59fb82 100644 --- a/packages/cli/src/commands/__tests__/lint.test.ts +++ b/packages/cli/src/commands/__tests__/lint.test.ts @@ -48,7 +48,7 @@ describe('lint', () => { ]; beforeEach(() => { - (lint as jest.Mock).mockResolvedValueOnce(results); + (lint as jest.Mock).mockResolvedValueOnce({ results: results, resolvedRuleset: {} }); (formatOutput as jest.Mock).mockReturnValueOnce(''); (writeOutput as jest.Mock).mockResolvedValueOnce(undefined); }); @@ -148,7 +148,7 @@ describe('lint', () => { it.each(['json', 'stylish'])('calls formatOutput with %s format', async format => { await run(`lint -f ${format} ./__fixtures__/empty-oas2-document.json`); - expect(formatOutput).toBeCalledWith(results, format, { failSeverity: DiagnosticSeverity.Error }); + expect(formatOutput).toBeCalledWith(results, format, { failSeverity: DiagnosticSeverity.Error }, expect.anything()); }); it('writes formatted output to a file', async () => { diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 7f4521960..91a4ec518 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -182,7 +182,7 @@ const lintCommand: CommandModule = { }; try { - let results = await lint(documents, { + const linterResult = await lint(documents, { format, output, encoding, @@ -194,18 +194,23 @@ const lintCommand: CommandModule = { }); if (displayOnlyFailures) { - results = filterResultsBySeverity(results, failSeverity); + linterResult.results = filterResultsBySeverity(linterResult.results, failSeverity); } await Promise.all( format.map(f => { - const formattedOutput = formatOutput(results, f, { failSeverity: getDiagnosticSeverity(failSeverity) }); + const formattedOutput = formatOutput( + linterResult.results, + f, + { failSeverity: getDiagnosticSeverity(failSeverity) }, + linterResult.resolvedRuleset, + ); return writeOutput(formattedOutput, output?.[f] ?? ''); }), ); - if (results.length > 0) { - process.exit(severeEnoughToFail(results, failSeverity) ? 1 : 0); + if (linterResult.results.length > 0) { + process.exit(severeEnoughToFail(linterResult.results, failSeverity) ? 1 : 0); } else if (config.quiet !== true) { const isErrorSeverity = getDiagnosticSeverity(failSeverity) === DiagnosticSeverity.Error; process.stdout.write( diff --git a/packages/cli/src/services/__tests__/linter.test.ts b/packages/cli/src/services/__tests__/linter.test.ts index e17a1b77e..b45843d89 100644 --- a/packages/cli/src/services/__tests__/linter.test.ts +++ b/packages/cli/src/services/__tests__/linter.test.ts @@ -10,7 +10,7 @@ import AggregateError = require('es-aggregate-error'); import * as process from 'process'; import lintCommand from '../../commands/lint'; -import { lint } from '../linter'; +import { LinterResult, lint } from '../linter'; jest.mock('process'); jest.mock('../output'); @@ -20,7 +20,7 @@ const invalidRulesetPath = resolve(__dirname, '__fixtures__/ruleset-invalid.js') const validRulesetPath = resolve(__dirname, '__fixtures__/ruleset-valid.js'); const validOas3SpecPath = resolve(__dirname, './__fixtures__/openapi-3.0-valid.yaml'); -async function run(command: string) { +async function run(command: string): Promise { const parser = yargs.command(lintCommand); const { documents, ...opts } = await new Promise((resolve, reject) => { parser.parse(`${command} --ignore-unknown-format`, {}, (err, argv) => { @@ -50,44 +50,44 @@ describe('Linter service', () => { }); it('handles relative path to a document', async () => { - const results = await run('lint -r ./gh-474/ruleset.js ./gh-474/document.json'); - - expect(results).toEqual([ - { - code: 'defined-name', - message: '"name" property must be truthy', - path: ['0', 'name'], - range: { - end: { - character: 16, - line: 1, - }, - start: { - character: 12, - line: 1, + return run('lint -r ./gh-474/ruleset.js ./gh-474/document.json').then(linterResult => { + return expect(linterResult.results).toEqual([ + { + code: 'defined-name', + message: '"name" property must be truthy', + path: ['0', 'name'], + range: { + end: { + character: 16, + line: 1, + }, + start: { + character: 12, + line: 1, + }, }, + severity: DiagnosticSeverity.Warning, + source: join(__dirname, './__fixtures__/gh-474/common.json'), }, - severity: DiagnosticSeverity.Warning, - source: join(__dirname, './__fixtures__/gh-474/common.json'), - }, - { - code: 'defined-name', - message: '"name" property must be truthy', - path: ['1', 'name'], - range: { - end: { - character: 16, - line: 2, - }, - start: { - character: 12, - line: 2, + { + code: 'defined-name', + message: '"name" property must be truthy', + path: ['1', 'name'], + range: { + end: { + character: 16, + line: 2, + }, + start: { + character: 12, + line: 2, + }, }, + severity: DiagnosticSeverity.Warning, + source: join(__dirname, './__fixtures__/gh-474/common.json'), }, - severity: DiagnosticSeverity.Warning, - source: join(__dirname, './__fixtures__/gh-474/common.json'), - }, - ]); + ]); + }); }); it('demands some ruleset to be present', () => { @@ -100,22 +100,26 @@ describe('Linter service', () => { describe('when document is local file', () => { describe('and the file is expected to have no warnings', () => { it('outputs no issues', () => { - return expect(run(`lint stoplight-info-document.json`)).resolves.toEqual([]); + return run(`lint stoplight-info-document.json`).then(linterResult => { + return expect(linterResult.results).toEqual([]); + }); }); }); describe('and the file is expected to trigger warnings', () => { it('outputs warnings', async () => { - return expect(run('lint missing-stoplight-info-document.json')).resolves.toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - }, - ]); + return run(`lint missing-stoplight-info-document.json`).then(linterResult => { + return expect(linterResult.results).toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + }, + ]); + }); }); }); }); @@ -126,61 +130,15 @@ describe('Linter service', () => { join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), ]; - return expect(run(['lint', ...documents].join(' '))).resolves.toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: documents[0], - }, - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: documents[1], - }, - ]); - }); - - it('sorts linting results in an alphabetical order', () => { - const documents = [ - join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), - join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), - ]; - - return expect(run(['lint', ...documents].join(' '))).resolves.toEqual([ - expect.objectContaining({ - code: 'info-matches-stoplight', - source: join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), - }), - expect.objectContaining({ - code: 'info-matches-stoplight', - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - }), - expect.objectContaining({ - code: 'info-matches-stoplight', - source: join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), - }), - ]); - }); - - describe('when glob is provided', () => { - const documents = join(__dirname, `./__fixtures__/missing-stoplight-info*.json`); - - it('outputs issues for each file', () => { - return expect(run(`lint ${documents}`)).resolves.toEqual([ + return run(['lint', ...documents].join(' ')).then(linterResult => { + return expect(linterResult.results).toEqual([ { code: 'info-matches-stoplight', message: 'Info must contain Stoplight', path: ['info', 'title'], range: expect.any(Object), severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), + source: documents[0], }, { code: 'info-matches-stoplight', @@ -188,40 +146,96 @@ describe('Linter service', () => { path: ['info', 'title'], range: expect.any(Object), severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + source: documents[1], }, ]); }); + }); - it('unixifies patterns', () => { - return expect(run(`lint } ${documents.replace(/\//g, '\\')}`)).resolves.toEqual([ - { + it('sorts linting results in an alphabetical order', () => { + const documents = [ + join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), + join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), + ]; + + return run(['lint', ...documents].join(' ')).then(linterResult => { + return expect(linterResult.results).toEqual([ + expect.objectContaining({ code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), - }, - { + source: join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), + }), + expect.objectContaining({ code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - }, + }), + expect.objectContaining({ + code: 'info-matches-stoplight', + source: join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), + }), ]); }); }); + describe('when glob is provided', () => { + const documents = join(__dirname, `./__fixtures__/missing-stoplight-info*.json`); + + it('outputs issues for each file', () => { + return run(`lint ${documents}`).then(linterResult => { + return expect(linterResult.results).toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), + }, + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + }, + ]); + }); + }); + + it('unixifies patterns', () => { + return run(`lint } ${documents.replace(/\//g, '\\')}`).then(linterResult => { + return expect(linterResult.results).toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), + }, + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + }, + ]); + }); + }); + }); + describe('--ruleset', () => { const validNestedRulesetPath = join(__dirname, '__fixtures__/ruleset-extends-valid.js'); const invalidNestedRulesetPath = join(__dirname, '__fixtures__/ruleset-extends-invalid.js'); describe('extends feature', () => { it('extends a valid relative ruleset', () => { - return expect(run(`lint ${validCustomOas3SpecPath} -r ${validNestedRulesetPath}`)).resolves.toEqual([]); + return run(`lint ${validCustomOas3SpecPath} -r ${validNestedRulesetPath}`).then(linterResult => { + return expect(linterResult.results).toEqual([]); + }); }); it('fails trying to extend an invalid relative ruleset', () => { @@ -279,13 +293,17 @@ describe('Linter service', () => { }); it('outputs no issues', () => { - return expect(run(`lint ${validCustomOas3SpecPath} -r ${validRulesetPath}`)).resolves.toEqual([]); + return run(`lint ${validCustomOas3SpecPath} -r ${validRulesetPath}`).then(linterResult => { + return expect(linterResult.results).toEqual([]); + }); }); it('outputs warnings', async () => { const output = await run(`lint ${validOas3SpecPath} -r ${validRulesetPath}`); - expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); - expect(output).toEqual( + expect(output.results).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), + ); + expect(output.results).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -298,8 +316,10 @@ describe('Linter service', () => { describe('given legacy ruleset', () => { it('outputs warnings', async () => { const output = await run(`lint ${validOas3SpecPath} -r ${join(__dirname, '__fixtures__/ruleset.json')}`); - expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); - expect(output).toEqual( + expect(output.results).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), + ); + expect(output.results).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -317,8 +337,10 @@ describe('Linter service', () => { }); const output = await run(`lint ${validOas3SpecPath} -r http://foo.local/ruleset.json`); - expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); - expect(output).toEqual( + expect(output.results).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), + ); + expect(output.results).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -336,8 +358,10 @@ describe('Linter service', () => { }); const output = await run(`lint ${validOas3SpecPath} -r http://foo.local/ruleset`); - expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); - expect(output).toEqual( + expect(output.results).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), + ); + expect(output.results).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -356,8 +380,10 @@ describe('Linter service', () => { }); const output = await run(`lint ${validOas3SpecPath} -r http://foo.local/ruleset.json?token=bar`); - expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); - expect(output).toEqual( + expect(output.results).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), + ); + expect(output.results).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -375,7 +401,9 @@ describe('Linter service', () => { 'Content-Type': 'application/yaml', }); - return expect(run('lint http://foo.local/openapi')).resolves.toEqual([]); + return run('lint http://foo.local/openapi').then(linterResult => { + return expect(linterResult.results).toEqual([]); + }); }); it('throws if cannot load URI', () => { @@ -392,151 +420,159 @@ describe('Linter service', () => { 'Content-Type': 'application/yaml', }); - return expect(run(`lint http://foo.local/openapi`)).resolves.toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: 'http://foo.local/openapi', - }, - ]); + return run(`lint http://foo.local/openapi`).then(linterResult => { + return expect(linterResult.results).toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: 'http://foo.local/openapi', + }, + ]); + }); }); }); describe('when using default ruleset file', () => { it('respects rules from a ruleset file', () => { - return expect(run('lint missing-stoplight-info-document.json')).resolves.toEqual([ - expect.objectContaining({ - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - }), - ]); + return run('lint missing-stoplight-info-document.json').then(linterResult => { + return expect(linterResult.results).toEqual([ + expect.objectContaining({ + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + }), + ]); + }); }); }); describe('$ref linting', () => { it('outputs errors occurring in referenced files', () => { - return expect(run(`lint -r references/ruleset.js references/no-nested.json`)).resolves.toEqual([ - expect.objectContaining({ - code: 'valid-schema', - message: '"info" property must have required property "version"', - path: ['definitions', 'info'], - range: { - end: { - character: 5, - line: 8, + return run(`lint -r references/ruleset.js references/no-nested.json`).then(linterResult => { + return expect(linterResult.results).toEqual([ + expect.objectContaining({ + code: 'valid-schema', + message: '"info" property must have required property "version"', + path: ['definitions', 'info'], + range: { + end: { + character: 5, + line: 8, + }, + start: { + character: 12, + line: 3, + }, }, - start: { - character: 12, - line: 3, - }, - }, - source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"description" property type must be string', - path: ['definitions', 'info', 'description'], - range: { - end: { - character: 22, - line: 4, - }, - start: { - character: 21, - line: 4, - }, - }, - source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: 'Property "foo" is not expected to be here', - path: ['paths'], - range: { - end: { - character: 13, - line: 6, + source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"description" property type must be string', + path: ['definitions', 'info', 'description'], + range: { + end: { + character: 22, + line: 4, + }, + start: { + character: 21, + line: 4, + }, }, - start: { - character: 10, - line: 4, + source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: 'Property "foo" is not expected to be here', + path: ['paths'], + range: { + end: { + character: 13, + line: 6, + }, + start: { + character: 10, + line: 4, + }, }, - }, - source: expect.stringContaining('__tests__/__fixtures__/references/no-nested.json'), - }), - ]); + source: expect.stringContaining('__tests__/__fixtures__/references/no-nested.json'), + }), + ]); + }); }); it('outputs errors occurring in nested referenced files', () => { - return expect(run(`lint -r references/ruleset.js references/nested.json`)).resolves.toEqual([ - expect.objectContaining({ - code: 'valid-schema', - message: '"info" property must have required property "version"', - path: [], - range: { - end: { - character: 1, - line: 3, - }, - start: { - character: 0, - line: 0, - }, - }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"description" property type must be string', - path: ['description'], - range: { - end: { - character: 18, - line: 2, - }, - start: { - character: 17, - line: 2, - }, - }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"get" property must have required property "responses"', - path: ['paths', '/test', 'get'], - range: { - end: { - character: 7, - line: 5, + return run(`lint -r references/ruleset.js references/nested.json`).then(linterResult => { + return expect(linterResult.results).toEqual([ + expect.objectContaining({ + code: 'valid-schema', + message: '"info" property must have required property "version"', + path: [], + range: { + end: { + character: 1, + line: 3, + }, + start: { + character: 0, + line: 0, + }, }, - start: { - character: 13, - line: 3, + source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"description" property type must be string', + path: ['description'], + range: { + end: { + character: 18, + line: 2, + }, + start: { + character: 17, + line: 2, + }, }, - }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"response" property type must be number', - path: ['paths', '/test', 'get', 'response'], - range: { - end: { - character: 25, - line: 4, + source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"get" property must have required property "responses"', + path: ['paths', '/test', 'get'], + range: { + end: { + character: 7, + line: 5, + }, + start: { + character: 13, + line: 3, + }, }, - start: { - character: 20, - line: 4, + source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"response" property type must be number', + path: ['paths', '/test', 'get', 'response'], + range: { + end: { + character: 25, + line: 4, + }, + start: { + character: 20, + line: 4, + }, }, - }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), - }), - ]); + source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), + }), + ]); + }); }); }); @@ -545,16 +581,18 @@ describe('Linter service', () => { const resolver = join(__dirname, '__fixtures__/resolver/resolver.js'); const document = join(__dirname, '__fixtures__/resolver/document.json'); - expect(await run(`lint --resolver ${resolver} ${document}`)).toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: [], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: expect.stringContaining('__fixtures__/resolver/document.json'), - }, - ]); + return run(`lint --resolver ${resolver} ${document}`).then(linterResult => { + return expect(linterResult.results).toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: [], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: expect.stringContaining('__fixtures__/resolver/document.json'), + }, + ]); + }); }); }); }); diff --git a/packages/cli/src/services/linter/linter.ts b/packages/cli/src/services/linter/linter.ts index c8249113a..aaaf19d9c 100644 --- a/packages/cli/src/services/linter/linter.ts +++ b/packages/cli/src/services/linter/linter.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import { Document, IRuleResult, Spectral } from '@stoplight/spectral-core'; +import { Document, IRuleResult, Ruleset, Spectral } from '@stoplight/spectral-core'; import { readParsable, IFileReadOptions } from '@stoplight/spectral-runtime'; import * as Parsers from '@stoplight/spectral-parsers'; import { getRuleset, listFiles, segregateEntriesPerKind, readFileDescriptor } from './utils'; @@ -7,7 +7,12 @@ import { getResolver } from './utils/getResolver'; import { ILintConfig } from '../config'; import { CLIError } from '../../errors'; -export async function lint(documents: Array, flags: ILintConfig): Promise { +export interface LinterResult { + results: IRuleResult[]; + resolvedRuleset: Ruleset; +} + +export async function lint(documents: Array, flags: ILintConfig): Promise { const spectral = new Spectral({ resolver: getResolver(flags.resolver), }); @@ -48,7 +53,10 @@ export async function lint(documents: Array, flags: ILintConfig ); } - return results; + return { + results: results, + resolvedRuleset: ruleset, + }; } const createDocument = async ( diff --git a/packages/cli/src/services/output.ts b/packages/cli/src/services/output.ts index 7f0a85a59..e23225554 100644 --- a/packages/cli/src/services/output.ts +++ b/packages/cli/src/services/output.ts @@ -1,5 +1,5 @@ import * as process from 'process'; -import { IRuleResult } from '@stoplight/spectral-core'; +import { IRuleResult, Ruleset } from '@stoplight/spectral-core'; import { promises as fs } from 'fs'; import { html, @@ -27,8 +27,13 @@ const formatters: Record = { sarif, }; -export function formatOutput(results: IRuleResult[], format: OutputFormat, formatOptions: FormatterOptions): string { - return formatters[format](results, formatOptions); +export function formatOutput( + results: IRuleResult[], + format: OutputFormat, + formatOptions: FormatterOptions, + ruleset: Ruleset, +): string { + return formatters[format](results, formatOptions, ruleset); } export async function writeOutput(outputStr: string, outputFile: string): Promise { diff --git a/packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml b/packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml new file mode 100644 index 000000000..799fa8b31 --- /dev/null +++ b/packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml @@ -0,0 +1,19 @@ +rules: + operation-description: + description: paths./pets.get.description is not truthy + message: paths./pets.get.description is not truthy + severity: error + given: "$.paths[*]~" + then: + function: pattern + functionOptions: + match: "^[a-z0-9\/\\{\\}]+$" + operation-tags: + description: paths./pets.get.tags is not truthy + message: paths./pets.get.tags is not truthy + severity: error + given: "$.paths[*]~" + then: + function: pattern + functionOptions: + match: "^[a-z0-9\/\\{\\}]+$" diff --git a/packages/formatters/src/__tests__/github-actions.jest.test.ts b/packages/formatters/src/__tests__/github-actions.jest.test.ts index c81d76fec..b13fa8ee5 100644 --- a/packages/formatters/src/__tests__/github-actions.jest.test.ts +++ b/packages/formatters/src/__tests__/github-actions.jest.test.ts @@ -42,7 +42,7 @@ const results: IRuleResult[] = [ describe('GitHub Actions formatter', () => { test('should be formatted correctly', () => { - expect(githubActions(results, { failSeverity: DiagnosticSeverity.Error }).split('\n')).toEqual([ + expect(githubActions(results, { failSeverity: DiagnosticSeverity.Error }, null).split('\n')).toEqual([ '::warning title=operation-description,file=__tests__/fixtures/petstore.oas2.yaml,col=9,endColumn=61,line=61,endLine=72::paths./pets.get.description is not truthy%0AMessage can have%0Amultiple lines', '::warning title=operation-tags,file=__tests__/fixtures/petstore.oas2.yaml,col=9,endColumn=61,line=61,endLine=72::paths./pets.get.tags is not truthy', ]); diff --git a/packages/formatters/src/__tests__/html.test.ts b/packages/formatters/src/__tests__/html.test.ts index 8db9acb7e..25b6e38e9 100644 --- a/packages/formatters/src/__tests__/html.test.ts +++ b/packages/formatters/src/__tests__/html.test.ts @@ -6,7 +6,7 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('HTML formatter', () => { test('should display proper severity levels', () => { - const result = parse(html(mixedErrors, { failSeverity: DiagnosticSeverity.Error })); + const result = parse(html(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null)); const table = result.querySelector('table tbody'); expect(table.innerHTML.trim()).toEqual(` diff --git a/packages/formatters/src/__tests__/json.test.ts b/packages/formatters/src/__tests__/json.test.ts index 57b872095..d8e879d17 100644 --- a/packages/formatters/src/__tests__/json.test.ts +++ b/packages/formatters/src/__tests__/json.test.ts @@ -41,7 +41,7 @@ const results: IRuleResult[] = [ describe('JSON formatter', () => { test('should include ranges', () => { - expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([ + expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }, null))).toEqual([ expect.objectContaining({ range: { start: { @@ -70,7 +70,7 @@ describe('JSON formatter', () => { }); test('should include message', () => { - expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([ + expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }, null))).toEqual([ expect.objectContaining({ message: 'paths./pets.get.description is not truthy', }), diff --git a/packages/formatters/src/__tests__/junit.test.ts b/packages/formatters/src/__tests__/junit.test.ts index 5fcad87f8..b58bb3a57 100644 --- a/packages/formatters/src/__tests__/junit.test.ts +++ b/packages/formatters/src/__tests__/junit.test.ts @@ -15,7 +15,7 @@ describe('JUnit formatter', () => { }); test('should produce valid report', async () => { - const result = await parse(junit(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error })); + const result = await parse(junit(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null)); expect(result).toEqual({ testsuites: { testsuite: [ @@ -82,7 +82,7 @@ describe('JUnit formatter', () => { }); test('given failSeverity set to error, should filter out non-error validation results', async () => { - const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Error })); + const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null)); expect(result).toEqual({ testsuites: { testsuite: [ @@ -119,7 +119,7 @@ describe('JUnit formatter', () => { }); test('given failSeverity set to other value than error, should filter treat all validation results matching the severity as errors', async () => { - const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Warning })); + const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Warning }, null)); expect(result).toEqual({ testsuites: { testsuite: [ @@ -171,7 +171,7 @@ describe('JUnit formatter', () => { }); test('handles special XML strings properly', async () => { - const result = await parse(junit(specialXmlStrings, { failSeverity: DiagnosticSeverity.Error })); + const result = await parse(junit(specialXmlStrings, { failSeverity: DiagnosticSeverity.Error }, null)); expect(result).toEqual({ testsuites: { testsuite: [ diff --git a/packages/formatters/src/__tests__/pretty.jest.test.ts b/packages/formatters/src/__tests__/pretty.jest.test.ts index 8a14b1da4..7503186be 100644 --- a/packages/formatters/src/__tests__/pretty.jest.test.ts +++ b/packages/formatters/src/__tests__/pretty.jest.test.ts @@ -31,7 +31,7 @@ function forceWrapped(s: string, wrapType: number): string { describe('Pretty formatter', () => { test('should not wrap when terminal width is wide enough', () => { setColumnWidth(185, function (): void { - const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }); + const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null); expect(result).toContain( `${chalk.red('36:22')} ${chalk.red.inverse('ERROR')} ${chalk.red.bold( 'oas3-schema', @@ -58,7 +58,7 @@ describe('Pretty formatter', () => { }); xtest('should wrap when terminal width is very small', () => { setColumnWidth(120, function (): void { - const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }); + const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null); expect(result).toContain(` File: /home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.invalid-schema.oas3.yaml ${chalk.red( @@ -82,7 +82,7 @@ ${chalk.red.bold('1 Unique Issue(s)')}\n`); }); test('should display proper severity level', () => { setColumnWidth(185, function (): void { - const result = pretty(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); + const result = pretty(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); expect( result, ).toContain(`${chalk.white('3:10')} ${chalk.white.inverse('HINT')} ${chalk.white.bold('info-contact')} ${chalk.gray('Info object should contain `contact` object.')} ${chalk.cyan('info')} diff --git a/packages/formatters/src/__tests__/sarif.jest.test.ts b/packages/formatters/src/__tests__/sarif.jest.test.ts index 9e2b721ee..e00dea4ac 100644 --- a/packages/formatters/src/__tests__/sarif.jest.test.ts +++ b/packages/formatters/src/__tests__/sarif.jest.test.ts @@ -1,12 +1,13 @@ import { DiagnosticSeverity } from '@stoplight/types'; import type { IRuleResult } from '@stoplight/spectral-core'; import { sarif, sarifToolVersion } from '../sarif'; +import { getRuleset } from '@stoplight/spectral-cli/src/services/linter/utils'; const cwd = process.cwd(); const results: IRuleResult[] = [ { code: 'operation-description', - message: 'paths./pets.get.description is not truthy\nMessage can have\nmultiple lines', + message: 'paths./pets.get.description is not truthy\nMessages can differ from the rule description', path: ['paths', '/pets', 'get', 'description'], severity: 1, source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`, @@ -41,8 +42,9 @@ const results: IRuleResult[] = [ ]; describe('Sarif formatter', () => { - test('should be formatted correctly', () => { - const output = sarif(results, { failSeverity: DiagnosticSeverity.Error }); + test('should be formatted correctly', async () => { + const ruleset = await getRuleset(`${__dirname}/__fixtures__/sairf-rules.yml`); + const output = sarif(results, { failSeverity: DiagnosticSeverity.Error }, ruleset); const outputObject = JSON.parse(output); const expectedObject = JSON.parse(` { @@ -57,7 +59,7 @@ describe('Sarif formatter', () => { { "id": "operation-description", "shortDescription": { - "text": "paths./pets.get.description is not truthy\\nMessage can have\\nmultiple lines" + "text": "paths./pets.get.description is not truthy" } }, { @@ -75,7 +77,7 @@ describe('Sarif formatter', () => { { "level": "warning", "message": { - "text": "paths./pets.get.description is not truthy\\nMessage can have\\nmultiple lines" + "text": "paths./pets.get.description is not truthy\\nMessages can differ from the rule description" }, "ruleId": "operation-description", "locations": [ diff --git a/packages/formatters/src/__tests__/stylish.test.ts b/packages/formatters/src/__tests__/stylish.test.ts index 9dc203191..613a4eead 100644 --- a/packages/formatters/src/__tests__/stylish.test.ts +++ b/packages/formatters/src/__tests__/stylish.test.ts @@ -7,14 +7,14 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('Stylish formatter', () => { test('should prefer message for oas-schema errors', () => { - const result = stylish(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }); + const result = stylish(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null); expect(result).toContain('oas3-schema should NOT have additional properties: type'); expect(result).toContain('oas3-schema should match exactly one schema in oneOf'); expect(result).toContain("oas3-schema should have required property '$ref'"); }); test('should display proper severity level', () => { - const result = stylish(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); + const result = stylish(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); expect(result).toContain(` 3:10 ${chalk.white( 'hint', diff --git a/packages/formatters/src/__tests__/teamcity.test.ts b/packages/formatters/src/__tests__/teamcity.test.ts index 027794a47..74beedf74 100644 --- a/packages/formatters/src/__tests__/teamcity.test.ts +++ b/packages/formatters/src/__tests__/teamcity.test.ts @@ -5,7 +5,7 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('Teamcity formatter', () => { test('should format messages', () => { - const result = teamcity(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); + const result = teamcity(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); expect(result) .toContain(`##teamcity[inspectionType category='openapi' id='info-contact' name='info-contact' description='hint -- Info object should contain \`contact\` object.'] ##teamcity[inspection typeId='info-contact' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='3' message='hint -- Info object should contain \`contact\` object.'] diff --git a/packages/formatters/src/__tests__/text.test.ts b/packages/formatters/src/__tests__/text.test.ts index 8c6c70f2c..0641e058d 100644 --- a/packages/formatters/src/__tests__/text.test.ts +++ b/packages/formatters/src/__tests__/text.test.ts @@ -5,7 +5,7 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('Text formatter', () => { test('should format messages', () => { - const result = text(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); + const result = text(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); expect(result) .toContain(`/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:3:10 hint info-contact "Info object should contain \`contact\` object." /home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:3:10 warning info-description "OpenAPI object info \`description\` must be present and non-empty string." diff --git a/packages/formatters/src/sarif.ts b/packages/formatters/src/sarif.ts index e4e9b5ae0..b2e59fe06 100644 --- a/packages/formatters/src/sarif.ts +++ b/packages/formatters/src/sarif.ts @@ -17,7 +17,7 @@ const OUTPUT_TYPES: Dictionary = { [DiagnosticSeverity.Hint]: 'note', }; -export const sarif: Formatter = results => { +export const sarif: Formatter = (results, _, ruleset) => { const sarifBuilder = new SarifBuilder({ $schema: 'http://json.schemastore.org/sarif-2.1.0-rtm.6.json', version: '2.1.0', @@ -30,27 +30,26 @@ export const sarif: Formatter = results => { url: 'https://github.com/stoplightio/spectral', }); - const uniqueRuleIds = new Set(); - results.forEach(result => { - const ruleId = result.code.toString(); - - // add to rules - if (!uniqueRuleIds.has(ruleId)) { - uniqueRuleIds.add(ruleId); + // add rules + if (ruleset != null) { + for (const rule of Object.values(ruleset.rules)) { const sarifRuleBuilder = new SarifRuleBuilder().initSimple({ - ruleId, - shortDescriptionText: result.message, + ruleId: rule.name, + shortDescriptionText: rule.description ?? 'No description.', + helpUri: rule.documentationUrl !== null ? rule.documentationUrl : undefined, }); sarifRunBuilder.addRule(sarifRuleBuilder); } + } - // add to results + // add results + results.forEach(result => { const sarifResultBuilder = new SarifResultBuilder(); const severity: DiagnosticSeverity = result.severity || DiagnosticSeverity.Error; sarifResultBuilder.initSimple({ level: OUTPUT_TYPES[severity] || 'error', messageText: result.message, - ruleId: ruleId, + ruleId: result.code.toString(), fileUri: relative(process.cwd(), result.source ?? '').replace(/\\/g, '/'), startLine: (result.range.start.line || 1) + 1, startColumn: result.range.start.character || 1, @@ -59,7 +58,6 @@ export const sarif: Formatter = results => { }); sarifRunBuilder.addResult(sarifResultBuilder); }); - sarifBuilder.addRun(sarifRunBuilder); return sarifBuilder.buildSarifJsonString({ indent: true }); }; diff --git a/packages/formatters/src/types.ts b/packages/formatters/src/types.ts index 80607838e..389d7bbff 100644 --- a/packages/formatters/src/types.ts +++ b/packages/formatters/src/types.ts @@ -1,8 +1,8 @@ -import { ISpectralDiagnostic } from '@stoplight/spectral-core'; +import { ISpectralDiagnostic, Ruleset } from '@stoplight/spectral-core'; import type { DiagnosticSeverity } from '@stoplight/types'; export type FormatterOptions = { failSeverity: DiagnosticSeverity; }; -export type Formatter = (results: ISpectralDiagnostic[], options: FormatterOptions) => string; +export type Formatter = (results: ISpectralDiagnostic[], options: FormatterOptions, ruleset: Ruleset | null) => string; From 22e8146fbfe06b73f73b869cd61d9f172fb61dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 14 Sep 2023 17:20:45 +0200 Subject: [PATCH 3/8] chore(formatters): avoid a breaking change --- .../__tests__/__fixtures__/sairf-rules.yml | 19 -- .../src/__tests__/github-actions.jest.test.ts | 2 +- .../formatters/src/__tests__/html.test.ts | 2 +- .../formatters/src/__tests__/json.test.ts | 4 +- .../formatters/src/__tests__/junit.test.ts | 8 +- .../src/__tests__/pretty.jest.test.ts | 6 +- .../src/__tests__/sarif.jest.test.ts | 184 ++++++++++-------- .../formatters/src/__tests__/stylish.test.ts | 4 +- .../formatters/src/__tests__/teamcity.test.ts | 2 +- .../formatters/src/__tests__/text.test.ts | 2 +- packages/formatters/src/sarif.ts | 49 +++-- packages/formatters/src/types.ts | 7 +- 12 files changed, 151 insertions(+), 138 deletions(-) delete mode 100644 packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml diff --git a/packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml b/packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml deleted file mode 100644 index 799fa8b31..000000000 --- a/packages/formatters/src/__tests__/__fixtures__/sairf-rules.yml +++ /dev/null @@ -1,19 +0,0 @@ -rules: - operation-description: - description: paths./pets.get.description is not truthy - message: paths./pets.get.description is not truthy - severity: error - given: "$.paths[*]~" - then: - function: pattern - functionOptions: - match: "^[a-z0-9\/\\{\\}]+$" - operation-tags: - description: paths./pets.get.tags is not truthy - message: paths./pets.get.tags is not truthy - severity: error - given: "$.paths[*]~" - then: - function: pattern - functionOptions: - match: "^[a-z0-9\/\\{\\}]+$" diff --git a/packages/formatters/src/__tests__/github-actions.jest.test.ts b/packages/formatters/src/__tests__/github-actions.jest.test.ts index b13fa8ee5..c81d76fec 100644 --- a/packages/formatters/src/__tests__/github-actions.jest.test.ts +++ b/packages/formatters/src/__tests__/github-actions.jest.test.ts @@ -42,7 +42,7 @@ const results: IRuleResult[] = [ describe('GitHub Actions formatter', () => { test('should be formatted correctly', () => { - expect(githubActions(results, { failSeverity: DiagnosticSeverity.Error }, null).split('\n')).toEqual([ + expect(githubActions(results, { failSeverity: DiagnosticSeverity.Error }).split('\n')).toEqual([ '::warning title=operation-description,file=__tests__/fixtures/petstore.oas2.yaml,col=9,endColumn=61,line=61,endLine=72::paths./pets.get.description is not truthy%0AMessage can have%0Amultiple lines', '::warning title=operation-tags,file=__tests__/fixtures/petstore.oas2.yaml,col=9,endColumn=61,line=61,endLine=72::paths./pets.get.tags is not truthy', ]); diff --git a/packages/formatters/src/__tests__/html.test.ts b/packages/formatters/src/__tests__/html.test.ts index 25b6e38e9..8db9acb7e 100644 --- a/packages/formatters/src/__tests__/html.test.ts +++ b/packages/formatters/src/__tests__/html.test.ts @@ -6,7 +6,7 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('HTML formatter', () => { test('should display proper severity levels', () => { - const result = parse(html(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null)); + const result = parse(html(mixedErrors, { failSeverity: DiagnosticSeverity.Error })); const table = result.querySelector('table tbody'); expect(table.innerHTML.trim()).toEqual(` diff --git a/packages/formatters/src/__tests__/json.test.ts b/packages/formatters/src/__tests__/json.test.ts index d8e879d17..57b872095 100644 --- a/packages/formatters/src/__tests__/json.test.ts +++ b/packages/formatters/src/__tests__/json.test.ts @@ -41,7 +41,7 @@ const results: IRuleResult[] = [ describe('JSON formatter', () => { test('should include ranges', () => { - expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }, null))).toEqual([ + expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([ expect.objectContaining({ range: { start: { @@ -70,7 +70,7 @@ describe('JSON formatter', () => { }); test('should include message', () => { - expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }, null))).toEqual([ + expect(JSON.parse(json(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([ expect.objectContaining({ message: 'paths./pets.get.description is not truthy', }), diff --git a/packages/formatters/src/__tests__/junit.test.ts b/packages/formatters/src/__tests__/junit.test.ts index b58bb3a57..5fcad87f8 100644 --- a/packages/formatters/src/__tests__/junit.test.ts +++ b/packages/formatters/src/__tests__/junit.test.ts @@ -15,7 +15,7 @@ describe('JUnit formatter', () => { }); test('should produce valid report', async () => { - const result = await parse(junit(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null)); + const result = await parse(junit(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error })); expect(result).toEqual({ testsuites: { testsuite: [ @@ -82,7 +82,7 @@ describe('JUnit formatter', () => { }); test('given failSeverity set to error, should filter out non-error validation results', async () => { - const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null)); + const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Error })); expect(result).toEqual({ testsuites: { testsuite: [ @@ -119,7 +119,7 @@ describe('JUnit formatter', () => { }); test('given failSeverity set to other value than error, should filter treat all validation results matching the severity as errors', async () => { - const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Warning }, null)); + const result = await parse(junit(mixedErrors, { failSeverity: DiagnosticSeverity.Warning })); expect(result).toEqual({ testsuites: { testsuite: [ @@ -171,7 +171,7 @@ describe('JUnit formatter', () => { }); test('handles special XML strings properly', async () => { - const result = await parse(junit(specialXmlStrings, { failSeverity: DiagnosticSeverity.Error }, null)); + const result = await parse(junit(specialXmlStrings, { failSeverity: DiagnosticSeverity.Error })); expect(result).toEqual({ testsuites: { testsuite: [ diff --git a/packages/formatters/src/__tests__/pretty.jest.test.ts b/packages/formatters/src/__tests__/pretty.jest.test.ts index 7503186be..8a14b1da4 100644 --- a/packages/formatters/src/__tests__/pretty.jest.test.ts +++ b/packages/formatters/src/__tests__/pretty.jest.test.ts @@ -31,7 +31,7 @@ function forceWrapped(s: string, wrapType: number): string { describe('Pretty formatter', () => { test('should not wrap when terminal width is wide enough', () => { setColumnWidth(185, function (): void { - const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null); + const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }); expect(result).toContain( `${chalk.red('36:22')} ${chalk.red.inverse('ERROR')} ${chalk.red.bold( 'oas3-schema', @@ -58,7 +58,7 @@ describe('Pretty formatter', () => { }); xtest('should wrap when terminal width is very small', () => { setColumnWidth(120, function (): void { - const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null); + const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }); expect(result).toContain(` File: /home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.invalid-schema.oas3.yaml ${chalk.red( @@ -82,7 +82,7 @@ ${chalk.red.bold('1 Unique Issue(s)')}\n`); }); test('should display proper severity level', () => { setColumnWidth(185, function (): void { - const result = pretty(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); + const result = pretty(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); expect( result, ).toContain(`${chalk.white('3:10')} ${chalk.white.inverse('HINT')} ${chalk.white.bold('info-contact')} ${chalk.gray('Info object should contain `contact` object.')} ${chalk.cyan('info')} diff --git a/packages/formatters/src/__tests__/sarif.jest.test.ts b/packages/formatters/src/__tests__/sarif.jest.test.ts index e00dea4ac..2e3ef2e07 100644 --- a/packages/formatters/src/__tests__/sarif.jest.test.ts +++ b/packages/formatters/src/__tests__/sarif.jest.test.ts @@ -1,7 +1,7 @@ import { DiagnosticSeverity } from '@stoplight/types'; import type { IRuleResult } from '@stoplight/spectral-core'; -import { sarif, sarifToolVersion } from '../sarif'; -import { getRuleset } from '@stoplight/spectral-cli/src/services/linter/utils'; +import { Ruleset } from '@stoplight/spectral-core'; +import { sarif } from '../sarif'; const cwd = process.cwd(); const results: IRuleResult[] = [ @@ -9,7 +9,7 @@ const results: IRuleResult[] = [ code: 'operation-description', message: 'paths./pets.get.description is not truthy\nMessages can differ from the rule description', path: ['paths', '/pets', 'get', 'description'], - severity: 1, + severity: DiagnosticSeverity.Warning, source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`, range: { start: { @@ -26,7 +26,7 @@ const results: IRuleResult[] = [ code: 'operation-tags', message: 'paths./pets.get.tags is not truthy', path: ['paths', '/pets', 'get', 'tags'], - severity: 0, + severity: DiagnosticSeverity.Error, source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`, range: { start: { @@ -43,97 +43,129 @@ const results: IRuleResult[] = [ describe('Sarif formatter', () => { test('should be formatted correctly', async () => { - const ruleset = await getRuleset(`${__dirname}/__fixtures__/sairf-rules.yml`); - const output = sarif(results, { failSeverity: DiagnosticSeverity.Error }, ruleset); + const sarifToolVersion = '6.11'; + const ruleset = new Ruleset({ + rules: { + 'operation-description': { + description: 'paths./pets.get.description is not truthy', + message: 'paths./pets.get.description is not truthy\nMessages can differ from the rule description', + severity: DiagnosticSeverity.Error, + given: '$.paths[*][*]', + then: { + field: 'description', + function: function truthy() { + return false; + }, + }, + }, + 'operation-tags': { + description: 'paths./pets.get.tags is not truthy', + message: 'paths./pets.get.tags is not truthy\nMessages can differ from the rule description', + severity: DiagnosticSeverity.Error, + given: '$.paths[*][*]', + then: { + field: 'description', + function: function truthy() { + return false; + }, + }, + }, + }, + }); + + const output = sarif( + results, + { failSeverity: DiagnosticSeverity.Error }, + { ruleset, spectralVersion: sarifToolVersion }, + ); + const outputObject = JSON.parse(output); - const expectedObject = JSON.parse(` - { - "$schema": "http://json.schemastore.org/sarif-2.1.0-rtm.6.json", - "version": "2.1.0", - "runs": [ + expect(outputObject).toStrictEqual({ + $schema: 'http://json.schemastore.org/sarif-2.1.0-rtm.6.json', + version: '2.1.0', + runs: [ { - "tool": { - "driver": { - "name": "spectral", - "rules": [ + tool: { + driver: { + name: 'spectral', + rules: [ { - "id": "operation-description", - "shortDescription": { - "text": "paths./pets.get.description is not truthy" - } + id: 'operation-description', + shortDescription: { + text: 'paths./pets.get.description is not truthy', + }, }, { - "id": "operation-tags", - "shortDescription": { - "text": "paths./pets.get.tags is not truthy" - } - } + id: 'operation-tags', + shortDescription: { + text: 'paths./pets.get.tags is not truthy', + }, + }, ], - "version": "${sarifToolVersion}", - "informationUri": "https://github.com/stoplightio/spectral" - } + version: sarifToolVersion, + informationUri: 'https://github.com/stoplightio/spectral', + }, }, - "results": [ + results: [ { - "level": "warning", - "message": { - "text": "paths./pets.get.description is not truthy\\nMessages can differ from the rule description" + level: 'warning', + message: { + text: 'paths./pets.get.description is not truthy\nMessages can differ from the rule description', }, - "ruleId": "operation-description", - "locations": [ + ruleId: 'operation-description', + locations: [ { - "physicalLocation": { - "artifactLocation": { - "uri": "__tests__/fixtures/petstore.oas2.yaml", - "index": 0 + physicalLocation: { + artifactLocation: { + uri: '__tests__/fixtures/petstore.oas2.yaml', + index: 0, }, - "region": { - "startLine": 61, - "startColumn": 8, - "endLine": 72, - "endColumn": 60 - } - } - } + region: { + startLine: 61, + startColumn: 9, + endLine: 72, + endColumn: 61, + }, + }, + }, ], - "ruleIndex": 0 + ruleIndex: 0, }, { - "level": "error", - "message": { - "text": "paths./pets.get.tags is not truthy" + level: 'error', + message: { + text: 'paths./pets.get.tags is not truthy', }, - "ruleId": "operation-tags", - "locations": [ + ruleId: 'operation-tags', + locations: [ { - "physicalLocation": { - "artifactLocation": { - "uri": "__tests__/fixtures/petstore.oas2.yaml", - "index": 0 + physicalLocation: { + artifactLocation: { + uri: '__tests__/fixtures/petstore.oas2.yaml', + index: 0, }, - "region": { - "startLine": 61, - "startColumn": 8, - "endLine": 72, - "endColumn": 60 - } - } - } + region: { + startLine: 61, + startColumn: 9, + endLine: 72, + endColumn: 61, + }, + }, + }, ], - "ruleIndex": 1 - } + ruleIndex: 1, + }, ], - "artifacts": [ + artifacts: [ { - "sourceLanguage": "YAML", - "location": { - "uri": "__tests__/fixtures/petstore.oas2.yaml" - } - } - ] - } - ] - }`); - expect(outputObject).toEqual(expectedObject); + sourceLanguage: 'YAML', + location: { + uri: '__tests__/fixtures/petstore.oas2.yaml', + }, + }, + ], + }, + ], + }); }); }); diff --git a/packages/formatters/src/__tests__/stylish.test.ts b/packages/formatters/src/__tests__/stylish.test.ts index 613a4eead..9dc203191 100644 --- a/packages/formatters/src/__tests__/stylish.test.ts +++ b/packages/formatters/src/__tests__/stylish.test.ts @@ -7,14 +7,14 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('Stylish formatter', () => { test('should prefer message for oas-schema errors', () => { - const result = stylish(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }, null); + const result = stylish(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error }); expect(result).toContain('oas3-schema should NOT have additional properties: type'); expect(result).toContain('oas3-schema should match exactly one schema in oneOf'); expect(result).toContain("oas3-schema should have required property '$ref'"); }); test('should display proper severity level', () => { - const result = stylish(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); + const result = stylish(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); expect(result).toContain(` 3:10 ${chalk.white( 'hint', diff --git a/packages/formatters/src/__tests__/teamcity.test.ts b/packages/formatters/src/__tests__/teamcity.test.ts index 74beedf74..027794a47 100644 --- a/packages/formatters/src/__tests__/teamcity.test.ts +++ b/packages/formatters/src/__tests__/teamcity.test.ts @@ -5,7 +5,7 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('Teamcity formatter', () => { test('should format messages', () => { - const result = teamcity(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); + const result = teamcity(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); expect(result) .toContain(`##teamcity[inspectionType category='openapi' id='info-contact' name='info-contact' description='hint -- Info object should contain \`contact\` object.'] ##teamcity[inspection typeId='info-contact' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='3' message='hint -- Info object should contain \`contact\` object.'] diff --git a/packages/formatters/src/__tests__/text.test.ts b/packages/formatters/src/__tests__/text.test.ts index 0641e058d..8c6c70f2c 100644 --- a/packages/formatters/src/__tests__/text.test.ts +++ b/packages/formatters/src/__tests__/text.test.ts @@ -5,7 +5,7 @@ import mixedErrors from './__fixtures__/mixed-errors.json'; describe('Text formatter', () => { test('should format messages', () => { - const result = text(mixedErrors, { failSeverity: DiagnosticSeverity.Error }, null); + const result = text(mixedErrors, { failSeverity: DiagnosticSeverity.Error }); expect(result) .toContain(`/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:3:10 hint info-contact "Info object should contain \`contact\` object." /home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:3:10 warning info-description "OpenAPI object info \`description\` must be present and non-empty string." diff --git a/packages/formatters/src/sarif.ts b/packages/formatters/src/sarif.ts index b2e59fe06..76c9f74eb 100644 --- a/packages/formatters/src/sarif.ts +++ b/packages/formatters/src/sarif.ts @@ -1,14 +1,8 @@ -import { Formatter } from './types'; import { DiagnosticSeverity, Dictionary } from '@stoplight/types'; import { relative } from '@stoplight/path'; import { SarifBuilder, SarifRunBuilder, SarifResultBuilder, SarifRuleBuilder } from 'node-sarif-builder'; -import { Result } from 'sarif'; - -const pkg = require('../../cli/package.json') as PackageJson; - -interface PackageJson { - version: string; -} +import type { Result } from 'sarif'; +import type { Formatter } from './types'; const OUTPUT_TYPES: Dictionary = { [DiagnosticSeverity.Error]: 'error', @@ -17,7 +11,11 @@ const OUTPUT_TYPES: Dictionary = { [DiagnosticSeverity.Hint]: 'note', }; -export const sarif: Formatter = (results, _, ruleset) => { +export const sarif: Formatter = (results, _, ctx) => { + if (ctx === void 0) { + throw Error('sarif formatter requires ctx'); + } + const sarifBuilder = new SarifBuilder({ $schema: 'http://json.schemastore.org/sarif-2.1.0-rtm.6.json', version: '2.1.0', @@ -26,24 +24,22 @@ export const sarif: Formatter = (results, _, ruleset) => { const sarifRunBuilder = new SarifRunBuilder().initSimple({ toolDriverName: 'spectral', - toolDriverVersion: pkg.version, + toolDriverVersion: ctx.spectralVersion, url: 'https://github.com/stoplightio/spectral', }); // add rules - if (ruleset != null) { - for (const rule of Object.values(ruleset.rules)) { - const sarifRuleBuilder = new SarifRuleBuilder().initSimple({ - ruleId: rule.name, - shortDescriptionText: rule.description ?? 'No description.', - helpUri: rule.documentationUrl !== null ? rule.documentationUrl : undefined, - }); - sarifRunBuilder.addRule(sarifRuleBuilder); - } + for (const rule of Object.values(ctx.ruleset.rules)) { + const sarifRuleBuilder = new SarifRuleBuilder().initSimple({ + ruleId: rule.name, + shortDescriptionText: rule.description ?? 'No description.', + helpUri: rule.documentationUrl !== null ? rule.documentationUrl : undefined, + }); + sarifRunBuilder.addRule(sarifRuleBuilder); } // add results - results.forEach(result => { + for (const result of results) { const sarifResultBuilder = new SarifResultBuilder(); const severity: DiagnosticSeverity = result.severity || DiagnosticSeverity.Error; sarifResultBuilder.initSimple({ @@ -51,15 +47,14 @@ export const sarif: Formatter = (results, _, ruleset) => { messageText: result.message, ruleId: result.code.toString(), fileUri: relative(process.cwd(), result.source ?? '').replace(/\\/g, '/'), - startLine: (result.range.start.line || 1) + 1, - startColumn: result.range.start.character || 1, - endLine: (result.range.end.line || 1) + 1, - endColumn: result.range.end.character || 1, + startLine: result.range.start.line + 1, + startColumn: result.range.start.character + 1, + endLine: result.range.end.line + 1, + endColumn: result.range.end.character + 1, }); sarifRunBuilder.addResult(sarifResultBuilder); - }); + } + sarifBuilder.addRun(sarifRunBuilder); return sarifBuilder.buildSarifJsonString({ indent: true }); }; - -export const sarifToolVersion: string = pkg.version; diff --git a/packages/formatters/src/types.ts b/packages/formatters/src/types.ts index 389d7bbff..8624af331 100644 --- a/packages/formatters/src/types.ts +++ b/packages/formatters/src/types.ts @@ -5,4 +5,9 @@ export type FormatterOptions = { failSeverity: DiagnosticSeverity; }; -export type Formatter = (results: ISpectralDiagnostic[], options: FormatterOptions, ruleset: Ruleset | null) => string; +export type FormatterContext = { + ruleset: Ruleset; + spectralVersion: string; +}; + +export type Formatter = (results: ISpectralDiagnostic[], options: FormatterOptions, ctx?: FormatterContext) => string; From 6d69a5172903241ba5d042f47a010feaa8322437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 14 Sep 2023 17:58:13 +0200 Subject: [PATCH 4/8] chore(cli): inline version --- .gitignore | 1 + package.json | 5 +++-- packages/cli/package.json | 2 ++ packages/cli/scripts/inline-version.mjs | 9 +++++++++ 4 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 packages/cli/scripts/inline-version.mjs diff --git a/.gitignore b/.gitignore index e796c1f4b..66c869471 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules packages/formatters/src/html/templates.ts packages/cli/binaries +packages/cli/src/version.ts /test-harness/tmp/ /test-harness/tests/ packages/*/dist diff --git a/package.json b/package.json index 6cb5dda0f..86c3c9cf7 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "scripts": { "clean": "rimraf .cache packages/*/{dist,.cache}", "prebuild": "yarn workspaces foreach run prebuild", - "build": "yarn prebuild && tsc --build ./tsconfig.build.json", + "build": "yarn prebuild && tsc --build ./tsconfig.build.json && yarn postbuild", + "postbuild": "yarn workspaces foreach run postbuild", "prelint": "yarn workspaces foreach run prelint", "lint": "yarn prelint && yarn lint.prettier && yarn lint.eslint", "lint.fix": "yarn lint.prettier --write && yarn lint.eslint --fix", @@ -37,7 +38,7 @@ "test.harness": "yarn pretest.harness && jest -c test-harness/jest.config.mjs", "test.jest": "jest --silent --cacheDirectory=.cache/.jest", "test.karma": "karma start", - "prepare": "husky install", + "prepare": "husky install && yarn workspaces foreach run prepare", "prerelease": "patch-package", "release": "yarn prerelease && multi-semantic-release --deps.bump=satisfy", "jest": "jest" diff --git a/packages/cli/package.json b/packages/cli/package.json index c174dbd1d..d67f46570 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,6 +24,8 @@ "url": "https://github.com/stoplightio/spectral.git" }, "scripts": { + "prepare": "node scripts/inline-version.mjs", + "postbuild": "node scripts/inline-version.mjs", "build.binary": "pkg . --output ./binaries/spectral", "build.windows": "pkg . --targets windows --out-path ./binaries", "build.nix": "pkg . --targets linux-x64,linux-arm64,macos-x64,macos-arm64,alpine-x64,alpine-arm64 --out-path ./binaries", diff --git a/packages/cli/scripts/inline-version.mjs b/packages/cli/scripts/inline-version.mjs new file mode 100644 index 000000000..0f809a8d6 --- /dev/null +++ b/packages/cli/scripts/inline-version.mjs @@ -0,0 +1,9 @@ +import * as fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const cwd = join(fileURLToPath(import.meta.url), '../..'); + +const { version } = JSON.parse(await fs.readFile(join(cwd, 'package.json'), 'utf8')); + +await fs.writeFile(join(cwd, 'src/version.ts'), `export const VERSION = '${version}';\n`); From a0148398be126772e7aceae58219927f00c59f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 14 Sep 2023 18:02:23 +0200 Subject: [PATCH 5/8] chore(cli): bump @stoplight/spectral-formatters --- packages/cli/package.json | 2 +- packages/cli/src/services/output.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index d67f46570..bf8b7d78b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,7 +36,7 @@ "@stoplight/json": "~3.21.0", "@stoplight/path": "1.3.2", "@stoplight/spectral-core": "^1.18.3", - "@stoplight/spectral-formatters": "^1.2.0", + "@stoplight/spectral-formatters": "^1.3.0", "@stoplight/spectral-parsers": "^1.0.3", "@stoplight/spectral-ref-resolver": "^1.0.4", "@stoplight/spectral-ruleset-bundler": "^1.5.2", diff --git a/packages/cli/src/services/output.ts b/packages/cli/src/services/output.ts index e23225554..dc07e221f 100644 --- a/packages/cli/src/services/output.ts +++ b/packages/cli/src/services/output.ts @@ -14,6 +14,7 @@ import { } from '@stoplight/spectral-formatters'; import type { Formatter, FormatterOptions } from '@stoplight/spectral-formatters'; import type { OutputFormat } from './config'; +import { VERSION } from '../version'; const formatters: Record = { json, @@ -33,7 +34,10 @@ export function formatOutput( formatOptions: FormatterOptions, ruleset: Ruleset, ): string { - return formatters[format](results, formatOptions, ruleset); + return formatters[format](results, formatOptions, { + ruleset, + spectralVersion: VERSION, + }); } export async function writeOutput(outputStr: string, outputFile: string): Promise { From 20892704304da2c8c20a1ed4a59c00531ea9e62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 14 Sep 2023 17:58:13 +0200 Subject: [PATCH 6/8] chore(cli): inline version --- .gitignore | 1 + package.json | 12 ++++++++++-- packages/cli/package.json | 2 ++ packages/cli/scripts/inline-version.mjs | 11 +++++++++++ yarn.lock | 17 +++++++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 packages/cli/scripts/inline-version.mjs diff --git a/.gitignore b/.gitignore index e796c1f4b..66c869471 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules packages/formatters/src/html/templates.ts packages/cli/binaries +packages/cli/src/version.ts /test-harness/tmp/ /test-harness/tests/ packages/*/dist diff --git a/package.json b/package.json index 6cb5dda0f..22e2f7e14 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "scripts": { "clean": "rimraf .cache packages/*/{dist,.cache}", "prebuild": "yarn workspaces foreach run prebuild", - "build": "yarn prebuild && tsc --build ./tsconfig.build.json", + "build": "yarn prebuild && tsc --build ./tsconfig.build.json && yarn postbuild", + "postbuild": "yarn workspaces foreach run postbuild", "prelint": "yarn workspaces foreach run prelint", "lint": "yarn prelint && yarn lint.prettier && yarn lint.eslint", "lint.fix": "yarn lint.prettier --write && yarn lint.eslint --fix", @@ -37,7 +38,7 @@ "test.harness": "yarn pretest.harness && jest -c test-harness/jest.config.mjs", "test.jest": "jest --silent --cacheDirectory=.cache/.jest", "test.karma": "karma start", - "prepare": "husky install", + "prepare": "husky install && yarn workspaces foreach run prepare", "prerelease": "patch-package", "release": "yarn prerelease && multi-semantic-release --deps.bump=satisfy", "jest": "jest" @@ -58,6 +59,12 @@ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/changelog", + [ + "@semantic-release/exec", + { + "publishCmd": "node scripts/inline-version.mjs ${nextRelease.version}" + } + ], "@semantic-release/npm", [ "@semantic-release/github", @@ -73,6 +80,7 @@ "@commitlint/config-conventional": "^12.1.4", "@octokit/core": "^3.5.1", "@semantic-release/changelog": "^6.0.3", + "@semantic-release/exec": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^8.1.0", "@semantic-release/npm": "^9.0.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index c174dbd1d..d67f46570 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,6 +24,8 @@ "url": "https://github.com/stoplightio/spectral.git" }, "scripts": { + "prepare": "node scripts/inline-version.mjs", + "postbuild": "node scripts/inline-version.mjs", "build.binary": "pkg . --output ./binaries/spectral", "build.windows": "pkg . --targets windows --out-path ./binaries", "build.nix": "pkg . --targets linux-x64,linux-arm64,macos-x64,macos-arm64,alpine-x64,alpine-arm64 --out-path ./binaries", diff --git a/packages/cli/scripts/inline-version.mjs b/packages/cli/scripts/inline-version.mjs new file mode 100644 index 000000000..d9089fa52 --- /dev/null +++ b/packages/cli/scripts/inline-version.mjs @@ -0,0 +1,11 @@ +import * as fs from 'node:fs'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const cwd = join(fileURLToPath(import.meta.url), '../..'); + +const version = + process.argv.length === 3 ? process.argv[2] : JSON.parse(fs.readFileSync(join(cwd, 'package.json'), 'utf8')).version; + +fs.writeFileSync(join(cwd, 'src/version.ts'), `export const VERSION = '${version}';\n`); diff --git a/yarn.lock b/yarn.lock index 626cc7257..b33444fbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2453,6 +2453,22 @@ __metadata: languageName: node linkType: hard +"@semantic-release/exec@npm:^6.0.3": + version: 6.0.3 + resolution: "@semantic-release/exec@npm:6.0.3" + dependencies: + "@semantic-release/error": ^3.0.0 + aggregate-error: ^3.0.0 + debug: ^4.0.0 + execa: ^5.0.0 + lodash: ^4.17.4 + parse-json: ^5.0.0 + peerDependencies: + semantic-release: ">=18.0.0" + checksum: c6ad2f02ff01a4709c4914f560d0343efea9afe993c733ff971da8bf89604a1460d87b26a1a2ace5992c5ace8e8d384cf314504e0c4b623fc8433e8e8d9e2fe0 + languageName: node + linkType: hard + "@semantic-release/git@npm:^10.0.1": version: 10.0.1 resolution: "@semantic-release/git@npm:10.0.1" @@ -11469,6 +11485,7 @@ __metadata: "@commitlint/config-conventional": ^12.1.4 "@octokit/core": ^3.5.1 "@semantic-release/changelog": ^6.0.3 + "@semantic-release/exec": ^6.0.3 "@semantic-release/git": ^10.0.1 "@semantic-release/github": ^8.1.0 "@semantic-release/npm": ^9.0.2 From 4e73e5fc6c3f47e97b40525a955ed95b81066045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Fri, 15 Sep 2023 17:52:56 +0200 Subject: [PATCH 7/8] chore(repo): lockfile --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index b33444fbd..fe0ea2704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2657,7 +2657,7 @@ __metadata: "@stoplight/json": ~3.21.0 "@stoplight/path": 1.3.2 "@stoplight/spectral-core": ^1.18.3 - "@stoplight/spectral-formatters": ^1.2.0 + "@stoplight/spectral-formatters": ^1.3.0 "@stoplight/spectral-parsers": ^1.0.3 "@stoplight/spectral-ref-resolver": ^1.0.4 "@stoplight/spectral-ruleset-bundler": ^1.5.2 @@ -2731,7 +2731,7 @@ __metadata: languageName: unknown linkType: soft -"@stoplight/spectral-formatters@^1.2.0, @stoplight/spectral-formatters@workspace:packages/formatters": +"@stoplight/spectral-formatters@^1.3.0, @stoplight/spectral-formatters@workspace:packages/formatters": version: 0.0.0-use.local resolution: "@stoplight/spectral-formatters@workspace:packages/formatters" dependencies: From 5bf67da4c283192e3e4df59eb1d1953bfefcba54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Fri, 15 Sep 2023 17:57:27 +0200 Subject: [PATCH 8/8] test(cli): tweak run command --- .../cli/src/services/__tests__/linter.test.ts | 558 ++++++++---------- 1 file changed, 260 insertions(+), 298 deletions(-) diff --git a/packages/cli/src/services/__tests__/linter.test.ts b/packages/cli/src/services/__tests__/linter.test.ts index b45843d89..19da4a16c 100644 --- a/packages/cli/src/services/__tests__/linter.test.ts +++ b/packages/cli/src/services/__tests__/linter.test.ts @@ -20,7 +20,7 @@ const invalidRulesetPath = resolve(__dirname, '__fixtures__/ruleset-invalid.js') const validRulesetPath = resolve(__dirname, '__fixtures__/ruleset-valid.js'); const validOas3SpecPath = resolve(__dirname, './__fixtures__/openapi-3.0-valid.yaml'); -async function run(command: string): Promise { +async function run(command: string): Promise { const parser = yargs.command(lintCommand); const { documents, ...opts } = await new Promise((resolve, reject) => { parser.parse(`${command} --ignore-unknown-format`, {}, (err, argv) => { @@ -32,7 +32,7 @@ async function run(command: string): Promise { }); }); - return lint(documents, opts); + return (await lint(documents, opts)).results; } describe('Linter service', () => { @@ -50,44 +50,44 @@ describe('Linter service', () => { }); it('handles relative path to a document', async () => { - return run('lint -r ./gh-474/ruleset.js ./gh-474/document.json').then(linterResult => { - return expect(linterResult.results).toEqual([ - { - code: 'defined-name', - message: '"name" property must be truthy', - path: ['0', 'name'], - range: { - end: { - character: 16, - line: 1, - }, - start: { - character: 12, - line: 1, - }, + const results = await run('lint -r ./gh-474/ruleset.js ./gh-474/document.json'); + + expect(results).toEqual([ + { + code: 'defined-name', + message: '"name" property must be truthy', + path: ['0', 'name'], + range: { + end: { + character: 16, + line: 1, + }, + start: { + character: 12, + line: 1, }, - severity: DiagnosticSeverity.Warning, - source: join(__dirname, './__fixtures__/gh-474/common.json'), }, - { - code: 'defined-name', - message: '"name" property must be truthy', - path: ['1', 'name'], - range: { - end: { - character: 16, - line: 2, - }, - start: { - character: 12, - line: 2, - }, + severity: DiagnosticSeverity.Warning, + source: join(__dirname, './__fixtures__/gh-474/common.json'), + }, + { + code: 'defined-name', + message: '"name" property must be truthy', + path: ['1', 'name'], + range: { + end: { + character: 16, + line: 2, + }, + start: { + character: 12, + line: 2, }, - severity: DiagnosticSeverity.Warning, - source: join(__dirname, './__fixtures__/gh-474/common.json'), }, - ]); - }); + severity: DiagnosticSeverity.Warning, + source: join(__dirname, './__fixtures__/gh-474/common.json'), + }, + ]); }); it('demands some ruleset to be present', () => { @@ -100,26 +100,22 @@ describe('Linter service', () => { describe('when document is local file', () => { describe('and the file is expected to have no warnings', () => { it('outputs no issues', () => { - return run(`lint stoplight-info-document.json`).then(linterResult => { - return expect(linterResult.results).toEqual([]); - }); + return expect(run(`lint stoplight-info-document.json`)).resolves.toEqual([]); }); }); describe('and the file is expected to trigger warnings', () => { it('outputs warnings', async () => { - return run(`lint missing-stoplight-info-document.json`).then(linterResult => { - return expect(linterResult.results).toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - }, - ]); - }); + return expect(run('lint missing-stoplight-info-document.json')).resolves.toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + }, + ]); }); }); }); @@ -130,15 +126,61 @@ describe('Linter service', () => { join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), ]; - return run(['lint', ...documents].join(' ')).then(linterResult => { - return expect(linterResult.results).toEqual([ + return expect(run(['lint', ...documents].join(' '))).resolves.toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: documents[0], + }, + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: documents[1], + }, + ]); + }); + + it('sorts linting results in an alphabetical order', () => { + const documents = [ + join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), + join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), + ]; + + return expect(run(['lint', ...documents].join(' '))).resolves.toEqual([ + expect.objectContaining({ + code: 'info-matches-stoplight', + source: join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), + }), + expect.objectContaining({ + code: 'info-matches-stoplight', + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), + }), + expect.objectContaining({ + code: 'info-matches-stoplight', + source: join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), + }), + ]); + }); + + describe('when glob is provided', () => { + const documents = join(__dirname, `./__fixtures__/missing-stoplight-info*.json`); + + it('outputs issues for each file', () => { + return expect(run(`lint ${documents}`)).resolves.toEqual([ { code: 'info-matches-stoplight', message: 'Info must contain Stoplight', path: ['info', 'title'], range: expect.any(Object), severity: DiagnosticSeverity.Warning, - source: documents[0], + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), }, { code: 'info-matches-stoplight', @@ -146,96 +188,40 @@ describe('Linter service', () => { path: ['info', 'title'], range: expect.any(Object), severity: DiagnosticSeverity.Warning, - source: documents[1], + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), }, ]); }); - }); - - it('sorts linting results in an alphabetical order', () => { - const documents = [ - join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), - join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), - ]; - return run(['lint', ...documents].join(' ')).then(linterResult => { - return expect(linterResult.results).toEqual([ - expect.objectContaining({ + it('unixifies patterns', () => { + return expect(run(`lint } ${documents.replace(/\//g, '\\')}`)).resolves.toEqual([ + { code: 'info-matches-stoplight', - source: join(__dirname, `./__fixtures__/invalid-stoplight-info-document.json`), - }), - expect.objectContaining({ + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), + }, + { code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - }), - expect.objectContaining({ - code: 'info-matches-stoplight', - source: join(__dirname, `./__fixtures__/openapi-3.0-valid.yaml`), - }), + }, ]); }); }); - describe('when glob is provided', () => { - const documents = join(__dirname, `./__fixtures__/missing-stoplight-info*.json`); - - it('outputs issues for each file', () => { - return run(`lint ${documents}`).then(linterResult => { - return expect(linterResult.results).toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), - }, - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - }, - ]); - }); - }); - - it('unixifies patterns', () => { - return run(`lint } ${documents.replace(/\//g, '\\')}`).then(linterResult => { - return expect(linterResult.results).toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document-copy.json`), - }, - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: join(__dirname, `./__fixtures__/missing-stoplight-info-document.json`), - }, - ]); - }); - }); - }); - describe('--ruleset', () => { const validNestedRulesetPath = join(__dirname, '__fixtures__/ruleset-extends-valid.js'); const invalidNestedRulesetPath = join(__dirname, '__fixtures__/ruleset-extends-invalid.js'); describe('extends feature', () => { it('extends a valid relative ruleset', () => { - return run(`lint ${validCustomOas3SpecPath} -r ${validNestedRulesetPath}`).then(linterResult => { - return expect(linterResult.results).toEqual([]); - }); + return expect(run(`lint ${validCustomOas3SpecPath} -r ${validNestedRulesetPath}`)).resolves.toEqual([]); }); it('fails trying to extend an invalid relative ruleset', () => { @@ -293,17 +279,13 @@ describe('Linter service', () => { }); it('outputs no issues', () => { - return run(`lint ${validCustomOas3SpecPath} -r ${validRulesetPath}`).then(linterResult => { - return expect(linterResult.results).toEqual([]); - }); + return expect(run(`lint ${validCustomOas3SpecPath} -r ${validRulesetPath}`)).resolves.toEqual([]); }); it('outputs warnings', async () => { const output = await run(`lint ${validOas3SpecPath} -r ${validRulesetPath}`); - expect(output.results).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), - ); - expect(output.results).toEqual( + expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); + expect(output).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -316,10 +298,8 @@ describe('Linter service', () => { describe('given legacy ruleset', () => { it('outputs warnings', async () => { const output = await run(`lint ${validOas3SpecPath} -r ${join(__dirname, '__fixtures__/ruleset.json')}`); - expect(output.results).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), - ); - expect(output.results).toEqual( + expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); + expect(output).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -337,10 +317,8 @@ describe('Linter service', () => { }); const output = await run(`lint ${validOas3SpecPath} -r http://foo.local/ruleset.json`); - expect(output.results).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), - ); - expect(output.results).toEqual( + expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); + expect(output).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -358,10 +336,8 @@ describe('Linter service', () => { }); const output = await run(`lint ${validOas3SpecPath} -r http://foo.local/ruleset`); - expect(output.results).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), - ); - expect(output.results).toEqual( + expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); + expect(output).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -380,10 +356,8 @@ describe('Linter service', () => { }); const output = await run(`lint ${validOas3SpecPath} -r http://foo.local/ruleset.json?token=bar`); - expect(output.results).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })]), - ); - expect(output.results).toEqual( + expect(output).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'info-matches-stoplight' })])); + expect(output).toEqual( expect.not.arrayContaining([ expect.objectContaining({ message: 'Info object should contain `contact` object', @@ -401,9 +375,7 @@ describe('Linter service', () => { 'Content-Type': 'application/yaml', }); - return run('lint http://foo.local/openapi').then(linterResult => { - return expect(linterResult.results).toEqual([]); - }); + return expect(run('lint http://foo.local/openapi')).resolves.toEqual([]); }); it('throws if cannot load URI', () => { @@ -420,159 +392,151 @@ describe('Linter service', () => { 'Content-Type': 'application/yaml', }); - return run(`lint http://foo.local/openapi`).then(linterResult => { - return expect(linterResult.results).toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: ['info', 'title'], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: 'http://foo.local/openapi', - }, - ]); - }); + return expect(run(`lint http://foo.local/openapi`)).resolves.toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: ['info', 'title'], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: 'http://foo.local/openapi', + }, + ]); }); }); describe('when using default ruleset file', () => { it('respects rules from a ruleset file', () => { - return run('lint missing-stoplight-info-document.json').then(linterResult => { - return expect(linterResult.results).toEqual([ - expect.objectContaining({ - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - }), - ]); - }); + return expect(run('lint missing-stoplight-info-document.json')).resolves.toEqual([ + expect.objectContaining({ + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + }), + ]); }); }); describe('$ref linting', () => { it('outputs errors occurring in referenced files', () => { - return run(`lint -r references/ruleset.js references/no-nested.json`).then(linterResult => { - return expect(linterResult.results).toEqual([ - expect.objectContaining({ - code: 'valid-schema', - message: '"info" property must have required property "version"', - path: ['definitions', 'info'], - range: { - end: { - character: 5, - line: 8, - }, - start: { - character: 12, - line: 3, - }, + return expect(run(`lint -r references/ruleset.js references/no-nested.json`)).resolves.toEqual([ + expect.objectContaining({ + code: 'valid-schema', + message: '"info" property must have required property "version"', + path: ['definitions', 'info'], + range: { + end: { + character: 5, + line: 8, }, - source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"description" property type must be string', - path: ['definitions', 'info', 'description'], - range: { - end: { - character: 22, - line: 4, - }, - start: { - character: 21, - line: 4, - }, + start: { + character: 12, + line: 3, + }, + }, + source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"description" property type must be string', + path: ['definitions', 'info', 'description'], + range: { + end: { + character: 22, + line: 4, }, - source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: 'Property "foo" is not expected to be here', - path: ['paths'], - range: { - end: { - character: 13, - line: 6, - }, - start: { - character: 10, - line: 4, - }, + start: { + character: 21, + line: 4, }, - source: expect.stringContaining('__tests__/__fixtures__/references/no-nested.json'), - }), - ]); - }); + }, + source: expect.stringContaining('/__tests__/__fixtures__/references/common/info.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: 'Property "foo" is not expected to be here', + path: ['paths'], + range: { + end: { + character: 13, + line: 6, + }, + start: { + character: 10, + line: 4, + }, + }, + source: expect.stringContaining('__tests__/__fixtures__/references/no-nested.json'), + }), + ]); }); it('outputs errors occurring in nested referenced files', () => { - return run(`lint -r references/ruleset.js references/nested.json`).then(linterResult => { - return expect(linterResult.results).toEqual([ - expect.objectContaining({ - code: 'valid-schema', - message: '"info" property must have required property "version"', - path: [], - range: { - end: { - character: 1, - line: 3, - }, - start: { - character: 0, - line: 0, - }, + return expect(run(`lint -r references/ruleset.js references/nested.json`)).resolves.toEqual([ + expect.objectContaining({ + code: 'valid-schema', + message: '"info" property must have required property "version"', + path: [], + range: { + end: { + character: 1, + line: 3, }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"description" property type must be string', - path: ['description'], - range: { - end: { - character: 18, - line: 2, - }, - start: { - character: 17, - line: 2, - }, + start: { + character: 0, + line: 0, + }, + }, + source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"description" property type must be string', + path: ['description'], + range: { + end: { + character: 18, + line: 2, }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"get" property must have required property "responses"', - path: ['paths', '/test', 'get'], - range: { - end: { - character: 7, - line: 5, - }, - start: { - character: 13, - line: 3, - }, + start: { + character: 17, + line: 2, }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), - }), - expect.objectContaining({ - code: 'valid-schema', - message: '"response" property type must be number', - path: ['paths', '/test', 'get', 'response'], - range: { - end: { - character: 25, - line: 4, - }, - start: { - character: 20, - line: 4, - }, + }, + source: expect.stringContaining('__tests__/__fixtures__/references/common/contact.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"get" property must have required property "responses"', + path: ['paths', '/test', 'get'], + range: { + end: { + character: 7, + line: 5, }, - source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), - }), - ]); - }); + start: { + character: 13, + line: 3, + }, + }, + source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), + }), + expect.objectContaining({ + code: 'valid-schema', + message: '"response" property type must be number', + path: ['paths', '/test', 'get', 'response'], + range: { + end: { + character: 25, + line: 4, + }, + start: { + character: 20, + line: 4, + }, + }, + source: expect.stringContaining('__tests__/__fixtures__/references/common/paths.json'), + }), + ]); }); }); @@ -581,18 +545,16 @@ describe('Linter service', () => { const resolver = join(__dirname, '__fixtures__/resolver/resolver.js'); const document = join(__dirname, '__fixtures__/resolver/document.json'); - return run(`lint --resolver ${resolver} ${document}`).then(linterResult => { - return expect(linterResult.results).toEqual([ - { - code: 'info-matches-stoplight', - message: 'Info must contain Stoplight', - path: [], - range: expect.any(Object), - severity: DiagnosticSeverity.Warning, - source: expect.stringContaining('__fixtures__/resolver/document.json'), - }, - ]); - }); + expect(await run(`lint --resolver ${resolver} ${document}`)).toEqual([ + { + code: 'info-matches-stoplight', + message: 'Info must contain Stoplight', + path: [], + range: expect.any(Object), + severity: DiagnosticSeverity.Warning, + source: expect.stringContaining('__fixtures__/resolver/document.json'), + }, + ]); }); }); });