Repository: stoplightio/spectral (ts)

Issue:
Gitlab code quality support
**User story.**
As a developer, I can export the results to a Gitlab code quality report to see a summary of my code quality in the MR widget and the pipeline.

**Is your feature request related to a problem?**
Currently, there is only a format enum for JUnit. That's cool because you can see the results in Gitlab, but I prefer linting results in the code quality section. The linting results are more about the quality, not in all cases about functionality.

**Describe the solution you'd like**
It would be cool to have another output format in the CodeClimate JSON format.


Pull request:
feat(formatters): add code climate (GitLab) formatter 
Fixes #2283.

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Screenshots**

If applicable, add screenshots or gifs to help demonstrate the changes. If not applicable, remove this screenshots section before creating the PR.

**Additional context**

Add any other context about the pull request here. Remove this section if there is no additional context.


Patch:
diff --git a/docs/guides/2-cli.md b/docs/guides/2-cli.md
index a68a7d4a..f0edd3e7 100644
--- a/docs/guides/2-cli.md
+++ b/docs/guides/2-cli.md
@@ -27,13 +27,13 @@ spectral lint ./reference/**/*.oas*.{json,yml,yaml}
 Other options include:
 
 ```
-      --version                  Show version number                                                           [boolean]
-      --help                     Show help                                                                     [boolean]
+      --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 provided by using
                                  multiple flags
-         [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif", "markdown"]
+         [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif", "markdown","gitlab"]
                                                                                                     [default: "stylish"]
   -o, --output                   where to output results, can be a single file name, multiple "output.<format>" or
                                  missing to print to stdout                                                     [string]
@@ -41,12 +41,12 @@ Other options include:
       --resolver                 path to custom json-ref-resolver instance                                      [string]
   -r, --ruleset                  path/URL to a ruleset file                                                     [string]
   -F, --fail-severity            results of this level or above will trigger a failure exit code
-                                                  [string] [choices: "error", "warn", "info", "hint"] [default: "error"]
-  -D, --display-only-failures    only output results equal to or greater than --fail-severity [boolean] [default: false]
-      --ignore-unknown-format    do not warn about unmatched formats                          [boolean] [default: false]
-      --fail-on-unmatched-globs  fail on unmatched glob patterns                              [boolean] [default: false]
-  -v, --verbose                  increase verbosity                                                            [boolean]
-  -q, --quiet                    no logging - output only                                                      [boolean]
+                                                                     [string] [choices: "error", "warn", "info", "hint"] [default: "error"]
+  -D, --display-only-failures    only output results equal to or greater than --fail-severity                    [boolean] [default: false]
+      --ignore-unknown-format    do not warn about unmatched formats                                             [boolean] [default: false]
+      --fail-on-unmatched-globs  fail on unmatched glob patterns                                                 [boolean] [default: false]
+  -v, --verbose                  increase verbosity                                                                               [boolean]
+  -q, --quiet                    no logging - output only                                                                         [boolean]
 ```
 
 The Spectral CLI supports loading documents as YAML or JSON, and validation of OpenAPI v2/v3 documents via the built-in ruleset.
diff --git a/packages/cli/src/services/config.ts b/packages/cli/src/services/config.ts
index 6c1f7db2..03047950 100644
--- a/packages/cli/src/services/config.ts
+++ b/packages/cli/src/services/config.ts
@@ -13,6 +13,8 @@ export enum OutputFormat {
   PRETTY = 'pretty',
   GITHUB_ACTIONS = 'github-actions',
   SARIF = 'sarif',
+  CODE_CLIMATE = 'code-climate',
+  GITLAB = 'gitlab',
   MARKDOWN = 'markdown',
 }
 
diff --git a/packages/cli/src/services/output.ts b/packages/cli/src/services/output.ts
index 996d03b1..23015090 100644
--- a/packages/cli/src/services/output.ts
+++ b/packages/cli/src/services/output.ts
@@ -11,6 +11,7 @@ import {
   pretty,
   githubActions,
   sarif,
+  codeClimate,
   markdown,
 } from '@stoplight/spectral-formatters';
 import type { Formatter, FormatterOptions } from '@stoplight/spectral-formatters';
@@ -27,6 +28,8 @@ const formatters: Record<OutputFormat, Formatter> = {
   teamcity,
   'github-actions': githubActions,
   sarif,
+  'code-climate': codeClimate,
+  gitlab: codeClimate,
   markdown,
 };
 
diff --git a/packages/formatters/README.md b/packages/formatters/README.md
index d1572679..06debf87 100644
--- a/packages/formatters/README.md
+++ b/packages/formatters/README.md
@@ -35,3 +35,5 @@ console.error(output);
 - pretty
 - github-actions
 - sarif
