From b3f16d3ad239c9eec749ad3b2f63cbbd946fb7c8 Mon Sep 17 00:00:00 2001 From: Will Norris Date: Tue, 30 Mar 2021 13:27:13 -0700 Subject: [PATCH] add 'extends' property for extending ruleset --- README.md | 50 ++++++++++++++++++++++++++++++++ lib/config.js | 26 ++++++++++++++++- rulesets/schema.json | 4 +++ tests/lib/absolute-override.yaml | 6 ++++ tests/lib/config_tests.js | 40 +++++++++++++++++++++++++ tests/lib/loop-a.yaml | 6 ++++ tests/lib/loop-b.yaml | 6 ++++ tests/lib/loop-self.yaml | 6 ++++ tests/lib/repolinter.yaml | 1 + 9 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/lib/absolute-override.yaml create mode 100644 tests/lib/loop-a.yaml create mode 100644 tests/lib/loop-b.yaml create mode 100644 tests/lib/loop-self.yaml diff --git a/README.md b/README.md index 7dc644ab..e485bc5e 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,56 @@ rules: ... ``` +### Extending Rulesets + +A ruleset can extend another ruleset, in which case the two files will be +recursively merged. Extended rulesets can themselves extend additional rulesets +up to 20 rulesets deep. + +Extend a ruleset by including an "extends" top-level key which identifies a URL +or file path: + +```JavaScript +{ + "extends": "https://raw.githubusercontent.com/todogroup/repolinter/master/rulesets/default.json" + "rules": { + # disable CI check + "integrates-with-ci": { + "level": "off" + } + } +} +``` + +```YAML +extends: https://raw.githubusercontent.com/todogroup/repolinter/master/rulesets/default.json +rules: + # disable CI check + integrates-with-ci + level: off + ... +``` + +Relative paths are resolved relative to the location used to access the +extending file. For example, if repolinter is invoked as: + +``` +repolinter -u http://example.com/custom-rules.yaml +``` + +And that ruleset includes `extends: "./default.yaml"`, the path will be resolved +relative to the original URL as `http://example.com/default.yaml`. If instead +repolinter is invoked as: + +``` +repolinter -r /etc/repolinter/custom-rules.yaml +``` + +And that ruleset includes `extends: "./default.yaml"`, the path will be resolved +relative to the original file path as `/etc/repolinter/default.yaml`. + +YAML and JSON rulesets can be extended from either format. + ## API Repolinter also includes an extensible JavaScript API: diff --git a/lib/config.js b/lib/config.js index 396e157b..4c915b56 100644 --- a/lib/config.js +++ b/lib/config.js @@ -6,6 +6,7 @@ const fetch = require('node-fetch') const findFile = require('find-config') const fs = require('fs') const jsonfile = require('jsonfile') +const lodash = require('lodash') const path = require('path') const yaml = require('js-yaml') @@ -56,10 +57,11 @@ function findConfig(directory) { * Load a ruleset config from the specified location. * * @param {string} configLocation A URL or local file containing a repolinter config file + * @param {array} [processed] List of config files already processed, used to prevent loops * @returns {Object} The loaded repolinter json config * @throws Will throw an error if unable to parse config or if config is invalid */ -async function loadConfig(configLocation) { +async function loadConfig(configLocation, processed = []) { if (!configLocation) { throw new Error('must specify config location') } @@ -91,6 +93,28 @@ async function loadConfig(configLocation) { } } + // merge extended rulesets + if (ruleset.extends) { + processed.push(configLocation) + if (processed.length > 20) { + // safeguard against infinite loops. expose as flag one day if needed + throw new Error('exceeded maximum 20 ruleset extensions') + } + + let parent + if (isAbsoluteURL(ruleset.extends)) { + parent = ruleset.extends + } else if (isAbsoluteURL(configLocation)) { + parent = new URL(ruleset.extends, configLocation) + } else { + parent = path.resolve(path.dirname(configLocation), ruleset.extends) + } + if (!processed.includes(parent)) { + const parentRuleset = await loadConfig(parent, processed) + ruleset = lodash.merge({}, parentRuleset, ruleset) + } + } + return ruleset } diff --git a/rulesets/schema.json b/rulesets/schema.json index 9e4a176e..8a9922b6 100644 --- a/rulesets/schema.json +++ b/rulesets/schema.json @@ -14,6 +14,10 @@ "properties": { "$schema": { "type": "string" }, "version": { "const": 2 }, + "extends": { + "type": "string", + "title": "URL or path of ruleset file this ruleset extends" + }, "axioms": { "type": "object", "title": "The axioms schema", diff --git a/tests/lib/absolute-override.yaml b/tests/lib/absolute-override.yaml new file mode 100644 index 00000000..67542262 --- /dev/null +++ b/tests/lib/absolute-override.yaml @@ -0,0 +1,6 @@ +$schema: "../../rulesets/schema.json" +extends: "http://localhost:9000/default.json" +version: 2 +rules: + test-file-exists: + level: off diff --git a/tests/lib/config_tests.js b/tests/lib/config_tests.js index f35caf3c..8bff17cd 100644 --- a/tests/lib/config_tests.js +++ b/tests/lib/config_tests.js @@ -78,6 +78,46 @@ describe('lib', () => { expect(actual.rules['test-file-exists'].level).to.equals('error') }) + it('should handle relative file extends', async () => { + const actual = await Config.loadConfig( + path.join(__dirname, 'repolinter.yaml') + ) + expect(actual.rules).to.have.property('test-file-exists') + expect(actual.rules['test-file-exists'].level).to.equals('error') + }) + + it('should handle relative URL extends', async () => { + server.on(serveDirectory(__dirname)) + const actual = await Config.loadConfig( + 'http://localhost:9000/repolinter.yaml' + ) + expect(actual.rules).to.have.property('test-file-exists') + expect(actual.rules['test-file-exists'].level).to.equals('error') + }) + + it('should handle absolute URL extends', async () => { + server.on(serveDirectory(__dirname)) + const actual = await Config.loadConfig( + path.join(__dirname, 'absolute-override.yaml') + ) + expect(actual.rules).to.have.property('test-file-exists') + expect(actual.rules['test-file-exists'].level).to.equals('off') + }) + + it('should detect loops in extended rulesets', async () => { + const loopSelf = await Config.loadConfig( + path.join(__dirname, 'loop-self.yaml') + ) + expect(loopSelf.rules).to.have.property('test-file-exists') + expect(loopSelf.rules['test-file-exists'].level).to.equals('error') + + const loopB = await Config.loadConfig( + path.join(__dirname, 'loop-b.yaml') + ) + expect(loopB.rules).to.have.property('test-file-exists') + expect(loopB.rules['test-file-exists'].level).to.equals('off') + }) + it('should throw error on non existant file', async () => { expect(Config.loadConfig('/does-not-exist')).to.eventually.throw( 'ENOENT' diff --git a/tests/lib/loop-a.yaml b/tests/lib/loop-a.yaml new file mode 100644 index 00000000..ff3345e0 --- /dev/null +++ b/tests/lib/loop-a.yaml @@ -0,0 +1,6 @@ +$schema: "../../rulesets/schema.json" +extends: "./loop-b.yaml" +version: 2 +rules: + test-file-exists: + level: error diff --git a/tests/lib/loop-b.yaml b/tests/lib/loop-b.yaml new file mode 100644 index 00000000..5df25ac4 --- /dev/null +++ b/tests/lib/loop-b.yaml @@ -0,0 +1,6 @@ +$schema: "../../rulesets/schema.json" +extends: "./loop-a.yaml" +version: 2 +rules: + test-file-exists: + level: off diff --git a/tests/lib/loop-self.yaml b/tests/lib/loop-self.yaml new file mode 100644 index 00000000..c752eab0 --- /dev/null +++ b/tests/lib/loop-self.yaml @@ -0,0 +1,6 @@ +$schema: "../../rulesets/schema.json" +extends: "./loop-self.yaml" +version: 2 +rules: + test-file-exists: + level: error diff --git a/tests/lib/repolinter.yaml b/tests/lib/repolinter.yaml index 3cbc5a93..d207119e 100644 --- a/tests/lib/repolinter.yaml +++ b/tests/lib/repolinter.yaml @@ -1,2 +1,3 @@ $schema: "../../rulesets/schema.json" +extends: "./default.json" version: 2