From 77fbe796fbddbf7210d8577d5de8b8074363ec95 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:24:54 -0400 Subject: [PATCH 1/2] Extract rule: template-no-args-paths --- README.md | 1 + docs/rules/template-no-args-paths.md | 78 +++++++++++++ lib/rules/template-no-args-paths.js | 34 ++++++ tests/lib/rules/template-no-args-paths.js | 131 ++++++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 docs/rules/template-no-args-paths.md create mode 100644 lib/rules/template-no-args-paths.js create mode 100644 tests/lib/rules/template-no-args-paths.js diff --git a/README.md b/README.md index b695f7a52e..cd5aa94416 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-builtin-component-arguments](docs/rules/template-builtin-component-arguments.md) | disallow setting certain attributes on builtin components | | | | | [template-no-action-modifiers](docs/rules/template-no-action-modifiers.md) | disallow usage of {{action}} modifiers | | | | | [template-no-action-on-submit-button](docs/rules/template-no-action-on-submit-button.md) | disallow action attribute on submit buttons | | | | +| [template-no-args-paths](docs/rules/template-no-args-paths.md) | disallow @args in paths | | | | | [template-no-arguments-for-html-elements](docs/rules/template-no-arguments-for-html-elements.md) | disallow @arguments on HTML elements | | | | | [template-no-array-prototype-extensions](docs/rules/template-no-array-prototype-extensions.md) | disallow usage of Ember Array prototype extensions | | | | | [template-no-at-ember-render-modifiers](docs/rules/template-no-at-ember-render-modifiers.md) | disallow usage of @ember/render-modifiers | | | | diff --git a/docs/rules/template-no-args-paths.md b/docs/rules/template-no-args-paths.md new file mode 100644 index 0000000000..bd51bce5c1 --- /dev/null +++ b/docs/rules/template-no-args-paths.md @@ -0,0 +1,78 @@ +# ember/template-no-args-paths + + + +Arguments that are passed to components are prefixed with the `@` symbol in Angle bracket syntax. +Ember Octane leverages this in the component's templates by allowing users to directly refer to an argument using the same prefix: + +```gjs + +``` + +We can immediately tell now by looking at this template that `@todos` is an argument that was passed to the component externally. This is in fact _always true_ - there is no way to modify the value referenced by `@todos` from the component class, it is the original, unmodified value. + +## Examples + +This rule **forbids** the following: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +## Migration + +- find in templates `this.args.` replace to `@` + +## Related Rules + +- [no-curly-component-invocation](no-curly-component-invocation.md) + +## References + +- [RFC #276](https://github.com/emberjs/rfcs/blob/master/text/0276-named-args.md) +- [Coming Soon in Ember Octane - Part 2: Named Argument Syntax](https://www.pzuraq.com/blog/coming-soon-in-ember-octane-part-2-angle-brackets-and-named-arguments/#namedargumentsyntax) +- [Named arguments in Ember.js](https://www.balinterdi.com/blog/named-arguments-in-ember-js/) +- [ember-named-arguments-polyfill](https://github.com/rwjblue/ember-named-arguments-polyfill) diff --git a/lib/rules/template-no-args-paths.js b/lib/rules/template-no-args-paths.js new file mode 100644 index 0000000000..34a4bd78bc --- /dev/null +++ b/lib/rules/template-no-args-paths.js @@ -0,0 +1,34 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'disallow @args in paths', + category: 'Best Practices', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-args-paths.md', + templateMode: 'both', + }, + schema: [], + messages: { argsPath: 'Do not use paths with @args, use @argName directly instead.' }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-args-paths.js', + docs: 'docs/rule/no-args-paths.md', + tests: 'test/unit/rules/no-args-paths-test.js', + }, + }, + create(context) { + return { + GlimmerPathExpression(node) { + const path = node.original; + if ( + path?.startsWith('@args.') || + path?.startsWith('args.') || + path?.startsWith('this.args.') + ) { + context.report({ node, messageId: 'argsPath' }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-args-paths.js b/tests/lib/rules/template-no-args-paths.js new file mode 100644 index 0000000000..5dbedcb0b2 --- /dev/null +++ b/tests/lib/rules/template-no-args-paths.js @@ -0,0 +1,131 @@ +const rule = require('../../../lib/rules/template-no-args-paths'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); +ruleTester.run('template-no-args-paths', rule, { + valid: [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + ], + invalid: [ + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'argsPath' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +hbsRuleTester.run('template-no-args-paths', rule, { + valid: [ + '
', + '{{foo (name this.args)}}', + '{{foo name=this.args}}', + '{{foo name=(extract this.args)}}', + '', + '', + '', + '', + ], + invalid: [ + { + code: '{{hello (format value=args.foo)}}', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + { + code: '{{hello value=args.foo}}', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + { + code: '{{hello (format args.foo.bar)}}', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + { + code: '
', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + { + code: '{{hello args.foo.bar}}', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + { + code: '{{args.foo.bar}}', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + { + code: '{{args.foo}}', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + { + code: '{{this.args.foo}}', + output: null, + errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + }, + ], +}); From d9befc81dd94e8ef502ba7250444d057b5e92f9d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:09:32 -0400 Subject: [PATCH 2/2] Apply PR Feedback --- README.md | 2 +- docs/rules/template-no-args-paths.md | 2 + lib/rules/template-no-args-paths.js | 79 ++++++++++++++++++++--- tests/lib/rules/template-no-args-paths.js | 62 +++++++++--------- 4 files changed, 106 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index cd5aa94416..c2924ba272 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-builtin-component-arguments](docs/rules/template-builtin-component-arguments.md) | disallow setting certain attributes on builtin components | | | | | [template-no-action-modifiers](docs/rules/template-no-action-modifiers.md) | disallow usage of {{action}} modifiers | | | | | [template-no-action-on-submit-button](docs/rules/template-no-action-on-submit-button.md) | disallow action attribute on submit buttons | | | | -| [template-no-args-paths](docs/rules/template-no-args-paths.md) | disallow @args in paths | | | | +| [template-no-args-paths](docs/rules/template-no-args-paths.md) | disallow args.foo paths in templates, use @foo instead | | 🔧 | | | [template-no-arguments-for-html-elements](docs/rules/template-no-arguments-for-html-elements.md) | disallow @arguments on HTML elements | | | | | [template-no-array-prototype-extensions](docs/rules/template-no-array-prototype-extensions.md) | disallow usage of Ember Array prototype extensions | | | | | [template-no-at-ember-render-modifiers](docs/rules/template-no-at-ember-render-modifiers.md) | disallow usage of @ember/render-modifiers | | | | diff --git a/docs/rules/template-no-args-paths.md b/docs/rules/template-no-args-paths.md index bd51bce5c1..c8753d8e4e 100644 --- a/docs/rules/template-no-args-paths.md +++ b/docs/rules/template-no-args-paths.md @@ -1,5 +1,7 @@ # ember/template-no-args-paths +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + Arguments that are passed to components are prefixed with the `@` symbol in Angle bracket syntax. diff --git a/lib/rules/template-no-args-paths.js b/lib/rules/template-no-args-paths.js index 34a4bd78bc..e687bcb112 100644 --- a/lib/rules/template-no-args-paths.js +++ b/lib/rules/template-no-args-paths.js @@ -3,13 +3,17 @@ module.exports = { meta: { type: 'problem', docs: { - description: 'disallow @args in paths', + description: 'disallow args.foo paths in templates, use @foo instead', category: 'Best Practices', url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-args-paths.md', templateMode: 'both', }, + fixable: 'code', schema: [], - messages: { argsPath: 'Do not use paths with @args, use @argName directly instead.' }, + messages: { + argsPath: + 'Component templates should avoid "{{path}}" usage, try "@{{replacement}}" instead.', + }, originallyFrom: { name: 'ember-template-lint', rule: 'lib/rules/no-args-paths.js', @@ -18,16 +22,75 @@ module.exports = { }, }, 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; + } + 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(); + } + }, + GlimmerPathExpression(node) { const path = node.original; - if ( - path?.startsWith('@args.') || - path?.startsWith('args.') || - path?.startsWith('this.args.') - ) { - context.report({ node, messageId: 'argsPath' }); + + // @args.foo is a valid named argument — skip paths starting with @ + if (node.head?.type === 'AtHead') { + return; + } + + if (!path?.startsWith('args.') && !path?.startsWith('this.args.')) { + return; + } + + // Skip when 'args' is a block param in the current scope + if (isLocal('args')) { + return; } + + const replacement = path.replace(/^(this\.)?args\./, ''); + + context.report({ + node, + messageId: 'argsPath', + data: { path, replacement }, + fix(fixer) { + return fixer.replaceText(node, `@${replacement}`); + }, + }); }, }; }, diff --git a/tests/lib/rules/template-no-args-paths.js b/tests/lib/rules/template-no-args-paths.js index 5dbedcb0b2..649468c2f9 100644 --- a/tests/lib/rules/template-no-args-paths.js +++ b/tests/lib/rules/template-no-args-paths.js @@ -8,6 +8,8 @@ const ruleTester = new RuleTester({ ruleTester.run('template-no-args-paths', rule, { valid: [ '', + // @args.foo is a valid named argument, not a path violation + '', '', '', '', @@ -16,52 +18,48 @@ ruleTester.run('template-no-args-paths', rule, { '', '', '', + // args as a block param is not flagged + '', ], invalid: [ - { - code: '', - output: null, - errors: [{ messageId: 'argsPath' }], - }, - { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, { code: '', - output: null, + output: '', errors: [{ messageId: 'argsPath' }], }, ], @@ -77,6 +75,8 @@ const hbsRuleTester = new RuleTester({ hbsRuleTester.run('template-no-args-paths', rule, { valid: [ + // @args.foo is a valid named argument + '{{@args.foo}}', '
', '{{foo (name this.args)}}', '{{foo name=this.args}}', @@ -85,47 +85,49 @@ hbsRuleTester.run('template-no-args-paths', rule, { '', '', '', + // args as a block param is not flagged + '{{#each items as |args|}}{{args.name}}{{/each}}', ], invalid: [ { code: '{{hello (format value=args.foo)}}', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '{{hello (format value=@foo)}}', + errors: [{ messageId: 'argsPath' }], }, { code: '{{hello value=args.foo}}', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '{{hello value=@foo}}', + errors: [{ messageId: 'argsPath' }], }, { code: '{{hello (format args.foo.bar)}}', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '{{hello (format @foo.bar)}}', + errors: [{ messageId: 'argsPath' }], }, { code: '
', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '
', + errors: [{ messageId: 'argsPath' }], }, { code: '{{hello args.foo.bar}}', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '{{hello @foo.bar}}', + errors: [{ messageId: 'argsPath' }], }, { code: '{{args.foo.bar}}', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '{{@foo.bar}}', + errors: [{ messageId: 'argsPath' }], }, { code: '{{args.foo}}', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '{{@foo}}', + errors: [{ messageId: 'argsPath' }], }, { code: '{{this.args.foo}}', - output: null, - errors: [{ message: 'Do not use paths with @args, use @argName directly instead.' }], + output: '{{@foo}}', + errors: [{ messageId: 'argsPath' }], }, ], });