You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #671, split out because it needs a different fix.
#671 closed the constness hole for the three bare-name mutation statements — add ... to, remove ... from, and clear — which carry their target as a list_name: String the analyzer can resolve directly. push does not: Statement::PushStatement carries list: Expression (src/parser/ast.rs:453), so the same check has nothing to look up, and a constant list can still be mutated.
Repro
store new constant ROLES as ["admin"]
push with ROLES and "guest"
display ROLES
[admin, guest]
exit 0
No analyzer report, no runtime error. Verified against the #671 fix on branch claude/issue-671-8ufs93 — that change does not affect this path.
Nested targets go the same way:
store new constant CONFIG as [["a"]]
push with CONFIG[0] and "b"
display CONFIG // [[a, b]] exit 0
Why it escapes both layers
Analyzer — the PushStatement arm only walks its two sub-expressions; there is no write-target concept for an expression:
Interpreter — push evaluates the target to a Value::List and mutates through the Rc<RefCell<Vec<Value>>> in place (src/interpreter/mod.rs:8942). The binding is never reassigned, so Environment::assign — the only place the constant flag is enforced at runtime — is never reached. This is the same in-place-mutation escape #671 described for add/remove/clear on constant lists; there, catching it during analysis was enough because the target was a bare name.
What a fix needs
Resolving a write target through an expression, at minimum for the Expression::Variable and index/member chains that bottom out in one:
push with ROLES and x → root binding ROLES
push with CONFIG[0] and x → root binding CONFIG
push with (some_call with y) and x → no root binding, nothing to report
The analyzer already tracks declared constants precisely in constant_bindings (added in #671), so the missing piece is the root-binding walk, not the constness test.
Related but distinct: aliasing defeats constness entirely
Worth recording here since it bounds how much a push fix buys, but it is a separate problem and should not be folded into this one:
store new constant ROLES as ["admin"]
store alias as ROLES
add "guest" to alias
display ROLES // [admin, guest] exit 0
alias is a legitimately mutable binding, so no write-target analysis can reject this. It shares the underlying Rc with the constant. Closing it means deciding what constant means for reference values — copy-on-bind, a deep freeze flag on the value, or an explicit documented "constants fix the binding, not the contents". That is a language-design question, not a missing check.
Acceptance criteria
push with <constant list> and <value> reports Cannot modify constant '<name>' during semantic analysis.
Indexed and member targets rooted at a constant (push with CONST[0] and x) report the same.
Push targets with no root binding (a call result, a literal) are unaffected.
Follow-up to #671, split out because it needs a different fix.
#671 closed the constness hole for the three bare-name mutation statements —
add ... to,remove ... from, andclear— which carry their target as alist_name: Stringthe analyzer can resolve directly.pushdoes not:Statement::PushStatementcarrieslist: Expression(src/parser/ast.rs:453), so the same check has nothing to look up, and a constant list can still be mutated.Repro
No analyzer report, no runtime error. Verified against the #671 fix on branch
claude/issue-671-8ufs93— that change does not affect this path.Nested targets go the same way:
Why it escapes both layers
Analyzer — the
PushStatementarm only walks its two sub-expressions; there is no write-target concept for an expression:Interpreter — push evaluates the target to a
Value::Listand mutates through theRc<RefCell<Vec<Value>>>in place (src/interpreter/mod.rs:8942). The binding is never reassigned, soEnvironment::assign— the only place the constant flag is enforced at runtime — is never reached. This is the same in-place-mutation escape #671 described foradd/remove/clearon constant lists; there, catching it during analysis was enough because the target was a bare name.What a fix needs
Resolving a write target through an expression, at minimum for the
Expression::Variableand index/member chains that bottom out in one:push with ROLES and x→ root bindingROLESpush with CONFIG[0] and x→ root bindingCONFIGpush with (some_call with y) and x→ no root binding, nothing to reportThe analyzer already tracks declared constants precisely in
constant_bindings(added in #671), so the missing piece is the root-binding walk, not the constness test.Related but distinct: aliasing defeats constness entirely
Worth recording here since it bounds how much a
pushfix buys, but it is a separate problem and should not be folded into this one:aliasis a legitimately mutable binding, so no write-target analysis can reject this. It shares the underlyingRcwith the constant. Closing it means deciding whatconstantmeans for reference values — copy-on-bind, a deep freeze flag on the value, or an explicit documented "constants fix the binding, not the contents". That is a language-design question, not a missing check.Acceptance criteria
push with <constant list> and <value>reportsCannot modify constant '<name>'during semantic analysis.push with CONST[0] and x) report the same.add ... to CONSTreport when combined with other constant mutations #671 negative tests intests/constant_mutation_analyzer_test.rscover the shape these must not regress into.