From c30878f616c4496c6cd1993d677aa019b2412b7e Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:11:24 -0400 Subject: [PATCH] fix(javascript): isolate lexical loop bindings (#511) * fix(javascript): isolate lexical loop bindings Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> * test(javascript): isolate loop lexical names regression cases Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> --------- Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- ...vascriptSemanticAnalysis.structure.test.ts | 60 +++++++++++++++++++ src/domain/javascriptSemanticAnalysis.ts | 6 ++ 2 files changed, 66 insertions(+) diff --git a/src/domain/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascriptSemanticAnalysis.structure.test.ts index 528ffe00d..d442e798d 100644 --- a/src/domain/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascriptSemanticAnalysis.structure.test.ts @@ -166,6 +166,66 @@ describe("JavaScript semantic analysis: structure 1", () => { }); }); +describe("JavaScript semantic analysis: loop lexical names", () => { + it.each([ + `for (let value = "inside"; ready; step()) { consume(value); }`, + `for (const value of entries) { consume(value); }`, + `for (const value in entries) { consume(value); }`, + ])("does not leak lexical loop bindings into the enclosing scope", (loop) => { + const ir = analyzeJavaScriptSemantics(` + const value = "outside"; + ${loop} + consume(value); + `); + expect(topLevelBinding(ir, "value").value).toEqual({ + status: "literal", + value: "outside", + }); + expect(bindingsNamed(ir, "value")).toHaveLength(2); + const reads = ir.references.filter( + ({ name, role }) => name === "value" && role === "read", + ); + expect(reads).toHaveLength(2); + expect(reads[0]?.bindingId).not.toBe(reads[1]?.bindingId); + expect(reads[1]?.bindingId).toBe(topLevelBinding(ir, "value").bindingId); + }); + + it("resolves closures inside a loop against the loop binding", () => { + const ir = analyzeJavaScriptSemantics(` + const value = "outside"; + for (const value of entries) { callbacks.push(() => value); } + consume(value); + `); + const reads = ir.references.filter( + ({ name, role }) => name === "value" && role === "read", + ); + expect(reads).toHaveLength(2); + expect(reads[0]?.bindingId).not.toBe( + topLevelBinding(ir, "value").bindingId, + ); + expect(reads[1]?.bindingId).toBe(topLevelBinding(ir, "value").bindingId); + expect(ir.closureCaptures).toContainEqual( + expect.objectContaining({ + bindingId: reads[0]?.bindingId, + }), + ); + }); + + it("keeps var loop declarations in the enclosing function", () => { + const ir = analyzeJavaScriptSemantics(` + function run() { for (var value of entries) { consume(value); } return value; } + `); + const binding = onlyBinding(ir, "value"); + const callable = ir.callables.find(({ name }) => name === "run"); + expect(binding.scopeId).toBe(callable?.bodyScopeId); + expect( + ir.references + .filter(({ name, role }) => name === "value" && role === "read") + .every(({ bindingId }) => bindingId === binding.bindingId), + ).toBe(true); + }); +}); + describe("JavaScript semantic analysis: class expression lexical names", () => { it("resolves named class expressions inside the class without changing the outer binding", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascriptSemanticAnalysis.ts b/src/domain/javascriptSemanticAnalysis.ts index 10129c29e..28fee29c9 100644 --- a/src/domain/javascriptSemanticAnalysis.ts +++ b/src/domain/javascriptSemanticAnalysis.ts @@ -265,6 +265,12 @@ const scopeKind = ( if (t.isFunction(node)) return "function"; if (t.isClass(node)) return "class"; if (t.isCatchClause(node)) return "catch"; + if ( + t.isForStatement(node) || + t.isForOfStatement(node) || + t.isForInStatement(node) + ) + return "block"; if ( t.isBlockStatement(node) && !(parent !== null && t.isFunction(parent) && parent.body === node)