From 55f0267c409cbf096bcdbd07d7604c44bcde3fbe Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:59:01 -0400 Subject: [PATCH] fix(javascript): bind named class expressions in their class scope (#510) * fix(javascript): bind named class expressions in their class scope Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> * test(javascript): isolate class expression lexical names regression cases Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> --------- Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> --- ...vascriptSemanticAnalysis.structure.test.ts | 44 +++++++++++++++++++ src/domain/javascriptSemanticAnalysis.ts | 10 +++++ 2 files changed, 54 insertions(+) diff --git a/src/domain/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascriptSemanticAnalysis.structure.test.ts index 31ab24e88..528ffe00d 100644 --- a/src/domain/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascriptSemanticAnalysis.structure.test.ts @@ -166,6 +166,50 @@ describe("JavaScript semantic analysis: structure 1", () => { }); }); +describe("JavaScript semantic analysis: class expression lexical names", () => { + it("resolves named class expressions inside the class without changing the outer binding", () => { + const ir = analyzeJavaScriptSemantics(` + const Service = "outside"; + const Wrapper = class Service { + create() { return new Service(); } + }; + consume(Service); + `); + expect(bindingsNamed(ir, "Service")).toHaveLength(2); + expect(topLevelBinding(ir, "Service").value).toEqual({ + status: "literal", + value: "outside", + }); + const references = ir.references.filter( + ({ name, role }) => name === "Service" && role === "read", + ); + expect(references).toHaveLength(2); + expect(references[0]?.bindingId).not.toBe(references[1]?.bindingId); + expect(references[0]?.resolution).toBe("resolved"); + const classScope = ir.scopes.find(({ kind }) => kind === "class"); + expect( + bindingsNamed(ir, "Service").some( + ({ scopeId }) => scopeId === classScope?.scopeId, + ), + ).toBe(true); + }); + + it("does not expose a named class expression outside its class", () => { + const ir = analyzeJavaScriptSemantics(` + const Wrapper = class Internal { create() { return new Internal(); } }; + consume(Internal); + `); + const references = ir.references.filter( + ({ name, role }) => name === "Internal" && role === "read", + ); + expect(references.map(({ resolution }) => resolution)).toEqual([ + "resolved", + "unbound", + ]); + expect(topLevelBinding(ir, "Wrapper").provenance.status).toBe("local"); + }); +}); + describe("JavaScript semantic analysis: structure 2", () => { it("retains ESM, re-export, require, and CommonJS export relationships", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascriptSemanticAnalysis.ts b/src/domain/javascriptSemanticAnalysis.ts index 06914f8ad..10129c29e 100644 --- a/src/domain/javascriptSemanticAnalysis.ts +++ b/src/domain/javascriptSemanticAnalysis.ts @@ -213,6 +213,16 @@ const bindInnerDeclaration = ( ? parent.kind !== "const" : true, }); + else if (t.isClassExpression(node) && t.isIdentifier(node.id)) + addBinding({ + state, + scope, + name: node.id.name, + kind: "class", + mutable: false, + definitionNode: node.id, + initializer: node, + }); else if (t.isFunction(node)) bindFunctionLocals(node, scope, state); else if (t.isCatchClause(node) && node.param != null) bindPattern({