diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 9049cb38c..849e512d5 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -10,11 +10,14 @@ import { execFile } from "child_process"; import fs from "fs/promises"; import path from "path"; import { promisify } from "util"; -import type { JSONSchema7 } from "json-schema"; +import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import { getSessionEventsSchemaPath, getApiSchemaPath, writeGeneratedFile, + collectDefinitions, + resolveRef, + refTypeName, isRpcMethod, isNodeFullyExperimental, EXCLUDED_EVENT_TYPES, @@ -199,6 +202,9 @@ interface EventVariant { let generatedEnums = new Map(); +/** Schema definitions available during session event generation (for $ref resolution). */ +let sessionDefinitions: Record = {}; + function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[], description?: string): string { const valuesKey = [...values].sort().join("|"); for (const [, existing] of generatedEnums) { @@ -402,6 +408,21 @@ function resolveSessionPropertyType( nestedClasses: Map, enumOutput: string[] ): string { + // Handle $ref by resolving against schema definitions + if (propSchema.$ref) { + const typeName = refTypeName(propSchema.$ref); + const className = typeToClassName(typeName); + if (!nestedClasses.has(className)) { + const refSchema = resolveRef(propSchema.$ref, sessionDefinitions); + if (refSchema) { + if (refSchema.enum && Array.isArray(refSchema.enum)) { + return getOrCreateEnum(className, "", refSchema.enum as string[], enumOutput); + } + nestedClasses.set(className, generateNestedClass(className, refSchema, knownTypes, nestedClasses, enumOutput)); + } + } + return isRequired ? className : `${className}?`; + } if (propSchema.anyOf) { const hasNull = propSchema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); const nonNull = propSchema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); @@ -433,6 +454,18 @@ function resolveSessionPropertyType( } if (propSchema.type === "array" && propSchema.items) { const items = propSchema.items as JSONSchema7; + // Handle $ref in array items + if (items.$ref) { + const typeName = refTypeName(items.$ref); + const className = typeToClassName(typeName); + if (!nestedClasses.has(className)) { + const refSchema = resolveRef(items.$ref, sessionDefinitions); + if (refSchema) { + nestedClasses.set(className, generateNestedClass(className, refSchema, knownTypes, nestedClasses, enumOutput)); + } + } + return isRequired ? `${className}[]` : `${className}[]?`; + } // Array of discriminated union (anyOf with shared discriminator) if (items.anyOf && Array.isArray(items.anyOf)) { const variants = items.anyOf.filter((v): v is JSONSchema7 => typeof v === "object"); @@ -491,6 +524,7 @@ function generateDataClass(variant: EventVariant, knownTypes: Map || {}; const variants = extractEventVariants(schema); const knownTypes = new Map(); const nestedClasses = new Map(); @@ -600,6 +634,9 @@ let experimentalRpcTypes = new Set(); let rpcKnownTypes = new Map(); let rpcEnumOutput: string[] = []; +/** Schema definitions available during RPC generation (for $ref resolution). */ +let rpcDefinitions: Record = {}; + function singularPascal(s: string): string { const p = toPascalCase(s); if (p.endsWith("ies")) return `${p.slice(0, -3)}y`; @@ -617,6 +654,16 @@ function paramsTypeName(rpcMethod: string): string { } function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string { + // Handle $ref by resolving against schema definitions and generating the referenced class + if (schema.$ref) { + const typeName = refTypeName(schema.$ref); + const refSchema = resolveRef(schema.$ref, rpcDefinitions); + if (refSchema && !emittedRpcClasses.has(typeName)) { + const cls = emitRpcClass(typeName, refSchema, "public", classes); + if (cls) classes.push(cls); + } + return isRequired ? typeName : `${typeName}?`; + } // Handle anyOf: [T, null] → T? (nullable typed property) if (schema.anyOf) { const hasNull = schema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); @@ -637,6 +684,16 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam } if (schema.type === "array" && schema.items) { const items = schema.items as JSONSchema7; + // Handle $ref in array items + if (items.$ref) { + const typeName = refTypeName(items.$ref); + const refSchema = resolveRef(items.$ref, rpcDefinitions); + if (refSchema && !emittedRpcClasses.has(typeName)) { + const cls = emitRpcClass(typeName, refSchema, "public", classes); + if (cls) classes.push(cls); + } + return isRequired ? `List<${typeName}>` : `List<${typeName}>?`; + } if (items.type === "object" && items.properties) { const itemClass = singularPascal(propName); if (!emittedRpcClasses.has(itemClass)) classes.push(emitRpcClass(itemClass, items, "public", classes)); @@ -1065,6 +1122,7 @@ function generateRpcCode(schema: ApiSchema): string { rpcKnownTypes.clear(); rpcEnumOutput = []; generatedEnums.clear(); // Clear shared enum deduplication map + rpcDefinitions = collectDefinitions(schema as Record); const classes: string[] = []; let serverRpcParts: string[] = []; diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 101702f18..90e49d302 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -8,7 +8,7 @@ import { execFile } from "child_process"; import fs from "fs/promises"; -import type { JSONSchema7 } from "json-schema"; +import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; import { promisify } from "util"; import { @@ -19,6 +19,9 @@ import { isRpcMethod, postProcessSchema, writeGeneratedFile, + collectDefinitions, + refTypeName, + resolveRef, type ApiSchema, type RpcMethod, } from "./utils.js"; @@ -152,6 +155,7 @@ interface GoCodegenCtx { enums: string[]; enumsByValues: Map; // sorted-values-key → enumName generatedNames: Set; + definitions?: Record; } function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { @@ -257,6 +261,21 @@ function resolveGoPropertyType( ): string { const nestedName = parentTypeName + toGoFieldName(jsonPropName); + // Handle $ref — resolve the reference and generate the referenced type + if (propSchema.$ref && typeof propSchema.$ref === "string") { + const typeName = toGoFieldName(refTypeName(propSchema.$ref)); + const resolved = resolveRef(propSchema.$ref, ctx.definitions); + if (resolved) { + if (resolved.enum) { + return getOrCreateGoEnum(typeName, resolved.enum as string[], ctx, resolved.description); + } + emitGoStruct(typeName, resolved, ctx); + return isRequired ? typeName : `*${typeName}`; + } + // Fallback: use the type name directly + return isRequired ? typeName : `*${typeName}`; + } + // Handle anyOf if (propSchema.anyOf) { const nonNull = (propSchema.anyOf as JSONSchema7[]).filter((s) => s.type !== "null"); @@ -514,6 +533,7 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { enums: [], enumsByValues: new Map(), generatedNames: new Set(), + definitions: schema.definitions as Record | undefined, }; // Generate per-event data structs @@ -802,10 +822,12 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.clientSession || {}), ]; - // Build a combined schema for quicktype - prefix types to avoid conflicts + // Build a combined schema for quicktype — prefix types to avoid conflicts. + // Include shared definitions from the API schema for $ref resolution. + const sharedDefs = collectDefinitions(schema as Record); const combinedSchema: JSONSchema7 = { $schema: "http://json-schema.org/draft-07/schema#", - definitions: {}, + definitions: { ...sharedDefs }, }; for (const method of allMethods) { @@ -832,10 +854,14 @@ async function generateRpc(schemaPath?: string): Promise { } } - // Generate types via quicktype + // Generate types via quicktype — include all definitions in each source for $ref resolution const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); for (const [name, def] of Object.entries(combinedSchema.definitions!)) { - await schemaInput.addSource({ name, schema: JSON.stringify(def) }); + const schemaWithDefs: JSONSchema7 = { + ...(typeof def === "object" ? (def as JSONSchema7) : {}), + definitions: combinedSchema.definitions, + }; + await schemaInput.addSource({ name, schema: JSON.stringify(schemaWithDefs) }); } const inputData = new InputData(); diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index 2aa593c5d..41ce7c2b1 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -15,6 +15,7 @@ import { isRpcMethod, postProcessSchema, writeGeneratedFile, + collectDefinitions, isRpcMethod, isNodeFullyExperimental, type ApiSchema, @@ -151,11 +152,20 @@ async function generateSessionEvents(schemaPath?: string): Promise { const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; - const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; - const processed = postProcessSchema(resolvedSchema); + const processed = postProcessSchema(schema); + + // Extract SessionEvent as root but keep all other definitions for $ref resolution + const sessionEventDef = (processed.definitions?.SessionEvent as JSONSchema7) || processed; + const otherDefs = Object.fromEntries( + Object.entries(processed.definitions || {}).filter(([key]) => key !== "SessionEvent") + ); + const schemaForQuicktype: JSONSchema7 = { + ...sessionEventDef, + ...(Object.keys(otherDefs).length > 0 ? { definitions: otherDefs } : {}), + }; const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); - await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(processed) }); + await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(schemaForQuicktype) }); const inputData = new InputData(); inputData.addInput(schemaInput); @@ -214,10 +224,11 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.clientSession || {}), ]; - // Build a combined schema for quicktype + // Build a combined schema for quicktype, including shared definitions from the API schema + const sharedDefs = collectDefinitions(schema as Record); const combinedSchema: JSONSchema7 = { $schema: "http://json-schema.org/draft-07/schema#", - definitions: {}, + definitions: { ...sharedDefs }, }; for (const method of allMethods) { @@ -243,10 +254,14 @@ async function generateRpc(schemaPath?: string): Promise { } } - // Generate types via quicktype + // Generate types via quicktype — include all definitions in each source for $ref resolution const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); for (const [name, def] of Object.entries(combinedSchema.definitions!)) { - await schemaInput.addSource({ name, schema: JSON.stringify(def) }); + const schemaWithDefs: JSONSchema7 = { + ...(typeof def === "object" ? (def as JSONSchema7) : {}), + definitions: combinedSchema.definitions, + }; + await schemaInput.addSource({ name, schema: JSON.stringify(schemaWithDefs) }); } const inputData = new InputData(); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index e5e82bdc6..5d6c49d26 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -14,6 +14,7 @@ import { getApiSchemaPath, postProcessSchema, writeGeneratedFile, + collectDefinitions, isRpcMethod, isNodeFullyExperimental, type ApiSchema, @@ -88,32 +89,58 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); + // Build a single combined schema with shared definitions and all method types. + // This ensures $ref-referenced types are generated exactly once. + const sharedDefs = collectDefinitions(schema as Record); + const combinedSchema: JSONSchema7 = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + definitions: { ...sharedDefs }, + }; + + // Track which type names come from experimental methods for JSDoc annotations. + const experimentalTypes = new Set(); + for (const method of [...allMethods, ...clientSessionMethods]) { if (method.result) { - const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { - bannerComment: "", - additionalProperties: false, - }); + combinedSchema.definitions![resultTypeName(method.rpcMethod)] = method.result; if (method.stability === "experimental") { - lines.push("/** @experimental */"); + experimentalTypes.add(resultTypeName(method.rpcMethod)); } - lines.push(compiled.trim()); - lines.push(""); } if (method.params?.properties && Object.keys(method.params.properties).length > 0) { - const paramsCompiled = await compile(method.params, paramsTypeName(method.rpcMethod), { - bannerComment: "", - additionalProperties: false, - }); + combinedSchema.definitions![paramsTypeName(method.rpcMethod)] = method.params; if (method.stability === "experimental") { - lines.push("/** @experimental */"); + experimentalTypes.add(paramsTypeName(method.rpcMethod)); } - lines.push(paramsCompiled.trim()); - lines.push(""); } } + const compiled = await compile(combinedSchema, "_RpcSchemaRoot", { + bannerComment: "", + additionalProperties: false, + unreachableDefinitions: true, + }); + + // Strip the placeholder root type and keep only the definition-generated types + const strippedTs = compiled + .replace(/export interface _RpcSchemaRoot\s*\{[^}]*\}\s*/g, "") + .trim(); + + if (strippedTs) { + // Add @experimental JSDoc annotations for types from experimental methods + let annotatedTs = strippedTs; + for (const expType of experimentalTypes) { + annotatedTs = annotatedTs.replace( + new RegExp(`(^|\\n)(export (?:interface|type) ${expType}\\b)`, "m"), + `$1/** @experimental */\n$2` + ); + } + lines.push(annotatedTs); + lines.push(""); + } + // Generate factory functions if (schema.server) { lines.push(`/** Create typed server-scoped RPC methods (no session required). */`); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 1e95b4dd4..a568bcf9c 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -56,6 +56,15 @@ export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { const processed: JSONSchema7 = { ...schema }; + // Normalize $defs → definitions for draft 2019+ compatibility + if ("$defs" in processed && !processed.definitions) { + processed.definitions = (processed as Record).$defs as Record< + string, + JSONSchema7Definition + >; + delete (processed as Record).$defs; + } + if ("const" in processed && typeof processed.const === "boolean") { processed.enum = [processed.const]; delete processed.const; @@ -130,6 +139,8 @@ export interface RpcMethod { } export interface ApiSchema { + definitions?: Record; + $defs?: Record; server?: Record; session?: Record; clientSession?: Record; @@ -153,3 +164,29 @@ export function isNodeFullyExperimental(node: Record): boolean })(node); return methods.length > 0 && methods.every(m => m.stability === "experimental"); } + +// ── $ref resolution ───────────────────────────────────────────────────────── + +/** Extract the type name from a `$ref` path (e.g. "#/definitions/Model" → "Model"). */ +export function refTypeName(ref: string): string { + return ref.split("/").pop()!; +} + +/** Resolve a `$ref` path against a definitions map, returning the referenced schema. */ +export function resolveRef( + ref: string, + definitions: Record | undefined +): JSONSchema7 | undefined { + const match = ref.match(/^#\/(definitions|\$defs)\/(.+)$/); + if (!match || !definitions) return undefined; + const def = definitions[match[2]]; + return typeof def === "object" ? (def as JSONSchema7) : undefined; +} + +/** Collect the shared definitions from a schema (handles both `definitions` and `$defs`). */ +export function collectDefinitions( + schema: Record +): Record { + const defs = (schema.definitions ?? schema.$defs ?? {}) as Record; + return { ...defs } +}