Repository: stoplightio/spectral
Pull request #1839 (2021-09-20 13:41:32)

chore(core): validate scoped aliases
Adds the validation piece of https://github.com/stoplightio/spectral/issues/1837

Thinking ahead I rewrote some $ids to have less work when tackling https://github.com/stoplightio/spectral/issues/1835

**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/meta/rule.schema.json b/packages/core/src/meta/rule.schema.json
index cdede785..5b40e90e 100644
--- a/packages/core/src/meta/rule.schema.json
+++ b/packages/core/src/meta/rule.schema.json
@@ -1,6 +1,6 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
-  "$id": "https://github.com/stoplightio/spectral/blob/develop/src/meta/rule.schema.json",
+  "$id": "@stoplight/spectral-core/meta/rule.schema",
   "$defs": {
     "Then": {
       "type": "object",
@@ -20,7 +20,7 @@
       ]
     },
     "Severity": {
-      "$ref": "shared.json#/$defs/Severity"
+      "$ref": "shared#severity"
     }
   },
   "oneOf": [
@@ -66,7 +66,7 @@
           "type": "array"
         },
         "formats": {
-          "$ref": "shared.json#/$defs/Formats"
+          "$ref": "shared#formats"
         },
         "then": {
           "anyOf": [
@@ -95,7 +95,7 @@
       }
     },
     {
-      "$ref": "shared.json#/$defs/HumanReadableSeverity"
+      "$ref": "shared#/$defs/HumanReadableSeverity"
     },
     {
       "type": "boolean"
diff --git a/packages/core/src/meta/ruleset.schema.json b/packages/core/src/meta/ruleset.schema.json
index 27f03a82..676f2f72 100644
--- a/packages/core/src/meta/ruleset.schema.json
+++ b/packages/core/src/meta/ruleset.schema.json
@@ -1,6 +1,6 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
-  "$id": "https://github.com/stoplightio/spectral/blob/develop/src/meta/ruleset.schema.json",
+  "$id": "@stoplight/spectral-core/meta/ruleset.schema",
   "type": "object",
   "additionalProperties": false,
   "properties": {
@@ -12,11 +12,11 @@
     "rules": {
       "type": "object",
       "additionalProperties": {
-        "$ref": "rule.schema.json#"
+        "$ref": "rule.schema#"
       }
     },
     "formats": {
-      "$ref": "shared.json#/$defs/Formats"
+      "$ref": "shared#formats"
     },
     "extends": {
       "oneOf": [
@@ -56,10 +56,10 @@
       "type": "object",
       "properties": {
         "duplicateKeys": {
-          "$ref": "shared.json#/$defs/Severity"
+          "$ref": "shared#severity"
         },
         "incompatibleValues": {
-          "$ref": "shared.json#/$defs/Severity"
+          "$ref": "shared#severity"
         }
       },
       "additionalProperties": false
@@ -91,7 +91,7 @@
             "rules": {
               "type": "object",
               "additionalProperties": {
-                "$ref": "shared.json#/$defs/Severity"
+                "$ref": "shared#severity"
               },
               "errorMessage": {
                 "enum": "must be a valid severity level"
@@ -130,7 +130,7 @@
               "type": "object",
               "properties": {
                 "formats": {
-                  "$ref": "#/properties/formats"
+                  "$ref": "shared#formats"
                 },
                 "extends": {
                   "$ref": "#/properties/extends"
@@ -171,9 +171,46 @@
         }
       },
       "additionalProperties": {
-        "type": "string",
-        "pattern": "^[$#]",
-        "errorMessage": "the value of an alias must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset"
+        "oneOf": [
+          {
+            "$id": "alias-value",
+            "type": "string",
+            "pattern": "^[$#]",
+            "errorMessage": {
+              "_": "the value of an alias must be a valid JSON Path expression, a reference to the existing Alias optionally paired with a JSON Path expression subset, or contain a valid set of targets",
+              "pattern": "the value of an alias must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset"
+            }
+          },
+          {
+            "type": "object",
+            "properties": {
+              "description": {
+                "type": "string"
+              },
+              "targets": {
+                "type": "array",
+                "minItems": 1,
+                "items": {
+                  "type": "object",
+                  "properties": {
+                    "formats": {
+                      "$ref": "shared#formats"
+                    },
+                    "given": {
+                      "$ref": "alias-value"
+                    }
+                  },
+                  "required": ["formats", "given"],
+                  "errorMessage": "a valid target must contain given and non-empty formats"
+                },
+                "errorMessage": {
+                  "minItems": "targets must have at least a single alias definition"
+                }
+              }
+            },
+            "required": ["targets"]
+          }
+        ]
       }
     }
   },
diff --git a/packages/core/src/meta/shared.json b/packages/core/src/meta/shared.json
index 09c51a95..8d9d8183 100644
--- a/packages/core/src/meta/shared.json
+++ b/packages/core/src/meta/shared.json
@@ -1,15 +1,17 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
-  "$id": "https://github.com/stoplightio/spectral/blob/develop/src/meta/shared.json",
+  "$id": "@stoplight/spectral-core/meta/shared",
   "$defs": {
     "Formats": {
+      "$anchor": "formats",
       "type": "array",
       "items": {
-        "$ref": "#/$defs/Format"
+        "$ref": "#format"
       },
       "errorMessage": "must be an array of formats"
     },
     "Format": {
+      "$anchor": "format",
       "spectral-runtime": "spectral-format",
       "errorMessage": "must be a valid format"
     },
@@ -20,6 +22,7 @@
       "enum": ["error", "warn", "info", "hint", "off"]
     },
     "Severity": {
+      "$anchor": "severity",
       "oneOf": [
         {
           "$ref": "#/$defs/DiagnosticSeverity"
diff --git a/packages/core/src/ruleset/validation.ts b/packages/core/src/ruleset/validation.ts
index 12f3d069..75023b23 100644
--- a/packages/core/src/ruleset/validation.ts
+++ b/packages/core/src/ruleset/validation.ts
@@ -11,7 +11,7 @@ import type { FileRuleDefinition, RuleDefinition, RulesetDefinition } from './ty
 
 const message = _`'spectral-message'`;
 
-const ajv = new Ajv({ allErrors: true, strict: true, strictRequired: false });
+const ajv = new Ajv({ allErrors: true, strict: true, strictRequired: false, keywords: ['$anchor'] });
 addFormats(ajv);
 addErrors(ajv);
 ajv.addKeyword({
@@ -48,7 +48,7 @@ export class RulesetValidationError extends Error {
 }
 
 const RULE_INSTANCE_PATH = /^\/rules\/[^/]+/;
-const GENERIC_INSTANCE_PATH = /^\/(?:extends|overrides(?:\/\d+\/extends)?)/;
+const GENERIC_INSTANCE_PATH = /^\/(?:aliases|extends|overrides(?:\/\d+\/extends)?)/;
 
 class RulesetAjvValidationError extends RulesetValidationError {
   constructor(public ruleset: Record<string, unknown>, public errors: ErrorObject[]) {


Test patch:
diff --git a/packages/core/src/ruleset/__tests__/validation.test.ts b/packages/core/src/ruleset/__tests__/validation.test.ts
index ac10b8b7..ac7a2a66 100644
--- a/packages/core/src/ruleset/__tests__/validation.test.ts
+++ b/packages/core/src/ruleset/__tests__/validation.test.ts
@@ -357,6 +357,21 @@ Error at #/rules/rule/formats/1: must be a valid format`,
       ).toThrow(new RulesetValidationError('Error at #/aliases: must be object'));
     });
 
+    it.each([null, 5, [], {}])('recognizes %p as an invalid type of aliases', alias => {
+      expect(
+        assertValidRuleset.bind(null, {
+          rules: {},
+          aliases: {
+            alias,
+          },
+        }),
+      ).toThrow(
+        new RulesetValidationError(
+          'Error at #/aliases/alias: the value of an alias must be a valid JSON Path expression, a reference to the existing Alias optionally paired with a JSON Path expression subset, or contain a valid set of targets',
+        ),
+      );
+    });
+
     it.each(['$', '#', '$bar', '9a', 'test!'])('given %s keyword used as a key of an alias, throws', key => {
       expect(
         assertValidRuleset.bind(null, {
@@ -386,6 +401,147 @@ Error at #/rules/rule/formats/1: must be a valid format`,
         ),
       );
     });
+
+    describe('given scoped aliases', () => {
+      it.each(['Info', 'Info-Description', 'Info_Description', 'Response404', 'errorMessage'])(
+        'recognizes %s as a valid key of an alias',
+        alias => {
+          expect(
+            assertValidRuleset.bind(null, {
+              rules: {},
+              aliases: {
+                [alias]: {
+                  targets: [
+                    {
+                      formats: [formatA],
+                      given: '$.definitions[*]',
+                    },
+                  ],
+                },
+              },
+            }),
+          ).not.toThrow();
+        },
+      );
+
+      it.each(['#Info', '#i', '#Info.contact', '#Info[*]'])('recognizes %s as a valid value of an alias', alias => {
+        expect(
+          assertValidRuleset.bind(null, {
+            rules: {},
+            aliases: {
+              alias: {
+                targets: [
+                  {
+                    formats: [formatA],
+                    given: alias,
+                  },
+                ],
+              },
+            },
+          }),
+        ).not.toThrow();
+      });
+
+      it.each([null, 1, {}, 'a'])('recognizes %p as invalid targets', targets => {
+        expect(
+          assertValidRuleset.bind(null, {
+            rules: {},
+            aliases: {
+              SchemaObject: {
+                targets,
+              },
+            },
+          }),
+        ).toThrow(new RulesetValidationError('Error at #/aliases/SchemaObject/targets: must be array'));
+      });
+
+      it('demands some target', () => {
+        expect(
+          assertValidRuleset.bind(null, {
+            rules: {},
+            aliases: {
+              SchemaObject: {
+                targets: [],
+              },
+            },
+          }),
+        ).toThrow(
+          new RulesetValidationError(
+            'Error at #/aliases/SchemaObject/targets: targets must have at least a single alias definition',
+          ),
+        );
+      });
+
+      it.each([{}, { formats: [] }, { given: '$' }])('demands given & formats to be present', targets => {
+        expect(
+          assertValidRuleset.bind(null, {
+            rules: {},
+            aliases: {
+              SchemaObject: {
+                targets: [targets],
+              },
+            },
+          }),
+        ).toThrow(
+          new RulesetValidationError(
+            'Error at #/aliases/SchemaObject/targets/0: a valid target must contain given and non-empty formats',
+          ),
+        );
+      });
+
+      it('recognizes invalid formats', () => {
+        expect(
+          assertValidRuleset.bind(null, {
+            rules: {},
+            aliases: {
+              SchemaObject: {
+                targets: [
+                  {
+                    formats: [2],
+                    given: '$.definitions[*]',
+                  },
+                  {
+                    formats: [formatA, 'formatB'],
+                    given: '$.components.schemas[*]',
+                  },
+                ],
+              },
+            },
+          }),
+        ).toThrow(
+          new RulesetValidationError(
+            `Error at #/aliases/SchemaObject/targets/0/formats/0: must be a valid format
+Error at #/aliases/SchemaObject/targets/1/formats/1: must be a valid format`,
+          ),
+        );
+      });
+
+      it('recognizes invalid given', () => {
+        expect(
+          assertValidRuleset.bind(null, {
+            rules: {},
+            aliases: {
+              SchemaObject: {
+                targets: [
+                  {
+                    formats: [formatA],
+                    given: '#.definitions[*]',
+                  },
+                  {
+                    formats: [formatA, formatB],
+                    given: '!.components.schemas[*]',
+                  },
+                ],
+              },
+            },
+          }),
+        ).toThrow(
+          new RulesetValidationError(
+            `Error at #/aliases/SchemaObject/targets/1/given: the value of an alias must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset`,
+          ),
+        );
+      });
+    });
   });
 
   describe('then validation', () => {