Repository: not-an-aardvark/eslint-plugin-eslint-plugin
Pull request #233 (2021-12-07 22:31:32)

fix: detect rules exported using a variable
Updates our rules to detect rules that are defined in variables, which is a style I have seen used by some eslint plugins:

```js
const rule = { meta: {}, create(context) {} }; 
export default rule;
```

```js
const rule = { meta: {}, create(context) {} }; 
module.exports = rule;
```

Follow-up to previous work around detecting additional types of rules and variables used inside rules:
* https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/pull/177
* https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/pull/225

Note that this could be considered either a fix or a breaking change since it will result in additional rules detected by our rules in some plugins.


Patch:
diff --git a/lib/utils.js b/lib/utils.js
index d7a2b82..8aca920 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -82,10 +82,32 @@ function isFunctionRule (node) {
   );
 }
 
+/**
+ * Check if the given node is a function call representing a known TypeScript rule creator format.
+ * @param {Node} node
+ * @returns {boolean}
+ */
+function isTypeScriptRuleHelper (node) {
+  return (
+    node.type === 'CallExpression' &&
+  node.arguments.length === 1 &&
+  node.arguments[0].type === 'ObjectExpression' &&
+  // Check various TypeScript rule helper formats.
+  (
+    // createESLintRule({ ... })
+    node.callee.type === 'Identifier' ||
+    // util.createRule({ ... })
+    (node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.property.type === 'Identifier') ||
+    // ESLintUtils.RuleCreator(docsUrl)({ ... })
+    (node.callee.type === 'CallExpression' && node.callee.callee.type === 'MemberExpression' && node.callee.callee.object.type === 'Identifier' && node.callee.callee.property.type === 'Identifier')
+  )
+  );
+}
+
 /**
  * Helper for `getRuleInfo`. Handles ESM and TypeScript rules.
  */
-function getRuleExportsESM (ast) {
+function getRuleExportsESM (ast, scopeManager) {
   return ast.body
     .filter(statement => statement.type === 'ExportDefaultDeclaration')
     .map(statement => statement.declaration)
@@ -97,22 +119,20 @@ function getRuleExportsESM (ast) {
       } else if (isFunctionRule(node)) {
         // Check `export default function(context) { return { ... }; }`
         return { create: node, meta: null, isNewStyle: false };
-      } else if (
-        node.type === 'CallExpression' &&
-        node.arguments.length === 1 &&
-        node.arguments[0].type === 'ObjectExpression' &&
-        // Check various TypeScript rule helper formats.
-        (
-          // createESLintRule({ ... })
-          node.callee.type === 'Identifier' ||
-          // util.createRule({ ... })
-          (node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && node.callee.property.type === 'Identifier') ||
-          // ESLintUtils.RuleCreator(docsUrl)({ ... })
-          (node.callee.type === 'CallExpression' && node.callee.callee.type === 'MemberExpression' && node.callee.callee.object.type === 'Identifier' && node.callee.callee.property.type === 'Identifier')
-        )
-      ) {
+      } else if (isTypeScriptRuleHelper(node)) {
         // Check `export default someTypeScriptHelper({ create() {}, meta: {} });
         return collectInterestingProperties(node.arguments[0].properties, INTERESTING_RULE_KEYS);
+      } else if (node.type === 'Identifier') {
+        const possibleRule = findVariableValue(node, scopeManager);
+        if (possibleRule) {
+          if (possibleRule.type === 'ObjectExpression') {
+            // Check `const possibleRule = { ... }; export default possibleRule;
+            return collectInterestingProperties(possibleRule.properties, INTERESTING_RULE_KEYS);
+          } else if (isTypeScriptRuleHelper(possibleRule)) {
+            // Check `const possibleRule = someTypeScriptHelper({ ... }); export default possibleRule;
+            return collectInterestingProperties(possibleRule.arguments[0].properties, INTERESTING_RULE_KEYS);
+          }
+        }
       }
       return currentExports;
     }, {});
