autoconfig.js 12 KB
/**
 * @fileoverview Used for creating a suggested configuration based on project code.
 * @author Ian VanSchooten
 */

"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const lodash = require("lodash"),
    recConfig = require("../../conf/eslint-recommended"),
    ConfigOps = require("@eslint/eslintrc/lib/shared/config-ops"),
    { Linter } = require("../linter"),
    configRule = require("./config-rule");

const debug = require("debug")("eslint:autoconfig");
const linter = new Linter();

//------------------------------------------------------------------------------
// Data
//------------------------------------------------------------------------------

const MAX_CONFIG_COMBINATIONS = 17, // 16 combinations + 1 for severity only
    RECOMMENDED_CONFIG_NAME = "eslint:recommended";

//------------------------------------------------------------------------------
// Private
//------------------------------------------------------------------------------

/**
 * Information about a rule configuration, in the context of a Registry.
 * @typedef {Object}     registryItem
 * @param   {ruleConfig} config        A valid configuration for the rule
 * @param   {number}     specificity   The number of elements in the ruleConfig array
 * @param   {number}     errorCount    The number of errors encountered when linting with the config
 */

/**
 * This callback is used to measure execution status in a progress bar
 * @callback progressCallback
 * @param {number} The total number of times the callback will be called.
 */

/**
 * Create registryItems for rules
 * @param   {rulesConfig} rulesConfig Hash of rule names and arrays of ruleConfig items
 * @returns {Object}                  registryItems for each rule in provided rulesConfig
 */
function makeRegistryItems(rulesConfig) {
    return Object.keys(rulesConfig).reduce((accumulator, ruleId) => {
        accumulator[ruleId] = rulesConfig[ruleId].map(config => ({
            config,
            specificity: config.length || 1,
            errorCount: void 0
        }));
        return accumulator;
    }, {});
}

/**
 * Creates an object in which to store rule configs and error counts
 *
 * Unless a rulesConfig is provided at construction, the registry will not contain
 * any rules, only methods.  This will be useful for building up registries manually.
 *
 * Registry class
 */
class Registry {

    // eslint-disable-next-line jsdoc/require-description
    /**
     * @param {rulesConfig} [rulesConfig] Hash of rule names and arrays of possible configurations
     */
    constructor(rulesConfig) {
        this.rules = (rulesConfig) ? makeRegistryItems(rulesConfig) : {};
    }

    /**
     * Populate the registry with core rule configs.
     *
     * It will set the registry's `rule` property to an object having rule names
     * as keys and an array of registryItems as values.
     * @returns {void}
     */
    populateFromCoreRules() {
        const rulesConfig = configRule.createCoreRuleConfigs();

        this.rules = makeRegistryItems(rulesConfig);
    }

    /**
     * Creates sets of rule configurations which can be used for linting
     * and initializes registry errors to zero for those configurations (side effect).
     *
     * This combines as many rules together as possible, such that the first sets
     * in the array will have the highest number of rules configured, and later sets
     * will have fewer and fewer, as not all rules have the same number of possible
     * configurations.
     *
     * The length of the returned array will be <= MAX_CONFIG_COMBINATIONS.
     * @returns {Object[]}          "rules" configurations to use for linting
     */
    buildRuleSets() {
        let idx = 0;
        const ruleIds = Object.keys(this.rules),
            ruleSets = [];

        /**
         * Add a rule configuration from the registry to the ruleSets
         *
         * This is broken out into its own function so that it doesn't need to be
         * created inside of the while loop.
         * @param   {string} rule The ruleId to add.
         * @returns {void}
         */
        const addRuleToRuleSet = function(rule) {

            /*
             * This check ensures that there is a rule configuration and that
             * it has fewer than the max combinations allowed.
             * If it has too many configs, we will only use the most basic of
             * the possible configurations.
             */
            const hasFewCombos = (this.rules[rule].length <= MAX_CONFIG_COMBINATIONS);

            if (this.rules[rule][idx] && (hasFewCombos || this.rules[rule][idx].specificity <= 2)) {

                /*
                 * If the rule has too many possible combinations, only take
                 * simple ones, avoiding objects.
                 */
                if (!hasFewCombos && typeof this.rules[rule][idx].config[1] === "object") {
                    return;
                }

                ruleSets[idx] = ruleSets[idx] || {};
                ruleSets[idx][rule] = this.rules[rule][idx].config;

                /*
                 * Initialize errorCount to zero, since this is a config which
                 * will be linted.
                 */
                this.rules[rule][idx].errorCount = 0;
            }
        }.bind(this);

        while (ruleSets.length === idx) {
            ruleIds.forEach(addRuleToRuleSet);
            idx += 1;
        }

        return ruleSets;
    }

    /**
     * Remove all items from the registry with a non-zero number of errors
     *
     * Note: this also removes rule configurations which were not linted
     * (meaning, they have an undefined errorCount).
     * @returns {void}
     */
    stripFailingConfigs() {
        const ruleIds = Object.keys(this.rules),
            newRegistry = new Registry();

        newRegistry.rules = Object.assign({}, this.rules);
        ruleIds.forEach(ruleId => {
            const errorFreeItems = newRegistry.rules[ruleId].filter(registryItem => (registryItem.errorCount === 0));

            if (errorFreeItems.length > 0) {
                newRegistry.rules[ruleId] = errorFreeItems;
            } else {
                delete newRegistry.rules[ruleId];
            }
        });

        return newRegistry;
    }

