Repository: ember-cli/eslint-plugin-ember (js)

Issue:
New Rule: no-missing-dependent-keys
It would be useful if there was a way to lint the following:

bad:

```js
prop: computed('dogs', function() {
  return this.get('dogs') + this.get('cats');
}),
```

good:

```js
prop: computed('dogs', 'cats', function() {
  return this.get('dogs') + this.get('cats');
}),
```

I often run into this situation where a dependent key is not explicitly listed and the prop doesn't update correctly.

Pull request description:
Add new rule `require-computed-property-dependencies`
This rule detects and autofixes computed properties with missing dependencies that look like:
* `this.get('property')`
* `this.property` (ES5 getter)
* `this.getProperties('a', 'b')`
* `Ember.get(this, 'property')`

Fixes #307. CC: @mhluska @rwjblue 

---

Examples of **incorrect** code for this rule:

```js
import EmberObject, { computed } from '@ember/object';

export default EmberObject.extend({
  name: computed(function() {
    return `${this.firstName} ${this.lastName}`;
  })
});
```

Examples of **correct** code for this rule:


```js
import EmberObject, { computed } from '@ember/object';

export default EmberObject.extend({
  name: computed('firstName', 'lastName', function() {
    return `${this.firstName} ${this.lastName}`;
  })
});
```

Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index c549442b..5cecb4da 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -8,6 +8,7 @@ module.exports = {
   plugins: ['import', 'prettier'],
   extends: ['eslint:recommended', 'plugin:import/errors', 'plugin:import/warnings', 'prettier'],
   env: {
+    es6: true,
     node: true,
     jest: true,
   },
diff --git a/README.md b/README.md
index 5034174c..a0da7d88 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,7 @@ The `--fix` option on the command line automatically fixes problems reported by
 | :white_check_mark: | [no-ember-testing-in-module-scope](./docs/rules/no-ember-testing-in-module-scope.md) | Prevents use of Ember.testing in module scope |
 |  | [no-invalid-debug-function-arguments](./docs/rules/no-invalid-debug-function-arguments.md) | Catch usages of Ember's `assert()` / `warn()` / `deprecate()` functions that have the arguments passed in the wrong order. |
 | :white_check_mark: | [no-side-effects](./docs/rules/no-side-effects.md) | Warns about unexpected side effects in computed properties |
+| :wrench: | [require-computed-property-dependencies](./docs/rules/require-computed-property-dependencies.md) | Requires dependencies to be declared statically in computed properties |
 |  | [require-return-from-computed](./docs/rules/require-return-from-computed.md) | Warns about missing return statements in computed properties |
 | :white_check_mark: | [require-super-in-init](./docs/rules/require-super-in-init.md) | Enforces super calls in init hooks |
 | :white_check_mark: | [routes-segments-snake-case](./docs/rules/routes-segments-snake-case.md) | Enforces usage of snake_cased dynamic segments in routes |
diff --git a/docs/rules/require-computed-property-dependencies.md b/docs/rules/require-computed-property-dependencies.md
new file mode 100644
index 00000000..6fccec44
--- /dev/null
+++ b/docs/rules/require-computed-property-dependencies.md
@@ -0,0 +1,45 @@
+# require-computed-property-dependencies
+
+Computed properties should have their property dependencies listed out so that they can recompute upon changes.
+
+## Rule Details
+
+This rule requires dependencies to be declared statically in computed properties. Properties accessed within the computed property function that are not listed out are assumed to be missing dependencies. Various forms of accessing properties will be detected including:
+
+* `this.get('property')`
+* `this.getProperties('a', 'b')`
+* `Ember.get(this, 'property')`
+* `this.property` (ES5 getter)
+
+This rule has an autofixer that will automatically add missing dependencies to computed properties.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```js
+import EmberObject, { computed } from '@ember/object';
+
+export default EmberObject.extend({
+  name: computed(function() {
+    return `${this.firstName} ${this.lastName}`;
+  })
+});
+```
+
+Examples of **correct** code for this rule:
+
+
+```js
+import EmberObject, { computed } from '@ember/object';
+
+export default EmberObject.extend({
+  name: computed('firstName', 'lastName', function() {
+    return `${this.firstName} ${this.lastName}`;
+  })
+});
+```
+
+## References
+
+* [Guide](https://guides.emberjs.com/release/object-model/computed-properties/) for computed properties
diff --git a/lib/index.js b/lib/index.js
index 8d8756ee..a05fd1ac 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -48,6 +48,7 @@ module.exports = {
     'order-in-routes': require('./rules/order-in-routes'),
     'route-path-style': require('./rules/route-path-style'),
     'require-computed-macros': require('./rules/require-computed-macros'),
+    'require-computed-property-dependencies': require('./rules/require-computed-property-dependencies'),
     'require-return-from-computed': require('./rules/require-return-from-computed'),
     'require-super-in-init': require('./rules/require-super-in-init'),
     'routes-segments-snake-case': require('./rules/routes-segments-snake-case'),
diff --git a/lib/recommended-rules.js b/lib/recommended-rules.js
index 669824b6..21d8aa5e 100644
--- a/lib/recommended-rules.js
+++ b/lib/recommended-rules.js
@@ -50,6 +50,7 @@ module.exports = {
   "ember/order-in-models": "off",
   "ember/order-in-routes": "off",
   "ember/require-computed-macros": "off",
+  "ember/require-computed-property-dependencies": "off",
   "ember/require-return-from-computed": "off",
   "ember/require-super-in-init": "error",
   "ember/route-path-style": "off",
diff --git a/lib/rules/require-computed-macros.js b/lib/rules/require-computed-macros.js
index 02dc17df..bb85d91c 100644
--- a/lib/rules/require-computed-macros.js
+++ b/lib/rules/require-computed-macros.js
@@ -2,6 +2,7 @@
 
 const utils = require('../utils/utils');
 const emberUtils = require('../utils/ember');
+const propertyGetterUtils = require('../utils/property-getter');
 
 //------------------------------------------------------------------------------
 // Rule Definition
@@ -21,34 +22,6 @@ const ERROR_MESSAGE_LTE = makeErrorMessage('lte');
 const ERROR_MESSAGE_NOT = makeErrorMessage('not');
 const ERROR_MESSAGE_EQUAL = makeErrorMessage('equal');
 
-/**
- * Checks if a MemberExpression node looks like `this.x` or `this.x.y`.
- *
- * @param {Node} node The MemberExpression node to check.
- * @returns {boolean} Whether the node looks like `this.x` or `this.x.y`.
- */
-function isSimpleThisExpression(node) {
-  if (!utils.isMemberExpression(node)) {
-    return false;
-  }
-
-  let current = node;
-  while (current !== null) {
-    if (utils.isMemberExpression(current)) {
-      if (!utils.isIdentifier(current.property)) {
-        return false;
-      }
-      current = current.object;
-    } else if (utils.isThisExpression(current)) {
-      return true;
-    } else {
-      return false;
-    }
-  }
-
-  return false;
-}
-
 /**
  * Checks if a LogicalExpression looks like `this.x && this.y && this.z`
  * containing only simple `this.x` MemberExpression nodes.
@@ -64,14 +37,14 @@ function isSimpleThisExpressionsInsideLogicalExpression(node, operator) {
   let current = node;
   while (current !== null) {
     if (utils.isLogicalExpression(current)) {
-      if (!isSimpleThisExpression(current.right)) {
+      if (!propertyGetterUtils.isSimpleThisExpression(current.right)) {
         return false;
       }
       if (current.operator !== operator) {
         return false;
       }
       current = current.left;
-    } else if (isSimpleThisExpression(current)) {
+    } else if (propertyGetterUtils.isSimpleThisExpression(current)) {
       return true;
     } else {
       return false;
@@ -125,26 +98,12 @@ module.exports = {
   ERROR_MESSAGE_EQUAL,
 
   create(context) {
-    /**
-     * Converts a Node containing a ThisExpression to its dependent key.
-     *
-     * Example Input:   A Node with this source code: `this.x.y`
-     * Example Output:  'x.y'
-     *
-     * @param {Node} node a MemberExpression node that looks like `this.x` or `this.x.y`.
-     * @returns {String} The dependent key of the input node (without `this.`).
-     */
-    function nodeToDependentKey(nodeWithThisExpression) {
-      const sourceCode = context.getSourceCode();
-      return sourceCode.getText(nodeWithThisExpression).replace(/^this\./, '');
-    }
-
     function reportSingleArg(nodeComputedProperty, nodeWithThisExpression, macro) {
       context.report({
         node: nodeComputedProperty,
         message: makeErrorMessage(macro),
         fix(fixer) {
-          const text = nodeToDependentKey(nodeWithThisExpression);
+          const text = propertyGetterUtils.nodeToDependentKey(nodeWithThisExpression, context);
           return fixer.replaceText(nodeComputedProperty, `computed.${macro}('${text}')`);
         },
       });
@@ -156,7 +115,10 @@ module.exports = {
         message: makeErrorMessage(macro),
         fix(fixer) {
           const sourceCode = context.getSourceCode();
-          const textLeft = nodeToDependentKey(nodeBinaryExpression.left);
+          const textLeft = propertyGetterUtils.nodeToDependentKey(
+            nodeBinaryExpression.left,
+            context
+          );
           const textRight = sourceCode.getText(nodeBinaryExpression.right);
           return fixer.replaceText(
             nodeComputedProperty,
@@ -172,7 +134,7 @@ module.exports = {
         message: makeErrorMessage(macro),
         fix(fixer) {
           const text = getThisExpressions(nodeLogicalExpression)
-            .map(nodeToDependentKey)
+            .map(node => propertyGetterUtils.nodeToDependentKey(node, context))
             .join("', '");
           return fixer.replaceText(nodeComputedProperty, `computed.${macro}('${text}')`);
         },
@@ -207,7 +169,7 @@ module.exports = {
         if (
           utils.isUnaryExpression(statement) &&
           statement.operator === '!' &&
-          isSimpleThisExpression(statement.argument)
+          propertyGetterUtils.isSimpleThisExpression(statement.argument)
         ) {
           reportSingleArg(node, statement.argument, 'not');
         } else if (utils.isLogicalExpression(statement)) {
@@ -219,7 +181,7 @@ module.exports = {
         } else if (
           utils.isBinaryExpression(statement) &&
           utils.isLiteral(statement.right) &&
-          isSimpleThisExpression(statement.left)
+          propertyGetterUtils.isSimpleThisExpression(statement.left)
         ) {
           if (statement.operator === '===') {
             reportBinaryExpression(node, statement, 'equal');
@@ -232,7 +194,7 @@ module.exports = {
           } else if (statement.operator === '<=') {
             reportBinaryExpression(node, statement, 'lte');
           }
-        } else if (isSimpleThisExpression(statement)) {
+        } else if (propertyGetterUtils.isSimpleThisExpression(statement)) {
           reportSingleArg(node, statement, 'reads');
         }
       },
diff --git a/lib/rules/require-computed-property-dependencies.js b/lib/rules/require-computed-property-dependencies.js
new file mode 100644
index 00000000..67eb4781
--- /dev/null
+++ b/lib/rules/require-computed-property-dependencies.js
@@ -0,0 +1,460 @@
+'use strict';
+
+function getTraverser() {
+  let traverser;
+  try {
+    traverser = require('eslint/lib/shared/traverser'); // eslint >= 6
+  } catch (e) {
+    traverser = require('eslint/lib/util/traverser'); // eslint < 6
+  }
+  return traverser;
+}
+
+const Traverser = getTraverser();
+const utils = require('../utils/utils');
+const propertyGetterUtils = require('../utils/property-getter');
+
+/**
+ * Checks whether the node is an identifier and optionally, its name.
+ *
+ * @param {ASTNode} node
+ * @param {string=} name
+ * @returns {boolean}
+ */
+function isIdentifier(node, name) {
+  if (!utils.isIdentifier(node)) {
+    return false;
+  }
+
+  if (name) {
+    return node.name === name;
+  }
+
+  return true;
+}
+
+/**
+ * Determines whether a node is a simple member expression with the given object
+ * and property.
+ *
+ * @param {ASTNode} node
+ * @param {string} objectName
+ * @param {string} propertyName
+ * @returns {boolean}
+ */
+function isMemberExpression(node, objectName, propertyName) {
+  if (!objectName && !propertyName) {
+    return node && utils.isMemberExpression(node);
+  }
+
+  return (
+    node &&
+    utils.isMemberExpression(node) &&
+    !node.computed &&
+    (objectName === 'this'
+      ? utils.isThisExpression(node.object)
+      : isIdentifier(node.object, objectName)) &&
+    isIdentifier(node.property, propertyName)
+  );
+}
+
+/**
+ * @param {ASTNode} node
+ * @returns {boolean}
+ */
+function isEmberComputed(node) {
+  return isIdentifier(node, 'computed') || isMemberExpression(node, 'Ember', 'computed');
+}
+
+/**
+ * Builds an array by concatenating the results of a map.
+ *
+ * @template T, U
+ * @param {Array<T>} array
+ * @param {function(T): Array<U>} callback
+ * @returns {Array<U>}
+ */
+function flatMap(array, callback) {
+  return array.reduce((result, item) => result.concat(callback(item)), []);
+}
+
+/**
+ * Splits arguments to `Ember.computed` into string keys and dynamic keys.
+ *
+ * @param {Array<ASTNode>} args
+ * @returns {{keys: Array<ASTNode>, dynamicKeys: Array<ASTNode>}}
+ */
+function parseComputedDependencies(args) {
+  const keys = [];
+  const dynamicKeys = [];
+
+  for (let i = 0; i < args.length - 1; i++) {
+    const arg = args[i];
+
+    if (utils.isStringLiteral(arg)) {
+      keys.push(arg);
+    } else {
+      dynamicKeys.push(arg);
+    }
+  }
+
+  return { keys, dynamicKeys };
+}
+
+const ARRAY_PROPERTIES = new Set(['length', 'firstObject', 'lastObject']);
+
+/**
+ * Determines whether a computed property dependency matches a key path.
+ *
+ * @param {string} dependency
+ * @param {string} keyPath
+ * @returns {boolean}
+ */
+function computedPropertyDependencyMatchesKeyPath(dependency, keyPath) {
+  const dependencyParts = dependency.split('.');
+  const keyPathParts = keyPath.split('.');
+  const minLength = Math.min(dependencyParts.length, keyPathParts.length);
+
+  for (let i = 0; i < minLength; i++) {
+    const dependencyPart = dependencyParts[i];
+    const keyPathPart = keyPathParts[i];
+
+    if (dependencyPart === keyPathPart) {
+      continue;
+    }
+
+    // When dealing with arrays some keys encompass others. For example, `@each`
+    // encompasses `[]` and `length` because any `@each` is triggered on any
+    // array mutation as well as for some element property. `[]` is triggered
+    // only on array mutation and so will always be triggered when `@each` is.
+    // Similarly, `length` will always trigger if `[]` triggers and so is
+    // encompassed by it.
+    if (dependencyPart === '[]' || dependencyPart === '@each') {
+      const subordinateProperties = new Set(ARRAY_PROPERTIES);
+
+      if (dependencyPart === '@each') {
+        subordinateProperties.add('[]');
+      }
+
+      return (
+        !keyPathPart || (keyPathParts.length === i + 1 && subordinateProperties.has(keyPathPart))
+      );
+    }
+
+    return false;
+  }
+
+  // len(foo.bar.baz) > len(foo.bar), and so matches.
+  return dependencyParts.length > keyPathParts.length;
+}
+
+/**
+ * Recursively finds all calls to `Ember#get`, whether like `Ember.get(this, …)`
+ * or `this.get(…)`.
+ *
+ * @param {ASTNode} node
+ * @returns {Array<ASTNode>}
+ */
+function findEmberGetCalls(node) {
+  const results = [];
+
+  new Traverser().traverse(node, {
+    enter(child) {
+      if (utils.isCallExpression(child)) {
+        const dependency = extractEmberGetDependencies(child);
+
+        if (dependency.length > 0) {
+          results.push(child);
+        }
+      }
+    },
+  });
+
+  return results;
+}
+
+/**
+ * Recursively finds all `this.property` usages.
+ *
+ * @param {ASTNode} node
+ * @returns {Array<ASTNode>}
+ */
+function findThisGetCalls(node) {
+  const results = [];
+
+  new Traverser().traverse(node, {
+    enter(child) {
+      if (
+        utils.isMemberExpression(child) &&
+        !utils.isCallExpression(child.parent) &&
+        !utils.isMemberExpression(child.parent) &&
+        propertyGetterUtils.isSimpleThisExpression(child)
+      ) {
+        results.push(child);
+      }
+    },
+  });
+
+  return results;
+}
+
+/**
+ * Get an array argument's elements or the rest params if the values were not
+ * passed as a single array argument.
+ *
+ * @param {Array<ASTNode>} args
+ * @returns {Array<ASTNode>}
+ */
+function getArrayOrRest(args) {
+  if (args.length === 1 && utils.isArrayExpression(args[0])) {
+    return args[0].elements;
+  }
+  return args;
+}
+
+/**
+ * Extracts all static property keys used in the various forms of `Ember.get`.
+ *
+ * @param {ASTNode} call
+ * @returns {Array<string>}
+ */
+function extractEmberGetDependencies(call) {
+  if (
+    isMemberExpression(call.callee, 'this', 'get') ||
+    isMemberExpression(call.callee, 'this', 'getWithDefault')
+  ) {
+    const firstArg = call.arguments[0];
+
+    if (utils.isStringLiteral(firstArg)) {
+      return [firstArg.value];
+    }
+  } else if (
+    isMemberExpression(call.callee, 'Ember', 'get') ||
+    isMemberExpression(call.callee, 'Ember', 'getWithDefault')
+  ) {
+    const firstArg = call.arguments[0];
+    const secondArgument = call.arguments[1];
+
+    if (utils.isThisExpression(firstArg) && utils.isStringLiteral(secondArgument)) {
+      return [secondArgument.value];
+    }
+  } else if (isMemberExpression(call.callee, 'this', 'getProperties')) {
+    return getArrayOrRest(call.arguments)
+      .filter(utils.isStringLiteral)
+      .map(arg => arg.value);
+  } else if (isMemberExpression(call.callee, 'Ember', 'getProperties')) {
+    const firstArg = call.arguments[0];
+    const rest = call.arguments.slice(1);
+
+    if (utils.isThisExpression(firstArg)) {
+      return getArrayOrRest(rest)
+        .filter(utils.isStringLiteral)
+        .map(arg => arg.value);
+    }
+  }
+
+  return [];
+}
+
+function extractThisGetDependencies(memberExpression, context) {
+  return propertyGetterUtils.nodeToDependentKey(memberExpression, context);
+}
+
+/**
+ * Checks if the `key` is a prefix of any item in `keys`.
+ *
+ * Example:
+ *    `keys`: `['a', 'b.c']`
+ *    `key`: `'b'`
+ *    Result: `true`
+ *
+ * @param {String[]} keys - list of dependent keys
+ * @param {String} key - dependent key
+ * @returns boolean
+ */
+function keyExistsAsPrefixInList(keys, key) {
+  return keys.some(currentKey => currentKey.startsWith(`${key}.`));
+}
+
+function removeRedundantKeys(keys) {
+  return keys.filter(currentKey => !keyExistsAsPrefixInList(keys, currentKey));
+}
+
+const ERROR_MESSAGE_NON_STRING_VALUE = 'Non-string value used as computed property dependency';
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'Requires dependencies to be declared statically in computed properties',
+      category: 'Possible Errors',
+      recommended: false,
+    },
+
+    fixable: 'code',
+  },
+
+  ERROR_MESSAGE_NON_STRING_VALUE,
+
+  create(context) {
+    return {
+      CallExpression(node) {
+        if (isEmberComputed(node.callee) && node.arguments.length >= 1) {
+          const declaredDependencies = parseComputedDependencies(node.arguments);
+
+          declaredDependencies.dynamicKeys.forEach(key => {
+            context.report({
+              node: key,
+              message: ERROR_MESSAGE_NON_STRING_VALUE,
+            });
+          });
+
+          const computedPropertyFunction = node.arguments[node.arguments.length - 1];
+
+          const usedKeys1 = flatMap(
+            findEmberGetCalls(computedPropertyFunction.body),
+            extractEmberGetDependencies
+          );
+          const usedKeys2 = flatMap(findThisGetCalls(computedPropertyFunction.body), node => {
+            return extractThisGetDependencies(node, context);
+          });
+          const usedKeys = [...usedKeys1, ...usedKeys2];
+
+          const expandedDeclaredKeys = expandKeys(
+            declaredDependencies.keys.map(node => node.value)
+          );
+
+          const undeclaredKeys = usedKeys
+            .filter(usedKey =>
+              expandedDeclaredKeys.every(
+                declaredKey =>
+                  declaredKey !== usedKey &&
+                  !computedPropertyDependencyMatchesKeyPath(declaredKey, usedKey)
+              )
+            )
+            .reduce((keys, key) => {
+              if (keys.indexOf(key) < 0) {
+                keys.push(key);
+              }
+              return keys;
+            }, [])
+            .sort();
+
+          if (undeclaredKeys.length > 0) {
+            context.report({
+              node,
+              message: `Use of undeclared dependencies in computed property: ${undeclaredKeys.join(
+                ', '
+              )}`,
+              fix(fixer) {
+                const missingDependenciesAsArguments = dependencyKeys(
+                  removeRedundantKeys([...undeclaredKeys, ...expandedDeclaredKeys])
+                );
+
+                if (node.arguments.length > 1) {
+                  const firstDependency = node.arguments[0];
+                  const lastDependency = node.arguments[node.arguments.length - 2];
+
+                  return fixer.replaceTextRange(
+                    [firstDependency.range[0], lastDependency.range[1]],
+                    missingDependenciesAsArguments
+                  );
+                } else {
+                  return fixer.insertTextBefore(
+                    computedPropertyFunction,
+                    `${missingDependenciesAsArguments}, `
+                  );
+                }
+              },
+            });
+          }
+        }
+      },
+    };
+  },
+};
+
+/**
+ * ["foo.bar", "foo.baz", "quux.[]"] => "'foo.{bar,baz}', 'quux.[]'"
+ * @param {Array<string>} keys
+ * @returns string
+ */
+function dependencyKeys(keys) {
+  const uniqueKeys = Array.from(new Set(keys));
+
+  function isBare(key) {
+    return key.indexOf('.') === -1 || key.endsWith('[]');
+  }
+
+  const bareKeys = uniqueKeys.filter(isBare);
+  const rest = uniqueKeys.filter(key => !isBare(key));
+
+  const mapByParent = rest.reduce((mapByParent, key) => {
+    const [head, ...rest] = key.split('.').reverse();
+    const parent = rest.reverse().join('.');
+
+    mapByParent.set(parent, mapByParent.get(parent) || []);
+    mapByParent.get(parent).push(head);
+
+    return mapByParent;
+  }, new Map());
+
+  const joined = Array.from(mapByParent.keys()).map(parent => {
+    const children = mapByParent.get(parent);
+    if (children.length > 1) {
+      return `${parent}.{${children.sort().join(',')}}`;
+    }
+    return `${parent}.${children[0]}`;
+  });
+
+  return [...bareKeys, ...joined]
+    .sort()
+    .map(key => `'${key}'`)
+    .join(', ');
+}
+
+/**
+ * ["foo.{bar,baz}", "quux"] => ["foo.bar", "foo.baz", "quux"]
+ * @param {Array<string>} keys
+ * @returns {Array<string>}
+ */
+function expandKeys(keys) {
+  // aka flat map
+  return [].concat(...keys.map(expandKey));
+}
+
+/**
+ * @param {string} key
+ * @returns {Array<string>}
+ */
+function expandKey(key) {
+  return key
+    .split('.') // ["foo", "{bar,baz}"]
+    .map(part => part.replace(/\{|\}/g, '').split(',')) // [["foo"], ["baz", "baz"]]
+    .reduce(
+      (acc, nextParts) =>
+        // iteration 1 (["foo"]): do nothing (duplicate 0 times), resulting in acc === [["foo"]]
+        // iteration 2 (["bar", "baz"]): duplicate acc once, resulting in `[["foo"], ["foo"]]
+        duplicateArrays(acc, nextParts.length - 1).map((base, index) =>
+          // evenly distribute the parts across the repeated base keys.
+          // nextParts[0 % 2] => "bar"
+          // nextParts[1 % 2] => "baz"
+          base.concat(nextParts[index % nextParts.length])
+        ),
+      [[]]
+    ) // [["foo", "bar"], ["foo", "baz"]]
+    .map(expanded => expanded.join('.')); // ["foo.bar", "foo.baz"]
+}
+
+/**
+ * duplicateArrays([["a", "b"]], 2) -> [["a", "b"], ["a", "b"], ["a", "b"]]
+ * @param {Array<Array>} arr
+ * @param {number} times
+ * @returns {Array<Array>}
+ */
+function duplicateArrays(arr, times) {
+  const result = [];
+  for (let i = 0; i <= times; i++) {
+    result.push(...arr.map(a => a.slice(0)));
+  }
+  return result;
+}
diff --git a/lib/utils/property-getter.js b/lib/utils/property-getter.js
new file mode 100644
index 00000000..ea9fd15f
--- /dev/null
+++ b/lib/utils/property-getter.js
@@ -0,0 +1,48 @@
+const utils = require('./utils');
+
+/**
+ * Checks if a MemberExpression node looks like `this.x` or `this.x.y`.
+ *
+ * @param {Node} node The MemberExpression node to check.
+ * @returns {boolean} Whether the node looks like `this.x` or `this.x.y`.
+ */
+function isSimpleThisExpression(node) {
+  if (!utils.isMemberExpression(node)) {
+    return false;
+  }
+
+  let current = node;
+  while (current !== null) {
+    if (utils.isMemberExpression(current) && !current.computed) {
+      if (!utils.isIdentifier(current.property)) {
+        return false;
+      }
+      current = current.object;
+    } else if (utils.isThisExpression(current)) {
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  return false;
+}
+
+/**
+ * Converts a Node containing a ThisExpression to its dependent key.
+ *
+ * Example Input:   A Node with this source code: `this.x.y`
+ * Example Output:  'x.y'
+ *
+ * @param {Node} node a MemberExpression node that looks like `this.x` or `this.x.y`.
+ * @returns {String} The dependent key of the input node (without `this.`).
+ */
+function nodeToDependentKey(nodeWithThisExpression, context) {
+  const sourceCode = context.getSourceCode();
+  return sourceCode.getText(nodeWithThisExpression).replace(/^this\./, '');
+}
+
+module.exports = {
+  isSimpleThisExpression,
+  nodeToDependentKey,
+};
diff --git a/lib/utils/utils.js b/lib/utils/utils.js
index 2680b795..bee9c0c9 100644
--- a/lib/utils/utils.js
+++ b/lib/utils/utils.js
@@ -11,6 +11,7 @@ module.exports = {
   isObjectPattern,
   isArrayExpression,
   isFunctionExpression,
+  isExpressionStatement,
   isArrowFunctionExpression,
   isConciseArrowFunctionWithCallExpression,
   isNewExpression,
@@ -162,6 +163,16 @@ function isFunctionExpression(node) {
   return node !== undefined && node.type === 'FunctionExpression';
 }
 
+/**
+ * Check whether or not a node is an isExpressionStatement.
+ *
+ * @param {Object} node The node to check.
+ * @returns {boolean} Whether or not the node is an isExpressionStatement.
+ */
+function isExpressionStatement(node) {
+  return node !== undefined && node.type === 'ExpressionStatement';
+}
+
 /**
  * Check whether or not a node is an ArrowFunctionExpression.
  *


Test patch:
diff --git a/tests/lib/rules/require-computed-macros.js b/tests/lib/rules/require-computed-macros.js
index f0a6685b..27654b3b 100644
--- a/tests/lib/rules/require-computed-macros.js
+++ b/tests/lib/rules/require-computed-macros.js
@@ -37,6 +37,7 @@ ruleTester.run('require-computed-macros', rule, {
     'computed(function() { return this; })',
     'computed(function() { return SOME_VAR; })',
     'computed(function() { return this.prop[123]; })',
+    'computed(function() { return this.prop[i]; })',
     'computed(function() { return this.someFunction(); })',
     'computed(function() { return this.prop.someFunction(); })',
 
diff --git a/tests/lib/rules/require-computed-property-dependencies.js b/tests/lib/rules/require-computed-property-dependencies.js
new file mode 100644
index 00000000..fe1d0ad5
--- /dev/null
+++ b/tests/lib/rules/require-computed-property-dependencies.js
@@ -0,0 +1,440 @@
+'use strict';
+
+const rule = require('../../../lib/rules/require-computed-property-dependencies');
+const RuleTester = require('eslint').RuleTester;
+
+const { ERROR_MESSAGE_NON_STRING_VALUE } = rule;
+
+const ruleTester = new RuleTester();
+ruleTester.run('require-computed-property-dependencies', rule, {
+  valid: [
+    `
+      Ember.computed();
+    `,
+    `
+      Ember.computed(function() {});
+    `,
+    `
+      Ember.computed('unused', function() {});
+    `,
+    // Volatile:
+    `
+      Ember.computed('name', function() {
+        return this.get('name');
+      }).volatile()
+    `,
+    // ES5 getter usage:
+    `
+      Ember.computed('name', function() {
+        return this.name;
+      });
+    `,
+    `
+      Ember.computed('name', function() {
+        return this.get('name');
+      });
+    `,
+    `
+      Ember.computed(function() {
+        return this.someArray[i];
+      });
+    `,
+    // TODO: an improvement would be to detect the missing `someArray.[]` dependency key.
+    `
+      Ember.computed(function() {
+        return this.someArray[1];
+      });
+    `,
+    // Without `Ember.`:
+    `
+      computed('name', function() {
+        return this.get('name');
+      });
+    `,
+    `
+      Ember.computed('list.@each.foo', function() {
+        return this.get('list').map(function(item) {
+          return item.get('foo');
+        });
+      });
+    `,
+    `
+      Ember.computed('list.[]', function() {
+        return this.get('list.length');
+      });
+    `,
+    `
+      Ember.computed('deeper.than.needed.but.okay', function() {
+        return this.get('deeper');
+      });
+    `,
+    `
+      Ember.computed('array.[]', function() {
+        return this.get('array.firstObject');
+      });
+    `,
+    `
+      Ember.computed('array.[]', function() {
+        return this.get('array.lastObject');
+      });
+    `,
+    `
+      Ember.computed('foo.{bar,baz}', function() {
+        return this.get('foo.bar') + this.get('foo.baz');
+      });
+    `,
+    `
+      Ember.computed('foo.@each.{bar,baz}', function() {
+        return this.get('foo').mapBy('bar') + this.get('foo').mapBy('bar');
+      });
+    `,
+    // Computed macro that should be ignored:
+    `
+      Ember.computed.someMacro(function() {
+        return this.x;
+      })
+    `,
+    `
+      Ember.computed.someMacro('test')
+    `,
+    // Incorrect usage that should be ignored:
+    `
+      Ember.computed(123)
+    `,
+  ],
+  invalid: [
+    {
+      code: 'Ember.computed(dynamic, function() {});',
+      output: null,
+      errors: [
+        {
+          message: ERROR_MESSAGE_NON_STRING_VALUE,
+          type: 'Identifier',
+        },
+      ],
+    },
+    {
+      code: 'Ember.computed(...PROPERTIES, function() {});',
+      output: null,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: [
+        {
+          message: ERROR_MESSAGE_NON_STRING_VALUE,
+          type: 'SpreadElement',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return Ember.get(this, 'undeclared');
+        });
+      `,
+      output: `
+        Ember.computed('undeclared', function() {
+          return Ember.get(this, 'undeclared');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: undeclared',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return this.get('undeclared') + this.get('undeclared2');
+        });
+      `,
+      output: `
+        Ember.computed('undeclared', 'undeclared2', function() {
+          return this.get('undeclared') + this.get('undeclared2');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: undeclared, undeclared2',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    // Volatile:
+    {
+      code: `
+        Ember.computed(function() {
+          return this.get('undeclared');
+        }).volatile();
+      `,
+      output: `
+        Ember.computed('undeclared', function() {
+          return this.get('undeclared');
+        }).volatile();
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: undeclared',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    // ES5 getter usage:
+    {
+      code: `
+        Ember.computed(function() {
+          return this.undeclared + this.undeclared2;
+        });
+      `,
+      output: `
+        Ember.computed('undeclared', 'undeclared2', function() {
+          return this.undeclared + this.undeclared2;
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: undeclared, undeclared2',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    // ES5 getter usage with nesting:
+    {
+      code: `
+        Ember.computed(function() {
+          return this.a.b.c;
+        });
+      `,
+      output: `
+        Ember.computed('a.b.c', function() {
+          return this.a.b.c;
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: a.b.c',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      // Without `Ember.`:
+      code: `
+        computed(function() {
+          return this.get('undeclared') + this.get('undeclared2');
+        });
+      `,
+      output: `
+        computed('undeclared', 'undeclared2', function() {
+          return this.get('undeclared') + this.get('undeclared2');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: undeclared, undeclared2',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return this.get('undeclared') + this.get('undeclared');
+        });
+      `,
+      output: `
+        Ember.computed('undeclared', function() {
+          return this.get('undeclared') + this.get('undeclared');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: undeclared',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return this.get('foo.bar.baz');
+        });
+      `,
+      output: `
+        Ember.computed('foo.bar.baz', function() {
+          return this.get('foo.bar.baz');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: foo.bar.baz',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    // Ensure that the redundant key (`foo`) is removed.
+    {
+      code: `
+        Ember.computed('foo', function() {
+          return this.get('foo.bar.baz');
+        });
+      `,
+      output: `
+        Ember.computed('foo.bar.baz', function() {
+          return this.get('foo.bar.baz');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: foo.bar.baz',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return this.getProperties('a', dynamic, 'b', 'c');
+        });
+      `,
+      output: `
+        Ember.computed('a', 'b', 'c', function() {
+          return this.getProperties('a', dynamic, 'b', 'c');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: a, b, c',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return this.getProperties(['a', dynamic, 'b', 'c']);
+        });
+      `,
+      output: `
+        Ember.computed('a', 'b', 'c', function() {
+          return this.getProperties(['a', dynamic, 'b', 'c']);
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: a, b, c',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return Ember.getProperties(this, ['a', dynamic, 'b', 'c']);
+        });
+      `,
+      output: `
+        Ember.computed('a', 'b', 'c', function() {
+          return Ember.getProperties(this, ['a', dynamic, 'b', 'c']);
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: a, b, c',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return this.getWithDefault('maybe', {});
+        });
+      `,
+      output: `
+        Ember.computed('maybe', function() {
+          return this.getWithDefault('maybe', {});
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: maybe',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed(function() {
+          return Ember.getWithDefault(this, 'maybe', {});
+        });
+      `,
+      output: `
+        Ember.computed('maybe', function() {
+          return Ember.getWithDefault(this, 'maybe', {});
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: maybe',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed('constructor.bar', function() {
+          return this.get('constructor.bar') + this.get('constructor.baz');
+        });
+      `,
+      output: `
+        Ember.computed('constructor.{bar,baz}', function() {
+          return this.get('constructor.bar') + this.get('constructor.baz');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: constructor.baz',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `
+        Ember.computed('foo.bar', 'quux.[]', 'quux.length', function() {
+          return this.get('foo.bar') + this.get('foo.baz');
+        });
+      `,
+      output: `
+        Ember.computed('foo.{bar,baz}', 'quux.[]', 'quux.length', function() {
+          return this.get('foo.bar') + this.get('foo.baz');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: foo.baz',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      // don't expand @each.{foo,bar}
+      code: `
+        Ember.computed('foo.@each.{bar,baz}', function() {
+          return this.get('foo').mapBy('bar') + this.get('quux');
+        });
+      `,
+      output: `
+        Ember.computed('foo.@each.{bar,baz}', 'quux', function() {
+          return this.get('foo').mapBy('bar') + this.get('quux');
+        });
+      `,
+      errors: [
+        {
+          message: 'Use of undeclared dependencies in computed property: quux',
+          type: 'CallExpression',
+        },
+      ],
+    },
+  ],
+});
diff --git a/tests/lib/utils/property-getter-test.js.js b/tests/lib/utils/property-getter-test.js.js
new file mode 100644
index 00000000..705a1868
--- /dev/null
+++ b/tests/lib/utils/property-getter-test.js.js
@@ -0,0 +1,22 @@
+'use strict';
+
+const babelEslint = require('babel-eslint');
+const propertyGetterUtils = require('../../../lib/utils/property-getter');
+
+function parse(code) {
+  return babelEslint.parse(code).body[0].expression;
+}
+
+describe('isSimpleThisExpression', () => {
+  it('behaves correctly', () => {
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x'))).toBeTruthy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x.y'))).toBeTruthy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x().y'))).toBeFalsy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x()'))).toBeFalsy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x.y()'))).toBeFalsy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.get("property")'))).toBeFalsy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x[1]'))).toBeFalsy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x[i]'))).toBeFalsy();
+    expect(propertyGetterUtils.isSimpleThisExpression(parse('this.x.y[i]'))).toBeFalsy();
+  });
+});