Repository: stoplightio/spectral
Pull request #652 (2019-10-08 17:09:22)

feat(ruleset): make rules optional


Patch:
diff --git a/src/meta/ruleset.schema.json b/src/meta/ruleset.schema.json
index 7d9b3ec1..9cab5e71 100644
--- a/src/meta/ruleset.schema.json
+++ b/src/meta/ruleset.schema.json
@@ -66,7 +66,12 @@
             }
         }
     },
-    "required": [
-        "rules"
+    "anyOf": [
+        {
+            "required": ["extends"]
+        },
+        {
+            "required": ["rules"]
+        }
     ]
 }
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
index d0725c25..df3f1429 100644
--- a/src/rulesets/reader.ts
+++ b/src/rulesets/reader.ts
@@ -102,7 +102,10 @@ const createRulesetProcessor = (
       }
     }
 
-    mergeRules(rules, ruleset.rules, severity === undefined ? 'recommended' : severity);
+    if (ruleset.rules !== void 0) {
+      mergeRules(rules, ruleset.rules, severity === undefined ? 'recommended' : severity);
+    }
+
     if (Array.isArray(ruleset.formats)) {
       mergeFormats(rules, ruleset.formats);
     }
diff --git a/src/rulesets/validation.ts b/src/rulesets/validation.ts
index 4aa3ceef..305c14d3 100644
--- a/src/rulesets/validation.ts
+++ b/src/rulesets/validation.ts
@@ -6,6 +6,7 @@ const AJV = require('ajv');
 import * as ruleSchema from '../meta/rule.schema.json';
 import * as rulesetSchema from '../meta/ruleset.schema.json';
 import { IFunction, IFunctionPaths, IFunctionValues, Rule } from '../types';
+import { isObject } from '../utils';
 
 const ajv = new AJV({ allErrors: true, jsonPointers: true });
 const validate = ajv.addSchema(ruleSchema).compile(rulesetSchema);
@@ -23,12 +24,12 @@ export class ValidationError extends AJV.ValidationError {
 }
 
 export function assertValidRuleset(ruleset: unknown): IRulesetFile {
-  if (ruleset === null || typeof ruleset !== 'object') {
+  if (!isObject(ruleset)) {
     throw new Error('Provided ruleset is not an object');
   }
 
-  if (!('rules' in ruleset!)) {
-    throw new Error('Ruleset must have rules property');
+  if (!('rules' in ruleset) && !('extends' in ruleset)) {
+    throw new Error('Ruleset must have rules or extends property');
   }
 
   if (!validate(ruleset)) {
diff --git a/src/types/ruleset.ts b/src/types/ruleset.ts
index 8dccf2ef..7d92900a 100644
--- a/src/types/ruleset.ts
+++ b/src/types/ruleset.ts
@@ -28,7 +28,7 @@ export interface IRuleset {
 export interface IRulesetFile {
   extends?: Array<string | [string, FileRulesetSeverity]>;
   formats?: string[];
-  rules: FileRuleCollection;
+  rules?: FileRuleCollection;
   functionsDir?: string;
   functions?: Array<string | [string, JSONSchema4 | JSONSchema6 | JSONSchema7]>;
 }


Test patch:
diff --git a/src/rulesets/__tests__/__fixtures__/extends-only-ruleset.json b/src/rulesets/__tests__/__fixtures__/extends-only-ruleset.json
new file mode 100644
index 00000000..d5f16a72
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/extends-only-ruleset.json
@@ -0,0 +1,6 @@
+{
+  "extends": [
+    "./custom-functions-directory-ruleset.json",
+    "./foo-ruleset.json"
+  ]
+}
diff --git a/src/rulesets/__tests__/reader.jest.test.ts b/src/rulesets/__tests__/reader.jest.test.ts
index ebb724e7..c6de4c2e 100644
--- a/src/rulesets/__tests__/reader.jest.test.ts
+++ b/src/rulesets/__tests__/reader.jest.test.ts
@@ -14,6 +14,7 @@ const validRequireInfo = path.join(__dirname, './__fixtures__/valid-require-info
 const github447 = path.join(__dirname, './__fixtures__/github-issue-447-fixture.yaml');
 const enabledAllRuleset = path.join(__dirname, './__fixtures__/enable-all-ruleset.json');
 const invalidRuleset = path.join(__dirname, './__fixtures__/invalid-ruleset.json');
+const extendsOnlyRuleset = path.join(__dirname, './__fixtures__/extends-only-ruleset.json');
 const extendsAllOas2Ruleset = path.join(__dirname, './__fixtures__/extends-oas2-ruleset.json');
 const extendsUnspecifiedOas2Ruleset = path.join(__dirname, './__fixtures__/extends-unspecified-oas2-ruleset.json');
 const extendsDisabledOas2Ruleset = path.join(__dirname, './__fixtures__/extends-disabled-oas2-ruleset.yaml');
@@ -95,6 +96,39 @@ describe('Rulesets reader', () => {
     );
   });
 
+  it('given ruleset  with no custom rules extending other rulesets', async () => {
+    const { rules } = await readRuleset(extendsOnlyRuleset);
+
+    expect(rules).toEqual({
+      'bar-rule': {
+        given: '$.info',
+        message: 'should be OK',
+        recommended: true,
+        severity: DiagnosticSeverity.Warning,
+        then: {
+          function: expect.stringMatching(/^random-id-\d$/),
+        },
+      },
+      'foo-rule': {
+        given: '$.info',
+        message: 'should be OK',
+        severity: -1,
+        then: {
+          function: expect.stringMatching(/^random-id-\d$/),
+        },
+      },
+      'truthy-rule': {
+        given: '$.x',
+        message: 'should be OK',
+        recommended: true,
+        severity: DiagnosticSeverity.Warning,
+        then: {
+          function: expect.stringMatching(/^random-id-\d$/),
+        },
+      },
+    });
+  });
+
   it('should inherit properties of extended rulesets', async () => {
     const { rules } = await readRuleset(extendsAllOas2Ruleset);
 
diff --git a/src/rulesets/__tests__/validation.test.ts b/src/rulesets/__tests__/validation.test.ts
index f38ed07a..28aad044 100644
--- a/src/rulesets/__tests__/validation.test.ts
+++ b/src/rulesets/__tests__/validation.test.ts
@@ -10,9 +10,17 @@ describe('Ruleset Validation', () => {
     expect(assertValidRuleset.bind(null, 'true')).toThrow('Provided ruleset is not an object');
   });
 
-  it('given object with no rules property should throw', () => {
-    expect(assertValidRuleset.bind(null, {})).toThrow('Ruleset must have rules property');
-    expect(assertValidRuleset.bind(null, { rule: {} })).toThrow('Ruleset must have rules property');
+  it('given object with no rules and no extends properties should throw', () => {
+    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', () => {
+    expect(assertValidRuleset.bind(null, { extends: [] })).not.toThrow();
+  });
+
+  it('given object with rules property only should emit no errors', () => {
+    expect(assertValidRuleset.bind(null, { rules: {} })).not.toThrow();
   });
 
   it('given invalid ruleset should throw', () => {