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

Issue:
New rule: `no-deprecated-transition-methods` 
Adds a nice way to handle [emberjs/rfcs#674](https://github.com/emberjs/rfcs/blob/master/text/0674-deprecate-transition-methods-of-controller-and-route.md) by flagging them as an issue, and adding a fixer to use the router service.


This would flag:

* Using `this.transitionTo` from a route
* Using `this.replaceWith` from a route
* Using `this.transitionToRoute` from a controller
* Using `this.replaceRoute` from a controller

The fixer would be:

* `this.transitionTo` -> adding router service + `this.router.transitionTo`
* `this.replaceWith` -> adding router service + `this.router.replaceWith`
* `this.transitionToRoute` -> adding router service + `this.router.transitionTo`
* `this.replaceRoute` -> adding router service + `this.router.replaceWith`

The deprecation was implemented in https://github.com/emberjs/ember.js/pull/19255 and will be included in Ember 3.26.

(note: I'm not super in love with the name)

Pull request:
Add new rule  `no-deprecated-router-transition-methods`
Similar to #1714 this implements a lint rule to detect and fix errors associated with the [`routing.transition-methods` deprecation](https://deprecations.emberjs.com/v3.x/#toc_routing-transition-methods).

This closes #1074


Patch:
diff --git a/README.md b/README.md
index b291b05a..091945bf 100644
--- a/README.md
+++ b/README.md
@@ -96,18 +96,19 @@ module.exports = {
 
 ### Deprecations
 
-| Name                                                                               | Description                                               | 💼 | 🔧 | 💡 |
-| :--------------------------------------------------------------------------------- | :-------------------------------------------------------- | :- | :- | :- |
-| [closure-actions](docs/rules/closure-actions.md)                                   | enforce usage of closure actions                          | ✅  |    |    |
-| [new-module-imports](docs/rules/new-module-imports.md)                             | enforce using "New Module Imports" from Ember RFC #176    | ✅  |    |    |
-| [no-array-prototype-extensions](docs/rules/no-array-prototype-extensions.md)       | disallow usage of Ember's `Array` prototype extensions    |    | 🔧 |    |
-| [no-function-prototype-extensions](docs/rules/no-function-prototype-extensions.md) | disallow usage of Ember's `function` prototype extensions | ✅  |    |    |
-| [no-implicit-injections](docs/rules/no-implicit-injections.md)                     | enforce usage of implicit service injections              |    | 🔧 |    |
-| [no-mixins](docs/rules/no-mixins.md)                                               | disallow the usage of mixins                              | ✅  |    |    |
-| [no-new-mixins](docs/rules/no-new-mixins.md)                                       | disallow the creation of new mixins                       | ✅  |    |    |
-| [no-observers](docs/rules/no-observers.md)                                         | disallow usage of observers                               | ✅  |    |    |
-| [no-old-shims](docs/rules/no-old-shims.md)                                         | disallow usage of old shims for modules                   | ✅  | 🔧 |    |
-| [no-string-prototype-extensions](docs/rules/no-string-prototype-extensions.md)     | disallow usage of `String` prototype extensions           | ✅  |    |    |
+| Name                                                                                             | Description                                               | 💼 | 🔧 | 💡 |
+| :----------------------------------------------------------------------------------------------- | :-------------------------------------------------------- | :- | :- | :- |
+| [closure-actions](docs/rules/closure-actions.md)                                                 | enforce usage of closure actions                          | ✅  |    |    |
+| [new-module-imports](docs/rules/new-module-imports.md)                                           | enforce using "New Module Imports" from Ember RFC #176    | ✅  |    |    |
+| [no-array-prototype-extensions](docs/rules/no-array-prototype-extensions.md)                     | disallow usage of Ember's `Array` prototype extensions    |    | 🔧 |    |
+| [no-deprecated-router-transition-methods](docs/rules/no-deprecated-router-transition-methods.md) | enforce usage of router service transition methods        |    | 🔧 |    |
+| [no-function-prototype-extensions](docs/rules/no-function-prototype-extensions.md)               | disallow usage of Ember's `function` prototype extensions | ✅  |    |    |
+| [no-implicit-injections](docs/rules/no-implicit-injections.md)                                   | enforce usage of implicit service injections              |    | 🔧 |    |
+| [no-mixins](docs/rules/no-mixins.md)                                                             | disallow the usage of mixins                              | ✅  |    |    |
+| [no-new-mixins](docs/rules/no-new-mixins.md)                                                     | disallow the creation of new mixins                       | ✅  |    |    |
+| [no-observers](docs/rules/no-observers.md)                                                       | disallow usage of observers                               | ✅  |    |    |
+| [no-old-shims](docs/rules/no-old-shims.md)                                                       | disallow usage of old shims for modules                   | ✅  | 🔧 |    |
+| [no-string-prototype-extensions](docs/rules/no-string-prototype-extensions.md)                   | disallow usage of `String` prototype extensions           | ✅  |    |    |
 
 ### Ember Data
 
diff --git a/docs/rules/no-deprecated-router-transition-methods.md b/docs/rules/no-deprecated-router-transition-methods.md
new file mode 100644
index 00000000..243455b2
--- /dev/null
+++ b/docs/rules/no-deprecated-router-transition-methods.md
@@ -0,0 +1,92 @@
+# ember/no-deprecated-router-transition-methods
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+<!-- end auto-generated rule header -->
+
+Ember 3.26 introduced a deprecation for using `transitionTo` and `replaceWith` in Routes or `transitionToRoute` and `replaceRoute` in Controllers. These methods should be replaced with an injected router service and calls to `this.router.transitionTo` and `this.router.replaceWith` instead.
+
+## Rule Details
+
+This rule checks for uses of `transitionTo` and `replaceWith` in Routes or `transitionToRoute` and `replaceRoute` in Controllers.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```js
+// app/routes/settings.js
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class SettingsRoute extends Route {
+  @service session;
+
+  beforeModel() {
+    if (!this.session.isAuthenticated) {
+      this.transitionTo('login');
+    }
+  }
+}
+```
+
+```js
+// app/controllers/new-post.js
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+
+export default class NewPostController extends Controller {
+  @action
+  async save({ title, text }) {
+    const post = this.store.createRecord('post', { title, text });
+    await post.save();
+    return this.transitionToRoute('post', post.id);
+  }
+}
+```
+
+Examples of **correct** code for this rule:
+
+```js
+// app/routes/settings.js
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class SettingsRoute extends Route {
+  @service('router') router;
+  @service('session') session;
+
+  beforeModel() {
+    if (!this.session.isAuthenticated) {
+      this.router.transitionTo('login');
+    }
+  }
+}
+```
+
+```js
+// app/controllers/new-post.js
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class NewPostController extends Controller {
+  @service('router') router;
+
+  @action
+  async save({ title, text }) {
+    const post = this.store.createRecord('post', { title, text });
+    await post.save();
+    return this.router.transitionTo('post', post.id);
+  }
+}
+```
+
+## Migration
+
+The autofixer for this rule will update method calls to use the router service, and will inject the router service as needed.
+
+## References
+
+- [Deprecation](https://deprecations.emberjs.com/v3.x/#toc_routing-transition-methods)
+- [Router Service](https://api.emberjs.com/ember/release/classes/RouterService)
diff --git a/lib/rules/no-deprecated-router-transition-methods.js b/lib/rules/no-deprecated-router-transition-methods.js
new file mode 100644
index 00000000..46b80c5e
--- /dev/null
+++ b/lib/rules/no-deprecated-router-transition-methods.js
@@ -0,0 +1,245 @@
+'use strict';
+
+const emberUtils = require('../utils/ember');
+const { getImportIdentifier } = require('../utils/import');
+const Stack = require('../utils/stack');
+const types = require('../utils/types');
+const decoratorUtils = require('../utils/decorators');
+
+function getBaseFixSteps(fixer, context, currentClass) {
+  const fixSteps = [];
+  const sourceCode = context.getSourceCode();
+  let serviceInjectImportName = currentClass.serviceInjectImportName;
+  let routerServicePropertyName = currentClass.routerServicePropertyName;
+
+  if (!serviceInjectImportName) {
+    fixSteps.push(
+      fixer.insertTextBefore(
+        sourceCode.ast,
+        "import { inject as service } from '@ember/service';\n"
+      )
+    );
+
+    serviceInjectImportName = 'service';
+  }
+
+  if (!routerServicePropertyName) {
+    fixSteps.push(
+      currentClass.node.type === 'CallExpression'
+        ? fixer.insertTextBefore(
+            currentClass.node.arguments[0].properties[0],
+            `router: ${serviceInjectImportName}('router'),\n`
+          )
+        : fixer.insertTextBefore(
+            currentClass.node.body.body[0],
+            `@${serviceInjectImportName}('router') router;\n`
+          )
+    );
+
+    routerServicePropertyName = 'router';
+  }
+
+  return { fixSteps, routerServicePropertyName };
+}
+
+//------------------------------------------------------------------------------
+// Rule Definition
+//------------------------------------------------------------------------------
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'enforce usage of router service transition methods',
+      category: 'Deprecations',
+      recommended: false,
+      url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-deprecated-router-transition-methods.md',
+    },
+    fixable: 'code',
+    schema: [],
+    messages: {
+      main: 'Calling "{{methodUsed}}" in {{moduleType}}s is deprecated, call {{desiredMethod}} on the injected router service instead.',
+    },
+  },
+
+  create(context) {
+    // State being tracked for this file.
+    let serviceInjectImportName = undefined;
+    let routerServicePropertyName = undefined;
+    let isValidModule = false;
+
+    // State being tracked for the current class we're inside.
+    const classStack = new Stack();
+
+    function onClassEnter(node) {
+      if (emberUtils.isAnyEmberCoreModule(context, node)) {
+        const classMembers = node.body.body;
+
+        for (const classMember of classMembers) {
+          if (emberUtils.isInjectedServiceProp(classMember, undefined, serviceInjectImportName)) {
+            const serviceExpression = decoratorUtils.findDecorator(
+              classMember,
+              serviceInjectImportName
+            ).expression;
+
+            if (serviceExpression.type === 'CallExpression') {
+              if (
+                (serviceExpression.arguments.length === 0 && classMember.key.name === 'router') ||
+                (serviceExpression.arguments.length > 0 &&
+                  serviceExpression.arguments[0].value === 'router')
+              ) {
+                routerServicePropertyName = classMember.key.name;
+              }
+            } else if (classMember.key.name === 'router') {
+              routerServicePropertyName = classMember.key.name;
+            }
+          }
+        }
+        const isRoute = emberUtils.isEmberRoute(context, node);
+        const isController = emberUtils.isEmberController(context, node);
+        isValidModule = isRoute || isController;
+
+        classStack.push({
+          node,
+          serviceInjectImportName,
+          routerServicePropertyName,
+          isValidModule,
+          isRoute,
+          isController,
+        });
+      } else {
+        classStack.push({
+          node,
+          isValidModule: false,
+        });
+      }
+    }
+
+    function onClassExit(node) {
+      // Leaving current (native) class.
+      if (classStack.size() > 0 && classStack.peek().node === node) {
+        classStack.pop();
+      }
+    }
+
+    return {
+      ImportDeclaration(node) {
+        if (node.source.value === '@ember/service') {
+          serviceInjectImportName =
+            serviceInjectImportName || getImportIdentifier(node, '@ember/service', 'inject');
+        }
+      },
+
+      ClassDeclaration: onClassEnter,
+      ClassExpression: onClassEnter,
+      CallExpression(node) {
+        if (emberUtils.isAnyEmberCoreModule(context, node)) {
+          const classMembers = node.arguments[0].properties;
+
+          for (const classMember of classMembers) {
+            if (emberUtils.isInjectedServiceProp(classMember, undefined, serviceInjectImportName)) {
+              const callExpression = classMember.value;
+
+              if (
+                (callExpression.arguments.length === 0 && classMember.key.name === 'router') ||
+                (callExpression.arguments.length > 0 &&
+                  callExpression.arguments[0].value === 'router')
+              ) {
+                routerServicePropertyName = classMember.key.name;
+              }
+            }
+          }
+          const isRoute = emberUtils.isEmberRoute(context, node);
+          const isController = emberUtils.isEmberController(context, node);
+          isValidModule = isRoute || isController;
+
+          classStack.push({
+            node,
+            serviceInjectImportName,
+            routerServicePropertyName,
+            isValidModule,
+            isRoute,
+            isController,
+          });
+        }
+      },
+
+      'ClassDeclaration:exit': onClassExit,
+      'ClassExpression:exit': onClassExit,
+      'CallExpression:exit': onClassExit,
+
+      MemberExpression(node) {
+        if (!isValidModule) {
+          return;
+        }
+
+        const currentClass = classStack.peek();
+
+        if (types.isThisExpression(node.object) && types.isIdentifier(node.property)) {
+          // Routes should not call transitionTo or replaceWith
+          const propertyName = node.property.name;
+
+          if (
+            currentClass.isRoute &&
+            (propertyName === 'transitionTo' || propertyName === 'replaceWith')
+          ) {
+            context.report({
+              node,
+              messageId: 'main',
+              data: {
+                methodUsed: propertyName,
+                desiredMethod: propertyName,
+                moduleType: 'Route',
+              },
+              fix(fixer) {
+                const { routerServicePropertyName, fixSteps } = getBaseFixSteps(
+                  fixer,
+                  context,
+                  currentClass
+                );
+
+                return [
+                  ...fixSteps,
+                  fixer.insertTextBefore(node.property, `${routerServicePropertyName}.`),
+                ];
+              },
+            });
+          }
+
+          if (
+            currentClass.isController &&
+            (propertyName === 'transitionToRoute' || propertyName === 'replaceRoute')
+          ) {
+            const replacementPropertyName =
+              propertyName === 'transitionToRoute' ? 'transitionTo' : 'replaceWith';
+            context.report({
+              node,
+              messageId: 'main',
+              data: {
+                methodUsed: propertyName,
+                desiredMethod: replacementPropertyName,
+                moduleType: 'Controller',
+              },
+              fix(fixer) {
+                const { routerServicePropertyName, fixSteps } = getBaseFixSteps(
+                  fixer,
+                  context,
+                  currentClass
+                );
+
+                return [
+                  ...fixSteps,
+                  fixer.replaceText(
+                    node.property,
+                    `${routerServicePropertyName}.${replacementPropertyName}`
+                  ),
+                ];
+              },
+            });
+          }
+        }
+      },
+    };
+  },
+};
diff --git a/lib/utils/decorators.js b/lib/utils/decorators.js
index be49739e..adaa3976 100644
--- a/lib/utils/decorators.js
+++ b/lib/utils/decorators.js
@@ -4,6 +4,7 @@ const assert = require('assert');
 
 module.exports = {
   getDecoratorName,
+  getDecorator,
   findDecorator,
   findDecoratorByNameCallback,
   hasDecorator,
@@ -61,6 +62,21 @@ function hasDecorator(node, decoratorName) {
   return node.decorators.some((decorator) => getDecoratorName(decorator) === decoratorName);
 }
 
+/**
+ * Get applied decorator node for the given decorator type
+ *
+ * @param {Object} node The node to check.
+ * @param {string?} decoratorName The decorator to look for
+ * @returns {Node} Node for the decorator
+ */
+function getDecorator(node, decoratorName) {
+  if (!node.decorators) {
+    return null;
+  }
+
+  return node.decorators.find((decorator) => getDecoratorName(decorator) === decoratorName);
+}
+
 /**
  * Check whether or not a node is a ClassProperty / PropertyDefinition, with at least the given decorator.
  * If no decoratorName is provided, will return whether the node has any decorators at all


Test patch:
diff --git a/tests/lib/rules/no-deprecated-router-transition-methods.js b/tests/lib/rules/no-deprecated-router-transition-methods.js
new file mode 100644
index 00000000..2851b8c2
--- /dev/null
+++ b/tests/lib/rules/no-deprecated-router-transition-methods.js
@@ -0,0 +1,763 @@
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/no-deprecated-router-transition-methods');
+const RuleTester = require('eslint').RuleTester;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+  parser: require.resolve('@babel/eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module',
+    babelOptions: {
+      configFile: require.resolve('../../../.babelrc'),
+    },
+  },
+});
+
+function validRouteClassUsage(serviceDefinition) {
+  return {
+    filename: 'routes/index.js',
+    code: `
+    import Route from '@ember/routing/route';
+    import { inject as service } from '@ember/service';
+
+    export default class SettingsRoute extends Route {
+      ${serviceDefinition}
+      @service session;
+
+      beforeModel() {
+        if (!this.session.isAuthenticated) {
+          this.router.transitionTo('login');
+        }
+      }
+    }`,
+  };
+}
+
+function validRouteExtendUsage(serviceDefinition) {
+  return {
+    filename: 'routes/index.js',
+    code: `
+    import Route from '@ember/routing/route';
+    import { inject as service } from '@ember/service';
+
+    export default Route.extend({
+      ${serviceDefinition}
+      session: service(),
+
+      beforeModel() {
+        if (!this.session.isAuthenticated) {
+          this.router.transitionTo('login');
+        }
+      }
+    })`,
+  };
+}
+
+function invalidRouteClassUsage(serviceDefinition, { routerServiceName, methodUsed }) {
+  return {
+    filename: 'routes/index.js',
+    errors: [
+      {
+        messageId: 'main',
+        data: { methodUsed, desiredMethod: methodUsed, moduleType: 'Route' },
+        type: 'MemberExpression',
+      },
+    ],
+    code: `
+    import Route from '@ember/routing/route';
+    import { inject as service } from '@ember/service';
+
+    export default class SettingsRoute extends Route {
+      ${serviceDefinition}
+      @service session;
+
+      beforeModel() {
+        if (!this.session.isAuthenticated) {
+          this.${methodUsed}('login');
+        }
+      }
+    }`,
+    output: `
+    import Route from '@ember/routing/route';
+    import { inject as service } from '@ember/service';
+
+    export default class SettingsRoute extends Route {
+      ${serviceDefinition}
+      @service session;
+
+      beforeModel() {
+        if (!this.session.isAuthenticated) {
+          this.${routerServiceName ?? 'router'}.${methodUsed}('login');
+        }
+      }
+    }`,
+  };
+}
+
+function invalidRouteExtendUsage(serviceDefinition, { routerServiceName, methodUsed }) {
+  return {
+    filename: 'routes/index.js',
+    errors: [
+      {
+        messageId: 'main',
+        data: { methodUsed, desiredMethod: methodUsed, moduleType: 'Route' },
+        type: 'MemberExpression',
+      },
+    ],
+    code: `
+    import Route from '@ember/routing/route';
+    import { inject as service } from '@ember/service';
+
+    export default Route.extend({
+      ${serviceDefinition}
+      session: service(),
+
+      beforeModel() {
+        if (!this.session.isAuthenticated) {
+          this.${methodUsed}('login');
+        }
+      }
+    })`,
+    output: `
+    import Route from '@ember/routing/route';
+    import { inject as service } from '@ember/service';
+
+    export default Route.extend({
+      ${serviceDefinition}
+      session: service(),
+
+      beforeModel() {
+        if (!this.session.isAuthenticated) {
+          this.${routerServiceName ?? 'router'}.${methodUsed}('login');
+        }
+      }
+    })`,
+  };
+}
+
+ruleTester.run('no-deprecated-router-transition-methods', rule, {
+  valid: [
+    // Route Uses RouterService.transitionTo with different service injection types
+    validRouteClassUsage('@service router'),
+    validRouteClassUsage('@service() router'),
+    validRouteClassUsage("@service('router') router"),
+
+    // Legacy .extends Route Uses RouterService.transitionTo with different service injection types
+    validRouteExtendUsage('router: service(),'),
+    validRouteExtendUsage("router: service('router'),"),
+
+    // Route Uses RouterService.replaceWith
+    {
+      filename: 'routes/index.js',
+      code: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export default class SettingsRoute extends Route {
+        @service('router') router;
+        @service session;
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.router.replaceWith('login');
+          }
+        }
+      }`,
+    },
+    // Controller Uses RouterService.transitionTo
+    {
+      filename: 'controllers/index.js',
+      code: `
+      import Controller from '@ember/controller';
+      import { inject as service } from '@ember/service';
+      import { action } from '@ember/object';
+
+      export default class NewPostController1 extends Controller {
+        @service('router') router;
+
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.router.transitionTo('post', post.id);
+        }
+      }`,
+    },
+    // Controller Uses RouterService.replaceWith
+    {
+      filename: 'controllers/index.js',
+      code: `
+      import Controller from '@ember/controller';
+      import { inject as service } from '@ember/service';
+      import { action } from '@ember/object';
+
+      export default class NewPostController1 extends Controller {
+        @service('router') router;
+
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.router.replaceWith('post', post.id);
+        }
+      }`,
+    },
+    // Rule does not fire in components or modules outside of controller/routes
+    {
+      filename: 'components/index.js',
+      code: `
+      import Component from '@ember/component';
+      import { action } from '@ember/object';
+
+      export default class NewPostComponent extends Component {
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.transitionTo('post', post.id);
+        }
+      }`,
+    },
+    // Test ignore rule in non Ember classes
+    {
+      filename: 'routes/index.js',
+      code: `
+      export class Settings {
+        model() {
+          this.transitionTo('index')
+        }
+      }`,
+    },
+
+    // Test rule in class returned by function
+    {
+      filename: 'controllers/index.js',
+      code: `
+      import Controller from '@ember/controller';
+      import { inject as service } from '@ember/service';
+      import { action } from '@ember/object';
+
+      export default function saver() {
+        return class NewPostController1 extends Controller {
+          @service('router') router;
+
+          @action
+          async save({ title, text }) {
+            let post = this.store.createRecord('post', { title, text });
+            await post.save();
+            return this.router.replaceWith('post', post.id);
+          }
+        }
+      }`,
+    },
+
+    // Test rule is not triggered by invalid nested classes
+    {
+      filename: 'controllers/index.js',
+      code: `
+      import Controller from '@ember/controller';
+
+      export default class NewPostController1 extends Controller {
+        createSetting() {
+          return class Settings {
+            model() {
+              this.transitionTo('index');
+            }
+          };
+        }
+      }`,
+    },
+
+    // Test Multiple Classes in One File
+    {
+      filename: 'routes/index.js',
+      code: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export class SettingsIndexRoute extends Route {
+        model() {
+          return [];
+        }
+      }
+
+      export class SettingsDetailRoute extends Route {
+        @service('settings') settingsService;
+
+        async model(id) {
+          return new Setting(await this.settingsService.find(id));
+        }
+      }
+
+      export class SettingsRoute extends Route {
+        @service() router;
+        @service session;
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.router.transitionTo('login');
+          }
+        }
+      }`,
+    },
+  ],
+  invalid: [
+    // Route Uses RouterService.transitionTo with different service injection types
+    invalidRouteClassUsage('@service router', { methodUsed: 'transitionTo' }),
+    invalidRouteClassUsage('@service() router', { methodUsed: 'transitionTo' }),
+    invalidRouteClassUsage("@service('router') router", { methodUsed: 'transitionTo' }),
+    invalidRouteClassUsage("@service('router') routerService", {
+      methodUsed: 'transitionTo',
+      routerServiceName: 'routerService',
+    }),
+
+    // Legacy .extends Route Uses RouterService.transitionTo with different service injection types
+    invalidRouteExtendUsage('router: service(),', { methodUsed: 'transitionTo' }),
+    invalidRouteExtendUsage("router: service('router'),", {
+      methodUsed: 'transitionTo',
+    }),
+    invalidRouteExtendUsage("routerMcRouteFace: service('router'),", {
+      methodUsed: 'transitionTo',
+      routerServiceName: 'routerMcRouteFace',
+    }),
+
+    // Legacy .extends without an existing router service injection
+    {
+      filename: 'routes/index.js',
+      code: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export default Route.extend({
+        session: service(),
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.transitionTo('login');
+          }
+        }
+      })`,
+      output: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export default Route.extend({
+        router: service('router'),
+session: service(),
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.router.transitionTo('login');
+          }
+        }
+      })`,
+      errors: [
+        {
+          messageId: 'main',
+          data: { methodUsed: 'transitionTo', desiredMethod: 'transitionTo', moduleType: 'Route' },
+          type: 'MemberExpression',
+        },
+      ],
+    },
+
+    // Basic lint error in routes
+    {
+      filename: 'routes/index.js',
+      code: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export default class SettingsRoute extends Route {
+        @service session;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.transitionTo('login');
+          }
+        }
+      }`,
+      output: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export default class SettingsRoute extends Route {
+        @service('router') router;
+@service session;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.router.transitionTo('login');
+          }
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+    {
+      filename: 'routes/index.js',
+      code: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export default class SettingsRoute extends Route {
+        @service session;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.replaceWith('login');
+          }
+        }
+      }`,
+      output: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export default class SettingsRoute extends Route {
+        @service('router') router;
+@service session;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.router.replaceWith('login');
+          }
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+    // Basic lint error in controllers
+    {
+      filename: 'controllers/new-post.js',
+      code: `
+      import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController1 extends Controller {
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.transitionToRoute('post', post.id);
+        }
+      }`,
+      output: `
+      import { inject as service } from '@ember/service';
+import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController1 extends Controller {
+        @service('router') router;
+@action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.router.transitionTo('post', post.id);
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+    {
+      filename: 'controllers/new-post.js',
+      code: `
+      import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController1 extends Controller {
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.replaceRoute('post', post.id);
+        }
+      }`,
+      output: `
+      import { inject as service } from '@ember/service';
+import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController1 extends Controller {
+        @service('router') router;
+@action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.router.replaceWith('post', post.id);
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+
+    // Existing router service
+    {
+      filename: 'controllers/new-post.js',
+      code: `
+      import { inject as service } from '@ember/service';
+      import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController2 extends Controller {
+        @service('router') routerService;
+
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.transitionToRoute('post', post.id);
+        }
+      }`,
+      output: `
+      import { inject as service } from '@ember/service';
+      import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController2 extends Controller {
+        @service('router') routerService;
+
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.routerService.transitionTo('post', post.id);
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+    // Controller uses transitionToRoute with custom service injection import
+    {
+      filename: 'controllers/new-post.js',
+      code: `
+      import { inject as serviceInjection } from '@ember/service';
+      import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController3 extends Controller {
+        @serviceInjection('router') routerService;
+
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.transitionToRoute('post', post.id);
+        }
+      }`,
+      output: `
+      import { inject as serviceInjection } from '@ember/service';
+      import Controller from '@ember/controller';
+      import { action } from '@ember/object';
+
+      export default class NewPostController3 extends Controller {
+        @serviceInjection('router') routerService;
+
+        @action
+        async save({ title, text }) {
+          let post = this.store.createRecord('post', { title, text });
+          await post.save();
+          return this.routerService.transitionTo('post', post.id);
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+    // Existing injection name other than service
+    {
+      filename: 'routes/index.js',
+      code: `
+      import Route from '@ember/routing/route';
+      import { inject as injectService } from '@ember/service';
+
+      export default class SettingsRoute extends Route {
+        @injectService session;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.transitionTo('login');
+          }
+        }
+      }`,
+      output: `
+      import Route from '@ember/routing/route';
+      import { inject as injectService } from '@ember/service';
+
+      export default class SettingsRoute extends Route {
+        @injectService('router') router;
+@injectService session;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.router.transitionTo('login');
+          }
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+    // Test multiple classes in a single file
+    {
+      filename: 'routes/index.js',
+      code: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export class SettingsIndexRoute extends Route {
+        @service session;
+        @service('router') routerService;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.transitionTo('login');
+          }
+        }
+
+        model() {
+          return [];
+        }
+      }
+
+      export class SettingsDetailRoute extends Route {
+        @service('settings') settingsService;
+
+        async model(id) {
+          return new Setting(await this.settingsService.find(id));
+        }
+      }
+
+      export class SettingsRoute extends Route {
+        @service() router;
+        @service session;
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.transitionTo('login');
+          }
+        }
+      }`,
+
+      output: `
+      import Route from '@ember/routing/route';
+      import { inject as service } from '@ember/service';
+
+      export class SettingsIndexRoute extends Route {
+        @service session;
+        @service('router') routerService;
+
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.routerService.transitionTo('login');
+          }
+        }
+
+        model() {
+          return [];
+        }
+      }
+
+      export class SettingsDetailRoute extends Route {
+        @service('settings') settingsService;
+
+        async model(id) {
+          return new Setting(await this.settingsService.find(id));
+        }
+      }
+
+      export class SettingsRoute extends Route {
+        @service() router;
+        @service session;
+        beforeModel() {
+          if (!this.session.isAuthenticated) {
+            this.router.transitionTo('login');
+          }
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+
+    // Test rule in class returned by function
+    {
+      filename: 'controllers/index.js',
+      code: `
+      import Controller from '@ember/controller';
+      import { inject as service } from '@ember/service';
+      import { action } from '@ember/object';
+
+      export default function saver() {
+        return class NewPostController1 extends Controller {
+          @service('router') router;
+
+          @action
+          async save({ title, text }) {
+            let post = this.store.createRecord('post', { title, text });
+            await post.save();
+            return this.replaceRoute('post', post.id);
+          }
+        }
+      }`,
+      output: `
+      import Controller from '@ember/controller';
+      import { inject as service } from '@ember/service';
+      import { action } from '@ember/object';
+
+      export default function saver() {
+        return class NewPostController1 extends Controller {
+          @service('router') router;
+
+          @action
+          async save({ title, text }) {
+            let post = this.store.createRecord('post', { title, text });
+            await post.save();
+            return this.router.replaceWith('post', post.id);
+          }
+        }
+      }`,
+      errors: [
+        {
+          messageId: 'main',
+          type: 'MemberExpression',
+        },
+      ],
+    },
+  ],
+});