Repository: stoplightio/spectral
Pull request #2443 (2023-03-29 21:54:19)

feat(core): add documentUrl to JS api and cli formatters
Fixes https://github.com/stoplightio/spectral/issues/1488.

**Checklist**

- [X] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No

**Screenshots**
<img width="1797" alt="CleanShot 2023-03-29 at 23 51 42@2x" src="https://user-images.githubusercontent.com/1021324/228675872-3ebf58df-d869-494d-af09-83e8194a3158.png">
<img width="2495" alt="CleanShot 2023-03-29 at 23 52 26@2x" src="https://user-images.githubusercontent.com/1021324/228676052-1ae22e88-9bf9-4771-80c8-a4eaeb6ebe21.png">

All formatters have been updated.


dweber019: Should we add the docuemtnUrl type here https://github.com/stoplightio/types/pull/127?
nahteb: thanks for raising this PR, we've just recently added documentationUrls to our ruleset and would find this really useful 👍 
dweber019: @magicmatatjahu @smoya @jonaslagoni any update on this?
jonaslagoni: I dont get why we are assigned tbh. @P0lip this is a core change, while it does change something for the AsyncAPI rules, this is all you 😄 
mnaumanali94: 👋🏼 @dweber019  First of all, I would like to apologize for the delay in responding to your pull request. We appreciate your valuable contribution to the project.

We understand your intention to output the documentation URL as part of the lint process. However it may clutter the output and become repetitive. Additionally, not all organizations utilize the documentation URL, and some prefer using the description field.

With that in mind, we would like to propose a couple of alternative options:

Add the functionality to output the documentationURL behind the --verbose flag. This would make it an opt-in feature, giving users the choice to include the documentation URL in their lint output. The downside to this approach is that it may not cater to organizations that use the description field instead of the documentationURL.
Introduce a new spectral ruleset explain command that displays information for each rule, including severity, documentationURL, and description. This would provide a more comprehensive overview of the rules without cluttering the lint output.
We would love to hear your thoughts on these approaches or any other ideas you might have. Once we have your input, we can work together to make the necessary changes and integrate them into the project.

Thank you once again for your contribution, and we look forward to collaborating with you on this feature.
dweber019: Hi @mnaumanali94 

Thx for the feedback. I agree with you. It depends on the use case.
How would you imagine this explain command.
1. Is this a options flag on lint, and if a rule fails more output is generated
2. or is it a standalone command to basically extract rule information from the rules file without linting?

For our use case, the first one would satisfy our needs and makes IMHO more sense, as the documentation url or description is needed in case of error mostly on CI side, where you don't like to rerun a command to understand the error.
shelleeboo: @dweber019 Thanks you for raising this issue and for your contribution. I have a similar requirement to make use of the documenationUrl property in Javascript. 

@mnaumanali94 Any idea if this PR will be accepted and merged anytime soon?

Cheers!
mnaumanali94: > Is this a options flag on lint, and if a rule fails more output is generated

I would lean towards this
johannesmarx: @dweber019 thanks for taking care of this feature as I was also about to raise a similare request. 
Is there any reason, why no all formatters are considered? 

IMHO also `github-actions` would heavily benefit from it as user simply can follow the docmentation links in GitHub pull requests.

Thanks
dweber019: > @dweber019 thanks for taking care of this feature as I was also about to raise a similare request. Is there any reason, why no all formatters are considered?
> 
> IMHO also `github-actions` would heavily benefit from it as users simply can follow the docmentation links in GitHub pull requests.
> 
> Thanks

No reason, github actions was not implemented when I last committed.

I'm just started to update this pr but have some harness binary issues.
dweber019: harness could be solved by using node 18.
I updated now everything and change the whole feature to a CLI flag.

@mnaumanali94 could you do a review?
mnaumanali94: LGTM!
dweber019: @smoya @jonaslagoni cloud I kindly ask you to do a review of this code. Thanks a lot
dweber019: @jonaslagoni @smoya could someone of you check this pr. Thanks a lot
austinmehmet: Any update on this? Would really appreciate this feature so we can produce better HTML reports that link to our documentation
frankkilcommins: @dweber019 I've reviewed and this is looking good. I've also resolved the merge conflicts and merged in the latest from `origin/develop`.

Some of the documentation-url tests are failing. Could you take a look and we can make sure to get this merged and released? 
mnaumanali94: @dweber019 gentle nudge on this. Would you be able to take a stab at fixing the tests. 

