Skip to content

feat(rust): encode write!/writeln! through the std sink functions (closes #630, advances #491) - #698

Merged
ahmednfwela merged 21 commits into
mainfrom
feat/w12-b2-rust-encoder-write-sinks
Sep 21, 2026
Merged

ahmednfwela merged 21 commits into
mainfrom
feat/w12-b2-rust-encoder-write-sinks

Conversation

@ahmednfwela

@ahmednfwela ahmednfwela commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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 declarations std.sink_create /
sink_write / sink_to_string, the tagged reference-semantic sink on every engine, compiler and
runtime, and conformance fixture 466_string_sink. This PR is the Rust encoder payload that
finally 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_string
to the universal std module … plus the syntax-only Rust rule (the first argument of write! is the
sink).

The design record now lives in the repo — docs/SINK_DESIGN.md

Issue #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 the
four 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 does
not exist in the repository. git grep claude/briefs origin/main finds nothing; this branch
introduced the first such reference, and #636's body links the same dead path.

docs/SINK_DESIGN.md is that record, carrying the durable half: the normative runtime contract, the
std-not-std_io placement and its ball audit reason, the per-target backing table with why a
by-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 gains
a row for the new file.

What was wrong

rust/encoder/src/methods.rs::encode_macro mapped exactly three macros — println!, format!,
vec! — and refused everything else. write! and writeln! were the measured largest remaining
macro 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

($dst:expr, $($arg:tt)*) => { $dst.write_fmt($crate::format_args!($($arg)*)) };

— 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_nl are builtin expanders, write!/writeln! go through the ordinary
macro_rules! path in core.

That matters concretely: 6 of the 7 first-blocked files write through an unannotated closure
parameter
(|f| write!(f, "-") in heck). Any design needing to infer the destination's type is
dead on arrival for them. This one consults none.

Three closed cases, chosen by syntax alone (docs/SINK_DESIGN.md §5):

destination arm emitted
a parameter, field, closure param, call result — anything not a local (b) std.sink_write{sink, text}
a bare name bound by a let whose initialiser is a String constructor (a) std.assign{target: s, value: std.concat(s, text), op: "="}
a bare name bound by a let of any other shape loud refusal, naming the local and its initialiser

Arm (a) is the "join sites" rule: in itertools::join the same result is also read as a String
(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! is write! plus a "\n" part — core itself spells its no-argument arm as literally
write!($dst, "\n"). Both arms are wrapped in the encoder's unified Ok(..) outcome message, because
write! evaluates to a fmt::Result and 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 at
all: write! is a builtin, so none of #629's macro_rules! machinery is involved.

One enabler, stated plainly

String::new() and String::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 been
unreachable. 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 the
documented 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_scopes is a stack of frames — one per fn / closure / impl method / default-bodied
trait method body, seeded with that body's parameters, and one per { .. } block (see the next
section) — filled with its lets as they are encoded
(recorded after the initialiser, so let s = s; still reads the outer s); lookup is
innermost-first, so a closure's own f, or a nested block's own f, shadows a same-named enclosing
local. It is kept separate
from the existing push_fn_scope: that one records parameters only for a 2+-parameter body (its
input-aliasing rule) and an impl method pushes no fn scope at all — either would leave a parameter
looking like a local, and a parameter misread as a local String is exactly the silent miscompile the
frame exists to prevent.

A scoping bug in that machinery, found in review and fixed RED-first

local_scopes opened one frame per fn/closure/method body, and record_local wrote into it
from anywhere inside that body — so a let in a nested block survived its own closing brace. That
is not Rust's rule, and it was wrong in both directions:

  • silently — an inner let f = String::from(..) shadowing a &mut fmt::Formatter parameter f
    left f looking like a local String after the block, so a later write!(f, ..) encoded as a
    re-assignment of a binding that is not in scope instead of std.sink_write on the parameter;
  • loudly — an inner let out = 1; shadowing a sink parameter out made a later
    write!(out, ..) a refused "local whose initialiser is not a String constructor".

Fixed where the rule actually lives: block.rs::encode_block now opens a frame of its own and pops
it, 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 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 the same representation question
the join-sites rule answers for let, not a scoping bug.

A second interaction, found after merging origin/main and fixed RED-first

origin/main moved this branch's own files while it was open: #646 gave the encoder a &mut ALIAS
table (Encoder::ref_aliases) and #685 gave it panic!/unreachable! arms. The merge is a UNION
in 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 let at all (Ball has no references, so binding it as
a value would turn every write through it into a write to a copy), and every later READ of slot
resolves back to result in lib.rs::encode_path_expr. A write! destination is a read like any
other — but classify_write_destination was looking the bare name up in the binding frames without
that 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 as
    std.sink_write against a plain String. That still fails LOUD — every engine and runtime proves
    the 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 intermediate
    binding, already took the re-assignment arm.
  • the other direction (an alias of a sink parameter) already worked, because the sink arm encodes
    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 name
through ref_aliases first, exactly as every other read does. An alias of a local that is not a
String reaches the same loud refusal the local itself would. Recorded in docs/SINK_DESIGN.md §5
(a third supporting mechanism), rust/AGENTS.md and .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 is block.rs's let handling, so a name introduced by a pattern
— a for-loop variable, a match-arm binding, an if let binding — was never recorded anywhere. Left
unrecorded 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 String is
the one kind that does not fail loud.

  • 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, and 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.

Encoder::with_pattern_binding is the frame for those three constructs, applied at the four
control_flow.rs call sites that encode a body under such a name; it also drops a same-named &mut
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, 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 not
something Ball models), and for w in writers.iter_mut() { write!(w, ..)?; } over real sinks is an
ordinary working shape that must keep encoding. When the element really is a plain String, the write
lands 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_sink ships 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.rs the only file
touched) — CI 35530751652's Rust
job failed with test result: FAILED. 23 passed; 4 failed, the four being exactly the three
pattern constructs plus the alias direction — and GREEN on the fix (ef599571:
test result: ok. 27 passed; 0 failed, quoted from run 35531059566's Rust → Test log). That RED
run's other jobs read cancelled because the GREEN push superseded it; the Rust job itself ran to
a 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 main while this PR was open — and pins write! inside an
impl 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-encoding
heck files actually are.

Known boundary, and why it is safe

A String that reaches a write! as anything other than a local let takes the sink arm — a local
handed to another function that writes into it (let mut s = String::new(); helper(&mut s);), a
String field (write!(self.out, ..)), or an element bound by a pattern (above). Either way the
encoded program passes a string where std.sink_write expects a sink. That mismatch is loud on
every 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 — both
panic 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_crate resolution question, not a
write! question. Recorded in docs/SINK_DESIGN.md §5 rather than guessed at.

Why the current tests did not catch it

rust/encoder/tests/'s suites are all single-file fn main programs built from the shared conformance
corpus, which is single-file-main-only by construction — no fixture in it has ever contained a
write!, because dart/encoder's corpus generator emits Dart. documented_gaps.rs pinned the macro
gap with assert!, not write!, so the bucket that actually mattered had a live gate and no test
observing 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, both
alias directions, the impl Display shape, both loud refusals, a real cargo build of the
compiled-back library, and an end-to-end compile-and-run whose stdout is asserted literally.

Per-target backing + std.type_of proof

The 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:

target backing type_of"Sink" proven by
Dart engine (source of all 6 self-hosted engines) __type__-tagged map, portable Dart dart/engine/test/engine_test.dart sink cases + fixture 466_string_sink
Dart compiler tagged map fixture 466_string_sink
TypeScript tagged plain object (the divergent ARRAY-buffer registration removed — #633) ts-engine / ts-compiler matrix rows on 466
Rust BallValue::Map = Arc<Mutex<IndexMap>> rust/shared/src/runtime.rs::sink_is_a_tagged_reference_value, rust/compiler/tests/string_sink.rs, and this PR's a_sink_write_compiles_back_into_a_real_rust_library
C# BallMap (reference type) csharp job's sink tests + csharp-engine row
Go pointer-backed ballrt.Map go job's sink tests + go-engine row
Python plain dict python job's sink tests + python-engine row
C++ shared-pointer-backed map cpp-engine / cpp-compiled rows on 466

Reference 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_sink
writes 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 Study dispatch on
this branch, whose Tier A (Rust) job runs the exact rq1-study invocation coverage-study.yml
pins, and whose publish job floors every row against tools/coverage-study/baseline.json — so a
measured 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 35545441446
at fec6023e (that plus the final origin/main merge). 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.

funnel stage before (committed baseline) after (measured at ef599571 and fec6023e)
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

The seven that now encode: heck/{kebab,shouty_kebab,shouty_snake,snake,title,train}.rs and
smallvec/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 two
this PR moves (encoded, compiledBack) and the two #685 added (reencoded, declarationsKept),
kept through three origin/main merges rather than overwritten. clean stays 0, and no clean
gain is promised. The publish job prints Rust Tier A … at floor: nothing here is raised past what
this 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 on main by #685. On every head since they read
reencode-error: unsupported runtime helper `ball_arg_get(...)` — the same compiler↔encoder
round-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 wall
was immediately behind the one that fell.

Flagged, not folded in

CI

RED on a test-only commit, GREEN on the fix, three times — once per slice.

  • RED, the write! payload (test-only commit cbcc6502): CI 34768498846
    — red in Rust → Test, the step that owns this contract, against the encoder's own refusal
    unsupported macro invocation `write!` (methods.rs:337).
  • RED, the block-scoping bug (test-only commit 3d4ebaf8, re-pushed as 931fad7a): CI
    34787803644
    test result: FAILED. 17 passed; 2 failed, the two being exactly the two new cases, one per
    direction.
  • RED, the alias interaction (test-only commit c89aa446): Rust → Test red with
    test result: FAILED. 20 passed; 1 failed in write_sinks.rs. CI's Rust job 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's
    then-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 panic
    being the assertion, not a compile error. GREEN on the fix (eb783ac5): ok. 21 passed; 0 failed.
  • RED, the pattern binding (test-only commit 573f30d7): CI
    35530751652Rust job
    failure, test result: FAILED. 23 passed; 4 failed, the four being exactly the three
    pattern-binding constructs and the alias direction. (The run's other jobs read cancelled: the
    GREEN push superseded it once the Rust job had already failed.)
  • GREEN at ef599571: CI 35531059566,
    Conformance Matrix 35531059525,
    Regression Gates 35531059585
    40 checks, 40 pass, including Ball Artifact Freshness (this PR moves no committed generated
    artifact), every engine row at Dart parity and every ratcheted compiler leg above its floor.
    write_sinks.rs runs and reads ok. 27 passed; 0 failed.
  • GREEN at this head (fec6023e, the final origin/main merge): CI
    35545426344 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 total for TS, C++ Compiled, C#, Go, Python and Rust) and every ratcheted compiler leg above its
    floor (Rust 280 (floor: 276), Go 296 (floor: 291), C# 279 (floor: 275), Python 259 (floor: 255)); the Rust round-trip measurement leg reads 100 passed … (floor: 100), the same value
    main measures.
  • Coverage Study (dispatched for the re-measure; not a PR gate):
    35531063478 at ef599571 and
    35545441446 at fec6023e — all
    seven measuring jobs green in both, both printing Rust Tier A clean 0/77 (0%) floor 0/77 (0%) at floor and the identical funnel, with publish red 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

CI and others added 12 commits September 13, 2026 19:23
…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
@ahmednfwela

Copy link
Copy Markdown
Contributor Author

verdict: PASS
sha: 91866e8
Model: claude-opus-5[1m]

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 -> matrix

RED, scoping slice (observed, clean). Run 34787803644, Rust -> Test:

Running tests/write_sinks.rs (target/debug/deps/write_sinks-af2019308b266dd8)
test a_non_string_let_inside_a_nested_block_does_not_refuse_an_outer_sink ... FAILED
test a_string_let_inside_a_nested_block_does_not_outlive_it ... FAILED
thread '...does_not_refuse_an_outer_sink' panicked at encoder/src/methods.rs:443:59:
thread '...does_not_outlive_it' panicked at encoder/tests/write_sinks.rs:150:5:
test result: FAILED. 17 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out

Exactly the two new cases, one per direction (silent miscompile / false loud refusal). git show --stat 3d4ebaf8 is rust/encoder/tests/write_sinks.rs | 54 + — tests only.

RED, first slice (sound, but inferred rather than observed — see finding A). Run 34768498846 fails in Rust -> Test:

Running tests/documented_gaps.rs (...)
test write_macro_encodes ... FAILED
thread 'write_macro_encodes' panicked at encoder/src/methods.rs:337:22:
test result: FAILED. 15 passed; 1 failed; ...
error: test failed, to rerun pass `-p ball-lang-encoder --test documented_gaps`

git show --stat cbcc6502 is documented_gaps.rs | 26 +-, write_sinks.rs | 737 + — tests only.

GREEN at head. Run 34788688388: write_sinks.rs -> test result: ok. 19 passed; 0 failed, documented_gaps.rs -> ok. 16 passed; 0 failed. gh pr checks 698 at 91866e80: 40 rows, 40 pass, 0 non-pass.

Matrix. Run 34788688427, green on every row it ran — the engine rows all at Dart parity (Results: 356 passed, 0 failed, 356 total, plus Dart Engine 360/360, TS Compiled Engine 358/358, TS Self-Hosted 395/395, C++ Compiled 356/356), and each ratcheted compiler leg above its floor (Go 290 (floor: 283), Rust 279 (floor: 273), C# 278 (floor: 270), Python 258 (floor: 245)). Ball Artifact Freshness passes; this PR moves no committed generated artifact.

2. The fix is real and complete against #630's DoD

1 encoded: 7/77   2 compiled back: 7/77   3 re-encoded: 1/77
4 declarations kept: 0/77   5 fixpoint (clean): 0/77
excluded (test-only): 34

tools/coverage-study/baseline.json moves encoded 1 -> 7 — exactly the measured value, no aspiration; clean is left at 0. Publish coverage table prints Rust Tier A clean 0/77 (0%) floor 0/77 (0%) at floor and Rows checked: 8, breaches: 0. The README row 77 | 0 (0%) | 7 | 7 | 1 | 0 | 34 matches the funnel cell-for-cell.

  • I tried to name a symptom with no failing-without-the-fix test and could not. Every case in write_sinks.rs fails at cbcc6502: the 18 that contain a write!/writeln! hit methods.rs:337's catch-all (git show cbcc6502:rust/encoder/src/methods.rs confirms the arm list was println!/format!/vec! only), and the one that does not (string_new_and_with_capacity_encode_as_the_empty_string) hits the pre-existing "unsupported call target" panic on String::new(). The suite is behavioural, not just structural: a_local_string_write_compiles_and_runs encodes -> compiles -> cargo runs and asserts the literal stdout "a7!\n\n", and a_sink_write_compiles_back_into_a_real_rust_library asserts a real cargo build. The #[should_panic] pin in documented_gaps.rs is flipped to a positive assertion in the same PR, per the rule file.
  • Implementation reads correctly. Every push_locals_frame is paired with a pop_locals_frame on straight-line code (lib.rs:1111/1127 via push_fn_scope/pop_fn_scope, used at 1162/1164 and 1858/1866; types.rs:460/462 and 632/634; block.rs::encode_block). record_local runs after the initialiser is encoded, so let s = s; reads the outer s — Rust's rule. is_string_constructor is a deliberately narrow closed set; the classifier's third case panics rather than guess. Refusals name both the local and its initialiser.

3. Hygiene

No TODO/FIXME/#[ignore]/unimplemented!/continue-on-error/allow_failure/|| true anywhere in the added lines. Format check and Clippy pass in the Rust job. Docs updated at the right altitude (docs/SINK_DESIGN.md + its index row in docs/AGENTS.md, TESTING_STRATEGY.md §5b, rust/AGENTS.md, .claude/rules/rust.md, COVERAGE_STUDY.md's now-false "Rust stops at stage 1 for every file" bullet corrected). All seven authored commits carry the four required trailers; the PR body ends with the footer then the session URL.

Findings (all advisory, none blocking)

A. The first RED run does not show the 17 write_sinks tests failing. cargo test is fail-fast, so documented_gaps aborted the run and tests/write_sinks.rs never executed in 34768498846 — the log has no Running tests/write_sinks.rs line at all. The claim is still sound (I verified the pre-fix catch-all arm and the pre-fix String::new() gap by reading cbcc6502 directly), but for the first slice the RED evidence is inferred, where the second slice's is observed. A --no-fail-fast on the RED push would have made it observed; worth doing next time rather than re-running now.

B. Nothing tests a write! inside an impl block or a default-bodied trait method — which is precisely what types.rs's two new push_locals_frame calls exist for, and the shape #630's body names ("inside impl Display targets the formatter"). Not a defect: with encode_block now framing every block, an impl method's own lets are already scoped, and an unrecorded parameter looks up None, which takes the same Sink arm as LocalKind::Parameter — so those two calls are defensive and currently behaviour-neutral. That also means the comment beside them ("every &mut fmt::Formatter parameter in an impl Display would look like a free name") slightly overstates the consequence: a free name and a parameter encode identically today. The only live evidence that the impl Display shape encodes is the Tier A number itself (the six newly-encoded heck files are fmt::Display impls). A single impl fmt::Display for X { fn fmt(&self, f: &mut Formatter) { write!(f, ...) } } case would turn that into a pinned one.

C. The committed README block is one row behind the run it cites, for Dart Tier B (whole-package): the run measured clean 3/5 -> 4/5 and the committed table keeps 3 (60%). Deliberate and explained in the PR body (the raise was earned on main by #647), consistent with baseline.json, and self-correcting on the next main run — so it is the right call, just worth knowing the generated block is not a byte-faithful render of run 34788637747.

D. Pre-existing, not caused here. Every Round-Trip Leg (measurement) row in the matrix reads Results: 0 passed, 356 failed, 356 total. Identical on main's run 34787825544, so it is the standing state of that leg, not a regression from this PR — flagging it because a measurement leg pinned at zero across four languages is easy to stop seeing.

No blocking issues. The open concerns the author raised (#632 blocking stage 3 for six of the seven, the BallMap::new() round-trip gap, the two loud syntax-only boundaries, the pattern-binding representation question) are all recorded in docs/SINK_DESIGN.md §5 / rust/AGENTS.md rather than fixed here, which is the right split — folding a compiler-side defect fix into an encoder PR would hide it.

CI and others added 5 commits September 14, 2026 02:46
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
@ahmednfwela

Copy link
Copy Markdown
Contributor Author

verdict: PASS

sha: 237e907

Model: Opus 5 (1M context) — claude-opus-5[1m]

Independent fresh-context review (round 2). I did not write this PR. Everything below was
re-derived from the diff, the run logs and the source of truth; nothing was taken from the lane
report.

1. RED → GREEN, at the current head

RED, the alias interactionc89aa446, run
34790808615: Rust failure.
git show --stat c89aa446 is one file, tests only: rust/encoder/tests/write_sinks.rs | 67 +.
The claim that CI cannot see this lane's own RED is confirmed structurally, not taken on
trust: cargo test aborts after the first failing binary, and the run's last binary is

Running tests/compile_reencode_roundtrip.rs (…)
test compiled_method_dispatcher_re_encodes ... FAILED
test result: FAILED. 5 passed; 1 failed; …
error: test failed, to rerun pass `-p ball-lang-encoder --test compile_reencode_roundtrip`

tests/write_sinks.rs never runs. I therefore verified the RED from the code rather than
taking the local run on trust, and it holds: at c89aa446 classify_write_destination had
let name = ident.to_string(); with no ref_aliases lookup (exactly the hunk eb783ac5 adds),
and block.rs::encode_local returns early for an alias binding (self.ref_aliases.insert(name, resolved); return None;) before record_local, so lookup_local("slot") is None → the
Sink arm → sink_write emitted → assert_eq!(count_std_calls(…, "sink_write"), 0, "an alias of a provably-local String IS that String, not a sink") fails on the assertion, not on a compile
error. RED is real, and for the stated reason.

RED, the block-scoping bug931fad7a, run
34787803644, visible in CI:

Running tests/write_sinks.rs (target/debug/deps/write_sinks-af2019308b266dd8)
test a_non_string_let_inside_a_nested_block_does_not_refuse_an_outer_sink ... FAILED
test a_string_let_inside_a_nested_block_does_not_outlive_it ... FAILED
test result: FAILED. 17 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out

GREEN, pre-merge91866e80, run
34788688388 success, and it is
the run that actually executed this payload end to end:

Running tests/write_sinks.rs (…)
running 19 tests
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s

Head 237e9078 — run 34791496758:
16 of 17 jobs green (Ball Artifact Freshness, Dart, TypeScript, Go, Python, C#, all three C++
rows, vcpkg smoke, CLI Verb Parity, Upstream Conformance (Editions), Proto Checks, Dart Coverage
Ratchet). The single failure is Rust, and I read its log: the only failing test in the whole
job
is compiled_method_dispatcher_re_encodes, panicking at encoder/src/lib.rs:1564 — issue
#718, which I verified independently is a main defect and not this branch's: main's own runs
34789634618 (ee919888) and
34791323749 (6795c064, current tip)
are failure, the last green main run is fb445407, and this branch's merge-base is
6795c064 (git merge-base == git rev-parse origin/main), so it carries main's red and adds
none of its own.

Conformance matrix34791491613
success on every row, quoting the Results: lines:

Dart Engine              Results: 361 passed, 0 failed, 361 total
TS Self-Hosted Engine    Results: 396 passed, 0 failed, 396 total
TS Compiled Engine       Results: 359 passed, 0 failed, 359 total
TS Compiled (Direct)     Results: 357 passed, 0 failed, 357 total
C++ Compiled             Results: 357 passed, 0 failed, 357 total
C# Self-Hosted Engine    Results: 357 passed, 0 failed, 357 total
Go Self-Hosted Engine    Results: 357 passed, 0 failed, 357 total
Python Self-Hosted       Results: 357 passed, 0 failed, 357 total
Rust Self-Hosted Engine  Results: 357 passed, 0 failed, 357 total
Rust Compiler Leg        Results: 279 passed, 78 failed, 357 total (floor: 276)
Rust Round-Trip Leg      Results: 100 passed, 257 failed, 357 total (floor: 99)

The Rust round-trip's 100 vs floor 99 is not this PR's gain — main's own matrix run
34791323674 measures the identical 100 passed … (floor: 99), so that un-raised floor is a
pre-existing advisory on main, not something this lane owes.
Regression Gates 34791491623 is
green on all four rows.

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
34791387753 prints, for Tier A (Rust):
1 encoded: 7/77, 2 compiled back: 7/77, 3 re-encoded: 1/77, 4 declarations kept: 0/77,
5 fixpoint (clean): 0/77, excluded (test-only): 34. baseline.json's Rust row is byte-for-byte
those numbers, and the publish job prints Rust Tier A … at floor. Its RED is three Python
floors (scored 70 vs 73, encoded 2/70 vs 5/73) — this branch touches no Python file, and it
is filed as #721 rather than absorbed. Nothing in baseline.json was lowered.

3. Hygiene

Advisory (none blocking)

  1. A pattern binding that shadows an enclosing local String mis-encodes silently. Pattern
    bindings (a for-loop variable, a match arm, an if let) are never passed to record_local
    its only call site is block.rs:162 — so in
    let mut s = String::new(); for s in writers.iter_mut() { write!(s, "x")?; } the classifier
    falls through to the enclosing s and emits assign{target: s, …} against the loop variable:
    writes lost, no error anywhere. SINK_DESIGN.md §5 discloses this and argues it is an open
    representation question — but every other unclear destination in this design refuses loudly,
    and the un-shadowed form of the same code already fails loud at run time. A
    record_local(name, None) at the pattern-binding sites would make it LocalKind::Other and
    reach the existing loud refusal, strictly improving on a silent guess without deciding the
    representation question. Out of corpus and narrow (needs a same-name collision), hence advisory.
  2. Three docs name a stage-3 wall the branch's own final measurement contradicts.
    COVERAGE_STUDY.md ("six now reach stage 3 and stop on the compiler's own panic!rust: the compiler emits panic! in method dispatchers that its own encoder refuses to re-encode (Tier A stage 3 capped) #632's
    class"), rust/AGENTS.md (~L824, "the remaining walls … are issue rust: the compiler emits panic! in method dispatchers that its own encoder refuses to re-encode (Tier A stage 3 capped) #632") and
    .claude/rules/rust.md ("its remaining walls are rust: the compiler emits panic! in method dispatchers that its own encoder refuses to re-encode (Tier A stage 3 capped) #632 and declaration drift") predate the
    merge: rust: the compiler emits panic! in method dispatchers that its own encoder refuses to re-encode (Tier A stage 3 capped) #632 was closed on main by fix(rust): make the compiler's method-dispatcher fallback re-encodable and prove it with a compile→re-encode round trip (closes #632) #685, and the PR body records the re-measured wall as
    ball_arg_get (Rust round-trip leg: recognise BallMap::new()/BallList::new() and the class-registry helpers — 68/350 measured #692). A one-line correction in each, next time those files are touched.
  3. write_sinks.rs is not CI-observed at this head (cargo aborts 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 first), so the two
    alias cases have CI coverage only once 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 lands. Self-correcting rather than a gap to act on:
    the PR cannot merge while Rust is red, and the moment it is green write_sinks has run —
    read that job's test result: ok. 21 passed line before landing.

Merge is blocked by #718, not by this PR. mergeStateStatus: BLOCKED, required context Rust
red for a main-wide #646 × #685 semantic merge conflict. Declining to fold a
ball_message_type_name mapping into an encoder PR is the right call — std.type_of strips the
module prefix the dispatcher's match arms carry, so a mapping would be a silent mis-dispatch, which
is a compiler-side contract decision. A re-merge after #718 should be all this branch needs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9

CI and others added 4 commits September 20, 2026 21:54
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.
@ahmednfwela

Copy link
Copy Markdown
Contributor Author

verdict: PASS
sha: fec6023
Model: Opus 5

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

  • RED, the write! payload. cbcc6502 touches rust/encoder/tests/{documented_gaps,write_sinks}.rs and nothing else (git show --name-only). Run 34768498846 = CI completed failure, job Rust, step Test failure, with the encoder's own refusal: thread 'write_macro_encodes' panicked at encoder/src/methods.rs:337:22: / ball-lang-encoder: unsupported macro invocation `write!` / test result: FAILED. 15 passed; 1 failed.
  • RED, the block-scoping bug. Run 34787803644 at 931fad7a (whose tree adds only 3d4ebaf8's write_sinks.rs cases plus a README row — no encoder source): Running tests/write_sinks.rs / a_non_string_let_inside_a_nested_block_does_not_refuse_an_outer_sink ... FAILED / a_string_let_inside_a_nested_block_does_not_outlive_it ... FAILED / test result: FAILED. 17 passed; 2 failed. One failure per direction, as claimed.
  • RED, the pattern binding. 573f30d7 touches rust/encoder/tests/write_sinks.rs and nothing else. Run 35530751652, job Rust completed failure: test a_for_loop_variable_shadows_a_mut_alias_binding ... FAILED, test a_for_loop_variable_shadows_an_enclosing_local_string ... FAILED, test a_match_arm_binding_shadows_an_enclosing_local_string ... FAILED, test an_if_let_binding_shadows_an_enclosing_local_string ... FAILED, test result: FAILED. 23 passed; 4 failed. The run's other jobs read cancelled, but the Rust job ran to a real failure first — that is the evidence, and the body says so.
  • The alias slice's RED (c89aa446) is the one that is not CI-observed (CI 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's then-live failure before reaching the binary). The body discloses exactly that rather than claiming a run. Accepted as honest; the case is green in the suite now.
  • GREEN at head. Run 35545426344 CI completed success at fec6023e, all 17 jobs success; Rust → Test: Running tests/write_sinks.rstest result: ok. 27 passed; 0 failed. Format check (cargo fmt --check) and Clippy both ran in that job.
  • Matrix 35545426346 completed success at fec6023e, every row it ran green. Quoted: Dart Engine … Results: 363 passed, 0 failed, 363 total; Results: 359 passed, 0 failed, 359 total for the Rust / C# / Go / Python self-hosted engines and TS Compiled (Direct); TS Self-Hosted Engine … 398/398; TS Compiled Engine … 361/361. Ratcheted legs above floor: Rust 280 … (floor: 276), Python 259 … (floor: 255); measurement legs at floor: Rust round-trip 100 … (floor: 100), C# 86 (floor: 86), Go 31 (floor: 31), Python 63 (floor: 63).
  • gh pr checks 698 at this head: 40 checks, 40 pass, 0 non-pass; mergeStateStatus: CLEAN.

2. The fix is real and complete against each issue's body

#630 DoD, item by item.

  1. Design record first. docs/SINK_DESIGN.md (273 lines) is it, and it is a record, not a status page: the normative runtime contract, the per-target backing table, the seven-language mapping, the Rust rule, the rejected options, and 11 live citations. I spot-checked its load-bearing claims against origin/main rather than taking them: std:Sink is really rust/shared/src/runtime.rs:2012 const BALL_SINK_TAG, sink_create really lives in dart/shared/lib/std.dart:816 (not std_io), and the std_io module description it quotes as the reason is really dart/shared/lib/std_io.dart:18. The four dead .claude/briefs/W12-B.md references this work had introduced are gone — git grep claude/briefs on the branch finds nothing.
  2. The full std chain if (b). Landed in feat(std): declare sink_create/sink_write/sink_to_string and implement the tagged text sink on every engine and compiler (advances #630, closes #633) #636 (b67a7ea1 on main), verified present: declarations in std.dart, fixture tests/conformance/466_string_sink.ball.json + its src/ generator + expected output. Not re-claimed here.
  3. Test-first, the files re-measured. 27 cases, and the re-measurement below.

The owner's 2026-09-13 decision on #630 (std, not std_io; the syntax-only "first argument of write! is the sink" rule; the type tag) is followed to the letter, including the deliberate departure from the issue body's std_io proposal, with its ball audit reason stated. The issue's "9 first-blocked files" is reported as the reproducible 7 rather than quietly matched — the right call.

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 write! refusal itself failed in 34768498846. The two new cases that passed pre-fix (a_for_loop_variable_that_shadows_nothing_is_still_a_sink, write_inside_an_impl_display_targets_the_formatter) are declared as controls in the commit message, and the first is precisely what makes "a pattern binding is a sink" falsifiable — "refuse every pattern binding" would have passed the other four and failed that one. The assertions are IR-shaped, not smoke: only_std_call counts, field(call,"sink") identity, text_pieces over the concat chain, assert_ok_wrapped on the outcome message. Both subprocess harnesses consume the exit status (assert!(output.status.success(), …)), and a_local_string_write_compiles_and_runs asserts literal stdout "a7!\n\n".

Decisions derived from the source of truth, not asserted. The whole rule rests on core's own ($dst:expr, $($arg:tt)*) => { $dst.write_fmt(format_args!(…)) } — quoted, with rustc's format_args!-only special case (rust-lang/rust#106745) and rust-analyzer's identical split cited as corroboration. writeln!'s newline is justified from core's own ($dst:expr $(,)?) => { $crate::write!($dst, "\n") }, and the code implements it as literally one extra "\n" part in the shared build_format_args. The Ok(..) wrap is justified from the measured 22-of-25 ?/.unwrap() census.

Scoping is complete, not partial. I checked every site that encodes a body: item fn and closure via push_fn_scopepush_locals_frame, impl method and default-bodied trait method via explicit push_locals_frame/pop_locals_frame in types.rs, and every { .. } via encode_block. All four control_flow.rs pattern sites (if let, both match-arm shapes, both for forms) go through with_pattern_binding, and the range/iterable expressions are encoded outside the frame, which is the correct evaluation order. record_local is called after the initialiser is encoded, so let s = s; still reads the outer s. Frames and the alias shadow/restore in with_pattern_binding are paired.

Every floor sits at a measured value. baseline.json's Rust row moves encoded and compiledBack 1 → 7 and nothing else. Coverage Study 35545441446 at this exact head reads 1 encoded: 7/77, 2 compiled back: 7/77, 3 re-encoded: 1/77, 4 declarations kept: 0/77, excluded (test-only): 34 — identical to the committed row. The publish job prints Rust Tier A clean 0/77 (0%) floor 0/77 (0%) at floor: clean is not raised and no clean gain is promised. The README row edit matches the same artifact. The publish job's RED is three Python floors (scored 70 vs 73, encoded/compiledBack 2/70 vs 5/73) — issue #721, a main regression from #646, and nothing in baseline.json was lowered to paper over it.

3. Hygiene

No TODO/FIXME/XXX/HACK/allow_failure/continue-on-error/|| true added anywhere in the diff. cargo fmt --check and Clippy green at head. Docs updated coherently and in the right places (docs/SINK_DESIGN.md + a docs/AGENTS.md index row, TESTING_STRATEGY.md's sink-contract row, rust/AGENTS.md, .claude/rules/rust.md, COVERAGE_STUDY.md, README.md), including the three stale #632-as-stage-3-wall references corrected to #692 with a "re-measure before quoting one" caveat. Ball Artifact Freshness green — this PR moves no committed generated artifact. Every authored commit carries all four trailers; the PR body ends with the required footer and the session URL. Closes #630 is earned.

Advisory (non-blocking — do not hold the merge for these)

  1. String::with_capacity(n) drops n unevaluated. lib.rs returns string_literal("") on with_capacity with e.args.len() == 1 without inspecting the argument, so String::with_capacity(next_id()) would silently drop the call. The capacity value is correctly argued to be unobservable; the argument's evaluation is a separate question, and this is a new silent path (the prior state was a loud "unsupported call target"). Narrow — every corpus site is s.len()-shaped — but it is the one place this PR guesses instead of refusing. Cheapest fix: accept only an argument containing no call, else fall through to the existing loud gap. Worth its own RED-first pin, not a change here.
  2. The AliasTarget::Opaque fall-through is documented but unpinned. methods.rs's comment and .claude/rules/rust.md both assert that a write! through an opaque alias (let slot = &mut v[0];) reaches the loud refusal naming the local. I verified by reading that it does — record_local records every let including alias bindings, the if let Some(AliasTarget::Variable(..)) resolution declines to match, and the lookup lands on LocalKind::Other — but no case in write_sinks.rs exercises it. One more three-line case would close it.
  3. The body calls 931fad7a a "re-push" of 3d4ebaf8; it is a separate README commit stacked on it. Immaterial — the RED tree carries no encoder source change either way — but the wording is loose.

Not merged, not labelled.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xv8Kka2YThKg6NMUSGMcw9

@ahmednfwela
ahmednfwela merged commit b8ae4e6 into main Sep 21, 2026
47 of 48 checks passed
@ahmednfwela
ahmednfwela deleted the feat/w12-b2-rust-encoder-write-sinks branch September 21, 2026 00:14
ahmednfwela pushed a commit that referenced this pull request Sep 21, 2026
`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
ahmednfwela pushed a commit that referenced this pull request Sep 21, 2026
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
ahmednfwela pushed a commit that referenced this pull request Sep 21, 2026
…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
ahmednfwela added a commit that referenced this pull request Sep 21, 2026
… 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>
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.

rust encoder + std: represent mutable output sinks so write!/writeln! encode (design record first)

1 participant