Repository: ember-cli/eslint-plugin-ember
Pull request #2155 (2024-07-08 18:29:27)

Add new ember-data rule: `require-async-inverse-relationship`
# Task:
 - Lints according to this deprecation https://deprecations.emberjs.com/id/ember-data-deprecate-non-strict-relationships
 - fixes: https://github.com/ember-cli/eslint-plugin-ember/issues/1776

wozny1989: @NullVoxPopuli What do you think about this?
NullVoxPopuli: Adding new recommended rules is a breaking change -- this new rule would have to be left out of the recommended set until the next major of eslint-plugin-ember.

As for the lint rule itself -- I'll let @runspired speak to that.

Code seems :+1: 
runspired: @NullVoxPopuli unclear to me on breaking changes when its a lint to support something that was deprecated and removed a full major version ago in EmberData itself.

That said: @wozny1989 think you could PR this to EmberData instead? we have the infra for a rules package we intended to start adding rules to (including this one) here https://github.com/emberjs/data/tree/main/packages/eslint-plugin-ember-data
bmish: The rule can be made `recommended` later but not when it's initially added, as mentioned here: https://github.com/ember-cli/eslint-plugin-ember#creating-a-new-rule

> Note that new rules should not immediately be added to the [recommended](https://github.com/ember-cli/eslint-plugin-ember/blob/master/lib/recommended-rules.js) configuration, as we only consider such breaking changes during major version updates.
wozny1989: @bmish Sorry, I didn't notice thing about recommendation, just pushed changes and fix lint errors on CI
bmish: Are we good to go with this? I don't mind having Ember Data rules in this plugin for now as we already have a couple and this plugin is widely used.

Patch:
diff --git a/README.md b/README.md
index 0eb6a85f..5227d9d0 100644
--- a/README.md
+++ b/README.md
@@ -237,10 +237,11 @@ rules in templates can be disabled with eslint directives with mustache or html
 
 ### Ember Data
 
-| Name                                                                           | Description                                                          | 💼 | 🔧 | 💡 |
-| :----------------------------------------------------------------------------- | :------------------------------------------------------------------- | :- | :- | :- |
-| [no-empty-attrs](docs/rules/no-empty-attrs.md)                                 | disallow usage of empty attributes in Ember Data models              |    |    |    |
-| [use-ember-data-rfc-395-imports](docs/rules/use-ember-data-rfc-395-imports.md) | enforce usage of `@ember-data/` package imports instead `ember-data` | ✅  | 🔧 |    |
+| Name                                                                                   | Description                                                           | 💼 | 🔧 | 💡 |
+| :------------------------------------------------------------------------------------- | :-------------------------------------------------------------------- | :- | :- | :- |
+| [no-empty-attrs](docs/rules/no-empty-attrs.md)                                         | disallow usage of empty attributes in Ember Data models               |    |    |    |
+| [require-async-inverse-relationship](docs/rules/require-async-inverse-relationship.md) | require inverse to be specified in @belongsTo and @hasMany decorators |    |    |    |
+| [use-ember-data-rfc-395-imports](docs/rules/use-ember-data-rfc-395-imports.md)         | enforce usage of `@ember-data/` package imports instead `ember-data`  | ✅  | 🔧 |    |
 
 ### Ember Object
 
diff --git a/docs/rules/require-async-inverse-relationship.md b/docs/rules/require-async-inverse-relationship.md
new file mode 100644
index 00000000..69fb816b
--- /dev/null
+++ b/docs/rules/require-async-inverse-relationship.md
@@ -0,0 +1,41 @@
+# ember/require-async-inverse-relationship
+
+<!-- end auto-generated rule header -->
+
+This rule ensures that the `async` and `inverse` properties are specified in `@belongsTo` and `@hasMany` decorators in Ember Data models.
+
+## Rule Details
+
+This rule disallows:
+
+- Using `@belongsTo` without specifying the `async` and `inverse` properties.
+- Using `@hasMany` without specifying the `async` and `inverse` properties.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```js
+import Model, { belongsTo, hasMany } from '@ember-data/model';
+
+export default class FolderModel extends Model {
+  @hasMany('folder', { inverse: 'parent' }) children;
+  @belongsTo('folder', { inverse: 'children' }) parent;
+}
+```
+
+Examples of **correct** code for this rule:
+
+```js
+import Model, { belongsTo, hasMany } from '@ember-data/model';
+
+export default class FolderModel extends Model {
+  @hasMany('folder', { async: true, inverse: 'parent' }) children;
+  @belongsTo('folder', { async: true, inverse: 'children' }) parent;
+}
+```
+
+## References
+
+- [Deprecate Non Strict Relationships](https://deprecations.emberjs.com/id/ember-data-deprecate-non-strict-relationships)
+- [Ember Data Relationships](https://guides.emberjs.com/release/models/relationships)
diff --git a/lib/rules/require-async-inverse-relationship.js b/lib/rules/require-async-inverse-relationship.js
new file mode 100644
index 00000000..ed531eb8
--- /dev/null
+++ b/lib/rules/require-async-inverse-relationship.js
@@ -0,0 +1,75 @@
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'require inverse to be specified in @belongsTo and @hasMany decorators',
+      category: 'Ember Data',
+      recommended: false,
+      url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/require-async-inverse-relationship.md',
+    },
+    schema: [],
+  },
+
+  create(context) {
+    return {
+      CallExpression(node) {
+        const decorator =
+          node.parent.type === 'Decorator' &&
+          ['belongsTo', 'hasMany'].includes(node.callee.name) &&
+          node;
+
+        if (decorator) {
+          const args = decorator.arguments;
+          const hasAsync = args.some(
+            (arg) =>
+              arg.type === 'ObjectExpression' &&
+              arg.properties.some((prop) => prop.key.name === 'async')
+          );
+          const hasBooleanAsync = args.some(
+            (arg) =>
+              arg.type === 'ObjectExpression' &&
+              arg.properties.some(
+                (prop) => prop.key.name === 'async' && typeof prop.value.value === 'boolean'
+              )
+          );
+          const hasInverse = args.some(
+            (arg) =>
+              arg.type === 'ObjectExpression' &&
+              arg.properties.some((prop) => prop.key.name === 'inverse')
+          );
+
+          if (!hasAsync) {
+            context.report({
+              node,
+              message: 'The @{{decorator}} decorator requires an `async` property to be specified.',
+              data: {
+                decorator: decorator.callee.name,
+              },
+            });
+          } else if (!hasBooleanAsync) {
+            context.report({
+              node,
+              message:
+                'The @{{decorator}} decorator requires an `async` property to be specified as a boolean.',
+              data: {
+                decorator: decorator.callee.name,
+              },
+            });
+          }
+
+          if (!hasInverse) {
+            context.report({
+              node,
+              message:
+                'The @{{decorator}} decorator requires an `inverse` property to be specified.',
+              data: {
+                decorator: decorator.callee.name,
+              },
+            });
+          }
+        }
+      },
+    };
+  },
+};
diff --git a/lib/utils/ember.js b/lib/utils/ember.js
index 9aff9841..45b934ca 100644
--- a/lib/utils/ember.js
+++ b/lib/utils/ember.js
@@ -176,9 +176,12 @@ function isModuleByFilePath(filePath, module) {
 }
 
 const validFileExtensions = ['js', 'ts', 'gjs', 'gts'];
