Repository: ember-cli/eslint-plugin-ember
Pull request #156 (2017-10-24 21:02:21)

updated avoid-leaking-state rule to work for all kind of ember object
Currently, we have lint rule set up to catch these issues only for ember components, extending that rule to cover all sort of ember objects like:  
Components, Routes, Controllers, Services and any other object which uses extend (ex Foo.extend({}).




Patch:
diff --git a/README.md b/README.md
index aa719324..3c9a9825 100644
--- a/README.md
+++ b/README.md
@@ -160,15 +160,14 @@ Please be aware that we're using `yarn` in this repository, so if you plan to ad
 If you have any suggestions, ideas or problems feel free to add new [issue](https://github.com/ember-cli/eslint-plugin-ember/issues), but first please make sure your question does not repeat previous ones.
 
 ## ⭐️ Contributors
-- [Michał Sajnóg](https://github.com/michalsnik)
-- [Rafał Leszczyński](https://github.com/rafleszczynski)
-- [Adrian Zalewski](https://github.com/bardzusny)
-- [Kamil Ejsymont](https://github.com/netes)
-- [Casey Watts](https://github.com/caseywatts)
-- [Steve Gargan](https://github.com/sgargan)
-- [Alex LaFroscia](https://github.com/alexlafroscia)
-- [Tobias Bieniek](https://github.com/Turbo87)
-- [Robert Wagner](https://github.com/rwwagner90)
+- [Michał Sajnóg](http://github.com/michalsnik)
+- [Rafał Leszczyński](http://github.com/rafleszczynski)
+- [Adrian Zalewski](http://github.com/bardzusny)
+- [Kamil Ejsymont](http://github.com/netes)
+- [Casey Watts](http://github.com/caseywatts)
+- [Steve Gargan](http://github.com/sgargan)
+- [Alex LaFroscia](http://github.com/alexlafroscia)
+- [Tobias Bieniek](http://github.com/Turbo87)
 
 ##  🙌 Credits
 - [DockYard team](http://github.com/DockYard) - for great inspiration with their [styleguide](https://github.com/DockYard/styleguides/blob/master/engineering/ember.md)
diff --git a/docs/rules/avoid-leaking-state-in-ember-objects.md b/docs/rules/avoid-leaking-state-in-ember-objects.md
new file mode 100644
index 00000000..943e46f0
--- /dev/null
+++ b/docs/rules/avoid-leaking-state-in-ember-objects.md
@@ -0,0 +1,49 @@
+## Avoid leaking state
+
+### Rule name: `avoid-leaking-state-in-ember-objects`
+
+#### Configuration
+
+Example configuration:
+
+```
+ember/avoid-leaking-state-in-ember-objects: [1, [
+  'array',
+  'of',
+  'ignored',
+  'properties',
+]]
+```
+
+#### Description
+
+Don't use arrays and objects as default properties. More info here: https://dockyard.com/blog/2015/09/18/ember-best-practices-avoid-leaking-state-into-factories
+
+```javascript
+// BAD
+export default Foo.extend({
+  items: [],
+
+  actions: {
+    addItem(item) {
+      this.get('items').pushObject(item);
+    },
+  },
+});
+```
+
+```javascript
+// Good
+export default Foo.extend({
+  init() {
+    this._super(...arguments);
+    this.items = [];
+  },
+
+  actions: {
+    addItem(item) {
+      this.get('items').pushObject(item);
+    },
+  },
+});
+```
diff --git a/lib/rules/avoid-leaking-state-in-ember-objects.js b/lib/rules/avoid-leaking-state-in-ember-objects.js
new file mode 100644
index 00000000..d1376eb0
--- /dev/null
+++ b/lib/rules/avoid-leaking-state-in-ember-objects.js
@@ -0,0 +1,71 @@
+'use strict';
+
+const ember = require('../utils/ember');
+const utils = require('../utils/utils');
+
+const DEFAULT_IGNORED_PROPERTIES = [
+  'classNames',
+  'classNameBindings',
+  'actions',
+  'concatenatedProperties',
+  'mergedProperties',
+  'positionalParams',
+  'attributeBindings',
+  'queryParams',
+  'attrs',
+];
+
+const isAllowed = function (property) {
+  const value = property.value;
+  return ember.isFunctionExpression(value) ||
+    utils.isLiteral(value) ||
+    utils.isIdentifier(value) ||
+    utils.isCallExpression(value) ||
+    utils.isBinaryExpression(value);
+};
+
+
+//------------------------------------------------------------------------------
+// Ember object rule - Avoid leaking state
+// (Don't use arrays or objects as default props)
+//------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'Avoids state leakage',
+      category: 'Ember Object',
+      recommended: true
+    },
+    fixable: null, // or "code" or "whitespace"
+    schema: [{
+      type: 'array',
+      items: { type: 'string' },
+    }],
+  },
+
+  create(context) {
+    const ignoredProperties = context.options[0] || DEFAULT_IGNORED_PROPERTIES;
+
+    const report = function (node) {
+      const message = 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties';
+      context.report(node, message);
+    };
+
+    return {
+      CallExpression(node) {
+        if (!(ember.isEmberObject(node) || ember.isReopenObject(node))) return;
+
+        const properties = ember.getModuleProperties(node);
+
+        properties
+          .filter(property => ignoredProperties.indexOf(property.key.name) === -1)
+          .forEach((property) => {
+            if (!isAllowed(property)) {
+              report(property);
+            }
+          });
+      },
+    };
+  },
+};
diff --git a/lib/utils/ember.js b/lib/utils/ember.js
index 283eac61..a4f1cbdf 100644
--- a/lib/utils/ember.js
+++ b/lib/utils/ember.js
@@ -45,6 +45,10 @@ module.exports = {
   parseDependentKeys,
   unwrapBraceExpressions,
   hasDuplicateDependentKeys,
+
+  isEmberObject,
+
+  isReopenObject,
 };
 
 // Private
@@ -101,7 +105,7 @@ function isModuleByFilePath(filePath, module) {
 }
 
 function isEmberCoreModule(node, module, filePath) {
-  const isExtended = node.callee.property ? node.callee.property.name === 'extend' : false;
+  const isExtended = isEmberObject(node);
   let isModuleByPath;
 
   if (filePath) {
@@ -111,6 +115,18 @@ function isEmberCoreModule(node, module, filePath) {
   return isModule(node, module) || isModuleByPath;
 }
 
+function isEmberObject(node) {
+  return node.callee.property &&
+  (
+    node.callee.property.name === 'extend' ||
+    node.callee.property.value === 'extend'
+  );
+}
+
+function isReopenObject(node) {
+  return node.callee.property && node.callee.property.name === 'reopen';
+}
+
 function isEmberComponent(node, filePath) {
   return isEmberCoreModule(node, 'Component', filePath);
 }
diff --git a/lib/utils/utils.js b/lib/utils/utils.js
index 3125fe82..5789f998 100644
--- a/lib/utils/utils.js
+++ b/lib/utils/utils.js
@@ -26,6 +26,7 @@ module.exports = {
   getPropertyValue,
   collectObjectPatternBindings,
   isEmptyMethod,
+  isBinaryExpression,
 };
 
 /**
@@ -95,6 +96,16 @@ function isCallExpression(node) {
   return node !== undefined && node.type === 'CallExpression';
 }
 
+/**
+ * Check whether or not a node is an BinaryExpression.
+ *
+ * @param {Object} node The node to check.
+ * @returns {boolean} Whether or not the node is an BinaryExpression.
+ */
+function isBinaryExpression(node) {
+  return node !== undefined && node.type === 'BinaryExpression';
+}
+
 /**
  * Check whether or not a node is an ObjectExpression.
  *


Test patch:
diff --git a/tests/__snapshots__/recommended.js.snap b/tests/__snapshots__/recommended.js.snap
index 051cf650..287e4fe1 100644
--- a/tests/__snapshots__/recommended.js.snap
+++ b/tests/__snapshots__/recommended.js.snap
@@ -4,6 +4,7 @@ exports[`recommended rules 1`] = `
 Array [
   "alias-model-in-controller",
   "avoid-leaking-state-in-components",
+  "avoid-leaking-state-in-ember-objects",
   "closure-actions",
   "jquery-ember-run",
   "named-functions-in-promises",
diff --git a/tests/lib/rules/avoid-leaking-state-in-ember-objects.js b/tests/lib/rules/avoid-leaking-state-in-ember-objects.js
new file mode 100644
index 00000000..a053a5dc
--- /dev/null
+++ b/tests/lib/rules/avoid-leaking-state-in-ember-objects.js
@@ -0,0 +1,156 @@
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/avoid-leaking-state-in-ember-objects');
+const RuleTester = require('eslint').RuleTester;
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const eslintTester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 6,
+    sourceType: 'module'
+  }
+});
+eslintTester.run('avoid-leaking-state-in-ember-objects', rule, {
+  valid: [
+    {
+      code: 'export default Foo.extend();',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: "example", init() { this.set("anotherProp", []) } });',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: "example", init() { this.set("anotherProp", {}) } });',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: "example", init() { this.set("anotherProp", new Ember.A()) } });',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: "example", init() { this.set("anotherProp", new A()) } });',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: "example", init() { this.set("anotherProp", new Ember.Object()) } });',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: "example", init() { this.set("anotherProp", new Object()) } });',
+    },
+    {
+      code: 'export default Foo.extend({ classNames: [], classNameBindings: [], actions: {}, concatenatedProperties: [], mergedProperties: [], positionalParams: [] });',
+    },
+    {
+      code: 'export default Foo.extend(someMixin, { classNames: [], classNameBindings: [], actions: {}, concatenatedProperties: [], mergedProperties: [], positionalParams: [] });'
+    },
+    {
+      code: 'export default Foo.extend({ someProp: "example",});',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: function() {},});',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: 5,});',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: Symbol(),});',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: undefined,});',
+    },
+    {
+      code: 'export default Foo.extend({ someProp: null,});',
+    },
+    {
+      code: 'export default Foo.extend({ doStuff() { }});'
+    },
+    {
+      code: 'export default Foo.extend({ derp: importedThing });'
+    },
+    {
+      code: 'export default Foo.extend({ derp });'
+    },
+    {
+      code: 'export default Foo.extend(SomeMixin, { simple: "string" });'
+    },
+    {
+      code: 'export default Foo.extend(SomeMixin, OtherMixin, { derp: null });'
+    },
+    {
+      code: 'export default Foo.extend({ doStuff: task(function* () {}) });'
+    },
+    {
+      code: 'export default Foo.extend({ fullName: computed(function() {}) });'
+    },
+    {
+      code: 'export default Foo.extend({ fullName: inject.service() });'
+    },
+    {
+      code: "export default Foo.extend({ fullName: 'a' + 'b' });"
+    },
+  ],
+  invalid: [
+    {
+      code: 'export default Foo.extend({someProp: []});',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: 'export default Foo.extend({someProp: new Ember.A()});',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: 'export default Foo.extend({someProp: new A()});',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: 'export default Foo.extend({someProp: {}});',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: 'export default Foo.extend({someProp: new Ember.Object()});',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: 'export default Foo.extend({someProp: new Object()});',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+
+    {
+      code: 'export default Foo.extend(SomeMixin, { derp: [] });',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: 'export default Foo.extend({ badThing: new Set() });',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: "export default Foo['extend']({ otherThing: {} });",
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+    {
+      code: 'export default Foo.reopen({ otherThing: {} });',
+      errors: [{
+        message: 'Only string, number, symbol, boolean, null, undefined, and function are allowed as default properties',
+      }],
+    },
+  ],
+});