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", + }, + { + filename: 'test.gts', + code: "import unbound from '@some/addon';\n", + }, + // Local block-param shadowing. + { + filename: 'test.gjs', + code: '', + }, + ], + 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: '', + output: null, + errors: [{ messageId: 'unexpected' }], + }, + { + filename: 'test.gts', + code: '', + output: null, + errors: [{ messageId: 'unexpected' }], + }, + ], }); const hbsRuleTester = new RuleTester({