+const validTestFilePatterns = ['-test', '_test'];
 
 function isTestFile(fileName) {
-  return validFileExtensions.some((ext) => fileName.endsWith(`-test.${ext}`));
+  return validFileExtensions.some((ext) =>
+    validTestFilePatterns.some((testFilePattern) => fileName.endsWith(`${testFilePattern}.${ext}`))
+  );
 }
 
 function isMirageDirectory(fileName) {
diff --git a/lib/utils/import.js b/lib/utils/import.js
index adae50f9..a0221bd7 100644
--- a/lib/utils/import.js
+++ b/lib/utils/import.js
@@ -23,9 +23,9 @@ module.exports = {
  * @returns {string | undefined} The name of the module the identifier was imported from, if it was imported
  */
 function getSourceModuleNameForIdentifier(context, node) {
+  const { ast } = context.sourceCode ?? context.getSourceCode();
   const sourceModuleName = getSourceModuleName(node);
-  const [program] = context.getAncestors(node);
-  const importDeclaration = program.body
+  const importDeclaration = ast.body
     .filter(isImportDeclaration)
     .find((importDeclaration) =>
       importDeclaration.specifiers.some((specifier) => specifier.local.name === sourceModuleName)
diff --git a/package.json b/package.json
index c2e99dbd..02729e46 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
     "coverageThreshold": {
       "global": {
         "branches": 95,
-        "functions": 99,
+        "functions": 98.95,
         "lines": 98,
         "statements": 98
       }


Test patch:
diff --git a/tests/lib/rules/require-async-inverse-relationship.js b/tests/lib/rules/require-async-inverse-relationship.js
new file mode 100644
index 00000000..f4a95777
--- /dev/null
+++ b/tests/lib/rules/require-async-inverse-relationship.js
@@ -0,0 +1,209 @@
+'use strict';
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/require-async-inverse-relationship');
+const RuleTester = require('eslint').RuleTester;
+
+const parserOptions = { ecmaVersion: 2022, sourceType: 'module' };
+
+const ruleTester = new RuleTester({
+  parserOptions,
+  parser: require.resolve('@babel/eslint-parser'),
+});
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+ruleTester.run('require-async-inverse-relationship', rule, {
+  valid: [
+    `import Model, { belongsTo } from '@ember-data/model';
+
+      export default class extends Model {
+        @belongsTo('post', { async: true, inverse: 'comments' }) post;
+      }`,
+    `import Model, { hasMany } from '@ember-data/model';
+
+      export default class extends Model {
+        @hasMany('comment', { async: true, inverse: 'post' }) comments;
+      }`,
+    `import Model, { belongsTo, hasMany } from '@ember-data/model';
+
+      export default class extends Model {
+        @belongsTo('post', { async: false, inverse: 'comments' }) post;
+        @hasMany('user', { async: true, inverse: null }) owner;
+      }`,
+  ],
+
+  invalid: [
+    {
+      code: `import Model, { belongsTo } from '@ember-data/model';
+
+      export default class extends Model {
+        @belongsTo('post') post;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @belongsTo decorator requires an `async` property to be specified.',
+          type: 'CallExpression',
+        },
+        {
+          message: 'The @belongsTo decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { belongsTo } from '@ember-data/model';
+
+      export default class extends Model {
+        @belongsTo('post', {}) post;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @belongsTo decorator requires an `async` property to be specified.',
+          type: 'CallExpression',
+        },
+        {
+          message: 'The @belongsTo decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { belongsTo } from '@ember-data/model';
+
+      export default class extends Model {
+        @belongsTo('post', { async: 'comments'}) post;
+      }`,
+      output: null,
+      errors: [
+        {
+          message:
+            'The @belongsTo decorator requires an `async` property to be specified as a boolean.',
+          type: 'CallExpression',
+        },
+        {
+          message: 'The @belongsTo decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { belongsTo } from '@ember-data/model';
+
+      export default class extends Model {
+        @belongsTo('post', { async: true }) post;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @belongsTo decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { belongsTo } from '@ember-data/model';
+
+      export default class extends Model {
+        @belongsTo('post', { inverse: 'comments' }) post;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @belongsTo decorator requires an `async` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { hasMany } from '@ember-data/model';
+
+      export default class extends Model {
+        @hasMany('comment') comments;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @hasMany decorator requires an `async` property to be specified.',
+          type: 'CallExpression',
+        },
+        {
+          message: 'The @hasMany decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { hasMany } from '@ember-data/model';
+
+      export default class extends Model {
+        @hasMany('comment', {}) comments;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @hasMany decorator requires an `async` property to be specified.',
+          type: 'CallExpression',
+        },
+        {
+          message: 'The @hasMany decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { hasMany } from '@ember-data/model';
+
+      export default class extends Model {
+        @hasMany('comment', { async: 'comments'}) comments;
+      }`,
+      output: null,
+      errors: [
+        {
+          message:
+            'The @hasMany decorator requires an `async` property to be specified as a boolean.',
+          type: 'CallExpression',
+        },
+        {
+          message: 'The @hasMany decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { hasMany } from '@ember-data/model';
+
+      export default class extends Model {
+        @hasMany('comment', { async: true }) comments;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @hasMany decorator requires an `inverse` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+    {
+      code: `import Model, { hasMany } from '@ember-data/model';
+
+      export default class extends Model {
+        @hasMany('comment', { inverse: 'post' }) comments;
+      }`,
+      output: null,
+      errors: [
+        {
+          message: 'The @hasMany decorator requires an `async` property to be specified.',
+          type: 'CallExpression',
+        },
+      ],
+    },
+  ],
+});
diff --git a/tests/lib/utils/ember-test.js b/tests/lib/utils/ember-test.js
index a44a0c14..0f1c643e 100644
--- a/tests/lib/utils/ember-test.js
+++ b/tests/lib/utils/ember-test.js
@@ -136,13 +136,20 @@ describe('isMirageConfig', () => {
 });
 
 describe('isTestFile', () => {
-  it('detects test files', () => {
+  it('detects test files ending with -test', () => {
     expect(emberUtils.isTestFile('some-test.js')).toBeTruthy();
     expect(emberUtils.isTestFile('some-test.ts')).toBeTruthy();
     expect(emberUtils.isTestFile('some-test.gjs')).toBeTruthy();
     expect(emberUtils.isTestFile('some-test.gts')).toBeTruthy();
   });
 
+  it('detects test files ending with _test', () => {
+    expect(emberUtils.isTestFile('some_test.js')).toBeTruthy();
+    expect(emberUtils.isTestFile('some_test.ts')).toBeTruthy();
+    expect(emberUtils.isTestFile('some_test.gjs')).toBeTruthy();
+    expect(emberUtils.isTestFile('some_test.gts')).toBeTruthy();
+  });
+
   it('does not detect other files', () => {
     expect(emberUtils.isTestFile('some-component.js')).toBeFalsy();
     expect(emberUtils.isTestFile('my-testing-component.js')).toBeFalsy();