Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for inheriting rulesets #207

Merged
merged 2 commits into from
Apr 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 1 addition & 44 deletions bin/repolinter.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ const repolinter = require('..')
const rimraf = require('rimraf')
const git = require('simple-git/promise')()
/** @type {any} */
const fetch = require('node-fetch')
const fs = require('fs')
const os = require('os')
const yaml = require('js-yaml')

// eslint-disable-next-line no-unused-expressions
require('yargs')
Expand Down Expand Up @@ -65,47 +63,6 @@ require('yargs')
})
},
async (/** @type {any} */ argv) => {
let rulesetParsed = null
let jsonerror
let yamlerror
// resolve the ruleset if a url is specified
if (argv.rulesetUrl) {
const res = await fetch(argv.rulesetUrl)
if (!res.ok) {
console.error(
`Failed to fetch config from ${argv.rulesetUrl} with status code ${res.status}`
)
process.exitCode = 1
return
}
const data = await res.text()
// attempt to parse as JSON
try {
rulesetParsed = JSON.parse(data)
} catch (e) {
jsonerror = e
}
// attempt to parse as YAML
if (!rulesetParsed) {
try {
rulesetParsed = yaml.safeLoad(data)
} catch (e) {
yamlerror = e
}
}
// throw an error if neither worked
if (!rulesetParsed) {
console.log(`Failed to fetch ruleset from URL ${argv.rulesetUrl}:`)
console.log(
`\tJSON failed with error ${jsonerror && jsonerror.toString()}`
)
console.log(
`\tYAML failed with error ${yamlerror && yamlerror.toString()}`
)
process.exitCode = 1
return
}
}
let tmpDir = null
// temporarily clone a git repo to lint
if (argv.git) {
Expand All @@ -124,7 +81,7 @@ require('yargs')
const output = await repolinter.lint(
tmpDir || path.resolve(process.cwd(), argv.directory),
argv.allowPaths,
rulesetParsed || argv.rulesetFile,
argv.rulesetUrl || argv.rulesetFile,
argv.dryRun
)
// create the output
Expand Down
136 changes: 14 additions & 122 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@

/** @module repolinter */

const jsonfile = require('jsonfile')
const Ajv = require('ajv')
const path = require('path')
const findConfig = require('find-config')
const fs = require('fs')
const yaml = require('js-yaml')
// eslint-disable-next-line no-unused-vars
const config = require('./lib/config')
const Result = require('./lib/result')
const RuleInfo = require('./lib/ruleinfo')
const FormatResult = require('./lib/formatresult')
Expand Down Expand Up @@ -126,26 +121,18 @@ async function lint(

let rulesetPath = null
if (typeof ruleset === 'string') {
rulesetPath = path.resolve(targetDir, ruleset)
if (config.isAbsoluteURL(ruleset)) {
rulesetPath = ruleset
} else {
rulesetPath = path.resolve(targetDir, ruleset)
}
} else if (!ruleset) {
rulesetPath =
findConfig('repolint.json', { cwd: targetDir }) ||
findConfig('repolint.yaml', { cwd: targetDir }) ||
findConfig('repolint.yml', { cwd: targetDir }) ||
findConfig('repolinter.json', { cwd: targetDir }) ||
findConfig('repolinter.yaml', { cwd: targetDir }) ||
findConfig('repolinter.yml', { cwd: targetDir }) ||
path.join(__dirname, 'rulesets/default.json')
rulesetPath = config.findConfig(targetDir)
}

if (rulesetPath !== null) {
const extension = path.extname(rulesetPath)
try {
const file = await fs.promises.readFile(rulesetPath, 'utf-8')
if (extension === '.yaml' || extension === '.yml') {
ruleset = yaml.safeLoad(file)
} else {
ruleset = JSON.parse(file)
}
ruleset = await config.loadConfig(rulesetPath)
} catch (e) {
return {
params: {
Expand All @@ -164,8 +151,9 @@ async function lint(
}
}
}

// validate config
const val = await validateConfig(ruleset)
const val = await config.validateConfig(ruleset)
if (!val.passed) {
return {
params: {
Expand All @@ -184,7 +172,7 @@ async function lint(
}
}
// parse it
const configParsed = parseConfig(ruleset)
const configParsed = config.parseConfig(ruleset)
// determine axiom targets
/** @ignore @type {Object.<string, Result>} */
let targetObj = {}
Expand Down Expand Up @@ -488,106 +476,10 @@ async function determineTargets(axiomconfig, fs) {
}, {})
}

/**
* Validate a repolint configuration against a known JSON schema
*
* @memberof repolinter
* @param {Object} config The configuration to validate
* @returns {Promise<Object>}
* A object representing or not the config validation succeeded (passed)
* an an error message if not (error)
*/
async function validateConfig(config) {
// compile the json schema
const ajvProps = new Ajv()
// find all json schemas
const parsedRuleSchemas = Promise.all(
Rules.map(rs =>
jsonfile.readFile(path.resolve(__dirname, 'rules', `${rs}-config.json`))
)
)
const parsedFixSchemas = Promise.all(
Fixes.map(f =>
jsonfile.readFile(path.resolve(__dirname, 'fixes', `${f}-config.json`))
)
)
const allSchemas = (
await Promise.all([parsedFixSchemas, parsedRuleSchemas])
).reduce((a, c) => a.concat(c), [])
// load them into the validator
for (const schema of allSchemas) {
ajvProps.addSchema(schema)
}
const validator = ajvProps.compile(
await jsonfile.readFile(require.resolve('./rulesets/schema.json'))
)

// validate it against the supplied ruleset
if (!validator(config)) {
return {
passed: false,
error: `Configuration validation failed with errors: \n${validator.errors
.map(e => `\tconfiguration${e.dataPath} ${e.message}`)
.join('\n')}`
}
} else {
return { passed: true }
}
}

/**
* Parse a JSON object config (with repolinter.json structure) and return a list
* of RuleInfo objects which will then be used to determine how to run the linter.
*
* @memberof repolinter
* @param {Object} config The repolinter.json config
* @returns {RuleInfo[]} The parsed rule data
*/
function parseConfig(config) {
// check to see if the config has a version marker
// parse modern config
if (config.version === 2) {
return Object.entries(config.rules).map(
([name, cfg]) =>
new RuleInfo(
name,
cfg.level,
cfg.where,
cfg.rule.type,
cfg.rule.options,
cfg.fix && cfg.fix.type,
cfg.fix && cfg.fix.options,
cfg.policyInfo,
cfg.policyUrl
)
)
}
// parse legacy config
// old format of "axiom": { "rule-name:rule-type": ["level", { "configvalue": false }]}
return (
Object.entries(config.rules)
// get axioms
.map(([where, rules]) => {
// get the rules in each axiom
return Object.entries(rules).map(([rulename, configray]) => {
const [name, type] = rulename.split(':')
return new RuleInfo(
name,
configray[0],
where === 'all' ? [] : [where],
type || name,
configray[1] || {}
)
})
})
.reduce((a, c) => a.concat(c))
)
}

module.exports.runRuleset = runRuleset
module.exports.determineTargets = determineTargets
module.exports.validateConfig = validateConfig
module.exports.parseConfig = parseConfig
module.exports.validateConfig = config.validateConfig
module.exports.parseConfig = config.parseConfig
module.exports.shouldRuleRun = shouldRuleRun
module.exports.lint = lint
module.exports.Result = Result
Expand Down
Loading