+- gitlab
+- code-climate
diff --git a/packages/formatters/src/code-climate.ts b/packages/formatters/src/code-climate.ts
new file mode 100644
index 00000000..04f4a2af
--- /dev/null
+++ b/packages/formatters/src/code-climate.ts
@@ -0,0 +1,69 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import { Formatter } from './types';
+import { relative } from '@stoplight/path';
+
+/**
+ * @see https://github.com/codeclimate/platform/blob/690633cb2a08839a5bfa350ed925ddb6de55bbdc/spec/analyzers/SPEC.md#data-types
+ */
+interface CodeClimateIssue {
+  type: 'issue';
+  check_name: string;
+  description: string;
+  categories: CodeClimateIssueCategory[];
+  location: CodeClimateIssueLocation;
+  content?: { body: string };
+  trace?: CodeClimateIssueTrace;
+  remediation_points?: number;
+  severity?: CodeClimateIssueSeverity;
+  fingerprint?: string;
+}
+type CodeClimateIssueCategory =
+  | 'Bug Risk'
+  | 'Clarity'
+  | 'Compatibility'
+  | 'Complexity'
+  | 'Duplication'
+  | 'Performance'
+  | 'Security'
+  | 'Style';
+interface CodeClimateIssueLocation {
+  path: string;
+  positions: {
+    begin: { line: number; column: number };
+    end: { line: number; column: number };
+  };
+}
+interface CodeClimateIssueTrace {
+  locations: CodeClimateIssueLocation[];
+  stackTrace: boolean;
+}
+type CodeClimateIssueSeverity = 'info' | 'minor' | 'major' | 'critical' | 'blocker';
+const severityMap: Record<DiagnosticSeverity, CodeClimateIssueSeverity> = {
+  [DiagnosticSeverity.Error]: 'critical',
+  [DiagnosticSeverity.Warning]: 'major',
+  [DiagnosticSeverity.Information]: 'minor',
+  [DiagnosticSeverity.Hint]: 'info',
+};
+
+export const codeClimate: Formatter = results => {
+  const outputJson: CodeClimateIssue[] = results.map(result => {
+    const relPath = relative(process.cwd(), result.source ?? '').replace(/\\/g, '/');
+    const fingerprint = `${relPath}:${result.path.join('.')}:${result.code}`;
+    return {
+      type: 'issue' as const,
+      check_name: result.code.toString(),
+      description: result.message,
+      categories: ['Style'],
+      location: {
+        path: relPath,
+        positions: {
+          begin: { line: result.range.start.line, column: result.range.start.character },
+          end: { line: result.range.end.line, column: result.range.end.character },
+        },
+      },
+      severity: severityMap[result.severity],
+      fingerprint,
+    };
+  });
+  return JSON.stringify(outputJson, null, '\t');
+};
diff --git a/packages/formatters/src/index.node.ts b/packages/formatters/src/index.node.ts
index 94a9c8d7..a33aacbe 100644
--- a/packages/formatters/src/index.node.ts
+++ b/packages/formatters/src/index.node.ts
@@ -3,3 +3,4 @@ export type { Formatter, FormatterOptions } from './index';
 export { pretty } from './pretty';
 export { githubActions } from './github-actions';
 export { sarif } from './sarif';
+export { codeClimate } from './code-climate';
diff --git a/packages/formatters/src/index.ts b/packages/formatters/src/index.ts
index 14567521..ad4c6891 100644
--- a/packages/formatters/src/index.ts
+++ b/packages/formatters/src/index.ts
@@ -19,3 +19,7 @@ export const githubActions: Formatter = () => {
 export const sarif: Formatter = () => {
   throw Error('sarif formatter is available only in Node.js');
 };
+
+export const codeClimate: Formatter = () => {
+  throw Error('sarif formatter is available only in Node.js');
+};


Test patch:
diff --git a/packages/formatters/src/__tests__/code-climate.jest.test.ts b/packages/formatters/src/__tests__/code-climate.jest.test.ts
new file mode 100644
index 00000000..92f3ad93
--- /dev/null
+++ b/packages/formatters/src/__tests__/code-climate.jest.test.ts
@@ -0,0 +1,89 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import type { IRuleResult } from '@stoplight/spectral-core';
+import { codeClimate } from '../code-climate';
+
+const cwd = process.cwd();
+const results: IRuleResult[] = [
+  {
+    code: 'operation-description',
+    message: 'paths./pets.get.description is not truthy',
+    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: 1,
+    source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`,
+    range: {
+      start: {
+        line: 60,
+        character: 8,
+      },
+      end: {
+        line: 71,
+        character: 60,
+      },
+    },
+  },
+];
+
+describe('Code climate formatter', () => {
+  test('should include ranges', () => {
+    expect(JSON.parse(codeClimate(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([
+      expect.objectContaining({
+        location: {
+          path: '__tests__/fixtures/petstore.oas2.yaml',
+          positions: {
+            begin: {
+              line: 60,
+              column: 8,
+            },
+            end: {
+              line: 71,
+              column: 60,
+            },
+          },
+        },
+      }),
+      expect.objectContaining({
+        location: {
+          path: '__tests__/fixtures/petstore.oas2.yaml',
+          positions: {
+            begin: {
+              line: 60,
+              column: 8,
+            },
+            end: {
+              line: 71,
+              column: 60,
+            },
+          },
+        },
+      }),
+    ]);
+  });
+
+  test('should include description', () => {
+    expect(JSON.parse(codeClimate(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([
+      expect.objectContaining({
+        description: 'paths./pets.get.description is not truthy',
+      }),
+      expect.objectContaining({
+        description: 'paths./pets.get.tags is not truthy',
+      }),
+    ]);
+  });
+});
diff --git a/test-harness/scenarios/help-no-document.scenario b/test-harness/scenarios/help-no-document.scenario
index 76f28fc3..ffe7d987 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 provided by using multiple flags  [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif", "markdown"] [default: "stylish"]
+  -f, --format                   formatters to use for outputting results, more than one can be provided by using multiple flags  [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif", "code-climate", "gitlab", "markdown"] [default: "stylish"]
   -o, --output                   where to output results, can be a single file name, multiple "output.<format>" 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 badde74b..06f345cc 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 provided by using multiple flags  [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif", "markdown"] [default: "stylish"]
+  -f, --format                   formatters to use for outputting results, more than one can be provided by using multiple flags  [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif", "code-climate", "gitlab", "markdown"] [default: "stylish"]
   -o, --output                   where to output results, can be a single file name, multiple "output.<format>" 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]