Repository: stoplightio/spectral
Pull request #799 (2019-11-21 01:54:11)

feat: arrayish given
**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

Currently we only allow a single JSON path expression to be provided at a time for each rule.
That limitation may lead to a situation where a complex json path expression needs to be provided in order to match all intended properties. In most cases, those expressions could be split into 2-3 that would be much simpler.


jerzyn: would this mean given can be a single value or an array? Or how do you envision that?
i.e. in JSON-Schema terms:
```
given:
  oneOf:
    - type: string
    - type: array
      items:
        type: string
```
P0lip: @jerzyn 
That's right. I even used exactly the same schema in ruleset JSON schema :sweat_smile: 
https://github.com/stoplightio/spectral/pull/799/files#diff-1eb67ba0248f0910e2cd0525f69731dcR69-R79
jerzyn: i should do these examples professionally ;)

Patch:
diff --git a/.editorconfig b/.editorconfig
index 63d409cd..f30edac1 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -3,7 +3,7 @@ root = true
 [*]
 charset = utf-8
 
-[*.{ts,js,yaml}]
+[*.{ts,js,yaml,scenario}]
 indent_style = space
 indent_size = 2
 
diff --git a/docs/getting-started/rulesets.md b/docs/getting-started/rulesets.md
index 7b76b3b3..3bf2ca7b 100644
--- a/docs/getting-started/rulesets.md
+++ b/docs/getting-started/rulesets.md
@@ -120,9 +120,15 @@ Now all the rules in this ruleset will only be applied if the specified format i
 
 Custom formats can be registered via the [JS API](../guides/javascript.md), but the CLI is limited to using the predefined ones.
 
+## Given
+
+The `given` keyword is, besides `then`, the only required property on each rule definition. 
+It can be any valid JSONPath expression or an array of JSONPath expressions.
+[JSONPath Online Evaluator](http://jsonpath.com/) is a helpful tool to determine what `given` path you want.
+
 ## Severity
 
-The `severity` keyword is optional and can be `error`, `warn`, `info`, or `hint`.
+The `severity` keyword is optional and can be `error`, `warn`, `info`, or `hint`. The default value is `warn`.
 
 ## Then
 
diff --git a/src/linter.ts b/src/linter.ts
index 7d839a44..b0724445 100644
--- a/src/linter.ts
+++ b/src/linter.ts
@@ -68,7 +68,7 @@ export const lintNode = (
     });
   }
 
-  let results: IRuleResult[] = [];
+  const results: IRuleResult[] = [];
 
   for (const target of targets) {
     const targetPath = givenPath.concat(target.path);
@@ -88,8 +88,8 @@ export const lintNode = (
         },
       ) || [];
 
