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

@@ -1,7 +1,7 @@
"""
Codegen entrypoint for the AFI fixture.
`mizan_fastapi.cli` imports a module and runs `build_schema()` from a
`mizan_fastapi.ir` imports a module and runs `build_ir()` from a
populated registry. The fixture's `register_fixture()` is a function
call, not an import side effect; this thin wrapper invokes it on
import so the CLI works without modifying fixture.py's semantics.

View File

@@ -1,4 +1,4 @@
"""FastAPI test app that registers the AFI fixture and exposes build_schema()."""
"""FastAPI test app that registers the AFI fixture."""
from __future__ import annotations

View File

@@ -1,79 +0,0 @@
"""
Normalize an OpenAPI schema dict so backend-specific framing doesn't make
two semantically-equivalent schemas compare unequal.
Drops:
- Top-level OpenAPI envelope fields that vary trivially (info, servers, tags)
- Django-only x-mizan-functions sub-fields (form metadata)
- Django-only top-level extensions (x-mizan-channels)
Sorts:
- x-mizan-functions by name
- components.schemas keys
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
_DJANGO_ONLY_FUNCTION_FIELDS = {"isForm", "formName", "formRole", "formFields", "formFieldsError"}
_OPENAPI_ENVELOPE_FIELDS_TO_DROP = {"info", "servers", "tags", "openapi"}
def normalize(schema: dict[str, Any]) -> dict[str, Any]:
"""Return a canonicalized copy suitable for deep-equal comparison."""
out = deepcopy(schema)
for k in _OPENAPI_ENVELOPE_FIELDS_TO_DROP:
out.pop(k, None)
out.pop("x-mizan-channels", None)
fns = out.get("x-mizan-functions") or []
for fn in fns:
for field in _DJANGO_ONLY_FUNCTION_FIELDS:
fn.pop(field, None)
out["x-mizan-functions"] = sorted(fns, key=lambda f: f["name"])
components = out.get("components", {})
schemas = components.get("schemas")
if isinstance(schemas, dict):
components["schemas"] = {k: schemas[k] for k in sorted(schemas)}
return out
def afi_subset(schema: dict[str, Any]) -> dict[str, Any]:
"""Just the AFI-essential surface — what every adapter must agree on."""
fns = schema.get("x-mizan-functions") or []
fns_clean = [
{k: v for k, v in fn.items() if k not in _DJANGO_ONLY_FUNCTION_FIELDS}
for fn in fns
]
return {
"x-mizan-functions": sorted(fns_clean, key=lambda f: f["name"]),
"x-mizan-contexts": schema.get("x-mizan-contexts", {}),
}
def function_io_schemas(schema: dict[str, Any]) -> dict[str, Any]:
"""
Subset of components.schemas containing only the per-function Input/Output
models (what the codegen actually consumes for TypeScript type generation).
Drops backend-specific noise like HTTPValidationError, ValidationError,
that one backend emits and the other doesn't.
"""
fns = schema.get("x-mizan-functions") or []
expected_names: set[str] = set()
for fn in fns:
if fn.get("inputType"):
expected_names.add(fn["inputType"])
if fn.get("outputType"):
expected_names.add(fn["outputType"])
components = schema.get("components", {})
schemas = components.get("schemas", {})
return {name: schemas[name] for name in sorted(expected_names) if name in schemas}

View File

@@ -1,19 +1,18 @@
"""
AFI conformance — same @client fixture, same schema, both adapters.
AFI conformance — same @client fixture, same Mizan IR (KDL), both adapters.
Gates that mizan-django and mizan-fastapi emit equivalent schemas for the
same registered functions. If this passes, the codegen produces equivalent
TypeScript output regardless of which backend the frontend is generated
against (codegen is deterministic over schema input).
Gates that mizan-django and mizan-fastapi emit byte-equivalent IR
for the same registered functions. If this passes, the codegen
produces identical TypeScript output regardless of backend
(codegen is deterministic over IR input).
This is a substrate-level gate, not e2e. It catches adapter symmetry
problems — Pydantic→OpenAPI converter divergence, metadata leakage,
ordering non-determinism — without running a real frontend or backend.
Substrate-level gate, not e2e. Catches adapter symmetry problems —
type-introspection divergence, ordering non-determinism — without
running a real frontend or backend.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
@@ -21,68 +20,53 @@ from pathlib import Path
import pytest
HERE = Path(__file__).parent
DJANGO_MANAGE = HERE / "django_app" / "manage.py"
# ─── Schema fetchers ────────────────────────────────────────────────────────
def _fetch_django_schema() -> dict:
"""Spawn Django's management command and parse its stdout JSON."""
def _fetch_django_ir() -> str:
"""Spawn Django's management command and parse stdout as KDL."""
result = subprocess.run(
[sys.executable, str(DJANGO_MANAGE), "export_mizan_schema", "--indent", "0"],
[sys.executable, str(DJANGO_MANAGE), "export_mizan_ir"],
capture_output=True,
text=True,
check=False,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
)
if result.returncode != 0:
pytest.fail(f"export_mizan_schema failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}")
return json.loads(result.stdout)
pytest.fail(
f"export_mizan_ir failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}",
)
return result.stdout
def _fetch_fastapi_schema() -> dict:
"""Build the FastAPI app inline (fresh registry) and call build_schema()."""
def _fetch_fastapi_ir() -> str:
"""Build the FastAPI app inline (fresh registry) and call build_ir()."""
sys.path.insert(0, str(HERE))
try:
from mizan_core.registry import clear_registry
from mizan_fastapi import build_schema
from mizan_core.ir import build_ir
from fastapi_app import make_app
clear_registry()
make_app()
return build_schema()
return build_ir()
finally:
sys.path.remove(str(HERE))
# ─── Tests ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def schemas() -> tuple[dict, dict]:
return _fetch_django_schema(), _fetch_fastapi_schema()
def ir_pair() -> tuple[str, str]:
return _fetch_django_ir(), _fetch_fastapi_ir()
class AFISubsetTests:
"""The AFI surface — x-mizan-functions and x-mizan-contexts — must match."""
class IRParityTests:
"""The Mizan IR is the contract — both backends must emit the same KDL."""
def test_x_mizan_functions_match(self, schemas):
from schema_normalizer import afi_subset
django, fastapi = schemas
assert afi_subset(django)["x-mizan-functions"] == afi_subset(fastapi)["x-mizan-functions"]
def test_x_mizan_contexts_match(self, schemas):
from schema_normalizer import afi_subset
django, fastapi = schemas
assert afi_subset(django)["x-mizan-contexts"] == afi_subset(fastapi)["x-mizan-contexts"]
class TypeSchemasTests:
"""Per-function Input/Output Pydantic→OpenAPI schemas — codegen feeds these to openapi-typescript."""
def test_function_io_schemas_match(self, schemas):
from schema_normalizer import function_io_schemas
django, fastapi = schemas
assert function_io_schemas(django) == function_io_schemas(fastapi)
def test_ir_bytes_match(self, ir_pair):
django, fastapi = ir_pair
assert django == fastapi, (
"Django and FastAPI emit divergent Mizan IR for the same "
"registered functions. Substrate gate is now red."
)