Repository: stoplightio/spectral
Pull request #301 (2019-07-06 14:49:00)

feat: include recomended rules by default

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated
- [x] Check this off Breaking change?

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

There are a couple of changes included in this PR.
The most notable ones are:
- unspecified severity in extends is considered recommended now
- stricter normalization of diagnostic, to make sure we work with DiagnosticSeverity severity level during the linting stage rather than DiagnosticSeverity and HumanReadableSeverity. The reason I didn't do it earlier is the fact we didn't have `-1` specified anywhere, therefore we returned `off` everywhere.
- extracted a few utils related to severity
- minor changes in interfaces
- better severity level handling
- moar tests


Patch:
diff --git a/package.json b/package.json
index 3404cc48..a2f6fe8f 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,7 @@
     "@stoplight/json": "^2.2.2",
     "@stoplight/json-ref-resolver": "^2.0.2",
     "@stoplight/path": "^1.0.1",
-    "@stoplight/types": "^9.0.2",
+    "@stoplight/types": "^9.1.2",
     "@stoplight/yaml": "^2.6.0",
     "ajv": "^6.7",
     "ajv-oai": "^1.1.1",
diff --git a/src/meta/ruleset.schema.json b/src/meta/ruleset.schema.json
index 5f65adc3..4058064e 100644
--- a/src/meta/ruleset.schema.json
+++ b/src/meta/ruleset.schema.json
@@ -10,7 +10,7 @@
             }
         },
         "extends": {
-            "type": "array",
+            "type": ["array", "string"],
             "items": {
                 "oneOf": [
                     {
diff --git a/src/rulesets/ajv.ts b/src/rulesets/ajv.ts
deleted file mode 100644
index 851cb0f8..00000000
--- a/src/rulesets/ajv.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-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/index.ts b/src/rulesets/index.ts
index 914facd6..a122cb87 100644
--- a/src/rulesets/index.ts
+++ b/src/rulesets/index.ts
@@ -1,14 +1,3 @@
-import { oas2Functions } from './oas2';
-import * as oas2Ruleset from './oas2/index.json';
-import { oas3Functions } from './oas3';
-import * as oas3Ruleset from './oas3/index.json';
-
-const merge = require('lodash/merge');
-
-export const defaultRules = () => {
-  return merge(oas2Ruleset.rules, oas3Ruleset.rules);
-};
-
-export const defaultFunctions = () => {
-  return merge(oas2Functions(), oas3Functions());
-};
+export { mergeRulesets } from './merger';
+export { readRulesFromRulesets } from './reader';
+export { assertValidRuleset } from './validation';
diff --git a/src/rulesets/merger.ts b/src/rulesets/merger.ts
index b04ab336..20937809 100644
--- a/src/rulesets/merger.ts
+++ b/src/rulesets/merger.ts
@@ -1,7 +1,7 @@
 import { cloneDeep } from 'lodash';
 import { Rule } from '../types';
 import { FileRule, FileRuleCollection, FileRulesetSeverity, IRulesetFile } from '../types/ruleset';
-import { DEFAULT_SEVERITY_LEVEL, getSeverityLevel } from './severity';
+import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity, getSeverityLevel } from './severity';
 import { isValidRule } from './validation';
 
 /*
@@ -16,10 +16,12 @@ export function mergeRulesets(target: IRulesetFile, src: IRulesetFile, rulesetSe
 
   for (const [name, rule] of Object.entries(src.rules)) {
     if (rulesetSeverity !== undefined) {
+      const severity = getSeverityLevel(src.rules, name, rulesetSeverity);
       if (isValidRule(rule)) {
-        processRule(rules, name, { ...rule, severity: getSeverityLevel(src.rules, name, rulesetSeverity) });
+        rule.severity = severity;
+        processRule(rules, name, rule);
       } else {
-        processRule(rules, name, rulesetSeverity);
+        processRule(rules, name, severity);
       }
     } else {
       processRule(rules, name, rule);
@@ -47,6 +49,10 @@ function updateRootRule(root: Rule, newRule: Rule | null) {
   Object.assign(root[ROOT_DESCRIPTOR], copyRule(newRule === null ? root : Object.assign(root, newRule)));
 }
 
+function getRootRule(rule: Rule): Rule {
+  return rule[ROOT_DESCRIPTOR] !== undefined ? rule[ROOT_DESCRIPTOR] : null;
+}
+
 function copyRule(rule: Rule) {
   return cloneDeep(rule);
 }
@@ -56,6 +62,12 @@ function processRule(rules: FileRuleCollection, name: string, rule: FileRule | F
 
   switch (typeof rule) {
     case 'boolean':
+      if (isValidRule(existingRule)) {
+        const rootRule = getRootRule(existingRule);
+        existingRule.severity = rootRule ? rootRule.severity : getSeverityLevel(rules, name, rule);
+        updateRootRule(existingRule, existingRule);
+      }
+      break;
     case 'string':
     case 'number':
       // what if rule does not exist (yet)? throw, store the invalid state somehow?
@@ -65,6 +77,7 @@ function processRule(rules: FileRuleCollection, name: string, rule: FileRule | F
 
       break;
     case 'object':
+      normalizeRule(rule);
       if (Array.isArray(rule)) {
         processRule(rules, name, rule[0]);
 
@@ -81,9 +94,6 @@ function processRule(rules: FileRuleCollection, name: string, rule: FileRule | F
         // new rule
         markRule(rule);
         rules[name] = rule;
-        if (rule.severity === undefined) {
-          rule.severity = DEFAULT_SEVERITY_LEVEL;
-        }
       }
 
       break;
@@ -91,3 +101,13 @@ function processRule(rules: FileRuleCollection, name: string, rule: FileRule | F
       throw new Error('Invalid value for a rule');
   }
 }
+
+function normalizeRule(rule: FileRule) {
+  if (isValidRule(rule)) {
+    if (rule.severity === undefined) {
+      rule.severity = DEFAULT_SEVERITY_LEVEL;
+    } else {
+      rule.severity = getDiagnosticSeverity(rule.severity);
+    }
+  }
+}
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
index 59670b58..edac3bc3 100644
--- a/src/rulesets/reader.ts
+++ b/src/rulesets/reader.ts
@@ -1,7 +1,7 @@
 import { parse } from '@stoplight/yaml';
 import { readParsable } from '../fs/reader';
 import { RuleCollection } from '../types';
-import { IRuleset, IRulesetFile } from '../types/ruleset';
+import { FileRulesetSeverity, IRuleset, IRulesetFile } from '../types/ruleset';
 import { findRuleset } from './finder';
 import { mergeRulesets } from './merger';
 import { assertValidRuleset } from './validation';
@@ -12,27 +12,33 @@ export async function readRulesFromRulesets(...uris: string[]): Promise<RuleColl
   };
 
   for (const uri of uris) {
-    mergeRulesets(base, await readRulesFromRuleset('', uri));
+    mergeRulesets(base, await readRulesFromRuleset(uri, uri));
   }
 
   return base.rules;
 }
 
-async function readRulesFromRuleset(baseUri: string, uri: string): Promise<IRulesetFile> {
+async function readRulesFromRuleset(
+  baseUri: string,
+  uri: string,
+  severity?: FileRulesetSeverity,
+): Promise<IRulesetFile> {
   const ruleset = assertValidRuleset(parse(await readParsable(await findRuleset(baseUri, uri), 'utf8')));
 
   const newRuleset: IRulesetFile = {
     rules: {},
   };
 
-  const extendz = ruleset.extends;
+  const extendedRulesets = ruleset.extends;
 
-  if (extendz && extendz.length) {
-    for (const extended of extendz) {
+  if (extendedRulesets !== undefined) {
+    for (const extended of Array.isArray(extendedRulesets) ? extendedRulesets : [extendedRulesets]) {
       if (Array.isArray(extended)) {
-        mergeRulesets(newRuleset, await readRulesFromRuleset(uri, extended[0]), extended[1]);
+        const parentSeverity = severity === undefined ? extended[1] : severity;
+        mergeRulesets(newRuleset, await readRulesFromRuleset(uri, extended[0], parentSeverity), parentSeverity);
       } else {
-        mergeRulesets(newRuleset, await readRulesFromRuleset(uri, extended), 'recommended');
+        const parentSeverity = severity === undefined ? 'recommended' : severity;
+        mergeRulesets(newRuleset, await readRulesFromRuleset(uri, extended, parentSeverity), parentSeverity);
       }
     }
   }
diff --git a/src/rulesets/severity.ts b/src/rulesets/severity.ts
index d329edf2..f4d8e279 100644
--- a/src/rulesets/severity.ts
+++ b/src/rulesets/severity.ts
@@ -1,10 +1,25 @@
 import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
 import { HumanReadableDiagnosticSeverity, SpectralDiagnosticSeverity } from '../types';
-import { FileRule, FileRuleCollection, FileRulesetSeverity } from '../types/ruleset';
+import { FileRule, FileRuleCollection, FileRulesetSeverity, FileRuleSeverity } from '../types/ruleset';
 import { isValidRule } from './validation';
 
 export const DEFAULT_SEVERITY_LEVEL = DiagnosticSeverity.Warning;
 
+function getSeverityForRule(
+  rule: FileRule | FileRuleSeverity,
+  defaultSeverity: SpectralDiagnosticSeverity,
+): DiagnosticSeverity {
+  switch (typeof rule) {
+    case 'number':
+    case 'string':
+      return getDiagnosticSeverity(rule);
+    case 'boolean':
+      return rule ? getDiagnosticSeverity(defaultSeverity) : -1;
+    default:
+      return defaultSeverity;
+  }
+}
+
 export function getSeverityLevel(
   rules: FileRuleCollection,
   name: string,
@@ -12,7 +27,20 @@ export function getSeverityLevel(
 ): SpectralDiagnosticSeverity {
   const existingRule = rules[name];
 
-  if (!isValidRule(existingRule)) return -1;
+  // this is an edge case, please refer to related tests
+  if (!isValidRule(existingRule)) {
+    if (rule === 'off') return -1;
+
+    if (rule === 'recommended' || rule === 'all') {
+      if (existingRule === false || existingRule === -1 || existingRule === 'off') {
+        return -1;
+      }
+
+      return getSeverityForRule(existingRule, DEFAULT_SEVERITY_LEVEL);
+    }
+
+    return getSeverityForRule(rule, DEFAULT_SEVERITY_LEVEL);
+  }
 
   const existingSeverity =
     existingRule.severity !== undefined ? getDiagnosticSeverity(existingRule.severity) : DEFAULT_SEVERITY_LEVEL;
@@ -25,15 +53,7 @@ export function getSeverityLevel(
     return existingSeverity;
   }
 
-  switch (typeof rule) {
-    case 'number':
-    case 'string':
-      return getDiagnosticSeverity(rule);
-    case 'boolean':
-      return rule ? existingSeverity : -1;
-    default:
-      return -1;
-  }
+  return getSeverityForRule(rule, existingSeverity);
 }
 
 const SEVERITY_MAP: Dictionary<SpectralDiagnosticSeverity, HumanReadableDiagnosticSeverity> = {
diff --git a/src/runner.ts b/src/runner.ts
index 0cd8584b..2841f6aa 100644
--- a/src/runner.ts
+++ b/src/runner.ts
@@ -14,7 +14,7 @@ export const runRules = (
   let results: IRuleResult[] = [];
 
   for (const name in rules) {
-    if (!Object.prototype.hasOwnProperty.call(rules, name)) continue;
+    if (!rules.hasOwnProperty(name)) continue;
 
     const rule = rules[name];
     if (!rule) continue;
diff --git a/src/spectral.ts b/src/spectral.ts
index afa40631..51ed3ec6 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -3,13 +3,13 @@ import { Resolver } from '@stoplight/json-ref-resolver';
 import { IUriParser } from '@stoplight/json-ref-resolver/types';
 import { getLocationForJsonPath as getLocationForJsonPathJSON } from '@stoplight/json/getLocationForJsonPath';
 import { parseWithPointers as parseJSONWithPointers } from '@stoplight/json/parseWithPointers';
+import { extname } from '@stoplight/path';
 import { DiagnosticSeverity, Dictionary, IParserResult } from '@stoplight/types';
 import {
   getLocationForJsonPath as getLocationForJsonPathYAML,
   parseWithPointers as parseYAMLWithPointers,
 } from '@stoplight/yaml';
 import { merge, set, uniqBy } from 'lodash';
-import { extname } from 'path';
 
 import { functions as defaultFunctions } from './functions';
 import { Resolved } from './resolved';
diff --git a/yarn.lock b/yarn.lock
index d7ce1c92..1547b90c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -577,6 +577,13 @@
   resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-9.0.2.tgz#1978287d2cfbcb176d00300fa158d5a1daf63c00"
   integrity sha512-fMVOgl0TSWA9VvsdGyTA26qp2AQ/g4jgSHm3BeFbGIBRGaVKU8JoUNV/tH+a7uxuPIzXzcE1BQa9fENOCKF2BQ==
 
+"@stoplight/types@^9.1.2":
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-9.1.2.tgz#0b2ed2032ae901491543b9dd19309ff3a0acf12a"
+  integrity sha512-eNeWwTZ1E47mrprEdgtOutovVKxYTbm8k/B7Lh4JAusGoiHbMmzVT+IDdayBpOCzq+qKLzuP0z3IZo74IXaaEg==
+  dependencies:
+    "@types/json-schema" "^7.0.3"
+
 "@stoplight/yaml@^2.6.0":
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/@stoplight/yaml/-/yaml-2.6.0.tgz#9ff3ad3cc9b9146a3a5f0c42b2ab0af011f2a272"
@@ -684,6 +691,11 @@
   dependencies:
     "@types/jest-diff" "*"
 
+"@types/json-schema@^7.0.3":
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
+  integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==
+
 "@types/lodash@*", "@types/lodash@^4.0.0":
   version "4.14.135"
   resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.135.tgz#d2607c35dd68f70c2b35ba020c667493dedd8447"


Test patch:
diff --git a/src/rulesets/__tests__/__fixtures__/casing-rules.json b/src/rulesets/__tests__/__fixtures__/casing-rules.json
new file mode 100644
index 00000000..edcd1b00
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/casing-rules.json
@@ -0,0 +1,21 @@
+{
+  "rules": {
+    "PascalCase": {
+      "severity": "warn",
+      "given": "$",
+      "message": "bar",
+      "then": {
+        "function": "truthy"
+      }
+    },
+    "camelCase": {
+      "severity": "warn",
+      "given": "$",
+      "recommended": true,
+      "message": "bar",
+      "then": {
+        "function": "truthy"
+      }
+    }
+  }
+}
diff --git a/src/rulesets/__tests__/__fixtures__/enable-all-ruleset.json b/src/rulesets/__tests__/__fixtures__/enable-all-ruleset.json
new file mode 100644
index 00000000..f1feac77
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/enable-all-ruleset.json
@@ -0,0 +1,6 @@
+{
+  "extends": [[
+    "./extends-disabled-oas2-ruleset.yaml", "all"
+  ]],
+  "rules": {}
+}
diff --git a/src/rulesets/__tests__/__fixtures__/extends-disabled-oas2-ruleset.yaml b/src/rulesets/__tests__/__fixtures__/extends-disabled-oas2-ruleset.yaml
new file mode 100644
index 00000000..dded304e
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/extends-disabled-oas2-ruleset.yaml
@@ -0,0 +1,3 @@
+extends: [[spectral:oas2, off]]
+rules:
+  operation-operationId-unique: true
diff --git a/src/rulesets/__tests__/__fixtures__/extends-relative-ruleset.json b/src/rulesets/__tests__/__fixtures__/extends-relative-ruleset.json
new file mode 100644
index 00000000..dd05b906
--- /dev/null
+++ b/src/rulesets/__tests__/__fixtures__/extends-relative-ruleset.json
@@ -0,0 +1,13 @@
+{
+  "extends": "./casing-rules.json",
+  "rules": {
+    "snake_case": {
+      "severity": "warn",
+      "given": "$",
+      "message": "foo",
+      "then": {
+        "function": "truthy"
+      }
+    }
+  }
+}
diff --git a/src/rulesets/__tests__/merger.unit.ts b/src/rulesets/__tests__/merger.test.ts
similarity index 82%
rename from src/rulesets/__tests__/merger.unit.ts
rename to src/rulesets/__tests__/merger.test.ts
index 4cfce8d5..4b9a4de1 100644
--- a/src/rulesets/__tests__/merger.unit.ts
+++ b/src/rulesets/__tests__/merger.test.ts
@@ -1,5 +1,6 @@
 import { DiagnosticSeverity } from '@stoplight/types';
 import { IRule } from '../../types';
+import { IRulesetFile } from '../../types/ruleset';
 import { mergeRulesets } from '../merger';
 
 describe('Rulesets merger', () => {
@@ -105,10 +106,10 @@ describe('Rulesets merger', () => {
       },
     });
 
-    expect(ruleset).toHaveProperty('rules.test.severity', 'error');
+    expect(ruleset).toHaveProperty('rules.test.severity', DiagnosticSeverity.Error);
   });
 
-  it('prefers the most recent severity level', () => {
+  it('prefers the root definition severity level', () => {
     const ruleset = {
       rules: {
         test: JSON.parse(JSON.stringify(baseRule)),
@@ -142,7 +143,7 @@ describe('Rulesets merger', () => {
       },
     });
 
-    expect(ruleset).toHaveProperty('rules.test.severity', -1);
+    expect(ruleset).toHaveProperty('rules.test.severity', DiagnosticSeverity.Error);
   });
 
   it('includes new rules', () => {
@@ -158,7 +159,10 @@ describe('Rulesets merger', () => {
 
     expect(ruleset).toEqual({
       rules: {
-        example: baseRule,
+        example: {
+          ...baseRule,
+          severity: DiagnosticSeverity.Warning,
+        },
       },
     });
   });
@@ -316,4 +320,58 @@ describe('Rulesets merger', () => {
     expect(ruleset).toHaveProperty('rules.test.severity', DiagnosticSeverity.Warning);
     expect(ruleset).toHaveProperty('rules.test2.severity', DiagnosticSeverity.Warning);
   });
+
+  it('overrides existing severity if no ruleset severity is given', () => {
+    const ruleset: IRulesetFile = {
+      rules: {
+        test: JSON.parse(JSON.stringify(baseRule)),
+      },
+    };
+
+    mergeRulesets(ruleset, {
+      rules: {
+        test: 'hint',
+      },
+    });
+
+    expect(ruleset).toHaveProperty('rules.test.severity', DiagnosticSeverity.Hint);
+  });
+
+  it('overrides existing severity if all ruleset severity is given', () => {
+    const ruleset: IRulesetFile = {
+      rules: {
+        test: JSON.parse(JSON.stringify(baseRule)),
+      },
+    };
+
+    mergeRulesets(
+      ruleset,
+      {
+        rules: {
+          test: 'hint',
+        },
+      },
+      'all',
+    );
+
+    expect(ruleset).toHaveProperty('rules.test.severity', DiagnosticSeverity.Hint);
+  });
+
+  it('given invalid rule value should throw', () => {
+    const ruleset: IRulesetFile = {
+      rules: {
+        test: JSON.parse(JSON.stringify(baseRule)),
+      },
+    };
+
+    expect(
+      mergeRulesets.bind(null, ruleset, {
+        rules: {
+          test() {
+            // should never happen
+          },
+        } as any,
+      }),
+    ).toThrow('Invalid value for a rule');
+  });
 });
diff --git a/src/rulesets/__tests__/reader.jest.test.ts b/src/rulesets/__tests__/reader.jest.test.ts
index 1a626bfe..242b03f9 100644
--- a/src/rulesets/__tests__/reader.jest.test.ts
+++ b/src/rulesets/__tests__/reader.jest.test.ts
@@ -1,18 +1,23 @@
+import * as path from '@stoplight/path';
 import { Dictionary } from '@stoplight/types';
 import { DiagnosticSeverity } from '@stoplight/types';
-import * as path from 'path';
 import { IRule } from '../../types';
 import { readRulesFromRulesets } from '../reader';
 
 const validFlatRuleset = path.join(__dirname, './__fixtures__/valid-flat-ruleset.json');
-const validFlatRuleset2 = path.join(__dirname, './__fixtures__/valid-require-info-ruleset.yaml');
+const validRequireInfo = path.join(__dirname, './__fixtures__/valid-require-info-ruleset.yaml');
+const enabledAllRuleset = path.join(__dirname, './__fixtures__/enable-all-ruleset.json');
 const invalidRuleset = path.join(__dirname, './__fixtures__/invalid-ruleset.json');
 const extendsOas2Ruleset = path.join(__dirname, './__fixtures__/extends-oas2-ruleset.json');
 const extendsUnspecifiedOas2Ruleset = path.join(__dirname, './__fixtures__/extends-unspecified-oas2-ruleset.json');
+const extendsDisabledOas2Ruleset = path.join(__dirname, './__fixtures__/extends-disabled-oas2-ruleset.yaml');
 const extendsOas2WithOverrideRuleset = path.join(__dirname, './__fixtures__/extends-oas2-with-override-ruleset.json');
+const extendsRelativeRuleset = path.join(__dirname, './__fixtures__/extends-relative-ruleset.json');
 const oasRuleset = require('../oas/index.json');
 const oas2Ruleset = require('../oas2/index.json');
 
+jest.setTimeout(10000);
+
 describe('Rulesets reader', () => {
   it('given flat, valid ruleset file should return rules', async () => {
     expect(await readRulesFromRulesets(validFlatRuleset)).toEqual({
@@ -28,7 +33,7 @@ describe('Rulesets reader', () => {
   });
 
   it('given two flat, valid ruleset files should return rules', async () => {
-    expect(await readRulesFromRulesets(validFlatRuleset, validFlatRuleset2)).toEqual({
+    expect(await readRulesFromRulesets(validFlatRuleset, validRequireInfo)).toEqual({
       'valid-rule': {
         given: '$.info',
         message: 'should be OK',
@@ -51,38 +56,27 @@ describe('Rulesets reader', () => {
   it('should inherit properties of extended rulesets', async () => {
     const rules = await readRulesFromRulesets(extendsOas2Ruleset);
 
-    // we pick up recommended rules only from spectral:oas
-    expect(rules).toMatchObject(
-      Object.entries(oasRuleset.rules).reduce<Dictionary<unknown>>((oasRules, [name, rule]) => {
-        oasRules[name] = {
-          ...rule,
-          ...((rule as IRule).severity === undefined && { severity: DiagnosticSeverity.Warning }),
-          ...(!(rule as IRule).recommended && { severity: -1 }),
-        };
-
-        return oasRules;
-      }, {}),
-    );
-
-    // we pick up all rules from spectral:oas2
-
-    expect(rules).toMatchObject(
-      Object.entries(oas2Ruleset.rules).reduce<Dictionary<unknown>>((oas2Rules, [name, rule]) => {
-        oas2Rules[name] = {
-          ...rule,
-          ...((rule as IRule).severity === undefined && { severity: DiagnosticSeverity.Warning }),
-        };
+    // we pick up *all* rules only from spectral:oas and spectral:oas2 and keep their severity level or set a default one
+    expect(rules).toEqual({
+      ...[...Object.entries(oasRuleset.rules), ...Object.entries(oas2Ruleset.rules)].reduce<Dictionary<unknown>>(
+        (oasRules, [name, rule]) => {
+          oasRules[name] = {
+            ...rule,
+            ...((rule as IRule).severity === undefined && { severity: DiagnosticSeverity.Warning }),
+          };
 
-        return oas2Rules;
-      }, {}),
-    );
+          return oasRules;
+        },
+        {},
+      ),
 
-    expect(rules).toHaveProperty('valid-rule', {
-      given: '$.info',
-      message: 'should be OK',
-      severity: DiagnosticSeverity.Warning,
-      then: {
-        function: 'truthy',
+      'valid-rule': {
+        given: '$.info',
+        message: 'should be OK',
+        severity: DiagnosticSeverity.Warning,
+        then: {
+          function: 'truthy',
+        },
       },
     });
   });
@@ -113,6 +107,35 @@ describe('Rulesets reader', () => {
     });
   });
 
+  it('should set severity of disabled rules to off', () => {
+    return expect(readRulesFromRulesets(extendsDisabledOas2Ruleset)).resolves.toEqual({
+      ...[...Object.entries(oasRuleset.rules), ...Object.entries(oas2Ruleset.rules)].reduce<Dictionary<unknown>>(
+        (rules, [name, rule]) => {
+          rules[name] = {
+            ...rule,
+            severity: -1,
+          };
+
+          return rules;
+        },
+        {},
+      ),
+
+      'operation-operationId-unique': {
+        // value of oasRuleset.rules['operation-operationId-unique']
+        description: 'Every operation must have a unique `operationId`.',
+        recommended: true,
+        type: 'validation',
+        severity: 0,
+        given: '$',
+        then: {
+          function: 'oasOpIdUnique',
+        },
+        tags: ['operation'],
+      },
+    });
+  });
+
   it('should override properties of extended rulesets', () => {
     return expect(readRulesFromRulesets(extendsOas2WithOverrideRuleset)).resolves.toHaveProperty(
       'operation-2xx-response',
@@ -130,7 +153,7 @@ describe('Rulesets reader', () => {
     );
   });
 
-  it('should persists disabled properties of extended rulesets', () => {
+  it('should persist disabled properties of extended rulesets', () => {
     return expect(readRulesFromRulesets(extendsOas2WithOverrideRuleset)).resolves.toHaveProperty(
       'operation-security-defined',
       {
@@ -150,6 +173,60 @@ describe('Rulesets reader', () => {
     );
   });
 
+  it('should prefer top-level ruleset severity level', async () => {
+    const enabledRules = await readRulesFromRulesets(enabledAllRuleset);
+    expect(enabledRules).toEqual(
+      [...Object.entries(oasRuleset.rules), ...Object.entries(oas2Ruleset.rules)].reduce<Dictionary<unknown>>(
+        (rules, [name, rule]) => {
+          rules[name] = {
+            ...rule,
+            ...((rule as IRule).severity === undefined && { severity: DiagnosticSeverity.Warning }),
+          };
+
+          return rules;
+        },
+        {},
+      ),
+    );
+
+    // let's make sure all rules are enabled
+    expect(
+      Object.values(enabledRules).filter(
+        rule => rule.severity === -1 || rule.severity === 'off' || rule.severity === undefined,
+      ),
+    ).toHaveLength(0);
+  });
+
+  it('should support local rulesets', () => {
+    return expect(readRulesFromRulesets(extendsRelativeRuleset)).resolves.toEqual({
+      PascalCase: {
+        given: '$',
+        message: 'bar',
+        severity: -1, // turned off, cause it's not recommended
+        then: {
+          function: 'truthy',
+        },
+      },
+      camelCase: {
+        given: '$',
+        message: 'bar',
+        recommended: true,
+        severity: DiagnosticSeverity.Warning,
+        then: {
+          function: 'truthy',
+        },
+      },
+      snake_case: {
+        given: '$',
+        message: 'foo',
+        severity: DiagnosticSeverity.Warning,
+        then: {
+          function: 'truthy',
+        },
+      },
+    });
+  });
+
   it('given non-existent ruleset should output error', () => {
     return expect(readRulesFromRulesets('oneParentRuleset')).rejects.toThrowError(
       'Could not parse https://unpkg.com/oneParentRuleset: Not Found',
diff --git a/src/rulesets/__tests__/severity.test.ts b/src/rulesets/__tests__/severity.test.ts
new file mode 100644
index 00000000..a4529e24
--- /dev/null
+++ b/src/rulesets/__tests__/severity.test.ts
@@ -0,0 +1,74 @@
+import { DiagnosticSeverity } from '@stoplight/types/dist';
+import { HumanReadableDiagnosticSeverity } from '../../types';
+import { FileRulesetSeverity } from '../../types/ruleset';
+import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity, getSeverityLevel } from '../severity';
+
+// @oclif/test packages requires @types/mocha, therefore we have 2 packages coming up with similar typings
+// TS is confused and prefers the mocha ones, so we need to instrument it to pick up the Jest ones
+declare var test: jest.It;
+
+describe('Ruleset severity', () => {
+  describe('getDiagnosticSeverity', () => {
+    test.each([
+      ['off', -1],
+      ['error', DiagnosticSeverity.Error],
+      ['warn', DiagnosticSeverity.Warning],
+      ['info', DiagnosticSeverity.Information],
+      ['hint', DiagnosticSeverity.Hint],
+    ])('should successfully match %s human readable severity', (human, severity) => {
+      expect(getDiagnosticSeverity(human as HumanReadableDiagnosticSeverity)).toEqual(severity);
+    });
+
+    test.each([
+      -1,
+      DiagnosticSeverity.Error,
+      DiagnosticSeverity.Warning,
+      DiagnosticSeverity.Information,
+      DiagnosticSeverity.Hint,
+    ])('should understand diagnostic severity', severity => {
+      expect(getDiagnosticSeverity(severity)).toEqual(severity);
+    });
+  });
+
+  describe('getSeverityLevel', () => {
+    describe('given invalid rule', () => {
+      test('should return provided severity', () => {
+        expect(getSeverityLevel({}, 'foo', DiagnosticSeverity.Information)).toEqual(DiagnosticSeverity.Information);
+        expect(getSeverityLevel({ foo: false }, 'foo', DiagnosticSeverity.Hint)).toEqual(DiagnosticSeverity.Hint);
+      });
+
+      test('should override existing severity level if rule is incomplete', () => {
+        expect(getSeverityLevel({ foo: DiagnosticSeverity.Error }, 'foo', DiagnosticSeverity.Information)).toEqual(
+          DiagnosticSeverity.Information,
+        );
+      });
+
+      test.each(['recommended', 'all'] as FileRulesetSeverity[])(
+        'should respect disabled state if %s flag is given',
+        flag => {
+          expect(getSeverityLevel({ foo: false }, 'foo', flag)).toEqual(-1);
+          expect(getSeverityLevel({ foo: -1 }, 'foo', flag)).toEqual(-1);
+          expect(getSeverityLevel({ foo: 'off' }, 'foo', flag)).toEqual(-1);
+        },
+      );
+
+      test.each(['recommended', 'all'] as FileRulesetSeverity[])(
+        'should try preserve existing severity if %s flag is provided',
+        flag => {
+          expect(getSeverityLevel({ foo: DiagnosticSeverity.Warning }, 'foo', flag)).toEqual(
+            DiagnosticSeverity.Warning,
+          );
+          expect(getSeverityLevel({ foo: 'error' }, 'foo', flag)).toEqual(DiagnosticSeverity.Error);
+          expect(getSeverityLevel({ foo: true }, 'foo', flag)).toEqual(DEFAULT_SEVERITY_LEVEL);
+        },
+      );
+
+      test.each(['recommended', 'all'] as FileRulesetSeverity[])(
+        'should set a default severity when %s flag is provided and a rule is missing',
+        flag => {
+          expect(getSeverityLevel({}, 'foo', flag)).toEqual(DEFAULT_SEVERITY_LEVEL);
+        },
+      );
+    });
+  });
+});
diff --git a/src/rulesets/__tests__/validation.test.ts b/src/rulesets/__tests__/validation.test.ts
index 7bb1403a..3b1fa898 100644
--- a/src/rulesets/__tests__/validation.test.ts
+++ b/src/rulesets/__tests__/validation.test.ts
@@ -7,6 +7,17 @@ const validRuleset = require('./__fixtures__/valid-flat-ruleset.json');
 declare var it: jest.It;
 
 describe('Ruleset Validation', () => {
+  it('given primitive type should throw', () => {
+    expect(assertValidRuleset.bind(null, null)).toThrow();
+    expect(assertValidRuleset.bind(null, 2)).toThrow();
+    expect(assertValidRuleset.bind(null, 'true')).toThrow();
+  });
+
+  it('given object with no rules property should throw', () => {
+    expect(assertValidRuleset.bind(null, {})).toThrow();
+    expect(assertValidRuleset.bind(null, { rule: {} })).toThrow();
+  });
+
   it('given invalid ruleset should throw', () => {
     expect(assertValidRuleset.bind(null, invalidRuleset)).toThrow();
   });
@@ -76,6 +87,15 @@ describe('Ruleset Validation', () => {
     ).not.toThrow();
   });
 
+  it('recognizes string extends syntax', () => {
+    expect(
+      assertValidRuleset.bind(null, {
+        extends: 'foo',
+        rules: {},
+      }),
+    ).not.toThrow();
+  });
+
   it('recognizes invalid array-ish extends syntax', () => {
     expect(
       assertValidRuleset.bind(null, {