Repository: stoplightio/spectral
Pull request #271 (2019-06-25 13:42:36)

feat: process referenced files
### Please check if the PR fulfills these requirements
- [x] Tests for the changes have been added (for bug fixes / features)
- [ ] Docs have been added / updated (for bug fixes / features)

### What kind of change does this PR introduce?

Feature

### What is the current behavior?

Currently, Spectral does not perform any extensive checks on `$ref` properties.
Although there are a few rules making use of resolved object literals, it's fair to say this is an exception and in general, we don't actually process external contents in any way. This behavior is not a bug yet is a result of a decision taken in the past, as the solution was considerably good and it was quite a fair compromise to make given we didn't resolve external refs by default. Now, when external refs are resolved by default, this became an issue, as we don't report any feedback...

### If this is a feature change, what is the new behavior?

... the purpose of the following PR is to change it and start reporting errors for referenced files as they were actually an integral part of a processed file.

### Does this PR introduce a breaking change?

Yeah, new resolver.

What changes might users need to make in their application due to this PR?

Use SpectralResolver rather than Resolver.

### Other information

(e.g. detailed explanation, related issues, links for us to have context, eg. stackoverflow, issues outside of the repo, forum, etc.)


marbemac: Hmm, how can we get this done without requiring a customized instance of resolver (the SpectralResolver thing)?

Graphite/Studio have their standard own resolver instances that they pass into Spectral. They do unique things like read latest file contents from memory (so that can see spectral issues before saving the file), have all the lookups already cached, etc.
P0lip: @marbemac 
I'm afraid it's impossible without touching json-ref-resolver. If `refMap` in resolved result contained nested refs and their contents, I could get rid of SpectralResolver.
marbemac: Probably wouldn't be very difficult, I could get that done. What would `refMap` ideally look like? Could you share a small example with some realistic data?
P0lip: @marbemac 

The biggest obstacle I faced with refMap is its lack of any support for nested refs.
At the moment, the scope of refMap seems to be limited to top-level' refs and does not contain any nested refs found in referenced files. That's why I have to follow them manually.
Obviously, the major showstopper is lack of resolved content in raw format - json-ref-resolver does not really need it, it's sufficient for it to have an object literal, but Spectral does need an actual raw format to map given error to a physical place in a file (line and character).

To sum up:
- refMap has to contain all refs & ideally point straight to final (tail) ref.
- unparsed content has to be exposed



P0lip: @marbemac any update on that?
P0lip: ```
[...] Instead of having the registry I added, we could simply load the files manually in Studio and then have some interceptor in Spectral to use require rather than fs.readFile or any other method operating on fs.
```
I'll try to make that work right now.
marbemac: Let's perhaps hop on a call tomorrow about this.
P0lip: @marbemac yeah, I've added an extra unit test concerning paths, but will try to toss one more in, though.

Patch:
diff --git a/package.json b/package.json
index e68dfa12..ca585871 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,7 @@
     "@oclif/config": "^1.12",
     "@oclif/plugin-help": "^2.0",
     "@stoplight/json": "^2.2.2",
-    "@stoplight/json-ref-resolver": "^2",
+    "@stoplight/json-ref-resolver": "^2.0.2",
     "@stoplight/types": "^9.0.2",
     "@stoplight/yaml": "^2.5",
     "ajv": "^6.7",
diff --git a/src/cli/commands/lint.ts b/src/cli/commands/lint.ts
index 79a350a2..cd809f24 100644
--- a/src/cli/commands/lint.ts
+++ b/src/cli/commands/lint.ts
@@ -6,7 +6,6 @@ import { isNil, omitBy } from 'lodash';
 import { resolve } from 'path';
 import { promisify } from 'util';
 
-import { startsWith } from '@stoplight/json';
 import { IRuleResult } from '../..';
 import { createEmptyConfig, getDefaultConfigFile, load as loadConfig } from '../../config/configLoader';
 import { json, stylish } from '../../formatters';
@@ -142,7 +141,7 @@ async function lint(name: string, flags: any, command: Lint, rules?: RuleCollect
   }
 
   let targetUri = name;
-  if (!startsWith(name, 'http')) {
+  if (!/^https?:\/\//.test(name)) {
     // we always want the absolute path to the target file
     targetUri = resolve(name);
   }
diff --git a/src/formatters/json.ts b/src/formatters/json.ts
index ff14c5f9..c91f7343 100644
--- a/src/formatters/json.ts
+++ b/src/formatters/json.ts
@@ -9,6 +9,7 @@ export const json = (results: IRuleResult[]): string => {
       summary: result.summary,
       severity: result.severity,
       range: result.range,
+      source: result.source,
     };
   });
   return JSON.stringify(outputJson, null, '\t');
