diff --git a/README.md b/README.md index 0764eaf1..fb1e2e6c 100644 --- a/README.md +++ b/README.md @@ -80,30 +80,31 @@ module.exports = [ ### Rules -| Name                          | Description | 💼 | 🔧 | 💡 | 💭 | -| :--------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- | -| [fixer-return](docs/rules/fixer-return.md) | require fixer functions to return a fix | ✅ | | | | -| [meta-property-ordering](docs/rules/meta-property-ordering.md) | enforce the order of meta properties | | 🔧 | | | -| [no-deprecated-context-methods](docs/rules/no-deprecated-context-methods.md) | disallow usage of deprecated methods on rule context objects | ✅ | 🔧 | | | -| [no-deprecated-report-api](docs/rules/no-deprecated-report-api.md) | disallow the version of `context.report()` with multiple arguments | ✅ | 🔧 | | | -| [no-missing-message-ids](docs/rules/no-missing-message-ids.md) | disallow `messageId`s that are missing from `meta.messages` | ✅ | | | | -| [no-missing-placeholders](docs/rules/no-missing-placeholders.md) | disallow missing placeholders in rule report messages | ✅ | | | | -| [no-property-in-node](docs/rules/no-property-in-node.md) | disallow using `in` to narrow node types instead of looking at properties | | | | 💭 | -| [no-unused-message-ids](docs/rules/no-unused-message-ids.md) | disallow unused `messageId`s in `meta.messages` | ✅ | | | | -| [no-unused-placeholders](docs/rules/no-unused-placeholders.md) | disallow unused placeholders in rule report messages | ✅ | | | | -| [no-useless-token-range](docs/rules/no-useless-token-range.md) | disallow unnecessary calls to `sourceCode.getFirstToken()` and `sourceCode.getLastToken()` | ✅ | 🔧 | | | -| [prefer-message-ids](docs/rules/prefer-message-ids.md) | require using `messageId` instead of `message` or `desc` to report rule violations | ✅ | | | | -| [prefer-object-rule](docs/rules/prefer-object-rule.md) | disallow function-style rules | ✅ | 🔧 | | | -| [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-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 | ✅ | 🔧 | | | -| [require-meta-schema](docs/rules/require-meta-schema.md) | require rules to implement a `meta.schema` property | ✅ | | 💡 | | -| [require-meta-type](docs/rules/require-meta-type.md) | require rules to implement a `meta.type` property | ✅ | | | | +| Name                            | Description | 💼 | 🔧 | 💡 | 💭 | +| :------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- | +| [fixer-return](docs/rules/fixer-return.md) | require fixer functions to return a fix | ✅ | | | | +| [meta-property-ordering](docs/rules/meta-property-ordering.md) | enforce the order of meta properties | | 🔧 | | | +| [no-deprecated-context-methods](docs/rules/no-deprecated-context-methods.md) | disallow usage of deprecated methods on rule context objects | ✅ | 🔧 | | | +| [no-deprecated-report-api](docs/rules/no-deprecated-report-api.md) | disallow the version of `context.report()` with multiple arguments | ✅ | 🔧 | | | +| [no-missing-message-ids](docs/rules/no-missing-message-ids.md) | disallow `messageId`s that are missing from `meta.messages` | ✅ | | | | +| [no-missing-placeholders](docs/rules/no-missing-placeholders.md) | disallow missing placeholders in rule report messages | ✅ | | | | +| [no-property-in-node](docs/rules/no-property-in-node.md) | disallow using `in` to narrow node types instead of looking at properties | | | | 💭 | +| [no-unused-message-ids](docs/rules/no-unused-message-ids.md) | disallow unused `messageId`s in `meta.messages` | ✅ | | | | +| [no-unused-placeholders](docs/rules/no-unused-placeholders.md) | disallow unused placeholders in rule report messages | ✅ | | | | +| [no-useless-token-range](docs/rules/no-useless-token-range.md) | disallow unnecessary calls to `sourceCode.getFirstToken()` and `sourceCode.getLastToken()` | ✅ | 🔧 | | | +| [prefer-message-ids](docs/rules/prefer-message-ids.md) | require using `messageId` instead of `message` or `desc` to report rule violations | ✅ | | | | +| [prefer-object-rule](docs/rules/prefer-object-rule.md) | disallow function-style rules | ✅ | 🔧 | | | +| [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-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 | ✅ | 🔧 | | | +| [require-meta-schema](docs/rules/require-meta-schema.md) | require rules to implement a `meta.schema` property | ✅ | | 💡 | | +| [require-meta-schema-description](docs/rules/require-meta-schema-description.md) | require rules `meta.schema` properties to include descriptions | | | | | +| [require-meta-type](docs/rules/require-meta-type.md) | require rules to implement a `meta.type` property | ✅ | | | | ### Tests diff --git a/docs/rules/require-meta-schema-description.md b/docs/rules/require-meta-schema-description.md new file mode 100644 index 00000000..3ecc6c24 --- /dev/null +++ b/docs/rules/require-meta-schema-description.md @@ -0,0 +1,88 @@ +# Require rules `meta.schema` properties to include descriptions (`eslint-plugin/require-meta-schema-description`) + + + +Defining a description in the schema for each rule option helps explain that option to users. +It also allows documentation generators such as [`eslint-doc-generator`](https://github.com/bmish/eslint-doc-generator) to generate more informative documentation for users. + +## Rule Details + +This rule requires that if a rule option has a property in the rule's `meta.schema`, it should have a `description`. + +Examples of **incorrect** code for this rule: + +```js +/* eslint eslint-plugin/require-meta-schema-description: error */ + +module.exports = { + meta: { + schema: [ + { + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {}, +}; + +module.exports = { + meta: { + schema: [ + { + properties: { + something: { + type: 'string', + }, + }, + type: 'object', + }, + ], + }, + create() {}, +}; +``` + +Examples of **correct** code for this rule: + +```js +/* eslint eslint-plugin/require-meta-schema-description: error */ + +module.exports = { + meta: { + schema: [ + { + description: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {}, +}; + +module.exports = { + meta: { + schema: [ + { + oneOf: [ + { + description: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + ], + }, + create() {}, +}; +``` + +## When Not To Use It + +If your rule options are very simple and well-named, and your rule isn't used by developers outside of your immediate team, you may not need this rule. + +## Further Reading + +- [working-with-rules#options-schemas](https://eslint.org/docs/developer-guide/working-with-rules#options-schemas) diff --git a/lib/rules/consistent-output.js b/lib/rules/consistent-output.js index b62ab7a2..57beac64 100644 --- a/lib/rules/consistent-output.js +++ b/lib/rules/consistent-output.js @@ -26,6 +26,8 @@ module.exports = { schema: [ { type: 'string', + description: + "Whether to enforce having output assertions 'always' or to be 'consistent' when some cases have them.", enum: ['always', 'consistent'], default: 'consistent', }, diff --git a/lib/rules/meta-property-ordering.js b/lib/rules/meta-property-ordering.js index 50fe30f7..e3de8cf1 100644 --- a/lib/rules/meta-property-ordering.js +++ b/lib/rules/meta-property-ordering.js @@ -24,6 +24,7 @@ module.exports = { schema: [ { type: 'array', + description: 'What order to enforce for meta properties.', elements: { type: 'string' }, }, ], diff --git a/lib/rules/report-message-format.js b/lib/rules/report-message-format.js index a73e9d0b..4d9d09ce 100644 --- a/lib/rules/report-message-format.js +++ b/lib/rules/report-message-format.js @@ -23,7 +23,12 @@ module.exports = { url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/report-message-format.md', }, fixable: null, - schema: [{ type: 'string' }], + schema: [ + { + description: 'Format that all report messages must match.', + type: 'string', + }, + ], messages: { noMatch: "Report message does not match the pattern '{{pattern}}'.", }, diff --git a/lib/rules/require-meta-docs-description.js b/lib/rules/require-meta-docs-description.js index 58156bf4..52918e19 100644 --- a/lib/rules/require-meta-docs-description.js +++ b/lib/rules/require-meta-docs-description.js @@ -70,14 +70,6 @@ module.exports = { return; } - if (!descriptionNode) { - context.report({ - node: docsNode || metaNode || ruleInfo.create, - messageId: 'missing', - }); - return; - } - const staticValue = getStaticValue(descriptionNode.value, scope); if (!staticValue) { // Ignore non-static values since we can't determine what they look like. diff --git a/lib/rules/require-meta-schema-description.js b/lib/rules/require-meta-schema-description.js new file mode 100644 index 00000000..5705f8db --- /dev/null +++ b/lib/rules/require-meta-schema-description.js @@ -0,0 +1,109 @@ +'use strict'; + +const { getStaticValue } = require('@eslint-community/eslint-utils'); +const utils = require('../utils'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'require rules `meta.schema` properties to include descriptions', + category: 'Rules', + recommended: false, + url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/require-meta-schema-description.md', + }, + schema: [], + messages: { + missingDescription: 'Schema option is missing an ajv description.', + }, + }, + + 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 schemaNode = utils.getMetaSchemaNode(ruleInfo.meta, scopeManager); + if (!schemaNode) { + return {}; + } + + const schemaProperty = utils.getMetaSchemaNodeProperty( + schemaNode, + scopeManager, + ); + if (schemaProperty?.type !== 'ArrayExpression') { + return {}; + } + + for (const element of schemaProperty.elements) { + checkSchemaElement(element, true); + } + + return {}; + + function checkSchemaElement(node, isRoot) { + if (node.type !== 'ObjectExpression') { + return; + } + + let hadChildren = false; + let hadDescription = false; + + for (const { key, value } of node.properties) { + const staticKey = + key.type === 'Identifier' ? { value: key.name } : getStaticValue(key); + if (!staticKey?.value) { + continue; + } + + switch (key.name ?? key.value) { + case 'description': { + hadDescription = true; + break; + } + + case 'oneOf': { + hadChildren = true; + + if (value.type === 'ArrayExpression') { + for (const element of value.elements) { + checkSchemaElement(element, isRoot); + } + } + + break; + } + + case 'properties': { + hadChildren = true; + + for (const property of value.properties) { + if (property.value?.type === 'ObjectExpression') { + checkSchemaElement(property.value); + } + } + + break; + } + } + } + + if (!hadDescription && !(hadChildren && isRoot)) { + context.report({ + messageId: 'missingDescription', + node, + }); + } + } + }, +}; diff --git a/lib/rules/require-meta-schema.js b/lib/rules/require-meta-schema.js index fc64242b..61f956b7 100644 --- a/lib/rules/require-meta-schema.js +++ b/lib/rules/require-meta-schema.js @@ -1,6 +1,5 @@ 'use strict'; -const { findVariable } = require('@eslint-community/eslint-utils'); const utils = require('../utils'); // ------------------------------------------------------------------------------ @@ -52,7 +51,6 @@ module.exports = { let contextIdentifiers; const metaNode = ruleInfo.meta; - let schemaNode; // Options const requireSchemaPropertyWhenOptionless = @@ -62,52 +60,35 @@ module.exports = { let hasEmptySchema = false; let isUsingOptions = false; + const schemaNode = utils.getMetaSchemaNode(metaNode, scopeManager); + const schemaProperty = utils.getMetaSchemaNodeProperty( + schemaNode, + scopeManager, + ); + return { Program(ast) { contextIdentifiers = utils.getContextIdentifiers(scopeManager, ast); - schemaNode = utils - .evaluateObjectProperties(metaNode, scopeManager) - .find( - (p) => p.type === 'Property' && utils.getKeyName(p) === 'schema', - ); - - if (!schemaNode) { + if (!schemaProperty) { return; } - let { value } = schemaNode; - if (value.type === 'Identifier' && value.name !== 'undefined') { - const variable = findVariable( - scopeManager.acquire(value) || scopeManager.globalScope, - value, - ); - - // If we can't find the declarator, we have to assume it's in correct type - if ( - !variable || - !variable.defs || - !variable.defs[0] || - !variable.defs[0].node || - variable.defs[0].node.type !== 'VariableDeclarator' || - !variable.defs[0].node.init - ) { - return; - } - - value = variable.defs[0].node.init; - } - if ( - (value.type === 'ArrayExpression' && value.elements.length === 0) || - (value.type === 'ObjectExpression' && value.properties.length === 0) + (schemaProperty.type === 'ArrayExpression' && + schemaProperty.elements.length === 0) || + (schemaProperty.type === 'ObjectExpression' && + schemaProperty.properties.length === 0) ) { // Schema is explicitly defined as having no options. hasEmptySchema = true; } - if (value.type === 'Literal' || utils.isUndefinedIdentifier(value)) { - context.report({ node: value, messageId: 'wrongType' }); + if ( + schemaProperty.type === 'Literal' || + utils.isUndefinedIdentifier(schemaProperty) + ) { + context.report({ node: schemaProperty, messageId: 'wrongType' }); } }, diff --git a/lib/rules/test-case-property-ordering.js b/lib/rules/test-case-property-ordering.js index e0e5b50a..5b161aba 100644 --- a/lib/rules/test-case-property-ordering.js +++ b/lib/rules/test-case-property-ordering.js @@ -26,6 +26,7 @@ module.exports = { schema: [ { type: 'array', + description: 'What order to enforce for test case properties.', elements: { type: 'string' }, }, ], diff --git a/lib/rules/test-case-shorthand-strings.js b/lib/rules/test-case-shorthand-strings.js index 7a4da747..432d4745 100644 --- a/lib/rules/test-case-shorthand-strings.js +++ b/lib/rules/test-case-shorthand-strings.js @@ -24,7 +24,11 @@ module.exports = { }, fixable: 'code', schema: [ - { enum: ['as-needed', 'never', 'consistent', 'consistent-as-needed'] }, + { + description: + 'What behavior to enforce of when shorthand strings should be banned or required.', + enum: ['as-needed', 'never', 'consistent', 'consistent-as-needed'], + }, ], messages: { useShorthand: diff --git a/lib/utils.js b/lib/utils.js index d584a1e9..b049eacb 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -917,6 +917,45 @@ module.exports = { ); }, + getMetaSchemaNode(metaNode, scopeManager) { + return module.exports + .evaluateObjectProperties(metaNode, scopeManager) + .find( + (p) => + p.type === 'Property' && module.exports.getKeyName(p) === 'schema', + ); + }, + + getMetaSchemaNodeProperty(schemaNode, scopeManager) { + if (!schemaNode) { + return null; + } + + let { value } = schemaNode; + if (value.type === 'Identifier' && value.name !== 'undefined') { + const variable = findVariable( + scopeManager.acquire(value) || scopeManager.globalScope, + value, + ); + + // If we can't find the declarator, we have to assume it's in correct type + if ( + !variable || + !variable.defs || + !variable.defs[0] || + !variable.defs[0].node || + variable.defs[0].node.type !== 'VariableDeclarator' || + !variable.defs[0].node.init + ) { + return; + } + + value = variable.defs[0].node.init; + } + + return value; + }, + /** * Get the possible values that a variable was initialized to at some point. * @param {Node} node - the Identifier node for the variable. diff --git a/tests/lib/rules/require-meta-schema-description.js b/tests/lib/rules/require-meta-schema-description.js new file mode 100644 index 00000000..f6171c51 --- /dev/null +++ b/tests/lib/rules/require-meta-schema-description.js @@ -0,0 +1,373 @@ +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/require-meta-schema-description'); +const RuleTester = require('../eslint-rule-tester').RuleTester; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + languageOptions: { sourceType: 'commonjs' }, +}); + +ruleTester.run('require-meta-schema-description', rule, { + valid: [ + ``, + ` +module.exports = {}; +`, + ` +module.exports = { + create() {} +}; +`, + ` +module.exports = { + meta: { + schema: false, + }, + create() {} +}; +`, + ` +module.exports = { + meta: { + schema: [false], + }, + create() {} +}; +`, + ` +module.exports = { + meta: { + schema: [ + { + description: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, +}; +`, + ` +const descriptionKey = 'description'; + +module.exports = { + meta: { + schema: [ + { + [descriptionKey]: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, +}; +`, + ` +module.exports = { + meta: { + schema: [ + { + description: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {} +}; +`, + ` +module.exports = { +meta: { +schema: [ + { + ['description']: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + }, +], +}, +create() {} +}; +`, + ` +module.exports = { + meta: { + schema: [ + { + oneOf: [ + { + description: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + } + ], + }, + ], + }, + create() {} +}; +`, + ` +module.exports = { + meta: { + schema: [ + { + type: 'object', + $defs: { + directiveConfigSchema: { + oneOf: [ + { + type: 'boolean', + default: true, + }, + ], + }, + }, + additionalProperties: false, + properties: { + 'ts-check': { + $ref: '#/items/0/$defs/directiveConfigSchema', + description: 'Whether to report @ts-check comments.', + }, + }, + }, + ], + }, + create() {} +}; +`, + ], + + invalid: [ + { + code: ` +module.exports = { + meta: { + schema: [ + { + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 7, + endColumn: 8, + endLine: 8, + line: 5, + messageId: 'missingDescription', + }, + ], + }, + { + code: ` +module.exports = { + meta: { + schema: [ + { + [unknownKey]: 'Computed.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 7, + endColumn: 8, + endLine: 9, + line: 5, + messageId: 'missingDescription', + }, + ], + }, + { + code: ` +module.exports = { + meta: { + schema: [ + { + [unknownKey()]: 'Computed.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 7, + endColumn: 8, + endLine: 9, + line: 5, + messageId: 'missingDescription', + }, + ], + }, + { + code: ` +const otherKey = 'other'; + +module.exports = { + meta: { + schema: [ + { + [otherKey]: 'Computed.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 7, + endColumn: 8, + endLine: 11, + line: 7, + messageId: 'missingDescription', + }, + ], + }, + { + code: ` +module.exports = { + meta: { + schema: [ + { + ['de' + 'scription']: 'Dynamic.', + elements: { type: 'string' }, + type: 'array', + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 7, + endColumn: 8, + endLine: 9, + line: 5, + messageId: 'missingDescription', + }, + ], + }, + { + code: ` +module.exports = { + meta: { + schema: [ + { + properties: { + something: { + type: 'string', + } + }, + type: 'object', + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 22, + endColumn: 12, + endLine: 9, + line: 7, + messageId: 'missingDescription', + }, + ], + }, + { + code: ` +module.exports = { + meta: { + schema: [ + { + oneOf: [ + { + elements: { type: 'string' }, + type: 'array', + } + ], + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 11, + endColumn: 12, + endLine: 10, + line: 7, + messageId: 'missingDescription', + }, + ], + }, + { + code: ` +module.exports = { + meta: { + schema: [ + { + type: 'object', + $defs: { + directiveConfigSchema: { + oneOf: [ + { + type: 'boolean', + default: true, + }, + ], + }, + }, + additionalProperties: false, + properties: { + 'ts-check': { + $ref: '#/items/0/$defs/directiveConfigSchema', + }, + }, + }, + ], + }, + create() {} +}; +`, + errors: [ + { + column: 23, + endColumn: 12, + endLine: 21, + line: 19, + messageId: 'missingDescription', + }, + ], + }, + ], +});