AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green: 90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The gaps the prose table used to launder as "Django-only" / "out of scope" are wired, against the pinned-spec model (single-authored spec, byte-identical conformance across languages) — never per-language reimplementation. FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest), WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes (SQLAlchemy projection, same declaration surface as django-readers), Forms (Pydantic schema/validate/submit). Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth= enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT mint+verify and cache-key derivation byte-pinned to the Python reference (cache_keys_pin, token_pin, invalidate_header_pin). TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS backend can feed the codegen — the largest gap), multipart upload, session-init, WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms. Verified in the merged tree: core 25, fastapi 74, django 353/21-skip, mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
409
backends/mizan-ts/src/ir/build.ts
Normal file
409
backends/mizan-ts/src/ir/build.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
|
||||
*
|
||||
* The Python emitter is the spec; this is a second implementation under the
|
||||
* same contract. `buildIr()` walks the registry, resolves the canonical named
|
||||
* types each function references (`_collect_named_types`), and emits KDL the
|
||||
* Rust codegen consumes. Any divergence is a bug here, not a contract change —
|
||||
* `tests/ir.test.ts` pins byte-equality against the live Python `build_ir()`.
|
||||
*/
|
||||
|
||||
import { getAllFunctions, getContextGroups, getFunction } from '../registry'
|
||||
import type { RegistryEntry } from '../types'
|
||||
import type { DefaultValue, NamedType, Primitive, StructField, TypeShape } from './types'
|
||||
|
||||
const INDENT = ' '
|
||||
|
||||
// ─── KDL value formatting (mirrors ir.py `_kdl_*`) ────────────────────────────
|
||||
|
||||
function kdlString(s: string): string {
|
||||
const escaped = s
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t')
|
||||
return `"${escaped}"`
|
||||
}
|
||||
|
||||
function kdlBool(b: boolean): string {
|
||||
return b ? '#true' : '#false'
|
||||
}
|
||||
|
||||
function kdlDefault(v: DefaultValue): string {
|
||||
switch (v.kind) {
|
||||
case 'null':
|
||||
return '#null'
|
||||
case 'boolean':
|
||||
return kdlBool(v.value)
|
||||
case 'integer':
|
||||
return String(v.value)
|
||||
case 'number':
|
||||
// Match Python's repr(float): whole-number floats render as "1.0".
|
||||
return Number.isInteger(v.value) ? `${v.value}.0` : String(v.value)
|
||||
case 'string':
|
||||
return kdlString(v.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** snake_case → camelCase. Matches ir.py `_snake_to_camel`. */
|
||||
export function snakeToCamel(name: string): string {
|
||||
const parts = name.replace(/\./g, '_').replace(/-/g, '_').split('_')
|
||||
return parts[0] + parts.slice(1).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
function primitiveName(p: Primitive): string {
|
||||
return p
|
||||
}
|
||||
|
||||
// ─── Emitter ──────────────────────────────────────────────────────────────
|
||||
|
||||
class Emitter {
|
||||
lines: string[] = []
|
||||
|
||||
private prefix(indent: number): string {
|
||||
return INDENT.repeat(indent)
|
||||
}
|
||||
|
||||
leaf(indent: number, ...parts: string[]): void {
|
||||
this.lines.push(this.prefix(indent) + parts.join(' '))
|
||||
}
|
||||
|
||||
open(indent: number, ...parts: string[]): void {
|
||||
this.lines.push(this.prefix(indent) + parts.join(' ') + ' {')
|
||||
}
|
||||
|
||||
close(indent: number): void {
|
||||
this.lines.push(this.prefix(indent) + '}')
|
||||
}
|
||||
|
||||
blank(): void {
|
||||
this.lines.push('')
|
||||
}
|
||||
|
||||
emitTypeChild(indent: number, shape: TypeShape): void {
|
||||
switch (shape.kind) {
|
||||
case 'primitive':
|
||||
this.leaf(indent, 'primitive', kdlString(primitiveName(shape.primitive)))
|
||||
return
|
||||
case 'ref':
|
||||
this.leaf(indent, 'ref', kdlString(shape.name))
|
||||
return
|
||||
case 'list':
|
||||
this.open(indent, 'list')
|
||||
this.emitTypeChild(indent + 1, shape.inner)
|
||||
this.close(indent)
|
||||
return
|
||||
case 'optional':
|
||||
this.open(indent, 'optional')
|
||||
this.emitTypeChild(indent + 1, shape.inner)
|
||||
this.close(indent)
|
||||
return
|
||||
case 'enum':
|
||||
this.leaf(indent, 'enum', ...shape.variants.map(kdlString))
|
||||
return
|
||||
case 'union':
|
||||
this.open(indent, 'union')
|
||||
for (const b of shape.branches) this.emitTypeChild(indent + 1, b)
|
||||
this.close(indent)
|
||||
return
|
||||
case 'upload':
|
||||
this.emitUpload(indent, shape)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpload(indent: number, shape: Extract<TypeShape, { kind: 'upload' }>): void {
|
||||
const props: string[] = []
|
||||
if (shape.maxSize !== undefined) props.push(`max-size=${shape.maxSize}`)
|
||||
if (shape.contentTypes && shape.contentTypes.length > 0) {
|
||||
this.open(indent, 'upload', ...props)
|
||||
for (const ct of shape.contentTypes) this.leaf(indent + 1, 'content-type', kdlString(ct))
|
||||
this.close(indent)
|
||||
} else {
|
||||
this.leaf(indent, 'upload', ...props)
|
||||
}
|
||||
}
|
||||
|
||||
emitNamedType(indent: number, name: string, body: NamedType): void {
|
||||
this.open(indent, 'type', kdlString(name))
|
||||
if (body.kind === 'struct') {
|
||||
this.open(indent + 1, 'struct')
|
||||
for (const field of body.fields) this.emitStructField(indent + 2, field)
|
||||
this.close(indent + 1)
|
||||
} else if (body.kind === 'alias') {
|
||||
this.open(indent + 1, 'alias')
|
||||
this.emitTypeChild(indent + 2, body.inner)
|
||||
this.close(indent + 1)
|
||||
} else {
|
||||
this.leaf(indent + 1, 'enum', ...body.variants.map(kdlString))
|
||||
}
|
||||
this.close(indent)
|
||||
}
|
||||
|
||||
emitStructField(indent: number, field: StructField): void {
|
||||
const header: string[] = ['field', kdlString(field.name)]
|
||||
if (!field.required) {
|
||||
header.push(`required=${kdlBool(false)}`)
|
||||
if (field.default !== undefined) header.push(`default=${kdlDefault(field.default)}`)
|
||||
}
|
||||
this.open(indent, ...header)
|
||||
this.emitTypeChild(indent + 1, field.shape)
|
||||
this.close(indent)
|
||||
}
|
||||
|
||||
intoString(): string {
|
||||
const lines = [...this.lines]
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Named-type collection (mirrors ir.py `_collect_named_types`) ─────────────
|
||||
|
||||
/** Strip Optional[T] → [inner, isOptional]. */
|
||||
function stripOptional(shape: TypeShape): [TypeShape, boolean] {
|
||||
if (shape.kind === 'optional') return [shape.inner, true]
|
||||
return [shape, false]
|
||||
}
|
||||
|
||||
/** list element type, or null. */
|
||||
function listElement(shape: TypeShape): TypeShape | null {
|
||||
if (shape.kind === 'list') return shape.inner
|
||||
return null
|
||||
}
|
||||
|
||||
/** All ref names reachable inside a shape. */
|
||||
function refsIn(shape: TypeShape): string[] {
|
||||
switch (shape.kind) {
|
||||
case 'ref':
|
||||
return [shape.name]
|
||||
case 'list':
|
||||
case 'optional':
|
||||
return refsIn(shape.inner)
|
||||
case 'union':
|
||||
return shape.branches.flatMap(refsIn)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** All ref names a NamedType body references. */
|
||||
function refsInBody(body: NamedType): string[] {
|
||||
if (body.kind === 'struct') return body.fields.flatMap(f => refsIn(f.shape))
|
||||
if (body.kind === 'alias') return refsIn(body.inner)
|
||||
return []
|
||||
}
|
||||
|
||||
interface FnTypeInfo {
|
||||
schema: import('./types').IrSchema
|
||||
camel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* First pass: collect every named type the IR's `function` section references,
|
||||
* keyed by emitted name. Two kinds, exactly as `_collect_named_types`:
|
||||
* - structs visited anywhere in input/output traversal (under their ref name,
|
||||
* and under the canonical `<camel>Input` / `<camel>Output` rename)
|
||||
* - output wrapper aliases (`<camel>Output = list[T]` / primitive / renamed
|
||||
* model) so the consumer has one named type to reference.
|
||||
*/
|
||||
function collectNamedTypes(fns: Map<string, FnTypeInfo>): Record<string, NamedType> {
|
||||
const seen: Record<string, NamedType> = {}
|
||||
|
||||
function visitModel(name: string, types: Record<string, NamedType>): void {
|
||||
if (name in seen) return
|
||||
const body = types[name]
|
||||
if (body === undefined) {
|
||||
throw new Error(
|
||||
`IR schema references type "${name}" but no definition was provided in the function's \`types\`.`,
|
||||
)
|
||||
}
|
||||
seen[name] = body
|
||||
for (const ref of refsInBody(body)) visitModel(ref, types)
|
||||
}
|
||||
|
||||
function visitShape(shape: TypeShape, types: Record<string, NamedType>): void {
|
||||
for (const ref of refsIn(shape)) visitModel(ref, types)
|
||||
}
|
||||
|
||||
for (const { schema, camel } of fns.values()) {
|
||||
const types = schema.types ?? {}
|
||||
|
||||
// Input — named `<camel>Input`, emitted as a struct.
|
||||
if (schema.input && schema.input.length > 0) {
|
||||
const inputName = `${camel}Input`
|
||||
if (!(inputName in seen)) seen[inputName] = { kind: 'struct', fields: schema.input }
|
||||
// Visit nested refs in the input fields.
|
||||
for (const field of schema.input) visitShape(field.shape, types)
|
||||
}
|
||||
|
||||
// Output.
|
||||
if (schema.output === undefined) continue
|
||||
const outputName = `${camel}Output`
|
||||
const [inner] = stripOptional(schema.output)
|
||||
const elem = listElement(inner)
|
||||
|
||||
if (elem !== null) {
|
||||
// list[T] (possibly Optional) — list alias. Visit element type.
|
||||
visitShape(schema.output, types)
|
||||
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||
} else if (inner.kind === 'ref') {
|
||||
// <Model> or Optional[<Model>] — emit the model under the canonical
|
||||
// output name (rename). Python renames the Pydantic model to
|
||||
// `<camel>Output`; we emit the referenced struct under that name.
|
||||
const refName = inner.name
|
||||
const body = types[refName]
|
||||
if (body === undefined) {
|
||||
throw new Error(
|
||||
`IR schema output references type "${refName}" but no definition was provided in the function's \`types\`.`,
|
||||
)
|
||||
}
|
||||
if (body.kind === 'struct') {
|
||||
// Emit the struct under the canonical output name (the rename),
|
||||
// and visit its nested refs.
|
||||
if (!(outputName in seen)) {
|
||||
seen[outputName] = body
|
||||
for (const ref of refsInBody(body)) visitModel(ref, types)
|
||||
}
|
||||
} else {
|
||||
// Non-struct named type referenced as output — emit under its
|
||||
// own name plus a canonical alias.
|
||||
visitModel(refName, types)
|
||||
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||
}
|
||||
} else {
|
||||
// Primitive-wrapped output (`result: int`) — alias.
|
||||
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
|
||||
}
|
||||
}
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
// ─── Function / context emission ──────────────────────────────────────────
|
||||
|
||||
function resolveOutput(entry: RegistryEntry): { name: string; nullable: boolean } {
|
||||
const camel = snakeToCamel(entry.name)
|
||||
const canonical = `${camel}Output`
|
||||
const schema = entry.ir
|
||||
if (!schema || schema.output === undefined) return { name: canonical, nullable: false }
|
||||
const [, nullable] = stripOptional(schema.output)
|
||||
return { name: canonical, nullable }
|
||||
}
|
||||
|
||||
function emitFunction(em: Emitter, entry: RegistryEntry): void {
|
||||
const camel = snakeToCamel(entry.name)
|
||||
const schema = entry.ir ?? {}
|
||||
const hasInput = !!(schema.input && schema.input.length > 0)
|
||||
const { name: outputName, nullable } = resolveOutput(entry)
|
||||
|
||||
em.open(0, 'function', kdlString(entry.name))
|
||||
em.leaf(1, 'camel', kdlString(camel))
|
||||
em.leaf(1, 'has-input', kdlBool(hasInput))
|
||||
if (hasInput) em.leaf(1, 'input', kdlString(`${camel}Input`))
|
||||
em.leaf(1, 'output', kdlString(outputName))
|
||||
if (nullable) em.leaf(1, 'output-nullable', kdlBool(true))
|
||||
em.leaf(1, 'transport', kdlString(entry.websocket ? 'websocket' : 'http'))
|
||||
if (entry.context) em.leaf(1, 'context', kdlString(entry.context))
|
||||
// Only context-typed affects make it into the KDL (matches ir.py).
|
||||
for (const a of entry.affects ?? []) {
|
||||
if (a.type === 'context') em.leaf(1, 'affects', kdlString(a.name))
|
||||
}
|
||||
for (const m of entry.merge ?? []) em.leaf(1, 'merge', kdlString(m))
|
||||
if (entry.form) {
|
||||
em.leaf(1, 'is-form', kdlBool(true))
|
||||
if (entry.formName) em.leaf(1, 'form-name', kdlString(entry.formName))
|
||||
if (entry.formRole) em.leaf(1, 'form-role', kdlString(entry.formRole))
|
||||
}
|
||||
em.close(0)
|
||||
}
|
||||
|
||||
function annotationToPrimitive(shape: TypeShape | undefined): Primitive {
|
||||
if (shape === undefined) return 'string'
|
||||
const [inner] = stripOptional(shape)
|
||||
if (inner.kind === 'primitive') return inner.primitive
|
||||
return 'string'
|
||||
}
|
||||
|
||||
function emitContext(em: Emitter, ctxName: string, fnNames: string[]): void {
|
||||
// Collect param info across every function in the context.
|
||||
interface Slot {
|
||||
type: Primitive
|
||||
sharedBy: string[]
|
||||
}
|
||||
const paramInfo = new Map<string, Slot>()
|
||||
for (const fnName of fnNames) {
|
||||
const entry = getFunction(fnName)
|
||||
if (!entry) continue
|
||||
const input = entry.ir?.input
|
||||
if (!input || input.length === 0) continue
|
||||
for (const field of input) {
|
||||
let slot = paramInfo.get(field.name)
|
||||
if (!slot) {
|
||||
slot = { type: 'string', sharedBy: [] }
|
||||
paramInfo.set(field.name, slot)
|
||||
}
|
||||
slot.type = annotationToPrimitive(field.shape)
|
||||
slot.sharedBy.push(fnName)
|
||||
}
|
||||
}
|
||||
|
||||
em.open(0, 'context', kdlString(ctxName))
|
||||
// Members alphabetical — canonical order.
|
||||
for (const fnName of [...fnNames].sort()) em.leaf(1, 'function', kdlString(fnName))
|
||||
for (const paramName of [...paramInfo.keys()].sort()) {
|
||||
const slot = paramInfo.get(paramName)!
|
||||
const required = slot.sharedBy.length === fnNames.length
|
||||
em.open(1, 'param', kdlString(paramName))
|
||||
em.leaf(2, 'type', kdlString(slot.type))
|
||||
em.leaf(2, 'required', kdlBool(required))
|
||||
for (const sharer of [...slot.sharedBy].sort()) em.leaf(2, 'shared-by', kdlString(sharer))
|
||||
em.close(1)
|
||||
}
|
||||
em.close(0)
|
||||
}
|
||||
|
||||
// ─── Top-level builder ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the Mizan IR (KDL) for every registered function. Byte-equivalent to
|
||||
* the Python `build_ir()` against the same registry.
|
||||
*
|
||||
* `private` and view-path functions are excluded from the function section,
|
||||
* matching ir.py.
|
||||
*/
|
||||
export function buildIr(): string {
|
||||
const functions = getAllFunctions()
|
||||
const contextGroups = getContextGroups()
|
||||
|
||||
// Functions contributing to the type/function sections (skip private + view).
|
||||
const typeFns = new Map<string, FnTypeInfo>()
|
||||
const emitFns: RegistryEntry[] = []
|
||||
for (const [name, entry] of functions) {
|
||||
if (entry.private || entry.viewPath) continue
|
||||
typeFns.set(name, { schema: entry.ir ?? {}, camel: snakeToCamel(name) })
|
||||
emitFns.push(entry)
|
||||
}
|
||||
|
||||
const namedTypes = collectNamedTypes(typeFns)
|
||||
|
||||
const em = new Emitter()
|
||||
|
||||
// Types — alphabetical by name (canonical IR ordering).
|
||||
const typeNames = Object.keys(namedTypes).sort()
|
||||
for (const typeName of typeNames) em.emitNamedType(0, typeName, namedTypes[typeName])
|
||||
if (typeNames.length > 0) em.blank()
|
||||
|
||||
// Functions — alphabetical by wire name.
|
||||
emitFns.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||
for (const entry of emitFns) emitFunction(em, entry)
|
||||
if (emitFns.length > 0) em.blank()
|
||||
|
||||
// Contexts — alphabetical by name.
|
||||
const ctxNames = Object.keys(contextGroups).sort()
|
||||
for (const ctxName of ctxNames) emitContext(em, ctxName, contextGroups[ctxName])
|
||||
if (ctxNames.length > 0) em.blank()
|
||||
|
||||
return em.intoString()
|
||||
}
|
||||
17
backends/mizan-ts/src/ir/index.ts
Normal file
17
backends/mizan-ts/src/ir/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Mizan IR (KDL) — the codegen contract.
|
||||
*
|
||||
* `buildIr()` emits KDL byte-identical to the Python `build_ir()` against the
|
||||
* same registry. This is what lets a TypeScript backend feed
|
||||
* `protocol/mizan-codegen`.
|
||||
*/
|
||||
|
||||
export { buildIr, snakeToCamel } from './build'
|
||||
export type {
|
||||
Primitive,
|
||||
TypeShape,
|
||||
DefaultValue,
|
||||
StructField,
|
||||
NamedType,
|
||||
IrSchema,
|
||||
} from './types'
|
||||
70
backends/mizan-ts/src/ir/types.ts
Normal file
70
backends/mizan-ts/src/ir/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` and
|
||||
* `cores/mizan-rust/src/ir.rs` 1:1.
|
||||
*
|
||||
* The IR is the contract. Backends emit it; the codegen consumes it. The
|
||||
* TypeScript side produces byte-equivalent KDL to the Python emitter against
|
||||
* the same function registry.
|
||||
*
|
||||
* TypeScript has no Pydantic to introspect, so the `@client` decorator carries
|
||||
* an explicit IR type schema (input fields + output shape). That schema is the
|
||||
* binding: a TS backend declares its IR types, and `buildIr()` emits the KDL
|
||||
* the codegen reads — exactly as the Rust adapter declares typed `StructField`
|
||||
* / `TypeShape` registrations.
|
||||
*/
|
||||
|
||||
export type Primitive = 'integer' | 'number' | 'boolean' | 'string'
|
||||
|
||||
/**
|
||||
* An in-place type shape — referenced from struct fields, function
|
||||
* inputs/outputs, and alias bodies.
|
||||
*/
|
||||
export type TypeShape =
|
||||
| { kind: 'primitive'; primitive: Primitive }
|
||||
| { kind: 'ref'; name: string }
|
||||
| { kind: 'list'; inner: TypeShape }
|
||||
| { kind: 'optional'; inner: TypeShape }
|
||||
| { kind: 'enum'; variants: string[] }
|
||||
| { kind: 'union'; branches: TypeShape[] }
|
||||
| { kind: 'upload'; maxSize?: number; contentTypes?: string[] }
|
||||
|
||||
export type DefaultValue =
|
||||
| { kind: 'integer'; value: number }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
| { kind: 'string'; value: string }
|
||||
| { kind: 'null' }
|
||||
|
||||
export interface StructField {
|
||||
name: string
|
||||
required: boolean
|
||||
default?: DefaultValue
|
||||
shape: TypeShape
|
||||
}
|
||||
|
||||
/** A named type that appears in the IR's `type "<Name>" { ... }` section. */
|
||||
export type NamedType =
|
||||
| { kind: 'struct'; fields: StructField[] }
|
||||
| { kind: 'alias'; inner: TypeShape }
|
||||
| { kind: 'enum'; variants: string[] }
|
||||
|
||||
/**
|
||||
* The IR type schema a `@client` function carries.
|
||||
*
|
||||
* `input` is the ordered list of input fields (already excluding the implicit
|
||||
* request/identity arg). When absent or empty, the function `has-input #false`.
|
||||
*
|
||||
* `output` is the function's return shape: a `ref` to a named struct, a `list`,
|
||||
* an `optional`, or a `primitive`. The emitter derives the canonical
|
||||
* `<camel>Input` / `<camel>Output` names and the struct-vs-alias split exactly
|
||||
* as `_collect_named_types` does.
|
||||
*
|
||||
* `types` resolves every `ref` used in `input`/`output` (and transitively) to
|
||||
* its `NamedType` definition — Python gets this from Pydantic model
|
||||
* introspection; TS declares it explicitly.
|
||||
*/
|
||||
export interface IrSchema {
|
||||
input?: StructField[]
|
||||
output?: TypeShape
|
||||
types?: Record<string, NamedType>
|
||||
}
|
||||
Reference in New Issue
Block a user