Repository: stoplightio/spectral (ts)

Issue:
Switching rules "off" doesn't work when using chain of inherited rulesets
If there is an hierarchy of rulesets (let’s say 5 inherited rulesets one from each other), then in the top ruleset in the `extends` field I switch off  all the rules, Spectral disables only rules from the direct parent, but not the rule of the other ancestors (parent-parent...).

**Example:**
```
const ruleset3 = {
    extends: [ruleset2],
    rules: { … }
}
```
```
const ruleset4 = {
    extends: [ruleset3],
    rules: { … }
}
```
```
const ruleset5 = {
   extends: [[ruleset4, “off”]],
    rules: { … }
}
```

It disables only rules from the `ruleset4` (direct parent), but not from the `ruleset3` and further ancestors.

**To Reproduce**

1. Checkout the repository - https://github.com/pavelkornev/spectral-example/
2. Run `npm start`
3. There should be no validation errors as they are all switched off, but there will be an error coming from the rule defined in `ruleset1`.
4. See https://github.com/pavelkornev/spectral-example/blob/master/src/index.ts#L14-L52

**Expected behavior**

When inheriting ruleset, it's expected that no matter how many ancestors it has, if I switch off all inherited rules, they must be switched off.


Pull request description:
fix(core): improve deep ruleset inheritance
Fixes #2211 

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No



Patch:
diff --git a/packages/core/src/ruleset/rule.ts b/packages/core/src/ruleset/rule.ts
index c0e2b6f8..6ce9aa85 100644
--- a/packages/core/src/ruleset/rule.ts
+++ b/packages/core/src/ruleset/rule.ts
@@ -11,7 +11,7 @@ import type { HumanReadableDiagnosticSeverity, IRuleThen, RuleDefinition, String
 import { minimatch } from './utils/minimatch';
 import { Formats } from './formats';
 import { resolveAlias } from './alias';
-import type { Stringified } from './types';
+import type { Stringified, FileRulesetSeverityDefinition } from './types';
 
 export interface IRule {
   description: string | null;
@@ -73,6 +73,10 @@ export class Rule implements IRule {
     this.#enabled = enabled;
   }
 
+  public static isEnabled(rule: IRule, severity: FileRulesetSeverityDefinition): boolean {
+    return severity === 'all' || (severity === 'recommended' && rule.recommended);
+  }
+
   public getSeverityForSource(source: string, path: JsonPath): DiagnosticSeverity | -1 {
     if (this.overrides === void 0 || this.overrides.definition.size === 0) {
       return this.severity;
diff --git a/packages/core/src/ruleset/ruleset.ts b/packages/core/src/ruleset/ruleset.ts
index 4020b37e..56095748 100644
--- a/packages/core/src/ruleset/ruleset.ts
+++ b/packages/core/src/ruleset/ruleset.ts
@@ -20,12 +20,14 @@ import { isSimpleAliasDefinition } from './utils/guards';
 import type { Stringified } from './types';
 
 const STACK_SYMBOL = Symbol('@stoplight/spectral/ruleset/#stack');
+const EXPLICIT_SEVERITY = Symbol('@stoplight/spectral/ruleset/#explicit-severity');
 const DEFAULT_RULESET_FILE = /^\.?spectral\.(ya?ml|json|m?js)$/;
 
 type RulesetContext = {
   readonly severity?: FileRulesetSeverityDefinition;
   readonly source?: string;
   readonly [STACK_SYMBOL]?: Map<RulesetDefinition, Ruleset>;
+  readonly [EXPLICIT_SEVERITY]?: boolean;
 };
 
 let SEED = 1;
@@ -110,8 +112,9 @@ export class Ruleset {
             (extensions, extension) => {
               let actualExtension;
               let severity: FileRulesetSeverityDefinition = 'recommended';
+              const explicitSeverity = Array.isArray(extension);
 
-              if (Array.isArray(extension)) {
+              if (explicitSeverity) {
                 [actualExtension, severity] = extension;
               } else {
                 actualExtension = extension;
@@ -123,7 +126,13 @@ export class Ruleset {
                 return extensions;
               }
 
-              extensions.push(new Ruleset(actualExtension, { severity, [STACK_SYMBOL]: stack }));
+              extensions.push(
+                new Ruleset(actualExtension, {
+                  severity,
+                  [STACK_SYMBOL]: stack,
+                  [EXPLICIT_SEVERITY]: explicitSeverity,
+                }),
+              );
               return extensions;
             },
             [],
@@ -271,6 +280,9 @@ export class Ruleset {
         if (extendedRuleset === this) continue;
         for (const rule of Object.values(extendedRuleset.rules)) {
           rules[rule.name] = rule;
+          if (this.#context[STACK_SYMBOL] !== void 0 && this.#context[EXPLICIT_SEVERITY] === true) {
+            rule.enabled = Rule.isEnabled(rule, this.#context.severity);
+          }
         }
       }
     }
@@ -281,8 +293,7 @@ export class Ruleset {
         rules[name] = rule;
 
         if (rule.owner === this) {
-          rule.enabled =
-            this.#context.severity === 'all' || (this.#context.severity === 'recommended' && rule.recommended);
+          rule.enabled = Rule.isEnabled(rule, this.#context.severity);
         }
 
         if (rule.formats !== null) {


Test patch:
diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/severity/off-proxy2.ts b/packages/core/src/ruleset/__tests__/__fixtures__/severity/off-proxy2.ts
new file mode 100644
index 00000000..cd52917a
--- /dev/null
+++ b/packages/core/src/ruleset/__tests__/__fixtures__/severity/off-proxy2.ts
@@ -0,0 +1,26 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import { truthy } from '@stoplight/spectral-functions';
+
+const ruleset1 = {
+  rules: {
+    'custom-info-description': {
+      message: 'API Description is missing',
+      severity: DiagnosticSeverity.Error,
+      given: '$.info',
+      then: {
+        field: 'description',
+        function: truthy,
+      },
+    },
+  },
+};
+
+const ruleset2 = {
+  extends: [ruleset1],
+  rules: {},
+};
+
+export default {
+  extends: [[ruleset2, 'off']],
+  rules: {},
+};
diff --git a/packages/core/src/ruleset/__tests__/ruleset.test.ts b/packages/core/src/ruleset/__tests__/ruleset.test.ts
index 7068adb2..d509cf96 100644
--- a/packages/core/src/ruleset/__tests__/ruleset.test.ts
+++ b/packages/core/src/ruleset/__tests__/ruleset.test.ts
@@ -95,6 +95,13 @@ describe('Ruleset', () => {
       expect(getEnabledRules(rules)).toEqual(['overridable-rule']);
     });
 
+    it('given nested extends with severity set to off #2', async () => {
+      const { rules } = await loadRuleset(import('./__fixtures__/severity/off-proxy2'));
+      expect(Object.keys(rules)).toEqual(['custom-info-description']);
+
+      expect(getEnabledRules(rules)).toEqual([]);
+    });
+
     it('given nested extends with severity set to off and explicit override to error', async () => {
       const { rules } = await loadRuleset(import('./__fixtures__/severity/error'));
       expect(Object.keys(rules)).toEqual([