Repository: stoplightio/spectral
Pull request #1747 (2021-07-12 20:08:13)

feat(core): serializable rulesets
For https://github.com/stoplightio/platform-internal/pull/7085

**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 4092b160..cebfadac 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -28,7 +28,7 @@
     "@stoplight/path": "1.3.2",
     "@stoplight/spectral-parsers": "^1.0.0",
     "@stoplight/spectral-ref-resolver": "*",
-    "@stoplight/spectral-runtime": "*",
+    "@stoplight/spectral-runtime": "^1.0.0",
     "@stoplight/types": "12.3.0",
     "ajv": "~8.6.0",
     "ajv-errors": "~3.0.0",
diff --git a/packages/core/src/ruleset/rule/rule.ts b/packages/core/src/ruleset/rule/rule.ts
index c749d787..c371abbf 100644
--- a/packages/core/src/ruleset/rule/rule.ts
+++ b/packages/core/src/ruleset/rule/rule.ts
@@ -10,6 +10,7 @@ import { Ruleset } from '../ruleset';
 import { Format } from '../format';
 import { HumanReadableDiagnosticSeverity, IRuleThen, RuleDefinition } from '../types';
 import { minimatch } from '../utils/minimatch';
+import { printValue } from '@stoplight/spectral-runtime';
 
 const ALIAS = /^#([A-Za-z0-9_-]+)/;
 
@@ -26,6 +27,13 @@ export interface IRule {
   given: string[];
 }
 
+export type StringifiedRule = Omit<IRule, 'formats' | 'then'> & {
+  name: string;
+  formats: string[] | null;
+  then: (Pick<IRuleThen, 'field'> & { function: string; functionOptions?: string })[];
+  owner: number;
+};
+
 export class Rule implements IRule {
   public description: string | null;
   public message: string | null;
@@ -218,6 +226,27 @@ export class Rule implements IRule {
       };
     }
   }
