Repository: stoplightio/spectral
Pull request #1369 (2020-10-01 10:44:07)

feat(ruleset): allow documentationUrl to be specified for each rule
Closes https://github.com/stoplightio/spectral/issues/1356

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No



Patch:
diff --git a/docs/guides/4-custom-rulesets.md b/docs/guides/4-custom-rulesets.md
index dbe67dd3..262381a1 100644
--- a/docs/guides/4-custom-rulesets.md
+++ b/docs/guides/4-custom-rulesets.md
@@ -188,6 +188,21 @@ In this example, violations of the `tag-description` rule would indicate `https:
 
 If no `documentationUrl` is provided, no links will show up, and users will just have to rely on the error messages to figure out how the errors can be fixed.
 
+If you wish to override a documentation URL for a particular rule, you can do so by specifying `documentationUrl`.
+
+```yaml
+extends: spectral:oas
+documentationUrl: https://www.example.com/docs/api-ruleset.md
+rules:
+  tag-description:
+    description: Please provide a description for each tag.
+    documentationUrl: https://www.example.com/docs/tag-description.md
+    given: $.tags[*]
+    then:
+      field: description
+      function: truthy
+```
+
 ## Core Functions
 
 Several functions [are provided by default](../reference/functions.md) for your rules.
diff --git a/src/meta/rule.schema.json b/src/meta/rule.schema.json
index a58a70e7..01091f59 100644
--- a/src/meta/rule.schema.json
+++ b/src/meta/rule.schema.json
@@ -348,6 +348,10 @@
         "description": {
           "type": "string"
         },
+        "documentationUrl": {
+          "type": "string",
+          "format": "url"
+        },
         "recommended": {
           "type": "boolean"
         },
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
index 5917c488..7b766995 100644
--- a/src/rulesets/reader.ts
+++ b/src/rulesets/reader.ts
@@ -8,7 +8,7 @@ import { FileRulesetSeverity, IRuleset, RulesetFunctionCollection } from '../typ
 import { findFile, isNPMSource } from './finder';
 import { mergeFormats, mergeFunctions, mergeRules } from './mergers';
 import { mergeExceptions } from './mergers/exceptions';
-import { assertValidRuleset } from './validation';
+import { assertValidRuleset, isValidRule } from './validation';
 import { parseYaml } from '../parsers';
 
 export interface IRulesetReadOptions extends IHttpAndFileResolverOptions {
@@ -40,10 +40,6 @@ export async function readRuleset(uris: string | string[], opts?: IRulesetReadOp
     Object.assign(base.rules, resolvedRuleset.rules);
     Object.assign(base.functions, resolvedRuleset.functions);
     Object.assign(base.exceptions, resolvedRuleset.exceptions);
-
-    if (resolvedRuleset.documentationUrl !== void 0 && !('documentationUrl' in base)) {
-      base.documentationUrl = resolvedRuleset.documentationUrl;
-    }
   }
 
   return base;
@@ -90,7 +86,6 @@ const createRulesetProcessor = (
     const functions = {};
     const exceptions = {};
     const newRuleset: IRuleset = {
-      ...('documentationUrl' in ruleset ? { documentationUrl: ruleset.documentationUrl } : null),
       rules,
       functions,
       exceptions,
@@ -104,10 +99,10 @@ const createRulesetProcessor = (
         let extendedRuleset: IRuleset | null;
         let parentSeverity: FileRulesetSeverity;
         if (Array.isArray(extended)) {
-          parentSeverity = severity === undefined ? extended[1] : severity;
+          parentSeverity = severity === void 0 ? extended[1] : severity;
           extendedRuleset = await processRuleset(rulesetUri, extended[0], parentSeverity);
         } else {
-          parentSeverity = severity === undefined ? 'recommended' : severity;
+          parentSeverity = severity === void 0 ? 'recommended' : severity;
           extendedRuleset = await processRuleset(rulesetUri, extended, parentSeverity);
         }
 
@@ -120,7 +115,15 @@ const createRulesetProcessor = (
     }
 
     if (ruleset.rules !== void 0) {
-      mergeRules(rules, ruleset.rules, severity === undefined ? 'recommended' : severity);
+      mergeRules(rules, ruleset.rules, severity === void 0 ? 'recommended' : severity);
+
+      if (ruleset.documentationUrl !== void 0) {
+        for (const rule of Object.values(ruleset.rules)) {
+          if (isValidRule(rule) && rule.documentationUrl === void 0) {
+            rule.documentationUrl = ruleset.documentationUrl;
+          }
+        }
+      }
     }
 
     if (ruleset.except !== void 0) {
diff --git a/src/types/rule.ts b/src/types/rule.ts
index 52f9a04d..5846bacc 100644
--- a/src/types/rule.ts
+++ b/src/types/rule.ts
@@ -6,6 +6,8 @@ export interface IRule<T = string, O = any> {
 
   formats?: string[];
 
+  documentationUrl?: string;
+
   // A meaningful feedback about the error
   message?: string;
 
diff --git a/src/types/ruleset.ts b/src/types/ruleset.ts
index 1e1b5cbe..ac1d4d0c 100644
--- a/src/types/ruleset.ts
+++ b/src/types/ruleset.ts
@@ -22,7 +22,6 @@ export type RulesetFunctionCollection = Dictionary<IRulesetFunctionDefinition, s
 export type RulesetExceptionCollection = Dictionary<string[], string>;
 
 export interface IRuleset {
-  documentationUrl?: string;
   rules: RuleCollection;
   functions: RulesetFunctionCollection;
   exceptions: RulesetExceptionCollection;


Test patch:
diff --git a/src/rulesets/__tests__/__fixtures__/documentation-url-ruleset.json b/src/rulesets/__tests__/__fixtures__/documentation-url-ruleset.json
new file mode 100644
index 00000000..353ead6e
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/documentation-url-ruleset.json
@@ -0,0 +1,18 @@
+{
+  "documentationUrl": "https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md",
+  "rules": {
+    "foo-rule": {
+      "given": "",
+      "then": {
+        "function": ""
+      }
+    },
+    "bar-rule": {
+      "documentationUrl": "https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/bar-rule.md",
+      "given": "",
+      "then": {
+        "function": ""
+      }
+    }
+  }
+}
diff --git a/src/rulesets/__tests__/reader.jest.test.ts b/src/rulesets/__tests__/reader.jest.test.ts
index 491aa95b..7997affb 100644
--- a/src/rulesets/__tests__/reader.jest.test.ts
+++ b/src/rulesets/__tests__/reader.jest.test.ts
@@ -101,7 +101,7 @@ describe('Rulesets reader', () => {
     );
   });
 
-  it('given ruleset  with no custom rules extending other rulesets', async () => {
+  it('given ruleset with no custom rules extending other rulesets', async () => {
     const { rules } = await readRuleset(extendsOnlyRuleset);
 
     expect(rules).toEqual({
@@ -145,6 +145,7 @@ describe('Rulesets reader', () => {
           oasRules[name] = {
             ...rule,
             formats: expect.arrayContaining([expect.any(String)]),
+            documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
             ...(rule.severity === void 0 && { severity: DiagnosticSeverity.Warning }),
             ...(rule.recommended === void 0 && { recommended: true }),
             then: expect.any(Object),
@@ -172,6 +173,7 @@ describe('Rulesets reader', () => {
             rules[name] = {
               ...rule,
               formats: expect.arrayContaining([expect.any(String)]),
+              documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
               ...(rule.severity === undefined && { severity: DiagnosticSeverity.Warning }),
               ...(rule.recommended === false && { severity: -1 }),
               ...(rule.recommended === void 0 && { recommended: true }),
@@ -213,6 +215,7 @@ describe('Rulesets reader', () => {
             const formattedRule: IRule = {
               ...rule,
               formats: expect.arrayContaining([expect.any(String)]),
+              documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
               ...(rule.severity === void 0 && { severity: DiagnosticSeverity.Warning }),
               ...(rule.recommended === false && { severity: -1 }),
               ...(rule.recommended === void 0 && { recommended: true }),
@@ -298,6 +301,7 @@ describe('Rulesets reader', () => {
       'rules.operation-success-response',
       {
         description: 'should be overridden',
+        documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
         given: '$.info',
         formats: expect.arrayContaining([expect.any(String)]),
         recommended: true,
@@ -317,6 +321,7 @@ describe('Rulesets reader', () => {
         formats: expect.arrayContaining([expect.any(String)]),
         severity: -1,
         description: 'Operation `security` values must match a scheme defined in the `securityDefinitions` object.',
+        documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
         then: expect.any(Object),
         type: 'validation',
       },
@@ -925,6 +930,31 @@ describe('Rulesets reader', () => {
     });
   });
 
+  it('should respect documentationUrl', async () => {
+    const ruleset = await readRuleset(path.join(__dirname, './__fixtures__/documentation-url-ruleset.json'));
+
+    expect(ruleset.rules).toStrictEqual({
+      'foo-rule': {
+        documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
+        given: '',
+        recommended: true,
+        severity: DiagnosticSeverity.Warning,
+        then: {
+          function: '',
+        },
+      },
+      'bar-rule': {
+        documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/bar-rule.md',
+        given: '',
+        recommended: true,
+        severity: DiagnosticSeverity.Warning,
+        then: {
+          function: '',
+        },
+      },
+    });
+  });
+
   describe('Exceptions loading', () => {
     it('should handle loading a standalone ruleset', async () => {
       const ruleset = await readRuleset(standaloneExceptRuleset);
diff --git a/src/rulesets/__tests__/validation.test.ts b/src/rulesets/__tests__/validation.test.ts
index dd5afca2..1018ce61 100644
--- a/src/rulesets/__tests__/validation.test.ts
+++ b/src/rulesets/__tests__/validation.test.ts
@@ -33,6 +33,20 @@ describe('Ruleset Validation', () => {
 
   it.each([false, 2, null, 'foo', '12.foo.com'])('given invalid %s documentationUrl, throws', documentationUrl => {
     expect(assertValidRuleset.bind(null, { documentationUrl, rules: {} })).toThrow(ValidationError);
+
+    expect(
+      assertValidRuleset.bind(null, {
+        rules: {
+          rule: {
+            documentationUrl,
+            given: '',
+            then: {
+              function: '',
+            },
+          },
+        },
+      }),
+    ).toThrow(ValidationError);
   });
 
   it('recognizes valid documentationUrl', () => {
@@ -42,6 +56,20 @@ describe('Ruleset Validation', () => {
         extends: ['spectral:oas'],
       }),
     ).not.toThrow();
+
+    expect(
+      assertValidRuleset.bind(null, {
+        rules: {
+          rule: {
+            documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
+            given: '',
+            then: {
+              function: '',
+            },
+          },
+        },
+      }),
+    ).not.toThrow();
   });
 
   it.each(['error', 'warn', 'info', 'hint', 'off'])('recognizes human-readable %s severity', severity => {