|
| 1 | +/** |
| 2 | + * Schema Generation Script using ts-to-zod as a library |
| 3 | + * |
| 4 | + * This script generates Zod schemas from spec.types.ts and performs necessary |
| 5 | + * post-processing for compatibility with this project. |
| 6 | + * |
| 7 | + * ## Why Library-based Generation? |
| 8 | + * |
| 9 | + * Using ts-to-zod as a library (vs CLI) provides: |
| 10 | + * - Access to configuration options like getSchemaName, keepComments |
| 11 | + * - Ability to generate integration tests that verify type-schema alignment |
| 12 | + * - Programmatic post-processing with full control |
| 13 | + * |
| 14 | + * ## Post-Processing |
| 15 | + * |
| 16 | + * ts-to-zod has limitations that require post-processing: |
| 17 | + * |
| 18 | + * ### 1. Zod Import Path (`"zod"` → `"zod/v4"`) |
| 19 | + * ts-to-zod generates `import { z } from "zod"` but this project uses `"zod/v4"`. |
| 20 | + * |
| 21 | + * ### 2. Index Signatures (`z.record().and()` → `z.looseObject()`) |
| 22 | + * TypeScript index signatures like `[key: string]: unknown` are translated to |
| 23 | + * `z.record(z.string(), z.unknown()).and(z.object({...}))`, which creates |
| 24 | + * ZodIntersection types that don't support `.extend()`. We replace these with |
| 25 | + * `z.looseObject()`. |
| 26 | + * |
| 27 | + * ### 3. TypeOf Expressions (`z.any()` → literal values) |
| 28 | + * ts-to-zod can't translate `typeof CONST` expressions and falls back to `z.any()`. |
| 29 | + * We replace these with the actual literal values from the spec: |
| 30 | + * - `jsonrpc: z.any()` → `jsonrpc: z.literal("2.0")` |
| 31 | + * - `code: z.any()` for URL_ELICITATION_REQUIRED → `code: z.literal(-32042)` |
| 32 | + * |
| 33 | + * @see https://github.com/fabien0102/ts-to-zod |
| 34 | + */ |
| 35 | +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; |
| 36 | +import { dirname, join } from 'node:path'; |
| 37 | +import { fileURLToPath } from 'node:url'; |
| 38 | +import { generate } from 'ts-to-zod'; |
| 39 | + |
| 40 | +const __filename = fileURLToPath(import.meta.url); |
| 41 | +const __dirname = dirname(__filename); |
| 42 | +const PROJECT_ROOT = join(__dirname, '..'); |
| 43 | + |
| 44 | +const SPEC_TYPES_FILE = join(PROJECT_ROOT, 'src', 'spec.types.ts'); |
| 45 | +const GENERATED_DIR = join(PROJECT_ROOT, 'src', 'generated'); |
| 46 | +const SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, 'spec.schemas.ts'); |
| 47 | +const SCHEMA_TEST_OUTPUT_FILE = join(GENERATED_DIR, 'spec.schemas.zod.test.ts'); |
| 48 | + |
| 49 | +async function main() { |
| 50 | + console.log('🔧 Generating Zod schemas from spec.types.ts...\n'); |
| 51 | + |
| 52 | + // Ensure generated directory exists |
| 53 | + if (!existsSync(GENERATED_DIR)) { |
| 54 | + mkdirSync(GENERATED_DIR, { recursive: true }); |
| 55 | + } |
| 56 | + |
| 57 | + const sourceText = readFileSync(SPEC_TYPES_FILE, 'utf-8'); |
| 58 | + |
| 59 | + const result = generate({ |
| 60 | + sourceText, |
| 61 | + keepComments: true, |
| 62 | + skipParseJSDoc: false, |
| 63 | + // Use PascalCase naming to match existing types.ts convention |
| 64 | + // e.g., ProgressToken → ProgressTokenSchema |
| 65 | + getSchemaName: (typeName: string) => `${typeName}Schema`, |
| 66 | + }); |
| 67 | + |
| 68 | + if (result.errors.length > 0) { |
| 69 | + console.error('❌ Generation errors:'); |
| 70 | + for (const error of result.errors) { |
| 71 | + console.error(` - ${error}`); |
| 72 | + } |
| 73 | + process.exit(1); |
| 74 | + } |
| 75 | + |
| 76 | + if (result.hasCircularDependencies) { |
| 77 | + console.warn('⚠️ Warning: Circular dependencies detected in types'); |
| 78 | + } |
| 79 | + |
| 80 | + // Generate schema file with relative import to spec.types |
| 81 | + let schemasContent = result.getZodSchemasFile('../spec.types.js'); |
| 82 | + schemasContent = postProcess(schemasContent); |
| 83 | + |
| 84 | + writeFileSync(SCHEMA_OUTPUT_FILE, schemasContent, 'utf-8'); |
| 85 | + console.log(`✅ Written: ${SCHEMA_OUTPUT_FILE}`); |
| 86 | + |
| 87 | + // Generate integration tests that verify schemas match TypeScript types |
| 88 | + const testsContent = result.getIntegrationTestFile( |
| 89 | + '../spec.types.js', |
| 90 | + './spec.schemas.js', |
| 91 | + ); |
| 92 | + if (testsContent) { |
| 93 | + const processedTests = postProcessTests(testsContent); |
| 94 | + writeFileSync(SCHEMA_TEST_OUTPUT_FILE, processedTests, 'utf-8'); |
| 95 | + console.log(`✅ Written: ${SCHEMA_TEST_OUTPUT_FILE}`); |
| 96 | + } |
| 97 | + |
| 98 | + console.log('\n🎉 Schema generation complete!'); |
| 99 | +} |
| 100 | + |
| 101 | +/** |
| 102 | + * Post-process generated schemas for project compatibility. |
| 103 | + */ |
| 104 | +function postProcess(content: string): string { |
| 105 | + // 1. Update import to use zod/v4 |
| 106 | + content = content.replace( |
| 107 | + 'import { z } from "zod";', |
| 108 | + 'import { z } from "zod/v4";', |
| 109 | + ); |
| 110 | + |
| 111 | + // 2. Replace z.record().and(z.object({...})) with z.looseObject({...}) |
| 112 | + // Uses brace-counting to handle nested objects correctly. |
| 113 | + content = replaceRecordAndWithLooseObject(content); |
| 114 | + |
| 115 | + // 3. Fix typeof expressions that became z.any() |
| 116 | + // ts-to-zod can't translate `typeof CONST` and falls back to z.any() |
| 117 | + content = fixTypeOfExpressions(content); |
| 118 | + |
| 119 | + // 4. Remap notification/request schemas to SDK-compatible hierarchy |
| 120 | + // (extend Notification/Request instead of JSONRPCNotification/JSONRPCRequest) |
| 121 | + content = remapToSdkHierarchy(content); |
| 122 | + |
| 123 | + // 5. Add header comment |
| 124 | + content = content.replace( |
| 125 | + '// Generated by ts-to-zod', |
| 126 | + `// Generated by ts-to-zod |
| 127 | +// Post-processed for Zod v4 compatibility |
| 128 | +// Run: npm run generate:schemas`, |
| 129 | + ); |
| 130 | + |
| 131 | + return content; |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * Fix typeof expressions that ts-to-zod couldn't translate. |
| 136 | + * |
| 137 | + * In the spec, these patterns use `typeof CONST`: |
| 138 | + * - `jsonrpc: typeof JSONRPC_VERSION` where JSONRPC_VERSION = "2.0" |
| 139 | + * - `code: typeof URL_ELICITATION_REQUIRED` where URL_ELICITATION_REQUIRED = -32042 |
| 140 | + * |
| 141 | + * ts-to-zod generates `z.any()` for these, which we replace with proper literals. |
| 142 | + */ |
| 143 | +function fixTypeOfExpressions(content: string): string { |
| 144 | + // Fix jsonrpc: z.any() → jsonrpc: z.literal("2.0") |
| 145 | + // This appears in JSONRPCRequest, JSONRPCNotification, JSONRPCResponse schemas |
| 146 | + content = content.replace( |
| 147 | + /jsonrpc: z\.any\(\)/g, |
| 148 | + 'jsonrpc: z.literal("2.0")' |
| 149 | + ); |
| 150 | + |
| 151 | + // Note: URL_ELICITATION_REQUIRED code field is inside a more complex structure |
| 152 | + // and may need specific handling if tests fail |
| 153 | + |
| 154 | + return content; |
| 155 | +} |
| 156 | + |
| 157 | +/** |
| 158 | + * Remap notification and request schemas to use SDK-compatible hierarchy. |
| 159 | + * |
| 160 | + * The spec defines: |
| 161 | + * - XxxNotification extends JSONRPCNotification (includes jsonrpc field) |
| 162 | + * - XxxRequest extends JSONRPCRequest (includes jsonrpc, id fields) |
| 163 | + * |
| 164 | + * The SDK types.ts uses: |
| 165 | + * - XxxNotification extends Notification (no jsonrpc field) |
| 166 | + * - XxxRequest extends Request (no jsonrpc, id fields) |
| 167 | + * |
| 168 | + * This allows the jsonrpc/id fields to be handled at the transport layer. |
| 169 | + */ |
| 170 | +function remapToSdkHierarchy(content: string): string { |
| 171 | + // List of notifications that should extend NotificationSchema instead of JSONRPCNotificationSchema |
| 172 | + const notifications = [ |
| 173 | + 'CancelledNotification', |
| 174 | + 'InitializedNotification', |
| 175 | + 'ProgressNotification', |
| 176 | + 'ResourceListChangedNotification', |
| 177 | + 'ResourceUpdatedNotification', |
| 178 | + 'PromptListChangedNotification', |
| 179 | + 'ToolListChangedNotification', |
| 180 | + 'TaskStatusNotification', |
| 181 | + 'LoggingMessageNotification', |
| 182 | + 'RootsListChangedNotification', |
| 183 | + 'ElicitationCompleteNotification', |
| 184 | + ]; |
| 185 | + |
| 186 | + // List of requests that should extend RequestSchema instead of JSONRPCRequestSchema |
| 187 | + const requests = [ |
| 188 | + 'InitializeRequest', |
| 189 | + 'PingRequest', |
| 190 | + 'ListResourcesRequest', |
| 191 | + 'ListResourceTemplatesRequest', |
| 192 | + 'ReadResourceRequest', |
| 193 | + 'SubscribeRequest', |
| 194 | + 'UnsubscribeRequest', |
| 195 | + 'ListPromptsRequest', |
| 196 | + 'GetPromptRequest', |
| 197 | + 'ListToolsRequest', |
| 198 | + 'CallToolRequest', |
| 199 | + 'GetTaskRequest', |
| 200 | + 'ListTasksRequest', |
| 201 | + 'GetTaskPayloadRequest', |
| 202 | + 'CancelTaskRequest', |
| 203 | + 'SetLevelRequest', |
| 204 | + 'CreateMessageRequest', |
| 205 | + 'CompleteRequest', |
| 206 | + 'ListRootsRequest', |
| 207 | + 'ElicitRequest', |
| 208 | + ]; |
| 209 | + |
| 210 | + // Replace JSONRPCNotificationSchema.extend with NotificationSchema.extend for specific schemas |
| 211 | + for (const name of notifications) { |
| 212 | + content = content.replace( |
| 213 | + new RegExp(`export const ${name}Schema = JSONRPCNotificationSchema\\.extend\\(`), |
| 214 | + `export const ${name}Schema = NotificationSchema.extend(` |
| 215 | + ); |
| 216 | + } |
| 217 | + |
| 218 | + // Replace JSONRPCRequestSchema.extend with RequestSchema.extend for specific schemas |
| 219 | + for (const name of requests) { |
| 220 | + content = content.replace( |
| 221 | + new RegExp(`export const ${name}Schema = JSONRPCRequestSchema\\.extend\\(`), |
| 222 | + `export const ${name}Schema = RequestSchema.extend(` |
| 223 | + ); |
| 224 | + } |
| 225 | + |
| 226 | + return content; |
| 227 | +} |
| 228 | + |
| 229 | +/** |
| 230 | + * Replace z.record(z.string(), z.unknown()).and(z.object({...})) with z.looseObject({...}) |
| 231 | + * Uses brace-counting to handle nested objects correctly. |
| 232 | + */ |
| 233 | +function replaceRecordAndWithLooseObject(content: string): string { |
| 234 | + const pattern = 'z.record(z.string(), z.unknown()).and(z.object({'; |
| 235 | + let result = content; |
| 236 | + let startIndex = 0; |
| 237 | + |
| 238 | + while (true) { |
| 239 | + const matchStart = result.indexOf(pattern, startIndex); |
| 240 | + if (matchStart === -1) break; |
| 241 | + |
| 242 | + // Find the matching closing brace for z.object({ |
| 243 | + const objectStart = matchStart + pattern.length; |
| 244 | + let braceCount = 1; |
| 245 | + let i = objectStart; |
| 246 | + |
| 247 | + while (i < result.length && braceCount > 0) { |
| 248 | + if (result[i] === '{') braceCount++; |
| 249 | + else if (result[i] === '}') braceCount--; |
| 250 | + i++; |
| 251 | + } |
| 252 | + |
| 253 | + // i now points after the closing } of z.object({...}) |
| 254 | + // Check if followed by )) |
| 255 | + if (result.slice(i, i + 2) === '))') { |
| 256 | + const objectContent = result.slice(objectStart, i - 1); |
| 257 | + const replacement = `z.looseObject({${objectContent}})`; |
| 258 | + result = result.slice(0, matchStart) + replacement + result.slice(i + 2); |
| 259 | + startIndex = matchStart + replacement.length; |
| 260 | + } else { |
| 261 | + startIndex = i; |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + return result; |
| 266 | +} |
| 267 | + |
| 268 | +/** |
| 269 | + * Post-process generated integration tests. |
| 270 | + */ |
| 271 | +function postProcessTests(content: string): string { |
| 272 | + content = content.replace( |
| 273 | + 'import { z } from "zod";', |
| 274 | + 'import { z } from "zod/v4";', |
| 275 | + ); |
| 276 | + |
| 277 | + content = content.replace( |
| 278 | + '// Generated by ts-to-zod', |
| 279 | + `// Generated by ts-to-zod |
| 280 | +// Integration tests verifying schemas match TypeScript types |
| 281 | +// Run: npm run generate:schemas`, |
| 282 | + ); |
| 283 | + |
| 284 | + return content; |
| 285 | +} |
| 286 | + |
| 287 | +main().catch((error) => { |
| 288 | + console.error('❌ Schema generation failed:', error); |
| 289 | + process.exit(1); |
| 290 | +}); |
0 commit comments