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:
248
protocol/mizan-codegen/src/ir.rs
Normal file
248
protocol/mizan-codegen/src/ir.rs
Normal 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/"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user