Skip to content

De-couple control flow from exception flow#45

Merged
frostney merged 5 commits intomainfrom
refactor/control-flow-records
Mar 8, 2026
Merged

De-couple control flow from exception flow#45
frostney merged 5 commits intomainfrom
refactor/control-flow-records

Conversation

@frostney
Copy link
Copy Markdown
Owner

@frostney frostney commented Mar 7, 2026

Summary by CodeRabbit

  • Refactor
    • Reworked control-flow so return/break use lightweight in-memory signals instead of exception objects, improving runtime efficiency on hot paths.
    • Statement/block evaluation now propagates these signals by early exit while preserving existing semantics (including try/catch/finally behavior).
    • Internal test adjustments to read values from the new control-flow wrapper where applicable.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 7, 2026

📝 Walkthrough

Walkthrough

Replaces exception-based return/break signaling with a bit-packed TGocciaControlFlow record; many statement-level evaluator functions now return TGocciaControlFlow and callers propagate non-normal kinds (cfkReturn/cfkBreak) by early exit. New unit Goccia.ControlFlow added; return/break exception classes removed.

Changes

Cohort / File(s) Summary
Documentation
AGENTS.md, docs/architecture.md, docs/code-style.md, docs/design-decisions.md
Docs updated to reflect EvaluateStatementsSafeEvaluateStatements rename, new TGocciaControlFlow return type and propagation semantics; added mentions of ExecuteCatchBlock and PascalExceptionToErrorObject.
Control Flow Unit
units/Goccia.ControlFlow.pas
New unit defining TGocciaControlFlowKind (cfkNormal, cfkReturn, cfkBreak) and TGocciaControlFlow record with Normal/Return/Break constructors and bit-encoded storage.
Core Evaluator
units/Goccia.Evaluator.pas
Evaluator APIs changed to return TGocciaControlFlow for statement-level and block constructs (Evaluate, EvaluateStatement, EvaluateStatements, EvaluateBlock, EvaluateIf, EvaluateTry, EvaluateSwitch, EvaluateForOf, EvaluateForAwaitOf); added Goccia.ControlFlow to uses; replaced EvaluateStatementsSafe with EvaluateStatements.
Interpreter integration
units/Goccia.Interpreter.pas
Interpreter now consumes TGocciaControlFlow from statement evaluation, reads .Value, and performs early exits when Kind = cfkReturn.
Values & Errors / Function logic
units/Goccia.Values.Error.pas, units/Goccia.Values.FunctionValue.pas, units/Goccia.Values.FunctionValue.Test.pas
Removed exception classes TGocciaReturnValue and TGocciaBreakSignal; function execution and tests updated to use/unwrap TGocciaControlFlow (CF.Kind / CF.Value) instead of exception-based signaling.
Tests
units/Goccia.Values.FunctionValue.Test.pas
Adjusted tests to import Goccia.ControlFlow and to extract .Value from evaluation results where needed.

Sequence Diagram(s)

sequenceDiagram
  participant Caller as Caller (Interpreter / Function)
  participant Evaluator as Evaluator
  participant CF as TGocciaControlFlow
  participant Throw as TGocciaThrowValue

  Caller->>Evaluator: EvaluateStatement(node, ctx)
  Evaluator->>Evaluator: execute statement logic
  alt normal completion
    Evaluator-->>CF: Normal(value)
    Evaluator-->>Caller: TGocciaControlFlow(kind: cfkNormal, value)
    Caller->>Caller: if kind = cfkNormal then continue (use value)
  else return encountered
    Evaluator-->>CF: Return(value)
    Evaluator-->>Caller: TGocciaControlFlow(kind: cfkReturn, value)
    Caller->>Caller: if kind = cfkReturn then early exit with value
  else break encountered
    Evaluator-->>CF: Break(nil)
    Evaluator-->>Caller: TGocciaControlFlow(kind: cfkBreak, nil)
    Caller->>Caller: if kind = cfkBreak then handle loop/switch break
  end
  Note right of Throw: TGocciaThrowValue still propagates as an exception for JS throws
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Named exports modules #13: Modifies evaluator APIs and evaluation context handling; likely overlaps changes to units/Goccia.Evaluator.pas and evaluator control-flow behavior.

Poem

🐰 I nibbled through branches, light and sly,
Replaced loud throws with tidy CFs on the fly.
Return or Break—just a Kind and a Value in tow,
No heavy exceptions — a softer flow.
Hopping onward, the interpreter hums — let's go!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'De-couple control flow from exception flow' directly and accurately summarizes the main architectural change: replacing exception-based control flow (TGocciaReturnValue, TGocciaBreakSignal exceptions) with TGocciaControlFlow records for return/break handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/control-flow-records

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 7, 2026

Benchmark Results

254 benchmarks · 🟢 72 improved · 182 unchanged · avg +5.8%