Patch:
diff --git a/docs/guides/2-cli.md b/docs/guides/2-cli.md
index 9bb41c3e..9b095b58 100644
--- a/docs/guides/2-cli.md
+++ b/docs/guides/2-cli.md
@@ -41,12 +41,13 @@ 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]
+      --show-documentation-url   show documentation url in output result                      [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/commands/lint.ts b/packages/cli/src/commands/lint.ts
index dffc1f3b..d84c6f89 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -151,6 +151,11 @@ const lintCommand: CommandModule = {
           type: 'boolean',
           default: false,
         },
+        'show-documentation-url': {
+          description: 'show documentation url in output result',
+          type: 'boolean',
+          default: false,
+        },
         verbose: {
           alias: 'v',
           description: 'increase verbosity',
@@ -175,6 +180,7 @@ const lintCommand: CommandModule = {
       encoding,
       ignoreUnknownFormat,
       failOnUnmatchedGlobs,
+      showDocumentationUrl,
       ...config
     } = args as unknown as ILintConfig & {
       documents: Array<number | string>;
@@ -189,6 +195,7 @@ const lintCommand: CommandModule = {
         encoding,
         ignoreUnknownFormat,
         failOnUnmatchedGlobs,
+        showDocumentationUrl,
         ruleset,
         stdinFilepath,
         ...pick<Partial<ILintConfig>, keyof ILintConfig>(config, ['verbose', 'quiet', 'resolver']),
@@ -198,6 +205,10 @@ const lintCommand: CommandModule = {
         linterResult.results = filterResultsBySeverity(linterResult.results, failSeverity);
       }
 
+      if (!showDocumentationUrl) {
+        linterResult.results = removeDocumentationUrlFromResults(linterResult.results);
+      }
+
       await Promise.all(
         format.map(f => {
           const formattedOutput = formatOutput(
@@ -279,6 +290,10 @@ const filterResultsBySeverity = (results: IRuleResult[], failSeverity: FailSever
   return results.filter(r => r.severity <= diagnosticSeverity);
 };
 
+const removeDocumentationUrlFromResults = (results: IRuleResult[]): IRuleResult[] => {
+  return results.map(r => ({ ...r, documentationUrl: undefined }));
+};
+
 export const severeEnoughToFail = (results: IRuleResult[], failSeverity: FailSeverity): boolean => {
   const diagnosticSeverity = getDiagnosticSeverity(failSeverity);
   return results.some(r => r.severity <= diagnosticSeverity);
diff --git a/packages/cli/src/services/config.ts b/packages/cli/src/services/config.ts
index 03047950..e594c853 100644
--- a/packages/cli/src/services/config.ts
+++ b/packages/cli/src/services/config.ts
@@ -27,6 +27,7 @@ export interface ILintConfig {
   stdinFilepath?: string;
   ignoreUnknownFormat: boolean;
   failOnUnmatchedGlobs: boolean;
+  showDocumentationUrl: boolean;
   verbose?: boolean;
   quiet?: boolean;
 }
diff --git a/packages/core/src/runner/lintNode.ts b/packages/core/src/runner/lintNode.ts
index 2dcf931c..6070c681 100644
--- a/packages/core/src/runner/lintNode.ts
+++ b/packages/core/src/runner/lintNode.ts
@@ -99,6 +99,7 @@ function processTargetResults(
       severity,
       ...(source !== null ? { source } : null),
       range,
+      documentationUrl: rule.documentationUrl ?? undefined,
     });
   }
 }
diff --git a/packages/core/src/types/spectral.ts b/packages/core/src/types/spectral.ts
index 6b30d739..a6ae3a1a 100644
--- a/packages/core/src/types/spectral.ts
+++ b/packages/core/src/types/spectral.ts
@@ -13,6 +13,7 @@ export interface IRunOpts {
 export interface ISpectralDiagnostic extends IDiagnostic {
   path: JsonPath;
   code: string | number;
+  documentationUrl?: string;
 }
 
 export type IRuleResult = ISpectralDiagnostic;
diff --git a/packages/formatters/src/github-actions.ts b/packages/formatters/src/github-actions.ts
index f09b43ad..bca7f05c 100644
--- a/packages/formatters/src/github-actions.ts
+++ b/packages/formatters/src/github-actions.ts
@@ -41,7 +41,9 @@ export const githubActions: Formatter = results => {
       // FIXME: Use replaceAll instead after removing Node.js 14 support.
       const message = result.message.replace(/\n/g, '%0A');
 
-      return `::${OUTPUT_TYPES[result.severity]} ${paramsString}::${message}`;
+      return `::${OUTPUT_TYPES[result.severity]} ${paramsString}::${message}${
+        result.documentationUrl ? `::${result.documentationUrl}` : ''
+      }`;
     })
     .join('\n');
 };
diff --git a/packages/formatters/src/html/html-template-message.html b/packages/formatters/src/html/html-template-message.html
index 73f5ba9d..74ac39f9 100644
--- a/packages/formatters/src/html/html-template-message.html
+++ b/packages/formatters/src/html/html-template-message.html
@@ -2,4 +2,5 @@
     <td><%= line %>:<%= character %></td>
     <td class="severity clr-<%= severity %>"><%= severity %></td>
     <td><%- message %></td>
+    <td><% if(documentationUrl) { %><a href="<%- documentationUrl %>" target="_blank">documentation</a><% } %></td>
 </tr>
diff --git a/packages/formatters/src/html/index.ts b/packages/formatters/src/html/index.ts
index f68f9d56..33bda525 100644
--- a/packages/formatters/src/html/index.ts
+++ b/packages/formatters/src/html/index.ts
@@ -50,6 +50,7 @@ function renderMessages(messages: IRuleResult[], parentIndex: number): string {
         severity: getSeverityName(message.severity),
         message: message.message,
         code: message.code,
+        documentationUrl: message.documentationUrl,
       });
     })
     .join('\n');
diff --git a/packages/formatters/src/json.ts b/packages/formatters/src/json.ts
index 4ff9fbce..0f57fc2f 100644
--- a/packages/formatters/src/json.ts
+++ b/packages/formatters/src/json.ts
@@ -2,6 +2,12 @@ import { Formatter } from './types';
 
 export const json: Formatter = results => {
   const outputJson = results.map(result => {
+    let documentationUrlObject = {};
+    if (result.documentationUrl) {
+      documentationUrlObject = {
+        documentationUrl: result.documentationUrl,
+      };
+    }
     return {
       code: result.code,
       path: result.path,
@@ -9,6 +15,7 @@ export const json: Formatter = results => {
       severity: result.severity,
       range: result.range,
       source: result.source,
+      ...documentationUrlObject,
     };
   });
   return JSON.stringify(outputJson, null, '\t');
diff --git a/packages/formatters/src/junit.ts b/packages/formatters/src/junit.ts
index 646341af..60eebf2c 100644
--- a/packages/formatters/src/junit.ts
+++ b/packages/formatters/src/junit.ts
@@ -62,6 +62,9 @@ export const junit: Formatter = (results, { failSeverity }) => {
         output += `line ${result.range.start.line + 1}, col ${result.range.start.character + 1}, `;
         output += `${prepareForCdata(result.message)} (${result.code}) `;
         output += `at path ${prepareForCdata(path)}`;
+        if (result.documentationUrl) {
+          output += `, ${result.documentationUrl}`;
+        }
         output += ']]>';
         output += `</failure>`;
         output += '</testcase>\n';
diff --git a/packages/formatters/src/pretty.ts b/packages/formatters/src/pretty.ts
index 3d1a4040..a4fd6390 100644
--- a/packages/formatters/src/pretty.ts
+++ b/packages/formatters/src/pretty.ts
@@ -74,6 +74,11 @@ export const pretty: Formatter = results => {
         { text: chalk[color].bold(result.code), padding: PAD_TOP0_LEFT2, width: COLUMNS[2] },
         { text: chalk.gray(result.message), padding: PAD_TOP0_LEFT2, width: COLUMNS[3] },
         { text: chalk.cyan(printPath(result.path, PrintStyle.Dot)), padding: PAD_TOP0_LEFT2 },
+        {
+          text: chalk.gray(result.documentationUrl ?? ''),
+          padding: PAD_TOP0_LEFT2,
+          width: result.documentationUrl ? undefined : 0.1,
+        },
       );
       ui.div();
     });
diff --git a/packages/formatters/src/sarif.ts b/packages/formatters/src/sarif.ts
index 76c9f74e..fb7d6910 100644
--- a/packages/formatters/src/sarif.ts
+++ b/packages/formatters/src/sarif.ts
@@ -44,7 +44,7 @@ export const sarif: Formatter = (results, _, ctx) => {
     const severity: DiagnosticSeverity = result.severity || DiagnosticSeverity.Error;
     sarifResultBuilder.initSimple({
       level: OUTPUT_TYPES[severity] || 'error',
-      messageText: result.message,
+      messageText: result.documentationUrl ? `${result.message} -- ${result.documentationUrl}` : result.message,
       ruleId: result.code.toString(),
       fileUri: relative(process.cwd(), result.source ?? '').replace(/\\/g, '/'),
       startLine: result.range.start.line + 1,
diff --git a/packages/formatters/src/stylish.ts b/packages/formatters/src/stylish.ts
index 7f0aecf3..013eb643 100644
--- a/packages/formatters/src/stylish.ts
+++ b/packages/formatters/src/stylish.ts
@@ -72,6 +72,7 @@ export const stylish: Formatter = results => {
       result.code ?? '',
       result.message,
       printPath(result.path, PrintStyle.Dot),
+      result.documentationUrl ?? '',
     ]);
 
     output += `${table(pathTableData, {
diff --git a/packages/formatters/src/teamcity.ts b/packages/formatters/src/teamcity.ts
index 8b3a53e3..c69bf5df 100644
--- a/packages/formatters/src/teamcity.ts
+++ b/packages/formatters/src/teamcity.ts
@@ -23,7 +23,8 @@ function inspectionType(result: IRuleResult & { source: string }): string {
   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}']`;
+  const documentationUrl = result.documentationUrl ? ` -- ${escapeString(result.documentationUrl)}` : '';
+  return `##teamcity[inspectionType category='openapi' id='${code}' name='${code}' description='${severity} -- ${message}${documentationUrl}']`;
 }
 
 function inspection(result: IRuleResult & { source: string }): string {
diff --git a/packages/formatters/src/text.ts b/packages/formatters/src/text.ts
index 12638bf0..54705272 100644
--- a/packages/formatters/src/text.ts
+++ b/packages/formatters/src/text.ts
@@ -12,7 +12,8 @@ function renderResults(results: IRuleResult[]): string {
       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}"`;
+      const documentationUrl = result.documentationUrl ? ` ${result.documentationUrl}` : '';
+      return `${result.source}:${line}:${character} ${severity} ${result.code} "${result.message}"${documentationUrl}`;
     })
     .join('\n');
 }


Test patch:
diff --git a/packages/cli/src/commands/__tests__/lint.test.ts b/packages/cli/src/commands/__tests__/lint.test.ts
index 69d59fb8..2390d1b0 100644
--- a/packages/cli/src/commands/__tests__/lint.test.ts
+++ b/packages/cli/src/commands/__tests__/lint.test.ts
@@ -81,6 +81,7 @@ describe('lint', () => {
         output: { stylish: '<stdout>' },
         ignoreUnknownFormat: false,
         failOnUnmatchedGlobs: false,
+        showDocumentationUrl: false,
       });
     });
   });
