Repository: stoplightio/spectral
Pull request #2132 (2022-04-20 12:33:27)

chore(core): improve ruleset typings
Extract typing-related changes from #2026

**Checklist**

- [x] Tests added / updated
- [ ] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No




Patch:
diff --git a/packages/core/package.json b/packages/core/package.json
index d58d697f..b93c49a2 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -19,11 +19,15 @@
   "exports": {
     ".": {
       "types": "./dist/index.d.ts",
-      "require": "./dist/index.js"
+      "default": "./dist/index.js"
+    },
+    "./ruleset": {
+      "types": "./dist/ruleset/index.d.ts",
+      "default": "./dist/ruleset/index.js"
     },
     "./ruleset/validation": {
       "types": "./dist/ruleset/validation/index.d.ts",
-      "require": "./dist/ruleset/validation/index.js"
+      "default": "./dist/ruleset/validation/index.js"
     }
   },
   "engines": {
@@ -40,12 +44,12 @@
     "@stoplight/spectral-parsers": "^1.0.0",
     "@stoplight/spectral-ref-resolver": "^1.0.0",
     "@stoplight/spectral-runtime": "^1.0.0",
-    "@stoplight/types": "12.3.0",
+    "@stoplight/types": "13.0.0",
+    "@types/json-schema": "^7.0.7",
     "ajv": "^8.6.0",
     "ajv-errors": "~3.0.0",
     "ajv-formats": "~2.1.0",
     "blueimp-md5": "2.18.0",
-    "json-schema": "0.4.0",
     "jsonpath-plus": "6.0.1",
     "lodash": "~4.17.21",
     "lodash.topath": "^4.5.2",
@@ -60,7 +64,6 @@
     "@stoplight/spectral-functions": "*",
     "@stoplight/spectral-parsers": "*",
     "@stoplight/yaml": "^4.2.2",
-    "@types/json-schema": "^7.0.7",
     "@types/minimatch": "^3.0.5",
     "@types/treeify": "^1.0.0",
     "nock": "^13.1.0",
diff --git a/packages/core/src/ruleset/utils/formatsSet.ts b/packages/core/src/ruleset/formats.ts
similarity index 61%
rename from packages/core/src/ruleset/utils/formatsSet.ts
rename to packages/core/src/ruleset/formats.ts
index 0be49eaf..c8a9063c 100644
--- a/packages/core/src/ruleset/utils/formatsSet.ts
+++ b/packages/core/src/ruleset/formats.ts
@@ -1,10 +1,10 @@
-import type { Format } from '../format';
+import type { Format } from './format';
 
 function printFormat(format: Format): string {
   return format.displayName ?? format.name;
 }
 
-export class FormatsSet<T extends Format = Format> extends Set<T> {
+export class Formats<T extends Format = Format> extends Set<T> {
   public toJSON(): string[] {
     return Array.from(this).map(printFormat);
   }
diff --git a/packages/core/src/ruleset/rulesetFunction.ts b/packages/core/src/ruleset/function.ts
similarity index 100%
rename from packages/core/src/ruleset/rulesetFunction.ts
rename to packages/core/src/ruleset/function.ts
diff --git a/packages/core/src/ruleset/index.ts b/packages/core/src/ruleset/index.ts
index 6a91d8fa..50addc0e 100644
--- a/packages/core/src/ruleset/index.ts
+++ b/packages/core/src/ruleset/index.ts
@@ -1,7 +1,7 @@
 export { assertValidRuleset, RulesetValidationError } from './validation/index';
-export { getDiagnosticSeverity } from './utils';
-export { createRulesetFunction, SchemaDefinition as RulesetFunctionSchemaDefinition } from './rulesetFunction';
+export { getDiagnosticSeverity } from './utils/severity';
+export { createRulesetFunction, SchemaDefinition as RulesetFunctionSchemaDefinition } from './function';
 export { Format } from './format';
 export { RulesetDefinition, RuleDefinition, ParserOptions, HumanReadableDiagnosticSeverity } from './types';
-export { Ruleset } from './ruleset';
-export { Rule } from './rule';
+export { Ruleset, StringifiedRuleset } from './ruleset';
+export { Rule, StringifiedRule } from './rule';
diff --git a/packages/core/src/ruleset/mergers/rules.ts b/packages/core/src/ruleset/mergers/rules.ts
index fd674773..5140a3e9 100644
--- a/packages/core/src/ruleset/mergers/rules.ts
+++ b/packages/core/src/ruleset/mergers/rules.ts
@@ -39,7 +39,10 @@ export function mergeRule(
       break;
     case 'object':
       if (existingRule !== void 0) {
-        Object.assign(existingRule, rule, { owner: existingRule.owner });
+        Object.assign(existingRule, rule, {
+          enabled: true,
+          owner: existingRule.owner,
+        });
       } else {
         assertValidRule(rule);
         return new Rule(name, rule, ruleset);
diff --git a/packages/core/src/ruleset/rule.ts b/packages/core/src/ruleset/rule.ts
index a97d1b03..d7a73608 100644
--- a/packages/core/src/ruleset/rule.ts
+++ b/packages/core/src/ruleset/rule.ts
@@ -13,9 +13,10 @@ import type {
   RuleDefinition,
   RulesetAliasesDefinition,
   RulesetScopedAliasDefinition,
+  Stringifable,
 } from './types';
 import { minimatch } from './utils/minimatch';
-import { FormatsSet } from './utils/formatsSet';
+import { Formats } from './formats';
 import { isSimpleAliasDefinition } from './utils/guards';
 
 const ALIAS = /^#([A-Za-z0-9_-]+)/;
@@ -25,7 +26,7 @@ export interface IRule {
   message: string | null;
   severity: DiagnosticSeverity;
   resolved: boolean;
-  formats: Set<Format> | null;
+  formats: Formats | null;
   enabled: boolean;
   recommended: boolean;
   documentationUrl: string | null;
@@ -35,7 +36,7 @@ export interface IRule {
 
 export type StringifiedRule = Omit<IRule, 'formats' | 'then'> & {
   name: string;
-  formats: FormatsSet | null;
+  formats: string[] | null;
   then: (Pick<IRuleThen, 'field'> & { function: string; functionOptions?: string })[];
   owner: number;
 };
@@ -45,7 +46,7 @@ export class Rule implements IRule {
   public message: string | null;
   #severity!: DiagnosticSeverity;
   public resolved: boolean;
-  public formats: FormatsSet | null;
+  public formats: Formats | null;
   #enabled: boolean;
   public recommended: boolean;
   public documentationUrl: string | null;
@@ -64,7 +65,7 @@ export class Rule implements IRule {
     this.documentationUrl = definition.documentationUrl ?? null;
     this.severity = definition.severity;
     this.resolved = definition.resolved !== false;
-    this.formats = 'formats' in definition ? new FormatsSet(definition.formats) : null;
+    this.formats = 'formats' in definition ? new Formats(definition.formats) : null;
     this.then = definition.then;
     this.given = definition.given;
   }
@@ -241,7 +242,7 @@ export class Rule implements IRule {
     return new Rule(this.name, this.definition, this.owner);
   }
 
-  public toJSON(): StringifiedRule {
+  public toJSON(): Stringifable<StringifiedRule> {
     return {
       name: this.name,
       recommended: this.recommended,
diff --git a/packages/core/src/ruleset/ruleset.ts b/packages/core/src/ruleset/ruleset.ts
index 0dbf80c0..87f11718 100644
--- a/packages/core/src/ruleset/ruleset.ts
+++ b/packages/core/src/ruleset/ruleset.ts
@@ -1,20 +1,21 @@
 import { dirname, relative } from '@stoplight/path';
+import { isPlainObject, extractPointerFromRef, extractSourceFromRef } from '@stoplight/json';
+import { DiagnosticSeverity } from '@stoplight/types';
 import { minimatch } from './utils/minimatch';
 import { Rule, StringifiedRule } from './rule';
-import {
+import type {
   FileRulesetSeverityDefinition,
   ParserOptions,
   RulesetAliasesDefinition,
   RulesetDefinition,
   RulesetOverridesDefinition,
+  Stringifable,
 } from './types';
-import { assertValidRuleset } from './validation';
+import { assertValidRuleset } from './validation/index';
 import { mergeRule } from './mergers/rules';
 import { DEFAULT_PARSER_OPTIONS, getDiagnosticSeverity } from '..';
 import { mergeRulesets } from './mergers/rulesets';
-import { isPlainObject, extractPointerFromRef, extractSourceFromRef } from '@stoplight/json';
-import { DiagnosticSeverity } from '@stoplight/types';
-import { FormatsSet } from './utils/formatsSet';
+import { Formats } from './formats';
 import { isSimpleAliasDefinition } from './utils/guards';
 
 const STACK_SYMBOL = Symbol('@stoplight/spectral/ruleset/#stack');
@@ -33,7 +34,7 @@ export type StringifiedRuleset = {
   extends: StringifiedRuleset[] | null;
   source: string | null;
   aliases: RulesetAliasesDefinition | null;
-  formats: FormatsSet | null;
+  formats: Formats | null;
   rules: Record<string, StringifiedRule>;
   overrides: RulesetOverridesDefinition | null;
   parserOptions: ParserOptions;
@@ -43,7 +44,7 @@ export class Ruleset {
   public readonly id = SEED++;
 
   protected readonly extends: Ruleset[] | null;
-  public readonly formats = new FormatsSet();
+  public readonly formats = new Formats();
   public readonly overrides: RulesetOverridesDefinition | null;
   public readonly aliases: RulesetAliasesDefinition | null;
   public readonly hasComplexAliases: boolean;
@@ -86,7 +87,7 @@ export class Ruleset {
               hasComplexAliases = true;
 
               const targets = value.targets.map(target => ({
-                formats: new FormatsSet(target.formats),
+                formats: new Formats(target.formats),
                 given: target.given,
               }));
 
@@ -286,10 +287,9 @@ export class Ruleset {
             this.formats.add(format);
           }
         } else if (rule.owner !== this) {
-          rule.formats =
-            rule.owner.definition.formats === void 0 ? null : new FormatsSet(rule.owner.definition.formats);
+          rule.formats = rule.owner.definition.formats === void 0 ? null : new Formats(rule.owner.definition.formats);
         } else if (this.definition.formats !== void 0) {
-          rule.formats = new FormatsSet(this.definition.formats);
+          rule.formats = new Formats(this.definition.formats);
         }
 
         if (this.definition.documentationUrl !== void 0 && rule.documentationUrl === null) {
@@ -309,10 +309,7 @@ export class Ruleset {
     return DEFAULT_RULESET_FILE.test(uri);
   }
 
-  public toJSON(): Omit<StringifiedRuleset, 'extends' | 'rules'> & {
-    extends: Ruleset['extends'];
-    rules: Ruleset['rules'];
-  } {
+  public toJSON(): Stringifable<StringifiedRuleset> {
     return {
       id: this.id,
       extends: this.extends,
diff --git a/packages/core/src/ruleset/types.ts b/packages/core/src/ruleset/types.ts
index f9882ee9..381a18d1 100644
--- a/packages/core/src/ruleset/types.ts
+++ b/packages/core/src/ruleset/types.ts
@@ -1,7 +1,7 @@
-import { DiagnosticSeverity } from '@stoplight/types';
-import { Format } from './format';
-import { RulesetFunction, RulesetFunctionWithValidator } from '../types';
-import { FormatsSet } from './utils/formatsSet';
+import type { DiagnosticSeverity } from '@stoplight/types';
+import type { Format } from './format';
+import type { RulesetFunction, RulesetFunctionWithValidator } from '../types';
+import type { Formats } from './formats';
 
 export type HumanReadableDiagnosticSeverity = 'error' | 'warn' | 'info' | 'hint' | 'off';
 export type FileRuleSeverityDefinition = DiagnosticSeverity | HumanReadableDiagnosticSeverity | boolean;
@@ -17,7 +17,7 @@ export type ParserOptions = {
 export type RuleDefinition = {
   type?: 'validation' | 'style';
 
-  formats?: Format[];
+  formats?: Formats | Format[];
 
   documentationUrl?: string;
 
@@ -81,7 +81,7 @@ export type RulesetOverridesDefinition = ReadonlyArray<{ files: string[] } & Rul
 export type RulesetScopedAliasDefinition = {
   description?: string;
   targets: {
-    formats: FormatsSet | Format[];
+    formats: Formats | Format[];
     given: string[];
   }[];
 };
@@ -92,7 +92,7 @@ export type RulesetDefinition = Readonly<
   {
     documentationUrl?: string;
     description?: string;
-    formats?: FormatsSet | Format[];
+    formats?: Formats | Format[];
     parserOptions?: Partial<ParserOptions>;
     overrides?: RulesetOverridesDefinition;
     aliases?: RulesetAliasesDefinition;
@@ -112,3 +112,10 @@ export type RulesetDefinition = Readonly<
       }
   >
 >;
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type Stringifable<T> = T extends object
+  ? {
+      [P in keyof T]: Stringifable<T[P]> | { toJSON?(): Stringifable<T[P]> };
+    }
+  : T;
diff --git a/packages/core/src/ruleset/utils/index.ts b/packages/core/src/ruleset/utils/index.ts
deleted file mode 100644
index f2f24e22..00000000
--- a/packages/core/src/ruleset/utils/index.ts
+++ /dev/null
@@ -1,1 +0,0 @@
-export * from './severity';
diff --git a/tsconfig.build.json b/tsconfig.build.json
index b5210fef..23d50a91 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -1,7 +1,9 @@
 {
   "include": [],
   "extends": "./tsconfig.json",
-  "composite": true,
+  "compilerOptions": {
+    "composite": true
+  },
   "references": [
     {
       "path": "./packages/cli/tsconfig.build.json"
diff --git a/yarn.lock b/yarn.lock
index f75cf7ec..c222a328 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2389,7 +2389,7 @@ __metadata:
     "@stoplight/spectral-parsers": "*"
     "@stoplight/spectral-ref-resolver": ^1.0.0
     "@stoplight/spectral-runtime": ^1.0.0
-    "@stoplight/types": 12.3.0
+    "@stoplight/types": 13.0.0
     "@stoplight/yaml": ^4.2.2
     "@types/json-schema": ^7.0.7
     "@types/minimatch": ^3.0.5
@@ -2398,7 +2398,6 @@ __metadata:
     ajv-errors: ~3.0.0
     ajv-formats: ~2.1.0
     blueimp-md5: 2.18.0
-    json-schema: 0.4.0
     jsonpath-plus: 6.0.1
     lodash: ~4.17.21
     lodash.topath: ^4.5.2
@@ -2559,7 +2558,7 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@stoplight/types@npm:12.3.0, @stoplight/types@npm:^12.0.0, @stoplight/types@npm:^12.3.0":
+"@stoplight/types@npm:12.3.0":
   version: 12.3.0
   resolution: "@stoplight/types@npm:12.3.0"
   dependencies:
@@ -2569,6 +2568,26 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@stoplight/types@npm:13.0.0":
+  version: 13.0.0
+  resolution: "@stoplight/types@npm:13.0.0"
+  dependencies:
+    "@types/json-schema": ^7.0.4
+    utility-types: ^3.10.0
+  checksum: 40a3995fe4f654b9761908eb41b4369d22241220e96aa0f3ace4541c160d995ddd52dee32fa574a87d7ba3f573befc40ca8708712f34e5f815162addb170f7f3
+  languageName: node
+  linkType: hard
+
+"@stoplight/types@npm:^12.0.0, @stoplight/types@npm:^12.3.0":
+  version: 12.5.0
+  resolution: "@stoplight/types@npm:12.5.0"
+  dependencies:
+    "@types/json-schema": ^7.0.4
+    utility-types: ^3.10.0
+  checksum: fe4a09df6e1c2f0cdb53f474b180cc7b8184e814e1ac4427d199642f10958335f597060530a908c0e5800ba2569d077afe124a51deaee466255ce942e1e03941
+  languageName: node
+  linkType: hard
+
 "@stoplight/yaml-ast-parser@npm:0.0.48":
   version: 0.0.48
   resolution: "@stoplight/yaml-ast-parser@npm:0.0.48"
@@ -3147,16 +3166,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"acorn@npm:^8.1.0, acorn@npm:^8.2.4, acorn@npm:^8.4.1":
-  version: 8.5.0
-  resolution: "acorn@npm:8.5.0"
-  bin:
-    acorn: bin/acorn
-  checksum: 2e4c1dbed3da327684863debf31d341bf8882c6893c506653872c00977eee45675feb9129255d6c74c88424d2b20d889ca6de5b39776e5e3cccfc756b3ca1da8
-  languageName: node
-  linkType: hard
-
-"acorn@npm:^8.7.0":
+"acorn@npm:^8.1.0, acorn@npm:^8.2.4, acorn@npm:^8.4.1, acorn@npm:^8.7.0":
   version: 8.7.0
   resolution: "acorn@npm:8.7.0"
   bin:
@@ -8093,7 +8103,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"json-schema@npm:0.4.0, json-schema@npm:^0.4.0":
+"json-schema@npm:^0.4.0":
   version: 0.4.0
   resolution: "json-schema@npm:0.4.0"
   checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72


Test patch:
diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/severity/off-redeclared.ts b/packages/core/src/ruleset/__tests__/__fixtures__/severity/off-redeclared.ts
new file mode 100644
index 00000000..b9c256a5
--- /dev/null
+++ b/packages/core/src/ruleset/__tests__/__fixtures__/severity/off-redeclared.ts
@@ -0,0 +1,15 @@
+import { RulesetDefinition } from '@stoplight/spectral-core';
+import shared from './shared';
+import { truthy } from '@stoplight/spectral-functions/src';
+
+export default {
+  extends: [[shared, 'off']],
+  rules: {
+    'overridable-rule': {
+      given: '$.foo',
+      then: {
+        function: truthy,
+      },
+    },
+  },
+} as RulesetDefinition;
diff --git a/packages/core/src/ruleset/__tests__/ruleset.test.ts b/packages/core/src/ruleset/__tests__/ruleset.test.ts
index bd23bd79..2fdc3fcc 100644
--- a/packages/core/src/ruleset/__tests__/ruleset.test.ts
+++ b/packages/core/src/ruleset/__tests__/ruleset.test.ts
@@ -9,8 +9,8 @@ import { print } from './__helpers__/print';
 import { RulesetValidationError } from '../validation/index';
 import { isPlainObject } from '@stoplight/json';
 import { Format } from '../format';
-import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
-import { FormatsSet } from '../utils/formatsSet';
+import type { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
+import { Formats } from '../formats';
 
 async function loadRuleset(mod: Promise<{ default: RulesetDefinition }>, source?: string): Promise<Ruleset> {
   return new Ruleset((await mod).default, { source });
@@ -68,6 +68,18 @@ describe('Ruleset', () => {
       expect(getEnabledRules(rules)).toEqual(['overridable-rule']);
     });
 
+    it('given ruleset with extends set to off, should disable all rules but explicitly redeclared', async () => {
+      const { rules } = await loadRuleset(import('./__fixtures__/severity/off-redeclared'));
+      expect(Object.keys(rules)).toEqual([
+        'description-matches-stoplight',
+        'title-matches-stoplight',
+        'contact-name-matches-stoplight',
+        'overridable-rule',
+      ]);
+
+      expect(getEnabledRules(rules)).toEqual(['overridable-rule']);
+    });
+
     it('given nested extends with severity set to off', async () => {
       const { rules } = await loadRuleset(import('./__fixtures__/severity/off-proxy'));
       expect(Object.keys(rules)).toEqual([
@@ -1378,24 +1390,22 @@ describe('Ruleset', () => {
           },
         });
 
-        expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft4]))).toStrictEqual(['$..id']);
-        expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft6]))).toStrictEqual(['$..$id']);
-        expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft7]))).toStrictEqual(['$..$id']);
-        expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft6, draft7]))).toStrictEqual([
-          '$..$id',
-        ]);
+        expect(ruleset.rules['valid-id'].getGivenForFormats(new Formats([draft4]))).toStrictEqual(['$..id']);
+        expect(ruleset.rules['valid-id'].getGivenForFormats(new Formats([draft6]))).toStrictEqual(['$..$id']);
+        expect(ruleset.rules['valid-id'].getGivenForFormats(new Formats([draft7]))).toStrictEqual(['$..$id']);
+        expect(ruleset.rules['valid-id'].getGivenForFormats(new Formats([draft6, draft7]))).toStrictEqual(['$..$id']);
 
-        expect(ruleset.rules['valid-parameter'].getGivenForFormats(new FormatsSet([oas2]))).toStrictEqual([
+        expect(ruleset.rules['valid-parameter'].getGivenForFormats(new Formats([oas2]))).toStrictEqual([
           '$.parameters[*]',
           '$.paths[*].parameters[?(@ && !@.$ref)]',
           '$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[?(@ && !@.$ref)]',
         ]);
-        expect(ruleset.rules['valid-parameter'].getGivenForFormats(new FormatsSet([oas3]))).toStrictEqual([
+        expect(ruleset.rules['valid-parameter'].getGivenForFormats(new Formats([oas3]))).toStrictEqual([
           '$.components.parameters[*]',
           '$.paths[*].parameters[?(@ && !@.$ref)]',
           '$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[?(@ && !@.$ref)]',
         ]);
-        expect(ruleset.rules['valid-parameter'].getGivenForFormats(new FormatsSet([oas2, oas3]))).toStrictEqual([
+        expect(ruleset.rules['valid-parameter'].getGivenForFormats(new Formats([oas2, oas3]))).toStrictEqual([
           '$.components.parameters[*]',
           '$.paths[*].parameters[?(@ && !@.$ref)]',
           '$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[?(@ && !@.$ref)]',
@@ -1442,7 +1452,7 @@ describe('Ruleset', () => {
           },
         });
 
-        expect(() => ruleset.rules['valid-header'].getGivenForFormats(new FormatsSet([oas3]))).toThrowError(
+        expect(() => ruleset.rules['valid-header'].getGivenForFormats(new Formats([oas3]))).toThrowError(
           ReferenceError(
             'Alias "HeaderObject" is circular. Resolution stack: HeaderObject -> HeaderObjects -> Components -> HeaderObject',
           ),
@@ -1479,8 +1489,8 @@ describe('Ruleset', () => {
           },
         });
 
-        expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft7]))).toStrictEqual([]);
-        expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([]))).toStrictEqual([]);
+        expect(ruleset.rules['valid-id'].getGivenForFormats(new Formats([draft7]))).toStrictEqual([]);
+        expect(ruleset.rules['valid-id'].getGivenForFormats(new Formats([]))).toStrictEqual([]);
       });
 
       it('should be serializable', () => {