-    results = results.concat(
-      targetResults.map<IRuleResult>(result => {
+    results.push(
+      ...targetResults.map<IRuleResult>(result => {
         const escapedJsonPath = (result.path || targetPath).map(segment => decodePointerFragment(String(segment)));
         const path = getClosestJsonPath(
           rule.resolved === false ? resolved.unresolved : resolved.resolved,
diff --git a/src/meta/rule.schema.json b/src/meta/rule.schema.json
index d4a3043f..5dbd7612 100644
--- a/src/meta/rule.schema.json
+++ b/src/meta/rule.schema.json
@@ -66,7 +66,17 @@
                     "type": "boolean"
                 },
                 "given": {
-                    "type": "string"
+                    "oneOf": [
+                        {
+                            "type": "string"
+                        },
+                        {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        }
+                    ]
                 },
                 "resolved": {
                     "type": "boolean"
diff --git a/src/runner.ts b/src/runner.ts
index 508591f1..bc9d658e 100644
--- a/src/runner.ts
+++ b/src/runner.ts
@@ -1,8 +1,7 @@
-import { Resolved } from './resolved';
-
 const { JSONPath } = require('jsonpath-plus');
 
 import { lintNode } from './linter';
+import { Resolved } from './resolved';
 import { getDiagnosticSeverity } from './rulesets/severity';
 import { FunctionCollection, IGivenNode, IRule, IRuleResult, IRunRule, RunRuleCollection } from './types';
 import { hasIntersectingElement } from './utils/';
@@ -26,8 +25,9 @@ export const runRules = (
       rule.formats !== void 0 &&
       (resolved.formats === null ||
         (resolved.formats !== void 0 && !hasIntersectingElement(rule.formats, resolved.formats)))
-    )
+    ) {
       continue;
+    }
 
     if (!isRuleEnabled(rule)) {
       continue;
@@ -47,48 +47,66 @@ const runRule = (resolved: Resolved, rule: IRunRule, functions: FunctionCollecti
   const target = rule.resolved === false ? resolved.unresolved : resolved.resolved;
 
   const results: IRuleResult[] = [];
-  const nodes: IGivenNode[] = [];
 
-  // don't have to spend time running jsonpath if given is $ - can just use the root object
-  if (rule.given && rule.given !== '$') {
-    try {
+  for (const given of Array.isArray(rule.given) ? rule.given : [rule.given]) {
+    // don't have to spend time running jsonpath if given is $ - can just use the root object
+    if (given === '$') {
+      lint(
+        {
+          path: ['$'],
+          value: target,
+        },
+        resolved,
+        rule,
+        functions,
+        results,
+      );
+    } else {
       JSONPath({
-        path: rule.given,
+        path: given,
         json: target,
         resultType: 'all',
         callback: (result: any) => {
-          nodes.push({
-            path: JSONPath.toPathArray(result.path),
-            value: result.value,
-          });
+          lint(
+            {
+              path: JSONPath.toPathArray(result.path),
+              value: result.value,
+            },
+            resolved,
+            rule,
+            functions,
+            results,
+          );
         },
       });
-    } catch (e) {
-      console.error(e);
     }
-  } else {
-    nodes.push({
-      path: ['$'],
-      value: target,
-    });
   }
 
-  for (const node of nodes) {
-    try {
-      const thens = Array.isArray(rule.then) ? rule.then : [rule.then];
-      for (const then of thens) {
-        const func = functions[then.function];
-        if (!func) {
-          console.warn(`Function ${then.function} not found. Called by rule ${rule.name}.`);
-          continue;
-        }
-
-        results.push(...lintNode(node, rule, then, func, resolved));
+  return results;
+};
+
+function lint(
+  node: IGivenNode,
+  resolved: Resolved,
+  rule: IRunRule,
+  functions: FunctionCollection,
+  results: IRuleResult[],
+): void {
+  try {
+    for (const then of Array.isArray(rule.then) ? rule.then : [rule.then]) {
+      const func = functions[then.function];
+      if (!func) {
+        console.warn(`Function ${then.function} not found. Called by rule ${rule.name}.`);
+        continue;
+      }
+
+      const validationResults = lintNode(node, rule, then, func, resolved);
+
+      if (validationResults.length > 0) {
+        results.push(...validationResults);
       }
-    } catch (e) {
-      console.warn(`Encountered error when running rule '${rule.name}' on node at path '${node.path}':\n${e}`);
     }
+  } catch (e) {
+    console.warn(`Encountered error when running rule '${rule.name}' on node at path '${node.path}':\n${e}`);
   }
-
-  return results;
-};
+}
diff --git a/src/types/rule.ts b/src/types/rule.ts
index c0625ba4..5e672488 100644
--- a/src/types/rule.ts
+++ b/src/types/rule.ts
@@ -25,7 +25,7 @@ export interface IRule<T = string, O = any> {
   recommended?: boolean;
 
   // Filter the target down to a subset[] with a JSON path
-  given: string;
+  given: string | string[];
 
   // If false, rule will operate on original (unresolved) data
   // If undefined or true, resolved data will be supplied


Test patch:
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index cb322326..90125ca3 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -700,6 +700,26 @@ responses:: !!foo
         expect(fakeLintingFunction.mock.calls[0][2].given).toEqual(['responses']);
         expect(fakeLintingFunction.mock.calls[0][3].given).toEqual(target.responses);
       });
+
+      test('given array of paths, should pass each given path through to lint function', async () => {
+        spectral.setRules({
+          example: {
+            message: '',
+            given: ['$.responses', '$..200'],
+            then: {
+              function: fnName,
+            },
+          },
+        });
+
+        await spectral.run(target);
+
+        expect(fakeLintingFunction).toHaveBeenCalledTimes(2);
+        expect(fakeLintingFunction.mock.calls[0][2].given).toEqual(['responses']);
+        expect(fakeLintingFunction.mock.calls[0][3].given).toEqual(target.responses);
+        expect(fakeLintingFunction.mock.calls[1][2].given).toEqual(['responses', '200']);
+        expect(fakeLintingFunction.mock.calls[1][3].given).toEqual(target.responses['200']);
+      });
     });
 
     describe('when given path is not set', () => {
diff --git a/test-harness/scenarios/rules-matching-multiple-places.scenario b/test-harness/scenarios/rules-matching-multiple-places.scenario
new file mode 100644
index 00000000..11d77f8e
--- /dev/null
+++ b/test-harness/scenarios/rules-matching-multiple-places.scenario
@@ -0,0 +1,40 @@
+====test====
+Rules matching multiple properties in the document
+====document====
+schemas:
+  user:
+    type: object
+    properties:
+      name:
+        type: number
+      age:
+        type: number
+      occupation:
+        type: boolean
+  address:
+====command====
+{bin} lint {document} -r {asset:ruleset}
+====asset:ruleset====
+rules:
+  valid-user-properties:
+    severity: error
+    given: [$.schemas.user.properties.name, $.schemas.user.properties.occupation]
+    then:
+      field: type
+      function: pattern
+      functionOptions:
+        match: /^string$/
+  require-user-and-address:
+    severity: error
+    given: [$.schemas.user, $.schemas.address]
+    then:
+      function: truthy
+====status====
+1
+====stdout====
+{document}
+  6:15  error  valid-user-properties     must match the pattern '/^string$/'
+ 10:15  error  valid-user-properties     must match the pattern '/^string$/'
+ 11:11  error  require-user-and-address  schemas.address is not truthy
+
+✖ 3 problems (3 errors, 0 warnings, 0 infos, 0 hints)