@@ -94,6 +95,7 @@ describe('lint', () => {
       output: { stylish: '<stdout>' },
       ignoreUnknownFormat: false,
       failOnUnmatchedGlobs: false,
+      showDocumentationUrl: false,
     });
   });
 
@@ -106,6 +108,7 @@ describe('lint', () => {
       output: { stylish: '<stdout>' },
       ignoreUnknownFormat: false,
       failOnUnmatchedGlobs: false,
+      showDocumentationUrl: false,
     });
   });
 
@@ -118,6 +121,7 @@ describe('lint', () => {
       output: { json: '<stdout>' },
       ignoreUnknownFormat: false,
       failOnUnmatchedGlobs: false,
+      showDocumentationUrl: false,
     });
   });
 
@@ -184,6 +188,7 @@ describe('lint', () => {
       output: { stylish: '<stdout>' },
       ignoreUnknownFormat: true,
       failOnUnmatchedGlobs: false,
+      showDocumentationUrl: false,
     });
   });
 
@@ -195,6 +200,7 @@ describe('lint', () => {
       output: { stylish: '<stdout>' },
       ignoreUnknownFormat: false,
       failOnUnmatchedGlobs: true,
+      showDocumentationUrl: false,
     });
   });
 
@@ -244,13 +250,13 @@ describe('lint', () => {
     expect(process.stderr.write).nthCalledWith(2, `Error #1: ${chalk.red('some unhandled exception')}\n`);
     expect(process.stderr.write).nthCalledWith(
       3,
-      expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:236`),
+      expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:242`),
     );
 
     expect(process.stderr.write).nthCalledWith(4, `Error #2: ${chalk.red('another one')}\n`);
     expect(process.stderr.write).nthCalledWith(
       5,
-      expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:237`),
+      expect.stringContaining(`packages/cli/src/commands/__tests__/lint.test.ts:243`),
     );
 
     expect(process.stderr.write).nthCalledWith(6, `Error #3: ${chalk.red('original exception')}\n`);
diff --git a/packages/formatters/src/__tests__/html.test.ts b/packages/formatters/src/__tests__/html.test.ts
index 8db9acb7..c9d7c456 100644
--- a/packages/formatters/src/__tests__/html.test.ts
+++ b/packages/formatters/src/__tests__/html.test.ts
@@ -18,36 +18,42 @@ describe('HTML formatter', () => {
     <td>3:10</td>
     <td class="severity clr-hint">hint</td>
     <td>Info object should contain \`contact\` object.</td>
+    <td></td>
 </tr>
 
 <tr style="display:none" class="f-0">
     <td>3:10</td>
     <td class="severity clr-warning">warning</td>
     <td>OpenAPI object info \`description\` must be present and non-empty string.</td>
+    <td></td>
 </tr>
 
 <tr style="display:none" class="f-0">
     <td>5:14</td>
     <td class="severity clr-error">error</td>
     <td>Info must contain Stoplight</td>
+    <td></td>
 </tr>
 
 <tr style="display:none" class="f-0">
     <td>17:13</td>
     <td class="severity clr-information">information</td>
     <td>Operation \`description\` must be present and non-empty string.</td>
+    <td></td>
 </tr>
 
 <tr style="display:none" class="f-0">
     <td>64:14</td>
     <td class="severity clr-information">information</td>
     <td>Operation \`description\` must be present and non-empty string.</td>
+    <td></td>
 </tr>
 
 <tr style="display:none" class="f-0">
     <td>86:13</td>
     <td class="severity clr-information">information</td>
     <td>Operation \`description\` must be present and non-empty string.</td>
+    <td></td>
 </tr>`);
   });
 });
