/** * @fileoverview Flat config schema * @author Nicholas C. Zakas */ "use strict"; //----------------------------------------------------------------------------- // Requirements //----------------------------------------------------------------------------- /* * Note: This can be removed in ESLint v9 because structuredClone is available globally * starting in Node.js v17. */ const structuredClone = require("@ungap/structured-clone").default; const { normalizeSeverityToNumber } = require("../shared/severity"); //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @typedef ObjectPropertySchema * @property {Function|string} merge The function or name of the function to call * to merge multiple objects with this property. * @property {Function|string} validate The function or name of the function to call * to validate the value of this property. */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- const ruleSeverities = new Map([ [0, 0], ["off", 0], [1, 1], ["warn", 1], [2, 2], ["error", 2] ]); const globalVariablesValues = new Set([ true, "true", "writable", "writeable", false, "false", "readonly", "readable", null, "off" ]); /** * Check if a value is a non-null object. * @param {any} value The value to check. * @returns {boolean} `true` if the value is a non-null object. */ function isNonNullObject(value) { return typeof value === "object" && value !== null; } /** * Check if a value is a non-null non-array object. * @param {any} value The value to check. * @returns {boolean} `true` if the value is a non-null non-array object. */ function isNonArrayObject(value) { return isNonNullObject(value) && !Array.isArray(value); } /** * 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 non-array objects. * @param {Object} first The base object. * @param {Object} second The overrides object. * @param {Map>} [mergeMap] Maps the combination of first and second arguments to a merged result. * @returns {Object} An object with properties from both first and second. */ function deepMerge(first, second, mergeMap = new Map()) { let secondMergeMap = mergeMap.get(first); if (secondMergeMap) { const result = secondMergeMap.get(second); if (result) { // If this combination of first and second arguments has been already visited, return the previously created result. return result; } } else { secondMergeMap = new Map(); mergeMap.set(first, secondMergeMap); } /* * 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 }; delete result.__proto__; // eslint-disable-line no-proto -- don't merge own property "__proto__" // Store the pending result for this combination of first and second arguments. secondMergeMap.set(second, result); for (const key of Object.keys(second)) { // avoid hairy edge case if (key === "__proto__" || !Object.prototype.propertyIsEnumerable.call(first, key)) { continue; } const firstValue = first[key]; const secondValue = second[key]; if (isNonArrayObject(firstValue) && isNonArrayObject(secondValue)) { result[key] = deepMerge(firstValue, secondValue, mergeMap); } else if (isUndefined(secondValue)) { result[key] = firstValue; } } return result; } /** * Normalizes the rule options config for a given rule by ensuring that * it is an array and that the first item is 0, 1, or 2. * @param {Array|string|number} ruleOptions The rule options config. * @returns {Array} An array of rule options. */ function normalizeRuleOptions(ruleOptions) { const finalOptions = Array.isArray(ruleOptions) ? ruleOptions.slice(0) : [ruleOptions]; finalOptions[0] = ruleSeverities.get(finalOptions[0]); return structuredClone(finalOptions); } //----------------------------------------------------------------------------- // Assertions //----------------------------------------------------------------------------- /** * The error type when a rule's options are configured with an invalid type. */ class InvalidRuleOptionsError extends Error { /** * @param {string} ruleId Rule name being configured. * @param {any} value The invalid value. */ constructor(ruleId, value) { super(`Key "${ruleId}": Expected severity of "off", 0, "warn", 1, "error", or 2.`); this.messageTemplate = "invalid-rule-options"; this.messageData = { ruleId, value }; } } /** * Validates that a value is a valid rule options entry. * @param {string} ruleId Rule name being configured. * @param {any} value The value to check. * @returns {void} * @throws {InvalidRuleOptionsError} If the value isn't a valid rule options. */ function assertIsRuleOptions(ruleId, value) { if (typeof value !== "string" && typeof value !== "number" && !Array.isArray(value)) { throw new InvalidRuleOptionsError(ruleId, value); } } /** * The error type when a rule's severity is invalid. */ class InvalidRuleSeverityError extends Error { /** * @param {string} ruleId Rule name being configured. * @param {any} value The invalid value. */ constructor(ruleId, value) { super(`Key "${ruleId}": Expected severity of "off", 0, "warn", 1, "error", or 2.`); this.messageTemplate = "invalid-rule-severity"; this.messageData = { ruleId, value }; } } /** * Validates that a value is valid rule severity. * @param {string} ruleId Rule name being configured. * @param {any} value The value to check. * @returns {void} * @throws {InvalidRuleSeverityError} If the value isn't a valid rule severity. */ function assertIsRuleSeverity(ruleId, value) { const severity = ruleSeverities.get(value); if (typeof severity === "undefined") { throw new InvalidRuleSeverityError(ruleId, value); } } /** * Validates that a given string is the form pluginName/objectName. * @param {string} value The string to check. * @returns {void} * @throws {TypeError} If the string isn't in the correct format. */ function assertIsPluginMemberName(value) { if (!/[@a-z0-9-_$]+(?:\/(?:[a-z0-9-_$]+))+$/iu.test(value)) { throw new TypeError(`Expected string in the form "pluginName/objectName" but found "${value}".`); } } /** * Validates that a value is an object. * @param {any} value The value to check. * @returns {void} * @throws {TypeError} If the value isn't an object. */ function assertIsObject(value) { if (!isNonNullObject(value)) { throw new TypeError("Expected an object."); } } /** * The error type when there's an eslintrc-style options in a flat config. */ class IncompatibleKeyError extends Error { /** * @param {string} key The invalid key. */ constructor(key) { super("This appears to be in eslintrc format rather than flat config format."); this.messageTemplate = "eslintrc-incompat"; this.messageData = { key }; } } /** * The error type when there's an eslintrc-style plugins array found. */ class IncompatiblePluginsError extends Error { /** * Creates a new instance. * @param {Array} plugins The plugins array. */ constructor(plugins) { super("This appears to be in eslintrc format (array of strings) rather than flat config format (object)."); this.messageTemplate = "eslintrc-plugins"; this.messageData = { plugins }; } } //----------------------------------------------------------------------------- // Low-Level Schemas //----------------------------------------------------------------------------- /** @type {ObjectPropertySchema} */ const booleanSchema = { merge: "replace", validate: "boolean" }; const ALLOWED_SEVERITIES = new Set(["error", "warn", "off", 2, 1, 0]); /** @type {ObjectPropertySchema} */ const disableDirectiveSeveritySchema = { merge(first, second) { const value = second === void 0 ? first : second; if (typeof value === "boolean") { return value ? "warn" : "off"; } return normalizeSeverityToNumber(value); }, validate(value) { if (!(ALLOWED_SEVERITIES.has(value) || typeof value === "boolean")) { throw new TypeError("Expected one of: \"error\", \"warn\", \"off\", 0, 1, 2, or a boolean."); } } }; /** @type {ObjectPropertySchema} */ const deepObjectAssignSchema = { merge(first = {}, second = {}) { return deepMerge(first, second); }, validate: "object" }; //----------------------------------------------------------------------------- // High-Level Schemas //----------------------------------------------------------------------------- /** @type {ObjectPropertySchema} */ const globalsSchema = { merge: "assign", validate(value) { assertIsObject(value); for (const key of Object.keys(value)) { // avoid hairy edge case if (key === "__proto__") { continue; } if (key !== key.trim()) { throw new TypeError(`Global "${key}" has leading or trailing whitespace.`); } if (!globalVariablesValues.has(value[key])) { throw new TypeError(`Key "${key}": Expected "readonly", "writable", or "off".`); } } } }; /** @type {ObjectPropertySchema} */ const parserSchema = { merge: "replace", validate(value) { if (!value || typeof value !== "object" || (typeof value.parse !== "function" && typeof value.parseForESLint !== "function") ) { throw new TypeError("Expected object with parse() or parseForESLint() method."); } } }; /** @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."); } // make sure it's not an array, which would mean eslintrc-style is used if (Array.isArray(value)) { throw new IncompatiblePluginsError(value); } // second check the keys to make sure they are objects for (const key of Object.keys(value)) { // avoid hairy edge case if (key === "__proto__") { continue; } if (value[key] === null || typeof value[key] !== "object") { throw new TypeError(`Key "${key}": Expected an object.`); } } } }; /** @type {ObjectPropertySchema} */ const processorSchema = { merge: "replace", validate(value) { if (typeof value === "string") { assertIsPluginMemberName(value); } else if (value && typeof value === "object") { if (typeof value.preprocess !== "function" || typeof value.postprocess !== "function") { throw new TypeError("Object must have a preprocess() and a postprocess() method."); } } else { throw new TypeError("Expected an object or a string."); } } }; /** @type {ObjectPropertySchema} */ const rulesSchema = { merge(first = {}, second = {}) { const result = { ...first, ...second }; for (const ruleId of Object.keys(result)) { try { // avoid hairy edge case if (ruleId === "__proto__") { /* eslint-disable-next-line no-proto -- Though deprecated, may still be present */ delete result.__proto__; continue; } result[ruleId] = normalizeRuleOptions(result[ruleId]); /* * If either rule config is missing, then the correct * config is already present and we just need to normalize * the severity. */ if (!(ruleId in first) || !(ruleId in second)) { continue; } const firstRuleOptions = normalizeRuleOptions(first[ruleId]); const secondRuleOptions = normalizeRuleOptions(second[ruleId]); /* * If the second rule config only has a severity (length of 1), * then use that severity and keep the rest of the options from * the first rule config. */ if (secondRuleOptions.length === 1) { result[ruleId] = [secondRuleOptions[0], ...firstRuleOptions.slice(1)]; continue; } /* * In any other situation, then the second rule config takes * precedence. That means the value at `result[ruleId]` is * already correct and no further work is necessary. */ } catch (ex) { throw new Error(`Key "${ruleId}": ${ex.message}`, { cause: ex }); } } return result; }, validate(value) { assertIsObject(value); /* * 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; } const ruleOptions = value[ruleId]; assertIsRuleOptions(ruleId, ruleOptions); if (Array.isArray(ruleOptions)) { assertIsRuleSeverity(ruleId, ruleOptions[0]); } else { assertIsRuleSeverity(ruleId, ruleOptions); } } } }; /** @type {ObjectPropertySchema} */ const ecmaVersionSchema = { merge: "replace", validate(value) { if (typeof value === "number" || value === "latest") { return; } throw new TypeError("Expected a number or \"latest\"."); } }; /** @type {ObjectPropertySchema} */ const sourceTypeSchema = { merge: "replace", validate(value) { if (typeof value !== "string" || !/^(?:script|module|commonjs)$/u.test(value)) { throw new TypeError("Expected \"script\", \"module\", or \"commonjs\"."); } } }; /** * Creates a schema that always throws an error. Useful for warning * about eslintrc-style keys. * @param {string} key The eslintrc key to create a schema for. * @returns {ObjectPropertySchema} The schema. */ function createEslintrcErrorSchema(key) { return { merge: "replace", validate() { throw new IncompatibleKeyError(key); } }; } const eslintrcKeys = [ "env", "extends", "globals", "ignorePatterns", "noInlineConfig", "overrides", "parser", "parserOptions", "reportUnusedDisableDirectives", "root" ]; //----------------------------------------------------------------------------- // Full schema //----------------------------------------------------------------------------- const flatConfigSchema = { // eslintrc-style keys that should always error ...Object.fromEntries(eslintrcKeys.map(key => [key, createEslintrcErrorSchema(key)])), // flat config keys settings: deepObjectAssignSchema, linterOptions: { schema: { noInlineConfig: booleanSchema, reportUnusedDisableDirectives: disableDirectiveSeveritySchema } }, languageOptions: { schema: { ecmaVersion: ecmaVersionSchema, sourceType: sourceTypeSchema, globals: globalsSchema, parser: parserSchema, parserOptions: deepObjectAssignSchema } }, processor: processorSchema, plugins: pluginsSchema, rules: rulesSchema }; //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- module.exports = { flatConfigSchema, assertIsRuleSeverity, assertIsRuleOptions };