| /** | |
|  * @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<string, Map<string, Object>>} [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<string>} 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 | |
| };
 |