Skip to content

2.2: wire IR + Go emitter; generated Go vs hand-written diff (on #53) - #54

Open
cunninghamcard-bit wants to merge 25 commits into
exp/2.1-manifestfrom
exp/2.2-codegen-go
Open

cunninghamcard-bit wants to merge 25 commits into
exp/2.1-manifestfrom
exp/2.2-codegen-go

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

2.2: codegen Go (wire IR + Go emitter + diff against the hand-written Go)

Stacked on #53 (exp/2.1-manifest, a926c89). Does not touch exp/restart, bindings/, aimux-core/ or the ts-rs output.

Commits

  1. f1f2f90: extractor prerequisites (tools/aimux-codegen/src/extract.rs)
    • (a) Shape::Struct.skipped lists #[serde(skip)] fields. 13 structs have them, e.g. EmbeddingCallOptions.abort_signal.
    • (b) Proxy: when from/try_from and into name the same type, that type's shape becomes the wire shape (AiMuxError gets AiMuxErrorWire's shape; Size/AspectRatio become newtype String). Different directions or a one-way proxy fail with file:line.
    • (c) A type-parameter reference becomes TypeRef::Generic. An undeclared Generic, a ts(concrete) key that names no parameter, or an unresolvable concrete type is an error.
    • (d) A cfg_attr(.., serde(..)|ts(..)) on a container, field or variant is an error with file:line.
    • (e) FIELD_CODECS is keyed by (owner, variant, field, serde). A failed shape is kept as a placeholder, so a missing codec now gives exactly one error; the cascading unregistered type BooleanCriteria at evaluation_model.rs:96 is gone.
    • (f) Flatten of anything other than a struct DTO is an error with file:line.
    • Regenerated generated/manifest.json (coverage.txt unchanged) and closure.md (extract.rs line count).
  2. 600b406: src/wire.rs IR
    • WireModel{types sorted}, WireType{name, doc_source, body}, WireBody{Record, Alias, StringEnum{values,open}, Union{repr,variants}}.
    • Variants stay in declaration order. WireField{wire, rust, ty, presence, aliases}.
    • Presence{omittable, nullable, tri_state, skip_if_empty}.
    • WireTy{Prim, Json, Array, Map, Tuple, Named} plus Nullable for an Option in a nested position (EvaluationQuestion criteria map values). A field-level flag cannot express that case.
    • Flatten is expanded here. Wire names are used as-is (D1 open).
    • The duplicate Rust name ReasoningPart (language_model_message.rs) takes its ts(rename) name LanguageModelReasoningPart.
  3. 91f4ef1: src/emit/{mod,go}.rs
    • trait Emitter. emit::write writes only under target/codegen-out/<lang>/ and refuses any bindings path component.
    • CLI: aimux-codegen --emit go.
    • Go mapping:
      • omitempty only for skip_serializing_if; nullable fields become pointers; tri-state becomes TriState[T] + omitzero.
      • StringEnum becomes type X string + consts; closed enums validate on decode.
      • Tagged unions get a {Tag, Value, Raw} wrapper that dispatches on the tag.
      • Untagged unions get a {Variant, Value, Raw} wrapper that tries variants in declaration order with a strict decode.
      • Vec<u8> becomes []U8.
    • Output: 340 types / 5700 lines, go vet clean.
  4. d1d1d8f: tests/wire_diff_go.rs + docs/plan/2.2-diff-go.md + docs/plan/2.2-diff-go-verdicts.toml
  5. d1d1d8f: tests/wire_diff_go.rs + docs/plan/2.2-diff-go.md
    • Fixtures are real aimux-core values serialized by serde. They go through the generated Go and through copies of bindings/go/types.go + multimodal_types.go, with real go run; missing go makes the test fail.
    • The doc is generated by the test and checked like --check. Mutation: renaming ToolCall toolCallId in the generated Go gives exactly one new row.
  6. c22fbd6: coverage, both directions, generated-only list
    • tests/common/go_fixtures.rs: one fixture per hand-written type with a generated counterpart (one per variant for enums), 144 fixtures in total. Coverage is 80/80, checked by every_hand_written_type_with_a_generated_counterpart_has_a_fixture; removing the VideoPollOptions fixture turns it red.
    • Reviewer A's items:
      • (a) Decode direction (Go unmarshal of the serde JSON, re-marshalled, equals serde, compared both ways): decode_and_encode_directions_match_serde.
      • (b) A field Rust serializes as null stays null in the generated Go: null_fields_stay_null.
      • (c) Missing/null/present round-trips on a tri-state field (BooleanCriteria): tri_state_and_nullable_round_trip.
      • (d) A proxied type (AiMuxError, Size) and generic instantiations (StreamPart ToolCall / ToolApprovalRequest / ReasoningFile) match serde: proxied_and_generic_types_match_serde.
    • The doc gains a Coverage section and a generated-only list (124 types), both produced by the test and stale-checked.
  7. 94c7cd1: gofmt-clean emitter. The emitter aligns struct fields and consts the way gofmt does. generated_go_is_gofmt_clean runs real gofmt -l and misaligns one field to prove the check catches it.
  8. d5618ee: verdicts as JSON. docs/plan/2.2-diff-go-verdicts.json holds {type, field, verdict, reason} entries read with serde_json, and the TOML file and its parser are deleted. verdict_and_doc_checks_each_catch_their_mutation proves the missing-verdict, contradicting-verdict, orphan-verdict, missing-reason and stale-doc checks, each with its own mutation.
  9. 19b900b: fix a wire_diff_go race (flaky test)
    • Root cause: libtest runs tests in parallel. Under AIMUX_BLESS_DIFF=1, generated_go_matches_rust_and_diff_doc_is_current rewrote docs/plan/2.2-diff-go.md in place with fs::write. At the same time, verdict_and_doc_checks_each_catch_their_mutation read that file and asserted it was current (old wire_diff_go.rs:1087 vs :1209-1210). Whether it passed depended on thread order.
    • Fix: the mutation test now works on an in-memory copy of the rendered doc and never reads the file. Only the owning test reads it (no bless) or writes it (write_atomic: temp file in the same directory, then rename).
    • Loops on the test binary, 50 runs per scenario:
