Repository: stoplightio/spectral
Pull request #144 (2019-04-16 15:00:15)

Feature/sl-2295/enable custom ruleset via cli
### 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 CLI doesn't support any kind of custom ruleset

### If this is a feature change, what is the new behavior?

Adds the ability to reference a custom ruleset with:

`spectral file.yaml --ruleset=path/to/ruleset.yaml`

### Does this PR introduce a breaking change?

@chris-miaskowski any breaking changes? 

### Other information

(e.g. detailed explanation, related issues, links for us to have context, eg. stackoverflow, issues outside of the repo, forum, etc.)


tbarn: @chris-miaskowski I'd love to check this out. How do I run it before it is merged? 
philsturgeon: This is going to need a rewrite sorry, we chatted  about it on Slack. It’s basically letting users define custom rules in a config file, which is basically what rulesets are. Instead we need to add a CLI option to enable already defined rulesets. 

Similar but different. :)
chris-miaskowski: @philsturgeon I appreciate your feedback but I completely don't get it.

The story says:

![Selection_004](https://user-images.githubusercontent.com/29548931/56278100-e8348e80-6105-11e9-9567-3f1a4816344f.png)

> As a Spectral user, I want to be able to **specify my own rulesets**, which **may contain custom rules**, and this should all work via the command line.

My story solves exactly that issue. User selects a file with rules (and only rules) and Spectral loads it (on top of the default rulesets - I could skip loading default).

> spectral lint foo.yaml --ruleset=something.yaml --ruleset=http://example.com/other.yaml

Isn't that line clearly stating that the ruleset option is used to pass in **a file**?  

> Must support both local files and URLs

If not, then this is for sure. I'm pretty confused to be honest... I don't see how I missed the point in this PR.

> This is going to need a rewrite sorry, we chatted about it on Slack. It’s basically letting users define custom rules in a config file, which is basically what rulesets are.

Well yes, it's letting users specify which rulesets they can load by allowing them to specify a **ruleset file** (not **config file** btw, I haven't implemented loading config files).

> Instead we need to add a CLI option to enable already defined rulesets.
> _(from Slack conversation)_ They define the rulesets in the rulesets file (already done) then they enable them in the CLI (this new feature)

Maybe that's where my confusion comes from the most. What do you mean "they define ruleset in rulesets file (already done)"?

How is that done?  I guess I'm missing something but currently the way we load specs in CLI is the following:

```ts
  const spectral = new Spectral();
  if (spec.data.swagger && spec.data.swagger === '2.0') {
    command.log('OpenAPI 2.0 (Swagger) detected');
    spectral.addFunctions(oas2Functions());
    spectral.addRules(oas2Rules());
  } else if (spec.data.openapi && typeof spec.data.openapi === 'string' && spec.data.openapi.startsWith('3.')) {
    command.log('OpenAPI 3.x detected');
    spectral.addFunctions(oas3Functions());
    spectral.addRules(oas3Rules());
  } else {
    throw new Error('Input document specification type could not be determined');
  }
```

I don't see how we store rulesets in "ruleset files". We merely export some rules collections in some TS files (but it's nothing close to supporting "ruleset files").

Also, I don't understand "Instead we need to add a CLI option to enable already defined rulesets.". What do you mean? We only have three rulesets "defined": common, oas2 and oas3. But they don't have _names_, they are simply collections of rules and we arbitrarily call them oas2, oas3 etc. 

Can you please give specific examples?

I'm happy to rewrite if letting users specify custom rulesets was not the intention but we need to clearly define what the intention is first then.
chris-miaskowski: Had a long call with @philsturgeon and we've cleared up the requirements.
Good news is: this does not need a rewrite but some additional features. Most of the confusion was coming from naming and dynamic VS static loading.

**Definitions**

**ruleset** - a structure holding a collection of rules **(rules collection)** and **functions** definitions.
A ruleset can define either **rules collection**, **functions** or both.

**Not in scope**

What @philsturgeon wanted to do on top of loading static _yaml/json_ rulesets is loading _ts/js_ files.
For now we will limit the scope to _yaml/json_ because the only advantage of a _typescripe rule collection_ is the fact it can use Enums rather than strings.

Loading _functions_ will also not be in the scope of this story.

**TODO**

1. Keep the static json/yaml files (rulesets)
2. Implement **require** function - ruleset A can **require** ruleset B (and merge)
3. Move oas2/oas3.ts collections into yaml/json
4. **Do not** autodetect oas2/3 if CLI option provided
5. Add definitions to README
6. Rename "config file" to "ruleset file"
philsturgeon: Great summary @chris-miaskowski!

To add a bit of context to "Do not autodetect oas2/3 if CLI option provided". 

In Spectral 2.0 we autodetect which ruleset to apply, and thats done in the code Chris posted a few comments ago: 

```
if (spec.data.swagger && spec.data.swagger === '2.0') {
```

When we add this new `--ruleset=` option in 2.1.0 we want to avoid automatically shoving our opinions in as well as the opinions of the user defined in the ruleset files they are adding in. When we convert the existing oas2 and oas3 rulesets to YAML and add the `require` feature to these rulesets, users will be able to define custom rulesets which include our oas2 or oas3 rulesets _if they want to._

The only downside to this is that they will not be able to load both oas2 and oas3 (schema validation would clash) and they will lose the autodetect logic. This _might_ be fine, we will have to see how our users feel about it when they get the new feature. 

Autodetect will remain by default, so this is not a breaking change. When users start using `--ruleset` they will then lose the autodetect functionality, and this will be documented clearly. 

It's possible we can follow up with a 2.2 feature later which merges oas2 and oas3 into just "oas" and there is some sort of filter in the rules. We can talk about this on #145.
chris-miaskowski: > @chris-miaskowski I'd love to check this out. How do I run it before it is merged?

Hey @tbarn, try this

1. clone repository and switch to this branch
2. `yarn && yarn build && npm link`
3. `spectral file.yaml --ruleset=path/to/ruleset.yaml`
chris-miaskowski: @philsturgeon , @P0lip , @XVincentX , @mallachari thanks for great review guys! Really appreciate the effort :) I will address the comments and get back.

Can everyone leave their _final_ review before I start addressing? Will save me a couple of roundtrips ;)

@philsturgeon see my comments about `config` and let's decide.
chris-miaskowski: :hankey: I've just realized that I've been addressing all requested changes in my `feature/sl-2295/sl-2470/add-extend-ruleset-feature` branch...

Meh. I guess I'm too lazy to revert this so I'll just merge everything here and we'll have one big-ace pull request.
chris-miaskowski: @philsturgeon @XVincentX @mallachari 
everything addressed
I have merged the `extends` feature in so please re-review.

