feat(rust): encode write!/writeln! through the std sink functions (closes #630, advances #491) - #698
Conversation
…d text sink (advances #630) Slice 5 of the W12-B design record (`.claude/briefs/W12-B.md`): the test-only half. Every assertion here fails on this commit, against the encoder's own refusal ``unsupported macro invocation `write!` ``. `rust/encoder/tests/write_sinks.rs` (17 cases) covers every destination shape the measured corpus contains plus the rules the design makes normative: - a `&mut fmt::Formatter` parameter — 22 of the corpus' 25 invocations — and the borrowed `&mut f` spelling reaching the same sink; - an UNANNOTATED closure parameter (`|f| write!(f, "-")`), the shape 6 of the 7 first-blocked Tier A files use, which no type inference could resolve; - the text argument reusing `format!`'s existing lowering unchanged; - the `Ok(..)` outcome wrap, so `write!(..)?` and `.unwrap()` see the shape `encode_try_operator`/`encode_unwrap` already expect; - `writeln!(f, "x")` = `write!` + `"\n"`, and `writeln!(f)` = just `"\n"` — `core`'s own definition; - the JOIN-SITES rule: a provably-local `String` is RE-ASSIGNED, never turned into a sink, so its non-sink reads in the same function still see a `String`; every `String` constructor initialiser is recognised; a closure parameter shadows a same-named enclosing local; - a local of some other type is a LOUD refusal naming the local and why; - `write!()` with no destination is a loud refusal; - `String::new()`/`String::with_capacity(n)` encode as the empty string (the enabler the local-`String` arm needs — both are currently in the encoder's "unsupported call target" bucket, so a `let mut s = String::new()` cannot be encoded at all today); - compile-back: the encoded program compiles to a library `cargo build` accepts and that calls `ball_sink_write` (#636); - end to end: a local-`String` `write!` program compiles and RUNS, printing the expected bytes. `documented_gaps.rs`'s macro pin gains its positive goalpost for the closed `write!` bucket, per that file's "closed gaps keep their test, flipped" rule. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…oses #630, advances #491) GREEN for the RED commit before it. Slices 5 and 6 of the design record `.claude/briefs/W12-B.md`, approved by the owner on 2026-09-13; PR #636 landed slices 1-4 (the three declarations, the tagged reference-semantic sink on every engine/compiler/runtime, fixture `466_string_sink`). `write!` needs NO type information, and that is the design. `core` defines it as `($dst:expr, $($arg:tt)*) => { $dst.write_fmt($crate::format_args!($($arg)*)) }` — the destination is a method RECEIVER, so the first argument IS the sink by construction. rustc special-cases only `format_args!` (rust-lang/rust#106745) and rust-analyzer makes the same split. That matters concretely: 6 of the 7 first-blocked Tier A files write through an UNANNOTATED closure parameter (`|f| write!(f, "-")`), which no inference could resolve. `methods.rs::encode_write_macro` therefore classifies by syntax alone: * not a local binding (parameter, field, closure param, call result) -> `std.sink_write{sink, text}`; * a bare name bound by a `let` whose initialiser is a `String` constructor -> a re-assignment `s = std.concat(s, text)`. This is the JOIN-SITES rule: in `itertools::join` the same local is also read AS a `String`, so an opaque sink would silently change those reads; * a bare name bound by a `let` of any other shape -> a LOUD refusal naming the local and its initialiser. Guessing either way is a behaviour change. `writeln!` is `write!` + a `"\n"` part, exactly how `core` spells its own no-argument arm. Both arms are wrapped in the unified `Ok(..)` outcome, because `write!` evaluates to a `fmt::Result` that 22 of the 25 corpus sites consume with `?` or `.unwrap()`; `?` on a non-outcome value is a silent-degradation seed. `build_format_expr` is extracted to `build_format_args(&[&syn::Expr], extra_newline)` so the text is built by the very lowering `println!`/`format!` already use — a pure extraction, no behaviour change. Two supporting changes, both load-bearing: * `Encoder::local_scopes` — a binding-frame stack, one frame per fn / closure / `impl` method / default-bodied trait method, seeded with that body's parameters, filled with its `let`s, looked up innermost-first so a closure parameter shadows a same-named enclosing local. Deliberately SEPARATE from `push_fn_scope`, which records parameters only for a 2+-parameter body and is not pushed at all for an `impl` method — either would leave a parameter looking like a local, and a parameter misread as a local `String` is exactly the silent miscompile the frame prevents. * `String::new()` / `String::with_capacity(n)` encode as the empty string. Both were in the encoder's "unsupported call target" bucket, so a `let mut s = String::new();` could not be encoded at all and the local-String arm would have been unreachable. Capacity is an allocation hint with no observable effect and Ball has no allocation model to carry it into; every other `Type::assoc()` on a foreign type stays the documented gap it was. No new dependency: `syn` + `quote` + `ball-lang-shared`, unchanged. `write!` is a builtin, so none of #629's `macro_rules!` machinery is involved. Measured (slice 6), re-run on the merged tree because the Tier A methodology lane changed the scored set — 77 scored, 34 excluded (test-only), not the 110 the #491 histograms are written against. One binary per side, same 5 pins: stage before after 1 encoded 1/77 7/77 2 compiled back 1/77 7/77 3 re-encoded 1/77 1/77 4 declarations kept 0/77 0/77 5 fixpoint (clean) 0/77 0/77 `tools/coverage-study/baseline.json`'s Rust row is raised on `encoded` ONLY. `clean` does not move and is not promised: the remaining walls for those 7 are issue #632 (the compiler's method dispatcher emits a `panic!` its own encoder refuses) and declaration drift. Stage 3 does not move for a second pre-existing reason: every `MessageCreation` — which the `Ok(..)` outcome is, and which a plain `Ok(x)` in hand-written source always has been — compiles to `{ let mut __ball_map = BallMap::new(); ... }`, and `BallMap::new()` is an associated function on a foreign type the encoder documents as a permanent gap. Same round-trip-closure class as #632; flagged, not folded in. Docs: `rust/AGENTS.md` gains a "`write!`/`writeln!` and the declared text sink" section with the measured funnel, and its stale "`write!` is the measured next-highest-yield target" prose is marked closed; `.claude/rules/rust.md` gains the encoder rule and has its gap list and Tier A baseline corrected. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
) Issue #630's first DoD item is a design record, reviewed before any encoder code. The record was written and reviewed, but it lived only in the lane's untracked `.claude/briefs/W12-B.md` — so the four references this work added (`.claude/rules/rust.md`, `rust/AGENTS.md`, `rust/encoder/src/methods.rs`, `rust/encoder/tests/write_sinks.rs`) all pointed at a path that does not exist in the repository, and neither #636's nor this PR's readers could reach it. `git grep claude/briefs origin/main` finds nothing: this branch introduced the first such reference. `docs/SINK_DESIGN.md` is that record, carrying the durable half — the normative runtime contract (a `__type__ = "std:Sink"`-tagged, REFERENCE-semantic map, with the required backing per target and why a by-value backing fails silently), the `std`-not-`std_io` placement and its `ball audit` reason, how all seven source languages' sink constructs map onto the same three declarations, the Rust encoder's syntax-only `write!`/`writeln!` classification with its join-sites rule and loud refusal, the rejected options (notably sink-as-`List<String>`, an issue #488-class silent API change), and the live citations each claim rests on. The lane-local half (worktree paths, the slice plan, per-commit tallies) is deliberately left out: status belongs in issues, per docs/AGENTS.md. The four references are re-pointed at it and `docs/AGENTS.md`'s index gains a row, so the file is discoverable rather than only linkable. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…the instrument behind the Tier A numbers (advances #630) Two gaps a reader of this work would otherwise have to rediscover. **The boundary.** A local `String` handed to another function that writes into it (`let mut s = String::new(); helper(&mut s);`) splits across the two arms: the caller's `s` encodes as a Ball `String`, `helper`'s parameter as a sink, so the encoded program passes a string where `std.sink_write` expects a sink. That is the one shape the syntax-only rule cannot see, and the record now says so — together with the reason it is safe to leave: every engine and runtime proves the value is a tagged sink before touching it and panics naming the function (`dart/engine/lib/engine_std.dart::_stdSinkBacking`, `rust/shared/src/runtime.rs::sink_backing`), so the mismatch is LOUD, never a discarded write. It does not occur in the Tier A corpus, and closing it needs cross-function knowledge of how a local is used — an `encode_crate` resolution question, not a `write!` question. **The instrument.** `rust/AGENTS.md`'s funnel table gave measured numbers with no way to re-derive them. It now names the run that produced them: a `Coverage Study` dispatch on this branch, whose `Tier A (Rust)` job runs the exact `rq1-study` invocation `coverage-study.yml` pins and whose `publish` job checks the raised row against `tools/coverage-study/baseline.json` — so the "after" column is the repo's own gate output, and the "before" column is the committed baseline row it was floored against, not a hand-run local build. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…advances #630) `Encoder::local_scopes` opens ONE frame per fn / closure / `impl` method body, and `record_local` writes into it from anywhere inside that body — so a `let` in a nested block survives its own closing brace. That is not Rust's scoping rule, and it is wrong in both directions for the `write!` destination rule this PR adds: - silently: an inner `let f = String::from(..)` that shadows a `&mut fmt::Formatter` parameter `f` leaves `f` looking like a local `String` AFTER the block, so a later `write!(f, ..)` encodes as a re-assignment of a binding that is not in scope instead of `std.sink_write` on the parameter — a miscompile with nothing to observe it; - loudly: an inner `let out = 1;` that shadows a sink parameter `out` makes a later `write!(out, ..)` a refused "local whose initialiser is not a `String` constructor" — a false refusal of an ordinary sink. Two cases, one per direction, both valid Rust. They fail on this commit. Deliberately NOT covered, and recorded as a known boundary rather than guessed at: a PATTERN binding (a for-loop variable, a match-arm or `if let` binding) that shadows an enclosing local `String`. That one has no obvious right answer — `for s in writers.iter_mut()` binds a genuine `&mut String`, which is BOTH a sink and a string — so it is a design question, not a scoping bug. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…630) `tools/coverage-study/baseline.json` already carries the raised Rust row (`encoded: 7`), but `README.md`'s published table still showed the pre-#630 `1`, because `coverage-study.yml`'s `publish` job only commits the regenerated table on `main`. The two halves of the same ratchet disagreeing is the state that makes a published number untrustworthy, so the row lands with the change that earned it. Regenerated, not hand-edited — by the workflow's own renderer, over the artifacts of the `Coverage Study` dispatch on this branch (run 34787000468): python3 tools/coverage-study/coverage_table.py \ --artifacts <the run's seven downloaded reports> \ --baseline tools/coverage-study/baseline.json \ --excluded-list tools/coverage-study/excluded.json \ --readme README.md --write which reports `Rows checked: 8, breaches: 0` and `baseline already current` — that second phrase is the proof the committed `encoded: 7` is EXACTLY the measured value, not a floor set above or below it. The run's own `publish` job reaches the same state and prints the identical one-line README diff under "Report what a main run would have committed". Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…ames (advances #630) `Encoder::local_scopes` opened one frame per fn / closure / `impl` method body, so a `let` in a NESTED block survived its own closing brace — not Rust's rule, and wrong in both directions for the `write!` destination classification: - silently, an inner `let f = String::from(..)` shadowing a `&mut fmt::Formatter` parameter `f` left `f` looking like a local `String` after the block, and a later `write!(f, ..)` encoded as a re-assignment of an out-of-scope binding instead of `std.sink_write` on the parameter; - loudly, an inner `let out = 1;` shadowing a sink parameter made a later `write!(out, ..)` a refused "local whose initialiser is not a `String` constructor". `block.rs::encode_block` now opens a frame of its own and pops it around the statements. Frames nest and lookup stays innermost-first, so an enclosing body's bindings are still visible — which is exactly what a block, unlike a `fn` item, may see — while its own `let`s stop leaking out. The two RED cases from the previous commit pass; nothing else moves, because every existing frame push is unchanged and a block frame is simply a narrower place for the same records. Recorded in the design record, the rule file and `rust/AGENTS.md`, together with the boundary this deliberately does NOT close: a PATTERN binding (a for-loop variable, a `match`-arm or `if let` binding) that shadows an enclosing local `String`. That has no obvious right answer — `for s in writers.iter_mut()` binds a genuine `&mut String`, which is both a sink and a string — so it is the same representation question the join-sites rule answers for `let`, left open rather than guessed at. `docs/SINK_DESIGN.md` §5 also now names the other shape a syntax-only rule cannot see (a `String` reaching a `write!` as a field, or as a local handed to a callee) and why it is loud on every target rather than silent. `tests/conformance/COVERAGE_STUDY.md`'s "Rust stops at stage 1" bullet is corrected in the same pass: with `write!` routed, 7 of 77 scored files now reach stage 1 and six of them stage 3, so "every scored file is an encode-error" is no longer true of that row. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…d (advances #630) Two one-line corrections found reading the merged result. `.claude/rules/rust.md`'s documented-gaps bullet said `write!`/`writeln!` are closed "— next bullet", but the bullet that describes the rule is several bullets further down that section, so the pointer sent a reader to the wrong place. It now names the bullet. `docs/TESTING_STRATEGY.md` §5b's sink row states the contract and lists the per-target tests but never named where the representation decision is written down. It now points at `SINK_DESIGN.md`, so the row and the record are reachable from each other. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…advances #630) The funnel table's provenance link pointed at the `Coverage Study` dispatch made before the block-scoping fix landed. A measurement taken at an earlier commit is not evidence for the tree being merged, however unlikely the fix was to move it — the whole point of naming the instrument is that a reader can re-derive the number from the code in front of them. It now names the dispatch made at this branch's final head, whose `publish` job floors the same row against `tools/coverage-study/baseline.json`. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
|
verdict: PASS Independent fresh-context review (round 1) of PR #698 / issue #630. I did not write this branch. Evidence below is quoted from the runs and the diff, not from the lane report. 1. RED -> GREEN -> matrixRED, scoping slice (observed, clean). Run 34787803644, Exactly the two new cases, one per direction (silent miscompile / false loud refusal). RED, first slice (sound, but inferred rather than observed — see finding A). Run 34768498846 fails in
GREEN at head. Run 34788688388: Matrix. Run 34788688427, green on every row it ran — the engine rows all at Dart parity ( 2. The fix is real and complete against #630's DoD
3. HygieneNo Findings (all advisory, none blocking)A. The first RED run does not show the 17 B. Nothing tests a C. The committed README block is one row behind the run it cites, for D. Pre-existing, not caused here. Every No blocking issues. The open concerns the author raised (#632 blocking stage 3 for six of the seven, the |
Five conflicts, every one of them resolved as a UNION of two additive changes — #685 (the Rust compiler↔encoder round trip) and #642's `&mut` alias table landed in the same files this branch's `write!` work touches: * `rust/encoder/src/block.rs` — `encode_block` keeps BOTH scopes: the binding frame this branch opens per `{ .. }` (issue #630) and the saved/restored `ref_aliases` table (#642). `encode_local` records the local AND drops any shadowed alias, both after the initialiser is encoded. * `rust/encoder/src/lib.rs` — the `Encoder` struct carries `local_scopes`, `ref_aliases` and `alias_scopes`; `push_fn_scope`/`pop_fn_scope` push and pop both stacks. * `rust/encoder/src/methods.rs` — the macro dispatcher maps `println!`/`format!`/`vec!`/`panic!`/`unreachable!`/`write!`/`writeln!`, and the single refusal message names all seven. * `rust/encoder/tests/documented_gaps.rs` — one doc comment for the `assert!` pin, saying why each of the two arm families was added (#632 for `panic!`/`unreachable!`, #630 for `write!`/`writeln!`) and that `matches!` stays pinned (#712). * `tools/coverage-study/baseline.json` — the Rust Tier A row keeps every floor #685 added and raises only `encoded`/`compiledBack` to this branch's measured 7/7. No floor is above a measured value. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…tions (advances #630) RED on purpose: `a_write_through_a_mut_alias_re_assigns_the_local_string_it_borrows` fails on this commit (`test result: FAILED. 20 passed; 1 failed` in `rust/encoder/tests/write_sinks.rs`). Merging `origin/main` brought issue #642's `&mut` alias table into the same encoder this branch's `write!` destination rule lives in, and the two do not agree about the same name. `let slot = &mut result;` is an ALIAS binding: `block.rs::encode_local` records it and emits no `let` at all (Ball has no references), and every later READ of `slot` resolves back to `result` in `lib.rs::encode_path_expr`. `classify_write_destination` does not do that resolution: it looks `slot` up in `local_scopes`, finds nothing — an alias is deliberately never recorded there — and reads that absence as "not a local". * Borrowing a local `String` (`let mut result = String::new(); let slot = &mut result;`) therefore encodes `write!(slot, ..)` as `std.sink_write` against a plain `String`. Every engine and runtime proves a sink is the tagged sink value before touching it (`rust/shared/src/runtime.rs::sink_backing`, `dart/engine/lib/engine_std.dart::_stdSinkBacking`), so the program still fails LOUD — but at run time, for a question the encoder answered at encode time. `write!(&mut result, ..)`, the same write with no intermediate binding, already takes the re-assignment arm today. * Borrowing a sink PARAMETER is the other direction of the same question. It passes already — the sink arm encodes the destination through `encode_path_expr`, which resolves the alias — and is pinned here so the fix cannot buy one direction by breaking the other. Neither shape occurs in the Tier A corpus; both are ordinary Rust, and the pair is what the fix commit is measured against. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…nation (advances #630) GREEN: `rust/encoder/tests/write_sinks.rs` is `test result: ok. 21 passed; 0 failed` — the case the previous commit left RED now passes, and the other direction it pinned still passes. `classify_write_destination` now resolves a bare destination name through `Encoder::ref_aliases` before looking it up in the binding frames, which is what `lib.rs::encode_path_expr` already does for every other read of an alias (issue #642). One line of behaviour, in the one place the two rules meet: * `let mut result = String::new(); let slot = &mut result; write!(slot, ..)` now takes the local-`String` arm and re-assigns **`result`** — the borrowed variable, which is the only binding the encoded program has, since an alias `let` emits no binding at all. It previously took the sink arm and handed `std.sink_write` a plain string. * `let out = &mut f;` over a sink parameter still encodes `std.sink_write` on `f`. That direction already worked (the sink arm encodes the destination through `encode_path_expr`), and its test is what keeps this change from buying one direction with the other. An alias of a local that is NOT a `String` reaches the same loud refusal the local itself would, naming the borrowed variable and its initialiser — the guess is still refused, just no longer missed. Recorded where the rule lives: `docs/SINK_DESIGN.md` §5 (a third supporting mechanism beside `local_scopes` and the `String::new()` enabler), `rust/AGENTS.md`, and the doc comment on `classify_write_destination` itself. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
Clean merge (#669's Dart compiler fix and its conformance fixture 472 — no overlap with this branch's Rust encoder payload, and no fixture-number collision: this branch adds none). Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…ion rule in the rust rule file (advances #630) `.claude/rules/rust.md`'s `write!` bullet lists the rule's supporting mechanisms; it was the one of the three references (with `rust/AGENTS.md` and `docs/SINK_DESIGN.md` §5) that had not gained the `&mut` alias resolution the previous commit added. Same sentence, same claim, so the three cannot drift. Generated-By: claude-code (model: claude-opus-5[1m]; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
|
verdict: PASS sha: 237e907 Model: Opus 5 (1M context) — Independent fresh-context review (round 2). I did not write this PR. Everything below was 1. RED → GREEN, at the current headRED, the alias interaction — — RED, the block-scoping bug — GREEN, pre-merge — Head Conformance matrix — 34791491613 The Rust round-trip's 2. Is the fix real and complete per the issue?I tried to name a symptom with no failing-without-the-fix test and could not.
Floors sit at measured values. The Coverage Study dispatch 3. Hygiene
Advisory (none blocking)
Merge is blocked by #718, not by this PR. 🤖 Generated with Claude Code |
Resolves the one conflict in `rust/encoder/src/lib.rs` as a UNION, and adapts this branch's one caller to main's widened alias type. `origin/main` (#729, issue #693) generalised `Encoder::ref_aliases` from `HashMap<String, String>` to `HashMap<String, AliasTarget>`: a `&mut` borrow of a plain NAMED variable is `AliasTarget::Variable` (no binding emitted, reads and writes resolve to the borrowed variable), while a borrow of a place this encoder cannot name (`&mut p.x`, `&mut v[0]`) is `AliasTarget::Opaque` — the binding IS emitted, and a WRITE through it is refused loudly rather than landing on the copy. The conflict is textual only: the two hunks are this branch's `local_scopes` field (issue #630) and main's rewritten `ref_aliases` doc comment, and both are kept. `methods.rs::classify_write_destination` is the one place the two rules meet. It now resolves only the MODELLED half — `AliasTarget::Variable` — exactly as `lib.rs::encode_path_expr` does. An `Opaque` alias deliberately falls through with its own name, which `block.rs::encode_local` DID record as a local whose initialiser is that borrow, so the existing lookup reaches the loud refusal naming both. That is the same answer `encode_assign` gives a plain write through such an alias, and silently taking the sink arm there would write into the copy the binding emits. `cargo test -p ball-lang-encoder --no-fail-fast` on the merged tree is GREEN in every binary — `write_sinks` `ok. 21 passed; 0 failed`, plus main's new `mut_borrow_writes` suite and `compile_reencode_roundtrip`. Issue #718's `compiled_method_dispatcher_re_encodes`, which was red on main and therefore on this branch at its previous head, is fixed on main and green here. Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…mpl Display` shape #630 names (advances #630) RED, test-only (`rust/encoder/tests/write_sinks.rs` is the only file touched): `test result: FAILED. 23 passed; 4 failed`. The four are exactly the four shadowing cases below; the two other new cases pass, and are pins on behaviour that already holds. Round 2's independent review raised the pattern-binding hole as advisory (#698 (comment) — "a pattern binding that shadows an enclosing local `String` mis-encodes silently"). It is the one remaining SILENT direction in issue #630's `write!` rule, so it is pinned and fixed rather than carried: * `record_local`'s only call site is `block.rs`'s `let` handling, so a for-loop variable, a `match`-arm binding and an `if let` binding are never recorded anywhere. The classifier therefore falls through to whatever ENCLOSING binding wears the same name — and an enclosing local `String` is the one kind that does not fail loud. `let mut s = String::new(); for s in writers.iter_mut() { write!(s, "x")?; }` encodes as a re-assignment of the OUTER `s`: every write the Rust aims at an element is lost, and nothing anywhere reports it. Measured here as `all calls: ["std.for_in", "std.assign", "std.concat", …]` with zero `std.sink_write`. * the same fall-through reaches a `&mut` ALIAS of the shadowed name (`let slot = &mut result; for slot in writers.iter_mut() { write!(slot, ..) }`), because the alias table is consulted for every read and a pattern binding never removed the entry — so the loop's own `slot` resolved to `result`. Three of the four are the three pattern-binding constructs (for / `if let` / `match`), which reach the rule through different call sites in `control_flow.rs`; the fourth is the alias direction. Two cases that already pass are added in the same commit, deliberately: * `a_for_loop_variable_that_shadows_nothing_is_still_a_sink` — the positive half. It is what makes the fix's choice falsifiable: a pattern binding must classify as a SINK (the arm every non-`let` destination takes), not as a loud refusal, because iterating real sinks and writing into each is an ordinary working shape. Without this case, "make pattern bindings refuse" would also pass. * `write_inside_an_impl_display_targets_the_formatter` — round 1's advisory B: nothing tested a `write!` inside an `impl` block, which is the shape #630's body names ("inside `impl Display` targets the formatter") and the shape 6 of the 7 newly-encoding Tier A files have. It pins that `types.rs::encode_item_impl`'s binding frame carries the formatter parameter — an `impl` method pushes no fn scope at all — and that the method's own local `String` does not become the destination. Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…rite!` through one stops silently re-assigning an enclosing local (closes #630) GREEN: `rust/encoder/tests/write_sinks.rs` is `test result: ok. 27 passed; 0 failed` — the four cases the previous commit left RED now pass, and the two it added as pins on existing behaviour still do. `cargo test -p ball-lang-encoder --no-fail-fast` is green in every binary (29/9/14/12/5/20/12/2/14/2/2/2/8/2/2/27), `cargo fmt -p ball-lang-encoder -- --check` and `cargo clippy -p ball-lang-encoder --all-targets -- -D warnings` both clean. `Encoder::with_pattern_binding` is a binding frame for the three constructs that introduce a name WITHOUT a `let` — a for-loop variable, a `match`-arm binding, an `if let` binding — applied at the four call sites in `control_flow.rs` that encode a body under such a name. `record_local`'s only call site is `block.rs`'s `let` handling, so before this a pattern binding was not merely unknown but INVISIBLE: the innermost-first lookup walked past it to whatever enclosing binding wore the same name. Both directions were wrong, and the first one silently: * `let mut s = String::new(); for s in writers.iter_mut() { write!(s, "x")?; }` encoded as a re-assignment of the OUTER `s` — every write the Rust aims at an element lost, nothing anywhere reporting it; * the same fall-through reached a `&mut` ALIAS of the shadowed name (`let slot = &mut result; for slot in writers.iter_mut() { … }`), because the alias table is consulted for every READ and a pattern binding never removed the entry, so the loop's own `slot` resolved to `result`. The frame drops the alias for its duration and restores it after, exactly as a plain `let` of that name does in `encode_local` — which fixes the read direction too, not only the `write!` destination. A pattern binding classifies as a **sink** (`LocalKind::PatternBinding`, a member of its own so the two can never be confused), like a parameter — not as a loud refusal: * arm (a) is not expressible for one. `s = std.concat(s, text)` would write to the loop variable, and whether that reaches the collection is not something Ball models. * `for w in writers.iter_mut() { write!(w, ..)?; }` over real sinks is an ordinary working shape and must keep encoding. Refusing every pattern binding would have regressed it, which is why `a_for_loop_variable_that_shadows_nothing_is_still_a_sink` ships alongside: without that case, "refuse" would have passed the other four. * when the element IS a plain `String`, the write lands in the boundary `docs/SINK_DESIGN.md` §5 already documents for a `String` field — a string where `std.sink_write` expects a sink, which every engine and runtime rejects LOUDLY at run time (`rust/shared/src/runtime.rs::sink_backing`). Docs, at the altitude each one owns: `docs/SINK_DESIGN.md` §5 replaces the "open representation question" paragraph with the decided rule and its tests; `rust/AGENTS.md` and `.claude/rules/rust.md` gain the same in their `write!` sections (plus the `AliasTarget::Opaque` half of the alias resolution, which main's #693 introduced under this branch). **Three stale wall references corrected in the same pass** (round 2's advisory 2): `tests/conformance/COVERAGE_STUDY.md`, `rust/AGENTS.md` and `.claude/rules/rust.md` each named issue #632 as the stage-3 wall for the six newly-encoding Tier A files. #685 closed #632 on `main` while this PR was open; the last measured wall is ``unsupported runtime helper `ball_arg_get(...)` ``, which is issue #692 — the same compiler↔encoder round-trip class, one construct further along. Each correction says to re-measure rather than quote, because this wall moved twice in a week. Closes #630: the design record (DoD 1) is `docs/SINK_DESIGN.md`, the full std chain (DoD 2) landed in #636, and DoD 3's test-first work plus the re-measurement are this PR. Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
No conflicts: main's only new commit since this branch's last merge is #688 (`fix(audit)`), which touches the Dart audit path, the `ball.proto` `CallSite` message and the committed artifacts regenerated with it — nothing under `rust/encoder/`. Merged so the re-measurement and the final CI run are taken on a head that contains every main commit, per the repo's merge-before-push rule.
|
verdict: PASS Independent fresh-context review, round 1. I did not write this PR. Every claim below was re-derived from the diff, the CI logs and the repo at this head — not from the PR body. 1. RED → GREEN, CI-observed
2. The fix is real and complete against each issue's body#630 DoD, item by item.
The owner's 2026-09-13 decision on #630 ( Every symptom has a test that fails without the fix. I tried to name one that does not and could not, in the blocking sense: the four pattern-shadowing directions each failed in 35530751652; the two block-scoping directions each failed in 34787803644; the Decisions derived from the source of truth, not asserted. The whole rule rests on Scoping is complete, not partial. I checked every site that encodes a body: item fn and closure via Every floor sits at a measured value. 3. HygieneNo Advisory (non-blocking — do not hold the merge for these)
Not merged, not labelled. 🤖 Generated with Claude Code |
`main` moved again while the green cycle ran — `ba0a20f0` (#751, Python encoder inverse pins) and `b8ae4e6c` (#698, the Rust `write!`/`writeln!` sink encoder). Re-checked against it rather than assumed, the way round 2's blocking finding requires: * **No fixture joined the corpus**, so no parity line moves: the corpus is still 364 fixtures / 360 goldens and `bash tools/check_conformance_doc_counts.sh` exits 0 on the merge result, checking the same 39 occurrences. * **Nothing `main` brought in feeds a committed generated artifact.** The merge touches `rust/encoder/**`, `python/encoder/tests`, the changed-stack detector and docs — no `dart/engine/lib`, no `dart/shared/lib`, no `proto/`, so `compiled_engine.{ts,go}`, `compiled_cli.{ts,go}`, `std.json`/`std.bin` and `cpp/shared/ball_protobuf_rt.h` are untouched by it and stay fresh. * **Clean merge** — no conflicts, so nothing had to be resolved and no generated file was merged textually. Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
Resolves the one conflict, in `rust/encoder/src/block.rs`: #698 split `encode_block` into a locals-frame push/pop around a new `encode_block_statements`, while this branch added #692's message-builder short-circuit at the top of `encode_block`. Both are kept, with the frame opening AHEAD of the short-circuit — the builder idiom declares its `let mut` map inside this block whether or not the whole idiom matches, so a fall-through must not have skipped the scope. Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
…f types, and pin `.finish()` as a permanent carve-out GREEN half of the RED commit before it. Two of issue #767's six stage-1 Tier A gaps close, one is pinned permanently, and one turns out to have closed already. Tuple expressions (6 of the 77 scored files). `lib.rs::encode_expr` had no `syn::Expr::Tuple` arm, so one tuple aborted the whole file. `encode_tuple` lowers `(a, b)` to `std.record` — the universal base function std.json declares for a positional record — with components named "0"/"1", and `()` to the Ball null literal. The names are Rust's own member spelling, not Dart's $1/$2: `encode_field` already renders a `t.0` read that way and `encode_item_struct` declares a tuple STRUCT's fields the same way, and `p.0` on a tuple struct is syntactically indistinguishable from `t.0` on a tuple — so $1 would have forced re-spelling the tuple-struct fields too, a separate representation decision. Portable either way: every target treats a component name that is neither $N nor argN as an opaque key on BOTH the `record` build side and the `field_access` read side (cpp/compiler's "record" arm; dart/engine's _stdRecord). Reference `impl` self types (4 of that bucket's 8). `type_short_name` now looks through `&T`/`&mut T`/`&'a T` to the referent. Ball has no reference-vs-value distinction at all — `encode_expr` has always encoded `&x` as `x` — so `impl Trait for &Counter` names the same Ball class as `impl Trait for Counter`, and nothing is guessed from a name. Tuple and array self types stay refused. `.finish()` (9 files, the single largest bucket) is a PERMANENT carve-out, named in methods.rs's carve-out list: the debug builder's output is each field rendered through that field's own Debug impl, which std.to_string is not — an arm would emit a program that runs and prints the wrong text. `write!` (7 files) was already closed by #698 and is absent from the re-measured histogram; the issue's list was stale when filed. Measured over the same five pins, one binary, one set of checkouts. Before reproduced baseline.json exactly (7/7/1/0, excluded 34). After: stage 1 `encoded` 7/77 -> 9/77, `compiled back` 7 -> 9, histogram exactly conserved at 77 (`tuple` 6 -> 0, `impl` self type 8 -> 4, every one of those ten files landing on a further gap). `reencoded` stays 1 and baseline.json is NOT raised on it: both newly-arriving files stop at stage 3 on `ball_arg_get`, tracked as #790. excluded.json is untouched by design — it records test-only files, and a path it lists that a run SCORED is a breach (#676). A carve-out is not a test-only file. Advances #767 Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9
… enum variants, non-plain impl Self, non-identifier bindings) dam Tier A round-trip at 1/77, untracked (advances #767) (#832) * test(rust encoder): RED tests + carve-out pins for #767's stage-1 Tier A gaps Issue #767 enumerates six encoder gaps that dam the Rust Tier A funnel at stage 1, from run 34769384905. Re-measured locally over the same five pins: `write!` (7 files) is already CLOSED by #698, and the rest are unchanged. RED (fail on main): - tuple_expressions.rs — `(a, b)`, a nested tuple and `()`. 6 of the 77 scored files first-block here, the largest implementable bucket. Panics today with "unsupported Rust expression kind `tuple`". - impl_for_reference_self_type.rs — `impl Trait for &Counter` / `&mut I`, 4 of the 8 files in the `impl` self-type bucket. Panics today with "unsupported `impl` self type". Pins of record (green by construction, per documented_gaps.rs's own rule that a gate nothing observes is a missing-test bug): - the_fmt_debug_builder_chain_is_a_permanent_carve_out — `.finish()`, the single largest bucket at 9 files, all of them `core::fmt`'s debug builder. - impl_for_an_array_self_type_is_a_documented_gap — the `[T; M]` half of the self-type boundary, previously unobserved beside the tuple half. Advances #767 Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9 * feat(rust encoder): encode tuple expressions and reference `impl` self types, and pin `.finish()` as a permanent carve-out GREEN half of the RED commit before it. Two of issue #767's six stage-1 Tier A gaps close, one is pinned permanently, and one turns out to have closed already. Tuple expressions (6 of the 77 scored files). `lib.rs::encode_expr` had no `syn::Expr::Tuple` arm, so one tuple aborted the whole file. `encode_tuple` lowers `(a, b)` to `std.record` — the universal base function std.json declares for a positional record — with components named "0"/"1", and `()` to the Ball null literal. The names are Rust's own member spelling, not Dart's $1/$2: `encode_field` already renders a `t.0` read that way and `encode_item_struct` declares a tuple STRUCT's fields the same way, and `p.0` on a tuple struct is syntactically indistinguishable from `t.0` on a tuple — so $1 would have forced re-spelling the tuple-struct fields too, a separate representation decision. Portable either way: every target treats a component name that is neither $N nor argN as an opaque key on BOTH the `record` build side and the `field_access` read side (cpp/compiler's "record" arm; dart/engine's _stdRecord). Reference `impl` self types (4 of that bucket's 8). `type_short_name` now looks through `&T`/`&mut T`/`&'a T` to the referent. Ball has no reference-vs-value distinction at all — `encode_expr` has always encoded `&x` as `x` — so `impl Trait for &Counter` names the same Ball class as `impl Trait for Counter`, and nothing is guessed from a name. Tuple and array self types stay refused. `.finish()` (9 files, the single largest bucket) is a PERMANENT carve-out, named in methods.rs's carve-out list: the debug builder's output is each field rendered through that field's own Debug impl, which std.to_string is not — an arm would emit a program that runs and prints the wrong text. `write!` (7 files) was already closed by #698 and is absent from the re-measured histogram; the issue's list was stale when filed. Measured over the same five pins, one binary, one set of checkouts. Before reproduced baseline.json exactly (7/7/1/0, excluded 34). After: stage 1 `encoded` 7/77 -> 9/77, `compiled back` 7 -> 9, histogram exactly conserved at 77 (`tuple` 6 -> 0, `impl` self type 8 -> 4, every one of those ten files landing on a further gap). `reencoded` stays 1 and baseline.json is NOT raised on it: both newly-arriving files stop at stage 3 on `ball_arg_get`, tracked as #790. excluded.json is untouched by design — it records test-only files, and a path it lists that a run SCORED is a breach (#676). A carve-out is not a test-only file. Advances #767 Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9 * docs(rust): retire the stale "stage 1 is the dam" framing now that stage 3 is The Rust Tier A headline in rust/AGENTS.md still read `1/77 encoded` (pre-#630), and both it and .claude/rules/rust.md still described stage 1 as the dam. After #630 took stage 1 to 7/77 and this branch to 9/77, every file that reaches stage 3 stops on `ball_arg_get` (#790) — so the dam has moved, and a lane reading either doc would have worked the wrong end of the funnel. Also records WHY #767's own list went stale (`write!` closed between the run it was read from and the issue being filed): read a funnel from the artifact, never from prose. Advances #767 Generated-By: claude-code (model: claude-opus-5; operator: ahmednfwela@digrum.com) Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9 --------- Signed-off-by: Ahmed Fwela <ahmednfwela@digrum.com> Co-authored-by: CI <ahmednfwela@digrum.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #630. Advances #491.
PR 2 of 2 for issue #630. PR 1 (#636, merged
b67a7ea1) landed slices 1–4 of the design record: the three declarationsstd.sink_create/sink_write/sink_to_string, the tagged reference-semantic sink on every engine, compiler andruntime, and conformance fixture
466_string_sink. This PR is the Rust encoder payload thatfinally produces those calls from real Rust source, the re-measurement, and the design record itself.
Owner decision (2026-09-13 on #630): approved — add
sink_create/sink_write/sink_to_stringto the universal
stdmodule … plus the syntax-only Rust rule (the first argument ofwrite!is thesink).
The design record now lives in the repo —
docs/SINK_DESIGN.mdIssue #630's first DoD item is a design record, reviewed before any encoder code. That record was
written and reviewed, but it lived only in the lane's untracked
.claude/briefs/W12-B.md— so thefour references this work added (
.claude/rules/rust.md,rust/AGENTS.md,rust/encoder/src/methods.rs,rust/encoder/tests/write_sinks.rs) each pointed at a path that doesnot exist in the repository.
git grep claude/briefs origin/mainfinds nothing; this branchintroduced the first such reference, and #636's body links the same dead path.
docs/SINK_DESIGN.mdis that record, carrying the durable half: the normative runtime contract, thestd-not-std_ioplacement and itsball auditreason, the per-target backing table with why aby-value backing fails silently, how all seven source languages' sink constructs map onto the same
three declarations, the Rust encoder rule below, the rejected options (notably sink-as-
List<String>,an issue #488-class silent public-API change), the known boundary of a syntax-only rule, and the live
citations each claim rests on. The lane-local half (worktree paths, the slice plan, per-commit
tallies) is deliberately left out — status belongs in issues, per
docs/AGENTS.md, whose index gainsa row for the new file.
What was wrong
rust/encoder/src/methods.rs::encode_macromapped exactly three macros —println!,format!,vec!— and refused everything else.write!andwriteln!were the measured largest remainingmacro bucket: 25 invocations across 14 of the scored Tier A files, and the FIRST blocker of 7 of
them. (The issue body says 9; the reproducible count at the design lane's base was 7 first-blocked /
14 on the critical path — reported here rather than quietly matched.)
The design, and why it needs no type information
core's own definition is— the destination is a method receiver, so the macro is not sink-aware at all and the first
argument is the sink by construction. rustc special-cases only
format_args!(rust-lang/rust#106745 moved it into the AST), and rust-analyzer makes the identical split:
format_args/format_args_nlare builtin expanders,write!/writeln!go through the ordinarymacro_rules!path incore.That matters concretely: 6 of the 7 first-blocked files write through an unannotated closure
parameter (
|f| write!(f, "-")inheck). Any design needing to infer the destination's type isdead on arrival for them. This one consults none.
Three closed cases, chosen by syntax alone (
docs/SINK_DESIGN.md§5):std.sink_write{sink, text}letwhose initialiser is aStringconstructorstd.assign{target: s, value: std.concat(s, text), op: "="}letof any other shapeArm (a) is the "join sites" rule: in
itertools::jointhe sameresultis also read as aString(returned, length-tested), so turning it into an opaque sink would silently change every one of those
reads. Guessing either way would be a silent behaviour change, so the third row refuses.
writeln!iswrite!plus a"\n"part —coreitself spells its no-argument arm as literallywrite!($dst, "\n"). Both arms are wrapped in the encoder's unifiedOk(..)outcome message, becausewrite!evaluates to afmt::Resultand 22 of the 25 corpus sites consume it with?or.unwrap();?applied to a non-outcome value is a silent-degradation seed.No new dependency.
rust/encoder's dependency set is unchanged (syn+quote+ball-lang-shared) — zero new build time, zero new CI surface. There is no macro expansion here atall:
write!is a builtin, so none of #629'smacro_rules!machinery is involved.One enabler, stated plainly
String::new()andString::with_capacity(n)were both in the encoder's "unsupported call target"bucket, so a
let mut s = String::new();could not be encoded at all — arm (a) would have beenunreachable. They now encode as the empty string. Capacity is an allocation hint with no observable
effect (docs), and
Ball has no allocation model to carry it into. Every other
Type::assoc()on a foreign type stays thedocumented gap it was.
Local-binding scoping
The classification needs to know what a bare name binds to, which the encoder did not track.
Encoder::local_scopesis a stack of frames — one per fn / closure /implmethod / default-bodiedtrait method body, seeded with that body's parameters, and one per
{ .. }block (see the nextsection) — filled with its
lets as they are encoded(recorded after the initialiser, so
let s = s;still reads the outers); lookup isinnermost-first, so a closure's own
f, or a nested block's ownf, shadows a same-named enclosinglocal. It is kept separate
from the existing
push_fn_scope: that one records parameters only for a 2+-parameter body (itsinput-aliasing rule) and animplmethod pushes no fn scope at all — either would leave a parameterlooking like a local, and a parameter misread as a local
Stringis exactly the silent miscompile theframe exists to prevent.
A scoping bug in that machinery, found in review and fixed RED-first
local_scopesopened one frame per fn/closure/method body, andrecord_localwrote into itfrom anywhere inside that body — so a
letin a nested block survived its own closing brace. Thatis not Rust's rule, and it was wrong in both directions:
let f = String::from(..)shadowing a&mut fmt::Formatterparameterfleft
flooking like a localStringafter the block, so a laterwrite!(f, ..)encoded as are-assignment of a binding that is not in scope instead of
std.sink_writeon the parameter;let out = 1;shadowing a sink parameteroutmade a laterwrite!(out, ..)a refused "local whose initialiser is not aStringconstructor".Fixed where the rule actually lives:
block.rs::encode_blocknow opens a frame of its own and popsit, so frames nest and lookup stays innermost-first. Two cases, one per direction, both valid Rust,
RED on their own commit.
Deliberately not folded in, and recorded in
docs/SINK_DESIGN.md§5 instead: a patternbinding (a for-loop variable, a
match-arm orif letbinding) that shadows an enclosing localString. That one has no obvious right answer —for s in writers.iter_mut()binds a genuine&mut String, which is both a sink and a string — so it is the same representation questionthe join-sites rule answers for
let, not a scoping bug.A second interaction, found after merging
origin/mainand fixed RED-firstorigin/mainmoved this branch's own files while it was open: #646 gave the encoder a&mutALIAStable (
Encoder::ref_aliases) and #685 gave itpanic!/unreachable!arms. The merge is a UNIONin all five conflicted files — no side dropped, and the merge commit says which hunk went where.
One of those two features meets this one head-on.
let slot = &mut result;is an alias binding:issue #642's rule records it and emits no
letat all (Ball has no references, so binding it asa value would turn every write through it into a write to a copy), and every later READ of
slotresolves back to
resultinlib.rs::encode_path_expr. Awrite!destination is a read like anyother — but
classify_write_destinationwas looking the bare name up in the binding frames withoutthat resolution, and an alias is deliberately never recorded there. The absence read as "not a
local":
let mut result = String::new(); let slot = &mut result; write!(slot, ..)encoded asstd.sink_writeagainst a plainString. That still fails LOUD — every engine and runtime provesthe value is the tagged sink before touching it — but at RUN time, for a question the encoder
could answer at encode time;
write!(&mut result, ..), the same write without the intermediatebinding, already took the re-assignment arm.
the destination through
encode_path_expr.Both directions are now pinned, RED on their own commit (
c89aa446:FAILED. 20 passed; 1 failed)and GREEN on the fix (
eb783ac5:ok. 21 passed; 0 failed), which is one line — resolve the namethrough
ref_aliasesfirst, exactly as every other read does. An alias of a local that is not aStringreaches the same loud refusal the local itself would. Recorded indocs/SINK_DESIGN.md§5(a third supporting mechanism),
rust/AGENTS.mdand.claude/rules/rust.md.A third interaction, raised by review round 2 and fixed RED-first: a PATTERN binding
Round 2's independent review raised the last silent direction the rule still had, as an advisory
(comment). It is fixed here
rather than carried, because every other unclear destination in this design already refuses loudly
and this one did not:
record_local's only call site isblock.rs'slethandling, so a name introduced by a pattern— a for-loop variable, a
match-arm binding, anif letbinding — was never recorded anywhere. Leftunrecorded such a name is not merely unknown but invisible: the innermost-first lookup walks
straight past it to whatever ENCLOSING binding wears the same name, and an enclosing local
Stringisthe one kind that does not fail loud.
let mut s = String::new(); for s in writers.iter_mut() { write!(s, "x")?; }encoded as are-assignment of the OUTER
s— every write the Rust aims at an element lost, and nothing anywherereporting it;
&mutALIAS of the shadowed name(
let slot = &mut result; for slot in writers.iter_mut() { … }), because the alias table isconsulted for every READ and a pattern binding never removed the entry.
Encoder::with_pattern_bindingis the frame for those three constructs, applied at the fourcontrol_flow.rscall sites that encode a body under such a name; it also drops a same-named&mutalias for its duration and restores it after, exactly as a plain
letof that name does inencode_local— which fixes the READ direction too, not only thewrite!destination.A pattern binding classifies as a sink (
LocalKind::PatternBinding, a member of its own so thetwo can never be confused), like a parameter, and not as a refusal: arm (a) is not expressible for one
(
s = concat(s, ..)would write to the loop variable, and whether that reaches the collection is notsomething Ball models), and
for w in writers.iter_mut() { write!(w, ..)?; }over real sinks is anordinary working shape that must keep encoding. When the element really is a plain
String, the writelands in the documented boundary below and fails LOUD at run time. That choice is falsifiable:
a_for_loop_variable_that_shadows_nothing_is_still_a_sinkships alongside the four shadowing cases,so "refuse every pattern binding" would have passed the other four and failed this one.
RED on its own test-only commit (
573f30d7,rust/encoder/tests/write_sinks.rsthe only filetouched) — CI 35530751652's
Rustjob failed with
test result: FAILED. 23 passed; 4 failed, the four being exactly the threepattern constructs plus the alias direction — and GREEN on the fix (
ef599571:test result: ok. 27 passed; 0 failed, quoted from run 35531059566'sRust → Testlog). That REDrun's other jobs read
cancelledbecause the GREEN push superseded it; theRustjob itself ran toa real failure first, which is the evidence.
The same slice also carries review round 2's advisory 2 — three docs still naming issue #632 as
the stage-3 wall, which #685 closed on
mainwhile this PR was open — and pinswrite!inside animpl fmt::Display, the shape #630's body names and the one round 1 flagged as untested(
write_inside_an_impl_display_targets_the_formatter). That shape is what the six newly-encodingheckfiles actually are.Known boundary, and why it is safe
A
Stringthat reaches awrite!as anything other than a locallettakes the sink arm — a localhanded to another function that writes into it (
let mut s = String::new(); helper(&mut s);), aStringfield (write!(self.out, ..)), or an element bound by a pattern (above). Either way theencoded program passes a string where
std.sink_writeexpects a sink. That mismatch is loud onevery target: each engine and runtime proves the value is a tagged sink before touching it
(
dart/engine/lib/engine_std.dart::_stdSinkBacking,rust/shared/src/runtime.rs::sink_backing— bothpanic naming the function). None of those shapes occurs in the corpus, and closing them needs
knowledge of how the destination is used elsewhere — an
encode_crateresolution question, not awrite!question. Recorded indocs/SINK_DESIGN.md§5 rather than guessed at.Why the current tests did not catch it
rust/encoder/tests/'s suites are all single-filefn mainprograms built from the shared conformancecorpus, which is single-file-main-only by construction — no fixture in it has ever contained a
write!, becausedart/encoder's corpus generator emits Dart.documented_gaps.rspinned the macrogap with
assert!, notwrite!, so the bucket that actually mattered had a live gate and no testobserving it. The only instrument that saw it was the Tier A study, which is not a PR gate. The RED
commits supply the missing inputs: 27 cases — every destination shape the corpus contains, the
writeln!newline rule, the join-sites rule, the closure-, block- and pattern-shadowing traps, bothalias directions, the
impl Displayshape, both loud refusals, a realcargo buildof thecompiled-back library, and an end-to-end compile-and-run whose stdout is asserted literally.
Per-target backing +
std.type_ofproofThe sink value itself landed in #636; this PR emits calls against it and its compile-back leg exercises
the Rust backing end to end. Recording the table the contract is normative on, with the test that
proves each half:
type_of⇒"Sink"proven by__type__-tagged map, portable Dartdart/engine/test/engine_test.dartsink cases + fixture466_string_sink466_string_sinkts-engine/ts-compilermatrix rows on466BallValue::Map=Arc<Mutex<IndexMap>>rust/shared/src/runtime.rs::sink_is_a_tagged_reference_value,rust/compiler/tests/string_sink.rs, and this PR'sa_sink_write_compiles_back_into_a_real_rust_libraryBallMap(reference type)csharpjob's sink tests +csharp-enginerowballrt.Mapgojob's sink tests +go-enginerowdictpythonjob's sink tests +python-enginerowcpp-engine/cpp-compiledrows on466Reference semantics — an append inside a callee visible to the caller — is the half that fails
silently when a target gets it wrong (the shape of #300), which is why fixture
466_string_sinkwrites across a function boundary.
Measurement (slice 6), re-measured at THIS head
Measured with the repo's own instrument, not a hand-run local build: a
Coverage Studydispatch onthis branch, whose
Tier A (Rust)job runs the exactrq1-studyinvocationcoverage-study.ymlpins, and whose
publishjob floors every row againsttools/coverage-study/baseline.json— so ameasured value below a committed floor fails the run rather than being asserted in prose. Two
dispatches bracket this round: 35531063478
at
ef599571(the pattern-binding fix) and 35545441446at
fec6023e(that plus the finalorigin/mainmerge). Both measure the same funnel.The Tier A methodology lane changed the scored set out from under the design record's numbers: the
Rust row is scored 77, excluded (test-only) 34, not the 110 every #491 histogram is written
against.
ef599571andfec6023e)The seven that now encode:
heck/{kebab,shouty_kebab,shouty_snake,snake,title,train}.rsandsmallvec/references.rs. Six stop at stage 3, the seventh at stage 4 (declaration drift —lost 6 declaration(s) — impl SmallVec.as_mut, impl SmallVec.as_ref, impl SmallVec.borrow).baseline.json's Rust row carries all four stage floors at exactly those measured values — the twothis PR moves (
encoded,compiledBack) and the two #685 added (reencoded,declarationsKept),kept through three
origin/mainmerges rather than overwritten.cleanstays 0, and nocleangain is promised. The publish job prints
Rust Tier A … at floor: nothing here is raised past whatthis tree measures.
The stage-3 wall moved twice while this PR was open, which is why the docs say to re-measure rather
than quote it. At the design lane's base those six read
reencode-error: unsupported macro invocation `panic!`— #632, closed onmainby #685. On every head since they readreencode-error: unsupported runtime helper `ball_arg_get(...)`— the same compiler↔encoderround-trip class, one construct further along, and squarely issue #692's. Re-verified on this
head from run 35545441446's own
tier_a.json. The stage-3 number did not move because the next wallwas immediately behind the one that fell.
Flagged, not folded in
main. While this PR was open therequired
Rustcontext was red main-wide on a#646 × #685semantic merge conflict(
compile_reencode_roundtrip::compiled_method_dispatcher_re_encodes,unsupported runtime helper `ball_message_type_name(...)`).mainhas since re-pinned that round trip as two explicitcases (
compiled_method_dispatcher_scrutinee_is_a_documented_gap,compiled_method_dispatcher_fallback_arm_is_the_mapped_panic_macro, both green in this branch'sRustjob), so every check on this PR now passes and, with it, round 2's advisory 3 isanswered:
write_sinks.rsis CI-observed at this head —Running tests/write_sinks.rs … running 27 tests … test result: ok. 27 passed; 0 failed. Issue Rust job is red on main: compiled_method_dispatcher_re_encodes hits the new fail-loud runtime-helper table on ball_message_type_name (#646 x #685 semantic merge conflict) #718 itself stays open as the underlyingcontract question; nothing about it was folded into this encoder PR.
rust/encoder/tests/write_sinks.rs's own doc comment: everyMessageCreation— which theunified
Ok(..)outcome is, and which a plainOk(x)in hand-written source always has been —compiles to
{ let mut __ball_map = BallMap::new(); … }, andBallMap::new()is an associatedfunction on a foreign type the encoder documents as a permanent gap (Rust round-trip leg: recognise BallMap::new()/BallList::new() and the class-registry helpers — 68/350 measured #692). That is why the
compile-back test asserts a real
cargo buildand deliberately stops short of a re-encode.publishjob is RED on three Python floors this branch cannot touch —scored 70against a baseline of 73,encoded/compiledBack2/70against5/73. That is amainregression from ci(matrix): honest round-trip measurement — no OK at zero, per-target gap issues, Go list_foreach + multi-line diagnostics (advances #642) #646, filed as issue python/encoder: #646 made three Tier A files encode to ZERO declarations — they leave the scored corpus silently, and Coverage Study is now RED on three Python floors #721; it reproduces identically on both dispatchesabove. Nothing in
baseline.jsonis lowered here to make it pass.while letis a loud, pre-existing gap with a misleading message.encode_whilehands itscondition to
encode_expr, sowhile let Some(x) = it.next()panics withunsupported expression: let-guard outside if/while— correct behaviour (loud, never silent), butthe message names the wrong place. Untouched here deliberately: it predates this branch, it is not
a
write!question, and changing it needs its own RED-first pin.clear/writeAllon a sink stay on the Dart-SDK method surface, deliberately(
docs/SINK_DESIGN.md§2).CI
RED on a test-only commit, GREEN on the fix, three times — once per slice.
write!payload (test-only commitcbcc6502): CI 34768498846— red in
Rust → Test, the step that owns this contract, against the encoder's own refusalunsupported macro invocation `write!`(methods.rs:337).3d4ebaf8, re-pushed as931fad7a): CI34787803644 —
test result: FAILED. 17 passed; 2 failed, the two being exactly the two new cases, one perdirection.
c89aa446):Rust → Testred withtest result: FAILED. 20 passed; 1 failedinwrite_sinks.rs. CI'sRustjob aborted at Rust job is red on main: compiled_method_dispatcher_re_encodes hits the new fail-loud runtime-helper table on ball_message_type_name (#646 x #685 semantic merge conflict) #718'sthen-live failure before reaching that binary, so this one was proven from the code and locally:
cargo test -p ball-lang-encoder --test write_sinks=FAILED. 20 passed; 1 failed, the panicbeing the assertion, not a compile error. GREEN on the fix (
eb783ac5):ok. 21 passed; 0 failed.573f30d7): CI35530751652 —
Rustjobfailure,
test result: FAILED. 23 passed; 4 failed, the four being exactly the threepattern-binding constructs and the alias direction. (The run's other jobs read
cancelled: theGREEN push superseded it once the
Rustjob had already failed.)ef599571: CI 35531059566,Conformance Matrix 35531059525,
Regression Gates 35531059585 —
40 checks, 40 pass, including
Ball Artifact Freshness(this PR moves no committed generatedartifact), every engine row at Dart parity and every ratcheted compiler leg above its floor.
write_sinks.rsruns and readsok. 27 passed; 0 failed.fec6023e, the finalorigin/mainmerge): CI35545426344 success,
Conformance Matrix 35545426346
success on every row, Regression Gates
35545426352 success. Every
self-hosted engine at Dart parity (
Dart Engine 363/363;Results: 359 passed, 0 failed, 359 totalfor TS, C++ Compiled, C#, Go, Python and Rust) and every ratcheted compiler leg above itsfloor (
Rust 280 (floor: 276),Go 296 (floor: 291),C# 279 (floor: 275),Python 259 (floor: 255)); the Rust round-trip measurement leg reads100 passed … (floor: 100), the same valuemainmeasures.35531063478 at
ef599571and35545441446 at
fec6023e— allseven measuring jobs green in both, both printing
Rust Tier A clean 0/77 (0%) floor 0/77 (0%) at floorand the identical funnel, withpublishred only on python/encoder: #646 made three Tier A files encode to ZERO declarations — they leave the scored corpus silently, and Coverage Study is now RED on three Python floors #721's three Python floors(
Rows checked: 8, breaches: 3, all three Python).🤖 Generated with Claude Code
https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9