Repository: stoplightio/spectral
Pull request #593 (2019-09-25 00:21:33)

refactor(resolving): accept any object implementing IResolver
A small piece of $ref resolving puzzle.

This is mostly internal refactoring that does not affect any consumers, including these using their own resolvers.




Patch:
diff --git a/src/linter.ts b/src/linter.ts
index ff738866..77edc123 100644
--- a/src/linter.ts
+++ b/src/linter.ts
@@ -116,18 +116,7 @@ export const lintNode = (
           path,
           severity: getDiagnosticSeverity(rule.severity),
           source: location.uri,
-          ...(location || {
-            range: {
-              start: {
-                character: 0,
-                line: 0,
-              },
-              end: {
-                character: 0,
-                line: 0,
-              },
-            },
-          }),
+          range: location.range,
         };
       }),
     );
diff --git a/src/resolved.ts b/src/resolved.ts
index 7e5c447c..e3106a83 100644
--- a/src/resolved.ts
+++ b/src/resolved.ts
@@ -1,22 +1,34 @@
-import { IResolveError, IResolveResult } from '@stoplight/json-ref-resolver/types';
-import { Dictionary, ILocation, JsonPath, Segment } from '@stoplight/types';
-import { get } from 'lodash';
-import { IParseMap, REF_METADATA } from './spectral';
+import { IResolveError } from '@stoplight/json-ref-resolver/types';
+import { Dictionary, ILocation, IRange, JsonPath, Segment } from '@stoplight/types';
+import { get, has } from 'lodash';
+import { IParseMap, REF_METADATA, ResolveResult } from './spectral';
 import { IParsedResult } from './types';
 
+const getDefaultRange = (): IRange => ({
+  start: {
+    line: 0,
+    character: 0,
+  },
+  end: {
+    line: 0,
+    character: 0,
+  },
+});
+
 export class Resolved {
-  public refMap: Dictionary<string>;
-  public resolved: unknown;
-  public unresolved: unknown;
-  public errors: IResolveError[];
+  public readonly refMap: Dictionary<string>;
+  public readonly resolved: unknown;
+  public readonly unresolved: unknown;
+  public readonly errors: IResolveError[];
   public formats?: string[] | null;
 
-  constructor(public spec: IParsedResult, resolveResult: IResolveResult, public parsedMap: IParseMap) {
+  constructor(public spec: IParsedResult, resolveResult: ResolveResult, public parsedMap: IParseMap) {
+    this.unresolved = spec.parsed.data;
+    this.formats = spec.formats;
+
     this.refMap = resolveResult.refMap;
     this.resolved = resolveResult.result;
-    this.unresolved = spec.parsed.data;
     this.errors = resolveResult.errors;
-    this.formats = spec.formats;
   }
 
   public getParsedForJsonPath(path: JsonPath) {
@@ -41,6 +53,10 @@ export class Resolved {
       };
     }
 
+    if (path.length > 0 && !has(this.spec.parsed.data, path)) {
+      return null;
+    }
+
     return {
       path,
       doc: this.spec,
@@ -49,23 +65,17 @@ export class Resolved {
 
   public getLocationForJsonPath(path: JsonPath, closest?: boolean): ILocation {
     const parsedResult = this.getParsedForJsonPath(path);
+    if (parsedResult === null) {
+      return {
+        range: getDefaultRange(),
+      };
+    }
+
     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,
-              },
-            },
+      range: location !== void 0 ? location.range : getDefaultRange(),
     };
   }
 }
