From 13e625a2d0273dcc14911b24ea809bf5f46d00c1 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Wed, 18 Dec 2024 07:59:23 +0100 Subject: [PATCH] feat: Add new `require-meta-default-options` rule (#502) * Add new `require-meta-default-options` rule * Add `defaultOptions` to all rules * Improve rule description * Allow array root schemas to have empty `defaultOptions` --- README.md | 1 + docs/rules/require-meta-default-options.md | 108 ++++++++++ lib/rules/consistent-output.js | 1 + lib/rules/meta-property-ordering.js | 25 +-- lib/rules/no-property-in-node.js | 1 + lib/rules/report-message-format.js | 1 + lib/rules/require-meta-default-options.js | 104 ++++++++++ lib/rules/require-meta-docs-description.js | 1 + lib/rules/require-meta-docs-recommended.js | 1 + lib/rules/require-meta-docs-url.js | 1 + lib/rules/require-meta-fixable.js | 1 + lib/rules/require-meta-schema.js | 1 + lib/rules/test-case-property-ordering.js | 27 +-- lib/rules/test-case-shorthand-strings.js | 1 + .../lib/rules/require-meta-default-options.js | 185 ++++++++++++++++++ 15 files changed, 436 insertions(+), 23 deletions(-) create mode 100644 docs/rules/require-meta-default-options.md create mode 100644 lib/rules/require-meta-default-options.js create mode 100644 tests/lib/rules/require-meta-default-options.js diff --git a/README.md b/README.md index 36454181..fb1035c0 100644 --- a/README.md +++ b/README.md @@ -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 | | 🔧 | | | diff --git a/docs/rules/require-meta-default-options.md b/docs/rules/require-meta-default-options.md new file mode 100644 index 00000000..70dab8ef --- /dev/null +++ b/docs/rules/require-meta-default-options.md @@ -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). + + + +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) diff --git a/lib/rules/consistent-output.js b/lib/rules/consistent-output.js index 57beac64..42cbf3ae 100644 --- a/lib/rules/consistent-output.js +++ b/lib/rules/consistent-output.js @@ -32,6 +32,7 @@ module.exports = { default: 'consistent', }, ], + defaultOptions: ['consistent'], messages: { missingOutput: 'This test case should have an output assertion.', }, diff --git a/lib/rules/meta-property-ordering.js b/lib/rules/meta-property-ordering.js index e3de8cf1..04142237 100644 --- a/lib/rules/meta-property-ordering.js +++ b/lib/rules/meta-property-ordering.js @@ -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 // ------------------------------------------------------------------------------ @@ -28,6 +40,7 @@ module.exports = { elements: { type: 'string' }, }, ], + defaultOptions: [defaultOrder], messages: { inconsistentOrder: 'The meta properties should be placed in a consistent order: [{{order}}].', @@ -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])); diff --git a/lib/rules/no-property-in-node.js b/lib/rules/no-property-in-node.js index 1f6563f6..ace20884 100644 --- a/lib/rules/no-property-in-node.js +++ b/lib/rules/no-property-in-node.js @@ -70,6 +70,7 @@ module.exports = { additionalProperties: false, }, ], + defaultOptions: [{ additionalNodeTypeFiles: [] }], messages: { in: 'Prefer checking specific node properties instead of a broad `in`.', }, diff --git a/lib/rules/report-message-format.js b/lib/rules/report-message-format.js index 4d9d09ce..d6d308f5 100644 --- a/lib/rules/report-message-format.js +++ b/lib/rules/report-message-format.js @@ -29,6 +29,7 @@ module.exports = { type: 'string', }, ], + defaultOptions: [''], messages: { noMatch: "Report message does not match the pattern '{{pattern}}'.", }, diff --git a/lib/rules/require-meta-default-options.js b/lib/rules/require-meta-default-options.js new file mode 100644 index 00000000..e1166b08 --- /dev/null +++ b/lib/rules/require-meta-default-options.js @@ -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 {}; + }, +}; diff --git a/lib/rules/require-meta-docs-description.js b/lib/rules/require-meta-docs-description.js index 52918e19..0ff4ac38 100644 --- a/lib/rules/require-meta-docs-description.js +++ b/lib/rules/require-meta-docs-description.js @@ -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.', diff --git a/lib/rules/require-meta-docs-recommended.js b/lib/rules/require-meta-docs-recommended.js index c4b1afb7..b4ae768b 100644 --- a/lib/rules/require-meta-docs-recommended.js +++ b/lib/rules/require-meta-docs-recommended.js @@ -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.', diff --git a/lib/rules/require-meta-docs-url.js b/lib/rules/require-meta-docs-url.js index d5039eee..90650626 100644 --- a/lib/rules/require-meta-docs-url.js +++ b/lib/rules/require-meta-docs-url.js @@ -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.', diff --git a/lib/rules/require-meta-fixable.js b/lib/rules/require-meta-fixable.js index 8e275f6d..76ab6032 100644 --- a/lib/rules/require-meta-fixable.js +++ b/lib/rules/require-meta-fixable.js @@ -36,6 +36,7 @@ module.exports = { additionalProperties: false, }, ], + defaultOptions: [{ catchNoFixerButFixableProperty: false }], messages: { invalid: '`meta.fixable` must be either `code`, `whitespace`, or `null`.', missing: diff --git a/lib/rules/require-meta-schema.js b/lib/rules/require-meta-schema.js index 61f956b7..f885aa4b 100644 --- a/lib/rules/require-meta-schema.js +++ b/lib/rules/require-meta-schema.js @@ -31,6 +31,7 @@ module.exports = { additionalProperties: false, }, ], + defaultOptions: [{ requireSchemaPropertyWhenOptionless: true }], messages: { addEmptySchema: 'Add empty schema indicating the rule has no options.', foundOptionsUsage: diff --git a/lib/rules/test-case-property-ordering.js b/lib/rules/test-case-property-ordering.js index 8ffe1336..6bf7a4a0 100644 --- a/lib/rules/test-case-property-ordering.js +++ b/lib/rules/test-case-property-ordering.js @@ -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 // ------------------------------------------------------------------------------ @@ -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}}].', @@ -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 { diff --git a/lib/rules/test-case-shorthand-strings.js b/lib/rules/test-case-shorthand-strings.js index 432d4745..68e813e3 100644 --- a/lib/rules/test-case-shorthand-strings.js +++ b/lib/rules/test-case-shorthand-strings.js @@ -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}}.', diff --git a/tests/lib/rules/require-meta-default-options.js b/tests/lib/rules/require-meta-default-options.js new file mode 100644 index 00000000..da2b4209 --- /dev/null +++ b/tests/lib/rules/require-meta-default-options.js @@ -0,0 +1,185 @@ +'use strict'; + +const rule = require('../../../lib/rules/require-meta-default-options'); +const RuleTester = require('../eslint-rule-tester').RuleTester; + +const ruleTester = new RuleTester({ + languageOptions: { sourceType: 'commonjs' }, +}); + +ruleTester.run('require-meta-default-options', rule, { + valid: [ + 'foo()', + 'module.exports = {};', + ` + module.exports = { + meta: {}, + create(context) {} + }; + `, + ` + module.exports = { + meta: { schema: [] }, + create(context) {} + }; + `, + { + code: ` + export default { + meta: { schema: [] }, + create(context) {} + }; + `, + languageOptions: { sourceType: 'module' }, + }, + ` + const mySchema = []; + module.exports = { + meta: { docs: { schema: mySchema } }, + create(context) {} + }; + `, + ` + const meta = { schema: [] }; + module.exports = { + meta, + create(context) {} + }; + `, + ` + module.exports = { + meta: { schema: [{}], defaultOptions: [1] }, + create(context) {} + }; + `, + ` + module.exports = { + meta: { schema: [{}, {}], defaultOptions: [1] }, + create(context) {} + }; + `, + ` + module.exports = { + meta: { schema: {}, defaultOptions: [1] }, + create(context) {} + }; + `, + ` + module.exports = { + meta: { + schema: { + type: 'array' + }, + defaultOptions: [] + }, + create(context) {} + }; + `, + ], + + invalid: [ + { + code: ` + module.exports = { + meta: { schema: [], defaultOptions: [1] }, + create(context) {} + }; + `, + output: ` + module.exports = { + meta: { schema: [], }, + create(context) {} + }; + `, + errors: [{ messageId: 'unnecessaryDefaultOptions', type: 'Property' }], + }, + { + code: ` + module.exports = { + meta: { schema: [{}] }, + create(context) {} + }; + `, + output: ` + module.exports = { + meta: { schema: [{}], defaultOptions: [] }, + create(context) {} + }; + `, + errors: [ + { messageId: 'missingDefaultOptions', type: 'ObjectExpression' }, + ], + }, + { + code: ` + module.exports = { + meta: { schema: [{}], defaultOptions: {} }, + create(context) {} + }; + `, + output: null, + errors: [ + { messageId: 'defaultOptionsMustBeArray', type: 'ObjectExpression' }, + ], + }, + { + code: ` + module.exports = { + meta: { schema: [{}], defaultOptions: undefined }, + create(context) {} + }; + `, + output: null, + errors: [{ messageId: 'defaultOptionsMustBeArray', type: 'Identifier' }], + }, + { + code: ` + module.exports = { + meta: { schema: [{}], defaultOptions: [] }, + create(context) {} + }; + `, + output: null, + errors: [ + { messageId: 'defaultOptionsMustNotBeEmpty', type: 'ArrayExpression' }, + ], + }, + ], +}); + +const ruleTesterTypeScript = new RuleTester({ + languageOptions: { + parser: require('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + }, +}); + +ruleTesterTypeScript.run('require-meta-default-options (TypeScript)', rule, { + valid: [ + ` + export default createESLintRule({ + meta: { schema: [] }, + create(context) {} + }); + `, + ], + invalid: [ + { + code: ` + export default createESLintRule({ + meta: { schema: [{}] }, + create(context) {} + }); + `, + output: ` + export default createESLintRule({ + meta: { schema: [{}], defaultOptions: [] }, + create(context) {} + }); + `, + errors: [ + { messageId: 'missingDefaultOptions', type: 'ObjectExpression' }, + ], + }, + ], +});