diff --git a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiDocumentSchema.test.ts b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiDocumentSchema.test.ts
index a6b6aa55..2150ea66 100644
--- a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiDocumentSchema.test.ts
+++ b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiDocumentSchema.test.ts
@@ -43,6 +43,7 @@ describe('asyncApiDocumentSchema', () => {
       ).toEqual([
         {
           code: 'asyncapi-schema',
+          documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md#asyncapi-schema',
           message: '"info" property must have required property "title"',
           path: ['info'],
           severity: DiagnosticSeverity.Error,
@@ -131,6 +132,7 @@ describe('asyncApiDocumentSchema', () => {
       ).toEqual([
         {
           code: 'asyncapi-schema',
+          documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md#asyncapi-schema',
           message: '"0" property type must be string',
           path: ['channels', '/user/signedup', 'servers', '0'],
           severity: DiagnosticSeverity.Error,
@@ -138,6 +140,7 @@ describe('asyncApiDocumentSchema', () => {
         },
         {
           code: 'asyncapi-schema',
+          documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md#asyncapi-schema',
           message: '"2" property type must be string',
           path: ['channels', '/user/signedup', 'servers', '2'],
           severity: DiagnosticSeverity.Error,
@@ -184,6 +187,7 @@ describe('asyncApiDocumentSchema', () => {
       ).toEqual([
         {
           code: 'asyncapi-schema',
+          documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md#asyncapi-schema',
           message: '"kafka" property must have required property "url"',
           path: ['components', 'servers', 'kafka'],
           severity: DiagnosticSeverity.Error,
diff --git a/test-harness/scenarios/documentation-url/results-default-output.scenario b/test-harness/scenarios/documentation-url/results-default-output.scenario
new file mode 100644
index 00000000..858e837f
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-default-output.scenario
@@ -0,0 +1,63 @@
+====test====
+Invalid document --output to a file, will show all the contents when the file is read
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command-nix====
+{bin} lint {document} --ruleset "{asset:ruleset.json}" --format=text --output={asset:output.txt} --show-documentation-url > /dev/null; cat {asset:output.txt}
+====command-win====
+{bin} lint {document} --ruleset "{asset:ruleset.json}" --format=text --output={asset:output.txt} --show-documentation-url | Out-Null; cat {asset:output.txt}
+====asset:output.txt====
+====stdout====
+{document}:1:1 warning api-servers ""servers" must be present and non-empty array." https://www.example.com/docs/api-servers.md
+{document}:2:6 warning info-contact "Info object must have a "contact" object."
+{document}:2:6 warning info-description "Info "description" must be present and non-empty string." https://www.example.com/docs/info-description.md
+
diff --git a/test-harness/scenarios/documentation-url/results-format-github-actions.scenario b/test-harness/scenarios/documentation-url/results-format-github-actions.scenario
new file mode 100644
index 00000000..b18f2e20
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-format-github-actions.scenario
@@ -0,0 +1,59 @@
+====test====
+Invalid document outputs results when --format=github-actions
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=github-actions --ruleset "{asset:ruleset.json}" --show-documentation-url
+====stdout====
+::warning title=api-servers,file=document,col=1,endColumn=19,line=1,endLine=4::"servers" must be present and non-empty array.::https://www.example.com/docs/api-servers.md
+::warning title=info-contact,file=document,col=6,endColumn=19,line=2,endLine=4::Info object must have a "contact" object.
+::warning title=info-description,file=document,col=6,endColumn=19,line=2,endLine=4::Info "description" must be present and non-empty string.::https://www.example.com/docs/info-description.md
\ No newline at end of file
diff --git a/test-harness/scenarios/documentation-url/results-format-html.scenario b/test-harness/scenarios/documentation-url/results-format-html.scenario
new file mode 100644
index 00000000..4fa05cb7
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-format-html.scenario
@@ -0,0 +1,200 @@
+====test====
+Invalid document outputs results when --format=html
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=html --ruleset "{asset:ruleset.json}" --show-documentation-url
+====stdout====
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8">
+    <style>
+        body {
+            font-family:Arial, "Helvetica Neue", Helvetica, sans-serif;
+            font-size:16px;
+            font-weight:normal;
+            margin:0;
+            padding:0;
+            color:#333
+        }
+        #overview {
+            padding:20px 30px
+        }
+        td, th {
+            padding:5px 10px
+        }
+        h1 {
+            margin:0
+        }
+        table {
+            margin:30px;
+            width:calc(100% - 60px);
+            max-width:1000px;
+            border-radius:5px;
+            border:1px solid #ddd;
+            border-spacing:0px;
+        }
+        th {
+            font-weight:400;
+            font-size:medium;
+            text-align:left;
+            cursor:pointer
+        }
+        td.severity, th span {
+            font-weight:700
+        }
+        th span {
+            float:right;
+            margin-left:20px
+        }
+        th span:after {
+            content:"";
+            clear:both;
+            display:block
+        }
+        tr:last-child td {
+            border-bottom:none
+        }
+        tr td:first-child, tr td:last-child {
+            color:#9da0a4
+        }
+        #overview.bg-success, tr.bg-success th {
+            color:#468847;
+            background:#dff0d8;
+            border-bottom:1px solid #d6e9c6
+        }
+        #overview.bg-error, tr.bg-error th {
+            color:#b94a48;
+            background:#f2dede;
+            border-bottom:1px solid #eed3d7
+        }
+        #overview.bg-warning, tr.bg-warning th {
+            color:#f0ad4e;
+            background:#fcf8e3;
+            border-bottom:1px solid #fbeed5
+        }
+        #overview.bg-information, tr.bg-information th {
+            color:#4e95f0;
+            background:#deecfb;
+            border-bottom:1px solid #d3e3ee
+        }
+        td {
+            border-bottom:1px solid #ddd
+        }
+        td.clr-error {
+            color:#b94a48
+        }
+        td.clr-warning {
+            color:#f0ad4e
+        }
+        td.clr-information {
+            color:#4e95f0
+        }
+        td.clr-hint {
+            color:#5a5a5a
+        }
+    </style>
+    <title>Spectral Report</title>
+</head>
+<body>
+<div id="overview" class="bg-warning">
+    <h1>Spectral Report</h1>
+    <div>
+        <span>3 problems (0 errors, 3 warnings, 0 infos, 0 hints)</span> - Generated on {date}
+    </div>
+</div>
+<table>
+    <tbody>
+    <tr class="bg-warning" data-group="f-0">
+    <th colspan="4">
+        [+] {document}
+        <span>3 problems (0 errors, 3 warnings, 0 infos, 0 hints)</span>
+    </th>
+</tr>
+<tr style="display:none" class="f-0">
+    <td>1:1</td>
+    <td class="severity clr-warning">warning</td>
+    <td>&quot;servers&quot; must be present and non-empty array.</td>
+    <td><a href="https://www.example.com/docs/api-servers.md" target="_blank">documentation</a></td>
+</tr>
+
+<tr style="display:none" class="f-0">
+    <td>2:6</td>
+    <td class="severity clr-warning">warning</td>
+    <td>Info object must have a &quot;contact&quot; object.</td>
+    <td></td>
+</tr>
+
+<tr style="display:none" class="f-0">
+    <td>2:6</td>
+    <td class="severity clr-warning">warning</td>
+    <td>Info &quot;description&quot; must be present and non-empty string.</td>
+    <td><a href="https://www.example.com/docs/info-description.md" target="_blank">documentation</a></td>
+</tr>
+
+    </tbody>
+</table>
+<script type="text/javascript">
+    var groups = document.querySelectorAll("tr[data-group]");
+    for (var i = 0; i < groups.length; i++) {
+        groups[i].addEventListener("click", function() {
+            var inGroup = document.getElementsByClassName(this.getAttribute("data-group"));
+            this.innerHTML = (this.innerHTML.indexOf("+") > -1) ? this.innerHTML.replace("+", "-") : this.innerHTML.replace("-", "+");
+            for (var j = 0; j < inGroup.length; j++) {
+                inGroup[j].style.display = (inGroup[j].style.display !== "none") ? "none" : "table-row";
+            }
+        });
+    }
+</script>
+</body>
+</html>
diff --git a/test-harness/scenarios/documentation-url/results-format-json.scenario b/test-harness/scenarios/documentation-url/results-format-json.scenario
new file mode 100644
index 00000000..0488022c
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-format-json.scenario
@@ -0,0 +1,115 @@
+====test====
+Invalid document outputs results --format=json
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=json --ruleset "{asset:ruleset.json}" --show-documentation-url
+====stdout====
+[
+	{
+		"code": "api-servers",
+		"path": [],
+		"message": "\"servers\" must be present and non-empty array.",
+		"severity": 1,
+		"range": {
+			"start": {
+				"line": 0,
+				"character": 0
+			},
+			"end": {
+				"line": 3,
+				"character": 18
+			}
+		},
+		"source": "{document}",
+		"documentationUrl": "https://www.example.com/docs/api-servers.md"
+	},
+	{
+		"code": "info-contact",
+		"path": [
+			"info"
+		],
+		"message": "Info object must have a \"contact\" object.",
+		"severity": 1,
+		"range": {
+			"start": {
+				"line": 1,
+				"character": 5
+			},
+			"end": {
+				"line": 3,
+				"character": 18
+			}
+		},
+		"source": "{document}"
+	},
+	{
+		"code": "info-description",
+		"path": [
+			"info"
+		],
+		"message": "Info \"description\" must be present and non-empty string.",
+		"severity": 1,
+		"range": {
+			"start": {
+				"line": 1,
+				"character": 5
+			},
+			"end": {
+				"line": 3,
+				"character": 18
+			}
+		},
+		"source": "{document}",
+		"documentationUrl": "https://www.example.com/docs/info-description.md"
+	}
+]
diff --git a/test-harness/scenarios/documentation-url/results-format-junit.scenario b/test-harness/scenarios/documentation-url/results-format-junit.scenario
new file mode 100644
index 00000000..5a6f2696
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-format-junit.scenario
@@ -0,0 +1,62 @@
+====test====
+Invalid document outputs results when --format=junit
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "severity": "error",
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=junit --ruleset "{asset:ruleset.json}" --show-documentation-url
+====stdout====
+<?xml version="1.0" encoding="utf-8"?>
+<testsuites>
+<testsuite package="org.spectral" time="0" tests="1" errors="0" failures="1" name="{document}">
+<testcase time="0" name="org.spectral.api-servers(#)" classname="{document}"><failure message="&quot;servers&quot; must be present and non-empty array."><![CDATA[line 1, col 1, "servers" must be present and non-empty array. (api-servers) at path #, https://www.example.com/docs/api-servers.md]]></failure></testcase>
+</testsuite>
+</testsuites>
diff --git a/test-harness/scenarios/documentation-url/results-format-sarif.scenario b/test-harness/scenarios/documentation-url/results-format-sarif.scenario
new file mode 100644
index 00000000..1f0f3940
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-format-sarif.scenario
@@ -0,0 +1,174 @@
+====test====
+Invalid document outputs results when --format=sarif
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=sarif --ruleset "{asset:ruleset.json}" --show-documentation-url
+====stdout====
+{
+  "$schema": "http://json.schemastore.org/sarif-2.1.0-rtm.6.json",
+  "version": "2.1.0",
+  "runs": [
+    {
+      "tool": {
+        "driver": {
+          "name": "spectral",
+          "rules": [
+            {
+              "id": "api-servers",
+              "shortDescription": {
+                "text": "\"servers\" must be present and non-empty array."
+              },
+              "helpUri": "https://www.example.com/docs/api-servers.md"
+            },
+            {
+              "id": "info-contact",
+              "shortDescription": {
+                "text": "Info object must have a \"contact\" object."
+              }
+            },
+            {
+              "id": "info-description",
+              "shortDescription": {
+                "text": "Info \"description\" must be present and non-empty string."
+              },
+              "helpUri": "https://www.example.com/docs/info-description.md"
+            }
+          ],
+          "informationUri": "https://github.com/stoplightio/spectral"
+        }
+      },
+      "results": [
+        {
+          "level": "warning",
+          "message": {
+            "text": "\"servers\" must be present and non-empty array. -- https://www.example.com/docs/api-servers.md"
+          },
+          "ruleId": "api-servers",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "document",
+                  "index": 0
+                },
+                "region": {
+                  "startLine": 1,
+                  "startColumn": 1,
+                  "endLine": 4,
+                  "endColumn": 19
+                }
+              }
+            }
+          ],
+          "ruleIndex": 0
+        },
+        {
+          "level": "warning",
+          "message": {
+            "text": "Info object must have a \"contact\" object."
+          },
+          "ruleId": "info-contact",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "document",
+                  "index": 0
+                },
+                "region": {
+                  "startLine": 2,
+                  "startColumn": 6,
+                  "endLine": 4,
+                  "endColumn": 19
+                }
+              }
+            }
+          ],
+          "ruleIndex": 1
+        },
+        {
+          "level": "warning",
+          "message": {
+            "text": "Info \"description\" must be present and non-empty string. -- https://www.example.com/docs/info-description.md"
+          },
+          "ruleId": "info-description",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "document",
+                  "index": 0
+                },
+                "region": {
+                  "startLine": 2,
+                  "startColumn": 6,
+                  "endLine": 4,
+                  "endColumn": 19
+                }
+              }
+            }
+          ],
+          "ruleIndex": 2
+        }
+      ],
+      "artifacts": [
+        {
+          "sourceLanguage": "unknown",
+          "location": {
+            "uri": "document"
+          }
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/test-harness/scenarios/documentation-url/results-format-stylish.scenario b/test-harness/scenarios/documentation-url/results-format-stylish.scenario
new file mode 100644
index 00000000..29103576
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-format-stylish.scenario
@@ -0,0 +1,63 @@
+====test====
+Invalid document outputs results when --format=stylish
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+paths: {}
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=stylish --ruleset "{asset:ruleset.json}" --show-documentation-url
+====stdout====
+{document}
+ 1:1  warning  api-servers       "servers" must be present and non-empty array.                  https://www.example.com/docs/api-servers.md
+ 2:6  warning  info-contact      Info object must have a "contact" object.                 info
+ 2:6  warning  info-description  Info "description" must be present and non-empty string.  info  https://www.example.com/docs/info-description.md
+
+✖ 3 problems (0 errors, 3 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/documentation-url/results-format-teamcity.scenario b/test-harness/scenarios/documentation-url/results-format-teamcity.scenario
new file mode 100644
index 00000000..6fc9cd44
--- /dev/null
+++ b/test-harness/scenarios/documentation-url/results-format-teamcity.scenario
@@ -0,0 +1,61 @@
+====test====
+Invalid document outputs results when --format=teamcity
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=teamcity --ruleset "{asset:ruleset.json}" --show-documentation-url
+====stdout====
+##teamcity[inspectionType category='openapi' id='api-servers' name='api-servers' description='warning -- "servers" must be present and non-empty array. -- https://www.example.com/docs/api-servers.md']##teamcity[inspection typeId='api-servers' file='{document}' line='1' message='warning -- "servers" must be present and non-empty array.']
+##teamcity[inspectionType category='openapi' id='info-contact' name='info-contact' description='warning -- Info object must have a "contact" object.']
+##teamcity[inspection typeId='info-contact' file='{document}' line='2' message='warning -- Info object must have a "contact" object.']
+##teamcity[inspectionType category='openapi' id='info-description' name='info-description' description='warning -- Info "description" must be present and non-empty string. -- https://www.example.com/docs/info-description.md']
+##teamcity[inspection typeId='info-description' file='{document}' line='2' message='warning -- Info "description" must be present and non-empty string.']
\ No newline at end of file
diff --git a/test-harness/scenarios/formats/results-format-html.scenario b/test-harness/scenarios/formats/results-format-html.scenario
index 63ae922d..75342e39 100644
--- a/test-harness/scenarios/formats/results-format-html.scenario
+++ b/test-harness/scenarios/formats/results-format-html.scenario
@@ -163,18 +163,21 @@ info:
     <td>1:1</td>
     <td class="severity clr-warning">warning</td>
     <td>&quot;servers&quot; must be present and non-empty array.</td>
+    <td></td>
 </tr>
 
 <tr style="display:none" class="f-0">
     <td>2:6</td>
     <td class="severity clr-warning">warning</td>
     <td>Info object must have a &quot;contact&quot; object.</td>
+    <td></td>
 </tr>
 
 <tr style="display:none" class="f-0">
     <td>2:6</td>
     <td class="severity clr-warning">warning</td>
     <td>Info &quot;description&quot; must be present and non-empty string.</td>
+    <td></td>
 </tr>
 
     </tbody>
diff --git a/test-harness/scenarios/formats/results-format-sarif.scenario b/test-harness/scenarios/formats/results-format-sarif.scenario
new file mode 100644
index 00000000..01c5b2ca
--- /dev/null
+++ b/test-harness/scenarios/formats/results-format-sarif.scenario
@@ -0,0 +1,174 @@
+====test====
+Invalid document outputs results when --format=sarif
+====document====
+---
+info:
+  version: 1.0.0
+  title: Stoplight
+====asset:ruleset.json====
+{
+  "rules": {
+    "api-servers": {
+      "documentationUrl": "https://www.example.com/docs/api-servers.md",
+      "description": "\"servers\" must be present and non-empty array.",
+      "recommended": true,
+      "given": "$",
+      "then": {
+        "field": "servers",
+        "function": "schema",
+        "functionOptions": {
+          "dialect": "draft7",
+          "schema": {
+            "items": {
+              "type": "object",
+            },
+            "minItems": 1,
+            "type": "array"
+          }
+        }
+      }
+    },
+    "info-contact": {
+      "description": "Info object must have a \"contact\" object.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.contact",
+        "function": "truthy",
+      }
+    },
+    "info-description": {
+      "documentationUrl": "https://www.example.com/docs/info-description.md",
+      "description": "Info \"description\" must be present and non-empty string.",
+      "recommended": true,
+      "type": "style",
+      "given": "$",
+      "then": {
+        "field": "info.description",
+        "function": "truthy"
+      }
+    }
+  }
+}
+====command====
+{bin} lint {document} --format=sarif --ruleset "{asset:ruleset.json}"
+====stdout====
+{
+  "$schema": "http://json.schemastore.org/sarif-2.1.0-rtm.6.json",
+  "version": "2.1.0",
+  "runs": [
+    {
+      "tool": {
+        "driver": {
+          "name": "spectral",
+          "rules": [
+            {
+              "id": "api-servers",
+              "shortDescription": {
+                "text": "\"servers\" must be present and non-empty array."
+              },
+              "helpUri": "https://www.example.com/docs/api-servers.md"
+            },
+            {
+              "id": "info-contact",
+              "shortDescription": {
+                "text": "Info object must have a \"contact\" object."
+              }
+            },
+            {
+              "id": "info-description",
+              "shortDescription": {
+                "text": "Info \"description\" must be present and non-empty string."
+              },
+              "helpUri": "https://www.example.com/docs/info-description.md"
+            }
+          ],
+          "informationUri": "https://github.com/stoplightio/spectral"
+        }
+      },
+      "results": [
+        {
+          "level": "warning",
+          "message": {
+            "text": "\"servers\" must be present and non-empty array."
+          },
+          "ruleId": "api-servers",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "document",
+                  "index": 0
+                },
+                "region": {
+                  "startLine": 1,
+                  "startColumn": 1,
+                  "endLine": 4,
+                  "endColumn": 19
+                }
+              }
+            }
+          ],
+          "ruleIndex": 0
+        },
+        {
+          "level": "warning",
+          "message": {
+            "text": "Info object must have a \"contact\" object."
+          },
+          "ruleId": "info-contact",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "document",
+                  "index": 0
+                },
+                "region": {
+                  "startLine": 2,
+                  "startColumn": 6,
+                  "endLine": 4,
+                  "endColumn": 19
+                }
+              }
+            }
+          ],
+          "ruleIndex": 1
+        },
+        {
+          "level": "warning",
+          "message": {
+            "text": "Info \"description\" must be present and non-empty string."
+          },
+          "ruleId": "info-description",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "document",
+                  "index": 0
+                },
+                "region": {
+                  "startLine": 2,
+                  "startColumn": 6,
+                  "endLine": 4,
+                  "endColumn": 19
+                }
+              }
+            }
+          ],
+          "ruleIndex": 2
+        }
+      ],
+      "artifacts": [
+        {
+          "sourceLanguage": "unknown",
+          "location": {
+            "uri": "document"
+          }
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/test-harness/scenarios/formats/too-few-outputs.scenario b/test-harness/scenarios/formats/too-few-outputs.scenario
index 733e5418..22acc57e 100644
--- a/test-harness/scenarios/formats/too-few-outputs.scenario
+++ b/test-harness/scenarios/formats/too-few-outputs.scenario
@@ -28,6 +28,7 @@ Options:
   -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]
+      --show-documentation-url   show documentation url in output result  [boolean] [default: false]
   -v, --verbose                  increase verbosity  [boolean]
   -q, --quiet                    no logging - output only  [boolean]
 
diff --git a/test-harness/scenarios/formats/too-many-outputs.scenario b/test-harness/scenarios/formats/too-many-outputs.scenario
index c127e994..5355283d 100644
--- a/test-harness/scenarios/formats/too-many-outputs.scenario
+++ b/test-harness/scenarios/formats/too-many-outputs.scenario
@@ -28,6 +28,7 @@ Options:
   -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]
