UNPKG

repolinter

Version:
291 lines (270 loc) 8.85 kB
// Copyright 2017 TODO Group. All rights reserved. // SPDX-License-Identifier: Apache-2.0 const Ajv = require('ajv') 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') const Rules = require('../rules/rules') const RuleInfo = require('./ruleinfo') const Fixes = require('../fixes/fixes') /** * Determine if provided string is an absolute URL. That is, if it is * parseable and has a 'host' URL component. * * @param {string} url string to test * @returns {boolean} true if the string is an absolute URL */ function isAbsoluteURL(url) { try { const u = new URL(url) if (u.host !== '') { return true } } catch (e) {} return false } /** * Find a repolinter config file in the specified directory. This looks for * files named repolint or repolinter with a file extension of .json, .yaml, or * .yml in the specified directory or the nearest ancestor directory. If no * file is found, the default configuration that ships with repolinter is * returned. * * @param {string} [directory] directory to search for config files in * @returns {string} absolute path of configuration file */ function findConfig(directory) { return ( findFile('repolint.json', { cwd: directory }) || findFile('repolint.yaml', { cwd: directory }) || findFile('repolint.yml', { cwd: directory }) || findFile('repolinter.json', { cwd: directory }) || findFile('repolinter.yaml', { cwd: directory }) || findFile('repolinter.yml', { cwd: directory }) || path.join(__dirname, '../rulesets/default.json') ) } /** * 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, processed = []) { if (!configLocation) { throw new Error('must specify config location') } let configData = null if (isAbsoluteURL(configLocation)) { const res = await fetch(configLocation) if (!res.ok) { throw new Error( `Failed to fetch config from ${configLocation} with status code ${res.status}` ) } configData = await res.text() } else { configData = await fs.promises.readFile(configLocation, 'utf-8') } let ruleset // try parsing as JSON, then YAML try { ruleset = JSON.parse(configData) } catch (je) { try { ruleset = yaml.safeLoad(configData) } catch (ye) { throw new Error( `unable to parse ${configLocation} as either JSON (error: ${je}) or YAML (error: ${ye})` ) } } // 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) || isBase64(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)) { let parentRuleset if (isBase64(parent)) { parentRuleset = await decodeConfig(parent, processed) } else { parentRuleset = await loadConfig(parent, processed) } ruleset = lodash.merge({}, parentRuleset, ruleset) } } return ruleset } /** * 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) * or 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( Object.keys(Rules).map(rs => jsonfile.readFile( path.resolve(__dirname, '../rules', `${rs}-config.json`) ) ) ) const parsedFixSchemas = Promise.all( Object.keys(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}\n\nIt's likely the rulesetPath or rulesetUrl isn't configured correctly.` ) .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)) ) } /** * Decodes a base64 encoded string into a config * * @param {string} encodedRuleSet A base64 encoded string that needs decoding * @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 decodeConfig(encodedRuleSet, processed = []) { const configData = Buffer.from(encodedRuleSet, 'base64').toString() let ruleset // try parsing as JSON, then YAML try { ruleset = JSON.parse(configData) } catch (je) { try { ruleset = yaml.safeLoad(configData) } catch (ye) { throw new Error( `unable to parse ruleset as either JSON (error: ${je}) or YAML (error: ${ye})` ) } } // merge extended rulesets if (ruleset.extends) { processed.push(encodedRuleSet) 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 let parentRuleset if (isAbsoluteURL(ruleset.extends)) { parent = ruleset.extends } else if (isBase64(ruleset.extends)) { parentRuleset = await decodeConfig(ruleset.extends, processed) } if (!processed.includes(parent)) { if (!isBase64(ruleset.extends)) { parentRuleset = await loadConfig(parent, processed) } ruleset = lodash.merge({}, parentRuleset, ruleset) } } return ruleset } // isBase64 returns a boolean when the string is a valid base64 string function isBase64(str) { const base64regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/ return base64regex.test(str) } module.exports.findConfig = findConfig module.exports.isAbsoluteURL = isAbsoluteURL module.exports.loadConfig = loadConfig module.exports.decodeConfig = decodeConfig module.exports.validateConfig = validateConfig module.exports.parseConfig = parseConfig module.exports.isBase64 = isBase64