diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index e6042eae5..21701703e 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -10,11 +10,15 @@ 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, + postProcessSchema, + resolveRef, + refTypeName, isRpcMethod, isNodeFullyExperimental, EXCLUDED_EVENT_TYPES, @@ -297,6 +301,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) { @@ -318,7 +325,7 @@ function getOrCreateEnum(parentClassName: string, propName: string, values: stri } function extractEventVariants(schema: JSONSchema7): EventVariant[] { - const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; + const sessionEvent = collectDefinitions(schema as Record).SessionEvent as JSONSchema7; if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); return sessionEvent.anyOf @@ -504,6 +511,28 @@ function resolveSessionPropertyType( nestedClasses: Map, enumOutput: string[] ): string { + // Handle $ref by resolving against schema definitions + if (propSchema.$ref) { + const className = typeToClassName(refTypeName(propSchema.$ref)); + const refSchema = resolveRef(propSchema.$ref, sessionDefinitions); + if (!refSchema) { + return isRequired ? className : `${className}?`; + } + + if (refSchema.enum && Array.isArray(refSchema.enum)) { + const enumName = getOrCreateEnum(className, "", refSchema.enum as string[], enumOutput, refSchema.description); + return isRequired ? enumName : `${enumName}?`; + } + + if (refSchema.type === "object" && refSchema.properties) { + if (!nestedClasses.has(className)) { + nestedClasses.set(className, generateNestedClass(className, refSchema, knownTypes, nestedClasses, enumOutput)); + } + return isRequired ? className : `${className}?`; + } + + return resolveSessionPropertyType(refSchema, parentClassName, propName, isRequired, knownTypes, nestedClasses, enumOutput); + } 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"); @@ -535,28 +564,15 @@ function resolveSessionPropertyType( } if (propSchema.type === "array" && propSchema.items) { const items = propSchema.items as JSONSchema7; - // 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"); - const discriminatorInfo = findDiscriminator(variants); - if (discriminatorInfo) { - const baseClassName = `${parentClassName}${propName}Item`; - const renamedBase = applyTypeRename(baseClassName); - const polymorphicCode = generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput, items.description); - nestedClasses.set(renamedBase, polymorphicCode); - return isRequired ? `${renamedBase}[]` : `${renamedBase}[]?`; - } - } - if (items.type === "object" && items.properties) { - const itemClassName = `${parentClassName}${propName}Item`; - nestedClasses.set(itemClassName, generateNestedClass(itemClassName, items, knownTypes, nestedClasses, enumOutput)); - return isRequired ? `${itemClassName}[]` : `${itemClassName}[]?`; - } - if (items.enum && Array.isArray(items.enum)) { - const enumName = getOrCreateEnum(parentClassName, `${propName}Item`, items.enum as string[], enumOutput, items.description); - return isRequired ? `${enumName}[]` : `${enumName}[]?`; - } - const itemType = schemaTypeToCSharp(items, true, knownTypes); + const itemType = resolveSessionPropertyType( + items, + parentClassName, + `${propName}Item`, + true, + knownTypes, + nestedClasses, + enumOutput + ); return isRequired ? `${itemType}[]` : `${itemType}[]?`; } return schemaTypeToCSharp(propSchema, isRequired, knownTypes); @@ -595,13 +611,15 @@ function generateDataClass(variant: EventVariant, knownTypes: Map); const variants = extractEventVariants(schema); const knownTypes = new Map(); const nestedClasses = new Map(); const enumOutput: string[] = []; // Extract descriptions for base class properties from the first variant - const firstVariant = (schema.definitions?.SessionEvent as JSONSchema7)?.anyOf?.[0]; + const sessionEventDefinition = sessionDefinitions.SessionEvent; + const firstVariant = typeof sessionEventDefinition === "object" ? (sessionEventDefinition as JSONSchema7).anyOf?.[0] : undefined; const baseProps = typeof firstVariant === "object" && firstVariant?.properties ? firstVariant.properties : {}; const baseDesc = (name: string) => { const prop = baseProps[name]; @@ -691,7 +709,8 @@ export async function generateSessionEvents(schemaPath?: string): Promise console.log("C#: generating session-events..."); const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; - const code = generateSessionEventsCode(schema); + const processed = postProcessSchema(schema); + const code = generateSessionEventsCode(processed); const outPath = await writeGeneratedFile("dotnet/src/Generated/SessionEvents.cs", code); console.log(` ✓ ${outPath}`); await formatCSharpFile(outPath); @@ -706,6 +725,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`; @@ -734,6 +756,27 @@ function stableStringify(value: unknown): 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 = typeToClassName(refTypeName(schema.$ref)); + const refSchema = resolveRef(schema.$ref, rpcDefinitions); + if (!refSchema) { + return isRequired ? typeName : `${typeName}?`; + } + + if (refSchema.enum && Array.isArray(refSchema.enum)) { + const enumName = getOrCreateEnum(typeName, "", refSchema.enum as string[], rpcEnumOutput, refSchema.description); + return isRequired ? enumName : `${enumName}?`; + } + + if (refSchema.type === "object" && refSchema.properties) { + const cls = emitRpcClass(typeName, refSchema, "public", classes); + if (cls) classes.push(cls); + return isRequired ? typeName : `${typeName}?`; + } + + return resolveRpcType(refSchema, isRequired, parentClassName, propName, classes); + } // 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"); @@ -759,17 +802,12 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam classes.push(emitRpcClass(itemClass, items, "public", classes)); return isRequired ? `IList<${itemClass}>` : `IList<${itemClass}>?`; } - const itemType = schemaTypeToCSharp(items, true, rpcKnownTypes); + const itemType = resolveRpcType(items, true, parentClassName, `${propName}Item`, classes); return isRequired ? `IList<${itemType}>` : `IList<${itemType}>?`; } if (schema.type === "object" && schema.additionalProperties && typeof schema.additionalProperties === "object") { const vs = schema.additionalProperties as JSONSchema7; - if (vs.type === "object" && vs.properties) { - const valClass = `${parentClassName}${propName}Value`; - classes.push(emitRpcClass(valClass, vs, "public", classes)); - return isRequired ? `IDictionary` : `IDictionary?`; - } - const valueType = schemaTypeToCSharp(vs, true, rpcKnownTypes); + const valueType = resolveRpcType(vs, true, parentClassName, `${propName}Value`, classes); return isRequired ? `IDictionary` : `IDictionary?`; } return schemaTypeToCSharp(schema, isRequired, rpcKnownTypes); @@ -950,15 +988,9 @@ function emitServerInstanceMethod( if (typeof pSchema !== "object") continue; const isReq = requiredSet.has(pName); const jsonSchema = pSchema as JSONSchema7; - let csType: string; - // If the property has an enum, resolve to the generated enum type - if (jsonSchema.enum && Array.isArray(jsonSchema.enum) && requestClassName) { - const valuesKey = [...jsonSchema.enum].sort().join("|"); - const match = [...generatedEnums.values()].find((e) => [...e.values].sort().join("|") === valuesKey); - csType = match ? (isReq ? match.enumName : `${match.enumName}?`) : schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); - } else { - csType = schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); - } + const csType = requestClassName + ? resolveRpcType(jsonSchema, isReq, requestClassName, toPascalCase(pName), classes) + : schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); } @@ -1197,6 +1229,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..378f0a79a 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,10 @@ import { isRpcMethod, postProcessSchema, writeGeneratedFile, + collectDefinitions, + withSharedDefinitions, + refTypeName, + resolveRef, type ApiSchema, type RpcMethod, } from "./utils.js"; @@ -152,6 +156,7 @@ interface GoCodegenCtx { enums: string[]; enumsByValues: Map; // sorted-values-key → enumName generatedNames: Set; + definitions?: Record; } function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { @@ -257,6 +262,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 +534,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,11 +823,15 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.clientSession || {}), ]; - // Build a combined schema for quicktype - prefix types to avoid conflicts - const combinedSchema: JSONSchema7 = { - $schema: "http://json-schema.org/draft-07/schema#", - definitions: {}, - }; + // 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 = withSharedDefinitions( + { + $schema: "http://json-schema.org/draft-07/schema#", + }, + sharedDefs + ); for (const method of allMethods) { const baseName = toPascalCase(method.rpcMethod); @@ -832,10 +857,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 = withSharedDefinitions( + typeof def === "object" ? (def as JSONSchema7) : {}, + 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..5d84b0538 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -15,7 +15,8 @@ import { isRpcMethod, postProcessSchema, writeGeneratedFile, - isRpcMethod, + collectDefinitions, + withSharedDefinitions, isNodeFullyExperimental, type ApiSchema, type RpcMethod, @@ -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,11 +224,14 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.clientSession || {}), ]; - // Build a combined schema for quicktype - const combinedSchema: JSONSchema7 = { - $schema: "http://json-schema.org/draft-07/schema#", - definitions: {}, - }; + // Build a combined schema for quicktype, including shared definitions from the API schema + const sharedDefs = collectDefinitions(schema as Record); + const combinedSchema = withSharedDefinitions( + { + $schema: "http://json-schema.org/draft-07/schema#", + }, + sharedDefs + ); for (const method of allMethods) { const baseName = toPascalCase(method.rpcMethod); @@ -243,10 +256,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 = withSharedDefinitions( + typeof def === "object" ? (def as JSONSchema7) : {}, + 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..3032d83d7 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -14,6 +14,8 @@ import { getApiSchemaPath, postProcessSchema, writeGeneratedFile, + collectDefinitions, + withSharedDefinitions, isRpcMethod, isNodeFullyExperimental, type ApiSchema, @@ -88,32 +90,65 @@ 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 = withSharedDefinitions( + { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + }, + 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( + /\/\*\*\n \* This (?:interface|type) was referenced by `_RpcSchemaRoot`'s JSON-Schema\n \* via the `definition` "[^"]+"\.\n \*\/\n/g, + "\n" + ) + .replace(/export interface _RpcSchemaRoot\s*\{[^}]*\}\s*/g, "") + .replace(/export type _RpcSchemaRoot = [^;]+;\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..c64590fe8 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -24,6 +24,15 @@ export const REPO_ROOT = path.resolve(__dirname, "../.."); /** Event types to exclude from generation (internal/legacy types) */ export const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); +export interface JSONSchema7WithDefs extends JSONSchema7 { + $defs?: Record; +} + +export type SchemaWithSharedDefinitions = T & { + definitions: Record; + $defs: Record; +}; + // ── Schema paths ──────────────────────────────────────────────────────────── export async function getSessionEventsSchemaPath(): Promise { @@ -54,7 +63,7 @@ export async function getApiSchemaPath(cliArg?: string): Promise { export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { if (typeof schema !== "object" || schema === null) return schema; - const processed: JSONSchema7 = { ...schema }; + const processed = { ...schema } as JSONSchema7WithDefs; if ("const" in processed && typeof processed.const === "boolean") { processed.enum = [processed.const]; @@ -96,12 +105,14 @@ export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { } } - if (processed.definitions) { + const definitions = collectDefinitions(processed as Record); + if (Object.keys(definitions).length > 0) { const newDefs: Record = {}; - for (const [key, value] of Object.entries(processed.definitions)) { + for (const [key, value] of Object.entries(definitions)) { newDefs[key] = typeof value === "object" ? postProcessSchema(value as JSONSchema7) : value; } processed.definitions = newDefs; + processed.$defs = newDefs; } if (typeof processed.additionalProperties === "object") { @@ -130,6 +141,8 @@ export interface RpcMethod { } export interface ApiSchema { + definitions?: Record; + $defs?: Record; server?: Record; session?: Record; clientSession?: Record; @@ -153,3 +166,42 @@ 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 legacyDefinitions = (schema.definitions ?? {}) as Record; + const draft2019Definitions = (schema.$defs ?? {}) as Record; + return { ...draft2019Definitions, ...legacyDefinitions }; +} + +export function withSharedDefinitions( + schema: T, + definitions: Record +): SchemaWithSharedDefinitions { + const sharedDefinitions = { ...definitions }; + return { + ...schema, + definitions: sharedDefinitions, + $defs: sharedDefinitions, + }; +}