Skip to content

Commit

Permalink
feat: Add new require-meta-default-options rule (#502)
Browse files Browse the repository at this point in the history
* Add new `require-meta-default-options` rule

* Add `defaultOptions` to all rules

* Improve rule description

* Allow array root schemas to have empty `defaultOptions`
  • Loading branch information
FloEdelmann authored Dec 18, 2024
1 parent 334aec3 commit 13e625a
Show file tree
Hide file tree
Showing 15 changed files with 436 additions and 23 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ module.exports = [
| [prefer-placeholders](docs/rules/prefer-placeholders.md) | require using placeholders for dynamic report messages | | | | |
| [prefer-replace-text](docs/rules/prefer-replace-text.md) | require using `replaceText()` instead of `replaceTextRange()` | | | | |
| [report-message-format](docs/rules/report-message-format.md) | enforce a consistent format for rule report messages | | | | |
| [require-meta-default-options](docs/rules/require-meta-default-options.md) | require only rules with options to implement a `meta.defaultOptions` property | | 🔧 | | |
| [require-meta-docs-description](docs/rules/require-meta-docs-description.md) | require rules to implement a `meta.docs.description` property with the correct format | | | | |
| [require-meta-docs-recommended](docs/rules/require-meta-docs-recommended.md) | require rules to implement a `meta.docs.recommended` property | | | 💡 | |
| [require-meta-docs-url](docs/rules/require-meta-docs-url.md) | require rules to implement a `meta.docs.url` property | | 🔧 | | |
Expand Down
108 changes: 108 additions & 0 deletions docs/rules/require-meta-default-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Require only rules with options to implement a `meta.defaultOptions` property (`eslint-plugin/require-meta-default-options`)

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

Defining default options declaratively in a rule's `meta.defaultOptions` property enables ESLint v9.15.0+ to merge any user-provided options with the default options, simplifying the rule's implementation. It can also be useful for other tools like [eslint-doc-generator](https://github.com/bmish/eslint-doc-generator) to generate documentation for the rule's options.

## Rule Details

This rule requires ESLint rules to have a valid `meta.defaultOptions` property if and only if the rule has options defined in its `meta.schema` property.

Examples of **incorrect** code for this rule:

```js
/* eslint eslint-plugin/require-meta-default-options: error */

module.exports = {
meta: {
schema: [
{
type: 'object',
/* ... */
},
],
// defaultOptions is missing
},
create(context) {
/* ... */
},
};

module.exports = {
meta: {
schema: [],
defaultOptions: [{}], // defaultOptions is not needed when schema is empty
},
create(context) {
/* ... */
},
};

module.exports = {
meta: {
schema: [
{
/* ... */
},
],
defaultOptions: {}, // defaultOptions should be an array
},
create(context) {
/* ... */
},
};

module.exports = {
meta: {
schema: [
{
/* ... */
},
],
defaultOptions: [], // defaultOptions should not be empty
},
create(context) {
/* ... */
},
};
```

Examples of **correct** code for this rule:

```js
/* eslint eslint-plugin/require-meta-default-options: error */

module.exports = {
meta: { schema: [] }, // no defaultOptions needed when schema is empty
create(context) {
/* ... */
},
};

module.exports = {
meta: {
schema: [
{
type: 'object',
properties: {
exceptRange: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
defaultOptions: [{ exceptRange: false }],
},
create(context) {
/* ... */
},
};
```

## Further Reading

- [ESLint rule docs: Option Defaults](https://eslint.org/docs/latest/extend/custom-rules#option-defaults)
- [RFC introducing `meta.defaultOptions`](https://github.com/eslint/rfcs/blob/main/designs/2023-rule-options-defaults/README.md)
1 change: 1 addition & 0 deletions lib/rules/consistent-output.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module.exports = {
default: 'consistent',
},
],
defaultOptions: ['consistent'],
messages: {
missingOutput: 'This test case should have an output assertion.',
},
Expand Down
25 changes: 14 additions & 11 deletions lib/rules/meta-property-ordering.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@

const { getKeyName, getRuleInfo } = require('../utils');

const defaultOrder = [
'type',
'docs',
'fixable',
'hasSuggestions',
'deprecated',
'replacedBy',
'schema',
'defaultOptions', // https://github.com/eslint/rfcs/tree/main/designs/2023-rule-options-defaults
'messages',
];

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
Expand All @@ -28,6 +40,7 @@ module.exports = {
elements: { type: 'string' },
},
],
defaultOptions: [defaultOrder],
messages: {
inconsistentOrder:
'The meta properties should be placed in a consistent order: [{{order}}].',
Expand All @@ -41,17 +54,7 @@ module.exports = {
return {};
}

const order = context.options[0] || [
'type',
'docs',
'fixable',
'hasSuggestions',
'deprecated',
'replacedBy',
'schema',
'defaultOptions', // https://github.com/eslint/rfcs/tree/main/designs/2023-rule-options-defaults
'messages',
];
const order = context.options[0] || defaultOrder;

const orderMap = new Map(order.map((name, i) => [name, i]));

Expand Down
1 change: 1 addition & 0 deletions lib/rules/no-property-in-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ module.exports = {
additionalProperties: false,
},
],
defaultOptions: [{ additionalNodeTypeFiles: [] }],
messages: {
in: 'Prefer checking specific node properties instead of a broad `in`.',
},
Expand Down
1 change: 1 addition & 0 deletions lib/rules/report-message-format.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module.exports = {
type: 'string',
},
],
defaultOptions: [''],
messages: {
noMatch: "Report message does not match the pattern '{{pattern}}'.",
},
Expand Down
104 changes: 104 additions & 0 deletions lib/rules/require-meta-default-options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use strict';

const utils = require('../utils');

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'require only rules with options to implement a `meta.defaultOptions` property',
category: 'Rules',
recommended: false,
url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/require-meta-default-options.md',
},
fixable: 'code',
schema: [],
messages: {
missingDefaultOptions:
'Rule with non-empty schema is missing a `meta.defaultOptions` property.',
unnecessaryDefaultOptions:
'Rule with empty schema should not have a `meta.defaultOptions` property.',
defaultOptionsMustBeArray: 'Default options must be an array.',
defaultOptionsMustNotBeEmpty: 'Default options must not be empty.',
},
},

