UNPKG

repolinter

Version:
425 lines (404 loc) 15.5 kB
// Copyright 2017 TODO Group. All rights reserved. // SPDX-License-Identifier: Apache-2.0 /** @module repolinter */ const path = require('path') const fs = require('fs') const config = require('./lib/config') const Result = require('./lib/result') const RuleInfo = require('./lib/ruleinfo') const FormatResult = require('./lib/formatresult') const FileSystem = require('./lib/file_system') const Rules = require('./rules/rules') const Fixes = require('./fixes/fixes') const Axioms = require('./axioms/axioms') /** * @typedef {Object} Formatter * @property {function(LintResult, boolean): string} formatOutput A function to format the entire linter output. */ /** * This formatter outputs the LintResult CLI style, including * colors on supported platforms. * ```console * ✔ license-file-exists: found (LICENSE) * ✔ readme-file-exists: found (README.md) * ✔ contributing-file-exists: found (CONTRIBUTING) * ✔ code-of-conduct-file-exists: found (CODE-OF-CONDUCT) * ✔ changelog-file-exists: found (CHANGELOG) * ✔ readme-references-license: File README.md contains license * ✔ license-detectable-by-licensee: Licensee identified the license for project: Apache License 2.0 * ✔ test-directory-exists: found (tests) * ✔ integrates-with-ci: found (.travis.yml) * ✔ source-license-headers-exist: The first 5 lines of 'index.js' contain all of the requested patterns. * ... * ✔ github-issue-template-exists: found (ISSUE_TEMPLATE) * ✔ github-pull-request-template-exists: found (PULL_REQUEST_TEMPLATE) * ✔ package-metadata-exists: found (Gemfile) * ✔ package-metadata-exists: found (package.json) * ``` * * @type {Formatter} */ module.exports.defaultFormatter = require('./formatters/symbol_formatter') /** * This formatter outputs the raw JSON string of the LintResult object. * * @type {Formatter} */ module.exports.jsonFormatter = require('./formatters/json_formatter') /** * This formatter outputs a markdown document designed to created into * a GitHub issue or similar. * ```markdown * # Repolinter Report * * This Repolinter run generated the following results: * | ❗ Error | ❌ Fail | ⚠️ Warn | ✅ Pass | Ignored | Total | * |---|---|---|---|---|---| * | 0 | 0 | 0 | 15 | 10 | 25 | * ... * ``` * You can also specify formatOptions.disclaimer to include a disclaimer * at the top of the markdown document. * * @type {Formatter} */ module.exports.markdownFormatter = require('./formatters/markdown_formatter') /** The same as defaultFormatter @type {Formatter} */ module.exports.resultFormatter = exports.defaultFormatter /** * @typedef {Object} LintResult * * @property {Object} params * The parameters to the lint function call, including the found/supplied ruleset object. * @property {string} params.targetDir The target directory repolinter was called with. May also be a git URL. * @property {string[]} params.filterPaths The filter paths repolinter was called with. * @property {string?} [params.rulesetPath] The path to the ruleset configuration repolinter was called with. * @property {Object} params.ruleset The deserialized ruleset that Repolinter ran. * * @property {boolean} passed Whether or not all lint rules and fix rules succeeded. Will be false if an error occurred during linting. * @property {boolean} errored Whether or not an error occurred during the linting process (ex. the configuration failed validation). * @property {string} [errMsg] A string indication error information, will be present if errored is true. * @property {FormatResult[]} results The output of all the linter rules. * @property {Object.<string, Result>} targets An object representing axiom type: axiom targets. * @property {Object} [formatOptions] Additional options to pass to the formatter, generated from the output or config. */ /** * An exposed function for the repolinter engine. Use this function * to run repolinter on a specified directory targetDir. You can * also optionally specify which paths to allowlist (filterPaths), * whether or not to actually commit modifications (fixes), and * a custom ruleset object to use. This function will not throw * an error on failure, instead indicating that an error has * ocurred in returned value. * * @memberof repolinter * @param {string} targetDir The directory of the repository to lint. * @param {string[]} [filterPaths] A list of directories to allow linting of, or [] for all. * @param {Object|string|null} [ruleset] A custom ruleset object with the same structure as the JSON ruleset configs, or a string path to a JSON config. * Set to null for repolinter to automatically find it in the repository. * @param {boolean} [dryRun] If true, repolinter will report suggested fixes, but will make no disk modifications. * @returns {Promise<LintResult>} An object representing the output of the linter */ async function lint( targetDir, filterPaths = [], ruleset = null, dryRun = false ) { const fileSystem = new FileSystem() fileSystem.targetDir = targetDir if (filterPaths.length > 0) { fileSystem.filterPaths = filterPaths } let rulesetPath = null let isEncoded = false if (ruleset !== undefined && ruleset !== null) { isEncoded = config.isBase64(ruleset) } if (isEncoded) { ruleset = await config.decodeConfig(ruleset) } else { if (typeof ruleset === 'string') { if (config.isAbsoluteURL(ruleset)) { rulesetPath = ruleset } else { if (fs.existsSync(path.resolve(targetDir, ruleset))) { rulesetPath = path.resolve(targetDir, ruleset) } else if (fs.existsSync(path.resolve(__dirname, ruleset))) { rulesetPath = path.resolve(__dirname, ruleset) } else { rulesetPath = null } } } else if (!ruleset) { rulesetPath = config.findConfig(targetDir) } if (rulesetPath !== null) { try { ruleset = await config.loadConfig(rulesetPath) } catch (e) { return { params: { targetDir, filterPaths, rulesetPath, ruleset }, passed: false, errored: true, /** @ignore */ errMsg: e && e.toString(), results: [], targets: {}, formatOptions: ruleset && ruleset.formatOptions } } } } // validate config const val = await config.validateConfig(ruleset) if (!val.passed) { return { params: { targetDir, filterPaths, rulesetPath, ruleset }, passed: false, errored: true, /** @ignore */ errMsg: val.error, results: [], targets: {}, formatOptions: ruleset.formatOptions } } // parse it const configParsed = config.parseConfig(ruleset) // determine axiom targets /** @ignore @type {Object.<string, Result>} */ let targetObj = {} // Identify axioms and execute them if (ruleset.axioms) { targetObj = await determineTargets(ruleset.axioms, fileSystem) } // execute ruleset const result = await runRuleset(configParsed, targetObj, fileSystem, dryRun) const passed = !result.find( r => r.status === FormatResult.ERROR || (r.status !== FormatResult.IGNORED && r.ruleInfo.level === 'error' && !r.lintResult.passed) ) // render all the results const allFormatInfo = { params: { targetDir, filterPaths, rulesetPath, ruleset }, passed, errored: false, results: result, targets: targetObj, formatOptions: ruleset.formatOptions } return allFormatInfo } /** * Checks a rule's list of axioms against a list of valid * targets, and determines if the rule should run or not * based on the following rules criteria: * * The rule's list has a direct match on a target OR * * The rule specifies a numerical axiom (ex. >) and the target * list contains a target that matches that axiom. * * Supported numerical axioms are >, <, >=, <=, and = Only * * @memberof repolinter * @param {string[]} validTargets The axiom target list in "target=thing" format, including the wildcard entry ("target=*"). * For numerical targets it is assumed that only one entry and the wildcard are present (e.g. ["target=2", "target=3", "target=*"] is invalid) * @param {string[]} ruleAxioms The rule "where" specification to validate against. * @returns {string[]} The list pf unsatisfied axioms, if any. Empty array indicates the rule should run. */ function shouldRuleRun(validTargets, ruleAxioms) { // parse out numerical axioms, splitting them by name, operand, and number const ruleRegex = /([\w-]+)((?:>|<)=?)(\d+)/i const numericalRuleAxioms = [] const regularRuleAxioms = [] for (const ruleax of ruleAxioms) { const match = ruleRegex.exec(ruleax) if (match !== null && match[1] && match[2] && !isNaN(parseInt(match[3]))) { // parse the numerical version numericalRuleAxioms.push({ axiom: ruleax, name: match[1], operand: match[2], number: parseInt(match[3]) }) } else { // parse the non-numerical version regularRuleAxioms.push(ruleax) } } // test that every non-number axiom matches a target // start a list of condidions that don't pass const table = new Set(validTargets) const failedRuleAxioms = regularRuleAxioms.filter(r => !table.has(r)) // check the numbered axioms // convert the targets into { targetName: number } for all numerical ones const numericalTargets = validTargets .map(r => r.split('=')) .map(([name, maybeNumber]) => [name, parseInt(maybeNumber)]) .filter(([name, maybeNumber]) => !isNaN(maybeNumber)) /** @ts-ignore */ const numericalTargetsMap = new Map(numericalTargets) // test each numerical Rule against it's numerical axiom, return the axioms that failed return numericalRuleAxioms .filter(({ axiom, name, operand, number }) => { // get the number to test against const target = numericalTargetsMap.get(name) if (target === undefined) return true // test the number based on the operand return !( (operand === '<' && target < number) || (operand === '<=' && target <= number) || (operand === '>' && target > number) || (operand === '>=' && target >= number) ) }) .map(({ axiom }) => axiom) .concat(failedRuleAxioms) } /** * Run all operations in a ruleset, including linting and fixing. Returns * a list of objects with the output of the linter rules * * @memberof repolinter * @param {RuleInfo[]} ruleset A ruleset (list of rules with information about each). This parameter can be generated from a config using parseConfig. * @param {Object.<string, Result>|boolean} targets The axiom targets to enable for this run of the ruleset. Structure is from the output of determineTargets. Use true for all targets. * @param {FileSystem} fileSystem A filesystem object configured with filter paths and a target directory. * @param {boolean} dryRun If true, repolinter will report suggested fixes, but will make no disk modifications. * @returns {Promise<FormatResult[]>} Objects indicating the result of the linter rules */ async function runRuleset(ruleset, targets, fileSystem, dryRun) { // generate a flat array of axiom string identifiers /** @ignore @type {string[]} */ let targetArray = [] if (typeof targets !== 'boolean') { targetArray = Object.entries(targets) // restricted to only passed axioms .filter(([axiomId, res]) => res.passed) // pair the axiom ID with the axiom target array .map(([axiomId, res]) => [axiomId, res.targets.map(t => t.path)]) // join the target arrays together into one array of all the targets .map(([axiomId, paths]) => [`${axiomId}=*`].concat(paths.map(p => `${axiomId}=${p}`)) ) .reduce((a, c) => a.concat(c), []) } // run the ruleset const results = ruleset.map(async r => { // check axioms and enable appropriately if (r.level === 'off') { return FormatResult.CreateIgnored(r, 'ignored because level is "off"') } // filter to only targets with no matches if (typeof targets !== 'boolean' && r.where && r.where.length) { const ignoreReasons = shouldRuleRun(targetArray, r.where) if (ignoreReasons.length > 0) { return FormatResult.CreateIgnored( r, `ignored due to unsatisfied condition(s): "${ignoreReasons.join( '", "' )}"` ) } } // check if the rule file exists if (!Object.prototype.hasOwnProperty.call(Rules, r.ruleType)) { return FormatResult.CreateError(r, `${r.ruleType} is not a valid rule`) } let result try { // load the rule const ruleFunc = Rules[r.ruleType] // run the rule! result = await ruleFunc(fileSystem, r.ruleConfig) } catch (e) { return FormatResult.CreateError( r, `${r.ruleType} threw an error: ${e.message}` ) } // generate fix targets const fixTargets = !result.passed ? result.targets.filter(t => !t.passed && t.path).map(t => t.path) : [] // if there's no fix or the rule passed, we're done if (!r.fixType || result.passed) { return FormatResult.CreateLintOnly(r, result) } // else run the fix // check if the rule file exists if (!Object.prototype.hasOwnProperty.call(Fixes, r.fixType)) { return FormatResult.CreateError(r, `${r.fixType} is not a valid fix`) } let fixresult try { const fixFunc = Fixes[r.fixType] fixresult = await fixFunc(fileSystem, r.fixConfig, fixTargets, dryRun) } catch (e) { return FormatResult.CreateError( r, `${r.fixType} threw an error: ${e.message}` ) } // all done! return the final format object return FormatResult.CreateLintAndFix(r, result, fixresult) }) return Promise.all(results) } /** * Given an axiom configuration, determine the appropriate targets to run against * (e.g. "target=javascript"). * * @memberof repolinter * @param {Object} axiomconfig A configuration conforming to the "axioms" section in schema.json * @param {FileSystem} fs The filesystem to run axioms against * @returns {Promise<Object.<string, Result>>} An object representing axiom name: axiom results. The array will be null if the axiom could not run. */ async function determineTargets(axiomconfig, fs) { // load axioms const ruleresults = await Promise.all( Object.entries(axiomconfig).map(async ([axiomId, axiomName]) => { // Execute axiom if it exists if (!Object.prototype.hasOwnProperty.call(Axioms, axiomId)) { return [ axiomName, new Result(`invalid axiom name ${axiomId}`, [], false) ] } const axiomFunction = Axioms[axiomId] return [axiomName, await axiomFunction(fs)] }) ) // flatten result return ruleresults.reduce((a, [k, v]) => { a[k] = v return a }, {}) } module.exports.runRuleset = runRuleset module.exports.determineTargets = determineTargets module.exports.validateConfig = config.validateConfig module.exports.parseConfig = config.parseConfig module.exports.shouldRuleRun = shouldRuleRun module.exports.lint = lint module.exports.Result = Result module.exports.RuleInfo = RuleInfo module.exports.FileSystem = FileSystem module.exports.FormatResult = FormatResult