599 lines
17 KiB
JavaScript
599 lines
17 KiB
JavaScript
|
/**
|
||
|
* @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
|
||
|
};
|