diff --git a/README.md b/README.md index 3886faaa..0764eaf1 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ module.exports = [ | [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-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 | | 🔧 | | | | [require-meta-fixable](docs/rules/require-meta-fixable.md) | require rules to implement a `meta.fixable` property | ✅ | | | | | [require-meta-has-suggestions](docs/rules/require-meta-has-suggestions.md) | require suggestable rules to implement a `meta.hasSuggestions` property | ✅ | 🔧 | | | diff --git a/docs/rules/require-meta-docs-recommended.md b/docs/rules/require-meta-docs-recommended.md new file mode 100644 index 00000000..28ff3feb --- /dev/null +++ b/docs/rules/require-meta-docs-recommended.md @@ -0,0 +1,75 @@ +# Require rules to implement a `meta.docs.recommended` property (`eslint-plugin/require-meta-docs-recommended`) + + + +Utilizing `meta.docs.recommended` makes it clear from each rule implementation whether a rule is part of the `recommended` config. Some plugins also have scripting for conveniently generating their config based on this flag. + +However, this flag may not be appropriate for all plugins: + +- Extra scripting/tooling is needed to keep the flags in sync with the config +- The flag may not scale to plugins that have multiple/many configs or don't have a recommended config +- Or some may simply prefer to keep the source of truth solely in the config rather than duplicating config membership data in the rules + +By default, this rule enforces a `recommended` property be set to a `boolean` value. + +## Rule Details + +This rule requires ESLint rules to have a valid `meta.docs.recommended` property. + +Examples of **incorrect** code for this rule: + +```js +/* eslint eslint-plugin/require-meta-docs-recommended: error */ + +module.exports = { + meta: {}, + create(context) { + /* ... */ + }, +}; +``` + +Examples of **correct** code for this rule: + +```js +/* eslint eslint-plugin/require-meta-docs-recommended: error */ + +module.exports = { + meta: { recommended: true }, + create(context) { + /* ... */ + }, +}; +``` + +## Options + + + +| Name | Description | Type | Default | +| :---------------- | :--------------------------------------------------- | :------ | :------ | +| `allowNonBoolean` | Whether to allow values of types other than boolean. | Boolean | `false` | + + + +### `allowNonBoolean` + +Some plugins require `meta.docs.recommended` values but allow value types other than `boolean`. +This option changes the rule to only enforce that the values exist. + +Example of **correct** code for this rule with `allowNonBoolean`: + +```js +/* eslint eslint-plugin/require-meta-docs-recommended: ["error", { "allowNonBoolean": true }] */ + +module.exports = { + meta: { recommended: 'strict' }, + create(context) { + /* ... */ + }, +}; +``` + +## Further Reading + +- [Rule Structure](https://eslint.org/docs/latest/extend/custom-rules#rule-structure) diff --git a/lib/rules/require-meta-docs-description.js b/lib/rules/require-meta-docs-description.js index 55e4c082..081b4ff6 100644 --- a/lib/rules/require-meta-docs-description.js +++ b/lib/rules/require-meta-docs-description.js @@ -56,22 +56,19 @@ module.exports = { const scope = sourceCode.getScope?.(ast) || context.getScope(); // TODO: just use sourceCode.getScope() when we drop support for ESLint < v9.0.0 const { scopeManager } = sourceCode; - const pattern = - context.options[0] && context.options[0].pattern - ? new RegExp(context.options[0].pattern) - : DEFAULT_PATTERN; + const { + docsNode, + metaNode, + metaPropertyNode: descriptionNode, + } = utils.getMetaDocsProperty('description', ruleInfo, scopeManager); - const metaNode = ruleInfo.meta; - const docsNode = utils - .evaluateObjectProperties(metaNode, scopeManager) - .find((p) => p.type === 'Property' && utils.getKeyName(p) === 'docs'); - - const descriptionNode = utils - .evaluateObjectProperties(docsNode && docsNode.value, scopeManager) - .find( - (p) => - p.type === 'Property' && utils.getKeyName(p) === 'description', - ); + if (!descriptionNode) { + context.report({ + node: docsNode || metaNode || ruleInfo.create, + messageId: 'missing', + }); + return; + } if (!descriptionNode) { context.report({ @@ -87,6 +84,10 @@ module.exports = { return; } + const pattern = context.options[0]?.pattern + ? new RegExp(context.options[0].pattern) + : DEFAULT_PATTERN; + if (typeof staticValue.value !== 'string' || staticValue.value === '') { context.report({ node: descriptionNode.value, diff --git a/lib/rules/require-meta-docs-recommended.js b/lib/rules/require-meta-docs-recommended.js new file mode 100644 index 00000000..d620f34b --- /dev/null +++ b/lib/rules/require-meta-docs-recommended.js @@ -0,0 +1,77 @@ +'use strict'; + +const { getStaticValue } = require('eslint-utils'); +const utils = require('../utils'); + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'require rules to implement a `meta.docs.recommended` property', + category: 'Rules', + recommended: false, + url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/require-meta-docs-recommended.md', + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + allowNonBoolean: { + default: false, + description: 'Whether to allow values of types other than boolean.', + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + messages: { + incorrect: '`meta.docs.recommended` is required to be a boolean.', + missing: '`meta.docs.recommended` is required.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode || context.getSourceCode(); // TODO: just use context.sourceCode when dropping eslint < v9 + const ruleInfo = utils.getRuleInfo(sourceCode); + if (!ruleInfo) { + return {}; + } + + const { scopeManager } = sourceCode; + const { + docsNode, + metaNode, + metaPropertyNode: descriptionNode, + } = utils.getMetaDocsProperty('recommended', ruleInfo, scopeManager); + + if (!descriptionNode) { + context.report({ + node: docsNode || metaNode || ruleInfo.create, + messageId: 'missing', + }); + return {}; + } + + if (context.options[0]?.allowNonBoolean) { + return {}; + } + + const staticValue = utils.isUndefinedIdentifier(descriptionNode.value) + ? { value: undefined } + : getStaticValue(descriptionNode.value); + + if (staticValue && typeof staticValue.value !== 'boolean') { + context.report({ + node: descriptionNode.value, + messageId: 'incorrect', + }); + return {}; + } + + return {}; + }, +}; diff --git a/lib/rules/require-meta-docs-url.js b/lib/rules/require-meta-docs-url.js index 37d49077..af3e6d40 100644 --- a/lib/rules/require-meta-docs-url.js +++ b/lib/rules/require-meta-docs-url.js @@ -9,7 +9,7 @@ // ----------------------------------------------------------------------------- const path = require('path'); -const util = require('../utils'); +const utils = require('../utils'); const { getStaticValue } = require('eslint-utils'); // ----------------------------------------------------------------------------- @@ -77,7 +77,7 @@ module.exports = { } const sourceCode = context.sourceCode || context.getSourceCode(); // TODO: just use context.sourceCode when dropping eslint < v9 - const ruleInfo = util.getRuleInfo(sourceCode); + const ruleInfo = utils.getRuleInfo(sourceCode); if (!ruleInfo) { return {}; } @@ -87,16 +87,11 @@ module.exports = { const scope = sourceCode.getScope?.(ast) || context.getScope(); // TODO: just use sourceCode.getScope() when we drop support for ESLint < v9.0.0 const { scopeManager } = sourceCode; - const metaNode = ruleInfo.meta; - const docsPropNode = util - .evaluateObjectProperties(metaNode, scopeManager) - .find((p) => p.type === 'Property' && util.getKeyName(p) === 'docs'); - const urlPropNode = util - .evaluateObjectProperties( - docsPropNode && docsPropNode.value, - scopeManager, - ) - .find((p) => p.type === 'Property' && util.getKeyName(p) === 'url'); + const { + docsNode, + metaNode, + metaPropertyNode: urlPropNode, + } = utils.getMetaDocsProperty('url', ruleInfo, scopeManager); const staticValue = urlPropNode ? getStaticValue(urlPropNode.value, scope) @@ -113,7 +108,7 @@ module.exports = { context.report({ node: (urlPropNode && urlPropNode.value) || - (docsPropNode && docsPropNode.value) || + (docsNode && docsNode.value) || metaNode || ruleInfo.create, @@ -138,27 +133,23 @@ module.exports = { if (urlPropNode) { if ( urlPropNode.value.type === 'Literal' || - (urlPropNode.value.type === 'Identifier' && - urlPropNode.value.name === 'undefined') + utils.isUndefinedIdentifier(urlPropNode.value) ) { return fixer.replaceText(urlPropNode.value, urlString); } - } else if ( - docsPropNode && - docsPropNode.value.type === 'ObjectExpression' - ) { - return util.insertProperty( + } else if (docsNode && docsNode.value.type === 'ObjectExpression') { + return utils.insertProperty( fixer, - docsPropNode.value, + docsNode.value, `url: ${urlString}`, sourceCode, ); } else if ( - !docsPropNode && + !docsNode && metaNode && metaNode.type === 'ObjectExpression' ) { - return util.insertProperty( + return utils.insertProperty( fixer, metaNode, `docs: {\nurl: ${urlString}\n}`, diff --git a/lib/rules/require-meta-has-suggestions.js b/lib/rules/require-meta-has-suggestions.js index 323e138c..a7b5547a 100644 --- a/lib/rules/require-meta-has-suggestions.js +++ b/lib/rules/require-meta-has-suggestions.js @@ -139,8 +139,7 @@ module.exports = { fix(fixer) { if ( hasSuggestionsProperty.value.type === 'Literal' || - (hasSuggestionsProperty.value.type === 'Identifier' && - hasSuggestionsProperty.value.name === 'undefined') + utils.isUndefinedIdentifier(hasSuggestionsProperty.value) ) { return fixer.replaceText( hasSuggestionsProperty.value, diff --git a/lib/rules/require-meta-schema.js b/lib/rules/require-meta-schema.js index bbb6e00a..9c3e908c 100644 --- a/lib/rules/require-meta-schema.js +++ b/lib/rules/require-meta-schema.js @@ -106,10 +106,7 @@ module.exports = { hasEmptySchema = true; } - if ( - value.type === 'Literal' || - (value.type === 'Identifier' && value.name === 'undefined') - ) { + if (value.type === 'Literal' || utils.isUndefinedIdentifier(value)) { context.report({ node: value, messageId: 'wrongType' }); } }, diff --git a/lib/utils.js b/lib/utils.js index bae2b891..d59555e5 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -832,6 +832,26 @@ module.exports = { }); }, + getMetaDocsProperty(propertyName, ruleInfo, scopeManager) { + const metaNode = ruleInfo.meta; + + const docsNode = module.exports + .evaluateObjectProperties(metaNode, scopeManager) + .find( + (p) => p.type === 'Property' && module.exports.getKeyName(p) === 'docs', + ); + + const metaPropertyNode = module.exports + .evaluateObjectProperties(docsNode?.value, scopeManager) + .find( + (p) => + p.type === 'Property' && + module.exports.getKeyName(p) === propertyName, + ); + + return { docsNode, metaNode, metaPropertyNode }; + }, + /** * Get the `meta.messages` node from a rule. * @param {RuleInfo} ruleInfo @@ -919,6 +939,14 @@ module.exports = { }); }, + /** + * @param {Node} node + * @returns {boolean} Whether the node is an Identifier with name `undefined`. + */ + isUndefinedIdentifier(node) { + return node.type === 'Identifier' && node.name === 'undefined'; + }, + /** * Check whether a variable's definition is from a function parameter. * @param {Node} node - the Identifier node for the variable. diff --git a/tests/lib/rules/require-meta-docs-recommended.js b/tests/lib/rules/require-meta-docs-recommended.js new file mode 100644 index 00000000..591a2c77 --- /dev/null +++ b/tests/lib/rules/require-meta-docs-recommended.js @@ -0,0 +1,184 @@ +'use strict'; + +const rule = require('../../../lib/rules/require-meta-docs-recommended'); +const RuleTester = require('../eslint-rule-tester').RuleTester; + +const ruleTester = new RuleTester({ + languageOptions: { sourceType: 'commonjs' }, +}); + +ruleTester.run('require-meta-docs-recommended', rule, { + valid: [ + 'foo()', + 'module.exports = {};', + ` + module.exports = { + meta: { docs: { recommended: true } }, + create(context) {} + }; + `, + { + code: ` + export default { + meta: { docs: { recommended: true } }, + create(context) {} + }; + `, + languageOptions: { sourceType: 'module' }, + }, + ` + const RECOMMENDED = true; + module.exports = { + meta: { docs: { recommended: RECOMMENDED } }, + create(context) {} + }; + `, + + ` + const meta = { docs: { recommended: true } }; + module.exports = { + meta, + create(context) {} + }; + `, + ` + const extraDocs = { recommended: true }; + const extraMeta = { docs: { ...extraDocs } }; + module.exports = { + meta: { ...extraMeta }, + create(context) {} + }; + `, + ` + module.exports = { + meta: { docs: { recommended: RECOMMENDED } }, + create(context) {} + }; + `, + { + code: ` + module.exports = { + meta: { docs: { recommended: undefined } }, + create(context) {} + }; + `, + options: [{ allowNonBoolean: true }], + }, + { + code: ` + module.exports = { + meta: { docs: { recommended: 'strict' } }, + create(context) {} + }; + `, + options: [{ allowNonBoolean: true }], + }, + { + code: ` + const extraDocs = { recommended: 'strict' }; + const extraMeta = { docs: { ...extraDocs } }; + module.exports = { + meta: { ...extraMeta }, + create(context) {} + }; + `, + options: [{ allowNonBoolean: true }], + }, + ], + + invalid: [ + { + code: 'module.exports = { create(context) {} };', + output: null, + errors: [{ messageId: 'missing', type: 'FunctionExpression' }], + }, + { + code: ` + module.exports = { + meta: {}, + create(context) {} + }; + `, + output: null, + errors: [{ messageId: 'missing', type: 'ObjectExpression' }], + }, + { + code: ` + module.exports = { + meta: { docs: {} }, + create(context) {} + }; + `, + output: null, + errors: [{ messageId: 'missing', type: 'Property' }], + }, + { + code: ` + module.exports = { + meta: { docs: { recommended: undefined } }, + create(context) {} + }; + `, + output: null, + errors: [{ messageId: 'incorrect', type: 'Identifier' }], + }, + { + code: ` + module.exports = { + meta: { docs: { recommended: 'strict' } }, + create(context) {} + }; + `, + output: null, + errors: [{ messageId: 'incorrect', type: 'Literal' }], + }, + { + code: ` + const extraDocs = { }; + const extraMeta = { docs: { ...extraDocs } }; + module.exports = { + meta: { ...extraMeta }, + create(context) {} + }; + `, + output: null, + errors: [{ messageId: 'missing', type: 'Property' }], + }, + { + code: 'module.exports = { create(context) {} };', + output: null, + options: [{ allowNonBoolean: true }], + errors: [{ messageId: 'missing', type: 'FunctionExpression' }], + }, + ], +}); + +const ruleTesterTypeScript = new RuleTester({ + languageOptions: { + parser: require('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + }, +}); + +ruleTesterTypeScript.run('require-meta-docs-recommended (TypeScript)', rule, { + valid: [ + ` + export default createESLintRule({ + meta: { docs: { recommended: true } }, + create(context) {} + }); + `, + ], + invalid: [ + { + code: ` + export default createESLintRule({ + meta: {}, + create(context) {} + }); + `, + output: null, + errors: [{ messageId: 'missing', type: 'ObjectExpression' }], + }, + ], +});