Files
Ryth Azhur 9900f8a36f Mizan IR: cut over to KDL, delete OpenAPI envelope
Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."

End-to-end cutover. No transitional path left on main.

Forward direction:
  cores/mizan-python/src/mizan_core/ir.py
    build_ir() walks mizan_core.registry, introspects Pydantic
    models directly (no JSON-Schema indirection), and emits the
    Mizan IR document. The KDL grammar is locked in this file's
    module docstring.

Backends emit KDL:
  backends/mizan-fastapi/src/mizan_fastapi/ir.py
    `python -m mizan_fastapi.ir <module>` — CLI entry point.
  backends/mizan-django/.../management/commands/export_mizan_ir.py
    `manage.py export_mizan_ir` — Django mgmt command.

Codegen consumes KDL:
  protocol/mizan-codegen/Cargo.toml: + kdl = "6"
  protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
    + TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
    replacing the JsonSchema sprawl. KDL parser walks the
    `kdl::KdlDocument` tree into typed Rust structs.
  protocol/mizan-codegen/src/fetch.rs: subprocess command switches
    to the new IR-export entry points.
  All emit modules (stage1 / react / python / rust / vue / svelte /
    channels) port their type-walkers from JsonSchema to the new
    sum types — case analysis collapses substantially.

Substrate-honesty wins beyond the moat closure:
  - `int | bool` multi-arm unions land as `TypeShape::Union` (was
    silently coerced to "string" before).
  - `<CamelName>Output = list[T]` returns emit as named alias
    types instead of struct-shaped wrappers, so consumer code
    `.map()` works directly on the type.
  - Pydantic field defaults flow through to `default` properties
    in KDL, then back to non-optional shape in every target.

Deleted:
  - backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
  - backends/mizan-django/.../export_mizan_schema.py
  - openapi-bearing half of mizan/export/__init__.py (edge
    manifest generator preserved — separate concern).
  - tests/afi/schema_normalizer.py
  - tests/fixtures/{afi_schema.json, channels_schema.json}
  - tests/fixtures/js_* baseline directories.

Verification:
  - 20 mizan-codegen unit tests green (IR deserialization,
    byte-equivalence parity across stage1/rust/python/react/vue/svelte
    against fresh KDL-driven baselines, channels structural).
  - tests/rust/run_wire_parity.py: 12/12 probes green driving
    the binary end-to-end through KDL.
  - Blazr studio-ui typechecks against the regenerated React client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:47 -04:00

165 lines
5.3 KiB
Rust

//! `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), {} type(s)",
ir.functions.len(),
ir.contexts.len(),
ir.types.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()))
}