Repository: stoplightio/spectral
Pull request #2345 (2022-11-21 12:39:57)

feat(core): support end-user extensions in the rule definitions
This change introduces a user extensions for rule definitions.

**Problem**
As a user of Spectral, we want to keep additional metadata close to the rule definitions. E.g. when this rule is introduced, who is responsible, etc.

**Solution**
As it's not possible to foresee what kind of metadata users might need, therefore introducing additional fields with a concrete specificity doesn't scale and will pollute the Rule interface.

The main idea behind this solution is to give Spectral users a way to define custom extensions similar to `x-` attributes in OpenAPI documents. In this change, the new property `extensions` is introduced in the rule type which can be used by users to define any user's payload inside.

Alternative idea for the property name: `metadata`.

**Checklist**

- [x] Tests added / updated
- [ ] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional information**
The new field is not supported by the `print` utility. As the type of the extensions properties is unknown, it might be challenging to print it via `treeify`. Since this utility is only used for testing purposes, it seems to be okay to ignore the new property in this utility function. The similar situation is with `toJSON()`.

pavelkornev: @P0lip should we adjust also the documentation? 
https://github.com/stoplightio/spectral/blob/develop/docs/guides/4a-rules.md
P0lip: > @P0lip should we adjust also the documentation?
> https://github.com/stoplightio/spectral/blob/develop/docs/guides/4a-rules.md

Up to you, I can do it later on. :)
pavelkornev: @P0lip please go ahead and merge as i don't have the write access to the `develop` branch and cannot merge myself.

Patch:
diff --git a/packages/core/src/ruleset/meta/rule.schema.json b/packages/core/src/ruleset/meta/rule.schema.json
index 342f84a5..48a0edaf 100644
--- a/packages/core/src/ruleset/meta/rule.schema.json
+++ b/packages/core/src/ruleset/meta/rule.schema.json
@@ -77,6 +77,9 @@
         "enum": ["style", "validation"],
         "type": "string",
         "errorMessage": "allowed types are \"style\" and \"validation\""
+      },
+      "extensions": {
+        "type": "object"
       }
     },
     "required": ["given", "then"],
diff --git a/packages/core/src/ruleset/rule.ts b/packages/core/src/ruleset/rule.ts
index 6ce9aa85..537eddc6 100644
--- a/packages/core/src/ruleset/rule.ts
+++ b/packages/core/src/ruleset/rule.ts
@@ -24,6 +24,7 @@ export interface IRule {
   documentationUrl: string | null;
   then: IRuleThen[];
   given: string[];
+  extensions: Record<string, unknown> | null;
 }
 
 type RuleJson = Omit<IRule, 'then'> & {
@@ -43,6 +44,7 @@ export class Rule implements IRule {
   #enabled: boolean;
   public recommended: boolean;
   public documentationUrl: string | null;
+  public extensions: Record<string, unknown> | null;
   #then!: IRuleThen[];
   #given!: string[];
 
@@ -61,6 +63,7 @@ export class Rule implements IRule {
     this.formats = 'formats' in definition ? new Formats(definition.formats) : null;
     this.then = definition.then;
     this.given = definition.given;
+    this.extensions = definition.extensions ?? null;
   }
 
   public overrides?: { rulesetSource: string; definition: Map<string, Map<string, DiagnosticSeverity | -1>> };
@@ -188,6 +191,7 @@ export class Rule implements IRule {
       })),
       given: Array.isArray(this.definition.given) ? this.definition.given : [this.definition.given],
       owner: this.owner.id,
+      extensions: this.extensions,
     };
   }
 }
