Repository: stoplightio/spectral
Pull request #581 (2019-09-21 18:17:34)

feat: let multiple formats to be matched
**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No


**Additional context**

Why?
Recently introduced json-schema are not mutually exclusive, meaning json-schema, json-schema-loose and json-schema-draft 4 (or any other draft) formats can be matched at the same time.
At the moment, Spectral associated a single format with a document, the first one it could match.
This is not ideal for developer experience, as you'd need to specify a couple of formats in your ruleset.

We might want to adjust the format syntax a little bit, will post a possible proposal.
Either way, allowing multiple formats to be matched should be fine enough for now.



Patch:
diff --git a/docs/getting-started/rulesets.md b/docs/getting-started/rulesets.md
index 873dfac3..9052e852 100644
--- a/docs/getting-started/rulesets.md
+++ b/docs/getting-started/rulesets.md
@@ -61,7 +61,7 @@ You might find `resolved` useful if your rule requires access to $refs.
 
 ## Formats
 
-Formats are an optional way to specify which API description formats a rule, or ruleset, is applicable to. Currently Spectral supports these two formats:
+Formats are an optional way to specify which API description formats a rule, or ruleset, is applicable to. Currently Spectral supports these formats:
 
 - `oas2` (this is OpenAPI v2.0 - formerly known as Swagger)
 - `oas3` (this is OpenAPI v3.0)
diff --git a/src/resolved.ts b/src/resolved.ts
index 42058a7e..7e5c447c 100644
--- a/src/resolved.ts
+++ b/src/resolved.ts
@@ -9,14 +9,14 @@ export class Resolved {
   public resolved: unknown;
   public unresolved: unknown;
   public errors: IResolveError[];
-  public format?: string | null;
+  public formats?: string[] | null;
 
   constructor(public spec: IParsedResult, resolveResult: IResolveResult, public parsedMap: IParseMap) {
     this.refMap = resolveResult.refMap;
     this.resolved = resolveResult.result;
     this.unresolved = spec.parsed.data;
     this.errors = resolveResult.errors;
-    this.format = spec.format;
+    this.formats = spec.formats;
   }
 
   public getParsedForJsonPath(path: JsonPath) {
diff --git a/src/runner.ts b/src/runner.ts
index 941405f8..0798be95 100644
--- a/src/runner.ts
+++ b/src/runner.ts
@@ -5,6 +5,7 @@ const { JSONPath } = require('jsonpath-plus');
 import { lintNode } from './linter';
 import { getDiagnosticSeverity } from './rulesets/severity';
 import { FunctionCollection, IGivenNode, IRuleResult, IRunRule, RunRuleCollection } from './types';
+import { hasIntersectingElement } from './utils/';
 
 export const runRules = (
   resolved: Resolved,
@@ -21,11 +22,12 @@ export const runRules = (
 
     if (
       rule.formats !== void 0 &&
-      (resolved.format === null || (resolved.format !== void 0 && !rule.formats.includes(resolved.format)))
+      (resolved.formats === null ||
+        (resolved.formats !== void 0 && !hasIntersectingElement(rule.formats, resolved.formats)))
     )
       continue;
 
-    if (rule.severity !== undefined && getDiagnosticSeverity(rule.severity) === -1) {
+    if (rule.severity !== void 0 && getDiagnosticSeverity(rule.severity) === -1) {
       continue;
     }
 
diff --git a/src/spectral.ts b/src/spectral.ts
index c521711f..968f8ccc 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -102,9 +102,9 @@ export class Spectral {
       this._parsedMap,
     );
 
-    if (resolved.format === void 0) {
-      const foundFormat = Object.keys(this.formats).find(format => this.formats[format](resolved.resolved));
-      resolved.format = foundFormat === void 0 ? null : foundFormat;
+    if (resolved.formats === void 0) {
+      const foundFormats = Object.keys(this.formats).filter(format => this.formats[format](resolved.resolved));
+      resolved.formats = foundFormats.length === 0 ? null : foundFormats;
     }
 
     const validationResults = [
diff --git a/src/types/spectral.ts b/src/types/spectral.ts
index c85ac6fe..e903e733 100644
--- a/src/types/spectral.ts
+++ b/src/types/spectral.ts
@@ -56,7 +56,7 @@ export interface IParsedResult<R extends IParserResult = IParserResult<unknown,
   parsed: IParserResult;
   getLocationForJsonPath: GetLocationForJsonPath<R>;
   source?: string;
-  format?: string;
+  formats?: string[];
 }
 
 export type FormatLookup = (document: unknown) => boolean;
diff --git a/src/utils/hasIntersectingElement.ts b/src/utils/hasIntersectingElement.ts
new file mode 100644
index 00000000..5121a9b9
--- /dev/null
+++ b/src/utils/hasIntersectingElement.ts
@@ -0,0 +1,1 @@
+export const hasIntersectingElement = (left: unknown[], right: unknown[]) => left.some(item => right.includes(item));
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 00000000..e235cac3
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,2 @@
+export * from './hasIntersectingElement';
+export * from './isObject';


Test patch:
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index 68412ee7..9faf0a1a 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -366,7 +366,7 @@ describe('linter', () => {
     expect(result).toEqual([]);
   });
 
-  test('should prefer the first matched format', async () => {
+  test('should execute rules matching all found formats', async () => {
     spectral.registerFormat('foo-bar', obj => typeof obj === 'object' && obj !== null && 'foo-bar' in obj);
     spectral.registerFormat('baz', () => true);
 
@@ -399,10 +399,13 @@ describe('linter', () => {
       expect.objectContaining({
         code: 'rule1',
       }),
+      expect.objectContaining({
+        code: 'rule2',
+      }),
     ]);
   });
 
-  test('should not run any rule with defined formats if some formats are are registered but document format could not be associated', async () => {
+  test('should not run any rule with defined formats if some formats are registered but document format could not be associated', async () => {
     spectral.registerFormat('foo-bar', obj => typeof obj === 'object' && obj !== null && 'foo-bar' in obj);
 
     spectral.setRules({
diff --git a/src/utils/__tests__/hasIntersectingElement.spec.ts b/src/utils/__tests__/hasIntersectingElement.spec.ts
new file mode 100644
index 00000000..7e8aa49e
--- /dev/null
+++ b/src/utils/__tests__/hasIntersectingElement.spec.ts
@@ -0,0 +1,26 @@
+import { hasIntersectingElement } from '../hasIntersectingElement';
+
+describe('hasIntersectingElement util', () => {
+  it('returns true if intersecting element is found', () => {
+    expect(hasIntersectingElement([1, 0], [0])).toBe(true);
+    expect(hasIntersectingElement(['a', 'b', 'c', 'd'], ['c', 'a', 'b', 'c'])).toBe(true);
+    const obj = {};
+    expect(hasIntersectingElement([{}, obj], [obj])).toBe(true);
+    expect(hasIntersectingElement([NaN], [NaN])).toBe(true);
+  });
+
+  it('returns false if intersecting element cannot be found', () => {
+    expect(hasIntersectingElement([1, 0], [2])).toBe(false);
+    expect(hasIntersectingElement(['a', 'b', 'c', 'd'], ['e', 'f', 'g'])).toBe(false);
+    expect(hasIntersectingElement([{}], [{}])).toBe(false);
+  });
+
+  it('returns false if any set is empty', () => {
+    expect(hasIntersectingElement([0], [])).toBe(false);
+    expect(hasIntersectingElement([], [1])).toBe(false);
+  });
+
+  it('returns false for empty sets', () => {
+    expect(hasIntersectingElement([], [])).toBe(false);
+  });
+});