From c7c01777c03fe486d311974cd3afbae1d76fef58 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Tue, 10 Mar 2026 18:29:46 -0400
Subject: [PATCH] Extract rule: template-no-index-component-invocation
---
README.md | 1 +
.../template-no-index-component-invocation.md | 61 +++++++
.../template-no-index-component-invocation.js | 104 +++++++++++
.../template-no-index-component-invocation.js | 168 ++++++++++++++++++
4 files changed, 334 insertions(+)
create mode 100644 docs/rules/template-no-index-component-invocation.md
create mode 100644 lib/rules/template-no-index-component-invocation.js
create mode 100644 tests/lib/rules/template-no-index-component-invocation.js
diff --git a/README.md b/README.md
index 5de51a02ff..de30c75deb 100644
--- a/README.md
+++ b/README.md
@@ -211,6 +211,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | |
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
| [template-no-implicit-this](docs/rules/template-no-implicit-this.md) | require explicit `this` in property access | | | |
+| [template-no-index-component-invocation](docs/rules/template-no-index-component-invocation.md) | disallow index component invocations | | | |
| [template-no-inline-event-handlers](docs/rules/template-no-inline-event-handlers.md) | disallow DOM event handler attributes | | | |
| [template-no-inline-styles](docs/rules/template-no-inline-styles.md) | disallow inline styles | | | |
| [template-no-input-block](docs/rules/template-no-input-block.md) | disallow block usage of {{input}} helper | | | |
diff --git a/docs/rules/template-no-index-component-invocation.md b/docs/rules/template-no-index-component-invocation.md
new file mode 100644
index 0000000000..88558f8139
--- /dev/null
+++ b/docs/rules/template-no-index-component-invocation.md
@@ -0,0 +1,61 @@
+# ember/template-no-index-component-invocation
+
+
+
+Disallows invoking components using an explicit `/index` or `::Index` suffix.
+
+Components and Component Templates can be structured as `app/components/foo-bar/index.js` and
+`app/components/foo-bar/index.hbs`. This allows additional files related to the
+component (such as a `README.md` file) to be co-located on the filesystem.
+
+For template-only components, they can be either `app/components/foo-bar.hbs`
+or `app/components/foo-bar/index.hbs` without a corresponding JavaScript file.
+
+Similarly, for addons, templates can be placed inside `addon/components` with
+the same rules laid out above.
+
+In all of these case, if a template file is present in `app/components` or
+`addon/components`, it will take precedence over any corresponding template
+files in `app/templates`, the `layout` property on classic components, or a
+template with the same name that is made available with the resolver API.
+Instead of being resolved at runtime, a template in `app/components` will be
+associated with the component's JavaScript class at build time.
+
+## Examples
+
+This rule **forbids** the following:
+
+```gjs
+
+```
+
+```gjs
+{{component 'foo/index'}}
+```
+
+```gjs
+{{foo/index}}
+```
+
+This rule **allows** the following:
+
+```gjs
+
+```
+
+```gjs
+{{component 'foo'}}
+```
+
+```gjs
+{{foo}}
+```
+
+## Migration
+
+- replace all `::Index>` to `>`
+- replace all `/index}}` to `}}`
+
+## References
+
+- [RFC #481](https://github.com/emberjs/rfcs/blob/master/text/0481-component-templates-co-location.md#high-level-design)
diff --git a/lib/rules/template-no-index-component-invocation.js b/lib/rules/template-no-index-component-invocation.js
new file mode 100644
index 0000000000..a3f71e98ec
--- /dev/null
+++ b/lib/rules/template-no-index-component-invocation.js
@@ -0,0 +1,104 @@
+/* eslint-disable complexity, eslint-plugin/prefer-placeholders, unicorn/explicit-length-check */
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'disallow index component invocations',
+ category: 'Best Practices',
+ recommended: false,
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-index-component-invocation.md',
+ templateMode: 'both',
+ },
+ fixable: null,
+ schema: [],
+ messages: {},
+ originallyFrom: {
+ name: 'ember-template-lint',
+ rule: 'lib/rules/no-index-component-invocation.js',
+ docs: 'docs/rule/no-index-component-invocation.md',
+ tests: 'test/unit/rules/no-index-component-invocation-test.js',
+ },
+ },
+
+ create(context) {
+ function lintIndexUsage(node) {
+ // Handle angle bracket components:
+ if (node.type === 'GlimmerElementNode') {
+ if (node.tag && node.tag.endsWith('::Index')) {
+ const invocation = `<${node.tag}`;
+ const replacement = `<${node.tag.replace('::Index', '')}`;
+
+ context.report({
+ node,
+ message: `Replace \`${invocation} ...\` to \`${replacement} ...\``,
+ });
+ }
+ return;
+ }
+
+ // Handle mustache and block statements: {{foo/index}} or {{#foo/index}}
+ if (node.type === 'GlimmerMustacheStatement' || node.type === 'GlimmerBlockStatement') {
+ if (
+ node.path &&
+ node.path.type === 'GlimmerPathExpression' &&
+ node.path.original &&
+ node.path.original.endsWith('/index')
+ ) {
+ const invocationPrefix = node.type === 'GlimmerBlockStatement' ? '{{#' : '{{';
+ const invocation = `${invocationPrefix}${node.path.original}`;
+ const replacement = `${invocationPrefix}${node.path.original.replace('/index', '')}`;
+
+ context.report({
+ node: node.path,
+ message: `Replace \`${invocation} ...\` to \`${replacement} ...\``,
+ });
+ return;
+ }
+ }
+
+ // Handle component helper: {{component "foo/index"}} or (component "foo/index")
+ if (
+ node.type === 'GlimmerMustacheStatement' ||
+ node.type === 'GlimmerBlockStatement' ||
+ node.type === 'GlimmerSubExpression'
+ ) {
+ const prefix =
+ node.type === 'GlimmerMustacheStatement'
+ ? '{{'
+ : node.type === 'GlimmerBlockStatement'
+ ? '{{#'
+ : '(';
+
+ if (
+ node.path &&
+ node.path.type === 'GlimmerPathExpression' &&
+ node.path.original === 'component' &&
+ node.params &&
+ node.params.length > 0 &&
+ node.params[0].type === 'GlimmerStringLiteral'
+ ) {
+ const componentName = node.params[0].value;
+
+ if (componentName.endsWith('/index')) {
+ const invocation = `${prefix}component "${componentName}"`;
+ const replacement = `${prefix}component "${componentName.replace('/index', '')}"`;
+
+ context.report({
+ node: node.params[0],
+ message: `Replace \`${invocation} ...\` to \`${replacement} ...\``,
+ });
+ }
+ }
+ }
+ }
+
+ return {
+ GlimmerElementNode: lintIndexUsage,
+ GlimmerMustacheStatement: lintIndexUsage,
+ GlimmerBlockStatement: lintIndexUsage,
+ GlimmerSubExpression: lintIndexUsage,
+ };
+ },
+};
+/* eslint-enable complexity, eslint-plugin/prefer-placeholders, unicorn/explicit-length-check */
diff --git a/tests/lib/rules/template-no-index-component-invocation.js b/tests/lib/rules/template-no-index-component-invocation.js
new file mode 100644
index 0000000000..f658d41093
--- /dev/null
+++ b/tests/lib/rules/template-no-index-component-invocation.js
@@ -0,0 +1,168 @@
+const rule = require('../../../lib/rules/template-no-index-component-invocation');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('template-no-index-component-invocation', rule, {
+ valid: [
+ '',
+ '',
+ '',
+ '',
+ '{{foo/index-item}}',
+ '{{foo/my-index}}',
+ '{{foo/bar}}',
+ '{{#foo/bar}}{{/foo/bar}}',
+ '{{component "foo/bar"}}',
+ '{{component "foo/my-index"}}',
+ '{{component "foo/index-item"}}',
+ '{{#component "foo/index-item"}}{{/component}}',
+ ],
+ invalid: [
+ {
+ code: '{{foo/index}}',
+ output: null,
+ errors: [
+ {
+ message: 'Replace `{{foo/index ...` to `{{foo ...`',
+ },
+ ],
+ },
+ {
+ code: '{{component "foo/index"}}',
+ output: null,
+ errors: [
+ {
+ message: 'Replace `{{component "foo/index" ...` to `{{component "foo" ...`',
+ },
+ ],
+ },
+ {
+ code: '{{#foo/index}}{{/foo/index}}',
+ output: null,
+ errors: [
+ {
+ message: 'Replace `{{#foo/index ...` to `{{#foo ...`',
+ },
+ ],
+ },
+ {
+ code: '{{#component "foo/index"}}{{/component}}',
+ output: null,
+ errors: [
+ {
+ message: 'Replace `{{#component "foo/index" ...` to `{{#component "foo" ...`',
+ },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ {
+ message: 'Replace `',
+ output: null,
+ errors: [
+ {
+ message: 'Replace `{{foo/bar (component "foo/index")}}',
+ output: null,
+ errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }],
+ },
+ {
+ code: '{{foo/bar name=(component "foo/index")}}',
+ output: null,
+ errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ message: 'Replace `',
+ '',
+ '',
+ '',
+ '{{foo/index-item}}',
+ '{{foo/my-index}}',
+ '{{foo/bar}}',
+ '{{#foo/bar}}{{/foo/bar}}',
+ '{{component "foo/bar"}}',
+ '{{component "foo/my-index"}}',
+ '{{component "foo/index-item"}}',
+ '{{#component "foo/index-item"}}{{/component}}',
+ ],
+ invalid: [
+ {
+ code: '{{foo/index}}',
+ output: null,
+ errors: [{ message: 'Replace `{{foo/index ...` to `{{foo ...`' }],
+ },
+ {
+ code: '{{component "foo/index"}}',
+ output: null,
+ errors: [{ message: 'Replace `{{component "foo/index" ...` to `{{component "foo" ...`' }],
+ },
+ {
+ code: '{{#foo/index}}{{/foo/index}}',
+ output: null,
+ errors: [{ message: 'Replace `{{#foo/index ...` to `{{#foo ...`' }],
+ },
+ {
+ code: '{{#component "foo/index"}}{{/component}}',
+ output: null,
+ errors: [{ message: 'Replace `{{#component "foo/index" ...` to `{{#component "foo" ...`' }],
+ },
+ {
+ code: '{{foo/bar (component "foo/index")}}',
+ output: null,
+ errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }],
+ },
+ {
+ code: '{{foo/bar name=(component "foo/index")}}',
+ output: null,
+ errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ message: 'Replace `',
+ output: null,
+ errors: [{ message: 'Replace `',
+ output: null,
+ errors: [{ message: 'Replace `