create(context) {
const sourceCode = context.sourceCode || context.getSourceCode(); // TODO: just use context.sourceCode when dropping eslint < v9
const { scopeManager } = sourceCode;
const ruleInfo = utils.getRuleInfo(sourceCode);
if (!ruleInfo) {
return {};
}

const metaNode = ruleInfo.meta;

const schemaNode = utils.getMetaSchemaNode(metaNode, scopeManager);
const schemaProperty = utils.getMetaSchemaNodeProperty(
schemaNode,
scopeManager,
);
if (!schemaProperty) {
return {};
}

const metaDefaultOptions = utils
.evaluateObjectProperties(metaNode, scopeManager)
.find(
(p) =>
p.type === 'Property' && utils.getKeyName(p) === 'defaultOptions',
);

if (
schemaProperty.type === 'ArrayExpression' &&
schemaProperty.elements.length === 0
) {
if (metaDefaultOptions) {
context.report({
node: metaDefaultOptions,
messageId: 'unnecessaryDefaultOptions',
fix(fixer) {
return fixer.remove(metaDefaultOptions);
},
});
}
return {};
}

if (!metaDefaultOptions) {
context.report({
node: metaNode,
messageId: 'missingDefaultOptions',
fix(fixer) {
return fixer.insertTextAfter(schemaProperty, ', defaultOptions: []');
},
});
return {};
}

if (metaDefaultOptions.value.type !== 'ArrayExpression') {
context.report({
node: metaDefaultOptions.value,
messageId: 'defaultOptionsMustBeArray',
});
return {};
}

const isArrayRootSchema =
schemaProperty.type === 'ObjectExpression' &&
schemaProperty.properties.find((property) => property.key.name === 'type')
?.value.value === 'array';

if (metaDefaultOptions.value.elements.length === 0 && !isArrayRootSchema) {
context.report({
node: metaDefaultOptions.value,
messageId: 'defaultOptionsMustNotBeEmpty',
});
return {};
}

return {};
},
};
1 change: 1 addition & 0 deletions lib/rules/require-meta-docs-description.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
additionalProperties: false,
},
],
defaultOptions: [{ pattern: '^(enforce|require|disallow)' }],
messages: {
extraWhitespace:
'`meta.docs.description` must not have leading nor trailing whitespace.',
Expand Down
1 change: 1 addition & 0 deletions lib/rules/require-meta-docs-recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ module.exports = {
additionalProperties: false,
},
],
defaultOptions: [{ allowNonBoolean: false }],
messages: {
incorrect: '`meta.docs.recommended` is required to be a boolean.',
missing: '`meta.docs.recommended` is required.',
Expand Down
1 change: 1 addition & 0 deletions lib/rules/require-meta-docs-url.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ module.exports = {
additionalProperties: false,
},
],
defaultOptions: [{}],
messages: {
mismatch: '`meta.docs.url` property must be `{{expectedUrl}}`.',
missing: '`meta.docs.url` property is missing.',
Expand Down
1 change: 1 addition & 0 deletions lib/rules/require-meta-fixable.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ module.exports = {
additionalProperties: false,
},
],
defaultOptions: [{ catchNoFixerButFixableProperty: false }],
messages: {
invalid: '`meta.fixable` must be either `code`, `whitespace`, or `null`.',
missing:
Expand Down
1 change: 1 addition & 0 deletions lib/rules/require-meta-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ module.exports = {
additionalProperties: false,
},
],
defaultOptions: [{ requireSchemaPropertyWhenOptionless: true }],
messages: {
addEmptySchema: 'Add empty schema indicating the rule has no options.',
foundOptionsUsage:
Expand Down
27 changes: 15 additions & 12 deletions lib/rules/test-case-property-ordering.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@

const utils = require('../utils');

const defaultOrder = [
'filename',
'code',
'output',
'options',
'parser',
'languageOptions', // flat-mode only
'parserOptions', // eslintrc-mode only
'globals', // eslintrc-mode only
'env', // eslintrc-mode only
'errors',
];

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
Expand All @@ -30,6 +43,7 @@ module.exports = {
elements: { type: 'string' },
},
],
defaultOptions: [defaultOrder],
messages: {
inconsistentOrder:
'The properties of a test case should be placed in a consistent order: [{{order}}].',
Expand All @@ -40,18 +54,7 @@ module.exports = {
// ----------------------------------------------------------------------
// Public
// ----------------------------------------------------------------------
const order = context.options[0] || [
'filename',
'code',
'output',
'options',
'parser',
'languageOptions', // flat-mode only
'parserOptions', // eslintrc-mode only
'globals', // eslintrc-mode only
'env', // eslintrc-mode only
'errors',
];
const order = context.options[0] || defaultOrder;
const sourceCode = context.sourceCode || context.getSourceCode(); // TODO: just use context.sourceCode when dropping eslint < v9

return {
Expand Down
1 change: 1 addition & 0 deletions lib/rules/test-case-shorthand-strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ module.exports = {
enum: ['as-needed', 'never', 'consistent', 'consistent-as-needed'],
},
],
defaultOptions: ['as-needed'],
messages: {
useShorthand:
'Use {{preferred}} for this test case instead of {{actual}}.',
Expand Down
Loading

0 comments on commit 13e625a

Please sign in to comment.