Mizan codegen substrate: Rust kernel + Rust codegen binary, JS generator deleted

The Mizan codegen substrate moves off JavaScript template-literal emission
onto a compiled Rust binary that consumes the same OpenAPI + x-mizan-* IR
the JS substrate consumed. Three structural wins fall out of one move:

1. Moat closes. The codegen logic (how `affects` becomes auto-invalidation,
   how named contexts collapse onto bundled fetches, how the registry-to-
   Provider mapping is shaped) ships compiled instead of as source bytes
   in every consumer's node_modules.

2. Pattern F (lines.push append-walls) becomes structurally unauthorable.
   The emit substrate is askama templates in templates/<target>/*.j2 —
   actual target-language files with {{ ... }} substitution markers,
   syntax-highlighted natively, type-checked against the render context
   structs at compile time. The Rust emit modules build typed render
   contexts and call .render(); no string-builder surface exists.

3. OpenAPI `default`-bearing fields now emit as non-optional in TS / Python
   / Rust — the server always populates them, so consumer code reads them
   without nullable checks. Surfaced by Blazr's typecheck on regeneration.

Layout:
  frontends/mizan-rust/        — Rust port of @mizan/base; #[cfg(feature="pyo3")]
                                 exposes PyMizanClient for the Python target.
  protocol/mizan-codegen/      — codegen binary source + askama templates.
  protocol/mizan-generate/     — npm-package shim. bin/launcher.mjs dispatches
                                 to the platform-appropriate prebuilt binary.
                                 Old generator/ JS tree deleted.
  tests/rust/                  — wire-parity drivers. drive_kernel exercises
                                 raw client.call() / fetch_context(); drive_emitted
                                 exercises the typed crate the codegen emits.
  tests/afi/afi_codegen_app.py — codegen entrypoint module (imports + registers).
  backends/mizan-fastapi/.../schema.py — adds outputNullable so the Rust
                                 codegen can wrap T | None responses in Option<T>.

Verification:
  - 20 mizan-codegen tests green (IR deserialization, byte-equivalent
    parity vs JS baseline for stage1/rust/python/react/vue/svelte,
    structural test for channels).
  - tests/rust/run_wire_parity.py — 12/12 probes green via the Rust binary
    driving the FastAPI fixture end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 18:26:32 -04:00
parent c15c6f3e14
commit 43bcf3f26f
114 changed files with 11090 additions and 2342 deletions

View File

@@ -0,0 +1,248 @@
//! Mizan IR — strongly-typed deserialization of the backends' schema export.
//!
//! Every Mizan backend (Django, FastAPI, mizan-ts) emits the same OpenAPI
//! document with three load-bearing extension fields:
//! - `x-mizan-functions` — array of function entries
//! - `x-mizan-contexts` — map of context groups
//! - `components.schemas` — OpenAPI Pydantic→JSONSchema per Input/Output
//!
//! The structs here deserialize that JSON envelope into typed Rust values
//! the emit targets walk. The OpenAPI document body (paths, info, etc.) is
//! intentionally not modeled — the codegen consumes only the extensions.
use std::collections::BTreeMap;
use indexmap::IndexMap;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct MizanIR {
#[serde(rename = "x-mizan-functions", default)]
pub functions: Vec<MizanFunction>,
#[serde(rename = "x-mizan-contexts", default)]
pub contexts: IndexMap<String, MizanContext>,
/// Django-only channel registrations. FastAPI backends emit an empty list.
#[serde(rename = "x-mizan-channels", default)]
pub channels: Vec<MizanChannel>,
#[serde(default)]
pub components: Components,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MizanChannel {
pub name: String,
#[serde(rename = "pascalName")]
pub pascal_name: String,
#[serde(rename = "hasParams", default)]
pub has_params: bool,
#[serde(rename = "hasReactMessage", default)]
pub has_react_message: bool,
#[serde(rename = "hasDjangoMessage", default)]
pub has_django_message: bool,
#[serde(rename = "paramsType", default)]
pub params_type: Option<String>,
#[serde(rename = "reactMessageType", default)]
pub react_message_type: Option<String>,
#[serde(rename = "djangoMessageType", default)]
pub django_message_type: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MizanFunction {
pub name: String,
#[serde(rename = "camelName")]
pub camel_name: String,
#[serde(rename = "hasInput")]
pub has_input: bool,
#[serde(rename = "inputType")]
pub input_type: Option<String>,
#[serde(rename = "outputType")]
pub output_type: String,
#[serde(rename = "outputNullable", default)]
pub output_nullable: bool,
pub transport: Transport,
#[serde(rename = "isContext", default)]
pub is_context: IsContext,
#[serde(rename = "isForm", default)]
pub is_form: bool,
#[serde(rename = "formName", default)]
pub form_name: Option<String>,
#[serde(rename = "formRole", default)]
pub form_role: Option<String>,
#[serde(default)]
pub affects: Vec<AffectTarget>,
/// Names of contexts whose state is patched by this function's return
/// body via the kernel's `splice_slot` merger. Empty when the function
/// is not a merge target.
#[serde(default)]
pub merge: Vec<String>,
}
#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
#[default]
Http,
Websocket,
Both,
}
/// IR-level `isContext` value. The backends emit `false` for non-context
/// functions and a string (`"global"`, `"user"`, …) for context-grouped
/// functions. Custom Deserialize bridges the boolean/string union into a
/// typed Rust enum.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum IsContext {
#[default]
No,
Yes(String),
}
impl IsContext {
pub fn as_str(&self) -> Option<&str> {
match self {
IsContext::No => None,
IsContext::Yes(s) => Some(s.as_str()),
}
}
}
impl<'de> Deserialize<'de> for IsContext {
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(de)?;
match v {
serde_json::Value::Bool(false) => Ok(IsContext::No),
serde_json::Value::Bool(true) => Err(serde::de::Error::custom(
"isContext: bare `true` is not a valid context name",
)),
serde_json::Value::String(s) => Ok(IsContext::Yes(s)),
serde_json::Value::Null => Ok(IsContext::No),
other => Err(serde::de::Error::custom(format!(
"isContext: expected `false` or string, got {other:?}"
))),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct AffectTarget {
#[serde(rename = "type")]
pub kind: AffectKind,
pub name: String,
#[serde(default)]
pub context: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AffectKind {
Context,
Function,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct MizanContext {
#[serde(default)]
pub functions: Vec<String>,
#[serde(default)]
pub params: IndexMap<String, ContextParam>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ContextParam {
#[serde(rename = "type")]
pub ty: String,
pub required: bool,
#[serde(rename = "sharedBy", default)]
pub shared_by: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct Components {
#[serde(default)]
pub schemas: IndexMap<String, JsonSchema>,
}
/// JSON Schema subset used by the emit targets. Mirrors the surface the
/// existing JS adapters traverse (`$ref`, `anyOf`, `enum`, `type`, `items`,
/// `properties`, `required`, `nullable`). Unknown fields are stashed in
/// `extra` so backends can include schema annotations the codegen ignores.
#[derive(Debug, Deserialize, Default, Clone)]
pub struct JsonSchema {
#[serde(rename = "type", default)]
pub ty: Option<String>,
#[serde(rename = "$ref", default)]
pub r#ref: Option<String>,
#[serde(rename = "enum", default)]
pub r#enum: Option<Vec<serde_json::Value>>,
#[serde(rename = "anyOf", default)]
pub any_of: Option<Vec<JsonSchema>>,
#[serde(default)]
pub nullable: bool,
#[serde(default)]
pub items: Option<Box<JsonSchema>>,
#[serde(default)]
pub properties: Option<IndexMap<String, JsonSchema>>,
#[serde(default)]
pub required: Vec<String>,
#[serde(rename = "additionalProperties", default)]
pub additional_properties: Option<serde_json::Value>,
/// Presence of this field means the schema has a default — the server
/// always populates it. Consumers can treat the field as non-optional
/// even if it's absent from `required`.
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl JsonSchema {
/// `$ref: "#/components/schemas/Foo"` → `Some("Foo")`.
pub fn ref_name(&self) -> Option<&str> {
self.r#ref
.as_deref()
.and_then(|s| s.strip_prefix("#/components/schemas/"))
}
}