Repository navigation
2.2: wire IR + Go emitter; generated Go vs hand-written diff (on #53) - #54
Open
cunninghamcard-bit wants to merge 25 commits into
Open
cunninghamcard-bit wants to merge 25 commits into
cunninghamcard-bit wants to merge 25 commits into
Conversation
…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.
This was referenced Oct 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
tools/aimux-codegen/src/extract.rs)Shape::Struct.skippedlists#[serde(skip)]fields. 13 structs have them, e.g.EmbeddingCallOptions.abort_signal.AiMuxErrorgetsAiMuxErrorWire's shape;Size/AspectRatiobecome newtype String). Different directions or a one-way proxy fail with file:line.TypeRef::Generic. An undeclared Generic, ats(concrete)key that names no parameter, or an unresolvable concrete type is an error.cfg_attr(.., serde(..)|ts(..))on a container, field or variant is an error with file:line.FIELD_CODECSis keyed by (owner, variant, field, serde). A failed shape is kept as a placeholder, so a missing codec now gives exactly one error; the cascadingunregistered type BooleanCriteriaat evaluation_model.rs:96 is gone.generated/manifest.json(coverage.txt unchanged) andclosure.md(extract.rs line count).src/wire.rsIRWireModel{types sorted},WireType{name, doc_source, body},WireBody{Record, Alias, StringEnum{values,open}, Union{repr,variants}}.WireField{wire, rust, ty, presence, aliases}.Presence{omittable, nullable, tri_state, skip_if_empty}.WireTy{Prim, Json, Array, Map, Tuple, Named}plusNullablefor an Option in a nested position (EvaluationQuestion criteria map values). A field-level flag cannot express that case.ReasoningPart(language_model_message.rs) takes its ts(rename) nameLanguageModelReasoningPart.src/emit/{mod,go}.rstrait Emitter.emit::writewrites only undertarget/codegen-out/<lang>/and refuses anybindingspath component.aimux-codegen --emit go.omitemptyonly for skip_serializing_if; nullable fields become pointers; tri-state becomesTriState[T]+omitzero.type X string+ consts; closed enums validate on decode.{Tag, Value, Raw}wrapper that dispatches on the tag.{Variant, Value, Raw}wrapper that tries variants in declaration order with a strict decode.Vec<u8>becomes[]U8.go vetclean.tests/wire_diff_go.rs+docs/plan/2.2-diff-go.md+docs/plan/2.2-diff-go-verdicts.tomltests/wire_diff_go.rs+docs/plan/2.2-diff-go.mdbindings/go/types.go+multimodal_types.go, with realgo run; missinggomakes the test fail.--check. Mutation: renaming ToolCalltoolCallIdin the generated Go gives exactly one new row.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 byevery_hand_written_type_with_a_generated_counterpart_has_a_fixture; removing the VideoPollOptions fixture turns it red.decode_and_encode_directions_match_serde.null_fields_stay_null.tri_state_and_nullable_round_trip.proxied_and_generic_types_match_serde.generated_go_is_gofmt_cleanruns realgofmt -land misaligns one field to prove the check catches it.docs/plan/2.2-diff-go-verdicts.jsonholds{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_mutationproves the missing-verdict, contradicting-verdict, orphan-verdict, missing-reasonand stale-doc checks, each with its own mutation.AIMUX_BLESS_DIFF=1,generated_go_matches_rust_and_diff_doc_is_currentrewrotedocs/plan/2.2-diff-go.mdin place withfs::write. At the same time,verdict_and_doc_checks_each_catch_their_mutationread that file and asserted it was current (old wire_diff_go.rs:1087 vs :1209-1210). Whether it passed depended on thread order.write_atomic: temp file in the same directory, then rename).AIMUX_BLESS_DIFF=1, doc currentAIMUX_BLESS_DIFF=1, doc made stale before each runguard(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_bindingswrites throughlink -> bindings(directly and vialink/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 byoutput_paths_with_parent_dir_or_absolute_are_rejected.mutation_unparseable_concrete_type_failscovers the extract.rs:405-408ts(concrete)parse-failure error. Deleting that error turns it red.go_type_nameshandles groupedtype ( ... )declarations.go_type_names_counts_single_and_grouped_declarationschecks 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.
skip_serializing_ifthat serde writes as[]/""while the hand-written field isomitempty. Of A's 12Warningslines, 11 give rows.multimodal_types.go:233RerankingResult does not, because its Rust field isOption<Vec<Warning>>withskip_serializing_if(reranking_model.rs:121-122), soomitemptyis correct there. Verdict labels are renamed to 手写错 / 生成器错 in the test and the JSON.null/[null]like serde.decodeStrict(emit/go.rs:127) now callsrejectNull(:158) first.untagged_decode_rejects_null_exactly_like_serdechecks accept/reject parity with real serde on 12 inputs. Recount at d0c7d86 (12 + 3 DecisionDescription cases):rejectNullremoved: 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.rejectNulland theU8null 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_serdecompares the generated Go's raw output bytes withserde_json::to_string, and AudioData must decode asBinary. The byte verdicts' reasons cite speech_model.rs:19-25, video_model.rs:101 and multimodal_types.go:17,255 (corrected in item 19).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 withsymlink_metadataand 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.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.go_type_namesuses go/parser (a smallgo runprogram printing top-level TypeSpec names, wire_diff_go.rs:350/:385). The test coverstype ( // comment, single-linetype (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.every_covered_type_has_an_empty_value_fixture. Deletingempty-ChatCompletionmakes it red (left ["ChatCompletion"]).Binary. With[]U8changed to[]byte, AudioData reads back asBase64("AAF//w==")and VideoData is rejected (expected a sequence).Untagged null audit
rg 'serde\(untagged' aimux-core/srcfinds 13 sites: 12 wire enums and tool.rs:255, a privateReprinside a hand-written Deserialize impl.serde_json::Valuepayload, so a top-level null is rejected by serde everywhere, andrejectNullneeds no exception.46ae3b5: literal bindings check restored.
reject_named(emit/mod.rs:123) runs on the unresolvedout_root(:46) before canonicalizing, layered on the resolved-root guard andguard_below.literal_parent_dir_into_bindings_is_rejected_before_resolving(emit_go_test.rs:279):target/../bindings[/go]is rejected even withbindingssymlinked 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.caa71fc: dangling-symlink root rejected; below-root re-check after create_dir_all.
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 withemit-out/one: File exists (os error 17): the write was stopped only incidentally by create_dir_all, not by the guard.guard_belowruns 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.2aeee10: relative output root made absolute before every check. A relative, nonexistent root (
newoutwith cwd<tmp>/bindings/go) used to stay relative throughresolveand was written into bindings/go.writenow joins a relative root ontocurrent_dir()first (emit/mod.rs:43).relative_nonexistent_root_inside_bindings_is_rejected, in its own binarytests/emit_relative_root_test.rsbecause chdir is process-global. After restoring the cwd it lists every file under<tmp>/bindingsrecursively and asserts the list is empty first (:43-46), then that the error saysnever write under bindings/(:47-48). (assertion order from df2a90d, reviewer B).files were written under bindings/: ["/tmp/aimux-emit-rel-1722990/bindings/go/newout/one/ok.go"].guard_belowafter 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).U8istype U8 uint8with a customMarshalJSON/UnmarshalJSONthat writes and reads a JSON number (runtime.go:13-15 in the output). In Go,[]uint8,[]byteand a namedtype X []uint8all marshal as base64, so a named element type with its own marshaller is required. Mutations in the test:[]U8replaced by[]byte: AudioData gives"AAF//w=="(serde[0,1,127,255]) and VideoDatadatagives"AAH/"(serde[0,1,255]). Both fixtures are red.U8.MarshalJSONremoved: the same base64 output. Both fixtures are red.Scope notes
gen_mismatchmust be empty) fails the test first, so the table records only hand-written differences.StreamPartis instantiated per itsts(concrete(C = RawToolCall, A = RawToolApprovalRequest, R = GeneratedFile))at stream_part.rs:28, because the type list followsts(export)(2.1 decision 2). Whether FFI'sTextStreamPart(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 (testno_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.
(value)[1]"AQ=="warnings[](absent)raw.files[](absent)raw.raw.content[](absent)raw.raw.warnings[](absent)raw.reasoning[](absent)raw.reasoningText""(absent)raw.responseMessages[](absent)raw.sources[](absent)raw.toolCalls[](absent)raw.warnings[](absent)warnings[](absent)content[](absent)warnings[](absent)files[](absent)raw.content[](absent)raw.warnings[](absent)reasoning[](absent)reasoningText""(absent)responseMessages[](absent)sources[](absent)toolCalls[](absent)warnings[](absent)[0][1]"AQ=="images[0][1,2,255]"AQL/"warnings[](absent)results[0].providerMetadata.x.x.k1(absent)warnings[](absent)providerMetadata.x.x.k1(absent)warnings[](absent)(all fields){"type":"custom","kind":"x","providerMetadata":{"x":{"x":{"k":1}}}}{}files[](absent)reasoning[](absent)reasoningText""(absent)responseMessages[](absent)sources[](absent)toolCalls[](absent)warnings[](absent)audio[1]"AQ=="segments[](absent)warnings[](absent)warnings[](absent)data[1]"AQ=="url""(absent)warnings[](absent)Rows: 45; 手写错: 45.
Tests at df2a90d (
-p aimux-codegenonly)cargo test -p aimux-codegen: 80 passed.-D warnings: clean.cargo fmt --all -- --check: OK.aimux-codegen --checkandscripts/gen_closure_inventory.py --check: up to date.