From c270ee26418e888edd5460b00dfae2a8bceac2b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:45:40 +0000 Subject: [PATCH 1/5] node:vm: let compileFunction see global let/const/class bindings vm.compileFunction created the function with the global object itself as its scope unless contextExtensions were given, so script-level let, const and class bindings (which live in the global lexical environment, in front of the global object) were unresolvable from the compiled body, and sloppy assignments to them created global properties instead. Start the scope chain at parsingContext->globalScope(), as the contextExtensions path, the Function constructor and program evaluation already do. --- src/jsc/bindings/NodeVM.cpp | 9 +++--- test/js/node/vm/vm.test.ts | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 54a3d25d2947..6299f3634fc1 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -1593,16 +1593,17 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject // Create the source origin SourceOrigin sourceOrigin { WTF::URL::fileURLWithFileSystemPath(options.filename), *fetcher }; - // Process contextExtensions if they exist - JSScope* functionScope = options.parsingContext ? options.parsingContext : globalObject; + // globalScope() is the global lexical environment (script-level let/const/ + // class bindings), which precedes the global object in every ordinary scope + // chain; a chain starting at the global object itself cannot see them. + JSScope* functionScope = options.parsingContext->globalScope(); if (!options.contextExtensions.isUndefinedOrNull() && !options.contextExtensions.isEmpty() && options.contextExtensions.isObject() && isArray(globalObject, options.contextExtensions)) { auto* contextExtensionsArray = dynamicDowncast(options.contextExtensions); unsigned length = contextExtensionsArray ? contextExtensionsArray->length() : 0; if (length > 0) { - // Get the global scope from the parsing context - JSScope* currentScope = options.parsingContext->globalScope(); + JSScope* currentScope = functionScope; // Create JSWithScope objects for each context extension for (unsigned i = 0; i < length; i++) { diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index d3d04239d179..eadd6a8a5d94 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -269,6 +269,70 @@ describe("vm", () => { expect(e).toBeTruthy(); } }); + + // Top-level let/const/class declared by a script live in the context's + // global lexical environment, not on its global object. Like Node, the + // compiled function resolves names through it, whether it is compiled in + // the caller's context or in a parsingContext. + describe.each([ + [ + "this context", + () => ({ + run: (code: string) => runInThisContext(code), + options: {}, + globalObject: globalThis as object, + }), + ], + [ + "a parsingContext", + () => { + // Undeclared sloppy-mode assignments made inside the context land on + // the contextified object, the way they land on globalThis above. + const context = createContext({}); + return { + run: (code: string) => runInContext(code, context), + options: { parsingContext: context }, + globalObject: context as object, + }; + }, + ], + ])("global lexical bindings of %s", (_label, setup) => { + test("are visible to the compiled function", () => { + const { run, options } = setup(); + const [v, l, c, k] = randomProps(4); + run(`var ${v} = "var"; let ${l} = "let"; const ${c} = "const"; class ${k} {}`); + const fn = compileFunction(`return [${v}, ${l}, ${c}, typeof ${k}];`, [], options); + expect(fn()).toEqual(["var", "let", "const", "function"]); + }); + + test("are visible when declared after the function was compiled", () => { + const { run, options } = setup(); + const [name] = randomProps(1); + const fn = compileFunction(`return ${name};`, [], options); + run(`let ${name} = "declared later";`); + expect(fn()).toBe("declared later"); + }); + + test("receive assignments instead of a new global property being created", () => { + const { run, options, globalObject } = setup(); + const [name] = randomProps(1); + run(`let ${name} = "initial";`); + compileFunction(`${name} = "assigned";`, [], options)(); + expect(run(name)).toBe("assigned"); + expect(Object.hasOwn(globalObject, name)).toBe(false); + }); + + test("are shadowed by contextExtensions", () => { + const { run, options } = setup(); + const [shadowed, visible, extensionOnly] = randomProps(3); + run(`let ${shadowed} = "lexical"; let ${visible} = "lexical";`); + const fn = compileFunction(`return [${shadowed}, ${visible}, ${extensionOnly}];`, [], { + ...options, + contextExtensions: [{ [shadowed]: "extension", [extensionOnly]: "extension" }], + }); + expect(fn()).toEqual(["extension", "lexical", "extension"]); + }); + }); }); }); From ec0c9359eec0f2c9a0bb8da9c59e313da2078ed7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:08:15 +0000 Subject: [PATCH 2/5] node:vm: do not install the compileFunction scope chain realm-wide With the function now created on the global lexical environment, leaving setGlobalScopeExtension in place would have made functions that are created directly on the global object (builtins and internal modules) resolve their unresolved identifiers against script-level let/const bindings after any compileFunction call. The chain is already what the function is created with, so only hand it to the function. --- src/jsc/bindings/NodeVM.cpp | 6 +++--- test/js/node/vm/vm.test.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 6299f3634fc1..92f9702fd7d6 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -1595,7 +1595,9 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject // globalScope() is the global lexical environment (script-level let/const/ // class bindings), which precedes the global object in every ordinary scope - // chain; a chain starting at the global object itself cannot see them. + // chain; a chain starting at the global object itself cannot see them. The + // chain is given to the compiled function only: setGlobalScopeExtension() + // would expose it to every unresolved lookup in the realm. JSScope* functionScope = options.parsingContext->globalScope(); if (!options.contextExtensions.isUndefinedOrNull() && !options.contextExtensions.isEmpty() && options.contextExtensions.isObject() && isArray(globalObject, options.contextExtensions)) { @@ -1620,8 +1622,6 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject } } - options.parsingContext->setGlobalScopeExtension(functionScope); - // Create the function using constructAnonymousFunction with the appropriate scope chain JSFunction* function = constructAnonymousFunction(globalObject, ArgList(constructFunctionArgs), sourceOrigin, WTF::move(options), JSC::SourceTaintedOrigin::Untainted, functionScope); RETURN_IF_EXCEPTION(scope, {}); diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index eadd6a8a5d94..38f3b44dcdec 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -305,10 +305,11 @@ describe("vm", () => { expect(fn()).toEqual(["var", "let", "const", "function"]); }); - test("are visible when declared after the function was compiled", () => { + test("are visible once declared, even after the function was compiled and called", () => { const { run, options } = setup(); const [name] = randomProps(1); const fn = compileFunction(`return ${name};`, [], options); + expect(fn).toThrow(`${name} is not defined`); run(`let ${name} = "declared later";`); expect(fn()).toBe("declared later"); }); @@ -322,7 +323,7 @@ describe("vm", () => { expect(Object.hasOwn(globalObject, name)).toBe(false); }); - test("are shadowed by contextExtensions", () => { + test("are shadowed by contextExtensions, which only the compiled function sees", () => { const { run, options } = setup(); const [shadowed, visible, extensionOnly] = randomProps(3); run(`let ${shadowed} = "lexical"; let ${visible} = "lexical";`); @@ -331,6 +332,9 @@ describe("vm", () => { contextExtensions: [{ [shadowed]: "extension", [extensionOnly]: "extension" }], }); expect(fn()).toEqual(["extension", "lexical", "extension"]); + // The scope chain built for fn must not be installed on the context itself. + expect(run(`[${shadowed}, typeof ${extensionOnly}]`)).toEqual(["lexical", "undefined"]); + expect(compileFunction(`return typeof ${extensionOnly};`, [], options)()).toBe("undefined"); }); }); }); From 685e17aad731c9bc4f8846721d1f8b95ab6bab54 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:11:50 +0000 Subject: [PATCH 3/5] node:vm: shorten the functionScope comment --- src/jsc/bindings/NodeVM.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 92f9702fd7d6..7cc3eebe1bd9 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -1593,11 +1593,8 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject // Create the source origin SourceOrigin sourceOrigin { WTF::URL::fileURLWithFileSystemPath(options.filename), *fetcher }; - // globalScope() is the global lexical environment (script-level let/const/ - // class bindings), which precedes the global object in every ordinary scope - // chain; a chain starting at the global object itself cannot see them. The - // chain is given to the compiled function only: setGlobalScopeExtension() - // would expose it to every unresolved lookup in the realm. + // globalScope() is the global lexical environment, which holds script-level + // let/const/class bindings; a chain rooted at the global object skips them. JSScope* functionScope = options.parsingContext->globalScope(); if (!options.contextExtensions.isUndefinedOrNull() && !options.contextExtensions.isEmpty() && options.contextExtensions.isObject() && isArray(globalObject, options.contextExtensions)) { From 0958ecad1039c42cffb4b72a68f00f2f0043e92d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:14:54 +0000 Subject: [PATCH 4/5] node:vm: drop the functionScope comment --- src/jsc/bindings/NodeVM.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 7cc3eebe1bd9..c08d8ed649cc 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -1593,8 +1593,6 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject // Create the source origin SourceOrigin sourceOrigin { WTF::URL::fileURLWithFileSystemPath(options.filename), *fetcher }; - // globalScope() is the global lexical environment, which holds script-level - // let/const/class bindings; a chain rooted at the global object skips them. JSScope* functionScope = options.parsingContext->globalScope(); if (!options.contextExtensions.isUndefinedOrNull() && !options.contextExtensions.isEmpty() && options.contextExtensions.isObject() && isArray(globalObject, options.contextExtensions)) { From ed936885446dcbd31e190ef98ab840dfcf804c24 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:45:28 +0000 Subject: [PATCH 5/5] ci: retrigger