diff --git a/src/rulesets/__tests__/evaluators.test.ts b/src/rulesets/__tests__/evaluators.test.ts index 31d558d67..92551967e 100644 --- a/src/rulesets/__tests__/evaluators.test.ts +++ b/src/rulesets/__tests__/evaluators.test.ts @@ -3,61 +3,61 @@ import { evaluateExport } from '../evaluators'; describe('Code evaluators', () => { describe('Export evaluator', () => { it('detects CJS default export', () => { - const exported = evaluateExport(`module.exports = function a(x, y) {}`); + const exported = evaluateExport(`module.exports = function a(x, y) {}`, null); expect(exported).toBeInstanceOf(Function); expect(exported).toHaveProperty('name', 'a'); expect(exported).toHaveProperty('length', 2); }); it('detects CJS-ES compatible default export', () => { - const exported = evaluateExport(`exports.default = function b(x, y) {}`); + const exported = evaluateExport(`exports.default = function b(x, y) {}`, null); expect(exported).toBeInstanceOf(Function); expect(exported).toHaveProperty('name', 'b'); expect(exported).toHaveProperty('length', 2); }); it('detects CJS-ES compatible default export variant #2', () => { - const exported = evaluateExport(`module.exports.default = function c(x, y, z) {}`); + const exported = evaluateExport(`module.exports.default = function c(x, y, z) {}`, null); expect(exported).toBeInstanceOf(Function); expect(exported).toHaveProperty('name', 'c'); expect(exported).toHaveProperty('length', 3); }); it('detects AMD export', () => { - const exported = evaluateExport(`define(['exports'], () => function d(x){} )`); + const exported = evaluateExport(`define(['exports'], () => function d(x){} )`, null); expect(exported).toBeInstanceOf(Function); expect(exported).toHaveProperty('name', 'd'); expect(exported).toHaveProperty('length', 1); }); it('detects anonymous AMD export', () => { - const exported = evaluateExport(`define(() => function d(x){} )`); + const exported = evaluateExport(`define(() => function d(x){} )`, null); expect(exported).toBeInstanceOf(Function); expect(exported).toHaveProperty('name', 'd'); expect(exported).toHaveProperty('length', 1); }); it('detects context-based export', () => { - const exported = evaluateExport(`this.returnExports = function e() {}`); + const exported = evaluateExport(`this.returnExports = function e() {}`, null); expect(exported).toBeInstanceOf(Function); expect(exported).toHaveProperty('name', 'e'); expect(exported).toHaveProperty('length', 0); }); it('detects context-based export', () => { - const exported = evaluateExport(`this.returnExports = function e() {}`); + const exported = evaluateExport(`this.returnExports = function e() {}`, null); expect(exported).toBeInstanceOf(Function); expect(exported).toHaveProperty('name', 'e'); expect(exported).toHaveProperty('length', 0); }); it('throws error if no default export can be found', () => { - expect(() => evaluateExport(`exports.a = function b(x, y) {}`)).toThrow(); + expect(() => evaluateExport(`exports.a = function b(x, y) {}`, null)).toThrow(); }); it('throws error default export is not a function', () => { - expect(() => evaluateExport(`module.exports = 2`)).toThrow(); - expect(() => evaluateExport(`this.returnExports = {}`)).toThrow(); + expect(() => evaluateExport(`module.exports = 2`, null)).toThrow(); + expect(() => evaluateExport(`this.returnExports = {}`, null)).toThrow(); }); }); }); diff --git a/src/rulesets/__tests__/reader.jest.test.ts b/src/rulesets/__tests__/reader.jest.test.ts index 953be8ed1..a62737434 100644 --- a/src/rulesets/__tests__/reader.jest.test.ts +++ b/src/rulesets/__tests__/reader.jest.test.ts @@ -563,11 +563,13 @@ describe('Rulesets reader', () => { name: 'foo.cjs', ref: 'random-id-0', schema: null, + source: path.join(fooRuleset, '../functions/foo.cjs.js'), }, 'random-id-0': { name: 'foo.cjs', code: fooCJSFunction, schema: null, + source: path.join(fooRuleset, '../functions/foo.cjs.js'), }, }); @@ -592,11 +594,13 @@ describe('Rulesets reader', () => { name: 'bar', ref: expect.stringMatching(/^random-id-[01]$/), schema: null, + source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/bar.js'), }, truthy: { name: 'truthy', ref: expect.stringMatching(/^random-id-[01]$/), schema: null, + source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/truthy.js'), }, }), ); @@ -613,12 +617,14 @@ describe('Rulesets reader', () => { name: 'bar', code: barFunction, schema: null, + source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/bar.js'), }); expect(truthyFunctionDef).toEqual({ name: 'truthy', code: truthyFunction, schema: null, + source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/truthy.js'), }); expect(ruleset.functions.bar).toHaveProperty('name', 'bar'); diff --git a/src/rulesets/evaluators.ts b/src/rulesets/evaluators.ts index 57e5a8f57..39834e9a2 100644 --- a/src/rulesets/evaluators.ts +++ b/src/rulesets/evaluators.ts @@ -1,12 +1,72 @@ -import { Optional } from '@stoplight/types'; +import { isAbsolute, join, stripRoot } from '@stoplight/path'; +import { Dictionary, Optional } from '@stoplight/types'; import { isObject } from 'lodash'; import { IFunction, JSONSchema } from '../types'; import { decorateIFunctionWithSchemaValidation } from './validation'; -export type CJSExport = Partial<{ exports: object | ESCJSCompatibleExport }>; +export type CJSExport = Partial<{ exports: object | ESCJSCompatibleExport; require: NodeJS.Require }>; export type ESCJSCompatibleExport = Partial<{ default: unknown }>; export type ContextExport = Partial<{ returnExports: unknown }>; +function requireUnavailable() { + throw new ReferenceError('require() is supported only in the Node.JS environment'); +} + +function stubRequire(): NodeJS.Require { + function req() { + requireUnavailable(); + } + + const descriptors: Dictionary = { + resolve: { + enumerable: true, + get: requireUnavailable, + }, + + main: { + enumerable: true, + get: requireUnavailable, + }, + + cache: { + enumerable: true, + get: requireUnavailable, + }, + + extensions: { + enumerable: true, + get: requireUnavailable, + }, + }; + + return Object.defineProperties(req, descriptors); +} + +function proxyRequire(source: string): NodeJS.Require { + const actualRequire = require; + function req(p: string) { + if (!isAbsolute(p)) { + p = join(source, '..', stripRoot(p)); + } + + return actualRequire.call(null, p); + } + + return Object.defineProperties(req, Object.getOwnPropertyDescriptors(actualRequire)); +} + +const createRequire = (source: string | null): NodeJS.Require => { + if (typeof require === 'undefined') { + return stubRequire(); + } + + if (source === null) { + return require; + } + + return proxyRequire(source); +}; + const createDefine = (exports: CJSExport) => { const define = (nameOrFactory: string | string[] | Function, factory: Function): Optional => { if (typeof nameOrFactory === 'function') { @@ -32,15 +92,17 @@ const isESCJSCompatibleExport = (obj: unknown): obj is ESCJSCompatibleExport => // note: this code is hand-crafted and cover cases we want to support // be aware of using it in your own project if you need to support a variety of module systems -export const evaluateExport = (body: string): Function => { +export const evaluateExport = (body: string, source: string | null): Function => { + const req = createRequire(source); const mod: CJSExport = { exports: {}, + require: req, }; const exports: ESCJSCompatibleExport | unknown = {}; const root: ContextExport = {}; const define = createDefine(mod); - Function('module, exports, define', String(body)).call(root, mod, exports, define); + Function('module, exports, define, require', String(body)).call(root, mod, exports, define, req); let maybeFn: unknown; @@ -61,8 +123,13 @@ export const evaluateExport = (body: string): Function => { return maybeFn; }; -export const compileExportedFunction = (code: string, name: string, schema: JSONSchema | null) => { - const exportedFn = evaluateExport(code) as IFunction; +export const compileExportedFunction = ( + code: string, + name: string, + source: string | null, + schema: JSONSchema | null, +) => { + const exportedFn = evaluateExport(code, source) as IFunction; const fn = schema !== null ? decorateIFunctionWithSchemaValidation(exportedFn, schema) : exportedFn; diff --git a/src/rulesets/mergers/__tests__/functions.jest.test.ts b/src/rulesets/mergers/__tests__/functions.jest.test.ts index 8c73da171..93a92bce0 100644 --- a/src/rulesets/mergers/__tests__/functions.jest.test.ts +++ b/src/rulesets/mergers/__tests__/functions.jest.test.ts @@ -18,6 +18,7 @@ describe('Ruleset functions merging', () => { name: 'foo', code: 'foo()', schema: null, + source: null, }, }; @@ -37,6 +38,7 @@ describe('Ruleset functions merging', () => { name: 'foo', code: 'foo()', schema: null, + source: null, }); }); @@ -46,6 +48,7 @@ describe('Ruleset functions merging', () => { name: 'foo', code: 'foo()', schema: null, + source: null, }, }; const sources: RulesetFunctionCollection = { @@ -53,6 +56,7 @@ describe('Ruleset functions merging', () => { name: 'foo.c', code: 'foo.a()', schema: null, + source: 'foo', }, }; @@ -62,11 +66,13 @@ describe('Ruleset functions merging', () => { name: 'foo.c', code: 'foo.a()', schema: null, + source: 'foo', }); expect(target).toHaveProperty('foo', { name: 'foo.c', ref: 'random-id-0', schema: null, + source: 'foo', }); }); @@ -76,6 +82,7 @@ describe('Ruleset functions merging', () => { name: 'foo', code: 'foo()', schema: null, + source: null, }, }; @@ -84,11 +91,13 @@ describe('Ruleset functions merging', () => { name: 'foo', code: 'a.foo.c();', schema: null, + source: null, }, bar: { name: 'bar', code: 'bar()', schema: null, + source: null, }, }; @@ -112,11 +121,13 @@ describe('Ruleset functions merging', () => { name: 'foo', code: 'a.foo.c();', schema: null, + source: null, }); expect(target).toHaveProperty('random-id-1', { name: 'bar', code: 'bar()', schema: null, + source: null, }); }); diff --git a/src/rulesets/mergers/functions.ts b/src/rulesets/mergers/functions.ts index 020a1a64f..be7b52dbf 100644 --- a/src/rulesets/mergers/functions.ts +++ b/src/rulesets/mergers/functions.ts @@ -18,6 +18,7 @@ export function mergeFunctions( name: def.name, schema: def.schema, ref: newName, + source: def.source, }; } diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts index 166a58526..86eef2b1f 100644 --- a/src/rulesets/reader.ts +++ b/src/rulesets/reader.ts @@ -130,15 +130,17 @@ const createRulesetProcessor = ( rulesetFunctions.map(async fn => { const fnName = Array.isArray(fn) ? fn[0] : fn; const fnSchema = Array.isArray(fn) ? fn[1] : null; + const source = await findFile(rulesetFunctionsBaseDir, `./${fnName}.js`); try { resolvedFunctions[fnName] = { name: fnName, - code: await readFile(await findFile(rulesetFunctionsBaseDir, `./${fnName}.js`), { + code: await readFile(source, { timeout: readOpts && readOpts.timeout, encoding: 'utf8', }), schema: fnSchema, + source, }; } catch (ex) { console.warn(`Function '${fnName}' could not be loaded: ${ex.message}`); diff --git a/src/spectral.ts b/src/spectral.ts index 120bc7a2d..2bd8b7e92 100644 --- a/src/spectral.ts +++ b/src/spectral.ts @@ -156,7 +156,7 @@ export class Spectral { this.setFunctions( Object.entries(ruleset.functions).reduce( - (fns, [key, { code, ref, name, schema }]) => { + (fns, [key, { code, ref, name, source, schema }]) => { if (code === void 0) { if (ref !== void 0) { ({ code } = ruleset.functions[ref]); @@ -168,7 +168,7 @@ export class Spectral { return fns; } - fns[key] = compileExportedFunction(code, name, schema); + fns[key] = compileExportedFunction(code, name, source, schema); return fns; }, { diff --git a/src/types/ruleset.ts b/src/types/ruleset.ts index 1651a1298..19fcadeeb 100644 --- a/src/types/ruleset.ts +++ b/src/types/ruleset.ts @@ -15,6 +15,7 @@ export interface IRulesetFunctionDefinition { ref?: string; schema: JSONSchema | null; name: string; + source: string | null; } export type RulesetFunctionCollection = Dictionary;