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: add meta.defaultOptions #17656

Draft
wants to merge 31 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9b70e84
[Reference] feat: add meta.defaultOptions
JoshuaKGoldberg Oct 16, 2023
90504b7
Removed optionsRaw
JoshuaKGoldberg Oct 19, 2023
7a61675
computed-property-spacing: defaultOptions
JoshuaKGoldberg Oct 26, 2023
13f6270
Merge branch 'main' into rule-meta-default-options
JoshuaKGoldberg Nov 21, 2023
e493311
fix: handle object type mismatches in merging
JoshuaKGoldberg Nov 21, 2023
1bb2568
Validate arrays in flat-config-array
JoshuaKGoldberg Nov 21, 2023
629f691
Fix rule defaultOptions typos
JoshuaKGoldberg Nov 21, 2023
6276b71
Put back getRuleOptions as before
JoshuaKGoldberg Nov 21, 2023
1f36e93
Apply deep merging in config-validator and rule-validator
JoshuaKGoldberg Nov 21, 2023
859594f
Merge branch 'main' into rule-meta-default-options
JoshuaKGoldberg Dec 16, 2023
ec021b5
Converted remaining rules. Note: inline comments still need to have d…
JoshuaKGoldberg Dec 18, 2023
f9b435f
Fixes around inline comments
JoshuaKGoldberg Dec 19, 2023
e04072f
Extract to a getRuleOptionsInline
JoshuaKGoldberg Dec 19, 2023
22b9fd6
Merge branch 'main' (with a few test failures)
JoshuaKGoldberg Jan 6, 2024
3e4f310
nit: new extra line
JoshuaKGoldberg Jan 6, 2024
f5e284d
Test fix: meta.defaultOptions in a comment
JoshuaKGoldberg Jan 6, 2024
2b2f99a
Refactor-level review feedback
JoshuaKGoldberg Jan 10, 2024
79d3eb0
Used a recommended rule in linter.js test
JoshuaKGoldberg Jan 10, 2024
efa307a
Added custom-rules.md docs
JoshuaKGoldberg Jan 10, 2024
de24526
Update docs/src/extend/custom-rules.md
JoshuaKGoldberg Jan 11, 2024
6b18f8a
Clarified undefined point
JoshuaKGoldberg Jan 11, 2024
7f3e808
Adjusted for edge cases per review
JoshuaKGoldberg Feb 2, 2024
753bf8d
Refactored per review
JoshuaKGoldberg Feb 2, 2024
9bc927b
Removed lint disable in source
JoshuaKGoldberg Feb 2, 2024
7d25a7b
Added Linter test for meta.defaultOptions
JoshuaKGoldberg Feb 2, 2024
ce75fa2
Documented useDefaults from Ajv
JoshuaKGoldberg Feb 2, 2024
6b9b8ac
Merge branch 'main'
JoshuaKGoldberg Feb 2, 2024
0dc4810
Set up meta+schema merging unit tests for flat (passing) and legacy (…
JoshuaKGoldberg Feb 15, 2024
adb7972
Potential solution: boolean applyDefaultOptions param for runRules
JoshuaKGoldberg Feb 15, 2024
2b8659b
Merge branch 'main'
JoshuaKGoldberg Mar 5, 2024
6657263
Merge branch 'main'
JoshuaKGoldberg Mar 23, 2024
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
47 changes: 47 additions & 0 deletions docs/src/extend/custom-rules.md
Expand Up @@ -60,6 +60,8 @@ The source file for a rule exports an object with the following properties. Both

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

* `defaultOptions`: (`array`) Specifies [default options](#option-defaults) for the rule. If present, any user-provided options in their config will be merged on top of them recursively.

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

* `replacedBy`: (`array`) In the case of a deprecated rule, specify replacement rule(s).
Expand Down Expand Up @@ -796,6 +798,51 @@ module.exports = {

To learn more about JSON Schema, we recommend looking at some examples on the [JSON Schema website](https://json-schema.org/learn/miscellaneous-examples), or reading the free [Understanding JSON Schema](https://json-schema.org/understanding-json-schema/) ebook.

#### Option Defaults
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

Rules may specify a `meta.defaultOptions` array of default values for any options.
When the rule is enabled in a user configuration, ESLint will recursively merge any user-provided option elements on top of the default elements.

For example, given the following defaults:

```js
export default {
meta: {
defaultOptions: [{
alias: "basic",
}],
schema: {
type: "object",
properties: {
alias: {
type: "string"
}
},
additionalProperties: false
}
},
create(context) {
const [{ alias }] = context.options;

return { /* ... */ };
}
}
```

The rule would have a runtime `alias` value of `"basic"` unless the user configuration specifies a different value, such as with `["error", { alias: "complex" }]`.

Each element of the options array is merged according to the following rules:

* Any missing value or explicit user-provided `undefined` will fall back to a default option
* User-provided arrays and primitive values other than `undefined` override a default option
* User-provided objects will merge into a default option object and replace a non-object default otherwise

Option defaults will also be validated against the rule's `meta.schema`.

**Note:** ESLint internally uses [Ajv](https://ajv.js.org) for schema validation with its [`useDefaults` option](https://ajv.js.org/guide/modifying-data.html#assigning-defaults) enabled.
Both user-provided and `meta.defaultOptions` options will override any defaults specified in a rule's schema.
ESLint may disable Ajv's `useDefaults` in a future major version.

### Accessing Shebangs

[Shebangs (#!)](https://en.wikipedia.org/wiki/Shebang_(Unix)) are represented by the unique tokens of type `"Shebang"`. They are treated as comments and can be accessed by the methods outlined in the [Accessing Comments](#accessing-comments) section, such as `sourceCode.getAllComments()`.
Expand Down
1 change: 1 addition & 0 deletions lib/config/flat-config-schema.js
Expand Up @@ -588,5 +588,6 @@ const flatConfigSchema = {

module.exports = {
flatConfigSchema,
assertIsRuleOptions,
assertIsRuleSeverity
};
10 changes: 9 additions & 1 deletion lib/config/rule-validator.js
Expand Up @@ -10,6 +10,7 @@
//-----------------------------------------------------------------------------

const ajvImport = require("../shared/ajv");
const { deepMergeArrays } = require("../shared/deep-merge-arrays");
const ajv = ajvImport();
const {
parseRuleId,
Expand Down Expand Up @@ -164,7 +165,10 @@ class RuleValidator {

if (validateRule) {

validateRule(ruleOptions.slice(1));
const slicedOptions = ruleOptions.slice(1);
const mergedOptions = deepMergeArrays(rule.meta?.defaultOptions, slicedOptions);
Comment on lines 166 to +169
Copy link
Member

Choose a reason for hiding this comment

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

Defaults options wouldn't be applied if the rule has schema: false?


validateRule(mergedOptions);

if (validateRule.errors) {
throw new Error(`Key "rules": Key "${ruleId}":\n${
Expand All @@ -186,6 +190,10 @@ class RuleValidator {
).join("")
}`);
}

if (mergedOptions.length) {
config.rules[ruleId] = [ruleOptions[0], ...mergedOptions];
}
}
}
}
Expand Down
50 changes: 41 additions & 9 deletions lib/linter/linter.js
Expand Up @@ -42,8 +42,9 @@ const
const { getRuleFromConfig } = require("../config/flat-config-helpers");
const { FlatConfigArray } = require("../config/flat-config-array");
const { RuleValidator } = require("../config/rule-validator");
const { assertIsRuleSeverity } = require("../config/flat-config-schema");
const { assertIsRuleOptions, assertIsRuleSeverity } = require("../config/flat-config-schema");
const { normalizeSeverityToString } = require("../shared/severity");
const { deepMergeArrays } = require("../shared/deep-merge-arrays");
const debug = require("debug")("eslint:linter");
const MAX_AUTOFIX_PASSES = 10;
const DEFAULT_PARSER_NAME = "espree";
Expand Down Expand Up @@ -828,14 +829,31 @@ function stripUnicodeBOM(text) {
/**
* Get the options for a rule (not including severity), if any
* @param {Array|number} ruleConfig rule configuration
* @param {Object|undefined} defaultOptions rule.meta.defaultOptions
* @returns {Array} of rule options, empty Array if none
*/
function getRuleOptions(ruleConfig) {
function getRuleOptions(ruleConfig, defaultOptions) {
if (Array.isArray(ruleConfig)) {
return ruleConfig.slice(1);
return deepMergeArrays(defaultOptions, ruleConfig.slice(1));
Copy link
Member

Choose a reason for hiding this comment

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

In flat config case, defaultOptions have already been merged during the validation so this would merge them again?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Aha! Yes, you're right. And there's an additional bug, I think, that default values from schemas aren't being consistently applied in the branch right now. 0dc4810 has a passing unit test for flat config and a failing unit test for legacy config.

1) Linter
       options
         rules should apply meta.defaultOptions on top of schema defaults:

      AssertionError: expected { Object (inBoth, inDefaultOptions) } to deeply equal { Object (inBoth, inDefaultOptions, ...) }
      + expected - actual

       {
         "inBoth": "from-default-options"
         "inDefaultOptions": "from-default-options"
      +  "inSchema": "from-schema"
       }

I think what needs to happen is moving the validation so it happens once for each config, and with both meta-based and schema-based edit: only meta-based default options applied. Working on that now.


Adding my notes as I found the code flow hard to keep track of without them:

  • Linter takes in a configType of type "eslintrc" | "flat"
    • For "eslintrc", it calls this._verifyWithConfigArray -> ... -> _verifyWithoutProcessors -> runRules
    • For "flat", it calls this._verifyWithFlatConfigArray -> ... -> _verifyWithFlatConfigArrayAndWithoutProcessors -> runRules
      • In _verifyWithFlatConfigArrayAndWithoutProcessors, const ruleValidator = new RuleValidator() calls to a validate() method. That uses a this.validators to build validators from ajv.compile(schema). This applies schema default options.
      • Later in validate(), deepMergeArrays is called with rule.meta?.defaultOptions. This applies meta default options.
  • runRules calls getRuleOptions(configuredRules[ruleId], rule.meta?.defaultOptions). This applies meta default options.

With both flat and legacy configs, rule validation happens inside runRules.

With flat configs, rule validation additionally happens before runRules. This earlier validation is applied to the result of merging both meta and schema default options.

With legacy configs, only the runRules validation is applied. That's why it doesn't apply to schema default options.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, sorry, I'm thoroughly confused and think I need help here. On main, Linter's unit tests never apply schema defaults - neither for flat nor legacy config. JoshuaKGoldberg@9ccaa85 shows a passing unit test per config type showing schema defaults not being applied.

In the flat config code path, Linter calls to ruleValidator.validate(...). That means Ajv's options validation and schema defaulting applies there as well as in flat-config-array.js.

So now I'm unsure what the intended boundaries are supposed to be. Do we want schema defaulting to be moved to happen inside Linter now? Is that a breaking change?

Copy link
Member

Choose a reason for hiding this comment

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

Just to clarify what happens with schema defaults.

Schema defaults in the top-level array never apply. That is, I believe, an unintentional omission (i.e., a bug) in the code that handles provided options. We could have considered fixing that for v9 if we hadn't decided to switch to defaultOptions and eventually drop schema defaults. So, in JoshuaKGoldberg@9ccaa85, default: { inSchema: "from-schema-root" } is a no-op. On the other hand, inSchema: { default: "from-schema", type: "string" } can apply, but you'd need to pass "my-rule": ["error", {}]. This should work in config files, both eslintrc and flat config. This should also work when Linter is used directly in flat config mode. But it won't work when Linter is used directly in eslintrc mode, because in eslintrc mode Linter doesn't validate rule options, which is probably another unintentional omission. I'm not sure if it's worth fixing that at this point, because eslintrc mode is deprecated and will be dropped in v10.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got it, thanks - that's very helpful.

should work in config files, both eslintrc and flat config
won't work when Linter is used directly in eslintrc mode

Just confirming then - is the behavior in the latest commit, adb7972, an acceptable one for v9? That its Linter unit test for flat config applies on top of schema defaults, while its unit test for legacy config ignores schema defaults?

Copy link
Member

Choose a reason for hiding this comment

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

I think if we are maintaining the current eslintrc behavior, then we are okay. @mdjermanovic?

Copy link
Member

Choose a reason for hiding this comment

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

}
return [];
return defaultOptions ?? [];
}

/**
* Get the options for a rule's inline comment, including severity
* @param {string} ruleId Rule name being configured.
* @param {Array|number} ruleValue rule severity and options, if any
* @param {Object|undefined} defaultOptions rule.meta.defaultOptions
* @returns {Array} of rule options, empty Array if none
*/
function getRuleOptionsInline(ruleId, ruleValue, defaultOptions) {
assertIsRuleOptions(ruleId, ruleValue);

const [ruleSeverity, ...ruleConfig] = Array.isArray(ruleValue) ? ruleValue : [ruleValue];

assertIsRuleSeverity(ruleId, ruleSeverity);

return [ruleSeverity, ...deepMergeArrays(defaultOptions, ruleConfig)];
}

/**
Expand Down Expand Up @@ -982,14 +1000,28 @@ function createRuleListeners(rule, ruleContext) {
* @param {LanguageOptions} languageOptions The options for parsing the code.
* @param {Object} settings The settings that were enabled in the config
* @param {string} filename The reported filename of the code
* @param {boolean} applyDefaultOptions If true, apply rules' meta.defaultOptions in computing their config options.
* @param {boolean} disableFixes If true, it doesn't make `fix` properties.
* @param {string | undefined} cwd cwd of the cli
* @param {string} physicalFilename The full path of the file on disk without any code block information
* @param {Function} ruleFilter A predicate function to filter which rules should be executed.
* @returns {LintMessage[]} An array of reported problems
* @throws {Error} If traversal into a node fails.
*/
function runRules(sourceCode, configuredRules, ruleMapper, parserName, languageOptions, settings, filename, disableFixes, cwd, physicalFilename, ruleFilter) {
function runRules(
sourceCode,
configuredRules,
ruleMapper,
parserName,
languageOptions,
settings,
filename,
applyDefaultOptions,
disableFixes,
cwd,
physicalFilename,
ruleFilter
) {
const emitter = createEmitter();

// must happen first to assign all node.parent properties
Expand Down Expand Up @@ -1047,7 +1079,7 @@ function runRules(sourceCode, configuredRules, ruleMapper, parserName, languageO
Object.create(sharedTraversalContext),
{
id: ruleId,
options: getRuleOptions(configuredRules[ruleId]),
options: getRuleOptions(configuredRules[ruleId], applyDefaultOptions && rule.meta?.defaultOptions),
report(...args) {

/*
Expand Down Expand Up @@ -1395,6 +1427,7 @@ class Linter {
languageOptions,
settings,
options.filename,
true,
options.disableFixes,
slots.cwd,
providedOptions.physicalFilename,
Expand Down Expand Up @@ -1723,9 +1756,7 @@ class Linter {

try {

let ruleOptions = Array.isArray(ruleValue) ? ruleValue : [ruleValue];

assertIsRuleSeverity(ruleId, ruleOptions[0]);
let ruleOptions = getRuleOptionsInline(ruleId, ruleValue, rule.meta?.defaultOptions);
Comment on lines -1726 to +1759
Copy link
Member

Choose a reason for hiding this comment

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

Merging default options at this point would cause a bug when the configuration comment just overrides severity (https://eslint.org/docs/next/use/migrate-to-9.0.0#eslint-comment-options) because for the rest of the code below it would look like the comment had options specified.

We could let ruleValidator.validate() below merge default options, and then get the final rule config from the config that was passed to it?


/*
* If the rule was already configured, inline rule configuration that
Expand Down Expand Up @@ -1838,6 +1869,7 @@ class Linter {
languageOptions,
settings,
options.filename,
false,
options.disableFixes,
slots.cwd,
providedOptions.physicalFilename,
Expand Down
17 changes: 10 additions & 7 deletions lib/rules/accessor-pairs.js
Expand Up @@ -139,6 +139,12 @@ module.exports = {
meta: {
type: "suggestion",

defaultOptions: [{
enforceForClassMembers: true,
getWithoutSet: false,
setWithoutGet: true
}],

docs: {
description: "Enforce getter and setter pairs in objects and classes",
recommended: false,
Expand All @@ -149,16 +155,13 @@ module.exports = {
type: "object",
properties: {
getWithoutSet: {
type: "boolean",
default: false
type: "boolean"
},
setWithoutGet: {
type: "boolean",
default: true
type: "boolean"
},
enforceForClassMembers: {
type: "boolean",
default: true
type: "boolean"
}
},
additionalProperties: false
Expand All @@ -174,7 +177,7 @@ module.exports = {
}
},
create(context) {
const config = context.options[0] || {};
const [config] = context.options;
const checkGetWithoutSet = config.getWithoutSet === true;
const checkSetWithoutGet = config.setWithoutGet !== false;
const enforceForClassMembers = config.enforceForClassMembers !== false;
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/array-bracket-spacing.js
Expand Up @@ -18,6 +18,8 @@ module.exports = {
replacedBy: [],
type: "layout",

defaultOptions: ["never"],

docs: {
description: "Enforce consistent spacing inside array brackets",
recommended: false,
Expand Down
18 changes: 10 additions & 8 deletions lib/rules/array-callback-return.js
Expand Up @@ -215,6 +215,12 @@ module.exports = {
meta: {
type: "problem",

defaultOptions: [{
allowImplicit: false,
checkForEach: false,
allowVoid: false
}],

docs: {
description: "Enforce `return` statements in callbacks of array methods",
recommended: false,
Expand All @@ -229,16 +235,13 @@ module.exports = {
type: "object",
properties: {
allowImplicit: {
type: "boolean",
default: false
type: "boolean"
},
checkForEach: {
type: "boolean",
default: false
type: "boolean"
},
allowVoid: {
type: "boolean",
default: false
type: "boolean"
}
},
additionalProperties: false
Expand All @@ -256,8 +259,7 @@ module.exports = {
},

create(context) {

const options = context.options[0] || { allowImplicit: false, checkForEach: false, allowVoid: false };
const [options] = context.options;
const sourceCode = context.sourceCode;

let funcInfo = {
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/array-element-newline.js
Expand Up @@ -19,6 +19,8 @@ module.exports = {
replacedBy: [],
type: "layout",

defaultOptions: ["always"],

docs: {
description: "Enforce line breaks after each array element",
recommended: false,
Expand Down
4 changes: 3 additions & 1 deletion lib/rules/arrow-body-style.js
Expand Up @@ -19,6 +19,8 @@ module.exports = {
meta: {
type: "suggestion",

defaultOptions: ["as-needed"],

docs: {
description: "Require braces around arrow function bodies",
recommended: false,
Expand Down Expand Up @@ -71,7 +73,7 @@ module.exports = {
create(context) {
const options = context.options;
const always = options[0] === "always";
const asNeeded = !options[0] || options[0] === "as-needed";
const asNeeded = options[0] === "as-needed";
const never = options[0] === "never";
const requireReturnForObjectLiteral = options[1] && options[1].requireReturnForObjectLiteral;
const sourceCode = context.sourceCode;
Expand Down
5 changes: 3 additions & 2 deletions lib/rules/arrow-parens.js
Expand Up @@ -35,6 +35,8 @@ module.exports = {
replacedBy: [],
type: "layout",

defaultOptions: ["always"],

docs: {
description: "Require parentheses around arrow function arguments",
recommended: false,
Expand All @@ -51,8 +53,7 @@ module.exports = {
type: "object",
properties: {
requireForBlockBody: {
type: "boolean",
default: false
type: "boolean"
}
},
additionalProperties: false
Expand Down