Skip to content

Commit e86edee

Browse files
nzakasCopilotlumirlumir
authored
refactor: Consolidate Config helpers (#19675)
* refactor: Consolidate Config helpers * Update lib/config/config.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove unneeded methods * Update lib/config/config.js Co-authored-by: 루밀LuMir <rpfos@naver.com> * Move validators cache to module level * Move getRuleNumericSeverity to static * Really move getRuleNumericSeverity to static * Remove extra files --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: 루밀LuMir <rpfos@naver.com>
1 parent 07c1a7e commit e86edee

File tree

8 files changed

+1006
-539
lines changed

8 files changed

+1006
-539
lines changed

lib/config/config.js

Lines changed: 328 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,31 @@
1010
//-----------------------------------------------------------------------------
1111

1212
const { deepMergeArrays } = require("../shared/deep-merge-arrays");
13-
const { getRuleFromConfig } = require("./flat-config-helpers");
1413
const { flatConfigSchema, hasMethod } = require("./flat-config-schema");
15-
const { RuleValidator } = require("./rule-validator");
1614
const { ObjectSchema } = require("@eslint/config-array");
15+
const ajvImport = require("../shared/ajv");
16+
const ajv = ajvImport();
17+
const ruleReplacements = require("../../conf/replacements.json");
1718

1819
//-----------------------------------------------------------------------------
19-
// Helpers
20+
// Typedefs
21+
//-----------------------------------------------------------------------------
22+
23+
/**
24+
* @import { RuleDefinition } from "@eslint/core";
25+
* @import { Linter } from "eslint";
26+
*/
27+
2028
//-----------------------------------------------------------------------------
29+
// Private Members
30+
//------------------------------------------------------------------------------
2131

22-
const ruleValidator = new RuleValidator();
32+
// JSON schema that disallows passing any options
33+
const noOptionsSchema = Object.freeze({
34+
type: "array",
35+
minItems: 0,
36+
maxItems: 0,
37+
});
2338

2439
const severities = new Map([
2540
[0, 0],
@@ -30,6 +45,174 @@ const severities = new Map([
3045
["error", 2],
3146
]);
3247

48+
/**
49+
* A collection of compiled validators for rules that have already
50+
* been validated.
51+
* @type {WeakMap}
52+
*/
53+
const validators = new WeakMap();
54+
55+
//-----------------------------------------------------------------------------
56+
// Helpers
57+
//-----------------------------------------------------------------------------
58+
59+
/**
60+
* Throws a helpful error when a rule cannot be found.
61+
* @param {Object} ruleId The rule identifier.
62+
* @param {string} ruleId.pluginName The ID of the rule to find.
63+
* @param {string} ruleId.ruleName The ID of the rule to find.
64+
* @param {Object} config The config to search in.
65+
* @throws {TypeError} For missing plugin or rule.
66+
* @returns {void}
67+
*/
68+
function throwRuleNotFoundError({ pluginName, ruleName }, config) {
69+
const ruleId = pluginName === "@" ? ruleName : `${pluginName}/${ruleName}`;
70+
71+
const errorMessageHeader = `Key "rules": Key "${ruleId}"`;
72+
73+
let errorMessage = `${errorMessageHeader}: Could not find plugin "${pluginName}" in configuration.`;
74+
75+
const missingPluginErrorMessage = errorMessage;
76+
77+
// if the plugin exists then we need to check if the rule exists
78+
if (config.plugins && config.plugins[pluginName]) {
79+
const replacementRuleName = ruleReplacements.rules[ruleName];
80+
81+
if (pluginName === "@" && replacementRuleName) {
82+
errorMessage = `${errorMessageHeader}: Rule "${ruleName}" was removed and replaced by "${replacementRuleName}".`;
83+
} else {
84+
errorMessage = `${errorMessageHeader}: Could not find "${ruleName}" in plugin "${pluginName}".`;
85+
86+
// otherwise, let's see if we can find the rule name elsewhere
87+
for (const [otherPluginName, otherPlugin] of Object.entries(
88+
config.plugins,
89+
)) {
90+
if (otherPlugin.rules && otherPlugin.rules[ruleName]) {
91+
errorMessage += ` Did you mean "${otherPluginName}/${ruleName}"?`;
92+
break;
93+
}
94+
}
95+
}
96+
97+
// falls through to throw error
98+
}
99+
100+
const error = new TypeError(errorMessage);
101+
102+
if (errorMessage === missingPluginErrorMessage) {
103+
error.messageTemplate = "config-plugin-missing";
104+
error.messageData = { pluginName, ruleId };
105+
}
106+
107+
throw error;
108+
}
109+
110+
/**
111+
* The error type when a rule has an invalid `meta.schema`.
112+
*/
113+
class InvalidRuleOptionsSchemaError extends Error {
114+
/**
115+
* Creates a new instance.
116+
* @param {string} ruleId Id of the rule that has an invalid `meta.schema`.
117+
* @param {Error} processingError Error caught while processing the `meta.schema`.
118+
*/
119+
constructor(ruleId, processingError) {
120+
super(
121+
`Error while processing options validation schema of rule '${ruleId}': ${processingError.message}`,
122+
{ cause: processingError },
123+
);
124+
this.code = "ESLINT_INVALID_RULE_OPTIONS_SCHEMA";
125+
}
126+
}
127+
128+
/**
129+
* Parses a ruleId into its plugin and rule parts.
130+
* @param {string} ruleId The rule ID to parse.
131+
* @returns {{pluginName:string,ruleName:string}} The plugin and rule
132+
* parts of the ruleId;
133+
*/
134+
function parseRuleId(ruleId) {
135+
let pluginName, ruleName;
136+
137+
// distinguish between core rules and plugin rules
138+
if (ruleId.includes("/")) {
139+
// mimic scoped npm packages
140+
if (ruleId.startsWith("@")) {
141+
pluginName = ruleId.slice(0, ruleId.lastIndexOf("/"));
142+
} else {
143+
pluginName = ruleId.slice(0, ruleId.indexOf("/"));
144+
}
145+
146+
ruleName = ruleId.slice(pluginName.length + 1);
147+
} else {
148+
pluginName = "@";
149+
ruleName = ruleId;
150+
}
151+
152+
return {
153+
pluginName,
154+
ruleName,
155+
};
156+
}
157+
158+
/**
159+
* Retrieves a rule instance from a given config based on the ruleId.
160+
* @param {string} ruleId The rule ID to look for.
161+
* @param {Linter.Config} config The config to search.
162+
* @returns {RuleDefinition|undefined} The rule if found
163+
* or undefined if not.
164+
*/
165+
function getRuleFromConfig(ruleId, config) {
166+
const { pluginName, ruleName } = parseRuleId(ruleId);
167+
168+
return config.plugins?.[pluginName]?.rules?.[ruleName];
169+
}
170+
171+
/**
172+
* Gets a complete options schema for a rule.
173+
* @param {RuleDefinition} rule A rule object
174+
* @throws {TypeError} If `meta.schema` is specified but is not an array, object or `false`.
175+
* @returns {Object|null} JSON Schema for the rule's options. `null` if `meta.schema` is `false`.
176+
*/
177+
function getRuleOptionsSchema(rule) {
178+
if (!rule.meta) {
179+
return { ...noOptionsSchema }; // default if `meta.schema` is not specified
180+
}
181+
182+
const schema = rule.meta.schema;
183+
184+
if (typeof schema === "undefined") {
185+
return { ...noOptionsSchema }; // default if `meta.schema` is not specified
186+
}
187+
188+
// `schema:false` is an allowed explicit opt-out of options validation for the rule
189+
if (schema === false) {
190+
return null;
191+
}
192+
193+
if (typeof schema !== "object" || schema === null) {
194+
throw new TypeError("Rule's `meta.schema` must be an array or object");
195+
}
196+
197+
// ESLint-specific array form needs to be converted into a valid JSON Schema definition
198+
if (Array.isArray(schema)) {
199+
if (schema.length) {
200+
return {
201+
type: "array",
202+
items: schema,
203+
minItems: 0,
204+
maxItems: schema.length,
205+
};
206+
}
207+
208+
// `schema:[]` is an explicit way to specify that the rule does not accept any options
209+
return { ...noOptionsSchema };
210+
}
211+
212+
// `schema:<object>` is assumed to be a valid JSON Schema definition
213+
return schema;
214+
}
215+
33216
/**
34217
* Splits a plugin identifier in the form a/b/c into two parts: a/b and c.
35218
* @param {string} identifier The identifier to parse.
@@ -124,6 +307,29 @@ function languageOptionsToJSON(languageOptions, objectKey = "languageOptions") {
124307
return result;
125308
}
126309

310+
/**
311+
* Gets or creates a validator for a rule.
312+
* @param {Object} rule The rule to get a validator for.
313+
* @param {string} ruleId The ID of the rule (for error reporting).
314+
* @returns {Function|null} A validation function or null if no validation is needed.
315+
* @throws {InvalidRuleOptionsSchemaError} If a rule's `meta.schema` is invalid.
316+
*/
317+
function getOrCreateValidator(rule, ruleId) {
318+
if (!validators.has(rule)) {
319+
try {
320+
const schema = getRuleOptionsSchema(rule);
321+
322+
if (schema) {
323+
validators.set(rule, ajv.compile(schema));
324+
}
325+
} catch (err) {
326+
throw new InvalidRuleOptionsSchemaError(ruleId, err);
327+
}
328+
}
329+
330+
return validators.get(rule);
331+
}
332+
127333
//-----------------------------------------------------------------------------
128334
// Exports
129335
//-----------------------------------------------------------------------------
@@ -252,7 +458,7 @@ class Config {
252458
// Process the rules
253459
if (this.rules) {
254460
this.#normalizeRulesConfig();
255-
ruleValidator.validate(this);
461+
this.validateRulesConfig(this.rules);
256462
}
257463
}
258464

@@ -291,6 +497,15 @@ class Config {
291497
};
292498
}
293499

500+
/**
501+
* Gets a rule configuration by its ID.
502+
* @param {string} ruleId The ID of the rule to get.
503+
* @returns {RuleDefinition|undefined} The rule definition from the plugin, or `undefined` if the rule is not found.
504+
*/
505+
getRuleDefinition(ruleId) {
506+
return getRuleFromConfig(ruleId, this);
507+
}
508+
294509
/**
295510
* Normalizes the rules configuration. Ensures that each rule config is
296511
* an array and that the severity is a number. Applies meta.defaultOptions.
@@ -323,6 +538,114 @@ class Config {
323538
this.rules[ruleId] = ruleConfig;
324539
}
325540
}
541+
542+
/**
543+
* Validates all of the rule configurations in the given rules config
544+
* against the plugins in this instance. This is used primarily to
545+
* validate inline configuration rules while inting.
546+
* @param {Object} rulesConfig The rules config to validate.
547+
* @returns {void}
548+
* @throws {Error} If a rule's configuration does not match its schema.
549+
* @throws {TypeError} If the rulesConfig is not provided or is invalid.
550+
* @throws {InvalidRuleOptionsSchemaError} If a rule's `meta.schema` is invalid.
551+
* @throws {TypeError} If a rule is not found in the plugins.
552+
*/
553+
validateRulesConfig(rulesConfig) {
554+
if (!rulesConfig) {
555+
throw new TypeError("Config is required for validation.");
556+
}
557+
558+
for (const [ruleId, ruleOptions] of Object.entries(rulesConfig)) {
559+
// check for edge case
560+
if (ruleId === "__proto__") {
561+
continue;
562+
}
563+
564+
/*
565+
* If a rule is disabled, we don't do any validation. This allows
566+
* users to safely set any value to 0 or "off" without worrying
567+
* that it will cause a validation error.
568+
*
569+
* Note: ruleOptions is always an array at this point because
570+
* this validation occurs after FlatConfigArray has merged and
571+
* normalized values.
572+
*/
573+
if (ruleOptions[0] === 0) {
574+
continue;
575+
}
576+
577+
const rule = getRuleFromConfig(ruleId, this);
578+
579+
if (!rule) {
580+
throwRuleNotFoundError(parseRuleId(ruleId), this);
581+
}
582+
583+
const validateRule = getOrCreateValidator(rule, ruleId);
584+
585+
if (validateRule) {
586+
validateRule(ruleOptions.slice(1));
587+
588+
if (validateRule.errors) {
589+
throw new Error(
590+
`Key "rules": Key "${ruleId}":\n${validateRule.errors
591+
.map(error => {
592+
if (
593+
error.keyword === "additionalProperties" &&
594+
error.schema === false &&
595+
typeof error.parentSchema?.properties ===
596+
"object" &&
597+
typeof error.params?.additionalProperty ===
598+
"string"
599+
) {
600+
const expectedProperties = Object.keys(
601+
error.parentSchema.properties,
602+
).map(property => `"${property}"`);
603+
604+
return `\tValue ${JSON.stringify(error.data)} ${error.message}.\n\t\tUnexpected property "${error.params.additionalProperty}". Expected properties: ${expectedProperties.join(", ")}.\n`;
605+
}
606+
607+
return `\tValue ${JSON.stringify(error.data)} ${error.message}.\n`;
608+
})
609+
.join("")}`,
610+
);
611+
}
612+
}
613+
}
614+
}
615+
616+
/**
617+
* Gets a complete options schema for a rule.
618+
* @param {RuleDefinition} ruleDefinition A rule definition object.
619+
* @throws {TypeError} If `meta.schema` is specified but is not an array, object or `false`.
620+
* @returns {Object|null} JSON Schema for the rule's options. `null` if `meta.schema` is `false`.
621+
*/
622+
static getRuleOptionsSchema(ruleDefinition) {
623+
return getRuleOptionsSchema(ruleDefinition);
624+
}
625+
626+
/**
627+
* Normalizes the severity value of a rule's configuration to a number
628+
* @param {(number|string|[number, ...*]|[string, ...*])} ruleConfig A rule's configuration value, generally
629+
* received from the user. A valid config value is either 0, 1, 2, the string "off" (treated the same as 0),
630+
* the string "warn" (treated the same as 1), the string "error" (treated the same as 2), or an array
631+
* whose first element is one of the above values. Strings are matched case-insensitively.
632+
* @returns {(0|1|2)} The numeric severity value if the config value was valid, otherwise 0.
633+
*/
634+
static getRuleNumericSeverity(ruleConfig) {
635+
const severityValue = Array.isArray(ruleConfig)
636+
? ruleConfig[0]
637+
: ruleConfig;
638+
639+
if (severities.has(severityValue)) {
640+
return severities.get(severityValue);
641+
}
642+
643+
if (typeof severityValue === "string") {
644+
return severities.get(severityValue.toLowerCase()) ?? 0;
645+
}
646+
647+
return 0;
648+
}
326649
}
327650

328651
module.exports = { Config };

0 commit comments

Comments
 (0)