Repository: stoplightio/spectral
Pull request #460 (2019-08-19 07:23:05)

feat: transferable rulesets
Closes https://github.com/stoplightio/spectral/issues/494

**Checklist**

- [x] Tests added / updated
- [ ] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

Everything can be found in https://github.com/stoplightio/spectral/issues/494



Patch:
diff --git a/package.json b/package.json
index bbe29763..23482ac0 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
   "scripts": {
     "prebuild": "copyfiles -u 1 \"src/rulesets/oas*/**/*.json\" dist && copyfiles -u 1 \"src/rulesets/oas*/**/*.json\" ./",
     "build": "tsc -p ./tsconfig.build.json",
-    "postbuild": "oclif-dev manifest && yarn build.oas-functions",
+    "postbuild": "oclif-dev manifest && yarn build.oas-functions && yarn compile-rulesets",
     "build.binary": "pkg . --targets linux,macos --out-path ./binaries",
     "build.oas-functions": "rollup -c",
     "postbuild.oas-functions": "copyfiles -u 1 \"dist/rulesets/oas*/functions/*.js\" ./",
@@ -58,6 +58,7 @@
     "pretest.karma": "node ./scripts/generate-karma-fixtures.js",
     "test.karma": "karma start",
     "inline-version": "./scripts/inline-version.js",
+    "compile-rulesets": "node ./scripts/compile-rulesets.js",
     "schema.update": "yarn typescript-json-schema --id \"http://stoplight.io/schemas/rule.schema.json\" --required tsconfig.json IRule --out ./src/meta/rule.schema.json"
   },
   "dependencies": {
@@ -69,6 +70,7 @@
     "@stoplight/path": "^1.1.0",
     "@stoplight/types": "^11.0.0",
     "@stoplight/yaml": "^3.0.0",
+    "abort-controller": "^3.0.0",
     "ajv": "^6.7",
     "ajv-oai": "^1.1.1",
     "better-ajv-errors": "stoplightio/better-ajv-errors#0c5be246c12c87993ba07b47479cff8257b9579b",
diff --git a/scripts/compile-rulesets.js b/scripts/compile-rulesets.js
new file mode 100755
index 00000000..9b8202da
--- /dev/null
+++ b/scripts/compile-rulesets.js
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+const path = require('path');
+const fs = require('fs');
+const { readRuleset } = require('../dist/rulesets');
+
+const dist = path.join(__dirname, '../rulesets/precompiled/');
+
+const rulesetNames = ['oas2', 'oas3', 'oas'];
+
+try {
+  fs.mkdirSync(dist);
+} catch (ex) {
+
+}
+
+Promise.all(
+  rulesetNames.map(ruleset => readRuleset(`spectral:${ruleset}`)),
+).then(rulesets => {
+  for (const [i, ruleset] of rulesets.entries()) {
+    fs.writeFileSync(
+      path.join(dist, `${rulesetNames[i]}.json`),
+      JSON.stringify(ruleset),
+    );
+  }
+});
+
diff --git a/src/cli/commands/lint.ts b/src/cli/commands/lint.ts
index 55e6302c..c5a08f78 100644
--- a/src/cli/commands/lint.ts
+++ b/src/cli/commands/lint.ts
@@ -121,7 +121,7 @@ async function lint(name: string, flags: ILintConfig, command: Lint, rulesets: O
   const spectral = new Spectral({ resolver: httpAndFileResolver });
 
   try {
-    await spectral.loadRuleset(rulesets ? rulesets : ['spectral:oas']);
+    await spectral.loadRuleset(rulesets || ['spectral:oas']);
   } catch (ex) {
     command.log(ex.message);
     process.exitCode = 2;
@@ -138,7 +138,7 @@ async function lint(name: string, flags: ILintConfig, command: Lint, rulesets: O
     targetUri = resolve(name);
   }
 
-  const spec: IParserResult = parseWithPointers(await readParsable(targetUri, flags.encoding), {
+  const spec: IParserResult = parseWithPointers(await readParsable(targetUri, { encoding: flags.encoding }), {
     ignoreDuplicateKeys: false,
     mergeKeys: true,
   });
diff --git a/src/fs/reader.ts b/src/fs/reader.ts
index bd35aefd..d63aeddc 100644
--- a/src/fs/reader.ts
+++ b/src/fs/reader.ts
@@ -1,16 +1,45 @@
 import { isURL } from '@stoplight/path';
 import * as fs from 'fs';
 const fetch = require('node-fetch');
+import AbortController from 'abort-controller';
 
-export async function readFile(name: string, encoding: string) {
+export interface IReadOptions {
+  encoding: string;
+  timeout?: number;
+}
+
+export async function readFile(name: string, opts: IReadOptions) {
   if (isURL(name)) {
-    const response = await fetch(name);
-    if (!response.ok) throw new Error(response.statusText);
-    return await response.text();
+    let response;
+    let timeout: NodeJS.Timeout | null = null;
+    try {
+      if (opts.timeout) {
+        const controller = new AbortController();
+        timeout = setTimeout(() => {
+          controller.abort();
+        }, opts.timeout);
+        response = await fetch(name, { signal: controller.signal });
+      } else {
+        response = await fetch(name);
+      }
+
+      if (!response.ok) throw new Error(response.statusText);
+      return await response.text();
+    } catch (ex) {
+      if (ex.name === 'AbortError') {
+        throw new Error('Timeout');
+      } else {
+        throw ex;
+      }
+    } finally {
+      if (timeout !== null) {
+        clearTimeout(timeout);
+      }
+    }
   } else {
     try {
       return await new Promise((resolve, reject) => {
-        fs.readFile(name, encoding, (err, data) => {
+        fs.readFile(name, opts.encoding, (err, data) => {
           if (err !== null) {
             reject(err);
           } else {
@@ -24,9 +53,9 @@ export async function readFile(name: string, encoding: string) {
   }
 }
 
-export async function readParsable(name: string, encoding: string): Promise<string> {
+export async function readParsable(name: string, opts: IReadOptions): Promise<string> {
   try {
-    return await readFile(name, encoding);
+    return await readFile(name, opts);
   } catch (ex) {
     throw new Error(`Could not parse ${name}: ${ex.message}`);
   }
diff --git a/src/rulesets/evaluators.ts b/src/rulesets/evaluators.ts
index 44d4c1c1..275561b8 100644
--- a/src/rulesets/evaluators.ts
+++ b/src/rulesets/evaluators.ts
@@ -1,4 +1,7 @@
 import { Optional } from '@stoplight/types';
+import { JSONSchema7 } from 'json-schema';
+import { IFunction } from '../types';
+import { decorateIFunctionWithSchemaValidation } from './validation';
 
 export type CJSExport = Partial<{ exports: object | ESCJSCompatibleExport }>;
 export type ESCJSCompatibleExport = Partial<{ default: unknown }>;
@@ -59,3 +62,16 @@ export const evaluateExport = (body: string): Function => {
 
   return maybeFn;
 };
+
+export const compileExportedFunction = (code: string, name: string, schema: JSONSchema7 | null) => {
+  const exportedFn = evaluateExport(code) as IFunction;
+
+  const fn = schema !== null ? decorateIFunctionWithSchemaValidation(exportedFn, schema) : exportedFn;
+
+  Reflect.defineProperty(fn, 'name', {
+    configurable: true,
+    value: name,
+  });
+
+  return fn;
+};
diff --git a/src/rulesets/loader.ts b/src/rulesets/loader.ts
index d5520bb1..bcb0aaf5 100644
--- a/src/rulesets/loader.ts
+++ b/src/rulesets/loader.ts
@@ -3,12 +3,14 @@ import * as fs from 'fs';
 
 const DEFAULT_RULESET_FILE = /^\.?spectral\.(?:ya?ml|json)$/;
 
+export const isDefaultRulesetFile = (uri: string) => DEFAULT_RULESET_FILE.test(uri);
+
 export const getDefaultRulesetFile = (directory: string): Promise<string | null> => {
   return new Promise(resolve => {
     fs.readdir(directory, (err, files) => {
       if (err === null) {
         for (const file of files) {
-          if (DEFAULT_RULESET_FILE.test(file)) {
+          if (isDefaultRulesetFile(file)) {
             resolve(join(directory, file));
             return;
           }
diff --git a/src/rulesets/mergers/functions.ts b/src/rulesets/mergers/functions.ts
index 07f3c93f..466cced1 100644
--- a/src/rulesets/mergers/functions.ts
+++ b/src/rulesets/mergers/functions.ts
@@ -1,15 +1,24 @@
 import { Dictionary } from '@stoplight/types/dist';
-import { FunctionCollection, RuleCollection } from '../../types';
+import { RuleCollection } from '../../types';
+import { RulesetFunctionCollection } from '../../types/ruleset';
 const nanoid = require('nanoid');
 
-export function mergeFunctions(target: FunctionCollection, source: FunctionCollection, rules: RuleCollection) {
+export function mergeFunctions(
+  target: RulesetFunctionCollection,
+  source: RulesetFunctionCollection,
+  rules: RuleCollection,
+) {
   const map: Dictionary<string, string> = {};
 
-  for (const [name, fn] of Object.entries(source)) {
+  for (const [name, def] of Object.entries(source)) {
     const newName = nanoid();
     map[name] = newName;
-    target[newName] = fn;
-    target[name] = fn;
+    target[newName] = def;
+    target[name] = {
+      name: def.name,
+      schema: def.schema,
+      ref: newName,
+    };
   }
 
   for (const rule of Object.values(rules)) {
diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts
index 35f11677..127957a2 100644
--- a/src/rulesets/reader.ts
+++ b/src/rulesets/reader.ts
@@ -1,25 +1,28 @@
 import { Cache } from '@stoplight/json-ref-resolver';
 import { ICache } from '@stoplight/json-ref-resolver/types';
 import { join } from '@stoplight/path';
+import { Optional } from '@stoplight/types';
 import { parse } from '@stoplight/yaml';
 import { JSONSchema7 } from 'json-schema';
 import { readFile, readParsable } from '../fs/reader';
 import { httpAndFileResolver } from '../resolvers/http-and-file';
-import { FunctionCollection, IFunction } from '../types';
-import { FileRulesetSeverity, IRuleset } from '../types/ruleset';
-import { evaluateExport } from './evaluators';
+import { FileRulesetSeverity, IRuleset, RulesetFunctionCollection } from '../types/ruleset';
 import { findFile } from './finder';
 import { mergeFormats, mergeFunctions, mergeRules } from './mergers';
-import { assertValidRuleset, wrapIFunctionWithSchema } from './validation';
+import { assertValidRuleset } from './validation';
 
-export async function readRuleset(uris: string | string[]): Promise<IRuleset> {
+export interface IRulesetReadOptions {
+  timeout?: number;
+}
+
+export async function readRuleset(uris: string | string[], opts?: IRulesetReadOptions): Promise<IRuleset> {
   const base: IRuleset = {
     rules: {},
     functions: {},
   };
 
   const processedRulesets = new Set<string>();
-  const processRuleset = createRulesetProcessor(processedRulesets, new Cache());
+  const processRuleset = createRulesetProcessor(processedRulesets, new Cache(), opts);
 
   for (const uri of Array.isArray(uris) ? new Set([...uris]) : [uris]) {
     processedRulesets.clear(); // makes sure each separate ruleset starts with clear list
@@ -32,7 +35,11 @@ export async function readRuleset(uris: string | string[]): Promise<IRuleset> {
   return base;
 }
 
-const createRulesetProcessor = (processedRulesets: Set<string>, uriCache: ICache) => {
+const createRulesetProcessor = (
+  processedRulesets: Set<string>,
+  uriCache: ICache,
+  readOpts: Optional<IRulesetReadOptions>,
+) => {
   return async function processRuleset(
     baseUri: string,
     uri: string,
@@ -44,19 +51,27 @@ const createRulesetProcessor = (processedRulesets: Set<string>, uriCache: ICache
     }
 
     processedRulesets.add(rulesetUri);
-    const { result } = await httpAndFileResolver.resolve(parse(await readParsable(rulesetUri, 'utf8')), {
-      baseUri: rulesetUri,
-      dereferenceInline: false,
-      uriCache,
-      async parseResolveResult(opts) {
-        try {
-          opts.result = parse(opts.result);
-        } catch {
-          // happens
-        }
-        return opts;
+    const { result } = await httpAndFileResolver.resolve(
+      parse(
+        await readParsable(rulesetUri, {
+          timeout: readOpts && readOpts.timeout,
+          encoding: 'utf-8',
+        }),
+      ),
+      {
+        baseUri: rulesetUri,
+        dereferenceInline: false,
+        uriCache,
+        async parseResolveResult(opts) {
+          try {
+            opts.result = parse(opts.result);
+          } catch {
+            // happens
+          }
+          return opts;
+        },
       },
-    });
+    );
     const ruleset = assertValidRuleset(JSON.parse(JSON.stringify(result)));
     const rules = {};
     const functions = {};
@@ -82,7 +97,7 @@ const createRulesetProcessor = (processedRulesets: Set<string>, uriCache: ICache
 
         if (extendedRuleset !== null) {
           mergeRules(rules, extendedRuleset.rules, parentSeverity);
-          mergeFunctions(functions, extendedRuleset.functions, rules);
+          Object.assign(functions, extendedRuleset.functions);
         }
       }
     }
@@ -97,7 +112,7 @@ const createRulesetProcessor = (processedRulesets: Set<string>, uriCache: ICache
         rulesetUri,
         ruleset.functionsDir !== void 0 ? join('..', ruleset.functionsDir) : '../functions',
       );
-      const resolvedFunctions: FunctionCollection = {};
+      const resolvedFunctions: RulesetFunctionCollection = {};
 
       await Promise.all(
         rulesetFunctions.map(async fn => {
@@ -105,16 +120,14 @@ const createRulesetProcessor = (processedRulesets: Set<string>, uriCache: ICache
           const fnSchema = Array.isArray(fn) ? (fn[1] as JSONSchema7) : null;
 
           try {
-            const exportedFn = evaluateExport(
-              await readFile(await findFile(rulesetFunctionsBaseDir, `./${fnName}.js`), 'utf-8'),
-            ) as IFunction;
-
-            resolvedFunctions[fnName] = fnSchema !== null ? wrapIFunctionWithSchema(exportedFn, fnSchema) : exportedFn;
-
-            Reflect.defineProperty(resolvedFunctions[fnName], 'name', {
-              configurable: true,
-              value: fnName,
-            });
+            resolvedFunctions[fnName] = {
+              name: fnName,
+              code: await readFile(await findFile(rulesetFunctionsBaseDir, `./${fnName}.js`), {
+                timeout: readOpts && readOpts.timeout,
+                encoding: 'utf-8',
+              }),
+              schema: fnSchema,
+            };
           } catch (ex) {
             console.warn(`Function '${fnName}' could not be loaded: ${ex.message}`);
           }
diff --git a/src/rulesets/validation.ts b/src/rulesets/validation.ts
index d15a2a15..44a1d3a6 100644
--- a/src/rulesets/validation.ts
+++ b/src/rulesets/validation.ts
@@ -5,7 +5,7 @@ import { ErrorObject } from 'ajv';
 const AJV = require('ajv');
 import * as ruleSchema from '../meta/rule.schema.json';
 import * as rulesetSchema from '../meta/ruleset.schema.json';
-import { Rule } from '../types';
+import { IFunction, IFunctionPaths, IFunctionValues, Rule } from '../types';
 
 const ajv = new AJV({ allErrors: true, jsonPointers: true });
 const validate = ajv.addSchema(ruleSchema).compile(rulesetSchema);
@@ -42,8 +42,8 @@ export function isValidRule(rule: FileRule): rule is Rule {
   return typeof rule === 'object' && rule !== null && !Array.isArray(rule) && ('given' in rule || 'then' in rule);
 }
 
-export function wrapIFunctionWithSchema(fn: Function, schema: JSONSchema7) {
-  return (data: unknown, opts: unknown, ...args: any[]) => {
+export function decorateIFunctionWithSchemaValidation(fn: IFunction, schema: JSONSchema7) {
+  return (data: unknown, opts: unknown, ...args: [IFunctionPaths, IFunctionValues]) => {
     if (!ajv.validate(schema, opts)) {
       throw new ValidationError(ajv.errors);
     }
diff --git a/src/spectral.ts b/src/spectral.ts
index d9227e7c..2391dd79 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -19,6 +19,8 @@ import { formatParserDiagnostics, formatResolverErrors } from './error-messages'
 import { functions as defaultFunctions } from './functions';
 import { Resolved } from './resolved';
 import { readRuleset } from './rulesets';
+import { compileExportedFunction } from './rulesets/evaluators';
+import { IRulesetReadOptions } from './rulesets/reader';
 import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity } from './rulesets/severity';
 import { runRules } from './runner';
 import {
@@ -34,6 +36,7 @@ import {
   RuleCollection,
   RunRuleCollection,
 } from './types';
+import { IRuleset } from './types/ruleset';
 
 export * from './types';
 
@@ -197,10 +200,35 @@ export class Spectral {
     }
   }
 
-  public async loadRuleset(uris: string[] | string) {
-    const { rules, functions } = await readRuleset(uris);
-    this._addRules(rules);
-    this._addFunctions(functions);
+  public async loadRuleset(uris: string[] | string, options?: IRulesetReadOptions) {
+    this.setRuleset(await readRuleset(Array.isArray(uris) ? uris : [uris], options));
+  }
+
+  public setRuleset(ruleset: IRuleset) {
+    this.setRules(ruleset.rules);
+
+    this.setFunctions(
+      Object.entries(ruleset.functions).reduce<FunctionCollection>(
+        (fns, [key, { code, ref, name, schema }]) => {
+          if (code === void 0) {
+            if (ref !== void 0) {
+              ({ code } = ruleset.functions[ref]);
+            }
+          }
+
+          if (code === void 0) {
+            // shall we log or sth?
+            return fns;
+          }
+
+          fns[key] = compileExportedFunction(code, name, schema);
+          return fns;
+        },
+        {
+          ...defaultFunctions,
+        },
+      ),
+    );
   }
 
   public registerFormat(format: string, fn: FormatLookup) {
diff --git a/src/types/ruleset.ts b/src/types/ruleset.ts
index cb4f53ad..8dccf2ef 100644
--- a/src/types/ruleset.ts
+++ b/src/types/ruleset.ts
@@ -2,7 +2,7 @@ import { Dictionary } from '@stoplight/types';
 import { DiagnosticSeverity } from '@stoplight/types';
 import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
 import { HumanReadableDiagnosticSeverity, Rule } from './rule';
-import { FunctionCollection, RuleCollection } from './spectral';
+import { RuleCollection } from './spectral';
 
 export type FileRuleSeverity = DiagnosticSeverity | HumanReadableDiagnosticSeverity | boolean;
 export type FileRulesetSeverity = 'off' | 'recommended' | 'all';
@@ -11,9 +11,18 @@ export type FileRule = Rule | FileRuleSeverity | [FileRuleSeverity] | [FileRuleS
 
 export type FileRuleCollection = Dictionary<FileRule, string>;
 
+export interface IRulesetFunctionDefinition {
+  code?: string;
+  ref?: string;
+  schema: JSONSchema7 | null;
+  name: string;
+}
+
+export type RulesetFunctionCollection = Dictionary<IRulesetFunctionDefinition, string>;
+
 export interface IRuleset {
   rules: RuleCollection;
-  functions: FunctionCollection;
+  functions: RulesetFunctionCollection;
 }
 
 export interface IRulesetFile {
@@ -23,7 +32,3 @@ export interface IRulesetFile {
   functionsDir?: string;
   functions?: Array<string | [string, JSONSchema4 | JSONSchema6 | JSONSchema7]>;
 }
-
-export interface IRulesetFileMergingStrategy {
-  severity?: FileRulesetSeverity;
-}
diff --git a/yarn.lock b/yarn.lock
index f8a2e23d..94b15712 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -881,6 +881,13 @@ abbrev@1.0.x:
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135"
   integrity sha1-kbR5JYinc4wl813W9jdSovh3YTU=
 
+abort-controller@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
+  integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
+  dependencies:
+    event-target-shim "^5.0.0"
+
 accepts@~1.3.4:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@@ -2640,6 +2647,11 @@ esutils@^2.0.2:
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
   integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=
 
+event-target-shim@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
+  integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
+
 eventemitter3@^3.0.0:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"


Test patch:
diff --git a/src/rulesets/__tests__/reader.jest.test.ts b/src/rulesets/__tests__/reader.jest.test.ts
index 4e05b197..06e09894 100644
--- a/src/rulesets/__tests__/reader.jest.test.ts
+++ b/src/rulesets/__tests__/reader.jest.test.ts
@@ -1,6 +1,7 @@
 import * as path from '@stoplight/path';
 import { Dictionary } from '@stoplight/types';
 import { DiagnosticSeverity } from '@stoplight/types';
+import * as fs from 'fs';
 import * as nock from 'nock';
 import { IRule, Rule } from '../../types';
 import { readRuleset } from '../reader';
@@ -24,6 +25,9 @@ const customFunctionsDirectoryRuleset = path.join(__dirname, './__fixtures__/cus
 const rulesetWithMissingFunctions = path.join(__dirname, './__fixtures__/ruleset-with-missing-functions.json');
 const fooExtendsBarRuleset = path.join(__dirname, './__fixtures__/foo-extends-bar-ruleset.json');
 const selfExtendingRuleset = path.join(__dirname, './__fixtures__/self-extending-ruleset.json');
+const fooCJSFunction = fs.readFileSync(path.join(__dirname, './__fixtures__/functions/foo.cjs.js'), 'utf-8');
+const barFunction = fs.readFileSync(path.join(__dirname, './__fixtures__/customFunctions/bar.js'), 'utf-8');
+const truthyFunction = fs.readFileSync(path.join(__dirname, './__fixtures__/customFunctions/truthy.js'), 'utf-8');
 const oasRuleset = require('../oas/index.json');
 const oas2Ruleset = require('../oas2/index.json');
 const oas3Ruleset = require('../oas3/index.json');
@@ -385,8 +389,16 @@ describe('Rulesets reader', () => {
   it('given a ruleset with custom functions should return rules and resolved functions', async () => {
     const ruleset = await readRuleset(fooRuleset);
     expect(ruleset.functions).toEqual({
-      'foo.cjs': expect.any(Function),
-      'random-id-0': ruleset.functions['foo.cjs'],
+      'foo.cjs': {
+        name: 'foo.cjs',
+        ref: 'random-id-0',
+        schema: null,
+      },
+      'random-id-0': {
+        name: 'foo.cjs',
+        code: fooCJSFunction,
+        schema: null,
+      },
     });
 
     expect(ruleset.rules).toEqual({
@@ -399,17 +411,31 @@ describe('Rulesets reader', () => {
         },
       }),
     });
-
-    expect((ruleset.functions['foo.cjs'] as Function)()).toEqual(5);
   });
 
   it('should load functions from custom directory', async () => {
     const ruleset = await readRuleset(customFunctionsDirectoryRuleset);
     expect(ruleset.functions).toEqual({
-      bar: expect.any(Function),
-      'random-id-0': expect.any(Function),
-      truthy: expect.any(Function),
-      'random-id-1': expect.any(Function),
+      bar: {
+        name: 'bar',
+        ref: 'random-id-0',
+        schema: null,
+      },
+      'random-id-0': {
+        name: 'bar',
+        code: barFunction,
+        schema: null,
+      },
+      truthy: {
+        name: 'truthy',
+        ref: 'random-id-1',
+        schema: null,
+      },
+      'random-id-1': {
+        name: 'truthy',
+        code: truthyFunction,
+        schema: null,
+      },
     });
 
     expect(ruleset.functions.bar).toHaveProperty('name', 'bar');
@@ -421,9 +447,7 @@ describe('Rulesets reader', () => {
         given: '$.info',
         severity: DiagnosticSeverity.Warning,
         then: {
-          function: Object.keys(ruleset.functions).find(
-            key => key !== 'bar' && ruleset.functions[key] === ruleset.functions.bar,
-          ),
+          function: 'random-id-0',
         },
       }),
       'truthy-rule': expect.objectContaining({
@@ -431,9 +455,7 @@ describe('Rulesets reader', () => {
         given: '$.x',
         severity: DiagnosticSeverity.Warning,
         then: {
-          function: Object.keys(ruleset.functions).find(
-            key => key !== 'truthy' && ruleset.functions[key] === ruleset.functions.truthy,
-          ),
+          function: 'random-id-1',
         },
       }),
     });
@@ -497,6 +519,17 @@ describe('Rulesets reader', () => {
     );
   });
 
+  it('should reject if request is not finished within a specified timeout', () => {
+    nock('https://unpkg.com')
+      .get('/oneParentRuleset')
+      .delay(10000)
+      .reply(200);
+
+    const ruleset = readRuleset('oneParentRuleset', { timeout: 100 });
+
+    return expect(ruleset).rejects.toThrowError('Could not parse https://unpkg.com/oneParentRuleset: Timeout');
+  });
+
   it('given invalid ruleset should output errors', () => {
     return expect(readRuleset(invalidRuleset)).rejects.toThrowError(/should have required property/);
   });
diff --git a/src/rulesets/__tests__/validation.test.ts b/src/rulesets/__tests__/validation.test.ts
index 32eeb934..0890cc55 100644
--- a/src/rulesets/__tests__/validation.test.ts
+++ b/src/rulesets/__tests__/validation.test.ts
@@ -1,5 +1,5 @@
 import { JSONSchema7 } from 'json-schema';
-import { assertValidRuleset, ValidationError, wrapIFunctionWithSchema } from '../validation';
+import { assertValidRuleset, decorateIFunctionWithSchemaValidation, ValidationError } from '../validation';
 const invalidRuleset = require('./__fixtures__/invalid-ruleset.json');
 const validRuleset = require('./__fixtures__/valid-flat-ruleset.json');
 
@@ -240,30 +240,30 @@ describe('Ruleset Validation', () => {
 describe('Function Validation', () => {
   it('throws if options supplied to fn does not meet schema', () => {
     const schema: JSONSchema7 = { type: 'string' };
-    const wrapped = wrapIFunctionWithSchema(Function, schema);
-    expect(() => wrapped({}, 2)).toThrow(ValidationError);
+    const wrapped = decorateIFunctionWithSchemaValidation(jest.fn(), schema);
+    expect(() => wrapped({}, 2, { given: [] }, { original: [], given: [] })).toThrow(ValidationError);
   });
 
   it('does not call supplied fn if options do not meet schema', () => {
     const schema: JSONSchema7 = { type: 'string' };
     const fn = jest.fn();
-    const wrapped = wrapIFunctionWithSchema(fn, schema);
+    const wrapped = decorateIFunctionWithSchemaValidation(fn, schema);
     try {
-      wrapped({}, 2);
+      wrapped({}, 2, { given: [] }, { original: [], given: [] });
     } catch {
       // will throw
     }
 
     expect(fn).not.toHaveBeenCalled();
-    expect(() => wrapped({}, 2)).toThrow(ValidationError);
+    expect(() => wrapped({}, {}, { given: [] }, { original: [], given: [] })).toThrow(ValidationError);
   });
 
-  it('calls supplied fn and passes all other arguments if options do not match schema', () => {
+  it('calls supplied fn and passes all other arguments if options do match schema', () => {
     const schema: JSONSchema7 = { type: 'string' };
     const fn = jest.fn();
-    const wrapped = wrapIFunctionWithSchema(fn, schema);
-    wrapped({}, '2', true, 1, []);
+    const wrapped = decorateIFunctionWithSchemaValidation(fn, schema);
+    wrapped({}, '2', { given: [] }, { original: [], given: [] });
 
-    expect(fn).toHaveBeenCalledWith({}, '2', true, 1, []);
+    expect(fn).toHaveBeenCalledWith({}, '2', { given: [] }, { original: [], given: [] });
   });
 });
diff --git a/src/rulesets/mergers/__tests__/functions.jest.test.ts b/src/rulesets/mergers/__tests__/functions.jest.test.ts
index 0c36c1da..2fbd364d 100644
--- a/src/rulesets/mergers/__tests__/functions.jest.test.ts
+++ b/src/rulesets/mergers/__tests__/functions.jest.test.ts
@@ -1,4 +1,5 @@
-import { FunctionCollection, IFunction, RuleCollection } from '../../../types';
+import { RuleCollection } from '../../../types';
+import { RulesetFunctionCollection } from '../../../types/ruleset';
 import { mergeFunctions } from '../functions';
 const nanoid = require('nanoid');
 
@@ -12,9 +13,12 @@ describe('Ruleset functions merging', () => {
 
   it('re-writes function names', () => {
     const target = {};
-    const foo = Function() as IFunction;
-    const sources: FunctionCollection = {
-      foo,
+    const sources: RulesetFunctionCollection = {
+      foo: {
+        name: 'foo',
+        code: 'foo()',
+        schema: null,
+      },
     };
 
     const rules: RuleCollection = {
@@ -29,33 +33,63 @@ describe('Ruleset functions merging', () => {
     mergeFunctions(target, sources, rules);
 
     expect(rules).toHaveProperty('myRule.then.function', 'random-id-0');
-    expect(target).toHaveProperty('random-id-0', foo);
+    expect(target).toHaveProperty('random-id-0', {
+      name: 'foo',
+      code: 'foo()',
+      schema: null,
+    });
   });
 
   it('overrides existing global function', () => {
-    const target: FunctionCollection = {
-      foo: Function() as IFunction,
+    const target: RulesetFunctionCollection = {
+      foo: {
+        name: 'foo',
+        code: 'foo()',
+        schema: null,
+      },
     };
-    const foo = Function() as IFunction;
-    const sources: FunctionCollection = {
-      foo,
+    const sources: RulesetFunctionCollection = {
+      foo: {
+        name: 'foo.c',
+        code: 'foo.a()',
+        schema: null,
+      },
     };
 
     mergeFunctions(target, sources, {});
 
-    expect(target).toHaveProperty('random-id-0', foo);
-    expect(target).toHaveProperty('foo', foo);
+    expect(target).toHaveProperty('random-id-0', {
+      name: 'foo.c',
+      code: 'foo.a()',
+      schema: null,
+    });
+    expect(target).toHaveProperty('foo', {
+      name: 'foo.c',
+      ref: 'random-id-0',
+      schema: null,
+    });
   });
 
   it('overrides all function names', () => {
-    const target: FunctionCollection = {
-      foo: Function() as IFunction,
+    const target: RulesetFunctionCollection = {
+      foo: {
+        name: 'foo',
+        code: 'foo()',
+        schema: null,
+      },
     };
-    const foo = Function() as IFunction;
-    const bar = Function() as IFunction;
-    const sources: FunctionCollection = {
-      foo,
-      bar,
+
+    const sources: RulesetFunctionCollection = {
+      foo: {
+        name: 'foo',
+        code: 'a.foo.c();',
+        schema: null,
+      },
+      bar: {
+        name: 'bar',
+        code: 'bar()',
+        schema: null,
+      },
     };
 
     const rules: RuleCollection = {
@@ -74,8 +108,16 @@ describe('Ruleset functions merging', () => {
 
     mergeFunctions(target, sources, rules);
 
-    expect(target).toHaveProperty('random-id-0', foo);
-    expect(target).toHaveProperty('random-id-1', bar);
+    expect(target).toHaveProperty('random-id-0', {
+      name: 'foo',
+      code: 'a.foo.c();',
+      schema: null,
+    });
+    expect(target).toHaveProperty('random-id-1', {
+      name: 'bar',
+      code: 'bar()',
+      schema: null,
+    });
   });
 
   it('does not rewrite function name if function cannot be referenced', () => {