diff --git a/README.md b/README.md index fb1035c0..e9553dfc 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ module.exports = [ | [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-meta-schema-default](docs/rules/no-meta-schema-default.md) | disallow rules `meta.schema` properties to include defaults | | | | | | [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 | | | | 💭 | diff --git a/docs/rules/no-meta-schema-default.md b/docs/rules/no-meta-schema-default.md new file mode 100644 index 00000000..7bd3726e --- /dev/null +++ b/docs/rules/no-meta-schema-default.md @@ -0,0 +1,83 @@ +# Disallow rules `meta.schema` properties to include defaults (`eslint-plugin/no-meta-schema-default`) + + + +Since ESLint v9.15.0, rules' default options are supported using `meta.defaultOptions`. Additionally defining them using the `default` property in `meta.schema` is confusing, error-prone, and can be ambiguous for complex schemas. + +## Rule Details + +This rule disallows the `default` property in rules' `meta.schema`. + +Examples of **incorrect** code for this rule: + +```js +/* eslint eslint-plugin/no-meta-schema-default: error */ + +module.exports = { + meta: { + schema: [ + { + elements: { type: 'string' }, + type: 'array', + default: [], + }, + ], + }, + create() {}, +}; + +module.exports = { + meta: { + schema: { + type: 'object', + properties: { + foo: { type: 'string', default: 'bar' }, + baz: { type: 'number', default: 42 }, + }, + }, + }, + create() {}, +}; +``` + +Examples of **correct** code for this rule: + +```js +/* eslint eslint-plugin/no-meta-schema-default: error */ + +module.exports = { + meta: { + schema: [ + { + elements: { type: 'string' }, + type: 'array', + }, + ], + defaultOptions: [[]], + }, + create() {}, +}; + +module.exports = { + meta: { + schema: { + type: 'object', + properties: { + foo: { type: 'string' }, + baz: { type: 'number' }, + }, + }, + defaultOptions: [{ foo: 'bar', baz: 42 }], + }, + create() {}, +}; +``` + +## When Not To Use It + +When using [`eslint-doc-generator`](https://github.com/bmish/eslint-doc-generator) to generate documentation for your rules, you may want to disable this rule to include the `default` property in the generated documentation. This is because `eslint-doc-generator` does not yet support `meta.defaultOptions`, see [bmish/eslint-doc-generator#513](https://github.com/bmish/eslint-doc-generator/issues/513). + +## 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/eslint.config.js b/eslint.config.js index 0b4d95f6..24f9f4ea 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,7 @@ module.exports = [ plugins: eslintPluginConfig.plugins, rules: { ...eslintPluginConfig.rules, + 'eslint-plugin/no-meta-schema-default': 'off', // TODO: enable once https://github.com/bmish/eslint-doc-generator/issues/513 is fixed and released 'eslint-plugin/report-message-format': ['error', '^[^a-z].*.$'], 'eslint-plugin/require-meta-docs-url': [ 'error', diff --git a/lib/rules/no-meta-schema-default.js b/lib/rules/no-meta-schema-default.js new file mode 100644 index 00000000..171d55e6 --- /dev/null +++ b/lib/rules/no-meta-schema-default.js @@ -0,0 +1,113 @@ +'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: + 'disallow rules `meta.schema` properties to include defaults', + category: 'Rules', + recommended: false, + url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/no-meta-schema-default.md', + }, + schema: [], + messages: { + foundDefault: 'Disallowed default value in schema.', + }, + }, + + 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 === 'ObjectExpression') { + checkSchemaElement(schemaProperty, true); + } else if (schemaProperty?.type === 'ArrayExpression') { + for (const element of schemaProperty.elements) { + checkSchemaElement(element, true); + } + } + + return {}; + + function checkSchemaElement(node) { + if (node.type !== 'ObjectExpression') { + return; + } + + for (const { type, key, value } of node.properties) { + if (type !== 'Property') { + continue; + } + const staticKey = + key.type === 'Identifier' ? { value: key.name } : getStaticValue(key); + if (!staticKey?.value) { + continue; + } + + switch (key.name ?? key.value) { + case 'allOf': + case 'anyOf': + case 'oneOf': { + if (value.type === 'ArrayExpression') { + for (const element of value.elements) { + checkSchemaElement(element); + } + } + + break; + } + + case 'properties': { + if (Array.isArray(value.properties)) { + for (const property of value.properties) { + if (property.value?.type === 'ObjectExpression') { + checkSchemaElement(property.value); + } + } + } + + break; + } + + case 'elements': { + checkSchemaElement(value); + + break; + } + + case 'default': { + context.report({ + messageId: 'foundDefault', + node: key, + }); + + break; + } + } + } + } + }, +}; diff --git a/tests/lib/rules/no-meta-schema-default.js b/tests/lib/rules/no-meta-schema-default.js new file mode 100644 index 00000000..869f4971 --- /dev/null +++ b/tests/lib/rules/no-meta-schema-default.js @@ -0,0 +1,251 @@ +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/no-meta-schema-default'); +const RuleTester = require('../eslint-rule-tester').RuleTester; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + languageOptions: { sourceType: 'commonjs' }, +}); + +ruleTester.run('no-meta-schema-default', 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', + }, + ], + }, + }; + `, + ` + module.exports = { + meta: { + schema: [ + { + oneOf: [ + { + description: 'Elements to allow.', + elements: { type: 'string' }, + type: 'array', + } + ], + }, + ], + }, + create() {} + }; + `, + ` + module.exports = { + meta: { + schema: [ + { + type: 'object', + properties: null, + additionalProperties: false + } + ], + }, + create() {} + } + `, + ` + const schemaProperties = Object.freeze({}); + + module.exports = { + meta: { + schema: [ + { + type: 'object', + properties: { + ...schemaProperties, + }, + } + ], + }, + create() {} + } + `, + ` + module.exports = { + meta: { + schema: [ + { + type: 'object', + properties: Object.fromEntries( + Object.keys(DEFAULT_OPTIONS).map((code) => [ + code, + { type: 'boolean' } + ]) + ), + additionalProperties: false + } + ], + }, + create() {} + } + `, + ], + + invalid: [ + { + code: ` + module.exports = { + meta: { + schema: [ + { + elements: { type: 'string' }, + type: 'array', + default: [], + }, + ], + }, + create() {} + }; + `, + errors: [ + { + messageId: 'foundDefault', + line: 8, + column: 17, + endLine: 8, + endColumn: 24, + }, + ], + }, + { + code: ` + module.exports = { + meta: { + schema: [ + { + elements: { type: 'string', default: 'foo' }, + type: 'array', + }, + ], + }, + create() {} + }; + `, + errors: [ + { + messageId: 'foundDefault', + line: 6, + column: 45, + endLine: 6, + endColumn: 52, + }, + ], + }, + { + code: ` + module.exports = { + meta: { + schema: { + anyOf: [ + { + elements: { type: 'string' }, + type: 'array', + default: [], + }, + { + type: 'string', + default: 'foo', + } + ], + }, + }, + create() {} + }; + `, + errors: [ + { + messageId: 'foundDefault', + line: 9, + column: 19, + endLine: 9, + endColumn: 26, + }, + { + messageId: 'foundDefault', + line: 13, + column: 19, + endLine: 13, + endColumn: 26, + }, + ], + }, + { + code: ` + module.exports = { + meta: { + schema: [ + { + type: 'object', + properties: { + foo: { type: 'string', default: 'bar' }, + baz: { type: 'number', default: 42 }, + }, + }, + ] + }, + create() {} + }; + `, + errors: [ + { + messageId: 'foundDefault', + line: 8, + column: 42, + endLine: 8, + endColumn: 49, + }, + { + messageId: 'foundDefault', + line: 9, + column: 42, + endLine: 9, + endColumn: 49, + }, + ], + }, + ], +});