From b7b53bbd858f92f24e88c0d37814ce10923c95c0 Mon Sep 17 00:00:00 2001 From: Michal Moskal Date: Thu, 24 Apr 2025 11:36:52 -0700 Subject: [PATCH 1/2] add getJsonSchema() to TypeChatJsonValidator --- typescript/src/ts/ast.ts | 348 ++++++++++++++++++++++++++++++++++ typescript/src/ts/validate.ts | 11 +- typescript/src/typechat.ts | 19 ++ 3 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 typescript/src/ts/ast.ts diff --git a/typescript/src/ts/ast.ts b/typescript/src/ts/ast.ts new file mode 100644 index 00000000..e25231a1 --- /dev/null +++ b/typescript/src/ts/ast.ts @@ -0,0 +1,348 @@ +import ts, { TypeNode } from "typescript"; +import { error, Result, success } from "../result"; +import { JsonSchemaOptions } from "../typechat"; + +type PendingSchema = { + name: string; + node: ts.Node; + schema: Schema; +}; + +type Schema = + | ArraySchema + | ObjectSchema + | ConstSchema + | PrimitiveSchema + | AnyOfSchema + | RefSchema + | EnumSchema + | true; + +type SchemaLiteral = string | number | boolean | null; + +type ArraySchema = { + type: "array"; + items: Schema; +}; + +type ConstSchema = { + const: SchemaLiteral; +}; + +type ObjectSchema = { + type: "object"; + properties: Record; + required: string[]; +}; + +type PrimitiveSchema = { + type: "string" | "number" | "boolean" | "null"; +}; + +type AnyOfSchema = { + anyOf: Schema[]; +}; + +type RefSchema = { + $ref: string; +}; + +type EnumSchema = { + enum: SchemaLiteral[]; +}; + +export function tsToJsonSchema( + program: ts.Program, + options?: JsonSchemaOptions +): Result { + const syntacticDiagnostics = program.getSyntacticDiagnostics(); + const programDiagnostics = syntacticDiagnostics.length + ? syntacticDiagnostics + : program.getSemanticDiagnostics(); + + const sourceFiles = program.getSourceFiles(); + const schemaSourceFile = sourceFiles.find( + (sourceFile) => sourceFile.fileName == "/schema.ts" + ); + const schemaFileName = options?.fileName ?? "/schema.ts"; + if (!schemaSourceFile) { + return error(`No schema.ts file found`); + } + + if (programDiagnostics.length) { + let errors = ""; + for (const diagnostic of programDiagnostics) { + const fmtLoc = formatLocation(diagnostic.start!, diagnostic.file!); + const message = ts.flattenDiagnosticMessageText( + diagnostic.messageText, + "\n" + ); + const formatted = `${fmtLoc}: ${message}`; + errors += formatted + "\n"; + } + return error(errors); + } + + const refs: [ts.Node, string][] = []; + const defs: Record = {}; + const flatDefs: Record = {}; + const diags: string[] = []; + + function addDef(node: ts.NamedDeclaration, schema: () => Schema) { + const name = node.name?.getText(); + if (!name) { + warn(node, "missing name"); + return; + } + if (defs[name]) { + warn(node, `duplicate type name ${name}`); + return; + } + + defs[name] = { + name, + node, + schema: schema(), + }; + flatDefs[name] = defs[name].schema; + } + + ts.forEachChild(schemaSourceFile, (node) => { + if (ts.isTypeAliasDeclaration(node)) { + if (node.typeParameters) { + warn(node, "type parameters not supported"); + } + addDef(node, () => mapTypeName(node.type)); + } else if (ts.isInterfaceDeclaration(node)) { + if (node.typeParameters) { + warn(node, "type parameters not supported"); + } + if (node.heritageClauses) { + warn(node, "interface extends not supported"); + } + addDef(node, () => mapMembers(node.members)); + } else if (node.kind == ts.SyntaxKind.EndOfFileToken) { + // skip + } else { + warn(node, `skipping node type ${ts.SyntaxKind[node.kind]}`); + } + }); + + for (const [node, name] of refs) { + const def = defs[name]; + if (!def) { + err(node, `missing type definition for ${name}`); + } else { + // OK + } + } + + if (diags.length) { + return error(diags.join("\n")); + } + return success({ + $defs: flatDefs, + }); + + function mapMembers(members: ts.NodeArray): ObjectSchema { + const res: ObjectSchema = { + type: "object", + properties: {}, + required: [], + }; + members.forEach((member) => { + const name = member.name?.getText(); + if (!name) { + warn(member, "member without name"); + } else if (ts.isPropertySignature(member)) { + const isOptional = !!member.questionToken; + if (!member.type) { + warn(member, "property signature without type"); + res.properties[name] = true; + } else { + res.properties[name] = mapTypeName(member.type, isOptional); + } + if (!isOptional) { + res.required.push(name); + } + } else if (ts.isMethodSignature(member)) { + warn(member, "method signature not supported"); + } else { + warn( + member, + `unsupported interface member ${ts.SyntaxKind[member.kind]}` + ); + } + }); + return res; + } + + function mapAsConst(tp: ts.TypeNode): ConstSchema | null { + if (!ts.isLiteralTypeNode(tp)) return null; + + const lit = tp.literal; + if (ts.isStringLiteralLike(lit)) { + const text = lit.getText(); + let c0 = text[0]; + if ((c0 == "'" || c0 == '"' || c0 == "`") && text.endsWith(c0)) { + const quoted = '"' + text.slice(1, -1) + '"'; + try { + const parsed = JSON.parse(quoted); + return { + const: parsed, + }; + } catch (e) { + err(lit, `failed to parse string literal: ${text}`); + return null; + } + } else { + err(lit, "string literal without quotes"); + return null; + } + } else if (ts.isNumericLiteral(lit)) { + const text = lit.getText(); + const parsed = parseFloat(text); + if (isNaN(parsed)) { + err(lit, `failed to parse number literal: ${text}`); + return null; + } + return { + const: parsed, + }; + } else if (lit.kind == ts.SyntaxKind.TrueKeyword) { + return { + const: true, + }; + } else if (lit.kind == ts.SyntaxKind.FalseKeyword) { + return { + const: false, + }; + } + + return null; + } + + function mapTypeName(tp: TypeNode, skipUndefined?: boolean): Schema { + const constSchema = mapAsConst(tp); + if (constSchema) return constSchema; + + if (ts.isTypeLiteralNode(tp)) { + return mapMembers(tp.members); + } else if (ts.isTypeReferenceNode(tp)) { + const name = tp.typeName.getText(); + if (name == "Array") { + if (tp.typeArguments?.length != 1) { + warn(tp, "Array type reference without type argument"); + return { + type: "array", + items: true, + }; + } + return { + type: "array", + items: mapTypeName(tp.typeArguments![0]), + }; + } + if (name == "Number") { + return { + type: "number", + }; + } + if (name == "String") { + return { + type: "string", + }; + } + if (name == "Boolean") { + return { + type: "boolean", + }; + } + + refs.push([tp, name]); + return { + $ref: "#/$defs/" + name, + }; + } else if (ts.isUnionTypeNode(tp)) { + if (tp.types.length == 1) skipUndefined = false; + const types = tp.types.filter( + (t) => !(skipUndefined && t.kind == ts.SyntaxKind.UndefinedKeyword) + ); + if (types.length == 1) { + return mapTypeName(types[0]); + } + + const consts = types.map(mapAsConst); + if (consts.some((c) => c == null)) { + return { + anyOf: types.map((t) => mapTypeName(t)), + }; + } else { + return { + enum: consts.map((c) => c!.const), + }; + } + } else if (ts.isArrayTypeNode(tp)) { + return { + type: "array", + items: mapTypeName(tp.elementType), + }; + } else if (ts.isParenthesizedTypeNode(tp)) { + return mapTypeName(tp.type); + } else if (tp.kind == ts.SyntaxKind.StringKeyword) { + return { + type: "string", + }; + } else if (tp.kind == ts.SyntaxKind.BooleanKeyword) { + return { + type: "boolean", + }; + } else if (tp.kind == ts.SyntaxKind.NumberKeyword) { + return { + type: "number", + }; + } else if (tp.kind == ts.SyntaxKind.AnyKeyword) { + return true; + } else if ( + tp.kind == ts.SyntaxKind.UndefinedKeyword || + tp.kind == ts.SyntaxKind.NullKeyword + ) { + return { + type: "null", + }; + } else { + warn(tp, "unhandled type kind: " + ts.SyntaxKind[tp.kind]); + return true; + } + } + + function warn(node: ts.Node, msg: string) { + if (options?.ignoreWarnings) return; + err(node, msg); + } + + function err(node: ts.Node, msg: string) { + const formatted = `${nodeLocation(node)}: ${msg}`; + diags.push(formatted); + } + + function nodeLocation(node: ts.Node) { + const sourceFile = node.getSourceFile() ?? schemaSourceFile; + return formatLocation(node.getEnd(), sourceFile); + } + + function formatLocation( + pos: number, + sourceFile: ts.SourceFile, + fileName?: string + ): string { + const { line, character } = ts.getLineAndCharacterOfPosition( + sourceFile, + pos + ); + if (!fileName) + fileName = + sourceFile == schemaSourceFile ? schemaFileName : sourceFile.fileName; + return `${fileName}(${line + 1},${character + 1})`; + } +} diff --git a/typescript/src/ts/validate.ts b/typescript/src/ts/validate.ts index 89aebffa..d6c4d681 100644 --- a/typescript/src/ts/validate.ts +++ b/typescript/src/ts/validate.ts @@ -1,6 +1,7 @@ import ts from 'typescript'; import { Result, success, error } from '../result'; -import { TypeChatJsonValidator } from "../typechat"; +import { JsonSchemaOptions, TypeChatJsonValidator } from "../typechat"; +import { tsToJsonSchema } from './ast'; const libText = `interface Array { length: number, [n: number]: T } interface Object { toString(): string } @@ -45,10 +46,18 @@ export function createTypeScriptJsonValidator(schema: getSchemaText: () => schema, getTypeName: () => typeName, createModuleTextFromJson, + getJsonSchema, validate }; + + return validator; + function getJsonSchema(options?: JsonSchemaOptions) { + const program = createProgramFromModuleText("", rootProgram); + return tsToJsonSchema(program, options); + } + function validate(jsonObject: object) { const moduleResult = validator.createModuleTextFromJson(jsonObject); if (!moduleResult.success) { diff --git a/typescript/src/typechat.ts b/typescript/src/typechat.ts index 74c67331..faf83519 100644 --- a/typescript/src/typechat.ts +++ b/typescript/src/typechat.ts @@ -63,6 +63,21 @@ export interface TypeChatJsonTranslator { translate(request: string, promptPreamble?: string | PromptSection[]): Promise>; } +/** + * Options for generating JSON schema from TypeScript source code. + */ +export interface JsonSchemaOptions { + /** + * Filename to use in error messages. + */ + fileName?: string; + + /** + * Whether to return Error on warnings. + */ + ignoreWarnings?: boolean; +} + /** * An object that represents a TypeScript schema for JSON objects. */ @@ -75,6 +90,10 @@ export interface TypeChatJsonValidator { * Return the name of the JSON object target type in the schema. */ getTypeName(): string; + /** + * Return the JSON schema as a JavaScript object if supported by the validator. + */ + getJsonSchema?(options?: JsonSchemaOptions): Result; /** * Validates the given JSON object according to the associated TypeScript schema. Returns a * `Success` object containing the JSON object if validation was successful. Otherwise, returns From 96734b883405cdc6c646ce83b3c46e6716be9394 Mon Sep 17 00:00:00 2001 From: Michal Moskal Date: Thu, 24 Apr 2025 11:43:26 -0700 Subject: [PATCH 2/2] fix typescript complaint --- typescript/src/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/typescript/src/tsconfig.json b/typescript/src/tsconfig.json index 9e97b242..91331076 100644 --- a/typescript/src/tsconfig.json +++ b/typescript/src/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "rootDir": ".", "target": "es2021", "lib": ["es2021"], "module": "node16",