Files
mizan/protocol/mizan-codegen/scripts/run_decoru.py
Ryth Azhur 3aafec6dd4 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>
2026-07-27 14:03:19 -04:00

100 lines
2.9 KiB
Python

#!/usr/bin/env python3
"""Introspect a Pydantic module and print its declarations as JSON.
argv[1] is a JSON object:
- module: Python module to import (e.g. "claude_manage.schema")
- derives: derive identifiers decoru applies to every emitted struct
stdout is a JSON object:
- enums: [{"name": <python class name>, "variants": [<rust ident>, ...]}]
- structs: [<rust source>, ...] as rendered by decoru
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
from enum import Enum
from pathlib import Path
from pydantic import BaseModel # type: ignore[import-untyped]
from decoru import ( # type: ignore[import-untyped]
emit_rust_struct,
to_rust_variant_ident,
walk_pydantic_model,
)
def _declared_in(module, obj) -> bool:
return getattr(obj, "__module__", None) == module.__name__
def discover_models(module) -> list[type[BaseModel]]:
"""BaseModel subclasses declared in this module. Imported helpers are
skipped — only own-module declarations qualify."""
return [
obj
for _, obj in inspect.getmembers(module, inspect.isclass)
if issubclass(obj, BaseModel)
and obj is not BaseModel
and _declared_in(module, obj)
]
def discover_enums(module) -> list[type[Enum]]:
"""Enum subclasses declared in this module. Filters out the
framework's own Enum class and anything imported from elsewhere."""
return [
obj
for _, obj in inspect.getmembers(module, inspect.isclass)
if issubclass(obj, Enum) and obj is not Enum and _declared_in(module, obj)
]
def main() -> int:
if len(sys.argv) < 2:
sys.stderr.write("run_decoru.py: missing JSON payload argument\n")
return 2
payload = json.loads(sys.argv[1])
module_name: str = payload["module"]
derives = tuple(payload["derives"])
sys.path.insert(0, str(Path.cwd()))
module = importlib.import_module(module_name)
enums = discover_enums(module)
models = discover_models(module)
if not enums and not models:
sys.stderr.write(
f"run_decoru.py: no Enum or BaseModel subclasses declared in {module_name!r}\n"
)
return 3
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
if __name__ == "__main__":
sys.exit(main())