branch:
json-schema-types.ts
11798 bytesRaw
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
import {
  sanitizeToolName,
  toPascalCase,
  escapeJsDoc,
  escapeStringLiteral,
  quoteProp
} from "./utils";

export interface ConversionContext {
  root: JSONSchema7;
  depth: number;
  seen: Set<unknown>;
  maxDepth: number;
}

/**
 * Resolve an internal JSON Pointer $ref (e.g. #/definitions/Foo) against the root schema.
 * Returns null for external URLs or unresolvable paths.
 */
function resolveRef(
  ref: string,
  root: JSONSchema7
): JSONSchema7Definition | null {
  // "#" is a valid self-reference to the root schema
  if (ref === "#") return root;

  if (!ref.startsWith("#/")) return null;

  const segments = ref
    .slice(2)
    .split("/")
    .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));

  let current: unknown = root;
  for (const seg of segments) {
    if (current === null || typeof current !== "object") return null;
    current = (current as Record<string, unknown>)[seg];
    if (current === undefined) return null;
  }

  // Allow both object schemas and boolean schemas (true = any, false = never)
  if (typeof current === "boolean") return current;
  if (current === null || typeof current !== "object") return null;
  return current as JSONSchema7;
}

/**
 * Apply OpenAPI 3.0 `nullable: true` to a type result.
 */
function applyNullable(result: string, schema: unknown): string {
  if (
    result !== "unknown" &&
    result !== "never" &&
    (schema as Record<string, unknown>)?.nullable === true
  ) {
    return `${result} | null`;
  }
  return result;
}

/**
 * Convert a JSON Schema to a TypeScript type string.
 * This is a direct conversion without going through Zod.
 */
