Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Set default schema: [], drop support for function-style rules #17792

Merged
merged 21 commits into from Dec 27, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9d924e3
drop support for function-style rules in flat-rule-tester
mdjermanovic Nov 22, 2023
c7a0b42
drop support for function-style rules in rule-tester
mdjermanovic Nov 22, 2023
0964578
drop support for function-style rules in config-rule (eslint-fuzzer)
mdjermanovic Nov 22, 2023
b37aeb4
add rule object checks to rule testers
mdjermanovic Nov 24, 2023
1658e6f
drop support for function-style rules in flat config and Linter#defin…
mdjermanovic Nov 25, 2023
d868180
update JSDoc
mdjermanovic Nov 25, 2023
47c060b
add rule object checks to Linter
mdjermanovic Nov 25, 2023
cd2caa0
drop support for function-style rules in eslintrc
mdjermanovic Nov 25, 2023
3cc25bb
remove custom-rules-deprecated.md
mdjermanovic Nov 25, 2023
f7c4f8d
update flat config getRuleOptionsSchema for schema changes
mdjermanovic Nov 25, 2023
1671198
add back and update custom-rules-deprecated.md
mdjermanovic Nov 27, 2023
e3ba846
show ruleId in error messages for invalid schema
mdjermanovic Nov 28, 2023
91fe762
throw error in Linter if rule has invalid schema
mdjermanovic Nov 28, 2023
5d6f96f
add short description of meta.schema to type errors in flat-rule-tester
mdjermanovic Nov 28, 2023
b3fa339
throw error for empty object schema in flat-rule-tester
mdjermanovic Nov 28, 2023
dabe3ef
update rule-tester for schema changes
mdjermanovic Nov 29, 2023
e078476
add integration tests for eslintrc schema changes
mdjermanovic Nov 29, 2023
78145ec
add more flat-config-array unit tests for schema changes
mdjermanovic Nov 29, 2023
d268d81
throw error in Linter in eslintrc mode if rule has invalid schema
mdjermanovic Nov 30, 2023
a92d4ab
update docs for schema changes
mdjermanovic Nov 30, 2023
27b4448
use eslintrc v3
mdjermanovic Dec 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
576 changes: 2 additions & 574 deletions docs/src/extend/custom-rules-deprecated.md
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, should we avoid deleting pages to avoid breaking links, and just add a placeholder page linking to the replacement page?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I've added back and updated this document in d14e2b5.

Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we also could do a redirect like this: eslint/eslint.org#499

Discussion here: #17840 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we also could do a redirect like this: eslint/eslint.org#499

Discussion here: #17840 (comment)

Maybe, but this is a slightly different situation so I'm not sure.

For configuration-files-new, its content is moved to configuration-files, so redirecting makes the most sense.

For custom-rules-deprecated, its content is not moved to custom-rules but deleted, so info that it no longer applies in ESLint 9+ might be more helpful.

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions docs/src/extend/custom-rules.md
Expand Up @@ -10,8 +10,6 @@ eleventyNavigation:

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.

**Note:** This page covers the most recent rule format for ESLint >= 3.0.0. There is also a [deprecated rule format](./custom-rules-deprecated).

Here's the basic format of a custom rule:

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

**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.

