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>
This commit is contained in:
2026-05-17 19:14:47 -04:00
parent 7fb0c4a400
commit 9900f8a36f
86 changed files with 2231 additions and 2272 deletions

View File

@@ -117,6 +117,12 @@ dependencies = [
"serde",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.1"
@@ -214,6 +220,17 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kdl"
version = "6.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
dependencies = [
"miette",
"num",
"winnow 0.6.24",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -226,6 +243,16 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"unicode-width",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -256,6 +283,7 @@ dependencies = [
"askama",
"clap",
"indexmap",
"kdl",
"serde",
"serde_json",
"toml",
@@ -271,6 +299,70 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -412,7 +504,7 @@ dependencies = [
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
"winnow 0.7.15",
]
[[package]]
@@ -433,6 +525,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -454,6 +552,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.15"

View File

@@ -15,6 +15,7 @@ path = "src/lib.rs"
[dependencies]
askama = "0.12"
clap = { version = "4", features = ["derive"] }
kdl = "6"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
toml = "0.8"

View File

@@ -11,7 +11,7 @@ use indexmap::IndexMap;
use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::ir::{JsonSchema, MizanChannel, MizanIR};
use crate::ir::{MizanChannel, MizanIR, NamedType, Primitive, StructField, TypeShape};
pub struct ChannelsTarget;
@@ -25,7 +25,7 @@ impl CodegenTarget for ChannelsTarget {
return Vec::new();
}
let schemas_block = emit_channel_schemas(&ir.channels, &ir.components.schemas);
let schemas_block = emit_channel_schemas(&ir.channels, &ir.types);
let types_content = ChannelsTypes {
channels: ir.channels.iter().map(ChannelView::from_ir).collect(),
@@ -34,9 +34,9 @@ impl CodegenTarget for ChannelsTarget {
let mut type_imports: Vec<String> = Vec::new();
for ch in &ir.channels {
if ch.has_params { if let Some(t) = &ch.params_type { type_imports.push(t.clone()); } }
if ch.has_react_message { if let Some(t) = &ch.react_message_type { type_imports.push(t.clone()); } }
if ch.has_django_message { if let Some(t) = &ch.django_message_type { type_imports.push(t.clone()); } }
if ch.has_params() { if let Some(t) = &ch.params_type { type_imports.push(t.clone()); } }
if ch.has_react_message() { if let Some(t) = &ch.react_message_type { type_imports.push(t.clone()); } }
if ch.has_django_message() { if let Some(t) = &ch.django_message_type { type_imports.push(t.clone()); } }
}
let hooks_content = ChannelsHooks {
@@ -92,12 +92,12 @@ impl<'a> ChannelView<'a> {
Self {
name: &ch.name,
pascal_name: &ch.pascal_name,
has_params: ch.has_params,
has_react_message: ch.has_react_message,
has_django_message: ch.has_django_message,
params_type_or_record: if ch.has_params { params_type.clone() } else { "Record<string, never>".to_string() },
react_msg_type_or_never: if ch.has_react_message { react_message_type.clone() } else { "never".to_string() },
django_msg_type_or_never: if ch.has_django_message { django_message_type.clone() } else { "never".to_string() },
has_params: ch.has_params(),
has_react_message: ch.has_react_message(),
has_django_message: ch.has_django_message(),
params_type_or_record: if ch.has_params() { params_type.clone() } else { "Record<string, never>".to_string() },
react_msg_type_or_never: if ch.has_react_message() { react_message_type.clone() } else { "never".to_string() },
django_msg_type_or_never: if ch.has_django_message() { django_message_type.clone() } else { "never".to_string() },
params_type,
react_message_type,
django_message_type,
@@ -108,13 +108,13 @@ impl<'a> ChannelView<'a> {
fn emit_channel_schemas(
channels: &[MizanChannel],
schemas: &IndexMap<String, JsonSchema>,
types: &IndexMap<String, NamedType>,
) -> String {
let mut blocks: Vec<String> = Vec::new();
for ch in channels {
for ty in [&ch.params_type, &ch.react_message_type, &ch.django_message_type].iter().filter_map(|t| t.as_ref()) {
if let Some(schema) = schemas.get(ty) {
blocks.push(emit_schema_as_ts(ty, schema));
if let Some(named) = types.get(ty) {
blocks.push(emit_named_type_as_ts(ty, named));
}
}
}
@@ -122,42 +122,57 @@ fn emit_channel_schemas(
}
fn emit_schema_as_ts(name: &str, schema: &JsonSchema) -> String {
if let Some(props) = &schema.properties {
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let fields = props.iter()
.map(|(field_name, field_schema)| {
let opt = if required.contains(field_name.as_str()) { "" } else { "?" };
let ty = ts_type_expression(field_schema);
format!(" {field_name}{opt}: {ty}")
})
fn emit_named_type_as_ts(name: &str, ty: &NamedType) -> String {
match ty {
NamedType::Struct(fields) => emit_interface(name, fields),
NamedType::List(inner) => format!("export type {name} = {}[]", ts_type_expression(inner)),
NamedType::Enum(variants) => {
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
format!("export type {name} = {union}")
}
NamedType::Alias(inner) => format!("export type {name} = {}", ts_type_expression(inner)),
}
}
fn emit_interface(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("export interface {name} {{}}");
}
let body = fields.iter()
.map(|f| {
let is_required = f.required || f.default.is_some();
let opt = if is_required { "" } else { "?" };
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
})
.collect::<Vec<_>>()
.join("\n");
format!("export interface {name} {{\n{body}\n}}")
}
fn ts_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => name.clone(),
TypeShape::Primitive(p) => primitive_to_ts(*p).to_string(),
TypeShape::List(inner) => format!("{}[]", ts_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | null", ts_type_expression(inner)),
TypeShape::Enum(variants) => variants.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join("\n");
if fields.is_empty() {
format!("export interface {name} {{}}")
} else {
format!("export interface {name} {{\n{fields}\n}}")
}
} else {
format!("export type {name} = {}", ts_type_expression(schema))
.join(" | "),
TypeShape::Union(branches) => branches.iter()
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | "),
}
}
fn ts_type_expression(schema: &JsonSchema) -> String {
if let Some(ref_name) = schema.ref_name() {
return ref_name.to_string();
}
match schema.ty.as_deref() {
Some("integer") | Some("number") => "number".to_string(),
Some("boolean") => "boolean".to_string(),
Some("string") => "string".to_string(),
Some("array") => {
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
format!("{elem}[]")
}
Some("object") => "Record<string, unknown>".to_string(),
_ => "unknown".to_string(),
fn primitive_to_ts(p: Primitive) -> &'static str {
match p {
Primitive::Integer | Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}

View File

@@ -14,7 +14,9 @@ use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::{pascal_case, rust_ident, snake_case};
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField, TypeShape,
};
pub struct PythonClient;
@@ -24,8 +26,8 @@ impl CodegenTarget for PythonClient {
fn name(&self) -> &'static str { "python" }
fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
let schemas_block = ir.components.schemas.iter()
.map(|(name, schema)| emit_schema_block(name, schema))
let schemas_block = ir.types.iter()
.map(|(name, ty)| emit_schema_block(name, ty))
.collect::<Vec<_>>()
.join("\n\n");
@@ -66,119 +68,67 @@ struct ClientTemplate {
// ─── types.py schema bodies ────────────────────────────────────────────────
fn emit_schema_block(raw_name: &str, schema: &JsonSchema) -> String {
fn emit_schema_block(raw_name: &str, ty: &NamedType) -> String {
let name = pascal_case(raw_name);
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
let literal = values.iter()
.filter_map(|v| v.as_str())
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ");
return format!("{name} = Literal[{literal}]");
match ty {
NamedType::Struct(fields) => emit_pydantic_class(&name, fields),
NamedType::List(inner) => format!("{name} = list[{}]", py_type_expression(inner)),
NamedType::Enum(variants) => {
let literal = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
format!("{name} = Literal[{literal}]")
}
NamedType::Alias(inner) => format!("{name} = {}", py_type_expression(inner)),
}
if schema.ty.as_deref() == Some("array") {
let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
return format!("{name} = list[{elem}]");
}
if schema.ty.as_deref() == Some("object") {
if let Some(props) = &schema.properties {
return emit_pydantic_class(&name, schema, props);
}
}
let ty = py_type_from_schema(schema);
format!("{name} = {ty}")
}
fn emit_pydantic_class(
name: &str,
schema: &JsonSchema,
properties: &IndexMap<String, JsonSchema>,
) -> String {
if properties.is_empty() {
fn emit_pydantic_class(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("class {name}(BaseModel):\n pass");
}
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let field_lines = properties.iter()
.map(|(field_raw, field_schema)| {
let mut ty = py_type_from_schema(field_schema);
let is_required = required.contains(field_raw.as_str())
|| field_schema.default.is_some();
let field_lines = fields.iter()
.map(|f| {
let mut ty = py_type_expression(&f.shape);
let is_required = f.required || f.default.is_some();
if !is_required {
if !ty.ends_with(" | None") {
ty = format!("{ty} | None");
}
format!(" {}: {ty} = None", rust_ident(field_raw))
format!(" {}: {ty} = None", rust_ident(&f.name))
} else {
format!(" {}: {ty}", rust_ident(field_raw))
format!(" {}: {ty}", rust_ident(&f.name))
}
})
.collect::<Vec<_>>()
.join("\n");
format!("class {name}(BaseModel):\n{field_lines}")
}
fn py_type_from_schema(schema: &JsonSchema) -> String {
if let Some(ref_name) = schema.ref_name() {
return pascal_case(ref_name);
}
if let Some(any_of) = &schema.any_of {
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
let non_null: Vec<&JsonSchema> = any_of
.iter()
.filter(|s| s.ty.as_deref() != Some("null"))
.collect();
if has_null && non_null.len() == 1 {
return format!("{} | None", py_type_from_schema(non_null[0]));
fn py_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => pascal_case(name),
TypeShape::Primitive(p) => primitive_to_py(*p).to_string(),
TypeShape::List(inner) => format!("list[{}]", py_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | None", py_type_expression(inner)),
TypeShape::Enum(variants) => {
let parts = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(", ");
format!("Literal[{parts}]")
}
}
let nullable = schema.nullable;
let inner = inner_py_type(schema);
if nullable {
format!("{inner} | None")
} else {
inner
TypeShape::Union(branches) => branches.iter()
.map(py_type_expression)
.collect::<Vec<_>>()
.join(" | "),
}
}
fn inner_py_type(schema: &JsonSchema) -> String {
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
let parts = values.iter()
.filter_map(|v| v.as_str())
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ");
return format!("Literal[{parts}]");
}
}
match schema.ty.as_deref() {
Some("integer") => "int".to_string(),
Some("number") => "float".to_string(),
Some("boolean") => "bool".to_string(),
Some("string") => "str".to_string(),
Some("array") => {
let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
format!("list[{elem}]")
}
Some("object") => {
if schema.properties.is_some() { "Any".to_string() }
else { "dict[str, Any]".to_string() }
}
_ => "Any".to_string(),
fn primitive_to_py(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "int",
Primitive::Number => "float",
Primitive::Boolean => "bool",
Primitive::String => "str",
}
}
@@ -216,12 +166,12 @@ fn build_client_template(ir: &MizanIR) -> ClientTemplate {
}
fn py_arg_type(json_ty: &str) -> &'static str {
match json_ty {
"integer" => "int",
"number" => "float",
"boolean" => "bool",
_ => "str",
fn py_arg_type(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "int",
Primitive::Number => "float",
Primitive::Boolean => "bool",
Primitive::String => "str",
}
}
@@ -231,7 +181,7 @@ fn emit_fetch_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
let param_args = ctx_meta.params.iter()
.map(|(n, m)| {
let ident = rust_ident(n);
let ty = py_arg_type(&m.ty);
let ty = py_arg_type(m.ty);
if m.required { format!("{ident}: {ty}") }
else { format!("{ident}: {ty} | None = None") }
})
@@ -259,7 +209,7 @@ fn emit_subscribe_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
let param_args = ctx_meta.params.iter()
.map(|(n, m)| {
let ident = rust_ident(n);
let ty = py_arg_type(&m.ty);
let ty = py_arg_type(m.ty);
if m.required { format!("{ident}: {ty}") }
else { format!("{ident}: {ty} | None = None") }
})

View File

@@ -10,7 +10,10 @@ use crate::config::{Config, RustKernelSpec};
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::{pascal_case, rust_ident, rust_type_ident, snake_case};
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive,
StructField as IrStructField, TypeShape,
};
pub struct RustCrate;
@@ -35,7 +38,7 @@ impl CodegenTarget for RustCrate {
.render().expect("Cargo.toml renders"),
));
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.components.schemas)));
out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.types)));
let mut context_modules: Vec<String> = Vec::new();
for (ctx_name, ctx_meta) in &ir.contexts {
@@ -119,8 +122,8 @@ struct ContextTemplate<'a> {
snake: String,
ctx_name: &'a str,
type_imports: Vec<String>,
data_fields: Vec<StructField>,
params: Vec<StructField>,
data_fields: Vec<RustField>,
params: Vec<RustField>,
}
@@ -144,7 +147,10 @@ struct TypesTemplate {
}
struct StructField {
/// Renderer-side view of a single Rust struct field. Distinct from
/// `ir::StructField` (the IR shape) because the renderer carries
/// already-rendered identifiers and rename flags.
struct RustField {
raw_name: String,
ident: String,
ty: String,
@@ -212,10 +218,10 @@ fn emit_context_file(
ctx_fns.iter().map(|f| rust_type_ident(&f.output_type)),
);
let data_fields: Vec<StructField> = ctx_fns.iter()
let data_fields: Vec<RustField> = ctx_fns.iter()
.map(|f| {
let ident = rust_ident(&f.name);
StructField {
RustField {
has_rename: ident != f.name,
raw_name: f.name.clone(),
ident,
@@ -224,12 +230,12 @@ fn emit_context_file(
})
.collect();
let params: Vec<StructField> = ctx_meta.params.iter()
let params: Vec<RustField> = ctx_meta.params.iter()
.map(|(p_name, p_meta)| {
let ident = rust_ident(p_name);
let base = param_rust_type(&p_meta.ty);
let base = param_rust_type(p_meta.ty);
let ty = if p_meta.required { base.to_string() } else { format!("Option<{base}>") };
StructField {
RustField {
has_rename: ident != *p_name,
raw_name: p_name.clone(),
ident,
@@ -249,12 +255,12 @@ fn emit_context_file(
}
fn param_rust_type(json_ty: &str) -> &'static str {
match json_ty {
"integer" => "i64",
"number" => "f64",
"boolean" => "bool",
_ => "String",
fn param_rust_type(p: Primitive) -> &'static str {
match p {
Primitive::Integer => "i64",
Primitive::Number => "f64",
Primitive::Boolean => "bool",
Primitive::String => "String",
}
}
@@ -303,33 +309,26 @@ fn emit_call_file(fn_meta: &MizanFunction) -> String {
// ─── types.rs ──────────────────────────────────────────────────────────────
/// Per-types-file context tracking enum names hoisted out of inline
/// `field { enum "a" "b" }` declarations into Rust top-level enum types.
struct EnumCtx {
hoisted: Vec<(String, Vec<serde_json::Value>)>,
depth: usize,
hoisted: Vec<(String, Vec<String>)>,
enum_name: Option<String>,
}
fn emit_types_rs(schemas: &IndexMap<String, JsonSchema>) -> String {
let mut ctx = EnumCtx { hoisted: Vec::new(), depth: 0, enum_name: None };
fn emit_types_rs(types: &IndexMap<String, NamedType>) -> String {
let mut ctx = EnumCtx { hoisted: Vec::new(), enum_name: None };
let schemas_block = schemas.iter()
.map(|(raw_name, schema)| {
let schemas_block = types.iter()
.map(|(raw_name, ty)| {
let name = rust_type_ident(raw_name);
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
return emit_string_enum(&name, values);
}
match ty {
NamedType::Struct(fields) => emit_struct_decl(&name, fields, &mut ctx),
NamedType::List(inner) => emit_transparent_array(&name, inner, &mut ctx),
NamedType::Enum(variants) => emit_string_enum(&name, variants),
NamedType::Alias(inner) => emit_type_alias(&name, inner, &mut ctx),
}
if schema.ty.as_deref() == Some("array") {
return emit_transparent_array(&name, schema, &mut ctx);
}
if schema.ty.as_deref() == Some("object") {
if let Some(props) = &schema.properties {
return emit_struct(&name, schema, props, &mut ctx);
}
}
emit_type_alias(&name, schema, &mut ctx)
})
.collect::<Vec<_>>()
.join("\n");
@@ -344,12 +343,11 @@ fn emit_types_rs(schemas: &IndexMap<String, JsonSchema>) -> String {
}
fn emit_string_enum(name: &str, variants: &[serde_json::Value]) -> String {
fn emit_string_enum(name: &str, variants: &[String]) -> String {
let body = variants.iter()
.filter_map(|v| v.as_str())
.map(|v| {
let ident = pascal_case(v);
let rename = if ident == v {
let rename = if ident == *v {
String::new()
} else {
format!(" #[serde(rename = {})]\n", json_str(v))
@@ -365,40 +363,33 @@ fn emit_string_enum(name: &str, variants: &[serde_json::Value]) -> String {
}
fn emit_transparent_array(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
ctx.depth = 1;
fn emit_transparent_array(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
ctx.enum_name = None;
let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx);
let inner_ty = rust_type_from_shape(inner, ctx);
format!(
"#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner}>);\n",
"#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner_ty}>);\n",
)
}
fn emit_struct(
fn emit_struct_decl(
name: &str,
schema: &JsonSchema,
properties: &IndexMap<String, JsonSchema>,
fields: &[IrStructField],
ctx: &mut EnumCtx,
) -> String {
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let fields = properties.iter()
.map(|(field_raw, field_schema)| {
let field_name = rust_ident(field_raw);
ctx.depth = 1;
ctx.enum_name = Some(format!("{name}_{}", pascal_case(field_raw)));
let mut ty = rust_type_from_schema(field_schema, ctx);
let is_required = required.contains(field_raw.as_str())
|| field_schema.default.is_some();
let fields_body = fields.iter()
.map(|f| {
let field_name = rust_ident(&f.name);
ctx.enum_name = Some(format!("{name}_{}", pascal_case(&f.name)));
let mut ty = rust_type_from_shape(&f.shape, ctx);
let is_required = f.required || f.default.is_some();
if !is_required && !ty.starts_with("Option<") {
ty = format!("Option<{ty}>");
}
let rename = if field_name == *field_raw {
let rename = if field_name == f.name {
String::new()
} else {
format!(" #[serde(rename = \"{field_raw}\")]\n")
format!(" #[serde(rename = \"{raw}\")]\n", raw = f.name)
};
format!("{rename} pub {field_name}: {ty},")
})
@@ -406,69 +397,48 @@ fn emit_struct(
.join("\n");
format!(
"#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields}\n}}\n",
"#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields_body}\n}}\n",
)
}
fn emit_type_alias(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
ctx.depth = 0;
fn emit_type_alias(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String {
ctx.enum_name = Some(name.to_string());
let ty = rust_type_from_schema(schema, ctx);
let ty = rust_type_from_shape(inner, ctx);
format!("pub type {name} = {ty};\n")
}
fn rust_type_from_schema(schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
if let Some(r) = schema.ref_name() {
return rust_type_ident(r);
}
if let Some(any_of) = &schema.any_of {
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
let non_null: Vec<&JsonSchema> = any_of
.iter()
.filter(|s| s.ty.as_deref() != Some("null"))
.collect();
if has_null && non_null.len() == 1 {
fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
match shape {
TypeShape::Ref(name) => rust_type_ident(name),
TypeShape::Primitive(Primitive::Integer) => "i64".to_string(),
TypeShape::Primitive(Primitive::Number) => "f64".to_string(),
TypeShape::Primitive(Primitive::Boolean) => "bool".to_string(),
TypeShape::Primitive(Primitive::String) => "String".to_string(),
TypeShape::List(inner) => {
ctx.enum_name = None;
return format!("Option<{}>", rust_type_from_schema(non_null[0], ctx));
format!("Vec<{}>", rust_type_from_shape(inner, ctx))
}
}
let nullable = schema.nullable;
let inner = inner_rust_type(schema, ctx);
if nullable {
format!("Option<{inner}>")
} else {
inner
}
}
fn inner_rust_type(schema: &JsonSchema, ctx: &mut EnumCtx) -> String {
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
TypeShape::Optional(inner) => {
ctx.enum_name = None;
format!("Option<{}>", rust_type_from_shape(inner, ctx))
}
TypeShape::Enum(variants) => {
// Inline enums hoist out into top-level Rust enum types so the
// generated struct field can reference them by name.
let enum_name = ctx
.enum_name
.clone()
.unwrap_or_else(|| format!("Enum_{}", ctx.depth));
ctx.hoisted.push((enum_name.clone(), values.clone()));
return enum_name;
.unwrap_or_else(|| "Enum_inline".to_string());
ctx.hoisted.push((enum_name.clone(), variants.clone()));
enum_name
}
}
match schema.ty.as_deref() {
Some("integer") => "i64".to_string(),
Some("number") => "f64".to_string(),
Some("boolean") => "bool".to_string(),
Some("string") => "String".to_string(),
Some("array") => {
ctx.depth += 1;
ctx.enum_name = None;
let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx);
format!("Vec<{inner}>")
TypeShape::Union(_branches) => {
// Rust serde doesn't have a clean way to deserialize an untagged
// multi-arm union without losing type info; fall back to a JSON
// Value so the consumer can match on the runtime variant.
"serde_json::Value".to_string()
}
Some("object") => "serde_json::Value".to_string(),
_ => "serde_json::Value".to_string(),
}
}

View File

@@ -20,7 +20,9 @@ use askama::Template;
use indexmap::IndexMap;
use crate::config::Config;
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};
use crate::ir::{
IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField, TypeShape,
};
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::pascal_case;
@@ -94,7 +96,7 @@ impl CodegenTarget for Stage1 {
fn emit(&self, ir: &MizanIR, config: &Config) -> Vec<EmittedFile> {
let mut out: Vec<EmittedFile> = Vec::new();
out.push(EmittedFile::new("types.ts", emit_types(&ir.components.schemas)));
out.push(EmittedFile::new("types.ts", emit_types(&ir.types)));
for (ctx_name, ctx_meta) in &ir.contexts {
let content = emit_context_file(ctx_name, ctx_meta, &ir.functions);
@@ -157,7 +159,7 @@ fn emit_context_file(
let params: Vec<ContextParamField> = ctx_meta.params.iter()
.map(|(name, meta)| ContextParamField {
name,
ts_type: json_ty_to_ts(&meta.ty),
ts_type: primitive_to_ts(meta.ty),
required: meta.required,
})
.collect();
@@ -174,11 +176,11 @@ fn emit_context_file(
}
fn json_ty_to_ts(json_ty: &str) -> &'static str {
match json_ty {
"integer" | "number" => "number",
"boolean" => "boolean",
_ => "string",
fn primitive_to_ts(p: Primitive) -> &'static str {
match p {
Primitive::Integer | Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}
@@ -239,131 +241,60 @@ fn emit_stage1_index(ir: &MizanIR, config: &Config) -> String {
// ─── types.ts ──────────────────────────────────────────────────────────────
fn emit_types(schemas: &IndexMap<String, JsonSchema>) -> String {
fn emit_types(types: &IndexMap<String, NamedType>) -> String {
let mut out = String::new();
out.push_str("// AUTO-GENERATED by mizan — do not edit\n\n");
for (raw_name, schema) in schemas {
out.push_str(&emit_schema_decl(raw_name, schema));
for (name, ty) in types {
out.push_str(&emit_named_type(name, ty));
out.push('\n');
}
out
}
fn emit_schema_decl(name: &str, schema: &JsonSchema) -> String {
// String enum → union of string literals.
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
let union = values
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(" | ");
return format!("export type {name} = {union}\n");
fn emit_named_type(name: &str, ty: &NamedType) -> String {
match ty {
NamedType::Struct(fields) => emit_interface(name, fields),
NamedType::List(inner) => format!("export type {name} = {}[]\n", ts_type_expression(inner)),
NamedType::Enum(variants) => {
let union = variants.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join(" | ");
format!("export type {name} = {union}\n")
}
NamedType::Alias(inner) => format!("export type {name} = {}\n", ts_type_expression(inner)),
}
// Top-level array → array alias.
if schema.ty.as_deref() == Some("array") {
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
return format!("export type {name} = {elem}[]\n");
}
// Object with properties → interface declaration.
if schema.ty.as_deref() == Some("object") {
if let Some(props) = &schema.properties {
return emit_interface(name, schema, props);
}
}
// Fallback — alias to a structural expression.
let expr = ts_type_expression(schema);
format!("export type {name} = {expr}\n")
}
fn emit_interface(
name: &str,
schema: &JsonSchema,
properties: &IndexMap<String, JsonSchema>,
) -> String {
let required: std::collections::HashSet<&str> =
schema.required.iter().map(String::as_str).collect();
let fields = properties
.iter()
.map(|(field_name, field_schema)| {
// Fields are non-optional if they're explicitly required OR
// if they carry a default value (server always populates).
let is_required = required.contains(field_name.as_str())
|| field_schema.default.is_some();
fn emit_interface(name: &str, fields: &[StructField]) -> String {
if fields.is_empty() {
return format!("export interface {name} {{}}\n");
}
let body = fields.iter()
.map(|f| {
// Field is non-optional if required OR has a default (server always populates).
let is_required = f.required || f.default.is_some();
let opt = if is_required { "" } else { "?" };
let ty = ts_type_expression(field_schema);
format!(" {field_name}{opt}: {ty}")
format!(" {}{opt}: {}", f.name, ts_type_expression(&f.shape))
})
.collect::<Vec<_>>()
.join("\n");
if fields.is_empty() {
format!("export interface {name} {{}}\n")
} else {
format!("export interface {name} {{\n{fields}\n}}\n")
}
format!("export interface {name} {{\n{body}\n}}\n")
}
fn ts_type_expression(schema: &JsonSchema) -> String {
// `$ref` → bare type name reference into components.schemas.
if let Some(ref_name) = schema.ref_name() {
return ref_name.to_string();
}
// `anyOf` with a null variant → `T | null`.
if let Some(any_of) = &schema.any_of {
let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
let non_null: Vec<&JsonSchema> = any_of
.iter()
.filter(|s| s.ty.as_deref() != Some("null"))
.collect();
if has_null && non_null.len() == 1 {
return format!("{} | null", ts_type_expression(non_null[0]));
}
let union = any_of
.iter()
fn ts_type_expression(shape: &TypeShape) -> String {
match shape {
TypeShape::Ref(name) => name.clone(),
TypeShape::Primitive(p) => primitive_to_ts(*p).to_string(),
TypeShape::List(inner) => format!("{}[]", ts_type_expression(inner)),
TypeShape::Optional(inner) => format!("{} | null", ts_type_expression(inner)),
TypeShape::Enum(variants) => variants.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(" | "),
TypeShape::Union(branches) => branches.iter()
.map(ts_type_expression)
.collect::<Vec<_>>()
.join(" | ");
return union;
}
if let Some(values) = &schema.r#enum {
if schema.ty.as_deref() == Some("string") {
return values
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(" | ");
}
}
let base = match schema.ty.as_deref() {
Some("integer") | Some("number") => "number".to_string(),
Some("boolean") => "boolean".to_string(),
Some("string") => "string".to_string(),
Some("array") => {
let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
format!("{elem}[]")
}
Some("object") => "Record<string, unknown>".to_string(),
Some("null") => "null".to_string(),
_ => "unknown".to_string(),
};
if schema.nullable {
format!("{base} | null")
} else {
base
.join(" | "),
}
}

View File

@@ -1,12 +1,9 @@
//! Schema fetching — spawns the configured backend's schema-export command
//! and deserializes its stdout into a typed `MizanIR`.
//! Schema fetching — spawns the configured backend's IR-export command
//! and parses the KDL it writes to stdout.
//!
//! Two backends recognized today:
//! - FastAPI: `python -m mizan_fastapi.cli <module>`
//! - Django: `python manage.py export_mizan_schema --indent 0`
//!
//! The fetcher reads stdout, skips any banner text before the first `{`,
//! and parses the remainder as JSON.
//! Backends:
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
//! - Django: `python manage.py export_mizan_ir`
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -14,7 +11,7 @@ use std::process::Command;
use anyhow::{anyhow, Context, Result};
use crate::config::{Config, DjangoSource, FastapiSource};
use crate::ir::MizanIR;
use crate::ir::{parse_ir, MizanIR};
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
@@ -28,7 +25,7 @@ pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
));
};
parse_ir(&raw)
parse_ir(&raw).context("parsing Mizan IR from backend KDL output")
}
@@ -41,11 +38,11 @@ fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result<String> {
let (program, mut args) = resolve_command(&src.command, &src.python);
args.extend([
"-m".to_string(),
"mizan_fastapi.cli".to_string(),
"mizan_fastapi.ir".to_string(),
src.module.clone(),
]);
run_subprocess(&program, &args, &cwd, &src.env, "FastAPI schema export")
run_subprocess(&program, &args, &cwd, &src.env, "FastAPI IR export")
}
@@ -58,22 +55,14 @@ fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
let (program, mut args) = resolve_command(&src.command, &src.python);
// If the user supplied an explicit command (e.g. `uv run python`), they
// expect to invoke from the manage_dir without a path prefix on manage.py.
// Otherwise we pass the absolute manage_path so the python interpreter
// doesn't depend on cwd.
if src.command.is_some() {
args.push("manage.py".to_string());
} else {
args.push(manage_path.to_string_lossy().into_owned());
}
args.extend([
"export_mizan_schema".to_string(),
"--indent".to_string(),
"0".to_string(),
]);
args.push("export_mizan_ir".to_string());
run_subprocess(&program, &args, &manage_dir, &src.env, "Django schema export")
run_subprocess(&program, &args, &manage_dir, &src.env, "Django IR export")
}
@@ -122,23 +111,13 @@ fn run_subprocess(
}
fn parse_ir(raw: &str) -> Result<MizanIR> {
let json_start = raw
.find('{')
.ok_or_else(|| anyhow!("no JSON object found in schema-export output"))?;
serde_json::from_str(&raw[json_start..]).context("deserializing Mizan IR from schema JSON")
/// Library helper: parse a KDL IR from a string.
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source)
}
/// Library helper for tests: deserialize an IR from a pre-fetched JSON string
/// (no subprocess). Mirrors `parse_ir` but exposed for crate-external callers.
pub fn parse_ir_from_str(json: &str) -> Result<MizanIR> {
parse_ir(json)
}
/// Library helper: resolve a path relative to the config directory, returning
/// an absolute path. Consumers may want this when constructing output paths.
/// Library helper: resolve a path relative to the config directory.
pub fn resolve_path(config_dir: &Path, p: impl Into<PathBuf>) -> PathBuf {
let p = p.into();
if p.is_absolute() {

View File

@@ -1,122 +1,121 @@
//! 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;
//! Mizan IR — the canonical KDL document every backend adapter emits and
//! every codegen target consumes. See `docs/AFI_ARCHITECTURE.md` and
//! `cores/mizan-python/src/mizan_core/ir.py` for the locked grammar.
use anyhow::{anyhow, bail, Context, Result};
use indexmap::IndexMap;
use serde::Deserialize;
use kdl::{KdlDocument, KdlNode, KdlValue};
#[derive(Debug, Deserialize)]
#[derive(Debug, Default)]
pub struct MizanIR {
#[serde(rename = "x-mizan-functions", default)]
pub types: IndexMap<String, NamedType>,
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 {
// ─── Type system ────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum NamedType {
Struct(Vec<StructField>),
List(TypeShape),
Enum(Vec<String>),
Alias(TypeShape),
}
#[derive(Debug, Clone)]
pub struct StructField {
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>,
pub required: bool,
pub default: Option<DefaultValue>,
pub shape: TypeShape,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub enum TypeShape {
Primitive(Primitive),
Ref(String),
List(Box<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<String>),
/// Multi-arm union with two or more non-null branches.
Union(Vec<TypeShape>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Primitive { Integer, Number, Boolean, String }
impl Primitive {
fn parse(s: &str) -> Result<Self> {
match s {
"integer" => Ok(Primitive::Integer),
"number" => Ok(Primitive::Number),
"boolean" => Ok(Primitive::Boolean),
"string" => Ok(Primitive::String),
other => bail!("unknown primitive {other:?}"),
}
}
}
#[derive(Debug, Clone)]
pub enum DefaultValue {
Integer(i64),
Number(f64),
Boolean(bool),
String(String),
Null,
}
// ─── Functions ──────────────────────────────────────────────────────────────
#[derive(Debug, 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,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport { Http, Websocket, Both }
impl Transport {
fn parse(s: &str) -> Result<Self> {
match s {
"http" => Ok(Transport::Http),
"websocket" => Ok(Transport::Websocket),
"both" => Ok(Transport::Both),
other => bail!("unknown transport {other:?}"),
}
}
}
/// 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),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IsContext { No, Yes(String) }
impl IsContext {
pub fn as_str(&self) -> Option<&str> {
@@ -127,122 +126,343 @@ impl IsContext {
}
}
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)]
#[derive(Debug, 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, Clone, Copy, PartialEq, Eq)]
pub enum AffectKind { Context, Function }
#[derive(Debug, Deserialize, Default, Clone)]
// ─── Contexts ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct MizanContext {
#[serde(default)]
pub functions: Vec<String>,
#[serde(default)]
pub params: IndexMap<String, ContextParam>,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone)]
pub struct ContextParam {
#[serde(rename = "type")]
pub ty: String,
pub ty: Primitive,
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>,
// ─── Channels (Django-only) ─────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct MizanChannel {
pub name: String,
pub pascal_name: String,
pub params_type: Option<String>,
pub react_message_type: Option<String>,
pub django_message_type: Option<String>,
}
/// 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 MizanChannel {
pub fn has_params(&self) -> bool { self.params_type.is_some() }
pub fn has_react_message(&self) -> bool { self.react_message_type.is_some() }
pub fn has_django_message(&self) -> bool { self.django_message_type.is_some() }
}
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/"))
// ─── KDL parsing ────────────────────────────────────────────────────────────
pub fn parse_ir(source: &str) -> Result<MizanIR> {
let doc: KdlDocument = source.parse()
.map_err(|e| anyhow!("KDL parse error: {e}"))?;
let mut ir = MizanIR::default();
for node in doc.nodes() {
match node.name().value() {
"type" => {
let (name, ty) = parse_named_type(node)?;
ir.types.insert(name, ty);
}
"function" => ir.functions.push(parse_function(node)?),
"context" => {
let (name, ctx) = parse_context(node)?;
ir.contexts.insert(name, ctx);
}
"channel" => ir.channels.push(parse_channel(node)?),
other => bail!("unknown top-level KDL node {other:?}"),
}
}
Ok(ir)
}
fn parse_named_type(node: &KdlNode) -> Result<(String, NamedType)> {
let name = first_string_arg(node)
.context("`type` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("type {name:?}: missing children block"))?;
let kind_node = single_child(children, &format!("type {name:?}"))?;
let kind = match kind_node.name().value() {
"struct" => NamedType::Struct(parse_struct_fields(kind_node)?),
"list" => NamedType::List(type_child_of(kind_node, &format!("type {name:?} list"))?),
"enum" => NamedType::Enum(parse_string_args(kind_node)),
"alias" => NamedType::Alias(type_child_of(kind_node, &format!("type {name:?} alias"))?),
other => bail!("type {name:?}: unknown shape node {other:?}"),
};
Ok((name, kind))
}
fn parse_struct_fields(struct_node: &KdlNode) -> Result<Vec<StructField>> {
let mut fields = Vec::new();
let Some(children) = struct_node.children() else { return Ok(fields); };
for child in children.nodes() {
if child.name().value() != "field" {
bail!("struct: unexpected node {:?}", child.name().value());
}
fields.push(parse_struct_field(child)?);
}
Ok(fields)
}
fn parse_struct_field(field_node: &KdlNode) -> Result<StructField> {
let name = first_string_arg(field_node).context("`field` requires a name")?;
let required = bool_prop(field_node, "required").unwrap_or(true);
let default = field_node.entry("default")
.map(|e| parse_default_value(e.value()))
.transpose()?;
let shape = type_child_of(field_node, &format!("field {name:?}"))?;
Ok(StructField { name, required, default, shape })
}
fn parse_default_value(v: &KdlValue) -> Result<DefaultValue> {
if v.is_null() { return Ok(DefaultValue::Null); }
if let Some(b) = v.as_bool() { return Ok(DefaultValue::Boolean(b)); }
if let Some(i) = v.as_integer() { return Ok(DefaultValue::Integer(i as i64)); }
if let Some(f) = v.as_float() { return Ok(DefaultValue::Number(f)); }
if let Some(s) = v.as_string() { return Ok(DefaultValue::String(s.to_string())); }
bail!("unsupported default literal: {v:?}")
}
fn type_child_of(parent: &KdlNode, label: &str) -> Result<TypeShape> {
let children = parent.children()
.ok_or_else(|| anyhow!("{label}: missing children for type-shape"))?;
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one type-shape child, got {}", nodes.len());
}
parse_type_shape(&nodes[0])
}
fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
match node.name().value() {
"primitive" => Ok(TypeShape::Primitive(Primitive::parse(&first_string_arg(node)?)?)),
"ref" => Ok(TypeShape::Ref(first_string_arg(node)?)),
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
"union" => {
let children = node.children()
.ok_or_else(|| anyhow!("union: missing children"))?;
let branches: Result<Vec<TypeShape>> = children.nodes().iter()
.map(parse_type_shape).collect();
Ok(TypeShape::Union(branches?))
}
other => bail!("unknown type-shape node {other:?}"),
}
}
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
let name = first_string_arg(node)
.context("`function` requires a name as its first argument")?;
let children = node.children()
.ok_or_else(|| anyhow!("function {name:?}: missing children"))?;
let mut camel = None;
let mut has_input = false;
let mut input_type = None;
let mut output_type = None;
let mut output_nullable = false;
let mut transport = Transport::Http;
let mut is_context = IsContext::No;
let mut is_form = false;
let mut form_name = None;
let mut form_role = None;
let mut affects: Vec<AffectTarget> = Vec::new();
let mut merge: Vec<String> = Vec::new();
for child in children.nodes() {
match child.name().value() {
"camel" => camel = Some(string_arg(child, "camel")?),
"has-input" => has_input = bool_arg(child, "has-input")?,
"input" => input_type = Some(string_arg(child, "input")?),
"output" => output_type = Some(string_arg(child, "output")?),
"output-nullable" => output_nullable = bool_arg(child, "output-nullable")?,
"transport" => transport = Transport::parse(&string_arg(child, "transport")?)?,
"context" => is_context = IsContext::Yes(string_arg(child, "context")?),
"is-form" => is_form = bool_arg(child, "is-form")?,
"form-name" => form_name = Some(string_arg(child, "form-name")?),
"form-role" => form_role = Some(string_arg(child, "form-role")?),
"affects" => affects.push(AffectTarget {
kind: AffectKind::Context,
name: string_arg(child, "affects")?,
context: None,
}),
"merge" => merge.push(string_arg(child, "merge")?),
other => bail!("function {name:?}: unknown child {other:?}"),
}
}
Ok(MizanFunction {
name: name.clone(),
camel_name: camel.ok_or_else(|| anyhow!("function {name:?}: missing `camel`"))?,
has_input,
input_type,
output_type: output_type.ok_or_else(|| anyhow!("function {name:?}: missing `output`"))?,
output_nullable,
transport,
is_context,
is_form,
form_name,
form_role,
affects,
merge,
})
}
fn parse_context(node: &KdlNode) -> Result<(String, MizanContext)> {
let name = first_string_arg(node).context("`context` requires a name")?;
let mut ctx = MizanContext::default();
let Some(children) = node.children() else { return Ok((name, ctx)); };
for child in children.nodes() {
match child.name().value() {
"function" => ctx.functions.push(string_arg(child, "function")?),
"param" => {
let (pname, param) = parse_context_param(child)?;
ctx.params.insert(pname, param);
}
other => bail!("context {name:?}: unknown child {other:?}"),
}
}
Ok((name, ctx))
}
fn parse_context_param(node: &KdlNode) -> Result<(String, ContextParam)> {
let pname = first_string_arg(node).context("`param` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("param {pname:?}: missing children"))?;
let mut ty = None;
let mut required = false;
let mut shared_by = Vec::new();
for child in children.nodes() {
match child.name().value() {
"type" => ty = Some(Primitive::parse(&string_arg(child, "type")?)?),
"required" => required = bool_arg(child, "required")?,
"shared-by" => shared_by.push(string_arg(child, "shared-by")?),
other => bail!("param {pname:?}: unknown child {other:?}"),
}
}
Ok((pname.clone(), ContextParam {
ty: ty.ok_or_else(|| anyhow!("param {pname:?}: missing `type`"))?,
required,
shared_by,
}))
}
fn parse_channel(node: &KdlNode) -> Result<MizanChannel> {
let name = first_string_arg(node).context("`channel` requires a name")?;
let children = node.children()
.ok_or_else(|| anyhow!("channel {name:?}: missing children"))?;
let mut pascal_name = None;
let mut params_type = None;
let mut react_message_type = None;
let mut django_message_type = None;
for child in children.nodes() {
match child.name().value() {
"pascal-name" => pascal_name = Some(string_arg(child, "pascal-name")?),
"params" => params_type = Some(string_arg(child, "params")?),
"react-message" => react_message_type = Some(string_arg(child, "react-message")?),
"django-message" => django_message_type = Some(string_arg(child, "django-message")?),
other => bail!("channel {name:?}: unknown child {other:?}"),
}
}
Ok(MizanChannel {
name: name.clone(),
pascal_name: pascal_name.ok_or_else(|| anyhow!("channel {name:?}: missing `pascal-name`"))?,
params_type,
react_message_type,
django_message_type,
})
}
// ─── KDL accessor helpers ───────────────────────────────────────────────────
fn first_string_arg(node: &KdlNode) -> Result<String> {
let entry = node.entries().iter()
.find(|e| e.name().is_none())
.ok_or_else(|| anyhow!("node {:?}: missing positional argument", node.name().value()))?;
entry.value().as_string()
.map(str::to_string)
.ok_or_else(|| anyhow!("node {:?}: positional argument is not a string", node.name().value()))
}
fn string_arg(node: &KdlNode, label: &str) -> Result<String> {
first_string_arg(node).context(format!("{label}: requires a string argument"))
}
fn bool_arg(node: &KdlNode, label: &str) -> Result<bool> {
node.entries().iter()
.find(|e| e.name().is_none())
.and_then(|e| e.value().as_bool())
.ok_or_else(|| anyhow!("{label}: missing positional bool argument"))
}
fn bool_prop(node: &KdlNode, key: &str) -> Option<bool> {
node.entry(key).and_then(|e| e.value().as_bool())
}
fn parse_string_args(node: &KdlNode) -> Vec<String> {
node.entries().iter()
.filter(|e| e.name().is_none())
.filter_map(|e| e.value().as_string().map(str::to_string))
.collect()
}
fn single_child<'a>(children: &'a KdlDocument, label: &str) -> Result<&'a KdlNode> {
let nodes = children.nodes();
if nodes.len() != 1 {
bail!("{label}: expected exactly one child node, got {}", nodes.len());
}
Ok(&nodes[0])
}
// ─── Library entry point ────────────────────────────────────────────────────
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source)
}

View File

@@ -95,10 +95,10 @@ fn main() -> Result<()> {
};
eprintln!(
"[mizan] Loaded {} function(s), {} context group(s), {} schema(s)",
"[mizan] Loaded {} function(s), {} context group(s), {} type(s)",
ir.functions.len(),
ir.contexts.len(),
ir.components.schemas.len(),
ir.types.len(),
);
// Stage 1 is the framework-agnostic foundation that react/vue/svelte

View File

@@ -28,7 +28,7 @@ fn fixture_config() -> Config {
#[test]
fn channels_target_emits_expected_files() {
let raw = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/channels_schema.json"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/channels_ir.kdl"),
).unwrap();
let ir = parse_ir_from_str(&raw).unwrap();
@@ -72,7 +72,7 @@ fn channels_target_emits_expected_files() {
fn channels_target_emits_nothing_when_empty() {
// AFI fixture has no channels — target should produce zero files.
let raw = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl"),
).unwrap();
let ir = parse_ir_from_str(&raw).unwrap();
let files = ChannelsTarget.emit(&ir, &fixture_config());

View File

@@ -0,0 +1,187 @@
type "OrderOutput" {
struct {
field "id" {
primitive "integer"
}
field "user_id" {
primitive "integer"
}
field "total" {
primitive "integer"
}
}
}
type "echoInput" {
struct {
field "text" {
primitive "string"
}
}
}
type "echoOutput" {
struct {
field "message" {
primitive "string"
}
}
}
type "findUserInput" {
struct {
field "user_id" {
primitive "integer"
}
}
}
type "findUserOutput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "renameUserInput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "renameUserOutput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "updateProfileInput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "updateProfileOutput" {
struct {
field "ok" {
primitive "boolean"
}
}
}
type "userOrdersInput" {
struct {
field "user_id" {
primitive "integer"
}
}
}
type "userOrdersOutput" {
alias {
list {
ref "OrderOutput"
}
}
}
type "userProfileInput" {
struct {
field "user_id" {
primitive "integer"
}
}
}
type "userProfileOutput" {
struct {
field "user_id" {
primitive "integer"
}
field "name" {
primitive "string"
}
}
}
type "whoamiOutput" {
struct {
field "email" {
primitive "string"
}
field "authenticated" {
primitive "boolean"
}
}
}
function "echo" {
camel "echo"
has-input #true
input "echoInput"
output "echoOutput"
transport "http"
}
function "whoami" {
camel "whoami"
has-input #false
output "whoamiOutput"
transport "http"
}
function "user_profile" {
camel "userProfile"
has-input #true
input "userProfileInput"
output "userProfileOutput"
transport "http"
context "user"
}
function "user_orders" {
camel "userOrders"
has-input #true
input "userOrdersInput"
output "userOrdersOutput"
transport "http"
context "user"
}
function "update_profile" {
camel "updateProfile"
has-input #true
input "updateProfileInput"
output "updateProfileOutput"
transport "http"
affects "user"
}
function "find_user" {
camel "findUser"
has-input #true
input "findUserInput"
output "findUserOutput"
output-nullable #true
transport "http"
}
function "rename_user" {
camel "renameUser"
has-input #true
input "renameUserInput"
output "renameUserOutput"
transport "http"
merge "user"
}
context "user" {
function "user_profile"
function "user_orders"
param "user_id" {
type "integer"
required #true
shared-by "user_profile"
shared-by "user_orders"
}
}

View File

@@ -1,685 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "mizan Server Functions",
"description": "Auto-generated schema for mizan server functions",
"version": "1.0.0"
},
"paths": {
"/mizan/echo": {
"post": {
"summary": "Echoes the input back.",
"operationId": "echo",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/echoInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/echoOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/whoami": {
"post": {
"summary": "Returns the current user identity.",
"operationId": "whoami",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/whoamiOutput"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/user_profile": {
"post": {
"summary": "One half of the user context.",
"operationId": "userProfile",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userProfileInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userProfileOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "user"
}
}
},
"/mizan/user_orders": {
"post": {
"summary": "Other half of the user context \u2014 same param, proves param elevation.",
"operationId": "userOrders",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userOrdersInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userOrdersOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": "user"
}
}
},
"/mizan/update_profile": {
"post": {
"summary": "Mutation declaring affects on the user context.",
"operationId": "updateProfile",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/updateProfileInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/updateProfileOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/find_user": {
"post": {
"summary": "Optional return \u2014 exercises Pydantic `T | None` schema introspection.",
"operationId": "findUser",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/findUserInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/findUserOutput"
},
{
"type": "null"
}
],
"title": "Response Finduser"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
},
"/mizan/rename_user": {
"post": {
"summary": "Merge target \u2014 kernel splices return value into the user context.",
"operationId": "renameUser",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/renameUserInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/renameUserOutput"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"x-mizan": {
"transport": "http",
"isContext": false
}
}
}
},
"components": {
"schemas": {
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"OrderOutput": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"user_id": {
"type": "integer",
"title": "User Id"
},
"total": {
"type": "integer",
"title": "Total"
}
},
"type": "object",
"required": [
"id",
"user_id",
"total"
],
"title": "OrderOutput"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
},
"input": {
"title": "Input"
},
"ctx": {
"type": "object",
"title": "Context"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"echoInput": {
"properties": {
"text": {
"type": "string",
"title": "Text"
}
},
"type": "object",
"required": [
"text"
],
"title": "echoInput"
},
"echoOutput": {
"properties": {
"message": {
"type": "string",
"title": "Message"
}
},
"type": "object",
"required": [
"message"
],
"title": "echoOutput"
},
"findUserInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
}
},
"type": "object",
"required": [
"user_id"
],
"title": "findUserInput"
},
"findUserOutput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "findUserOutput"
},
"renameUserInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "renameUserInput"
},
"renameUserOutput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "renameUserOutput"
},
"updateProfileInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "updateProfileInput"
},
"updateProfileOutput": {
"properties": {
"ok": {
"type": "boolean",
"title": "Ok"
}
},
"type": "object",
"required": [
"ok"
],
"title": "updateProfileOutput"
},
"userOrdersInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
}
},
"type": "object",
"required": [
"user_id"
],
"title": "userOrdersInput"
},
"userOrdersOutput": {
"items": {
"$ref": "#/components/schemas/OrderOutput"
},
"type": "array",
"title": "userOrdersOutput"
},
"userProfileInput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
}
},
"type": "object",
"required": [
"user_id"
],
"title": "userProfileInput"
},
"userProfileOutput": {
"properties": {
"user_id": {
"type": "integer",
"title": "User Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"user_id",
"name"
],
"title": "userProfileOutput"
},
"whoamiOutput": {
"properties": {
"email": {
"type": "string",
"title": "Email"
},
"authenticated": {
"type": "boolean",
"title": "Authenticated"
}
},
"type": "object",
"required": [
"email",
"authenticated"
],
"title": "whoamiOutput"
}
}
},
"x-mizan-functions": [
{
"name": "echo",
"camelName": "echo",
"hasInput": true,
"inputType": "echoInput",
"outputType": "echoOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "whoami",
"camelName": "whoami",
"hasInput": false,
"inputType": null,
"outputType": "whoamiOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "user_profile",
"camelName": "userProfile",
"hasInput": true,
"inputType": "userProfileInput",
"outputType": "userProfileOutput",
"outputNullable": false,
"transport": "http",
"isContext": "user",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "user_orders",
"camelName": "userOrders",
"hasInput": true,
"inputType": "userOrdersInput",
"outputType": "userOrdersOutput",
"outputNullable": false,
"transport": "http",
"isContext": "user",
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "update_profile",
"camelName": "updateProfile",
"hasInput": true,
"inputType": "updateProfileInput",
"outputType": "updateProfileOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null,
"affects": [
{
"type": "context",
"name": "user"
}
]
},
{
"name": "find_user",
"camelName": "findUser",
"hasInput": true,
"inputType": "findUserInput",
"outputType": "findUserOutput",
"outputNullable": true,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null
},
{
"name": "rename_user",
"camelName": "renameUser",
"hasInput": true,
"inputType": "renameUserInput",
"outputType": "renameUserOutput",
"outputNullable": false,
"transport": "http",
"isContext": false,
"isForm": false,
"formName": null,
"formRole": null,
"merge": [
"user"
]
}
],
"x-mizan-contexts": {
"user": {
"functions": [
"user_profile",
"user_orders"
],
"params": {
"user_id": {
"type": "integer",
"sharedBy": [
"user_profile",
"user_orders"
],
"required": true
}
}
}
}
}

View File

@@ -6,21 +6,11 @@ from typing import Any, Literal
from pydantic import BaseModel
class HTTPValidationError(BaseModel):
detail: list[ValidationError] | None = None
class OrderOutput(BaseModel):
id: int
user_id: int
total: int
class ValidationError(BaseModel):
loc: list[Any]
msg: str
r#type: str
input: Any | None = None
ctx: dict[str, Any] | None = None
class EchoInput(BaseModel):
text: str

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
// Stage 2 framework adapter
export * from './react'

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -4,11 +4,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HTTPValidationError {
pub detail: Option<Vec<ValidationError>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderOutput {
pub id: i64,
@@ -16,16 +11,6 @@ pub struct OrderOutput {
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub loc: Vec<serde_json::Value>,
pub msg: String,
#[serde(rename = "type")]
pub r#type: String,
pub input: Option<serde_json::Value>,
pub ctx: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EchoInput {
pub text: String,
@@ -75,9 +60,7 @@ pub struct UserOrdersInput {
pub user_id: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserOrdersOutput(pub Vec<OrderOutput>);
pub type UserOrdersOutput = Vec<OrderOutput>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfileInput {

View File

@@ -0,0 +1,18 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -0,0 +1,18 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
// Stage 2 framework adapter
export * from './svelte'

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -0,0 +1,18 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanFetch } from '@mizan/base'
import type { userProfileOutput, userOrdersOutput } from '../types'
export interface UserContextData {
user_profile: userProfileOutput
user_orders: userOrdersOutput
}
export interface UserContextParams {
user_id: number
}
export function fetchUserContext(params: UserContextParams): Promise<UserContextData> {
return mizanFetch('user', params)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { echoInput, echoOutput } from '../types'
export function callEcho(args: echoInput): Promise<echoOutput> {
return mizanCall('echo', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { findUserInput, findUserOutput } from '../types'
export function callFindUser(args: findUserInput): Promise<findUserOutput> {
return mizanCall('find_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { renameUserInput, renameUserOutput } from '../types'
export function callRenameUser(args: renameUserInput): Promise<renameUserOutput> {
return mizanCall('rename_user', args)
}

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { whoamiOutput } from '../types'
export function callWhoami(): Promise<whoamiOutput> {
return mizanCall('whoami', {})
}

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED by mizan — do not edit
export * from './types'
export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user'
export { callEcho } from './functions/echo'
export { callWhoami } from './functions/whoami'
export { callUpdateProfile } from './mutations/updateProfile'
export { callFindUser } from './functions/findUser'
export { callRenameUser } from './functions/renameUser'
// Stage 2 framework adapter
export * from './vue'

View File

@@ -0,0 +1,9 @@
// AUTO-GENERATED by mizan — do not edit
import { mizanCall } from '@mizan/base'
import type { updateProfileInput, updateProfileOutput } from '../types'
export function callUpdateProfile(args: updateProfileInput): Promise<updateProfileOutput> {
return mizanCall('update_profile', args)
}

View File

@@ -0,0 +1,64 @@
// AUTO-GENERATED by mizan — do not edit
export interface OrderOutput {
id: number
user_id: number
total: number
}
export interface echoInput {
text: string
}
export interface echoOutput {
message: string
}
export interface findUserInput {
user_id: number
}
export interface findUserOutput {
user_id: number
name: string
}
export interface renameUserInput {
user_id: number
name: string
}
export interface renameUserOutput {
user_id: number
name: string
}
export interface updateProfileInput {
user_id: number
name: string
}
export interface updateProfileOutput {
ok: boolean
}
export interface userOrdersInput {
user_id: number
}
export type userOrdersOutput = OrderOutput[]
export interface userProfileInput {
user_id: number
}
export interface userProfileOutput {
user_id: number
name: string
}
export interface whoamiOutput {
email: string
authenticated: boolean
}

View File

@@ -0,0 +1,46 @@
type "ChatChannelParams" {
struct {
field "room_id" {
primitive "string"
}
}
}
type "ChatReactMessage" {
struct {
field "text" {
primitive "string"
}
}
}
type "ChatDjangoMessage" {
struct {
field "text" {
primitive "string"
}
field "from_user" {
primitive "string"
}
}
}
type "NotificationsDjangoMessage" {
struct {
field "body" {
primitive "string"
}
}
}
channel "chat" {
pascal-name "Chat"
params "ChatChannelParams"
react-message "ChatReactMessage"
django-message "ChatDjangoMessage"
}
channel "notifications" {
pascal-name "Notifications"
django-message "NotificationsDjangoMessage"
}

View File

@@ -1,55 +0,0 @@
{
"x-mizan-channels": [
{
"name": "chat",
"pascalName": "Chat",
"hasParams": true,
"hasReactMessage": true,
"hasDjangoMessage": true,
"paramsType": "ChatChannelParams",
"reactMessageType": "ChatReactMessage",
"djangoMessageType": "ChatDjangoMessage"
},
{
"name": "notifications",
"pascalName": "Notifications",
"hasParams": false,
"hasReactMessage": false,
"hasDjangoMessage": true,
"djangoMessageType": "NotificationsDjangoMessage"
}
],
"components": {
"schemas": {
"ChatChannelParams": {
"type": "object",
"properties": {
"room_id": { "type": "string" }
},
"required": ["room_id"]
},
"ChatReactMessage": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"]
},
"ChatDjangoMessage": {
"type": "object",
"properties": {
"text": { "type": "string" },
"from_user": { "type": "string" }
},
"required": ["text", "from_user"]
},
"NotificationsDjangoMessage": {
"type": "object",
"properties": {
"body": { "type": "string" }
},
"required": ["body"]
}
}
}
}

View File

@@ -1,20 +1,20 @@
//! IR deserialization tests against the AFI fixture schema.
//! IR deserialization tests against the AFI fixture (KDL).
//!
//! The fixture is captured from the FastAPI backend's `build_schema()`
//! The fixture is captured from `cores/mizan-python/src/mizan_core/ir.py::build_ir()`
//! against `tests/afi/fixture.py`. Each test exercises a different facet
//! of the IR — function set, per-function field decoding, context-param
//! elevation, and components.schemas presence — to confirm the typed
//! Rust structs match the JSON shape the backends emit.
//! elevation, and named-type presence — to confirm the typed Rust structs
//! match the KDL shape the backend emits.
use std::path::PathBuf;
use mizan_codegen::fetch::parse_ir_from_str;
use mizan_codegen::ir::{AffectKind, IsContext, Transport};
use mizan_codegen::ir::{AffectKind, IsContext, NamedType, Primitive, Transport};
fn load_fixture() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/afi_schema.json");
.join("tests/fixtures/afi_ir.kdl");
let raw = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
parse_ir_from_str(&raw).unwrap_or_else(|e| panic!("parse IR: {e}"))
@@ -26,7 +26,6 @@ fn afi_fixture_deserializes_function_set() {
let ir = load_fixture();
let names: Vec<&str> = ir.functions.iter().map(|f| f.name.as_str()).collect();
// Seven fixture functions per tests/afi/fixture.py.
assert_eq!(ir.functions.len(), 7, "expected 7 functions, got {}: {names:?}", ir.functions.len());
for expected in [
@@ -67,6 +66,10 @@ fn afi_fixture_function_field_decode() {
assert_eq!(update_profile.affects.len(), 1);
assert_eq!(update_profile.affects[0].kind, AffectKind::Context);
assert_eq!(update_profile.affects[0].name, "user");
// Mutation with `merge="user"`.
let rename_user = ir.functions.iter().find(|f| f.name == "rename_user").unwrap();
assert_eq!(rename_user.merge, vec!["user".to_string()]);
}
@@ -77,7 +80,7 @@ fn afi_fixture_context_param_elevation() {
// Both context functions share `user_id` as a required param.
let user_id = user.params.get("user_id").expect("user_id param");
assert_eq!(user_id.ty, "integer");
assert_eq!(user_id.ty, Primitive::Integer);
assert!(user_id.required, "user_id is required (declared by every fn in the group)");
assert!(user_id.shared_by.contains(&"user_profile".to_string()));
assert!(user_id.shared_by.contains(&"user_orders".to_string()));
@@ -85,19 +88,26 @@ fn afi_fixture_context_param_elevation() {
#[test]
fn afi_fixture_components_schemas_present() {
fn afi_fixture_named_types_present() {
let ir = load_fixture();
// Each fixture function pairs with an *Input/Output schema in components.
// Every IR function references its <camelName>Input / <camelName>Output
// type by name; the IR's `type` section must declare each as a named
// type (struct, alias to a list, etc.).
for expected in [
"echoInput", "echoOutput",
"whoamiOutput",
"userProfileInput", "userProfileOutput",
"userOrdersInput",
"updateProfileInput", "updateProfileOutput",
"findUserInput", "findUserOutput",
"renameUserInput", "renameUserOutput",
] {
assert!(
ir.components.schemas.contains_key(expected),
"missing schema {expected:?}",
);
let ty = ir.types.get(expected)
.unwrap_or_else(|| panic!("missing type {expected:?}"));
// Each named type is one of the four KDL shapes — sanity-check
// we round-tripped a non-trivial declaration.
match ty {
NamedType::Struct(_) | NamedType::List(_) | NamedType::Enum(_) | NamedType::Alias(_) => {}
}
}
}

View File

@@ -10,7 +10,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json");
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -29,7 +29,7 @@ fn fixture_config() -> Config {
fn read_baseline(rel: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_python")
.join("tests/fixtures/baselines/python")
.join(rel);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))

View File

@@ -9,7 +9,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json");
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -34,7 +34,7 @@ fn react_target_byte_match() {
let actual = &files[0].content;
let expected_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_react/react.tsx");
.join("tests/fixtures/baselines/react/react.tsx");
let expected = std::fs::read_to_string(&expected_path).unwrap();
if *actual != expected {

View File

@@ -14,7 +14,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/afi_schema.json");
.join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -35,7 +35,7 @@ fn fixture_config() -> Config {
fn read_baseline(rel: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_rust")
.join("tests/fixtures/baselines/rust")
.join(rel);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))

View File

@@ -1,7 +1,7 @@
//! Byte-equivalence tests for the deterministic Stage 1 files (contexts,
//! mutations, functions, index). Baseline output captured from the JS
//! codegen at `protocol/mizan-generate/generator/lib/stage1.mjs` against
//! the AFI fixture schema (`tests/fixtures/afi_schema.json`).
//! the AFI fixture schema (`tests/fixtures/afi_ir.kdl`).
//!
//! `types.ts` is NOT byte-checked here — the JS codegen routes type
//! emission through openapi-typescript while the Rust substrate emits
@@ -19,7 +19,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/afi_schema.json");
.join("tests/fixtures/afi_ir.kdl");
let raw = std::fs::read_to_string(&path).unwrap();
parse_ir_from_str(&raw).unwrap()
}
@@ -44,7 +44,7 @@ fn emit_index(files: &[EmittedFile]) -> BTreeMap<PathBuf, &str> {
fn read_baseline(rel: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/js_stage1")
.join("tests/fixtures/baselines/stage1")
.join(rel);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display()))

View File

@@ -10,7 +10,7 @@ use mizan_codegen::fetch::parse_ir_from_str;
fn load_ir() -> mizan_codegen::ir::MizanIR {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json");
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_ir.kdl");
parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
}
@@ -53,7 +53,7 @@ fn vue_target_byte_match() {
let ir = load_ir();
let files = VueAdapter.emit(&ir, &fixture_config("vue"));
assert_eq!(files.len(), 1);
assert_byte_equal(&files[0].content, "tests/fixtures/js_vue/vue.ts", "vue.ts");
assert_byte_equal(&files[0].content, "tests/fixtures/baselines/vue/vue.ts", "vue.ts");
}
@@ -62,5 +62,5 @@ fn svelte_target_byte_match() {
let ir = load_ir();
let files = SvelteAdapter.emit(&ir, &fixture_config("svelte"));
assert_eq!(files.len(), 1);
assert_byte_equal(&files[0].content, "tests/fixtures/js_svelte/svelte.ts", "svelte.ts");
assert_byte_equal(&files[0].content, "tests/fixtures/baselines/svelte/svelte.ts", "svelte.ts");
}