import ts from "typescript"; import { StaticCacheFS } from "@commontools/static"; import { isObject } from "@commontools/utils/types"; import type { JSONSchemaObj } from "@commontools/api"; // Cache for TypeScript library definitions let typeLibsCache: Record | undefined; const CELL_BRAND_PRELUDE = ` declare const CELL_BRAND: unique symbol; declare interface BrandedCell { readonly [CELL_BRAND]: Brand; } declare interface OpaqueCell extends BrandedCell {} declare interface OpaqueRef extends OpaqueCell {} declare interface Cell extends BrandedCell {} declare interface Stream extends BrandedCell {} declare interface ComparableCell extends BrandedCell {} declare interface ReadonlyCell extends BrandedCell {} declare interface WriteonlyCell extends BrandedCell {} `; /** * Load TypeScript environment types (es2023, dom, jsx) * Same functionality as js-compiler but implemented independently */ async function getTypeScriptEnvironmentTypes(): Promise< Record > { if (typeLibsCache) { return typeLibsCache; } const cache = new StaticCacheFS(); const es2023 = await cache.getText("types/es2023.d.ts"); const jsx = await cache.getText("types/jsx.d.ts"); const dom = await cache.getText("types/dom.d.ts"); typeLibsCache = { es2023, dom, jsx, }; return typeLibsCache; } export async function createTestProgram( code: string, ): Promise< { program: ts.Program; checker: ts.TypeChecker; sourceFile: ts.SourceFile } > { const fullCode = `${CELL_BRAND_PRELUDE}\n${code}`; const fileName = "test.ts"; const sourceFile = ts.createSourceFile( fileName, fullCode, ts.ScriptTarget.ES2023, true, ); // Load TypeScript library definitions const typeLibs = await getTypeScriptEnvironmentTypes(); const compilerHost: ts.CompilerHost = { getSourceFile: (name) => { if (name === fileName) { return sourceFile; } // Map lib.d.ts requests to es2023 definitions (same as js-compiler) if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) { return ts.createSourceFile( name, typeLibs.es2023 || "", ts.ScriptTarget.ES2023, true, ); } // Handle other library files (map case-insensitive) const libName = name.toLowerCase().replace(".d.ts", ""); if (typeLibs[libName]) { return ts.createSourceFile( name, typeLibs[libName], ts.ScriptTarget.ES2023, true, ); } return undefined; }, writeFile: () => {}, getCurrentDirectory: () => "", getDirectories: () => [], fileExists: (name) => { if (name === fileName) return true; if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) return true; // Check library files (case-insensitive) const libName = name.toLowerCase().replace(".d.ts", ""); if (typeLibs[libName]) return true; return false; }, readFile: (name) => { if (name === fileName) return fullCode; if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) { return typeLibs.es2023; } // Handle library files (case-insensitive) const libName = name.toLowerCase().replace(".d.ts", ""); if (typeLibs[libName]) return typeLibs[libName]; return undefined; }, getCanonicalFileName: (f) => f, useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", getDefaultLibFileName: () => "lib.d.ts", }; const program = ts.createProgram([fileName], { target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.ESNext, // Add proper lib configuration (key difference from broken version) lib: ["ES2023", "DOM", "JSX"], strict: true, strictNullChecks: true, }, compilerHost); return { program, checker: program.getTypeChecker(), sourceFile: sourceFile!, }; } export async function getTypeFromCode( code: string, typeName: string, ): Promise<{ type: ts.Type; checker: ts.TypeChecker; typeNode?: ts.TypeNode }> { const { checker, sourceFile } = await createTestProgram(code); let foundType: ts.Type | undefined; let foundTypeNode: ts.TypeNode | undefined; ts.forEachChild(sourceFile, (node) => { if (ts.isInterfaceDeclaration(node) && node.name.text === typeName) { const symbol = checker.getSymbolAtLocation(node.name); if (symbol) foundType = checker.getDeclaredTypeOfSymbol(symbol); } else if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) { foundType = checker.getTypeFromTypeNode(node.type); foundTypeNode = node.type; } }); if (!foundType) throw new Error(`Type ${typeName} not found in code`); return foundTypeNode ? { type: foundType, checker, typeNode: foundTypeNode } : { type: foundType, checker }; } /** * Check TypeScript diagnostics for a code snippet. * Returns an array of diagnostic messages, empty if no errors. */ export async function checkTypeErrors(code: string): Promise { const { program, sourceFile } = await createTestProgram(code); const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile); return [...diagnostics]; } /** * Batch type-check multiple fixture files in a single TypeScript program. * Returns a map from file path to diagnostics. */ export async function batchTypeCheckFixtures( fixtures: Record, ): Promise> { const typeLibs = await getTypeScriptEnvironmentTypes(); // Prepend Cell prelude to all fixture files const fixturesWithPrelude: Record = {}; for (const [path, code] of Object.entries(fixtures)) { fixturesWithPrelude[path] = `${CELL_BRAND_PRELUDE}\n${code}`; } const compilerOptions: ts.CompilerOptions = { target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.ESNext, lib: ["ES2023", "DOM", "JSX"], strict: true, strictNullChecks: true, }; // Cache for type definition source files const typeDefCache = new Map(); const compilerHost: ts.CompilerHost = { getSourceFile: (name) => { // Check if it's a fixture file if (fixturesWithPrelude[name] !== undefined) { return ts.createSourceFile( name, fixturesWithPrelude[name], compilerOptions.target!, true, ); } // Check cache for type definition files if (typeDefCache.has(name)) { return typeDefCache.get(name); } let sourceText: string | undefined; // Map lib.d.ts requests to es2023 definitions if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) { sourceText = typeLibs.es2023 || ""; } else { // Handle other library files (map case-insensitive) const libName = name.toLowerCase().replace(".d.ts", ""); if (typeLibs[libName]) { sourceText = typeLibs[libName]; } } if (sourceText === undefined) { return undefined; } const sourceFile = ts.createSourceFile( name, sourceText, compilerOptions.target!, true, ); typeDefCache.set(name, sourceFile); return sourceFile; }, writeFile: () => {}, getCurrentDirectory: () => "/", getDirectories: () => [], fileExists: (name) => { if (fixturesWithPrelude[name] !== undefined) return true; if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) return true; const libName = name.toLowerCase().replace(".d.ts", ""); return !!typeLibs[libName]; }, readFile: (name) => { if (fixturesWithPrelude[name] !== undefined) { return fixturesWithPrelude[name]; } if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) { return typeLibs.es2023; } const libName = name.toLowerCase().replace(".d.ts", ""); return typeLibs[libName]; }, getCanonicalFileName: (f) => f, useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", getDefaultLibFileName: () => "lib.d.ts", }; const fileNames = Object.keys(fixturesWithPrelude); const program = ts.createProgram(fileNames, compilerOptions, compilerHost); // Get all diagnostics const allDiagnostics = ts.getPreEmitDiagnostics(program); // Group diagnostics by file const diagnosticsByFile = new Map(); // Initialize empty arrays for all fixture files for (const filePath of fileNames) { diagnosticsByFile.set(filePath, []); } // Assign diagnostics to their respective files for (const diagnostic of allDiagnostics) { if ( diagnostic.file && fixturesWithPrelude[diagnostic.file.fileName] !== undefined ) { const existing = diagnosticsByFile.get(diagnostic.file.fileName) || []; existing.push(diagnostic); diagnosticsByFile.set(diagnostic.file.fileName, existing); } } return diagnosticsByFile; } export function normalizeSchema | boolean>( schema: T, ): T { // Boolean schemas are valid and should pass through unchanged if (typeof schema === "boolean") { return schema; } const clone: any = JSON.parse(JSON.stringify(schema)); // Strip top-level $schema noise delete clone.$schema; // Canonicalize recursively return deepCanonicalize(clone) as T; } /** * Type assertion helper for tests that know they're working with object schemas. * Use this when the test context guarantees the schema is an object, not a boolean. */ export function asObjectSchema(schema: T): T & JSONSchemaObj { if (typeof schema === "boolean") { throw new Error( `Expected object schema but got boolean: ${schema}`, ); } return schema as T & JSONSchemaObj; } function sortObjectKeys(obj: Record): Record { const sorted: Record = {}; for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]; return sorted; } function normalizeAnyOf(node: any): any { if (!Array.isArray(node.anyOf)) return node; // If anyOf contains exactly one null and one non-null, put null first. if (node.anyOf.length === 2) { const a = node.anyOf[0]; const b = node.anyOf[1]; const isNull = (x: any) => isObject(x) && (x as any).type === "null"; if (isNull(b) && !isNull(a)) { node.anyOf = [b, a]; } } return node; } function deepCanonicalize(node: unknown): unknown { if (Array.isArray(node)) { // Sort specific arrays we know should be order-insensitive return (node as unknown[]).map(deepCanonicalize); } if (!isObject(node)) return node; // Clone and canonicalize children first const out: Record = {}; for (const [k, v] of Object.entries(node)) { out[k] = deepCanonicalize(v); } // Sort known arrays if (Array.isArray(out.required)) { out.required = [...(out.required as unknown[])].sort(); } if (Array.isArray(out.enum)) { out.enum = [...(out.enum as unknown[])].slice().sort(); } // Sort definitions keys deterministically if (isObject(out.definitions)) { out.definitions = sortObjectKeys( out.definitions as Record, ); } // Apply anyOf normalization for nullable patterns normalizeAnyOf(out); // Finally sort all object keys to ensure stable ordering in comparisons return sortObjectKeys(out); }