scenario before after
plain 0/50 failed 0/50 failed
AIMUX_BLESS_DIFF=1, doc current 0/50 failed 0/50 failed
AIMUX_BLESS_DIFF=1, doc made stale before each run 19/50 failed (panic at :1210) 0/50 failed
  1. 0831136: symlink-proof emit guard (reviewer B). guard (emit/mod.rs:86) canonicalizes the nearest existing ancestor (resolve, :67) and refuses a path with a bindings component or one under the canonical bindings/. Mutation: write_targets_codegen_out_and_never_bindings writes through link -> bindings (directly and via link/new/deeper). With the old guard it panics at emit_go_test.rs:131 because the write succeeded. The .. and absolute-path rejections are kept and tested by output_paths_with_parent_dir_or_absolute_are_rejected.
  2. dce840b: mutation_unparseable_concrete_type_fails covers the extract.rs:405-408 ts(concrete) parse-failure error. Deleting that error turns it red.
  3. 60f7c0c: go_type_names handles grouped type ( ... ) declarations. go_type_names_counts_single_and_grouped_declarations checks this and fails when grouping is disabled. The current files have no grouped declarations, so coverage stays 80/80.

The flake in item 8 was reproduced (19/50 before the fix, 0/50 after), so the structural fix is kept.

  1. b3bf41d: empty-value fixture per covered type (reviewer A). Each of the 80 types gets one, with arrays, maps and strings empty and Options None, serialized by serde. This adds 36 rows, all 手写错: Rust fields without skip_serializing_if that serde writes as []/"" while the hand-written field is omitempty. Of A's 12 Warnings lines, 11 give rows. multimodal_types.go:233 RerankingResult does not, because its Rust field is Option<Vec<Warning>> with skip_serializing_if (reranking_model.rs:121-122), so omitempty is correct there. Verdict labels are renamed to 手写错 / 生成器错 in the test and the JSON.
  2. e80fa53: untagged decode rejects null / [null] like serde. decodeStrict (emit/go.rs:127) now calls rejectNull (:158) first. untagged_decode_rejects_null_exactly_like_serde checks accept/reject parity with real serde on 12 inputs. Recount at d0c7d86 (12 + 3 DecisionDescription cases):
  • Only rejectNull removed: 6 diverge, namely null-FileBytes, null-ImageOutputs, null-ImageOutputs-elem, null-EvaluationInput, null-DecisionValue and null-DecisionDescription. These are reviewer B's 5 plus DecisionDescription, added in d0c7d86.
  • rejectNull and the U8 null check both removed: 8 diverge, the 6 above plus null-FileBytes-elem and null-ImageOutputs-inner.

