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/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 new file mode 100644 index 00000000..956ee8f3 --- /dev/null +++ b/tests/lib/config_tests.js @@ -0,0 +1,129 @@ +// Copyright 2017 TODO Group. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const chai = require('chai') +const chaiAsPromised = require('chai-as-promised') +const expect = chai.expect +const path = require('path') +const fs = require('fs') +const ServerMock = require('mock-http-server') + +chai.use(chaiAsPromised) + +describe('lib', () => { + describe('config', function () { + const Config = require('../../lib/config') + + this.timeout(10000) + + describe('isAbsoluteURL', () => { + it('should identify absolute URLs', async () => { + expect(Config.isAbsoluteURL('http://example.com/')).to.equals(true) + expect(Config.isAbsoluteURL('https://example.com/')).to.equals(true) + expect(Config.isAbsoluteURL('ftp://example.com/')).to.equals(true) + }) + + it('should identify relative URLs', async () => { + expect(Config.isAbsoluteURL('foo')).to.equals(false) + expect(Config.isAbsoluteURL('/foo')).to.equals(false) + expect(Config.isAbsoluteURL('file:/foo')).to.equals(false) + expect(Config.isAbsoluteURL('file:///foo')).to.equals(false) + expect(Config.isAbsoluteURL('c:\\foo')).to.equals(false) + }) + }) + + describe('findConfig', () => { + it('should find config file in directory', async () => { + const localConfig = path.join(__dirname, 'repolinter.yaml') + expect(Config.findConfig(__dirname)).to.equals(localConfig) + }) + it('should return default file when no config present', async () => { + const parent = path.join(__dirname, '..') + const defaultConfig = path.join( + __dirname, + '../../rulesets/default.json' + ) + expect(Config.findConfig(parent)).to.equals(defaultConfig) + }) + }) + + describe('loadConfig', async () => { + const server = new ServerMock({ host: 'localhost', port: 9000 }, {}) + const serveDirectory = dir => ({ + method: 'GET', + path: '*', + reply: { + status: 200, + body: request => + fs.readFileSync(path.resolve(dir, request.pathname.substring(1))) + } + }) + beforeEach(done => server.start(done)) + afterEach(done => server.stop(done)) + + it('should load local config file', async () => { + const actual = await Config.loadConfig( + path.join(__dirname, 'default.json') + ) + expect(actual.rules).to.have.property('test-file-exists') + expect(actual.rules['test-file-exists'].level).to.equals('error') + }) + + it('should load URL config file', async () => { + server.on(serveDirectory(__dirname)) + const actual = await Config.loadConfig( + 'http://localhost:9000/default.json' + ) + expect(actual.rules).to.have.property('test-file-exists') + 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 throw error on non existant file', async () => { + expect(Config.loadConfig('/does-not-exist')).to.eventually.throw( + 'ENOENT' + ) + }) + + it('should throw error on non existant URL', async () => { + server.on(serveDirectory(__dirname)) + expect( + Config.loadConfig('http://localhost:9000/404') + ).to.eventually.throw('404') + }) + }) + + describe('validateConfig', () => { + // already tested as part of the repolinter api tests in tests/api + }) + + describe('parseConfig', () => { + // already tested as part of the repolinter api tests in tests/api + }) + }) +}) diff --git a/tests/lib/default.json b/tests/lib/default.json new file mode 100644 index 00000000..078b9916 --- /dev/null +++ b/tests/lib/default.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../rulesets/schema.json", + "version": 2, + "axioms": {}, + "rules": { + "test-file-exists": { + "level": "error", + "rule": { + "type": "file-existence", + "options": { + "globsAny": ["text_file_for_test.txt"] + } + } + } + } +} diff --git a/tests/lib/repolinter.yaml b/tests/lib/repolinter.yaml new file mode 100644 index 00000000..d207119e --- /dev/null +++ b/tests/lib/repolinter.yaml @@ -0,0 +1,3 @@ +$schema: "../../rulesets/schema.json" +extends: "./default.json" +version: 2