Repository: ember-cli/eslint-plugin-ember
Pull request #883 (2020-07-05 20:07:33)

Handle computed property macros in `no-assignment-of-untracked-properties-used-in-tracking-contexts` rule
Updates the rule to gather the list of tracked dependent keys from computed property macros like `readOnly` or `computed.readOnly` (in addition to `computed`).

CC: @mongoose700 


Patch:
diff --git a/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js b/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
index 2a13e35e..d688cf6e 100644
--- a/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
+++ b/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
@@ -3,12 +3,11 @@
 const emberUtils = require('../utils/ember');
 const types = require('../utils/types');
 const decoratorUtils = require('../utils/decorators');
-const javascriptUtils = require('../utils/javascript');
 const propertySetterUtils = require('../utils/property-setter');
-const assert = require('assert');
 const { getImportIdentifier } = require('../utils/import');
+const { getMacros } = require('../utils/computed-property-macros');
 const {
-  expandKeys,
+  findComputedPropertyDependentKeys,
   keyExistsAsPrefixInList,
 } = require('../utils/computed-property-dependent-keys');
 
@@ -16,80 +15,22 @@ const ERROR_MESSAGE =
   "Use `set(this, 'propertyName', 'value')` instead of assignment for untracked properties that are used as computed property dependencies (or convert to using tracked properties).";
 
 /**
- * Gets the list of string dependent keys from a computed property.
- *
- * @param {Node} node - the computed property node
- * @returns {String[]} - the list of string dependent keys from this computed property
- */
-function getComputedPropertyDependentKeys(node) {
-  if (!node.arguments) {
-    return [];
-  }
-
-  return expandKeys(
-    node.arguments
-      .filter((arg) => arg.type === 'Literal' && typeof arg.value === 'string')
-      .map((node) => node.value)
-  );
-}
-
-/**
- * Gets a list of computed property dependency keys used inside a class.
+ * Gets a set of tracked properties used inside a class.
  *
  * @param {Node} nodeClass - Node for the class
- * @returns {String[]} - list of dependent keys used inside the class
- */
-function findComputedPropertyDependentKeys(nodeClass, computedImportName) {
-  if (types.isClassDeclaration(nodeClass)) {
-    // Native JS class.
-    return javascriptUtils.flatMap(nodeClass.body.body, (node) => {
-      const computedDecorator = decoratorUtils.findDecorator(node, computedImportName);
-      if (computedDecorator) {
-        return getComputedPropertyDependentKeys(computedDecorator.expression);
-      } else {
-        return [];
-      }
-    });
-  } else if (types.isCallExpression(nodeClass)) {
-    // Classic class.
-    return javascriptUtils.flatMap(
-      nodeClass.arguments.filter(types.isObjectExpression),
-      (classObject) => {
-        return javascriptUtils.flatMap(classObject.properties, (node) => {
-          if (
-            types.isProperty(node) &&
-            emberUtils.isComputedProp(node.value) &&
-            node.value.arguments
-          ) {
-            return getComputedPropertyDependentKeys(node.value);
-          } else {
-            return [];
-          }
-        });
-      }
-    );
-  } else {
-    assert(false, 'Unexpected node type for a class.');
-  }
-
-  return [];
-}
-
-/**
- * Gets a list of tracked properties used inside a class.
- *
- * @param {Node} nodeClass - Node for the class
- * @returns {String[]} - list of tracked properties used inside the class
+ * @returns {Set<string>} - set of tracked properties used inside the class
  */
 function findTrackedProperties(nodeClassDeclaration, trackedImportName) {
-  return nodeClassDeclaration.body.body
-    .filter(
-      (node) =>
-        types.isClassProperty(node) &&
-        decoratorUtils.hasDecorator(node, trackedImportName) &&
-        types.isIdentifier(node.key)
-    )
-    .map((node) => node.key.name);
+  return new Set(
+    nodeClassDeclaration.body.body
+      .filter(
+        (node) =>
+          types.isClassProperty(node) &&
+          decoratorUtils.hasDecorator(node, trackedImportName) &&
+          types.isIdentifier(node.key)
+      )
+      .map((node) => node.key.name)
+  );
 }
 
 class Stack {
@@ -137,6 +78,7 @@ module.exports = {
     let computedImportName = undefined;
     let trackedImportName = undefined;
     let setImportName = undefined;
+    let macroImportNames = new Map();
 
     // State being tracked for the current class we're inside.
     const classStack = new Stack();
@@ -147,6 +89,14 @@ module.exports = {
           computedImportName =
             computedImportName || getImportIdentifier(node, '@ember/object', 'computed');
           setImportName = setImportName || getImportIdentifier(node, '@ember/object', 'set');
+        } else if (node.source.value === '@ember/object/computed') {
+          macroImportNames = new Map(
+            getMacros().map((macro) => [
+              macro,
+              macroImportNames.get(macro) ||
+                getImportIdentifier(node, '@ember/object/computed', macro),
+            ])
+          );
         } else if (node.source.value === '@glimmer/tracking') {
           trackedImportName =
             trackedImportName || getImportIdentifier(node, '@glimmer/tracking', 'tracked');
@@ -156,12 +106,14 @@ module.exports = {
       // Native JS class:
       ClassDeclaration(node) {
         // Gather computed property dependent keys from this class.
-        const computedPropertyDependentKeys = new Set(
-          findComputedPropertyDependentKeys(node, computedImportName)
+        const computedPropertyDependentKeys = findComputedPropertyDependentKeys(
+          node,
+          computedImportName,
+          macroImportNames
         );
 
         // Gather tracked properties from this class.
-        const trackedProperties = new Set(findTrackedProperties(node, trackedImportName));
+        const trackedProperties = findTrackedProperties(node, trackedImportName);
 
         // Keep track of whether we're inside a Glimmer component.
         const isGlimmerComponent = emberUtils.isGlimmerComponent(context, node);
@@ -178,8 +130,10 @@ module.exports = {
         // Classic class:
         if (emberUtils.isAnyEmberCoreModule(context, node)) {
           // Gather computed property dependent keys from this class.
-          const computedPropertyDependentKeys = new Set(
-            findComputedPropertyDependentKeys(node, computedImportName)
+          const computedPropertyDependentKeys = findComputedPropertyDependentKeys(
+            node,
+            computedImportName,
+            macroImportNames
           );
 
           // No tracked properties in classic classes.
diff --git a/lib/utils/computed-property-dependent-keys.js b/lib/utils/computed-property-dependent-keys.js
index a9d3a396..8ff4f9d5 100644
--- a/lib/utils/computed-property-dependent-keys.js
+++ b/lib/utils/computed-property-dependent-keys.js
@@ -1,11 +1,22 @@
 'use strict';
 
 const javascriptUtils = require('./javascript');
+const { getName } = require('../utils/utils');
+const types = require('../utils/types');
+const decoratorUtils = require('../utils/decorators');
+const assert = require('assert');
+const {
+  getMacrosFromImportNames,
+  getTrackedArgumentCount,
+  macroToCanonicalName,
+} = require('../utils/computed-property-macros');
 
 module.exports = {
   collapseKeys,
   expandKeys,
   expandKey,
+  findComputedPropertyDependentKeys,
+  getComputedPropertyDependentKeys,
   computedPropertyDependencyMatchesKeyPath,
   keyExistsAsPrefixInList,
 };
@@ -161,3 +172,99 @@ function computedPropertyDependencyMatchesKeyPath(dependency, keyPath) {
 function keyExistsAsPrefixInList(keys, key) {
   return keys.some((currentKey) => computedPropertyDependencyMatchesKeyPath(currentKey, key));
 }
+
+/**
+ * Gets the set of computed property dependency keys used inside a class.
+ *
+ * @param {Node} nodeClass - Node for the class
+ * @returns {Set<string>} - set of dependent keys used inside the class
+ */
+function findComputedPropertyDependentKeys(nodeClass, computedImportName, macroImportNames) {
+  if (types.isClassDeclaration(nodeClass)) {
+    // Native JS class.
+    return new Set(
+      javascriptUtils.flatMap(nodeClass.body.body, (node) => {
+        // Check for `computed` itself.
+        const computedDecorator = decoratorUtils.findDecorator(node, computedImportName);
+        if (computedDecorator) {
+          return getComputedPropertyDependentKeys(computedDecorator.expression);
+        }
+
+        // Check for a computed macro.
+        const macroNames = getMacrosFromImportNames(computedImportName, macroImportNames);
+        const macroDecorator = decoratorUtils.findDecoratorByNameCallback(node, (decoratorName) =>
+          macroNames.has(decoratorName)
+        );
+        if (macroDecorator) {
+          return getComputedPropertyDependentKeys(
+            macroDecorator.expression,
+            getTrackedArgumentCount(
+              macroToCanonicalName(
+                decoratorUtils.getDecoratorName(macroDecorator),
+                macroImportNames
+              )
+            )
+          );
+        }
+
+        return [];
+      })
+    );
+  } else if (types.isCallExpression(nodeClass)) {
+    // Classic class.
+    return new Set(
+      javascriptUtils.flatMap(
+        nodeClass.arguments.filter(types.isObjectExpression),
+        (classObject) => {
+          return javascriptUtils.flatMap(classObject.properties, (node) => {
+            if (types.isProperty(node)) {
+              const name = getName(node.value);
+              if (!name) {
+                return [];
+              }
+
+              // Check for `computed` itself.
+              if (name === computedImportName) {
+                return getComputedPropertyDependentKeys(node.value);
+              }
+
+              // Check for a computed macro.
+              const macroNames = getMacrosFromImportNames(computedImportName, macroImportNames);
+              if (macroNames.has(name)) {
+                return getComputedPropertyDependentKeys(
+                  node.value,
+                  getTrackedArgumentCount(macroToCanonicalName(name, macroImportNames))
+                );
+              }
+            }
+            return [];
+          });
+        }
+      )
+    );
+  } else {
+    assert(false, 'Unexpected node type for a class.');
+  }
+
+  return new Set();
+}
+
+/**
+ * Gets the list of string dependent keys from a computed property.
+ *
+ * @param {Node} node - the computed property node
+ * @param {number} dependentKeyArgumentCount - number of arguments to check for dependent keys
+ * @returns {String[]} - the list of string dependent keys from this computed property
+ */
+function getComputedPropertyDependentKeys(node, dependentKeyArgumentCount = Number.MAX_VALUE) {
+  if (!node.arguments) {
+    return [];
+  }
+
+  return expandKeys(
+    node.arguments
+      .slice(0, dependentKeyArgumentCount)
+      .filter((arg) => arg.type === 'Literal' && typeof arg.value === 'string')
+      .map((node) => node.value)
+  );
+}
diff --git a/lib/utils/computed-property-macros.js b/lib/utils/computed-property-macros.js
new file mode 100644
index 00000000..1443aa16
--- /dev/null
+++ b/lib/utils/computed-property-macros.js
@@ -0,0 +1,94 @@
+const assert = require('assert');
+
+module.exports = {
+  getMacros,
+  getMacrosFromImportNames,
+  getTrackedArgumentCount,
+  macroToCanonicalName,
+};
+
+/**
+ * Example macros:
+ * - and('x', 'y', 'z') can have any number of tracked dependent key arguments
+ * - mapBy('chores', 'done', true) only has a single tracked dependent key argument
+ */
+const MACROS_TO_TRACKED_ARGUMENT_COUNT = {
+  alias: 1,
+  and: Number.MAX_VALUE,
+  bool: 1,
+  collect: Number.MAX_VALUE,
+  deprecatingAlias: 1,
+  empty: 1,
+  equal: 1,
+  filter: 1,
+  filterBy: 1,
+  gt: 1,
+  gte: 1,
+  intersect: Number.MAX_VALUE,
+  lt: 1,
+  lte: 1,
+  map: 1,
+  mapBy: 1,
+  match: 1,
+  max: 1,
+  min: 1,
+  none: 1,
+  not: 1,
+  notEmpty: 1,
+  oneWay: 1,
+  or: Number.MAX_VALUE,
+  readOnly: 1,
+  reads: 1,
+  setDiff: 2,
+  sort: 1,
+  sum: Number.MAX_VALUE,
+  union: Number.MAX_VALUE,
+  uniq: 1,
+  uniqBy: 1,
+};
+
+/**
+ * @returns {string[]} - a list of all macros.
+ */
+function getMacros() {
+  return Object.keys(MACROS_TO_TRACKED_ARGUMENT_COUNT);
+}
+
+/**
+ * Example of return value: ['and', 'readOnly', 'computed.and', 'computed.readOnly', ...]
+ *
+ * @param {string} computedImportName - name that computed is imported under
+ * @param {set<string>} macroImportNames
+ * @returns {set<string>} - a set containing all possible macro names to check for.
+ */
+function getMacrosFromImportNames(computedImportName, macroImportNames) {
+  return new Set([
+    ...macroImportNames.values(),
+    ...(computedImportName ? getMacros().map((macro) => `${computedImportName}.${macro}`) : []),
+  ]);
+}
+
+/**
+ * @param {string} macro
+ * @returns {number} - the number arguments that are tracked dependent keys for a given macro.
+ */
+function getTrackedArgumentCount(macro) {
+  assert(typeof macro === 'string', 'macro parameter should be a string');
+
+  return MACROS_TO_TRACKED_ARGUMENT_COUNT[macro];
+}
+
+/**
+ * @param {string} macro - imported macro name
+ * @param {map<string,string>} macroImportNames
+ * @returns {string} the original name of the imported macro
+ */
+function macroToCanonicalName(macro, macroImportNames) {
+  assert(typeof macro === 'string', 'macro parameter should be a string');
+
+  return macro.includes('.')
+    ? macro.slice(macro.lastIndexOf('.') + 1) // Removes the leading `computed.`
+    : [...macroImportNames.keys()].find(
+        (canonicalName) => macroImportNames.get(canonicalName) === macro
+      );
+}
diff --git a/lib/utils/decorators.js b/lib/utils/decorators.js
index c78d66f6..c01a43e2 100644
--- a/lib/utils/decorators.js
+++ b/lib/utils/decorators.js
@@ -1,11 +1,24 @@
+const { getName } = require('./utils');
 const types = require('./types');
+const assert = require('assert');
 
 module.exports = {
+  getDecoratorName,
   findDecorator,
+  findDecoratorByNameCallback,
   hasDecorator,
   isClassPropertyWithDecorator,
 };
 
+/**
+ * @param {node} node - decorator
+ * @returns {string} - name of decorator (i.e. `computed`, `computed.readOnly`)
+ */
+function getDecoratorName(node) {
+  assert(types.isDecorator(node), 'must call this function on a Decorator');
+  return getName(node.expression);
+}
+
 /**
  * Finds the decorator with the given name.
  *
@@ -16,13 +29,20 @@ module.exports = {
 function findDecorator(node, decoratorName) {
   return (
     node.decorators &&
-    node.decorators.find(
-      (decorator) =>
-        (types.isIdentifier(decorator.expression) && decorator.expression.name === decoratorName) ||
-        (types.isCallExpression(decorator.expression) &&
-          types.isIdentifier(decorator.expression.callee) &&
-          decorator.expression.callee.name === decoratorName)
-    )
+    node.decorators.find((decorator) => getDecoratorName(decorator) === decoratorName)
+  );
+}
+
+/**
+ * Finds the decorator that returns true for the given decorator name callback function.
+ *
+ * @param {Node} node The node to check.
+ * @param {function} callback The callback function to check against each decorator name.
+ * @returns {Node} The decorator found.
+ */
+function findDecoratorByNameCallback(node, callback) {
+  return (
+    node.decorators && node.decorators.find((decorator) => callback(getDecoratorName(decorator)))
   );
 }
 
@@ -38,13 +58,7 @@ function hasDecorator(node, decoratorName) {
     return false;
   }
 
-  return node.decorators.some((decorator) => {
-    const expression = decorator.expression;
-    return (
-      (types.isIdentifier(expression) && expression.name === decoratorName) ||
-      (types.isCallExpression(expression) && expression.callee.name === decoratorName)
-    );
-  });
+  return node.decorators.some((decorator) => getDecoratorName(decorator) === decoratorName);
 }
 
 /**
diff --git a/lib/utils/utils.js b/lib/utils/utils.js
index 87f33990..49070e17 100644
--- a/lib/utils/utils.js
+++ b/lib/utils/utils.js
@@ -8,6 +8,8 @@ const {
   isMemberExpression,
   isNewExpression,
   isObjectPattern,
+  isOptionalCallExpression,
+  isOptionalMemberExpression,
 } = require('../utils/types');
 
 module.exports = {
@@ -15,6 +17,7 @@ module.exports = {
   findNodes,
   findUnorderedProperty,
   getAncestor,
+  getName,
   getPropertyValue,
   getSize,
   isEmptyMethod,
@@ -222,6 +225,27 @@ function getAncestor(node, predicate) {
   return null;
 }
 
+/**
+ * Examples:
+ * x -> x
+ * x() -> x
+ * x.y -> x.y
+ * x.y() -> x.y
+ *
+ * @param {node} node
+ * @returns {string}
+ */
+function getName(node) {
+  if (isIdentifier(node)) {
+    return node.name;
+  } else if (isCallExpression(node) || isOptionalCallExpression(node)) {
+    return getName(node.callee);
+  } else if (isMemberExpression(node) || isOptionalMemberExpression(node)) {
+    return `${getName(node.object)}.${getName(node.property)}`;
+  }
+  return undefined;
+}
+
 /**
  * Check whether a node is inside the left side of an AssignmentExpression.
  *


Test patch:
diff --git a/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js b/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
index fe91e043..d10144fd 100644
--- a/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
+++ b/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
@@ -21,6 +21,9 @@ const ruleTester = new RuleTester({
 
 ruleTester.run('no-assignment-of-untracked-properties-used-in-tracking-contexts', rule, {
   valid: [
+    // **********************
+    // Native class
+    // **********************
     {
       // Assignment of property which is not used as a dependent key.
       code: `
@@ -129,7 +132,21 @@ ruleTester.run('no-assignment-of-untracked-properties-used-in-tracking-contexts'
       filename: '/components/foo.js',
     },
     {
-      // Classic class.
+      // Without computed imports.
+      code: `
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('x') get prop1() {}
+        @readOnly('y') get prop2() {}
+        myFunction() { this.x = 123; this.y = 456; }
+      }`,
+      filename: '/components/foo.js',
+    },
+
+    // **********************
+    // Classic class
+    // **********************
+    {
       code: `
       import { computed } from '@ember/object';
       import Component from '@ember/component';
@@ -145,8 +162,23 @@ ruleTester.run('no-assignment-of-untracked-properties-used-in-tracking-contexts'
       })`,
       filename: '/components/foo.js',
     },
+    {
+      // Imports present but no computed properties.
+      code: `
+      import { readOnly, equal, gt } from '@ember/object/computed';
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      Component.extends({
+        actions: {},
+      });`,
+      filename: '/components/foo.js',
+    },
   ],
   invalid: [
+    // **********************
+    // Native class
+    // **********************
+
     {
       // Assignment of dependent key property.
       code: `
@@ -312,8 +344,84 @@ import { computed } from '@ember/object';
       filename: '/components/foo.js',
       errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
     },
+
+    // **********************
+    // Macros (native class)
+    // **********************
+
     {
-      // Assignment of dependent key property (classic class).
+      // Simple macro (`readOnly`), renamed
+      code: `
+      import { computed } from '@ember/object';
+      import { readOnly as ro } from '@ember/object/computed';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @ro('x') get myProp() {}
+        myFunctionX() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      import { readOnly as ro } from '@ember/object/computed';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @ro('x') get myProp() {}
+        myFunctionX() { set(this, 'x', 123); }
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Simple macro (`computed.readOnly`), with `computed` rename
+      code: `
+      import { computed as c } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @c.readOnly('x') get myProp() {}
+        myFunctionX() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+      output: `
+      import { set } from '@ember/object';
+import { computed as c } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @c.readOnly('x') get myProp() {}
+        myFunctionX() { set(this, 'x', 123); }
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Macro with non-tracked arguments (`mapBy`)
+      code: `
+      import { computed } from '@ember/object';
+      import { mapBy } from '@ember/object/computed';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @mapBy('chores', 'done', true) get myProp() {}
+        myFunction1() { this.chores = 123; }
+        myFunction2() { this.done = 123; } // Allowed since this isn't a dependent key.
+      }`,
+      filename: '/components/foo.js',
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      import { mapBy } from '@ember/object/computed';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @mapBy('chores', 'done', true) get myProp() {}
+        myFunction1() { set(this, 'chores', 123); }
+        myFunction2() { this.done = 123; } // Allowed since this isn't a dependent key.
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+
+    // **********************
+    // Classic class
+    // **********************
+
+    {
+      // Assignment of dependent key property
       code: `
       import { computed } from '@ember/object';
       import Component from '@ember/component';
@@ -340,5 +448,73 @@ import { computed } from '@ember/object';
       filename: '/components/foo.js',
       errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
     },
+
+    // **********************
+    // Macros (classic class)
+    // **********************
+
+    {
+      // Simple macro (`readOnly`), renamed
+      code: `
+      import { readOnly as ro } from '@ember/object/computed';
+      import Component from '@ember/component';
+      Component.extends({
+        myProp: ro('x'),
+        myFunction() { this.x = 123; }
+      })`,
+      output: `
+      import { set } from '@ember/object';
+import { readOnly as ro } from '@ember/object/computed';
+      import Component from '@ember/component';
+      Component.extends({
+        myProp: ro('x'),
+        myFunction() { set(this, 'x', 123); }
+      })`,
+      filename: '/components/foo.js',
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Simple macro (`computed.readOnly`), with `computed` rename
+      code: `
+      import { computed as c } from '@ember/object';
+      import Component from '@ember/component';
+      Component.extends({
+        myProp: c.readOnly('x'),
+        myFunction() { this.x = 123; }
+      })`,
+      output: `
+      import { set } from '@ember/object';
+import { computed as c } from '@ember/object';
+      import Component from '@ember/component';
+      Component.extends({
+        myProp: c.readOnly('x'),
+        myFunction() { set(this, 'x', 123); }
+      })`,
+      filename: '/components/foo.js',
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+
+    {
+      // Macro with non-tracked arguments (`mapBy`)
+      code: `
+      import { mapBy } from '@ember/object/computed';
+      import Component from '@ember/component';
+      Component.extends({
+        myProp: mapBy('chores', 'done', true),
+        myFunction1() { this.chores = 123; },
+        myFunction2() { this.done = 123; } // Allowed since this isn't a dependent key.
+      })`,
+      output: `
+      import { set } from '@ember/object';
+import { mapBy } from '@ember/object/computed';
+      import Component from '@ember/component';
+      Component.extends({
+        myProp: mapBy('chores', 'done', true),
+        myFunction1() { set(this, 'chores', 123); },
+        myFunction2() { this.done = 123; } // Allowed since this isn't a dependent key.
+      })`,
+      filename: '/components/foo.js',
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
   ],
 });
diff --git a/tests/lib/utils/computed-property-dependent-keys-test.js b/tests/lib/utils/computed-property-dependent-keys-test.js
index 7a9ef4bf..c6809b7e 100644
--- a/tests/lib/utils/computed-property-dependent-keys-test.js
+++ b/tests/lib/utils/computed-property-dependent-keys-test.js
@@ -1,6 +1,7 @@
 'use strict';
 
 const cpdkUtils = require('../../../lib/utils/computed-property-dependent-keys');
+const babelEslint = require('babel-eslint');
 
 describe('collapseKeys', () => {
   it('returns the right result', () => {
@@ -68,3 +69,69 @@ describe('keyExistsAsPrefixInList', () => {
     expect(cpdkUtils.keyExistsAsPrefixInList(['a', 'b.c'], 'b')).toStrictEqual(true);
   });
 });
+
+describe('findComputedPropertyDependentKeys', () => {
+  it('returns the right result with native class', () => {
+    const computedImportName = 'c';
+    const macroImportNames = new Map([
+      ['readOnly', 'readOnlyRenamed'],
+      ['mapBy', 'mapBy'],
+    ]);
+    const classNode = babelEslint.parse(`
+    import { computed as c } from '@ember/object';
+    import { readOnly as readOnlyRenamed, mapBy } from '@ember/object/computed';
+    import Component from '@ember/component';
+    class MyClass extends Component {
+      @c('x') get myProp() {}
+      @c('x', 'x') get myProp() {} // Intentional duplicate dependent key.
+      @c() get myProp() {}
+      @readOnlyRenamed('y') get myProp() {}
+      @c.readOnly('z') get myProp() {}
+      @mapBy('chores', 'done', true) get myProp() {}
+      @random() get myProp() {}
+      @random('') get myProp() {}
+      @random('z') get myProp() {}
+      @computed('bad') get myProp() {} // Wrong name.
+      @readOnly('bad') get myProp() {} // Wrong name.
+    }`).body[3];
+    expect([
+      ...cpdkUtils.findComputedPropertyDependentKeys(
+        classNode,
+        computedImportName,
+        macroImportNames
+      ),
+    ]).toStrictEqual(['x', 'y', 'z', 'chores']);
+  });
+
+  it('returns the right result with classic class', () => {
+    const computedImportName = 'c';
+    const macroImportNames = new Map([
+      ['readOnly', 'readOnlyRenamed'],
+      ['mapBy', 'mapBy'],
+    ]);
+    const classNode = babelEslint.parse(`
+    import { computed as c } from '@ember/object';
+    import { readOnly as readOnlyRenamed, mapBy } from '@ember/object/computed';
+    import Component from '@ember/component';
+    Component.extend({
+      prop1: c('x'),
+      prop2: c('x', 'x'), // Intentional duplicate dependent key.
+      prop3: c(),
+      prop4: readOnlyRenamed('y'),
+      prop5: c?.readOnly('z'), // Macro plus optional expression.
+      prop6: mapBy('chores', 'done', true),
+      prop7: random(),
+      prop8: random(''),
+      prop9: random('z'),
+      prop10: computed('bad'), // Wrong name.
+      prop11: readOnly('bad'), // Wrong name.
+    });`).body[3].expression;
+    expect([
+      ...cpdkUtils.findComputedPropertyDependentKeys(
+        classNode,
+        computedImportName,
+        macroImportNames
+      ),
+    ]).toStrictEqual(['x', 'y', 'z', 'chores']);
+  });
+});
diff --git a/tests/lib/utils/computed-property-macros-test.js b/tests/lib/utils/computed-property-macros-test.js
new file mode 100644
index 00000000..3e51144c
--- /dev/null
+++ b/tests/lib/utils/computed-property-macros-test.js
@@ -0,0 +1,47 @@
+const {
+  getMacros,
+  getMacrosFromImportNames,
+  getTrackedArgumentCount,
+  macroToCanonicalName,
+} = require('../../../lib/utils/computed-property-macros');
+
+describe('getMacros', () => {
+  it('returns some of the correct macros', () => {
+    expect(getMacros()).toStrictEqual(expect.arrayContaining(['and', 'readOnly']));
+  });
+});
+
+describe('getMacrosFromImportNames', () => {
+  it('returns some of the correct macros', () => {
+    const result = [
+      ...getMacrosFromImportNames('computedRenamed', new Map([['readOnly', 'readOnlyRenamed']])),
+    ];
+    expect(result).toStrictEqual(
+      expect.arrayContaining(['computedRenamed.readOnly', 'readOnlyRenamed'])
+    );
+  });
+});
+
+describe('getTrackedArgumentCount', () => {
+  it('returns the correct number for some example macros', () => {
+    expect(getTrackedArgumentCount('and')).toStrictEqual(Number.MAX_VALUE);
+    expect(getTrackedArgumentCount('readOnly')).toStrictEqual(1);
+  });
+});
+
+describe('macroToCanonicalName', () => {
+  it('returns the correct canonical name for some example macros', () => {
+    const macroImportNames = new Map([
+      ['and', 'and'],
+      ['readOnly', 'readOnlyRenamed'],
+    ]);
+
+    expect(macroToCanonicalName('and', macroImportNames)).toStrictEqual('and');
+    expect(macroToCanonicalName('readOnlyRenamed', macroImportNames)).toStrictEqual('readOnly');
+
+    expect(macroToCanonicalName('computed.readOnly', macroImportNames)).toStrictEqual('readOnly');
+    expect(macroToCanonicalName('computedRenamed.readOnly', macroImportNames)).toStrictEqual(
+      'readOnly'
+    );
+  });
+});
diff --git a/tests/lib/utils/decorators-test.js b/tests/lib/utils/decorators-test.js
index dffb66a9..6f5f29a2 100644
--- a/tests/lib/utils/decorators-test.js
+++ b/tests/lib/utils/decorators-test.js
@@ -1,6 +1,24 @@
 const babelEslint = require('babel-eslint');
 const decoratorUtils = require('../../../lib/utils/decorators');
 
+describe('getDecoratorName', () => {
+  it('should return decorator name with Identifier', () => {
+    const node = babelEslint.parse('class Test { @computed get prop() {} }').body[0].body.body[0];
+    expect(decoratorUtils.getDecoratorName(node.decorators[0])).toStrictEqual('computed');
+  });
+
+  it('should return decorator name with CallExpression', () => {
+    const node = babelEslint.parse('class Test { @computed() get prop() {} }').body[0].body.body[0];
+    expect(decoratorUtils.getDecoratorName(node.decorators[0])).toStrictEqual('computed');
+  });
+
+  it('should return decorator name with MemberExpression', () => {
+    const node = babelEslint.parse('class Test { @computed.readOnly() get prop() {} }').body[0].body
+      .body[0];
+    expect(decoratorUtils.getDecoratorName(node.decorators[0])).toStrictEqual('computed.readOnly');
+  });
+});
+
 describe('findDecorator', () => {
   it('should not find anything with non-decorator', () => {
     const node = babelEslint.parse('const x = 123').body[0];
@@ -21,6 +39,28 @@ describe('findDecorator', () => {
     const node = babelEslint.parse('class Test { @computed() get prop() {} }').body[0].body.body[0];
     expect(decoratorUtils.findDecorator(node, 'computed')).toBeTruthy();
   });
+
+  it('should find something with MemberExpression decorator', () => {
+    const node = babelEslint.parse('class Test { @computed.readOnly() get prop() {} }').body[0].body
+      .body[0];
+    expect(decoratorUtils.findDecorator(node, 'computed.readOnly')).toBeTruthy();
+  });
+});
+
+describe('findDecoratorByNameCallback', () => {
+  it('should not find anything with callback checking for wrong name', () => {
+    const node = babelEslint.parse('class Test { @computed get prop() {} }').body[0].body.body[0];
+    expect(
+      decoratorUtils.findDecoratorByNameCallback(node, (name) => name === 'random')
+    ).toBeUndefined();
+  });
+
+  it('should find decorator with callback checking for correct name', () => {
+    const node = babelEslint.parse('class Test { @computed get prop() {} }').body[0].body.body[0];
+    expect(
+      decoratorUtils.findDecoratorByNameCallback(node, (name) => name === 'computed')
+    ).toStrictEqual(node.decorators[0]);
+  });
 });
 
 describe('hasDecorator', () => {
diff --git a/tests/lib/utils/utils-test.js b/tests/lib/utils/utils-test.js
index 0eda6d0f..13d94cb2 100644
--- a/tests/lib/utils/utils-test.js
+++ b/tests/lib/utils/utils-test.js
@@ -88,6 +88,18 @@ describe('function sort order', function () {
   });
 });
 
+describe('getName', () => {
+  it('should behave correctly', () => {
+    expect(utils.getName(parse('x'))).toStrictEqual('x');
+    expect(utils.getName(parse('x()'))).toStrictEqual('x');
+    expect(utils.getName(parse('x?.()'))).toStrictEqual('x');
+    expect(utils.getName(parse('x.y'))).toStrictEqual('x.y');
+    expect(utils.getName(parse('x?.y'))).toStrictEqual('x.y');
+    expect(utils.getName(parse('x.y()'))).toStrictEqual('x.y');
+    expect(utils.getName(parse('x?.y?.()'))).toStrictEqual('x.y');
+  });
+});
+
 describe('getPropertyValue', () => {
   const simpleObject = {
     foo: true,