The earlier "7" was the both-removed count before the DecisionDescription cases existed.
14. c0641d6: binary AudioData / VideoData are byte-identical to serde. binary_audio_and_video_are_byte_identical_to_serde compares the generated Go's raw output bytes with serde_json::to_string, and AudioData must decode as Binary. The byte verdicts' reasons cite speech_model.rs:19-25, video_model.rs:101 and multimodal_types.go:17,255 (corrected in item 19).

  1. 3bca5d7: symlink-proof emit guard, round 2. write (emit/mod.rs:35) canonicalizes the output root once (:43), so symlinks in the root's own path are allowed, and the bindings checks run on the resolved root. guard_below (:76) walks every component below the root with symlink_metadata and rejects any symlink, dangling or not, without following it.
    • dangling_symlink_output_file_is_rejected: out/one/ok.go -> nonexistent bindings/go/new.go gives Err and the file is not created. With the symlink check disabled it is red (the write went through).
    • output_root_reached_through_a_symlinked_parent_still_works: a root under a symlinked parent still writes.
  2. 6a6aab0: output_root_symlinked_into_bindings_is_rejected. A root symlinked to a directory under bindings/ (as itself and with a subdirectory) is rejected. With resolution bypassed, both this test and the existing link -> bindings test go red.
  3. d27cee1: go_type_names uses go/parser (a small go run program printing top-level TypeSpec names, wire_diff_go.rs:350/:385). The test covers type ( // comment, single-line type (G int; H string), generic, alias and function-local types. In-test mutation: counting only the first spec of each declaration gives {A,B,E,F,G} instead of A..H.
  4. b81cf0d: every_covered_type_has_an_empty_value_fixture. Deleting empty-ChatCompletion makes it red (left ["ChatCompletion"]).
  5. 5b3f537: Bytes verdict reasons now cite multimodal_types.go:17 and end with 见 aimux 计划 DECISIONS D33(待 Trey).
  6. 0939e71: reverse direction for the binary fixtures. serde reads the Go output back, and AudioData must come back as Binary. With []U8 changed to []byte, AudioData reads back as Base64("AAF//w==") and VideoData is rejected (expected a sequence).
  7. d0c7d86: null audit (see below) plus DecisionDescription parity cases.

Untagged null audit

rg 'serde\(untagged' aimux-core/src finds 13 sites: 12 wire enums and tool.rs:255, a private Repr inside a hand-written Deserialize impl.

  • The 12 wire enums are at image_model.rs:43,60; evaluation_model.rs:24; message.rs:28,82; video_model.rs:49; transcription_model.rs:25,36; shared.rs:85; speech_model.rs:19; decision_model.rs:70,79.
  • Every variant is a newtype over String, bool, Vec, Vec, Vec<Vec>, Vec, a serde_json Map, or Vec<serde_json::Value>.
  • None has a unit variant or a bare serde_json::Value payload, so a top-level null is rejected by serde everywhere, and rejectNull needs no exception.
  • Inner nulls that serde accepts (Vec, Map) are covered by parity cases for EvaluationInput and DecisionDescription.
  1. 46ae3b5: literal bindings check restored. reject_named (emit/mod.rs:123) runs on the unresolved out_root (:46) before canonicalizing, layered on the resolved-root guard and guard_below.

    • literal_parent_dir_into_bindings_is_rejected_before_resolving (emit_go_test.rs:279): target/../bindings[/go] is rejected even with bindings symlinked elsewhere. With the literal check removed it is red at :292.
    • default_out_root_under_worktree_target_is_writable (:300): target/ (an mbx symlink locally) stays writable.
  2. caa71fc: dangling-symlink root rejected; below-root re-check after create_dir_all.

    • The old line-prefix go_type_names (from c0641d6), run in a scratch copy against the go/parser cases, is red: left {"(", "(G", "A", "B", "C", "D", "E"}, right {A..H}. That copy was not committed, and reviewer A reproduced the same result independently. No repo change.
    • resolve (emit/mod.rs:109) now returns Err on a dangling symlink (:118).
    • dangling_symlink_output_root_is_rejected (emit_go_test.rs:312) covers emit-out -> missing, emit-out -> bindings/go/x and emit-out/sub. With the dangling check disabled the test is red at :326 with emit-out/one: File exists (os error 17): the write was stopped only incidentally by create_dir_all, not by the guard.
    • guard_below runs on the full final path before create_dir_all (emit/mod.rs:66). The second call after it was removed in item 24.
    • symlinked_intermediate_dir_below_root_is_rejected (:337): out/one/a is a symlink and the output is a/b/c.go. With both checks removed the test is red at :345 and elsewhere/b gets created.
  3. 2aeee10: relative output root made absolute before every check. A relative, nonexistent root (newout with cwd <tmp>/bindings/go) used to stay relative through resolve and was written into bindings/go. write now joins a relative root onto current_dir() first (emit/mod.rs:43).

    • Test: relative_nonexistent_root_inside_bindings_is_rejected, in its own binary tests/emit_relative_root_test.rs because chdir is process-global. After restoring the cwd it lists every file under <tmp>/bindings recursively and asserts the list is empty first (:43-46), then that the error says never write under bindings/ (:47-48). (assertion order from df2a90d, reviewer B).
    • Mutation (absolutize step skipped): the empty-bindings assertion goes red at :43 with files were written under bindings/: ["/tmp/aimux-emit-rel-1722990/bindings/go/newout/one/ok.go"].
    • The untested second guard_below after create_dir_all was removed. The pre-mkdir check on the full path stays (emit/mod.rs:77).

Vec mapping

Vec<u8> maps to []U8 (emit/go.rs:351). U8 is type U8 uint8 with a custom MarshalJSON/UnmarshalJSON that writes and reads a JSON number (runtime.go:13-15 in the output). In Go, []uint8, []byte and a named type X []uint8 all marshal as base64, so a named element type with its own marshaller is required. Mutations in the test:

  • (a) []U8 replaced by []byte: AudioData gives "AAF//w==" (serde [0,1,127,255]) and VideoData data gives "AAH/" (serde [0,1,255]). Both fixtures are red.
  • (b) U8.MarshalJSON removed: the same base64 output. Both fixtures are red.

Scope notes

  • Generator differences never enter the table. The assertion at wire_diff_go.rs:1104-1108 (gen_mismatch must be empty) fails the test first, so the table records only hand-written differences.
  • StreamPart is instantiated per its ts(concrete(C = RawToolCall, A = RawToolApprovalRequest, R = GeneratedFile)) at stream_part.rs:28, because the type list follows ts(export) (2.1 decision 2). Whether FFI's TextStreamPart (aimux-ffi/src/lib.rs:2304) gets its own comparison is deferred to 2.3.

Flatten-map finding

No type in aimux-core uses #[serde(flatten)] at all, so no type flattens a map (test no_committed_type_uses_flatten).

Diff result (generated by the test, docs/plan/2.2-diff-go.md)

45 rows, all 手写错, and 0 generator differences across 224 fixtures.

type field generated handwritten verdict
AudioData (value) [1] "AQ==" 手写错
EmbeddingResult warnings [] (absent) 手写错
GenerateObjectResult raw.files [] (absent) 手写错
GenerateObjectResult raw.raw.content [] (absent) 手写错
GenerateObjectResult raw.raw.warnings [] (absent) 手写错
GenerateObjectResult raw.reasoning [] (absent) 手写错
GenerateObjectResult raw.reasoningText "" (absent) 手写错
GenerateObjectResult raw.responseMessages [] (absent) 手写错
GenerateObjectResult raw.sources [] (absent) 手写错
GenerateObjectResult raw.toolCalls [] (absent) 手写错
GenerateObjectResult raw.warnings [] (absent) 手写错
GenerateObjectResult warnings [] (absent) 手写错
GenerateResult content [] (absent) 手写错
GenerateResult warnings [] (absent) 手写错
GenerateTextResult files [] (absent) 手写错
GenerateTextResult raw.content [] (absent) 手写错
GenerateTextResult raw.warnings [] (absent) 手写错
GenerateTextResult reasoning [] (absent) 手写错
GenerateTextResult reasoningText "" (absent) 手写错
GenerateTextResult responseMessages [] (absent) 手写错
GenerateTextResult sources [] (absent) 手写错
GenerateTextResult toolCalls [] (absent) 手写错
GenerateTextResult warnings [] (absent) 手写错
ImageOutputs [0] [1] "AQ==" 手写错
ImageResult images[0] [1,2,255] "AQL/" 手写错
ImageResult warnings [] (absent) 手写错
SearchResult results[0].providerMetadata.x.x.k 1 (absent) 手写错
SearchResult warnings [] (absent) 手写错
SearchResultItem providerMetadata.x.x.k 1 (absent) 手写错
SpeechResult warnings [] (absent) 手写错
StreamPart (all fields) {"type":"custom","kind":"x","providerMetadata":{"x":{"x":{"k":1}}}} {} 手写错
StreamTextResultAggregated files [] (absent) 手写错
StreamTextResultAggregated reasoning [] (absent) 手写错
StreamTextResultAggregated reasoningText "" (absent) 手写错
StreamTextResultAggregated responseMessages [] (absent) 手写错
StreamTextResultAggregated sources [] (absent) 手写错
StreamTextResultAggregated toolCalls [] (absent) 手写错
StreamTextResultAggregated warnings [] (absent) 手写错
TranscriptionCallOptions audio [1] "AQ==" 手写错
TranscriptionResult segments [] (absent) 手写错
TranscriptionResult warnings [] (absent) 手写错
UploadFileResult warnings [] (absent) 手写错
VideoData data [1] "AQ==" 手写错
VideoData url "" (absent) 手写错
VideoResult warnings [] (absent) 手写错

Rows: 45; 手写错: 45.

Tests at df2a90d (-p aimux-codegen only)

  • cargo test -p aimux-codegen: 80 passed.
    • lib: 11
    • codegen_test: 8
    • emit_go_test: 15
    • emit_relative_root_test: 1
    • extract_prereq_test: 17
    • manifest_test: 16
    • wire_diff_go: 12
  • clippy -D warnings: clean. cargo fmt --all -- --check: OK.
  • aimux-codegen --check and scripts/gen_closure_inventory.py --check: up to date.

…key, flatten)

