Skip to content

Commit fb81b1c

Browse files
authored
feat!: Set default schema: [], drop support for function-style rules (#17792)
* drop support for function-style rules in flat-rule-tester * drop support for function-style rules in rule-tester * drop support for function-style rules in config-rule (eslint-fuzzer) * add rule object checks to rule testers * drop support for function-style rules in flat config and Linter#defineRule * update JSDoc * add rule object checks to Linter * drop support for function-style rules in eslintrc * remove custom-rules-deprecated.md * update flat config getRuleOptionsSchema for schema changes * add back and update custom-rules-deprecated.md * show ruleId in error messages for invalid schema * throw error in Linter if rule has invalid schema * add short description of meta.schema to type errors in flat-rule-tester * throw error for empty object schema in flat-rule-tester * update rule-tester for schema changes * add integration tests for eslintrc schema changes * add more flat-config-array unit tests for schema changes * throw error in Linter in eslintrc mode if rule has invalid schema * update docs for schema changes * use eslintrc v3
1 parent 1da0723 commit fb81b1c

36 files changed

+1638
-1008
lines changed

docs/src/extend/custom-rules-deprecated.md

+2-574
Large diffs are not rendered by default.

docs/src/extend/custom-rules.md

+14-5
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ eleventyNavigation:
1010

1111
You can create custom rules to use with ESLint. You might want to create a custom rule if the [core rules](../rules/) do not cover your use case.
1212

13-
**Note:** This page covers the most recent rule format for ESLint >= 3.0.0. There is also a [deprecated rule format](./custom-rules-deprecated).
14-
1513
Here's the basic format of a custom rule:
1614

1715
```js
@@ -60,7 +58,7 @@ The source file for a rule exports an object with the following properties. Both
6058

6159
**Important:** the `hasSuggestions` property is mandatory for rules that provide suggestions. If this property isn't set to `true`, ESLint will throw an error whenever the rule attempts to produce a suggestion. Omit the `hasSuggestions` property if the rule does not provide suggestions.
6260

63-
* `schema`: (`object | array`) Specifies the [options](#options-schemas) so ESLint can prevent invalid [rule configurations](../use/configure/rules).
61+
* `schema`: (`object | array | false`) Specifies the [options](#options-schemas) so ESLint can prevent invalid [rule configurations](../use/configure/rules). Mandatory when the rule has options.
6462

6563
* `deprecated`: (`boolean`) Indicates whether the rule has been deprecated. You may omit the `deprecated` property if the rule has not been deprecated.
6664

@@ -482,6 +480,13 @@ The `quotes` rule in this example has one option, `"double"` (the `error` is the
482480

483481
```js
484482
module.exports = {
483+
meta: {
484+
schema: [
485+
{
486+
enum: ["single", "double", "backtick"]
487+
}
488+
]
489+
},
485490
create: function(context) {
486491
var isDouble = (context.options[0] === "double");
487492

@@ -494,6 +499,8 @@ Since `context.options` is just an array, you can use it to determine how many o
494499

495500
When using options, make sure that your rule has some logical defaults in case the options are not provided.
496501

502+
Rules with options must specify a [schema](#options-schemas).
503+
497504
### Accessing the Source Code
498505

499506
The `SourceCode` object is the main object for getting more information about the source code being linted. You can retrieve the `SourceCode` object at any time by using the `context.sourceCode` property:
@@ -612,9 +619,11 @@ You can also access comments through many of `sourceCode`'s methods using the `i
612619

613620
### Options Schemas
614621

615-
Rules may specify a `schema` property, which is a [JSON Schema](https://json-schema.org/) format description of a rule's options which will be used by ESLint to validate configuration options and prevent invalid or unexpected inputs before they are passed to the rule in `context.options`.
622+
Rules with options must specify a `meta.schema` property, which is a [JSON Schema](https://json-schema.org/) format description of a rule's options which will be used by ESLint to validate configuration options and prevent invalid or unexpected inputs before they are passed to the rule in `context.options`.
623+
624+
If your rule has options, it is strongly recommended that you specify a schema for options validation. However, it is possible to opt-out of options validation by setting `schema: false`, but doing so is discouraged as it increases the chance of bugs and mistakes.
616625

617-
Note: Prior to ESLint v9.0.0, rules without a schema are passed their options directly from the config without any validation. In ESLint v9.0.0 and later, rules without schemas will throw errors when options are passed. See the [Require schemas and object-style rules](https://github.com/eslint/rfcs/blob/main/designs/2021-schema-object-rules/README.md) RFC for further details.
626+
For rules that don't specify a `meta.schema` property, ESLint throws errors when any options are passed. If your rule doesn't have options, do not set `schema: false`, but simply omit the schema property or use `schema: []`, both of which prevent any options from being passed.
618627

619628
When validating a rule's config, there are five steps:
620629

lib/config/flat-config-helpers.js

+41-20
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@
55

66
"use strict";
77

8+
//------------------------------------------------------------------------------
9+
// Typedefs
10+
//------------------------------------------------------------------------------
11+
12+
/** @typedef {import("../shared/types").Rule} Rule */
13+
14+
//------------------------------------------------------------------------------
15+
// Private Members
16+
//------------------------------------------------------------------------------
17+
18+
// JSON schema that disallows passing any options
19+
const noOptionsSchema = Object.freeze({
20+
type: "array",
21+
minItems: 0,
22+
maxItems: 0
23+
});
24+
825
//-----------------------------------------------------------------------------
926
// Functions
1027
//-----------------------------------------------------------------------------
@@ -52,32 +69,39 @@ function getRuleFromConfig(ruleId, config) {
5269
const { pluginName, ruleName } = parseRuleId(ruleId);
5370

5471
const plugin = config.plugins && config.plugins[pluginName];
55-
let rule = plugin && plugin.rules && plugin.rules[ruleName];
56-
57-
58-
// normalize function rules into objects
59-
if (rule && typeof rule === "function") {
60-
rule = {
61-
create: rule
62-
};
63-
}
72+
const rule = plugin && plugin.rules && plugin.rules[ruleName];
6473

6574
return rule;
6675
}
6776

6877
/**
6978
* Gets a complete options schema for a rule.
70-
* @param {{create: Function, schema: (Array|null)}} rule A new-style rule object
71-
* @returns {Object} JSON Schema for the rule's options.
79+
* @param {Rule} rule A rule object
80+
* @throws {TypeError} If `meta.schema` is specified but is not an array, object or `false`.
81+
* @returns {Object|null} JSON Schema for the rule's options. `null` if `meta.schema` is `false`.
7282
*/
7383
function getRuleOptionsSchema(rule) {
7484

75-
if (!rule) {
85+
if (!rule.meta) {
86+
return { ...noOptionsSchema }; // default if `meta.schema` is not specified
87+
}
88+
89+
const schema = rule.meta.schema;
90+
91+
if (typeof schema === "undefined") {
92+
return { ...noOptionsSchema }; // default if `meta.schema` is not specified
93+
}
94+
95+
// `schema:false` is an allowed explicit opt-out of options validation for the rule
96+
if (schema === false) {
7697
return null;
7798
}
7899

79-
const schema = rule.schema || rule.meta && rule.meta.schema;
100+
if (typeof schema !== "object" || schema === null) {
101+
throw new TypeError("Rule's `meta.schema` must be an array or object");
102+
}
80103

104+
// ESLint-specific array form needs to be converted into a valid JSON Schema definition
81105
if (Array.isArray(schema)) {
82106
if (schema.length) {
83107
return {
@@ -87,16 +111,13 @@ function getRuleOptionsSchema(rule) {
87111
maxItems: schema.length
88112
};
89113
}
90-
return {
91-
type: "array",
92-
minItems: 0,
93-
maxItems: 0
94-
};
95114

115+
// `schema:[]` is an explicit way to specify that the rule does not accept any options
116+
return { ...noOptionsSchema };
96117
}
97118

98-
// Given a full schema, leave it alone
99-
return schema || null;
119+
// `schema:<object>` is assumed to be a valid JSON Schema definition
120+
return schema;
100121
}
101122

102123

lib/config/rule-validator.js

+27-4
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,25 @@ function throwRuleNotFoundError({ pluginName, ruleName }, config) {
6666
throw new TypeError(errorMessage);
6767
}
6868

69+
/**
70+
* The error type when a rule has an invalid `meta.schema`.
71+
*/
72+
class InvalidRuleOptionsSchemaError extends Error {
73+
74+
/**
75+
* Creates a new instance.
76+
* @param {string} ruleId Id of the rule that has an invalid `meta.schema`.
77+
* @param {Error} processingError Error caught while processing the `meta.schema`.
78+
*/
79+
constructor(ruleId, processingError) {
80+
super(
81+
`Error while processing options validation schema of rule '${ruleId}': ${processingError.message}`,
82+
{ cause: processingError }
83+
);
84+
this.code = "ESLINT_INVALID_RULE_OPTIONS_SCHEMA";
85+
}
86+
}
87+
6988
//-----------------------------------------------------------------------------
7089
// Exports
7190
//-----------------------------------------------------------------------------
@@ -130,10 +149,14 @@ class RuleValidator {
130149

131150
// Precompile and cache validator the first time
132151
if (!this.validators.has(rule)) {
133-
const schema = getRuleOptionsSchema(rule);
134-
135-
if (schema) {
136-
this.validators.set(rule, ajv.compile(schema));
152+
try {
153+
const schema = getRuleOptionsSchema(rule);
154+
155+
if (schema) {
156+
this.validators.set(rule, ajv.compile(schema));
157+
}
158+
} catch (err) {
159+
throw new InvalidRuleOptionsSchemaError(ruleId, err);
137160
}
138161
}
139162

lib/linter/linter.js

+28-5
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,15 @@ function getDirectiveComments(sourceCode, ruleMapper, warnInlineConfig) {
439439
try {
440440
validator.validateRuleOptions(rule, name, ruleValue);
441441
} catch (err) {
442+
443+
/*
444+
* If the rule has invalid `meta.schema`, throw the error because
445+
* this is not an invalid inline configuration but an invalid rule.
446+
*/
447+
if (err.code === "ESLINT_INVALID_RULE_OPTIONS_SCHEMA") {
448+
throw err;
449+
}
450+
442451
problems.push(createLintingProblem({
443452
ruleId: name,
444453
message: err.message,
@@ -885,12 +894,18 @@ function parse(text, languageOptions, filePath) {
885894

886895
/**
887896
* Runs a rule, and gets its listeners
888-
* @param {Rule} rule A normalized rule with a `create` method
897+
* @param {Rule} rule A rule object
889898
* @param {Context} ruleContext The context that should be passed to the rule
899+
* @throws {TypeError} If `rule` is not an object with a `create` method
890900
* @throws {any} Any error during the rule's `create`
891901
* @returns {Object} A map of selector listeners provided by the rule
892902
*/
893903
function createRuleListeners(rule, ruleContext) {
904+
905+
if (!rule || typeof rule !== "object" || typeof rule.create !== "function") {
906+
throw new TypeError(`Error while loading rule '${ruleContext.id}': Rule must be an object with a \`create\` method`);
907+
}
908+
894909
try {
895910
return rule.create(ruleContext);
896911
} catch (ex) {
@@ -1648,6 +1663,14 @@ class Linter {
16481663
mergedInlineConfig.rules[ruleId] = ruleValue;
16491664
} catch (err) {
16501665

1666+
/*
1667+
* If the rule has invalid `meta.schema`, throw the error because
1668+
* this is not an invalid inline configuration but an invalid rule.
1669+
*/
1670+
if (err.code === "ESLINT_INVALID_RULE_OPTIONS_SCHEMA") {
1671+
throw err;
1672+
}
1673+
16511674
let baseMessage = err.message.slice(
16521675
err.message.startsWith("Key \"rules\":")
16531676
? err.message.indexOf(":", 12) + 1
@@ -1941,17 +1964,17 @@ class Linter {
19411964
/**
19421965
* Defines a new linting rule.
19431966
* @param {string} ruleId A unique rule identifier
1944-
* @param {Function | Rule} ruleModule Function from context to object mapping AST node types to event handlers
1967+
* @param {Rule} rule A rule object
19451968
* @returns {void}
19461969
*/
1947-
defineRule(ruleId, ruleModule) {
1970+
defineRule(ruleId, rule) {
19481971
assertEslintrcConfig(this);
1949-
internalSlotsMap.get(this).ruleMap.define(ruleId, ruleModule);
1972+
internalSlotsMap.get(this).ruleMap.define(ruleId, rule);
19501973
}
19511974

19521975
/**
19531976
* Defines many new linting rules.
1954-
* @param {Record<string, Function | Rule>} rulesToDefine map from unique rule identifier to rule
1977+
* @param {Record<string, Rule>} rulesToDefine map from unique rule identifier to rule
19551978
* @returns {void}
19561979
*/
19571980
defineRules(rulesToDefine) {

lib/linter/rules.js

+6-15
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,10 @@
1313
const builtInRules = require("../rules");
1414

1515
//------------------------------------------------------------------------------
16-
// Helpers
16+
// Typedefs
1717
//------------------------------------------------------------------------------
1818

19-
/**
20-
* Normalizes a rule module to the new-style API
21-
* @param {(Function|{create: Function})} rule A rule object, which can either be a function
22-
* ("old-style") or an object with a `create` method ("new-style")
23-
* @returns {{create: Function}} A new-style rule.
24-
*/
25-
function normalizeRule(rule) {
26-
return typeof rule === "function" ? Object.assign({ create: rule }, rule) : rule;
27-
}
19+
/** @typedef {import("../shared/types").Rule} Rule */
2820

2921
//------------------------------------------------------------------------------
3022
// Public Interface
@@ -41,18 +33,17 @@ class Rules {
4133
/**
4234
* Registers a rule module for rule id in storage.
4335
* @param {string} ruleId Rule id (file name).
44-
* @param {Function} ruleModule Rule handler.
36+
* @param {Rule} rule Rule object.
4537
* @returns {void}
4638
*/
47-
define(ruleId, ruleModule) {
48-
this._rules[ruleId] = normalizeRule(ruleModule);
39+
define(ruleId, rule) {
40+
this._rules[ruleId] = rule;
4941
}
5042

5143
/**
5244
* Access rule handler by id (file name).
5345
* @param {string} ruleId Rule id (file name).
54-
* @returns {{create: Function, schema: JsonSchema[]}}
55-
* A rule. This is normalized to always have the new-style shape with a `create` method.
46+
* @returns {Rule} Rule object.
5647
*/
5748
get(ruleId) {
5849
if (typeof this._rules[ruleId] === "string") {

lib/rule-tester/flat-rule-tester.js

+41-5
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,14 @@ function throwForbiddenMethodError(methodName, prototype) {
317317
};
318318
}
319319

320+
const metaSchemaDescription = `
321+
\t- If the rule has options, set \`meta.schema\` to an array or non-empty object to enable options validation.
322+
\t- If the rule doesn't have options, omit \`meta.schema\` to enforce that no options can be passed to the rule.
323+
\t- You can also set \`meta.schema\` to \`false\` to opt-out of options validation (not recommended).
324+
325+
\thttps://eslint.org/docs/latest/extend/custom-rules#options-schemas
326+
`;
327+
320328
//------------------------------------------------------------------------------
321329
// Public Interface
322330
//------------------------------------------------------------------------------
@@ -490,13 +498,13 @@ class FlatRuleTester {
490498
/**
491499
* Adds a new rule test to execute.
492500
* @param {string} ruleName The name of the rule to run.
493-
* @param {Function | Rule} rule The rule to test.
501+
* @param {Rule} rule The rule to test.
494502
* @param {{
495503
* valid: (ValidTestCase | string)[],
496504
* invalid: InvalidTestCase[]
497505
* }} test The collection of tests to run.
498-
* @throws {TypeError|Error} If non-object `test`, or if a required
499-
* scenario of the given type is missing.
506+
* @throws {TypeError|Error} If `rule` is not an object with a `create` method,
507+
* or if non-object `test`, or if a required scenario of the given type is missing.
500508
* @returns {void}
501509
*/
502510
run(ruleName, rule, test) {
@@ -507,6 +515,10 @@ class FlatRuleTester {
507515
linter = this.linter,
508516
ruleId = `rule-to-test/${ruleName}`;
509517

518+
if (!rule || typeof rule !== "object" || typeof rule.create !== "function") {
519+
throw new TypeError("Rule must be an object with a `create` method");
520+
}
521+
510522
if (!test || typeof test !== "object") {
511523
throw new TypeError(`Test Scenarios for rule ${ruleName} : Could not find test scenario object`);
512524
}
@@ -560,7 +572,7 @@ class FlatRuleTester {
560572

561573
// freezeDeeply(context.languageOptions);
562574

563-
return (typeof rule === "function" ? rule : rule.create)(context);
575+
return rule.create(context);
564576
}
565577
})
566578
}
@@ -652,7 +664,31 @@ class FlatRuleTester {
652664
}
653665
});
654666

655-
const schema = getRuleOptionsSchema(rule);
667+
let schema;
668+
669+
try {
670+
schema = getRuleOptionsSchema(rule);
671+
} catch (err) {
672+
err.message += metaSchemaDescription;
673+
throw err;
674+
}
675+
676+
/*
677+
* Check and throw an error if the schema is an empty object (`schema:{}`), because such schema
678+
* doesn't validate or enforce anything and is therefore considered a possible error. If the intent
679+
* was to skip options validation, `schema:false` should be set instead (explicit opt-out).
680+
*
681+
* For this purpose, a schema object is considered empty if it doesn't have any own enumerable string-keyed
682+
* properties. While `ajv.compile()` does use enumerable properties from the prototype chain as well,
683+
* it caches compiled schemas by serializing only own enumerable properties, so it's generally not a good idea
684+
* to use inherited properties in schemas because schemas that differ only in inherited properties would end up
685+
* having the same cache entry that would be correct for only one of them.
686+
*
687+
* At this point, `schema` can only be an object or `null`.
688+
*/
689+
if (schema && Object.keys(schema).length === 0) {
690+
throw new Error(`\`schema: {}\` is a no-op${metaSchemaDescription}`);
691+
}
656692

657693
/*
658694
* Setup AST getters.

0 commit comments

Comments
 (0)