From 0d6db1b17d660af6e815fed17dfcf95f0f0f3513 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 19 Mar 2021 12:34:58 -0700 Subject: [PATCH 01/35] Update: Implement FlatConfigArray (refs #13481) --- lib/config/flat-config-array.js | 155 ++++++++++++++++++++++++++ package.json | 1 + tests/lib/config/flat-config-array.js | 114 +++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 lib/config/flat-config-array.js create mode 100644 tests/lib/config/flat-config-array.js diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js new file mode 100644 index 00000000000..6c4c2c824e5 --- /dev/null +++ b/lib/config/flat-config-array.js @@ -0,0 +1,155 @@ +/** + * @fileoverview Flat Config Array + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const { ConfigArray } = require("@humanwhocodes/config-array"); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * 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 (value && typeof value !== "object") { + 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."); + } +} + +/** + * Validates that a value is a string. + * @param {any} value The value to check. + * @returns {void} + * @throws {TypeError} If the value isn't a string. + */ +function assertIsString(value) { + if (typeof value !== "string") { + throw new TypeError("Expected a string."); + } +} + +/** + * Validates that a value is a number. + * @param {any} value The value to check. + * @returns {void} + * @throws {TypeError} If the value isn't a number. + */ +function assertIsNumber(value) { + if (typeof value !== "number") { + throw new TypeError("Expected a number."); + } +} + +/** + * Merges two objects into a new object with combined properties. + * @param {Object} first The base object. + * @param {Object} second The object with overrides to merge. + * @returns {Object} A new object with a combination of the properties from + * the two parameter objects. + */ +function mergeByMix(first, second) { + return { + ...first, + ...second + }; +} + +/** + * Replaces one value with an override if present. + * @param {any} first The original value. + * @param {any} second The value to override the first with. + * @returns {any} The final value. + */ +function mergeByReplace(first, second) { + + // as long as the second value isn't undefined, return it + if (second !== void 0) { + return second; + } + + return first; +} + +//----------------------------------------------------------------------------- +// Schemas +//----------------------------------------------------------------------------- + +// values must be an object and properties are merged +const objectMixSchema = { + merge: mergeByMix, + validate: assertIsObject +}; + +const stringSchema = { + merge: mergeByReplace, + validate: assertIsString +}; + +const numberSchema = { + merge: mergeByReplace, + validate: assertIsNumber +}; + +const languageOptionsSchema = { + ecmaVersion: numberSchema, + sourceType: stringSchema, + globals: objectMixSchema, + parser: { + merge: mergeByReplace, + validate: assertIsObjectOrString + }, + parserOptions: objectMixSchema, +}; + +const linterOptionsSchema = { + reportUnusedDisableDirectives: { + merge: mergeByReplace, + validate(value) { + if (typeof value !== "string" || !/^off|warn|error$/.test(value)) { + throw new TypeError("Value must be 'off', 'warn', or 'error'."); + } + } + } +}; + +const topLevelSchema = { + settings: objectMixSchema +}; + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +class FlatConfigArray extends ConfigArray { + + constructor(configs, options) { + super(configs, { + ...options, + schema: topLevelSchema + }); + } + +} + +exports.FlatConfigArray = FlatConfigArray; diff --git a/package.json b/package.json index 5cb9c6a0d2c..db9928fa5db 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.3.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..6a45f8a68e1 --- /dev/null +++ b/tests/lib/config/flat-config-array.js @@ -0,0 +1,114 @@ +/** + * @fileoverview Tests for FlatConfigArray + * @author Nicholas C. Zakas + */ + +"use strict"; + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const { FlatConfigArray } = require("../../../lib/config/flat-config-array"); +const assert = require("chai").assert; + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +function createFlatConfigArray(configs) { + return new FlatConfigArray(configs, { + basePath: __dirname + }); +} + +async function assertMergedResult(values, result) { + const configs = createFlatConfigArray(values); + await configs.normalize(); + + const config = configs.getConfig("foo.js"); + assert.deepStrictEqual(config, result); +} + +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + +describe("FlatConfigArray", () => { + + describe("Config Properties", () => { + + describe("settings", () => { + + it("should merge two objects", () => { + + return assertMergedResult([ + { + settings: { + a: true, + b: false + } + }, + { + settings: { + c: true, + d: false + } + } + ], { + settings: { + a: true, + b: false, + c: true, + d: false + } + }); + }); + + it("should merge two objects when second object has overrides", () => { + + return assertMergedResult([ + { + settings: { + a: true, + b: false + } + }, + { + settings: { + c: true, + a: false + } + } + ], { + settings: { + a: false, + b: false, + c: true + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + settings: { + a: true, + b: false + } + }, + { + } + ], { + settings: { + a: true, + b: false + } + }); + + }); + + }); + }); +}); From 003aa0a2648809f0a4f9f929b755052203da71aa Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 19 Mar 2021 18:59:13 -0700 Subject: [PATCH 02/35] Upgrade config-array package --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db9928fa5db..3d5bb65e706 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.2", - "@humanwhocodes/config-array": "^0.3.0", + "@humanwhocodes/config-array": "^0.3.1", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", From f596b2b54930e00980bf9bca92fdf2f12ccd7e7d Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 22 Mar 2021 11:54:21 -0700 Subject: [PATCH 03/35] Add schemas for linterOptions, processor, plugins --- lib/config/flat-config-array.js | 152 +++++++------- package.json | 2 +- tests/lib/config/flat-config-array.js | 278 ++++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 77 deletions(-) diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index 6c4c2c824e5..1c81ece0171 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -13,15 +13,9 @@ const { ConfigArray } = require("@humanwhocodes/config-array"); // Helpers //----------------------------------------------------------------------------- -/** - * 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 (value && typeof value !== "object") { - throw new TypeError("Expected an object."); +function assertIsPluginMemberName(value) { + if (!/[a-z0-9-_$]+\/[a-z0-9-_$]+/i.test(value)) { + throw new TypeError("Expected string in the form \"plugin-name/object-name\".") } } @@ -37,104 +31,110 @@ function assertIsObjectOrString(value) { } } -/** - * Validates that a value is a string. - * @param {any} value The value to check. - * @returns {void} - * @throws {TypeError} If the value isn't a string. - */ -function assertIsString(value) { - if (typeof value !== "string") { - throw new TypeError("Expected a string."); - } -} - -/** - * Validates that a value is a number. - * @param {any} value The value to check. - * @returns {void} - * @throws {TypeError} If the value isn't a number. - */ -function assertIsNumber(value) { - if (typeof value !== "number") { - throw new TypeError("Expected a number."); - } -} - -/** - * Merges two objects into a new object with combined properties. - * @param {Object} first The base object. - * @param {Object} second The object with overrides to merge. - * @returns {Object} A new object with a combination of the properties from - * the two parameter objects. - */ -function mergeByMix(first, second) { - return { - ...first, - ...second - }; -} - -/** - * Replaces one value with an override if present. - * @param {any} first The original value. - * @param {any} second The value to override the first with. - * @returns {any} The final value. - */ -function mergeByReplace(first, second) { - - // as long as the second value isn't undefined, return it - if (second !== void 0) { - return second; - } - - return first; -} - //----------------------------------------------------------------------------- // Schemas //----------------------------------------------------------------------------- // values must be an object and properties are merged -const objectMixSchema = { - merge: mergeByMix, - validate: assertIsObject +const objectAssignSchema = { + merge: "assign", + validate: "object" }; const stringSchema = { - merge: mergeByReplace, - validate: assertIsString + merge: "replace", + validate: "string" }; const numberSchema = { - merge: mergeByReplace, - validate: assertIsNumber + merge: "replace", + validate: "number" +}; + +const booleanSchema = { + merge: "replace", + validate: "boolean" }; const languageOptionsSchema = { ecmaVersion: numberSchema, sourceType: stringSchema, - globals: objectMixSchema, + globals: objectAssignSchema, parser: { - merge: mergeByReplace, + merge: "replace", validate: assertIsObjectOrString }, - parserOptions: objectMixSchema, + parserOptions: objectAssignSchema, }; const linterOptionsSchema = { + noInlineConfig: booleanSchema, reportUnusedDisableDirectives: { - merge: mergeByReplace, + merge: "replace", validate(value) { if (typeof value !== "string" || !/^off|warn|error$/.test(value)) { - throw new TypeError("Value must be 'off', 'warn', or 'error'."); + throw new TypeError("Value must be \"off\", \"warn\", or \"error\"."); } } } }; const topLevelSchema = { - settings: objectMixSchema + settings: objectAssignSchema, + linterOptions: { + schema: linterOptionsSchema + }, + // languageOptions: { + + // }, + processor: { + merge: "replace", + validate(value) { + if (typeof value === "string") { + assertIsPluginMemberName(value); + } else if (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."); + } + } + }, + plugins: { + 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) { + 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)) { + if (value[key] === null || typeof value[key] !== "object") { + throw new TypeError(`Key "${key}": Expected an object.`); + } + } + } + }, + // rules: { + + // } }; //----------------------------------------------------------------------------- diff --git a/package.json b/package.json index 3d5bb65e706..39a591c2dd7 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.2", - "@humanwhocodes/config-array": "^0.3.1", + "@humanwhocodes/config-array": "^0.3.2", "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 index 6a45f8a68e1..fa2eba62f0d 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -30,6 +30,15 @@ async function assertMergedResult(values, result) { assert.deepStrictEqual(config, result); } +async function assertInvalidConfig(values, message) { + const configs = createFlatConfigArray(values); + await configs.normalize(); + + assert.throws(() => { + configs.getConfig("foo.js"); + }, message); +} + //----------------------------------------------------------------------------- // Tests //----------------------------------------------------------------------------- @@ -110,5 +119,274 @@ describe("FlatConfigArray", () => { }); }); + + describe("plugins", () => { + + const pluginA = {}; + const pluginB = {}; + const pluginC = {}; + + it("should merge two objects", () => { + + return assertMergedResult([ + { + plugins: { + a: pluginA, + b: pluginB + } + }, + { + plugins: { + c: pluginC + } + } + ], { + plugins: { + a: pluginA, + b: pluginB, + c: pluginC + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + plugins: { + a: pluginA, + b: pluginB + } + }, + { + } + ], { + plugins: { + a: pluginA, + b: pluginB + } + }); + + }); + + it("should error when attempting to redefine a plugin", async () => { + + await assertInvalidConfig([ + { + plugins: { + a: pluginA, + b: pluginB + } + }, + { + plugins: { + a: pluginC + } + } + ], "redefine plugin"); + }); + + 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", () => { + + return assertMergedResult([ + { + processor: { + preprocess() {}, + postprocess() {} + } + }, + { + processor: "markdown/markdown" + } + ], { + processor: "markdown/markdown" + }); + }); + + it("should merge two values when second is an object", () => { + + const processor = { + preprocess() { }, + postprocess() { } + }; + + return assertMergedResult([ + { + processor: "markdown/markdown" + }, + { + processor + } + ], { + processor + }); + }); + + it("should error when an invalid string is used", async () => { + + await assertInvalidConfig([ + { + processor: "foo" + } + ], "plugin-name/object-name"); + }); + + it("should error when an empty string is used", async () => { + + await assertInvalidConfig([ + { + processor: "" + } + ], "plugin-name/object-name"); + }); + + it("should error when an invalid processor is used", async () => { + await assertInvalidConfig([ + { + processor: {} + } + ], "preprocess() and a postprocess()"); + + }); + + }); + + 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", () => { + + return assertMergedResult([ + { + linterOptions: { + noInlineConfig: true + } + }, + { + linterOptions: { + noInlineConfig: false + } + } + ], { + linterOptions: { + noInlineConfig: false + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + linterOptions: { + noInlineConfig: false + } + }, + { + } + ], { + linterOptions: { + noInlineConfig: false + } + }); + + }); + + + }); + describe("reportUnusedDisableDirectives", () => { + + it("should error when an unexpected value is found", async () => { + + await assertInvalidConfig([ + { + linterOptions: { + reportUnusedDisableDirectives: "true" + } + } + ], "Value must be \"off\", \"warn\", or \"error\"."); + }); + + it("should merge two objects when second object has overrides", () => { + + return assertMergedResult([ + { + linterOptions: { + reportUnusedDisableDirectives: "off" + } + }, + { + linterOptions: { + reportUnusedDisableDirectives: "error" + } + } + ], { + linterOptions: { + reportUnusedDisableDirectives: "error" + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + {}, + { + linterOptions: { + reportUnusedDisableDirectives: "warn" + } + } + ], { + linterOptions: { + reportUnusedDisableDirectives: "warn" + } + }); + + }); + + + }); + + }); }); }); From 919d8c84df06a0d8aa05ce7aaf31a5fb294ce82c Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Wed, 31 Mar 2021 12:13:24 -0700 Subject: [PATCH 04/35] Continue implementing config schemas --- lib/config/flat-config-array.js | 202 +++++++---- tests/lib/config/flat-config-array.js | 501 ++++++++++++++++++++++++++ 2 files changed, 642 insertions(+), 61 deletions(-) diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index 1c81ece0171..11b99655c99 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -9,16 +9,42 @@ const { ConfigArray } = require("@humanwhocodes/config-array"); +//----------------------------------------------------------------------------- +// 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 globalVariablesValues = new Set([true, false, "readonly", "writeable", "off"]); + function assertIsPluginMemberName(value) { if (!/[a-z0-9-_$]+\/[a-z0-9-_$]+/i.test(value)) { throw new TypeError("Expected string in the form \"plugin-name/object-name\".") } } +/** + * 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 (!value || typeof value !== "object") { + throw new TypeError("Expected an object."); + } +} + /** * Validates that a value is an object or a string. * @param {any} value The value to check. @@ -26,7 +52,7 @@ function assertIsPluginMemberName(value) { * @throws {TypeError} If the value isn't an object or a string. */ function assertIsObjectOrString(value) { - if (value && typeof value !== "object" && typeof value !== "string") { + if ((!value || typeof value !== "object") && typeof value !== "string") { throw new TypeError("Expected an object or string."); } } @@ -36,102 +62,156 @@ function assertIsObjectOrString(value) { //----------------------------------------------------------------------------- // values must be an object and properties are merged + +/** @type {ObjectPropertySchema} */ const objectAssignSchema = { merge: "assign", validate: "object" }; +/** @type {ObjectPropertySchema} */ const stringSchema = { merge: "replace", validate: "string" }; +/** @type {ObjectPropertySchema} */ const numberSchema = { merge: "replace", validate: "number" }; +/** @type {ObjectPropertySchema} */ const booleanSchema = { merge: "replace", validate: "boolean" }; -const languageOptionsSchema = { - ecmaVersion: numberSchema, - sourceType: stringSchema, - globals: objectAssignSchema, - parser: { - merge: "replace", - validate: assertIsObjectOrString - }, - parserOptions: objectAssignSchema, -}; +/** @type {ObjectPropertySchema} */ +const globalsSchema = { + merge: "assign", + validate(value) { + + assertIsObject(value); + + for (const key of Object.keys(value)) { + if (key !== key.trim()) { + throw new TypeError(`Global "${key}" has leading or trailing whitespace.`); + } -const linterOptionsSchema = { - noInlineConfig: booleanSchema, - reportUnusedDisableDirectives: { - merge: "replace", - validate(value) { - if (typeof value !== "string" || !/^off|warn|error$/.test(value)) { - throw new TypeError("Value must be \"off\", \"warn\", or \"error\"."); + if (!globalVariablesValues.has(value[key])) { + throw new TypeError("Expected \"readonly\", \"writeable\", or \"off\"."); } } } }; -const topLevelSchema = { - settings: objectAssignSchema, - linterOptions: { - schema: linterOptionsSchema - }, - // languageOptions: { - - // }, - processor: { - merge: "replace", - validate(value) { - if (typeof value === "string") { - assertIsPluginMemberName(value); - } else if (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 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) { + 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; }, - plugins: { - 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) { - if (key in first && key in second && first[key] !== second[key]) { - throw new TypeError(`Cannot redefine plugin "${key}".`); - } - - result[key] = second[key] ?? first[key]; - } + validate(value) { - 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."); + } - // 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)) { + if (value[key] === null || typeof value[key] !== "object") { + throw new TypeError(`Key "${key}": Expected an object.`); } + } + } +}; - // second check the keys to make sure they are objects - for (const key of Object.keys(value)) { - 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 reportUnusedDisableDirectivesSchema = { + merge: "replace", + validate(value) { + if (typeof value !== "string" || !/^off|warn|error$/.test(value)) { + throw new TypeError("Value must be \"off\", \"warn\", or \"error\"."); + } + } +}; + +/** @type {ObjectPropertySchema} */ +const sourceTypeSchema = { + merge: "replace", + validate(value) { + if (typeof value !== "string" || !/^(?:script|module|commonjs)$/.test(value)) { + throw new TypeError("Expected \"script\", \"module\", or \"commonjs\"."); + } + } +}; + + + +const topLevelSchema = { + settings: objectAssignSchema, + linterOptions: { + schema: { + noInlineConfig: booleanSchema, + reportUnusedDisableDirectives: reportUnusedDisableDirectivesSchema + } + }, + languageOptions: { + schema: { + ecmaVersion: numberSchema, + sourceType: sourceTypeSchema, + globals: globalsSchema, + parser: parserSchema, + parserOptions: objectAssignSchema } }, + processor: processorSchema, + plugins: pluginsSchema, // rules: { // } diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index fa2eba62f0d..d40064a26ea 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -388,5 +388,506 @@ describe("FlatConfigArray", () => { }); }); + + describe("languageOptions", () => { + + it("should error when an unexpected key is found", async () => { + + await assertInvalidConfig([ + { + languageOptions: { + foo: true + } + } + ], "Unexpected key \"foo\" found."); + + }); + + 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", () => { + + return assertMergedResult([ + { + languageOptions: { + ecmaVersion: 2019 + } + }, + { + languageOptions: { + ecmaVersion: 2021 + } + } + ], { + languageOptions: { + ecmaVersion: 2021 + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + languageOptions: { + ecmaVersion: 2021 + } + }, + { + } + ], { + languageOptions: { + ecmaVersion: 2021 + } + }); + + }); + + + it("should merge undefined and an object into one object", () => { + + return assertMergedResult([ + { + }, + { + languageOptions: { + ecmaVersion: 2021 + } + } + ], { + 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", () => { + + return assertMergedResult([ + { + languageOptions: { + sourceType: "module" + } + }, + { + languageOptions: { + sourceType: "script" + } + } + ], { + languageOptions: { + sourceType: "script" + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + languageOptions: { + sourceType: "script" + } + }, + { + } + ], { + languageOptions: { + sourceType: "script" + } + }); + + }); + + + it("should merge undefined and an object into one object", () => { + + return assertMergedResult([ + { + }, + { + languageOptions: { + sourceType: "module" + } + } + ], { + 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: "true" + } + } + } + ], "Expected \"readonly\", \"writeable\", or \"off\".") + }); + + it("should merge two objects when second object has different keys", () => { + + return assertMergedResult([ + { + languageOptions: { + globals: { + foo: "readonly" + } + } + }, + { + languageOptions: { + globals: { + bar: "writeable" + } + } + } + ], { + languageOptions: { + globals: { + foo: "readonly", + bar: "writeable" + } + } + }); + }); + + it("should merge two objects when second object has overrides", () => { + + return assertMergedResult([ + { + languageOptions: { + globals: { + foo: "readonly" + } + } + }, + { + languageOptions: { + globals: { + foo: "writeable" + } + } + } + ], { + languageOptions: { + globals: { + foo: "writeable" + } + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + languageOptions: { + globals: { + foo: "readonly" + } + } + }, + { + } + ], { + languageOptions: { + globals: { + foo: "readonly" + } + } + }); + + }); + + + it("should merge undefined and an object into one object", () => { + + return assertMergedResult([ + { + }, + { + languageOptions: { + globals: { + foo: "readonly" + } + } + } + ], { + languageOptions: { + globals: { + foo: "readonly" + } + } + }); + + }); + + + }); + + 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 \"plugin-name/object-name\".") + }); + + 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(){} }; + + return assertMergedResult([ + { + languageOptions: { + parser: "foo/bar" + } + }, + { + languageOptions: { + parser + } + } + ], { + languageOptions: { + parser + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + languageOptions: { + parser: "foo/bar" + } + }, + { + } + ], { + languageOptions: { + parser: "foo/bar" + } + }); + + }); + + + it("should merge undefined and an object into one object", () => { + + return assertMergedResult([ + { + }, + { + languageOptions: { + parser: "foo/bar" + } + } + ], { + languageOptions: { + parser: "foo/bar" + } + }); + + }); + + }); + + + 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", () => { + + return assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + }, + { + languageOptions: { + parserOptions: { + bar: "baz" + } + } + } + ], { + languageOptions: { + parserOptions: { + foo: "whatever", + bar: "baz" + } + } + }); + }); + + it("should merge two objects when second object has overrides", () => { + + return assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + }, + { + languageOptions: { + parserOptions: { + foo: "bar" + } + } + } + ], { + languageOptions: { + parserOptions: { + foo: "bar" + } + } + }); + }); + + it("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + }, + { + } + ], { + languageOptions: { + parserOptions: { + foo: "whatever" + } + } + }); + + }); + + + it("should merge undefined and an object into one object", () => { + + return assertMergedResult([ + { + }, + { + languageOptions: { + parserOptions: { + foo: "bar" + } + } + } + ], { + languageOptions: { + parserOptions: { + foo: "bar" + } + } + }); + + }); + + + }); + + + }); }); }); From 07aac9a7f74c28da233410e2357c83c514291986 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 2 Apr 2021 18:49:25 -0700 Subject: [PATCH 05/35] RulesSchema start --- lib/config/assertions.js | 40 +++++++ lib/config/flat-config-array.js | 92 ++++++--------- lib/config/rules-schema.js | 163 ++++++++++++++++++++++++++ tests/lib/config/flat-config-array.js | 129 +++++++++++++++++++- 4 files changed, 364 insertions(+), 60 deletions(-) create mode 100644 lib/config/assertions.js create mode 100644 lib/config/rules-schema.js diff --git a/lib/config/assertions.js b/lib/config/assertions.js new file mode 100644 index 00000000000..35530ef8728 --- /dev/null +++ b/lib/config/assertions.js @@ -0,0 +1,40 @@ +/** + * @fileoverview Assertions for configs + * @author Nicholas C. Zakas + */ + +/** + * 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. + */ +exports.assertIsPluginMemberName = function(value) { + if (!/[a-z0-9-_$]+\/[a-z0-9-_$]+/i.test(value)) { + throw new TypeError("Expected string in the form \"plugin-name/object-name\".") + } +} + +/** + * 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. + */ +exports.assertIsObject = function(value) { + if (!value || typeof value !== "object") { + 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. + */ +exports.assertIsObjectOrString = function(value) { + if ((!value || typeof value !== "object") && typeof value !== "string") { + throw new TypeError("Expected an object or string."); + } +} diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index 11b99655c99..4107d04254c 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -8,6 +8,14 @@ //----------------------------------------------------------------------------- const { ConfigArray } = require("@humanwhocodes/config-array"); +const { + assertIsObject, + assertIsObjectOrString, + assertIsPluginMemberName +} = require("./assertions"); + +const Rules = require("../rules"); +const { RulesSchema } = require("./rules-schema"); //----------------------------------------------------------------------------- // Type Definitions @@ -27,36 +35,6 @@ const { ConfigArray } = require("@humanwhocodes/config-array"); const globalVariablesValues = new Set([true, false, "readonly", "writeable", "off"]); -function assertIsPluginMemberName(value) { - if (!/[a-z0-9-_$]+\/[a-z0-9-_$]+/i.test(value)) { - throw new TypeError("Expected string in the form \"plugin-name/object-name\".") - } -} - -/** - * 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 (!value || typeof value !== "object") { - 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."); - } -} - //----------------------------------------------------------------------------- // Schemas //----------------------------------------------------------------------------- @@ -191,43 +169,39 @@ const sourceTypeSchema = { } }; - - -const topLevelSchema = { - settings: objectAssignSchema, - linterOptions: { - schema: { - noInlineConfig: booleanSchema, - reportUnusedDisableDirectives: reportUnusedDisableDirectivesSchema - } - }, - languageOptions: { - schema: { - ecmaVersion: numberSchema, - sourceType: sourceTypeSchema, - globals: globalsSchema, - parser: parserSchema, - parserOptions: objectAssignSchema - } - }, - processor: processorSchema, - plugins: pluginsSchema, - // rules: { - - // } -}; - //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- class FlatConfigArray extends ConfigArray { - constructor(configs, options) { + constructor(configs, { basePath, builtInRules }) { super(configs, { - ...options, - schema: topLevelSchema + basePath, + schema: { + settings: objectAssignSchema, + linterOptions: { + schema: { + noInlineConfig: booleanSchema, + reportUnusedDisableDirectives: reportUnusedDisableDirectivesSchema + } + }, + languageOptions: { + schema: { + ecmaVersion: numberSchema, + sourceType: sourceTypeSchema, + globals: globalsSchema, + parser: parserSchema, + parserOptions: objectAssignSchema + } + }, + processor: processorSchema, + plugins: pluginsSchema, + rules: new RulesSchema({ builtInRules }) + } }); + + this.builtInRules = builtInRules; } } diff --git a/lib/config/rules-schema.js b/lib/config/rules-schema.js new file mode 100644 index 00000000000..cd65f64f1ad --- /dev/null +++ b/lib/config/rules-schema.js @@ -0,0 +1,163 @@ +/** + * @fileoverview RulesSchema Class + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const ajv = require("../shared/ajv")(); +const { + assertIsObject, + assertIsObjectOrString, + assertIsPluginMemberName +} = require("./assertions"); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const ruleSeverities = new Map([ + [0, 0], ["off", 0], + [1, 1], ["warn", 1], + [2, 2], ["error", 2] +]); + +/** + * 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.`); + } +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + + +class RulesSchema { + + merge(first = {}, second = {}) { + + const result = { + ...first, + ...second + }; + + for (const ruleId of Object.keys(result)) { + + /* + * If either rule config is missing, then no more work + * is necessary; the correct config is already there. + */ + if (!(ruleId in first) || !(ruleId in second)) { + continue; + } + + const firstIsArray = Array.isArray(first[ruleId]); + const secondIsArray = Array.isArray(second[ruleId]); + + /* + * If the first rule config is an array and the second isn't, just + * create a new array where the first element is the severity from + * the second rule config and the other elements are copied over + * from the first rule config. + */ + if (firstIsArray && !secondIsArray) { + result[ruleId] = [second[ruleId], ...first[ruleId].slice(1)]; + continue; + } + + /* + * If the first rule config isn't an array, then the second rule + * config takes precedence. If it's an array, we return a copy; + * otherwise we return the full value (for just severity); + */ + if (!firstIsArray) { + result[ruleId] = secondIsArray + ? second[ruleId].slice(0) + : second[ruleId]; + continue; + } + + /* + * If both the first rule config and the second rule config are + * arrays, then we need to do this complicated merging, which is no + * fun. + */ + result[ruleId] = [ + + // second severity always takes precedence + second[ruleId][0] + ]; + + const length = Math.max(first.length, second.length); + + for (let i = 1; i < length; i++) { + + } + + } + + return result; + } + + /** + * Checks to see if the rules object is valid. This does not validate + * rule options -- that step happens after configuration is calculated. + * @param {any} value The value to check. + * @throws {TypeError} If the rules object isn't valid. + */ + validate(value) { + assertIsObject(value); + + let lastRuleId; + + // Performance: One try-catch has less overhead than one per loop iteration + try { + + for (const ruleId of Object.keys(value)) { + 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; + } + } +} + + +exports.RulesSchema = RulesSchema; diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index d40064a26ea..f284b17afd3 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -18,7 +18,7 @@ const assert = require("chai").assert; function createFlatConfigArray(configs) { return new FlatConfigArray(configs, { - basePath: __dirname + basePath: __dirname, }); } @@ -889,5 +889,132 @@ describe("FlatConfigArray", () => { }); + + 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 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 merge two objects", () => { + + return assertMergedResult([ + { + rules: { + foo: 1, + bar: "error" + } + }, + { + rules: { + baz: "warn", + boom: 0 + } + } + ], { + rules: { + foo: 1, + bar: "error", + baz: "warn", + boom: 0 + } + }); + }); + + it("should merge two objects when second object has simple overrides", () => { + + return assertMergedResult([ + { + rules: { + foo: [1, "always"], + bar: "error" + } + }, + { + rules: { + foo: "error", + bar: 0 + } + } + ], { + rules: { + foo: ["error", "always"], + bar: 0 + } + }); + }); + + it("should merge two objects when second object has array overrides", () => { + + return assertMergedResult([ + { + rules: { + foo: 1, + bar: "error" + } + }, + { + rules: { + foo: ["error", "never"], + bar: ["warn", "foo"] + } + } + ], { + rules: { + foo: ["error", "never"], + bar: ["warn", "foo"] + } + }); + }); + + xit("should merge an object and undefined into one object", () => { + + return assertMergedResult([ + { + rules: { + a: true, + b: false + } + }, + { + } + ], { + rules: { + a: true, + b: false + } + }); + + }); + + }); + }); }); From 5e9521aeca30d58ca9fdbeb04eae453bd024ab8b Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 5 Apr 2021 12:07:14 -0700 Subject: [PATCH 06/35] Add initial finalization step --- lib/config/assertions.js | 40 --- lib/config/flat-config-array.js | 209 +++------------ lib/config/flat-config-schema.js | 370 ++++++++++++++++++++++++++ lib/config/rules-schema.js | 163 ------------ package.json | 2 +- tests/lib/config/flat-config-array.js | 103 ++++++- 6 files changed, 497 insertions(+), 390 deletions(-) delete mode 100644 lib/config/assertions.js create mode 100644 lib/config/flat-config-schema.js delete mode 100644 lib/config/rules-schema.js diff --git a/lib/config/assertions.js b/lib/config/assertions.js deleted file mode 100644 index 35530ef8728..00000000000 --- a/lib/config/assertions.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @fileoverview Assertions for configs - * @author Nicholas C. Zakas - */ - -/** - * 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. - */ -exports.assertIsPluginMemberName = function(value) { - if (!/[a-z0-9-_$]+\/[a-z0-9-_$]+/i.test(value)) { - throw new TypeError("Expected string in the form \"plugin-name/object-name\".") - } -} - -/** - * 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. - */ -exports.assertIsObject = function(value) { - if (!value || typeof value !== "object") { - 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. - */ -exports.assertIsObjectOrString = function(value) { - if ((!value || typeof value !== "object") && typeof value !== "string") { - throw new TypeError("Expected an object or string."); - } -} diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index 4107d04254c..4db1919f277 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -7,201 +7,60 @@ // Requirements //----------------------------------------------------------------------------- -const { ConfigArray } = require("@humanwhocodes/config-array"); -const { - assertIsObject, - assertIsObjectOrString, - assertIsPluginMemberName -} = require("./assertions"); - +const { ConfigArray, ConfigArraySchema, ConfigArraySymbol } = require("@humanwhocodes/config-array"); +const { flatConfigSchema } = require("./flat-config-schema"); const Rules = require("../rules"); -const { RulesSchema } = require("./rules-schema"); - -//----------------------------------------------------------------------------- -// 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 +// Exports //----------------------------------------------------------------------------- -const globalVariablesValues = new Set([true, false, "readonly", "writeable", "off"]); - -//----------------------------------------------------------------------------- -// Schemas -//----------------------------------------------------------------------------- +class FlatConfigArray extends ConfigArray { -// values must be an object and properties are merged - -/** @type {ObjectPropertySchema} */ -const objectAssignSchema = { - merge: "assign", - validate: "object" -}; - -/** @type {ObjectPropertySchema} */ -const stringSchema = { - merge: "replace", - validate: "string" -}; - -/** @type {ObjectPropertySchema} */ -const numberSchema = { - merge: "replace", - validate: "number" -}; - -/** @type {ObjectPropertySchema} */ -const booleanSchema = { - merge: "replace", - validate: "boolean" -}; - -/** @type {ObjectPropertySchema} */ -const globalsSchema = { - merge: "assign", - validate(value) { - - assertIsObject(value); - - for (const key of Object.keys(value)) { - if (key !== key.trim()) { - throw new TypeError(`Global "${key}" has leading or trailing whitespace.`); - } + constructor(configs, { basePath, builtInRules }) { + super(configs, { + basePath, + schema: flatConfigSchema + }); - if (!globalVariablesValues.has(value[key])) { - throw new TypeError("Expected \"readonly\", \"writeable\", or \"off\"."); - } - } + this.builtInRules = builtInRules; } -}; -/** @type {ObjectPropertySchema} */ -const parserSchema = { - merge: "replace", - validate(value) { - assertIsObjectOrString(value); + /** + * 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) { - if (typeof value === "object" && typeof value.parse !== "function" && typeof value.parseForESLint !== "function") { - throw new TypeError("Expected object to have a parse() or parseForESLint() method."); - } + const { plugins, languageOptions, processor, rules } = config; - 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) { - if (key in first && key in second && first[key] !== second[key]) { - throw new TypeError(`Cannot redefine plugin "${key}".`); + // Check parser value + if (languageOptions && languageOptions.parser && typeof languageOptions.parser === "string") { + const [pluginName, parserName] = languageOptions.parser.split("/"); + + if (!plugins || !plugins[pluginName] || !plugins[pluginName].parsers || !plugins[pluginName].parsers[parserName]) { + throw new TypeError(`Key "parser": Could not find "${ parserName }" in plugin "${pluginName}".`); } - result[key] = second[key] ?? first[key]; + languageOptions.parser = plugins[pluginName].parsers[parserName]; } - return result; - }, - validate(value) { + // Check processor value + if (processor && typeof processor === "string") { + const [pluginName, processorName] = processor.split("/"); - // 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)) { - 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."); + if (!plugins || !plugins[pluginName] || !plugins[pluginName].processors || !plugins[pluginName].processors[processorName]) { + throw new TypeError(`Key "processor": Could not find "${processorName}" in plugin "${pluginName}".`); } - } else { - throw new TypeError("Expected an object or a string."); - } - } -}; - -/** @type {ObjectPropertySchema} */ -const reportUnusedDisableDirectivesSchema = { - merge: "replace", - validate(value) { - if (typeof value !== "string" || !/^off|warn|error$/.test(value)) { - throw new TypeError("Value must be \"off\", \"warn\", or \"error\"."); - } - } -}; - -/** @type {ObjectPropertySchema} */ -const sourceTypeSchema = { - merge: "replace", - validate(value) { - if (typeof value !== "string" || !/^(?:script|module|commonjs)$/.test(value)) { - throw new TypeError("Expected \"script\", \"module\", or \"commonjs\"."); - } - } -}; -//----------------------------------------------------------------------------- -// Exports -//----------------------------------------------------------------------------- + config.processor = plugins[pluginName].processors[processorName]; + } -class FlatConfigArray extends ConfigArray { - constructor(configs, { basePath, builtInRules }) { - super(configs, { - basePath, - schema: { - settings: objectAssignSchema, - linterOptions: { - schema: { - noInlineConfig: booleanSchema, - reportUnusedDisableDirectives: reportUnusedDisableDirectivesSchema - } - }, - languageOptions: { - schema: { - ecmaVersion: numberSchema, - sourceType: sourceTypeSchema, - globals: globalsSchema, - parser: parserSchema, - parserOptions: objectAssignSchema - } - }, - processor: processorSchema, - plugins: pluginsSchema, - rules: new RulesSchema({ builtInRules }) - } - }); - - this.builtInRules = builtInRules; + return config; } } diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js new file mode 100644 index 00000000000..d0016b31ad5 --- /dev/null +++ b/lib/config/flat-config-schema.js @@ -0,0 +1,370 @@ +/** + * @fileoverview Flat config schema + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// 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, false, "readonly", "writeable", "off"]); + +//----------------------------------------------------------------------------- +// 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-_$]+/i.test(value)) { + throw new TypeError("Expected string in the form \"pluginName/objectName\".") + } +} + +/** + * 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 (!value || typeof value !== "object") { + 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 objectAssignSchema = { + merge: "assign", + validate: "object" +}; + +/** @type {ObjectPropertySchema} */ +const stringSchema = { + merge: "replace", + validate: "string" +}; + +/** @type {ObjectPropertySchema} */ +const numberSchema = { + merge: "replace", + validate: "number" +}; + +/** @type {ObjectPropertySchema} */ +const booleanSchema = { + merge: "replace", + validate: "boolean" +}; + +//----------------------------------------------------------------------------- +// 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("Expected \"readonly\", \"writeable\", 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 reportUnusedDisableDirectivesSchema = { + merge: "replace", + validate(value) { + if (typeof value !== "string" || !/^off|warn|error$/.test(value)) { + throw new TypeError("Value must be \"off\", \"warn\", or \"error\"."); + } + } +}; + +/** @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__") { + delete result.__proto__; + continue; + } + + /* + * If either rule config is missing, then no more work + * is necessary; the correct config is already there. + */ + if (!(ruleId in first) || !(ruleId in second)) { + continue; + } + + const firstIsArray = Array.isArray(first[ruleId]); + const secondIsArray = Array.isArray(second[ruleId]); + + /* + * If the first rule config is an array and the second isn't, just + * create a new array where the first element is the severity from + * the second rule config and the other elements are copied over + * from the first rule config. + */ + if (firstIsArray && !secondIsArray) { + result[ruleId] = [second[ruleId], ...first[ruleId].slice(1)]; + continue; + } + + /* + * In any other situation, then the second rule config takes + * precedence. If it's an array, we return a copy; + * otherwise we return the value, which is just the severity. + */ + result[ruleId] = secondIsArray + ? second[ruleId].slice(0) + : second[ruleId]; + } + + 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)$/.test(value)) { + throw new TypeError("Expected \"script\", \"module\", or \"commonjs\"."); + } + } +}; + +//----------------------------------------------------------------------------- +// Full schema +//----------------------------------------------------------------------------- + +exports.flatConfigSchema = { + settings: objectAssignSchema, + linterOptions: { + schema: { + noInlineConfig: booleanSchema, + reportUnusedDisableDirectives: reportUnusedDisableDirectivesSchema + } + }, + languageOptions: { + schema: { + ecmaVersion: numberSchema, + sourceType: sourceTypeSchema, + globals: globalsSchema, + parser: parserSchema, + parserOptions: objectAssignSchema + } + }, + processor: processorSchema, + plugins: pluginsSchema, + rules: rulesSchema +}; diff --git a/lib/config/rules-schema.js b/lib/config/rules-schema.js deleted file mode 100644 index cd65f64f1ad..00000000000 --- a/lib/config/rules-schema.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @fileoverview RulesSchema Class - * @author Nicholas C. Zakas - */ - -//----------------------------------------------------------------------------- -// Requirements -//----------------------------------------------------------------------------- - -const ajv = require("../shared/ajv")(); -const { - assertIsObject, - assertIsObjectOrString, - assertIsPluginMemberName -} = require("./assertions"); - -//----------------------------------------------------------------------------- -// Helpers -//----------------------------------------------------------------------------- - -const ruleSeverities = new Map([ - [0, 0], ["off", 0], - [1, 1], ["warn", 1], - [2, 2], ["error", 2] -]); - -/** - * 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.`); - } -} - -//----------------------------------------------------------------------------- -// Exports -//----------------------------------------------------------------------------- - - -class RulesSchema { - - merge(first = {}, second = {}) { - - const result = { - ...first, - ...second - }; - - for (const ruleId of Object.keys(result)) { - - /* - * If either rule config is missing, then no more work - * is necessary; the correct config is already there. - */ - if (!(ruleId in first) || !(ruleId in second)) { - continue; - } - - const firstIsArray = Array.isArray(first[ruleId]); - const secondIsArray = Array.isArray(second[ruleId]); - - /* - * If the first rule config is an array and the second isn't, just - * create a new array where the first element is the severity from - * the second rule config and the other elements are copied over - * from the first rule config. - */ - if (firstIsArray && !secondIsArray) { - result[ruleId] = [second[ruleId], ...first[ruleId].slice(1)]; - continue; - } - - /* - * If the first rule config isn't an array, then the second rule - * config takes precedence. If it's an array, we return a copy; - * otherwise we return the full value (for just severity); - */ - if (!firstIsArray) { - result[ruleId] = secondIsArray - ? second[ruleId].slice(0) - : second[ruleId]; - continue; - } - - /* - * If both the first rule config and the second rule config are - * arrays, then we need to do this complicated merging, which is no - * fun. - */ - result[ruleId] = [ - - // second severity always takes precedence - second[ruleId][0] - ]; - - const length = Math.max(first.length, second.length); - - for (let i = 1; i < length; i++) { - - } - - } - - return result; - } - - /** - * Checks to see if the rules object is valid. This does not validate - * rule options -- that step happens after configuration is calculated. - * @param {any} value The value to check. - * @throws {TypeError} If the rules object isn't valid. - */ - validate(value) { - assertIsObject(value); - - let lastRuleId; - - // Performance: One try-catch has less overhead than one per loop iteration - try { - - for (const ruleId of Object.keys(value)) { - 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; - } - } -} - - -exports.RulesSchema = RulesSchema; diff --git a/package.json b/package.json index 39a591c2dd7..bc38ff6a28b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.2", - "@humanwhocodes/config-array": "^0.3.2", + "@humanwhocodes/config-array": "^0.4.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 index f284b17afd3..1ebe410f8b7 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -204,6 +204,11 @@ describe("FlatConfigArray", () => { it("should merge two values when second is a string", () => { + const stubProcessor = { + preprocess() {}, + postprocess() {} + }; + return assertMergedResult([ { processor: { @@ -212,10 +217,24 @@ describe("FlatConfigArray", () => { } }, { + plugins: { + markdown: { + processors: { + markdown: stubProcessor + } + } + }, processor: "markdown/markdown" } ], { - processor: "markdown/markdown" + plugins: { + markdown: { + processors: { + markdown: stubProcessor + } + } + }, + processor: stubProcessor }); }); @@ -244,7 +263,7 @@ describe("FlatConfigArray", () => { { processor: "foo" } - ], "plugin-name/object-name"); + ], "pluginName/objectName"); }); it("should error when an empty string is used", async () => { @@ -253,7 +272,7 @@ describe("FlatConfigArray", () => { { processor: "" } - ], "plugin-name/object-name"); + ], "pluginName/objectName"); }); it("should error when an invalid processor is used", async () => { @@ -697,7 +716,18 @@ describe("FlatConfigArray", () => { parser: "true" } } - ], "Expected string in the form \"plugin-name/object-name\".") + ], "Expected string in the form \"pluginName/objectName\".") + }); + + 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 () => { @@ -714,9 +744,17 @@ describe("FlatConfigArray", () => { it("should merge two objects when second object has overrides", () => { const parser = { parse(){} }; + const stubParser = { parse() { } }; return assertMergedResult([ { + plugins: { + foo: { + parsers: { + bar: stubParser + } + } + }, languageOptions: { parser: "foo/bar" } @@ -727,6 +765,13 @@ describe("FlatConfigArray", () => { } } ], { + plugins: { + foo: { + parsers: { + bar: stubParser + } + } + }, languageOptions: { parser } @@ -735,8 +780,18 @@ describe("FlatConfigArray", () => { it("should merge an object and undefined into one object", () => { + const stubParser = { parse() { } }; + return assertMergedResult([ { + plugins: { + foo: { + parsers: { + bar: stubParser + } + } + }, + languageOptions: { parser: "foo/bar" } @@ -744,8 +799,16 @@ describe("FlatConfigArray", () => { { } ], { + plugins: { + foo: { + parsers: { + bar: stubParser + } + } + }, + languageOptions: { - parser: "foo/bar" + parser: stubParser } }); @@ -754,17 +817,35 @@ describe("FlatConfigArray", () => { 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 + } + } + }, + languageOptions: { - parser: "foo/bar" + parser: stubParser } }); @@ -994,21 +1075,21 @@ describe("FlatConfigArray", () => { }); }); - xit("should merge an object and undefined into one object", () => { + it("should merge an object and undefined into one object", () => { return assertMergedResult([ { rules: { - a: true, - b: false + a: 0, + b: 1 } }, { } ], { rules: { - a: true, - b: false + a: 0, + b: 1 } }); From 99b6591130745981534cf389011f9bb740cbf559 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 5 Apr 2021 12:16:47 -0700 Subject: [PATCH 07/35] Default config --- lib/config/default-config.js | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 lib/config/default-config.js diff --git a/lib/config/default-config.js b/lib/config/default-config.js new file mode 100644 index 00000000000..c6cb93c8847 --- /dev/null +++ b/lib/config/default-config.js @@ -0,0 +1,37 @@ +/** + * @fileoverview Default configuration + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Requirements +//----------------------------------------------------------------------------- + +const Rules = require("../rules"); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/* + * 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. + */ +const defaultRulesConfig = new Proxy({}, { + get(target, property) { + return Rules.get(property); + } +}); + +exports.defaultConfig = { + plugins: { + "@": { + parsers: { + espree: require("espree") + } + }, + rules: defaultRulesConfig + }, + ignores: ["node_modules/**"] +}; From 516bd799dfb4db211dcff827250e41d564b12193 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 5 Apr 2021 12:17:23 -0700 Subject: [PATCH 08/35] Strict mode --- lib/config/default-config.js | 2 ++ lib/config/flat-config-array.js | 2 ++ lib/config/flat-config-schema.js | 2 ++ 3 files changed, 6 insertions(+) diff --git a/lib/config/default-config.js b/lib/config/default-config.js index c6cb93c8847..dc7aa1c4161 100644 --- a/lib/config/default-config.js +++ b/lib/config/default-config.js @@ -3,6 +3,8 @@ * @author Nicholas C. Zakas */ +"use strict"; + //----------------------------------------------------------------------------- // Requirements //----------------------------------------------------------------------------- diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index 4db1919f277..fb2d71556a9 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -3,6 +3,8 @@ * @author Nicholas C. Zakas */ +"use strict"; + //----------------------------------------------------------------------------- // Requirements //----------------------------------------------------------------------------- diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index d0016b31ad5..dcfb815796c 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -3,6 +3,8 @@ * @author Nicholas C. Zakas */ +"use strict"; + //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- From 72eddee1196366bc19cec1cc9661e772386b1ec4 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Apr 2021 10:38:17 -0700 Subject: [PATCH 09/35] Start rule validation --- lib/config/default-config.js | 26 +++-- lib/config/flat-config-array.js | 20 ++-- lib/config/flat-config-schema.js | 6 +- lib/config/rule-validator.js | 131 ++++++++++++++++++++++++++ tests/lib/config/flat-config-array.js | 119 +++++++++++++++++++++-- 5 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 lib/config/rule-validator.js diff --git a/lib/config/default-config.js b/lib/config/default-config.js index dc7aa1c4161..98b80c6871e 100644 --- a/lib/config/default-config.js +++ b/lib/config/default-config.js @@ -26,14 +26,22 @@ const defaultRulesConfig = new Proxy({}, { } }); -exports.defaultConfig = { - plugins: { - "@": { - parsers: { - espree: require("espree") - } +exports.defaultConfig = [ + { + plugins: { + "@": { + parsers: { + espree: require("espree") + } + }, + rules: defaultRulesConfig }, - rules: defaultRulesConfig + ignores: ["node_modules/**"] }, - ignores: ["node_modules/**"] -}; + { + files: ["**/*.js", "**/*.cjs", "**/*.mjs", "**/*.jsx"], + languageOptions: { + parser: "@/espree" + } + } +]; diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index fb2d71556a9..ae9858ab68b 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -9,9 +9,16 @@ // Requirements //----------------------------------------------------------------------------- -const { ConfigArray, ConfigArraySchema, ConfigArraySymbol } = require("@humanwhocodes/config-array"); +const { ConfigArray, ConfigArraySymbol } = require("@humanwhocodes/config-array"); const { flatConfigSchema } = require("./flat-config-schema"); -const Rules = require("../rules"); +const { RuleValidator } = require("./rule-validator"); +const { defaultConfig } = require("./default-config"); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const ruleValidator = new RuleValidator(); //----------------------------------------------------------------------------- // Exports @@ -19,13 +26,13 @@ const Rules = require("../rules"); class FlatConfigArray extends ConfigArray { - constructor(configs, { basePath, builtInRules }) { + constructor(configs, { basePath, baseConfig = defaultConfig }) { super(configs, { basePath, schema: flatConfigSchema }); - this.builtInRules = builtInRules; + this.unshift(baseConfig); } /** @@ -36,8 +43,8 @@ class FlatConfigArray extends ConfigArray { * @throws {TypeError} If the config is invalid. */ [ConfigArraySymbol.finalizeConfig](config) { - - const { plugins, languageOptions, processor, rules } = config; + + const { plugins, languageOptions, processor } = config; // Check parser value if (languageOptions && languageOptions.parser && typeof languageOptions.parser === "string") { @@ -61,6 +68,7 @@ class FlatConfigArray extends ConfigArray { config.processor = plugins[pluginName].processors[processorName]; } + ruleValidator.validate(config); return config; } diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index dcfb815796c..67fb830c28b 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -27,7 +27,11 @@ const ruleSeverities = new Map([ [2, 2], ["error", 2] ]); -const globalVariablesValues = new Set([true, false, "readonly", "writeable", "off"]); +const globalVariablesValues = new Set([ + true, "writable", "writeable", + false, "readonly", + "off" +]); //----------------------------------------------------------------------------- // Assertions diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js new file mode 100644 index 00000000000..b5b552508a3 --- /dev/null +++ b/lib/config/rule-validator.js @@ -0,0 +1,131 @@ +/** + * @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 { + ([pluginName, ruleName] = ruleIdParts); + } + + 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; + + // Given a tuple of schemas, insert warning level at the beginning + 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 +//----------------------------------------------------------------------------- + +class RuleValidator { + constructor() { + this.validators = new WeakMap(); + } + + validate(config) { + + if (!config.rules) { + return; + } + + for (const ruleId of Object.keys(config.rules)) { + + // check for edge case + if (ruleId === "__proto__") { + 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 && Array.isArray(config.rules[ruleId])) { + validateRule(config.rules[ruleId].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/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index 1ebe410f8b7..ecf61b2b6f2 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -16,9 +16,41 @@ const assert = require("chai").assert; // Helpers //----------------------------------------------------------------------------- +const baseConfig = { + plugins: { + "@": { + rules: { + foo: { + schema: { + type: "array", + items: [ + { + enum: ["always", "never"] + } + ], + minItems: 0, + maxItems: 1 + } + + }, + bar: { + + }, + baz: { + + }, + + // old-style + boom() {} + } + } + } +} + function createFlatConfigArray(configs) { return new FlatConfigArray(configs, { basePath: __dirname, + baseConfig }); } @@ -65,6 +97,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + settings: { a: true, b: false, @@ -90,6 +124,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + settings: { a: false, b: false, @@ -110,6 +146,8 @@ describe("FlatConfigArray", () => { { } ], { + plugins: baseConfig.plugins, + settings: { a: true, b: false @@ -144,7 +182,8 @@ describe("FlatConfigArray", () => { plugins: { a: pluginA, b: pluginB, - c: pluginC + c: pluginC, + ...baseConfig.plugins } }); }); @@ -163,7 +202,8 @@ describe("FlatConfigArray", () => { ], { plugins: { a: pluginA, - b: pluginB + b: pluginB, + ...baseConfig.plugins } }); @@ -232,7 +272,8 @@ describe("FlatConfigArray", () => { processors: { markdown: stubProcessor } - } + }, + ...baseConfig.plugins }, processor: stubProcessor }); @@ -253,6 +294,8 @@ describe("FlatConfigArray", () => { processor } ], { + plugins: baseConfig.plugins, + processor }); }); @@ -327,6 +370,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + linterOptions: { noInlineConfig: false } @@ -344,6 +389,8 @@ describe("FlatConfigArray", () => { { } ], { + plugins: baseConfig.plugins, + linterOptions: { noInlineConfig: false } @@ -380,6 +427,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + linterOptions: { reportUnusedDisableDirectives: "error" } @@ -396,6 +445,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + linterOptions: { reportUnusedDisableDirectives: "warn" } @@ -449,6 +500,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { ecmaVersion: 2021 } @@ -466,6 +519,8 @@ describe("FlatConfigArray", () => { { } ], { + plugins: baseConfig.plugins, + languageOptions: { ecmaVersion: 2021 } @@ -485,6 +540,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { ecmaVersion: 2021 } @@ -522,6 +579,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { sourceType: "script" } @@ -539,6 +598,8 @@ describe("FlatConfigArray", () => { { } ], { + plugins: baseConfig.plugins, + languageOptions: { sourceType: "script" } @@ -558,6 +619,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { sourceType: "module" } @@ -612,6 +675,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { globals: { foo: "readonly", @@ -639,6 +704,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { globals: { foo: "writeable" @@ -660,6 +727,8 @@ describe("FlatConfigArray", () => { { } ], { + plugins: baseConfig.plugins, + languageOptions: { globals: { foo: "readonly" @@ -683,6 +752,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { globals: { foo: "readonly" @@ -770,7 +841,8 @@ describe("FlatConfigArray", () => { parsers: { bar: stubParser } - } + }, + ...baseConfig.plugins }, languageOptions: { parser @@ -804,7 +876,8 @@ describe("FlatConfigArray", () => { parsers: { bar: stubParser } - } + }, + ...baseConfig.plugins }, languageOptions: { @@ -841,7 +914,8 @@ describe("FlatConfigArray", () => { parsers: { bar: stubParser } - } + }, + ...baseConfig.plugins }, languageOptions: { @@ -885,6 +959,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { parserOptions: { foo: "whatever", @@ -912,6 +988,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { parserOptions: { foo: "bar" @@ -933,6 +1011,8 @@ describe("FlatConfigArray", () => { { } ], { + plugins: baseConfig.plugins, + languageOptions: { parserOptions: { foo: "whatever" @@ -956,6 +1036,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + languageOptions: { parserOptions: { foo: "bar" @@ -1004,6 +1086,17 @@ describe("FlatConfigArray", () => { ], "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/) + }); + it("should merge two objects", () => { return assertMergedResult([ @@ -1020,6 +1113,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + rules: { foo: 1, bar: "error", @@ -1045,6 +1140,8 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, + rules: { foo: ["error", "always"], bar: 0 @@ -1068,6 +1165,7 @@ describe("FlatConfigArray", () => { } } ], { + plugins: baseConfig.plugins, rules: { foo: ["error", "never"], bar: ["warn", "foo"] @@ -1080,16 +1178,17 @@ describe("FlatConfigArray", () => { return assertMergedResult([ { rules: { - a: 0, - b: 1 + foo: 0, + bar: 1 } }, { } ], { + plugins: baseConfig.plugins, rules: { - a: 0, - b: 1 + foo: 0, + bar: 1 } }); From d50b2dd64d8ec153b85bb936bcee1574f692e006 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 13 Apr 2021 10:44:18 -0700 Subject: [PATCH 10/35] Finish FlatConfigArray implementation --- lib/config/default-config.js | 12 +- lib/config/flat-config-array.js | 38 +- lib/config/flat-config-schema.js | 26 +- lib/config/rule-validator.js | 3 +- package.json | 7 +- tests/lib/config/flat-config-array.js | 939 ++++++++++++-------------- 6 files changed, 508 insertions(+), 517 deletions(-) diff --git a/lib/config/default-config.js b/lib/config/default-config.js index 98b80c6871e..09c017d018d 100644 --- a/lib/config/default-config.js +++ b/lib/config/default-config.js @@ -23,7 +23,11 @@ const Rules = require("../rules"); const defaultRulesConfig = new Proxy({}, { get(target, property) { return Rules.get(property); - } + }, + + has(target, property) { + return Rules.has(property); + } }); exports.defaultConfig = [ @@ -32,9 +36,9 @@ exports.defaultConfig = [ "@": { parsers: { espree: require("espree") - } - }, - rules: defaultRulesConfig + }, + rules: defaultRulesConfig + } }, ignores: ["node_modules/**"] }, diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index ae9858ab68b..6814216ddc4 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -13,6 +13,8 @@ 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 @@ -24,8 +26,17 @@ const ruleValidator = new RuleValidator(); // 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, @@ -35,15 +46,35 @@ class FlatConfigArray extends ConfigArray { 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. + * @throws {TypeError} If the config is invalid. */ [ConfigArraySymbol.finalizeConfig](config) { - + const { plugins, languageOptions, processor } = config; // Check parser value @@ -51,7 +82,7 @@ class FlatConfigArray extends ConfigArray { const [pluginName, parserName] = languageOptions.parser.split("/"); if (!plugins || !plugins[pluginName] || !plugins[pluginName].parsers || !plugins[pluginName].parsers[parserName]) { - throw new TypeError(`Key "parser": Could not find "${ parserName }" in plugin "${pluginName}".`); + throw new TypeError(`Key "parser": Could not find "${parserName}" in plugin "${pluginName}".`); } languageOptions.parser = plugins[pluginName].parsers[parserName]; @@ -72,6 +103,7 @@ class FlatConfigArray extends ConfigArray { return config; } + /* eslint-enable class-methods-use-this */ } diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 67fb830c28b..6b7cb954bb5 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -46,7 +46,7 @@ const globalVariablesValues = new Set([ function assertIsRuleOptions(value) { if (typeof value !== "string" && typeof value !== "number" && !Array.isArray(value)) { - throw new TypeError(`Expected a string, number, or array.`); + throw new TypeError("Expected a string, number, or array."); } } @@ -62,20 +62,20 @@ function assertIsRuleSeverity(value) { : ruleSeverities.get(value); if (typeof severity === "undefined") { - throw new TypeError(`Expected severity of "off", 0, "warn", 1, "error", or 2.`); + 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. + * @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-_$]+/i.test(value)) { - throw new TypeError("Expected string in the form \"pluginName/objectName\".") + if (!/[a-z0-9-_$]+\/[a-z0-9-_$]+/iu.test(value)) { + throw new TypeError("Expected string in the form \"pluginName/objectName\"."); } } @@ -83,7 +83,7 @@ function assertIsPluginMemberName(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. + * @throws {TypeError} If the value isn't an object. */ function assertIsObject(value) { if (!value || typeof value !== "object") { @@ -95,7 +95,7 @@ function assertIsObject(value) { * 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. + * @throws {TypeError} If the value isn't an object or a string. */ function assertIsObjectOrString(value) { if ((!value || typeof value !== "object") && typeof value !== "string") { @@ -113,12 +113,6 @@ const objectAssignSchema = { validate: "object" }; -/** @type {ObjectPropertySchema} */ -const stringSchema = { - merge: "replace", - validate: "string" -}; - /** @type {ObjectPropertySchema} */ const numberSchema = { merge: "replace", @@ -241,7 +235,7 @@ const processorSchema = { const reportUnusedDisableDirectivesSchema = { merge: "replace", validate(value) { - if (typeof value !== "string" || !/^off|warn|error$/.test(value)) { + if (typeof value !== "string" || !/^off|warn|error$/u.test(value)) { throw new TypeError("Value must be \"off\", \"warn\", or \"error\"."); } } @@ -260,6 +254,8 @@ const rulesSchema = { // avoid hairy edge case if (ruleId === "__proto__") { + + /* eslint-disable-next-line no-proto */ delete result.__proto__; continue; } @@ -343,7 +339,7 @@ const rulesSchema = { const sourceTypeSchema = { merge: "replace", validate(value) { - if (typeof value !== "string" || !/^(?:script|module|commonjs)$/.test(value)) { + if (typeof value !== "string" || !/^(?:script|module|commonjs)$/u.test(value)) { throw new TypeError("Expected \"script\", \"module\", or \"commonjs\"."); } } diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js index b5b552508a3..4e0e14c8fc9 100644 --- a/lib/config/rule-validator.js +++ b/lib/config/rule-validator.js @@ -17,7 +17,7 @@ const ajv = require("../shared/ajv")(); /** * Finds a rule with the given ID in the given config. - * @param {string} ruleId The ID of the rule to find. + * @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. */ @@ -107,6 +107,7 @@ class RuleValidator { // 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)); } diff --git a/package.json b/package.json index bc38ff6a28b..a25a1bb975f 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.2", - "@humanwhocodes/config-array": "^0.4.0", + "@humanwhocodes/config-array": "^0.5.0", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -116,8 +116,13 @@ "markdownlint": "^0.19.0", "markdownlint-cli": "^0.22.0", "memfs": "^3.0.1", +<<<<<<< HEAD "mocha": "^8.3.2", "mocha-junit-reporter": "^2.0.0", +======= + "mocha": "^7.2.0", + "mocha-junit-reporter": "^1.23.0", +>>>>>>> 1a6b3245b... Finish FlatConfigArray implementation "node-polyfill-webpack-plugin": "^1.0.3", "npm-license": "^0.3.3", "nyc": "^15.0.1", diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index ecf61b2b6f2..b9a7ea0dd5d 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -11,6 +11,8 @@ 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 @@ -45,8 +47,13 @@ const baseConfig = { } } } -} +}; +/** + * 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, @@ -54,16 +61,36 @@ function createFlatConfigArray(configs) { }); } +/** + * 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(() => { @@ -76,85 +103,95 @@ async function assertInvalidConfig(values, message) { //----------------------------------------------------------------------------- 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, 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, allConfig.rules); + }); + }); + describe("Config Properties", () => { describe("settings", () => { - it("should merge two objects", () => { - - return assertMergedResult([ - { - settings: { - a: true, - b: false - } - }, - { - settings: { - c: true, - d: false - } - } - ], { - plugins: baseConfig.plugins, - + it("should merge two objects", () => assertMergedResult([ + { settings: { a: true, - b: false, + b: false + } + }, + { + settings: { c: true, d: false } - }); - }); - - it("should merge two objects when second object has overrides", () => { - - return assertMergedResult([ - { - settings: { - a: true, - b: false - } - }, - { - settings: { - c: true, - a: false - } - } - ], { - plugins: baseConfig.plugins, - + } + ], { + 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: false, - b: false, - c: true + a: true, + b: false } - }); - }); - - it("should merge an object and undefined into one object", () => { - - return assertMergedResult([ - { - settings: { - a: true, - b: false - } - }, - { + }, + { + settings: { + c: true, + a: false } - ], { - plugins: baseConfig.plugins, - + } + ], { + plugins: baseConfig.plugins, + + settings: { + 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 + } + })); }); @@ -164,50 +201,43 @@ describe("FlatConfigArray", () => { const pluginB = {}; const pluginC = {}; - it("should merge two objects", () => { - - return assertMergedResult([ - { - plugins: { - a: pluginA, - b: pluginB - } - }, - { - plugins: { - c: pluginC - } - } - ], { + it("should merge two objects", () => assertMergedResult([ + { plugins: { a: pluginA, - b: pluginB, - c: pluginC, - ...baseConfig.plugins + b: pluginB } - }); - }); - - it("should merge an object and undefined into one object", () => { - - return 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, - ...baseConfig.plugins + b: pluginB } - }); - - }); + }, + { + } + ], { + plugins: { + a: pluginA, + b: pluginB, + ...baseConfig.plugins + } + })); it("should error when attempting to redefine a plugin", async () => { @@ -231,7 +261,7 @@ describe("FlatConfigArray", () => { await assertInvalidConfig([ { plugins: { - a: true, + a: true } } ], "Key \"a\": Expected an object."); @@ -353,50 +383,43 @@ describe("FlatConfigArray", () => { noInlineConfig: "true" } } - ], "Expected a Boolean.") + ], "Expected a Boolean."); }); - it("should merge two objects when second object has overrides", () => { - - return assertMergedResult([ - { - linterOptions: { - noInlineConfig: true - } - }, - { - linterOptions: { - noInlineConfig: false - } + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + linterOptions: { + noInlineConfig: true } - ], { - plugins: baseConfig.plugins, - + }, + { linterOptions: { noInlineConfig: false } - }); - }); - - it("should merge an object and undefined into one object", () => { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - { - 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 + } + })); }); @@ -413,46 +436,39 @@ describe("FlatConfigArray", () => { ], "Value must be \"off\", \"warn\", or \"error\"."); }); - it("should merge two objects when second object has overrides", () => { - - return assertMergedResult([ - { - linterOptions: { - reportUnusedDisableDirectives: "off" - } - }, - { - linterOptions: { - reportUnusedDisableDirectives: "error" - } + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + linterOptions: { + reportUnusedDisableDirectives: "off" } - ], { - plugins: baseConfig.plugins, - + }, + { linterOptions: { reportUnusedDisableDirectives: "error" } - }); - }); - - it("should merge an object and undefined into one object", () => { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - {}, - { - linterOptions: { - reportUnusedDisableDirectives: "warn" - } - } - ], { - plugins: baseConfig.plugins, + linterOptions: { + reportUnusedDisableDirectives: "error" + } + })); + it("should merge an object and undefined into one object", () => assertMergedResult([ + {}, + { linterOptions: { reportUnusedDisableDirectives: "warn" } - }); + } + ], { + plugins: baseConfig.plugins, - }); + linterOptions: { + reportUnusedDisableDirectives: "warn" + } + })); }); @@ -483,71 +499,60 @@ describe("FlatConfigArray", () => { ecmaVersion: "true" } } - ], "Expected a number.") + ], "Expected a number."); }); - it("should merge two objects when second object has overrides", () => { - - return assertMergedResult([ - { - languageOptions: { - ecmaVersion: 2019 - } - }, - { - languageOptions: { - ecmaVersion: 2021 - } + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + ecmaVersion: 2019 } - ], { - plugins: baseConfig.plugins, - + }, + { languageOptions: { ecmaVersion: 2021 } - }); - }); - - it("should merge an object and undefined into one object", () => { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - { - 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", () => { - - return assertMergedResult([ - { - }, - { - languageOptions: { - ecmaVersion: 2021 - } - } - ], { - plugins: baseConfig.plugins, + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { languageOptions: { ecmaVersion: 2021 } - }); + } + ], { + plugins: baseConfig.plugins, - }); + languageOptions: { + ecmaVersion: 2021 + } + })); }); @@ -562,71 +567,60 @@ describe("FlatConfigArray", () => { sourceType: "true" } } - ], "Expected \"script\", \"module\", or \"commonjs\".") + ], "Expected \"script\", \"module\", or \"commonjs\"."); }); - it("should merge two objects when second object has overrides", () => { - - return assertMergedResult([ - { - languageOptions: { - sourceType: "module" - } - }, - { - languageOptions: { - sourceType: "script" - } + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + sourceType: "module" } - ], { - plugins: baseConfig.plugins, - + }, + { languageOptions: { sourceType: "script" } - }); - }); - - it("should merge an object and undefined into one object", () => { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - { - 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, - it("should merge undefined and an object into one object", () => { + languageOptions: { + sourceType: "script" + } + })); - return assertMergedResult([ - { - }, - { - languageOptions: { - sourceType: "module" - } - } - ], { - plugins: baseConfig.plugins, + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { languageOptions: { sourceType: "module" } - }); + } + ], { + plugins: baseConfig.plugins, - }); + languageOptions: { + sourceType: "module" + } + })); }); @@ -641,7 +635,7 @@ describe("FlatConfigArray", () => { globals: "true" } } - ], "Expected an object.") + ], "Expected an object."); }); it("should error when an unexpected key value is found", async () => { @@ -654,114 +648,100 @@ describe("FlatConfigArray", () => { } } } - ], "Expected \"readonly\", \"writeable\", or \"off\".") + ], "Expected \"readonly\", \"writeable\", or \"off\"."); }); - it("should merge two objects when second object has different keys", () => { - - return assertMergedResult([ - { - languageOptions: { - globals: { - foo: "readonly" - } - } - }, - { - languageOptions: { - globals: { - bar: "writeable" - } + it("should merge two objects when second object has different keys", () => assertMergedResult([ + { + languageOptions: { + globals: { + foo: "readonly" } } - ], { - plugins: baseConfig.plugins, - + }, + { languageOptions: { globals: { - foo: "readonly", bar: "writeable" } } - }); - }); + } + ], { + plugins: baseConfig.plugins, - it("should merge two objects when second object has overrides", () => { + languageOptions: { + globals: { + foo: "readonly", + bar: "writeable" + } + } + })); - return assertMergedResult([ - { - languageOptions: { - globals: { - foo: "readonly" - } - } - }, - { - languageOptions: { - globals: { - foo: "writeable" - } + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + globals: { + foo: "readonly" } } - ], { - plugins: baseConfig.plugins, - + }, + { languageOptions: { globals: { foo: "writeable" } } - }); - }); - - it("should merge an object and undefined into one object", () => { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - { - languageOptions: { - globals: { - foo: "readonly" - } - } - }, - { + languageOptions: { + globals: { + foo: "writeable" } - ], { - plugins: baseConfig.plugins, + } + })); + it("should merge an object and undefined into one object", () => assertMergedResult([ + { languageOptions: { globals: { foo: "readonly" } } - }); - - }); - - - it("should merge undefined and an object into one object", () => { + }, + { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - { - }, - { - languageOptions: { - globals: { - foo: "readonly" - } - } + languageOptions: { + globals: { + foo: "readonly" } - ], { - plugins: baseConfig.plugins, + } + })); + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { languageOptions: { globals: { foo: "readonly" } } - }); + } + ], { + plugins: baseConfig.plugins, - }); + languageOptions: { + globals: { + foo: "readonly" + } + } + })); }); @@ -776,7 +756,7 @@ describe("FlatConfigArray", () => { parser: true } } - ], "Expected an object or string.") + ], "Expected an object or string."); }); it("should error when an unexpected value is found", async () => { @@ -787,7 +767,7 @@ describe("FlatConfigArray", () => { parser: "true" } } - ], "Expected string in the form \"pluginName/objectName\".") + ], "Expected string in the form \"pluginName/objectName\"."); }); it("should error when a plugin parser can't be found", async () => { @@ -798,7 +778,7 @@ describe("FlatConfigArray", () => { parser: "foo/bar" } } - ], "Key \"parser\": Could not find \"bar\" in plugin \"foo\".") + ], "Key \"parser\": Could not find \"bar\" in plugin \"foo\"."); }); it("should error when a value doesn't have a parse() method", async () => { @@ -809,12 +789,12 @@ describe("FlatConfigArray", () => { parser: {} } } - ], "Expected object to have a parse() or parseForESLint() method.") + ], "Expected object to have a parse() or parseForESLint() method."); }); it("should merge two objects when second object has overrides", () => { - const parser = { parse(){} }; + const parser = { parse() {} }; const stubParser = { parse() { } }; return assertMergedResult([ @@ -890,7 +870,7 @@ describe("FlatConfigArray", () => { it("should merge undefined and an object into one object", () => { - const stubParser = { parse(){} }; + const stubParser = { parse() {} }; return assertMergedResult([ { @@ -938,119 +918,105 @@ describe("FlatConfigArray", () => { parserOptions: "true" } } - ], "Expected an object.") + ], "Expected an object."); }); - it("should merge two objects when second object has different keys", () => { - - return assertMergedResult([ - { - languageOptions: { - parserOptions: { - foo: "whatever" - } - } - }, - { - languageOptions: { - parserOptions: { - bar: "baz" - } + it("should merge two objects when second object has different keys", () => assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" } } - ], { - plugins: baseConfig.plugins, - + }, + { languageOptions: { parserOptions: { - foo: "whatever", bar: "baz" } } - }); - }); + } + ], { + plugins: baseConfig.plugins, - it("should merge two objects when second object has overrides", () => { + languageOptions: { + parserOptions: { + foo: "whatever", + bar: "baz" + } + } + })); - return assertMergedResult([ - { - languageOptions: { - parserOptions: { - foo: "whatever" - } - } - }, - { - languageOptions: { - parserOptions: { - foo: "bar" - } + it("should merge two objects when second object has overrides", () => assertMergedResult([ + { + languageOptions: { + parserOptions: { + foo: "whatever" } } - ], { - plugins: baseConfig.plugins, - + }, + { languageOptions: { parserOptions: { foo: "bar" } } - }); - }); - - it("should merge an object and undefined into one object", () => { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - { - languageOptions: { - parserOptions: { - foo: "whatever" - } - } - }, - { + languageOptions: { + parserOptions: { + foo: "bar" } - ], { - plugins: baseConfig.plugins, + } + })); + it("should merge an object and undefined into one object", () => assertMergedResult([ + { languageOptions: { parserOptions: { foo: "whatever" } } - }); - - }); - - - it("should merge undefined and an object into one object", () => { + }, + { + } + ], { + plugins: baseConfig.plugins, - return assertMergedResult([ - { - }, - { - languageOptions: { - parserOptions: { - foo: "bar" - } - } + languageOptions: { + parserOptions: { + foo: "whatever" } - ], { - plugins: baseConfig.plugins, + } + })); + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { languageOptions: { parserOptions: { foo: "bar" } } - }); + } + ], { + plugins: baseConfig.plugins, - }); + languageOptions: { + parserOptions: { + foo: "bar" + } + } + })); }); - + }); describe("rules", () => { @@ -1061,7 +1027,7 @@ describe("FlatConfigArray", () => { { rules: true } - ], "Expected an object.") + ], "Expected an object."); }); it("should error when an invalid rule severity is set", async () => { @@ -1072,7 +1038,7 @@ describe("FlatConfigArray", () => { foo: true } } - ], "Key \"rules\": Key \"foo\": Expected a string, number, or array.") + ], "Key \"rules\": Key \"foo\": Expected a string, number, or array."); }); it("should error when an invalid rule severity is set in an array", async () => { @@ -1083,7 +1049,7 @@ describe("FlatConfigArray", () => { foo: [true] } } - ], "Key \"rules\": Key \"foo\": Expected severity of \"off\", 0, \"warn\", 1, \"error\", or 2.") + ], "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 () => { @@ -1094,105 +1060,92 @@ describe("FlatConfigArray", () => { foo: [1, "bar"] } } - ], /Value "bar" should be equal to one of the allowed values/) + ], /Value "bar" should be equal to one of the allowed values/u); }); - it("should merge two objects", () => { - - return assertMergedResult([ - { - rules: { - foo: 1, - bar: "error" - } - }, - { - rules: { - baz: "warn", - boom: 0 - } - } - ], { - plugins: baseConfig.plugins, - + it("should merge two objects", () => assertMergedResult([ + { rules: { foo: 1, - bar: "error", + bar: "error" + } + }, + { + rules: { baz: "warn", boom: 0 } - }); - }); - - it("should merge two objects when second object has simple overrides", () => { - - return assertMergedResult([ - { - rules: { - foo: [1, "always"], - bar: "error" - } - }, - { - rules: { - foo: "error", - bar: 0 - } + } + ], { + plugins: baseConfig.plugins, + + rules: { + foo: 1, + bar: "error", + baz: "warn", + boom: 0 + } + })); + + it("should merge two objects when second object has simple overrides", () => assertMergedResult([ + { + rules: { + foo: [1, "always"], + bar: "error" } - ], { - plugins: baseConfig.plugins, - + }, + { rules: { - foo: ["error", "always"], + foo: "error", bar: 0 } - }); - }); - - it("should merge two objects when second object has array overrides", () => { - - return assertMergedResult([ - { - rules: { - foo: 1, - bar: "error" - } - }, - { - rules: { - foo: ["error", "never"], - bar: ["warn", "foo"] - } + } + ], { + plugins: baseConfig.plugins, + + rules: { + foo: ["error", "always"], + bar: 0 + } + })); + + it("should merge two objects when second object has array overrides", () => assertMergedResult([ + { + rules: { + foo: 1, + bar: "error" } - ], { - plugins: baseConfig.plugins, + }, + { rules: { foo: ["error", "never"], bar: ["warn", "foo"] } - }); - }); - - it("should merge an object and undefined into one object", () => { - - return assertMergedResult([ - { - rules: { - foo: 0, - bar: 1 - } - }, - { - } - ], { - plugins: baseConfig.plugins, + } + ], { + plugins: baseConfig.plugins, + rules: { + foo: ["error", "never"], + bar: ["warn", "foo"] + } + })); + + it("should merge an object and undefined into one object", () => assertMergedResult([ + { rules: { foo: 0, bar: 1 } - }); - - }); + }, + { + } + ], { + plugins: baseConfig.plugins, + rules: { + foo: 0, + bar: 1 + } + })); }); From 3189abc4d758ff438dbf6b93df7e936891f81ea7 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 13 Apr 2021 11:40:18 -0700 Subject: [PATCH 11/35] Remove too-new syntax --- lib/config/flat-config-schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 6b7cb954bb5..63b0e032dae 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -188,7 +188,7 @@ const pluginsSchema = { throw new TypeError(`Cannot redefine plugin "${key}".`); } - result[key] = second[key] ?? first[key]; + result[key] = second[key] || first[key]; } return result; From ede77e6b98b228668514acfd2bb3664376d4cd9f Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 16 Apr 2021 10:24:14 -0700 Subject: [PATCH 12/35] Fix default config --- lib/config/default-config.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/config/default-config.js b/lib/config/default-config.js index 09c017d018d..864ac98afbe 100644 --- a/lib/config/default-config.js +++ b/lib/config/default-config.js @@ -40,10 +40,7 @@ exports.defaultConfig = [ rules: defaultRulesConfig } }, - ignores: ["node_modules/**"] - }, - { - files: ["**/*.js", "**/*.cjs", "**/*.mjs", "**/*.jsx"], + ignores: ["node_modules/**"], languageOptions: { parser: "@/espree" } From 0bfe1251e582643f3ab963f86ead16f2e56ead84 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 16 Apr 2021 18:16:56 -0700 Subject: [PATCH 13/35] fix test --- lib/config/flat-config-schema.js | 4 ++-- tests/lib/config/flat-config-array.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 63b0e032dae..ae770e386a4 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -74,8 +74,8 @@ function assertIsRuleSeverity(value) { * @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\"."); + if (!/[@a-z0-9-_$]+\/[a-z0-9-_$]+/iu.test(value)) { + throw new TypeError(`Expected string in the form "pluginName/objectName" but found "${value}".`); } } diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index b9a7ea0dd5d..2476288eb39 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -767,7 +767,7 @@ describe("FlatConfigArray", () => { parser: "true" } } - ], "Expected string in the form \"pluginName/objectName\"."); + ], /Expected string in the form "pluginName\/objectName"/u); }); it("should error when a plugin parser can't be found", async () => { From 12df97bc047c16aac6fcc44ba25df05fc40b9efe Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 19 Apr 2021 10:35:55 -0700 Subject: [PATCH 14/35] Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills --- tests/lib/config/flat-config-array.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index 2476288eb39..82e4f8fce8b 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -192,6 +192,24 @@ describe("FlatConfigArray", () => { 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 + } + })); }); From c8ce1aa3c686d7bbf9dbff25a27824cd0889b6b0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 19 Apr 2021 10:36:10 -0700 Subject: [PATCH 15/35] Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills --- tests/lib/config/flat-config-array.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index 82e4f8fce8b..a254efa9b1a 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -438,6 +438,22 @@ describe("FlatConfigArray", () => { noInlineConfig: false } })); + + it("should merge undefined and an object into one object", () => assertMergedResult([ + { + }, + { + linterOptions: { + noInlineConfig: false + } + } + ], { + plugins: baseConfig.plugins, + + linterOptions: { + noInlineConfig: false + } + })); }); From e7f7dbc51654a57f047e7fbea7e657ad3e31dfd4 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 19 Apr 2021 10:36:29 -0700 Subject: [PATCH 16/35] Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills --- tests/lib/config/flat-config-array.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index a254efa9b1a..e96e781119a 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -1163,6 +1163,28 @@ describe("FlatConfigArray", () => { bar: ["warn", "foo"] } })); + + it("should merge two objects and clear options when second object overrides without options", () => assertMergedResult([ + { + rules: { + foo: [1, "always"], + bar: "error" + } + }, + { + rules: { + foo: ["error"], + bar: 0 + } + } + ], { + plugins: baseConfig.plugins, + + rules: { + foo: ["error"], + bar: 0 + } + })); it("should merge an object and undefined into one object", () => assertMergedResult([ { From 48025f2f6349d1d7453d3caf96bc5c3a82139fea Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 19 Apr 2021 10:36:42 -0700 Subject: [PATCH 17/35] Update tests/lib/config/flat-config-array.js Co-authored-by: Brandon Mills --- tests/lib/config/flat-config-array.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index e96e781119a..137e03a855f 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -1074,6 +1074,17 @@ describe("FlatConfigArray", () => { } ], "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 () => { From 905a5d431e37d7b1bb3458e0768481ad90fcf0a2 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 19 Apr 2021 11:03:08 -0700 Subject: [PATCH 18/35] Update tests --- tests/lib/config/flat-config-array.js | 28 +++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index 137e03a855f..b6fa65012b6 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -192,7 +192,7 @@ describe("FlatConfigArray", () => { b: false } })); - + it("should merge undefined and an object into one object", () => assertMergedResult([ { }, @@ -438,7 +438,7 @@ describe("FlatConfigArray", () => { noInlineConfig: false } })); - + it("should merge undefined and an object into one object", () => assertMergedResult([ { }, @@ -523,6 +523,26 @@ describe("FlatConfigArray", () => { }); + 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 () => { @@ -1074,7 +1094,7 @@ describe("FlatConfigArray", () => { } ], "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([ @@ -1174,7 +1194,7 @@ describe("FlatConfigArray", () => { bar: ["warn", "foo"] } })); - + it("should merge two objects and clear options when second object overrides without options", () => assertMergedResult([ { rules: { From b99f3af2ce13e775f120a59743ebcee7d60093c2 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 19 Apr 2021 11:09:02 -0700 Subject: [PATCH 19/35] fix test --- tests/lib/config/flat-config-array.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index b6fa65012b6..d21ea16fdaa 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -523,7 +523,7 @@ describe("FlatConfigArray", () => { }); - it("should merge two languageOptions objects with different properties", assertMergedResult([ + it("should merge two languageOptions objects with different properties", () => assertMergedResult([ { languageOptions: { ecmaVersion: 2019 From 1117b319c4ff7309bfb788d5fa17e1effa17e615 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Wed, 21 Apr 2021 11:22:55 -0700 Subject: [PATCH 20/35] Allow old-style plugin names --- lib/config/flat-config-array.js | 19 +++++++++++-- lib/config/flat-config-schema.js | 2 +- lib/config/rule-validator.js | 3 +- tests/lib/config/flat-config-array.js | 41 ++++++++++++++++++--------- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/lib/config/flat-config-array.js b/lib/config/flat-config-array.js index 6814216ddc4..ecf396a3314 100644 --- a/lib/config/flat-config-array.js +++ b/lib/config/flat-config-array.js @@ -22,6 +22,21 @@ const allConfig = require("../../conf/eslint-all"); 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 //----------------------------------------------------------------------------- @@ -79,7 +94,7 @@ class FlatConfigArray extends ConfigArray { // Check parser value if (languageOptions && languageOptions.parser && typeof languageOptions.parser === "string") { - const [pluginName, parserName] = languageOptions.parser.split("/"); + 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}".`); @@ -90,7 +105,7 @@ class FlatConfigArray extends ConfigArray { // Check processor value if (processor && typeof processor === "string") { - const [pluginName, processorName] = processor.split("/"); + 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}".`); diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index ae770e386a4..e0df90cbff8 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -74,7 +74,7 @@ function assertIsRuleSeverity(value) { * @throws {TypeError} If the string isn't in the correct format. */ function assertIsPluginMemberName(value) { - if (!/[@a-z0-9-_$]+\/[a-z0-9-_$]+/iu.test(value)) { + if (!/[@a-z0-9-_$]+\/(?:[a-z0-9-_$]+)+/iu.test(value)) { throw new TypeError(`Expected string in the form "pluginName/objectName" but found "${value}".`); } } diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js index 4e0e14c8fc9..661ceddf1a7 100644 --- a/lib/config/rule-validator.js +++ b/lib/config/rule-validator.js @@ -30,7 +30,8 @@ function findRuleDefinition(ruleId, config) { pluginName = "@"; ruleName = ruleIdParts[0]; } else { - ([pluginName, ruleName] = ruleIdParts); + ruleName = ruleIdParts.pop(); + pluginName = ruleIdParts.join("/"); } if (!config.plugins || !config.plugins[pluginName]) { diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index d21ea16fdaa..6f9e6fa3ba8 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -852,26 +852,26 @@ describe("FlatConfigArray", () => { const stubParser = { parse() { } }; return assertMergedResult([ + { + languageOptions: { + parser + } + }, { plugins: { - foo: { + "@foo/baz": { parsers: { bar: stubParser } } }, languageOptions: { - parser: "foo/bar" - } - }, - { - languageOptions: { - parser + parser: "@foo/baz/bar" } } ], { plugins: { - foo: { + "@foo/baz": { parsers: { bar: stubParser } @@ -879,7 +879,7 @@ describe("FlatConfigArray", () => { ...baseConfig.plugins }, languageOptions: { - parser + parser: stubParser } }); }); @@ -1203,17 +1203,32 @@ describe("FlatConfigArray", () => { } }, { + plugins: { + "foo/baz": { + rules: { + bang: {} + } + } + }, rules: { foo: ["error"], - bar: 0 + bar: 0, + "foo/baz/bang": "error" } } ], { - plugins: baseConfig.plugins, - + plugins: { + ...baseConfig.plugins, + "foo/baz": { + rules: { + bang: {} + } + } + }, rules: { foo: ["error"], - bar: 0 + bar: 0, + "foo/baz/bang": "error" } })); From 10343ad5a2b82dd3eea96caeb800a4ebacb34689 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Wed, 21 Apr 2021 11:28:48 -0700 Subject: [PATCH 21/35] Fix reportUnusedDisableDirectives and add JSDoc --- lib/config/flat-config-schema.js | 12 +----------- lib/config/rule-validator.js | 22 ++++++++++++++++++++++ tests/lib/config/flat-config-array.js | 12 ++++++------ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index e0df90cbff8..2f04a226e3a 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -231,16 +231,6 @@ const processorSchema = { } }; -/** @type {ObjectPropertySchema} */ -const reportUnusedDisableDirectivesSchema = { - merge: "replace", - validate(value) { - if (typeof value !== "string" || !/^off|warn|error$/u.test(value)) { - throw new TypeError("Value must be \"off\", \"warn\", or \"error\"."); - } - } -}; - /** @type {ObjectPropertySchema} */ const rulesSchema = { merge(first = {}, second = {}) { @@ -354,7 +344,7 @@ exports.flatConfigSchema = { linterOptions: { schema: { noInlineConfig: booleanSchema, - reportUnusedDisableDirectives: reportUnusedDisableDirectivesSchema + reportUnusedDisableDirectives: booleanSchema } }, languageOptions: { diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js index 661ceddf1a7..3bb3431aba3 100644 --- a/lib/config/rule-validator.js +++ b/lib/config/rule-validator.js @@ -85,11 +85,33 @@ function getRuleOptionsSchema(rule) { // 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) { diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index 6f9e6fa3ba8..f45da6a744e 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -467,25 +467,25 @@ describe("FlatConfigArray", () => { reportUnusedDisableDirectives: "true" } } - ], "Value must be \"off\", \"warn\", or \"error\"."); + ], /Expected a Boolean/u); }); it("should merge two objects when second object has overrides", () => assertMergedResult([ { linterOptions: { - reportUnusedDisableDirectives: "off" + reportUnusedDisableDirectives: false } }, { linterOptions: { - reportUnusedDisableDirectives: "error" + reportUnusedDisableDirectives: true } } ], { plugins: baseConfig.plugins, linterOptions: { - reportUnusedDisableDirectives: "error" + reportUnusedDisableDirectives: true } })); @@ -493,14 +493,14 @@ describe("FlatConfigArray", () => { {}, { linterOptions: { - reportUnusedDisableDirectives: "warn" + reportUnusedDisableDirectives: true } } ], { plugins: baseConfig.plugins, linterOptions: { - reportUnusedDisableDirectives: "warn" + reportUnusedDisableDirectives: true } })); From 40fe6184c36ab357b48806f91c7f37eed14e6f65 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 22 Apr 2021 18:16:58 -0700 Subject: [PATCH 22/35] Add more tests --- tests/lib/config/flat-config-array.js | 42 +++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index f45da6a744e..357c12db570 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -271,7 +271,7 @@ describe("FlatConfigArray", () => { a: pluginC } } - ], "redefine plugin"); + ], "Cannot redefine plugin \"a\"."); }); it("should error when plugin is not an object", async () => { @@ -371,7 +371,19 @@ describe("FlatConfigArray", () => { { processor: {} } - ], "preprocess() and a postprocess()"); + ], "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); }); @@ -705,6 +717,32 @@ describe("FlatConfigArray", () => { ], "Expected \"readonly\", \"writeable\", 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: { From 70af1ec744035343d65f7400f1314111ccf2bc5a Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Wed, 28 Apr 2021 10:37:36 -0700 Subject: [PATCH 23/35] address review comments --- lib/config/default-config.js | 5 ++++- lib/config/flat-config-schema.js | 6 +++--- tests/lib/config/flat-config-array.js | 10 +++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/config/default-config.js b/lib/config/default-config.js index 864ac98afbe..1fbdfafb5d9 100644 --- a/lib/config/default-config.js +++ b/lib/config/default-config.js @@ -40,7 +40,10 @@ exports.defaultConfig = [ rules: defaultRulesConfig } }, - ignores: ["node_modules/**"], + ignores: [ + "**/node_modules/**", + ".*" + ], languageOptions: { parser: "@/espree" } diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 2f04a226e3a..a9ec39d2488 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -28,8 +28,8 @@ const ruleSeverities = new Map([ ]); const globalVariablesValues = new Set([ - true, "writable", "writeable", - false, "readonly", + true, "true", "writable", "writeable", + false, "false", "readonly", "readable", "off" ]); @@ -74,7 +74,7 @@ function assertIsRuleSeverity(value) { * @throws {TypeError} If the string isn't in the correct format. */ function assertIsPluginMemberName(value) { - if (!/[@a-z0-9-_$]+\/(?:[a-z0-9-_$]+)+/iu.test(value)) { + if (!/[@a-z0-9-_$]+(?:\/(?:[a-z0-9-_$]+))+$/iu.test(value)) { throw new TypeError(`Expected string in the form "pluginName/objectName" but found "${value}".`); } } diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index 357c12db570..bebb3a0ae51 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -710,7 +710,7 @@ describe("FlatConfigArray", () => { { languageOptions: { globals: { - foo: "true" + foo: "truex" } } } @@ -1242,7 +1242,7 @@ describe("FlatConfigArray", () => { }, { plugins: { - "foo/baz": { + "foo/baz/boom": { rules: { bang: {} } @@ -1251,13 +1251,13 @@ describe("FlatConfigArray", () => { rules: { foo: ["error"], bar: 0, - "foo/baz/bang": "error" + "foo/baz/boom/bang": "error" } } ], { plugins: { ...baseConfig.plugins, - "foo/baz": { + "foo/baz/boom": { rules: { bang: {} } @@ -1266,7 +1266,7 @@ describe("FlatConfigArray", () => { rules: { foo: ["error"], bar: 0, - "foo/baz/bang": "error" + "foo/baz/boom/bang": "error" } })); From acabd019c7de5789ad02e9564118793138661b70 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 30 Apr 2021 11:31:26 -0700 Subject: [PATCH 24/35] Ignore only .git directory --- lib/config/default-config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/config/default-config.js b/lib/config/default-config.js index 1fbdfafb5d9..f7492e6d9d9 100644 --- a/lib/config/default-config.js +++ b/lib/config/default-config.js @@ -42,7 +42,7 @@ exports.defaultConfig = [ }, ignores: [ "**/node_modules/**", - ".*" + ".git/**" ], languageOptions: { parser: "@/espree" From c0bbff6572a1d713cae29719f85d244f4cb7663b Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 30 Apr 2021 11:33:29 -0700 Subject: [PATCH 25/35] Allow null for global settings --- lib/config/flat-config-schema.js | 2 +- tests/lib/config/flat-config-array.js | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index a9ec39d2488..15e602f2806 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -29,7 +29,7 @@ const ruleSeverities = new Map([ const globalVariablesValues = new Set([ true, "true", "writable", "writeable", - false, "false", "readonly", "readable", + false, "false", "readonly", "readable", null, "off" ]); diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index bebb3a0ae51..c7702cde640 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -754,7 +754,7 @@ describe("FlatConfigArray", () => { { languageOptions: { globals: { - bar: "writeable" + bar: "writable" } } } @@ -764,7 +764,7 @@ describe("FlatConfigArray", () => { languageOptions: { globals: { foo: "readonly", - bar: "writeable" + bar: "writable" } } })); @@ -773,7 +773,7 @@ describe("FlatConfigArray", () => { { languageOptions: { globals: { - foo: "readonly" + foo: null } } }, @@ -798,7 +798,7 @@ describe("FlatConfigArray", () => { { languageOptions: { globals: { - foo: "readonly" + foo: "readable" } } }, @@ -809,7 +809,7 @@ describe("FlatConfigArray", () => { languageOptions: { globals: { - foo: "readonly" + foo: "readable" } } })); @@ -821,7 +821,7 @@ describe("FlatConfigArray", () => { { languageOptions: { globals: { - foo: "readonly" + foo: "false" } } } @@ -830,7 +830,7 @@ describe("FlatConfigArray", () => { languageOptions: { globals: { - foo: "readonly" + foo: "false" } } })); From 210e792813d55e4e1a8d1ef6658a965415433ff0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 30 Apr 2021 11:35:05 -0700 Subject: [PATCH 26/35] writeable -> writable --- lib/config/flat-config-schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 15e602f2806..451fd75e026 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -148,7 +148,7 @@ const globalsSchema = { } if (!globalVariablesValues.has(value[key])) { - throw new TypeError("Expected \"readonly\", \"writeable\", or \"off\"."); + throw new TypeError("Expected \"readonly\", \"writable\", or \"off\"."); } } } From 6ba32a62a188881205f3832c1adf33d301501e9a Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 30 Apr 2021 11:35:52 -0700 Subject: [PATCH 27/35] Remove incorrect comment --- lib/config/rule-validator.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js index 3bb3431aba3..f5aae178a44 100644 --- a/lib/config/rule-validator.js +++ b/lib/config/rule-validator.js @@ -59,7 +59,6 @@ function getRuleOptionsSchema(rule) { const schema = rule.schema || rule.meta && rule.meta.schema; - // Given a tuple of schemas, insert warning level at the beginning if (Array.isArray(schema)) { if (schema.length) { return { From b2dcefaf77f9b3b177faec8bf50af7d0b6622280 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 30 Apr 2021 11:51:38 -0700 Subject: [PATCH 28/35] Validate severity-only rule options --- lib/config/rule-validator.js | 17 +++++++++++++++-- tests/lib/config/flat-config-array.js | 26 ++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js index f5aae178a44..2da3abce334 100644 --- a/lib/config/rule-validator.js +++ b/lib/config/rule-validator.js @@ -137,8 +137,21 @@ class RuleValidator { const validateRule = this.validators.get(rule); - if (validateRule && Array.isArray(config.rules[ruleId])) { - validateRule(config.rules[ruleId].slice(1)); + if (validateRule) { + + /* + * Because rule schemas can specify a minimum number of items + * required in a rule option, a configuration such as + * foo: "warn" might be invalid because it lacks the expected + * options. To catch that case, we validate against an empty + * array if the rule options aren't already an array. + */ + const ruleOptions = Array.isArray(config.rules[ruleId]) + ? config.rules[ruleId].slice(1) + : []; + + validateRule(ruleOptions); + if (validateRule.errors) { throw new Error(`Key "rules": Key "${ruleId}": ${ validateRule.errors.map( diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index c7702cde640..dccb0ca2660 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -43,7 +43,18 @@ const baseConfig = { }, // old-style - boom() {} + boom() {}, + + foo2: { + schema: { + type: "array", + items: { + type: "string" + }, + uniqueItems: true, + minItems: 1 + } + } } } } @@ -714,7 +725,7 @@ describe("FlatConfigArray", () => { } } } - ], "Expected \"readonly\", \"writeable\", or \"off\"."); + ], "Expected \"readonly\", \"writable\", or \"off\"."); }); it("should error when a global has leading whitespace", async () => { @@ -1166,6 +1177,17 @@ describe("FlatConfigArray", () => { ], /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: { From 0ce35cf8e884e57c0f893db076c60057dbf2d652 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 30 Apr 2021 12:06:27 -0700 Subject: [PATCH 29/35] Add key to global error message --- lib/config/flat-config-schema.js | 2 +- tests/lib/config/flat-config-array.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 451fd75e026..c743d3128de 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -148,7 +148,7 @@ const globalsSchema = { } if (!globalVariablesValues.has(value[key])) { - throw new TypeError("Expected \"readonly\", \"writable\", or \"off\"."); + throw new TypeError(`Key "${key}": Expected "readonly", "writable", or "off".`); } } } diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index dccb0ca2660..928241006db 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -725,7 +725,7 @@ describe("FlatConfigArray", () => { } } } - ], "Expected \"readonly\", \"writable\", or \"off\"."); + ], "Key \"foo\": Expected \"readonly\", \"writable\", or \"off\"."); }); it("should error when a global has leading whitespace", async () => { From 553b931b441a9562abfa41900f45ecdfe9f3ac2e Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 30 Apr 2021 12:39:33 -0700 Subject: [PATCH 30/35] deeply merge parserOptions and settings --- lib/config/flat-config-schema.js | 87 ++++++++++++++++++++++++--- tests/lib/config/flat-config-array.js | 61 +++++++++++++++++++ 2 files changed, 138 insertions(+), 10 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index c743d3128de..49d33010484 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -33,6 +33,71 @@ const globalVariablesValues = new Set([ "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) { + + /* + * 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(result)) { + + // 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; + +} + //----------------------------------------------------------------------------- // Assertions //----------------------------------------------------------------------------- @@ -66,7 +131,6 @@ function assertIsRuleSeverity(value) { } } - /** * Validates that a given string is the form pluginName/objectName. * @param {string} value The string to check. @@ -86,7 +150,7 @@ function assertIsPluginMemberName(value) { * @throws {TypeError} If the value isn't an object. */ function assertIsObject(value) { - if (!value || typeof value !== "object") { + if (!isNonNullObject(value)) { throw new TypeError("Expected an object."); } } @@ -107,12 +171,6 @@ function assertIsObjectOrString(value) { // Low-Level Schemas //----------------------------------------------------------------------------- -/** @type {ObjectPropertySchema} */ -const objectAssignSchema = { - merge: "assign", - validate: "object" -}; - /** @type {ObjectPropertySchema} */ const numberSchema = { merge: "replace", @@ -125,6 +183,15 @@ const booleanSchema = { validate: "boolean" }; +/** @type {ObjectPropertySchema} */ +const deepObjectAssignSchema = { + merge(first = {}, second = {}) { + return deepMerge(first, second); + }, + validate: "object" +}; + + //----------------------------------------------------------------------------- // High-Level Schemas //----------------------------------------------------------------------------- @@ -340,7 +407,7 @@ const sourceTypeSchema = { //----------------------------------------------------------------------------- exports.flatConfigSchema = { - settings: objectAssignSchema, + settings: deepObjectAssignSchema, linterOptions: { schema: { noInlineConfig: booleanSchema, @@ -353,7 +420,7 @@ exports.flatConfigSchema = { sourceType: sourceTypeSchema, globals: globalsSchema, parser: parserSchema, - parserOptions: objectAssignSchema + parserOptions: deepObjectAssignSchema } }, processor: processorSchema, diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index 928241006db..b2c2f2f143b 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -186,6 +186,35 @@ describe("FlatConfigArray", () => { } })); + 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: { @@ -1050,6 +1079,38 @@ describe("FlatConfigArray", () => { } })); + 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 merge two objects when second object has overrides", () => assertMergedResult([ { languageOptions: { From adf4f2f0f3bd061a9aa058ce2d5acec4d2c249e0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 3 May 2021 11:21:16 -0700 Subject: [PATCH 31/35] Rename defaultResultConfig --- lib/config/default-config.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/lib/config/default-config.js b/lib/config/default-config.js index f7492e6d9d9..cb6f403380d 100644 --- a/lib/config/default-config.js +++ b/lib/config/default-config.js @@ -15,20 +15,6 @@ const Rules = require("../rules"); // Helpers //----------------------------------------------------------------------------- -/* - * 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. - */ -const defaultRulesConfig = new Proxy({}, { - get(target, property) { - return Rules.get(property); - }, - - has(target, property) { - return Rules.has(property); - } -}); exports.defaultConfig = [ { @@ -37,7 +23,22 @@ exports.defaultConfig = [ parsers: { espree: require("espree") }, - rules: defaultRulesConfig + + /* + * 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: [ From 751352d830bf6c54c9dd9d4170cbfbeec02657fc Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 3 May 2021 11:52:13 -0700 Subject: [PATCH 32/35] Normalize and fix rule validations --- lib/config/flat-config-schema.js | 38 +++++++++++---- lib/config/rule-validator.js | 28 +++++------ tests/lib/config/flat-config-array.js | 67 +++++++++++++++++++++------ 3 files changed, 97 insertions(+), 36 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 49d33010484..16153b3ecc9 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -98,6 +98,22 @@ function deepMerge(first, second) { } +/** + * 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 + : [ruleOptions]; + + finalOptions[0] = ruleSeverities.get(finalOptions[0]); + return finalOptions; +} + //----------------------------------------------------------------------------- // Assertions //----------------------------------------------------------------------------- @@ -171,6 +187,7 @@ function assertIsObjectOrString(value) { // Low-Level Schemas //----------------------------------------------------------------------------- + /** @type {ObjectPropertySchema} */ const numberSchema = { merge: "replace", @@ -191,7 +208,6 @@ const deepObjectAssignSchema = { validate: "object" }; - //----------------------------------------------------------------------------- // High-Level Schemas //----------------------------------------------------------------------------- @@ -318,10 +334,12 @@ const rulesSchema = { } /* - * If either rule config is missing, then no more work - * is necessary; the correct config is already there. + * 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)) { + result[ruleId] = normalizeRuleOptions(result[ruleId]); continue; } @@ -335,18 +353,22 @@ const rulesSchema = { * from the first rule config. */ if (firstIsArray && !secondIsArray) { - result[ruleId] = [second[ruleId], ...first[ruleId].slice(1)]; + result[ruleId] = normalizeRuleOptions( + [second[ruleId], ...first[ruleId].slice(1)] + ); continue; } /* * In any other situation, then the second rule config takes * precedence. If it's an array, we return a copy; - * otherwise we return the value, which is just the severity. + * otherwise we return the severity in a new array. */ - result[ruleId] = secondIsArray - ? second[ruleId].slice(0) - : second[ruleId]; + result[ruleId] = normalizeRuleOptions( + secondIsArray + ? second[ruleId].slice(0) + : [second[ruleId]] + ); } return result; diff --git a/lib/config/rule-validator.js b/lib/config/rule-validator.js index 2da3abce334..f162dd81a05 100644 --- a/lib/config/rule-validator.js +++ b/lib/config/rule-validator.js @@ -117,13 +117,26 @@ class RuleValidator { return; } - for (const ruleId of Object.keys(config.rules)) { + 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 @@ -139,18 +152,7 @@ class RuleValidator { if (validateRule) { - /* - * Because rule schemas can specify a minimum number of items - * required in a rule option, a configuration such as - * foo: "warn" might be invalid because it lacks the expected - * options. To catch that case, we validate against an empty - * array if the rule options aren't already an array. - */ - const ruleOptions = Array.isArray(config.rules[ruleId]) - ? config.rules[ruleId].slice(1) - : []; - - validateRule(ruleOptions); + validateRule(ruleOptions.slice(1)); if (validateRule.errors) { throw new Error(`Key "rules": Key "${ruleId}": ${ diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index b2c2f2f143b..e5bc4582c0d 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -109,6 +109,20 @@ async function assertInvalidConfig(values, message) { }, 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) { + for (const ruleId of Object.keys(rulesConfig)) { + rulesConfig[ruleId] = [2]; + } + + return rulesConfig; +} + //----------------------------------------------------------------------------- // Tests //----------------------------------------------------------------------------- @@ -122,7 +136,7 @@ describe("FlatConfigArray", () => { await configs.normalize(); const config = configs.getConfig("foo.js"); - assert.deepStrictEqual(config.rules, recommendedConfig.rules); + assert.deepStrictEqual(config.rules, normalizeRuleConfig(recommendedConfig.rules)); }); it("eslint:all is replaced with an actual config", async () => { @@ -131,7 +145,7 @@ describe("FlatConfigArray", () => { await configs.normalize(); const config = configs.getConfig("foo.js"); - assert.deepStrictEqual(config.rules, allConfig.rules); + assert.deepStrictEqual(config.rules, normalizeRuleConfig(allConfig.rules)); }); }); @@ -1266,10 +1280,10 @@ describe("FlatConfigArray", () => { plugins: baseConfig.plugins, rules: { - foo: 1, - bar: "error", - baz: "warn", - boom: 0 + foo: [1], + bar: [2], + baz: [1], + boom: [0] } })); @@ -1290,8 +1304,8 @@ describe("FlatConfigArray", () => { plugins: baseConfig.plugins, rules: { - foo: ["error", "always"], - bar: 0 + foo: [2, "always"], + bar: [0] } })); @@ -1311,8 +1325,8 @@ describe("FlatConfigArray", () => { ], { plugins: baseConfig.plugins, rules: { - foo: ["error", "never"], - bar: ["warn", "foo"] + foo: [2, "never"], + bar: [1, "foo"] } })); @@ -1347,9 +1361,9 @@ describe("FlatConfigArray", () => { } }, rules: { - foo: ["error"], - bar: 0, - "foo/baz/boom/bang": "error" + foo: [2], + bar: [0], + "foo/baz/boom/bang": [2] } })); @@ -1365,8 +1379,31 @@ describe("FlatConfigArray", () => { ], { plugins: baseConfig.plugins, rules: { - foo: 0, - bar: 1 + 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"] } })); From b2600023fb389ef570067319e08a19beef200276 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 3 May 2021 12:00:16 -0700 Subject: [PATCH 33/35] Fix rule options merging --- lib/config/flat-config-schema.js | 31 +++++++++++---------------- tests/lib/config/flat-config-array.js | 4 ++-- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index 16153b3ecc9..b8cbfc9c02d 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -107,7 +107,7 @@ function deepMerge(first, second) { function normalizeRuleOptions(ruleOptions) { const finalOptions = Array.isArray(ruleOptions) - ? ruleOptions + ? ruleOptions.slice(0) : [ruleOptions]; finalOptions[0] = ruleSeverities.get(finalOptions[0]); @@ -333,42 +333,35 @@ const rulesSchema = { 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)) { - result[ruleId] = normalizeRuleOptions(result[ruleId]); continue; } - const firstIsArray = Array.isArray(first[ruleId]); - const secondIsArray = Array.isArray(second[ruleId]); + const firstRuleOptions = normalizeRuleOptions(first[ruleId]); + const secondRuleOptions = normalizeRuleOptions(second[ruleId]); /* - * If the first rule config is an array and the second isn't, just - * create a new array where the first element is the severity from - * the second rule config and the other elements are copied over - * from the first rule config. + * 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 (firstIsArray && !secondIsArray) { - result[ruleId] = normalizeRuleOptions( - [second[ruleId], ...first[ruleId].slice(1)] - ); + if (secondRuleOptions.length === 1) { + result[ruleId] = [secondRuleOptions[0], ...firstRuleOptions.slice(1)]; continue; } /* * In any other situation, then the second rule config takes - * precedence. If it's an array, we return a copy; - * otherwise we return the severity in a new array. + * precedence. That means the value at `result[ruleId]` is + * already correct and no further work is necessary. */ - result[ruleId] = normalizeRuleOptions( - secondIsArray - ? second[ruleId].slice(0) - : [second[ruleId]] - ); } return result; diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index e5bc4582c0d..d6998b478af 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -1330,7 +1330,7 @@ describe("FlatConfigArray", () => { } })); - it("should merge two objects and clear options when second object overrides without options", () => assertMergedResult([ + it("should merge two objects and options when second object overrides without options", () => assertMergedResult([ { rules: { foo: [1, "always"], @@ -1361,7 +1361,7 @@ describe("FlatConfigArray", () => { } }, rules: { - foo: [2], + foo: [2, "always"], bar: [0], "foo/baz/boom/bang": [2] } From 9acd1c26deb1901ebe3194ff55804183c22e2ec7 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 7 May 2021 11:47:48 -0700 Subject: [PATCH 34/35] Fix various errors --- lib/config/flat-config-schema.js | 12 +++++-- tests/lib/config/flat-config-array.js | 49 +++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/lib/config/flat-config-schema.js b/lib/config/flat-config-schema.js index b8cbfc9c02d..80785476133 100644 --- a/lib/config/flat-config-schema.js +++ b/lib/config/flat-config-schema.js @@ -57,7 +57,15 @@ function isUndefined(value) { * @param {Object} second The overrides object. * @returns {Object} An object with properties from both first and second. */ -function deepMerge(first, 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 @@ -70,7 +78,7 @@ function deepMerge(first, second) { ...second }; - for (const key of Object.keys(result)) { + for (const key of Object.keys(second)) { // avoid hairy edge case if (key === "__proto__") { diff --git a/tests/lib/config/flat-config-array.js b/tests/lib/config/flat-config-array.js index d6998b478af..fd89f8d972c 100644 --- a/tests/lib/config/flat-config-array.js +++ b/tests/lib/config/flat-config-array.js @@ -116,11 +116,15 @@ async function assertInvalidConfig(values, message) { * @returns {Array} The rules config object. */ function normalizeRuleConfig(rulesConfig) { - for (const ruleId of Object.keys(rulesConfig)) { - rulesConfig[ruleId] = [2]; + const rulesConfigCopy = { + ...rulesConfig + }; + + for (const ruleId of Object.keys(rulesConfigCopy)) { + rulesConfigCopy[ruleId] = [2]; } - return rulesConfig; + return rulesConfigCopy; } //----------------------------------------------------------------------------- @@ -181,13 +185,16 @@ describe("FlatConfigArray", () => { { settings: { a: true, - b: false + b: false, + d: [1, 2], + e: [5, 6] } }, { settings: { c: true, - a: false + a: false, + d: [3, 4] } } ], { @@ -196,7 +203,9 @@ describe("FlatConfigArray", () => { settings: { a: false, b: false, - c: true + c: true, + d: [3, 4], + e: [5, 6] } })); @@ -1125,6 +1134,34 @@ describe("FlatConfigArray", () => { } })); + 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: { From 7b664081211d7b459c673c97444e6ebd0bfe0b38 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 25 Jun 2021 11:27:07 -0700 Subject: [PATCH 35/35] Rebase onto master --- package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/package.json b/package.json index a25a1bb975f..5d53e1e9aa6 100644 --- a/package.json +++ b/package.json @@ -116,13 +116,8 @@ "markdownlint": "^0.19.0", "markdownlint-cli": "^0.22.0", "memfs": "^3.0.1", -<<<<<<< HEAD "mocha": "^8.3.2", "mocha-junit-reporter": "^2.0.0", -======= - "mocha": "^7.2.0", - "mocha-junit-reporter": "^1.23.0", ->>>>>>> 1a6b3245b... Finish FlatConfigArray implementation "node-polyfill-webpack-plugin": "^1.0.3", "npm-license": "^0.3.3", "nyc": "^15.0.1",