tools/aimux-codegen/src/extract.rs:
(a) Shape::Struct records `skipped`: Rust fields with #[serde(skip)]
    (13 structs in the manifest, e.g. EmbeddingCallOptions.abort_signal).
(b) Proxies: from/try_from and into naming the same type make that type's
    shape the wire shape (AiMuxError -> AiMuxErrorWire's shape; Size and
    AspectRatio -> newtype String). Different directions, or a one-way
    proxy, fail with file:line.
(c) Generics: a bare reference to a type parameter becomes
    TypeRef::Generic; a Generic that is not a declared parameter, a
    ts(concrete) key naming no parameter, or an unresolvable concrete type
    is an error (StreamPart C/A/R now appear as {"generic": ..}).
(d) #[cfg_attr(.., serde(..))] / #[cfg_attr(.., ts(..))] on a container,
    field or variant is an error with file:line.
(e) FIELD_CODECS is keyed by (owner, variant, field, serde). A type whose
    shape fails is kept as a placeholder, so a missing codec gives exactly
    one error; the cascading `unregistered type BooleanCriteria` reported
    at evaluation_model.rs:96 (EvaluationQuestion) is gone.
(f) A flatten field must name a struct DTO (directly or via an alias);
    a map/enum/Option/primitive flatten is an error with file:line.

Flatten-map finding: no type in aimux-core uses #[serde(flatten)] at all
(rg 'serde\(.*flatten' aimux-core/src: no hits; the committed manifest has
no "flatten": true, asserted by no_committed_type_uses_flatten), so no type
flattens a map and (f) changes no committed output.

