From 1799862a46616909e5661764cc78c7ca379156a3 Mon Sep 17 00:00:00 2001 From: Barry Kaplan Date: Sat, 30 Nov 2019 14:18:31 -0800 Subject: [PATCH] Add two formatters: text, teamcity. Closes issues https://github.com/stoplightio/spectral/issues/823 and https://github.com/stoplightio/spectral/issues/822. The text formatter is close to the stylish but with no special formatting and a source:line:column suitable for navigation within an IDE console (IntelliJ specifically). The teamcity formatter emits teamcity inspection service messages that teamcity will automatically detect and add to the build inspection tab. --- .gitignore | 1 + src/cli/services/output.ts | 4 +- src/formatters/__tests__/teamcity.test.ts | 22 ++++++++++ src/formatters/__tests__/text.test.ts | 16 +++++++ src/formatters/index.ts | 2 + src/formatters/teamcity.ts | 53 +++++++++++++++++++++++ src/formatters/text.ts | 26 +++++++++++ src/types/config.ts | 2 + 8 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/formatters/__tests__/teamcity.test.ts create mode 100644 src/formatters/__tests__/text.test.ts create mode 100644 src/formatters/teamcity.ts create mode 100644 src/formatters/text.ts diff --git a/.gitignore b/.gitignore index ea39399a1..e90fa0607 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ .env.production.local .vscode .idea +.nvmrc .nyc_output .DS_Store .awcache diff --git a/src/cli/services/output.ts b/src/cli/services/output.ts index ffd162e5a..6ec81f4ba 100644 --- a/src/cli/services/output.ts +++ b/src/cli/services/output.ts @@ -1,7 +1,7 @@ import { Dictionary } from '@stoplight/types'; import { writeFile } from 'fs'; import { promisify } from 'util'; -import { html, json, junit, stylish } from '../../formatters'; +import { html, json, junit, stylish, teamcity, text } from '../../formatters'; import { Formatter } from '../../formatters/types'; import { IRuleResult } from '../../types'; import { OutputFormat } from '../../types/config'; @@ -13,6 +13,8 @@ const formatters: Dictionary = { stylish, junit, html, + text, + teamcity, }; export function formatOutput(results: IRuleResult[], format: OutputFormat): string { diff --git a/src/formatters/__tests__/teamcity.test.ts b/src/formatters/__tests__/teamcity.test.ts new file mode 100644 index 000000000..929611966 --- /dev/null +++ b/src/formatters/__tests__/teamcity.test.ts @@ -0,0 +1,22 @@ +import { teamcity } from '../teamcity'; + +const mixedErrors = require('./__fixtures__/mixed-errors.json'); + +describe('Teamcity formatter', () => { + test('should format messages', () => { + const result = teamcity(mixedErrors); + 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.'] +##teamcity[inspectionType category='openapi' id='info-description' name='info-description' description='warning -- OpenAPI object info \`description\` must be present and non-empty string.'] +##teamcity[inspection typeId='info-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='3' message='warning -- OpenAPI object info \`description\` must be present and non-empty string.'] +##teamcity[inspectionType category='openapi' id='info-matches-stoplight' name='info-matches-stoplight' description='error -- Info must contain Stoplight'] +##teamcity[inspection typeId='info-matches-stoplight' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='5' message='error -- Info must contain Stoplight'] +##teamcity[inspectionType category='openapi' id='operation-description' name='operation-description' description='information -- Operation \`description\` must be present and non-empty string.'] +##teamcity[inspection typeId='operation-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='17' message='information -- Operation \`description\` must be present and non-empty string.'] +##teamcity[inspectionType category='openapi' id='operation-description' name='operation-description' description='information -- Operation \`description\` must be present and non-empty string.'] +##teamcity[inspection typeId='operation-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='64' message='information -- Operation \`description\` must be present and non-empty string.'] +##teamcity[inspectionType category='openapi' id='operation-description' name='operation-description' description='information -- Operation \`description\` must be present and non-empty string.'] +##teamcity[inspection typeId='operation-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='86' message='information -- Operation \`description\` must be present and non-empty string.']`); + }); +}); diff --git a/src/formatters/__tests__/text.test.ts b/src/formatters/__tests__/text.test.ts new file mode 100644 index 000000000..3cb5f772c --- /dev/null +++ b/src/formatters/__tests__/text.test.ts @@ -0,0 +1,16 @@ +import { text } from '../text'; + +const mixedErrors = require('./__fixtures__/mixed-errors.json'); + +describe('Text formatter', () => { + test('should format messages', () => { + const result = text(mixedErrors); + 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." +/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:5:14 error info-matches-stoplight "Info must contain Stoplight" +/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:17:13 information operation-description "Operation \`description\` must be present and non-empty string." +/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:64:14 information operation-description "Operation \`description\` must be present and non-empty string." +/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:86:13 information operation-description "Operation \`description\` must be present and non-empty string."`); + }); +}); diff --git a/src/formatters/index.ts b/src/formatters/index.ts index 4294008b3..7908adaa2 100644 --- a/src/formatters/index.ts +++ b/src/formatters/index.ts @@ -2,3 +2,5 @@ export * from './json'; export * from './stylish'; export * from './junit'; export * from './html'; +export * from './text'; +export * from './teamcity'; diff --git a/src/formatters/teamcity.ts b/src/formatters/teamcity.ts new file mode 100644 index 000000000..3012919cf --- /dev/null +++ b/src/formatters/teamcity.ts @@ -0,0 +1,53 @@ +import { Dictionary } from '@stoplight/types'; +import { IRuleResult } from '../types'; +import { Formatter } from './types'; +import { getSeverityName, groupBySource, sortResults } from './utils'; + +function escapeString(str?: string | number) { + if (!str) { + return ''; + } + return str + .toString() + .replace(/\|/g, '||') + .replace(/'/g, "|'") + .replace(/\n/g, '|n') + .replace(/\r/g, '|r') + .replace(/\u0085/g, '|x') // TeamCity 6 + .replace(/\u2028/g, '|l') // TeamCity 6 + .replace(/\u2029/g, '|p') // TeamCity 6 + .replace(/\[/g, '|[') + .replace(/\]/g, '|]'); +} + +function inspectionType(result: IRuleResult) { + const code = escapeString(result.code); + const severity = getSeverityName(result.severity); + const message = escapeString(result.message); + return `##teamcity[inspectionType category='openapi' id='${code}' name='${code}' description='${severity} -- ${message}']`; +} + +function inspection(result: IRuleResult) { + const code = escapeString(result.code); + const severity = getSeverityName(result.severity); + const message = escapeString(result.message); + const line = result.range.start.line + 1; + return `##teamcity[inspection typeId='${code}' file='${result.source}' line='${line}' message='${severity} -- ${message}']`; +} + +function renderResults(results: IRuleResult[], parentIndex: number) { + return sortResults(results) + .map(result => `${inspectionType(result)}\n${inspection(result)}`) + .join('\n'); +} + +function renderGroupedResults(groupedResults: Dictionary) { + return Object.keys(groupedResults) + .map((source, index) => renderResults(groupedResults[source], index)) + .join('\n'); +} + +export const teamcity: Formatter = results => { + const groupedResults = groupBySource(results); + return renderGroupedResults(groupedResults); +}; diff --git a/src/formatters/text.ts b/src/formatters/text.ts new file mode 100644 index 000000000..045f87357 --- /dev/null +++ b/src/formatters/text.ts @@ -0,0 +1,26 @@ +import { Dictionary } from '@stoplight/types'; +import { IRuleResult } from '../types'; +import { Formatter } from './types'; +import { getSeverityName, groupBySource, sortResults } from './utils'; + +function renderResults(results: IRuleResult[], parentIndex: number) { + return sortResults(results) + .map(result => { + const line = result.range.start.line + 1; + const character = result.range.start.character + 1; + const severity = getSeverityName(result.severity); + return `${result.source}:${line}:${character} ${severity} ${result.code} "${result.message}"`; + }) + .join('\n'); +} + +function renderGroupedResults(groupedResults: Dictionary) { + return Object.keys(groupedResults) + .map((source, index) => renderResults(groupedResults[source], index)) + .join('\n'); +} + +export const text: Formatter = results => { + const groupedResults = groupBySource(results); + return renderGroupedResults(groupedResults); +}; diff --git a/src/types/config.ts b/src/types/config.ts index f2f3e9f3c..8f723fc8a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -7,6 +7,8 @@ export enum OutputFormat { STYLISH = 'stylish', JUNIT = 'junit', HTML = 'html', + TEXT = 'text', + TEAMCITY = 'teamcity', } export interface ILintConfig {