diff --git a/src/functions/schema-path.ts b/src/functions/schema-path.ts
index 3ba8f321..ba2bda18 100644
--- a/src/functions/schema-path.ts
+++ b/src/functions/schema-path.ts
@@ -15,19 +15,7 @@ const { JSONPath } = require('jsonpath-plus');
 export const schemaPath: IFunction<ISchemaPathOptions> = (targetVal, opts, paths, otherValues) => {
   if (!targetVal || typeof targetVal !== 'object') return [];
 
-  let object = targetVal;
-
-  const { resolved } = otherValues;
-  if (!resolved) {
-    console.warn('schema-path expects a resolved object, but none was provided. Results may not be correct.');
-  } else {
-    // Take the relevant part of the resolved schema
-    try {
-      object = JSONPath({ path: ['$', ...paths.given], json: resolved })[0];
-    } catch (e) {
-      console.error(e);
-    }
-  }
+  const { original: object } = otherValues;
 
   // The subsection of the targetVal which contains the good bit
   const relevantObject = opts.field ? object[opts.field] : object;
diff --git a/src/functions/schema.ts b/src/functions/schema.ts
index bc507671..ce4939ad 100644
--- a/src/functions/schema.ts
+++ b/src/functions/schema.ts
@@ -6,6 +6,10 @@ import { IOutputError } from 'better-ajv-errors';
 import { IFunction, IFunctionResult, ISchemaOptions } from '../types';
 const betterAjvErrors = require('better-ajv-errors');
 
+interface IAJVOutputError extends IOutputError {
+  path?: string;
+}
+
 const ajv = new AJV({
   meta: false,
   schemaId: 'auto',
@@ -60,11 +64,13 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
     const validator = validators.get(schemaObj);
     if (!validator(targetVal) && validator.errors) {
       results.push(
-        ...(betterAjvErrors(schemaObj, targetVal, validator.errors, { format: 'js' }) as IOutputError[]).map(
-          ({ error }) => ({
-            message: cleanAJVErrorMessage(error),
-            path,
-          }),
+        ...(betterAjvErrors(schemaObj, targetVal, validator.errors, { format: 'js' }) as IAJVOutputError[]).map(
+          ({ error, path: errorPath }) => {
+            return {
+              message: cleanAJVErrorMessage(error),
+              path: [...path, ...(errorPath ? errorPath.replace(/^\//, '').split('/') : [])],
+            };
+          },
         ),
       );
     }
diff --git a/src/linter.ts b/src/linter.ts
index 18661861..7bdb2ffb 100644
--- a/src/linter.ts
+++ b/src/linter.ts
@@ -3,8 +3,9 @@ import { get, has } from 'lodash';
 
 const { JSONPath } = require('jsonpath-plus');
 
+import { Resolved } from './resolved';
 import { message } from './rulesets/message';
-import { IFunction, IGivenNode, IParsedResult, IRuleResult, IRunRule, IRunRuleOpts, IThen } from './types';
+import { IFunction, IGivenNode, IRuleResult, IRunRule, IThen } from './types';
 
 // TODO(SO-23): unit test but mock whatShouldBeLinted
 export const lintNode = (
@@ -12,8 +13,7 @@ export const lintNode = (
   rule: IRunRule,
   then: IThen<string, any>,
   apply: IFunction,
-  opts: IRunRuleOpts,
-  parsedResult: IParsedResult,
+  resolved: Resolved,
 ): IRuleResult[] => {
   const givenPath = node.path[0] === '$' ? node.path.slice(1) : node.path;
   const conditioning = whatShouldBeLinted(givenPath, node.value, rule);
@@ -23,7 +23,6 @@ export const lintNode = (
     return [];
   }
 
-  const { parsed } = parsedResult;
   const targetValue = conditioning.value;
 
   const targets: any[] = [];
@@ -89,7 +88,6 @@ export const lintNode = (
         {
           original: node.value,
           given: node.value,
-          resolved: opts.resolvedTarget,
         },
       ) || [];
 
@@ -98,7 +96,7 @@ export const lintNode = (
     results = results.concat(
       targetResults.map(result => {
         const path = result.path || targetPath;
-        const location = parsedResult.getLocationForJsonPath(parsed, path, true);
+        const location = resolved.getLocationForJsonPath(path, true);
 
         return {
           code: rule.name,
@@ -118,7 +116,7 @@ export const lintNode = (
                 }),
           path,
           severity,
-          source: parsedResult.source,
+          source: location.uri,
           ...(location || {
             range: {
               start: {
diff --git a/src/resolved.ts b/src/resolved.ts
new file mode 100644
index 00000000..83655727
--- /dev/null
+++ b/src/resolved.ts
@@ -0,0 +1,70 @@
+import { IResolveError, IResolveResult, IResolveRunner } from '@stoplight/json-ref-resolver/types';
+import { Dictionary, ILocation, JsonPath } from '@stoplight/types';
+import { Segment } from '@stoplight/types/dist';
+import { get } from 'lodash';
+import { IParseMap, REF_METADATA } from './spectral';
+import { IParsedResult } from './types';
+
+export class Resolved implements IResolveResult {
+  public refMap: Dictionary<string>;
+  public result: unknown;
+  public errors: IResolveError[];
+  public runner: IResolveRunner;
+
+  constructor(public spec: IParsedResult, result: IResolveResult, public parsedMap: IParseMap) {
+    this.refMap = result.refMap;
+    this.result = result.result;
+    this.errors = result.errors;
+    this.runner = result.runner;
+  }
+
+  public getParsedForJsonPath(path: JsonPath) {
+    let target: object = this.parsedMap.refs;
+    const newPath = [...path];
+    let segment: Segment;
+
+    while (newPath.length > 0) {
+      segment = newPath.shift()!;
+      if (segment && segment in target) {
+        target = target[segment];
+      } else {
+        newPath.unshift(segment);
+        break;
+      }
+    }
+
+    if (target) {
+      return {
+        path: [...get(target, [REF_METADATA, 'root'], []), ...newPath],
+        doc: get(this.parsedMap.parsed, get(target, [REF_METADATA, 'ref']), this.spec),
+      };
+    }
+
+    return {
+      path: newPath,
+      doc: this.spec,
+    };
+  }
+
+  public getLocationForJsonPath(path: JsonPath, closest?: boolean): ILocation {
+    const parsedResult = this.getParsedForJsonPath(path);
+    const location = parsedResult.doc.getLocationForJsonPath(parsedResult.doc.parsed, parsedResult.path, closest);
+
+    return {
+      ...(parsedResult.doc.source && { uri: parsedResult.doc.source }),
+      range:
+        location !== undefined
+          ? location.range
+          : {
+              start: {
+                line: 0,
+                character: 0,
+              },
+              end: {
+                line: 0,
+                character: 0,
+              },
+            },
+    };
+  }
+}
diff --git a/src/rulesets/oas/functions/oasOpParams/index.ts b/src/rulesets/oas/functions/oasOpParams/index.ts
index 7b4a9a73..12cb6fa3 100644
--- a/src/rulesets/oas/functions/oasOpParams/index.ts
+++ b/src/rulesets/oas/functions/oasOpParams/index.ts
@@ -3,14 +3,7 @@ import { IFunction, IFunctionResult, Rule } from '../../../../types';
 export const oasOpParams: IFunction<Rule> = (targetVal, _options, _paths, vals) => {
   const results: IFunctionResult[] = [];
 
-  let object = targetVal;
-
-  const { resolved } = vals;
-  if (!resolved) {
-    console.warn('oasOpParams expects a resolved object, but none was provided. Results may not be correct.');
-  } else {
-    object = resolved;
-  }
+  const { original: object } = vals;
 
   /**
    * This rule verifies:
diff --git a/src/rulesets/oas/functions/oasPathParam/index.ts b/src/rulesets/oas/functions/oasPathParam/index.ts
index 2f44e4f4..4d937980 100644
--- a/src/rulesets/oas/functions/oasPathParam/index.ts
+++ b/src/rulesets/oas/functions/oasPathParam/index.ts
@@ -5,14 +5,7 @@ const pathRegex = /(\{[a-zA-Z0-9_-]+\})+/g;
 export const oasPathParam: IFunction<Rule> = (targetVal, _options, paths, vals) => {
   const results: IFunctionResult[] = [];
 
-  let object = targetVal;
-  const { resolved } = vals;
-
-  if (!resolved) {
-    console.warn('oasPathParam expects a resolved object, but none was provided. Results may not be correct.');
-  } else {
-    object = resolved;
-  }
+  const { original: object } = vals;
 
   /**
    * This rule verifies:
diff --git a/src/runner.ts b/src/runner.ts
index ffb9e5cc..0a4f48d8 100644
--- a/src/runner.ts
+++ b/src/runner.ts
@@ -1,21 +1,14 @@
+import { Resolved } from './resolved';
+
 const { JSONPath } = require('jsonpath-plus');
 
 import { lintNode } from './linter';
-import {
-  FunctionCollection,
-  IGivenNode,
-  IParsedResult,
-  IRuleResult,
-  IRunRule,
-  IRunRuleOpts,
-  RunRuleCollection,
-} from './types';
+import { FunctionCollection, IGivenNode, IRuleResult, IRunRule, RunRuleCollection } from './types';
 
 export const runRules = (
-  parsedResult: IParsedResult,
+  resolved: Resolved,
   rules: RunRuleCollection,
   functions: FunctionCollection,
-  opts: IRunRuleOpts,
 ): IRuleResult[] => {
   let results: IRuleResult[] = [];
 
@@ -30,7 +23,7 @@ export const runRules = (
     }
 
     try {
-      results = results.concat(runRule(parsedResult, rule, functions, opts));
+      results = results.concat(runRule(resolved, rule, functions));
     } catch (e) {
       console.error(`Unable to run rule '${name}':\n${e}`);
     }
@@ -39,14 +32,8 @@ export const runRules = (
   return results;
 };
 
-const runRule = (
-  parsedResult: IParsedResult,
-  rule: IRunRule,
-  functions: FunctionCollection,
-  opts: IRunRuleOpts,
-): IRuleResult[] => {
-  const { parsed } = parsedResult;
-  const { data: target } = parsed;
+const runRule = (resolved: Resolved, rule: IRunRule, functions: FunctionCollection): IRuleResult[] => {
+  const { result: target } = resolved;
 
   let results: IRuleResult[] = [];
   const nodes: IGivenNode[] = [];
@@ -85,7 +72,7 @@ const runRule = (
           continue;
         }
 
-        results = results.concat(lintNode(node, rule, then, func, opts, parsedResult));
+        results = results.concat(lintNode(node, rule, then, func, resolved));
       }
     } catch (e) {
       console.warn(`Encountered error when running rule '${rule.name}' on node at path '${node.path}':\n${e}`);
diff --git a/src/spectral.ts b/src/spectral.ts
index 838a67a5..6b7dbe05 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -1,11 +1,18 @@
 import { safeStringify } from '@stoplight/json';
 import { Resolver } from '@stoplight/json-ref-resolver';
-import { IResolveError } from '@stoplight/json-ref-resolver/types';
-import { DiagnosticSeverity, IParserResult } from '@stoplight/types';
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/yaml';
-import { merge, uniqBy } from 'lodash';
+import { IUriParser } from '@stoplight/json-ref-resolver/types';
+import { getLocationForJsonPath as getLocationForJsonPathJSON } from '@stoplight/json/getLocationForJsonPath';
+import { parseWithPointers as parseJSONWithPointers } from '@stoplight/json/parseWithPointers';
+import { DiagnosticSeverity, Dictionary, IParserResult } from '@stoplight/types';
+import {
+  getLocationForJsonPath as getLocationForJsonPathYAML,
+  parseWithPointers as parseYAMLWithPointers,
+} from '@stoplight/yaml';
+import { merge, set, uniqBy } from 'lodash';
+import { extname } from 'path';
 
 import { functions as defaultFunctions } from './functions';
+import { Resolved } from './resolved';
 import { runRules } from './runner';
 import {
   FunctionCollection,
@@ -36,8 +43,8 @@ export class Spectral {
     let parsedResult: IParsedResult;
     if (!isParsedResult(target)) {
       parsedResult = {
-        parsed: parseWithPointers(typeof target === 'string' ? target : safeStringify(target, undefined, 2)),
-        getLocationForJsonPath,
+        parsed: parseYAMLWithPointers(typeof target === 'string' ? target : safeStringify(target, undefined, 2)),
+        getLocationForJsonPath: getLocationForJsonPathYAML,
       };
       results = results.concat(formatParserDiagnostics(parsedResult.parsed, parsedResult.source));
     } else {
@@ -45,14 +52,52 @@ export class Spectral {
     }
 
     const documentUri = opts.resolve && opts.resolve.documentUri;
-    const { result: resolvedTarget, errors } = await this._resolver.resolve(parsedResult.parsed.data, {
-      baseUri: documentUri,
-    });
+    const refDiagnostics: IRuleResult[] = [];
+
+    const resolved = new Resolved(
+      parsedResult,
+      await this._resolver.resolve(parsedResult.parsed.data, {
+        baseUri: documentUri,
+        parseResolveResult: async resolveOpts => {
+          const ref = resolveOpts.targetAuthority.toString();
+          const ext = extname(ref);
+
+          const content = String(resolveOpts.result);
+          let parsedRefResult: IParsedResult | void;
+          if (ext === '.yml' || ext === '.yaml') {
+            parsedRefResult = {
+              parsed: parseYAMLWithPointers(content),
+              source: ref,
+              getLocationForJsonPath: getLocationForJsonPathYAML,
+            };
+          } else if (ext === '.json') {
+            parsedRefResult = {
+              parsed: parseJSONWithPointers(content),
+              source: ref,
+              getLocationForJsonPath: getLocationForJsonPathJSON,
+            };
+          }
+
+          if (parsedRefResult !== undefined) {
+            resolveOpts.result = parsedRefResult.parsed.data;
+            if (parsedRefResult.parsed.diagnostics.length > 0) {
+              refDiagnostics.push(...formatParserDiagnostics(parsedRefResult.parsed, parsedRefResult.source));
+            }
+
+            this._processExternalRef(parsedRefResult, resolveOpts);
+          }
+
+          return resolveOpts;
+        },
+      }),
+      this._parsedMap,
+    );
 
     return [
+      ...refDiagnostics,
       ...results,
-      ...formatResolverErrors(errors, parsedResult),
-      ...runRules(parsedResult, this.rules, this.functions, { resolvedTarget }),
+      ...formatResolverErrors(resolved),
+      ...runRules(resolved, this.rules, this.functions),
     ];
   }
 
@@ -115,8 +160,36 @@ export class Spectral {
       }
     }
   }
+
+  private _parsedMap: IParseMap = {
+    refs: {},
+    parsed: {},
+    pointers: {},
+  };
+
+  private _processExternalRef(parsedResult: IParsedResult, opts: IUriParser) {
+    const ref = opts.targetAuthority.toString();
+    this._parsedMap.parsed[ref] = parsedResult;
+    this._parsedMap.pointers[ref] = opts.parentPath;
+    const parentRef = opts.parentAuthority.toString();
+
+    set(
+      this._parsedMap.refs,
+      [...(this._parsedMap.pointers[parentRef] ? this._parsedMap.pointers[parentRef] : []), ...opts.parentPath],
+      Object.defineProperty({}, REF_METADATA, {
+        enumerable: false,
+        writable: false,
+        value: {
+          ref,
+          root: opts.fragment.split('/').slice(1),
+        },
+      }),
+    );
+  }
 }
 
+export const REF_METADATA = Symbol('external_ref_metadata');
+
 export const isParsedResult = (obj: any): obj is IParsedResult => {
   if (!obj || typeof obj !== 'object') return false;
   if (!obj.parsed || typeof obj.parsed !== 'object') return false;
@@ -135,10 +208,10 @@ function formatParserDiagnostics(parsed: IParserResult, source?: string): IRuleR
 
 const prettyPrintResolverError = (message: string) => message.replace(/^Error\s*:\s*/, '');
 
-const formatResolverErrors = (resolveErrors: IResolveError[], result: IParsedResult): IRuleResult[] => {
-  return uniqBy(resolveErrors, 'message').reduce<IRuleResult[]>((errors, error) => {
+const formatResolverErrors = (resolved: Resolved): IRuleResult[] => {
+  return uniqBy(resolved.errors, 'message').reduce<IRuleResult[]>((errors, error) => {
     const path = [...error.path, '$ref'];
-    const location = result.getLocationForJsonPath(result.parsed, path);
+    const location = resolved.getLocationForJsonPath(path);
 
     if (location) {
       errors.push({
@@ -153,3 +226,9 @@ const formatResolverErrors = (resolveErrors: IResolveError[], result: IParsedRes
     return errors;
   }, []);
 };
+
+export interface IParseMap {
+  refs: Dictionary<object>;
+  parsed: Dictionary<IParsedResult>;
+  pointers: Dictionary<string[]>;
+}
diff --git a/src/types/function.ts b/src/types/function.ts
index 00e77d94..a42300bd 100644
--- a/src/types/function.ts
+++ b/src/types/function.ts
@@ -15,7 +15,6 @@ export interface IFunctionPaths {
 export interface IFunctionValues {
   original: any;
   given: any;
-  resolved?: any;
 }
 
 export interface IFunctionResult {
diff --git a/src/types/spectral.ts b/src/types/spectral.ts
index 6309f4c3..8ab0cfcb 100644
--- a/src/types/spectral.ts
+++ b/src/types/spectral.ts
@@ -30,16 +30,6 @@ export interface IRunOpts {
   };
 }
 
-export interface IRunRuleOpts {
-  /**
-   * The resolved version of the target object (could vary depending on the
-   * resolver used)
-   *
-   * Some functions require this in order to operate.
-   */
-  resolvedTarget?: object;
-}
-
 export interface IRuleResult extends IDiagnostic {
   // @deprecated, use message instead
   summary?: string;
diff --git a/yarn.lock b/yarn.lock
index c1244147..bc3bc964 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -804,10 +804,10 @@
   resolved "https://registry.yarnpkg.com/@stoplight/fast-safe-stringify/-/fast-safe-stringify-2.1.2.tgz#2811eff4989c3d4c5c9a3789f057e25358db0899"
   integrity sha512-ayw3qQ9KNn2K5q6ggrLclh+rdZsSLwYIUz3uAer1NzaCpalJLR4g32G845ylgq7Xy2AunMHnNfD3FC3s0rUcsA==
 
-"@stoplight/json-ref-resolver@^2":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@stoplight/json-ref-resolver/-/json-ref-resolver-2.0.1.tgz#cb2c232ea2a3626f3090a47eb6fec4fa667aa2ac"
-  integrity sha512-ht99x0g9Fx57nyFleBTW3Z3QIKVAD2qp1mk4RWp+U15cFyr7k9zdG/8tkDSSmNz25ssiLSbPPwHkuhF4c5AZiQ==
+"@stoplight/json-ref-resolver@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@stoplight/json-ref-resolver/-/json-ref-resolver-2.0.2.tgz#508cf634f5bdab0c4cbe638df7a9f604bacdc560"
+  integrity sha512-vqi8R4RmnYqgEPGrISlgAPFDWPqoZb3i39l4qhhFGT7YIPprgxqtBrRhCcPOrG808720jomjz/R18+nS0EjBPw==
   dependencies:
     "@stoplight/json" "2.x.x"
     "@types/urijs" "1.x.x"


Test patch:
diff --git a/src/__tests__/spectral.test.ts b/src/__tests__/spectral.test.ts
index e7f88880..6d923529 100644
--- a/src/__tests__/spectral.test.ts
+++ b/src/__tests__/spectral.test.ts
@@ -111,7 +111,10 @@ describe('spectral', () => {
 
       await s.run(target);
 
-      expect(fakeResolver.resolve).toBeCalledWith(target, { authority: undefined });
+      expect(fakeResolver.resolve).toBeCalledWith(target, {
+        authority: undefined,
+        parseResolveResult: expect.any(Function),
+      });
     });
   });
 
diff --git a/src/cli/commands/__tests__/__fixtures__/draft-nested-ref.oas2.json b/src/cli/commands/__tests__/__fixtures__/draft-nested-ref.oas2.json
new file mode 100644
index 00000000..441e923e
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/draft-nested-ref.oas2.json
@@ -0,0 +1,7 @@
+{
+  "swagger": "2.0",
+  "host": "d",
+  "info": {
+    "$ref": "./refs/info-ref.json"
+  }
+}
diff --git a/src/cli/commands/__tests__/__fixtures__/draft-ref.oas2.json b/src/cli/commands/__tests__/__fixtures__/draft-ref.oas2.json
new file mode 100644
index 00000000..aed1ba3d
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/draft-ref.oas2.json
@@ -0,0 +1,10 @@
+{
+  "swagger": "2.0",
+  "host": "d",
+  "info": {
+    "$ref": "./refs/info.json#/definitions/info"
+  },
+  "paths": {
+    "test": {}
+  }
+}
diff --git a/src/cli/commands/__tests__/__fixtures__/refs/contact.json b/src/cli/commands/__tests__/__fixtures__/refs/contact.json
new file mode 100644
index 00000000..96f18d12
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/refs/contact.json
@@ -0,0 +1,4 @@
+{
+  "contact": {},
+  "description": 0
+}
diff --git a/src/cli/commands/__tests__/__fixtures__/refs/info-ref.json b/src/cli/commands/__tests__/__fixtures__/refs/info-ref.json
new file mode 100644
index 00000000..2e07a69d
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/refs/info-ref.json
@@ -0,0 +1,3 @@
+{
+  "$ref": "./contact.json"
+}
diff --git a/src/cli/commands/__tests__/__fixtures__/refs/info.json b/src/cli/commands/__tests__/__fixtures__/refs/info.json
new file mode 100644
index 00000000..62c33b05
--- /dev/null
+++ b/src/cli/commands/__tests__/__fixtures__/refs/info.json
@@ -0,0 +1,12 @@
+{
+  "$schema": "",
+  "definitions": {
+    "info": {
+      "foo": "bar",
+      "description": 0,
+      "contact": {
+        "name": "test"
+      }
+    }
+  }
+}
diff --git a/src/cli/commands/__tests__/lint.test.ts b/src/cli/commands/__tests__/lint.test.ts
index 4902e185..5f5163ba 100644
--- a/src/cli/commands/__tests__/lint.test.ts
+++ b/src/cli/commands/__tests__/lint.test.ts
@@ -17,6 +17,8 @@ const validRulesetConfigPath = resolve(__dirname, '__fixtures__/config.ruleset.y
 const invalidRulesetConfigPath = resolve(__dirname, '__fixtures__/config.ruleset.invalid.yml');
 const standardOas3RulesetPath = resolve(__dirname, '../../../rulesets/oas3/ruleset.json');
 const standardOas2RulesetPath = resolve(__dirname, '../../../rulesets/oas2/ruleset.json');
+const draftRefSpec = resolve(__dirname, './__fixtures__/draft-ref.oas2.json');
+const draftNestedRefSpec = resolve(__dirname, './__fixtures__/draft-nested-ref.oas2.json');
 
 /*
  * These tests currently do not assert stderr because it doesn't seem to be
@@ -351,7 +353,7 @@ describe('lint', () => {
     beforeAll(() => {
       spy = jest.spyOn(process, 'cwd').mockReturnValue(resolve(__dirname, '__fixtures__'));
     });
-    afterAll(() => spy.mockClear());
+    afterAll(() => spy.mockRestore());
 
     test
       .stdout()
@@ -360,4 +362,100 @@ describe('lint', () => {
         expect(ctx.stdout).toContain('"Info object should contain `contact` object."');
       });
   });
+
+  describe('ref linting', () => {
+    test
+      .stdout()
+      .command(['lint', draftRefSpec, '-q', '-f=json'])
+      .it('outputs errors occurring in referenced files', ctx => {
+        expect(JSON.parse(ctx.stdout)).toEqual([
+          expect.objectContaining({
+            code: 'info-description',
+            message: 'OpenAPI object info `description` must be present and non-empty string.',
+            path: ['info', 'description'], // todo: relative path or absolute path? there is no such path in linted ref, but there is such in spec when working on resolved file
+            range: {
+              end: {
+                character: 22,
+                line: 5,
+              },
+              start: {
+                character: 21,
+                line: 5,
+              },
+            },
+            source: expect.stringContaining('src/cli/commands/__tests__/__fixtures__/refs/info.json'),
+          }),
+          expect.objectContaining({
+            code: 'oas2-schema',
+            message: '/info Property foo is not expected to be here',
+            path: ['info'],
+            range: {
+              end: {
+                character: 5,
+                line: 9,
+              },
+              start: {
+                character: 12,
+                line: 3,
+              },
+            },
+            source: expect.stringContaining('src/cli/commands/__tests__/__fixtures__/refs/info.json'),
+          }),
+          expect.objectContaining({
+            code: 'api-schemes',
+            message: 'OpenAPI host `schemes` must be present and non-empty array.',
+            path: ['schemes'],
+            range: expect.any(Object),
+            source: expect.stringContaining('src/cli/commands/__tests__/__fixtures__/draft-ref.oas2.json'),
+          }),
+        ]);
+      });
+
+    test
+      .stdout()
+      .command(['lint', draftNestedRefSpec, '-q', '-f=json'])
+      .it('outputs errors occurring in nested referenced files', ctx => {
+        expect(JSON.parse(ctx.stdout)).toEqual([
+          expect.objectContaining({
+            code: 'info-description',
+            message: 'OpenAPI object info `description` must be present and non-empty string.',
+            path: ['info', 'description'],
+            range: {
+              end: {
+                character: 18,
+                line: 2,
+              },
+              start: {
+                character: 17,
+                line: 2,
+              },
+            },
+            source: expect.stringContaining('src/cli/commands/__tests__/__fixtures__/refs/contact.json'),
+          }),
+          expect.objectContaining({
+            code: 'oas2-schema',
+            message: "/info should have required property 'title'",
+            path: ['info'],
+            range: {
+              end: {
+                character: 1,
+                line: 3,
+              },
+              start: {
+                character: 0,
+                line: 0,
+              },
+            },
+            source: expect.stringContaining('src/cli/commands/__tests__/__fixtures__/refs/contact.json'),
+          }),
+          expect.objectContaining({
+            code: 'api-schemes',
+            message: 'OpenAPI host `schemes` must be present and non-empty array.',
+            path: ['schemes'],
+            range: expect.any(Object),
+            source: expect.stringContaining('src/cli/commands/__tests__/__fixtures__/draft-nested-ref.oas2.json'),
+          }),
+        ]);
+      });
+  });
 });
diff --git a/src/functions/__tests__/schema-path.test.ts b/src/functions/__tests__/schema-path.test.ts
index 4db31af2..d14f6aa0 100644
--- a/src/functions/__tests__/schema-path.test.ts
+++ b/src/functions/__tests__/schema-path.test.ts
@@ -5,7 +5,7 @@ function runSchemaPath(target: any, field: string, schemaPathStr: string) {
     target,
     { field, schemaPath: schemaPathStr },
     { given: [], target: [] },
-    { given: null, original: null, resolved: target },
+    { given: null, original: target },
   );
 }
 
@@ -59,14 +59,12 @@ describe('schema', () => {
         notUrl: 'images/38.png',
       },
     };
-    expect(runSchemaPath(target, fieldToCheck, path)).toMatchInlineSnapshot(`
-Array [
-  Object {
-    "message": "should have required property 'url'",
-    "path": Array [],
-  },
-]
-`);
+    expect(runSchemaPath(target, fieldToCheck, path)).toEqual([
+      {
+        path: [],
+        message: "should have required property 'url'",
+      },
+    ]);
   });
 
   test('will error formats', () => {
@@ -78,14 +76,12 @@ Array [
       example: 'turtle',
     };
 
-    expect(runSchemaPath(target, fieldToCheck, path)).toMatchInlineSnapshot(`
-Array [
-  Object {
-    "message": "format should match format \\"url\\"",
-    "path": Array [],
-  },
-]
-`);
+    expect(runSchemaPath(target, fieldToCheck, path)).toEqual([
+      {
+        message: 'format should match format "url"',
+        path: [],
+      },
+    ]);
   });
 
   describe('when schema path is not there', () => {
diff --git a/src/functions/__tests__/schema.test.ts b/src/functions/__tests__/schema.test.ts
index 7a51190a..7325cbd5 100644
--- a/src/functions/__tests__/schema.test.ts
+++ b/src/functions/__tests__/schema.test.ts
@@ -1,7 +1,7 @@
 import { schema } from '../schema';
 
 function runSchema(target: any, schemaObj: object) {
-  return schema(target, { schema: schemaObj }, { given: ['$'] }, { given: null, original: null });
+  return schema(target, { schema: schemaObj }, { given: [] }, { given: null, original: null });
 }
 
 describe('schema', () => {
@@ -19,7 +19,7 @@ describe('schema', () => {
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
           message: 'type should be array',
-          path: ['$'],
+          path: [],
         }),
       ]);
     });
@@ -29,12 +29,65 @@ describe('schema', () => {
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
           message: 'maxItems should NOT have more than 1 items',
-          path: ['$'],
+          path: [],
         }),
       ]);
     });
   });
 
+  describe('when schema defines a nested object', () => {
+    const testSchema = {
+      type: 'object',
+      properties: {
+        foo: {
+          type: 'object',
+          properties: {
+            bar: {
+              type: 'string',
+            },
+          },
+          additionalProperties: false,
+        },
+      },
+    };
+
+    test('reports correct paths', () => {
+      expect(
+        runSchema(
+          {
+            abc: 'string',
+            foo: {
+              bar: 0,
+            },
+          },
+          testSchema,
+        ),
+      ).toEqual([
+        {
+          message: 'type should be string',
+          path: ['foo', 'bar'],
+        },
+      ]);
+
+      expect(
+        runSchema(
+          {
+            abc: 'string',
+            foo: {
+              baz: 'test',
+            },
+          },
+          testSchema,
+        ),
+      ).toEqual([
+        {
+          message: '/foo Property baz is not expected to be here',
+          path: ['foo'],
+        },
+      ]);
+    });
+  });
+
   describe('when schema defines common formats', () => {
     const testSchema = {
       type: 'string',
@@ -46,7 +99,7 @@ describe('schema', () => {
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
           message: 'format should match format "email"',
-          path: ['$'],
+          path: [],
         }),
       ]);
     });
diff --git a/src/rulesets/__tests__/reader.unit.ts b/src/rulesets/__tests__/reader.unit.ts
index 37c8bd28..afd9b8f8 100644
--- a/src/rulesets/__tests__/reader.unit.ts
+++ b/src/rulesets/__tests__/reader.unit.ts
@@ -119,7 +119,7 @@ describe('reader', () => {
     });
   });
 
-  it('should blend together parent rulesets', async () => {
+  it('should blend together pointers rulesets', async () => {
     validateRulesetMock.mockReturnValue([]);
     givenRulesets({
       rulesetA: {
diff --git a/src/rulesets/oas2/__tests__/valid-example.ts b/src/rulesets/oas2/__tests__/valid-example.ts
index 651c11e6..161cf097 100644
--- a/src/rulesets/oas2/__tests__/valid-example.ts
+++ b/src/rulesets/oas2/__tests__/valid-example.ts
@@ -154,8 +154,8 @@ describe('valid-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'valid-example',
-        message: '"halRoot" property type should be array',
-        path: ['definitions', 'halRoot'],
+        message: '"self" property type should be array',
+        path: ['definitions', 'halRoot', '_links', 'self'],
       }),
     ]);
   });