Tests: tests/extract_prereq_test.rs (16), each mutation verified to turn
red when its rule is disabled in extract.rs. manifest_test's flatten probe
moves to a struct-typed field (ModelSpec.limits). generated/manifest.json
regenerated (coverage.txt unchanged); closure.md regenerated for the new
extract.rs line count. `aimux-codegen --check` and
`scripts/gen_closure_inventory.py --check` pass.
Lowers the extracted model (the manifest's content) into a shared IR that
every emitter reads:

- WireModel { types sorted by name }, WireType { name, doc_source, body }.
- WireBody: Record { fields } | Alias(WireTy) | StringEnum { values, open }
  | Union { repr, variants }. Variants keep serde declaration order (not
  sorted; untagged decoding depends on it).
- WireField { wire, rust, ty, presence, aliases }. Presence { omittable
  (skip_serializing_if/default), nullable (Option), tri_state (both plus
  the codec tri_state flag), skip_if_empty (skip_serializing_if alone, the
  only case a Go omitempty mirrors) }.
- WireTy: Prim | Json | Array | Map | Tuple | Named, plus Nullable for an
  Option in a nested position (EvaluationQuestion criteria map values),
  which a field-level presence flag cannot express.
- Flatten is expanded (inlined struct fields; non-struct target = error).
- Manifest wire names as-is, no case conversion (D1 open).
- Generic parameters take their ts(concrete) type; a Rust name defined in
  two files (ReasoningPart) takes its ts(rename) name
  (LanguageModelReasoningPart) and references resolve same-file first.
- Codecs: the Rust type when structural or a known DTO, else opaque JSON;
  a serde `with` field with ts(type) stays opaque.

extract.rs gains pub parse_type_ref; manifest::SRC_PREFIX is pub.
11 unit tests in wire.rs (cargo test -p aimux-codegen --lib: 11 passed).
closure.md regenerated for the extract.rs line count.
emit/mod.rs: `trait Emitter { fn lang(&self) -> &'static str;
fn emit(&self, &WireModel) -> Vec<Output>; }` and `write`, which puts
files under `<out_root>/<lang>/` (default target/codegen-out/) and refuses
any path with a `bindings` component or `..`. CLI: `aimux-codegen --emit go
[--emit-out DIR]`.

emit/go.rs (package `wire`; go.mod, runtime.go, types.go):
- Record -> struct with json tags; `omitempty` only for Rust
  skip_serializing_if; nullable -> pointer; tri-state -> TriState[T] with
  `omitzero` (go 1.24) for absent / null / value.
- StringEnum -> `type X string` + constants; closed enums reject unknown
  values in UnmarshalJSON.
- Tagged Union -> {Tag, Value, Raw} wrapper; UnmarshalJSON dispatches on
  the tag (internal / adjacent / external, aliases and serde(other)
  honoured), MarshalJSON writes the tag back.
- Untagged Union -> {Variant, Value, Raw}; tries variants in declaration
  order with a strict decode (JSON kind + required keys), like serde.
- Vec<u8> -> []U8 (JSON number array, not base64 []byte); tuples and
  opaque codecs -> json.RawMessage; u128/i128 -> json.Number.

Output: 340 Go types, 5700 lines; `go vet` clean (go1.24.4). Not gofmt'd
(field alignment only). tests/emit_go_test.rs: 6 passed, including a real
`go vet` of the output (missing `go` fails the test).
tools/aimux-codegen/tests/wire_diff_go.rs:
- 14 fixtures, each a real aimux-core value serialized with serde_json
  (Usage x2, FinishReason, ToolCall, ResponseMetadata, ChatCompletion x2,
  ChatCompletionChunk, TraceRecord x2, TimeoutConfiguration, ImageResult x2,
  ModelMessage). No hand-written JSON.
- A scratch Go module gets the generated package and copies of
  bindings/go/types.go + multimodal_types.go (plus a stub for the cgo-only
  RepairToolCallFunc); `go run` decodes and re-marshals every fixture
  through both. Missing `go` fails the test (no skip).
- Untagged overlap: `image-result-overlap` has "images": [], which both
  ImageOutputs::Base64(Vec<String>) and ::Binary(Vec<Vec<u8>>) accept; the
  generated Go picks Base64 (declaration order), as serde does (asserted).
- The generated output must equal the Rust output for every fixture and
  add no keys: 0 generator bugs found, so none needed fixing.
- docs/plan/2.2-diff-go.md is rendered by the test and must equal the
  committed file (AIMUX_BLESS_DIFF=1 rewrites it). Verdicts live in
  docs/plan/2.2-diff-go-verdicts.toml keyed by [type] + field; a row without
  a verdict, a stale verdict, or a verdict contradicting the Rust output
  fails.
- Mutation: renaming ToolCall's `toolCallId` tag in the generated Go yields
  exactly one new row (ToolCall | toolCallId | (absent) | "call_1"), flagged
  as a generator bug, plus the generated-only key ToolCall.toolCallID.

Result: 2 rows, both handwritten bugs (ImageResult.images[0]: Bytes
re-encodes Vec<u8> as base64; ImageResult.warnings: omitempty drops the []
serde writes). bindings/ and ts-rs output are not modified.
…y list

tests/common/go_fixtures.rs (new): one fixture per hand-written Go type
that has a generated counterpart, one per variant for enums. Each is a real
aimux-core literal passed through serde_json::to_value. The literals were
first produced from the manifest field list, then edited by hand (valid
base64 in Base64 variants).

tests/wire_diff_go.rs:
- Coverage: every_hand_written_type_with_a_generated_counterpart_has_a_fixture.
  80/80 hand-written types (types.go + multimodal_types.go) with a
  generated counterpart are root types of a fixture. Mutation in the test:
  dropping VideoPollOptions -> ["VideoPollOptions"]. Removing its fixture
  from go_fixtures.rs also turns the test red (left ["VideoPollOptions"]).
- (a) decode_and_encode_directions_match_serde: serde JSON -> Go decode ->
  Go encode must equal serde, and serde must decode the Go output back to
  the same value (every Deserialize type).
- (b) null_fields_stay_null: ChatCompletionChunk finish_reason and
  ChatCompletionMessage content stay `null`.
- (c) tri_state_and_nullable_round_trip: BooleanCriteria absent / null /
  present ({}, {"true":null}, {"true":"yes","false":null}).
- (d) proxied_and_generic_types_match_serde: AiMuxError (from/into
  AiMuxErrorWire), Size (into/try_from String), StreamPart ToolCall /
  ToolApprovalRequest / ReasoningFile (concrete generic instantiation).
- Gen-only fixtures (no hand-written type) run through the generated Go
  only. A hand-written type that marshals to {} produces one
  "(all fields)" row.
- The wire-name mutation test is now scoped to the tool-call fixture (other
  fixtures embed ToolCall) and also asserts that serde rejects the mutated
  output.
- 2.2-diff-go.md gains Coverage and "Generated types with no hand-written
  counterpart" (124) sections, rendered by the test and stale-checked.

New diff rows (all handwritten bug): 7. AudioData, ImageOutputs,
TranscriptionCallOptions.audio and VideoData.data re-encode Vec<u8> as
base64; SearchResultItem lacks providerMetadata (2 rows); StreamPart
marshals {}. Total 9 rows, 9 handwritten bug, 0 generator bug. Hand-written
files not modified. closure.md regenerated (go_fixtures.rs > 1000 lines).
emit/go.rs: struct fields and const specs are column-aligned the way
gofmt aligns them (`align`), and wireRequired is emitted multi-line.
`gofmt -l` on target/codegen-out/go reports nothing (go1.24.4).

tests/emit_go_test.rs: generated_go_is_gofmt_clean runs real `gofmt -l`
(missing gofmt fails). In-test mutation: misaligning ToolCall's
ToolCallId field makes gofmt report ["types.go"]. Disabling the padding
in `align` also turns the test red (left ["types.go"], right []). The
mapping tests now compare with runs of spaces collapsed.

Also regenerates docs/plan/closure.md. The previous commit (c22fbd6) left
it stale, because the inventory script counts tracked files and
tests/common/go_fixtures.rs (3149 lines) was not tracked yet when it ran.
…removed

docs/plan/2.2-diff-go-verdicts.toml -> docs/plan/2.2-diff-go-verdicts.json:
entries {type, field, verdict, reason} (reviewer B's decision). The 9
existing verdicts are carried over unchanged, and their reasons are the
former TOML comments.

tests/wire_diff_go.rs: the TOML subset parser is deleted. `Verdict` is
deserialized with serde_json (deny_unknown_fields). `verdict_problems`
fails on: a row with no verdict, an orphan verdict, a verdict that
contradicts the Rust output, a value other than the two verdicts, a
missing or empty `reason`, and a duplicate entry. `doc_problem` reports a
stale 2.2-diff-go.md.

verdict_and_doc_checks_each_catch_their_mutation proves each check on the
real rows and committed file:
- drop ImageResult|warnings: ["no verdict for ImageResult | warnings"]
- flip it to "generator bug": ["verdict `generator bug` for ImageResult |
  warnings contradicts the Rust output []"]
- add ImageResult|nonexistent: ["orphan verdict for ImageResult |
  nonexistent (no such diff row)"]
- remove `reason` from the JSON / set it to "  ": ["verdict for
  ImageResult | warnings has no reason"]
- committed doc with one verdict flipped / missing: stale.
The doc header now names the JSON file. closure.md regenerated
(wire_diff_go.rs line count).
Root cause: two tests in the wire_diff_go binary used the same file while
running in parallel (the libtest default):
- generated_go_matches_rust_and_diff_doc_is_current rewrote
  docs/plan/2.2-diff-go.md in place with fs::write under AIMUX_BLESS_DIFF=1
  (old wire_diff_go.rs:1087).
- verdict_and_doc_checks_each_catch_their_mutation read the same file and
  asserted it equals the rendered doc (old :1209-1210).
When the committed doc was stale and a bless run was underway, the reader
got the pre-bless contents (or a truncated file, though that case was not
observed) depending on thread order.

Reproduced on the old binary, 50 runs per scenario:
- plain: 0/50 failed
- AIMUX_BLESS_DIFF=1 with a current doc: 0/50 failed
- AIMUX_BLESS_DIFF=1 with the doc made stale before each run: 19/50
  failed, panicking at :1210 with "2.2-diff-go.md is out of date"

Fix:
- The mutation test never reads the doc. It mutates an in-memory scratch
  copy of the rendered doc.
- Only the owning test touches the file. It compares the in-memory render
  against the committed file, or under bless writes it via write_atomic
  (temp file in the same dir plus rename), so no reader sees a partial
  file.

After, on the same three scenarios x 50: 0/50, 0/50, 0/50. A stale doc
without bless still fails the check.
Reviewer B: --emit-out pointing at a symlink to bindings/ was written. guard() (emit/mod.rs:86) now canonicalizes the path's nearest existing ancestor (resolve, :67), appends the not-yet-existing rest, and refuses it if it has a bindings component or lies under the canonical workspace bindings/.

Mutation: write_targets_codegen_out_and_never_bindings now writes through dir/link -> dir/bindings, both directly and through link/new/deeper, and asserts an error with the target dir still empty. With the old component-only guard restored, it panics at emit_go_test.rs:131 (unwrap_err on Ok: the files were written). The .. and absolute output-path rejections are unchanged and now tested by output_paths_with_parent_dir_or_absolute_are_rejected (../x.go, a/../../x.go, /tmp/x.go).
mutation_unparseable_concrete_type_fails: concrete(T = 1) must give 'unresolved generic parameter `T`: cannot parse `ts(concrete)` type `1`' (extract.rs:405-408). Deleting that Err arm turns it red: the only error left is the unrelated closure-dtos coverage one.
go_type_names (wire_diff_go.rs:347) now also reads the specs of grouped declarations at the group's indent, skipping comments and nested fields. go_type_names_counts_single_and_grouped_declarations checks single, grouped (plain, generic, alias) and generic single forms. With grouping disabled it fails: left {"(", "A", "E"} vs right {A..E}. The current hand-written and generated files have no grouped declarations, so coverage (80/80) and the doc are unchanged. closure.md regenerated for line counts.
tests/common/go_fixtures_empty.rs: one empty-<Type> fixture for each of the 80 covered types, a real aimux-core value with arrays, maps and strings empty and Options None, serialized by serde. Produced from the manifest like go_fixtures.rs.

Result: 36 new diff rows, all 手写错 with reasons (45 total, 0 generator differences). Each is a Rust field without skip_serializing_if that serde always writes ([] or ""), while the hand-written Go field is omitempty and drops it. Reviewer A's 12 Warnings lines: 11 give rows (EmbeddingResult, SpeechResult, ImageResult, TranscriptionResult, VideoResult, SearchResult, UploadFileResult, GenerateResult, GenerateTextResult, GenerateObjectResult, StreamTextResultAggregated). multimodal_types.go:233 RerankingResult gives no row: its Rust field is Option<Vec<Warning>> with skip_serializing_if (reranking_model.rs:121-122), so omitempty matches serde there.

Verdict labels renamed in the test and the JSON: handwritten bug -> 手写错, generator bug -> 生成器错.
Reviewer A: decodeStrict (emit/go.rs) let encoding/json take null as the zero value, so FileBytes, ImageOutputs, EvaluationInput and DecisionValue accepted null, and FileBytes and ImageOutputs accepted [null]. decodeStrict now runs rejectNull first. It walks the JSON against the Go type and rejects null wherever the Rust type is not an Option or a JSON value (pointer, interface, RawMessage or TriState), including in array elements, map values and struct fields. U8.UnmarshalJSON rejects null too.

untagged_decode_rejects_null_exactly_like_serde: 12 inputs (null, [null], [[null]], {"a":null}, plus accepted controls). For each, real serde deserialization and the generated Go must agree on accept/reject, and both outcomes occur. Mutation: with rejectNull and the U8 null check removed, 7 cases diverge: null-FileBytes, null-FileBytes-elem, null-ImageOutputs, null-ImageOutputs-elem, null-ImageOutputs-inner, null-EvaluationInput, null-DecisionValue.
…n tests

binary_audio_and_video_are_byte_identical_to_serde: AudioData::Binary([0,1,127,255]) and VideoData::Binary{data:[0,1,255]} go through the generated Go. The output bytes (now passed back verbatim as gen_raw) must equal serde_json::to_string, and AudioData must decode as the Binary variant. Vec<u8> maps to []U8 (emit/go.rs:351), where U8 is a named uint8 whose MarshalJSON writes a number. Plain []byte, []uint8 or any named uint8 slice without that method is written as base64 by encoding/json.

Mutations, both run in the test, and both fixtures go red in each case:
- (a) []U8 -> []byte gives AudioData "AAF//w==" (serde [0,1,127,255]) and VideoData data "AAH/" (serde [0,1,255]).
- (b) U8.MarshalJSON removed gives the same base64 output.

run_go's mutate now applies to every generated file, so it can reach runtime.go. The wire-name mutation skips files without ToolCall.

Byte verdicts stay 手写错. Their reasons now cite reviewer A's evidence (speech_model.rs:19-25, video_model.rs:101, multimodal_types.go:15,255) and carry 见 D33. bindings/ untouched.
Reviewer B/A: resolve() treated any canonicalize failure as 'does not exist', so a dangling link out/one/ok.go -> bindings/go/new.go passed the guard and the write created bindings/go/new.go.

write() (emit/mod.rs:35) now canonicalizes the output root once (:43). Symlinks in the root's own path (/tmp, /workspace) are resolved, not rejected, and the bindings checks run on the resolved root. guard_below (:76) then walks every component below the root with symlink_metadata (no read_link, no following) and rejects any symlink, dangling or not.

Tests (emit_go_test.rs):
- dangling_symlink_output_file_is_rejected (:230): Err, and the target is not created. Mutation: with the symlink check in guard_below disabled, it panics at :241 (unwrap_err on Ok, meaning the file was written through the link).
- output_root_reached_through_a_symlinked_parent_still_works (:248): alias -> real, writing to alias/out lands in real/out/one/ok.go.
- The link -> bindings cases and the ../absolute rejections are unchanged.
aimux RFC boundary: symlinks in the root path are allowed, but the resolved root is checked. output_root_symlinked_into_bindings_is_rejected (emit_go_test.rs:262): emit-out -> <dir>/bindings/go, as the root itself and as emit-out/sub, gives Err with nothing written. The guard already runs on resolve(out_root) (emit/mod.rs:43-44).

Mutation: with the root and guard paths left unresolved (resolve() bypassed at emit/mod.rs:43 and in guard), this test panics at :271 and the existing link -> bindings test panics at :131. Both go red as before.
Reviewer: go_type_names still prefix-matched lines. It now pipes the file through a small go/parser program (TYPE_NAMES_GO, wire_diff_go.rs:350, run with go run) that prints the name of every top-level TypeSpec (go_type_names, :385).

go_type_names_counts_single_and_grouped_declarations (:433) covers single, grouped with doc comment, generic, alias, 'type ( // comment', single-line 'type (G int; H string)', and a function-local type, which is not counted. Expected: A..H.

In-test mutation: the parser program counting only the first spec of each declaration (gd.Specs[:1]) yields {A,B,E,F,G}. The test asserts that differs, so the check is not vacuous. hand_types counts are unchanged: coverage stays 80/80 and the doc is current.
every_covered_type_has_an_empty_value_fixture, modeled on every_hand_written_type_with_a_generated_counterpart_has_a_fixture. without_empty lists the covered types (hand-written with a generated counterpart) that have no empty-<Type> fixture. The test asserts the list is empty, and an in-test mutation dropping empty-ChatCompletion reports ["ChatCompletion"].

Real removal check: deleting the empty-ChatCompletion tuple from tests/common/go_fixtures_empty.rs fails the test with left ["ChatCompletion"], right [].
…led out

The five byte-related 手写错 reasons in docs/plan/2.2-diff-go-verdicts.json now cite `type Bytes []byte` at multimodal_types.go:17 (line 15 is its doc comment) and end with '见 aimux 计划 DECISIONS D33(待 Trey)' instead of '见 D33'. The doc was regenerated with AIMUX_BLESS_DIFF=1 (reasons are not rendered into it, so it is unchanged).
… output back

binary_failures now also feeds the generated Go's raw bytes to serde. AudioData must come back as AudioData::Binary([0,1,127,255]), and VideoData must re-serialize to exactly the original serde bytes.

Mutation ([]U8 -> []byte), asserted in binary_audio_and_video_are_byte_identical_to_serde:
- 'reverse bin-AudioData: serde read the Go output back as Base64("AAF//w=="), not Binary([0, 1, 127, 255])'
- 'reverse bin-VideoData: serde rejected the Go output: invalid type: string "AAH/", expected a sequence'
…ption parity cases

Audit: rg 'serde\(untagged' aimux-core/src finds 13 sites. 12 are the wire enums, at image_model.rs:43,60; evaluation_model.rs:24; message.rs:28,82; video_model.rs:49; transcription_model.rs:25,36; shared.rs:85; speech_model.rs:19; decision_model.rs:70,79. The 13th is tool.rs:255, a private Repr inside a hand-written Deserialize impl (Str(String) | Tool{..}), not a wire type. Every variant of all 12 is a newtype over String, bool, Vec<u8>, Vec<String>, Vec<Vec<u8>>, Vec<T>, JsonObject (serde_json Map) or Vec<serde_json::Value> (manifest shapes). None has a unit variant or a bare serde_json::Value payload, so serde rejects a top-level null for all of them and rejectNull needs no exception.

Nulls serde does accept appear only inside Vec<Value> / Map payloads. EvaluationInput ([null], {"a":null}) already covered these, and three DecisionDescription parity cases are added: null rejected, [null] and {"a":null} accepted, the same in serde and the generated Go. closure.md regenerated for line counts.
…rget/ symlink test

Reviewer B: since 3bca5d7, write() only ran guard() on the resolved root, so the literal named(path) check on the path as given (formerly emit/mod.rs:86-91) no longer saw the unresolved input. reject_named (emit/mod.rs:123, sharing named_bindings at :117) now runs on out_root first (:46), layered on top of the resolved-root guard and guard_below.

- literal_parent_dir_into_bindings_is_rejected_before_resolving (emit_go_test.rs:279): target/../bindings and target/../bindings/go are rejected while <dir>/bindings is a symlink to <dir>/elsewhere, so the resolved path has no bindings component and only the literal check can catch it. Nothing is written to elsewhere/. Mutation: with the reject_named(out_root) call removed, it panics at :292 (unwrap_err on Ok).
- default_out_root_under_worktree_target_is_writable (:300): writing to emit::default_out_root(workspace) succeeds. Locally target is an mbx symlink to ~/.cache/mbx/targets/v1/...; it is in the root's own path, so it is resolved, not refused.
…after create_dir_all

RFC check 2: resolve() (emit/mod.rs:109) now returns Err when a component exists per symlink_metadata but cannot be canonicalized (a dangling symlink, :118), instead of treating it as not-yet-existing. write() propagates it (:48).

dangling_symlink_output_root_is_rejected (emit_go_test.rs:312): emit-out -> missing, emit-out2 -> bindings/go/x (missing) and emit-out2/sub all give a 'dangling symlink' error. Nothing is created at the target and bindings/go stays empty. Mutation (dangling check disabled): the test fails at :318 with '.../emit-out/one: File exists (os error 17)'. The write was refused only incidentally by create_dir_all, not by the guard.

RFC check 3: guard_below already runs on the full rel path (lang + output path) before create_dir_all (:65). It now also runs again after create_dir_all and right before the write (:72-74). symlinked_intermediate_dir_below_root_is_rejected (:337): out/one/a is a pre-made symlink to elsewhere, and the output is a/b/c.go. It gets 'is a symlink below the output root' and elsewhere/ stays empty. Mutation (both guard_below calls in the loop removed): it panics at :345 (unwrap_err on Ok), and elsewhere/b was created.
…untested post-mkdir guard

Reviewer A: a relative, nonexistent root ("newout" with cwd <tmp>/bindings/go) stayed relative through resolve(): its parent is "", so canonicalize fails and the fallback returns the path unchanged. It never matched bindings, and the files were written into bindings/go/newout. write() now joins a relative out_root onto std::env::current_dir() first (emit/mod.rs:43), before the literal, resolved and below-root checks.

tests/emit_relative_root_test.rs is its own binary, because set_current_dir is process-global. relative_nonexistent_root_inside_bindings_is_rejected chdirs into <tmp>/bindings/go, writes relative newout, and expects Err with nothing written. Mutation (absolutize step skipped): it panics at :42 with 'relative root under bindings/ must be refused: ["newout/one/ok.go"]', i.e. the write succeeded.

The second guard_below call after create_dir_all (former emit/mod.rs:72-74) is removed. It only guarded an untested race; the pre-mkdir check on the full path (now :77) stays.
…ings/

Reviewer B: expect_err panicked before the !wrote check ran, and that check only looked at bindings/go/newout. After restoring the cwd, the test now lists every file under <tmp>/bindings recursively (files_under, emit_relative_root_test.rs:52, std::fs only). It asserts that list is empty first (:43-46, the message lists the files), then that the error contains 'never write under bindings/' (:47-48).

Mutation (absolutize step at emit/mod.rs:43 skipped): the empty-bindings assertion is the one that goes red, at :43:5 with 'files were written under bindings/: ["/tmp/aimux-emit-rel-1722990/bindings/go/newout/one/ok.go"]'.
cunninghamcard-bit added a commit that referenced this pull request Oct 12, 2026
…ance, A/B points)

- skip_if_empty non-nullable decodes with contains ? decode : empty (wire.rs:348,351), so null is rejected; the null-alignment fixture and its mutation cover it.
- JSONValue first measurements add 0, 1 and true (Bool/Int exclusivity); non-blocking until the toolchain is installed.
- New docs/plan/2.2-known-differences.json, machine-readable: id, language, limitationSource, a phasesExcerpt quoting aimux-plan PHASES.md:307 verbatim (the in-repo excerpt is authoritative), and a scope of (fixture, leaf path). An entry applies only on label + path match with both values numbers. Three-way checks (ID registered with matching language, row in scope, every ID referenced) plus the stale check each have a planned mutation, red test and expected error; the second mutation uses Usage inputTokens.total ('out of scope'). KD-SWIFT-NUM-1 is planned and unmeasured.
- New docs/plan/2.2-provenance.md: in-repo excerpts of PHASES.md:307 and DECISIONS D33. Swift byte reasons will cite it. The Go verdicts (#54) still cite out-of-repo DECISIONS D33; switching them is noted as a 2.2 follow-up.
- IR premise scan expands aliases first (DecisionState, TextContentBinding, ToolChoice are Alias(Json)) and treats Value(Nullable) as null-accepting.
- Stated as not verified: internal tag position, since the comparison ignores key order; no assertion added.
- String enums counted from the IR rule (wire.rs:244-248) = 19, with the exact command. Go's 21 includes the Size and AspectRatio aliases.
- Every planned mutation names its red test and expected error.
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.

1 participant