Skip to content

Commit e5b3dfc

Browse files
committed
Fix template-no-unbound false positive in GJS/GTS
`unbound` is an ambient strict-mode keyword in Glimmer/Ember (registered in STRICT_MODE_KEYWORDS, backed by BUILTIN_KEYWORD_HELPERS.unbound), so `{{unbound foo}}` works in .gjs/.gts templates without an import. The rule should still flag those uses — its purpose is to discourage `unbound` everywhere — but skip cases where the user has shadowed `unbound` with a JS import or a template block param. Replace the previous .hbs-only gate with a JS-scope-binding check (mirroring template-no-log): walk sourceCode.getScope().variables to detect imports/consts/params, plus track template block params, and skip reporting only when the identifier resolves to a local. This restores correct behavior for: - bare `{{unbound foo}}` in .gjs (now flagged again — was a false negative under the .hbs-only gate) - `import unbound from '...'; {{unbound foo}}` (correctly skipped) - `{{#let (...) as |unbound|}}{{unbound}}{{/let}}` (correctly skipped) Update templateMode metadata from 'loose' to 'both' to reflect that the rule now runs in strict mode.
1 parent b705850 commit e5b3dfc

2 files changed

Lines changed: 109 additions & 5 deletions

File tree

lib/rules/template-no-unbound.js

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module.exports = {
66
description: 'disallow {{unbound}} helper',
77
category: 'Deprecations',
88
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unbound.md',
9-
templateMode: 'loose',
9+
templateMode: 'both',
1010
},
1111
schema: [],
1212
messages: { unexpected: 'Unexpected {{unboundHelper}} usage.' },
@@ -18,18 +18,90 @@ module.exports = {
1818
},
1919
},
2020
create(context) {
21+
// `unbound` is an ambient strict-mode keyword in Glimmer/Ember (registered
22+
// in STRICT_MODE_KEYWORDS), so `{{unbound foo}}` in a .gjs/.gts file
23+
// resolves to the built-in helper without an import. Flag it everywhere
24+
// unless the user has shadowed `unbound` with a JS binding or a template
25+
// block param.
26+
const sourceCode = context.sourceCode;
27+
const localScopes = [];
28+
29+
function isJsScopeVariable(node) {
30+
if (!sourceCode || !node.path?.original) {
31+
return false;
32+
}
33+
const name = node.path.original;
34+
try {
35+
let scope = sourceCode.getScope(node);
36+
while (scope) {
37+
if (scope.variables.some((v) => v.name === name)) {
38+
return true;
39+
}
40+
scope = scope.upper;
41+
}
42+
} catch {
43+
// sourceCode.getScope may not be available in .hbs-only mode; ignore.
44+
}
45+
return false;
46+
}
47+
48+
function pushLocals(params) {
49+
localScopes.push(new Set(params || []));
50+
}
51+
52+
function popLocals() {
53+
localScopes.pop();
54+
}
55+
56+
function isLocal(name) {
57+
for (const scope of localScopes) {
58+
if (scope.has(name)) {
59+
return true;
60+
}
61+
}
62+
return false;
63+
}
64+
2165
function check(node) {
22-
if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unbound') {
66+
if (
67+
node.path?.type === 'GlimmerPathExpression' &&
68+
node.path.original === 'unbound' &&
69+
!isLocal('unbound') &&
70+
!isJsScopeVariable(node)
71+
) {
2372
context.report({
2473
node,
2574
messageId: 'unexpected',
2675
data: { unboundHelper: '{{unbound}}' },
2776
});
2877
}
2978
}
79+
3080
return {
81+
GlimmerBlockStatement(node) {
82+
if (node.program?.blockParams) {
83+
pushLocals(node.program.blockParams);
84+
}
85+
check(node);
86+
},
87+
'GlimmerBlockStatement:exit'(node) {
88+
if (node.program?.blockParams) {
89+
popLocals();
90+
}
91+
},
92+
93+
GlimmerElementNode(node) {
94+
if (node.blockParams?.length > 0) {
95+
pushLocals(node.blockParams);
96+
}
97+
},
98+
'GlimmerElementNode:exit'(node) {
99+
if (node.blockParams?.length > 0) {
100+
popLocals();
101+
}
102+
},
103+
31104
GlimmerMustacheStatement: check,
32-
GlimmerBlockStatement: check,
33105
GlimmerSubExpression: check,
34106
};
35107
},

tests/lib/rules/template-no-unbound.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,40 @@ const gjsRuleTester = new RuleTester({
3535
});
3636

3737
gjsRuleTester.run('template-no-unbound', rule, {
38-
valid: validHbs.map(wrapTemplate),
39-
invalid: invalidHbs.map(wrapTemplate),
38+
valid: [
39+
...validHbs.map(wrapTemplate),
40+
// JS-scope shadowing: a user-imported `unbound` is not the Glimmer keyword.
41+
{
42+
filename: 'test.gjs',
43+
code: "import unbound from './my-unbound-helper';\n<template>{{unbound foo}}</template>",
44+
},
45+
{
46+
filename: 'test.gts',
47+
code: "import unbound from '@some/addon';\n<template>{{my-thing foo=(unbound foo)}}</template>",
48+
},
49+
// Local block-param shadowing.
50+
{
51+
filename: 'test.gjs',
52+
code: '<template>{{#let (component "foo") as |unbound|}}{{unbound}}{{/let}}</template>',
53+
},
54+
],
55+
invalid: [
56+
...invalidHbs.map(wrapTemplate),
57+
// `unbound` is an ambient Glimmer keyword in strict mode — flag bare uses
58+
// without a shadowing import or block param.
59+
{
60+
filename: 'test.gjs',
61+
code: '<template>{{unbound foo}}</template>',
62+
output: null,
63+
errors: [{ messageId: 'unexpected' }],
64+
},
65+
{
66+
filename: 'test.gts',
67+
code: '<template>{{my-thing foo=(unbound foo)}}</template>',
68+
output: null,
69+
errors: [{ messageId: 'unexpected' }],
70+
},
71+
],
4072
});
4173

4274
const hbsRuleTester = new RuleTester({

0 commit comments

Comments
 (0)