Repository: not-an-aardvark/eslint-plugin-eslint-plugin (js)

Issue:
`require-meta-type` don't support spread operator
When filling `meta` object using a spread operator instead of explicitly set the fields, the eslint validator fails. It should look for the content of the spread and see that the required fields are there, instead of forcing to set them explicitly.

Pull request:
fix: handle properties behind spread syntax in `require-meta-*` rules
We're now able to take into account meta properties inside spreads in the `require-meta-*` rules.

1. Introduces new util function `evaluateObjectProperties` which gathers all properties from an object including those behind spreads.
2. Uses this new util function when checking for properties inside the meta object in the `require-meta-*` rules.

Fixes #250.

Example: this is now valid for the `require-meta-type` rule:

```
const extra = { type: 'problem' };
module.exports = {
    meta: { ...extra },
    create(context) {}
};
```

TODO:

- [x] Unit tests for `evaluateObjectProperties` util
- [x] Check for additional places we should evaluate spreads
- [x] Tests for autofixing behavior when properties are behind a spread


Patch:
diff --git a/lib/rules/require-meta-docs-description.js b/lib/rules/require-meta-docs-description.js
index d3346eb..226efa3 100644
--- a/lib/rules/require-meta-docs-description.js
+++ b/lib/rules/require-meta-docs-description.js
@@ -45,6 +45,7 @@ module.exports = {
     return {
       Program() {
         const sourceCode = context.getSourceCode();
+        const { scopeManager } = sourceCode;
         const info = utils.getRuleInfo(sourceCode);
 
         if (info === null) {
@@ -57,17 +58,13 @@ module.exports = {
             : DEFAULT_PATTERN;
 
         const metaNode = info.meta;
-        const docsNode =
-          metaNode &&
-          metaNode.properties &&
-          metaNode.properties.find(
-            (p) => p.type === 'Property' && utils.getKeyName(p) === 'docs'
-          );
+        const docsNode = utils
+          .evaluateObjectProperties(metaNode, scopeManager)
+          .find((p) => p.type === 'Property' && utils.getKeyName(p) === 'docs');
 
-        const descriptionNode =
-          docsNode &&
-          docsNode.value.properties &&
-          docsNode.value.properties.find(
+        const descriptionNode = utils
+          .evaluateObjectProperties(docsNode && docsNode.value, scopeManager)
+          .find(
             (p) =>
               p.type === 'Property' && utils.getKeyName(p) === 'description'
           );
diff --git a/lib/rules/require-meta-docs-url.js b/lib/rules/require-meta-docs-url.js
index 6c0dee2..4932467 100644
--- a/lib/rules/require-meta-docs-url.js
+++ b/lib/rules/require-meta-docs-url.js
@@ -50,7 +50,6 @@ module.exports = {
    */
   create(context) {
     const options = context.options[0] || {};
-    const sourceCode = context.getSourceCode();
     const filename = context.getFilename();
     const ruleName =
       filename === '<input>'
@@ -75,24 +74,24 @@ module.exports = {
 
     return {
       Program() {
+        const sourceCode = context.getSourceCode();
+        const { scopeManager } = sourceCode;
+
         const info = util.getRuleInfo(sourceCode);
         if (info === null) {
           return;
         }
 
         const metaNode = info.meta;
-        const docsPropNode =
-          metaNode &&
-          metaNode.properties &&
-          metaNode.properties.find(
-            (p) => p.type === 'Property' && util.getKeyName(p) === 'docs'
-          );
-        const urlPropNode =
-          docsPropNode &&
-          docsPropNode.value.properties &&
-          docsPropNode.value.properties.find(
-            (p) => p.type === 'Property' && util.getKeyName(p) === 'url'
-          );
+        const docsPropNode = util
+          .evaluateObjectProperties(metaNode, scopeManager)
+          .find((p) => p.type === 'Property' && util.getKeyName(p) === 'docs');
+        const urlPropNode = util
+          .evaluateObjectProperties(
+            docsPropNode && docsPropNode.value,
+            scopeManager
+          )
+          .find((p) => p.type === 'Property' && util.getKeyName(p) === 'url');
 
         const staticValue = urlPropNode
           ? getStaticValue(urlPropNode.value, context.getScope())
diff --git a/lib/rules/require-meta-fixable.js b/lib/rules/require-meta-fixable.js
index 506e761..e03d3ae 100644
--- a/lib/rules/require-meta-fixable.js
+++ b/lib/rules/require-meta-fixable.js
@@ -48,6 +48,7 @@ module.exports = {
       context.options[0] && context.options[0].catchNoFixerButFixableProperty;
 
     const sourceCode = context.getSourceCode();
+    const { scopeManager } = sourceCode;
     const ruleInfo = utils.getRuleInfo(sourceCode);
     let contextIdentifiers;
     let usesFixFunctions;
@@ -62,10 +63,7 @@ module.exports = {
 
     return {
       Program(ast) {
-        contextIdentifiers = utils.getContextIdentifiers(
-          sourceCode.scopeManager,
-          ast
-        );
+        contextIdentifiers = utils.getContextIdentifiers(scopeManager, ast);
       },
       CallExpression(node) {
         if (
@@ -86,11 +84,9 @@ module.exports = {
       'Program:exit'() {
         const metaFixableProp =
           ruleInfo &&
-          ruleInfo.meta &&
-          ruleInfo.meta.type === 'ObjectExpression' &&
-          ruleInfo.meta.properties.find(
-            (prop) => utils.getKeyName(prop) === 'fixable'
-          );
+          utils
+            .evaluateObjectProperties(ruleInfo.meta, scopeManager)
+            .find((prop) => utils.getKeyName(prop) === 'fixable');
 
         if (metaFixableProp) {
           const staticValue = getStaticValue(
diff --git a/lib/rules/require-meta-has-suggestions.js b/lib/rules/require-meta-has-suggestions.js
index ccaf93e..939b517 100644
--- a/lib/rules/require-meta-has-suggestions.js
+++ b/lib/rules/require-meta-has-suggestions.js
@@ -30,16 +30,14 @@ module.exports = {
 
   create(context) {
     const sourceCode = context.getSourceCode();
+    const { scopeManager } = sourceCode;
     const ruleInfo = utils.getRuleInfo(sourceCode);
     let contextIdentifiers;
     let ruleReportsSuggestions;
 
     return {
       Program(ast) {
-        contextIdentifiers = utils.getContextIdentifiers(
-          sourceCode.scopeManager,
-          ast
-        );
+        contextIdentifiers = utils.getContextIdentifiers(scopeManager, ast);
       },
       CallExpression(node) {
         if (
@@ -78,12 +76,9 @@ module.exports = {
       },
       'Program:exit'() {
         const metaNode = ruleInfo && ruleInfo.meta;
-        const hasSuggestionsProperty =
-          metaNode && metaNode.type === 'ObjectExpression'
-            ? metaNode.properties.find(
-                (prop) => utils.getKeyName(prop) === 'hasSuggestions'
-              )
-            : undefined;
+        const hasSuggestionsProperty = utils
+          .evaluateObjectProperties(metaNode, scopeManager)
+          .find((prop) => utils.getKeyName(prop) === 'hasSuggestions');
         const hasSuggestionsStaticValue =
           hasSuggestionsProperty &&
           getStaticValue(hasSuggestionsProperty.value, context.getScope());
diff --git a/lib/rules/require-meta-schema.js b/lib/rules/require-meta-schema.js
index 4f218f3..0789bc6 100644
--- a/lib/rules/require-meta-schema.js
+++ b/lib/rules/require-meta-schema.js
@@ -64,10 +64,9 @@ module.exports = {
       Program(ast) {
         contextIdentifiers = utils.getContextIdentifiers(scopeManager, ast);
 
-        schemaNode =
-          metaNode &&
-          metaNode.properties &&
-          metaNode.properties.find(
+        schemaNode = utils
+          .evaluateObjectProperties(metaNode, scopeManager)
+          .find(
             (p) => p.type === 'Property' && utils.getKeyName(p) === 'schema'
           );
 
diff --git a/lib/rules/require-meta-type.js b/lib/rules/require-meta-type.js
index 12082b3..d06e6d6 100644
--- a/lib/rules/require-meta-type.js
+++ b/lib/rules/require-meta-type.js
@@ -41,6 +41,7 @@ module.exports = {
     return {
       Program() {
         const sourceCode = context.getSourceCode();
+        const { scopeManager } = sourceCode;
         const info = utils.getRuleInfo(sourceCode);
 
         if (info === null) {
@@ -48,12 +49,9 @@ module.exports = {
         }
 
         const metaNode = info.meta;
-        const typeNode =
-          metaNode &&
-          metaNode.properties &&
-          metaNode.properties.find(
-            (p) => p.type === 'Property' && utils.getKeyName(p) === 'type'
-          );
+        const typeNode = utils
+          .evaluateObjectProperties(metaNode, scopeManager)
+          .find((p) => p.type === 'Property' && utils.getKeyName(p) === 'type');
 
         if (!typeNode) {
           context.report({
diff --git a/lib/utils.js b/lib/utils.js
index 6eef18a..2613875 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -738,4 +738,28 @@ module.exports = {
       ).suggest === parent.parent.parent
     );
   },
+
+  /**
+   * List all properties contained in an object.
+   * Evaluates and includes any properties that may be behind spreads.
+   * @param {Node} objectNode
+   * @param {ScopeManager} scopeManager
+   * @returns {Node[]} the list of all properties that could be found
+   */
+  evaluateObjectProperties(objectNode, scopeManager) {
+    if (!objectNode || objectNode.type !== 'ObjectExpression') {
+      return [];
+    }
+
+    return objectNode.properties.flatMap((property) => {
+      if (property.type === 'SpreadElement') {
+        const value = findVariableValue(property.argument, scopeManager);
+        if (value && value.type === 'ObjectExpression') {
+          return value.properties;
+        }
+        return [];
+      }
+      return [property];
+    });
+  },
 };


Test patch:
diff --git a/tests/lib/rules/require-meta-docs-description.js b/tests/lib/rules/require-meta-docs-description.js
index dc9849c..1b40d7e 100644
--- a/tests/lib/rules/require-meta-docs-description.js
+++ b/tests/lib/rules/require-meta-docs-description.js
@@ -11,7 +11,7 @@ const RuleTester = require('eslint').RuleTester;
 // Tests
 // ------------------------------------------------------------------------------
 
-const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
+const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 9 } });
 ruleTester.run('require-meta-docs-description', rule, {
   valid: [
     'foo()',
@@ -107,6 +107,15 @@ ruleTester.run('require-meta-docs-description', rule, {
         create(context) {}
       };
     `,
+    // Spread.
+    `
+      const extraDocs = { description: 'enforce foo' };
+      const extraMeta = { docs: { ...extraDocs } };
+      module.exports = {
+        meta: { ...extraMeta },
+        create(context) {}
+      };
+    `,
   ],
 
   invalid: [
diff --git a/tests/lib/rules/require-meta-docs-url.js b/tests/lib/rules/require-meta-docs-url.js
index e63a7cd..7aae009 100644
--- a/tests/lib/rules/require-meta-docs-url.js
+++ b/tests/lib/rules/require-meta-docs-url.js
@@ -155,6 +155,19 @@ tester.run('require-meta-docs-url', rule, {
         },
       ],
     },
+    {
+      // Spread.
+      filename: 'test-rule',
+      code: `
+        const extraDocs = { url: "path/to/test-rule.md" };
+        const extraMeta = { docs: { ...extraDocs } };
+        module.exports = {
+          meta: { ...extraMeta },
+          create() {}
+        }
+      `,
+      options: [{ pattern: 'path/to/{{name}}.md' }],
+    },
   ],
 
   invalid: [
@@ -624,6 +637,51 @@ url: "plugin-name/test.md"
       ],
       errors: [{ messageId: 'missing', type: 'ObjectExpression' }],
     },
+    {
+      // URL missing, spreads present.
+      filename: 'test.js',
+      code: `
+        const extraDocs = { };
+        const extraMeta = { docs: { ...extraDocs } };
+        module.exports = {
+          meta: { ...extraMeta },
+          create() {}
+        }
+      `,
+      output: `
+        const extraDocs = { };
+        const extraMeta = { docs: { ...extraDocs,
+url: "plugin-name/test.md" } };
+        module.exports = {
+          meta: { ...extraMeta },
+          create() {}
+        }
+      `,
+      options: [{ pattern: 'plugin-name/{{ name }}.md' }],
+      errors: [{ messageId: 'missing', type: 'ObjectExpression' }],
+    },
+    {
+      // URL wrong inside spreads.
+      filename: 'test.js',
+      code: `
+        const extraDocs = { url: 'wrong' };
+        const extraMeta = { docs: { ...extraDocs } };
+        module.exports = {
+          meta: { ...extraMeta },
+          create() {}
+        }
+      `,
+      output: `
+        const extraDocs = { url: "plugin-name/test.md" };
+        const extraMeta = { docs: { ...extraDocs } };
+        module.exports = {
+          meta: { ...extraMeta },
+          create() {}
+        }
+      `,
+      options: [{ pattern: 'plugin-name/{{ name }}.md' }],
+      errors: [{ messageId: 'mismatch', type: 'Literal' }],
+    },
     {
       // CJS file extension
       filename: 'test.cjs',
diff --git a/tests/lib/rules/require-meta-fixable.js b/tests/lib/rules/require-meta-fixable.js
index 3fe3857..75de844 100644
--- a/tests/lib/rules/require-meta-fixable.js
+++ b/tests/lib/rules/require-meta-fixable.js
@@ -16,7 +16,7 @@ const RuleTester = require('eslint').RuleTester;
 // Tests
 // ------------------------------------------------------------------------------
 
-const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
+const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 9 } });
 ruleTester.run('require-meta-fixable', rule, {
   valid: [
     // No `meta`.
@@ -189,6 +189,16 @@ ruleTester.run('require-meta-fixable', rule, {
       `,
       options: [{ catchNoFixerButFixableProperty: true }],
     },
+    // Spread.
+    `
+      const extra = { 'fixable': 'code' };
+      module.exports = {
+        meta: { ...extra },
+        create(context) {
+          context.report({node, message, fix: foo});
+        }
+      };
+    `,
   ],
 
   invalid: [
diff --git a/tests/lib/rules/require-meta-has-suggestions.js b/tests/lib/rules/require-meta-has-suggestions.js
index 8c357f1..fa3adc6 100644
--- a/tests/lib/rules/require-meta-has-suggestions.js
+++ b/tests/lib/rules/require-meta-has-suggestions.js
@@ -11,7 +11,7 @@ const RuleTester = require('eslint').RuleTester;
 // Tests
 // ------------------------------------------------------------------------------
 
-const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
+const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 9 } });
 ruleTester.run('require-meta-has-suggestions', rule, {
   valid: [
     'module.exports = context => { return {}; };',
@@ -171,7 +171,7 @@ ruleTester.run('require-meta-has-suggestions', rule, {
         }
       };
     `,
-    // Spread syntax.
+    // Unrelated spread syntax.
     {
       code: `
         const extra = {};
@@ -185,6 +185,16 @@ ruleTester.run('require-meta-has-suggestions', rule, {
         ecmaVersion: 9,
       },
     },
+    // Related spread.
+    `
+      const extra = { hasSuggestions: true };
+      module.exports = {
+        meta: { ...extra },
+        create(context) {
+          context.report({node, message, suggest: [{}]});
+        }
+      };
+    `,
   ],
 
   invalid: [
diff --git a/tests/lib/rules/require-meta-schema.js b/tests/lib/rules/require-meta-schema.js
index 789aff9..a0c2a4d 100644
--- a/tests/lib/rules/require-meta-schema.js
+++ b/tests/lib/rules/require-meta-schema.js
@@ -11,7 +11,7 @@ const RuleTester = require('eslint').RuleTester;
 // Tests
 // ------------------------------------------------------------------------------
 
-const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
+const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 9 } });
 ruleTester.run('require-meta-schema', rule, {
   valid: [
     `
@@ -109,6 +109,14 @@ ruleTester.run('require-meta-schema', rule, {
       code: 'module.exports = { create(context) {} };',
       options: [{ requireSchemaPropertyWhenOptionless: false }],
     },
+    // Spread.
+    `
+      const extra = { schema: [] };
+      module.exports = {
+        meta: { ...extra },
+        create(context) {}
+      };
+    `,
   ],
 
   invalid: [
diff --git a/tests/lib/rules/require-meta-type.js b/tests/lib/rules/require-meta-type.js
index cb05f6d..e4bfbd7 100644
--- a/tests/lib/rules/require-meta-type.js
+++ b/tests/lib/rules/require-meta-type.js
@@ -16,7 +16,7 @@ const RuleTester = require('eslint').RuleTester;
 // Tests
 // ------------------------------------------------------------------------------
 
-const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
+const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 9 } });
 ruleTester.run('require-meta-type', rule, {
   valid: [
     `
@@ -76,6 +76,14 @@ ruleTester.run('require-meta-type', rule, {
       `,
       errors: [{ messageId: 'missing' }],
     },
+    // Spread.
+    `
+      const extra = { type: 'problem' };
+      module.exports = {
+        meta: { ...extra },
+        create(context) {}
+      };
+    `,
   ],
 
   invalid: [
diff --git a/tests/lib/utils.js b/tests/lib/utils.js
index 83a57d5..9b79770 100644
--- a/tests/lib/utils.js
+++ b/tests/lib/utils.js
@@ -1274,4 +1274,81 @@ describe('utils', () => {
       });
     });
   });
+
+  describe('evaluateObjectProperties', function () {
+    it('behaves correctly with simple object expression', function () {
+      const ast = espree.parse('const obj = { a: 123, b: foo() };', {
+        ecmaVersion: 9,
+        range: true,
+      });
+      const scopeManager = eslintScope.analyze(ast);
+      const result = utils.evaluateObjectProperties(
+        ast.body[0].declarations[0].init,
+        scopeManager
+      );
+      assert.deepEqual(result, ast.body[0].declarations[0].init.properties);
+    });
+
+    it('behaves correctly with spreads of objects', function () {
+      const ast = espree.parse(
+        `
+        const extra1 = { a: 123 };
+        const extra2 = { b: 456 };
+        const obj = { ...extra1, c: 789, ...extra2 };
+        `,
+        {
+          ecmaVersion: 9,
+          range: true,
+        }
+      );
+      const scopeManager = eslintScope.analyze(ast);
+      const result = utils.evaluateObjectProperties(
+        ast.body[2].declarations[0].init,
+        scopeManager
+      );
+      assert.deepEqual(result, [
+        ...ast.body[0].declarations[0].init.properties, // First spread properties
+        ...ast.body[2].declarations[0].init.properties.filter(
+          (property) => property.type !== 'SpreadElement'
+        ), // Non-spread properties
+        ...ast.body[1].declarations[0].init.properties, // Second spread properties
+      ]);
+    });
+
+    it('behaves correctly with non-variable spreads', function () {
+      const ast = espree.parse(`function foo() {} const obj = { ...foo() };`, {
+        ecmaVersion: 9,
+        range: true,
+      });
+      const scopeManager = eslintScope.analyze(ast);
+      const result = utils.evaluateObjectProperties(
+        ast.body[1].declarations[0].init,
+        scopeManager
+      );
+      assert.deepEqual(result, []);
+    });
+
+    it('behaves correctly with spread with variable that cannot be found', function () {
+      const ast = espree.parse(`const obj = { ...foo };`, {
+        ecmaVersion: 9,
+        range: true,
+      });
+      const scopeManager = eslintScope.analyze(ast);
+      const result = utils.evaluateObjectProperties(
+        ast.body[0].declarations[0].init,
+        scopeManager
+      );
+      assert.deepEqual(result, []);
+    });
+
+    it('behaves correctly when passed wrong node type', function () {
+      const ast = espree.parse(`foo();`, {
+        ecmaVersion: 9,
+        range: true,
+      });
+      const scopeManager = eslintScope.analyze(ast);
+      const result = utils.evaluateObjectProperties(ast.body[0], scopeManager);
+      assert.deepEqual(result, []);
+    });
+  });
 });