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:
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' },
+      ],
+    },
+  ],
+});