Repository: stoplightio/spectral
Pull request #856 (2019-12-16 00:36:24)

feat: dedupe results by unique fingerprint
- [x] merge #839 first, this PR was branched off of that work

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [x] Yes

Duplicate lint results are no longer reported.


Patch:
diff --git a/docs/guides/javascript.md b/docs/guides/javascript.md
index a2b3445e..3cf73fb4 100644
--- a/docs/guides/javascript.md
+++ b/docs/guides/javascript.md
@@ -76,10 +76,10 @@ spectral.registerFormat('oas3', isOpenApiv3);
 const {
   Spectral,
   isJSONSchema,
-  isJSONSchemaDraft4, 
+  isJSONSchemaDraft4,
   isJSONSchemaDraft6,
-  isJSONSchemaDraft7, 
-  isJSONSchemaDraft2019_09, 
+  isJSONSchemaDraft7,
+  isJSONSchemaDraft2019_09,
   isJSONSchemaLoose,
 } = require('@stoplight/spectral');
 
@@ -96,7 +96,7 @@ Learn more about predefined formats in the (ruleset documentation)[../getting-st
 
 ## Loading Rules
 
-Spectral comes with some rulesets that are very specific to OpenAPI v2/v3, and they can be loaded using `Spectral.loadRuleset()`. 
+Spectral comes with some rulesets that are very specific to OpenAPI v2/v3, and they can be loaded using `Spectral.loadRuleset()`.
 
 ```js
 const { Spectral, isOpenApiv2, isOpenApiv3 } = require('@stoplight/spectral');
@@ -114,7 +114,7 @@ spectral.loadRuleset('spectral:oas')
   .then(results => {
     console.log('here are the results', results);
   });
-``` 
+```
 
 The OpenAPI rules are opinionated. There might be some rules that you prefer to change, or disable. We encourage you to create your rules to fit your use case, and we welcome additions to the existing rulesets as well!
 
@@ -153,7 +153,7 @@ spectral
   .then(result => {
     expect(result).toEqual([
       expect.objectContaining({
-       code: 'rule1',   
+       code: 'rule1',
      }),
    ]);
   });
@@ -161,9 +161,12 @@ spectral
 
 ### Using custom resolver
 
-Spectral lets you provide any custom $ref resolver. By default, http(s) and file protocols are resolved, relatively to the document Spectral lints against.
-If you'd like support any additional protocol or adjust the resolution, you are absolutely fine to do it.
-In order to achieve that, you need to create a custom json-ref-resolver instance.
+Spectral lets you provide any custom $ref resolver. By default, http(s) and file protocols are resolved, relatively to
+the document Spectral lints against. If you'd like support any additional protocol or adjust the resolution, you are
+absolutely fine to do it. In order to achieve that, you need to create a custom json-ref-resolver instance.
+
+You can find more information about how to create custom resolvers in
+the [@stoplight/json-ref-resolver](https://github.com/stoplightio/json-ref-resolver) repository.
 
 ```js
 const path = require('path');
@@ -199,3 +202,28 @@ const spectral = new Spectral({ resolver: customFileResolver });
 The custom resolver we've just created will resolve all remote file refs relatively to the current working directory.
 
 More on that can be found in the [json-ref-resolver repo](https://github.com/stoplightio/json-ref-resolver).
+
+### Using custom de-duplication strategy
+
+By default, Spectral will de-duplicate results based on the result code and document location. You can customize this
+behavior with the `computeFingerprint` option. For example, here is the default fingerprint implementation:
+
+The final reported results are de-duplicated based on their computed fingerprint.
+
+```ts
+const spectral = new Spectral({
+  computeFingerprint: (rule: IRuleResult, hash) => {
+    let id = String(rule.code);
+
+    if (rule.path && rule.path.length) {
+      id += JSON.stringify(rule.path);
+    } else if (rule.range) {
+      id += JSON.stringify(rule.range);
+    }
+
+    if (rule.source) id += rule.source;
+
+    return hash(id);
+  },
+});
+```
diff --git a/package.json b/package.json
index a171aa43..ba52717c 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
     "ajv": "^6.10",
     "ajv-oai": "^1.1.5",
     "better-ajv-errors": "^0.6.7",
+    "blueimp-md5": "^2.12.0",
     "chalk": "^3.0.0",
     "eol": "^0.9.1",
     "fast-glob": "^3.1.0",
diff --git a/src/cli/services/linter/linter.ts b/src/cli/services/linter/linter.ts
index 4b3f4e1e..c00236a0 100644
--- a/src/cli/services/linter/linter.ts
+++ b/src/cli/services/linter/linter.ts
@@ -17,7 +17,7 @@ import { isRuleEnabled } from '../../../runner';
 import { IRuleResult, Spectral } from '../../../spectral';
 import { FormatLookup, IParsedResult } from '../../../types';
 import { ILintConfig } from '../../../types/config';
-import { deduplicateResults, getRuleset, listFiles, skipRules } from './utils';
+import { getRuleset, listFiles, skipRules } from './utils';
 import { getResolver } from './utils/getResolver';
 
 const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [
@@ -92,5 +92,5 @@ export async function lint(documents: Array<number | string>, flags: ILintConfig
     );
   }
 
-  return deduplicateResults(results);
+  return results;
 }
diff --git a/src/cli/services/linter/utils/deduplicateResults.ts b/src/cli/services/linter/utils/deduplicateResults.ts
deleted file mode 100644
index 88a76cd0..00000000
--- a/src/cli/services/linter/utils/deduplicateResults.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { compareResults } from '../../../../formatters/utils/sortResults';
-import { IRuleResult } from '../../../../types';
-
-export const deduplicateResults = (results: IRuleResult[]): IRuleResult[] => {
-  const filtered: IRuleResult[] = [];
-
-  const totalResults = results.length;
-
-  if (totalResults < 2) {
-    return [...results];
-  }
-
-  const sorted = [...results].sort(compareResults);
-
-  filtered.push(sorted[0]);
-
-  for (let i = 1; i < totalResults; i++) {
-    if (compareResults(sorted[i], sorted[i - 1]) === 0) {
-      continue;
-    }
-
-    filtered.push(sorted[i]);
-  }
-
-  return filtered;
-};
diff --git a/src/cli/services/linter/utils/index.ts b/src/cli/services/linter/utils/index.ts
index 64d27e7d..4912e9c0 100644
--- a/src/cli/services/linter/utils/index.ts
+++ b/src/cli/services/linter/utils/index.ts
@@ -1,4 +1,3 @@
-export * from './deduplicateResults';
 export * from './getRuleset';
 export * from './listFiles';
 export * from './skipRules';
diff --git a/src/formatters/html/index.ts b/src/formatters/html/index.ts
index 3e1e20fa..505df7e6 100644
--- a/src/formatters/html/index.ts
+++ b/src/formatters/html/index.ts
@@ -29,14 +29,7 @@ import * as fs from 'fs';
 import { template } from 'lodash';
 import { IRuleResult } from '../../types';
 import { Formatter } from '../types';
-import {
-  getHighestSeverity,
-  getSeverityName,
-  getSummary,
-  getSummaryForSource,
-  groupBySource,
-  sortResults,
-} from '../utils';
+import { getHighestSeverity, getSeverityName, getSummary, getSummaryForSource, groupBySource } from '../utils';
 
 // ------------------------------------------------------------------------------
 // Helpers
@@ -47,7 +40,7 @@ const messageTemplate = template(eol.lf(fs.readFileSync(path.join(__dirname, 'ht
 const resultTemplate = template(eol.lf(fs.readFileSync(path.join(__dirname, 'html-template-result.html'), 'utf8')));
 
 function renderMessages(messages: IRuleResult[], parentIndex: number) {
-  return sortResults(messages)
+  return messages
     .map(message => {
       const line = message.range.start.line + 1;
       const character = message.range.start.character + 1;
diff --git a/src/formatters/stylish.ts b/src/formatters/stylish.ts
index 12804cbf..20a5d0ad 100644
--- a/src/formatters/stylish.ts
+++ b/src/formatters/stylish.ts
@@ -30,14 +30,7 @@ import * as table from 'text-table';
 
 import { IRuleResult } from '../types';
 import { Formatter } from './types';
-import {
-  getColorForSeverity,
-  getHighestSeverity,
-  getSeverityName,
-  getSummary,
-  groupBySource,
-  sortResults,
-} from './utils';
+import { getColorForSeverity, getHighestSeverity, getSeverityName, getSummary, groupBySource } from './utils';
 
 // -----------------------------------------------------------------------------
 // Helpers
@@ -71,7 +64,7 @@ export const stylish: Formatter = results => {
 
     output += `${chalk.underline(path)}\n`;
 
-    const pathTableData = sortResults(pathResults).map((result: IRuleResult) => [
+    const pathTableData = pathResults.map((result: IRuleResult) => [
       formatRange(result.range),
       getMessageType(result.severity),
       result.code ?? '',
diff --git a/src/formatters/teamcity.ts b/src/formatters/teamcity.ts
index 58861035..50b54e9b 100644
--- a/src/formatters/teamcity.ts
+++ b/src/formatters/teamcity.ts
@@ -1,7 +1,7 @@
 import { Dictionary, Optional } from '@stoplight/types';
 import { IRuleResult } from '../types';
 import { Formatter } from './types';
-import { getSeverityName, groupBySource, sortResults } from './utils';
+import { getSeverityName, groupBySource } from './utils';
 
 function escapeString(str: Optional<string | number>) {
   if (str === void 0) {
@@ -35,9 +35,7 @@ function inspection(result: IRuleResult) {
 }
 
 function renderResults(results: IRuleResult[], parentIndex: number) {
-  return sortResults(results)
-    .map(result => `${inspectionType(result)}\n${inspection(result)}`)
-    .join('\n');
+  return results.map(result => `${inspectionType(result)}\n${inspection(result)}`).join('\n');
 }
 
 function renderGroupedResults(groupedResults: Dictionary<IRuleResult[]>) {
diff --git a/src/formatters/text.ts b/src/formatters/text.ts
index 045f8735..f6be00ce 100644
--- a/src/formatters/text.ts
+++ b/src/formatters/text.ts
@@ -1,10 +1,10 @@
 import { Dictionary } from '@stoplight/types';
 import { IRuleResult } from '../types';
 import { Formatter } from './types';
-import { getSeverityName, groupBySource, sortResults } from './utils';
+import { getSeverityName, groupBySource } from './utils';
 
 function renderResults(results: IRuleResult[], parentIndex: number) {
-  return sortResults(results)
+  return results
     .map(result => {
       const line = result.range.start.line + 1;
       const character = result.range.start.character + 1;
diff --git a/src/formatters/utils/index.ts b/src/formatters/utils/index.ts
index 7a4a92c0..7733c661 100644
--- a/src/formatters/utils/index.ts
+++ b/src/formatters/utils/index.ts
@@ -5,5 +5,4 @@ export * from './getSummary';
 export * from './groupBySeverity';
 export * from './groupBySource';
 export * from './pluralize';
-export * from './sortResults';
 export * from './xmlEscape';
diff --git a/src/spectral.ts b/src/spectral.ts
index fceb1f21..bdff9044 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -4,7 +4,7 @@ import { ICache, IUriParser } from '@stoplight/json-ref-resolver/types';
 import { extname, normalize } from '@stoplight/path';
 import { DiagnosticSeverity, Dictionary, IDiagnostic, Optional } from '@stoplight/types';
 import { getLocationForJsonPath as getLocationForJsonPathYaml, YamlParserResult } from '@stoplight/yaml';
-import { merge } from 'lodash';
+import { memoize, merge } from 'lodash';
 
 import { STATIC_ASSETS } from './assets';
 import { formatParserDiagnostics, formatResolverErrors } from './error-messages';
@@ -31,20 +31,24 @@ import {
   RunRuleCollection,
 } from './types';
 import { IRuleset } from './types/ruleset';
-import { empty } from './utils';
+import { ComputeFingerprintFunc, defaultComputeResultFingerprint, empty, prepareResults } from './utils';
+
+memoize.Cache = WeakMap;
 
 export * from './types';
 
 export class Spectral {
-  private readonly _resolver: IResolver;
-  private readonly _parsedRefs: Dictionary<IParsedResult>;
-  private static readonly _parsedCache = new WeakMap<ICache | IResolver, Dictionary<IParsedResult>>();
   public functions: FunctionCollection = { ...defaultFunctions };
   public rules: RunRuleCollection = {};
-
   public formats: RegisteredFormats;
 
+  private readonly _computeFingerprint: ComputeFingerprintFunc;
+  private readonly _resolver: IResolver;
+  private readonly _parsedRefs: Dictionary<IParsedResult>;
+  private static readonly _parsedCache = new WeakMap<ICache | IResolver, Dictionary<IParsedResult>>();
+
   constructor(opts?: IConstructorOpts) {
+    this._computeFingerprint = memoize(opts?.computeFingerprint || defaultComputeResultFingerprint);
     this._resolver = opts && opts.resolver ? opts.resolver : new Resolver();
     this.formats = {};
 
@@ -113,7 +117,10 @@ export class Spectral {
 
     return {
       resolved: resolved.resolved,
-      results: [...validationResults, ...runRules(resolved, this.rules, this.functions)],
+      results: prepareResults(
+        [...validationResults, ...runRules(resolved, this.rules, this.functions)],
+        this._computeFingerprint,
+      ),
     };
   }
 
diff --git a/src/types/spectral.ts b/src/types/spectral.ts
index 87663874..e6e40499 100644
--- a/src/types/spectral.ts
+++ b/src/types/spectral.ts
@@ -9,6 +9,7 @@ import {
 } from '@stoplight/types';
 import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
 import { IFunction, IRule, Rule } from '.';
+import { ComputeFingerprintFunc } from '../utils';
 
 export type FunctionCollection = Dictionary<IFunction, string>;
 export type RuleCollection = Dictionary<Rule, string>;
@@ -31,6 +32,7 @@ export type RuleDeclarationCollection = Dictionary<boolean, string>;
 
 export interface IConstructorOpts {
   resolver?: IResolver;
+  computeFingerprint?: ComputeFingerprintFunc;
 }
 
 export interface IRunOpts {
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 60a050ee..db6cc12f 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -2,3 +2,5 @@ export * from './empty';
 export * from './hasIntersectingElement';
 export * from './isObject';
 export * from './refs';
+export * from './prepareResults';
+export * from './sortResults';
diff --git a/src/utils/prepareResults.ts b/src/utils/prepareResults.ts
new file mode 100644
index 00000000..26001c54
--- /dev/null
+++ b/src/utils/prepareResults.ts
@@ -0,0 +1,46 @@
+const md5 = require('blueimp-md5');
+
+import { uniqBy } from 'lodash';
+
+import { IRuleResult } from '../types';
+import { compareResults } from './sortResults';
+
+export type ComputeFingerprintFunc = (rule: IRuleResult, hash: (val: string) => string) => string;
+
+export const defaultComputeResultFingerprint: ComputeFingerprintFunc = (rule, hash) => {
+  let id = String(rule.code);
+
+  if (rule.path.length) {
+    id += JSON.stringify(rule.path);
+  } else if (rule.range) {
+    id += JSON.stringify(rule.range);
+  }
+
+  if (rule.source) id += rule.source;
+
+  return hash(id);
+};
+
+export const prepareResults = (results: IRuleResult[], computeFingerprint: ComputeFingerprintFunc) => {
+  decorateResultsWithFingerprint(results, computeFingerprint);
+
+  return sortResults(deduplicateResults(results));
+};
+
+const decorateResultsWithFingerprint = (results: IRuleResult[], computeFingerprint: ComputeFingerprintFunc) => {
+  for (const r of results) {
+    Object.defineProperty(r, 'fingerprint', {
+      value: computeFingerprint(r, md5),
+    });
+  }
+
+  return results;
+};
+
+const deduplicateResults = (results: IRuleResult[]): IRuleResult[] => {
+  return uniqBy([...results], 'fingerprint');
+};
+
+const sortResults = (results: IRuleResult[]): IRuleResult[] => {
+  return [...results].sort(compareResults);
+};
diff --git a/src/formatters/utils/sortResults.ts b/src/utils/sortResults.ts
similarity index 97%
rename from src/formatters/utils/sortResults.ts
rename to src/utils/sortResults.ts
index 02c5bd37..29238078 100644
--- a/src/formatters/utils/sortResults.ts
+++ b/src/utils/sortResults.ts
@@ -1,4 +1,4 @@
-import { IRuleResult } from '../../types';
+import { IRuleResult } from '../types';
 
 const compareCode = (left: string | number | undefined, right: string | number | undefined): number => {
   if (left === void 0 && right === void 0) {
diff --git a/yarn.lock b/yarn.lock
index 73df64ce..7bc5ff18 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1105,6 +1105,11 @@ bluebird@^3.3.0:
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de"
   integrity sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg==
 
+blueimp-md5@^2.12.0:
+  version "2.12.0"
+  resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.12.0.tgz#be7367938a889dec3ffbb71138617c117e9c130a"
+  integrity sha512-zo+HIdIhzojv6F1siQPqPFROyVy7C50KzHv/k/Iz+BtvtVzSHXiMXOpq2wCfNkeBqdCv+V8XOV96tsEt2W/3rQ==
+
 bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"


Test patch:
diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts
index c320b347..e0cd212c 100644
--- a/src/__tests__/linter.jest.test.ts
+++ b/src/__tests__/linter.jest.test.ts
@@ -64,18 +64,18 @@ describe('Linter', () => {
   it('should respect the scope of defined functions (ruleset-based)', async () => {
     await spectral.loadRuleset(customDirectoryFunctionsRuleset);
     expect(await spectral.run({})).toEqual([
-      expect.objectContaining({
-        code: 'has-info-property',
-        message: 'info property is missing',
-      }),
       expect.objectContaining({
         code: 'has-field-property',
         message: 'Object does not have field property',
       }),
+      expect.objectContaining({
+        code: 'has-info-property',
+        message: 'info property is missing',
+      }),
     ]);
   });
 
-  describe('evaluate {{value}} in validation messages', () => {
+  describe('evaluate "value" in validation messages', () => {
     test('should print correct values for referenced files', async () => {
       spectral = new Spectral({ resolver: httpAndFileResolver });
 
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index 24d545b1..5ac5ed73 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -97,7 +97,7 @@ describe('linter', () => {
     );
 
     expect(result).toEqual([
-      {
+      expect.objectContaining({
         code: 'rule1',
         message,
         severity: DiagnosticSeverity.Warning,
@@ -112,7 +112,7 @@ describe('linter', () => {
             line: 5,
           },
         },
-      },
+      }),
     ]);
   });
 
@@ -162,15 +162,15 @@ describe('linter', () => {
 
     expect(result).toEqual([
       expect.objectContaining({
-        code: 'invalid-ref',
+        code: 'oas3-schema',
+        message: "/paths//pets/get/responses/200 should have required property '$ref'",
+        path: ['paths', '/pets', 'get', 'responses', '200'],
       }),
       expect.objectContaining({
         code: 'invalid-ref',
       }),
       expect.objectContaining({
-        code: 'oas3-schema',
-        message: "/paths//pets/get/responses/200 should have required property '$ref'",
-        path: ['paths', '/pets', 'get', 'responses', '200'],
+        code: 'invalid-ref',
       }),
       expect.objectContaining({
         code: 'oas3-unused-components-schema',
@@ -614,26 +614,21 @@ responses:: !!foo
 
     expect(result).toEqual([
       expect.objectContaining({
-        code: 'invalid-ref',
-      }),
-      expect.objectContaining({
-        code: 'invalid-ref',
+        code: 'openapi-tags',
       }),
       expect.objectContaining({
         code: 'operation-tag-defined',
       }),
       expect.objectContaining({
-        code: 'openapi-tags',
+        code: 'oas3-schema',
+        message: "/paths//pets/get/responses/200 should have required property '$ref'",
+        path: ['paths', '/pets', 'get', 'responses', '200'],
       }),
       expect.objectContaining({
-        code: 'oas3-valid-schema-example',
-        message: '"foo.example" property type should be number',
-        path: ['components', 'schemas', 'foo', 'example'],
+        code: 'invalid-ref',
       }),
       expect.objectContaining({
-        code: 'oas3-schema',
-        message: "/paths//pets/get/responses/200 should have required property '$ref'",
-        path: ['paths', '/pets', 'get', 'responses', '200'],
+        code: 'invalid-ref',
       }),
       expect.objectContaining({
         code: 'oas3-unused-components-schema',
@@ -645,6 +640,11 @@ responses:: !!foo
         message: 'Potentially unused components schema has been detected.',
         path: ['components', 'schemas', 'foo'],
       }),
+      expect.objectContaining({
+        code: 'oas3-valid-schema-example',
+        message: '"foo.example" property type should be number',
+        path: ['components', 'schemas', 'foo', 'example'],
+      }),
     ]);
   });
 
diff --git a/src/__tests__/spectral.jest.test.ts b/src/__tests__/spectral.jest.test.ts
index 847840c8..2398c2a4 100644
--- a/src/__tests__/spectral.jest.test.ts
+++ b/src/__tests__/spectral.jest.test.ts
@@ -195,7 +195,7 @@ describe('Spectral', () => {
     );
   });
 
-  test('should recognize the source of remote $refs', async () => {
+  test('should recognize the source of remote $refs, and de-dupe results by fingerprint', async () => {
     const s = new Spectral({ resolver: httpAndFileResolver });
     const documentUri = path.join(__dirname, './__fixtures__/gh-658/URIError.yaml');
 
@@ -214,51 +214,9 @@ describe('Spectral', () => {
 
     const results = await s.run(fs.readFileSync(documentUri, 'utf8'), { resolve: { documentUri } });
 
-    return expect(results).toEqual([
-      expect.objectContaining({
-        path: ['paths', '/test', 'get', 'responses', '200', 'content', 'application/json', 'schema'],
-        source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'),
-        range: {
-          end: {
-            character: 28,
-            line: 23,
-          },
-          start: {
-            character: 21,
-            line: 22,
-          },
-        },
-      }),
-
-      expect.objectContaining({
-        path: ['components', 'schemas', 'Error', 'properties', 'status_code'],
-        source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'),
-        range: {
-          end: {
-            character: 22,
-            line: 21,
-          },
-          start: {
-            character: 20,
-            line: 20,
-          },
-        },
-      }),
-      expect.objectContaining({
-        path: ['components', 'schemas', 'Foo'],
-        source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'),
-        range: {
-          end: {
-            character: 18,
-            line: 43,
-          },
-          start: {
-            character: 8,
-            line: 42,
-          },
-        },
-      }),
+    expect(results.length).toEqual(3);
 
+    return expect(results).toEqual([
       expect.objectContaining({
         path: ['components', 'schemas', 'Error', 'properties', 'status_code'],
         source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'),
@@ -273,79 +231,22 @@ describe('Spectral', () => {
           },
         },
       }),
-      expect.objectContaining({
-        path: ['components', 'schemas', 'Foo'],
-        source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'),
-        range: {
-          end: {
-            character: 18,
-            line: 43,
-          },
-          start: {
-            character: 8,
-            line: 42,
-          },
-        },
-      }),
-
-      expect.objectContaining({
-        path: ['components', 'schemas', 'Foo'],
-        source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'),
-        range: {
-          end: {
-            character: 18,
-            line: 43,
-          },
-          start: {
-            character: 8,
-            line: 42,
-          },
-        },
-      }),
 
       expect.objectContaining({
-        path: ['components', 'schemas', 'Error', 'properties', 'status_code'],
-        source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'),
-        range: {
-          end: {
-            character: 22,
-            line: 21,
-          },
-          start: {
-            character: 20,
-            line: 20,
-          },
-        },
-      }),
-      expect.objectContaining({
-        path: ['components', 'schemas', 'Foo'],
+        path: ['paths', '/test', 'get', 'responses', '200', 'content', 'application/json', 'schema'],
         source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'),
         range: {
           end: {
-            character: 18,
-            line: 43,
+            character: 28,
+            line: 23,
           },
           start: {
-            character: 8,
-            line: 42,
+            character: 21,
+            line: 22,
           },
         },
       }),
 
-      expect.objectContaining({
-        path: ['components', 'schemas', 'Error', 'properties', 'status_code'],
-        source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/lib.yaml'),
-        range: {
-          end: {
-            character: 22,
-            line: 21,
-          },
-          start: {
-            character: 20,
-            line: 20,
-          },
-        },
-      }),
       expect.objectContaining({
         path: ['components', 'schemas', 'Foo'],
         source: expect.stringContaining('/src/__tests__/__fixtures__/gh-658/URIError.yaml'),
diff --git a/src/formatters/__tests__/html.test.ts b/src/formatters/__tests__/html.test.ts
index 640a6f60..ec67396a 100644
--- a/src/formatters/__tests__/html.test.ts
+++ b/src/formatters/__tests__/html.test.ts
@@ -1,7 +1,8 @@
 import { HTMLElement, parse } from 'node-html-parser';
+import { sortResults } from '../../utils';
 import { html } from '../html';
 
-const mixedErrors = require('./__fixtures__/mixed-errors.json');
+const mixedErrors = sortResults(require('./__fixtures__/mixed-errors.json'));
 
 describe('HTML formatter', () => {
   test('should display proper severity levels', () => {
diff --git a/src/formatters/__tests__/json.test.ts b/src/formatters/__tests__/json.test.ts
index d75f8e08..8aaab451 100644
--- a/src/formatters/__tests__/json.test.ts
+++ b/src/formatters/__tests__/json.test.ts
@@ -1,7 +1,8 @@
 import { IRuleResult } from '../../types';
+import { sortResults } from '../../utils';
 import { json } from '../json';
 
-const results: IRuleResult[] = [
+const results: IRuleResult[] = sortResults([
   {
     code: 'operation-description',
     message: 'paths./pets.get.description is not truthy',
@@ -36,7 +37,7 @@ const results: IRuleResult[] = [
       },
     },
   },
-];
+]);
 
 describe('JSON formatter', () => {
   test('should include ranges', () => {
diff --git a/src/formatters/__tests__/junit.test.ts b/src/formatters/__tests__/junit.test.ts
index 1019b62a..b224898d 100644
--- a/src/formatters/__tests__/junit.test.ts
+++ b/src/formatters/__tests__/junit.test.ts
@@ -1,9 +1,10 @@
 import { promisify } from 'util';
 import { Parser } from 'xml2js';
+import { sortResults } from '../../utils';
 import { junit } from '../junit';
 
-const oas3SchemaErrors = require('./__fixtures__/oas3-schema-errors.json');
-const mixedErrors = require('./__fixtures__/mixed-errors-with-different-paths.json');
+const oas3SchemaErrors = sortResults(require('./__fixtures__/oas3-schema-errors.json'));
+const mixedErrors = sortResults(require('./__fixtures__/mixed-errors.json'));
 
 describe('JUnit formatter', () => {
   let parse: Parser['parseString'];
diff --git a/src/formatters/__tests__/stylish.test.ts b/src/formatters/__tests__/stylish.test.ts
index 95374692..b262c3a9 100644
--- a/src/formatters/__tests__/stylish.test.ts
+++ b/src/formatters/__tests__/stylish.test.ts
@@ -1,8 +1,9 @@
 import * as chalk from 'chalk';
+import { sortResults } from '../../utils';
 import { stylish } from '../stylish';
 
-const oas3SchemaErrors = require('./__fixtures__/oas3-schema-errors.json');
-const mixedErrors = require('./__fixtures__/mixed-errors.json');
+const oas3SchemaErrors = sortResults(require('./__fixtures__/oas3-schema-errors.json'));
+const mixedErrors = sortResults(require('./__fixtures__/mixed-errors.json'));
 
 describe('Stylish formatter', () => {
   test('should prefer message for oas-schema errors', () => {
diff --git a/src/formatters/__tests__/teamcity.test.ts b/src/formatters/__tests__/teamcity.test.ts
index 92961196..d3b4f702 100644
--- a/src/formatters/__tests__/teamcity.test.ts
+++ b/src/formatters/__tests__/teamcity.test.ts
@@ -1,6 +1,7 @@
+import { sortResults } from '../../utils';
 import { teamcity } from '../teamcity';
 
-const mixedErrors = require('./__fixtures__/mixed-errors.json');
+const mixedErrors = sortResults(require('./__fixtures__/mixed-errors.json'));
 
 describe('Teamcity formatter', () => {
   test('should format messages', () => {
diff --git a/src/formatters/__tests__/text.test.ts b/src/formatters/__tests__/text.test.ts
index 3cb5f772..7afcd452 100644
--- a/src/formatters/__tests__/text.test.ts
+++ b/src/formatters/__tests__/text.test.ts
@@ -1,6 +1,7 @@
+import { sortResults } from '../../utils';
 import { text } from '../text';
 
-const mixedErrors = require('./__fixtures__/mixed-errors.json');
+const mixedErrors = sortResults(require('./__fixtures__/mixed-errors.json'));
 
 describe('Text formatter', () => {
   test('should format messages', () => {
diff --git a/src/rulesets/oas/__tests__/contact-properties.ts b/src/rulesets/oas/__tests__/contact-properties.ts
index bac9b051..f458dd6c 100644
--- a/src/rulesets/oas/__tests__/contact-properties.ts
+++ b/src/rulesets/oas/__tests__/contact-properties.ts
@@ -33,39 +33,6 @@ describe('contact-properties', () => {
       info: { contact: {} },
     });
     expect(results).toEqual([
-      {
-        code: 'contact-properties',
-        message: 'Contact object should have `name`, `url` and `email`.',
-        path: ['info', 'contact'],
-        range: {
-          end: {
-            character: 17,
-            line: 4,
-          },
-          start: {
-            character: 14,
-            line: 4,
-          },
-        },
-        severity: 1,
-        source: undefined,
-      },
-      {
-        code: 'contact-properties',
-        message: 'Contact object should have `name`, `url` and `email`.',
-        path: ['info', 'contact'],
-        range: {
-          end: {
-            character: 17,
-            line: 4,
-          },
-          start: {
-            character: 14,
-            line: 4,
-          },
-        },
-        severity: DiagnosticSeverity.Warning,
-      },
       {
         code: 'contact-properties',
         message: 'Contact object should have `name`, `url` and `email`.',
diff --git a/src/rulesets/oas/functions/__tests__/oasOpParams.test.ts b/src/rulesets/oas/functions/__tests__/oasOpParams.test.ts
index 66df95dc..1fe2674e 100644
--- a/src/rulesets/oas/functions/__tests__/oasOpParams.test.ts
+++ b/src/rulesets/oas/functions/__tests__/oasOpParams.test.ts
@@ -144,7 +144,7 @@ describe('oasOpParams', () => {
         },
       },
     });
-    expect(results.length).toEqual(2);
+    expect(results.length).toEqual(1);
   });
 
   test('Error if multiple in:body', async () => {
diff --git a/src/cli/services/linter/utils/__tests__/__fixtures__/duplicate-validation-results.json b/src/utils/__tests__/__fixtures__/duplicate-validation-results.json
similarity index 100%
rename from src/cli/services/linter/utils/__tests__/__fixtures__/duplicate-validation-results.json
rename to src/utils/__tests__/__fixtures__/duplicate-validation-results.json
diff --git a/src/cli/services/linter/utils/__tests__/deduplicateResults.spec.ts b/src/utils/__tests__/prepareResults.spec.ts
similarity index 70%
rename from src/cli/services/linter/utils/__tests__/deduplicateResults.spec.ts
rename to src/utils/__tests__/prepareResults.spec.ts
index bdf4d024..07960785 100644
--- a/src/cli/services/linter/utils/__tests__/deduplicateResults.spec.ts
+++ b/src/utils/__tests__/prepareResults.spec.ts
@@ -1,10 +1,10 @@
-import { deduplicateResults } from '../deduplicateResults';
+import { defaultComputeResultFingerprint, prepareResults } from '../prepareResults';
 
 import * as duplicateValidationResults from './__fixtures__/duplicate-validation-results.json';
 
-describe('deduplicateResults util', () => {
+describe('prepareResults util', () => {
   it('deduplicate exact validation results', () => {
-    expect(deduplicateResults(duplicateValidationResults)).toEqual([
+    expect(prepareResults(duplicateValidationResults, defaultComputeResultFingerprint)).toEqual([
       expect.objectContaining({
         code: 'valid-example-in-schemas',
       }),
@@ -20,7 +20,7 @@ describe('deduplicateResults util', () => {
       source: void 0,
     }));
 
-    expect(deduplicateResults(duplicateValidationResultsWithNoSource)).toEqual([
+    expect(prepareResults(duplicateValidationResultsWithNoSource, defaultComputeResultFingerprint)).toEqual([
       expect.objectContaining({
         code: 'valid-example-in-schemas',
       }),
@@ -38,6 +38,6 @@ describe('deduplicateResults util', () => {
       { ...duplicateValidationResults[0] },
     ];
 
-    expect(deduplicateResults(onlyDuplicates).length).toBe(1);
+    expect(prepareResults(onlyDuplicates, defaultComputeResultFingerprint).length).toBe(1);
   });
 });
diff --git a/src/formatters/utils/__tests__/sortResults.test.ts b/src/utils/__tests__/sortResults.test.ts
similarity index 99%
rename from src/formatters/utils/__tests__/sortResults.test.ts
rename to src/utils/__tests__/sortResults.test.ts
index 44113e56..00b2441f 100644
--- a/src/formatters/utils/__tests__/sortResults.test.ts
+++ b/src/utils/__tests__/sortResults.test.ts
@@ -1,5 +1,5 @@
 import { DiagnosticSeverity } from '@stoplight/types';
-import { IRuleResult } from '../../../types';
+import { IRuleResult } from '../../types';
 import { compareResults, sortResults } from '../sortResults';
 
 const results: IRuleResult[] = [