Repository: ember-cli/eslint-plugin-ember
Pull request #855 (2020-06-16 18:27:44)

Add new rule `no-assignment-of-untracked-properties-used-in-tracking-contexts`
See rule documentation file for explanation and examples.

CC: @mongoose700 
CC: @pzuraq who added this assertion originally

bmish: Considering renaming this to `no-untracked-unassignments`.
bmish: @rwjblue I'm planning to rename this rule to `no-assignment-of-untracked-properties-used-in-tracking-contexts` to be more accurate and to allow it to be expanded to handle other tracking contexts beyond just computed properties (such as observers) in the future. Let me know if you have any thoughts.
rwjblue: Ya, that sounds good to me. In theory, this could apply to any `{{this.foo}}` in a template too (and I honestly think we could make that work).

Patch:
diff --git a/README.md b/README.md
index ccb563d4..61012818 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,7 @@ Rules are grouped by category to help you understand their purpose. Each rule ha
 |:---|:--------|:------------|
 |  | [computed-property-getters](./docs/rules/computed-property-getters.md) | enforce the consistent use of getters in computed properties |
 | :white_check_mark: | [no-arrow-function-computed-properties](./docs/rules/no-arrow-function-computed-properties.md) | disallow arrow functions in computed properties |
+| :wrench: | [no-assignment-of-untracked-properties-used-in-tracking-contexts](./docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md) | disallow assignment of untracked properties that are used as computed property dependencies |
 | :car: | [no-computed-properties-in-native-classes](./docs/rules/no-computed-properties-in-native-classes.md) | disallow using computed properties in native classes |
 | :white_check_mark: | [no-deeply-nested-dependent-keys-with-each](./docs/rules/no-deeply-nested-dependent-keys-with-each.md) | disallow usage of deeply-nested computed property dependent keys with `@each` |
 | :white_check_mark::wrench: | [no-duplicate-dependent-keys](./docs/rules/no-duplicate-dependent-keys.md) | disallow repeating computed property dependent keys |
