From 10f96528d124f644bed74fc529d80fd108145b4d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:32:10 -0400 Subject: [PATCH] Add scope tracker with two entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ember-estree/scope`: standalone scope analysis (JS + Glimmer) with lightweight built-in classes. For codemods, tools, zmod-ember. - `ember-estree/eslint-scope`: augments an eslint-scope ScopeManager with Glimmer bindings. One function, one argument: `registerGlimmerScopes(scopeManager)`. Both share internal Glimmer walking logic (scope-shared.js) that handles path expressions, component references, and block params. Each entry point merges oxc-parser's visitor keys with glimmer's so the shared walk always has complete coverage — no Object.keys fallbacks needed. Simplifies the eslint-parser example to use `registerGlimmerScopes` from the new entry point, removing ~80 lines of hand-rolled scope helpers. Co-Authored-By: Claude Opus 4.6 (1M context) Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/eslint-parser/parser.js | 126 +------ package.json | 17 + pnpm-lock.yaml | 3 + src/eslint-scope.d.ts | 7 + src/eslint-scope.js | 87 +++++ src/scope-shared.js | 108 ++++++ src/scope.d.ts | 66 ++++ src/scope.js | 621 +++++++++++++++++++++++++++++++ tests/eslint-scope.test.js | 127 +++++++ tests/scope.test.js | 304 +++++++++++++++ 10 files changed, 1345 insertions(+), 121 deletions(-) create mode 100644 src/eslint-scope.d.ts create mode 100644 src/eslint-scope.js create mode 100644 src/scope-shared.js create mode 100644 src/scope.d.ts create mode 100644 src/scope.js create mode 100644 tests/eslint-scope.test.js create mode 100644 tests/scope.test.js diff --git a/examples/eslint-parser/parser.js b/examples/eslint-parser/parser.js index 2d16929..0242ee6 100644 --- a/examples/eslint-parser/parser.js +++ b/examples/eslint-parser/parser.js @@ -4,8 +4,10 @@ * @see https://eslint.org/docs/latest/extend/custom-parsers */ import { toTree, glimmerVisitorKeys, DocumentLines } from "ember-estree"; -import { analyze, Reference, Scope, Variable, Definition } from "eslint-scope"; -import { isKeyword } from "@glimmer/syntax"; +import { analyze } from "eslint-scope"; +import { registerGlimmerScopes } from "ember-estree/eslint-scope"; + +const EXCLUDED_KEYS = ["parent", "loc", "range", "tokens", "comments"]; /** * Add `range` and `loc` to every AST node. ESLint requires both. @@ -35,124 +37,6 @@ function addRangesAndLocs(node, docLines, visited = new Set()) { } } -// ── Scope helpers ── - -function findVarInParentScopes(scopeManager, path, name) { - let defScope = null; - let currentScope = null; - let p = path; - while (p) { - const s = scopeManager.acquire(p.node, true); - if (s) { - if (!currentScope) currentScope = s; - if (s.set.has(name)) { - defScope = s; - break; - } - } - p = p.parentPath; - } - if (!defScope) return { scope: currentScope }; - return { scope: currentScope, variable: defScope.set.get(name) }; -} - -function findParentScope(scopeManager, path) { - let p = path; - while (p) { - const scope = scopeManager.acquire(p.node, true); - if (scope) return scope; - p = p.parentPath; - } - return null; -} - -function registerNodeInScope(node, scope, variable) { - const ref = new Reference(node, scope, Reference.READ); - if (variable) { - variable.references.push(ref); - ref.resolved = variable; - } else { - let s = scope; - while (s.upper) s = s.upper; - s.through.push(ref); - } - scope.references.push(ref); -} - -const EXCLUDED_KEYS = ["parent", "loc", "range", "tokens", "comments"]; - -function traverseAST(visitorKeys, node, visitor) { - const queue = [{ node, parent: null, parentKey: null, parentPath: null }]; - while (queue.length > 0) { - const currentPath = queue.pop(); - visitor(currentPath); - if (!currentPath.node?.type) continue; - let keys = visitorKeys[currentPath.node.type]; - if (!keys) keys = Object.keys(currentPath.node).filter((k) => !EXCLUDED_KEYS.includes(k)); - for (const key of keys) { - const child = currentPath.node[key]; - if (!child) continue; - if (Array.isArray(child)) { - for (const item of child) { - if (item?.type) - queue.push({ - node: item, - parent: currentPath.node, - parentKey: key, - parentPath: currentPath, - }); - } - } else if (child.type) { - queue.push({ - node: child, - parent: currentPath.node, - parentKey: key, - parentPath: currentPath, - }); - } - } - } -} - -function registerGlimmerScopes(program, scopeManager, visitorKeys) { - traverseAST(visitorKeys, program, (path) => { - const node = path.node; - if (!node) return; - - if (node.type === "GlimmerPathExpression" && node.head?.type === "VarHead") { - if (isKeyword(node.head.name)) return; - const { scope, variable } = findVarInParentScopes(scopeManager, path, node.head.name); - if (scope) { - node.head.parent = node; - registerNodeInScope(node.head, scope, variable); - } - } - - if (node.type === "GlimmerElementNode" && node.parts?.[0]) { - const name = node.parts[0].name; - const ignore = - name === "this" || name.startsWith(":") || name.startsWith("@") || name.includes("-"); - if (!ignore && /^[A-Z]/.test(name)) { - const { scope, variable } = findVarInParentScopes(scopeManager, path, name); - if (scope) registerNodeInScope(node.parts[0], scope, variable); - } - } - - if (node.blockParamNodes?.length > 0) { - const upperScope = findParentScope(scopeManager, path); - if (!upperScope) return; - const scope = new Scope(scopeManager, "block", upperScope, node, false); - for (const [i, param] of node.blockParamNodes.entries()) { - const v = new Variable(param.name, scope); - v.identifiers.push(param); - scope.variables.push(v); - scope.set.set(param.name, v); - v.defs.push(new Definition("Parameter", param, node, node, i, "Block Param")); - } - } - }); -} - /** * Implements the ESLint `parseForESLint()` API. */ @@ -186,7 +70,7 @@ export function parseForESLint(code, options = {}) { fallback: (node) => Object.keys(node).filter((k) => !EXCLUDED_KEYS.includes(k)), }); - registerGlimmerScopes(program, scopeManager, visitorKeys); + registerGlimmerScopes(scopeManager); return { ast: program, visitorKeys, scopeManager }; } diff --git a/package.json b/package.json index 5ae7b15..e4d7c23 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,14 @@ ".": { "types": "./src/index.d.ts", "default": "./src/index.js" + }, + "./scope": { + "types": "./src/scope.d.ts", + "default": "./src/scope.js" + }, + "./eslint-scope": { + "types": "./src/eslint-scope.d.ts", + "default": "./src/eslint-scope.js" } }, "scripts": { @@ -48,6 +56,7 @@ "devDependencies": { "@tsconfig/node-lts": "^22.0.2", "@typescript-eslint/parser": "^8.59.0", + "eslint-scope": "^9.1.2", "mitata": "^1.0.34", "oxfmt": "^0.40.0", "oxlint": "^1.55.0", @@ -57,5 +66,13 @@ "vitest": "^3.2.4", "zimmerframe": "^1.1.4" }, + "peerDependencies": { + "eslint-scope": "^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "eslint-scope": { + "optional": true + } + }, "packageManager": "pnpm@10.18.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d03c69..a401abd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.59.0 version: 8.59.0(eslint@10.0.3)(typescript@5.9.3) + eslint-scope: + specifier: ^9.1.2 + version: 9.1.2 mitata: specifier: ^1.0.34 version: 1.0.34 diff --git a/src/eslint-scope.d.ts b/src/eslint-scope.d.ts new file mode 100644 index 0000000..e9aa7a9 --- /dev/null +++ b/src/eslint-scope.d.ts @@ -0,0 +1,7 @@ +import type { ScopeManager } from "eslint-scope"; + +/** + * Register Glimmer template scopes (path expressions, component references, + * block params) into an existing eslint-scope ScopeManager. + */ +export function registerGlimmerScopes(scopeManager: ScopeManager): void; diff --git a/src/eslint-scope.js b/src/eslint-scope.js new file mode 100644 index 0000000..80e46ad --- /dev/null +++ b/src/eslint-scope.js @@ -0,0 +1,87 @@ +/** + * Augment an eslint-scope ScopeManager with Glimmer scope bindings. + * + * @example + * ```js + * import { analyze } from "eslint-scope"; + * import { registerGlimmerScopes } from "ember-estree/eslint-scope"; + * + * const scopeManager = analyze(program, { ... }); + * registerGlimmerScopes(scopeManager); + * ``` + */ + +import { Scope, Variable, Reference, Definition } from "eslint-scope"; +import { visitorKeys as oxcVisitorKeys } from "oxc-parser"; +import { glimmerVisitorKeys } from "./transforms.js"; +import { walkGlimmerScopes } from "./scope-shared.js"; + +/** + * Register Glimmer template scopes (path expressions, component references, + * block params) into an existing eslint-scope ScopeManager. + * + * @param {import("eslint-scope").ScopeManager} scopeManager + */ +export function registerGlimmerScopes(scopeManager) { + const program = scopeManager.globalScope.block; + const visitorKeys = { + ...oxcVisitorKeys, + ...glimmerVisitorKeys, + ...program.visitorKeys, + }; + + walkGlimmerScopes(program, visitorKeys, { + findScope(path) { + let p = path; + while (p) { + const scope = scopeManager.acquire(p.node, true); + if (scope) return scope; + p = p.parentPath; + } + return null; + }, + + findVariable(path, name) { + let defScope = null; + let currentScope = null; + let p = path; + while (p) { + const s = scopeManager.acquire(p.node, true); + if (s) { + if (!currentScope) currentScope = s; + if (s.set.has(name)) { + defScope = s; + break; + } + } + p = p.parentPath; + } + if (!defScope) return { scope: currentScope }; + return { scope: currentScope, variable: defScope.set.get(name) }; + }, + + addReference(node, scope, variable) { + const ref = new Reference(node, scope, Reference.READ); + if (variable) { + variable.references.push(ref); + ref.resolved = variable; + } else { + let s = scope; + while (s.upper) s = s.upper; + s.through.push(ref); + } + scope.references.push(ref); + }, + + addBlockScope(node, upperScope, params) { + const scope = new Scope(scopeManager, "block", upperScope, node, false); + for (const [i, param] of params.entries()) { + const v = new Variable(param.name, scope); + v.identifiers.push(param); + scope.variables.push(v); + scope.set.set(param.name, v); + v.defs.push(new Definition("Parameter", param, node, node, i, "Block Param")); + } + }, + }); +} diff --git a/src/scope-shared.js b/src/scope-shared.js new file mode 100644 index 0000000..6e3750d --- /dev/null +++ b/src/scope-shared.js @@ -0,0 +1,108 @@ +/** + * Shared Glimmer scope walking logic. + * + * Used by both the standalone scope analyzer (scope.js) and the + * eslint-scope augmentation (eslint-scope.js). Each caller provides + * callbacks that create the right scope/variable/reference objects + * for their respective class systems. + */ + +import { isKeyword } from "@glimmer/syntax"; + +/** + * Walk an AST and invoke callbacks for Glimmer scope-relevant nodes. + * + * @param {object} program The Program (or root) AST node + * @param {object} visitorKeys Merged visitor keys for the full AST + * @param {object} callbacks + * @param {(path: object) => object|null} callbacks.findScope + * Return the enclosing scope for a path, or null. + * @param {(path: object, name: string) => { scope: object|null, variable?: object }} callbacks.findVariable + * Look up a variable by name walking parent scopes from path. + * @param {(node: object, scope: object, variable?: object) => void} callbacks.addReference + * Register a read reference for node in scope, optionally resolved to variable. + * @param {(node: object, upperScope: object, params: object[]) => void} callbacks.addBlockScope + * Create a new block scope on node with the given block param nodes. + */ +export function walkGlimmerScopes(program, visitorKeys, callbacks) { + traverseAST(visitorKeys, program, (path) => { + const node = path.node; + if (!node) return; + + // GlimmerPathExpression with VarHead → variable reference + if (node.type === "GlimmerPathExpression" && node.head?.type === "VarHead") { + if (isKeyword(node.head.name)) return; + const { scope, variable } = callbacks.findVariable(path, node.head.name); + if (scope) { + // Ensure parent is set — ESLint rules (e.g. no-undef) access identifier.parent + node.head.parent = node; + callbacks.addReference(node.head, scope, variable); + } + } + + // GlimmerElementNode with uppercase first part → component reference + if (node.type === "GlimmerElementNode" && node.parts?.[0]) { + const part = node.parts[0]; + const name = part.name; + const skip = + !name || + name === "this" || + name.startsWith(":") || + name.startsWith("@") || + name.includes("-") || + !/^[A-Z]/.test(name); + if (!skip) { + const { scope, variable } = callbacks.findVariable(path, name); + if (scope) { + callbacks.addReference(part, scope, variable); + } + } + } + + // blockParamNodes → new scope with variable definitions + if (node.blockParamNodes?.length > 0) { + const upperScope = callbacks.findScope(path); + if (upperScope) { + callbacks.addBlockScope(node, upperScope, node.blockParamNodes); + } + } + }); +} + +/** + * DFS traversal of an AST using visitor keys. + * Callers must pass a complete visitorKeys map covering every node type + * the walk will encounter — unknown types are silently skipped. + */ +function traverseAST(visitorKeys, node, visitor) { + const queue = [{ node, parent: null, parentKey: null, parentPath: null }]; + while (queue.length > 0) { + const currentPath = queue.pop(); + visitor(currentPath); + const keys = visitorKeys[currentPath.node?.type]; + if (!keys) continue; + for (const key of keys) { + const child = currentPath.node[key]; + if (!child) continue; + if (Array.isArray(child)) { + for (const item of child) { + if (item?.type) { + queue.push({ + node: item, + parent: currentPath.node, + parentKey: key, + parentPath: currentPath, + }); + } + } + } else if (child.type) { + queue.push({ + node: child, + parent: currentPath.node, + parentKey: key, + parentPath: currentPath, + }); + } + } + } +} diff --git a/src/scope.d.ts b/src/scope.d.ts new file mode 100644 index 0000000..bf1a2d9 --- /dev/null +++ b/src/scope.d.ts @@ -0,0 +1,66 @@ +export interface ASTNode { + type: string; + start?: number; + end?: number; + [key: string]: unknown; +} + +export class Definition { + type: string; + name: ASTNode; + node: ASTNode; + parent: ASTNode; + index: number | null; + constructor(type: string, name: ASTNode, node: ASTNode, parent: ASTNode, index?: number | null); +} + +export class Variable { + name: string; + scope: Scope; + defs: Definition[]; + references: Reference[]; + identifiers: ASTNode[]; + constructor(name: string, scope: Scope); +} + +export class Reference { + identifier: ASTNode; + from: Scope; + resolved: Variable | null; + flag: number; + readonly scope: Scope; + static readonly READ: number; + static readonly WRITE: number; + static readonly RW: number; + constructor(identifier: ASTNode, scope: Scope, flag?: number); + isRead(): boolean; + isWrite(): boolean; + isReadWrite(): boolean; +} + +export class Scope { + type: string; + block: ASTNode; + upper: Scope | null; + childScopes: Scope[]; + variables: Variable[]; + references: Reference[]; + through: Reference[]; + set: Map; + isStrict: boolean; + constructor(type: string, block: ASTNode, upper: Scope | null, isStrict?: boolean); +} + +export class ScopeManager { + scopes: Scope[]; + globalScope: Scope; + constructor(); + acquire(node: ASTNode, inner?: boolean): Scope | null; + getDeclaredVariables(node: ASTNode): Variable[]; +} + +export interface AnalyzeOptions { + sourceType?: "module" | "script"; +} + +export function analyze(ast: ASTNode, options?: AnalyzeOptions): ScopeManager; diff --git a/src/scope.js b/src/scope.js new file mode 100644 index 0000000..a19ba5c --- /dev/null +++ b/src/scope.js @@ -0,0 +1,621 @@ +/** + * Standalone scope analysis for Ember ESTree ASTs. + * + * Provides eslint-scope–compatible scope tracking that understands both + * standard ESTree (JS/TS) nodes and Glimmer template nodes. Tracks + * variable definitions, references, and resolution across the JS ↔ Glimmer + * boundary. + * + * @example + * ```js + * import { toTree } from "ember-estree"; + * import { analyze } from "ember-estree/scope"; + * + * const ast = toTree(source); + * const scopeManager = analyze(ast); + * const imported = scopeManager.globalScope.set.get("MyComponent"); + * ``` + */ + +import { visitorKeys as oxcVisitorKeys } from "oxc-parser"; +import { glimmerVisitorKeys } from "./transforms.js"; +import { walkGlimmerScopes } from "./scope-shared.js"; + +// ── Constants ──────────────────────────────────────────────────────────── + +const READ = 0x1; +const WRITE = 0x2; +const RW = READ | WRITE; + +// ── Core Classes ───────────────────────────────────────────────────────── + +export class Definition { + /** + * @param {string} type + * @param {object} name The identifier node + * @param {object} node The declaration node + * @param {object} parent The parent declaration + * @param {number|null} [index] + */ + constructor(type, name, node, parent, index = null) { + this.type = type; + this.name = name; + this.node = node; + this.parent = parent; + this.index = index; + } +} + +export class Variable { + /** + * @param {string} name + * @param {Scope} scope + */ + constructor(name, scope) { + this.name = name; + this.scope = scope; + /** @type {Definition[]} */ + this.defs = []; + /** @type {Reference[]} */ + this.references = []; + /** @type {object[]} */ + this.identifiers = []; + } +} + +export class Reference { + /** + * @param {object} identifier + * @param {Scope} scope + * @param {number} [flag] + */ + constructor(identifier, scope, flag = READ) { + this.identifier = identifier; + this.from = scope; + /** @type {Variable|null} */ + this.resolved = null; + this.flag = flag; + } + + get scope() { + return this.from; + } + + isRead() { + return (this.flag & READ) !== 0; + } + + isWrite() { + return (this.flag & WRITE) !== 0; + } + + isReadWrite() { + return this.flag === RW; + } +} + +Reference.READ = READ; +Reference.WRITE = WRITE; +Reference.RW = RW; + +export class Scope { + /** + * @param {string} type + * @param {object} block + * @param {Scope|null} upper + * @param {boolean} [isStrict] + */ + constructor(type, block, upper, isStrict = false) { + this.type = type; + this.block = block; + this.upper = upper; + this.isStrict = isStrict || (upper?.isStrict ?? false); + /** @type {Scope[]} */ + this.childScopes = []; + /** @type {Variable[]} */ + this.variables = []; + /** @type {Reference[]} */ + this.references = []; + /** @type {Reference[]} */ + this.through = []; + /** @type {Map} */ + this.set = new Map(); + if (upper) { + upper.childScopes.push(this); + } + } +} + +export class ScopeManager { + constructor() { + /** @type {Scope[]} */ + this.scopes = []; + /** @type {Scope} */ + this.globalScope = null; + /** @private */ + this._nodeToScope = new WeakMap(); + /** @private */ + this._innerNodeToScope = new WeakMap(); + /** @private */ + this._declaredVars = new WeakMap(); + } + + /** + * @param {object} node + * @param {boolean} [inner] + * @returns {Scope|null} + */ + acquire(node, inner) { + if (inner) { + return this._innerNodeToScope.get(node) ?? this._nodeToScope.get(node) ?? null; + } + return this._nodeToScope.get(node) ?? null; + } + + /** + * @param {object} node + * @returns {Variable[]} + */ + getDeclaredVariables(node) { + return this._declaredVars.get(node) ?? []; + } +} + +// ── Analyze ────────────────────────────────────────────────────────────── + +/** + * Analyze an AST and return a ScopeManager with full scope information. + * + * @param {object} ast + * @param {{ sourceType?: "module" | "script" }} [options] + * @returns {ScopeManager} + */ +export function analyze(ast, options = {}) { + const manager = new ScopeManager(); + const sourceType = options.sourceType ?? "module"; + + const visitorKeys = { + ...oxcVisitorKeys, + File: ["program"], + ...glimmerVisitorKeys, + ...ast.visitorKeys, + }; + + let currentScope; + const pendingRefs = []; + + // ── Scope helpers ── + + function registerScope(scope, node) { + manager.scopes.push(scope); + manager._nodeToScope.set(node, scope); + } + + function pushScope(type, node) { + const scope = new Scope(type, node, currentScope, currentScope?.isStrict); + registerScope(scope, node); + currentScope = scope; + return scope; + } + + function popScope() { + currentScope = currentScope.upper; + } + + function defineVariable( + name, + identifierNode, + defType, + declarationNode, + parentNode, + scope, + index, + ) { + let variable = scope.set.get(name); + if (!variable) { + variable = new Variable(name, scope); + scope.variables.push(variable); + scope.set.set(name, variable); + } + const def = new Definition(defType, identifierNode, declarationNode, parentNode, index ?? null); + variable.defs.push(def); + variable.identifiers.push(identifierNode); + const existing = manager._declaredVars.get(declarationNode); + if (existing) { + existing.push(variable); + } else { + manager._declaredVars.set(declarationNode, [variable]); + } + return variable; + } + + function addReference(identifierNode, scope, flag) { + const ref = new Reference(identifierNode, scope, flag ?? READ); + scope.references.push(ref); + pendingRefs.push({ ref, scope }); + return ref; + } + + function findFunctionScope(scope) { + let s = scope; + while (s) { + if (s.type === "function" || s.type === "module" || s.type === "global") return s; + s = s.upper; + } + return scope; + } + + function findModuleScope() { + let s = currentScope; + while (s) { + if (s.type === "module" || s.type === "global") return s; + s = s.upper; + } + return currentScope; + } + + // ── Pattern destructuring ── + + function collectPatternIds(pattern, defType, declarationNode, parentNode, scope, startIndex) { + if (!pattern) return; + switch (pattern.type) { + case "Identifier": + defineVariable( + pattern.name, + pattern, + defType, + declarationNode, + parentNode, + scope, + startIndex, + ); + break; + case "ObjectPattern": + for (const prop of pattern.properties ?? []) { + if (prop.type === "RestElement") { + collectPatternIds( + prop.argument, + defType, + declarationNode, + parentNode, + scope, + startIndex, + ); + } else { + collectPatternIds( + prop.value ?? prop, + defType, + declarationNode, + parentNode, + scope, + startIndex, + ); + } + } + break; + case "ArrayPattern": + for (let i = 0; i < (pattern.elements?.length ?? 0); i++) { + const el = pattern.elements[i]; + if (el) { + collectPatternIds(el, defType, declarationNode, parentNode, scope, startIndex + i); + } + } + break; + case "RestElement": + collectPatternIds( + pattern.argument, + defType, + declarationNode, + parentNode, + scope, + startIndex, + ); + break; + case "AssignmentPattern": + collectPatternIds(pattern.left, defType, declarationNode, parentNode, scope, startIndex); + break; + } + } + + // ── Identifier role ── + + function shouldSkipIdentifier(node, parent, parentKey) { + if (!parent || !parentKey) return false; + if (parent.type === "MemberExpression" && parentKey === "property" && !parent.computed) + return true; + if (parent.type === "Property" && parentKey === "key" && !parent.computed && !parent.shorthand) + return true; + if ( + (parent.type === "MethodDefinition" || parent.type === "PropertyDefinition") && + parentKey === "key" && + !parent.computed + ) + return true; + if (parent.type === "ImportSpecifier" && parentKey === "imported") return true; + if (parent.type === "ExportSpecifier" && parentKey === "exported") return true; + if ( + (parent.type === "LabeledStatement" || + parent.type === "BreakStatement" || + parent.type === "ContinueStatement") && + parentKey === "label" + ) + return true; + if ( + (parent.type === "FunctionDeclaration" || + parent.type === "FunctionExpression" || + parent.type === "ClassDeclaration" || + parent.type === "ClassExpression") && + parentKey === "id" + ) + return true; + if (parent.type === "VariableDeclarator" && parentKey === "id") return true; + if (parent.type === "CatchClause" && parentKey === "param") return true; + if ( + (parent.type === "FunctionDeclaration" || + parent.type === "FunctionExpression" || + parent.type === "ArrowFunctionExpression") && + parentKey === "params" + ) + return true; + if ( + (parent.type === "ImportSpecifier" || + parent.type === "ImportDefaultSpecifier" || + parent.type === "ImportNamespaceSpecifier") && + parentKey === "local" + ) + return true; + if (parent.type?.startsWith("TS") && parentKey !== "init") return true; + return false; + } + + function getIdentifierFlag(parent, parentKey) { + if (!parent) return READ; + if (parent.type === "AssignmentExpression" && parentKey === "left") return WRITE; + if (parent.type === "UpdateExpression" && parentKey === "argument") return RW; + if ( + (parent.type === "ForInStatement" || parent.type === "ForOfStatement") && + parentKey === "left" + ) + return WRITE; + return READ; + } + + // ── Node type sets ── + + const FUNCTION_TYPES = new Set([ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", + ]); + + const BLOCK_SCOPE_PARENTS = new Set([ + "ForStatement", + "ForInStatement", + "ForOfStatement", + "SwitchStatement", + ]); + + // ── Main JS walk ── + + function visit(node, parent, parentKey) { + if (!node || typeof node !== "object" || !node.type) return; + + let scopePushed = false; + const nodeType = node.type; + + // File wraps Program + if (nodeType === "File") { + if (node.program) { + visit(node.program, node, "program"); + } + return; + } + + // Program → module or global scope + if (nodeType === "Program") { + const scopeType = sourceType === "module" ? "module" : "global"; + const scope = new Scope(scopeType, node, null, sourceType === "module"); + registerScope(scope, node); + manager.globalScope = scope; + currentScope = scope; + scopePushed = true; + } + + // Functions + else if (FUNCTION_TYPES.has(nodeType)) { + if (nodeType === "FunctionDeclaration" && node.id?.name) { + defineVariable(node.id.name, node.id, "FunctionName", node, parent, currentScope); + } + const funcScope = pushScope("function", node); + manager._innerNodeToScope.set(node, funcScope); + scopePushed = true; + + if (nodeType === "FunctionExpression" && node.id?.name) { + defineVariable(node.id.name, node.id, "FunctionName", node, node, funcScope); + } + for (let i = 0; i < (node.params?.length ?? 0); i++) { + collectPatternIds(node.params[i], "Parameter", node, node, funcScope, i); + } + } + + // Classes + else if (nodeType === "ClassDeclaration" || nodeType === "ClassExpression") { + if (nodeType === "ClassDeclaration" && node.id?.name) { + defineVariable(node.id.name, node.id, "ClassName", node, parent, currentScope); + } + const classScope = pushScope("class", node); + manager._innerNodeToScope.set(node, classScope); + scopePushed = true; + + if (nodeType === "ClassExpression" && node.id?.name) { + defineVariable(node.id.name, node.id, "ClassName", node, node, classScope); + } + } + + // Block scope (non-function parent) + else if (nodeType === "BlockStatement" && parent && !FUNCTION_TYPES.has(parent.type)) { + pushScope("block", node); + scopePushed = true; + } + + // For loops, switch + else if (BLOCK_SCOPE_PARENTS.has(nodeType)) { + pushScope("block", node); + scopePushed = true; + } + + // Catch clause + else if (nodeType === "CatchClause") { + pushScope("block", node); + scopePushed = true; + if (node.param) { + collectPatternIds(node.param, "Parameter", node, node, currentScope, 0); + } + } + + // Variable declarations + else if (nodeType === "VariableDeclaration") { + const targetScope = node.kind === "var" ? findFunctionScope(currentScope) : currentScope; + for (const decl of node.declarations ?? []) { + if (decl.type === "VariableDeclarator" && decl.id) { + collectPatternIds(decl.id, "Variable", decl, node, targetScope, 0); + } + } + } + + // Import declarations + else if (nodeType === "ImportDeclaration") { + const moduleScope = findModuleScope(); + for (const spec of node.specifiers ?? []) { + if (spec.local?.name) { + defineVariable(spec.local.name, spec.local, "ImportBinding", spec, node, moduleScope); + } + } + } + + // Identifiers (references) + else if (nodeType === "Identifier") { + if (!shouldSkipIdentifier(node, parent, parentKey)) { + addReference(node, currentScope, getIdentifierFlag(parent, parentKey)); + } + } + + // Glimmer nodes with blockParamNodes → scope (using plain `if`, not `else if`) + if ( + node.blockParamNodes?.length > 0 && + nodeType.startsWith("Glimmer") && + !scopePushed // don't double-push if somehow already pushed + ) { + const glimmerScope = pushScope("glimmer-block", node); + manager._innerNodeToScope.set(node, glimmerScope); + scopePushed = true; + for (let i = 0; i < node.blockParamNodes.length; i++) { + const bp = node.blockParamNodes[i]; + defineVariable(bp.name, bp, "BlockParam", node, node, glimmerScope, i); + } + } + + // Recurse into children + const keys = visitorKeys[nodeType]; + if (keys) { + for (const key of keys) { + const child = node[key]; + if (!child) continue; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === "object" && item.type) { + visit(item, node, key); + } + } + } else if (typeof child === "object" && child.type) { + visit(child, node, key); + } + } + } + + if (scopePushed) { + popScope(); + } + } + + // Run JS walk + visit(ast, null, null); + + // Run Glimmer walk (path expressions + component refs that aren't block-param-creating) + walkGlimmerScopes(ast.program ?? ast, visitorKeys, { + findScope(path) { + let p = path; + while (p) { + const s = manager.acquire(p.node, true); + if (s) return s; + p = p.parentPath; + } + return null; + }, + findVariable(path, name) { + let currentScopeForPath = null; + let p = path; + while (p) { + const s = manager.acquire(p.node, true); + if (s) { + if (!currentScopeForPath) currentScopeForPath = s; + if (s.set.has(name)) { + return { scope: currentScopeForPath, variable: s.set.get(name) }; + } + } + p = p.parentPath; + } + // Not found in any acquired scope, walk the scope chain from the nearest scope + if (currentScopeForPath) { + let s = currentScopeForPath.upper; + while (s) { + if (s.set.has(name)) { + return { scope: currentScopeForPath, variable: s.set.get(name) }; + } + s = s.upper; + } + } + return { scope: currentScopeForPath }; + }, + addReference(node, scope, variable) { + const ref = new Reference(node, scope, READ); + if (variable) { + ref.resolved = variable; + variable.references.push(ref); + } else { + // Unresolved — push through to global + let s = scope; + while (s.upper) s = s.upper; + s.through.push(ref); + } + scope.references.push(ref); + }, + addBlockScope(_node, _upperScope, _params) { + // Block scopes were already created during the JS walk above + // (the visit() function handles blockParamNodes directly) + }, + }); + + // Resolve JS references + for (const { ref, scope } of pendingRefs) { + let s = scope; + while (s) { + const name = ref.identifier.name ?? ref.identifier.original; + const variable = s.set.get(name); + if (variable) { + ref.resolved = variable; + variable.references.push(ref); + break; + } + s = s.upper; + } + if (!ref.resolved) { + // Propagate through to the global scope + let target = scope; + while (target.upper) target = target.upper; + target.through.push(ref); + } + } + + return manager; +} diff --git a/tests/eslint-scope.test.js b/tests/eslint-scope.test.js new file mode 100644 index 0000000..53a652a --- /dev/null +++ b/tests/eslint-scope.test.js @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { toTree, glimmerVisitorKeys } from "../src/index.js"; +import { analyze } from "eslint-scope"; +import { registerGlimmerScopes } from "../src/eslint-scope.js"; + +const EXCLUDED_KEYS = ["parent", "loc", "range", "tokens", "comments"]; + +function parseAndAnalyze(source) { + const ast = toTree(source, { filePath: "test.gjs" }); + const program = ast.program ?? ast; + const visitorKeys = { ...ast.visitorKeys, ...glimmerVisitorKeys }; + + const scopeManager = analyze(program, { + ecmaVersion: 2024, + sourceType: "module", + childVisitorKeys: visitorKeys, + fallback: (node) => Object.keys(node).filter((k) => !EXCLUDED_KEYS.includes(k)), + }); + + registerGlimmerScopes(scopeManager); + return { scopeManager, program }; +} + +describe("registerGlimmerScopes", () => { + it("registers GlimmerPathExpression references", () => { + const { scopeManager } = parseAndAnalyze(` + import { myHelper } from 'my-lib'; + + `); + + const moduleScope = scopeManager.scopes.find((s) => s.type === "module"); + const v = moduleScope.set.get("myHelper"); + expect(v).toBeDefined(); + + // Should have at least one reference from the template + expect(v.references.length).toBeGreaterThanOrEqual(1); + const ref = v.references.find((r) => r.resolved === v); + expect(ref).toBeDefined(); + }); + + it("registers GlimmerElementNode component references", () => { + const { scopeManager } = parseAndAnalyze(` + import MyComponent from 'my-lib'; + + `); + + const moduleScope = scopeManager.scopes.find((s) => s.type === "module"); + const v = moduleScope.set.get("MyComponent"); + expect(v).toBeDefined(); + expect(v.references.length).toBeGreaterThanOrEqual(1); + }); + + it("skips Glimmer keywords", () => { + const { scopeManager } = parseAndAnalyze(` + + `); + + const globalScope = scopeManager.globalScope; + const ifRef = globalScope.through.find( + (r) => r.identifier?.name === "if" || r.identifier?.original === "if", + ); + expect(ifRef).toBeUndefined(); + }); + + it("skips lowercase and dashed elements", () => { + const { scopeManager } = parseAndAnalyze(` + + `); + + const globalScope = scopeManager.globalScope; + expect(globalScope.through.find((r) => r.identifier?.name === "div")).toBeUndefined(); + }); + + it("creates block scopes for blockParamNodes", () => { + const { scopeManager } = parseAndAnalyze(` + import { items } from 'data'; + + `); + + // Should have created a block scope with "item" variable + const blockScope = scopeManager.scopes.find((s) => s.type === "block" && s.set.has("item")); + expect(blockScope).toBeDefined(); + + const itemVar = blockScope.set.get("item"); + expect(itemVar).toBeDefined(); + expect(itemVar.defs[0].type).toBe("Parameter"); + }); + + it("resolves block param references", () => { + const { scopeManager } = parseAndAnalyze(` + import { items } from 'data'; + + `); + + const blockScope = scopeManager.scopes.find((s) => s.type === "block" && s.set.has("item")); + const itemVar = blockScope.set.get("item"); + + // item should have references from inside the block + expect(itemVar.references.length).toBeGreaterThanOrEqual(1); + expect(itemVar.references[0].resolved).toBe(itemVar); + }); + + it("puts unresolved Glimmer refs in global through", () => { + const { scopeManager } = parseAndAnalyze(` + + `); + + let globalScope = scopeManager.globalScope; + const ref = globalScope.through.find( + (r) => r.identifier?.name === "unknownHelper" || r.identifier?.original === "unknownHelper", + ); + expect(ref).toBeDefined(); + expect(ref.resolved).toBeNull(); + }); + + it("works with multiple templates", () => { + const { scopeManager } = parseAndAnalyze(` + import { A, B } from 'lib'; + const x = ; + const y = ; + `); + + const moduleScope = scopeManager.scopes.find((s) => s.type === "module"); + expect(moduleScope.set.get("A").references.length).toBeGreaterThanOrEqual(1); + expect(moduleScope.set.get("B").references.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/tests/scope.test.js b/tests/scope.test.js new file mode 100644 index 0000000..9b734d2 --- /dev/null +++ b/tests/scope.test.js @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; +import { toTree } from "../src/index.js"; +import { analyze, ScopeManager, Scope, Variable, Reference, Definition } from "../src/scope.js"; + +function parse(source) { + return toTree(source, { filePath: "test.gjs" }); +} + +// ── JS Scope Basics ────────────────────────────────────────────────────── + +describe("JS scope basics", () => { + it("creates a module scope for the program", () => { + const ast = parse("const x = 1;"); + const mgr = analyze(ast); + + expect(mgr.globalScope).toBeDefined(); + expect(mgr.globalScope.type).toBe("module"); + expect(mgr.scopes.length).toBeGreaterThanOrEqual(1); + }); + + it("creates a global scope when sourceType is script", () => { + const ast = parse("const x = 1;"); + const mgr = analyze(ast, { sourceType: "script" }); + + expect(mgr.globalScope.type).toBe("global"); + }); + + it("tracks import bindings in the module scope", () => { + const ast = parse('import { Foo, Bar } from "my-lib";'); + const mgr = analyze(ast); + + expect(mgr.globalScope.set.has("Foo")).toBe(true); + expect(mgr.globalScope.set.has("Bar")).toBe(true); + expect(mgr.globalScope.set.get("Foo").defs[0].type).toBe("ImportBinding"); + }); + + it("tracks default and namespace imports", () => { + const ast = parse('import MyDefault from "a"; import * as ns from "b";'); + const mgr = analyze(ast); + + expect(mgr.globalScope.set.has("MyDefault")).toBe(true); + expect(mgr.globalScope.set.has("ns")).toBe(true); + }); + + it("tracks const/let/var declarations", () => { + const ast = parse("const a = 1; let b = 2;"); + const mgr = analyze(ast); + + expect(mgr.globalScope.set.has("a")).toBe(true); + expect(mgr.globalScope.set.has("b")).toBe(true); + expect(mgr.globalScope.set.get("a").defs[0].type).toBe("Variable"); + }); + + it("tracks var in function scope (not block)", () => { + const ast = parse("function foo() { var x = 1; }"); + const mgr = analyze(ast); + + expect(mgr.globalScope.set.has("foo")).toBe(true); + expect(mgr.globalScope.set.has("x")).toBe(false); + + const funcScopes = mgr.scopes.filter((s) => s.type === "function"); + expect(funcScopes[0].set.has("x")).toBe(true); + }); + + it("tracks function and class declarations", () => { + const ast = parse("function myFunc() {} class MyClass {}"); + const mgr = analyze(ast); + + expect(mgr.globalScope.set.get("myFunc").defs[0].type).toBe("FunctionName"); + expect(mgr.globalScope.set.get("MyClass").defs[0].type).toBe("ClassName"); + }); + + it("tracks function parameters including destructuring", () => { + const ast = parse("function foo(a, { b }, [c]) {}"); + const mgr = analyze(ast); + + const fScope = mgr.scopes.find((s) => s.type === "function"); + expect(fScope.set.has("a")).toBe(true); + expect(fScope.set.has("b")).toBe(true); + expect(fScope.set.has("c")).toBe(true); + }); + + it("tracks destructuring variable declarations", () => { + const ast = parse("const { x, y } = obj; const [a, b] = arr;"); + const mgr = analyze(ast); + + for (const name of ["x", "y", "a", "b"]) { + expect(mgr.globalScope.set.has(name)).toBe(true); + } + }); + + it("resolves identifier references", () => { + const ast = parse("const x = 1; console.log(x);"); + const mgr = analyze(ast); + + const x = mgr.globalScope.set.get("x"); + const readRef = x.references.find((r) => r.isRead()); + expect(readRef).toBeDefined(); + expect(readRef.resolved).toBe(x); + }); + + it("tracks unresolved references in through", () => { + const ast = parse('console.log("hello");'); + const mgr = analyze(ast); + + const consoleRef = mgr.globalScope.through.find((r) => r.identifier.name === "console"); + expect(consoleRef).toBeDefined(); + expect(consoleRef.resolved).toBeNull(); + }); + + it("handles arrow functions, catch, for-of", () => { + const ast = parse("const fn = (a) => a; try {} catch (e) {} for (const x of []) {}"); + const mgr = analyze(ast); + + expect(mgr.scopes.filter((s) => s.type === "function").length).toBe(1); + expect(mgr.scopes.some((s) => s.set.has("e"))).toBe(true); + expect(mgr.scopes.some((s) => s.set.has("x"))).toBe(true); + }); + + it("scope.childScopes forms a tree", () => { + const ast = parse("function outer() { function inner() {} }"); + const mgr = analyze(ast); + + const outerFunc = mgr.globalScope.childScopes.find((s) => s.type === "function"); + expect(outerFunc).toBeDefined(); + expect(outerFunc.childScopes.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ── Glimmer Scope ──────────────────────────────────────────────────────── + +describe("Glimmer scope", () => { + it("resolves GlimmerPathExpression to an import", () => { + const ast = parse(` + import { myHelper } from 'my-lib'; + + `); + const mgr = analyze(ast); + + const v = mgr.globalScope.set.get("myHelper"); + expect(v).toBeDefined(); + expect(v.references.length).toBeGreaterThanOrEqual(1); + expect(v.references[0].resolved).toBe(v); + }); + + it("resolves GlimmerElementNode component reference to an import", () => { + const ast = parse(` + import MyComponent from 'my-lib'; + + `); + const mgr = analyze(ast); + + const v = mgr.globalScope.set.get("MyComponent"); + expect(v).toBeDefined(); + expect(v.references.length).toBeGreaterThanOrEqual(1); + }); + + it("does not create references for lowercase/dashed elements", () => { + const ast = parse(""); + const mgr = analyze(ast); + + const through = mgr.globalScope.through; + expect(through.find((r) => r.identifier.name === "div")).toBeUndefined(); + expect(through.find((r) => r.identifier.name === "my-comp")).toBeUndefined(); + }); + + it("skips Glimmer keywords", () => { + const ast = parse(""); + const mgr = analyze(ast); + + expect(mgr.globalScope.through.find((r) => r.identifier.name === "if")).toBeUndefined(); + }); + + it("creates glimmer-block scope for block params", () => { + const ast = parse(` + import { items } from 'data'; + + `); + const mgr = analyze(ast); + + const glimmerScopes = mgr.scopes.filter((s) => s.type === "glimmer-block"); + expect(glimmerScopes.length).toBeGreaterThanOrEqual(1); + + const blockScope = glimmerScopes.find((s) => s.set.has("item")); + expect(blockScope).toBeDefined(); + expect(blockScope.set.get("item").defs[0].type).toBe("BlockParam"); + }); + + it("resolves references inside block to block params", () => { + const ast = parse(` + import { items } from 'data'; + + `); + const mgr = analyze(ast); + + const blockScope = mgr.scopes.find((s) => s.type === "glimmer-block" && s.set.has("item")); + const itemVar = blockScope.set.get("item"); + expect(itemVar.references.length).toBeGreaterThanOrEqual(1); + expect(itemVar.references[0].resolved).toBe(itemVar); + }); + + it("does not leak block params to sibling scope", () => { + const ast = parse(` + + `); + const mgr = analyze(ast); + + // The outer {{item}} should be unresolved + const unresolvedItem = mgr.globalScope.through.find( + (r) => (r.identifier.name ?? r.identifier.original) === "item", + ); + expect(unresolvedItem).toBeDefined(); + }); + + it("handles named blocks and @-prefixed elements", () => { + const ast = parse(""); + const mgr = analyze(ast); + expect(mgr.scopes.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ── Cross-boundary ─────────────────────────────────────────────────────── + +describe("cross-boundary scope", () => { + it("JS variable referenced in Glimmer template", () => { + const ast = parse(` + const greeting = "hello"; + + `); + const mgr = analyze(ast); + + const v = mgr.globalScope.set.get("greeting"); + expect(v.references.filter((r) => r.isRead()).length).toBeGreaterThanOrEqual(1); + }); + + it("multiple templates share outer scope", () => { + const ast = parse(` + import { A, B } from 'lib'; + const x = ; + const y = ; + `); + const mgr = analyze(ast); + + expect(mgr.globalScope.set.get("A").references.length).toBeGreaterThanOrEqual(1); + expect(mgr.globalScope.set.get("B").references.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ── API surface ────────────────────────────────────────────────────────── + +describe("API surface", () => { + it("acquire() returns scope for nodes", () => { + const ast = parse("function foo() { const x = 1; }"); + const mgr = analyze(ast); + + const program = ast.program ?? ast; + expect(mgr.acquire(program)).toBeDefined(); + expect(mgr.acquire(program).type).toBe("module"); + }); + + it("acquire(node, true) returns inner scope", () => { + const ast = parse("function foo() {}"); + const mgr = analyze(ast); + + const program = ast.program ?? ast; + const funcNode = program.body.find((n) => n.type === "FunctionDeclaration"); + const inner = mgr.acquire(funcNode, true); + expect(inner).toBeDefined(); + expect(inner.type).toBe("function"); + }); + + it("getDeclaredVariables() works", () => { + const ast = parse("const x = 1, y = 2;"); + const mgr = analyze(ast); + + const program = ast.program ?? ast; + for (const decl of program.body[0].declarations) { + expect(mgr.getDeclaredVariables(decl).length).toBe(1); + } + }); + + it("Reference.isRead/isWrite/isReadWrite", () => { + const ast = parse("let x = 1; x = 2; x;"); + const mgr = analyze(ast); + + const xVar = mgr.globalScope.set.get("x"); + expect(xVar.references.some((r) => r.isRead() && !r.isWrite())).toBe(true); + expect(xVar.references.some((r) => r.isWrite())).toBe(true); + }); + + it("exports all expected classes", () => { + expect(ScopeManager).toBeDefined(); + expect(Scope).toBeDefined(); + expect(Variable).toBeDefined(); + expect(Reference).toBeDefined(); + expect(Definition).toBeDefined(); + expect(analyze).toBeDefined(); + expect(Reference.READ).toBeDefined(); + expect(Reference.WRITE).toBeDefined(); + }); +});