diff --git a/src/spectral.ts b/src/spectral.ts
index 968f8ccc..3c4fc545 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -30,6 +30,7 @@ import {
   FunctionCollection,
   IConstructorOpts,
   IParsedResult,
+  IResolver,
   IRuleResult,
   IRunOpts,
   ISpectralFullResult,
@@ -43,9 +44,9 @@ import { IRuleset } from './types/ruleset';
 export * from './types';
 
 export class Spectral {
-  private readonly _resolver: Resolver;
+  private readonly _resolver: IResolver;
   private readonly _parsedMap: IParseMap;
-  private static readonly _parsedCache = new WeakMap<ICache, IParseMap>();
+  private static readonly _parsedCache = new WeakMap<ICache | IResolver, IParseMap>();
   public functions: FunctionCollection = { ...defaultFunctions };
   public rules: RunRuleCollection = {};
 
@@ -55,7 +56,8 @@ export class Spectral {
     this._resolver = opts && opts.resolver ? opts.resolver : new Resolver();
     this.formats = {};
 
-    const _parsedMap = Spectral._parsedCache.get(this._resolver.uriCache);
+    const cacheKey = this._resolver instanceof Resolver ? this._resolver.uriCache : this._resolver;
+    const _parsedMap = Spectral._parsedCache.get(cacheKey);
     if (_parsedMap) {
       this._parsedMap = _parsedMap;
     } else {
@@ -65,7 +67,7 @@ export class Spectral {
         pointers: {},
       };
 
-      Spectral._parsedCache.set(this._resolver.uriCache, this._parsedMap);
+      Spectral._parsedCache.set(cacheKey, this._parsedMap);
     }
   }
 
diff --git a/src/types/spectral.ts b/src/types/spectral.ts
index e903e733..4cbcc4e3 100644
--- a/src/types/spectral.ts
+++ b/src/types/spectral.ts
@@ -1,4 +1,4 @@
-import { Resolver } from '@stoplight/json-ref-resolver';
+import { IResolveOpts, IResolveResult } from '@stoplight/json-ref-resolver/types';
 import {
   DiagnosticSeverity,
   Dictionary,
@@ -29,7 +29,7 @@ export type SpectralDiagnosticSeverity = DiagnosticSeverity | -1;
 export type RuleDeclarationCollection = Dictionary<boolean, string>;
 
 export interface IConstructorOpts {
-  resolver?: Resolver;
+  resolver?: IResolver;
 }
 
 export interface IRunOpts {
@@ -59,5 +59,11 @@ export interface IParsedResult<R extends IParserResult = IParserResult<unknown,
   formats?: string[];
 }
 
+export type ResolveResult = Omit<IResolveResult, 'runner'>;
+
+export interface IResolver {
+  resolve(source: unknown, opts?: IResolveOpts): Promise<ResolveResult>;
+}
+
 export type FormatLookup = (document: unknown) => boolean;
 export type RegisteredFormats = Dictionary<FormatLookup, string>;


Test patch:
diff --git a/src/__tests__/spectral.test.ts b/src/__tests__/spectral.test.ts
index 20bd1e35..e7958d84 100644
--- a/src/__tests__/spectral.test.ts
+++ b/src/__tests__/spectral.test.ts
@@ -1,6 +1,7 @@
+import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
 import { isParsedResult, Spectral } from '../spectral';
-import { IParsedResult, RuleFunction } from '../types';
+import { IParsedResult, IResolver, RuleFunction } from '../types';
 
 const merge = require('lodash/merge');
 
@@ -124,27 +125,86 @@ describe('spectral', () => {
   });
 
   describe('when a $ref appears', () => {
-    test('will call the resolver with target', async () => {
-      const fakeResolver = {
-        uriCache: {
-          get: jest.fn(),
-          set: jest.fn(),
-          has: jest.fn(),
-        },
-        resolve: jest.fn(() => Promise.resolve([])),
-      };
-
-      const s = new Spectral({
-        resolver: fakeResolver as any,
+    describe('and a custom resolver is provided', () => {
+      test('will call the resolver with target', async () => {
+        const customResolver: IResolver = {
+          resolve: jest.fn(async () => ({
+            result: {},
+            refMap: {},
+            errors: [],
+          })),
+        };
+
+        const s = new Spectral({
+          resolver: customResolver,
+        });
+
+        const target = { foo: 'bar' };
+
+        await s.run(target);
+
+        expect(customResolver.resolve).toBeCalledWith(target, {
+          authority: undefined,
+          parseResolveResult: expect.any(Function),
+        });
       });
 
-      const target = { foo: 'bar' };
-
-      await s.run(target);
-
-      expect(fakeResolver.resolve).toBeCalledWith(target, {
-        authority: undefined,
-        parseResolveResult: expect.any(Function),
+      test('should handle lack of information about $refs gracefully', () => {
+        const customResolver: IResolver = {
+          resolve: jest.fn(async () => ({
+            result: {
+              foo: {
+                bar: {
+                  baz: '',
+                },
+              },
+            },
+            refMap: {},
+            errors: [],
+          })),
+        };
+
+        const s = new Spectral({
+          resolver: customResolver,
+        });
+
+        s.setRules({
+          'truthy-baz': {
+            given: '$.foo.bar.baz',
+            message: 'Baz must be truthy',
+            severity: DiagnosticSeverity.Error,
+            recommended: true,
+            then: {
+              function: 'truthy',
+            },
+          },
+        });
+
+        const target: IParsedResult = {
+          parsed: parseWithPointers(`{"foo":"bar"}`),
+          getLocationForJsonPath,
+          source: 'foo',
+        };
+
+        return expect(s.run(target)).resolves.toStrictEqual([
+          {
+            code: 'truthy-baz',
+            message: 'Baz must be truthy',
+            path: ['foo', 'bar', 'baz'],
+            range: {
+              end: {
+                character: 0,
+                line: 0,
+              },
+              start: {
+                character: 0,
+                line: 0,
+              },
+            },
+            severity: 0,
+            source: void 0,
+          },
+        ]);
       });
     });
   });