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,164 @@
//! `mizan-generate` — Rust codegen binary.
//!
//! Replaces the Node-based `protocol/mizan-generate/generator/cli.mjs`.
//! Reads `mizan.toml`, spawns the configured backend to fetch the IR, and
//! dispatches each `--target` to its `CodegenTarget` impl. Per-target file
//! emission writes under the configured `output` directory.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use mizan_codegen::{config, emit, fetch};
#[derive(Parser, Debug)]
#[command(
name = "mizan-generate",
about = "Mizan code generator — consumes Mizan IR; emits typed clients.",
)]
struct Cli {
/// Path to the codegen config file.
#[arg(short, long, default_value = "mizan.toml")]
config: PathBuf,
/// Output directory (overrides `output` in config).
#[arg(short, long)]
output: Option<PathBuf>,
/// Comma-separated list of targets (overrides `targets` in config).
#[arg(short, long)]
target: Option<String>,
/// Read the IR from a JSON file instead of spawning the backend's
/// schema-export command. The fixture path used by integration tests.
#[arg(long)]
from_json: Option<PathBuf>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let config_exists = cli.config.exists();
let mut config: config::Config = if config_exists {
let config_text = fs::read_to_string(&cli.config)
.with_context(|| format!("reading config: {}", cli.config.display()))?;
toml::from_str(&config_text)
.with_context(|| format!("parsing TOML: {}", cli.config.display()))?
} else if cli.from_json.is_some() {
// --from-json bypasses the fetcher, so a missing config is fine —
// CLI flags supply output + targets.
config::Config {
project_id: None,
output: PathBuf::from("."),
targets: vec![],
source: Default::default(),
rust_kernel: None,
rust_crate_name: None,
}
} else {
return Err(anyhow::anyhow!(
"config not found: {} (pass --from-json to skip fetch)",
cli.config.display(),
));
};
if let Some(o) = cli.output {
config.output = o;
}
if let Some(t) = cli.target {
config.targets = t.split(',').map(|s| s.trim().to_string()).collect();
}
let config_dir = if config_exists {
resolve_config_dir(&cli.config)?
} else {
std::env::current_dir()?
};
let ir = if let Some(json_path) = &cli.from_json {
let abs = if json_path.is_absolute() {
json_path.clone()
} else {
config_dir.join(json_path)
};
eprintln!("[mizan] Reading IR from {}", abs.display());
let raw = fs::read_to_string(&abs)
.with_context(|| format!("read {}", abs.display()))?;
fetch::parse_ir_from_str(&raw)?
} else {
eprintln!("[mizan] Fetching schema...");
fetch::fetch_schema(&config, &config_dir)?
};
eprintln!(
"[mizan] Loaded {} function(s), {} context group(s), {} schema(s)",
ir.functions.len(),
ir.contexts.len(),
ir.components.schemas.len(),
);
// Stage 1 is the framework-agnostic foundation that react/vue/svelte
// import from. Auto-include it whenever any consumer of `./index`
// (the Stage 1 re-export root) is in the target set.
let needs_stage1 = config.targets.iter()
.any(|t| matches!(t.as_str(), "react" | "vue" | "svelte"));
if needs_stage1 && !config.targets.iter().any(|t| t == "stage1") {
config.targets.insert(0, "stage1".to_string());
}
// Channels schema piggybacks on the main schema (x-mizan-channels);
// auto-include the channels emit when react is the target and the
// schema actually carries channels.
if config.targets.iter().any(|t| t == "react")
&& !ir.channels.is_empty()
&& !config.targets.iter().any(|t| t == "channels")
{
config.targets.push("channels".to_string());
}
eprintln!("[mizan] Targets: {}", config.targets.join(", "));
let output_dir = if config.output.is_absolute() {
config.output.clone()
} else {
config_dir.join(&config.output)
};
for target_name in &config.targets {
let Some(target) = emit::target_by_name(target_name) else {
eprintln!("[mizan] WARN: target '{target_name}' has no emitter yet (Phase 2 scaffold)");
continue;
};
let files = target.emit(&ir, &config);
for file in files {
let path = output_dir.join(&file.rel_path);
write_output(&path, &file.content)?;
eprintln!("[mizan] {} -> {}", target.name(), file.rel_path.display());
}
}
eprintln!("[mizan] Generation complete.");
Ok(())
}
fn resolve_config_dir(config_path: &Path) -> Result<PathBuf> {
let abs = fs::canonicalize(config_path)
.with_context(|| format!("canonicalize {}", config_path.display()))?;
Ok(abs
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from(".")))
}
fn write_output(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
fs::write(path, content).with_context(|| format!("write {}", path.display()))
}