// serializeConf / lintRegistryForConf — the write side of the `.conf` layer. // // serializeConf renders a RunConfig to canonical TOML text: deterministic key // order, default-valued flags omitted (curated provenance, shadow=false, // absent enabled), sections in a fixed order. The round trip is value-lossless // (parseConf(serializeConf(c)) reproduces c through the schema) but // comment/format-lossy — hand-authored comments and spacing are not preserved. // // The serializer is hand-rolled (not smol-toml's stringify) so the exact byte // output stays under our control: goldens are byte-exact, and the canonical // form is what diffs and reviews read. import { z } from "zod"; import type { BlockRegistry } from "../level-blocks/registry.ts"; import type { BlockInstance } from "../level-blocks/graph.ts"; import type { RunConfig } from "../level-blocks/runConfig.ts"; import type { ConfIssue } from "./issues.ts"; import { RESERVED_BLOCK_KEYS, reservedParamCollisions } from "./parse.ts"; export interface SerializeConfOptions { /** Comment lines to emit at the top of the file (each prefixed with `# `). */ readonly header?: readonly string[]; /** When set, emit each block's rendered assumptionTemplate as a comment * line above its `[blocks.]` section. */ readonly annotate?: BlockRegistry; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } const BARE_KEY = /^[A-Za-z0-9_-]+$/; /** A TOML key segment — bare when safe, else a quoted basic string. */ function tomlKey(key: string): string { return BARE_KEY.test(key) ? key : tomlString(key); } /** A TOML basic string with the minimal escapes. */ function tomlString(value: string): string { const escaped = value .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\t/g, "\\t"); return `"${escaped}"`; } /** A scalar TOML value. Numbers render through String (finite by * construction — E306 rejects inf/nan at parse, and RunConfig values are * validated), so `2 → "2"`, `2.5 → "2.5"`; round-trips to the same JS value. */ function tomlScalar(value: unknown): string { if (typeof value === "string") return tomlString(value); if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); if (Array.isArray(value)) return inlineArray(value); if (isPlainObject(value)) { const entries = Object.entries(value).map( ([key, child]) => `${tomlKey(key)} = ${tomlScalar(child)}`, ); return `{ ${entries.join(", ")} }`; } // Should not occur in a validated RunConfig; render defensively. return tomlString(String(value)); } function inlineArray(values: readonly unknown[]): string { return `[${values.map((v) => tomlScalar(v)).join(", ")}]`; } interface SubTable { /** Key path from the table root to the object-array param. */ path: string[]; items: Record[]; } /** Flatten an object's scalar/array leaves into `key = value` lines (nested * objects become dotted keys), collecting non-empty object arrays as * sub-tables to emit afterwards. */ function collectLeaves( obj: Record, prefix: string[], lines: string[], subTables: SubTable[], ): void { for (const [key, value] of Object.entries(obj)) { const path = [...prefix, key]; if (isPlainObject(value)) { collectLeaves(value, path, lines, subTables); } else if (Array.isArray(value)) { if (value.length > 0 && value.every(isPlainObject)) { subTables.push({ path, items: value }); } else { // Empty arrays and scalar arrays render inline (an empty array is // ambiguous otherwise — `[]` round-trips, a missing key does not). lines.push(`${path.map(tomlKey).join(".")} = ${inlineArray(value)}`); } } else { lines.push(`${path.map(tomlKey).join(".")} = ${tomlScalar(value)}`); } } } /** Emit a table (or array-of-tables item) rooted at `headerPath`: a header * line, its scalar/dotted leaves, then each object-array child recursively. */ function emitTable( headerPath: string[], leadLines: readonly string[], obj: Record, isArrayItem: boolean, sections: string[], ): void { const lines: string[] = [...leadLines]; const subTables: SubTable[] = []; collectLeaves(obj, [], lines, subTables); const dotted = headerPath.map(tomlKey).join("."); const header = isArrayItem ? `[[${dotted}]]` : `[${dotted}]`; sections.push([header, ...lines].join("\n")); for (const sub of subTables) { for (const item of sub.items) { emitTable([...headerPath, ...sub.path], [], item, true, sections); } } } /** Substitute `{param}` placeholders in an assumptionTemplate with the block's * param values; unresolved placeholders are left as-is. */ function renderAssumption( template: string, params: Record, ): string { return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (whole, path: string) => { let cur: unknown = params; for (const seg of path.split(".")) { if (!isPlainObject(cur)) return whole; cur = cur[seg]; } // Params are user-supplied TOML values. `String()` is the documented // substitution behavior (arrays render comma-joined); JSON-encoding here // would change rendered assumption text. // oxlint-disable-next-line typescript/no-base-to-string return cur === undefined ? whole : String(cur); }); } function serializeBlock( instance: BlockInstance, annotate: BlockRegistry | undefined, ): string[] { const lead: string[] = [`code = ${tomlString(instance.code)}`]; // Reserved flags, in canonical order; only the non-default values. if (instance.enabled === false) lead.push(`enabled = false`); if (instance.provenance !== undefined && instance.provenance !== "curated") { lead.push(`provenance = ${tomlString(instance.provenance)}`); } if (instance.shadow === true) lead.push(`shadow = true`); const sections: string[] = []; emitTable( ["blocks", instance.instanceId], lead, instance.params, false, sections, ); const def = annotate?.get(instance.code); if (def?.assumptionTemplate !== undefined && sections.length > 0) { const comment = `# ${renderAssumption(def.assumptionTemplate, instance.params)}`; sections[0] = `${comment}\n${sections[0]!}`; } return sections; } /** * Render a RunConfig to canonical `.conf` (TOML 1.0) text. Deterministic: * the same config always produces the same bytes. Default-valued block flags * are omitted. Pass `annotate` to comment each block with its rendered * assumption, or `header` for top-of-file comment lines. */ export function serializeConf( config: RunConfig, opts?: SerializeConfOptions, ): string { const sections: string[] = []; if (opts?.header && opts.header.length > 0) { sections.push(opts.header.map((line) => `# ${line}`).join("\n")); } // [levelset] const levelsetLines = [`format = 1`]; if (config.levelset !== undefined) levelsetLines.push(`version = ${tomlString(config.levelset)}`); if (config.seed !== undefined) levelsetLines.push(`seed = ${tomlScalar(config.seed)}`); sections.push([`[levelset]`, ...levelsetLines].join("\n")); // [resolve] if (config.resolve !== undefined) { emitTable(["resolve"], [], config.resolve, false, sections); } // [pipeline] — systemCode only; formatVersion is implied by [levelset]. if (config.pipeline.systemCode !== undefined) { sections.push( [ `[pipeline]`, `systemCode = ${tomlString(config.pipeline.systemCode)}`, ].join("\n"), ); } // [blocks.] in document order. for (const instance of config.pipeline.blocks) { sections.push(...serializeBlock(instance, opts?.annotate)); } // [weights] if (config.weights !== undefined) { const lines = Object.entries(config.weights).map( ([name, weight]) => `${tomlKey(name)} = ${weight === null ? '"report"' : tomlScalar(weight)}`, ); sections.push([`[weights]`, ...lines].join("\n")); } // [objective] if (config.objective !== undefined) { sections.push( [ `[objective]`, `lexicographic = ${inlineArray(config.objective.lexicographic)}`, ].join("\n"), ); } // [sweep] + [[sweep.axes]] — emitted through the generic table walker so // targets and zip-axis record values retain their exact JSON structure. if (config.sweep !== undefined) { emitTable(["sweep"], [], config.sweep, false, sections); } return sections.join("\n\n") + "\n"; } /** The JSON-schema subset lint scans for null-typed / unrepresentable nodes. */ interface SchemaNode { type?: string | string[]; enum?: unknown[]; properties?: Record; items?: SchemaNode; anyOf?: SchemaNode[]; allOf?: SchemaNode[]; oneOf?: SchemaNode[]; } function schemaRequiresNull(node: SchemaNode | undefined): boolean { if (!node) return false; if (node.type === "null") return true; if (Array.isArray(node.type) && node.type.includes("null")) return true; if (node.enum?.some((v) => v === null)) return true; for (const child of Object.values(node.properties ?? {})) { if (schemaRequiresNull(child)) return true; } if (schemaRequiresNull(node.items)) return true; for (const group of [node.anyOf, node.allOf, node.oneOf]) { if (group?.some((n) => schemaRequiresNull(n))) return true; } return false; } /** * Static-check a registry for blocks the `.conf` format can't faithfully * express: a param name that collides with a reserved block-table key (E305), * a schema that admits `null` where params can't be null, or an input schema * `z.toJSONSchema` can't render at all. Returns [] for a clean registry (the * standard registry is clean); a build/test asserts that. */ export function lintRegistryForConf(registry: BlockRegistry): ConfIssue[] { const issues: ConfIssue[] = []; for (const def of registry.list()) { for (const collision of reservedParamCollisions(def)) { issues.push({ code: "LEVELSET_E305", message: `block "${def.code}" declares a param "${collision}" that collides with the reserved conf key "${collision}" — it cannot be set through the conf format`, }); } let schema: SchemaNode; try { schema = z.toJSONSchema(def.block.schema.input, { io: "input", }) as SchemaNode; } catch { issues.push({ code: "LEVELSET_E302", message: `block "${def.code}" has an input schema that cannot be rendered as conf (z.toJSONSchema failed)`, }); continue; } if (schemaRequiresNull(schema)) { issues.push({ code: "LEVELSET_E302", message: `block "${def.code}" has an input schema that admits null, which the conf format cannot express (model "unbounded" as an absent optional param)`, }); } } // Confirm every reserved key is a bare-safe token (a defensive invariant — // a non-bare reserved key would need quoting everywhere). for (const key of RESERVED_BLOCK_KEYS) { if (!BARE_KEY.test(key)) { issues.push({ code: "LEVELSET_E302", message: `reserved key "${key}" is not bare-safe`, }); } } return issues; }