From 65d7dde88bdf091b08f7b761d8d116217b5bbc4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 15 Apr 2026 20:09:41 +0200 Subject: [PATCH 1/2] hbs-parser: register block-param scope for `as |x|` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HBS parser previously analyzed an empty stub Program, so block params from `` and `{{#let ... as |x|}}` were never registered in any scope. Downstream rules that walk scope to resolve block-param references (or check whether an angle-bracket tag is a local variable, e.g. template-no-block-params-for-html-elements in eslint-plugin-ember) had no scope to consult and silently fell back to heuristics on .hbs files. Wires the existing `registerGlimmerScopes` helper into the HBS path with a new `blockParamsOnly` mode, leaving free identifiers (`{{path}}`, ``) unregistered so they remain runtime-defined and do not surface as no-undef errors. The global scope is rebound from a stub Program to the real Program — analyzing the real Program directly hits an esrecurse cycle on the Glimmer subtree's parent back-links. Also adds an early-exit to `registerBlockParams` when there are no block params to declare, which avoids creating empty BlockScopes that slow `findParentScope`/`findVarInParentScopes` lookups (benefits the gjs/gts path too). --- src/parser/hbs-parser.js | 33 +++++++++++++++++-------------- src/parser/transforms.js | 26 ++++++++++++++++--------- tests/hbs-parser.test.js | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/parser/hbs-parser.js b/src/parser/hbs-parser.js index 00fb8f6..6752dff 100644 --- a/src/parser/hbs-parser.js +++ b/src/parser/hbs-parser.js @@ -1,5 +1,6 @@ import * as eslintScope from 'eslint-scope'; import { toTree, glimmerVisitorKeys, DocumentLines } from 'ember-estree'; +import { registerGlimmerScopes } from './transforms.js'; // Constant: Program + all Glimmer node types. Computed once at module load. const hbsVisitorKeys = { Program: ['body'], ...glimmerVisitorKeys }; @@ -64,26 +65,30 @@ export function parseForESLint(code, options) { }, }; - // Build visitor keys: Program + all Glimmer node types - const visitorKeys = hbsVisitorKeys; + // Analyze a stub Program then rebind the resulting global scope to the real + // Program. Analyzing the real Program directly causes infinite recursion in + // esrecurse because Glimmer subtree nodes carry parent back-links. + const stubProgram = { + type: 'Program', + body: [], + range: [0, code.length], + loc: program.loc, + }; + const scopeManager = eslintScope.analyze(stubProgram, { range: true }); + const globalScope = scopeManager.acquire(stubProgram); + globalScope.block = program; + scopeManager.__nodeToScope.delete(stubProgram); + scopeManager.__nodeToScope.set(program, [globalScope]); - // Create an empty scope manager. - // For HBS, all locals are assumed to be defined at runtime, - // so we don't track variable references (no no-undef errors). - const scopeManager = eslintScope.analyze( - { - type: 'Program', - body: [], - range: [0, code.length], - loc: program.loc, - }, - { range: true } + registerGlimmerScopes( + { ast: program, scopeManager, visitorKeys: hbsVisitorKeys, isTypescript: false }, + { blockParamsOnly: true } ); return { ast: program, scopeManager, - visitorKeys, + visitorKeys: hbsVisitorKeys, services: {}, }; } diff --git a/src/parser/transforms.js b/src/parser/transforms.js index e9e0c60..e2f5c80 100644 --- a/src/parser/transforms.js +++ b/src/parser/transforms.js @@ -82,6 +82,7 @@ function isUpperCase(char) { function registerBlockParams(node, path, scopeManager, isTypescript) { const blockParamNodes = node.blockParamNodes || []; + if (blockParamNodes.length === 0) return; const upperScope = findParentScope(scopeManager, path); const scope = isTypescript ? new TypescriptScope.BlockScope(scopeManager, upperScope, node) @@ -220,21 +221,28 @@ function traverse(visitorKeys, node, visitor) { } /** - * Full AST traversal for scope registration — used as fallback for JS/oxc path. - * For the TS path, toTree's visitor API handles this during splicing. + * Full AST traversal for scope registration — used as fallback for JS/oxc path + * and for the HBS parser. For the TS path, toTree's visitor API handles this + * during splicing. + * + * Pass `{ blockParamsOnly: true }` to skip PathExpression/ElementNode + * reference registration — used by HBS where free identifiers are treated as + * runtime-defined and must not surface as no-undef errors. */ -export function registerGlimmerScopes(result) { +export function registerGlimmerScopes(result, { blockParamsOnly = false } = {}) { // eslint-disable-next-line complexity traverse(result.visitorKeys, result.ast, (path) => { const node = path.node; if (!node) return; - if (node.type === 'GlimmerPathExpression') { - registerPathExpression(node, path, result.scopeManager); - } - if (node.type === 'GlimmerElementNode') { - registerElementNode(node, path, result.scopeManager); + if (!blockParamsOnly) { + if (node.type === 'GlimmerPathExpression') { + registerPathExpression(node, path, result.scopeManager); + } + if (node.type === 'GlimmerElementNode') { + registerElementNode(node, path, result.scopeManager); + } } - if ('blockParams' in node && node.type?.startsWith('Glimmer')) { + if ('blockParams' in node && node.type.startsWith('Glimmer')) { registerBlockParams(node, path, result.scopeManager, result.isTypescript); } }); diff --git a/tests/hbs-parser.test.js b/tests/hbs-parser.test.js index a539314..68f9e24 100644 --- a/tests/hbs-parser.test.js +++ b/tests/hbs-parser.test.js @@ -118,6 +118,48 @@ describe('hbs-parser', () => { }); }); + describe('block-param scope', () => { + function findBlockParamVar(scopeManager, name) { + for (const scope of scopeManager.scopes) { + if (scope.type !== 'block') continue; + const v = scope.set.get(name); + if (v) return { scope, variable: v }; + } + return null; + } + + it('registers block params from ', () => { + const r = parseForESLint('{{x}}', { filePath: 'bp.hbs' }); + const found = findBlockParamVar(r.scopeManager, 'x'); + expect(found).not.toBeNull(); + expect(found.variable.defs).toHaveLength(2); + expect(found.variable.defs[0].type).toBe('Parameter'); + }); + + it('registers block params from {{#let ... as |router|}}', () => { + const r = parseForESLint('{{#let foo as |router|}}{{router}}{{/let}}', { + filePath: 'let.hbs', + }); + const found = findBlockParamVar(r.scopeManager, 'router'); + expect(found).not.toBeNull(); + }); + + it('registers multiple block params', () => { + const r = parseForESLint('{{a}}{{b}}{{c}}', { + filePath: 'multi.hbs', + }); + expect(findBlockParamVar(r.scopeManager, 'a')).not.toBeNull(); + expect(findBlockParamVar(r.scopeManager, 'b')).not.toBeNull(); + expect(findBlockParamVar(r.scopeManager, 'c')).not.toBeNull(); + }); + + it('does not create block scopes for elements without `as |...|`', () => { + const r = parseForESLint('hi', { filePath: 'noparams.hbs' }); + const blockScopes = r.scopeManager.scopes.filter((s) => s.type === 'block'); + expect(blockScopes).toHaveLength(0); + }); + }); + describe('lint rules', () => { let linter; From 1c9c18bc1f53682abd3dd7890f5181bab22ee186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 15 Apr 2026 23:35:54 +0200 Subject: [PATCH 2/2] Extract registerHBSScopes instead of adding option to registerGlimmerScopes Addresses review: https://github.com/ember-tooling/ember-eslint-parser/pull/189#discussion_r* --- src/parser/hbs-parser.js | 7 ++----- src/parser/transforms.js | 41 ++++++++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/parser/hbs-parser.js b/src/parser/hbs-parser.js index 6752dff..e0cf58e 100644 --- a/src/parser/hbs-parser.js +++ b/src/parser/hbs-parser.js @@ -1,6 +1,6 @@ import * as eslintScope from 'eslint-scope'; import { toTree, glimmerVisitorKeys, DocumentLines } from 'ember-estree'; -import { registerGlimmerScopes } from './transforms.js'; +import { registerHBSScopes } from './transforms.js'; // Constant: Program + all Glimmer node types. Computed once at module load. const hbsVisitorKeys = { Program: ['body'], ...glimmerVisitorKeys }; @@ -80,10 +80,7 @@ export function parseForESLint(code, options) { scopeManager.__nodeToScope.delete(stubProgram); scopeManager.__nodeToScope.set(program, [globalScope]); - registerGlimmerScopes( - { ast: program, scopeManager, visitorKeys: hbsVisitorKeys, isTypescript: false }, - { blockParamsOnly: true } - ); + registerHBSScopes({ ast: program, scopeManager, visitorKeys: hbsVisitorKeys }); return { ast: program, diff --git a/src/parser/transforms.js b/src/parser/transforms.js index e2f5c80..35e6b38 100644 --- a/src/parser/transforms.js +++ b/src/parser/transforms.js @@ -221,33 +221,42 @@ function traverse(visitorKeys, node, visitor) { } /** - * Full AST traversal for scope registration — used as fallback for JS/oxc path - * and for the HBS parser. For the TS path, toTree's visitor API handles this - * during splicing. - * - * Pass `{ blockParamsOnly: true }` to skip PathExpression/ElementNode - * reference registration — used by HBS where free identifiers are treated as - * runtime-defined and must not surface as no-undef errors. + * Full AST traversal for scope registration — used as fallback for JS/oxc path. + * For the TS path, toTree's visitor API handles this during splicing. */ -export function registerGlimmerScopes(result, { blockParamsOnly = false } = {}) { +export function registerGlimmerScopes(result) { // eslint-disable-next-line complexity traverse(result.visitorKeys, result.ast, (path) => { const node = path.node; if (!node) return; - if (!blockParamsOnly) { - if (node.type === 'GlimmerPathExpression') { - registerPathExpression(node, path, result.scopeManager); - } - if (node.type === 'GlimmerElementNode') { - registerElementNode(node, path, result.scopeManager); - } + if (node.type === 'GlimmerPathExpression') { + registerPathExpression(node, path, result.scopeManager); } - if ('blockParams' in node && node.type.startsWith('Glimmer')) { + if (node.type === 'GlimmerElementNode') { + registerElementNode(node, path, result.scopeManager); + } + if ('blockParams' in node && node.type?.startsWith('Glimmer')) { registerBlockParams(node, path, result.scopeManager, result.isTypescript); } }); } +/** + * Scope registration for the HBS parser. Unlike the gjs/gts path, we do not + * register references for free identifiers (`{{path}}`, ``) — all + * template locals are treated as runtime-defined, so no-undef stays quiet. + * Only block params from `as |x|` constructs are declared. + */ +export function registerHBSScopes(result) { + traverse(result.visitorKeys, result.ast, (path) => { + const node = path.node; + if (!node) return; + if ('blockParams' in node && node.type.startsWith('Glimmer')) { + registerBlockParams(node, path, result.scopeManager, false); + } + }); +} + // ── transformForLint (used by ts-patch.js) ──────────────────────────── export const replaceRange = function replaceRange(s, start, end, substitute) {