diff --git a/README.md b/README.md
index df001c9acf..fd29191384 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
| [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | |
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
+| [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-inline-event-handlers](docs/rules/template-no-inline-event-handlers.md) | disallow DOM event handler attributes | | | |
diff --git a/docs/rules/template-no-dynamic-subexpression-invocations.md b/docs/rules/template-no-dynamic-subexpression-invocations.md
new file mode 100644
index 0000000000..699f304a4e
--- /dev/null
+++ b/docs/rules/template-no-dynamic-subexpression-invocations.md
@@ -0,0 +1,62 @@
+# ember/template-no-dynamic-subexpression-invocations
+
+
+
+Disallow dynamic helper invocations.
+
+Dynamic helper invocations (where the helper name comes from a property or argument) make code harder to understand and can have performance implications. Use explicit helper names instead.
+
+## Rule Details
+
+This rule disallows invoking helpers dynamically using `this` or `@` properties.
+
+## Examples
+
+### Incorrect ❌
+
+```gjs
+
+ {{(this.helper "arg")}}
+
+```
+
+```gjs
+
+ {{(@helperName "value")}}
+
+```
+
+```gjs
+
+ {{this.formatter this.data}}
+
+```
+
+### Correct ✅
+
+```gjs
+
+ {{format-date this.date}}
+
+```
+
+```gjs
+
+ {{(upper-case this.name)}}
+
+```
+
+```gjs
+
+ {{this.formattedData}}
+
+```
+
+## Related Rules
+
+- [template-no-implicit-this](./template-no-implicit-this.md)
+
+## References
+
+- [Ember Guides - Template Helpers](https://guides.emberjs.com/release/components/helper-functions/)
+- [eslint-plugin-ember template-no-dynamic-subexpression-invocations](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-dynamic-subexpression-invocations.md)
diff --git a/lib/rules/template-no-dynamic-subexpression-invocations.js b/lib/rules/template-no-dynamic-subexpression-invocations.js
new file mode 100644
index 0000000000..78a87413e9
--- /dev/null
+++ b/lib/rules/template-no-dynamic-subexpression-invocations.js
@@ -0,0 +1,157 @@
+function isInAttrPosition(node) {
+ let p = node.parent;
+ while (p) {
+ if (p.type === 'GlimmerAttrNode') {
+ return true;
+ }
+ if (p.type === 'GlimmerConcatStatement') {
+ p = p.parent;
+ continue;
+ }
+ if (
+ p.type === 'GlimmerElementNode' ||
+ p.type === 'GlimmerTemplate' ||
+ p.type === 'GlimmerBlockStatement' ||
+ p.type === 'GlimmerBlock'
+ ) {
+ return false;
+ }
+ p = p.parent;
+ }
+ return false;
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'disallow dynamic subexpression invocations',
+ category: 'Best Practices',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-dynamic-subexpression-invocations.md',
+ templateMode: 'both',
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ noDynamicSubexpressionInvocations:
+ 'Do not use dynamic helper invocations. Use explicit helper names instead.',
+ },
+ originallyFrom: {
+ name: 'ember-template-lint',
+ rule: 'lib/rules/no-dynamic-subexpression-invocations.js',
+ docs: 'docs/rule/no-dynamic-subexpression-invocations.md',
+ tests: 'test/unit/rules/no-dynamic-subexpression-invocations-test.js',
+ },
+ },
+
+ create(context) {
+ const localScopes = [];
+
+ function pushLocals(params) {
+ localScopes.push(new Set(params || []));
+ }
+
+ function popLocals() {
+ localScopes.pop();
+ }
+
+ function isLocal(name) {
+ for (const scope of localScopes) {
+ if (scope.has(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function isDynamicPath(path) {
+ if (!path || path.type !== 'GlimmerPathExpression') {
+ return false;
+ }
+ if (path.head?.type === 'AtHead') {
+ return true;
+ }
+ if (path.head?.type === 'ThisHead') {
+ return true;
+ }
+ if (path.original && path.original.includes('.')) {
+ return true;
+ }
+ if (path.original && isLocal(path.original)) {
+ return true;
+ }
+ return false;
+ }
+
+ return {
+ GlimmerBlockStatement(node) {
+ if (node.program && node.program.blockParams) {
+ pushLocals(node.program.blockParams);
+ }
+ },
+ 'GlimmerBlockStatement:exit'(node) {
+ if (node.program && node.program.blockParams) {
+ popLocals();
+ }
+ },
+
+ GlimmerElementNode(node) {
+ if (node.blockParams && node.blockParams.length > 0) {
+ pushLocals(node.blockParams);
+ }
+ },
+ 'GlimmerElementNode:exit'(node) {
+ if (node.blockParams && node.blockParams.length > 0) {
+ popLocals();
+ }
+ },
+
+ GlimmerSubExpression(node) {
+ if (node.path && node.path.type === 'GlimmerPathExpression' && isDynamicPath(node.path)) {
+ context.report({
+ node,
+ messageId: 'noDynamicSubexpressionInvocations',
+ });
+ }
+ },
+
+ GlimmerElementModifierStatement(node) {
+ if (node.path && node.path.type === 'GlimmerPathExpression' && isDynamicPath(node.path)) {
+ context.report({
+ node,
+ messageId: 'noDynamicSubexpressionInvocations',
+ });
+ }
+ },
+
+ GlimmerMustacheStatement(node) {
+ if (node.path && node.path.type === 'GlimmerPathExpression') {
+ const inAttr = isInAttrPosition(node);
+ const hasArgs =
+ (node.params && node.params.length > 0) ||
+ (node.hash && node.hash.pairs && node.hash.pairs.length > 0);
+
+ if (inAttr && isDynamicPath(node.path) && hasArgs) {
+ // In attribute context, flag dynamic paths with arguments
+ context.report({
+ node,
+ messageId: 'noDynamicSubexpressionInvocations',
+ });
+ return;
+ }
+
+ if (!inAttr && hasArgs) {
+ // In body context, only flag this.* paths (not @args)
+ if (node.path.head?.type === 'ThisHead') {
+ context.report({
+ node,
+ messageId: 'noDynamicSubexpressionInvocations',
+ });
+ }
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/tests/lib/rules/template-no-dynamic-subexpression-invocations.js b/tests/lib/rules/template-no-dynamic-subexpression-invocations.js
new file mode 100644
index 0000000000..7ae67ef96e
--- /dev/null
+++ b/tests/lib/rules/template-no-dynamic-subexpression-invocations.js
@@ -0,0 +1,253 @@
+const rule = require('../../../lib/rules/template-no-dynamic-subexpression-invocations');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('template-no-dynamic-subexpression-invocations', rule, {
+ valid: [
+ '{{format-date this.date}}',
+ '{{(upper-case this.name)}}',
+ '{{helper "static"}}',
+
+ '{{something "here"}}',
+ '{{something}}',
+ '{{something here="goes"}}',
+ '',
+ '{{@thing "somearg"}}',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '{{1}}',
+ '{{true}}',
+ '{{null}}',
+ '{{undefined}}',
+ '{{"foo"}}',
+ ],
+
+ invalid: [
+ {
+ code: '{{(this.helper "arg")}}',
+ output: null,
+ errors: [
+ {
+ message: 'Do not use dynamic helper invocations. Use explicit helper names instead.',
+ type: 'GlimmerSubExpression',
+ },
+ ],
+ },
+ {
+ code: '{{(@helperName "value")}}',
+ output: null,
+ errors: [
+ {
+ message: 'Do not use dynamic helper invocations. Use explicit helper names instead.',
+ type: 'GlimmerSubExpression',
+ },
+ ],
+ },
+ {
+ code: '{{this.formatter this.data}}',
+ output: null,
+ errors: [
+ {
+ message: 'Do not use dynamic helper invocations. Use explicit helper names instead.',
+ type: 'GlimmerMustacheStatement',
+ },
+ ],
+ },
+
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '{{#let "whatever" as |thing|}}{{/let}}',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '{{if (this.foo) "true" "false"}}',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+});
+
+hbsRuleTester.run('template-no-dynamic-subexpression-invocations', rule, {
+ valid: [
+ '{{something "here"}}',
+ '{{something}}',
+ '{{something here="goes"}}',
+ '',
+ '{{@thing "somearg"}}',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '{{1}}',
+ '{{true}}',
+ '{{null}}',
+ '{{undefined}}',
+ '{{"foo"}}',
+ ],
+ invalid: [
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '{{#let "whatever" as |thing|}}{{/let}}',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '{{if (this.foo) "true" "false"}}',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ { message: 'Do not use dynamic helper invocations. Use explicit helper names instead.' },
+ ],
+ },
+ ],
+});