diff --git a/packages/core/src/ruleset/types.ts b/packages/core/src/ruleset/types.ts
index e18e84b6..2b7a7c72 100644
--- a/packages/core/src/ruleset/types.ts
+++ b/packages/core/src/ruleset/types.ts
@@ -42,6 +42,9 @@ export type RuleDefinition = {
   resolved?: boolean;
 
   then: IRuleThen | IRuleThen[];
+
+  // User extensions/metadata point
+  extensions?: Record<string, unknown>;
 };
 
 export interface IRuleThen {


Test patch:
diff --git a/packages/core/src/ruleset/__tests__/ruleset.test.ts b/packages/core/src/ruleset/__tests__/ruleset.test.ts
index d509cf96..6dedf031 100644
--- a/packages/core/src/ruleset/__tests__/ruleset.test.ts
+++ b/packages/core/src/ruleset/__tests__/ruleset.test.ts
@@ -247,6 +247,7 @@ describe('Ruleset', () => {
             description: null,
             documentationUrl: null,
             enabled: true,
+            extensions: null,
             formats: null,
             given: ['$'],
             message: null,
@@ -322,6 +323,30 @@ describe('Ruleset', () => {
 `);
   });
 
+  it('should respect extensions', async () => {
+    const ruleset = {
+      rules: {
+        'foo-rule': {
+          given: '$',
+          then: {
+            function() {
+              return;
+            },
+          },
+          extensions: {
+            foo: 'bar',
+          },
+        },
+      },
+    };
+
+    const rulesetInstance = new Ruleset(ruleset);
+
+    expect(rulesetInstance.rules['foo-rule'].extensions).toEqual({
+      foo: 'bar',
+    });
+  });
+
   it('should include parserOptions', async () => {
     const { parserOptions } = await loadRuleset(import('./__fixtures__/parser-options-ruleset'));
 
@@ -581,6 +606,7 @@ describe('Ruleset', () => {
             description: null,
             documentationUrl: null,
             enabled: false,
+            extensions: null,
             formats: null,
             given: ['$.info.contact'],
             message: 'Contact name must contain Stoplight',
@@ -600,6 +626,7 @@ describe('Ruleset', () => {
             description: null,
             documentationUrl: null,
             enabled: true,
+            extensions: null,
             formats: null,
             given: ['$.info'],
             message: 'Description must contain Stoplight',
@@ -619,6 +646,7 @@ describe('Ruleset', () => {
             description: null,
             documentationUrl: null,
             enabled: true,
+            extensions: null,
             formats: null,
             given: ['$.info'],
             message: 'Title must contain Stoplight',
@@ -1144,6 +1172,7 @@ describe('Ruleset', () => {
             description: null,
             documentationUrl: null,
             enabled: true,
+            extensions: null,
             formats: null,
             given: ['#PathItem'],
             message: null,
@@ -1163,6 +1192,7 @@ describe('Ruleset', () => {
             description: null,
             documentationUrl: null,
             enabled: true,
+            extensions: null,
             formats: null,
             given: ['#Name', '#Description'],
             message: null,
@@ -1182,6 +1212,7 @@ describe('Ruleset', () => {
             description: null,
             documentationUrl: null,
             enabled: true,
+            extensions: null,
             formats: null,
             given: ['#Info.contact'],
             message: null,
@@ -1606,6 +1637,7 @@ describe('Ruleset', () => {
               description: null,
               documentationUrl: null,
               enabled: true,
+              extensions: null,
               formats: null,
               given: ['#Id'],
               message: null,
diff --git a/packages/ruleset-bundler/src/loader/__tests__/browser.test.ts b/packages/ruleset-bundler/src/loader/__tests__/browser.test.ts
index 052b0228..60e36f94 100644
--- a/packages/ruleset-bundler/src/loader/__tests__/browser.test.ts
+++ b/packages/ruleset-bundler/src/loader/__tests__/browser.test.ts
@@ -116,6 +116,7 @@ export default {
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -134,6 +135,7 @@ export default {
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -152,6 +154,7 @@ export default {
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -189,6 +192,7 @@ export default {
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -207,6 +211,7 @@ export default {
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -225,6 +230,7 @@ export default {
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
diff --git a/packages/ruleset-bundler/src/loader/__tests__/node.jest.test.ts b/packages/ruleset-bundler/src/loader/__tests__/node.jest.test.ts
index 3ec7ab3b..6284aa80 100644
--- a/packages/ruleset-bundler/src/loader/__tests__/node.jest.test.ts
+++ b/packages/ruleset-bundler/src/loader/__tests__/node.jest.test.ts
@@ -48,6 +48,7 @@ export {default} from '/-/lodash.uppercase@v4.3.0-Ghj8UDzvgbRFVHwnUM53/dist=es20
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -66,6 +67,7 @@ export {default} from '/-/lodash.uppercase@v4.3.0-Ghj8UDzvgbRFVHwnUM53/dist=es20
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -84,6 +86,7 @@ export {default} from '/-/lodash.uppercase@v4.3.0-Ghj8UDzvgbRFVHwnUM53/dist=es20
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -121,6 +124,7 @@ export {default} from '/-/lodash.uppercase@v4.3.0-Ghj8UDzvgbRFVHwnUM53/dist=es20
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -139,6 +143,7 @@ export {default} from '/-/lodash.uppercase@v4.3.0-Ghj8UDzvgbRFVHwnUM53/dist=es20
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,
@@ -157,6 +162,7 @@ export {default} from '/-/lodash.uppercase@v4.3.0-Ghj8UDzvgbRFVHwnUM53/dist=es20
           description: null,
           documentationUrl: null,
           enabled: true,
+          extensions: null,
           formats: null,
           given: ['$'],
           message: null,