The gap
assignonce gives assign-once bindings, but the macro layer has no way to produce one — the let constructs build a plain env. So macro-enabled code cannot use the discipline at all, and a user who wants it has to drop out of the macro layer.
env.finalize() is not a substitute. The two guarantees are orthogonal:
finalize() freezes the set of names — no additions, no deletions — while existing bindings stay rebindable.
assignonce fixes each binding's first value, while the set of names stays open.
Proposed decomposition
Two halves, with quite different cost profiles, and they are separable.
1. Reachability (the actual gap). Let env carry the assign-once discipline, so the let constructs can produce one. This is a runtime change; the macro layer needs no new intelligence, since it already builds the env. Independently useful and low risk.
2. Static rejection (the rackety part). Have the expander raise SyntaxError on an assignment to an already-bound name in an assign-once env, where that is statically detectable — so the macro-layer compiler rejects invalid code rather than deferring to a runtime error.
On the second half
Worth doing only if it pays for its complexity, and it is not obvious that it does.
The easy case is already within reach: letdoutil has isenvassign and UnexpandedEnvAssignView, so detecting two assignments to the same name within one lexical block is a local syntactic check.
What escapes it: assignment inside a loop or conditional, an env passed to a helper function, and any aliasing. Since the check can only ever be partial, runtime enforcement has to stay regardless — so the static half buys earlier detection for the mistakes least likely to survive a first test run, at the price of permanent macro-layer complexity.
There is also a design-philosophy pull. Racket can reject statically because its binding forms are the language; here an env is a runtime object that can be passed around, so the analysis is necessarily incomplete. And doc/design-notes.md says to minimize macro magic — if a feature can be implemented in the pure-Python layer, that is where it belongs.
Suggested order
Do (1), keep runtime enforcement, and revisit (2) once something real is using the feature and we know which mistakes actually occur.
— Claude (Opus 5)
The gap
assignoncegives assign-once bindings, but the macro layer has no way to produce one — theletconstructs build a plainenv. So macro-enabled code cannot use the discipline at all, and a user who wants it has to drop out of the macro layer.env.finalize()is not a substitute. The two guarantees are orthogonal:finalize()freezes the set of names — no additions, no deletions — while existing bindings stay rebindable.assignoncefixes each binding's first value, while the set of names stays open.Proposed decomposition
Two halves, with quite different cost profiles, and they are separable.
1. Reachability (the actual gap). Let
envcarry the assign-once discipline, so theletconstructs can produce one. This is a runtime change; the macro layer needs no new intelligence, since it already builds the env. Independently useful and low risk.2. Static rejection (the rackety part). Have the expander raise
SyntaxErroron an assignment to an already-bound name in an assign-once env, where that is statically detectable — so the macro-layer compiler rejects invalid code rather than deferring to a runtime error.On the second half
Worth doing only if it pays for its complexity, and it is not obvious that it does.
The easy case is already within reach:
letdoutilhasisenvassignandUnexpandedEnvAssignView, so detecting two assignments to the same name within one lexical block is a local syntactic check.What escapes it: assignment inside a loop or conditional, an env passed to a helper function, and any aliasing. Since the check can only ever be partial, runtime enforcement has to stay regardless — so the static half buys earlier detection for the mistakes least likely to survive a first test run, at the price of permanent macro-layer complexity.
There is also a design-philosophy pull. Racket can reject statically because its binding forms are the language; here an
envis a runtime object that can be passed around, so the analysis is necessarily incomplete. Anddoc/design-notes.mdsays to minimize macro magic — if a feature can be implemented in the pure-Python layer, that is where it belongs.Suggested order
Do (1), keep runtime enforcement, and revisit (2) once something real is using the feature and we know which mistakes actually occur.
— Claude (Opus 5)