From b08170b92beb22db6ec612ebdfff930f9e0582ab Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Sat, 26 Jun 2021 04:33:02 -0700 Subject: [PATCH] Update: Implement FlatConfigArray (refs #13481) (#14321) * Update: Implement FlatConfigArray (refs #13481) * Upgrade config-array package * Add schemas for linterOptions, processor, plugins * Continue implementing config schemas * RulesSchema start * Add initial finalization step * Default config * Strict mode * Start rule validation * Finish FlatConfigArray implementation * Remove too-new syntax * Fix default config * fix test * Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills * Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills * Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills * Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills * Update tests * fix test * Allow old-style plugin names * Fix reportUnusedDisableDirectives and add JSDoc * Add more tests * address review comments * Ignore only .git directory * Allow null for global settings * writeable -> writable * Remove incorrect comment * Validate severity-only rule options * Add key to global error message * deeply merge parserOptions and settings * Rename defaultResultConfig * Normalize and fix rule validations * Fix rule options merging * Fix various errors * Rebase onto master Co-authored-by: Brandon Mills --- lib/config/default-config.js | 52 + lib/config/flat-config-array.js | 125 +++ lib/config/flat-config-schema.js | 452 ++++++++ lib/config/rule-validator.js | 169 +++ package.json | 1 + tests/lib/config/flat-config-array.js | 1450 +++++++++++++++++++++++++ 6 files changed, 2249 insertions(+) create mode 100644 lib/config/default-config.js create mode 100644 lib/config/flat-config-array.js create mode 100644 lib/config/flat-config-schema.js create mode 100644 lib/config/rule-validator.js create mode 100644 tests/lib/config/flat-config-array.js diff --git a/lib/config/default-config.js b/lib/config/default-config.js new file mode 100644 index 00000000000..cb6f403380d --- /dev/null +++ b/lib/config/default-config.js @@ -0,0 +1,52 @@ +/** + * @fileoverview Default configuration + * @author Nicholas C. Zakas + */ + +"use strict"; + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const Rules = require("../rules"); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + + +exports.defaultConfig = [ + { + plugins: { + "@": { + parsers: { + espree: require("espree") + }, + + /* + * Because we try to delay loading rules until absolutely + * necessary, a proxy allows us to hook into the lazy-loading + * aspect of the rules map while still keeping all of the + * relevant configuration inside of the config array. + */ + rules: new Proxy({}, { + get(target, property) { + return Rules.get(property); + }, + + has(target, property) { + return Rules.has(property); + } + }) + } + }, + ignores: [ + "**/node_modules/**", + ".git/**" + ], + languageOptions: { + parser: "@/espree" + } + } +]; diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js new file mode 100644 index 00000000000..ecf396a3314 --- /dev/null +++ b/lib/config/flat-config-array.js @@ -0,0 +1,125 @@ +/** + * @fileoverview Flat Config Array + * @author Nicholas C. Zakas + */ + +"use strict"; + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const { ConfigArray, ConfigArraySymbol } = require("@humanwhocodes/config-array"); +const { flatConfigSchema } = require("./flat-config-schema"); +const { RuleValidator } = require("./rule-validator"); +const { defaultConfig } = require("./default-config"); +const recommendedConfig = require("../../conf/eslint-recommended"); +const allConfig = require("../../conf/eslint-all"); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const ruleValidator = new RuleValidator(); + +/** + * Splits a plugin identifier in the form a/b/c into two parts: a/b and c. + * @param {string} identifier The identifier to parse. + * @returns {{objectName: string, pluginName: string}} The parts of the plugin + * name. + */ +function splitPluginIdentifier(identifier) { + const parts = identifier.split("/"); + + return { + objectName: parts.pop(), + pluginName: parts.join("/") + }; +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * Represents an array containing configuration information for ESLint. + */ +class FlatConfigArray extends ConfigArray { + + /** + * Creates a new instance. + * @param {*[]} configs An array of configuration information. + * @param {{basePath: string, baseConfig: FlatConfig}} options The options + * to use for the config array instance. + */ + constructor(configs, { basePath, baseConfig = defaultConfig }) { + super(configs, { + basePath, + schema: flatConfigSchema + }); + + this.unshift(baseConfig); + } + + /* eslint-disable class-methods-use-this */ + /** + * Replaces a config with another config to allow us to put strings + * in the config array that will be replaced by objects before + * normalization. + * @param {Object} config The config to preprocess. + * @returns {Object} The preprocessed config. + */ + [ConfigArraySymbol.preprocessConfig](config) { + if (config === "eslint:recommended") { + return recommendedConfig; + } + + if (config === "eslint:all") { + return allConfig; + } + + return config; + } + + /** + * Finalizes the config by replacing plugin references with their objects + * and validating rule option schemas. + * @param {Object} config The config to finalize. + * @returns {Object} The finalized config. + * @throws {TypeError} If the config is invalid. + */ + [ConfigArraySymbol.finalizeConfig](config) { + + const { plugins, languageOptions, processor } = config; + + // Check parser value + if (languageOptions && languageOptions.parser && typeof languageOptions.parser === "string") { + const { pluginName, objectName: parserName } = splitPluginIdentifier(languageOptions.parser); + + if (!plugins || !plugins[pluginName] || !plugins[pluginName].parsers || !plugins[pluginName].parsers[parserName]) { + throw new TypeError(`Key "parser": Could not find "${parserName}" in plugin "${pluginName}".`); + } + + languageOptions.parser = plugins[pluginName].parsers[parserName]; + } + + // Check processor value + if (processor && typeof processor === "string") { + const { pluginName, objectName: processorName } = splitPluginIdentifier(processor); + + if (!plugins || !plugins[pluginName] || !plugins[pluginName].processors || !plugins[pluginName].processors[processorName]) { + throw new TypeError(`Key "processor": Could not find "${processorName}" in plugin "${pluginName}".`); + } + + config.processor = plugins[pluginName].processors[processorName]; + } + + ruleValidator.validate(config); + + return config; + } + /* eslint-enable class-methods-use-this */ + +} + +exports.FlatConfigArray = FlatConfigArray; diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js new file mode 100644 index 00000000000..80785476133 --- /dev/null +++ b/lib/config/flat-config-schema.js @@ -0,0 +1,452 @@ +/** + * @fileoverview Flat config schema + * @author Nicholas C. Zakas + */ + +"use strict"; + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @typedef ObjectPropertySchema + * @property {Function|string} merge The function or name of the function to call + * to merge multiple objects with this property. + * @property {Function|string} validate The function or name of the function to call + * to validate the value of this property. + */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const ruleSeverities = new Map([ + [0, 0], ["off", 0], + [1, 1], ["warn", 1], + [2, 2], ["error", 2] +]); + +const globalVariablesValues = new Set([ + true, "true", "writable", "writeable", + false, "false", "readonly", "readable", null, + "off" +]); + +/** + * Check if a value is a non-null object. + * @param {any} value The value to check. + * @returns {boolean} `true` if the value is a non-null object. + */ +function isNonNullObject(value) { + return typeof value === "object" && value !== null; +} + +/** + * Check if a value is undefined. + * @param {any} value The value to check. + * @returns {boolean} `true` if the value is undefined. + */ +function isUndefined(value) { + return typeof value === "undefined"; +} + +/** + * Deeply merges two objects. + * @param {Object} first The base object. + * @param {Object} second The overrides object. + * @returns {Object} An object with properties from both first and second. + */ +function deepMerge(first = {}, second = {}) { + + /* + * If the second value is an array, just return it. We don't merge + * arrays because order matters and we can't know the correct order. + */ + if (Array.isArray(second)) { + return second; + } + + /* + * First create a result object where properties from the second object + * overwrite properties from the first. This sets up a baseline to use + * later rather than needing to inspect and change every property + * individually. + */ + const result = { + ...first, + ...second + }; + + for (const key of Object.keys(second)) { + + // avoid hairy edge case + if (key === "__proto__") { + continue; + } + + const firstValue = first[key]; + const secondValue = second[key]; + + if (isNonNullObject(firstValue)) { + result[key] = deepMerge(firstValue, secondValue); + } else if (isUndefined(firstValue)) { + if (isNonNullObject(secondValue)) { + result[key] = deepMerge( + Array.isArray(secondValue) ? [] : {}, + secondValue + ); + } else if (!isUndefined(secondValue)) { + result[key] = secondValue; + } + } + } + + return result; + +} + +/** + * Normalizes the rule options config for a given rule by ensuring that + * it is an array and that the first item is 0, 1, or 2. + * @param {Array|string|number} ruleOptions The rule options config. + * @returns {Array} An array of rule options. + */ +function normalizeRuleOptions(ruleOptions) { + + const finalOptions = Array.isArray(ruleOptions) + ? ruleOptions.slice(0) + : [ruleOptions]; + + finalOptions[0] = ruleSeverities.get(finalOptions[0]); + return finalOptions; +} + +//----------------------------------------------------------------------------- +// Assertions +//----------------------------------------------------------------------------- + +/** + * Validates that a value is a valid rule options entry. + * @param {any} value The value to check. + * @returns {void} + * @throws {TypeError} If the value isn't a valid rule options. + */ +function assertIsRuleOptions(value) { + + if (typeof value !== "string" && typeof value !== "number" && !Array.isArray(value)) { + throw new TypeError("Expected a string, number, or array."); + } +} + +/** + * Validates that a value is valid rule severity. + * @param {any} value The value to check. + * @returns {void} + * @throws {TypeError} If the value isn't a valid rule severity. + */ +function assertIsRuleSeverity(value) { + const severity = typeof value === "string" + ? ruleSeverities.get(value.toLowerCase()) + : ruleSeverities.get(value); + + if (typeof severity === "undefined") { + throw new TypeError("Expected severity of \"off\", 0, \"warn\", 1, \"error\", or 2."); + } +} + +/** + * Validates that a given string is the form pluginName/objectName. + * @param {string} value The string to check. + * @returns {void} + * @throws {TypeError} If the string isn't in the correct format. + */ +function assertIsPluginMemberName(value) { + if (!/[@a-z0-9-_$]+(?:\/(?:[a-z0-9-_$]+))+$/iu.test(value)) { + throw new TypeError(`Expected string in the form "pluginName/objectName" but found "${value}".`); + } +} + +/** + * Validates that a value is an object. + * @param {any} value The value to check. + * @returns {void} + * @throws {TypeError} If the value isn't an object. + */ +function assertIsObject(value) { + if (!isNonNullObject(value)) { + throw new TypeError("Expected an object."); + } +} + +/** + * Validates that a value is an object or a string. + * @param {any} value The value to check. + * @returns {void} + * @throws {TypeError} If the value isn't an object or a string. + */ +function assertIsObjectOrString(value) { + if ((!value || typeof value !== "object") && typeof value !== "string") { + throw new TypeError("Expected an object or string."); + } +} + +//----------------------------------------------------------------------------- +// Low-Level Schemas +//----------------------------------------------------------------------------- + + +/** @type {ObjectPropertySchema} */ +const numberSchema = { + merge: "replace", + validate: "number" +}; + +/** @type {ObjectPropertySchema} */ +const booleanSchema = { + merge: "replace", + validate: "boolean" +}; + +/** @type {ObjectPropertySchema} */ +const deepObjectAssignSchema = { + merge(first = {}, second = {}) { + return deepMerge(first, second); + }, + validate: "object" +}; + +//----------------------------------------------------------------------------- +// High-Level Schemas +//----------------------------------------------------------------------------- + +/** @type {ObjectPropertySchema} */ +const globalsSchema = { + merge: "assign", + validate(value) { + + assertIsObject(value); + + for (const key of Object.keys(value)) { + + // avoid hairy edge case + if (key === "__proto__") { + continue; + } + + if (key !== key.trim()) { + throw new TypeError(`Global "${key}" has leading or trailing whitespace.`); + } + + if (!globalVariablesValues.has(value[key])) { + throw new TypeError(`Key "${key}": Expected "readonly", "writable", or "off".`); + } + } + } +}; + +/** @type {ObjectPropertySchema} */ +const parserSchema = { + merge: "replace", + validate(value) { + assertIsObjectOrString(value); + + if (typeof value === "object" && typeof value.parse !== "function" && typeof value.parseForESLint !== "function") { + throw new TypeError("Expected object to have a parse() or parseForESLint() method."); + } + + if (typeof value === "string") { + assertIsPluginMemberName(value); + } + } +}; + +/** @type {ObjectPropertySchema} */ +const pluginsSchema = { + merge(first = {}, second = {}) { + const keys = new Set([...Object.keys(first), ...Object.keys(second)]); + const result = {}; + + // manually validate that plugins are not redefined + for (const key of keys) { + + // avoid hairy edge case + if (key === "__proto__") { + continue; + } + + if (key in first && key in second && first[key] !== second[key]) { + throw new TypeError(`Cannot redefine plugin "${key}".`); + } + + result[key] = second[key] || first[key]; + } + + return result; + }, + validate(value) { + + // first check the value to be sure it's an object + if (value === null || typeof value !== "object") { + throw new TypeError("Expected an object."); + } + + // second check the keys to make sure they are objects + for (const key of Object.keys(value)) { + + // avoid hairy edge case + if (key === "__proto__") { + continue; + } + + if (value[key] === null || typeof value[key] !== "object") { + throw new TypeError(`Key "${key}": Expected an object.`); + } + } + } +}; + +/** @type {ObjectPropertySchema} */ +const processorSchema = { + merge: "replace", + validate(value) { + if (typeof value === "string") { + assertIsPluginMemberName(value); + } else if (value && typeof value === "object") { + if (typeof value.preprocess !== "function" || typeof value.postprocess !== "function") { + throw new TypeError("Object must have a preprocess() and a postprocess() method."); + } + } else { + throw new TypeError("Expected an object or a string."); + } + } +}; + +/** @type {ObjectPropertySchema} */ +const rulesSchema = { + merge(first = {}, second = {}) { + + const result = { + ...first, + ...second + }; + + for (const ruleId of Object.keys(result)) { + + // avoid hairy edge case + if (ruleId === "__proto__") { + + /* eslint-disable-next-line no-proto */ + delete result.__proto__; + continue; + } + + result[ruleId] = normalizeRuleOptions(result[ruleId]); + + /* + * If either rule config is missing, then the correct + * config is already present and we just need to normalize + * the severity. + */ + if (!(ruleId in first) || !(ruleId in second)) { + continue; + } + + const firstRuleOptions = normalizeRuleOptions(first[ruleId]); + const secondRuleOptions = normalizeRuleOptions(second[ruleId]); + + /* + * If the second rule config only has a severity (length of 1), + * then use that severity and keep the rest of the options from + * the first rule config. + */ + if (secondRuleOptions.length === 1) { + result[ruleId] = [secondRuleOptions[0], ...firstRuleOptions.slice(1)]; + continue; + } + + /* + * In any other situation, then the second rule config takes + * precedence. That means the value at `result[ruleId]` is + * already correct and no further work is necessary. + */ + } + + return result; + }, + + validate(value) { + assertIsObject(value); + + let lastRuleId; + + // Performance: One try-catch has less overhead than one per loop iteration + try { + + /* + * We are not checking the rule schema here because there is no + * guarantee that the rule definition is present at this point. Instead + * we wait and check the rule schema during the finalization step + * of calculating a config. + */ + for (const ruleId of Object.keys(value)) { + + // avoid hairy edge case + if (ruleId === "__proto__") { + continue; + } + + lastRuleId = ruleId; + + const ruleOptions = value[ruleId]; + + assertIsRuleOptions(ruleOptions); + + if (Array.isArray(ruleOptions)) { + assertIsRuleSeverity(ruleOptions[0]); + } else { + assertIsRuleSeverity(ruleOptions); + } + } + } catch (error) { + error.message = `Key "${lastRuleId}": ${error.message}`; + throw error; + } + } +}; + +/** @type {ObjectPropertySchema} */ +const sourceTypeSchema = { + merge: "replace", + validate(value) { + if (typeof value !== "string" || !/^(?:script|module|commonjs)$/u.test(value)) { + throw new TypeError("Expected \"script\", \"module\", or \"commonjs\"."); + } + } +}; + +//----------------------------------------------------------------------------- +// Full schema +//----------------------------------------------------------------------------- + +exports.flatConfigSchema = { + settings: deepObjectAssignSchema, + linterOptions: { + schema: { + noInlineConfig: booleanSchema, + reportUnusedDisableDirectives: booleanSchema + } + }, + languageOptions: { + schema: { + ecmaVersion: numberSchema, + sourceType: sourceTypeSchema, + globals: globalsSchema, + parser: parserSchema, + parserOptions: deepObjectAssignSchema + } + }, + processor: processorSchema, + plugins: pluginsSchema, + rules: rulesSchema +}; diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js new file mode 100644 index 00000000000..f162dd81a05 --- /dev/null +++ b/lib/config/rule-validator.js @@ -0,0 +1,169 @@ +/** + * @fileoverview Rule Validator + * @author Nicholas C. Zakas + */ + +"use strict"; + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const ajv = require("../shared/ajv")(); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * Finds a rule with the given ID in the given config. + * @param {string} ruleId The ID of the rule to find. + * @param {Object} config The config to search in. + * @returns {{create: Function, schema: (Array|null)}} THe rule object. + */ +function findRuleDefinition(ruleId, config) { + const ruleIdParts = ruleId.split("/"); + let pluginName, ruleName; + + // built-in rule + if (ruleIdParts.length === 1) { + pluginName = "@"; + ruleName = ruleIdParts[0]; + } else { + ruleName = ruleIdParts.pop(); + pluginName = ruleIdParts.join("/"); + } + + if (!config.plugins || !config.plugins[pluginName]) { + throw new TypeError(`Key "rules": Key "${ruleId}": Could not find plugin "${pluginName}".`); + } + + if (!config.plugins[pluginName].rules || !config.plugins[pluginName].rules[ruleName]) { + throw new TypeError(`Key "rules": Key "${ruleId}": Could not find "${ruleName}" in plugin "${pluginName}".`); + } + + return config.plugins[pluginName].rules[ruleName]; + +} + +/** + * Gets a complete options schema for a rule. + * @param {{create: Function, schema: (Array|null)}} rule A new-style rule object + * @returns {Object} JSON Schema for the rule's options. + */ +function getRuleOptionsSchema(rule) { + + if (!rule) { + return null; + } + + const schema = rule.schema || rule.meta && rule.meta.schema; + + if (Array.isArray(schema)) { + if (schema.length) { + return { + type: "array", + items: schema, + minItems: 0, + maxItems: schema.length + }; + } + return { + type: "array", + minItems: 0, + maxItems: 0 + }; + + } + + // Given a full schema, leave it alone + return schema || null; +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * Implements validation functionality for the rules portion of a config. + */ +class RuleValidator { + + /** + * Creates a new instance. + */ + constructor() { + + /** + * A collection of compiled validators for rules that have already + * been validated. + * @type {WeakMap} + * @property validators + */ + this.validators = new WeakMap(); + } + + /** + * Validates all of the rule configurations in a config against each + * rule's schema. + * @param {Object} config The full config to validate. This object must + * contain both the rules section and the plugins section. + * @returns {void} + * @throws {Error} If a rule's configuration does not match its schema. + */ + validate(config) { + + if (!config.rules) { + return; + } + + for (const [ruleId, ruleOptions] of Object.entries(config.rules)) { + + // check for edge case + if (ruleId === "__proto__") { + continue; + } + + /* + * If a rule is disabled, we don't do any validation. This allows + * users to safely set any value to 0 or "off" without worrying + * that it will cause a validation error. + * + * Note: ruleOptions is always an array at this point because + * this validation occurs after FlatConfigArray has merged and + * normalized values. + */ + if (ruleOptions[0] === 0) { + continue; + } + + const rule = findRuleDefinition(ruleId, config); + + // Precompile and cache validator the first time + if (!this.validators.has(rule)) { + const schema = getRuleOptionsSchema(rule); + + if (schema) { + this.validators.set(rule, ajv.compile(schema)); + } + } + + const validateRule = this.validators.get(rule); + + if (validateRule) { + + validateRule(ruleOptions.slice(1)); + + if (validateRule.errors) { + throw new Error(`Key "rules": Key "${ruleId}": ${ + validateRule.errors.map( + error => `\tValue ${JSON.stringify(error.data)} ${error.message}.\n` + ).join("") + }`); + } + } + } + } +} + +exports.RuleValidator = RuleValidator; diff --git a/package.json b/package.json index 5cb9c6a0d2c..5d53e1e9aa6 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.2", + "@humanwhocodes/config-array": "^0.5.0", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js new file mode 100644 index 00000000000..fd89f8d972c --- /dev/null +++ b/tests/lib/config/flat-config-array.js @@ -0,0 +1,1450 @@ +/** + * @fileoverview Tests for FlatConfigArray + * @author Nicholas C. Zakas + */ + +"use strict"; + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const { FlatConfigArray } = require("../../../lib/config/flat-config-array"); +const assert = require("chai").assert; +const allConfig = require("../../../conf/eslint-all"); +const recommendedConfig = require("../../../conf/eslint-recommended"); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const baseConfig = { + plugins: { + "@": { + rules: { + foo: { + schema: { + type: "array", + items: [ + { + enum: ["always", "never"] + } + ], + minItems: 0, + maxItems: 1 + } + + }, + bar: { + + }, + baz: { + + }, + + // old-style + boom() {}, + + foo2: { + schema: { + type: "array", + items: { + type: "string" + }, + uniqueItems: true, + minItems: 1 + } + } + } + } + } +}; + +/** + * Creates a config array with the correct default options. + * @param {*[]} configs An array of configs to use in the config array. + * @returns {FlatConfigArray} The config array; + */ +function createFlatConfigArray(configs) { + return new FlatConfigArray(configs, { + basePath: __dirname, + baseConfig + }); +} + +/** + * Asserts that a given set of configs will be merged into the given + * result config. + * @param {*[]} values An array of configs to use in the config array. + * @param {Object} result The expected merged result of the configs. + * @returns {void} + * @throws {AssertionError} If the actual result doesn't match the + * expected result. + */ +async function assertMergedResult(values, result) { + const configs = createFlatConfigArray(values); + + await configs.normalize(); + + const config = configs.getConfig("foo.js"); + + assert.deepStrictEqual(config, result); +} + +/** + * Asserts that a given set of configs results in an invalid config. + * @param {*[]} values An array of configs to use in the config array. + * @param {string|RegExp} message The expected error message. + * @returns {void} + * @throws {AssertionError} If the config is valid or if the error + * has an unexpected message. + */ +async function assertInvalidConfig(values, message) { + const configs = createFlatConfigArray(values); + + await configs.normalize(); + + assert.throws(() => { + configs.getConfig("foo.js"); + }, message); +} + +/** + * Normalizes the rule configs to an array with severity to match + * how Flat Config merges rule options. + * @param {Object} rulesConfig The rules config portion of a config. + * @returns {Array} The rules config object. + */ +function normalizeRuleConfig(rulesConfig) { + const rulesConfigCopy = { + ...rulesConfig + }; + + for (const ruleId of Object.keys(rulesConfigCopy)) { + rulesConfigCopy[ruleId] = [2]; + } + + return rulesConfigCopy; +} + +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + +describe("FlatConfigArray", () => { + + describe("Special configs", () => { + it("eslint:recommended is replaced with an actual config", async () => { + const configs = new FlatConfigArray(["eslint:recommended"], { basePath: __dirname }); + + await configs.normalize(); + const config = configs.getConfig("foo.js"); + + assert.deepStrictEqual(config.rules, normalizeRuleConfig(recommendedConfig.rules)); + }); + + it("eslint:all is replaced with an actual config", async () => { + const configs = new FlatConfigArray(["eslint:all"], { basePath: __dirname }); + + await configs.normalize(); + const config = configs.getConfig("foo.js"); + + assert.deepStrictEqual(config.rules, normalizeRuleConfig(allConfig.rules)); + }); + }); + + describe("Config Properties", () => { + + describe("settings", () => { + + it("should merge two objects", () => assertMergedResult([ + { + settings: { + a: true, + b: false + } + }, + { + settings: { + c: true, + d: false + } + } + ], { + plugins: baseConfig.plugins, + + settings: { + a: true, + b: false, + c: true, + d: false + } + })); + + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + settings: { + a: true, + b: false, + d: [1, 2], + e: [5, 6] + } + }, + { + settings: { + c: true, + a: false, + d: [3, 4] + } + } + ], { + plugins: baseConfig.plugins, + + settings: { + a: false, + b: false, + c: true, + d: [3, 4], + e: [5, 6] + } + })); + + it("should deeply merge two objects when second object has overrides", () => assertMergedResult([ + { + settings: { + object: { + a: true, + b: false + } + } + }, + { + settings: { + object: { + c: true, + a: false + } + } + } + ], { + plugins: baseConfig.plugins, + + settings: { + object: { + a: false, + b: false, + c: true + } + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + settings: { + a: true, + b: false + } + }, + { + } + ], { + plugins: baseConfig.plugins, + + settings: { + a: true, + b: false + } + })); + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { + settings: { + a: true, + b: false + } + } + ], { + plugins: baseConfig.plugins, + + settings: { + a: true, + b: false + } + })); + + }); + + describe("plugins", () => { + + const pluginA = {}; + const pluginB = {}; + const pluginC = {}; + + it("should merge two objects", () => assertMergedResult([ + { + plugins: { + a: pluginA, + b: pluginB + } + }, + { + plugins: { + c: pluginC + } + } + ], { + plugins: { + a: pluginA, + b: pluginB, + c: pluginC, + ...baseConfig.plugins + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + plugins: { + a: pluginA, + b: pluginB + } + }, + { + } + ], { + plugins: { + a: pluginA, + b: pluginB, + ...baseConfig.plugins + } + })); + + it("should error when attempting to redefine a plugin", async () => { + + await assertInvalidConfig([ + { + plugins: { + a: pluginA, + b: pluginB + } + }, + { + plugins: { + a: pluginC + } + } + ], "Cannot redefine plugin \"a\"."); + }); + + it("should error when plugin is not an object", async () => { + + await assertInvalidConfig([ + { + plugins: { + a: true + } + } + ], "Key \"a\": Expected an object."); + }); + + + }); + + describe("processor", () => { + + it("should merge two values when second is a string", () => { + + const stubProcessor = { + preprocess() {}, + postprocess() {} + }; + + return assertMergedResult([ + { + processor: { + preprocess() {}, + postprocess() {} + } + }, + { + plugins: { + markdown: { + processors: { + markdown: stubProcessor + } + } + }, + processor: "markdown/markdown" + } + ], { + plugins: { + markdown: { + processors: { + markdown: stubProcessor + } + }, + ...baseConfig.plugins + }, + processor: stubProcessor + }); + }); + + it("should merge two values when second is an object", () => { + + const processor = { + preprocess() { }, + postprocess() { } + }; + + return assertMergedResult([ + { + processor: "markdown/markdown" + }, + { + processor + } + ], { + plugins: baseConfig.plugins, + + processor + }); + }); + + it("should error when an invalid string is used", async () => { + + await assertInvalidConfig([ + { + processor: "foo" + } + ], "pluginName/objectName"); + }); + + it("should error when an empty string is used", async () => { + + await assertInvalidConfig([ + { + processor: "" + } + ], "pluginName/objectName"); + }); + + it("should error when an invalid processor is used", async () => { + await assertInvalidConfig([ + { + processor: {} + } + ], "Object must have a preprocess() and a postprocess() method."); + + }); + + it("should error when a processor cannot be found in a plugin", async () => { + await assertInvalidConfig([ + { + plugins: { + foo: {} + }, + processor: "foo/bar" + } + ], /Could not find "bar" in plugin "foo"/u); + + }); + + }); + + describe("linterOptions", () => { + + it("should error when an unexpected key is found", async () => { + + await assertInvalidConfig([ + { + linterOptions: { + foo: true + } + } + ], "Unexpected key \"foo\" found."); + + }); + + describe("noInlineConfig", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + linterOptions: { + noInlineConfig: "true" + } + } + ], "Expected a Boolean."); + }); + + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + linterOptions: { + noInlineConfig: true + } + }, + { + linterOptions: { + noInlineConfig: false + } + } + ], { + plugins: baseConfig.plugins, + + linterOptions: { + noInlineConfig: false + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + linterOptions: { + noInlineConfig: false + } + }, + { + } + ], { + plugins: baseConfig.plugins, + + linterOptions: { + noInlineConfig: false + } + })); + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { + linterOptions: { + noInlineConfig: false + } + } + ], { + plugins: baseConfig.plugins, + + linterOptions: { + noInlineConfig: false + } + })); + + + }); + describe("reportUnusedDisableDirectives", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + linterOptions: { + reportUnusedDisableDirectives: "true" + } + } + ], /Expected a Boolean/u); + }); + + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + linterOptions: { + reportUnusedDisableDirectives: false + } + }, + { + linterOptions: { + reportUnusedDisableDirectives: true + } + } + ], { + plugins: baseConfig.plugins, + + linterOptions: { + reportUnusedDisableDirectives: true + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + {}, + { + linterOptions: { + reportUnusedDisableDirectives: true + } + } + ], { + plugins: baseConfig.plugins, + + linterOptions: { + reportUnusedDisableDirectives: true + } + })); + + + }); + + }); + + describe("languageOptions", () => { + + it("should error when an unexpected key is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + foo: true + } + } + ], "Unexpected key \"foo\" found."); + + }); + + it("should merge two languageOptions objects with different properties", () => assertMergedResult([ + { + languageOptions: { + ecmaVersion: 2019 + } + }, + { + languageOptions: { + sourceType: "commonjs" + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + ecmaVersion: 2019, + sourceType: "commonjs" + } + })); + + describe("ecmaVersion", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + ecmaVersion: "true" + } + } + ], "Expected a number."); + }); + + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + ecmaVersion: 2019 + } + }, + { + languageOptions: { + ecmaVersion: 2021 + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + ecmaVersion: 2021 + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + languageOptions: { + ecmaVersion: 2021 + } + }, + { + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + ecmaVersion: 2021 + } + })); + + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { + languageOptions: { + ecmaVersion: 2021 + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + ecmaVersion: 2021 + } + })); + + + }); + + describe("sourceType", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + sourceType: "true" + } + } + ], "Expected \"script\", \"module\", or \"commonjs\"."); + }); + + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + sourceType: "module" + } + }, + { + languageOptions: { + sourceType: "script" + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + sourceType: "script" + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + languageOptions: { + sourceType: "script" + } + }, + { + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + sourceType: "script" + } + })); + + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { + languageOptions: { + sourceType: "module" + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + sourceType: "module" + } + })); + + + }); + + describe("globals", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + globals: "true" + } + } + ], "Expected an object."); + }); + + it("should error when an unexpected key value is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + globals: { + foo: "truex" + } + } + } + ], "Key \"foo\": Expected \"readonly\", \"writable\", or \"off\"."); + }); + + it("should error when a global has leading whitespace", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + globals: { + " foo": "readonly" + } + } + } + ], /Global " foo" has leading or trailing whitespace/u); + }); + + it("should error when a global has trailing whitespace", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + globals: { + "foo ": "readonly" + } + } + } + ], /Global "foo " has leading or trailing whitespace/u); + }); + + it("should merge two objects when second object has different keys", () => assertMergedResult([ + { + languageOptions: { + globals: { + foo: "readonly" + } + } + }, + { + languageOptions: { + globals: { + bar: "writable" + } + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + globals: { + foo: "readonly", + bar: "writable" + } + } + })); + + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + globals: { + foo: null + } + } + }, + { + languageOptions: { + globals: { + foo: "writeable" + } + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + globals: { + foo: "writeable" + } + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + languageOptions: { + globals: { + foo: "readable" + } + } + }, + { + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + globals: { + foo: "readable" + } + } + })); + + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { + languageOptions: { + globals: { + foo: "false" + } + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + globals: { + foo: "false" + } + } + })); + + + }); + + describe("parser", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + parser: true + } + } + ], "Expected an object or string."); + }); + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + parser: "true" + } + } + ], /Expected string in the form "pluginName\/objectName"/u); + }); + + it("should error when a plugin parser can't be found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + parser: "foo/bar" + } + } + ], "Key \"parser\": Could not find \"bar\" in plugin \"foo\"."); + }); + + it("should error when a value doesn't have a parse() method", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + parser: {} + } + } + ], "Expected object to have a parse() or parseForESLint() method."); + }); + + it("should merge two objects when second object has overrides", () => { + + const parser = { parse() {} }; + const stubParser = { parse() { } }; + + return assertMergedResult([ + { + languageOptions: { + parser + } + }, + { + plugins: { + "@foo/baz": { + parsers: { + bar: stubParser + } + } + }, + languageOptions: { + parser: "@foo/baz/bar" + } + } + ], { + plugins: { + "@foo/baz": { + parsers: { + bar: stubParser + } + }, + ...baseConfig.plugins + }, + languageOptions: { + parser: stubParser + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + const stubParser = { parse() { } }; + + return assertMergedResult([ + { + plugins: { + foo: { + parsers: { + bar: stubParser + } + } + }, + + languageOptions: { + parser: "foo/bar" + } + }, + { + } + ], { + plugins: { + foo: { + parsers: { + bar: stubParser + } + }, + ...baseConfig.plugins + }, + + languageOptions: { + parser: stubParser + } + }); + + }); + + + it("should merge undefined and an object into one object", () => { + + const stubParser = { parse() {} }; + + return assertMergedResult([ + { + }, + { + plugins: { + foo: { + parsers: { + bar: stubParser + } + } + }, + + languageOptions: { + parser: "foo/bar" + } + } + ], { + plugins: { + foo: { + parsers: { + bar: stubParser + } + }, + ...baseConfig.plugins + }, + + languageOptions: { + parser: stubParser + } + }); + + }); + + }); + + + describe("parserOptions", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + parserOptions: "true" + } + } + ], "Expected an object."); + }); + + it("should merge two objects when second object has different keys", () => assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + }, + { + languageOptions: { + parserOptions: { + bar: "baz" + } + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + parserOptions: { + foo: "whatever", + bar: "baz" + } + } + })); + + it("should deeply merge two objects when second object has different keys", () => assertMergedResult([ + { + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true + } + } + } + }, + { + languageOptions: { + parserOptions: { + ecmaFeatures: { + globalReturn: true + } + } + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + globalReturn: true + } + } + } + })); + + it("should deeply merge two objects when second object has missing key", () => assertMergedResult([ + { + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true + } + } + } + }, + { + languageOptions: { + ecmaVersion: 2021 + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + ecmaVersion: 2021, + parserOptions: { + ecmaFeatures: { + jsx: true + } + } + } + })); + + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + }, + { + languageOptions: { + parserOptions: { + foo: "bar" + } + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + parserOptions: { + foo: "bar" + } + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + }, + { + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + })); + + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { + languageOptions: { + parserOptions: { + foo: "bar" + } + } + } + ], { + plugins: baseConfig.plugins, + + languageOptions: { + parserOptions: { + foo: "bar" + } + } + })); + + + }); + + + }); + + describe("rules", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + rules: true + } + ], "Expected an object."); + }); + + it("should error when an invalid rule severity is set", async () => { + + await assertInvalidConfig([ + { + rules: { + foo: true + } + } + ], "Key \"rules\": Key \"foo\": Expected a string, number, or array."); + }); + + it("should error when an invalid rule severity of the right type is set", async () => { + + await assertInvalidConfig([ + { + rules: { + foo: 3 + } + } + ], "Key \"rules\": Key \"foo\": Expected severity of \"off\", 0, \"warn\", 1, \"error\", or 2."); + }); + + it("should error when an invalid rule severity is set in an array", async () => { + + await assertInvalidConfig([ + { + rules: { + foo: [true] + } + } + ], "Key \"rules\": Key \"foo\": Expected severity of \"off\", 0, \"warn\", 1, \"error\", or 2."); + }); + + it("should error when rule options don't match schema", async () => { + + await assertInvalidConfig([ + { + rules: { + foo: [1, "bar"] + } + } + ], /Value "bar" should be equal to one of the allowed values/u); + }); + + it("should error when rule options don't match schema requiring at least one item", async () => { + + await assertInvalidConfig([ + { + rules: { + foo2: 1 + } + } + ], /Value \[\] should NOT have fewer than 1 items/u); + }); + + it("should merge two objects", () => assertMergedResult([ + { + rules: { + foo: 1, + bar: "error" + } + }, + { + rules: { + baz: "warn", + boom: 0 + } + } + ], { + plugins: baseConfig.plugins, + + rules: { + foo: [1], + bar: [2], + baz: [1], + boom: [0] + } + })); + + it("should merge two objects when second object has simple overrides", () => assertMergedResult([ + { + rules: { + foo: [1, "always"], + bar: "error" + } + }, + { + rules: { + foo: "error", + bar: 0 + } + } + ], { + plugins: baseConfig.plugins, + + rules: { + foo: [2, "always"], + bar: [0] + } + })); + + it("should merge two objects when second object has array overrides", () => assertMergedResult([ + { + rules: { + foo: 1, + bar: "error" + } + }, + { + rules: { + foo: ["error", "never"], + bar: ["warn", "foo"] + } + } + ], { + plugins: baseConfig.plugins, + rules: { + foo: [2, "never"], + bar: [1, "foo"] + } + })); + + it("should merge two objects and options when second object overrides without options", () => assertMergedResult([ + { + rules: { + foo: [1, "always"], + bar: "error" + } + }, + { + plugins: { + "foo/baz/boom": { + rules: { + bang: {} + } + } + }, + rules: { + foo: ["error"], + bar: 0, + "foo/baz/boom/bang": "error" + } + } + ], { + plugins: { + ...baseConfig.plugins, + "foo/baz/boom": { + rules: { + bang: {} + } + } + }, + rules: { + foo: [2, "always"], + bar: [0], + "foo/baz/boom/bang": [2] + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { + rules: { + foo: 0, + bar: 1 + } + }, + { + } + ], { + plugins: baseConfig.plugins, + rules: { + foo: [0], + bar: [1] + } + })); + + it("should merge a rule that doesn't exist without error when the rule is off", () => assertMergedResult([ + { + rules: { + foo: 0, + bar: 1 + } + }, + { + rules: { + nonExistentRule: 0, + nonExistentRule2: ["off", "bar"] + } + } + ], { + plugins: baseConfig.plugins, + rules: { + foo: [0], + bar: [1], + nonExistentRule: [0], + nonExistentRule2: [0, "bar"] + } + })); + + }); + + }); +});