* `schema`: (`object | array`) Specifies the [options](#options-schemas) so ESLint can prevent invalid [rule configurations](../use/configure/rules).
* `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.

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

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

```js
module.exports = {
meta: {
schema: [
{
enum: ["single", "double", "backtick"]
}
]
},
create: function(context) {
var isDouble = (context.options[0] === "double");

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

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

Rules with options must specify a [schema](#options-schemas).

### Accessing the Source Code

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:
Expand Down Expand Up @@ -612,9 +619,11 @@ You can also access comments through many of `sourceCode`'s methods using the `i

### Options Schemas

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`.
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`.

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.

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.
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.

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

Expand Down
61 changes: 41 additions & 20 deletions lib/config/flat-config-helpers.js
Expand Up @@ -5,6 +5,23 @@

"use strict";

//------------------------------------------------------------------------------
// Typedefs
//------------------------------------------------------------------------------

/** @typedef {import("../shared/types").Rule} Rule */

//------------------------------------------------------------------------------
// Private Members
//------------------------------------------------------------------------------

// JSON schema that disallows passing any options
const noOptionsSchema = Object.freeze({
type: "array",
minItems: 0,
maxItems: 0
});

//-----------------------------------------------------------------------------
// Functions
//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -52,32 +69,39 @@ function getRuleFromConfig(ruleId, config) {
const { pluginName, ruleName } = parseRuleId(ruleId);

const plugin = config.plugins && config.plugins[pluginName];
let rule = plugin && plugin.rules && plugin.rules[ruleName];


// normalize function rules into objects
if (rule && typeof rule === "function") {
rule = {
create: rule
};
}
const rule = plugin && plugin.rules && plugin.rules[ruleName];

return rule;
}

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

if (!rule) {
if (!rule.meta) {
return { ...noOptionsSchema }; // default if `meta.schema` is not specified
}

const schema = rule.meta.schema;

if (typeof schema === "undefined") {
return { ...noOptionsSchema }; // default if `meta.schema` is not specified
}

// `schema:false` is an allowed explicit opt-out of options validation for the rule
if (schema === false) {
return null;
}

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

// ESLint-specific array form needs to be converted into a valid JSON Schema definition
if (Array.isArray(schema)) {
if (schema.length) {
return {
Expand All @@ -87,16 +111,13 @@ function getRuleOptionsSchema(rule) {
maxItems: schema.length
};
}
return {
type: "array",
minItems: 0,
maxItems: 0
};

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

// Given a full schema, leave it alone
return schema || null;
// `schema:<object>` is assumed to be a valid JSON Schema definition
return schema;
}


Expand Down
31 changes: 27 additions & 4 deletions lib/config/rule-validator.js
Expand Up @@ -66,6 +66,25 @@ function throwRuleNotFoundError({ pluginName, ruleName }, config) {
throw new TypeError(errorMessage);
}

/**
* The error type when a rule has an invalid `meta.schema`.
*/
class InvalidRuleOptionsSchemaError extends Error {

/**
* Creates a new instance.
* @param {string} ruleId Id of the rule that has an invalid `meta.schema`.
* @param {Error} processingError Error caught while processing the `meta.schema`.
*/
constructor(ruleId, processingError) {
super(
`Error while processing options validation schema of rule '${ruleId}': ${processingError.message}`,
{ cause: processingError }
);
this.code = "ESLINT_INVALID_RULE_OPTIONS_SCHEMA";
}
}

//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -130,10 +149,14 @@ class RuleValidator {

// Precompile and cache validator the first time
if (!this.validators.has(rule)) {
const schema = getRuleOptionsSchema(rule);

if (schema) {
this.validators.set(rule, ajv.compile(schema));
try {
const schema = getRuleOptionsSchema(rule);

if (schema) {
this.validators.set(rule, ajv.compile(schema));
}
} catch (err) {
throw new InvalidRuleOptionsSchemaError(ruleId, err);
}
}

Expand Down
33 changes: 28 additions & 5 deletions lib/linter/linter.js
Expand Up @@ -439,6 +439,15 @@ function getDirectiveComments(sourceCode, ruleMapper, warnInlineConfig) {
try {
validator.validateRuleOptions(rule, name, ruleValue);
} catch (err) {

/*
* If the rule has invalid `meta.schema`, throw the error because
* this is not an invalid inline configuration but an invalid rule.
*/
if (err.code === "ESLINT_INVALID_RULE_OPTIONS_SCHEMA") {
throw err;
}

problems.push(createLintingProblem({
ruleId: name,
message: err.message,
Expand Down Expand Up @@ -885,12 +894,18 @@ function parse(text, languageOptions, filePath) {

/**
* Runs a rule, and gets its listeners
* @param {Rule} rule A normalized rule with a `create` method
* @param {Rule} rule A rule object
* @param {Context} ruleContext The context that should be passed to the rule
* @throws {TypeError} If `rule` is not an object with a `create` method
* @throws {any} Any error during the rule's `create`
* @returns {Object} A map of selector listeners provided by the rule
*/
function createRuleListeners(rule, ruleContext) {

if (!rule || typeof rule !== "object" || typeof rule.create !== "function") {
throw new TypeError(`Error while loading rule '${ruleContext.id}': Rule must be an object with a \`create\` method`);
}

try {
return rule.create(ruleContext);
} catch (ex) {
Expand Down Expand Up @@ -1648,6 +1663,14 @@ class Linter {
mergedInlineConfig.rules[ruleId] = ruleValue;
} catch (err) {

/*
* If the rule has invalid `meta.schema`, throw the error because
* this is not an invalid inline configuration but an invalid rule.
*/
if (err.code === "ESLINT_INVALID_RULE_OPTIONS_SCHEMA") {
throw err;
}

let baseMessage = err.message.slice(
err.message.startsWith("Key \"rules\":")
? err.message.indexOf(":", 12) + 1
Expand Down Expand Up @@ -1941,17 +1964,17 @@ class Linter {
/**
* Defines a new linting rule.
* @param {string} ruleId A unique rule identifier
* @param {Function | Rule} ruleModule Function from context to object mapping AST node types to event handlers
* @param {Rule} rule A rule object
* @returns {void}
*/
defineRule(ruleId, ruleModule) {
defineRule(ruleId, rule) {
assertEslintrcConfig(this);
internalSlotsMap.get(this).ruleMap.define(ruleId, ruleModule);
internalSlotsMap.get(this).ruleMap.define(ruleId, rule);
}

/**
* Defines many new linting rules.
* @param {Record<string, Function | Rule>} rulesToDefine map from unique rule identifier to rule
* @param {Record<string, Rule>} rulesToDefine map from unique rule identifier to rule
* @returns {void}
*/
defineRules(rulesToDefine) {
Expand Down
21 changes: 6 additions & 15 deletions lib/linter/rules.js
Expand Up @@ -13,18 +13,10 @@
const builtInRules = require("../rules");

//------------------------------------------------------------------------------
// Helpers
// Typedefs
//------------------------------------------------------------------------------

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

//------------------------------------------------------------------------------
// Public Interface
Expand All @@ -41,18 +33,17 @@ class Rules {
/**
* Registers a rule module for rule id in storage.
* @param {string} ruleId Rule id (file name).
* @param {Function} ruleModule Rule handler.
* @param {Rule} rule Rule object.
* @returns {void}
*/
define(ruleId, ruleModule) {
this._rules[ruleId] = normalizeRule(ruleModule);
define(ruleId, rule) {
this._rules[ruleId] = rule;
}

/**
* Access rule handler by id (file name).
* @param {string} ruleId Rule id (file name).
* @returns {{create: Function, schema: JsonSchema[]}}
* A rule. This is normalized to always have the new-style shape with a `create` method.
* @returns {Rule} Rule object.
*/
get(ruleId) {
if (typeof this._rules[ruleId] === "string") {
Expand Down
46 changes: 41 additions & 5 deletions lib/rule-tester/flat-rule-tester.js
Expand Up @@ -317,6 +317,14 @@ function throwForbiddenMethodError(methodName, prototype) {
};
}

const metaSchemaDescription = `
\t- If the rule has options, set \`meta.schema\` to an array or non-empty object to enable options validation.
\t- If the rule doesn't have options, omit \`meta.schema\` to enforce that no options can be passed to the rule.
\t- You can also set \`meta.schema\` to \`false\` to opt-out of options validation (not recommended).

\thttps://eslint.org/docs/latest/extend/custom-rules#options-schemas
`;

//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
Expand Down Expand Up @@ -490,13 +498,13 @@ class FlatRuleTester {
/**
* Adds a new rule test to execute.
* @param {string} ruleName The name of the rule to run.
* @param {Function | Rule} rule The rule to test.
* @param {Rule} rule The rule to test.
* @param {{
* valid: (ValidTestCase | string)[],
* invalid: InvalidTestCase[]
* }} test The collection of tests to run.
* @throws {TypeError|Error} If non-object `test`, or if a required
* scenario of the given type is missing.
* @throws {TypeError|Error} If `rule` is not an object with a `create` method,
* or if non-object `test`, or if a required scenario of the given type is missing.
* @returns {void}
*/
run(ruleName, rule, test) {
Expand All @@ -507,6 +515,10 @@ class FlatRuleTester {
linter = this.linter,
ruleId = `rule-to-test/${ruleName}`;

if (!rule || typeof rule !== "object" || typeof rule.create !== "function") {
throw new TypeError("Rule must be an object with a `create` method");
}

if (!test || typeof test !== "object") {
throw new TypeError(`Test Scenarios for rule ${ruleName} : Could not find test scenario object`);
}
Expand Down Expand Up @@ -560,7 +572,7 @@ class FlatRuleTester {

// freezeDeeply(context.languageOptions);

return (typeof rule === "function" ? rule : rule.create)(context);
return rule.create(context);
}
})
}
Expand Down Expand Up @@ -652,7 +664,31 @@ class FlatRuleTester {
}
});

const schema = getRuleOptionsSchema(rule);
let schema;

try {
schema = getRuleOptionsSchema(rule);
} catch (err) {
err.message += metaSchemaDescription;
throw err;
}

/*
* Check and throw an error if the schema is an empty object (`schema:{}`), because such schema
* doesn't validate or enforce anything and is therefore considered a possible error. If the intent
* was to skip options validation, `schema:false` should be set instead (explicit opt-out).
*
* For this purpose, a schema object is considered empty if it doesn't have any own enumerable string-keyed
* properties. While `ajv.compile()` does use enumerable properties from the prototype chain as well,
* it caches compiled schemas by serializing only own enumerable properties, so it's generally not a good idea
* to use inherited properties in schemas because schemas that differ only in inherited properties would end up
* having the same cache entry that would be correct for only one of them.
*
* At this point, `schema` can only be an object or `null`.
*/
if (schema && Object.keys(schema).length === 0) {
throw new Error(`\`schema: {}\` is a no-op${metaSchemaDescription}`);
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disallowing schema: {} is most important since it's the most common, accidental, no-op schema.

I'm fine with this one check for now, but just want to ask if any other obvious/common no-op schemas come to mind? In the RFC, I mentioned schema: { type: "array" } as another no-op schema we could disallow, with the intention being to leave the door open to any others that we judge worth checking for. Obviously, we can only add update the banned no-op list during major versions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a related discussion in ajv repo? it could benefit all the dependents (not just eslint) - if it could be supported by ajv.

Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a brief search and didn't immediately find a relevant discussion in in ajv. But I have included a lot more context in this issue discussing a potential lint rule:

Overall, we should probably just allow any schema in ESLint except extremely common and obvious mistakes like {}, and consider linting to detect further possible problems.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Newer versions of Ajv have some built-in validations, but there were backwards compatibility issues when we tried to switch to Ajv 7+ (#13911). However, I believe schema: { type: "array" } would be considered valid by Ajv. It's just useless in ESLint because we're always passing arrays to these validations.

I agree that for now RuleTester should only check for the common and obvious mistake schema: {}.

}

/*
* Setup AST getters.
Expand Down