diff --git a/docs/rules/template-no-unbound.md b/docs/rules/template-no-unbound.md
index a9044f146e..1203697f71 100644
--- a/docs/rules/template-no-unbound.md
+++ b/docs/rules/template-no-unbound.md
@@ -1,7 +1,5 @@
# ember/template-no-unbound
-> **HBS Only**: This rule applies to classic `.hbs` template files only (loose mode). It is not relevant for `gjs`/`gts` files (strict mode), where these patterns cannot occur.
-
`{{unbound}}` is a legacy hold over from the days in which Ember's template engine was less performant. Its use today
diff --git a/lib/rules/template-no-unbound.js b/lib/rules/template-no-unbound.js
index 29eb9d2c07..22b6d83a4a 100644
--- a/lib/rules/template-no-unbound.js
+++ b/lib/rules/template-no-unbound.js
@@ -6,7 +6,7 @@ module.exports = {
description: 'disallow {{unbound}} helper',
category: 'Deprecations',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unbound.md',
- templateMode: 'loose',
+ templateMode: 'both',
},
schema: [],
messages: { unexpected: 'Unexpected {{unboundHelper}} usage.' },
@@ -18,8 +18,38 @@ module.exports = {
},
},
create(context) {
+ // `unbound` is an ambient strict-mode keyword (registered in Ember's
+ // STRICT_MODE_KEYWORDS, backed by BUILTIN_KEYWORD_HELPERS.unbound), so
+ // `{{unbound foo}}` works in .gjs/.gts without an import. Flag it
+ // everywhere unless shadowed by a JS binding or template block param —
+ // ember-eslint-parser registers template block params in scope, so a
+ // single getScope walk covers both.
+ const sourceCode = context.sourceCode;
+
+ function isInScope(node, name) {
+ if (!sourceCode) {
+ return false;
+ }
+ try {
+ let scope = sourceCode.getScope(node);
+ while (scope) {
+ if (scope.variables.some((v) => v.name === name)) {
+ return true;
+ }
+ scope = scope.upper;
+ }
+ } catch {
+ // getScope not available in .hbs-only mode
+ }
+ return false;
+ }
+
function check(node) {
- if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unbound') {
+ if (
+ node.path?.type === 'GlimmerPathExpression' &&
+ node.path.original === 'unbound' &&
+ !isInScope(node, 'unbound')
+ ) {
context.report({
node,
messageId: 'unexpected',
@@ -27,6 +57,7 @@ module.exports = {
});
}
}
+
return {
GlimmerMustacheStatement: check,
GlimmerBlockStatement: check,
diff --git a/tests/lib/rules/template-no-unbound.js b/tests/lib/rules/template-no-unbound.js
index 5a7013f477..9dd20e1a12 100644
--- a/tests/lib/rules/template-no-unbound.js
+++ b/tests/lib/rules/template-no-unbound.js
@@ -35,8 +35,40 @@ const gjsRuleTester = new RuleTester({
});
gjsRuleTester.run('template-no-unbound', rule, {
- valid: validHbs.map(wrapTemplate),
- invalid: invalidHbs.map(wrapTemplate),
+ valid: [
+ ...validHbs.map(wrapTemplate),
+ // JS-scope shadowing: a user-imported `unbound` is not the Glimmer keyword.
+ {
+ filename: 'test.gjs',
+ code: "import unbound from './my-unbound-helper';\n{{unbound foo}}",
+ },
+ {
+ filename: 'test.gts',
+ code: "import unbound from '@some/addon';\n{{my-thing foo=(unbound foo)}}",
+ },
+ // Local block-param shadowing.
+ {
+ filename: 'test.gjs',
+ code: '{{#let (component "foo") as |unbound|}}{{unbound}}{{/let}}',
+ },
+ ],
+ invalid: [
+ ...invalidHbs.map(wrapTemplate),
+ // `unbound` is an ambient Glimmer keyword in strict mode — flag bare uses
+ // without a shadowing import or block param.
+ {
+ filename: 'test.gjs',
+ code: '{{unbound foo}}',
+ output: null,
+ errors: [{ messageId: 'unexpected' }],
+ },
+ {
+ filename: 'test.gts',
+ code: '{{my-thing foo=(unbound foo)}}',
+ output: null,
+ errors: [{ messageId: 'unexpected' }],
+ },
+ ],
});
const hbsRuleTester = new RuleTester({