Repository: stoplightio/spectral
Pull request #1465 (2021-01-09 01:05:56)

fix: nested extends broken in rulesets
Fixes https://github.com/stoplightio/spectral/issues/1352
Fixes https://github.com/stoplightio/spectral/issues/1380

I'm going to split this PR into 2 smaller ones.
I cleaned up all tests we had, so that they don't rely on OAS ruleset as well as moved a few files around.

Prior to that, I spent a bit of time improving the current ruleset API we have - at the moment it's not the most convenient for JS consumers.
The work can be found on [feat/nicer-rulesets](https://github.com/stoplightio/spectral/tree/feat/nicer-rulesets) branch

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No





Patch:
diff --git a/src/rule.ts b/src/rule.ts
index d53f7073..66965499 100644
--- a/src/rule.ts
+++ b/src/rule.ts
@@ -3,26 +3,26 @@ import { JSONPathExpression } from 'nimma';
 
 import { IDocument } from './document';
 import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity } from './ruleset/severity';
-import { IGivenNode, IRule, IThen, SpectralDiagnosticSeverity } from './types';
+import { IGivenNode, IProcessedRule, IThen } from './types';
 import { hasIntersectingElement } from './utils';
+import { DiagnosticSeverity } from '@stoplight/types';
 
 export class Rule {
   public readonly name: string;
   public readonly description: string | null;
   public readonly message: string | null;
-  public readonly severity: SpectralDiagnosticSeverity;
+  public readonly severity: DiagnosticSeverity;
   public readonly resolved: boolean;
   public readonly formats: Optional<string[]>;
 
   public readonly then: IThen[];
   public readonly given: string[];
 
-  public get enabled(): boolean {
-    return this.severity !== -1;
-  }
+  public readonly enabled: boolean;
 
-  constructor(name: string, rule: IRule) {
+  constructor(name: string, rule: IProcessedRule) {
     this.name = name;
+    this.enabled = rule.enabled ?? true;
     this.description = rule.description ?? null;
     this.message = rule.message ?? null;
     this.severity = rule.severity === void 0 ? DEFAULT_SEVERITY_LEVEL : getDiagnosticSeverity(rule.severity);
@@ -49,7 +49,7 @@ function stub(): void {
 export class OptimizedRule extends Rule {
   public readonly expressions: JSONPathExpression[];
 
-  constructor(name: string, rule: IRule) {
+  constructor(name: string, rule: IProcessedRule) {
     super(name, rule);
     this.expressions = this.given.map(given => {
       const expr = new JSONPathExpression(given, stub, stub);
diff --git a/src/ruleset/mergers/rules.ts b/src/ruleset/mergers/rules.ts
index fb3a47c8..cf7d38cc 100644
--- a/src/ruleset/mergers/rules.ts
+++ b/src/ruleset/mergers/rules.ts
@@ -1,9 +1,9 @@
-import { DiagnosticSeverity } from '@stoplight/types';
-import { isObject } from 'lodash';
-import { HumanReadableDiagnosticSeverity, IRule, IThen } from '../../types';
 import { FileRule, FileRuleCollection, FileRulesetSeverity } from '../../types/ruleset';
-import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity, getSeverityLevel } from '../severity';
+import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity } from '../severity';
 import { isValidRule } from '../validation';
+import { DiagnosticSeverity } from '@stoplight/types/dist';
+import { Dictionary } from '@stoplight/types';
+import { IRule, IProcessedRule } from '../../types';
 
 /*
 - if rule is object, simple deep merge (or we could replace to be a bit stricter?)
@@ -13,20 +13,26 @@ import { isValidRule } from '../validation';
 - if rule is array, index 0 should be false/true/string/number - same severity logic as above. optional second
 */
 export function mergeRules(
-  target: FileRuleCollection,
+  target: Dictionary<IProcessedRule>,
   source: FileRuleCollection,
   rulesetSeverity?: FileRulesetSeverity,
-): FileRuleCollection {
+): Dictionary<IProcessedRule> {
   for (const [name, rule] of Object.entries(source)) {
-    if (rulesetSeverity !== undefined) {
-      const severity = getSeverityLevel(source, name, rulesetSeverity);
-      if (isValidRule(rule)) {
-        markRule(rule);
-        rule.severity = severity;
-        processRule(target, name, rule);
-      } else {
-        processRule(target, name, typeof rule === 'boolean' ? rule : severity);
+    if (rulesetSeverity !== void 0) {
+      if (isValidRule(rule) && !('enabled' in rule)) {
+        let enabled;
+        if (rulesetSeverity === 'all') {
+          enabled = true;
+        } else if (rulesetSeverity === 'off') {
+          enabled = false;
+        } else {
+          enabled = rule.recommended !== false;
+        }
+
+        (rule as IProcessedRule).enabled = enabled;
       }
+
+      processRule(target, name, rule);
     } else {
       processRule(target, name, rule);
     }
@@ -35,69 +41,26 @@ export function mergeRules(
   return target;
 }
 
-const ROOT_DESCRIPTOR = Symbol('root-descriptor');
-
-function markRule(rule: IRule): void {
-  if (!(ROOT_DESCRIPTOR in rule)) {
-    Object.defineProperty(rule, ROOT_DESCRIPTOR, {
-      configurable: false,
-      enumerable: false,
-      writable: false,
-      value: copyRule(rule),
-    });
-  }
-}
-
-function updateRootRule(root: IRule, newRule: IRule | null): void {
-  markRule(root);
-  Object.assign(root[ROOT_DESCRIPTOR], copyRule(newRule === null ? root : Object.assign(root, newRule)));
-}
-
-function getRootRule(rule: IRule): IRule | null {
-  return rule[ROOT_DESCRIPTOR] !== undefined ? rule[ROOT_DESCRIPTOR] : null;
-}
-
-function copyRuleThen(then: IThen): IThen {
-  return {
-    ...then,
-    ...('functionOptions' in then && isObject(then.functionOptions) ? { ...then.functionOptions } : null),
-  };
-}
-
-function copyRule(rule: IRule): IRule {
-  return {
-    ...rule,
-    ...('then' in rule
-      ? { then: Array.isArray(rule.then) ? rule.then.map(copyRuleThen) : copyRuleThen(rule.then) }
-      : null),
-  };
-}
-
-function processRule(rules: FileRuleCollection, name: string, rule: FileRule | FileRulesetSeverity): void {
+function processRule(rules: Dictionary<IProcessedRule>, name: string, rule: FileRule): void {
   const existingRule = rules[name];
 
   switch (typeof rule) {
     case 'boolean':
       if (isValidRule(existingRule)) {
-        const rootRule = getRootRule(existingRule);
-        if (!rule) {
-          existingRule.severity = -1;
-        } else if (rootRule === null) {
-          existingRule.severity = getSeverityLevel(rules, name, rule);
-          updateRootRule(existingRule, existingRule);
-        } else if ('severity' in rootRule) {
-          existingRule.severity = rootRule.severity;
-          updateRootRule(existingRule, existingRule);
-        } else {
-          existingRule.severity = DiagnosticSeverity.Warning;
-        }
+        existingRule.enabled = rule;
       }
+
       break;
     case 'string':
     case 'number':
       // what if rule does not exist (yet)? throw, store the invalid state somehow?
       if (isValidRule(existingRule)) {
-        existingRule.severity = getSeverityLevel(rules, name, rule);
+        if (rule === 'off') {
+          existingRule.enabled = false;
+        } else {
+          existingRule.severity = getDiagnosticSeverity(rule);
+          existingRule.enabled = true;
+        }
       }
 
       break;
@@ -105,21 +68,15 @@ function processRule(rules: FileRuleCollection, name: string, rule: FileRule | F
       if (Array.isArray(rule)) {
         processRule(rules, name, rule[0]);
 
-        if (isValidRule(existingRule) && rule.length === 2 && rule[1] !== undefined) {
+        if (isValidRule(existingRule) && rule.length === 2 && rule[1] !== void 0) {
           if ('functionOptions' in existingRule.then) {
             existingRule.then.functionOptions = rule[1];
           }
-
-          updateRootRule(existingRule, null);
         }
       } else if (isValidRule(existingRule)) {
-        normalizeRule(rule, existingRule.severity);
-        updateRootRule(existingRule, rule);
+        Object.assign(existingRule, normalizeRule(rule));
       } else {
-        normalizeRule(rule, getSeverityLevel(rules, name, rule));
-        // new rule
-        markRule(rule);
-        rules[name] = rule;
+        rules[name] = normalizeRule(rule);
       }
 
       break;
@@ -128,14 +85,9 @@ function processRule(rules: FileRuleCollection, name: string, rule: FileRule | F
   }
 }
 
-function normalizeRule(rule: IRule, severity: DiagnosticSeverity | HumanReadableDiagnosticSeverity | undefined): void {
-  if (rule.recommended === void 0) {
-    rule.recommended = true;
-  }
-
-  if (rule.severity === void 0) {
-    rule.severity = severity === void 0 ? (rule.recommended !== false ? DEFAULT_SEVERITY_LEVEL : -1) : severity;
-  } else {
-    rule.severity = getDiagnosticSeverity(rule.severity);
-  }
+function normalizeRule(rule: IRule): IProcessedRule & { recommended: boolean; severity: DiagnosticSeverity } {
+  return Object.assign(Object.defineProperties({}, Object.getOwnPropertyDescriptors(rule)), {
+    recommended: rule.recommended !== false,
+    severity: rule.severity === void 0 ? DEFAULT_SEVERITY_LEVEL : getDiagnosticSeverity(rule.severity),
+  });
 }
diff --git a/src/ruleset/readRuleset.ts b/src/ruleset/readRuleset.ts
index cb12fc80..873c596b 100644
--- a/src/ruleset/readRuleset.ts
+++ b/src/ruleset/readRuleset.ts
@@ -57,7 +57,7 @@ const createRulesetProcessor = (
   return async function processRuleset(
     baseUri: string,
     uri: string,
-    severity?: FileRulesetSeverity,
+    severity: FileRulesetSeverity = 'recommended',
   ): Promise<IRuleset | null> {
     const rulesetUri = await findFile(join(baseUri, '..'), uri);
     if (processedRulesets.has(rulesetUri)) {
@@ -105,12 +105,11 @@ const createRulesetProcessor = (
     if (extendedRulesets !== void 0) {
       for (const extended of Array.isArray(extendedRulesets) ? extendedRulesets : [extendedRulesets]) {
         let extendedRuleset: IRuleset | null;
-        let parentSeverity: FileRulesetSeverity;
+        let parentSeverity: FileRulesetSeverity = severity;
         if (Array.isArray(extended)) {
-          parentSeverity = severity === void 0 ? extended[1] : severity;
-          extendedRuleset = await processRuleset(rulesetUri, extended[0], parentSeverity);
+          parentSeverity = extended[1];
+          extendedRuleset = await processRuleset(rulesetUri, extended[0], extended[1]);
         } else {
-          parentSeverity = severity === void 0 ? 'recommended' : severity;
           extendedRuleset = await processRuleset(rulesetUri, extended, parentSeverity);
         }
 
@@ -123,12 +122,12 @@ const createRulesetProcessor = (
     }
 
     if (ruleset.rules !== void 0) {
-      mergeRules(rules, ruleset.rules, severity === void 0 ? 'recommended' : severity);
+      mergeRules(rules, ruleset.rules, severity);
 
       if (ruleset.documentationUrl !== void 0) {
         for (const [name, rule] of Object.entries(ruleset.rules)) {
           if (isValidRule(rule) && rule.documentationUrl === void 0) {
-            rule.documentationUrl = `${ruleset.documentationUrl}#${name}`;
+            newRuleset.rules[name].documentationUrl = `${ruleset.documentationUrl}#${name}`;
           }
         }
       }
diff --git a/src/ruleset/severity.ts b/src/ruleset/severity.ts
index be012655..469741f2 100644
--- a/src/ruleset/severity.ts
+++ b/src/ruleset/severity.ts
@@ -1,68 +1,8 @@
 import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
 import { HumanReadableDiagnosticSeverity, SpectralDiagnosticSeverity } from '../types';
-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;
-  }
-}
-
-function getSeverityForInvalidRule(
-  existingRule: FileRuleSeverity | [FileRuleSeverity] | [FileRuleSeverity, object],
-  newRule: FileRule | FileRulesetSeverity,
-) {
-  if (newRule === 'off') return -1;
-
-  if (newRule === 'recommended' || newRule === 'all') {
-    if (existingRule === false || existingRule === -1 || existingRule === 'off') {
-      return -1;
-    }
-
-    return getSeverityForRule(existingRule, DEFAULT_SEVERITY_LEVEL);
-  }
-
-  return getSeverityForRule(newRule, DEFAULT_SEVERITY_LEVEL);
-}
-
-export function getSeverityLevel(
-  rules: FileRuleCollection,
-  name: string,
-  newRule: FileRule | FileRulesetSeverity,
-): SpectralDiagnosticSeverity {
-  const existingRule = rules[name];
-
-  // this is an edge case, please refer to related tests
-  if (!isValidRule(existingRule)) {
-    return getSeverityForInvalidRule(existingRule, newRule);
-  }
-
-  const existingSeverity =
-    existingRule.severity !== undefined ? getDiagnosticSeverity(existingRule.severity) : DEFAULT_SEVERITY_LEVEL;
-
-  if (newRule === 'recommended') {
-    return existingRule.recommended !== false ? existingSeverity : -1;
-  }
-
-  if (newRule === 'all') {
-    return existingSeverity;
-  }
-
-  return getSeverityForRule(newRule, existingSeverity);
-}
-
 const SEVERITY_MAP: Dictionary<SpectralDiagnosticSeverity, HumanReadableDiagnosticSeverity> = {
   error: DiagnosticSeverity.Error,
   warn: DiagnosticSeverity.Warning,
diff --git a/src/runner/lintNode.ts b/src/runner/lintNode.ts
index 0e312698..ac81adf9 100644
--- a/src/runner/lintNode.ts
+++ b/src/runner/lintNode.ts
@@ -128,7 +128,6 @@ function processTargetResults(
 
     context.results.push({
       code: rule.name,
-      // todo: rule.isInterpolable
       message: (rule.message === null ? rule.description ?? resultMessage : message(rule.message, vars)).trim(),
       path,
       severity: getDiagnosticSeverity(rule.severity),
diff --git a/src/types/rule.ts b/src/types/rule.ts
index 85e17e7e..1186a679 100644
--- a/src/types/rule.ts
+++ b/src/types/rule.ts
@@ -31,6 +31,10 @@ export interface IRule<T = string, O = unknown> {
   then: IThen<T, O> | Array<IThen<T, O>>;
 }
 
+export interface IProcessedRule extends IRule {
+  enabled?: boolean;
+}
+
 export interface IThen<T = string, O = unknown> {
   // 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)
diff --git a/src/types/spectral.ts b/src/types/spectral.ts
index aa761dbb..01a88d39 100644
--- a/src/types/spectral.ts
+++ b/src/types/spectral.ts
@@ -1,12 +1,12 @@
 import { IResolveOpts, IResolveResult } from '@stoplight/json-ref-resolver/types';
 import { DiagnosticSeverity, Dictionary, IDiagnostic, JsonPath } from '@stoplight/types';
 import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
-import { IFunction, IRule } from '.';
+import { IFunction, IProcessedRule, IRule } from '.';
 import { Rule } from '../rule';
 import { ComputeFingerprintFunc } from '../utils';
 
 export type FunctionCollection = Dictionary<IFunction<any>, string>;
-export type RuleCollection = Dictionary<IRule, string>;
+export type RuleCollection = Dictionary<IProcessedRule, string>;
 export type PartialRuleCollection = Dictionary<Partial<IRule>, string>;
 export type RunRuleCollection = Dictionary<Rule, string>;
 


Test patch:
diff --git a/src/ruleset/__tests__/__fixtures__/severity/off-proxy.json b/src/ruleset/__tests__/__fixtures__/severity/off-proxy.json
new file mode 100644
index 00000000..1de95407
--- /dev/null
+++ b/src/ruleset/__tests__/__fixtures__/severity/off-proxy.json
@@ -0,0 +1,3 @@
+{
+  "extends": "./off.json"
+}
diff --git a/src/ruleset/__tests__/reader.jest.test.ts b/src/ruleset/__tests__/reader.jest.test.ts
index f53c8188..788d614f 100644
--- a/src/ruleset/__tests__/reader.jest.test.ts
+++ b/src/ruleset/__tests__/reader.jest.test.ts
@@ -37,6 +37,7 @@ describe('Rulesets reader', () => {
           'valid-rule': {
             given: '$.info',
             severity: DiagnosticSeverity.Warning,
+            enabled: true,
             recommended: true,
             then: expect.any(Object),
           },
@@ -53,6 +54,7 @@ describe('Rulesets reader', () => {
         rules: {
           'valid-rule': {
             given: '$.info',
+            enabled: true,
             recommended: true,
             severity: DiagnosticSeverity.Warning,
             then: {
@@ -61,6 +63,7 @@ describe('Rulesets reader', () => {
           },
           'valid-rule-2': {
             given: '$.info',
+            enabled: true,
             recommended: true,
             severity: DiagnosticSeverity.Warning,
             then: {
@@ -74,7 +77,7 @@ describe('Rulesets reader', () => {
 
   describe('severity', () => {
     function getEnabledRules(rules: RuleCollection) {
-      return Object.keys(rules).filter(name => rules[name].severity !== -1);
+      return Object.keys(rules).filter(name => rules[name].enabled);
     }
 
     it('given ruleset with extends set to recommended, should enable recommended rules', async () => {
@@ -122,6 +125,18 @@ describe('Rulesets reader', () => {
 
       expect(getEnabledRules(rules)).toEqual(['overridable-rule']);
     });
+
+    it('given nested extends with severity set to off', async () => {
+      const { rules } = await readRuleset(getFixturePath('severity/off-proxy.json'));
+      expect(Object.keys(rules)).toEqual([
+        'description-matches-stoplight',
+        'title-matches-stoplight',
+        'contact-name-matches-stoplight',
+        'overridable-rule',
+      ]);
+
+      expect(getEnabledRules(rules)).toEqual(['overridable-rule']);
+    });
   });
 
   it('should limit the scope of formats to a ruleset', async () => {
@@ -175,6 +190,7 @@ describe('Rulesets reader', () => {
           'valid-foo-value': {
             given: '$',
             severity: DiagnosticSeverity.Warning,
+            enabled: true,
             recommended: true,
             then: {
               field: 'foo',
@@ -234,6 +250,7 @@ describe('Rulesets reader', () => {
           'valid-foo-value': {
             given: '$',
             severity: DiagnosticSeverity.Warning,
+            enabled: true,
             recommended: true,
             then: {
               field: 'foo',
@@ -378,6 +395,7 @@ describe('Rulesets reader', () => {
         'bar-rule': {
           given: '$.bar',
           message: 'Bar is truthy',
+          enabled: true,
           recommended: true,
           severity: DiagnosticSeverity.Warning,
           then: {
@@ -387,6 +405,7 @@ describe('Rulesets reader', () => {
         'foo-rule': {
           given: '$.foo',
           message: 'Foo is falsy',
+          enabled: true,
           recommended: true,
           severity: DiagnosticSeverity.Warning,
           then: {
@@ -406,6 +425,7 @@ describe('Rulesets reader', () => {
           given: '$',
           message: 'Foo',
           severity: DiagnosticSeverity.Warning,
+          enabled: true,
           recommended: true,
           then: {
             function: 'falsy',
@@ -480,6 +500,7 @@ describe('Rulesets reader', () => {
         description: "All 'HTTP' headers SHOULD NOT include 'X-' headers (https://tools.ietf.org/html/rfc6648).",
         given: ["$..parameters[?(@.in == 'header')].name"],
         message: "HTTP header '{{value}}' SHOULD NOT include 'X-' prefix in {{path}}",
+        enabled: true,
         recommended: true,
         severity: 1,
         then: {
@@ -494,6 +515,7 @@ describe('Rulesets reader', () => {
         description: "All 'HTTP' headers SHOULD NOT include 'X-' headers (https://tools.ietf.org/html/rfc6648).",
         given: ['$.[responses][*].headers.*~'],
         message: "HTTP header '{{value}}' SHOULD NOT include 'X-' prefix in {{path}}",
+        enabled: true,
         recommended: true,
         severity: 1,
         then: {
@@ -515,6 +537,7 @@ describe('Rulesets reader', () => {
         documentationUrl:
           'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md#foo-rule',
         given: '',
+        enabled: true,
         recommended: true,
         severity: DiagnosticSeverity.Warning,
         then: {
@@ -524,6 +547,7 @@ describe('Rulesets reader', () => {
       'bar-rule': {
         documentationUrl: 'https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/bar-rule.md',
         given: '',
+        enabled: true,
         recommended: true,
         severity: DiagnosticSeverity.Warning,
         then: {
diff --git a/src/ruleset/__tests__/severity.test.ts b/src/ruleset/__tests__/severity.test.ts
deleted file mode 100644
index b88b81fc..00000000
--- a/src/ruleset/__tests__/severity.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { DiagnosticSeverity } from '@stoplight/types';
-import { HumanReadableDiagnosticSeverity } from '../../types';
-import { FileRulesetSeverity } from '../../types/ruleset';
-import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity, getSeverityLevel } from '../severity';
-
-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/ruleset/mergers/__tests__/rules.test.ts b/src/ruleset/mergers/__tests__/rules.test.ts
index 1348804c..07f5f0a0 100644
--- a/src/ruleset/mergers/__tests__/rules.test.ts
+++ b/src/ruleset/mergers/__tests__/rules.test.ts
@@ -51,7 +51,7 @@ describe('Ruleset rules merging', () => {
       test: false,
     });
 
-    expect(rules).toHaveProperty('test.severity', -1);
+    expect(rules).toHaveProperty('test.enabled', false);
   });
 
   it('supports nested severity', () => {
@@ -144,7 +144,7 @@ describe('Ruleset rules merging', () => {
       test: ['off'],
     });
 
-    expect(rules).toHaveProperty('test.severity', -1);
+    expect(rules).toHaveProperty('test.enabled', false);
   });
 
   it('does not set functionOptions if rule does not implement it', () => {
@@ -185,8 +185,8 @@ describe('Ruleset rules merging', () => {
       'off',
     );
 
-    expect(rules).toHaveProperty('test.severity', -1);
-    expect(rules).toHaveProperty('test2.severity', -1);
+    expect(rules).toHaveProperty('test.enabled', false);
+    expect(rules).toHaveProperty('test2.enabled', false);
   });
 
   it('picks up recommended rules', () => {
@@ -212,9 +212,9 @@ describe('Ruleset rules merging', () => {
     expect(rules.test2.recommended).toBe(false);
     expect(rules.test3.recommended).toBe(true);
 
-    expect(rules).toHaveProperty('test.severity', DiagnosticSeverity.Warning);
-    expect(rules).toHaveProperty('test2.severity', -1);
-    expect(rules).toHaveProperty('test3.severity', DiagnosticSeverity.Warning);
+    expect(rules).toHaveProperty('test.enabled', true);
+    expect(rules).toHaveProperty('test2.enabled', false);
+    expect(rules).toHaveProperty('test3.enabled', true);
   });
 
   it('rules with no severity and no recommended set are treated as warnings', () => {
@@ -249,7 +249,7 @@ describe('Ruleset rules merging', () => {
       'recommended',
     );
 
-    expect(rules).toHaveProperty('rule.severity', -1);
+    expect(rules).toHaveProperty('rule.enabled', false);
   });
 
   it('sets warning as default severity level if a rule has no severity specified', () => {
@@ -388,45 +388,54 @@ describe('Ruleset rules merging', () => {
     });
 
     it('respects ruleset severity', () => {
-      expect(mergeRules({}, rules, 'all')).toEqual({
+      expect(mergeRules({}, JSON.parse(JSON.stringify(rules)), 'all')).toEqual({
         rule: expect.objectContaining({
           recommended: true,
           severity: DiagnosticSeverity.Warning,
+          enabled: true,
         }),
         'rule-with-no-recommended': expect.objectContaining({
           severity: DiagnosticSeverity.Warning,
+          enabled: true,
         }),
         'optional-rule': expect.objectContaining({
           recommended: false,
           severity: DiagnosticSeverity.Warning,
+          enabled: true,
         }),
       });
 
-      expect(mergeRules({}, rules, 'recommended')).toEqual({
+      expect(mergeRules({}, JSON.parse(JSON.stringify(rules)), 'recommended')).toEqual({
         rule: expect.objectContaining({
           recommended: true,
           severity: DiagnosticSeverity.Warning,
+          enabled: true,
         }),
         'rule-with-no-recommended': expect.objectContaining({
           severity: DiagnosticSeverity.Warning,
+          enabled: true,
         }),
         'optional-rule': expect.objectContaining({
           recommended: false,
-          severity: -1,
+          severity: DiagnosticSeverity.Warning,
+          enabled: false,
         }),
       });
 
-      expect(mergeRules({}, rules, 'off')).toEqual({
+      expect(mergeRules({}, JSON.parse(JSON.stringify(rules)), 'off')).toEqual({
         rule: expect.objectContaining({
           recommended: true,
-          severity: -1,
+          severity: DiagnosticSeverity.Warning,
+          enabled: false,
         }),
         'rule-with-no-recommended': expect.objectContaining({
-          severity: -1,
+          severity: DiagnosticSeverity.Warning,
+          enabled: false,
         }),
         'optional-rule': expect.objectContaining({
           recommended: false,
-          severity: -1,
+          severity: DiagnosticSeverity.Warning,
+          enabled: false,
         }),
       });
     });