arraybuffer.js — 14 unchanged · avg +4.4%
Benchmark Base (ops/sec) PR (ops/sec) Change
create ArrayBuffer(0) 352,746 372,163 +5.5%
create ArrayBuffer(64) 348,702 359,394 +3.1%
create ArrayBuffer(1024) 279,980 288,169 +2.9%
create ArrayBuffer(8192) 131,914 132,767 +0.6%
slice full buffer (64 bytes) 431,695 455,024 +5.4%
slice half buffer (512 of 1024 bytes) 370,925 389,034 +4.9%
slice with negative indices 362,273 378,379 +4.4%
slice empty range 415,960 439,282 +5.6%
byteLength access 1,090,849 1,140,013 +4.5%
Symbol.toStringTag access 779,429 814,324 +4.5%
ArrayBuffer.isView 593,524 624,843 +5.3%
clone ArrayBuffer(64) 325,737 342,849 +5.3%
clone ArrayBuffer(1024) 258,907 271,072 +4.7%
clone ArrayBuffer inside object 230,482 241,158 +4.6%
arrays.js — 🟢 3 improved, 16 unchanged · avg +3.0%
Benchmark Base (ops/sec) PR (ops/sec) Change
Array.from length 100 12,769 12,855 +0.7%
Array.from 10 elements 196,236 208,046 +6.0%
Array.of 10 elements 267,196 278,383 +4.2%
spread into new array 261,502 279,072 +6.7%
map over 50 elements 23,696 24,332 +2.7%
filter over 50 elements 19,529 20,272 +3.8%
reduce sum 50 elements 22,427 23,258 +3.7%
forEach over 50 elements 17,154 18,511 🟢 +7.9%
find in 50 elements 29,570 30,686 +3.8%
sort 20 elements 10,186 10,337 +1.5%
flat nested array 95,119 102,017 🟢 +7.3%
flatMap 56,954 61,759 🟢 +8.4%
map inside map (5x5) 17,306 17,871 +3.3%
filter inside map (5x10) 13,236 13,127 -0.8%
reduce inside map (5x10) 15,996 15,771 -1.4%
forEach inside forEach (5x10) 12,658 12,226 -3.4%
find inside some (10x10) 11,592 11,308 -2.4%
map+filter chain nested (5x20) 4,520 4,572 +1.2%
reduce flatten (10x5) 32,150 33,392 +3.9%
async-await.js — 🟢 4 improved, 2 unchanged · avg +7.1%
Benchmark Base (ops/sec) PR (ops/sec) Change
single await 275,112 294,088 +6.9%
multiple awaits 120,186 128,715 🟢 +7.1%
await non-Promise value 581,349 622,636 🟢 +7.1%
await with try/catch 259,077 277,965 🟢 +7.3%
await Promise.all 42,663 45,731 🟢 +7.2%
nested async function call 137,733 147,286 +6.9%
classes.js — 🟢 3 improved, 28 unchanged · avg +4.7%
Benchmark Base (ops/sec) PR (ops/sec) Change
simple class new 96,753 99,398 +2.7%
class with defaults 73,640 75,019 +1.9%
50 instances via Array.from 4,177 4,298 +2.9%
instance method call 45,610 47,216 +3.5%
static method call 80,816 83,198 +2.9%
single-level inheritance 38,072 39,154 +2.8%
two-level inheritance 32,573 33,852 +3.9%
private field access 45,718 47,256 +3.4%
private methods 52,009 54,175 +4.2%
getter/setter access 53,752 56,084 +4.3%
class decorator (identity) 71,673 73,994 +3.2%
class decorator (wrapping) 40,196 41,487 +3.2%
identity method decorator 49,861 53,067 +6.4%
wrapping method decorator 41,123 43,760 +6.4%
stacked method decorators (x3) 30,073 30,726 +2.2%
identity field decorator 57,138 59,979 +5.0%
field initializer decorator 47,766 50,254 +5.2%
getter decorator (identity) 47,950 49,977 +4.2%
setter decorator (identity) 38,991 41,372 +6.1%
static method decorator 53,167 55,832 +5.0%
static field decorator 62,099 64,945 +4.6%
private method decorator 40,304 43,175 🟢 +7.1%
private field decorator 43,230 45,645 +5.6%
plain auto-accessor (no decorator) 74,831 78,286 +4.6%
auto-accessor with decorator 43,609 47,539 🟢 +9.0%
decorator writing metadata 33,874 37,690 🟢 +11.3%
static getter read 83,680 88,616 +5.9%
static getter/setter pair 62,194 65,448 +5.2%
inherited static getter 48,679 50,708 +4.2%
inherited static setter 52,153 54,786 +5.0%
inherited static getter with this binding 41,766 42,643 +2.1%
closures.js — 🟢 2 improved, 9 unchanged · avg +6.8%
Benchmark Base (ops/sec) PR (ops/sec) Change
closure over single variable 77,319 90,862 🟢 +17.5%
closure over multiple variables 90,847 94,408 +3.9%
nested closures 92,259 97,961 +6.2%
function as argument 71,834 74,770 +4.1%
function returning function 88,246 92,216 +4.5%
compose two functions 52,960 55,010 +3.9%
fn.call 116,253 123,420 +6.2%
fn.apply 87,221 91,276 +4.6%
fn.bind 105,342 111,156 +5.5%
recursive sum to 50 8,325 8,725 +4.8%
recursive tree traversal 12,919 14,711 🟢 +13.9%
collections.js — 🟢 2 improved, 10 unchanged · avg +5.2%
Benchmark Base (ops/sec) PR (ops/sec) Change
add 50 elements 6,467 6,741 +4.2%
has lookup (50 elements) 84,667 86,527 +2.2%
delete elements 44,397 46,636 +5.0%
forEach iteration 12,414 13,117 +5.7%
spread to array 27,716 27,814 +0.4%
deduplicate array 35,625 38,192 🟢 +7.2%
set 50 entries 4,718 4,964 +5.2%
get lookup (50 entries) 83,539 86,266 +3.3%
has check 122,187 128,004 +4.8%
delete entries 43,743 45,743 +4.6%
forEach iteration 12,364 13,207 +6.8%
keys/values/entries 6,595 7,465 🟢 +13.2%
destructuring.js — 🟢 10 improved, 12 unchanged · avg +7.1%
Benchmark Base (ops/sec) PR (ops/sec) Change
simple array destructuring 314,952 336,537 +6.9%
with rest element 216,350 233,088 🟢 +7.7%
with defaults 315,738 337,503 +6.9%
skip elements 328,020 351,365 🟢 +7.1%
nested array destructuring 131,904 144,583 🟢 +9.6%
swap variables 330,484 356,225 🟢 +7.8%
simple object destructuring 244,154 255,063 +4.5%
with defaults 284,364 296,503 +4.3%
with renaming 249,549 265,206 +6.3%
nested object destructuring 115,477 121,486 +5.2%
rest properties 155,285 163,641 +5.4%
object parameter 72,958 78,465 🟢 +7.5%
array parameter 92,085 98,749 🟢 +7.2%
mixed destructuring in map 29,344 30,965 +5.5%
forEach with array destructuring 47,311 52,321 🟢 +10.6%
map with array destructuring 49,652 54,131 🟢 +9.0%
filter with array destructuring 51,392 56,555 🟢 +10.0%
reduce with array destructuring 55,830 59,448 +6.5%
map with object destructuring 64,714 68,881 +6.4%
map with nested destructuring 55,305 57,591 +4.1%
map with rest in destructuring 28,507 31,720 🟢 +11.3%
map with defaults in destructuring 48,315 50,877 +5.3%
fibonacci.js — 🟢 3 improved, 5 unchanged · avg +7.3%
Benchmark Base (ops/sec) PR (ops/sec) Change
recursive fib(15) 227 242 +6.7%
recursive fib(20) 21 21 +2.5%
recursive fib(15) typed 230 242 +5.2%
recursive fib(20) typed 21 21 +2.8%
iterative fib(20) via reduce 8,855 9,341 +5.5%
iterator fib(20) 6,481 7,195 🟢 +11.0%
iterator fib(20) via Iterator.from + take 6,745 7,567 🟢 +12.2%
iterator fib(20) last value via reduce 5,735 6,448 🟢 +12.4%
for-of.js — 🟢 7 improved · avg +9.7%
Benchmark Base (ops/sec) PR (ops/sec) Change
for...of with 10-element array 35,400 38,544 🟢 +8.9%
for...of with 100-element array 4,035 4,388 🟢 +8.7%
for...of with string (10 chars) 26,375 28,977 🟢 +9.9%
for...of with Set (10 elements) 35,636 38,413 🟢 +7.8%
for...of with Map entries (10 entries) 22,964 25,338 🟢 +10.3%
for...of with destructuring 30,815 33,524 🟢 +8.8%
for-await-of with sync array 32,409 36,730 🟢 +13.3%
iterators.js — 🟢 18 improved, 2 unchanged · avg +11.5%
Benchmark Base (ops/sec) PR (ops/sec) Change
Iterator.from({next}).toArray() — 20 elements 7,976 9,289 🟢 +16.5%
Iterator.from({next}).toArray() — 50 elements 3,415 3,911 🟢 +14.5%
spread pre-wrapped iterator — 20 elements 7,997 9,010 🟢 +12.7%
Iterator.from({next}).forEach — 50 elements 2,696 2,999 🟢 +11.2%
Iterator.from({next}).reduce — 50 elements 2,734 3,057 🟢 +11.8%
wrap array iterator 50,468 56,884 🟢 +12.7%
wrap plain {next()} object 5,505 6,280 🟢 +14.1%
map + toArray (50 elements) 2,358 2,618 🟢 +11.1%
filter + toArray (50 elements) 2,484 2,751 🟢 +10.8%
take(10) + toArray (50 element source) 12,857 14,278 🟢 +11.1%
drop(40) + toArray (50 element source) 3,311 3,694 🟢 +11.6%
chained map + filter + take (100 element source) 4,288 4,804 🟢 +12.0%
some + every (50 elements) 1,541 1,785 🟢 +15.9%
find (50 elements) 3,373 3,793 🟢 +12.4%
array.values().map().filter().toArray() 3,309 3,585 🟢 +8.3%
array.values().take(5).toArray() 56,115 62,671 🟢 +11.7%
array.values().drop(45).toArray() 15,955 16,968 +6.3%
map.entries() chained helpers 5,196 5,555 +6.9%
set.values() chained helpers 7,613 8,393 🟢 +10.2%
string iterator map + toArray 6,533 7,125 🟢 +9.1%
json.js — 🟢 8 improved, 12 unchanged · avg +7.2%
Benchmark Base (ops/sec) PR (ops/sec) Change
parse simple object 145,532 154,712 +6.3%
parse nested object 91,684 96,216 +4.9%
parse array of objects 48,786 51,110 +4.8%
parse large flat object 46,423 48,131 +3.7%
parse mixed types 64,480 67,180 +4.2%
stringify simple object 134,408 141,914 +5.6%
stringify nested object 71,248 76,394 🟢 +7.2%
stringify array of objects 38,357 38,847 +1.3%
stringify mixed types 61,730 64,532 +4.5%
reviver doubles numbers 32,661 37,652 🟢 +15.3%
reviver filters properties 30,419 33,860 🟢 +11.3%
reviver on nested object 39,331 44,172 🟢 +12.3%
reviver on array 21,719 24,342 🟢 +12.1%
replacer function doubles numbers 34,089 38,265 🟢 +12.3%
replacer function excludes properties 42,925 48,320 🟢 +12.6%
array replacer (allowlist) 90,634 96,877 +6.9%
stringify with 2-space indent 70,924 75,449 +6.4%
stringify with tab indent 70,878 73,716 +4.0%
parse then stringify 42,830 46,868 🟢 +9.4%
stringify then parse 26,843 26,610 -0.9%
jsx.jsx — 21 unchanged · avg +3.5%
Benchmark Base (ops/sec) PR (ops/sec) Change
simple element 172,463 178,823 +3.7%
self-closing element 173,686 181,495 +4.5%
element with string attribute 139,695 147,845 +5.8%
element with multiple attributes 120,180 127,421 +6.0%
element with expression attribute 129,929 138,220 +6.4%
text child 164,148 167,436 +2.0%
expression child 156,191 159,659 +2.2%
mixed text and expression 148,911 152,105 +2.1%
nested elements (3 levels) 63,818 61,227 -4.1%
sibling children 45,533 45,643 +0.2%
component element 112,205 115,667 +3.1%
component with children 68,681 72,012 +4.9%
dotted component 91,946 96,800 +5.3%
empty fragment 162,909 169,938 +4.3%
fragment with children 45,415 44,914 -1.1%
spread attributes 84,037 87,998 +4.7%
spread with overrides 75,190 77,496 +3.1%
shorthand props 117,602 125,097 +6.4%
nav bar structure 21,547 21,915 +1.7%
card component tree 23,748 25,267 +6.4%
10 list items via Array.from 11,015 11,645 +5.7%
numbers.js — 🟢 2 improved, 9 unchanged · avg +5.2%
Benchmark Base (ops/sec) PR (ops/sec) Change
integer arithmetic 357,405 374,875 +4.9%
floating point arithmetic 394,572 405,151 +2.7%
number coercion 146,592 151,440 +3.3%
toFixed 89,045 93,773 +5.3%
toString 126,092 131,024 +3.9%
valueOf 174,725 193,500 🟢 +10.7%
toPrecision 115,833 122,881 +6.1%
Number.isNaN 230,810 240,264 +4.1%
Number.isFinite 222,996 239,354 🟢 +7.3%
Number.isInteger 225,946 237,168 +5.0%
Number.parseInt and parseFloat 195,338 203,637 +4.2%
objects.js — 🟢 1 improved, 6 unchanged · avg +4.3%
Benchmark Base (ops/sec) PR (ops/sec) Change
create simple object 376,647 389,218 +3.3%
create nested object 175,180 181,775 +3.8%
create 50 objects via Array.from 7,601 7,700 +1.3%
property read 378,022 384,121 +1.6%
Object.keys 237,770 253,591 +6.7%
Object.entries 91,708 99,337 🟢 +8.3%
spread operator 144,623 151,820 +5.0%
promises.js — 🟢 7 improved, 5 unchanged · avg +6.9%
Benchmark Base (ops/sec) PR (ops/sec) Change
Promise.resolve(value) 403,364 428,432 +6.2%
new Promise(resolve => resolve(value)) 144,863 152,268 +5.1%
Promise.reject(reason) 391,345 421,971 🟢 +7.8%
resolve + then (1 handler) 126,161 137,595 🟢 +9.1%
resolve + then chain (3 deep) 54,196 55,581 +2.6%
resolve + then chain (10 deep) 16,614 18,112 🟢 +9.0%
reject + catch + then 71,038 78,142 🟢 +10.0%
resolve + finally + then 61,296 65,896 🟢 +7.5%
Promise.all (5 resolved) 23,558 25,747 🟢 +9.3%
Promise.race (5 resolved) 26,824 27,248 +1.6%
Promise.allSettled (5 mixed) 20,641 21,925 +6.2%
Promise.any (5 mixed) 23,801 25,716 🟢 +8.0%
strings.js — 🟢 2 improved, 9 unchanged · avg +4.2%
Benchmark Base (ops/sec) PR (ops/sec) Change
string concatenation 342,626 325,628 -5.0%
template literal 357,441 365,431 +2.2%
string repeat 330,655 350,984 +6.1%
split and join 114,672 116,219 +1.3%
indexOf and includes 137,432 139,625 +1.6%
toUpperCase and toLowerCase 187,400 204,434 🟢 +9.1%
slice and substring 123,015 130,417 +6.0%
trim operations 131,830 148,791 🟢 +12.9%
replace and replaceAll 162,429 172,331 +6.1%
startsWith and endsWith 105,682 110,196 +4.3%
padStart and padEnd 162,835 164,588 +1.1%
typed-arrays.js — 22 unchanged · avg +3.3%
Benchmark Base (ops/sec) PR (ops/sec) Change
new Int32Array(0) 250,860 255,047 +1.7%
new Int32Array(100) 233,778 239,657 +2.5%
new Int32Array(1000) 144,002 148,519 +3.1%
new Float64Array(100) 212,051 222,351 +4.9%
Int32Array.from([...]) 151,660 160,224 +5.6%
Int32Array.of(1, 2, 3, 4, 5) 253,030 267,509 +5.7%
sequential write 100 elements 2,728 2,861 +4.9%
sequential read 100 elements 2,830 2,937 +3.8%
Float64Array write 100 elements 2,469 2,593 +5.0%
fill(42) 44,768 47,115 +5.2%
slice() 172,435 184,360 +6.9%
map(x => x * 2) 6,885 6,843 -0.6%
filter(x => x > 50) 6,969 7,265 +4.2%
reduce (sum) 6,683 6,763 +1.2%
sort() 156,109 161,685 +3.6%
indexOf() 375,717 379,114 +0.9%
reverse() 294,930 298,104 +1.1%
create view over existing buffer 299,637 313,118 +4.5%
subarray() 367,408 385,957 +5.0%
set() from array 428,773 432,817 +0.9%
for-of loop 3,854 4,011 +4.1%
spread into array 16,296 16,112 -1.1%