+      --show-documentation-url   show documentation url in output result  [boolean] [default: false]
   -v, --verbose                  increase verbosity  [boolean]
   -q, --quiet                    no logging - output only  [boolean]
 
diff --git a/test-harness/scenarios/formats/unmatched-outputs.scenario b/test-harness/scenarios/formats/unmatched-outputs.scenario
index 69f7f1fc..84828a5a 100644
--- a/test-harness/scenarios/formats/unmatched-outputs.scenario
+++ b/test-harness/scenarios/formats/unmatched-outputs.scenario
@@ -28,6 +28,7 @@ Options:
   -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]
+      --show-documentation-url   show documentation url in output result  [boolean] [default: false]
   -v, --verbose                  increase verbosity  [boolean]
   -q, --quiet                    no logging - output only  [boolean]
 
diff --git a/test-harness/scenarios/help-no-document.scenario b/test-harness/scenarios/help-no-document.scenario
index ffe7d987..4a28bb1b 100644
--- a/test-harness/scenarios/help-no-document.scenario
+++ b/test-harness/scenarios/help-no-document.scenario
@@ -29,6 +29,7 @@ Options:
   -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]
+      --show-documentation-url   show documentation url in output result  [boolean] [default: false]
   -v, --verbose                  increase verbosity  [boolean]
   -q, --quiet                    no logging - output only  [boolean]
 
