Repository: stoplightio/spectral
Pull request #164 (2019-04-25 09:45:10)

Feature/sl-2295/sl-2493/load multiple rulesets
### Please check if the PR fulfills these requirements
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been added / updated (for bug fixes / features)

### What kind of change does this PR introduce?

Feature

### What is the current behavior?

Currently the `--ruleset` option takes only a single file.

### If this is a feature change, what is the new behavior?

Add support for multiple `--ruleset` inputs.
E.g.
```bash
spectral lint oas-file.yaml --ruleset one-ruleset.yaml --ruleset two-ruleset.json
```

### Does this PR introduce a breaking change?

N/A

### Other information

N/A



Patch:
diff --git a/docs/rulesets.md b/docs/rulesets.md
index dacc5c84..74f136e1 100644
--- a/docs/rulesets.md
+++ b/docs/rulesets.md
@@ -1,5 +1,11 @@
 # Spectral Rulesets
 
+## Usage
+
+```bash
+spectral lint foo.yaml --ruleset=path/to/acme-company-ruleset.yaml --ruleset=http://example.com/acme-common-ruleset.yaml
+```
+
 ## Example ruleset file
 
 We currently support ruleset files in both `yaml` and `json` formats.
@@ -134,12 +140,6 @@ Rules are highly configurable. There are only few required parameters but the op
   </tbody>
 </table>
 
-## Configuring rulesets via CLI
-
-```bash
-spectral lint foo.yaml --ruleset=path/to/acme-company-ruleset.yaml
-```
-
 ### Ruleset validation
 
 We use JSON Schema & AJV to validate your rulesets file and help you spot issues early.
diff --git a/src/cli/commands/lint.ts b/src/cli/commands/lint.ts
index ad382720..c6f27f30 100644
--- a/src/cli/commands/lint.ts
+++ b/src/cli/commands/lint.ts
@@ -9,7 +9,7 @@ import { json, stylish } from '../../formatters';
 import { readParsable } from '../../fs/reader';
 import { oas2Functions, oas2Rules } from '../../rulesets/oas2';
 import { oas3Functions, oas3Rules } from '../../rulesets/oas3';
-import { readRuleset } from '../../rulesets/reader';
+import { readRulesets } from '../../rulesets/reader';
 import { Spectral } from '../../spectral';
 import { IParsedResult, RuleCollection } from '../../types';
 
@@ -50,7 +50,8 @@ linting ./openapi.yaml
     }),
     ruleset: flagHelpers.string({
       char: 'r',
-      description: 'path to a ruleset file (supports http)',
+      description: 'path to a ruleset file (supports remote files)',
+      multiple: true,
     }),
   };
 