@@ -121,7 +141,7 @@ function getRuleExportsESM (ast) {
 /**
  * Helper for `getRuleInfo`. Handles CJS rules.
  */
-function getRuleExportsCJS (ast) {
+function getRuleExportsCJS (ast, scopeManager) {
   let exportsVarOverridden = false;
   let exportsIsFunction = false;
   return ast.body
@@ -145,6 +165,12 @@ function getRuleExportsCJS (ast) {
           // Check `module.exports = { create: function () {}, meta: {} }`
 
           return collectInterestingProperties(node.right.properties, INTERESTING_RULE_KEYS);
+        } else if (node.right.type === 'Identifier') {
+          const possibleRule = findVariableValue(node.right, scopeManager);
+          if (possibleRule && possibleRule.type === 'ObjectExpression') {
+            // Check `const possibleRule = { ... }; module.exports = possibleRule;
+            return collectInterestingProperties(possibleRule.properties, INTERESTING_RULE_KEYS);
+          }
         }
         return {};
       } else if (
@@ -218,7 +244,7 @@ module.exports = {
   from the file, the return value will be `null`.
   */
   getRuleInfo ({ ast, scopeManager }) {
-    const exportNodes = ast.sourceType === 'module' ? getRuleExportsESM(ast) : getRuleExportsCJS(ast);
+    const exportNodes = ast.sourceType === 'module' ? getRuleExportsESM(ast, scopeManager) : getRuleExportsCJS(ast, scopeManager);
 
     const createExists = Object.prototype.hasOwnProperty.call(exportNodes, 'create');
     if (!createExists) {


Test patch:
diff --git a/tests/lib/rules/require-meta-schema.js b/tests/lib/rules/require-meta-schema.js
index 1be0d7a..3cb523e 100644
--- a/tests/lib/rules/require-meta-schema.js
+++ b/tests/lib/rules/require-meta-schema.js
@@ -370,5 +370,19 @@ schema: [] },
         { messageId: 'missing', type: 'ObjectExpression', suggestions: [] },
       ],
     },
+    {
+      // `rule`, `create`, and `meta` as variable.
+      code: `
+        const meta = {};
+        const create = function create(context) { const options = context.options; }
+        const rule = { meta, create };
+        module.exports = rule;
+      `,
+      output: null,
+      errors: [
+        { messageId: 'foundOptionsUsage', type: 'ObjectExpression', suggestions: [] },
+        { messageId: 'missing', type: 'ObjectExpression', suggestions: [] },
+      ],
+    },
   ],
 });
diff --git a/tests/lib/utils.js b/tests/lib/utils.js
index ac32bc6..a87862a 100644
--- a/tests/lib/utils.js
+++ b/tests/lib/utils.js
@@ -16,6 +16,9 @@ describe('utils', () => {
         '',
         'module.exports;',
         'module.exports = foo;',
+        'const foo = {}; module.exports = foo;',
+        'const foo = function() { return {}; }; module.exports = foo;',
+        'const foo = 123; module.exports = foo;',
         'module.boop = function(context) { return {};};',
         'exports = function(context) { return {};};',
         'module.exports = function* (context) { return {}; };',
@@ -62,6 +65,7 @@ describe('utils', () => {
         'export const foo = { create() {} }',
         'export default { foo: {} }',
         'const foo = {}; export default foo',
+        'const foo = 123; export default foo',
 
         // Exports function but not default export.
         'export function foo (context) { return {}; }',
@@ -102,6 +106,7 @@ describe('utils', () => {
         'export default foo<Options, MessageIds>(123);',
         'export default foo.bar<Options, MessageIds>(123);',
         'export default foo.bar()<Options, MessageIds>(123);',
+        'const notRule = foo(); export default notRule;',
       ].forEach(noRuleCase => {
         it(`returns null for ${noRuleCase}`, () => {
           const ast = typescriptEslintParser.parse(noRuleCase, { ecmaVersion: 8, range: true, sourceType: 'module' });
@@ -149,6 +154,11 @@ describe('utils', () => {
           meta: { type: 'ObjectExpression' },
           isNewStyle: true,
         },
+        'const rule = createESLintRule({ create() {}, meta: {} }); export default rule;': {
+          create: { type: 'FunctionExpression' },
+          meta: { type: 'ObjectExpression' },
+          isNewStyle: true,
+        },
 
         // Util function with "{} as const".
         'export default createESLintRule({ create() {}, meta: {} as const });': {
@@ -292,6 +302,11 @@ describe('utils', () => {
           meta: { type: 'ObjectExpression' },
           isNewStyle: true,
         },
+        'const rule = { create() {}, meta: {} }; module.exports = rule;': {
+          create: { type: 'FunctionExpression' },
+          meta: { type: 'ObjectExpression' },
+          isNewStyle: true,
+        },
       };
 
       Object.keys(CASES).forEach(ruleSource => {
@@ -330,6 +345,16 @@ describe('utils', () => {
           meta: { type: 'ObjectExpression' },
           isNewStyle: true,
         },
+        'const rule = { create() {}, meta: {} }; export default rule;': {
+          create: { type: 'FunctionExpression' },
+          meta: { type: 'ObjectExpression' },
+          isNewStyle: true,
+        },
+        'const create = function() {}; const meta = {}; const rule = { create, meta }; export default rule;': {
+          create: { type: 'FunctionExpression' },
+          meta: { type: 'ObjectExpression' },
+          isNewStyle: true,
+        },
 
         // ESM (function style)
         'export default function (context) { return {}; }': {