Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion scripts/codegen/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -199,6 +202,9 @@ interface EventVariant {

let generatedEnums = new Map<string, { enumName: string; values: string[] }>();

/** Schema definitions available during session event generation (for $ref resolution). */
let sessionDefinitions: Record<string, JSONSchema7Definition> = {};

function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[], description?: string): string {
const valuesKey = [...values].sort().join("|");
for (const [, existing] of generatedEnums) {
Expand Down Expand Up @@ -402,6 +408,21 @@ function resolveSessionPropertyType(
nestedClasses: Map<string, string>,
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}?`;
Comment on lines +415 to +424
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For $ref to an enum schema, the code returns the enum type name directly, bypassing the isRequired nullability handling. This makes optional enum properties non-nullable. Apply ? when isRequired is false (and keep enum generation/dedup logic intact).

Suggested change
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}?`;
let resolvedTypeName = className;
if (!nestedClasses.has(className)) {
const refSchema = resolveRef(propSchema.$ref, sessionDefinitions);
if (refSchema) {
if (refSchema.enum && Array.isArray(refSchema.enum)) {
resolvedTypeName = getOrCreateEnum(className, "", refSchema.enum as string[], enumOutput);
} else {
nestedClasses.set(className, generateNestedClass(className, refSchema, knownTypes, nestedClasses, enumOutput));
}
}
}
return isRequired ? resolvedTypeName : `${resolvedTypeName}?`;

Copilot uses AI. Check for mistakes.
}
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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -491,6 +524,7 @@ function generateDataClass(variant: EventVariant, knownTypes: Map<string, string

function generateSessionEventsCode(schema: JSONSchema7): string {
generatedEnums.clear();
sessionDefinitions = schema.definitions as Record<string, JSONSchema7Definition> || {};
const variants = extractEventVariants(schema);
Comment on lines 525 to 528
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionDefinitions is populated from schema.definitions only. If the session-events schema uses $defs (or if $ref paths point to #/$defs/...), $ref resolution in resolveSessionPropertyType will fail. Populate sessionDefinitions via collectDefinitions(...) (or preprocess the schema to normalize $defsdefinitions).

See below for a potential fix:

    sessionDefinitions = collectDefinitions(schema);
    const variants = extractEventVariants(schema);
    const knownTypes = new Map<string, string>();
    const nestedClasses = new Map<string, string>();
    const enumOutput: string[] = [];

    // Extract descriptions for base class properties from the first variant
    const sessionEventDefinition = sessionDefinitions.SessionEvent;
    const firstVariant = typeof sessionEventDefinition === "object" ? (sessionEventDefinition as JSONSchema7)?.anyOf?.[0] : undefined;

Copilot uses AI. Check for mistakes.
const knownTypes = new Map<string, string>();
const nestedClasses = new Map<string, string>();
Expand Down Expand Up @@ -600,6 +634,9 @@ let experimentalRpcTypes = new Set<string>();
let rpcKnownTypes = new Map<string, string>();
let rpcEnumOutput: string[] = [];

/** Schema definitions available during RPC generation (for $ref resolution). */
let rpcDefinitions: Record<string, JSONSchema7Definition> = {};

function singularPascal(s: string): string {
const p = toPascalCase(s);
if (p.endsWith("ies")) return `${p.slice(0, -3)}y`;
Expand All @@ -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");
Expand All @@ -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));
Expand Down Expand Up @@ -1065,6 +1122,7 @@ function generateRpcCode(schema: ApiSchema): string {
rpcKnownTypes.clear();
rpcEnumOutput = [];
generatedEnums.clear(); // Clear shared enum deduplication map
rpcDefinitions = collectDefinitions(schema as Record<string, unknown>);
const classes: string[] = [];

let serverRpcParts: string[] = [];
Expand Down
36 changes: 31 additions & 5 deletions scripts/codegen/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,6 +19,9 @@ import {
isRpcMethod,
postProcessSchema,
writeGeneratedFile,
collectDefinitions,
refTypeName,
resolveRef,
type ApiSchema,
type RpcMethod,
} from "./utils.js";
Expand Down Expand Up @@ -152,6 +155,7 @@ interface GoCodegenCtx {
enums: string[];
enumsByValues: Map<string, string>; // sorted-values-key → enumName
generatedNames: Set<string>;
definitions?: Record<string, JSONSchema7Definition>;
}

function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -514,6 +533,7 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string {
enums: [],
enumsByValues: new Map(),
generatedNames: new Set(),
definitions: schema.definitions as Record<string, JSONSchema7Definition> | undefined,
};

// Generate per-event data structs
Expand Down Expand Up @@ -802,10 +822,12 @@ async function generateRpc(schemaPath?: string): Promise<void> {
...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<string, unknown>);
const combinedSchema: JSONSchema7 = {
$schema: "http://json-schema.org/draft-07/schema#",
definitions: {},
definitions: { ...sharedDefs },
};

Comment on lines +825 to 832
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The combined RPC schema only sets definitions. If the API schema’s $ref pointers use #/$defs/..., quicktype may not resolve them unless $defs is also present (or refs are normalized). Consider adding $defs: combinedSchema.definitions (or normalizing $ref) when building the combined schema and per-type sources.

See below for a potential fix:

    const combinedDefinitions: Record<string, JSONSchema7Definition> = { ...sharedDefs };
    const combinedSchema: JSONSchema7 & { $defs?: Record<string, JSONSchema7Definition> } = {
        $schema: "http://json-schema.org/draft-07/schema#",
        definitions: combinedDefinitions,
        $defs: combinedDefinitions,

Copilot uses AI. Check for mistakes.
for (const method of allMethods) {
Expand All @@ -832,10 +854,14 @@ async function generateRpc(schemaPath?: string): Promise<void> {
}
}

// 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();
Expand Down
29 changes: 22 additions & 7 deletions scripts/codegen/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
isRpcMethod,
postProcessSchema,
writeGeneratedFile,
collectDefinitions,
isRpcMethod,
isNodeFullyExperimental,
type ApiSchema,
Expand Down Expand Up @@ -151,11 +152,20 @@ async function generateSessionEvents(schemaPath?: string): Promise<void> {

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);
Expand Down Expand Up @@ -214,10 +224,11 @@ async function generateRpc(schemaPath?: string): Promise<void> {
...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<string, unknown>);
const combinedSchema: JSONSchema7 = {
$schema: "http://json-schema.org/draft-07/schema#",
definitions: {},
definitions: { ...sharedDefs },
};

for (const method of allMethods) {
Expand All @@ -243,10 +254,14 @@ async function generateRpc(schemaPath?: string): Promise<void> {
}
}

// 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,
Comment on lines +260 to +262
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During RPC generation, each per-type schema source is created with only definitions. If an API schema uses $defs and $ref: "#/$defs/...", quicktype may fail to resolve those references. Include a $defs field (or normalize refs) alongside definitions when building schemaWithDefs to cover both pointer styles.

Suggested change
const schemaWithDefs: JSONSchema7 = {
...(typeof def === "object" ? (def as JSONSchema7) : {}),
definitions: combinedSchema.definitions,
const schemaWithDefs: JSONSchema7 & { $defs?: JSONSchema7["definitions"] } = {
...(typeof def === "object" ? (def as JSONSchema7) : {}),
definitions: combinedSchema.definitions,
$defs: combinedSchema.definitions,

Copilot uses AI. Check for mistakes.
};
await schemaInput.addSource({ name, schema: JSON.stringify(schemaWithDefs) });
}

const inputData = new InputData();
Expand Down
55 changes: 41 additions & 14 deletions scripts/codegen/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getApiSchemaPath,
postProcessSchema,
writeGeneratedFile,
collectDefinitions,
isRpcMethod,
isNodeFullyExperimental,
type ApiSchema,
Expand Down Expand Up @@ -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<string, unknown>);
const combinedSchema: JSONSchema7 = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
definitions: { ...sharedDefs },
Comment on lines +95 to +98
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The combined schema only populates definitions. If an input schema uses $defs and $ref: "#/$defs/...", json-schema-to-typescript won’t be able to resolve those pointers unless the combined schema also includes $defs (or refs are normalized). Consider emitting both definitions and $defs pointing at the shared map, or rewriting $ref paths during preprocessing.

Suggested change
const combinedSchema: JSONSchema7 = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
definitions: { ...sharedDefs },
const sharedDefinitions: Record<string, unknown> = { ...sharedDefs };
const combinedSchema: JSONSchema7 & { $defs: Record<string, unknown> } = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
definitions: sharedDefinitions,
$defs: sharedDefinitions,

Copilot uses AI. Check for mistakes.
};

// Track which type names come from experimental methods for JSDoc annotations.
const experimentalTypes = new Set<string>();

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). */`);
Expand Down
Loading
Loading