Repository: stoplightio/spectral
Pull request #1242 (2020-06-22 14:27:05)

feat(ruleset): introduce documentationUrl property
Related to https://github.com/stoplightio/studio/issues/445

**Checklist**

- [x] Tests added / updated
- [ ] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No



nulltoken: @P0lip Neat!
P0lip: One last review :laughing: hopefully.
@philsturgeon just hit merge once you approve

Patch:
diff --git a/docs/getting-started/rulesets.md b/docs/getting-started/rulesets.md
index 471107aa..0f8f1d45 100644
--- a/docs/getting-started/rulesets.md
+++ b/docs/getting-started/rulesets.md
@@ -125,3 +125,20 @@ Spectral comes with two rulesets included:
  - `spectral:asyncapi` - AsyncAPI v2 rules
 
 You can also make your own: read more about [Custom Rulesets](../guides/4-custom-rulesets.md).
+
+## Documentation URL
+
+Optionally provide a documentation URL to your ruleset in order to help end-users find more information about various warnings. Result messages will sometimes be more than enough to explain what the problem is, but it can also be beneficial to explain _why_ a message exists, and this is a great place to do that.
+
+Whatever you link you provide, the rule name will be appended as an anchor.
+
+Given the following `documentationUrl` [`https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md`](https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md), an example URL for `info-contact` rule would look as follows [`https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md#info-contact`](https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md#info-contact).
+
+```yaml
+documentationUrl: https://www.example.com/docs/api-ruleset.md
+
+rules:
+  # ...
+```
+
+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.
diff --git a/src/meta/ruleset.schema.json b/src/meta/ruleset.schema.json
index a4ec140d..863ae4ba 100644
--- a/src/meta/ruleset.schema.json
+++ b/src/meta/ruleset.schema.json
@@ -3,6 +3,10 @@
     "$id": "http://stoplight.io/schemas/ruleset.schema.json",
     "type": "object",
     "properties": {
+        "documentationUrl": {
+            "type": "string",
+            "format": "url"
+        },
         "rules": {
             "type": "object",
             "additionalProperties": {
diff --git a/src/rulesets/asyncapi/index.json b/src/rulesets/asyncapi/index.json
index 54e47863..c14da0c3 100644
--- a/src/rulesets/asyncapi/index.json
+++ b/src/rulesets/asyncapi/index.json
@@ -1,4 +1,5 @@
 {
+  "documentationUrl": "https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/asyncapi-rules.md",
   "formats": [
     "asyncapi2"
   ],
@@ -398,4 +399,4 @@
       }
     }
   }
-}
\ No newline at end of file
+}
diff --git a/src/rulesets/oas/index.json b/src/rulesets/oas/index.json
index c5dbfde7..17c07877 100644
--- a/src/rulesets/oas/index.json
+++ b/src/rulesets/oas/index.json
@@ -1,4 +1,5 @@
 {
+  "documentationUrl": "https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md",
   "formats": ["oas2", "oas3"],
   "functions": [
     "oasDocumentSchema",
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
index 6015d2ec..3ed6b692 100644
--- a/src/rulesets/reader.ts
+++ b/src/rulesets/reader.ts
@@ -40,6 +40,10 @@ 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;
@@ -86,6 +90,7 @@ const createRulesetProcessor = (
     const functions = {};
     const exceptions = {};
     const newRuleset: IRuleset = {
+      ...('documentationUrl' in ruleset ? { documentationUrl: ruleset.documentationUrl } : null),
       rules,
       functions,
       exceptions,
diff --git a/src/types/ruleset.ts b/src/types/ruleset.ts
index 06d33502..1e1b5cbe 100644
--- a/src/types/ruleset.ts
+++ b/src/types/ruleset.ts
@@ -22,12 +22,14 @@ export type RulesetFunctionCollection = Dictionary<IRulesetFunctionDefinition, s
 export type RulesetExceptionCollection = Dictionary<string[], string>;
 
 export interface IRuleset {
+  documentationUrl?: string;
   rules: RuleCollection;
   functions: RulesetFunctionCollection;
   exceptions: RulesetExceptionCollection;
 }
 
 export interface IRulesetFile {
+  documentationUrl?: string;
   extends?: Array<string | [string, FileRulesetSeverity]>;
   formats?: string[];
   rules?: FileRuleCollection;


Test patch:
diff --git a/src/rulesets/__tests__/validation.test.ts b/src/rulesets/__tests__/validation.test.ts
index 1bf0316c..dd5afca2 100644
--- a/src/rulesets/__tests__/validation.test.ts
+++ b/src/rulesets/__tests__/validation.test.ts
@@ -4,33 +4,46 @@ const invalidRuleset = require('./__fixtures__/invalid-ruleset.json');
 const validRuleset = require('./__fixtures__/valid-flat-ruleset.json');
 
 describe('Ruleset Validation', () => {
-  it('given primitive type should throw', () => {
+  it('given primitive type, throws', () => {
     expect(assertValidRuleset.bind(null, null)).toThrow('Provided ruleset is not an object');
     expect(assertValidRuleset.bind(null, 2)).toThrow('Provided ruleset is not an object');
     expect(assertValidRuleset.bind(null, 'true')).toThrow('Provided ruleset is not an object');
   });
 
-  it('given object with no rules and no extends properties should throw', () => {
+  it('given object with no rules and no extends properties, throws', () => {
     expect(assertValidRuleset.bind(null, {})).toThrow('Ruleset must have rules or extends property');
     expect(assertValidRuleset.bind(null, { rule: {} })).toThrow('Ruleset must have rules or extends property');
   });
 
-  it('given object with extends property only should emit no errors', () => {
+  it('given object with extends property only, emits no errors', () => {
     expect(assertValidRuleset.bind(null, { extends: [] })).not.toThrow();
   });
 
-  it('given object with rules property only should emit no errors', () => {
+  it('given object with rules property only, emits no errors', () => {
     expect(assertValidRuleset.bind(null, { rules: {} })).not.toThrow();
   });
 
-  it('given invalid ruleset should throw', () => {
+  it('given invalid ruleset, throws', () => {
     expect(assertValidRuleset.bind(null, invalidRuleset)).toThrow(ValidationError);
   });
 
-  it('given valid ruleset should emit no errors', () => {
+  it('given valid ruleset should, emits no errors', () => {
     expect(assertValidRuleset.bind(null, validRuleset)).not.toThrow();
   });
 
+  it.each([false, 2, null, 'foo', '12.foo.com'])('given invalid %s documentationUrl, throws', documentationUrl => {
+    expect(assertValidRuleset.bind(null, { documentationUrl, rules: {} })).toThrow(ValidationError);
+  });
+
+  it('recognizes valid documentationUrl', () => {
+    expect(
+      assertValidRuleset.bind(null, {
+        documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md',
+        extends: ['spectral:oas'],
+      }),
+    ).not.toThrow();
+  });
+
   it.each(['error', 'warn', 'info', 'hint', 'off'])('recognizes human-readable %s severity', severity => {
     expect(
       assertValidRuleset.bind(null, {