A channel's message slots are named from the client, on every backend

The IR called them react-message and django-message, so a FastAPI channel had
to declare a DjangoMessage. They are client-message and server-message now,
and the direction words hold wherever a channel is declared: Params /
ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and
friends so no backend names a type itself. Django's ReactChannel and
FastAPI's ReactChannel are both Channel.

mizan-fastapi never registered a channels extension, so build_ir() emitted no
channel at all and every payload type was invisible to codegen. It registers
one now. RegistryExtension is an ABC requiring all(), which is what the IR
reads — an extension that cannot enumerate its registrations no longer exists.

The gate that should have caught the rename could not: tests/afi registered no
channel because mizan-rust had no channel registry to register one in, so a
five-package rename of the wire contract passed byte-parity without a channel
byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a
#[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's
split; the AFI fixture now carries a channel with every slot and one with a
single slot, so all three backends prove the contract byte for byte.

MizanChannel held three Option<String> beside three has_*() predicates and
unwrapped them with defaults; it holds an ordered slot vector, so an absent
slot is absent rather than defaulted. The channels target emitted a React
hooks file that a stage1-only consumer could not compile — react emits that
now. The codegen's parity tests byte-compared emitted source against baselines
without ever compiling it: they compile the generated crate and run its tests,
import the generated Python package and call every method, and typecheck each
TypeScript target against a consumer.

Also fixed at source: app_visitor printed its import diagnostic to stdout, the
stream export_mizan_ir writes KDL to, so a failed import silently corrupted the
IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on
any non-JSON-serializable field default; Django and mizan-core derived Pascal
names two different ways, disagreeing on every dotted channel name.

ir.py builds a document and renders templates/ir/document.kdl.j2 rather than
appending KDL strings with hand-tracked indentation, and named types resolve to
a fixed point — a model reachable only through a union branch was referenced by
a ref that no type block ever defined.

The rest is the write-gate's own classifiers run over the standing tree:
relative imports, silent swallows, Protocol contracts that should be ABCs,
emitters hand-rendering target source, catch-all arms over closed enums, and
comments narrating the project rather than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,27 +1,23 @@
#!/usr/bin/env python3
"""Pydantic → Rust codegen helper invoked by mizan-codegen's
`[source.rust.pydantic]` step.
"""Introspect a Pydantic module and print its declarations as JSON.
Reads a JSON payload from argv[1] with keys:
argv[1] is a JSON object:
- module: Python module to import (e.g. "claude_manage.schema")
- output: Path to write the generated Rust file
- derives: List of derive identifiers to apply to every emitted item
- header: Optional file prefix (e.g. an AUTO-GENERATED warning)
- derives: derive identifiers decoru applies to every emitted struct
Discovers every BaseModel subclass declared in the module (handled by
decoru) AND every Enum subclass declared there (handled inline — decoru
itself is scoped to BaseModel). Writes one Rust file containing both.
stdout is a JSON object:
- enums: [{"name": <python class name>, "variants": [<rust ident>, ...]}]
- structs: [<rust source>, ...] as rendered by decoru
Bundled with the mizan-codegen binary (include_str!) and piped to
`python -` at codegen time — no install step beyond decoru being
importable in the python environment.
decoru itself is scoped to BaseModel, so Enum subclasses are reported as
shapes for the caller to render; only their variant identifiers go through
decoru, which keeps them equal to the ones it bakes into field defaults.
"""
import importlib
import inspect
import json
import sys
import textwrap
from enum import Enum
from pathlib import Path
@@ -60,46 +56,6 @@ def discover_enums(module) -> list[type[Enum]]:
]
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
# emits `impl Default` unconditionally on every BaseModel, so any
# enum-typed field that lacks a Pydantic default must still satisfy
# `EntryType::default()`. Forcing #[default] on the last member keeps
# the generated structs compilable without per-enum config.
_ENUM_TEMPLATE = textwrap.dedent("""\
#[derive({derives})]
#[serde(rename_all = "snake_case")]
pub enum {name} {{
{variants}
}}
""")
def _render_variant(member: Enum, *, is_default: bool) -> str:
# Pascal-casing the Python member name is the same conversion decoru
# applies when capturing enum field defaults. Sharing the function is
# load-bearing — divergent conversions emit non-compiling schema.rs.
pascal = to_rust_variant_ident(member.name)
default_attr = " #[default]\n" if is_default else ""
return f"{default_attr} {pascal},"
def emit_rust_enum(enum_class: type[Enum], derives: tuple[str, ...]) -> str:
"""Render a Rust enum with PascalCase variants from Python member
names. Pairs `#[serde(rename_all = "snake_case")]` so the wire form
matches each member's `value`. Adds `Default` to the derives and
marks the last member `#[default]` — see `_ENUM_TEMPLATE` for the
rationale."""
name = enum_class.__name__
members = list(enum_class)
full_derives = ", ".join((*derives, "Default"))
variants = "\n".join(
_render_variant(m, is_default=(i == len(members) - 1))
for i, m in enumerate(members)
)
return _ENUM_TEMPLATE.format(derives=full_derives, name=name, variants=variants)
def main() -> int:
if len(sys.argv) < 2:
sys.stderr.write("run_decoru.py: missing JSON payload argument\n")
@@ -107,9 +63,7 @@ def main() -> int:
payload = json.loads(sys.argv[1])
module_name: str = payload["module"]
output_path = Path(payload["output"]).resolve()
derives = tuple(payload.get("derives", ()))
header = payload.get("header") or ""
derives = tuple(payload["derives"])
sys.path.insert(0, str(Path.cwd()))
@@ -122,15 +76,21 @@ def main() -> int:
)
return 3
enum_blocks = [emit_rust_enum(e, derives) for e in enums]
struct_blocks = [emit_rust_struct(walk_pydantic_model(m), derives=derives) for m in models]
body = "\n".join((*enum_blocks, *struct_blocks))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(header + body)
sys.stderr.write(
f"run_decoru.py: wrote {len(enums)} enum(s) + {len(models)} struct(s) to {output_path}\n"
json.dump(
{
"enums": [
{
"name": enum_class.__name__,
"variants": [to_rust_variant_ident(m.name) for m in enum_class],
}
for enum_class in enums
],
"structs": [
emit_rust_struct(walk_pydantic_model(model), derives=derives)
for model in models
],
},
sys.stdout,
)
return 0