export function jsonSchemaToTypeString(
  schema: JSONSchema7Definition,
  indent: string,
  ctx: ConversionContext
): string {
  // Handle boolean schemas
  if (typeof schema === "boolean") {
    return schema ? "unknown" : "never";
  }

  // Depth guard
  if (ctx.depth >= ctx.maxDepth) return "unknown";

  // Circular reference guard
  if (ctx.seen.has(schema)) return "unknown";

  ctx.seen.add(schema);
  const nextCtx: ConversionContext = {
    ...ctx,
    depth: ctx.depth + 1
  };

  try {
    // Handle $ref
    if (schema.$ref) {
      const resolved = resolveRef(schema.$ref, ctx.root);
      if (!resolved) return "unknown";
      return applyNullable(
        jsonSchemaToTypeString(resolved, indent, nextCtx),
        schema
      );
    }

    // Handle anyOf/oneOf (union types)
    if (schema.anyOf) {
      const types = schema.anyOf.map((s) =>
        jsonSchemaToTypeString(s, indent, nextCtx)
      );
      return applyNullable(types.join(" | "), schema);
    }
    if (schema.oneOf) {
      const types = schema.oneOf.map((s) =>
        jsonSchemaToTypeString(s, indent, nextCtx)
      );
      return applyNullable(types.join(" | "), schema);
    }

    // Handle allOf (intersection types)
    if (schema.allOf) {
      const types = schema.allOf.map((s) =>
        jsonSchemaToTypeString(s, indent, nextCtx)
      );
      return applyNullable(types.join(" & "), schema);
    }

    // Handle enum
    if (schema.enum) {
      if (schema.enum.length === 0) return "never";
      const result = schema.enum
        .map((v) => {
          if (v === null) return "null";
          if (typeof v === "string") return '"' + escapeStringLiteral(v) + '"';
          if (typeof v === "object") return JSON.stringify(v) ?? "unknown";
          return String(v);
        })
        .join(" | ");
      return applyNullable(result, schema);
    }

    // Handle const
    if (schema.const !== undefined) {
      const result =
        schema.const === null
          ? "null"
          : typeof schema.const === "string"
            ? '"' + escapeStringLiteral(schema.const) + '"'
            : typeof schema.const === "object"
              ? (JSON.stringify(schema.const) ?? "unknown")
              : String(schema.const);
      return applyNullable(result, schema);
    }

    // Handle type
    const type = schema.type;

    if (type === "string") return applyNullable("string", schema);
    if (type === "number" || type === "integer")
      return applyNullable("number", schema);
    if (type === "boolean") return applyNullable("boolean", schema);
    if (type === "null") return "null";

    if (type === "array") {
      // Tuple support: prefixItems (JSON Schema 2020-12)
      const prefixItems = (schema as Record<string, unknown>)
        .prefixItems as JSONSchema7Definition[];
      if (Array.isArray(prefixItems)) {
        const types = prefixItems.map((s) =>
          jsonSchemaToTypeString(s, indent, nextCtx)
        );
        return applyNullable(`[${types.join(", ")}]`, schema);
      }

      // Tuple support: items as array (draft-07)
      if (Array.isArray(schema.items)) {
        const types = schema.items.map((s) =>
          jsonSchemaToTypeString(s, indent, nextCtx)
        );
        return applyNullable(`[${types.join(", ")}]`, schema);
      }

      if (schema.items) {
        const itemType = jsonSchemaToTypeString(schema.items, indent, nextCtx);
        return applyNullable(`${itemType}[]`, schema);
      }
      return applyNullable("unknown[]", schema);
    }

    if (type === "object" || schema.properties) {
      const props = schema.properties || {};
      const required = new Set(schema.required || []);
      const lines: string[] = [];

      for (const [propName, propSchema] of Object.entries(props)) {
        if (typeof propSchema === "boolean") {
          const boolType = propSchema ? "unknown" : "never";
          const optionalMark = required.has(propName) ? "" : "?";
          lines.push(
            `${indent}    ${quoteProp(propName)}${optionalMark}: ${boolType};`
          );
          continue;
        }

        const isRequired = required.has(propName);
        const propType = jsonSchemaToTypeString(
          propSchema,
          indent + "    ",
          nextCtx
        );
        const desc = propSchema.description;
        const format = propSchema.format;

        if (desc || format) {
          const descText = desc
            ? escapeJsDoc(desc.replace(/\r?\n/g, " "))
            : undefined;
          const formatTag = format
            ? `@format ${escapeJsDoc(format)}`
            : undefined;

          if (descText && formatTag) {
            lines.push(`${indent}    /**`);
            lines.push(`${indent}     * ${descText}`);
            lines.push(`${indent}     * ${formatTag}`);
            lines.push(`${indent}     */`);
          } else {
            lines.push(`${indent}    /** ${descText ?? formatTag} */`);
          }
        }

        const quotedName = quoteProp(propName);
        const optionalMark = isRequired ? "" : "?";
        lines.push(`${indent}    ${quotedName}${optionalMark}: ${propType};`);
      }

      // Handle additionalProperties
      if (schema.additionalProperties) {
        const valueType =
          schema.additionalProperties === true
            ? "unknown"
            : jsonSchemaToTypeString(
                schema.additionalProperties,
                indent + "    ",
                nextCtx
              );
        lines.push(`${indent}    [key: string]: ${valueType};`);
      }

      if (lines.length === 0) {
        if (schema.additionalProperties === false) {
          return applyNullable("{}", schema);
        }
        return applyNullable("Record<string, unknown>", schema);
      }

      const result = `{\n${lines.join("\n")}\n${indent}}`;
      return applyNullable(result, schema);
    }

    // Handle array of types (e.g., ["string", "null"])
    if (Array.isArray(type)) {
      const types = type.map((t) => {
        if (t === "string") return "string";
        if (t === "number" || t === "integer") return "number";
        if (t === "boolean") return "boolean";
        if (t === "null") return "null";
        if (t === "array") return "unknown[]";
        if (t === "object") return "Record<string, unknown>";
        return "unknown";
      });
      return applyNullable(types.join(" | "), schema);
    }

    return "unknown";
  } finally {
    ctx.seen.delete(schema);
  }
}

