/* Generator for lagebild-contracts.types.generated.ts — derives the Werkstatt-side wire types for the Lagebild ingress contract from the vendored Verbund spec snapshot (PLAN-RFC-1238-01, RFC-1238/AMD-WERKSTATT-02 delivered). Reads the YAML schemas byte-exact from docs/specs/ and emits TypeScript interfaces; never reads the Lagebild repository (VID-001) and never patches snapshots (VID-014). Not a generic JSON Schema to TypeScript mapper — it covers exactly the constructs the Verbund schemas use (object, enum, const, oneOf/anyOf, $ref, type:[X,"null"], arrays) and fails loudly on anything else so a schema change can never silently mis-derive. Does not generate the client-side input/result types (IngressSubmitInput, IngressResult, LagebildIngressConfig) — those are Werkstatt-internal, not contract surfaces. RFC-1238: initial generator (v0.1.4 pin).RFC-1269: pin bump to verbund-integration-spec-v0.1.5 — AMD-WERKSTATT-05 accepted and shipped by Lagebild (ADR-0009). */ // Run: pnpm --filter @warpgogol/werkstatt-shared exec node \ // src/integration/codegen/verbund-contract-types.ts // (Node 24 type-stripping — no tsx dependency.) import { readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; // VID-011 pin — bump when a newer verbund-integration-spec snapshot is // vendored, then regenerate. const SPEC_VERSION = "verbund-integration-spec-v0.1.5"; const SPECS_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../../../../docs/specs"); const SCHEMAS_DIR = join(SPECS_ROOT, SPEC_VERSION, "schemas"); const OUT_FILE = join( dirname(fileURLToPath(import.meta.url)), "../lagebild-contracts.types.generated.ts", ); // The egress closure of website-ingress.schema.yaml, in dependency order. const SCHEMA_FILES = [ "process-hint.schema.yaml", "acquisition-context.schema.yaml", "website-ingress.schema.yaml", ] as const; // Canonical type names per schema file (kept stable for lagebild-ingress.ts // re-exports and adapter call sites). const ROOT_NAMES: Record = { "process-hint.schema.yaml": "IngressProcessHint", "acquisition-context.schema.yaml": "AcquisitionContext", "website-ingress.schema.yaml": "WebsiteIngressPayload", }; // Nested object/enum constructs get names via this path map — anything not // listed here makes the generator throw instead of inventing names. const NESTED_NAMES: Record = { "website-ingress.identity_claims.items": "IdentityClaim", "website-ingress.identity_claims.items.claim_type": "IdentityClaimType", "website-ingress.origin": "IngressOrigin", "website-ingress.explicit_policy_assertions.items": "ExplicitPolicyAssertion", "website-ingress.explicit_policy_assertions.items.kind": "PolicyAssertionKind", "website-ingress.attribution": "IngressAttribution", "website-ingress.interaction_kind": "IngressInteractionKind", "process-hint.system": "IngressProcessHintSystem", }; interface Emitted { name: string; jsdoc: string[]; body: string[]; } const emitted: Emitted[] = []; const emittedNames = new Set(); type Schema = Record; function jsdoc(text: unknown): string[] { return typeof text === "string" && text.trim() !== "" ? [`/** ${text.trim().replace(/\s+/g, " ")} */`] : []; } function schemaBasename(ref: string): string { return ref.replace(/^\.\//, ""); } function rootName(file: string): string { const name = ROOT_NAMES[file]; if (!name) throw new Error(`no ROOT_NAMES entry for ${file} — extend the generator`); return name; } function shortPath(path: string): string { return path.replace(/\.schema\.yaml$/, ""); } // Resolve a $ref to a named type, lazily emitting the target schema first. function emitRef(ref: string, schemas: Map): string { const file = schemaBasename(ref); const name = rootName(file); if (!emittedNames.has(name)) { const schema = schemas.get(file); if (!schema) throw new Error(`unresolvable $ref ${ref} — file not in SCHEMA_FILES`); emitRoot(file, schema, schemas); } return name; } // Union member or leaf: returns a TS type expression. function tsType(node: Schema, path: string, schemas: Map): string { if (typeof node["const"] !== "undefined") return JSON.stringify(node["const"]); if (Array.isArray(node["enum"])) { return (node["enum"] as unknown[]).map((v) => JSON.stringify(v)).join(" | "); } if (typeof node["$ref"] === "string") return emitRef(node["$ref"], schemas); if (Array.isArray(node["oneOf"]) || Array.isArray(node["anyOf"])) { const members = (node["oneOf"] ?? node["anyOf"]) as Schema[]; return members.map((m) => tsType(m, path, schemas)).join(" | "); } const t = node["type"]; if (Array.isArray(t)) { return t.map((member) => tsType({ ...node, type: member }, path, schemas)).join(" | "); } switch (t) { case "string": return "string"; case "boolean": return "boolean"; case "number": case "integer": return "number"; case "null": return "null"; case "array": return `readonly ${tsType(node["items"] as Schema, `${path}.items`, schemas)}[]`; case "object": { if (!node["properties"]) return "Record"; const name = NESTED_NAMES[path]; if (!name) { throw new Error( `anonymous object at ${path} — add a NESTED_NAMES entry (schema changed; regenerate deliberately)`, ); } emitInterface(name, node, path, schemas); return name; } default: throw new Error(`unsupported schema construct at ${path}: ${JSON.stringify(node)}`); } } function emitInterface( name: string, schema: Schema, path: string, schemas: Map, ): void { if (emittedNames.has(name)) return; emittedNames.add(name); const required = new Set((schema["required"] as string[] | undefined) ?? []); const props = (schema["properties"] as Record | undefined) ?? {}; const body: string[] = [`export interface ${name} {`]; for (const [key, propSchema] of Object.entries(props)) { // Enums on the wire get named union types via NESTED_NAMES. let typeExpr: string; const enumPath = `${path}.${key}`; if (Array.isArray(propSchema["enum"]) && NESTED_NAMES[enumPath]) { const enumName = NESTED_NAMES[enumPath]; emitEnum(enumName, propSchema); typeExpr = enumName; } else { typeExpr = tsType(propSchema, enumPath, schemas); } body.push(...jsdoc(propSchema["description"]).map((l) => ` ${l}`)); body.push(` readonly ${key}${required.has(key) ? "" : "?"}: ${typeExpr};`); } body.push("}"); emitted.push({ name, jsdoc: jsdoc(schema["description"]), body }); } function emitEnum(name: string, schema: Schema): void { if (emittedNames.has(name)) return; emittedNames.add(name); const members = (schema["enum"] as unknown[]).map((v) => JSON.stringify(v)).join(" | "); emitted.push({ name, jsdoc: jsdoc(schema["description"]), body: [`export type ${name} = ${members};`], }); } function emitRoot(file: string, schema: Schema, schemas: Map): void { emitInterface(rootName(file), schema, shortPath(file), schemas); } // --- load schemas ----------------------------------------------------------- const schemas = new Map(); const hash = createHash("sha256"); for (const file of SCHEMA_FILES) { const bytes = readFileSync(join(SCHEMAS_DIR, file)); hash.update(bytes); schemas.set(file, parseYaml(bytes.toString("utf8")) as Schema); } const sourceHash = hash.digest("hex"); // --- emit ------------------------------------------------------------------- for (const file of SCHEMA_FILES) { emitRoot(file, schemas.get(file)!, schemas); } const header = [ "// GENERATED. Do not change this line unless the file contains project specific changes.", "// DO NOT EDIT THIS FILE. Changes are overwritten on the next build.", "// Owner command: verbund.contract.types.generate", "// Edit instead: packages/werkstatt-shared/src/integration/codegen/verbund-contract-types.ts", "// Regenerate: pnpm --filter @warpgogol/werkstatt-shared exec node src/integration/codegen/verbund-contract-types.ts", `// sourceHash: ${sourceHash}`, `// Source: docs/specs/${SPEC_VERSION}/schemas/{${SCHEMA_FILES.map((f) => f.replace(/\.schema\.yaml$/, "")).join(",")}}.schema.yaml`, "// Generated by: PLAN-RFC-1238-01 (RFC-1238) — types derived byte-exact from the vendored Verbund contract. Do not hand-edit.", "", ].join("\n"); const output = header + emitted.map((e) => [...e.jsdoc, ...e.body].join("\n")).join("\n\n") + "\n"; writeFileSync(OUT_FILE, output); console.log(`wrote ${OUT_FILE} (${emitted.length} types, sourceHash ${sourceHash.slice(0, 12)}…)`);