+
+  public toJSON(): StringifiedRule {
+    return {
+      name: this.name,
+      recommended: this.recommended,
+      enabled: this.enabled,
+      description: this.description,
+      message: this.message,
+      documentationUrl: this.documentationUrl,
+      severity: this.severity,
+      resolved: this.resolved,
+      formats: this.formats === null ? null : Array.from(this.formats).map(fn => fn.displayName ?? fn.name),
+      then: this.then.map(then => ({
+        ...then.function,
+        function: then.function.name,
+        ...('functionOptions' in then ? { functionOptions: printValue(then.functionOptions) } : null),
+      })),
+      given: this.#given,
+      owner: this.owner.id,
+    };
+  }
 }
 
 function stub(): void {
diff --git a/packages/core/src/ruleset/ruleset.ts b/packages/core/src/ruleset/ruleset.ts
index a4fc1560..117a4e77 100644
--- a/packages/core/src/ruleset/ruleset.ts
+++ b/packages/core/src/ruleset/ruleset.ts
@@ -1,6 +1,6 @@
 import { dirname, relative } from '@stoplight/path';
 import { minimatch } from './utils/minimatch';
-import { Rule } from './rule/rule';
+import { Rule, StringifiedRule } from './rule/rule';
 import {
   FileRulesetSeverityDefinition,
   ParserOptions,
@@ -25,8 +25,23 @@ type RulesetContext = {
   readonly [STACK_SYMBOL]?: Map<RulesetDefinition, Ruleset>;
 };
 
+let SEED = 1;
+
+export type StringifiedRuleset = {
+  id: number;
+  extends: StringifiedRuleset[] | null;
+  source: string | null;
+  aliases: RulesetAliasesDefinition | null;
+  formats: string[] | null;
+  rules: Record<string, StringifiedRule>;
+  overrides: RulesetOverridesDefinition | null;
+  parserOptions: ParserOptions;
+};
+
 export class Ruleset {
-  protected extends!: Ruleset[];
+  public readonly id = SEED++;
+
+  protected readonly extends: Ruleset[] | null;
   public readonly formats = new Set<Format>();
   public readonly overrides: RulesetOverridesDefinition | null;
   public readonly aliases: RulesetAliasesDefinition | null;
@@ -84,7 +99,7 @@ export class Ruleset {
             },
             [],
           )
-        : [];
+        : null;
 
     if (stack.size === 1 && definition.overrides) {
       this.overrides = definition.overrides;
@@ -100,9 +115,11 @@ export class Ruleset {
       }
     }
 
-    for (const { formats } of this.extends) {
-      for (const format of formats) {
-        this.formats.add(format);
+    if (Array.isArray(this.extends)) {
+      for (const { formats } of this.extends) {
+        for (const format of formats) {
+          this.formats.add(format);
+        }
       }
     }
 
@@ -220,7 +237,7 @@ export class Ruleset {
   #getRules(): Record<string, Rule> {
     const rules: Record<string, Rule> = {};
 
-    if (this.extends.length > 0) {
+    if (this.extends !== null && this.extends.length > 0) {
       for (const extendedRuleset of this.extends) {
         if (extendedRuleset === this) continue;
         for (const rule of Object.values(extendedRuleset.rules)) {
@@ -265,4 +282,20 @@ export class Ruleset {
   public static isDefaultRulesetFile(uri: string): boolean {
     return DEFAULT_RULESET_FILE.test(uri);
   }
+
+  public toJSON(): Omit<StringifiedRuleset, 'extends' | 'rules'> & {
+    extends: Ruleset['extends'];
+    rules: Ruleset['rules'];
+  } {
+    return {
+      id: this.id,
+      extends: this.extends,
+      source: this.source,
+      aliases: this.aliases,
+      formats: this.formats.size === 0 ? null : Array.from(this.formats).map(fn => fn.displayName ?? fn.name),
+      rules: this.rules,
+      overrides: this.overrides,
+      parserOptions: this.parserOptions,
+    };
+  }
 }
diff --git a/yarn.lock b/yarn.lock
index 6eda9dea..0c600e23 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2399,9 +2399,9 @@ ajv@^8.0.0, ajv@^8.0.1:
     uri-js "^4.2.2"
 
 ajv@^8.6.0, ajv@~8.6.0:
-  version "8.6.0"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.0.tgz#60cc45d9c46a477d80d92c48076d972c342e5720"
-  integrity sha512-cnUG4NSBiM4YFBxgZIj/In3/6KX+rQ2l2YPRVcvAMQGWEPKuXoPIhxzwqh31jA3IPbI4qEOp/5ILI4ynioXsGQ==
+  version "8.6.1"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.1.tgz#ae65764bf1edde8cd861281cda5057852364a295"
+  integrity sha512-42VLtQUOLefAvKFAQIxIZDaThq6om/PrfP0CYk3/vn+y4BMNkKnbli8ON2QCiHov4KkzOSJ/xSoBJdayiiYvVQ==
   dependencies:
     fast-deep-equal "^3.1.1"
     json-schema-traverse "^1.0.0"


Test patch:
diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/overrides/serializing.ts b/packages/core/src/ruleset/__tests__/__fixtures__/overrides/serializing.ts
new file mode 100644
index 00000000..534ce48a
--- /dev/null
+++ b/packages/core/src/ruleset/__tests__/__fixtures__/overrides/serializing.ts
@@ -0,0 +1,34 @@
+import _base from './_base';
+import { RulesetDefinition } from '../../../types';
+
+export { ruleset as default };
+
+const ruleset: RulesetDefinition = {
+  rules: {
+    ..._base.rules,
+  },
+  overrides: [
+    {
+      files: ['legacy/**/*.json'],
+      rules: {
+        'description-matches-stoplight': 'off',
+        'title-matches-stoplight': 'warn',
+        'contact-name-matches-stoplight': true,
+      },
+    },
+    {
+      files: ['v2/**/*.json'],
+      rules: {
+        'description-matches-stoplight': 'error',
+        'title-matches-stoplight': 'hint',
+      },
+    },
+    {
+      files: ['**/*.json'],
+      rules: {
+        'description-matches-stoplight': 'info',
+        'title-matches-stoplight': 'off',
+      },
+    },
+  ],
+};
diff --git a/packages/core/src/ruleset/__tests__/ruleset.test.ts b/packages/core/src/ruleset/__tests__/ruleset.test.ts
index db9ed0d9..523387ce 100644
--- a/packages/core/src/ruleset/__tests__/ruleset.test.ts
+++ b/packages/core/src/ruleset/__tests__/ruleset.test.ts
@@ -19,21 +19,21 @@ describe('Ruleset', () => {
     }
 
     it('given ruleset with extends set to recommended, should enable recommended rules', async () => {
-      const { rules } = await loadRuleset(import('./__fixtures__/severity/recommended'));
-      expect(Object.keys(rules)).toEqual([
+      const ruleset = await loadRuleset(import('./__fixtures__/severity/recommended'));
+      expect(Object.keys(ruleset.rules)).toEqual([
         'description-matches-stoplight',
         'title-matches-stoplight',
         'contact-name-matches-stoplight',
         'overridable-rule',
       ]);
 
-      expect(getEnabledRules(rules)).toEqual([
+      expect(getEnabledRules(ruleset.rules)).toEqual([
         'description-matches-stoplight',
         'title-matches-stoplight',
         'overridable-rule',
       ]);
 
-      expect(rules).toStrictEqual((await loadRuleset(import('./__fixtures__/severity/implicit'))).rules);
+      expect(print(ruleset)).toEqual(print(await loadRuleset(import('./__fixtures__/severity/implicit'))));
     });
 
     it('given ruleset with extends set to all, should enable all rules but explicitly disabled', async () => {
@@ -203,6 +203,41 @@ describe('Ruleset', () => {
       └─ severity: 1
 `);
     });
+
+    it('should be serializable', async () => {
+      expect(JSON.parse(JSON.stringify(await loadRuleset(import('./__fixtures__/circularity/direct'))))).toEqual({
+        id: expect.any(Number),
+        source: null,
+        aliases: null,
+        extends: [],
+        formats: null,
+        overrides: null,
+        parserOptions: {
+          duplicateKeys: DiagnosticSeverity.Error,
+          incompatibleValues: DiagnosticSeverity.Error,
+        },
+        rules: {
+          'foo-rule': {
+            description: null,
+            documentationUrl: null,
+            enabled: true,
+            formats: null,
+            given: ['$'],
+            message: null,
+            name: 'foo-rule',
+            owner: expect.any(Number),
+            recommended: true,
+            resolved: true,
+            severity: 1,
+            then: [
+              {
+                function: 'falsy',
+              },
+            ],
+          },
+        },
+      });
+    });
   });
 
   describe('error handling', () => {
@@ -474,6 +509,105 @@ describe('Ruleset', () => {
 `);
     });
 
+    it('should be serializable', async () => {
+      const ruleset = await loadRuleset(import('./__fixtures__/overrides/serializing'), path.join(cwd, 'serializing'));
+
+      expect(JSON.parse(JSON.stringify(ruleset))).toEqual({
+        id: expect.any(Number),
+        source: path.join(cwd, 'serializing'),
+        aliases: null,
+        extends: null,
+        formats: null,
+        overrides: [
+          {
+            files: ['legacy/**/*.json'],
+            rules: {
+              'contact-name-matches-stoplight': true,
+              'description-matches-stoplight': 'off',
+              'title-matches-stoplight': 'warn',
+            },
+          },
+          {
+            files: ['v2/**/*.json'],
+            rules: {
+              'description-matches-stoplight': 'error',
+              'title-matches-stoplight': 'hint',
+            },
+          },
+          {
+            files: ['**/*.json'],
+            rules: {
+              'description-matches-stoplight': 'info',
+              'title-matches-stoplight': 'off',
+            },
+          },
+        ],
+        parserOptions: {
+          duplicateKeys: DiagnosticSeverity.Error,
+          incompatibleValues: DiagnosticSeverity.Error,
+        },
+        rules: {
+          'contact-name-matches-stoplight': {
+            description: null,
+            documentationUrl: null,
+            enabled: false,
+            formats: null,
+            given: ['$.info.contact'],
+            message: 'Contact name must contain Stoplight',
+            name: 'contact-name-matches-stoplight',
+            owner: expect.any(Number),
+            recommended: false,
+            resolved: true,
+            severity: DiagnosticSeverity.Warning,
+            then: [
+              {
+                function: 'pattern',
+                functionOptions: 'Object{}',
+              },
+            ],
+          },
+          'description-matches-stoplight': {
+            description: null,
+            documentationUrl: null,
+            enabled: true,
+            formats: null,
+            given: ['$.info'],
+            message: 'Description must contain Stoplight',
+            name: 'description-matches-stoplight',
+            owner: expect.any(Number),
+            recommended: true,
+            resolved: true,
+            severity: DiagnosticSeverity.Error,
+            then: [
+              {
+                function: 'pattern',
+                functionOptions: 'Object{}',
+              },
+            ],
+          },
+          'title-matches-stoplight': {
+            description: null,
+            documentationUrl: null,
+            enabled: true,
+            formats: null,
+            given: ['$.info'],
+            message: 'Title must contain Stoplight',
+            name: 'title-matches-stoplight',
+            owner: expect.any(Number),
+            recommended: true,
+            resolved: true,
+            severity: DiagnosticSeverity.Warning,
+            then: [
+              {
+                function: 'pattern',
+                functionOptions: 'Object{}',
+              },
+            ],
+          },
+        },
+      });
+    });
+
     it('should support new rule definitions', async () => {
       const ruleset = await loadRuleset(
         import('./__fixtures__/overrides/new-definitions'),
@@ -922,6 +1056,120 @@ describe('Ruleset', () => {
 `);
     });
 
+    it('should be serializable', () => {
+      expect(
+        JSON.parse(
+          JSON.stringify(
+            new Ruleset({
+              aliases: {
+                Info: '$.info',
+                PathItem: '$.paths[*][*]',
+                Description: '$..description',
+                Name: '$..name',
+              },
+
+              rules: {
+                'valid-path': {
+                  given: '#PathItem',
+                  then: {
+                    function: truthy,
+                  },
+                },
+
+                'valid-name-and-description': {
+                  given: ['#Name', '#Description'],
+                  then: {
+                    function: truthy,
+                  },
+                },
+
+                'valid-contact': {
+                  given: '#Info.contact',
+                  then: {
+                    function: truthy,
+                  },
+                },
+              },
+            }),
+          ),
+        ),
+      ).toEqual({
+        extends: null,
+        source: null,
+        id: expect.any(Number),
+        formats: null,
+        overrides: null,
+        parserOptions: {
+          duplicateKeys: DiagnosticSeverity.Error,
+          incompatibleValues: DiagnosticSeverity.Error,
+        },
+        aliases: {
+          Info: '$.info',
+          PathItem: '$.paths[*][*]',
+          Description: '$..description',
+          Name: '$..name',
+        },
+        rules: {
+          'valid-path': {
+            description: null,
+            documentationUrl: null,
+            enabled: true,
+            formats: null,
+            given: ['#PathItem'],
+            message: null,
+            name: 'valid-path',
+            owner: expect.any(Number),
+            recommended: true,
+            resolved: true,
+            severity: DiagnosticSeverity.Warning,
+            then: [
+              {
+                function: 'truthy',
+              },
+            ],
+          },
+
+          'valid-name-and-description': {
+            description: null,
+            documentationUrl: null,
+            enabled: true,
+            formats: null,
+            given: ['#Name', '#Description'],
+            message: null,
+            name: 'valid-name-and-description',
+            owner: expect.any(Number),
+            recommended: true,
+            resolved: true,
+            severity: DiagnosticSeverity.Warning,
+            then: [
+              {
+                function: 'truthy',
+              },
+            ],
+          },
+
+          'valid-contact': {
+            description: null,
+            documentationUrl: null,
+            enabled: true,
+            formats: null,
+            given: ['#Info.contact'],
+            message: null,
+            name: 'valid-contact',
+            owner: expect.any(Number),
+            recommended: true,
+            resolved: true,
+            severity: DiagnosticSeverity.Warning,
+            then: [
+              {
+                function: 'truthy',
+              },
+            ],
+          },
+        },
+      });
+    });
+
     it('should resolve nested aliases', () => {
       expect(
         print(