    /**
     * Removes rule configurations which were not included in a ruleSet
     * @returns {void}
     */
    stripExtraConfigs() {
        const ruleIds = Object.keys(this.rules),
            newRegistry = new Registry();

        newRegistry.rules = Object.assign({}, this.rules);
        ruleIds.forEach(ruleId => {
            newRegistry.rules[ruleId] = newRegistry.rules[ruleId].filter(registryItem => (typeof registryItem.errorCount !== "undefined"));
        });

        return newRegistry;
    }

    /**
     * Creates a registry of rules which had no error-free configs.
     * The new registry is intended to be analyzed to determine whether its rules
     * should be disabled or set to warning.
     * @returns {Registry}  A registry of failing rules.
     */
    getFailingRulesRegistry() {
        const ruleIds = Object.keys(this.rules),
            failingRegistry = new Registry();

        ruleIds.forEach(ruleId => {
            const failingConfigs = this.rules[ruleId].filter(registryItem => (registryItem.errorCount > 0));

            if (failingConfigs && failingConfigs.length === this.rules[ruleId].length) {
                failingRegistry.rules[ruleId] = failingConfigs;
            }
        });

        return failingRegistry;
    }

    /**
     * Create an eslint config for any rules which only have one configuration
     * in the registry.
     * @returns {Object} An eslint config with rules section populated
     */
    createConfig() {
        const ruleIds = Object.keys(this.rules),
            config = { rules: {} };

        ruleIds.forEach(ruleId => {
            if (this.rules[ruleId].length === 1) {
                config.rules[ruleId] = this.rules[ruleId][0].config;
            }
        });

        return config;
    }

    /**
     * Return a cloned registry containing only configs with a desired specificity
     * @param   {number} specificity Only keep configs with this specificity
     * @returns {Registry}           A registry of rules
     */
    filterBySpecificity(specificity) {
        const ruleIds = Object.keys(this.rules),
            newRegistry = new Registry();

        newRegistry.rules = Object.assign({}, this.rules);
        ruleIds.forEach(ruleId => {
            newRegistry.rules[ruleId] = this.rules[ruleId].filter(registryItem => (registryItem.specificity === specificity));
        });

        return newRegistry;
    }

    /**
     * Lint SourceCodes against all configurations in the registry, and record results
     * @param   {Object[]} sourceCodes  SourceCode objects for each filename
     * @param   {Object}   config       ESLint config object
     * @param   {progressCallback} [cb] Optional callback for reporting execution status
     * @returns {Registry}              New registry with errorCount populated
     */
    lintSourceCode(sourceCodes, config, cb) {
        let lintedRegistry = new Registry();

        lintedRegistry.rules = Object.assign({}, this.rules);

        const ruleSets = lintedRegistry.buildRuleSets();

        lintedRegistry = lintedRegistry.stripExtraConfigs();

        debug("Linting with all possible rule combinations");

        const filenames = Object.keys(sourceCodes);
        const totalFilesLinting = filenames.length * ruleSets.length;

        filenames.forEach(filename => {
            debug(`Linting file: ${filename}`);

            let ruleSetIdx = 0;

            ruleSets.forEach(ruleSet => {
                const lintConfig = Object.assign({}, config, { rules: ruleSet });
                const lintResults = linter.verify(sourceCodes[filename], lintConfig);

                lintResults.forEach(result => {

                    /*
                     * It is possible that the error is from a configuration comment
                     * in a linted file, in which case there may not be a config
                     * set in this ruleSetIdx.
                     * (https://github.com/eslint/eslint/issues/5992)
                     * (https://github.com/eslint/eslint/issues/7860)
                     */
                    if (
                        lintedRegistry.rules[result.ruleId] &&
                        lintedRegistry.rules[result.ruleId][ruleSetIdx]
                    ) {
                        lintedRegistry.rules[result.ruleId][ruleSetIdx].errorCount += 1;
                    }
                });

                ruleSetIdx += 1;

                if (cb) {
                    cb(totalFilesLinting); // eslint-disable-line node/callback-return
                }
            });

            // Deallocate for GC
            sourceCodes[filename] = null;
        });

        return lintedRegistry;
    }
}

/**
 * Extract rule configuration into eslint:recommended where possible.
 *
 * This will return a new config with `["extends": [ ..., "eslint:recommended"]` and
 * only the rules which have configurations different from the recommended config.
 * @param   {Object} config config object
 * @returns {Object}        config object using `"extends": ["eslint:recommended"]`
 */
function extendFromRecommended(config) {
    const newConfig = Object.assign({}, config);

    ConfigOps.normalizeToStrings(newConfig);

    const recRules = Object.keys(recConfig.rules).filter(ruleId => ConfigOps.isErrorSeverity(recConfig.rules[ruleId]));

    recRules.forEach(ruleId => {
        if (lodash.isEqual(recConfig.rules[ruleId], newConfig.rules[ruleId])) {
            delete newConfig.rules[ruleId];
        }
    });
    newConfig.extends.unshift(RECOMMENDED_CONFIG_NAME);
    return newConfig;
}


//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------

module.exports = {
    Registry,
    extendFromRecommended
};