Skip to content

fix(analyzer): resolve push write targets to their root binding for constness (#673) - #715

Merged
logbie merged 5 commits into
mainfrom
fix/673-push-constant-write-target
Aug 14, 2026
Merged

fix(analyzer): resolve push write targets to their root binding for constness (#673)#715
logbie merged 5 commits into
mainfrom
fix/673-push-constant-write-target

Conversation

@logbie

@logbie logbie commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #673.

store new constant ROLES as ["admin"]
push with ROLES and "guest"
display ROLES

printed [admin, guest], exit 0 — no analyzer report, no runtime error.

#671 closed this for the three bare-name mutation statements (add ... to,
remove ... from, clear), which carry their target as a list_name: String
the analyzer resolves directly. push does not — Statement::PushStatement
carries list: Expression, so the constness check had nothing to look up and
the arm merely walked both sub-expressions. At runtime the interpreter mutates
in place through the Rc<RefCell<Vec<Value>>> and never reassigns the binding,
so Environment::assign — the only runtime enforcement point for constness — is
never reached.

Caught at analysis time, exactly as #671 did it. The interpreter is
unchanged.

The change

src/analyzer/mod.rs only — one pure helper plus the PushStatement arm:

fn write_target_root_binding(expression: &Expression) -> Option<&str> {
    match expression {
        Expression::Variable(name, _, _) => Some(name),
        Expression::IndexAccess { collection, .. } => Self::write_target_root_binding(collection),
        Expression::MemberAccess { object, .. } | Expression::PropertyAccess { object, .. } => {
            Self::write_target_root_binding(object)
        }
        _ => None,
    }
}

The root name feeds the existing report_constant_mutation, so the constness
test is still constant_bindings membership and the wording is identical to
what add/remove/clear already report. An unresolvable name yields no
binding key, so there is no double-report alongside analyze_expression's
"not defined".

Deliberately out of scope

The issue documents a separate hole and says explicitly it must not be folded in:

store new constant ROLES as ["admin"]
store alias as ROLES
add "guest" to alias     // mutates ROLES, exit 0

alias is a legitimately mutable binding, so no write-target analysis can
reject it. Closing that means deciding what constant means for reference
values — copy-on-bind, a deep freeze, or an explicit "constants fix the binding,
not the contents". That is a language-design question. The existing docs caveat
covering it is kept verbatim.

Test evidence

  • Risk class: R2 — analyzer diagnostics gate whether a program runs at all
    (wfl <file> exits 3 on an analysis error), so this is public CLI behavior.

  • Acceptance criteria → tests (in tests/constant_mutation_analyzer_test.rs,
    alongside the 9 from Analyzer drops the add ... to CONST report when combined with other constant mutations #671):

    • push onto a constant is reportedpush_onto_a_constant_list_is_rejected
    • Indexed targets report the root bindingpush_onto_an_indexed_constant_target_names_the_root_binding
    • No root binding → unaffected → call-result target, no diagnostic
    • Mutable bindings unaffected → no diagnostic
    • Parameters and loop variables unaffected → no diagnostic (guards the Analyzer drops the add ... to CONST report when combined with other constant mutations #671 shapes)
  • Red evidence: 0518fd9 (test-only, ancestor of the fix). The two positives
    failed, the three negatives passed in both states as intended:

    ---- push_onto_a_constant_list_is_rejected stdout ----
    assertion `left == right` failed: `push with CONST and value` should be reported by the analyzer: []
      left: 0
     right: 1
    
  • Green evidence: 7bb01c9 — 14/14.

  • Unit/component: cargo test --test constant_mutation_analyzer_test → 14
    passed; diagnostics_fixtures_test 3/3.

  • Integration/contract: verified against the release binary — both repros now
    exit 3 with Cannot modify constant 'ROLES' / 'CONFIG', while
    push with items and "b" on a mutable list still exits 0.

  • End-to-end / compatibility: every gated TestPrograms/ and examples/
    program swept through --analyzezero new Cannot modify constant
    reports. No existing test encoded the old behavior, so nothing was weakened.

  • Static: cargo fmt --all -- --check clean; cargo clippy --all-targets --all-features -- -D warnings clean; python scripts/check_repo_hygiene.py --mode static exit 0.

  • Docs: required, because the docs documented the gap and this makes them
    false. Docs/03-language-basics/variables-and-types.md loses the "Known
    gap:
    push with <list> and <value> is not yet checked" callout and now lists
    push with add/remove/clear, with an indexed-target example;
    Docs/06-best-practices/naming-conventions.md loses its matching pointer;
    TestPrograms/docs_examples/basic_syntax/constants_immutable_01.wfl now
    exercises bare and indexed push (10 reports, was 8) with its manifest entry
    updated. python scripts/validate_docs_examples.py → 25/25. Dev diary added.

  • Platforms: authored and run on Windows; CI covers Ubuntu and Windows.

  • Rollback/recovery: revert; no persistent state involved.

  • Residual risk: aliasing (above) still defeats constness for reference
    values — unchanged by this PR, documented, and out of scope by the issue's own
    instruction. Rebased onto 8fb784f after Fix #698, #699, #700: replace, count loop cap, exit program #710 merged; no conflicts.

🤖 Generated with Claude Code


Open in Devin Review

logbie and others added 2 commits August 14, 2026 12:01
`push with <list> and <value>` carries its target as an `Expression`, not the
`list_name: String` the #671 constness check resolves, so pushing onto a
constant list is silently accepted at analysis time and mutated in place at
runtime.

Red: `push_onto_a_constant_list_is_rejected` and
`push_onto_an_indexed_constant_target_names_the_root_binding` both fail with
zero reports. The three negative tests (no root binding, mutable target,
action parameter / loop variable) already pass and guard against the fix
widening into shapes that must stay legal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onstness (#673)

`push with <list> and <value>` carries its target as an `Expression`, not the
`list_name: String` the #671 constness check resolves, so pushing onto a
constant list escaped analysis entirely. There is no runtime backstop either:
the interpreter mutates in place through the list's `Rc<RefCell<Vec<Value>>>`
and never reassigns the binding, so `Environment::assign` — the only runtime
enforcement point for constness — is never reached.

Add `Analyzer::write_target_root_binding`, which resolves a write-target
expression to the binding the write ultimately reaches: a `Variable` resolves
to itself, and index/member chains resolve to whatever they bottom out in, so
`CONFIG[0]`, `CONFIG[0][1]`, and `CONFIG.entries` all resolve to `CONFIG`. A
target rooted in a computed value (a call result, a literal) resolves to
`None` and reports nothing. The `PushStatement` arm feeds that root name to
the existing `report_constant_mutation`, so `push` now emits exactly the
message `add`/`remove`/`clear` already emit.

The constness test itself is unchanged — still `constant_bindings` membership,
not `mutable: false` — so action and container-method parameters, loop
variables, and REPL parent-scope bindings stay unaffected.

Analyzer-only: the interpreter, parser, and typechecker are untouched.
Aliasing (`store alias as CONST` then mutating `alias`) remains out of scope
per the issue, and the docs still state that limitation.

Risk class R2 (analysis errors gate execution; `wfl <file>` now exits 3 on
these programs). Sweeping every gated `TestPrograms/` and `examples/` program
through `--analyze` with the release binary produced zero new reports.

Docs ship with the fix: the "Known gap" callout in variables-and-types.md and
the matching pointer in naming-conventions.md are now false and removed;
`push` is listed with the other rejected forms, the indexed-target case is
documented, and the docs error-example exercises both push forms (25/25
examples validate). Dev Diary entry added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 14, 2026 17:02
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@logbie, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 802c0c1e-e726-4b16-94e2-01a17792d653

📥 Commits

Reviewing files that changed from the base of the PR and between d571bd9 and 011431d.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • Docs/03-language-basics/variables-and-types.md
  • Docs/06-best-practices/naming-conventions.md
  • History/dev-diary/2026/2026-08-14-issue-673-push-write-target-constness.md
  • TestPrograms/docs_examples/_meta/manifest.json
  • TestPrograms/docs_examples/basic_syntax/constants_immutable_01.wfl
  • src/analyzer/mod.rs
  • tests/constant_mutation_analyzer_test.rs

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR closes #673 by extending the analyzer’s constant-mutation checks to push with <list> and <value> by resolving the push target expression back to its root binding name (e.g., CONFIG[0]CONFIG) and reusing the existing Cannot modify constant '<name>' diagnostic path. It also updates tests and documentation/examples to reflect that push is now enforced for constant lists.

Changes:

  • Add analyzer support for resolving push write targets to their root binding and reporting constant mutations.
  • Add regression tests covering constant, indexed, no-root-binding, mutable, and parameter/loop-variable push targets.
  • Update docs, docs examples, and dev diary to remove the previously documented “push constness gap” and demonstrate the new behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/analyzer/mod.rs Adds write_target_root_binding and applies constant-mutation reporting to PushStatement.
tests/constant_mutation_analyzer_test.rs Adds targeted analyzer tests for push constness behavior and non-regressions.
Docs/03-language-basics/variables-and-types.md Updates constants documentation to include push and adds an indexed-target example.
Docs/06-best-practices/naming-conventions.md Updates constant mutation forms list to include push.
TestPrograms/docs_examples/basic_syntax/constants_immutable_01.wfl Extends the constants error-example to include bare and indexed push failures.
TestPrograms/docs_examples/_meta/manifest.json Updates doc purpose text to reflect push and indexed push coverage.
History/dev-diary/2026/2026-08-14-issue-673-push-write-target-constness.md Adds dev diary entry documenting the bug, fix, and test evidence.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/analyzer/mod.rs
Comment on lines +2431 to +2434
if let Some(root) = Self::write_target_root_binding(list) {
let root = root.to_string();
self.report_constant_mutation(&root, *line, *column);
}

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread src/analyzer/mod.rs
Comment on lines +4773 to +4784
fn write_target_root_binding(expression: &Expression) -> Option<&str> {
match expression {
Expression::Variable(name, _, _) => Some(name),
Expression::IndexAccess { collection, .. } => {
Self::write_target_root_binding(collection)
}
Expression::MemberAccess { object, .. } | Expression::PropertyAccess { object, .. } => {
Self::write_target_root_binding(object)
}
_ => None,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Constness enforcement for push is purely syntactic — element writes reached via a loop variable still pass

The new walk resolves CONFIG[0]/CONFIG.entries back to the root name, so a lexical write through a constant is rejected. But the same runtime mutation reached through a binding that is not the constant name still slips through, e.g.

store new constant ROLE_GROUPS as [["admin"]]
for each group in ROLE_GROUPS:
    push with group and "guest"   // accepted, mutates ROLE_GROUPS at run time
end for

Loop variables are only inserted into action_parameters (src/analyzer/mod.rs:1274-1275, src/analyzer/mod.rs:1352-1353), not the scope, so get_symbol_binding_key returns None in report_constant_mutation (src/analyzer/mod.rs:4786-4797) and nothing is reported. This is the same aliasing class the PR explicitly leaves out of scope, but the doc example added at Docs/03-language-basics/variables-and-types.md:319-326 now asserts "a push into an element of a constant … is rejected too" right next to "constants fix the binding, not the contents reached through it", which reads as a stronger guarantee than what is actually enforced. Worth confirming the docs wording is acceptable to the maintainer.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/analyzer/mod.rs
Comment on lines +2418 to 2435
Statement::PushStatement {
list,
value,
line,
column,
} => {
self.analyze_expression(list);
self.analyze_expression(value);
// #673: unlike `add`/`remove`/`clear`, this statement carries its
// target as an expression rather than a bare name, so resolve the
// binding the write actually reaches before the constness check.
// A target with no root binding (a call result, a literal) has
// nothing to check and reports nothing.
if let Some(root) = Self::write_target_root_binding(list) {
let root = root.to_string();
self.report_constant_mutation(&root, *line, *column);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Behavior change gates previously-running programs at analysis time with no CHANGELOG entry

Programs that pushed onto a constant list previously ran to completion and now exit 3 at analysis. GOVERNANCE.md §3.1 asks for a documented path plus a CHANGELOG.md entry for semantic breaks; CHANGELOG.md has no entry for this (nor for the #671 precedent, and the preceding merged PR #710 likewise did not touch it), so this appears to match existing practice rather than a deliberate omission. Flagging so a maintainer can decide whether an Unreleased → Fixed line is wanted.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7bb01c9b8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/analyzer/mod.rs Outdated
Comment on lines +2431 to +2433
if let Some(root) = Self::write_target_root_binding(list) {
let root = root.to_string();
self.report_constant_mutation(&root, *line, *column);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep push behavior during the deprecation window

For any existing program that uses push with CONST and value, this unconditional analyzer diagnostic changes the documented behavior from successful execution to exit code 3. The previous user guide explicitly documented that this form succeeded, so sweeping only repository-owned examples cannot establish downstream compatibility; retain the old behavior while introducing the required documented deprecation and migration path.

AGENTS.md reference: AGENTS.md:L21-L21

Useful? React with 👍 / 👎.

Comment on lines +88 to +91
Risk class **R2** — analyzer diagnostics gate whether a program runs at all
(`wfl <file>` exits 3 on an analysis error), so this is public CLI/contract
behavior, and pushing onto a constant list moves from "silently allowed" to
"rejected at analysis time".

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reclassify the compatibility change as R3

This section acknowledges that previously accepted WFL programs will now be rejected by the public CLI, making this a backward-compatibility change rather than R2. Reclassify it as R3 and provide the corresponding failure-path, compatibility, and release-relevant evidence before treating the change as merge-ready.

AGENTS.md reference: AGENTS.md:L138-L141

Useful? React with 👍 / 👎.

@logbie

logbie commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review findings — all three addressed

@copilot — fixed. The root.to_string() was a needless allocation on every push; report_constant_mutation takes &str and the slice outlives the call. Removed, borrow-check clean.

@devin-ai-integration on the docs wording — you are right, and this was the most valuable finding here. I verified your example rather than taking it on trust:

store new constant ROLE_GROUPS as [["admin"]]
for each group in ROLE_GROUPS:
    push with group and "guest"
end for
display ROLE_GROUPS
[[admin, guest]]
exit 0

So the prose I added did overclaim. This repo's docs policy is explicit that documentation describes what actually ships and must not overclaim runtime behavior, so that wording was a bug I was shipping alongside the fix. Corrected two ways:

  • The element-push paragraph now says the write is rejected when spelled through the constant's own name, rather than implying element writes are rejected generally.
  • The caveat below now states that these checks are made by reading the code, and names the loop-variable path explicitly with the verified example — not just the alias case.

The enforcement itself is unchanged and stays in scope: #673 asks for write-target resolution, and the aliasing class is a language-design question (what constant means for reference values) that the issue says explicitly must not be folded in.

@devin-ai-integration on the CHANGELOG — agreed, added. GOVERNANCE.md:95 does ask for it and you were right that this is a semantic break: a program that pushed onto a constant now exits 3 at analysis instead of running. Added under [Unreleased] → Fixed, stating the compatibility impact, what is unaffected (no-root-binding targets, mutable bindings, parameters, loop variables), and the alias/loop-variable limit.

You were also right that #671 and #710 have no entries. I have not backfilled those — that is the maintainer's call, not something to slip into this PR.

Copilot AI review requested due to automatic review settings August 14, 2026 17:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 14, 2026 18:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 14, 2026 18:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Docs/03-language-basics/variables-and-types.md:332

  • The alias example in this note references ALLOWED_ROLES, but the code sample immediately below uses ROLE_GROUPS, which can be confusing when reading the section top-to-bottom. Consider using the same constant name in both places so the alias and loop-variable caveats read as one coherent example.
> Constants fix the *binding*, not the contents reached through it. These checks
> are made by reading the code, so they catch writes spelled through the
> constant's name. A write that reaches the same list through a *different*
> name is not caught, and does change the underlying list — whether that name
> comes from an alias (`store alias as ALLOWED_ROLES`) or from a loop variable:

@logbie
logbie merged commit 62bc736 into main Aug 14, 2026
20 checks passed
@logbie
logbie deleted the fix/673-push-constant-write-target branch August 14, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

push with CONST and value mutates a constant list — expression write-targets escape the constness check

2 participants