diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index b208edb62..de74f418a 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -32,7 +32,7 @@ Fib is the best relative row because it already uses `OP_CALL_SELF_NUM` / `OP_SU ## Profile facts (function-wrapped equivalents) -- `loop-dispatch-floor`: original baseline was 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT`. Counted-for now matches `i = i + 1` and emits `OP_ADD_INT` (`e6f3e177`); standalone assignment still uses `OP_INC_NUMERIC`. Loop/if `<` fuses as `OP_JUMP_IF_NOT_LT` (`d398e8c5`); `<=` loops (Sieve) still use the LTE jump. Number literals type as `sltFloat`. +- `loop-dispatch-floor`: original baseline was 38% `OP_GET_LOCAL`, 15% `OP_LOAD_INT`, 11% `OP_SET_LOCAL`, 8% `OP_ADD_FLOAT`. Counted-for now matches `i = i + 1` and emits `OP_ADD_INT` for ascending loops and `OP_SUB_INT` for descending loops (`e6f3e177`); standalone assignment still uses `OP_INC_NUMERIC`. Loop/if `<` fuses as `OP_JUMP_IF_NOT_LT` (`d398e8c5`); `<=` loops (Sieve) still use the LTE jump. Number literals type as `sltFloat`. - `nbody-minimal`: original baseline was 31% `OP_GET_LOCAL`, 12% `OP_GET_PROP_CONST`, 10% `OP_LOAD_HOLE`, 8% `OP_MOVE`. Hot pair `GET_LOCAL → GET_PROP_CONST` (11%) now fuses as `OP_GET_LOCAL_PROP_CONST` (`db9567bd`). Generic `OP_MUL`/`OP_ADD` still 100% scalar hit rate. - Script-level `let` in a non-function profiled as `OP_GET_GLOBAL` (29% of opcodes) — not the AWFY/probe shape. @@ -150,7 +150,7 @@ Json 24.96, Permute 21.86, Sieve 21.63, CD 20.41, Bounce 19.94, Havlak 19.09, To - **Broader read-PIC:** still rejected (ADR 0088). Own+proto read ICs already ship. - **Write-IC:** landed this wave (`15e8eca7`). Own writable-data stores on `OP_SET_PROP_CONST` hit a shape-keyed IC; misses still go through `AssignProperty`. Broader read-PIC remains rejected (ADR 0088). - **`OP_SET_PROP_CONST`** uses the write IC for ordinary own writable data; `VMTrySetOwnWritableDataProperty` remains available for non-IC paths. -- **Counted-for** now matches `i = i + 1` / `i += 1` and minus (`e6f3e177`), emitting `OP_ADD_INT`. Loop `<` fuses as `OP_JUMP_IF_NOT_LT` (`d398e8c5`). Integer-valued number literals still type as `sltFloat`; wave-2 TypeHint retry was measured and rejected. +- **Counted-for** now matches `i = i + 1` / `i += 1` and minus (`e6f3e177`), emitting `OP_ADD_INT` for ascending loops and `OP_SUB_INT` for descending loops. Loop `<` fuses as `OP_JUMP_IF_NOT_LT` (`d398e8c5`). Integer-valued number literals still type as `sltFloat`; wave-2 TypeHint retry was measured and rejected. - **CALL:** bytecode→bytecode already trampolines; `ExecuteClosureRegisters0–3` are native ingress only. Revisit `OP_CALL_METHOD` staging only with AWFY transfer (ADR 0089 previously noise). - **Dispatch preamble:** prod vs instrumented dual loop landed (`c776b95b`). Do not retry a sparse hot/cold opcode split or duplicating the full `case` (FPC register-pressure failure). - **ALLOC:** do not revive value caches. Property-store `RegisterToValue` boxing is the live allocation tax. diff --git a/AGENTS.md b/AGENTS.md index af8496314..d4c050b3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,4 +64,4 @@ runtime command lists here. - **Contribution requirements:** [CONTRIBUTING.md](CONTRIBUTING.md) - **Engine shape:** [docs/architecture.md](docs/architecture.md), [docs/interpreter.md](docs/interpreter.md), [docs/bytecode-vm.md](docs/bytecode-vm.md), [docs/core-patterns.md](docs/core-patterns.md) - **Optional extended agent skills:** [.agents/skills/](.agents/skills/) (installable playbooks; not a substitute for CONTRIBUTING) -- **Runtime optimization waves:** [.agents/skills/optimize-runtime/SKILL.md](.agents/skills/optimize-runtime/SKILL.md) — benchmark-gated bytecode/interpreter speed work vs QuickJS; keep only measured wins +- **Runtime optimization waves:** [.agents/skills/optimize-runtime/SKILL.md](.agents/skills/optimize-runtime/SKILL.md) — Use when closing the bytecode-vs-QuickJS gap or running a measured runtime optimization wave. Benchmark-gated; keep only measured wins diff --git a/source/units/Goccia.Compiler.Expressions.pas b/source/units/Goccia.Compiler.Expressions.pas index 7424b43f2..7a2a7e7c7 100644 --- a/source/units/Goccia.Compiler.Expressions.pas +++ b/source/units/Goccia.Compiler.Expressions.pas @@ -2561,15 +2561,11 @@ procedure CompileAssignment(const ACtx: TGocciaCompilationContext; IsNumericSelfIncrementByOne(ACtx.Scope, AExpr.Name, AExpr.Value) then begin Local := ACtx.Scope.GetLocal(LocalIdx); - if (not Local.IsGlobalBacked) and (not Local.IsImportBinding) then + // Const locals must use the generic assignment path so the RHS is + // evaluated before const-assignment / TDZ behavior (ES2026 §13.15.2). + if (not Local.IsGlobalBacked) and (not Local.IsImportBinding) and + (not Local.IsConst) then begin - if Local.IsConst then - begin - if ShouldIgnoreNonStrictImmutableLocalAssignment(ACtx, Local) then - Exit; - EmitConstAssignmentError(ACtx); - Exit; - end; Slot := Local.Slot; if Local.IsCaptured then EmitInstruction(ACtx, EncodeABx(OP_GET_LOCAL, Slot, UInt16(Slot))); diff --git a/source/units/Goccia.Compiler.Statements.pas b/source/units/Goccia.Compiler.Statements.pas index 6f3a5c7c1..05bcb36a0 100644 --- a/source/units/Goccia.Compiler.Statements.pas +++ b/source/units/Goccia.Compiler.Statements.pas @@ -3332,7 +3332,11 @@ function TryMatchCountedForLimit(const ACtx: TGocciaCompilationContext; // writes are rejected; mutable captured bindings and bodies that create // closures (which can assign through the capture) fall back too. const // bindings cannot be assigned, so they remain snapshot-safe even when the - // body creates closures that capture the loop index. + // body creates closures that capture the loop index. Global-backed vars can + // still change through the global object from a callee the body analysis + // does not see. + if LimitLocal.IsGlobalBacked then + Exit; if ForBodyAssignsIdentifier(ABody, LimitIdent.Name) then Exit; if not LimitLocal.IsConst then diff --git a/source/units/Goccia.Compiler.Test.pas b/source/units/Goccia.Compiler.Test.pas index 60a2c1cce..2c854762a 100644 --- a/source/units/Goccia.Compiler.Test.pas +++ b/source/units/Goccia.Compiler.Test.pas @@ -116,6 +116,8 @@ TTestCompiler = class(TTestSuite) procedure TestForOfUsesHandlerForExpressionBody; procedure TestForOfUsesOneIteratorCloseHandler; procedure TestCountedForLessThanUsesJumpIfNotLt; + procedure TestConstSelfIncrementUsesGenericAssignment; + procedure TestGlobalBackedCountedForLimitIsNotSnapshotted; procedure TestIfAndConditionalLessThanUseJumpIfNotLt; procedure TestLessThanValueKeepsGenericCompare; procedure TestConstantIfEliminatesBranch; @@ -197,6 +199,10 @@ procedure TTestCompiler.SetupTests; Test('for-of uses one iterator-close handler', TestForOfUsesOneIteratorCloseHandler); Test('counted-for less-than uses jump-if-not-lt', TestCountedForLessThanUsesJumpIfNotLt); + Test('const self-increment uses generic assignment', + TestConstSelfIncrementUsesGenericAssignment); + Test('global-backed counted-for limit is not snapshotted', + TestGlobalBackedCountedForLimitIsNotSnapshotted); Test('if and conditional less-than use jump-if-not-lt', TestIfAndConditionalLessThanUseJumpIfNotLt); Test('less-than value keeps generic compare', @@ -1661,6 +1667,40 @@ procedure TTestCompiler.TestCountedForLessThanUsesJumpIfNotLt; end; end; +procedure TTestCompiler.TestConstSelfIncrementUsesGenericAssignment; +var + Module: TGocciaBytecodeModule; +begin + Module := CompileSource('const x = (x = x + 1);'); + try + Expect(CountOp(Module.TopLevel, OP_INC_NUMERIC)).ToBe(0); + finally + Module.Free; + end; + + Module := CompileSource('let x = 0; x = x + 1;'); + try + Expect(CountOp(Module.TopLevel, OP_INC_NUMERIC)).ToBe(1); + finally + Module.Free; + end; +end; + +procedure TTestCompiler.TestGlobalBackedCountedForLimitIsNotSnapshotted; +var + Module: TGocciaBytecodeModule; +begin + Module := CompileSource( + 'let n = 5; for (let i = 0; i < n; i = i + 1) { i; }', + False, False, True, True, True, True, True); + try + Expect(CountOp(Module.TopLevel, OP_ADD_INT)).ToBe(0); + Expect(CountOp(Module.TopLevel, OP_INC_NUMERIC) > 0).ToBe(True); + finally + Module.Free; + end; +end; + procedure TTestCompiler.TestIfAndConditionalLessThanUseJumpIfNotLt; var Module: TGocciaBytecodeModule; diff --git a/tests/language/expressions/arithmetic/self-increment-assignment.js b/tests/language/expressions/arithmetic/self-increment-assignment.js index 3b81081a2..1d4b70a37 100644 --- a/tests/language/expressions/arithmetic/self-increment-assignment.js +++ b/tests/language/expressions/arithmetic/self-increment-assignment.js @@ -64,3 +64,16 @@ test("self-increment assignment result can be captured after writes", () => { }; expect(f()).toEqual([11, 12, 12]); }); + +test("const initializer self-increment is TDZ not const-assignment", () => { + expect(() => { + const x = (x = x + 1); + }).toThrow(ReferenceError); +}); + +test("initialized const self-increment throws TypeError", () => { + expect(() => { + const x = 1; + x = x + 1; + }).toThrow(TypeError); +}); diff --git a/tests/language/for-loop-var/global-limit-mutation.js b/tests/language/for-loop-var/global-limit-mutation.js new file mode 100644 index 000000000..35487a0a9 --- /dev/null +++ b/tests/language/for-loop-var/global-limit-mutation.js @@ -0,0 +1,15 @@ +/*--- +description: counted-for reevaluates a global-backed var limit +features: [compat-traditional-for-loop, compat-var] +---*/ + +var countedForGlobalLimit = 3; +let countedForGlobalLimitCount = 0; +for (let i = 0; i < countedForGlobalLimit; i = i + 1) { + countedForGlobalLimitCount += 1; + globalThis.countedForGlobalLimit = 0; +} + +test("counted-for reevaluates a global-backed var limit mutated via globalThis", () => { + expect(countedForGlobalLimitCount).toBe(1); +});