diff --git a/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md b/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md
new file mode 100644
index 00000000..148a86ba
--- /dev/null
+++ b/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md
@@ -0,0 +1,76 @@
+# no-assignment-of-untracked-properties-used-in-tracking-contexts
+
+:wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+Ember 3.13 added an assertion that fires when using assignment `this.x = 123` on an untracked property that is used in a tracking context such as a computed property.
+
+> You attempted to update "propertyX" to "valueY",
+but it is being tracked by a tracking context, such as a template, computed property, or observer.
+>
+> In order to make sure the context updates properly, you must invalidate the property when updating it.
+>
+> You can mark the property as `@tracked`, or use `@ember/object#set` to do this.
+
+## Rule Details
+
+This rule catches assignments of untracked properties that are used as computed property dependency keys.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```js
+import { computed } from '@ember/object';
+import Component from '@ember/component';
+
+class MyComponent extends Component {
+  @computed('x') get myProp() {
+    return this.x;
+  }
+  myFunction() {
+    this.x = 123; // Not okay to use assignment here.
+  }
+}
+```
+
+Examples of **correct** code for this rule:
+
+```js
+import { computed, set } from '@ember/object';
+import Component from '@ember/component';
+
+class MyComponent extends Component {
+  @computed('x') get myProp() {
+    return this.x;
+  }
+  myFunction() {
+    set(this, 'x', 123); // Okay because it uses set.
+  }
+}
+```
+
+```js
+import { computed, set } from '@ember/object';
+import Component from '@ember/component';
+import { tracked } from '@glimmer/tracking';
+
+class MyComponent extends Component {
+  @tracked x;
+  @computed('x') get myProp() {
+    return this.x;
+  }
+  myFunction() {
+    this.x = 123; // Okay because `x` is a tracked property.
+  }
+}
+```
+
+## Migration
+
+The autofixer for this rule will update assignments to use `set`. Alternatively, you can begin using tracked properties.
+
+## References
+
+* [Spec](https://api.emberjs.com/ember/release/functions/@ember%2Fobject/set) for `set()`
+* [Spec](https://api.emberjs.com/ember/3.16/functions/@glimmer%2Ftracking/tracked) for `@tracked`
+* [Guide](https://guides.emberjs.com/release/upgrading/current-edition/tracked-properties/) for tracked properties
diff --git a/lib/index.js b/lib/index.js
index e86767f0..2502aab2 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -14,6 +14,7 @@ module.exports = {
     'new-module-imports': require('./rules/new-module-imports'),
     'no-actions-hash': require('./rules/no-actions-hash'),
     'no-arrow-function-computed-properties': require('./rules/no-arrow-function-computed-properties'),
+    'no-assignment-of-untracked-properties-used-in-tracking-contexts': require('./rules/no-assignment-of-untracked-properties-used-in-tracking-contexts'),
     'no-attrs-in-components': require('./rules/no-attrs-in-components'),
     'no-attrs-snapshot': require('./rules/no-attrs-snapshot'),
     'no-capital-letters-in-routes': require('./rules/no-capital-letters-in-routes'),
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
new file mode 100644
index 00000000..2a13e35e
--- /dev/null
+++ b/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
@@ -0,0 +1,276 @@
+'use strict';
+
+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 {
+  expandKeys,
+  keyExistsAsPrefixInList,
+} = require('../utils/computed-property-dependent-keys');
+
+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.
+ *
+ * @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
+ */
+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);
+}
+
+class Stack {
+  constructor() {
+    this.stack = new Array();
+  }
+  pop() {
+    return this.stack.pop();
+  }
+  push(item) {
+    this.stack.push(item);
+  }
+  peek() {
+    return this.stack.length > 0 ? this.stack[this.stack.length - 1] : undefined;
+  }
+  size() {
+    return this.stack.length;
+  }
+}
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description:
+        'disallow assignment of untracked properties that are used as computed property dependencies',
+      category: 'Computed Properties',
+      recommended: false,
+      url:
+        'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md',
+    },
+    fixable: 'code',
+    schema: [],
+  },
+
+  ERROR_MESSAGE,
+
+  create(context) {
+    if (emberUtils.isTestFile(context.getFilename())) {
+      // This rule does not apply to test files.
+      return {};
+    }
+
+    // State being tracked for this file.
+    let computedImportName = undefined;
+    let trackedImportName = undefined;
+    let setImportName = undefined;
+
+    // State being tracked for the current class we're inside.
+    const classStack = new Stack();
+
+    return {
+      ImportDeclaration(node) {
+        if (node.source.value === '@ember/object') {
+          computedImportName =
+            computedImportName || getImportIdentifier(node, '@ember/object', 'computed');
+          setImportName = setImportName || getImportIdentifier(node, '@ember/object', 'set');
+        } else if (node.source.value === '@glimmer/tracking') {
+          trackedImportName =
+            trackedImportName || getImportIdentifier(node, '@glimmer/tracking', 'tracked');
+        }
+      },
+
+      // Native JS class:
+      ClassDeclaration(node) {
+        // Gather computed property dependent keys from this class.
+        const computedPropertyDependentKeys = new Set(
+          findComputedPropertyDependentKeys(node, computedImportName)
+        );
+
+        // Gather tracked properties from this class.
+        const trackedProperties = new Set(findTrackedProperties(node, trackedImportName));
+
+        // Keep track of whether we're inside a Glimmer component.
+        const isGlimmerComponent = emberUtils.isGlimmerComponent(context, node);
+
+        classStack.push({
+          node,
+          computedPropertyDependentKeys,
+          trackedProperties,
+          isGlimmerComponent,
+        });
+      },
+
+      CallExpression(node) {
+        // Classic class:
+        if (emberUtils.isAnyEmberCoreModule(context, node)) {
+          // Gather computed property dependent keys from this class.
+          const computedPropertyDependentKeys = new Set(
+            findComputedPropertyDependentKeys(node, computedImportName)
+          );
+
+          // No tracked properties in classic classes.
+          const trackedProperties = new Set();
+
+          // Keep track of whether we're inside a Glimmer component.
+          const isGlimmerComponent = emberUtils.isGlimmerComponent(context, node);
+
+          classStack.push({
+            node,
+            computedPropertyDependentKeys,
+            trackedProperties,
+            isGlimmerComponent,
+          });
+        }
+      },
+
+      'ClassDeclaration:exit'(node) {
+        if (classStack.size() > 0 && classStack.peek().node === node) {
+          // Leaving current (native) class.
+          classStack.pop();
+        }
+      },
+
+      'CallExpression:exit'(node) {
+        if (classStack.size() > 0 && classStack.peek().node === node) {
+          // Leaving current (classic) class.
+          classStack.pop();
+        }
+      },
+
+      AssignmentExpression(node) {
+        if (classStack.size() === 0) {
+          // Not inside a class.
+          return;
+        }
+
+        // Ensure this is an assignment with `this.x = ` or `this.x.y = `.
+        if (!propertySetterUtils.isThisSet(node)) {
+          return;
+        }
+
+        const currentClass = classStack.peek();
+
+        const sourceCode = context.getSourceCode();
+        const nodeTextLeft = sourceCode.getText(node.left);
+        const nodeTextRight = sourceCode.getText(node.right);
+        const propertyName = nodeTextLeft.replace('this.', '');
+
+        if (currentClass.isGlimmerComponent && propertyName.startsWith('args.')) {
+          // The Glimmer component args hash is automatically tracked so ignored it.
+          return;
+        }
+
+        if (
+          !currentClass.computedPropertyDependentKeys.has(propertyName) &&
+          !keyExistsAsPrefixInList(
+            [...currentClass.computedPropertyDependentKeys.keys()],
+            propertyName
+          )
+        ) {
+          // Haven't seen this property as a computed property dependent key so ignore it.
+          return;
+        }
+
+        if (currentClass.trackedProperties.has(propertyName)) {
+          // Assignment is fine with tracked properties so ignore it.
+          return;
+        }
+
+        context.report({
+          node,
+          message: ERROR_MESSAGE,
+          fix(fixer) {
+            if (setImportName) {
+              // `set` is already imported.
+              return fixer.replaceText(
+                node,
+                `${setImportName}(this, '${propertyName}', ${nodeTextRight})`
+              );
+            } else {
+              // Need to add an import statement for `set`.
+              const sourceCode = context.getSourceCode();
+              return [
+                fixer.insertTextBefore(sourceCode.ast, "import { set } from '@ember/object';\n"),
+                fixer.replaceText(node, `set(this, '${propertyName}', ${nodeTextRight})`),
+              ];
+            }
+          },
+        });
+      },
+    };
+  },
+};
diff --git a/lib/utils/ember.js b/lib/utils/ember.js
index 5c068fa9..c30d4202 100644
--- a/lib/utils/ember.js
+++ b/lib/utils/ember.js
@@ -16,6 +16,7 @@ module.exports = {
   isTestFile,
 
   isEmberCoreModule,
+  isAnyEmberCoreModule,
   isEmberComponent,
   isGlimmerComponent,
   isEmberController,
@@ -24,6 +25,8 @@ module.exports = {
   isEmberService,
   isEmberArrayProxy,
   isEmberObjectProxy,
+  isEmberObject,
+  isEmberHelper,
   isEmberProxy,
 
   isSingleLineFn,
@@ -74,6 +77,8 @@ const CORE_MODULE_IMPORT_PATHS = {
   Service: '@ember/service',
   ArrayProxy: '@ember/array/proxy',
   ObjectProxy: '@ember/object/proxy',
+  EmberObject: '@ember/object',
+  Helper: '@ember/component/helper',
 };
 
 function isClassicEmberCoreModule(node, module, filePath) {
@@ -222,6 +227,21 @@ function isEmberCoreModule(context, node, moduleName) {
   return false;
 }
 
+function isAnyEmberCoreModule(context, node) {
+  return (
+    isEmberComponent(context, node) ||
+    isGlimmerComponent(context, node) ||
+    isEmberController(context, node) ||
+    isEmberMixin(context, node) ||
+    isEmberRoute(context, node) ||
+    isEmberService(context, node) ||
+    isEmberArrayProxy(context, node) ||
+    isEmberObjectProxy(context, node) ||
+    isEmberObject(context, node) ||
+    isEmberHelper(context, node)
+  );
+}
+
 function isEmberComponent(context, node) {
   return isEmberCoreModule(context, node, 'Component');
 }
@@ -254,6 +274,14 @@ function isEmberObjectProxy(context, node) {
   return isEmberCoreModule(context, node, 'ObjectProxy');
 }
 
+function isEmberObject(context, node) {
+  return isEmberCoreModule(context, node, 'EmberObject');
+}
+
+function isEmberHelper(context, node) {
+  return isEmberCoreModule(context, node, 'Helper');
+}
+
 function isEmberProxy(context, node) {
   return isEmberArrayProxy(context, node) || isEmberObjectProxy(context, node);
 }


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
new file mode 100644
index 00000000..fe91e043
--- /dev/null
+++ b/tests/lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.js
@@ -0,0 +1,344 @@
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts');
+const RuleTester = require('eslint').RuleTester;
+
+const { ERROR_MESSAGE } = rule;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+  parser: require.resolve('babel-eslint'),
+  parserOptions: {
+    ecmaVersion: 6,
+    sourceType: 'module',
+  },
+});
+
+ruleTester.run('no-assignment-of-untracked-properties-used-in-tracking-contexts', rule, {
+  valid: [
+    {
+      // Assignment of property which is not used as a dependent key.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed get prop1() {}
+        @computed() get prop2() {}
+        @computed('random') get prop3() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+    },
+    {
+      // Assignment of tracked property.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      import { tracked } from '@glimmer/tracking';
+      class MyClass extends Component {
+        @tracked x
+        @computed('x') get prop() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+    },
+    {
+      // Assignment of tracked property (with aliased `tracked` import).
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      import { tracked as t } from '@glimmer/tracking';
+      class MyClass extends Component {
+        @t x
+        @computed('x') get prop() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+    },
+    {
+      // Assignment of property in the Glimmer component args hash which is automatically tracked.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@glimmer/component';
+      class MyClass extends Component {
+        @computed('args.x') get prop() {}
+        myFunction() { this.args.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+    },
+    {
+      // Assignment of dependent key property but outside the class.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('x') get prop() {}
+      }
+      this.x = 123;`,
+      filename: '/components/foo.js',
+    },
+    {
+      // Assignment missing `this.`.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('x') get prop() {}
+        myFunction() { x = 123; }
+      }`,
+      filename: '/components/foo.js',
+    },
+    {
+      // Test files should be ignored.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('x') get prop() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo-test.js',
+    },
+    {
+      // Not an Ember module file.
+      code: `
+      import { computed } from '@ember/object';
+      import SomeThing from 'random';
+      SomeThing.extends({
+        prop: computed('x', function() {}),
+        myFunction() { this.x = 123; }
+      })`,
+      filename: '/random/foo.js',
+    },
+    {
+      // Assignment in separate class from computed property.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class Class1 extends Component {
+        @computed('x') get myProp() {}
+      }
+      class Class2 extends Component {
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+    },
+    {
+      // Classic class.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      Component.extends({
+        prop: computed('x') // This is here to make sure it is not considered in the next class.
+      });
+      Component.extends({
+        ...someProp.getProperties('propA', 'propB'),
+        prop1: computed(),
+        prop2: computed(function() {}),
+        prop3: computed('random', function() {}),
+        myFunction() { this.x = 123; }
+      })`,
+      filename: '/components/foo.js',
+    },
+  ],
+  invalid: [
+    {
+      // Assignment of dependent key property.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      import { tracked } from '@glimmer/tracking';
+      class Class1 extends Component {
+        @tracked x; // This is included just to make sure it gets ignored in the next class.
+      }
+      class Class2 extends Component {
+        @computed('x') get myProp() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      import { tracked } from '@glimmer/tracking';
+      class Class1 extends Component {
+        @tracked x; // This is included just to make sure it gets ignored in the next class.
+      }
+      class Class2 extends Component {
+        @computed('x') get myProp() {}
+        myFunction() { set(this, 'x', 123); }
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Assignment of dependent key property (with dependent key brace expansion / assignment property is substring of dependent key).
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('x.{a,b}.@each.z') get myProp() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('x.{a,b}.@each.z') get myProp() {}
+        myFunction() { set(this, 'x', 123); }
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Assignment of dependent key property (with aliased `computed` import).
+      code: `
+      import { computed as c } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @c('x') get myProp() {}
+        myFunction() { 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('x') get myProp() {}
+        myFunction() { set(this, 'x', 123); }
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Assignment of dependent key property (should use aliased `set` import instead of adding a new import).
+      code: `
+      import { computed as c, set as s } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @c('x') get myProp() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+      output: `
+      import { computed as c, set as s } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @c('x') get myProp() {}
+        myFunction() { s(this, 'x', 123); }
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Assignment of dependent key property (inside generic class that does not extend from an Ember module)
+      code: `
+      import { computed } from '@ember/object';
+      class MyClass {
+        @computed('x') get myProp() {}
+        myFunction() { this.x = 123; }
+      }`,
+      filename: '/components/foo.js',
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      class MyClass {
+        @computed('x') get myProp() {}
+        myFunction() { set(this, 'x', 123); }
+      }`,
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // `args` should not be treated special outside of Glimmer components.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('args.x') get prop() {}
+        myFunction() { this.args.x = 123; }
+      }`,
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class MyClass extends Component {
+        @computed('args.x') get prop() {}
+        myFunction() { set(this, 'args.x', 123); }
+      }`,
+      filename: '/components/foo.js',
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Manages state of inner class separate from outer class.
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class Class1 extends Component {
+        @computed('x') get myProp() {}
+        function1() {
+          class InternalStateTracker {
+            @tracked x;
+            innerFunction() {
+              this.x = 123; // Allowed because tracked in this inner class.
+            }
+          }
+        }
+        function2() {
+          this.x = 123;
+        }
+      }`,
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      class Class1 extends Component {
+        @computed('x') get myProp() {}
+        function1() {
+          class InternalStateTracker {
+            @tracked x;
+            innerFunction() {
+              this.x = 123; // Allowed because tracked in this inner class.
+            }
+          }
+        }
+        function2() {
+          set(this, 'x', 123);
+        }
+      }`,
+      filename: '/components/foo.js',
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+    {
+      // Assignment of dependent key property (classic class).
+      code: `
+      import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      import { tracked } from '@glimmer/tracking';
+      class MyClass extends Component {
+        @tracked x // This is here to ensure that it is ignored in the subsequent class.
+      }
+      Component.extends({
+        myProp: computed('x', function() {}),
+        myFunction() { this.x = 123; }
+      })`,
+      output: `
+      import { set } from '@ember/object';
+import { computed } from '@ember/object';
+      import Component from '@ember/component';
+      import { tracked } from '@glimmer/tracking';
+      class MyClass extends Component {
+        @tracked x // This is here to ensure that it is ignored in the subsequent class.
+      }
+      Component.extends({
+        myProp: computed('x', function() {}),
+        myFunction() { set(this, 'x', 123); }
+      })`,
+      filename: '/components/foo.js',
+      errors: [{ message: ERROR_MESSAGE, type: 'AssignmentExpression' }],
+    },
+  ],
+});
diff --git a/tests/lib/utils/ember-test.js b/tests/lib/utils/ember-test.js
index b6a51ea7..1550073c 100644
--- a/tests/lib/utils/ember-test.js
+++ b/tests/lib/utils/ember-test.js
@@ -635,6 +635,72 @@ describe('isEmberObjectProxy', () => {
   });
 });
 
+describe('isEmberObject', () => {
+  it('should detect when using local module', () => {
+    const context = new FauxContext('EmberObject.extend()');
+    const node = context.ast.body[0].expression;
+    expect(emberUtils.isEmberObject(context, node)).toBeTruthy();
+  });
+
+  it('should not detect when using local module with wrong name', () => {
+    const context = new FauxContext('SomethingElse.extend()');
+    const node = context.ast.body[0].expression;
+    expect(emberUtils.isEmberObject(context, node)).toBeFalsy();
+  });
+
+  it('should detect when using native classes', () => {
+    const context = new FauxContext(`
+      import EmberObject from '@ember/object';
+      class MyObject extends EmberObject {}
+    `);
+
+    const node = context.ast.body[1];
+    expect(emberUtils.isEmberObject(context, node)).toBeTruthy();
+  });
+
+  it('should not detect when using native classes if the import path is incorrect', () => {
+    const context = new FauxContext(`
+      import EmberObject from '@something-else/object';
+      class MyObject extends EmberObject {}
+    `);
+    const node = context.ast.body[1];
+    expect(emberUtils.isEmberObject(context, node)).toBeFalsy();
+  });
+});
+
+describe('isEmberHelper', () => {
+  it('should detect when using local module', () => {
+    const context = new FauxContext('Helper.extend()');
+    const node = context.ast.body[0].expression;
+    expect(emberUtils.isEmberHelper(context, node)).toBeTruthy();
+  });
+
+  it('should not detect when using local module with wrong name', () => {
+    const context = new FauxContext('SomethingElse.extend()');
+    const node = context.ast.body[0].expression;
+    expect(emberUtils.isEmberHelper(context, node)).toBeFalsy();
+  });
+
+  it('should detect when using native classes', () => {
+    const context = new FauxContext(`
+      import Helper from '@ember/component/helper';
+      class MyHelper extends Helper {}
+    `);
+
+    const node = context.ast.body[1];
+    expect(emberUtils.isEmberHelper(context, node)).toBeTruthy();
+  });
+
+  it('should not detect when using native classes if the import path is incorrect', () => {
+    const context = new FauxContext(`
+      import Helper from '@something-else/component/helper';
+      class MyHelper extends Helper {}
+    `);
+    const node = context.ast.body[1];
+    expect(emberUtils.isEmberHelper(context, node)).toBeFalsy();
+  });
+});
+
 describe('isEmberProxy', () => {
   it('should detect ArrayProxy example', () => {
     const context = new FauxContext(`