Patch:
diff --git a/README.md b/README.md
index f593c30b..63ba5589 100644
--- a/README.md
+++ b/README.md
@@ -129,14 +129,19 @@ You can also [add to these rules](#Creating-a-custom-rule) to create a customize
 
 The existing OAS rules are opinionated. There might be some rules that you prefer to change. We encourage you to create your rules to fit your use case. We welcome additions to the existing rulesets as well!
 
+## Rulesets
+
+You can find all about [rulesets here](docs/rulesets.md).
+
 ## Advanced
 
 ### Customization
 
-There are two key concepts in Spectral: **Rules** and **Functions**.
+There are three key concepts in Spectral: **Rulesets**, **Rules** and **Functions**.
 
-- **Rules** filter your object down to a set of target values, and specify the function that should evaluate those values.
-- **Functions** accept a value and return issue(s) if the value is incorrect.
+- **Ruleset** is a container for a collection of rules and functions.
+- **Rule** filters your object down to a set of target values, and specify the function that should evaluate those values.
+- **Function** accept a value and return issue(s) if the value is incorrect.
 
 Think of a set of **rules** and **functions** as a flexible and customizable style guide for your JSON objects.
 
diff --git a/docs/rulesets.md b/docs/rulesets.md
new file mode 100644
index 00000000..dacc5c84
--- /dev/null
+++ b/docs/rulesets.md
@@ -0,0 +1,169 @@
+# Spectral Rulesets
+
+## Example ruleset file
+
+We currently support ruleset files in both `yaml` and `json` formats.
+
+```yaml
+extends:
+  - https://acme.com/ruleset.json
+  - /path/to/ruleset.yaml
+rules:
+    rule-name:
+        given: $..parameters[*]
+        then:
+            field: description
+            function: truthy
+```
+
+## Rules
+
+Rules are highly configurable. There are only few required parameters but the optional ones gives powerful flexibility. Please see the following type tables for more information.
+
+*TODO: generate this table automatically from the TS file.*
+
+### Rule
+
+<table>
+  <thead>
+    <tr>
+      <th>Field</th>
+      <th>Type</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td>given</td>
+      <td><code>string</code></td>
+      <td><b>Required</b> Filter the target down to a subset with a <b>JSON path</b>. Example <code>'$.paths..example'</code></td>
+    </tr>
+    <tr>
+      <td>then</td>
+      <td><code><a href="#then">Then</a> | <a href="#then">Then</a>[]</code></td>
+      <td><b>Required</b> Filter the target down to a subset with a <b>JSON path</b>. Example <code>'$.paths..example'</code></td>
+    </tr>
+    <tr>
+      <td>type</td>
+      <td><code>'validation' | 'style'`</code></td>
+      <td></td>
+    </tr>
+    <tr>
+      <td>summary</td>
+      <td><code>string</code></td>
+      <td>A short summary of the rule and its intended purpose</td>
+    </tr>
+    <tr>
+      <td>description</td>
+      <td><code>string</code></td>
+      <td>A long-form description of the rule formatted in markdown</td>
+    </tr>
+    <tr>
+      <td>severity</td>
+      <td><code>Error = 0, Warning = 1, Information = 2, Hint = 3</code></td>
+      <td>The severity of results this rule generates</td>
+    </tr>
+    <tr>
+      <td>tags</td>
+      <td><code>string[]</code></td>
+      <td>Tags attached to the rule, which can be used for organizational purposes</td>
+    </tr>
+    <tr>
+      <td>enabled</td>
+      <td><code>boolean</code></td>
+      <td>should the rule be enabled by default?</td>
+    </tr>        
+    <tr>
+      <td>when</td>
+      <td><code><a href="#when">When</a></code></td>
+      <td>A filter object to narrow down the selected items.</td>
+    </tr>
+  </tbody>
+</table>
+
+### Then
+
+<table>
+  <thead>
+    <tr>
+      <th>Field</th>
+      <th>Type</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>    
+    <tr>
+      <td>function</td>
+      <td><code>string</code></td>
+      <td><b>Required</b> Name of the function to run. Note: to use a function you must first register it (<code>spectral.addFunctions(...)</code>). Please check out the default common functions.</td>
+    </tr>
+    <tr>
+      <td>field</td>
+      <td><code>string</code></td>
+      <td>Name of the field to narrow by. Given this field, the function will receive value of that field.</td>
+    </tr>    
+    <tr>
+      <td>functionOptions</td>
+      <td><code>Object</code></td>
+      <td>Options passed to the function. Specific to the function in use.</td>
+    </tr>
+  </tbody>
+</table>
+
+### When
+
+<table>
+  <thead>
+    <tr>
+      <th>Field</th>
+      <th>Type</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>    
+    <tr>
+      <td>field</td>
+      <td><code>string</code></td>
+      <td><b>Required</b> The <code>path.to.prop</code> to field, or special <code>@key</code> value to target keys for matched <code>given</code> object. <br/><br/> Example: if the target object is an oas object and given = <code>$..responses[*]</code>, then <code>@key</code> would be the response code (200, 400, etc)</td>
+    </tr>
+    <tr>
+      <td>patterns</td>
+      <td><code>string</code></td>
+      <td>A regex pattern</td>
+    </tr>
+  </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.
+
+**Example output**
+
+```bash
+spectral lint some-oas.yaml --ruleset acme-company.json
+
+Reading ruleset
+
+/rules/rule-without-given-nor-them 	 should have required property 'given' 
+/rules/rule-without-given-nor-them 	 should have required property 'then' 
+/rules/rule-with-invalid-enum/severity 	 should be number 
+/rules/rule-with-invalid-enum/severity 	 should be equal to one of the allowed values 
+/rules/rule-with-invalid-enum/type 	 should be equal to one of the allowed values 
+```
+
+**Note for developers**
+
+Supporting YAML and JSON file validation doesn't come free. 
+We need to maintain schema files that mirror IRule and IRuleset types (see `/src/meta/*.schema.json`).
+Ideally, we would have a script that converts TS type to JSON Schema and keeps the meta files up to date. As of now we have a helper that partially automates the work.
+
+Execute `yarn schema.update` to recreate the `/src/meta/rule.schema.json`.
+It will take `IRule` type from `types.ts` file and automatically update the JSON Schema file we use to validate yaml/json ruleset files.
+**Warning**: make sure to update *generic* types. Current tools fails to recognize it properly and e.g. treats `string` as `object`.
\ No newline at end of file
diff --git a/package.json b/package.json
index 2d8e0b18..91048f96 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,8 @@
     "test": "jest",
     "test.prod": "yarn build && yarn lint && yarn test --coverage --maxWorkers=2",
     "test.update": "yarn test --updateSnapshot",
-    "test.watch": "yarn test --watch"
+    "test.watch": "yarn test --watch",
+    "schema.update": "yarn typescript-json-schema --id \"http://stoplight.io/schemas/rule.schema.json\" --required tsconfig.json IRule --out ./src/meta/rule.schema.json"
   },
   "dependencies": {
     "@oclif/command": "^1.0",
@@ -68,7 +69,8 @@
     "lodash": ">=4.17.5",
     "node-fetch": "2.x.x",
     "strip-ansi": "^5.2.0",
-    "text-table": "^0.2.0"
+    "text-table": "^0.2.0",
+    "typescript-json-schema": "^0.37.0"
   },
   "devDependencies": {
     "@oclif/dev-cli": "^1",
@@ -77,6 +79,7 @@
     "@stoplight/scripts": "^5.1.0",
     "@types/chalk": "^2.2.0",
     "@types/jest": "24.0.x",
+    "@types/jest-when": "^1.1.4",
     "@types/jsonpath": "0.2.x",
     "@types/lodash": "4.x.x",
     "@types/node": "11.13.x",
@@ -84,6 +87,7 @@
     "@types/text-table": "^0.2.0",
     "fs-extra": "^7.0.1",
     "jest": "24.7.x",
+    "jest-when": "^2.4.0",
     "nock": "^10.0.6",
     "pkg": "4.3.7",
     "ts-jest": "24.0.x",
diff --git a/src/cli/commands/lint.ts b/src/cli/commands/lint.ts
index 6b5fd5d3..ad382720 100644
--- a/src/cli/commands/lint.ts
+++ b/src/cli/commands/lint.ts
@@ -1,21 +1,19 @@
 import { Command, flags as flagHelpers } from '@oclif/command';
 import { IParserResult } from '@stoplight/types';
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/yaml';
-import { existsSync, readFileSync, writeFile } from 'fs';
+import { getLocationForJsonPath } from '@stoplight/yaml';
+import { writeFile } from 'fs';
 import { resolve } from 'path';
 import { promisify } from 'util';
-
-// @ts-ignore
-import * as fetch from 'node-fetch';
-
+import { IRuleResult } from '../..';
 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 { Spectral } from '../../spectral';
-import { IParsedResult, IRuleResult } from '../../types';
+import { IParsedResult, RuleCollection } from '../../types';
 
 const writeFileAsync = promisify(writeFile);
-
 export default class Lint extends Command {
   public static description = 'lint a JSON/YAML document from a file or URL';
 
@@ -50,16 +48,30 @@ linting ./openapi.yaml
       char: 'v',
       description: 'increase verbosity',
     }),
+    ruleset: flagHelpers.string({
+      char: 'r',
+      description: 'path to a ruleset file (supports http)',
+    }),
   };
 
   public static args = [{ name: 'source' }];
 
   public async run() {
     const { args, flags } = this.parse(Lint);
+    const { ruleset } = flags;
+    let rules;
+
+    if (ruleset) {
+      try {
+        rules = await readRuleset(ruleset, this);
+      } catch (ex) {
+        this.error(ex.message);
+      }
+    }
 
     if (args.source) {
       try {
-        await lint(args.source, flags, this);
+        await lint(args.source, flags, this, rules);
       } catch (ex) {
         this.error(ex.message);
       }
@@ -69,21 +81,19 @@ linting ./openapi.yaml
   }
 }
 
-async function lint(name: string, flags: any, command: Lint) {
-  command.log(`linting ${name}`);
-  let obj: IParserResult;
-  try {
-    obj = await readInputArguments(name, flags.encoding);
-  } catch (ex) {
-    throw new Error(`Could not parse ${name}: ${ex.message}`);
-  }
+async function lint(name: string, flags: any, command: Lint, customRules?: RuleCollection) {
+  command.log(`Linting ${name}`);
+  const spec: IParserResult = await readParsable(name, flags.encoding);
 
   const spectral = new Spectral();
-  if (obj.data.swagger && obj.data.swagger === '2.0') {
+  if (customRules) {
+    command.log('Applying custom rules. Automatic rule detection is off.');
+    spectral.addRules(customRules);
+  } else if (parseInt(spec.data.swagger) === 2) {
     command.log('OpenAPI 2.0 (Swagger) detected');
     spectral.addFunctions(oas2Functions());
     spectral.addRules(oas2Rules());
-  } else if (obj.data.openapi && typeof obj.data.openapi === 'string' && obj.data.openapi.startsWith('3.')) {
+  } else if (parseInt(spec.data.openapi) === 3) {
     command.log('OpenAPI 3.x detected');
     spectral.addFunctions(oas3Functions());
     spectral.addRules(oas3Rules());
@@ -95,7 +105,7 @@ async function lint(name: string, flags: any, command: Lint) {
   try {
     const parsedResult: IParsedResult = {
       source: resolve(process.cwd(), name),
-      parsed: obj,
+      parsed: spec,
       getLocationForJsonPath,
     };
 
@@ -119,7 +129,7 @@ async function lint(name: string, flags: any, command: Lint) {
   }
 }
 
-async function formatOutput(results: IRuleResult[], flags: any): Promise<string> {
+export async function formatOutput(results: IRuleResult[], flags: any): Promise<string> {
   if (flags.maxResults) {
     results = results.slice(0, flags.maxResults);
   }
@@ -129,24 +139,10 @@ async function formatOutput(results: IRuleResult[], flags: any): Promise<string>
   }[flags.format]();
 }
 
-async function writeOutput(outputStr: string, flags: any, command: Lint) {
+export async function writeOutput(outputStr: string, flags: any, command: Lint) {
   if (flags.output) {
     return writeFileAsync(flags.output, outputStr);
   }
 
   command.log(outputStr);
 }
-
-async function readInputArguments(name: string, encoding: string) {
-  if (name.startsWith('http')) {
-    const result = await fetch(name);
-    return parseWithPointers(await result.text());
-  } else if (existsSync(name)) {
-    try {
-      return parseWithPointers(readFileSync(name, encoding));
-    } catch (ex) {
-      throw new Error(`Could not read ${name}: ${ex.message}`);
-    }
-  }
-  throw new Error(`${name} does not exist`);
-}
diff --git a/src/fs/reader.ts b/src/fs/reader.ts
new file mode 100644
index 00000000..0ba337bb
--- /dev/null
+++ b/src/fs/reader.ts
@@ -0,0 +1,27 @@
+import { IParserResult } from '@stoplight/types';
+import { parseWithPointers } from '@stoplight/yaml';
+import { existsSync, readFileSync } from 'fs';
+// @ts-ignore
+import * as fetch from 'node-fetch';
+
+async function doRead(name: string, encoding: string) {
+  if (name.startsWith('http')) {
+    const result = await fetch(name);
+    return parseWithPointers(await result.text());
+  } else if (existsSync(name)) {
+    try {
+      return parseWithPointers(readFileSync(name, encoding));
+    } catch (ex) {
+      throw new Error(`Could not read ${name}: ${ex.message}`);
+    }
+  }
+  throw new Error(`${name} does not exist`);
+}
+
+export async function readParsable(name: string, encoding: string): Promise<IParserResult> {
+  try {
+    return doRead(name, encoding);
+  } catch (ex) {
+    throw new Error(`Could not parse ${name}: ${ex.message}`);
+  }
+}
diff --git a/src/functions/enumeration.ts b/src/functions/enumeration.ts
new file mode 100644
index 00000000..210b8513
--- /dev/null
+++ b/src/functions/enumeration.ts
@@ -0,0 +1,17 @@
+import { IEnumRuleOptions, IFunction, IFunctionResult } from '../types';
+
+export const enumeration: IFunction<IEnumRuleOptions> = (targetVal, opts) => {
+  const results: IFunctionResult[] = [];
+
+  const { values } = opts;
+
+  if (!targetVal) return results;
+
+  if (!values.includes(targetVal)) {
+    results.push({
+      message: `${targetVal} does not equal to one of ${values}`,
+    });
+  }
+
+  return results;
+};
diff --git a/src/functions/index.ts b/src/functions/index.ts
index 8106b6cd..ebf0d669 100644
--- a/src/functions/index.ts
+++ b/src/functions/index.ts
@@ -6,4 +6,5 @@ export const functions = {
   schemaPath: require('./schema-path').schemaPath,
   truthy: require('./truthy').truthy,
   xor: require('./xor').xor,
+  enumeration: require('./enumeration').enumeration,
 };
diff --git a/src/meta/rule.schema.json b/src/meta/rule.schema.json
new file mode 100644
index 00000000..c9a5f869
--- /dev/null
+++ b/src/meta/rule.schema.json
@@ -0,0 +1,98 @@
+{
+    "$id": "http://stoplight.io/schemas/rule.schema.json",
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "definitions": {
+        "IThen<T,O>": {
+            "properties": {
+                "field": {
+                    "type": "string"
+                },
+                "function": {
+                    "$ref": "http://stoplight.io/schemas/rule.schema.json#/definitions/T"
+                },
+                "functionOptions": {
+                    "$ref": "http://stoplight.io/schemas/rule.schema.json#/definitions/O"
+                }
+            },
+            "required": [
+                "function"
+            ],
+            "type": "object"
+        },
+        "O": {
+            "type": "object"
+        },
+        "T": {
+            "type": "string"
+        }
+    },
+    "properties": {
+        "description": {
+            "type": "string"
+        },
+        "enabled": {
+            "type": "boolean"
+        },
+        "given": {
+            "type": "string"
+        },
+        "severity": {
+            "enum": [
+                0,
+                1,
+                2,
+                3
+            ],
+            "type": "number"
+        },
+        "summary": {
+            "type": "string"
+        },
+        "tags": {
+            "items": {
+                "type": "string"
+            },
+            "type": "array"
+        },
+        "then": {
+            "anyOf": [
+                {
+                    "$ref": "http://stoplight.io/schemas/rule.schema.json#/definitions/IThen<T,O>"
+                },
+                {
+                    "items": {
+                        "$ref": "http://stoplight.io/schemas/rule.schema.json#/definitions/IThen<T,O>"
+                    },
+                    "type": "array"
+                }
+            ]
+        },
+        "type": {
+            "enum": [
+                "style",
+                "validation"
+            ],
+            "type": "string"
+        },
+        "when": {
+            "properties": {
+                "field": {
+                    "type": "string"
+                },
+                "pattern": {
+                    "type": "string"
+                }
+            },
+            "required": [
+                "field"
+            ],
+            "type": "object"
+        }
+    },
+    "required": [
+        "given",
+        "then"
+    ],
+    "type": "object"
+}
+
diff --git a/src/meta/ruleset.schema.json b/src/meta/ruleset.schema.json
new file mode 100644
index 00000000..e13c1431
--- /dev/null
+++ b/src/meta/ruleset.schema.json
@@ -0,0 +1,22 @@
+{
+    "$schema": "http://json-schema.org/draft-07/schema#",    
+    "$id": "http://stoplight.io/schemas/ruleset.schema.json",
+    "type": "object",
+    "properties": {
+        "rules": {
+            "type": "object",
+            "additionalProperties": {
+                "$ref": "rule.schema.json"
+            }
+        },
+        "extends": {
+            "type": "array",
+            "items": {
+                "type": "string"
+            }
+        }
+    },
+    "required": [
+        "rules"
+    ]
+}
diff --git a/src/rulesets/ajv.ts b/src/rulesets/ajv.ts
new file mode 100644
index 00000000..851cb0f8
--- /dev/null
+++ b/src/rulesets/ajv.ts
@@ -0,0 +1,10 @@
+import { ErrorObject } from 'ajv';
+import chalk from 'chalk';
+
+export const formatAjv = (errors: ErrorObject[]): string => {
+  let output = '\n';
+  errors.forEach(error => {
+    output += `${chalk.underline(error.dataPath)} \t ${error.message} \n`;
+  });
+  return output;
+};
diff --git a/src/rulesets/path.ts b/src/rulesets/path.ts
new file mode 100644
index 00000000..4143b7ef
--- /dev/null
+++ b/src/rulesets/path.ts
@@ -0,0 +1,9 @@
+const URL = require('url').URL;
+import { dirname, resolve } from 'path';
+
+export function resolvePath(from: string, to: string) {
+  if (from.startsWith('http')) {
+    return new URL(to, from).href;
+  }
+  return resolve(dirname(resolve(process.cwd(), from)), to);
+}
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
new file mode 100644
index 00000000..e0f1306f
--- /dev/null
+++ b/src/rulesets/reader.ts
@@ -0,0 +1,36 @@
+import merge = require('lodash/merge');
+import Lint from '../cli/commands/lint';
+import { readParsable } from '../fs/reader';
+import { RuleCollection } from '../types';
+import { IRulesetFile } from '../types/ruleset';
+import { formatAjv } from './ajv';
+import { resolvePath } from './path';
+import { validateRuleset } from './validation';
+
+export async function readRuleset(file: string, command: Lint): Promise<RuleCollection> {
+  command.log(`Reading ruleset ${file}`);
+  const parsed = await readParsable(file, 'utf8');
+  const { data: ruleset } = parsed;
+  const errors = validateRuleset(ruleset);
+
+  if (errors.length) {
+    command.log(formatAjv(errors));
+    command.error(`Provided ruleset '${file}' is not valid`, { exit: 1 });
+  }
+
+  const extendz = (ruleset as IRulesetFile).extends;
+  let extendedRules = {};
+  if (extendz && extendz.length) {
+    extendedRules = await blendRuleCollections(
+      extendz.map(extend => {
+        return readRuleset(resolvePath(file, extend), command);
+      })
+    );
+  }
+
+  return merge(extendedRules, ruleset.rules);
+}
+
+export async function blendRuleCollections(futureCollections: Array<Promise<RuleCollection>>) {
+  return Promise.all(futureCollections).then(collections => merge({}, ...collections));
+}
diff --git a/src/rulesets/validation.ts b/src/rulesets/validation.ts
new file mode 100644
index 00000000..07d78cfb
--- /dev/null
+++ b/src/rulesets/validation.ts
@@ -0,0 +1,15 @@
+import AJV = require('ajv');
+import { ErrorObject } from 'ajv';
+import ruleSchema = require('../meta/rule.schema.json');
+import rulesetSchema = require('../meta/ruleset.schema.json');
+import { IRuleset } from '../types/ruleset';
+
+const ajv = new AJV({ allErrors: true, jsonPointers: true });
+const validate = ajv.addSchema(ruleSchema).compile(rulesetSchema);
+
+export function validateRuleset(ruleset: IRuleset): ErrorObject[] {
+  if (validate(ruleset)) {
+    return [];
+  }
+  return validate.errors || [];
+}
diff --git a/src/types/enums.ts b/src/types/enums.ts
index 2364c41a..8de2842d 100644
--- a/src/types/enums.ts
+++ b/src/types/enums.ts
@@ -7,6 +7,7 @@ export enum RuleFunction {
   ALPHABETICAL = 'alphabetical',
   LENGTH = 'length',
   PATTERN = 'pattern',
+  ENUM = 'enumeration',
   SCHEMA = 'schema',
   SCHEMAPATH = 'schemaPath',
   TRUTHY = 'truthy',
diff --git a/src/types/rule.ts b/src/types/rule.ts
index 3809c1d9..efc069bb 100644
--- a/src/types/rule.ts
+++ b/src/types/rule.ts
@@ -36,7 +36,7 @@ export interface IRule<T = string, O = any> {
   then: IThen<T, O> | Array<IThen<T, O>>;
 }
 
-export interface IThen<T, O> {
+export interface IThen<T = string, O = any> {
   // the `path.to.prop` to field, or special `@key` value to target keys for matched `given` object
   // EXAMPLE: if the target object is an oas object and given = `$..responses[*]`, then `@key` would be the response code (200, 400, etc)
   field?: string;
@@ -62,6 +62,11 @@ export interface ILengthRuleOptions {
 }
 export type LengthRule = IRule<RuleFunction.LENGTH, ILengthRuleOptions>;
 
+export interface IEnumRuleOptions {
+  values: Array<string | number>;
+}
+export type EnumRule = IRule<RuleFunction.ENUM, IEnumRuleOptions>;
+
 export interface IAlphaRuleOptions {
   /** if sorting array of objects, which key to use for comparison */
   keyedBy?: string;
diff --git a/src/types/ruleset.ts b/src/types/ruleset.ts
new file mode 100644
index 00000000..4c07a747
--- /dev/null
+++ b/src/types/ruleset.ts
@@ -0,0 +1,10 @@
+import { RuleCollection } from './spectral';
+
+export interface IRuleset {
+  rules: RuleCollection;
+}
+
+export interface IRulesetFile {
+  extends?: string[];
+  rules: RuleCollection;
+}
diff --git a/yarn.lock b/yarn.lock
index 8a60623f..259e3e25 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,7 +2,7 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.0.0":
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.0.0-beta.35":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
   integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==
@@ -864,7 +864,14 @@
   resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89"
   integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==
 
-"@types/jest@24.0.x":
+"@types/jest-when@^1.1.4":
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@types/jest-when/-/jest-when-1.1.4.tgz#4ca90ead832fc0ea55ff36e663b71b1a964c8665"
+  integrity sha512-C7RaMoSFd45iOkxezpjdA2Yfd+jUK9zD/bAi7dBousT4kqfiTOeYe7u/2BWZ2+c4Tbb3L5YNO1BGaYD7ZZvINA==
+  dependencies:
+    "@types/jest" "*"
+
+"@types/jest@*", "@types/jest@24.0.x":
   version "24.0.11"
   resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.11.tgz#1f099bea332c228ea6505a88159bfa86a5858340"
   integrity sha512-2kLuPC5FDnWIDvaJBzsGTBQaBbnDweznicvK7UGYzlIJP4RJR2a4A/ByLUXEyEgag6jz8eHdlWExGDtH3EYUXQ==
@@ -1123,12 +1130,19 @@ argv-formatter@~1.0.0:
   resolved "https://registry.yarnpkg.com/argv-formatter/-/argv-formatter-1.0.0.tgz#a0ca0cbc29a5b73e836eebe1cbf6c5e0e4eb82f9"
   integrity sha1-oMoMvCmltz6Dbuvhy/bF4OTrgvk=
 
+arr-diff@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
+  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
+  dependencies:
+    arr-flatten "^1.0.1"
+
 arr-diff@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
   integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
 
-arr-flatten@^1.1.0:
+arr-flatten@^1.0.1, arr-flatten@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
   integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
@@ -1170,6 +1184,11 @@ array-uniq@^2.0.0:
   resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-2.0.0.tgz#0009e30306e37a6dd2e2e2480db5316fdade1583"
   integrity sha512-O3QZEr+3wDj7otzF7PjNGs6CA3qmYMLvt5xGkjY/V0VxS+ovvqVo/5wKM/OVOAyuX4DTh9H31zE/yKtO66hTkg==
 
+array-unique@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
+  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
+
 array-unique@^0.3.2:
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
@@ -1394,6 +1413,15 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
+braces@^1.8.2:
+  version "1.8.5"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
+  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
+  dependencies:
+    expand-range "^1.8.1"
+    preserve "^0.2.0"
+    repeat-element "^1.1.2"
+
 braces@^2.3.1:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
@@ -1474,6 +1502,16 @@ builtins@^1.0.3:
   resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88"
   integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og=
 
+bunyan@^1.8.12:
+  version "1.8.12"
+  resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.12.tgz#f150f0f6748abdd72aeae84f04403be2ef113797"
+  integrity sha1-8VDw9nSKvdcq6uhPBEA74u8RN5c=
+  optionalDependencies:
+    dtrace-provider "~0.8"
+    moment "^2.10.6"
+    mv "~2"
+    safe-json-stringify "~1"
+
 byline@^5.0.0, byline@~5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1"
@@ -2467,6 +2505,13 @@ dotenv@^5.0.1:
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef"
   integrity sha512-4As8uPrjfwb7VXC+WnLCbXK7y+Ueb2B3zgNCePYfhxS1PYeaO1YTeplffTEcbfLhvFNGLAz90VvJs9yomG7bow==
 
+dtrace-provider@~0.8:
+  version "0.8.7"
+  resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.7.tgz#dc939b4d3e0620cfe0c1cd803d0d2d7ed04ffd04"
+  integrity sha1-3JObTT4GIM/gwc2APQ0tftBP/QQ=
+  dependencies:
+    nan "^2.10.0"
+
 duplexer2@~0.1.0:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@@ -2727,6 +2772,13 @@ exit@^0.1.2:
   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
   integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=
 
+expand-brackets@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
+  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
+  dependencies:
+    is-posix-bracket "^0.1.0"
+
 expand-brackets@^2.1.4:
   version "2.1.4"
   resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
@@ -2740,6 +2792,13 @@ expand-brackets@^2.1.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
+expand-range@^1.8.1:
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
+  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
+  dependencies:
+    fill-range "^2.1.0"
+
 expand-template@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-1.1.1.tgz#981f188c0c3a87d2e28f559bc541426ff94f21dd"
@@ -2752,6 +2811,18 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
   dependencies:
     homedir-polyfill "^1.0.1"
 
+expect@^22.4.3:
+  version "22.4.3"
+  resolved "https://registry.yarnpkg.com/expect/-/expect-22.4.3.tgz#d5a29d0a0e1fb2153557caef2674d4547e914674"
+  integrity sha512-XcNXEPehqn8b/jm8FYotdX0YrXn36qp4HWlrVT4ktwQas1l1LPxiVWncYnnL2eyMtKAmVIaG0XAp0QlrqJaxaA==
+  dependencies:
+    ansi-styles "^3.2.0"
+    jest-diff "^22.4.3"
+    jest-get-type "^22.4.3"
+    jest-matcher-utils "^22.4.3"
+    jest-message-util "^22.4.3"
+    jest-regex-util "^22.4.3"
+
 expect@^24.7.1:
   version "24.7.1"
   resolved "https://registry.yarnpkg.com/expect/-/expect-24.7.1.tgz#d91defbab4e627470a152feaf35b3c31aa1c7c14"
@@ -2793,6 +2864,13 @@ external-editor@^3.0.0, external-editor@^3.0.3:
     iconv-lite "^0.4.24"
     tmp "^0.0.33"
 
+extglob@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
+  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
+  dependencies:
+    is-extglob "^1.0.0"
+
 extglob@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
@@ -2901,6 +2979,11 @@ figures@^2.0.0:
   dependencies:
     escape-string-regexp "^1.0.5"
 
+filename-regex@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
+  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
+
 fileset@^2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0"
@@ -2909,6 +2992,17 @@ fileset@^2.0.3:
     glob "^7.0.3"
     minimatch "^3.0.3"
 
+fill-range@^2.1.0:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
+  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
+  dependencies:
+    is-number "^2.1.0"
+    isobject "^2.0.0"
+    randomatic "^3.0.0"
+    repeat-element "^1.1.2"
+    repeat-string "^1.5.2"
+
 fill-range@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
@@ -2987,11 +3081,18 @@ fn-name@~2.0.1:
   resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7"
   integrity sha1-UhTXU3pNBqSjAcDMJi/rhBiAAuc=
 
-for-in@^1.0.2:
+for-in@^1.0.1, for-in@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
   integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
 
+for-own@^0.1.4:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
+  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
+  dependencies:
+    for-in "^1.0.1"
+
 forever-agent@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
@@ -3227,6 +3328,21 @@ github-slugger@^1.2.1:
   dependencies:
     emoji-regex ">=6.0.0 <=6.1.1"
 
+glob-base@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
+  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
+  dependencies:
+    glob-parent "^2.0.0"
+    is-glob "^2.0.0"
+
+glob-parent@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
+  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
+  dependencies:
+    is-glob "^2.0.0"
+
 glob-parent@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
@@ -3240,7 +3356,7 @@ glob-to-regexp@^0.3.0:
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
   integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
 
-glob@7.1.3, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3:
+glob@7.1.3, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@~7.1.2:
   version "7.1.3"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
   integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==
@@ -3252,6 +3368,17 @@ glob@7.1.3, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^6.0.1:
+  version "6.0.4"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
+  integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 global-dirs@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
@@ -3835,6 +3962,18 @@ is-directory@^0.3.1:
   resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1"
   integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=
 
+is-dotfile@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
+  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
+
+is-equal-shallow@^0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
+  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
+  dependencies:
+    is-primitive "^2.0.0"
+
 is-extendable@^0.1.0, is-extendable@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
@@ -3847,6 +3986,11 @@ is-extendable@^1.0.1:
   dependencies:
     is-plain-object "^2.0.4"
 
+is-extglob@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
+  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
+
 is-extglob@^2.1.0, is-extglob@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -3869,6 +4013,13 @@ is-generator-fn@^2.0.0:
   resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.0.0.tgz#038c31b774709641bda678b1f06a4e3227c10b3e"
   integrity sha512-elzyIdM7iKoFHzcrndIqjYomImhxrFRnGP3galODoII4TB9gI7mZ+FnlLQmmjf27SxHS2gKEeyhX5/+YRS6H9g==
 
+is-glob@^2.0.0, is-glob@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
+  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
+  dependencies:
+    is-extglob "^1.0.0"
+
 is-glob@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
@@ -3896,6 +4047,13 @@ is-npm@^1.0.0:
   resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
   integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
 
+is-number@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
+  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
+  dependencies:
+    kind-of "^3.0.2"
+
 is-number@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
@@ -3903,6 +4061,11 @@ is-number@^3.0.0:
   dependencies:
     kind-of "^3.0.2"
 
+is-number@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+  integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
+
 is-obj@^1.0.0, is-obj@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
@@ -3946,6 +4109,16 @@ is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   dependencies:
     isobject "^3.0.1"
 
+is-posix-bracket@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
+  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
+
+is-primitive@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
+
 is-promise@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
@@ -4187,6 +4360,16 @@ jest-config@^24.7.1:
     pretty-format "^24.7.0"
     realpath-native "^1.1.0"
 
+jest-diff@^22.4.3:
+  version "22.4.3"
+  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-22.4.3.tgz#e18cc3feff0aeef159d02310f2686d4065378030"
+  integrity sha512-/QqGvCDP5oZOF6PebDuLwrB2BMD8ffJv6TAGAdEVuDx1+uEgrHpSFrfrOiMRx2eJ1hgNjlQrOQEHetVwij90KA==
+  dependencies:
+    chalk "^2.0.1"
+    diff "^3.2.0"
+    jest-get-type "^22.4.3"
+    pretty-format "^22.4.3"
+
 jest-diff@^24.7.0:
   version "24.7.0"
   resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-24.7.0.tgz#5d862899be46249754806f66e5729c07fcb3580f"
@@ -4243,6 +4426,11 @@ jest-environment-node@^24.7.1:
     jest-mock "^24.7.0"
     jest-util "^24.7.1"
 
+jest-get-type@^22.4.3:
+  version "22.4.3"
+  resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.4.3.tgz#e3a8504d8479342dd4420236b322869f18900ce4"
+  integrity sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w==
+
 jest-get-type@^24.3.0:
   version "24.3.0"
   resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.3.0.tgz#582cfd1a4f91b5cdad1d43d2932f816d543c65da"
@@ -4296,6 +4484,15 @@ jest-leak-detector@^24.7.0:
   dependencies:
     pretty-format "^24.7.0"
 
+jest-matcher-utils@^22.4.3:
+  version "22.4.3"
+  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-22.4.3.tgz#4632fe428ebc73ebc194d3c7b65d37b161f710ff"
+  integrity sha512-lsEHVaTnKzdAPR5t4B6OcxXo9Vy4K+kRRbG5gtddY8lBEC+Mlpvm1CJcsMESRjzUhzkz568exMV1hTB76nAKbA==
+  dependencies:
+    chalk "^2.0.1"
+    jest-get-type "^22.4.3"
+    pretty-format "^22.4.3"
+
 jest-matcher-utils@^24.7.0:
   version "24.7.0"
   resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.7.0.tgz#bbee1ff37bc8b2e4afcaabc91617c1526af4bcd4"
@@ -4306,6 +4503,17 @@ jest-matcher-utils@^24.7.0:
     jest-get-type "^24.3.0"
     pretty-format "^24.7.0"
 
+jest-message-util@^22.4.3:
+  version "22.4.3"
+  resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-22.4.3.tgz#cf3d38aafe4befddbfc455e57d65d5239e399eb7"
+  integrity sha512-iAMeKxhB3Se5xkSjU0NndLLCHtP4n+GtCqV0bISKA5dmOXQfEbdEmYiu2qpnWBDCQdEafNDDU6Q+l6oBMd/+BA==
+  dependencies:
+    "@babel/code-frame" "^7.0.0-beta.35"
+    chalk "^2.0.1"
+    micromatch "^2.3.11"
+    slash "^1.0.0"
+    stack-utils "^1.0.1"
+
 jest-message-util@^24.7.1:
   version "24.7.1"
   resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.7.1.tgz#f1dc3a6c195647096a99d0f1dadbc447ae547018"
@@ -4332,6 +4540,11 @@ jest-pnp-resolver@^1.2.1:
   resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz#ecdae604c077a7fbc70defb6d517c3c1c898923a"
   integrity sha512-pgFw2tm54fzgYvc/OHrnysABEObZCUNFnhjoRjaVOCN8NYc032/gVjPaHD4Aq6ApkSieWtfKAFQtmDKAmhupnQ==
 
+jest-regex-util@^22.4.3:
+  version "22.4.3"
+  resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-22.4.3.tgz#a826eb191cdf22502198c5401a1fc04de9cef5af"
+  integrity sha512-LFg1gWr3QinIjb8j833bq7jtQopiwdAs67OGfkPrvy7uNUbVMfTXXcOKXJaeY5GgjobELkKvKENqq1xrUectWg==
+
 jest-regex-util@^24.3.0:
   version "24.3.0"
   resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-24.3.0.tgz#d5a65f60be1ae3e310d5214a0307581995227b36"
@@ -4477,6 +4690,14 @@ jest-watcher@^24.7.1:
     jest-util "^24.7.1"
     string-length "^2.0.0"
 
+jest-when@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/jest-when/-/jest-when-2.4.0.tgz#cb969a05cccf107e4e2509a0b54e62b65582a32b"
+  integrity sha512-Xf2QStQOnIF5bnyvuRh1iIBSX9iW8IgNon24Ab4MAQjLtFDgJrxDhpx7zoJvhLWYaBKRyqbTBWLlbd4r0lkLrA==
+  dependencies:
+    bunyan "^1.8.12"
+    expect "^22.4.3"
+
 jest-worker@^24.6.0:
   version "24.6.0"
   resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.6.0.tgz#7f81ceae34b7cde0c9827a6980c35b7cdc0161b3"
@@ -4590,6 +4811,13 @@ json-schema@0.2.3:
   resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
   integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
 
+json-stable-stringify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af"
+  integrity sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=
+  dependencies:
+    jsonify "~0.0.0"
+
 json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@@ -4614,6 +4842,11 @@ jsonfile@^4.0.0:
   optionalDependencies:
     graceful-fs "^4.1.6"
 
+jsonify@~0.0.0:
+  version "0.0.0"
+  resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
+  integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=
+
 jsonparse@^1.2.0:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
@@ -5181,6 +5414,11 @@ matcher@^1.0.0:
   dependencies:
     escape-string-regexp "^1.0.4"
 
+math-random@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
+  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
+
 meant@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/meant/-/meant-1.0.1.tgz#66044fea2f23230ec806fb515efea29c44d2115d"
@@ -5249,6 +5487,25 @@ merge@^1.2.1:
   resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145"
   integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==
 
+micromatch@^2.3.11:
+  version "2.3.11"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
+  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
+  dependencies:
+    arr-diff "^2.0.0"
+    array-unique "^0.2.1"
+    braces "^1.8.2"
+    expand-brackets "^0.1.4"
+    extglob "^0.3.1"
+    filename-regex "^2.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.1"
+    kind-of "^3.0.2"
+    normalize-path "^2.0.1"
+    object.omit "^2.0.0"
+    parse-glob "^3.0.4"
+    regex-cache "^0.4.2"
+
 micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8:
   version "3.1.10"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
@@ -5295,7 +5552,7 @@ mimic-fn@^2.0.0:
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.0.0.tgz#0913ff0b121db44ef5848242c38bbb35d44cabde"
   integrity sha512-jbex9Yd/3lmICXwYT6gA/j2mNQGU48wCh/VzRd+/Y/PjYQtlg1gLMdZqvu9s/xH7qKvngxRObl56XZR609IMbA==
 
-minimatch@^3.0.0, minimatch@^3.0.3, minimatch@^3.0.4:
+"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.3, minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
@@ -5397,6 +5654,11 @@ modify-values@^1.0.0:
   resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
   integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==
 
+moment@^2.10.6:
+  version "2.24.0"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
+  integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
+
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -5437,7 +5699,16 @@ mute-stream@~0.0.4:
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
   integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
-nan@^2.9.2:
+mv@~2:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2"
+  integrity sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=
+  dependencies:
+    mkdirp "~0.5.1"
+    ncp "~2.0.0"
+    rimraf "~2.4.0"
+
+nan@^2.10.0, nan@^2.9.2:
   version "2.13.2"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7"
   integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==
@@ -5474,6 +5745,11 @@ natural-orderby@^2.0.1:
   resolved "https://registry.yarnpkg.com/natural-orderby/-/natural-orderby-2.0.2.tgz#de06ec11633dddbc93e642aaf3774f99122cbff2"
   integrity sha512-ehKWiU0ZoYJw6JRlxcW3PPNW8xUpomrak7bXLyGzkx4f9eQVs7VpxEFMpGITdgrPuAWd5/9bD1MQha5w3z3v6A==
 
+ncp@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
+  integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
+
 needle@^2.2.1:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e"
@@ -5632,7 +5908,7 @@ normalize-package-data@~2.4.0:
     semver "2 || 3 || 4 || 5"
     validate-npm-package-license "^3.0.1"
 
-normalize-path@^2.1.1:
+normalize-path@^2.0.1, normalize-path@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
   integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
@@ -5968,6 +6244,14 @@ object.getownpropertydescriptors@^2.0.3:
     define-properties "^1.1.2"
     es-abstract "^1.5.1"
 
+object.omit@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
+  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
+  dependencies:
+    for-own "^0.1.4"
+    is-extendable "^0.1.1"
+
 object.pick@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
@@ -6214,6 +6498,16 @@ parse-github-url@^1.0.1:
   resolved "https://registry.yarnpkg.com/parse-github-url/-/parse-github-url-1.0.2.tgz#242d3b65cbcdda14bb50439e3242acf6971db395"
   integrity sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==
 
+parse-glob@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
+  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
+  dependencies:
+    glob-base "^0.3.0"
+    is-dotfile "^1.0.0"
+    is-extglob "^1.0.0"
+    is-glob "^2.0.0"
+
 parse-json@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
@@ -6412,11 +6706,24 @@ prepend-http@^1.0.1:
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
   integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
 
+preserve@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
+
 prettier@1.14.x:
   version "1.14.3"
   resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.14.3.tgz#90238dd4c0684b7edce5f83b0fb7328e48bd0895"
   integrity sha512-qZDVnCrnpsRJJq5nSsiHCE3BYMED2OtsI+cmzIzF1QIfqm5ALf8tEJcO27zV1gKNKRPdhjO0dNWnrzssDQ1tFg==
 
+pretty-format@^22.4.3:
+  version "22.4.3"
+  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-22.4.3.tgz#f873d780839a9c02e9664c8a082e9ee79eaac16f"
+  integrity sha512-S4oT9/sT6MN7/3COoOy+ZJeA92VmOnveLHgrwBE3Z1W5N9S2A1QGNYiE1z75DAENbJrXXUb+OWXhpJcg05QKQQ==
+  dependencies:
+    ansi-regex "^3.0.0"
+    ansi-styles "^3.2.0"
+
 pretty-format@^24.7.0:
   version "24.7.0"
   resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.7.0.tgz#d23106bc2edcd776079c2daa5da02bcb12ed0c10"
@@ -6603,6 +6910,15 @@ qw@~1.0.1:
   resolved "https://registry.yarnpkg.com/qw/-/qw-1.0.1.tgz#efbfdc740f9ad054304426acb183412cc8b996d4"
   integrity sha1-77/cdA+a0FQwRCassYNBLMi5ltQ=
 
+randomatic@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
+  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
+  dependencies:
+    is-number "^4.0.0"
+    kind-of "^6.0.0"
+    math-random "^1.0.1"
+
 rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@@ -6780,6 +7096,13 @@ regenerator-runtime@^0.12.0:
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
   integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
 
+regex-cache@^0.4.2:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
+  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
+  dependencies:
+    is-equal-shallow "^0.1.3"
+
 regex-not@^1.0.0, regex-not@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
@@ -6813,7 +7136,7 @@ repeat-element@^1.1.2:
   resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
   integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==
 
-repeat-string@^1.6.1:
+repeat-string@^1.5.2, repeat-string@^1.6.1:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
   integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
@@ -6973,6 +7296,13 @@ rimraf@2, rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6
   dependencies:
     glob "^7.1.3"
 
+rimraf@~2.4.0:
+  version "2.4.5"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da"
+  integrity sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=
+  dependencies:
+    glob "^6.0.1"
+
 rsvp@^4.8.4:
   version "4.8.4"
   resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.4.tgz#b50e6b34583f3dd89329a2f23a8a2be072845911"
@@ -7009,6 +7339,11 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2,
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
+safe-json-stringify@~1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd"
+  integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==
+
 safe-regex@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
@@ -8025,6 +8360,16 @@ typedoc@0.14.2:
     typedoc-default-themes "^0.5.0"
     typescript "3.2.x"
 
+typescript-json-schema@^0.37.0:
+  version "0.37.0"
+  resolved "https://registry.yarnpkg.com/typescript-json-schema/-/typescript-json-schema-0.37.0.tgz#c7c8c6eaeba68aed68f7cedb2000e721f7afb032"
+  integrity sha512-ipR41ZPQY74X7nOYsoiI6dgCrU+etui9yvsZH3qeTp2938G4nsdce5bxQhdlWE7OPoP+adKkvukIptvuPhv4PQ==
+  dependencies:
+    glob "~7.1.2"
+    json-stable-stringify "^1.0.1"
+    typescript "^3.0.1"
+    yargs "^12.0.1"
+
 typescript@3.2.x:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.2.4.tgz#c585cb952912263d915b462726ce244ba510ef3d"
@@ -8035,6 +8380,11 @@ typescript@3.4.4:
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.4.4.tgz#aac4a08abecab8091a75f10842ffa0631818f785"
   integrity sha512-xt5RsIRCEaf6+j9AyOBgvVuAec0i92rgCaS3S+UVf5Z/vF2Hvtsw08wtUTJqp4djwznoAgjSxeCcU4r+CcDBJA==
 
+typescript@^3.0.1:
+  version "3.4.3"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.4.3.tgz#0eb320e4ace9b10eadf5bc6103286b0f8b7c224f"
+  integrity sha512-FFgHdPt4T/duxx6Ndf7hwgMZZjZpB+U0nMNGVCYPq0rEzWKjEDobm4J6yb3CS7naZ0yURFqdw9Gwc7UOh/P9oQ==
+
 uglify-js@^3.1.4:
   version "3.5.2"
   resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.5.2.tgz#dc0c7ac2da0a4b7d15e84266818ff30e82529474"
@@ -8498,7 +8848,7 @@ yargs@^11.0.0:
     y18n "^3.2.1"
     yargs-parser "^9.0.2"
 
-yargs@^12.0.0, yargs@^12.0.2:
+yargs@^12.0.0, yargs@^12.0.1, yargs@^12.0.2:
   version "12.0.5"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"
   integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==


Test patch:
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index 153429af..817cab26 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -1,6 +1,6 @@
 import { DiagnosticSeverity } from '@stoplight/types';
 
-import { oas2Functions, oas2Rules } from '../../dist/rulesets/oas2';
+import { oas2Functions, oas2Rules } from '../rulesets/oas2';
 import { Spectral } from '../spectral';
 
 const fnName = 'fake';
diff --git a/src/__tests__/spectral.test.ts b/src/__tests__/spectral.test.ts
index eb86edb7..aa18ce90 100644
--- a/src/__tests__/spectral.test.ts
+++ b/src/__tests__/spectral.test.ts
@@ -1,9 +1,8 @@
 import { DiagnosticSeverity } from '@stoplight/types';
-const merge = require('lodash/merge');
-
-import { IParsedResult } from '../../dist';
 import { isParsedResult, Spectral } from '../spectral';
-import { RuleFunction } from '../types';
+import { IParsedResult, RuleFunction } from '../types';
+
+const merge = require('lodash/merge');
 
 describe('spectral', () => {
   describe('addRules & mergeRules', () => {
diff --git a/src/cli/commands/__tests__/__fixtures__/openapi-3.0-valid-custom.yaml b/src/cli/commands/__tests__/__fixtures__/openapi-3.0-valid-custom.yaml
new file mode 100644
index 00000000..235c84c4
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/openapi-3.0-valid-custom.yaml
@@ -0,0 +1,12 @@
+---
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  title: Stoplight
+  description: lots of text
+  contact:
+    name: fred
+servers:
+  - description: thing
+    url: http://localhost
+paths: {}
\ No newline at end of file
diff --git a/src/cli/commands/__tests__/__fixtures__/ruleset-extends-invalid.yaml b/src/cli/commands/__tests__/__fixtures__/ruleset-extends-invalid.yaml
new file mode 100644
index 00000000..70787152
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/ruleset-extends-invalid.yaml
@@ -0,0 +1,11 @@
+extends:
+  - ruleset-invalid.yaml
+rules:
+  no-swagger:
+    summary: Use OpenAPI instead ;)
+    given: $..*
+    type: style
+    then:      
+      function: pattern
+      functionOptions:
+        notMatch: Swagger
\ No newline at end of file
diff --git a/src/cli/commands/__tests__/__fixtures__/ruleset-extends-valid.yaml b/src/cli/commands/__tests__/__fixtures__/ruleset-extends-valid.yaml
new file mode 100644
index 00000000..2d96fe69
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/ruleset-extends-valid.yaml
@@ -0,0 +1,11 @@
+extends:
+  - ./ruleset-valid.yaml
+rules:
+  no-swagger:
+    summary: Use OpenAPI instead ;)
+    given: $..*
+    type: style
+    then:      
+      function: pattern
+      functionOptions:
+        notMatch: Swagger
diff --git a/src/cli/commands/__tests__/__fixtures__/ruleset-invalid.yaml b/src/cli/commands/__tests__/__fixtures__/ruleset-invalid.yaml
new file mode 100644
index 00000000..581f249c
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/ruleset-invalid.yaml
@@ -0,0 +1,14 @@
+rules:
+  rule-without-given-nor-them:
+    summary: 'deliberetely invalid'
+  valid-rule:
+    summary: 'should be OK'
+    given: '$.info'
+    then:
+      function: 'truthy'
+  rule-with-invalid-enum:
+    given: '$.info'
+    then:
+      function: 'truthy'
+    severity: 'some bs severity value'
+    type: 'some bs type value'
\ No newline at end of file
diff --git a/src/cli/commands/__tests__/__fixtures__/ruleset-valid.yaml b/src/cli/commands/__tests__/__fixtures__/ruleset-valid.yaml
new file mode 100644
index 00000000..053fc1f9
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/ruleset-valid.yaml
@@ -0,0 +1,10 @@
+rules:
+  info-matches-stoplight:
+    summary: Info must contain Stoplight
+    given: $.info
+    type: style
+    then:
+      field: title
+      function: pattern
+      functionOptions:
+        match: Stoplight
diff --git a/src/cli/commands/__tests__/lint.test.ts b/src/cli/commands/__tests__/lint.test.ts
index 274dcd45..bfae7b79 100644
--- a/src/cli/commands/__tests__/lint.test.ts
+++ b/src/cli/commands/__tests__/lint.test.ts
@@ -4,6 +4,11 @@ import { resolve } from 'path';
 
 const invalidSpecPath = resolve(__dirname, '__fixtures__/openapi-3.0-no-contact.yaml');
 const validSpecPath = resolve(__dirname, '__fixtures__/openapi-3.0-valid.yaml');
+const validCustomSpecPath = resolve(__dirname, '__fixtures__/openapi-3.0-valid-custom.yaml');
+const invalidRulesetPath = resolve(__dirname, '__fixtures__/ruleset-invalid.yaml');
+const validRulesetPath = resolve(__dirname, '__fixtures__/ruleset-valid.yaml');
+const validNestedRulesetPath = resolve(__dirname, '__fixtures__/ruleset-extends-valid.yaml');
+const invalidNestedRulesetPath = resolve(__dirname, '__fixtures__/ruleset-extends-invalid.yaml');
 
 /*
  * These tests currently do not assert stderr because it doesn't seem to be
@@ -24,6 +29,7 @@ describe('lint', () => {
       .stdout()
       .command(['lint', invalidSpecPath])
       .it('outputs warnings in default format', ctx => {
+        expect(ctx.stdout).toContain('OpenAPI 3.x detected');
         expect(ctx.stdout).toContain('Info object should contain `contact` object');
       });
 
@@ -54,6 +60,91 @@ describe('lint', () => {
       });
   });
 
+  describe('--ruleset', () => {
+    describe('extends feature', () => {
+      test
+        .stdout()
+        .command(['lint', validCustomSpecPath, '-r', validNestedRulesetPath])
+        .it('should extend a valid relative ruleset', ctx => {
+          expect(ctx.stdout).toContain('No errors or warnings found!');
+        });
+
+      test
+        .stdout()
+        .command(['lint', validCustomSpecPath, '-r', invalidNestedRulesetPath])
+        .exit(2)
+        .it('should fail trying to extend an invalid relative ruleset', ctx => {
+          expect(ctx.stdout).toContain("should have required property 'given'");
+        });
+
+      test
+        .nock('http://foo.local', api => {
+          api.get('/ruleset-master.yaml').replyWithFile(200, validNestedRulesetPath, {
+            'Content-Type': 'application/yaml',
+          });
+
+          api.get('/ruleset-valid.yaml').replyWithFile(200, validRulesetPath, {
+            'Content-Type': 'application/yaml',
+          });
+        })
+        .stdout()
+        .command(['lint', validCustomSpecPath, '-r', 'http://foo.local/ruleset-master.yaml'])
+        .it('given remote nested ruleset should resolve', ctx => {
+          expect(ctx.stdout).toContain('No errors or warnings found!');
+        });
+    });
+
+    describe('when single ruleset option provided', () => {
+      test
+        .stdout()
+        .command(['lint', validSpecPath, '-r', 'non-existent-path'])
+        .exit(2)
+        .it('outputs "does not exist" error');
+
+      test
+        .stdout()
+        .command(['lint', validSpecPath, '-r', invalidRulesetPath])
+        .exit(2)
+        .it('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', validCustomSpecPath, '-r', validRulesetPath])
+        .it('outputs no issues', ctx => {
+          expect(ctx.stdout).toContain('No errors or warnings found!');
+        });
+
+      test
+        .stdout()
+        .command(['lint', validSpecPath, '-r', validRulesetPath])
+        .it('outputs warnings in default format', ctx => {
+          expect(ctx.stdout).toContain('Applying custom rules. Automatic rule detection is off.');
+          expect(ctx.stdout).toContain('5:10  warning  info-matches-stoplight  Info must contain Stoplight');
+          expect(ctx.stdout).not.toContain('Info object should contain `contact` object');
+          expect(ctx.stdout).not.toContain('OpenAPI 3.x detected');
+        });
+
+      test
+        .nock('http://foo.local', api =>
+          api.get('/ruleset.yaml').replyWithFile(200, validRulesetPath, {
+            'Content-Type': 'application/yaml',
+          })
+        )
+        .stdout()
+        .command(['lint', validCustomSpecPath, '-r', 'http://foo.local/ruleset.yaml'])
+        .it('given valid remote ruleset file, outputs no issues', ctx => {
+          expect(ctx.stdout).toContain('No errors or warnings found!');
+        });
+    });
+  });
+
   describe('when loading specification files from web', () => {
     test
       .nock('http://foo.local', api =>
diff --git a/src/formatters/__tests__/json.test.ts b/src/formatters/__tests__/json.test.ts
index 5a12440d..d724e364 100644
--- a/src/formatters/__tests__/json.test.ts
+++ b/src/formatters/__tests__/json.test.ts
@@ -1,4 +1,4 @@
-import { IRuleResult } from '../../../dist/types';
+import { IRuleResult } from '../../types';
 import { json } from '../json';
 
 const results: IRuleResult[] = [
diff --git a/src/functions/__tests__/enum.test.ts b/src/functions/__tests__/enum.test.ts
new file mode 100644
index 00000000..8872fe91
--- /dev/null
+++ b/src/functions/__tests__/enum.test.ts
@@ -0,0 +1,29 @@
+import { enumeration } from '../enumeration';
+
+function runEnum(targetVal: any, values: any[]) {
+  return enumeration(
+    targetVal,
+    {
+      values,
+    },
+    {
+      given: [],
+    },
+    {
+      given: [],
+      original: '',
+    }
+  );
+}
+
+describe('enum', () => {
+  test('should return undefined if target value is truthy', () => {
+    expect(runEnum('x', ['x', 'y', 'z'])).toEqual([]);
+  });
+
+  test('should return an error message if target value is falsy', () => {
+    const results = runEnum('x', ['y', 'z']);
+    expect(results).toHaveLength(1);
+    expect(results[0].message).toEqual('x does not equal to one of y,z');
+  });
+});
diff --git a/src/rulesets/__tests__/__fixtures__/ruleset-invalid.json b/src/rulesets/__tests__/__fixtures__/ruleset-invalid.json
new file mode 100644
index 00000000..836d8595
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/ruleset-invalid.json
@@ -0,0 +1,22 @@
+{
+  "rules": {
+    "no-given-no-then": {
+      "summary": "deliberetely invalid"
+    },
+    "valid-rule": {
+      "summary": "should be OK",
+      "given": "$.info",
+      "then": {
+        "function": "truthy"
+      }
+    },
+    "rule-with-invalid-enum": {
+      "given": "$.info",
+      "then": {
+        "function": "truthy"
+      },
+      "severity": "must not be a string",
+      "type": "some bs type value"
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/rulesets/__tests__/__fixtures__/valid-ruleset.json b/src/rulesets/__tests__/__fixtures__/valid-ruleset.json
new file mode 100644
index 00000000..bb04ec9e
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/valid-ruleset.json
@@ -0,0 +1,11 @@
+{
+  "rules": {
+    "valid-rule": {
+      "summary": "should be OK",
+      "given": "$.info",
+      "then": {
+        "function": "truthy"
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/rulesets/__tests__/__snapshots__/validation.unit.ts.snap b/src/rulesets/__tests__/__snapshots__/validation.unit.ts.snap
new file mode 100644
index 00000000..662669ac
--- /dev/null
+++ b/src/rulesets/__tests__/__snapshots__/validation.unit.ts.snap
@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Ruleset Validation given invalid ruleset should emit errors 1`] = `
+"[
+  {
+    \\"keyword\\": \\"required\\",
+    \\"dataPath\\": \\"/rules/no-given-no-then\\",
+    \\"schemaPath\\": \\"#/required\\",
+    \\"params\\": {
+      \\"missingProperty\\": \\"given\\"
+    },
+    \\"message\\": \\"should have required property 'given'\\"
+  },
+  {
+    \\"keyword\\": \\"required\\",
+    \\"dataPath\\": \\"/rules/no-given-no-then\\",
+    \\"schemaPath\\": \\"#/required\\",
+    \\"params\\": {
+      \\"missingProperty\\": \\"then\\"
+    },
+    \\"message\\": \\"should have required property 'then'\\"
+  },
+  {
+    \\"keyword\\": \\"type\\",
+    \\"dataPath\\": \\"/rules/rule-with-invalid-enum/severity\\",
+    \\"schemaPath\\": \\"#/properties/severity/type\\",
+    \\"params\\": {
+      \\"type\\": \\"number\\"
+    },
+    \\"message\\": \\"should be number\\"
+  },
+  {
+    \\"keyword\\": \\"enum\\",
+    \\"dataPath\\": \\"/rules/rule-with-invalid-enum/severity\\",
+    \\"schemaPath\\": \\"#/properties/severity/enum\\",
+    \\"params\\": {
+      \\"allowedValues\\": [
+        0,
+        1,
+        2,
+        3
+      ]
+    },
+    \\"message\\": \\"should be equal to one of the allowed values\\"
+  },
+  {
+    \\"keyword\\": \\"enum\\",
+    \\"dataPath\\": \\"/rules/rule-with-invalid-enum/type\\",
+    \\"schemaPath\\": \\"#/properties/type/enum\\",
+    \\"params\\": {
+      \\"allowedValues\\": [
+        \\"style\\",
+        \\"validation\\"
+      ]
+    },
+    \\"message\\": \\"should be equal to one of the allowed values\\"
+  }
+]"
+`;
diff --git a/src/rulesets/__tests__/path.unit.ts b/src/rulesets/__tests__/path.unit.ts
new file mode 100644
index 00000000..1292e84b
--- /dev/null
+++ b/src/rulesets/__tests__/path.unit.ts
@@ -0,0 +1,21 @@
+import { resolvePath } from '../path';
+
+describe('Path', () => {
+  it('should resolve relative path', () => {
+    expect(resolvePath('a/b/c', 'd')).toEqual(`${process.cwd()}/a/b/d`);
+  });
+
+  it('should resolve absolute path', () => {
+    expect(resolvePath('a/b/c', '/d')).toEqual('/d');
+  });
+
+  it('should resolve http relative', () => {
+    expect(resolvePath('http://www.example.com/a/b.json', 'd.json')).toEqual('http://www.example.com/a/d.json');
+  });
+
+  it('should resolve http absolute', () => {
+    expect(resolvePath('http://www.example.com/a/b.json', 'http://www.acme.com/b.json')).toEqual(
+      'http://www.acme.com/b.json'
+    );
+  });
+});
diff --git a/src/rulesets/__tests__/reader.unit.ts b/src/rulesets/__tests__/reader.unit.ts
new file mode 100644
index 00000000..03657c33
--- /dev/null
+++ b/src/rulesets/__tests__/reader.unit.ts
@@ -0,0 +1,195 @@
+jest.mock('../../fs/reader');
+jest.mock('../ajv');
+jest.mock('../validation');
+jest.mock('../path');
+
+import { Dictionary } from '@stoplight/types/basic';
+import { when } from 'jest-when';
+import { IRule } from '../..';
+import Lint from '../../cli/commands/lint';
+import { readParsable } from '../../fs/reader';
+import { IRulesetFile } from '../../types/ruleset';
+import { formatAjv } from '../ajv';
+import { resolvePath } from '../path';
+import { readRuleset } from '../reader';
+import { validateRuleset } from '../validation';
+
+const readParsableMock: jest.Mock = readParsable as jest.Mock;
+const formatAjvMock: jest.Mock = formatAjv as jest.Mock;
+const validateRulesetMock: jest.Mock = validateRuleset as jest.Mock;
+const resolvePathMock: jest.Mock = resolvePath as jest.Mock;
+
+const simpleRule: IRule = {
+  enabled: false,
+  given: 'abc',
+  then: {
+    function: 'f',
+  },
+};
+
+const command: Lint = {
+  log: jest.fn(),
+  error: jest.fn(),
+} as any;
+
+describe('reader', () => {
+  afterEach(() => {
+    jest.resetAllMocks();
+  });
+
+  it('given flat, valid ruleset file should return rules', async () => {
+    validateRulesetMock.mockReturnValue([]);
+    givenRulesets({
+      'flat-ruleset.yaml': {
+        rules: {
+          'rule-1': simpleRule,
+        },
+      },
+    });
+
+    expect(await readRuleset('flat-ruleset.yaml', command)).toEqual({
+      'rule-1': { given: 'abc', then: { function: 'f' }, enabled: false },
+    });
+  });
+
+  it('should override properties of extended rulesets', async () => {
+    validateRulesetMock.mockReturnValue([]);
+    givenRulesets({
+      flatRuleset: {
+        rules: {
+          'rule-1': simpleRule,
+        },
+      },
+      oneParentRuleset: {
+        extends: ['flatRuleset'],
+        rules: {
+          'rule-1': {
+            ...simpleRule,
+            // note that i'm enabling the rule here
+            enabled: true,
+          },
+        },
+      },
+    });
+
+    expect(await readRuleset('oneParentRuleset', command)).toEqual({
+      'rule-1': { given: 'abc', then: { function: 'f' }, enabled: true },
+    });
+  });
+
+  it('should inherit properties of extended rulesets', async () => {
+    validateRulesetMock.mockReturnValue([]);
+    givenRulesets({
+      flatRuleset: {
+        rules: {
+          'rule-2': {
+            given: 'another given',
+            then: {
+              function: 'b',
+            },
+          },
+        },
+      },
+      oneParentRuleset: {
+        extends: ['flatRuleset'],
+        rules: {
+          'rule-1': simpleRule,
+        },
+      },
+    });
+
+    expect(await readRuleset('oneParentRuleset', command)).toEqual({
+      'rule-1': { given: 'abc', then: { function: 'f' }, enabled: false },
+      'rule-2': { given: 'another given', then: { function: 'b' } },
+    });
+  });
+
+  it('should blend together parent rulesets', async () => {
+    validateRulesetMock.mockReturnValue([]);
+    givenRulesets({
+      rulesetA: {
+        rules: {
+          'rule-a': {
+            given: 'given-a',
+            then: {
+              function: 'a',
+            },
+          },
+          'common-rule': {
+            given: 'common',
+            then: {
+              function: 'ca',
+            },
+          },
+        },
+      },
+      rulesetB: {
+        rules: {
+          'rule-b': {
+            given: 'given-b',
+            then: {
+              function: 'b',
+            },
+          },
+          'common-rule': {
+            given: 'common',
+            then: {
+              function: 'cb',
+            },
+          },
+        },
+      },
+      oneParentRuleset: {
+        extends: ['rulesetA', 'rulesetB'],
+        rules: {
+          'rule-1': simpleRule,
+        },
+      },
+    });
+
+    expect(await readRuleset('oneParentRuleset', command)).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' } },
+      'common-rule': { given: 'common', then: { function: 'cb' } },
+    });
+  });
+
+  it('given invalid ruleset should output errors', async () => {
+    const flatRuleset = {
+      rules: {
+        'rule-1': simpleRule,
+      },
+    };
+    givenRulesets({
+      'flat-ruleset.yaml': flatRuleset,
+    });
+    validateRulesetMock.mockReturnValue(['fake errors']);
+    when(formatAjvMock)
+      .calledWith(['fake errors'])
+      .mockReturnValue('fake formatted message');
+
+    await readRuleset('flat-ruleset.yaml', command);
+
+    expect(command.log).toHaveBeenCalledTimes(2);
+    expect(command.log).toHaveBeenCalledWith('fake formatted message');
+    expect(command.error).toHaveBeenCalledTimes(1);
+    expect(command.error).toHaveBeenCalledWith(`Provided ruleset 'flat-ruleset.yaml' is not valid`, { exit: 1 });
+  });
+
+  function givenRulesets(rulesets: Dictionary<IRulesetFile, string>) {
+    Object.entries(rulesets).forEach(([key, ruleset]) => {
+      (ruleset.extends || []).forEach(extend =>
+        when(resolvePathMock)
+          .calledWith(key, extend)
+          .mockReturnValue(extend)
+      );
+
+      when(readParsableMock)
+        .calledWith(key, 'utf8')
+        .mockReturnValue({
+          data: ruleset,
+        });
+    });
+  }
+});
diff --git a/src/rulesets/__tests__/validation.unit.ts b/src/rulesets/__tests__/validation.unit.ts
new file mode 100644
index 00000000..001aae00
--- /dev/null
+++ b/src/rulesets/__tests__/validation.unit.ts
@@ -0,0 +1,13 @@
+import { validateRuleset } from '../validation';
+const invalidRuleset = require('./__fixtures__/ruleset-invalid.json');
+const validRuleset = require('./__fixtures__/valid-ruleset.json');
+
+describe('Ruleset Validation', () => {
+  it('given invalid ruleset should emit errors', () => {
+    expect(JSON.stringify(validateRuleset(invalidRuleset), null, 2)).toMatchSnapshot();
+  });
+
+  it('given valid ruleset should emit no errors', () => {
+    expect(validateRuleset(validRuleset)).toEqual([]);
+  });
+});
diff --git a/src/rulesets/oas/__tests__/parameter-description.ts b/src/rulesets/oas/__tests__/parameter-description.ts
index 8a500269..6cb6bda6 100644
--- a/src/rulesets/oas/__tests__/parameter-description.ts
+++ b/src/rulesets/oas/__tests__/parameter-description.ts
@@ -78,7 +78,15 @@ describe('parameter-description', () => {
         },
       },
     });
-    expect(results).toMatchSnapshot();
+    expect(results).toEqual([
+      expect.objectContaining({
+        code: 'parameter-description',
+        message: 'parameters.limit.description is not truthy',
+        path: ['parameters', 'limit', 'description'],
+        severity: 1,
+        summary: 'Parameter objects should have a `description`.',
+      }),
+    ]);
   });
 
   test('return errors if top level path parameter description is missing', async () => {
@@ -96,7 +104,15 @@ describe('parameter-description', () => {
         },
       },
     });
-    expect(results).toMatchSnapshot();
+    expect(results).toEqual([
+      expect.objectContaining({
+        code: 'parameter-description',
+        message: 'paths./todos.parameters.0.description is not truthy',
+        path: ['paths', '/todos', 'parameters', 0, 'description'],
+        severity: 1,
+        summary: 'Parameter objects should have a `description`.',
+      }),
+    ]);
   });
 
   test('return errors if operation level parameter description is missing', async () => {
@@ -116,10 +132,18 @@ describe('parameter-description', () => {
         },
       },
     });
-    expect(results).toMatchSnapshot();
+    expect(results).toEqual([
+      expect.objectContaining({
+        code: 'parameter-description',
+        message: 'paths./todos.get.parameters.0.description is not truthy',
+        path: ['paths', '/todos', 'get', 'parameters', 0, 'description'],
+        severity: 1,
+        summary: 'Parameter objects should have a `description`.',
+      }),
+    ]);
   });
 
-  test.only('does not throw on refs', async () => {
+  test('does not throw on refs', async () => {
     const results = await s.run({
       swagger: '2.0',
       paths: {