/**
 * Convert a JSON Schema to a TypeScript type declaration.
 */
export function jsonSchemaToType(
  schema: JSONSchema7,
  typeName: string
): string {
  const ctx: ConversionContext = {
    root: schema,
    depth: 0,
    seen: new Set(),
    maxDepth: 20
  };
  const typeBody = jsonSchemaToTypeString(schema, "", ctx);
  return `type ${typeName} = ${typeBody}`;
}

/**
 * Extract field descriptions from a JSON Schema's properties.
 */
function extractJsonSchemaDescriptions(
  schema: JSONSchema7
): Record<string, string> {
  const descriptions: Record<string, string> = {};
  if (schema.properties) {
    for (const [fieldName, propSchema] of Object.entries(schema.properties)) {
      if (
        propSchema &&
        typeof propSchema === "object" &&
        propSchema.description
      ) {
        descriptions[fieldName] = propSchema.description;
      }
    }
  }
  return descriptions;
}

/**
 * A tool descriptor using plain JSON Schema (no Zod or AI SDK dependency).
 */
export interface JsonSchemaToolDescriptor {
  description?: string;
  inputSchema: JSONSchema7;
  outputSchema?: JSONSchema7;
}

export type JsonSchemaToolDescriptors = Record<
  string,
  JsonSchemaToolDescriptor
>;

/**
 * Generate TypeScript type definitions from tool descriptors with JSON Schema.
 * This function has NO dependency on the AI SDK or Zod — it works purely with
 * JSON Schema objects.
 *
 * Use this when you have raw JSON Schema (e.g. from OpenAPI specs, MCP tool
 * definitions, etc.) and don't need the AI SDK.
 */
export function generateTypesFromJsonSchema(
  tools: JsonSchemaToolDescriptors
): string {
  let availableTools = "";
  let availableTypes = "";

  for (const [toolName, tool] of Object.entries(tools)) {
    const safeName = sanitizeToolName(toolName);
    const typeName = toPascalCase(safeName);

    try {
      const inputType = jsonSchemaToType(tool.inputSchema, `${typeName}Input`);

      const outputType = tool.outputSchema
        ? jsonSchemaToType(tool.outputSchema, `${typeName}Output`)
        : `type ${typeName}Output = unknown`;

      availableTypes += `\n${inputType.trim()}`;
      availableTypes += `\n${outputType.trim()}`;

      const paramLines = (() => {
        try {
          const paramDescs = extractJsonSchemaDescriptions(tool.inputSchema);
          return Object.entries(paramDescs).map(
            ([fieldName, desc]) => `@param input.${fieldName} - ${desc}`
          );
        } catch {
          return [];
        }
      })();
      const jsdocLines: string[] = [];
      if (tool.description?.trim()) {
        jsdocLines.push(
          escapeJsDoc(tool.description.trim().replace(/\r?\n/g, " "))
        );
      } else {
        jsdocLines.push(escapeJsDoc(toolName));
      }
      for (const pd of paramLines) {
        jsdocLines.push(escapeJsDoc(pd.replace(/\r?\n/g, " ")));
      }

      const jsdocBody = jsdocLines.map((l) => `\t * ${l}`).join("\n");
      availableTools += `\n\t/**\n${jsdocBody}\n\t */`;
      availableTools += `\n\t${safeName}: (input: ${typeName}Input) => Promise<${typeName}Output>;`;
      availableTools += "\n";
    } catch {
      availableTypes += `\ntype ${typeName}Input = unknown`;
      availableTypes += `\ntype ${typeName}Output = unknown`;

      availableTools += `\n\t/**\n\t * ${escapeJsDoc(toolName)}\n\t */`;
      availableTools += `\n\t${safeName}: (input: ${typeName}Input) => Promise<${typeName}Output>;`;
      availableTools += "\n";
    }
  }

  availableTools = `\ndeclare const codemode: {${availableTools}}`;

  return `
${availableTypes}
${availableTools}
  `.trim();
}