diff --git a/docs/guides/custom-functions.md b/docs/guides/custom-functions.md index a46d1deea..e1572bede 100644 --- a/docs/guides/custom-functions.md +++ b/docs/guides/custom-functions.md @@ -233,6 +233,30 @@ module.exports = (targetVal, _opts, paths) => { }; ``` +Furthermore, if your function performs any fs calls to obtain dictionaries and similar, you may want to leverage cache. +Each custom function is provided with its **own** cache instance that has a function-live lifespan. +What does "function-live lifespan" mean? It means the cache is persisted for the whole life of a particular function. +The cache will be retained between subsequent function calls and is never invalidated unless you compile the function again, i.e. load a ruleset again. +In other words: +- if you Spectral programmatically via JS API and your ruleset remains unchanged, all subsequent `spectral.run` calls will invoke custom functions with the same cache instance. +As soon as you set a ruleset using `setRuleset` or `loadRuleset` method, each custom function will receive a new cache instance. +- if you are a CLI user, the cache will never be invalidated during the timespan of process. + +Please bear in mind that while you are welcome to store certain kind of data, using cache for exchanging information between subsequent function calls it strongly discouraged. +Spectral does not guarantee any particular order of execution meaning the functions can be executed in random order, depending on the rules you have, and the document you lint. + +```js +module.exports = function () { + if (!this.cache.has('cached-item')) { + this.cache.set('cached-item', anyValue); + } + + const cached = this.cache.get('cached-item'); + + // the rest of function +} +``` + ## Inheritance Core functions can be overridden with custom rulesets, so if you'd like to make your own truthy go ahead. Custom functions are only available in the ruleset which defines them, so loading a foo in one ruleset will not clobber a foo in another ruleset. diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts index 232c4effd..9f491fe77 100644 --- a/src/__tests__/linter.jest.test.ts +++ b/src/__tests__/linter.jest.test.ts @@ -19,6 +19,10 @@ describe('Linter', () => { spectral = new Spectral(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should make use of custom functions', async () => { await spectral.loadRuleset(customFunctionOASRuleset); expect(await spectral.run({})).toEqual([ @@ -66,6 +70,102 @@ describe('Linter', () => { ); }); + it('should expose function-live lifespan cache to custom functions', async () => { + const logSpy = jest.spyOn(global.console, 'log').mockImplementation(Function); + + await spectral.setRuleset({ + exceptions: {}, + rules: { + foo: { + given: '$', + then: { + function: 'fn', + }, + }, + bar: { + given: '$', + then: { + function: 'fn', + }, + }, + }, + functions: { + fn: { + source: null, + name: 'fn', + schema: null, + code: `module.exports = function() { +console.log(this.cache.get('test') || this.cache.set('test', []).get('test')); +}`, + }, + }, + }); + + await spectral.run({}); + + // verifies whether the 2 subsequent calls passed the same cache instance as the first argument + expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[1][0]); + + await spectral.run({}); + + expect(logSpy.mock.calls[2][0]).toBe(logSpy.mock.calls[3][0]); + expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[2][0]); + }); + + it('should expose cache to custom functions that is not shared among them', async () => { + const logSpy = jest.spyOn(global.console, 'log').mockImplementation(Function); + + await spectral.setRuleset({ + exceptions: {}, + rules: { + foo: { + given: '$', + then: { + function: 'fn', + }, + }, + bar: { + given: '$', + then: { + function: 'fn-2', + }, + }, + }, + functions: { + fn: { + source: null, + name: 'fn', + schema: null, + code: `module.exports = function() { +console.log(this.cache.get('test') || this.cache.set('test', []).get('test')); +}`, + }, + 'fn-2': { + source: null, + name: 'fn-2', + schema: null, + code: `module.exports = function() { +console.log(this.cache.get('test') || this.cache.set('test', []).get('test')); +}`, + }, + }, + }); + + await spectral.run({}); + + // verifies whether the 2 subsequent calls **DID NOT** pass the same cache instance as the first argument + expect(logSpy.mock.calls[0][0]).not.toBe(logSpy.mock.calls[1][0]); + + await spectral.run({}); + + // verifies whether the 2 subsequent calls **DID NOT** pass the same cache instance as the first argument + expect(logSpy.mock.calls[2][0]).not.toBe(logSpy.mock.calls[3][0]); + + // verifies whether the 2 subsequent calls to the same function passe the same cache instance as the first argument + expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[2][0]); + expect(logSpy.mock.calls[1][0]).toBe(logSpy.mock.calls[3][0]); + }); + it('should support require calls', async () => { await spectral.loadRuleset(customFunctionOASRuleset); expect( diff --git a/src/spectral.ts b/src/spectral.ts index 68c161c1b..ebcd71b5f 100644 --- a/src/spectral.ts +++ b/src/spectral.ts @@ -171,6 +171,7 @@ export class Spectral { const context: IFunctionContext = { functions: this.functions, + cache: new Map(), }; fns[key] = setFunctionContext(context, compileExportedFunction(code, name, source, schema)); diff --git a/src/types/function.ts b/src/types/function.ts index 356167df6..25729b55d 100644 --- a/src/types/function.ts +++ b/src/types/function.ts @@ -4,6 +4,7 @@ import { CoreFunctions } from '../functions'; export interface IFunctionContext { functions: CoreFunctions; + cache: Map; } export type IFunction = (