Repository: eslint-community/eslint-plugin-eslint-plugin (js)

Issue:
Rule suggestion: `no-meta-schema-default`
https://github.com/eslint/eslint/pull/17656 added support for `meta.defaultOptions` in rules and [mentions in the docs](https://github.com/eslint/eslint/pull/17656/files#diff-dc3f44ef800795b890a57c8dd95c6ce26ba8a47a4551d7a05d7a5cd9cf016597R846-R848) that Ajv's `useDefaults` option might be disabled in the future. See also https://github.com/eslint/rfcs/blob/main/designs/2023-rule-options-defaults/README.md:

> This RFC proposes removing the `useDefaults: true` setting in a future major version of ESLint.

To prepare for that, a new ESLint rule could warn against using `default` in `meta.schema` already.

Pull request:
Add new `no-meta-schema-default` rule
Fixes #498.

Patch:
diff --git a/README.md b/README.md
index fb1035c..e9553df 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 0000000..7bd3726
--- /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`)
+
+<!-- end auto-generated rule header -->
+
+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 0b4d95f..24f9f4e 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 0000000..171d55e
--- /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;
+          }
+        }
+      }
+    }
+  },
+};


Test patch:
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 0000000..869f497
--- /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,
+        },
+      ],
+    },
+  ],
+});