Measured on ubuntu-latest x64. Changes within ±7% are considered insignificant.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/architecture.md (1)

200-216: ⚠️ Potential issue | 🟡 Minor

The evaluator section still describes a TGocciaValue-only contract.

These additions introduce TGocciaControlFlow, but the nearby prose and the Script Execution diagram still say the evaluator returns TGocciaValue. Please update those spots too so the docs stay internally consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/architecture.md` around lines 200 - 216, Update the evaluator
documentation and the Script Execution diagram to reflect that evaluator
routines return TGocciaControlFlow (which can carry a TGocciaValue) instead of a
TGocciaValue-only contract; change prose that references "returns TGocciaValue"
to "returns TGocciaControlFlow" and mention how TGocciaControlFlow encodes
cfkNormal/cfkReturn/cfkBreak plus an optional value, and update any examples or
labels that reference EvaluateStatements, EvaluateBlock, EvaluateTry, or the
Script Execution diagram to use TGocciaControlFlow semantics consistently.
units/Goccia.Evaluator.pas (1)

1638-1651: ⚠️ Potential issue | 🟠 Major

Route try / catch / finally bodies through EvaluateBlock.

These paths now execute raw .Nodes lists, so let / destructuring declarations inside try, catch, or finally skip the normal block-scope handling and leak into the surrounding scope. A try { let x = 1 } finally {} will currently bind x outside the try.

♻️ Suggested fix
-      Result := EvaluateStatements(ATryStatement.CatchBlock.Nodes, CatchContext);
+      Result := EvaluateBlock(ATryStatement.CatchBlock, CatchContext);
...
-    Result := EvaluateStatements(ATryStatement.CatchBlock.Nodes, AContext);
+    Result := EvaluateBlock(ATryStatement.CatchBlock, AContext);
...
-    Result := EvaluateStatements(ATryStatement.Block.Nodes, AContext);
+    Result := EvaluateBlock(ATryStatement.Block, AContext);
...
-    FinallyCF := EvaluateStatements(ATryStatement.FinallyBlock.Nodes, AContext);
+    FinallyCF := EvaluateBlock(ATryStatement.FinallyBlock, AContext);

Also applies to: 1678-1727

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@units/Goccia.Evaluator.pas` around lines 1638 - 1651, The try/catch/finally
bodies currently call EvaluateStatements with raw .Nodes which bypasses
block-scoped handling and causes let/destructuring to leak; change calls to
EvaluateStatements(… .Nodes, …) to call EvaluateBlock with the Block node itself
so block scope is applied: e.g. replace
EvaluateStatements(ATryStatement.CatchBlock.Nodes, CatchContext) with
EvaluateBlock(ATryStatement.CatchBlock, CatchContext) (and similarly for the
main try block and finally block), keeping the existing CatchScope
creation/assignment (TGocciaCatchScope, CatchContext.Scope) and return handling
otherwise unchanged; apply the same replacement for the other occurrence
handling the try/finally paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@units/Goccia.ControlFlow.pas`:
- Around line 13-18: TGocciaControlFlow currently holds a TGocciaValue in a
plain record so a Pascal-only reference can be reclaimed by the GC; fix this by
making the record self-managing: add class operator Initialize(var Dest:
TGocciaControlFlow) and Finalize(var Dest: TGocciaControlFlow) and in Finalize
call RemoveTempRoot(Dest.Value) when Dest.Value is non-nil, and in the factory
methods Normal(const AValue: TGocciaValue) and Return(const AValue:
TGocciaValue) call AddTempRoot(AValue) before assigning Result.Value (ensure
Break sets Result.Value to a nil/empty value), so every control-flow instance
roots its payload for its lifetime and releases it when the record is finalized.

In `@units/Goccia.Evaluator.pas`:
- Around line 1691-1739: The pending thrown value (ThrownValue) must be
temp-rooted while the finally block runs to prevent GC collecting it; modify the
try/finally handling in the evaluator so that if HasUnhandledThrow is true you
call AddTempRoot(ThrownValue) immediately before
EvaluateStatements(ATryStatement.FinallyBlock.Nodes, AContext) and ensure
RemoveTempRoot(ThrownValue) is called on every control-flow path after the
finally runs (i.e. before you Exit when FinallyCF.Kind <> cfkNormal and before
re-raising the TGocciaThrowValue at the end); reference ThrownValue,
HasUnhandledThrow, EvaluateStatements, FinallyCF, AddTempRoot and RemoveTempRoot
when locating where to add these calls.
- Around line 1886-1909: The unconditional Continue when CaseClause.Test is nil
causes the default clause to be skipped even if a prior branch matched; change
the logic in the case evaluation loop so DefaultIndex := I is still recorded but
only skip to next iteration when no prior match exists (i.e. only Continue if
not Matched). Locate the loop using symbols CaseClause.Test, DefaultIndex,
Matched, Discriminant, CaseTest, EvaluateExpression and IsStrictEqual and adjust
the early-branch handling so default can participate in fallthrough after a
match while preserving recording of the default index.

In `@units/Goccia.Values.FunctionValue.pas`:
- Around line 224-240: The function body evaluator (in
TGocciaFunctionValue.ExecuteBody where FBodyStatements are iterated and CF :=
Evaluate(...) is used) currently treats cfkBreak by silently Exit which converts
stray break statements into an undefined return; change the cfkBreak branch so
that if a break reaches this function boundary you raise a syntax/runtime error
instead (e.g. raise a SyntaxError/EGocciaSyntaxError with message "Illegal break
statement") rather than exiting; locate the cfkBreak handling in the loop that
checks CF.Kind and replace the Exit with a thrown error so invalid standalone
breaks (evaluated to cfkBreak by TGocciaBreakStatement) are reported.

---

Outside diff comments:
In `@docs/architecture.md`:
- Around line 200-216: Update the evaluator documentation and the Script
Execution diagram to reflect that evaluator routines return TGocciaControlFlow
(which can carry a TGocciaValue) instead of a TGocciaValue-only contract; change
prose that references "returns TGocciaValue" to "returns TGocciaControlFlow" and
mention how TGocciaControlFlow encodes cfkNormal/cfkReturn/cfkBreak plus an
optional value, and update any examples or labels that reference
EvaluateStatements, EvaluateBlock, EvaluateTry, or the Script Execution diagram
to use TGocciaControlFlow semantics consistently.

In `@units/Goccia.Evaluator.pas`:
- Around line 1638-1651: The try/catch/finally bodies currently call
EvaluateStatements with raw .Nodes which bypasses block-scoped handling and
causes let/destructuring to leak; change calls to EvaluateStatements(… .Nodes,
…) to call EvaluateBlock with the Block node itself so block scope is applied:
e.g. replace EvaluateStatements(ATryStatement.CatchBlock.Nodes, CatchContext)
with EvaluateBlock(ATryStatement.CatchBlock, CatchContext) (and similarly for
the main try block and finally block), keeping the existing CatchScope
creation/assignment (TGocciaCatchScope, CatchContext.Scope) and return handling
otherwise unchanged; apply the same replacement for the other occurrence
handling the try/finally paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ecf6c619-b757-4a9d-8683-3cc0cbf52745

📥 Commits

Reviewing files that changed from the base of the PR and between 34d54dd and acd289a.

📒 Files selected for processing (10)
  • AGENTS.md
  • docs/architecture.md
  • docs/code-style.md
  • docs/design-decisions.md
  • units/Goccia.ControlFlow.pas
  • units/Goccia.Evaluator.pas
  • units/Goccia.Interpreter.pas
  • units/Goccia.Values.Error.pas
  • units/Goccia.Values.FunctionValue.Test.pas
  • units/Goccia.Values.FunctionValue.pas

Comment thread units/Goccia.ControlFlow.pas
Comment thread units/Goccia.Evaluator.pas
Comment thread units/Goccia.Evaluator.pas
Comment thread units/Goccia.Values.FunctionValue.pas Outdated
- Temp-root ThrownValue during finally block execution in EvaluateTry
  to prevent GC from collecting the pending throw value
- Throw SyntaxError for illegal break statements that escape to
  ExecuteBody instead of silently returning undefined

Made-with: Cursor
@frostney
Copy link
Copy Markdown
Owner Author

frostney commented Mar 7, 2026

Re: comment 4017629999

Acknowledged. The benchmark regressions are significant and need investigation. The likely cause is the overhead of returning TGocciaControlFlow records (16 bytes) from Evaluate instead of TGocciaValue pointers (8 bytes) — this affects every AST evaluation, not just control flow. The record creation in TGocciaControlFlow.Normal() adds per-expression overhead. This needs profiling to determine whether FPC is inlining the record factory or generating unnecessary memory operations. The correctness improvements (temp-rooting ThrownValue, SyntaxError for illegal break) are in 6281579.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
units/Goccia.Evaluator.pas (1)

1893-1916: ⚠️ Potential issue | 🔴 Critical

default clause does not participate in fallthrough after a prior match.

The unconditional Continue at line 1896 skips the default clause even when Matched is already true from an earlier case. For switch(1) { case 1: ...; default: ... } without a break, the default body is never executed when 1 matches.

♻️ Suggested fix
     if not Assigned(CaseClause.Test) then
     begin
       DefaultIndex := I;
-      Continue;
-    end;
-
-    if not Matched then
+      if not Matched then
+        Continue;
+    end
+    else if not Matched then
     begin
       CaseTest := EvaluateExpression(CaseClause.Test, AContext);
       if IsStrictEqual(Discriminant, CaseTest) then
-        Matched := True;
-    end;
+        Matched := True
+      else
+        Continue;
+    end;
 
     if Matched then
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@units/Goccia.Evaluator.pas` around lines 1893 - 1916, The default clause is
skipped unconditionally because the code sets DefaultIndex and always does
Continue when CaseClause.Test is nil; modify the logic so when CaseClause.Test
is nil you still set DefaultIndex := I but only do Continue if not Matched
(i.e., change the block around CaseClause.Test to: if not
Assigned(CaseClause.Test) then begin DefaultIndex := I; if not Matched then
Continue; end) so that a prior Matched value allows fallthrough into the default
Consequent; keep the rest of the evaluation flow (EvaluateExpression,
IsStrictEqual, Matched, Consequent loop, CF.Kind checks) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@units/Goccia.Evaluator.pas`:
- Around line 1893-1916: The default clause is skipped unconditionally because
the code sets DefaultIndex and always does Continue when CaseClause.Test is nil;
modify the logic so when CaseClause.Test is nil you still set DefaultIndex := I
but only do Continue if not Matched (i.e., change the block around
CaseClause.Test to: if not Assigned(CaseClause.Test) then begin DefaultIndex :=
I; if not Matched then Continue; end) so that a prior Matched value allows
fallthrough into the default Consequent; keep the rest of the evaluation flow
(EvaluateExpression, IsStrictEqual, Matched, Consequent loop, CF.Kind checks)
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6343151b-7769-4cd0-88b9-1c1bc55eb8ab

📥 Commits

Reviewing files that changed from the base of the PR and between acd289a and 6281579.

📒 Files selected for processing (2)
  • units/Goccia.Evaluator.pas
  • units/Goccia.Values.FunctionValue.pas

@cursor
Copy link
Copy Markdown

cursor Bot commented Mar 8, 2026

You have run out of free Bugbot PR reviews for this billing cycle. This will reset on April 11.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
units/Goccia.Evaluator.pas (1)

1403-1404: ⚠️ Potential issue | 🟠 Major

Use ThrowTypeError here, not a raw TGocciaTypeError.

This branch bypasses the normal JS-error path and can surface a Pascal exception instead of a catchable TGocciaThrowValue. EvaluateForAwaitOf already uses ThrowTypeError('Value is not iterable') for the same condition.

♻️ Suggested change
-  if Iterator = nil then
-    raise TGocciaTypeError.Create('Value is not iterable', AForOfStatement.Line, AForOfStatement.Column, '', nil);
+  if Iterator = nil then
+    ThrowTypeError('Value is not iterable');

As per coding guidelines: all error construction must use centralized helpers in Goccia.Values.ErrorHelper.pas.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@units/Goccia.Evaluator.pas` around lines 1403 - 1404, Replace the direct
TGocciaTypeError.Create construction in the branch where Iterator = nil with the
centralized helper ThrowTypeError('Value is not iterable') so the runtime raises
a catchable TGocciaThrowValue instead of a raw Pascal exception; locate the
check around Iterator and AForOfStatement (the current raise
TGocciaTypeError.Create call) and swap it to call ThrowTypeError (as used by
EvaluateForAwaitOf) from Goccia.Values.ErrorHelper.pas to conform to the error
handling guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@units/Goccia.Evaluator.pas`:
- Around line 1428-1430: The loop evaluators currently only handle cfkBreak and
cfkReturn, discarding the normal completion value (CF.Value) so loops end up as
undefined; to fix, after calling EvaluateStatement(AForOfStatement.Body,
IterContext) assign the body's completion to the overall Result (i.e., add
Result := CF) before checking/handling cfkBreak and cfkReturn so normal
completions are preserved; apply this same change in the other for-await-of body
loops as well (locations using EvaluateStatement, AForOfStatement.Body,
IterContext and testing CF.Kind against cfkBreak/cfkReturn).

In `@units/Goccia.Values.FunctionValue.pas`:
- Around line 204-226: The fast-paths for expression bodies and single-return
bodies evaluate and exit before Context.OnError is copied onto the call scope,
causing closures to capture ACallScope.OnError = nil; before doing any early
Exit in the method, ensure you propagate the error handler by setting
ACallScope.OnError := Context.OnError when Assigned(Context.OnError) and not
Assigned(ACallScope.OnError) (same logic currently at the end) — do this
immediately after setting Context.Scope := ACallScope and before calling
EvaluateExpression or returning TGocciaUndefinedLiteralValue so that closures
created during the FIsExpressionBody and TGocciaReturnStatement fast-paths
receive the correct OnError.

---

Outside diff comments:
In `@units/Goccia.Evaluator.pas`:
- Around line 1403-1404: Replace the direct TGocciaTypeError.Create construction
in the branch where Iterator = nil with the centralized helper
ThrowTypeError('Value is not iterable') so the runtime raises a catchable
TGocciaThrowValue instead of a raw Pascal exception; locate the check around
Iterator and AForOfStatement (the current raise TGocciaTypeError.Create call)
and swap it to call ThrowTypeError (as used by EvaluateForAwaitOf) from
Goccia.Values.ErrorHelper.pas to conform to the error handling guidelines.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c47eea1-63ce-4458-8198-18d6055d227a

📥 Commits

Reviewing files that changed from the base of the PR and between 6281579 and 7b37eab.

📒 Files selected for processing (3)
  • units/Goccia.ControlFlow.pas
  • units/Goccia.Evaluator.pas
  • units/Goccia.Values.FunctionValue.pas

Comment thread units/Goccia.Evaluator.pas
Comment thread units/Goccia.Values.FunctionValue.pas Outdated
Move Context.Scope and OnError propagation before the expression-body
and single-return fast paths so closures created during those paths
inherit the error handler correctly.

Made-with: Cursor
@frostney
Copy link
Copy Markdown
Owner Author

frostney commented Mar 8, 2026

Re: comment 4017732474

Acknowledged.

@frostney
Copy link
Copy Markdown
Owner Author

frostney commented Mar 8, 2026

Re: comment 4017629999

Acknowledged. The tagged-pointer optimization (7b37eab) reduced TGocciaControlFlow from 16 bytes to 8 bytes (single register), recovering the regression from the initial refactoring. CI now shows avg +0.3% with 17 improvements and 7 regressions — effectively at parity with the original exception-based approach.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
units/Goccia.Evaluator.pas (1)

1644-1651: ⚠️ Potential issue | 🔴 Critical

Temp-root the caught value before constructing the catch scope.

Line 1646 allocates TGocciaCatchScope before AErrorValue is stored in any GocciaScript scope. At that point the thrown JS value exists only in Pascal code, so a GC during scope creation can drop the catch parameter before the handler runs.

🔒 Proposed fix
 function ExecuteCatchBlock(const ATryStatement: TGocciaTryStatement; const AErrorValue: TGocciaValue; const AContext: TGocciaEvaluationContext): TGocciaControlFlow;
 var
   CatchScope: TGocciaScope;
   CatchContext: TGocciaEvaluationContext;
+  GarbageCollector: TGocciaGarbageCollector;
 begin
   if ATryStatement.CatchParam <> '' then
   begin
-    CatchScope := TGocciaCatchScope.Create(AContext.Scope, ATryStatement.CatchParam);
-    try
-      CatchScope.DefineLexicalBinding(ATryStatement.CatchParam, AErrorValue, dtParameter);
-      CatchContext := AContext;
-      CatchContext.Scope := CatchScope;
-      Result := EvaluateStatements(ATryStatement.CatchBlock.Nodes, CatchContext);
-    finally
-      CatchScope.Free;
-    end;
+    GarbageCollector := TGocciaGarbageCollector.Instance;
+    if Assigned(GarbageCollector) and Assigned(AErrorValue) then
+      GarbageCollector.AddTempRoot(AErrorValue);
+    try
+      CatchScope := TGocciaCatchScope.Create(AContext.Scope, ATryStatement.CatchParam);
+      try
+        CatchScope.DefineLexicalBinding(ATryStatement.CatchParam, AErrorValue, dtParameter);
+        CatchContext := AContext;
+        CatchContext.Scope := CatchScope;
+        Result := EvaluateStatements(ATryStatement.CatchBlock.Nodes, CatchContext);
+      finally
+        CatchScope.Free;
+      end;
+    finally
+      if Assigned(GarbageCollector) and Assigned(AErrorValue) then
+        GarbageCollector.RemoveTempRoot(AErrorValue);
+    end;
   end
   else
     Result := EvaluateStatements(ATryStatement.CatchBlock.Nodes, AContext);
 end;
Based on learnings: Values held only by Pascal code (not in any GocciaScript scope) must be protected with `AddTempRoot`/`RemoveTempRoot` for the duration they are needed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@units/Goccia.Evaluator.pas` around lines 1644 - 1651, The thrown value
AErrorValue must be temp-rooted before creating TGocciaCatchScope to prevent GC
from collecting it; call AddTempRoot(AErrorValue) immediately before
constructing CatchScope (or before any allocation that can trigger GC) and use a
try/finally to call RemoveTempRoot in the finally block after you have moved
AErrorValue into the new scope via CatchScope.DefineLexicalBinding (so the
binding owns the value). Update the block that creates CatchScope / calls
DefineLexicalBinding / EvaluateStatements to surround scope creation and binding
with AddTempRoot/RemoveTempRoot and ensure RemoveTempRoot runs even on
exceptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@units/Goccia.Evaluator.pas`:
- Around line 1644-1651: The thrown value AErrorValue must be temp-rooted before
creating TGocciaCatchScope to prevent GC from collecting it; call
AddTempRoot(AErrorValue) immediately before constructing CatchScope (or before
any allocation that can trigger GC) and use a try/finally to call RemoveTempRoot
in the finally block after you have moved AErrorValue into the new scope via
CatchScope.DefineLexicalBinding (so the binding owns the value). Update the
block that creates CatchScope / calls DefineLexicalBinding / EvaluateStatements
to surround scope creation and binding with AddTempRoot/RemoveTempRoot and
ensure RemoveTempRoot runs even on exceptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 46c8b881-fba3-41fa-8d2c-ca2154fd3f2b

📥 Commits

Reviewing files that changed from the base of the PR and between 7b37eab and 80216ac.

📒 Files selected for processing (2)
  • units/Goccia.Evaluator.pas
  • units/Goccia.Values.FunctionValue.pas

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Refactoring, CI, tooling, cleanup

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant