You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

563 lines
16 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;
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef ObjectPropertySchema
* @property {Function|string} merge The function or name of the function to call
* to merge multiple objects with this property.
* @property {Function|string} validate The function or name of the function to call
* to validate the value of this property.
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const ruleSeverities = new Map([
[0, 0], ["off", 0],
[1, 1], ["warn", 1],
[2, 2], ["error", 2]
]);
const globalVariablesValues = new Set([
true, "true", "writable", "writeable",
false, "false", "readonly", "readable", null,
"off"
]);
/**
* Check if a value is a non-null object.
* @param {any} value The value to check.
* @returns {boolean} `true` if the value is a non-null object.
*/
function isNonNullObject(value) {
return typeof value === "object" && value !== null;
}
/**
* Check if a value is undefined.
* @param {any} value The value to check.
* @returns {boolean} `true` if the value is undefined.
*/
function isUndefined(value) {
return typeof value === "undefined";
}
/**
* Deeply merges two objects.
* @param {Object} first The base object.
* @param {Object} second The overrides object.
* @returns {Object} An object with properties from both first and second.
*/
function deepMerge(first = {}, second = {}) {
/*
* If the second value is an array, just return it. We don't merge
* arrays because order matters and we can't know the correct order.
*/
if (Array.isArray(second)) {
return second;
}
/*
* First create a result object where properties from the second object
* overwrite properties from the first. This sets up a baseline to use
* later rather than needing to inspect and change every property
* individually.
*/
const result = {
...first,
...second
};
for (const key of Object.keys(second)) {
// avoid hairy edge case
if (key === "__proto__") {
continue;
}
const firstValue = first[key];
const secondValue = second[key];
if (isNonNullObject(firstValue)) {
result[key] = deepMerge(firstValue, secondValue);
} else if (isUndefined(firstValue)) {
if (isNonNullObject(secondValue)) {
result[key] = deepMerge(
Array.isArray(secondValue) ? [] : {},
secondValue
);
} else if (!isUndefined(secondValue)) {
result[key] = secondValue;
}
}
}
return result;
}
/**
* Normalizes the rule options config for a given rule by ensuring that
* it is an array and that the first item is 0, 1, or 2.
* @param {Array|string|number} ruleOptions The rule options config.
* @returns {Array} An array of rule options.
*/
function normalizeRuleOptions(ruleOptions) {
const finalOptions = Array.isArray(ruleOptions)
? ruleOptions.slice(0)
: [ruleOptions];
finalOptions[0] = ruleSeverities.get(finalOptions[0]);
return 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"
};
/** @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: booleanSchema
}
},
languageOptions: {
schema: {
ecmaVersion: ecmaVersionSchema,
sourceType: sourceTypeSchema,
globals: globalsSchema,
parser: parserSchema,
parserOptions: deepObjectAssignSchema
}
},
processor: processorSchema,
plugins: pluginsSchema,
rules: rulesSchema
};
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
module.exports = {
flatConfigSchema,
assertIsRuleSeverity,
assertIsRuleOptions
};