diff --git a/test-harness/scenarios/strict-options.scenario b/test-harness/scenarios/strict-options.scenario
index 06f345cc..d1acb40c 100644
--- a/test-harness/scenarios/strict-options.scenario
+++ b/test-harness/scenarios/strict-options.scenario
@@ -29,6 +29,7 @@ Options:
   -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]
+      --show-documentation-url   show documentation url in output result  [boolean] [default: false]
   -v, --verbose                  increase verbosity  [boolean]
   -q, --quiet                    no logging - output only  [boolean]
 
diff --git a/test-harness/src/suite.ts b/test-harness/src/suite.ts
index c8e73811..76ae1946 100644
--- a/test-harness/src/suite.ts
+++ b/test-harness/src/suite.ts
@@ -35,6 +35,23 @@ if (scenario.command === null) {
       },
     });
 
+    const stripSpectralVersionFromSarif = (str: string): string => {
+      try {
+        const parsed = JSON.parse(str);
+        if (parsed?.$schema?.includes('sarif')) {
+          for (const run of parsed.runs ?? []) {
+            if (run?.tool?.driver?.version) {
+              delete run.tool.driver.version;
+            }
+          }
+          return JSON.stringify(parsed, null, 2);
+        }
+      } catch {
+        // not SARIF or not JSON
+      }
+      return str;
+    };
+
     if (stderr !== null) {
       expect(output.stderr).toEqual(normalizeLineEndings(applyReplacements(stderr, env)));
     } else if (output.stderr !== '') {
@@ -42,7 +59,15 @@ if (scenario.command === null) {
     }
 
     if (stdout !== null) {
-      expect(output.stdout).toEqual(normalizeLineEndings(applyReplacements(stdout, env)));
+      let actual = normalizeLineEndings(output.stdout);
+      let expected = normalizeLineEndings(applyReplacements(stdout, env));
+
+      if (/--format=sarif\b/i.test(scenario.command ?? '')) {
+        actual = stripSpectralVersionFromSarif(actual);
+        expected = stripSpectralVersionFromSarif(expected);
+      }
+
+      expect(actual).toEqual(expected);
     }
 
     if (status !== null) {