Record the Chisel/Studio findings, and fix the scoping model (§13.13–13.15) - #176
Merged
Merged
Conversation
…ons 9–10 Building Chisel (a retained-mode widget toolkit) and Studio (a sprite and tilemap editor shell) against Ghost 1.0 was the first time a substantial Ghost *library* — rather than a script — was written, and it surfaced a cluster of defects around scoping, module boundaries and class shape that the standard-library-shaped gap list in §12 never would have. Every behavior recorded here was executed against a build of the interpreter rather than inferred from the specification, and each callout names the code path responsible: - §13.13 assignment never walks the outer chain (Environment.Set), so a function cannot rebind a variable outside itself, silently. - §13.14 a closure made in a loop captures the enclosing environment, which the loop's own save-and-restore then unbinds. - §13.15 only function bodies introduce a scope; §8.3 claims every block does. - §13.16 a module's scope is its export surface, so private helpers and the module's own imports are re-exported. - §13.17 a bare sibling method call resolves but is invoked with the wrong receiver, failing once the callee touches `this`; §8.8 claims it works. - §13.18 a field and a method may share one name; the read path and the call path never meet, so nothing is reported. - §13.19 module resolution scans a process-wide, purely additive search path, making it first-match-wins and import-order dependent. - §13.20 the smaller surprises, including the one stale report from that work (`list.length` now raises properly). §13.13, §13.14 and §13.15 are one design question, so §14 gains decision 9 stating it — assignment walks the chain, or a declaration keyword — with the trade-offs weighed and the call deliberately left open, since it is the one item on this list that changes what existing correct-looking programs mean. Decision 10 records the module-export question the same way. §12 gains the two small arithmetic gaps the same work found: `math.floorDiv` and the missing `%=`. §8.3 and §8.8 now point at the callouts that contradict them, so neither reads as settled while it is not, and decision 5 (no statics) is reaffirmed with the cost it imposes on library shape written down rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016EtZB6qLJfvYbrBdnjoeH2
…3.15)
These three findings were one design question wearing three faces, and §14
decision 9 settles it: assignment walks the enclosing chain, and blocks have
scopes of their own. Fixing any one in isolation would have changed what the
other two meant, so all three land together.
scale = 1
function set(n) { scale = n }
set(4)
scale // was 1; now 4
handlers = []
for (name in ["a", "b"]) {
handlers.push(function () { return name })
}
handlers[0]() // was: name error: `name` is not defined; now "a"
x = 1
if (true) { x = 2; y = 99 }
x // 2 — assignment still reaches the outer x
y // was 99; now: name error: `y` is not defined
Environment gains Assign, which walks the chain exactly as Get does and
rebinds a name wherever it is already bound, built on a rebind helper that
Set now shares so "update in place" exists once rather than twice. The
evaluator's bind is the single point every name-assignment goes through —
plain `=`, both destructuring forms, the compound operators and `++`/`--` —
so every spelling reaches an outer variable the same way.
The scope is introduced by the statements that own a block rather than by
evaluateBlock, because two of its callers must not get one: a class or trait
body is evaluated in the environment collecting its members, and a function
body already runs in its own frame. Both loops bind their control variables
once per iteration, which replaces the save-and-restore dance entirely —
nothing is written to the enclosing scope, so there is nothing to put back.
In the C-style loop the increment runs at the top of each iteration after the
first, against that iteration's own scope: incrementing in the iteration a
closure just captured would move the value out from under it.
One breaking change, called out in §13.15 and §8.3: a name first assigned
inside a branch no longer outlives it, so `if (c) { r = 1 } else { r = 2 }`
followed by a read of `r` now needs `r` assigned before the branch. All 41
programs in examples/ produce byte-identical output before and after.
A scope per block execution is an allocation per loop iteration, which cost
up to 8.5x the bytes and 1.8x the wall time on the loop-heavy benchmarks.
Scope.Enclose/Release keep one finished block scope per environment and hand
it to the next block rather than allocating, and Environment.Capture marks an
environment and the whole chain enclosing it whenever a closure, class or
trait is created inside it, so a captured scope is dropped instead of reused.
Reuse is safe across goroutines by construction: every concurrent entry into
Ghost code runs in a function frame of its own, so a block's environment is a
child of that frame rather than of anything shared. Allocation is back at
parity on every benchmark; wall time is 1.03x-1.21x, the intrinsic cost of
the extra link each name lookup crosses. go test -race passes.
Tested in evaluator/scoping_test.go: TestAssignmentReachesAnEnclosingScope,
TestBlocksIntroduceAScope, TestLoopVariablesDoNotDisturbTheEnclosingScope,
TestClosuresCaptureTheirIteration (which doubles as the correctness test for
the reuse — every case there fails if a captured environment is handed to the
next iteration) and TestMethodScopingIsUnchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EtZB6qLJfvYbrBdnjoeH2
kaidesu
marked this pull request as ready for review
September 1, 2026 02:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Building Chisel (a retained-mode widget toolkit) and Studio (a sprite and tilemap editor shell) was the first time a substantial Ghost library — rather than a script — was written against 1.0. It surfaced a cluster of defects the standard-library-shaped gap list in §12 would never have found, because they are all about scoping, module boundaries and class shape.
Two commits. The first records every finding in §12/§13/§14. The second fixes the three highest-damage ones, which are one design question wearing three faces — §14 decision 9 settles it as assignment walks the enclosing chain, and blocks have scopes of their own.
Every behavior recorded here was executed against a build of the interpreter, not inferred from the specification, and each callout names the code path responsible.
Two findings were cases where the specification already described the behavior we want and the interpreter did not implement it (§13.15 against §8.3, §13.17 against §8.8). §13.15 is now fixed; §13.17 remains open with §8.8 pointing at it.
Changes
Added
Environment.Assign, which walks the enclosing chain exactly asGetdoes and rebinds a name wherever it is already bound, built on arebindhelper thatSetnow shares so "update in place" exists once rather than twice.Scope.Enclose/Scope.ReleaseandEnvironment.Capture, the block-scope reuse that keeps this from allocating per loop iteration.evaluator/scoping_test.go— five tables covering the fix.math.floorDiv(a, b), and the missing%=(+=,-=,*=,/=all work).Fixed
bindis the single point every name-assignment goes through, so plain=, both destructuring forms, the compound operators and++/--all reach an outer variable the same way.evaluateBlock, because two of its callers must not get one: a class or trait body is evaluated in the environment collecting its members, and a function body already runs in its own frame.list.lengthwithout parentheses already raises a properproperty error. The report's "division always promotes to float" claim is likewise corrected —7 / 2is3.5but6 / 3is2; the real gap is the absence of floor division, filed in §12.Changed
this.method()until it is fixed.Related issues
None open for these; this PR is the record.
Additional context
One breaking change, called out in both §13.15 and §8.3: a name first assigned inside a branch no longer outlives it, so
if (c) { r = 1 } else { r = 2 }followed by a read ofrnow needsrassigned before the branch. All 41 programs inexamples/produce byte-identical output before and after, so the pattern is rarer in practice than it looks.The rejected alternative was a declaration keyword (
let/var) — safer semantics, and the one every reader of modern JS already has loaded. It was not chosen because it costs a reversal of §8.3's most prominently documented stance, a new keyword in a language that has kept its surface deliberately small (§4), and a migration for every line of existing Ghost. The known cost of the path taken is recorded rather than glossed: a method-local whose name matches a sibling method rebinds that method, which block scoping contains but does not eliminate.Performance. A scope per block execution is an allocation per loop iteration, which initially cost up to 8.5× the allocated bytes and 1.8× the wall time on the loop-heavy cases in
evaluator/benchmark_test.go. Scope reuse plus capture tracking brings allocation back to parity on every benchmark; wall time is 1.03×–1.21×, the intrinsic cost of the extra link each name lookup crosses. Reuse is safe across goroutines by construction — every concurrent entry into Ghost code (anhttp.handlecallback, an embedder'sCall) runs in a function frame of its own, so a block's environment is a child of that frame rather than of anything shared.Verification:
go build ./...,go test ./...,go test -race ./...,go vet ./...andgofmt -l .all clean; benchmarks and all 41 examples diffed against a baseline build of the parent commit.Still open from this batch, in damage order: §13.16 (module exports everything), §13.17 (bare sibling call loses the receiver — independent of decision 9 and fixable on its own), §13.18, §13.19.