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

Issue:
Rule suggestion: `require-meta-default-options`
https://github.com/eslint/eslint/pull/17656 added support for `meta.defaultOptions` in rules. See also https://github.com/eslint/rfcs/blob/main/designs/2023-rule-options-defaults/README.md.

A new lint rule should report:

* if `meta.schema` is defined and not just `[]` and `meta.defaultOptions` is not defined
* if `meta.schema === []` but `meta.defaultOptions` _is_ defined
* if `meta.defaultOptions` is not an array
* if `meta.defaultOptions` is an empty array (Only exception in the core ESLint repo is [the `id-denylist` rule](https://github.com/eslint/eslint/pull/17656/files#diff-4f692363a6ac7a32ec4bafcd63a3b22c43ddaebae255f2bce09c4397ad9389a6R107), so maybe we should add an option to disable this check for certain rules? Or just be okay with having to add an ESLint disable comment for such cases.)

Pull request description:
Add new `require-meta-default-options` rule
Fixes #499.

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 7659f8b..470964a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -56,5 +56,4 @@ jobs:
         with:
           node-version: "lts/*"
       - run: npm install
-      - run: npm install --save-dev eslint@8 # eslint-remote-tester does not support eslint v9.
       - run: npm run test:remote
diff --git a/README.md b/README.md
index 3645418..fb1035c 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,7 @@ module.exports = [
 | [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-default-options](docs/rules/require-meta-default-options.md)       | require only rules with options to implement a `meta.defaultOptions` property              |     | 🔧  |     |     |
 | [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                                      |     | 🔧  |     |     |
diff --git a/docs/rules/require-meta-default-options.md b/docs/rules/require-meta-default-options.md
new file mode 100644
index 0000000..70dab8e
--- /dev/null
+++ b/docs/rules/require-meta-default-options.md
@@ -0,0 +1,108 @@
+# Require only rules with options to implement a `meta.defaultOptions` property (`eslint-plugin/require-meta-default-options`)
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+<!-- end auto-generated rule header -->
+
+Defining default options declaratively in a rule's `meta.defaultOptions` property enables ESLint v9.15.0+ to merge any user-provided options with the default options, simplifying the rule's implementation. It can also be useful for other tools like [eslint-doc-generator](https://github.com/bmish/eslint-doc-generator) to generate documentation for the rule's options.
+
+## Rule Details
+
+This rule requires ESLint rules to have a valid `meta.defaultOptions` property if and only if the rule has options defined in its `meta.schema` property.
+
+Examples of **incorrect** code for this rule:
+
+```js
+/* eslint eslint-plugin/require-meta-default-options: error */
+
+module.exports = {
+  meta: {
+    schema: [
+      {
+        type: 'object',
+        /* ... */
+      },
+    ],
+    // defaultOptions is missing
+  },
+  create(context) {
+    /* ... */
+  },
+};
+
+module.exports = {
+  meta: {
+    schema: [],
+    defaultOptions: [{}], // defaultOptions is not needed when schema is empty
+  },
+  create(context) {
+    /* ... */
+  },
+};
+
+module.exports = {
+  meta: {
+    schema: [
+      {
+        /* ... */
+      },
+    ],
+    defaultOptions: {}, // defaultOptions should be an array
+  },
+  create(context) {
+    /* ... */
+  },
+};
+
+module.exports = {
+  meta: {
+    schema: [
+      {
+        /* ... */
+      },
+    ],
+    defaultOptions: [], // defaultOptions should not be empty
+  },
+  create(context) {
+    /* ... */
+  },
+};
+```
+
+Examples of **correct** code for this rule:
+
+```js
+/* eslint eslint-plugin/require-meta-default-options: error */
+
+module.exports = {
+  meta: { schema: [] }, // no defaultOptions needed when schema is empty
+  create(context) {
+    /* ... */
+  },
+};
+
+module.exports = {
+  meta: {
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          exceptRange: {
+            type: 'boolean',
+          },
+        },
+        additionalProperties: false,
+      },
+    ],
+    defaultOptions: [{ exceptRange: false }],
+  },
+  create(context) {
+    /* ... */
+  },
+};
+```
+
+## 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 ecc609d..0b4d95f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -19,7 +19,7 @@ module.exports = [
     'plugin:prettier/recommended',
     'plugin:unicorn/recommended',
   ),
-  pluginN.configs['flat/recommended'],
+  ...pluginN.configs['flat/mixed-esm-and-cjs'],
   {
     rules: {
       '@eslint-community/eslint-comments/no-unused-disable': 'error',
diff --git a/lib/rules/consistent-output.js b/lib/rules/consistent-output.js
index 57beac6..42cbf3a 100644
--- a/lib/rules/consistent-output.js
+++ b/lib/rules/consistent-output.js
@@ -32,6 +32,7 @@ module.exports = {
         default: 'consistent',
       },
     ],
+    defaultOptions: ['consistent'],
     messages: {
       missingOutput: 'This test case should have an output assertion.',
     },
diff --git a/lib/rules/meta-property-ordering.js b/lib/rules/meta-property-ordering.js
index e3de8cf..0414223 100644
--- a/lib/rules/meta-property-ordering.js
+++ b/lib/rules/meta-property-ordering.js
@@ -6,6 +6,18 @@
 
 const { getKeyName, getRuleInfo } = require('../utils');
 
+const defaultOrder = [
+  'type',
+  'docs',
+  'fixable',
+  'hasSuggestions',
+  'deprecated',
+  'replacedBy',
+  'schema',
+  'defaultOptions', // https://github.com/eslint/rfcs/tree/main/designs/2023-rule-options-defaults
+  'messages',
+];
+
 // ------------------------------------------------------------------------------
 // Rule Definition
 // ------------------------------------------------------------------------------
@@ -28,6 +40,7 @@ module.exports = {
         elements: { type: 'string' },
       },
     ],
+    defaultOptions: [defaultOrder],
     messages: {
       inconsistentOrder:
         'The meta properties should be placed in a consistent order: [{{order}}].',
@@ -41,17 +54,7 @@ module.exports = {
       return {};
     }
 
-    const order = context.options[0] || [
-      'type',
-      'docs',
-      'fixable',
-      'hasSuggestions',
-      'deprecated',
-      'replacedBy',
-      'schema',
-      'defaultOptions', // https://github.com/eslint/rfcs/tree/main/designs/2023-rule-options-defaults
-      'messages',
-    ];
+    const order = context.options[0] || defaultOrder;
 
     const orderMap = new Map(order.map((name, i) => [name, i]));
 
diff --git a/lib/rules/no-property-in-node.js b/lib/rules/no-property-in-node.js
index 1f6563f..ace2088 100644
--- a/lib/rules/no-property-in-node.js
+++ b/lib/rules/no-property-in-node.js
@@ -70,6 +70,7 @@ module.exports = {
         additionalProperties: false,
       },
     ],
+    defaultOptions: [{ additionalNodeTypeFiles: [] }],
     messages: {
       in: 'Prefer checking specific node properties instead of a broad `in`.',
     },
diff --git a/lib/rules/report-message-format.js b/lib/rules/report-message-format.js
index 4d9d09c..d6d308f 100644
--- a/lib/rules/report-message-format.js
+++ b/lib/rules/report-message-format.js
@@ -29,6 +29,7 @@ module.exports = {
         type: 'string',
       },
     ],
+    defaultOptions: [''],
     messages: {
       noMatch: "Report message does not match the pattern '{{pattern}}'.",
     },
diff --git a/lib/rules/require-meta-default-options.js b/lib/rules/require-meta-default-options.js
new file mode 100644
index 0000000..e1166b0
--- /dev/null
+++ b/lib/rules/require-meta-default-options.js
@@ -0,0 +1,104 @@
+'use strict';
+
+const utils = require('../utils');
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'require only rules with options to implement a `meta.defaultOptions` property',
+      category: 'Rules',
+      recommended: false,
+      url: 'https://github.com/eslint-community/eslint-plugin-eslint-plugin/tree/HEAD/docs/rules/require-meta-default-options.md',
+    },
+    fixable: 'code',
+    schema: [],
+    messages: {
+      missingDefaultOptions:
+        'Rule with non-empty schema is missing a `meta.defaultOptions` property.',
+      unnecessaryDefaultOptions:
+        'Rule with empty schema should not have a `meta.defaultOptions` property.',
+      defaultOptionsMustBeArray: 'Default options must be an array.',
+      defaultOptionsMustNotBeEmpty: 'Default options must not be empty.',
+    },
+  },
+
+  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 metaNode = ruleInfo.meta;
+
+    const schemaNode = utils.getMetaSchemaNode(metaNode, scopeManager);
+    const schemaProperty = utils.getMetaSchemaNodeProperty(
+      schemaNode,
+      scopeManager,
+    );
+    if (!schemaProperty) {
+      return {};
+    }
+
+    const metaDefaultOptions = utils
+      .evaluateObjectProperties(metaNode, scopeManager)
+      .find(
+        (p) =>
+          p.type === 'Property' && utils.getKeyName(p) === 'defaultOptions',
+      );
+
+    if (
+      schemaProperty.type === 'ArrayExpression' &&
+      schemaProperty.elements.length === 0
+    ) {
+      if (metaDefaultOptions) {
+        context.report({
+          node: metaDefaultOptions,
+          messageId: 'unnecessaryDefaultOptions',
+          fix(fixer) {
+            return fixer.remove(metaDefaultOptions);
+          },
+        });
+      }
+      return {};
+    }
+
+    if (!metaDefaultOptions) {
+      context.report({
+        node: metaNode,
+        messageId: 'missingDefaultOptions',
+        fix(fixer) {
+          return fixer.insertTextAfter(schemaProperty, ', defaultOptions: []');
+        },
+      });
+      return {};
+    }
+
+    if (metaDefaultOptions.value.type !== 'ArrayExpression') {
+      context.report({
+        node: metaDefaultOptions.value,
+        messageId: 'defaultOptionsMustBeArray',
+      });
+      return {};
+    }
+
+    const isArrayRootSchema =
+      schemaProperty.type === 'ObjectExpression' &&
+      schemaProperty.properties.find((property) => property.key.name === 'type')
+        ?.value.value === 'array';
+
+    if (metaDefaultOptions.value.elements.length === 0 && !isArrayRootSchema) {
+      context.report({
+        node: metaDefaultOptions.value,
+        messageId: 'defaultOptionsMustNotBeEmpty',
+      });
+      return {};
+    }
+
+    return {};
+  },
+};
diff --git a/lib/rules/require-meta-docs-description.js b/lib/rules/require-meta-docs-description.js
index 52918e1..0ff4ac3 100644
--- a/lib/rules/require-meta-docs-description.js
+++ b/lib/rules/require-meta-docs-description.js
@@ -35,6 +35,7 @@ module.exports = {
         additionalProperties: false,
       },
     ],
+    defaultOptions: [{ pattern: '^(enforce|require|disallow)' }],
     messages: {
       extraWhitespace:
         '`meta.docs.description` must not have leading nor trailing whitespace.',
diff --git a/lib/rules/require-meta-docs-recommended.js b/lib/rules/require-meta-docs-recommended.js
index c4b1afb..b4ae768 100644
--- a/lib/rules/require-meta-docs-recommended.js
+++ b/lib/rules/require-meta-docs-recommended.js
@@ -47,6 +47,7 @@ module.exports = {
         additionalProperties: false,
       },
     ],
+    defaultOptions: [{ allowNonBoolean: false }],
     messages: {
       incorrect: '`meta.docs.recommended` is required to be a boolean.',
       missing: '`meta.docs.recommended` is required.',
diff --git a/lib/rules/require-meta-docs-url.js b/lib/rules/require-meta-docs-url.js
index d5039ee..9065062 100644
--- a/lib/rules/require-meta-docs-url.js
+++ b/lib/rules/require-meta-docs-url.js
@@ -40,6 +40,7 @@ module.exports = {
         additionalProperties: false,
       },
     ],
+    defaultOptions: [{}],
     messages: {
       mismatch: '`meta.docs.url` property must be `{{expectedUrl}}`.',
       missing: '`meta.docs.url` property is missing.',
diff --git a/lib/rules/require-meta-fixable.js b/lib/rules/require-meta-fixable.js
index 8e275f6..76ab603 100644
--- a/lib/rules/require-meta-fixable.js
+++ b/lib/rules/require-meta-fixable.js
@@ -36,6 +36,7 @@ module.exports = {
         additionalProperties: false,
       },
     ],
+    defaultOptions: [{ catchNoFixerButFixableProperty: false }],
     messages: {
       invalid: '`meta.fixable` must be either `code`, `whitespace`, or `null`.',
       missing:
diff --git a/lib/rules/require-meta-schema.js b/lib/rules/require-meta-schema.js
index 61f956b..f885aa4 100644
--- a/lib/rules/require-meta-schema.js
+++ b/lib/rules/require-meta-schema.js
@@ -31,6 +31,7 @@ module.exports = {
         additionalProperties: false,
       },
     ],
+    defaultOptions: [{ requireSchemaPropertyWhenOptionless: true }],
     messages: {
       addEmptySchema: 'Add empty schema indicating the rule has no options.',
       foundOptionsUsage:
diff --git a/package.json b/package.json
index f779d13..b4d17e6 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
     "lint:package-json": "npmPkgJsonLint .",
     "release": "release-it",
     "test": "nyc --all --check-coverage --include lib mocha tests --recursive",
-    "test:remote": "eslint-remote-tester",
+    "test:remote": "eslint-remote-tester -c ./eslint-remote-tester.config.mjs",
     "update:eslint-docs": "eslint-doc-generator"
   },
   "files": [
@@ -50,41 +50,40 @@
     "statements": 99
   },
   "devDependencies": {
-    "@commitlint/cli": "^19.2.2",
-    "@commitlint/config-conventional": "^19.2.2",
+    "@commitlint/cli": "^19.6.0",
+    "@commitlint/config-conventional": "^19.6.0",
     "@eslint-community/eslint-plugin-eslint-comments": "^4.3.0",
     "@eslint/eslintrc": "^3.0.2",
-    "@eslint/js": "^9.0.0",
-    "@release-it/conventional-changelog": "^8.0.1",
-    "@types/eslint": "^8.56.9",
+    "@eslint/js": "^9.16.0",
+    "@release-it/conventional-changelog": "^9.0.3",
+    "@types/eslint": "^9.6.1",
     "@types/estree": "^1.0.5",
     "@typescript-eslint/parser": "^7.7.0",
     "@typescript-eslint/utils": "^7.7.0",
-    "chai": "^4.4.1",
-    "dirty-chai": "^2.0.1",
-    "eslint": "9.14.0",
+    "chai": "^4.5.0",
+    "eslint": "^9.16.0",
     "eslint-config-not-an-aardvark": "^2.1.0",
     "eslint-config-prettier": "^9.1.0",
     "eslint-doc-generator": "^1.7.0",
     "eslint-plugin-eslint-plugin": "file:./",
     "eslint-plugin-markdown": "^5.0.0",
-    "eslint-plugin-n": "^17.2.1",
+    "eslint-plugin-n": "^17.14.0",
     "eslint-plugin-prettier": "^5.1.3",
-    "eslint-plugin-unicorn": "^53.0.0",
-    "eslint-remote-tester": "^3.0.1",
+    "eslint-plugin-unicorn": "^56.0.1",
+    "eslint-remote-tester": "^4.0.1",
     "eslint-scope": "^8.0.1",
     "espree": "^10.0.1",
-    "globals": "^15.0.0",
-    "husky": "^9.0.11",
+    "globals": "^15.13.0",
+    "husky": "^9.1.7",
     "lodash": "^4.17.21",
-    "markdownlint-cli": "^0.41.0",
+    "markdownlint-cli": "^0.43.0",
     "mocha": "^10.4.0",
-    "npm-package-json-lint": "^7.1.0",
-    "npm-run-all2": "^6.1.2",
-    "nyc": "^15.1.0",
-    "prettier": "^3.2.5",
+    "npm-package-json-lint": "^8.0.0",
+    "npm-run-all2": "^7.0.1",
+    "nyc": "^17.1.0",
+    "prettier": "^3.4.1",
     "release-it": "^17.2.0",
-    "typescript": "^5.4.3"
+    "typescript": "^5.7.2"
   },
   "peerDependencies": {
     "eslint": ">=8.23.0"


Test patch:
diff --git a/eslint-remote-tester.config.js b/eslint-remote-tester.config.mjs
similarity index 79%
rename from eslint-remote-tester.config.js
rename to eslint-remote-tester.config.mjs
index daf0601..313c334 100644
--- a/eslint-remote-tester.config.js
+++ b/eslint-remote-tester.config.mjs
@@ -1,7 +1,8 @@
-'use strict';
+import eslintPlugin from 'eslint-plugin-eslint-plugin';
+import tsparser from '@typescript-eslint/parser';
 
 /** @type {import('eslint-remote-tester').Config} */
-module.exports = {
+export default {
   /** Repositories to scan */
   repositories: [
     // A few dozen top ESLint plugins.
@@ -41,18 +42,18 @@ module.exports = {
   /** Optional boolean flag used to enable caching of cloned repositories. For CIs it's ideal to disable caching. Defaults to true. */
   cache: false,
 
-  pathIgnorePattern: 'fixtures',
-
   /** ESLint configuration */
-  eslintrc: {
-    root: true,
-    extends: ['plugin:eslint-plugin/all'],
-    // ignorePatterns: ['fixtures/**/*'],  // not working somehow - using `pathIgnorePattern` as of now.
-    overrides: [
-      {
-        files: ['*.ts', '*.mts', '*.cts'],
-        parser: '@typescript-eslint/parser',
+  eslintConfig: [
+    {
+      files: ['**/*.{js,mjs,cjs,ts,mts,cts}'],
+      ...eslintPlugin.configs['flat/all'],
+    },
+    {
+      files: ['*.ts', '*.mts', '*.cts'],
+      ...eslintPlugin.configs['flat/all-type-checked'],
+      languageOptions: {
+        parser: tsparser,
       },
-    ],
-  },
+    },
+  ],
 };
diff --git a/lib/rules/test-case-property-ordering.js b/lib/rules/test-case-property-ordering.js
index 5b161ab..6bf7a4a 100644
--- a/lib/rules/test-case-property-ordering.js
+++ b/lib/rules/test-case-property-ordering.js
@@ -7,6 +7,19 @@
 
 const utils = require('../utils');
 
+const defaultOrder = [
+  'filename',
+  'code',
+  'output',
+  'options',
+  'parser',
+  'languageOptions', // flat-mode only
+  'parserOptions', // eslintrc-mode only
+  'globals', // eslintrc-mode only
+  'env', // eslintrc-mode only
+  'errors',
+];
+
 // ------------------------------------------------------------------------------
 // Rule Definition
 // ------------------------------------------------------------------------------
@@ -30,6 +43,7 @@ module.exports = {
         elements: { type: 'string' },
       },
     ],
+    defaultOptions: [defaultOrder],
     messages: {
       inconsistentOrder:
         'The properties of a test case should be placed in a consistent order: [{{order}}].',
@@ -40,18 +54,7 @@ module.exports = {
     // ----------------------------------------------------------------------
     // Public
     // ----------------------------------------------------------------------
-    const order = context.options[0] || [
-      'filename',
-      'code',
-      'output',
-      'options',
-      'parser',
-      'languageOptions', // flat-mode only
-      'parserOptions', // eslintrc-mode only
-      'globals', // eslintrc-mode only
-      'env', // eslintrc-mode only
-      'errors',
-    ];
+    const order = context.options[0] || defaultOrder;
     const sourceCode = context.sourceCode || context.getSourceCode(); // TODO: just use context.sourceCode when dropping eslint < v9
 
     return {
@@ -68,7 +71,7 @@ module.exports = {
                 // current < lastChecked to catch unordered;
                 // and lastChecked === -1 to catch extra properties before.
                 if (
-                  current > -1 &&
+                  current !== -1 &&
                   (current < lastChecked || lastChecked === -1)
                 ) {
                   let orderMsg = order.filter((item) =>
diff --git a/lib/rules/test-case-shorthand-strings.js b/lib/rules/test-case-shorthand-strings.js
index 432d474..68e813e 100644
--- a/lib/rules/test-case-shorthand-strings.js
+++ b/lib/rules/test-case-shorthand-strings.js
@@ -30,6 +30,7 @@ module.exports = {
         enum: ['as-needed', 'never', 'consistent', 'consistent-as-needed'],
       },
     ],
+    defaultOptions: ['as-needed'],
     messages: {
       useShorthand:
         'Use {{preferred}} for this test case instead of {{actual}}.',
diff --git a/tests/lib/rules/require-meta-default-options.js b/tests/lib/rules/require-meta-default-options.js
new file mode 100644
index 0000000..da2b420
--- /dev/null
+++ b/tests/lib/rules/require-meta-default-options.js
@@ -0,0 +1,185 @@
+'use strict';
+
+const rule = require('../../../lib/rules/require-meta-default-options');
+const RuleTester = require('../eslint-rule-tester').RuleTester;
+
+const ruleTester = new RuleTester({
+  languageOptions: { sourceType: 'commonjs' },
+});
+
+ruleTester.run('require-meta-default-options', rule, {
+  valid: [
+    'foo()',
+    'module.exports = {};',
+    `
+      module.exports = {
+        meta: {},
+        create(context) {}
+      };
+    `,
+    `
+      module.exports = {
+        meta: { schema: [] },
+        create(context) {}
+      };
+    `,
+    {
+      code: `
+        export default {
+          meta: { schema: [] },
+          create(context) {}
+        };
+      `,
+      languageOptions: { sourceType: 'module' },
+    },
+    `
+      const mySchema = [];
+      module.exports = {
+        meta: { docs: { schema: mySchema } },
+        create(context) {}
+      };
+    `,
+    `
+      const meta = { schema: [] };
+      module.exports = {
+        meta,
+        create(context) {}
+      };
+    `,
+    `
+      module.exports = {
+        meta: { schema: [{}], defaultOptions: [1] },
+        create(context) {}
+      };
+    `,
+    `
+      module.exports = {
+        meta: { schema: [{}, {}], defaultOptions: [1] },
+        create(context) {}
+      };
+    `,
+    `
+      module.exports = {
+        meta: { schema: {}, defaultOptions: [1] },
+        create(context) {}
+      };
+    `,
+    `
+      module.exports = {
+        meta: {
+          schema: {
+            type: 'array'
+          },
+          defaultOptions: []
+        },
+        create(context) {}
+      };
+    `,
+  ],
+
+  invalid: [
+    {
+      code: `
+        module.exports = {
+          meta: { schema: [], defaultOptions: [1] },
+          create(context) {}
+        };
+      `,
+      output: `
+        module.exports = {
+          meta: { schema: [],  },
+          create(context) {}
+        };
+      `,
+      errors: [{ messageId: 'unnecessaryDefaultOptions', type: 'Property' }],
+    },
+    {
+      code: `
+        module.exports = {
+          meta: { schema: [{}] },
+          create(context) {}
+        };
+      `,
+      output: `
+        module.exports = {
+          meta: { schema: [{}], defaultOptions: [] },
+          create(context) {}
+        };
+      `,
+      errors: [
+        { messageId: 'missingDefaultOptions', type: 'ObjectExpression' },
+      ],
+    },
+    {
+      code: `
+        module.exports = {
+          meta: { schema: [{}], defaultOptions: {} },
+          create(context) {}
+        };
+      `,
+      output: null,
+      errors: [
+        { messageId: 'defaultOptionsMustBeArray', type: 'ObjectExpression' },
+      ],
+    },
+    {
+      code: `
+        module.exports = {
+          meta: { schema: [{}], defaultOptions: undefined },
+          create(context) {}
+        };
+      `,
+      output: null,
+      errors: [{ messageId: 'defaultOptionsMustBeArray', type: 'Identifier' }],
+    },
+    {
+      code: `
+        module.exports = {
+          meta: { schema: [{}], defaultOptions: [] },
+          create(context) {}
+        };
+      `,
+      output: null,
+      errors: [
+        { messageId: 'defaultOptionsMustNotBeEmpty', type: 'ArrayExpression' },
+      ],
+    },
+  ],
+});
+
+const ruleTesterTypeScript = new RuleTester({
+  languageOptions: {
+    parser: require('@typescript-eslint/parser'),
+    parserOptions: { sourceType: 'module' },
+  },
+});
+
+ruleTesterTypeScript.run('require-meta-default-options (TypeScript)', rule, {
+  valid: [
+    `
+      export default createESLintRule<Options, MessageIds>({
+        meta: { schema: [] },
+        create(context) {}
+      });
+    `,
+  ],
+  invalid: [
+    {
+      code: `
+        export default createESLintRule<Options, MessageIds>({
+          meta: { schema: [{}] },
+          create(context) {}
+        });
+      `,
+      output: `
+        export default createESLintRule<Options, MessageIds>({
+          meta: { schema: [{}], defaultOptions: [] },
+          create(context) {}
+        });
+      `,
+      errors: [
+        { messageId: 'missingDefaultOptions', type: 'ObjectExpression' },
+      ],
+    },
+  ],
+});