// Derived block metadata — the registry, projected. Every downstream view // (a Blockly canvas, a Tier-1 panel, an assumptions sheet, an LLM tool spec) // needs the same facts about a block: which params are editable scalars, // which are variadic scalar arrays, which are complex (edited off-canvas), // each field's kind/label/bounds/default, and the block's family/tier/doc. // `deriveBlockMeta` computes that from a `BlockDef` alone — zod only, no UI // dependency — by walking the block's input schema through `z.toJSONSchema`. // // This is the levelset-side port of src/lib/blockly/generate.ts's schema // walk (walkSchema / leafField / variadic detection), minus all Blockly JSON // assembly. Both sides agree on the ISO-date convention by construction: the // pattern below is the same one blocks/define.ts stamps on `isoDateSchema`. import { z } from "zod"; import { FAMILY_CARDINALITY, FAMILY_FOLLOWS, FAMILY_ORDER, type FamilyCardinality, } from "./composition.ts"; import { blockInstanceSchema } from "./graph.ts"; import type { BlockDef, BlockFamily, BlockTier, PanelBinding, } from "./registry.ts"; import { lexicographicTierSchema } from "./runConfig.ts"; import { DEFAULT_MAX_COMBINATIONS, HARD_MAX_COMBINATIONS, SWEEP_ENABLED_TARGET, sweepSchema, } from "./sweep.ts"; import type { BlockDoc } from "./types.ts"; import { LEVELSET_VERSION } from "../version.ts"; /** The ISO-date shape blocks stamp on a date param's zod schema — exported so * both the block authors and every projection agree by construction. */ export const ISO_DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; /** z.int() bounds (±Number.MAX_SAFE_INTEGER) — schema noise, not real caps; * stripped from derived min/max. */ const INT_SENTINEL = 9007199254740991; /** The JSON-Schema subset z.toJSONSchema emits for block input schemas. */ interface JsonSchemaNode { type?: string; properties?: Record; enum?: unknown[]; pattern?: string; default?: unknown; minimum?: number; maximum?: number; exclusiveMinimum?: number; items?: JsonSchemaNode; minItems?: number; maxItems?: number; } /** Editable scalar-field kinds. Variadic array *elements* can also be plain * text ("text"); a top-level scalar string with no field form stays complex, * so a field is never "text". */ export type BlockFieldKind = | "date" | "number" | "slider" | "dropdown" | "checkbox"; /** Slider bounds for a param the registry binds to a slider control. */ export interface SliderSpec { min?: number; max?: number; step?: number; } /** One editable scalar param, flattened to its dot-path. */ export interface BlockFieldSpec { /** Dot-path into the block instance's params. */ path: string; kind: BlockFieldKind; /** panelBinding label ?? last path segment. */ label: string; /** defaultShadow value at this path ?? the schema default; omitted when * neither is present. */ defaultValue?: unknown; /** Inclusive numeric bounds, sentinel-stripped. */ min?: number; max?: number; /** True for integer-typed numeric fields. */ integer?: boolean; /** Enum option values (as strings), for dropdown fields. */ options?: string[]; /** Slider bounds, when the registry binds this param to a slider. */ slider?: SliderSpec; } /** A scalar-array param rendered as indexed plus/minus rows, mirroring * VariadicParam verbatim — object arrays (modes, phases, limits) have no * field form and stay complex. */ export interface VariadicParamSpec { /** Dot-path of the array param itself. */ path: string; label: string; itemKind: "number" | "text"; integer: boolean; /** Per-element numeric bounds (same derivation as scalar fields). */ min?: number; max?: number; minItems: number; maxItems?: number; /** Value for an element added to an empty array (copy-last wins when the * array is non-empty), chosen inside the element bounds. */ emptySeed: number | string; /** Element values a fresh block carries (defaultShadow, else schema * default). */ defaultValues: readonly (number | string)[]; } /** A block's registry facts plus its flattened param taxonomy. */ export interface DerivedBlockMeta { code: string; family: BlockFamily; tier: BlockTier; doc: BlockDoc; defaultShadow: unknown; assumptionTemplate?: string; panelBindings?: PanelBinding[]; projectAnchoredPaths?: string[]; /** The block's input schema as JSON Schema (io: "input"). */ inputSchema: unknown; /** Editable scalar params, in display order. */ fields: BlockFieldSpec[]; /** Scalar-array params surfaced as plus/minus rows. */ variadic: VariadicParamSpec[]; /** Params with no field form (object arrays, bare scalar strings). */ complexParams: string[]; } /** The committed dump: engine version, format version, composition grammar, * and every registered block's derived meta. */ export interface BlockMetaDump { levelsetVersion: string; formatVersion: 1; composition: { order: readonly BlockFamily[]; follows: Record; cardinality: Record; }; /** Framework-owned authoring shapes shared by every canvas. Block-specific * params continue to come from each entry in `blocks`. */ authoring: { instance: { /** Graph-instance keys that cannot also be block param names. */ reservedParamNames: readonly string[]; /** Runtime meaning of an omitted optional `enabled` property. */ enabledWhenOmitted: true; schema: unknown; }; sweep: { /** Special target path that toggles the whole block instance. */ enabledTarget: typeof SWEEP_ENABLED_TARGET; defaultMaxCombinations: number; hardMaxCombinations: number; schema: unknown; }; objective: { /** One entry in objective.lexicographic: a scorer id or weighted tier. */ lexicographicTierSchema: unknown; }; }; blocks: DerivedBlockMeta[]; } function authoringMeta(): BlockMetaDump["authoring"] { const instanceSchema = z.toJSONSchema(blockInstanceSchema, { io: "input", }) as JsonSchemaNode; const reservedParamNames = Object.keys( instanceSchema.properties ?? {}, ).filter((name) => name !== "instanceId" && name !== "params"); return { instance: { reservedParamNames, enabledWhenOmitted: true, schema: instanceSchema, }, sweep: { enabledTarget: SWEEP_ENABLED_TARGET, defaultMaxCombinations: DEFAULT_MAX_COMBINATIONS, hardMaxCombinations: HARD_MAX_COMBINATIONS, schema: z.toJSONSchema(sweepSchema, { io: "input" }), }, objective: { lexicographicTierSchema: z.toJSONSchema(lexicographicTierSchema, { io: "input", }), }, }; } function resolveDotPath(value: unknown, path: string): unknown { let current = value; for (const segment of path.split(".")) { if (current === null || typeof current !== "object") return undefined; current = (current as Record)[segment]; } return current; } /** Inclusive lower bound for a numeric field; sentinel and exclusive bounds * normalized the way fieldMin does (integer exclusive → +1). */ function fieldMin(node: JsonSchemaNode, integer: boolean): number | undefined { if (node.minimum !== undefined && node.minimum !== -INT_SENTINEL) { return node.minimum; } if (node.exclusiveMinimum !== undefined) { return integer ? node.exclusiveMinimum + 1 : node.exclusiveMinimum; } return undefined; } function fieldMax(node: JsonSchemaNode): number | undefined { if (node.maximum !== undefined && node.maximum !== INT_SENTINEL) { return node.maximum; } return undefined; } /** A scalar leaf schema node → BlockFieldSpec, or null when it has no field * form (a bare scalar string, or any type z.toJSONSchema doesn't reduce to * a known field kind). */ function leafField( path: string, node: JsonSchemaNode, shadowValue: unknown, binding: PanelBinding | undefined, ): BlockFieldSpec | null { const label = binding?.label ?? path.split(".").at(-1) ?? path; const defaultValue = shadowValue ?? node.default; const withDefault = defaultValue !== undefined ? { defaultValue } : {}; if (node.type === "string" && node.pattern === ISO_DATE_PATTERN) { return { path, kind: "date", label, ...withDefault }; } if (node.enum !== undefined) { return { path, kind: "dropdown", label, options: node.enum.map((value) => String(value)), ...withDefault, }; } if (node.type === "integer" || node.type === "number") { const integer = node.type === "integer"; const min = fieldMin(node, integer); const max = fieldMax(node); const bounds = { ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}), integer, }; if (binding?.control === "slider") { const options = (binding.options ?? {}) as { min?: number; max?: number; step?: number; }; const sliderMin = options.min ?? min; const sliderMax = options.max ?? max; const slider: SliderSpec = { ...(sliderMin !== undefined ? { min: sliderMin } : {}), ...(sliderMax !== undefined ? { max: sliderMax } : {}), ...(options.step !== undefined ? { step: options.step } : {}), }; return { path, kind: "slider", label, ...bounds, slider, ...withDefault }; } return { path, kind: "number", label, ...bounds, ...withDefault }; } if (node.type === "boolean") { return { path, kind: "checkbox", label, ...withDefault }; } return null; } /** A scalar-array schema node → VariadicParamSpec, or null when the element * type has no field form (object arrays, enum arrays). */ function variadicParam( path: string, node: JsonSchemaNode, shadowValue: unknown, binding: PanelBinding | undefined, ): VariadicParamSpec | null { const items = node.items; if (items === undefined || items.enum !== undefined) return null; const label = binding?.label ?? path.split(".").at(-1) ?? path; const minItems = node.minItems ?? 0; const defaults = Array.isArray(shadowValue) ? shadowValue : Array.isArray(node.default) ? node.default : undefined; if (items.type === "integer" || items.type === "number") { const integer = items.type === "integer"; const min = fieldMin(items, integer); const max = fieldMax(items); if (defaults === undefined || defaults.some((v) => typeof v !== "number")) { return null; } return { path, label, itemKind: "number", integer, ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}), minItems, ...(node.maxItems !== undefined ? { maxItems: node.maxItems } : {}), emptySeed: min ?? 0, defaultValues: defaults as number[], }; } if (items.type === "string" && items.pattern === undefined) { if (defaults === undefined || defaults.some((v) => typeof v !== "string")) { return null; } return { path, label, itemKind: "text", integer: false, minItems, ...(node.maxItems !== undefined ? { maxItems: node.maxItems } : {}), emptySeed: "", defaultValues: defaults as string[], }; } return null; } /** Flatten a block input schema into scalar fields, variadic scalar-array * params, and complex (field-less) params. Nested objects flatten into * dot-path fields. */ function walkSchema( def: BlockDef, node: JsonSchemaNode, prefix: string, fields: BlockFieldSpec[], variadic: VariadicParamSpec[], complex: string[], ): void { for (const [key, child] of Object.entries(node.properties ?? {})) { const path = prefix === "" ? key : `${prefix}.${key}`; if (child.type === "object" && child.properties !== undefined) { walkSchema(def, child, path, fields, variadic, complex); continue; } const binding = def.panelBindings?.find((b) => b.paramPath === path); const shadowValue = resolveDotPath(def.defaultShadow, path); if (child.type === "array") { const param = variadicParam(path, child, shadowValue, binding); if (param !== null) { variadic.push(param); } else { complex.push(path); } continue; } const leaf = leafField(path, child, shadowValue, binding); if (leaf !== null) { fields.push(leaf); } else { complex.push(path); } } } /** Derive one block's metadata from its `BlockDef`. Pure; zod only. An * unrepresentable input schema degrades to a label-only block (empty * fields/variadic/complex) rather than throwing — the meta stays valid and * the block stays editable off-canvas. */ export function deriveBlockMeta(def: BlockDef): DerivedBlockMeta { const fields: BlockFieldSpec[] = []; const variadic: VariadicParamSpec[] = []; const complex: string[] = []; let inputSchema: unknown = { type: "object" }; try { const schema = z.toJSONSchema(def.block.schema.input, { io: "input", }) as JsonSchemaNode; inputSchema = schema; walkSchema(def, schema, "", fields, variadic, complex); } catch { fields.length = 0; variadic.length = 0; complex.length = 0; } return { code: def.code, family: def.family, tier: def.tier, doc: def.block.doc, defaultShadow: def.defaultShadow, ...(def.assumptionTemplate !== undefined ? { assumptionTemplate: def.assumptionTemplate } : {}), ...(def.panelBindings !== undefined ? { panelBindings: def.panelBindings } : {}), ...(def.projectAnchoredPaths !== undefined ? { projectAnchoredPaths: def.projectAnchoredPaths } : {}), inputSchema, fields, variadic, complexParams: complex, }; } /** Dump the whole registry to a serializable metadata document: engine * version, format version, the composition grammar, and every block's * derived meta in registry order. This is the build-time artifact * scripts/generate-block-meta.ts writes to generated/block-meta.json. */ export function dumpBlockMeta(registry: { list(): BlockDef[] }): BlockMetaDump { return { levelsetVersion: LEVELSET_VERSION, formatVersion: 1, composition: { order: FAMILY_ORDER, follows: FAMILY_FOLLOWS, cardinality: FAMILY_CARDINALITY, }, authoring: authoringMeta(), blocks: registry.list().map(deriveBlockMeta), }; }