@@ -63,7 +64,7 @@ linting ./openapi.yaml
 
     if (ruleset) {
       try {
-        rules = await readRuleset(ruleset, this);
+        rules = await readRulesets(this, ...ruleset);
       } catch (ex) {
         this.error(ex.message);
       }
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
index e0f1306f..f3a84966 100644
--- a/src/rulesets/reader.ts
+++ b/src/rulesets/reader.ts
@@ -7,7 +7,12 @@ import { formatAjv } from './ajv';
 import { resolvePath } from './path';
 import { validateRuleset } from './validation';
 
-export async function readRuleset(file: string, command: Lint): Promise<RuleCollection> {
+export async function readRulesets(command: Lint, ...files: string[]): Promise<RuleCollection> {
+  const rulesets = await Promise.all(files.map(file => readRuleset(command, file)));
+  return merge({}, ...rulesets);
+}
+
+async function readRuleset(command: Lint, file: string): Promise<RuleCollection> {
   command.log(`Reading ruleset ${file}`);
   const parsed = await readParsable(file, 'utf8');
   const { data: ruleset } = parsed;
@@ -23,7 +28,7 @@ export async function readRuleset(file: string, command: Lint): Promise<RuleColl
   if (extendz && extendz.length) {
     extendedRules = await blendRuleCollections(
       extendz.map(extend => {
-        return readRuleset(resolvePath(file, extend), command);
+        return readRuleset(command, resolvePath(file, extend));
       })
     );
   }


Test patch:
diff --git a/src/cli/commands/__tests__/lint.test.ts b/src/cli/commands/__tests__/lint.test.ts
index bfae7b79..083cf13a 100644
--- a/src/cli/commands/__tests__/lint.test.ts
+++ b/src/cli/commands/__tests__/lint.test.ts
@@ -94,6 +94,30 @@ describe('lint', () => {
         });
     });
 
+    describe('when multiple ruleset options provided', () => {
+      test
+        .stdout()
+        .command(['lint', validSpecPath, '-r', invalidRulesetPath, '-r', validRulesetPath])
+        .exit(2)
+        .it('given one is valid other is not, outputs "invalid ruleset" error', ctx => {
+          expect(ctx.stdout).toContain(`/rules/rule-without-given-nor-them 	 should have required property 'given'`);
+          expect(ctx.stdout).toContain(`/rules/rule-without-given-nor-them 	 should have required property 'then'`);
+          expect(ctx.stdout).toContain(`/rules/rule-with-invalid-enum/severity 	 should be number`);
+          expect(ctx.stdout).toContain(
+            `/rules/rule-with-invalid-enum/severity 	 should be equal to one of the allowed values`
+          );
+        });
+
+      test
+        .stdout()
+        .command(['lint', validSpecPath, '-r', invalidRulesetPath, '-r', validRulesetPath])
+        .exit(2)
+        .it('given one is valid other is not, reads both', ctx => {
+          expect(ctx.stdout).toContain(`Reading ruleset ${invalidRulesetPath}`);
+          expect(ctx.stdout).toContain(`Reading ruleset ${validRulesetPath}`);
+        });
+    });
+
     describe('when single ruleset option provided', () => {
       test
         .stdout()
diff --git a/src/rulesets/__tests__/reader.unit.ts b/src/rulesets/__tests__/reader.unit.ts
index 03657c33..1697951c 100644
--- a/src/rulesets/__tests__/reader.unit.ts
+++ b/src/rulesets/__tests__/reader.unit.ts
@@ -11,7 +11,7 @@ import { readParsable } from '../../fs/reader';
 import { IRulesetFile } from '../../types/ruleset';
 import { formatAjv } from '../ajv';
 import { resolvePath } from '../path';
-import { readRuleset } from '../reader';
+import { readRulesets } from '../reader';
 import { validateRuleset } from '../validation';
 
 const readParsableMock: jest.Mock = readParsable as jest.Mock;
@@ -47,11 +47,32 @@ describe('reader', () => {
       },
     });
 
-    expect(await readRuleset('flat-ruleset.yaml', command)).toEqual({
+    expect(await readRulesets(command, 'flat-ruleset.yaml')).toEqual({
       'rule-1': { given: 'abc', then: { function: 'f' }, enabled: false },
     });
   });
 
+  it('given two flat, valid ruleset files should return rules', async () => {
+    validateRulesetMock.mockReturnValue([]);
+    givenRulesets({
+      'flat-ruleset-a.yaml': {
+        rules: {
+          'rule-1': simpleRule,
+        },
+      },
+      'flat-ruleset-b.yaml': {
+        rules: {
+          'rule-2': simpleRule,
+        },
+      },
+    });
+
+    expect(await readRulesets(command, 'flat-ruleset-a.yaml', 'flat-ruleset-b.yaml')).toEqual({
+      'rule-1': { given: 'abc', then: { function: 'f' }, enabled: false },
+      'rule-2': { given: 'abc', then: { function: 'f' }, enabled: false },
+    });
+  });
+
   it('should override properties of extended rulesets', async () => {
     validateRulesetMock.mockReturnValue([]);
     givenRulesets({
@@ -72,7 +93,7 @@ describe('reader', () => {
       },
     });
 
-    expect(await readRuleset('oneParentRuleset', command)).toEqual({
+    expect(await readRulesets(command, 'oneParentRuleset')).toEqual({
       'rule-1': { given: 'abc', then: { function: 'f' }, enabled: true },
     });
   });
@@ -98,7 +119,7 @@ describe('reader', () => {
       },
     });
 
-    expect(await readRuleset('oneParentRuleset', command)).toEqual({
+    expect(await readRulesets(command, 'oneParentRuleset')).toEqual({
       'rule-1': { given: 'abc', then: { function: 'f' }, enabled: false },
       'rule-2': { given: 'another given', then: { function: 'b' } },
     });
@@ -147,7 +168,7 @@ describe('reader', () => {
       },
     });
 
-    expect(await readRuleset('oneParentRuleset', command)).toEqual({
+    expect(await readRulesets(command, 'oneParentRuleset')).toEqual({
       'rule-1': { given: 'abc', then: { function: 'f' }, enabled: false },
       'rule-a': { given: 'given-a', then: { function: 'a' } },
       'rule-b': { given: 'given-b', then: { function: 'b' } },
@@ -169,7 +190,7 @@ describe('reader', () => {
       .calledWith(['fake errors'])
       .mockReturnValue('fake formatted message');
 
-    await readRuleset('flat-ruleset.yaml', command);
+    await readRulesets(command, 'flat-ruleset.yaml');
 
     expect(command.log).toHaveBeenCalledTimes(2);
     expect(command.log).toHaveBeenCalledWith('fake formatted message');