From 2d6201410577a5fc52bfcb41e77e3eae37b84e7d Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Sat, 17 Oct 2026 21:49:53 +0100 Subject: [PATCH] vm, compiler: make global var bindings properties of the global object Global var and function bindings lived only in heap slots. The global object gained copies of them only after an indirect eval, so getOwnPropertyDescriptor(globalThis, name) saw no script vars. On top of that, hasOwnProperty answered true for let/const/class, and `var x;` in eval reset an existing x to undefined. Var and function slots are now mirrored onto the global object as data properties, kept current through the heap's slot watch: - script declarations are non-configurable - eval declarations and implicit globals are configurable Deleting a binding drops its mirror. Top-level classes initialize their slot like let, so they are no longer mistaken for implicit globals. Eval code declares globals through a new OpDeclareGlobal, which implements CreateGlobalVarBinding and CreateGlobalFunctionBinding. It first checks CanDeclareGlobalFunction and CanDeclareGlobalVar for every name, so a conflict throws before any binding is created. Co-Authored-By: Claude Opus 5.5 --- pkg/builtins/object_init.go | 11 +- pkg/builtins/reflect_delete.go | 4 +- pkg/compiler/compile_class.go | 4 +- pkg/compiler/compiler.go | 43 ++++- pkg/compiler/emit.go | 10 + pkg/driver/driver.go | 6 - pkg/vm/bytecode.go | 15 ++ pkg/vm/global_bindings.go | 174 ++++++++++++++++++ pkg/vm/vm.go | 38 +++- .../global_var_bindings_on_global_object.ts | 31 ++++ 10 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 pkg/vm/global_bindings.go create mode 100644 tests/scripts/global_var_bindings_on_global_object.ts diff --git a/pkg/builtins/object_init.go b/pkg/builtins/object_init.go index 49b892c4..d6667f25 100644 --- a/pkg/builtins/object_init.go +++ b/pkg/builtins/object_init.go @@ -169,15 +169,8 @@ func (o *ObjectInitializer) InitRuntime(ctx *RuntimeContext) error { switch thisValue.Type() { case vm.TypeObject: plainObj := thisValue.AsPlainObject() - // Special case for globalThis: check heap for top-level declarations - if plainObj == vmInstance.GlobalObject { - if idx, exists := vmInstance.GetHeap().GetNameToIndex()[propName]; exists { - // Also verify the heap value is still initialized (not deleted) - if _, isInit := vmInstance.GetHeap().Get(idx); isInit { - return vm.BooleanValue(true), nil - } - } - } + // globalThis's var/function bindings are mirrored as own + // properties; its let/const/class bindings are not properties. _, hasOwn := plainObj.GetOwn(propName) return vm.BooleanValue(hasOwn), nil case vm.TypeDictObject: diff --git a/pkg/builtins/reflect_delete.go b/pkg/builtins/reflect_delete.go index 39a31b24..d65a2063 100644 --- a/pkg/builtins/reflect_delete.go +++ b/pkg/builtins/reflect_delete.go @@ -58,8 +58,8 @@ func reflectDeleteProperty(vmInstance *vm.VM, target vm.Value, key vm.Value) (bo } success := deleteFromTable(po, name) if success && po == vmInstance.GlobalObject && heap != nil { - if idx, exists := heap.GetNameToIndex()[name]; exists { - heap.Delete(idx) + if idx, exists := heap.GetNameToIndex()[name]; exists && heap.Delete(idx) { + vmInstance.UnmirrorGlobalVar(idx) } } return success, nil diff --git a/pkg/compiler/compile_class.go b/pkg/compiler/compile_class.go index 0bfefe39..66092b6d 100644 --- a/pkg/compiler/compile_class.go +++ b/pkg/compiler/compile_class.go @@ -505,7 +505,7 @@ func (c *Compiler) compileClassDeclaration(node *parser.ClassDeclaration, hint R // decorator replaced it (#608). if node.Name.Value != "" { if isGlobalClassScope { - c.emitSetGlobal(classGlobalIdx, constructorReg, node.Token.Line) + c.emitSetGlobalInit(classGlobalIdx, constructorReg, node.Token.Line) } else { c.emitStoreSpill(classSpillSlot, constructorReg, node.Token.Line) } @@ -539,7 +539,7 @@ func (c *Compiler) compileClassDeclaration(node *parser.ClassDeclaration, hint R // spill slot (defined up front in step 1), so a single store satisfies both. if isGlobalClassScope { // Top-level class declaration - set the global value - c.emitSetGlobal(classGlobalIdx, constructorReg, node.Token.Line) + c.emitSetGlobalInit(classGlobalIdx, constructorReg, node.Token.Line) debugPrintf("// DEBUG compileClassDeclaration: Set global class '%s' to R%d\n", node.Name.Value, constructorReg) } else { c.emitStoreSpill(classSpillSlot, constructorReg, node.Token.Line) diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go index e7a8b358..ced7057e 100644 --- a/pkg/compiler/compiler.go +++ b/pkg/compiler/compiler.go @@ -1382,6 +1382,27 @@ func (c *Compiler) Compile(node parser.Node) (resultChunk *vm.Chunk, resultErrs hoistedGlobalNames = append(hoistedGlobalNames, name) } sort.Strings(hoistedGlobalNames) + // Eval code declares nothing unless every global function and var + // is definable (EvalDeclarationInstantiation steps 8 and 10, before + // any binding is created). + if (c.isIndirectEval || c.callerScopeDesc != nil) && !c.isStrictEvalCode() && c.enclosing == nil && !c.evalHoistsToEnv() { + for _, name := range hoistedGlobalNames { + if _, ok := program.HoistedDeclarations[name].(*parser.FunctionLiteral); ok { + globalIdx := c.GetOrAssignGlobalIndex(c.moduleGlobalKey(name)) + c.emitDeclareGlobal(globalIdx, name, vm.DeclareGlobalFunction|vm.DeclareGlobalCheck, 0, 0) + } + } + for _, name := range collectVarDeclarations(program.Statements) { + if _, isFunc := program.HoistedDeclarations[name]; isFunc { + continue + } + if _, _, found := c.currentSymbolTable.Resolve(name); found { + continue + } + globalIdx := c.GetOrAssignGlobalIndex(c.moduleGlobalKey(name)) + c.emitDeclareGlobal(globalIdx, name, vm.DeclareGlobalCheck, 0, 0) + } + } for _, name := range hoistedGlobalNames { hoistedNode := program.HoistedDeclarations[name] funcLit, ok := hoistedNode.(*parser.FunctionLiteral) @@ -1427,8 +1448,14 @@ func (c *Compiler) Compile(node parser.Node) (resultChunk *vm.Chunk, resultErrs // Update the symbol table entry to mark it as global c.currentSymbolTable.DefineGlobal(name, globalIdx) - // 5. Emit OpSetGlobal to store the function in the VM's globals array - c.emitSetGlobal(globalIdx, closureReg, funcLit.Token.Line) + // 5. Store the function in the VM's globals array. Eval code + // creates the binding with CreateGlobalFunctionBinding, which + // also redefines an existing configurable global property. + if c.isIndirectEval || c.callerScopeDesc != nil { + c.emitDeclareGlobal(globalIdx, name, vm.DeclareGlobalFunction|vm.DeclareGlobalDeletable, closureReg, funcLit.Token.Line) + } else { + c.emitSetGlobal(globalIdx, closureReg, funcLit.Token.Line) + } // 6. Mark as non-configurable (DontDelete) only for true top-level function declarations, // not for eval-created bindings which should be configurable @@ -1478,9 +1505,17 @@ func (c *Compiler) Compile(node parser.Node) (resultChunk *vm.Chunk, resultErrs // pre-existing non-writable global of the same name (e.g. `var // undefined = 42`, see #440 follow-up; OpSetGlobalInit resets // writability for exactly this reason). + // + // Eval code instead leaves an existing binding of the name + // alone (CreateGlobalVarBinding): `var x;` in eval must not reset + // the caller's x to undefined. tempReg := c.regAlloc.Alloc() - c.emitLoadUndefined(tempReg, 0) - c.emitSetGlobalInit(globalIdx, tempReg, 0) + if c.isIndirectEval || c.callerScopeDesc != nil { + c.emitDeclareGlobal(globalIdx, name, vm.DeclareGlobalDeletable, tempReg, 0) + } else { + c.emitLoadUndefined(tempReg, 0) + c.emitSetGlobalInit(globalIdx, tempReg, 0) + } c.regAlloc.Free(tempReg) // Mark as non-configurable (DontDelete) only for true top-level var declarations, // not for eval-created bindings which should be configurable diff --git a/pkg/compiler/emit.go b/pkg/compiler/emit.go index 5a77aa1e..cddfd832 100644 --- a/pkg/compiler/emit.go +++ b/pkg/compiler/emit.go @@ -754,6 +754,16 @@ func (c *Compiler) emitSetGlobalInit(globalIdx uint16, src Register, line int) { c.emitByte(byte(src)) } +// emitDeclareGlobal emits OpDeclareGlobal (flags: vm.DeclareGlobal*); src +// holds a function declaration's value. +func (c *Compiler) emitDeclareGlobal(globalIdx uint16, name string, flags byte, src Register, line int) { + c.emitOpCode(vm.OpDeclareGlobal, line) + c.emitUint16(globalIdx) + c.emitUint16(c.chunk.AddConstant(vm.String(name))) + c.emitByte(flags) + c.emitByte(byte(src)) +} + // emitToNumber implements unary plus by converting the operand to a number func (c *Compiler) emitToNumber(dest, src Register, line int) { c.emitOpCode(vm.OpToNumber, line) diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go index 35a15f17..1578eeb9 100644 --- a/pkg/driver/driver.go +++ b/pkg/driver/driver.go @@ -567,12 +567,6 @@ func (p *Paserati) IndirectEvalCode(code string) (vm.Value, []error) { return vm.Undefined, errs } - // For non-strict indirect eval, sync heap vars to GlobalObject - // This makes var declarations accessible via globalThis (ECMAScript requirement) - if !chunk.IsStrict { - p.vmInstance.SyncHeapToGlobalObject() - } - return result, nil } diff --git a/pkg/vm/bytecode.go b/pkg/vm/bytecode.go index aa51a2ba..13179d2b 100644 --- a/pkg/vm/bytecode.go +++ b/pkg/vm/bytecode.go @@ -335,6 +335,10 @@ const ( // OpTruncateWith drops the current frame's with objects beyond the first // N, for control leaving `with` bodies by break/continue or exception. OpTruncateWith OpCode = 185 // N + // OpDeclareGlobal creates an eval-declared global var or function binding + // (flags: DeclareGlobal*), leaving an existing var binding untouched and + // reflecting it on the global object; Ry is the function's value. + OpDeclareGlobal OpCode = 186 // GlobalIdx(16bit) NameIdx(16bit) Flags Ry // --- Accessor Property Support --- OpDefineAccessor OpCode = 80 // ObjReg GetterReg SetterReg NameIdx(16bit): Define accessor property on object @@ -676,6 +680,8 @@ func (op OpCode) String() string { return "OpCreateDeferredNamespace" case OpTruncateWith: return "OpTruncateWith" + case OpDeclareGlobal: + return "OpDeclareGlobal" // --- Arguments Object --- case OpGetArguments: @@ -1537,6 +1543,15 @@ func (c *Chunk) disassembleInstruction(builder *strings.Builder, offset int) int return c.registerConstantInstruction(builder, "OpCreateNamespace", offset, true) case OpCreateDeferredNamespace: return c.registerConstantInstruction(builder, "OpCreateDeferredNamespace", offset, true) + case OpDeclareGlobal: + if offset+6 >= len(c.Code) { + builder.WriteString("OpDeclareGlobal (missing operands)\n") + return offset + 1 + } + gi := uint16(c.Code[offset+1])<<8 | uint16(c.Code[offset+2]) + ni := uint16(c.Code[offset+3])<<8 | uint16(c.Code[offset+4]) + builder.WriteString(fmt.Sprintf("%-16s G%d %s flags=%d R%d\n", "OpDeclareGlobal", gi, c.Constants[ni].Inspect(), c.Code[offset+5], c.Code[offset+6])) + return offset + 7 // --- Arguments Object --- case OpGetArguments: diff --git a/pkg/vm/global_bindings.go b/pkg/vm/global_bindings.go new file mode 100644 index 00000000..da1e58f9 --- /dev/null +++ b/pkg/vm/global_bindings.go @@ -0,0 +1,174 @@ +package vm + +import "strings" + +// Global var and function bindings live in heap slots, but ECMAScript also +// makes them own data properties of the global object (the global +// environment's object record). Each such slot is mirrored onto the global +// object as an ordinary data property kept current by the heap's slot watch +// (see module_namespace.go), so descriptors, key listing and +// hasOwnProperty see it like any other property. + +// Flags of OpDeclareGlobal. +const ( + DeclareGlobalFunction = 1 << 0 // CreateGlobalFunctionBinding (else CreateGlobalVarBinding) + DeclareGlobalDeletable = 1 << 1 // eval code: the binding is configurable + DeclareGlobalCheck = 1 << 2 // only check CanDeclareGlobalFunction / CanDeclareGlobalVar +) + +// mirrorGlobalVar makes heap slot idx (global var name) an own data property +// of the global object kept in sync with the slot. A property the global +// object already has keeps its attributes; otherwise one is defined as +// writable, enumerable, and configurable only if deletable. +func (vm *VM) mirrorGlobalVar(idx int, name string, deletable bool) { + g := vm.GlobalObject + if g == nil || name == "" || strings.Contains(name, "\x00") { + return + } + val, ok := vm.heap.Get(idx) + if !ok || val.typ == TypeUninitialized { + val = Undefined + } + if _, exists := g.GetOwn(name); exists { + g.SetOwn(name, val) + } else { + w, e, c := true, true, deletable + g.DefineOwnProperty(name, val, &w, &e, &c) + } + vm.heap.watchGlobal(idx, g, name) +} + +// unmirrorGlobalVar drops the mirror of a deleted heap slot. +func (vm *VM) unmirrorGlobalVar(idx int) { + if vm.GlobalObject == nil { + return + } + if name := vm.heap.unwatchGlobal(idx, vm.GlobalObject); name != "" { + vm.GlobalObject.DeleteOwn(name) + } +} + +// reflectScriptVars mirrors a script's var and function declarations +// (chunk.VarGlobalIndices, already marked non-configurable) onto the global +// object. Module top-level bindings are not global object properties. +func (vm *VM) reflectScriptVars(chunk *Chunk) { + if chunk.IsModule || len(chunk.VarGlobalIndices) == 0 || vm.GlobalObject == nil { + return + } + isVar := make(map[int]bool, len(chunk.VarGlobalIndices)) + for _, idx := range chunk.VarGlobalIndices { + isVar[int(idx)] = true + } + for name, idx := range vm.heap.GetNameToIndex() { + if isVar[idx] { + vm.mirrorGlobalVar(idx, name, false) + } + } +} + +// canDeclareGlobalFunction is CanDeclareGlobalFunction (ECMA-262 +// 9.1.1.4.16): an existing non-configurable property must be a writable, +// enumerable data property. +func (vm *VM) canDeclareGlobalFunction(name string) bool { + g := vm.GlobalObject + if g == nil { + return true + } + i := g.shape.lookupStringField(name) + if i < 0 { + return g.IsExtensible() + } + f := g.shape.fields[i] + return f.configurable || (!f.isAccessor && f.writable && f.enumerable) +} + +// canDeclareGlobalVar is CanDeclareGlobalVar (ECMA-262 9.1.1.4.15). +func (vm *VM) canDeclareGlobalVar(idx int, name string) bool { + g := vm.GlobalObject + if g == nil || vm.heap.IsInitialized(idx) { + return true + } + if _, exists := g.GetOwn(name); exists { + return true + } + return g.IsExtensible() +} + +// declareGlobal implements CreateGlobalVarBinding and +// CreateGlobalFunctionBinding (ECMA-262 9.1.1.4.17-18) for eval code, where +// the binding may already exist - as an earlier declaration's heap slot or +// as a property of the global object. +func (vm *VM) declareGlobal(idx int, name string, flags byte, value Value) { + h := vm.heap + g := vm.GlobalObject + deletable := flags&DeclareGlobalDeletable != 0 + if flags&DeclareGlobalFunction == 0 { + // An existing binding keeps its value and attributes. + if h.IsInitialized(idx) { + vm.mirrorGlobalVar(idx, name, deletable) + return + } + if g != nil { + if v, exists := g.GetOwn(name); exists { + _ = h.Set(idx, v) + vm.mirrorGlobalVar(idx, name, deletable) + return + } + } + _ = h.Set(idx, Undefined) + _ = h.SetConfigurable(idx, deletable) + _ = h.SetWritable(idx, true) + vm.mirrorGlobalVar(idx, name, deletable) + return + } + // A function replaces the binding's value; an existing non-configurable + // property also keeps its attributes, anything else is redefined. + redefine := true + if g != nil { + if i := g.shape.lookupStringField(name); i >= 0 && !g.shape.fields[i].configurable { + redefine = false + } + } + if redefine { + _ = h.SetConfigurable(idx, deletable) + _ = h.SetWritable(idx, true) + if g != nil { + w, e, c := true, true, deletable + g.DefineOwnProperty(name, value, &w, &e, &c) + } + } + _ = h.Set(idx, value) + vm.mirrorGlobalVar(idx, name, deletable) +} + +// watchGlobal registers the global object property name to mirror slot +// index, once. +func (h *Heap) watchGlobal(index int, g *PlainObject, name string) { + if index >= 0 && index < len(h.watch) { + for _, b := range h.watch[index] { + if b.ns == g && b.name == name { + return + } + } + } + h.watchSlot(index, namespaceBinding{ns: g, name: name}) +} + +// unwatchGlobal removes slot index's mirror on g, returning its name. +func (h *Heap) unwatchGlobal(index int, g *PlainObject) string { + if index < 0 || index >= len(h.watch) { + return "" + } + bs := h.watch[index] + for i, b := range bs { + if b.ns == g { + h.watch[index] = append(bs[:i:i], bs[i+1:]...) + return b.name + } + } + return "" +} + +// UnmirrorGlobalVar is unmirrorGlobalVar for builtins deleting a global +// object property backed by heap slot idx. +func (vm *VM) UnmirrorGlobalVar(idx int) { vm.unmirrorGlobalVar(idx) } diff --git a/pkg/vm/vm.go b/pkg/vm/vm.go index fc1bd418..0d608fd4 100644 --- a/pkg/vm/vm.go +++ b/pkg/vm/vm.go @@ -1755,6 +1755,7 @@ func (vm *VM) Interpret(chunk *Chunk) (Value, []errors.PaseratiError) { _ = vm.heap.SetConfigurable(int(idx), false) // After resize, this should succeed } } + vm.reflectScriptVars(chunk) // Run the VM // fmt.Printf("// [VM] Interpret: About to call vm.run() for chunk '%s' (stack depth: %d)\n", mainFuncObj.Name, len(vm.executionContextStack)) @@ -14503,6 +14504,11 @@ startExecution: } vm.setGlobalInTable(globalIdx, value) + // A sloppy assignment to an unresolvable name creates a + // configurable property of the global object. + if !heapExists && !function.Chunk.IsModule { + vm.mirrorGlobalVar(int(globalIdx), vm.heap.GetNameByIndex(int(globalIdx)), true) + } // Debug output (disabled) // if globalIdx == 21 { @@ -17081,7 +17087,9 @@ startExecution: // If this is the GlobalObject, also mark the heap entry as deleted if success && po == vm.GlobalObject { if idx, exists := vm.heap.GetNameToIndex()[propName]; exists { - vm.heap.Delete(idx) + if vm.heap.Delete(idx) { + vm.unmirrorGlobalVar(idx) + } } } } else if obj.Type() == TypeDictObject { @@ -17619,7 +17627,9 @@ startExecution: // GlobalThis special case: keep heap in sync if success && po == vm.GlobalObject { if idx, exists := vm.heap.GetNameToIndex()[propName]; exists { - vm.heap.Delete(idx) + if vm.heap.Delete(idx) { + vm.unmirrorGlobalVar(idx) + } } } } @@ -17826,8 +17836,32 @@ startExecution: // Try to delete from heap - returns false if non-configurable success := vm.heap.Delete(int(heapIdx)) + if success { + vm.unmirrorGlobalVar(int(heapIdx)) + } registers[destReg] = BooleanValue(success) + case OpDeclareGlobal: + globalIdx := int(uint16(code[ip])<<8 | uint16(code[ip+1])) + nameIdx := int(uint16(code[ip+2])<<8 | uint16(code[ip+3])) + flags := code[ip+4] + srcReg := code[ip+5] + ip += 6 + name := constants[nameIdx].AsString() + if flags&DeclareGlobalCheck != 0 { + isFunc := flags&DeclareGlobalFunction != 0 + if (isFunc && !vm.canDeclareGlobalFunction(name)) || (!isFunc && !vm.canDeclareGlobalVar(globalIdx, name)) { + frame.ip = ip + vm.ThrowTypeError(fmt.Sprintf("Cannot declare global binding '%s'", name)) + if vm.unwinding { + return InterpretRuntimeError, Undefined + } + goto reloadFrame + } + continue + } + vm.declareGlobal(globalIdx, name, flags, registers[srcReg]) + case OpToPropertyKey: // OpToPropertyKey: Rx Ry: Rx = ToPropertyKey(Ry) // Converts value to property key, calling toString() method for objects diff --git a/tests/scripts/global_var_bindings_on_global_object.ts b/tests/scripts/global_var_bindings_on_global_object.ts new file mode 100644 index 00000000..48620e19 --- /dev/null +++ b/tests/scripts/global_var_bindings_on_global_object.ts @@ -0,0 +1,31 @@ +// Global var/function bindings are data properties of the global object +// (non-configurable for script declarations, configurable for eval-created +// ones and implicit globals); let/const/class bindings are not; a var +// redeclared in eval keeps its value +// no-typecheck +// expect: v:w/e/-|f:w/e/-|ev:w/e/c|ef:w/e/c|iv:w/e/c|zz:w/e/c|let:none|class:none|23,7,function + +var v = 1; +function f() {} +let l = 1; +class K {} +eval("var ev = 1; function ef() {}"); +(0, eval)("var iv = 1;"); +zz = 1; + +function attrs(name) { + var d = Object.getOwnPropertyDescriptor(globalThis, name); + if (!d) return name + ":none"; + return name + ":" + (d.writable ? "w" : "-") + "/" + (d.enumerable ? "e" : "-") + "/" + (d.configurable ? "c" : "-"); +} + +var x = 23; +eval("var x;"); +var y = 7; +(0, eval)("var y;"); +(0, eval)("var f;"); + +[attrs("v"), attrs("f"), attrs("ev"), attrs("ef"), attrs("iv"), attrs("zz"), + "let:" + (globalThis.hasOwnProperty("l") ? "own" : "none"), + "class:" + (globalThis.hasOwnProperty("K") ? "own" : "none"), + [x, y, typeof f].join(",")].join("|");