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

Issue:
Rule: require that properties in a schema include a description
Porting over from https://github.com/typescript-eslint/typescript-eslint/issues/5392: it's theoretically possible for docs generators to use the JSON schema `description` fields on rule options per https://json-schema.org/draft/2020-12/json-schema-core.html#name-schema-vocabularies. That way auto-generated rule docs can automatically be set up with those descriptions.

Is this something you'd be interested in taking in as a core rule here? I'd imagine it wouldn't be something to enable in the _recommended_ config to start given these descriptions aren't used by the default markdown docs generator. But it might be useful long-term as an opt-in option to enforce that the descriptions live in the page somehow?

---

For a concrete example, in typescript-eslint, the `no-explicit-any` rule defines descriptions:

```js
schema: [
  {
    type: 'object',
    additionalProperties: false,
    properties: {
      fixToUnknown: {
        description:
          'Whether to enable auto-fixing in which the `any` type is converted to the `unknown` type.',
        type: 'boolean',
      },
      ignoreRestArgs: {
        description: 'Whether to ignore rest parameter arrays.',
        type: 'boolean',
      },
    },
  },
],
```

...and then https://typescript-eslint.io/rules/no-explicit-any/#options includes them as JSDoc options:

```ts
interface Options {
  /**
   * Whether to enable auto-fixing in which the `any` type is converted to the `unknown` type.
   */
  fixToUnknown?: boolean;
  /**
   * Whether to ignore rest parameter arrays.
   */
  ignoreRestArgs?: boolean;
}

const defaultOptions: Options = [
  { fixToUnknown: false, ignoreRestArgs: false },
];
```

Pull request description:
feat: add `require-meta-schema-description` rule
Fixes #334.

This is a relatively straightforward approach that descends through the AST of `meta.schema`. It doesn't attempt to dynamically construct the properties through AJV. 

I tried it out on typescript-eslint here: https://github.com/typescript-eslint/typescript-eslint/pull/10171

Patch:
diff --git a/README.md b/README.md
index 0764eaf..fb1e2e6 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 0000000..3ecc6c2
--- /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`)
+
+<!-- end auto-generated rule header -->
+
+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 b62ab7a..57beac6 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 50fe30f..e3de8cf 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 a73e9d0..4d9d09c 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 58156bf..52918e1 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 0000000..5705f8d
--- /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 fc64242..61f956b 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/utils.js b/lib/utils.js
index d584a1e..b049eac 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.


Test patch:
diff --git a/lib/rules/test-case-property-ordering.js b/lib/rules/test-case-property-ordering.js
index e0e5b50..5b161ab 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 7a4da74..432d474 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/tests/lib/rules/require-meta-schema-description.js b/tests/lib/rules/require-meta-schema-description.js
new file mode 100644
index 0000000..f6171c5
--- /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',
+        },
+      ],
+    },
+  ],
+});