trickle-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/db/connection.d.ts +3 -0
  2. package/dist/db/connection.js +16 -0
  3. package/dist/db/migrations.d.ts +2 -0
  4. package/dist/db/migrations.js +51 -0
  5. package/dist/db/queries.d.ts +70 -0
  6. package/dist/db/queries.js +186 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +10 -0
  9. package/dist/routes/audit.d.ts +2 -0
  10. package/dist/routes/audit.js +251 -0
  11. package/dist/routes/codegen.d.ts +2 -0
  12. package/dist/routes/codegen.js +224 -0
  13. package/dist/routes/coverage.d.ts +2 -0
  14. package/dist/routes/coverage.js +98 -0
  15. package/dist/routes/dashboard.d.ts +2 -0
  16. package/dist/routes/dashboard.js +433 -0
  17. package/dist/routes/diff.d.ts +2 -0
  18. package/dist/routes/diff.js +181 -0
  19. package/dist/routes/errors.d.ts +2 -0
  20. package/dist/routes/errors.js +86 -0
  21. package/dist/routes/functions.d.ts +2 -0
  22. package/dist/routes/functions.js +69 -0
  23. package/dist/routes/ingest.d.ts +2 -0
  24. package/dist/routes/ingest.js +111 -0
  25. package/dist/routes/mock.d.ts +2 -0
  26. package/dist/routes/mock.js +57 -0
  27. package/dist/routes/search.d.ts +2 -0
  28. package/dist/routes/search.js +136 -0
  29. package/dist/routes/tail.d.ts +2 -0
  30. package/dist/routes/tail.js +11 -0
  31. package/dist/routes/types.d.ts +2 -0
  32. package/dist/routes/types.js +97 -0
  33. package/dist/server.d.ts +2 -0
  34. package/dist/server.js +40 -0
  35. package/dist/services/sse-broker.d.ts +10 -0
  36. package/dist/services/sse-broker.js +39 -0
  37. package/dist/services/type-differ.d.ts +2 -0
  38. package/dist/services/type-differ.js +126 -0
  39. package/dist/services/type-generator.d.ts +319 -0
  40. package/dist/services/type-generator.js +3207 -0
  41. package/dist/types.d.ts +56 -0
  42. package/dist/types.js +2 -0
  43. package/package.json +22 -0
  44. package/src/db/connection.ts +16 -0
  45. package/src/db/migrations.ts +50 -0
  46. package/src/db/queries.ts +260 -0
  47. package/src/index.ts +11 -0
  48. package/src/routes/audit.ts +283 -0
  49. package/src/routes/codegen.ts +237 -0
  50. package/src/routes/coverage.ts +120 -0
  51. package/src/routes/dashboard.ts +435 -0
  52. package/src/routes/diff.ts +215 -0
  53. package/src/routes/errors.ts +91 -0
  54. package/src/routes/functions.ts +75 -0
  55. package/src/routes/ingest.ts +139 -0
  56. package/src/routes/mock.ts +66 -0
  57. package/src/routes/search.ts +169 -0
  58. package/src/routes/tail.ts +12 -0
  59. package/src/routes/types.ts +106 -0
  60. package/src/server.ts +40 -0
  61. package/src/services/sse-broker.ts +51 -0
  62. package/src/services/type-differ.ts +141 -0
  63. package/src/services/type-generator.ts +3853 -0
  64. package/src/types.ts +37 -0
  65. package/tsconfig.json +8 -0
@@ -0,0 +1,3853 @@
1
+ import { TypeNode } from "../types";
2
+
3
+ // ── Naming helpers ──
4
+
5
+ function toPascalCase(name: string): string {
6
+ // Sanitize route-style names like "GET /api/users/:id" → "GetApiUsersId"
7
+ // Also handles camelCase/PascalCase input by splitting on boundaries
8
+ return name
9
+ .replace(/[^a-zA-Z0-9]+/g, " ") // replace non-alphanumeric with spaces
10
+ .replace(/([a-z])([A-Z])/g, "$1 $2") // split camelCase: "userId" → "user Id"
11
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // split acronyms: "XMLParser" → "XML Parser"
12
+ .trim()
13
+ .split(/\s+/)
14
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
15
+ .join("");
16
+ }
17
+
18
+ function toSnakeCase(name: string): string {
19
+ return name
20
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
21
+ .replace(/[-\s]+/g, "_")
22
+ .toLowerCase();
23
+ }
24
+
25
+ function formatTimeAgo(isoDate: string): string {
26
+ try {
27
+ const date = new Date(isoDate);
28
+ const now = new Date();
29
+ const diffMs = now.getTime() - date.getTime();
30
+ const diffSec = Math.floor(diffMs / 1000);
31
+ if (diffSec < 60) return `${diffSec}s ago`;
32
+ const diffMin = Math.floor(diffSec / 60);
33
+ if (diffMin < 60) return `${diffMin}m ago`;
34
+ const diffHr = Math.floor(diffMin / 60);
35
+ if (diffHr < 24) return `${diffHr}h ago`;
36
+ const diffDay = Math.floor(diffHr / 24);
37
+ return `${diffDay}d ago`;
38
+ } catch {
39
+ return isoDate;
40
+ }
41
+ }
42
+
43
+ // ── TypeScript generation ──
44
+
45
+ /**
46
+ * Accumulates extracted named interfaces so complex nested objects
47
+ * get their own `export interface Foo { ... }` block.
48
+ */
49
+ interface ExtractedInterface {
50
+ name: string;
51
+ node: Extract<TypeNode, { kind: "object" }>;
52
+ }
53
+
54
+ /**
55
+ * Convert a TypeNode to a TypeScript type string.
56
+ *
57
+ * Large nested objects (>2 properties) are extracted into named interfaces
58
+ * so the output stays readable.
59
+ */
60
+ function typeNodeToTS(
61
+ node: TypeNode,
62
+ extracted: ExtractedInterface[],
63
+ parentName: string,
64
+ propName: string | undefined,
65
+ indent: number,
66
+ ): string {
67
+ switch (node.kind) {
68
+ case "primitive":
69
+ return node.name;
70
+
71
+ case "unknown":
72
+ return "unknown";
73
+
74
+ case "array": {
75
+ const inner = typeNodeToTS(node.element, extracted, parentName, propName, indent);
76
+ if (node.element.kind === "union" || node.element.kind === "function") {
77
+ return `Array<${inner}>`;
78
+ }
79
+ return `${inner}[]`;
80
+ }
81
+
82
+ case "tuple": {
83
+ const elements = node.elements.map((el, i) =>
84
+ typeNodeToTS(el, extracted, parentName, `${propName || "el"}${i}`, indent)
85
+ );
86
+ return `[${elements.join(", ")}]`;
87
+ }
88
+
89
+ case "union": {
90
+ const members = node.members.map((m) =>
91
+ typeNodeToTS(m, extracted, parentName, propName, indent)
92
+ );
93
+ return members.join(" | ");
94
+ }
95
+
96
+ case "map": {
97
+ const k = typeNodeToTS(node.key, extracted, parentName, "key", indent);
98
+ const v = typeNodeToTS(node.value, extracted, parentName, "value", indent);
99
+ return `Map<${k}, ${v}>`;
100
+ }
101
+
102
+ case "set": {
103
+ const inner = typeNodeToTS(node.element, extracted, parentName, propName, indent);
104
+ return `Set<${inner}>`;
105
+ }
106
+
107
+ case "promise": {
108
+ const inner = typeNodeToTS(node.resolved, extracted, parentName, propName, indent);
109
+ return `Promise<${inner}>`;
110
+ }
111
+
112
+ case "function": {
113
+ const params = node.params.map((p, i) =>
114
+ `arg${i}: ${typeNodeToTS(p, extracted, parentName, `param${i}`, indent)}`
115
+ );
116
+ const ret = typeNodeToTS(node.returnType, extracted, parentName, "return", indent);
117
+ return `(${params.join(", ")}) => ${ret}`;
118
+ }
119
+
120
+ case "object": {
121
+ const keys = Object.keys(node.properties);
122
+ if (keys.length === 0) return "Record<string, never>";
123
+
124
+ // Extract large nested objects into named interfaces for readability
125
+ if (keys.length > 2 && propName) {
126
+ const ifaceName = toPascalCase(parentName) + toPascalCase(propName);
127
+ // Avoid duplicate extractions
128
+ if (!extracted.some((e) => e.name === ifaceName)) {
129
+ extracted.push({ name: ifaceName, node });
130
+ }
131
+ return ifaceName;
132
+ }
133
+
134
+ // Inline small objects
135
+ const pad = " ".repeat(indent + 1);
136
+ const closePad = " ".repeat(indent);
137
+ const entries = keys.map((key) => {
138
+ const val = typeNodeToTS(node.properties[key], extracted, parentName, key, indent + 1);
139
+ return `${pad}${key}: ${val};`;
140
+ });
141
+ return `{\n${entries.join("\n")}\n${closePad}}`;
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Render a named interface from an extracted object TypeNode.
148
+ * Recursively extracts any further nested objects.
149
+ */
150
+ function renderInterface(
151
+ name: string,
152
+ node: Extract<TypeNode, { kind: "object" }>,
153
+ allExtracted: ExtractedInterface[],
154
+ ): string {
155
+ const keys = Object.keys(node.properties);
156
+ const lines: string[] = [];
157
+
158
+ lines.push(`export interface ${name} {`);
159
+ for (const key of keys) {
160
+ const val = typeNodeToTS(node.properties[key], allExtracted, name, key, 1);
161
+ lines.push(` ${key}: ${val};`);
162
+ }
163
+ lines.push(`}`);
164
+ return lines.join("\n");
165
+ }
166
+
167
+ /**
168
+ * Generate TypeScript definitions for a single function.
169
+ *
170
+ * For a function `processOrder(order)` that takes an object and returns an object:
171
+ * ```ts
172
+ * export interface ProcessOrderInput { id: string; customer: Customer; ... }
173
+ * export interface ProcessOrderOutput { orderId: string; total: number; ... }
174
+ * export declare function processOrder(order: ProcessOrderInput): ProcessOrderOutput;
175
+ * ```
176
+ */
177
+ export function generateFunctionTypes(
178
+ functionName: string,
179
+ argsType: TypeNode,
180
+ returnType: TypeNode,
181
+ meta?: { module?: string; env?: string; observedAt?: string },
182
+ ): string {
183
+ const baseName = toPascalCase(functionName);
184
+ const extracted: ExtractedInterface[] = [];
185
+ const lines: string[] = [];
186
+
187
+ // Metadata comment
188
+ const metaParts: string[] = [];
189
+ if (meta?.module) metaParts.push(`${meta.module} module`);
190
+ if (meta?.env) metaParts.push(`observed in ${meta.env}`);
191
+ if (meta?.observedAt) metaParts.push(formatTimeAgo(meta.observedAt));
192
+ const metaStr = metaParts.length > 0 ? ` — ${metaParts.join(", ")}` : "";
193
+
194
+ // ── Determine argument structure ──
195
+ // argsType is typically a tuple of the function's positional args.
196
+ // For a single-object arg, we use that object directly as the "Input" interface.
197
+ // For multiple args, we generate separate types and a function declaration with individual params.
198
+
199
+ let argEntries: Array<{ paramName: string; typeNode: TypeNode }> = [];
200
+
201
+ if (argsType.kind === "tuple") {
202
+ argEntries = argsType.elements.map((el, i) => ({
203
+ paramName: `arg${i}`,
204
+ typeNode: el,
205
+ }));
206
+ } else if (argsType.kind === "object") {
207
+ // Named params from Python kwargs
208
+ for (const key of Object.keys(argsType.properties)) {
209
+ argEntries.push({ paramName: key, typeNode: argsType.properties[key] });
210
+ }
211
+ } else {
212
+ argEntries = [{ paramName: "input", typeNode: argsType }];
213
+ }
214
+
215
+ // ── Single-object shortcut ──
216
+ // If there's exactly one arg and it's an object, promote it to a named interface.
217
+ const singleObjectArg =
218
+ argEntries.length === 1 && argEntries[0].typeNode.kind === "object";
219
+
220
+ // ── Generate input type(s) ──
221
+
222
+ if (singleObjectArg) {
223
+ const inputName = `${baseName}Input`;
224
+ const objNode = argEntries[0].typeNode as Extract<TypeNode, { kind: "object" }>;
225
+
226
+ // Extract nested objects from within the input
227
+ const inputBody = renderInterface(inputName, objNode, extracted);
228
+
229
+ lines.push(`/**`);
230
+ lines.push(` * Input type for \`${functionName}\`${metaStr}`);
231
+ lines.push(` */`);
232
+ lines.push(inputBody);
233
+ lines.push("");
234
+ } else if (argEntries.length > 1) {
235
+ // Multiple args — generate a type for each non-primitive arg
236
+ for (let i = 0; i < argEntries.length; i++) {
237
+ const entry = argEntries[i];
238
+ if (entry.typeNode.kind === "object" && Object.keys(entry.typeNode.properties).length > 0) {
239
+ const typeName = `${baseName}${toPascalCase(entry.paramName)}`;
240
+ const body = renderInterface(typeName, entry.typeNode as Extract<TypeNode, { kind: "object" }>, extracted);
241
+ lines.push(body);
242
+ lines.push("");
243
+ }
244
+ }
245
+ }
246
+
247
+ // ── Generate output type ──
248
+
249
+ const outputName = `${baseName}Output`;
250
+
251
+ if (returnType.kind === "object" && Object.keys(returnType.properties).length > 0) {
252
+ const outputBody = renderInterface(outputName, returnType as Extract<TypeNode, { kind: "object" }>, extracted);
253
+ lines.push(`/**`);
254
+ lines.push(` * Output type for \`${functionName}\`${metaStr}`);
255
+ lines.push(` */`);
256
+ lines.push(outputBody);
257
+ lines.push("");
258
+ } else {
259
+ const retStr = typeNodeToTS(returnType, extracted, baseName, undefined, 0);
260
+ lines.push(`/**`);
261
+ lines.push(` * Output type for \`${functionName}\`${metaStr}`);
262
+ lines.push(` */`);
263
+ lines.push(`export type ${outputName} = ${retStr};`);
264
+ lines.push("");
265
+ }
266
+
267
+ // ── Emit extracted interfaces (dependencies) ──
268
+ // Process in order: render each, which may add more extractions.
269
+ const emitted = new Set<string>();
270
+ const extractedLines: string[] = [];
271
+ let cursor = 0;
272
+ while (cursor < extracted.length) {
273
+ const iface = extracted[cursor];
274
+ cursor++;
275
+ if (emitted.has(iface.name)) continue;
276
+ emitted.add(iface.name);
277
+ extractedLines.push(renderInterface(iface.name, iface.node, extracted));
278
+ extractedLines.push("");
279
+ }
280
+
281
+ // ── Build function declaration ──
282
+
283
+ // Use camelCase version of baseName for function declaration identifier
284
+ const funcIdent = baseName.charAt(0).toLowerCase() + baseName.slice(1);
285
+
286
+ let funcDecl: string;
287
+ if (singleObjectArg) {
288
+ const inputName = `${baseName}Input`;
289
+ funcDecl = `export declare function ${funcIdent}(input: ${inputName}): ${outputName};`;
290
+ } else {
291
+ // Build param list with proper types
292
+ const params = argEntries.map((entry) => {
293
+ if (entry.typeNode.kind === "object" && Object.keys(entry.typeNode.properties).length > 0) {
294
+ return `${entry.paramName}: ${baseName}${toPascalCase(entry.paramName)}`;
295
+ }
296
+ return `${entry.paramName}: ${typeNodeToTS(entry.typeNode, extracted, baseName, entry.paramName, 0)}`;
297
+ });
298
+ funcDecl = `export declare function ${funcIdent}(${params.join(", ")}): ${outputName};`;
299
+ }
300
+
301
+ // ── Assemble output ──
302
+ // Order: extracted interfaces → input → output → function declaration
303
+
304
+ const result: string[] = [];
305
+ if (extractedLines.length > 0) {
306
+ result.push(...extractedLines);
307
+ }
308
+ result.push(...lines);
309
+ result.push(funcDecl);
310
+
311
+ return result.join("\n");
312
+ }
313
+
314
+ /**
315
+ * Generate a complete TypeScript declarations file for all functions.
316
+ */
317
+ export function generateAllTypes(
318
+ functions: Array<{
319
+ name: string;
320
+ argsType: TypeNode;
321
+ returnType: TypeNode;
322
+ module?: string;
323
+ env?: string;
324
+ observedAt?: string;
325
+ }>,
326
+ ): string {
327
+ const sections: string[] = [];
328
+
329
+ sections.push("// Auto-generated by trickle from runtime type observations");
330
+ sections.push(`// Generated at ${new Date().toISOString()}`);
331
+ sections.push("// Do not edit manually — re-run `trickle codegen` to update");
332
+ sections.push("");
333
+
334
+ for (const fn of functions) {
335
+ sections.push(
336
+ generateFunctionTypes(fn.name, fn.argsType, fn.returnType, {
337
+ module: fn.module,
338
+ env: fn.env,
339
+ observedAt: fn.observedAt,
340
+ }),
341
+ );
342
+ sections.push("");
343
+ }
344
+
345
+ return sections.join("\n").trimEnd() + "\n";
346
+ }
347
+
348
+ // ── Python stub generation ──
349
+
350
+ function typeNodeToPython(
351
+ node: TypeNode,
352
+ extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }>,
353
+ parentName: string,
354
+ propName: string | undefined,
355
+ depth: number,
356
+ ): string {
357
+ switch (node.kind) {
358
+ case "primitive":
359
+ switch (node.name) {
360
+ case "string": return "str";
361
+ case "number": return "float";
362
+ case "boolean": return "bool";
363
+ case "null": return "None";
364
+ case "undefined": return "None";
365
+ case "bigint": return "int";
366
+ case "symbol": return "str";
367
+ default: return "Any";
368
+ }
369
+
370
+ case "unknown":
371
+ return "Any";
372
+
373
+ case "array": {
374
+ const inner = typeNodeToPython(node.element, extracted, parentName, propName, depth + 1);
375
+ return `List[${inner}]`;
376
+ }
377
+
378
+ case "tuple": {
379
+ const elements = node.elements.map((el, i) =>
380
+ typeNodeToPython(el, extracted, parentName, `el${i}`, depth + 1)
381
+ );
382
+ return `Tuple[${elements.join(", ")}]`;
383
+ }
384
+
385
+ case "union": {
386
+ const members = node.members.map((m) =>
387
+ typeNodeToPython(m, extracted, parentName, propName, depth + 1)
388
+ );
389
+ if (members.length === 2 && members.includes("None")) {
390
+ const nonNone = members.find((m) => m !== "None");
391
+ return `Optional[${nonNone}]`;
392
+ }
393
+ return `Union[${members.join(", ")}]`;
394
+ }
395
+
396
+ case "map": {
397
+ const k = typeNodeToPython(node.key, extracted, parentName, "key", depth + 1);
398
+ const v = typeNodeToPython(node.value, extracted, parentName, "value", depth + 1);
399
+ return `Dict[${k}, ${v}]`;
400
+ }
401
+
402
+ case "set": {
403
+ const inner = typeNodeToPython(node.element, extracted, parentName, propName, depth + 1);
404
+ return `Set[${inner}]`;
405
+ }
406
+
407
+ case "promise": {
408
+ const inner = typeNodeToPython(node.resolved, extracted, parentName, propName, depth + 1);
409
+ return `Awaitable[${inner}]`;
410
+ }
411
+
412
+ case "function": {
413
+ const params = node.params.map((p) =>
414
+ typeNodeToPython(p, extracted, parentName, undefined, depth + 1)
415
+ );
416
+ const ret = typeNodeToPython(node.returnType, extracted, parentName, "return", depth + 1);
417
+ return `Callable[[${params.join(", ")}], ${ret}]`;
418
+ }
419
+
420
+ case "object": {
421
+ const keys = Object.keys(node.properties);
422
+ if (keys.length === 0) return "Dict[str, Any]";
423
+
424
+ // Always extract objects as named TypedDicts
425
+ if (propName) {
426
+ const className = toPascalCase(parentName) + toPascalCase(propName);
427
+ if (!extracted.some((e) => e.name === className)) {
428
+ extracted.push({ name: className, node });
429
+ }
430
+ return className;
431
+ }
432
+ return "Dict[str, Any]";
433
+ }
434
+ }
435
+ }
436
+
437
+ function renderPythonTypedDict(
438
+ name: string,
439
+ node: Extract<TypeNode, { kind: "object" }>,
440
+ extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }>,
441
+ ): string {
442
+ const keys = Object.keys(node.properties);
443
+ const innerExtracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
444
+ const lines: string[] = [];
445
+
446
+ const entries = keys.map((key) => {
447
+ const pyType = typeNodeToPython(node.properties[key], innerExtracted, name, key, 1);
448
+ return ` ${toSnakeCase(key)}: ${pyType}`;
449
+ });
450
+
451
+ // Emit nested TypedDicts first
452
+ for (const iface of innerExtracted) {
453
+ lines.push(renderPythonTypedDict(iface.name, iface.node, innerExtracted));
454
+ lines.push("");
455
+ lines.push("");
456
+ }
457
+
458
+ lines.push(`class ${name}(TypedDict):`);
459
+ if (entries.length === 0) {
460
+ lines.push(" pass");
461
+ } else {
462
+ lines.push(...entries);
463
+ }
464
+ return lines.join("\n");
465
+ }
466
+
467
+ export function generatePythonTypes(
468
+ functions: Array<{
469
+ name: string;
470
+ argsType: TypeNode;
471
+ returnType: TypeNode;
472
+ module?: string;
473
+ env?: string;
474
+ observedAt?: string;
475
+ }>,
476
+ ): string {
477
+ const sections: string[] = [];
478
+
479
+ sections.push("# Auto-generated by trickle from runtime type observations");
480
+ sections.push(`# Generated at ${new Date().toISOString()}`);
481
+ sections.push("# Do not edit manually — re-run `trickle codegen --python` to update");
482
+ sections.push("");
483
+ sections.push("from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, TypedDict, Union");
484
+ sections.push("");
485
+ sections.push("");
486
+
487
+ for (const fn of functions) {
488
+ const baseName = toPascalCase(fn.name);
489
+ const extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
490
+
491
+ // Meta comment
492
+ const metaParts: string[] = [];
493
+ if (fn.module) metaParts.push(`${fn.module} module`);
494
+ if (fn.env) metaParts.push(`observed in ${fn.env}`);
495
+ if (fn.observedAt) metaParts.push(formatTimeAgo(fn.observedAt));
496
+ if (metaParts.length > 0) {
497
+ sections.push(`# ${baseName} — ${metaParts.join(", ")}`);
498
+ }
499
+
500
+ // Input type
501
+ if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1 && fn.argsType.elements[0].kind === "object") {
502
+ // Single-object arg: use it directly
503
+ sections.push(renderPythonTypedDict(`${baseName}Input`, fn.argsType.elements[0] as Extract<TypeNode, { kind: "object" }>, extracted));
504
+ } else if (fn.argsType.kind === "object") {
505
+ sections.push(renderPythonTypedDict(`${baseName}Input`, fn.argsType as Extract<TypeNode, { kind: "object" }>, extracted));
506
+ } else if (fn.argsType.kind === "tuple") {
507
+ // Multiple args — create a TypedDict with param names
508
+ const fakeObj: Extract<TypeNode, { kind: "object" }> = { kind: "object", properties: {} };
509
+ fn.argsType.elements.forEach((el, i) => {
510
+ fakeObj.properties[`arg${i}`] = el;
511
+ });
512
+ sections.push(renderPythonTypedDict(`${baseName}Args`, fakeObj, extracted));
513
+ } else {
514
+ const pyType = typeNodeToPython(fn.argsType, extracted, baseName, undefined, 0);
515
+ sections.push(`${baseName}Input = ${pyType}`);
516
+ }
517
+ sections.push("");
518
+ sections.push("");
519
+
520
+ // Output type
521
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
522
+ sections.push(renderPythonTypedDict(`${baseName}Output`, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
523
+ } else {
524
+ const pyType = typeNodeToPython(fn.returnType, extracted, baseName, undefined, 0);
525
+ sections.push(`${baseName}Output = ${pyType}`);
526
+ }
527
+
528
+ // Emit extracted TypedDicts that were accumulated
529
+ const emitted = new Set<string>();
530
+ const pendingExtracted: string[] = [];
531
+ for (const iface of extracted) {
532
+ if (emitted.has(iface.name)) continue;
533
+ emitted.add(iface.name);
534
+ pendingExtracted.push("");
535
+ pendingExtracted.push("");
536
+ pendingExtracted.push(renderPythonTypedDict(iface.name, iface.node, extracted));
537
+ }
538
+ // Insert extracted defs before the last output (they need to be defined first)
539
+ // Actually, since Python TypedDicts reference forward, just append
540
+ if (pendingExtracted.length > 0) {
541
+ // Re-order: put extracted before the Input/Output that reference them
542
+ // For simplicity, just include them — Python TypedDict forward refs work with __future__.annotations
543
+ }
544
+
545
+ sections.push("");
546
+ sections.push("");
547
+ }
548
+
549
+ return sections.join("\n").trimEnd() + "\n";
550
+ }
551
+
552
+ // ── Typed API client generation ──
553
+
554
+ interface ParsedRoute {
555
+ method: string; // GET, POST, PUT, DELETE, PATCH
556
+ path: string; // /api/users/:id
557
+ pathParams: string[]; // ["id"]
558
+ funcName: string; // camelCase: getApiUsersById
559
+ typeName: string; // PascalCase: GetApiUsersById
560
+ }
561
+
562
+ function parseRouteName(name: string): ParsedRoute | null {
563
+ const match = name.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(.+)$/i);
564
+ if (!match) return null;
565
+
566
+ const method = match[1].toUpperCase();
567
+ const path = match[2];
568
+
569
+ // Extract path params like :id, :userId
570
+ const pathParams: string[] = [];
571
+ path.replace(/:(\w+)/g, (_, param) => {
572
+ pathParams.push(param);
573
+ return _;
574
+ });
575
+
576
+ const typeName = toPascalCase(name);
577
+ // For camelCase: lowercase the HTTP method prefix (e.g., GETApiUsers → getApiUsers)
578
+ const methodLower = method.charAt(0).toUpperCase() + method.slice(1).toLowerCase();
579
+ const pathPart = toPascalCase(path);
580
+ const funcName = method.toLowerCase() + pathPart;
581
+
582
+ return { method, path, pathParams, funcName, typeName };
583
+ }
584
+
585
+ function toCamelCase(name: string): string {
586
+ const pascal = toPascalCase(name);
587
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
588
+ }
589
+
590
+ /**
591
+ * Generate a fully-typed fetch-based API client from runtime-observed routes.
592
+ *
593
+ * Output is a single TypeScript file with:
594
+ * - All request/response interfaces
595
+ * - A `createTrickleClient(baseUrl)` factory that returns typed fetch wrappers
596
+ * - Proper path parameter substitution
597
+ * - Request body typing for POST/PUT/PATCH
598
+ */
599
+ export function generateApiClient(
600
+ functions: Array<{
601
+ name: string;
602
+ argsType: TypeNode;
603
+ returnType: TypeNode;
604
+ module?: string;
605
+ env?: string;
606
+ observedAt?: string;
607
+ }>,
608
+ ): string {
609
+ // Filter to route-style functions only
610
+ const routes: Array<{
611
+ parsed: ParsedRoute;
612
+ fn: (typeof functions)[number];
613
+ }> = [];
614
+
615
+ for (const fn of functions) {
616
+ const parsed = parseRouteName(fn.name);
617
+ if (parsed) {
618
+ routes.push({ parsed, fn });
619
+ }
620
+ }
621
+
622
+ if (routes.length === 0) {
623
+ return "// No API routes found. Instrument your Express/FastAPI app to generate a typed client.\n";
624
+ }
625
+
626
+ const sections: string[] = [];
627
+ sections.push("// Auto-generated typed API client by trickle");
628
+ sections.push(`// Generated at ${new Date().toISOString()}`);
629
+ sections.push("// Do not edit manually — re-run `trickle codegen --client` to update");
630
+ sections.push("");
631
+
632
+ // Generate interfaces for each route
633
+ const extracted: ExtractedInterface[] = [];
634
+
635
+ for (const { parsed, fn } of routes) {
636
+ const baseName = parsed.typeName;
637
+
638
+ // --- Input types (only for methods with body) ---
639
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
640
+ // argsType is typically an object with { body, params, query } from Express instrumentation
641
+ if (fn.argsType.kind === "object") {
642
+ const bodyNode = fn.argsType.properties["body"];
643
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
644
+ const inputName = `${baseName}Input`;
645
+ sections.push(renderInterface(inputName, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
646
+ sections.push("");
647
+ }
648
+ } else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
649
+ const el = fn.argsType.elements[0];
650
+ if (el.kind === "object") {
651
+ const bodyNode = el.properties["body"];
652
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
653
+ const inputName = `${baseName}Input`;
654
+ sections.push(renderInterface(inputName, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
655
+ sections.push("");
656
+ }
657
+ }
658
+ }
659
+ }
660
+
661
+ // --- Path params type (if route has :params) ---
662
+ if (parsed.pathParams.length > 0) {
663
+ // Try to extract from argsType.properties.params
664
+ let paramsNode: TypeNode | undefined;
665
+ if (fn.argsType.kind === "object" && fn.argsType.properties["params"]) {
666
+ paramsNode = fn.argsType.properties["params"];
667
+ }
668
+ // Only generate if we have object-typed params
669
+ if (paramsNode && paramsNode.kind === "object" && Object.keys(paramsNode.properties).length > 0) {
670
+ const paramsName = `${baseName}Params`;
671
+ sections.push(renderInterface(paramsName, paramsNode as Extract<TypeNode, { kind: "object" }>, extracted));
672
+ sections.push("");
673
+ }
674
+ }
675
+
676
+ // --- Query params type ---
677
+ if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
678
+ const queryNode = fn.argsType.properties["query"];
679
+ if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
680
+ const queryName = `${baseName}Query`;
681
+ sections.push(renderInterface(queryName, queryNode as Extract<TypeNode, { kind: "object" }>, extracted));
682
+ sections.push("");
683
+ }
684
+ }
685
+
686
+ // --- Output type ---
687
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
688
+ const outputName = `${baseName}Output`;
689
+ sections.push(renderInterface(outputName, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
690
+ sections.push("");
691
+ } else {
692
+ const outputName = `${baseName}Output`;
693
+ const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
694
+ sections.push(`export type ${outputName} = ${retStr};`);
695
+ sections.push("");
696
+ }
697
+ }
698
+
699
+ // Emit extracted sub-interfaces
700
+ const emitted = new Set<string>();
701
+ const extractedLines: string[] = [];
702
+ let cursor = 0;
703
+ while (cursor < extracted.length) {
704
+ const iface = extracted[cursor];
705
+ cursor++;
706
+ if (emitted.has(iface.name)) continue;
707
+ emitted.add(iface.name);
708
+ extractedLines.push(renderInterface(iface.name, iface.node, extracted));
709
+ extractedLines.push("");
710
+ }
711
+
712
+ if (extractedLines.length > 0) {
713
+ // Insert extracted interfaces before the main interfaces
714
+ sections.splice(4, 0, ...extractedLines);
715
+ }
716
+
717
+ // --- Generate the client factory ---
718
+ sections.push("// ── API Client ──");
719
+ sections.push("");
720
+ sections.push("export function createTrickleClient(baseUrl: string) {");
721
+ sections.push(" async function request<T>(method: string, path: string, body?: unknown, query?: Record<string, string>): Promise<T> {");
722
+ sections.push(" const url = new URL(path, baseUrl);");
723
+ sections.push(" if (query) { for (const [k, v] of Object.entries(query)) { if (v !== undefined) url.searchParams.set(k, v); } }");
724
+ sections.push(" const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };");
725
+ sections.push(" if (body !== undefined) opts.body = JSON.stringify(body);");
726
+ sections.push(" const res = await fetch(url.toString(), opts);");
727
+ sections.push(" if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status}`);");
728
+ sections.push(" return res.json() as Promise<T>;");
729
+ sections.push(" }");
730
+ sections.push("");
731
+ sections.push(" return {");
732
+
733
+ for (const { parsed, fn } of routes) {
734
+ const baseName = parsed.typeName;
735
+ const outputType = `${baseName}Output`;
736
+ const hasBody = ["POST", "PUT", "PATCH"].includes(parsed.method);
737
+
738
+ // Determine if we have a typed input
739
+ let hasInputType = false;
740
+ if (hasBody && fn.argsType.kind === "object") {
741
+ const bodyNode = fn.argsType.properties["body"];
742
+ hasInputType = !!(bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0);
743
+ }
744
+
745
+ // Build path expression with template literals for path params
746
+ let pathExpr: string;
747
+ if (parsed.pathParams.length > 0) {
748
+ pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${${toCamelCase(param)}}`) + "`";
749
+ } else {
750
+ pathExpr = `"${parsed.path}"`;
751
+ }
752
+
753
+ // Build method signature
754
+ const params: string[] = [];
755
+ if (parsed.pathParams.length > 0) {
756
+ for (const p of parsed.pathParams) {
757
+ params.push(`${toCamelCase(p)}: string`);
758
+ }
759
+ }
760
+ if (hasInputType) {
761
+ params.push(`input: ${baseName}Input`);
762
+ }
763
+
764
+ const bodyArg = hasInputType ? "input" : "undefined";
765
+ const paramStr = params.length > 0 ? params.join(", ") : "";
766
+
767
+ // Generate JSDoc comment
768
+ sections.push(` /** ${parsed.method} ${parsed.path} */`);
769
+ sections.push(` ${parsed.funcName}: (${paramStr}): Promise<${outputType}> =>`);
770
+ sections.push(` request<${outputType}>("${parsed.method}", ${pathExpr}, ${bodyArg}),`);
771
+ sections.push("");
772
+ }
773
+
774
+ sections.push(" };");
775
+ sections.push("}");
776
+ sections.push("");
777
+ sections.push("export type TrickleClient = ReturnType<typeof createTrickleClient>;");
778
+
779
+ return sections.join("\n").trimEnd() + "\n";
780
+ }
781
+
782
+ // ── OpenAPI spec generation ──
783
+
784
+ /**
785
+ * Convert a TypeNode to a JSON Schema object.
786
+ */
787
+ function typeNodeToJsonSchema(
788
+ node: TypeNode,
789
+ defs: Record<string, object>,
790
+ parentName: string,
791
+ propName: string | undefined,
792
+ ): object {
793
+ switch (node.kind) {
794
+ case "primitive":
795
+ switch (node.name) {
796
+ case "string": return { type: "string" };
797
+ case "number": return { type: "number" };
798
+ case "boolean": return { type: "boolean" };
799
+ case "null": return { type: "string", nullable: true };
800
+ case "undefined": return { type: "string", nullable: true };
801
+ case "bigint": return { type: "integer", format: "int64" };
802
+ case "symbol": return { type: "string" };
803
+ default: return {};
804
+ }
805
+
806
+ case "unknown":
807
+ return {};
808
+
809
+ case "array":
810
+ return {
811
+ type: "array",
812
+ items: typeNodeToJsonSchema(node.element, defs, parentName, propName),
813
+ };
814
+
815
+ case "tuple":
816
+ return {
817
+ type: "array",
818
+ items: node.elements.length > 0
819
+ ? typeNodeToJsonSchema(node.elements[0], defs, parentName, propName)
820
+ : {},
821
+ minItems: node.elements.length,
822
+ maxItems: node.elements.length,
823
+ };
824
+
825
+ case "union": {
826
+ const schemas = node.members.map((m) =>
827
+ typeNodeToJsonSchema(m, defs, parentName, propName)
828
+ );
829
+ // Simplify nullable unions: { type: "string" } | null → nullable string
830
+ const nonNull = schemas.filter(
831
+ (s) => !("nullable" in s && (s as Record<string, unknown>).nullable === true),
832
+ );
833
+ if (nonNull.length === 1 && nonNull.length < schemas.length) {
834
+ return { ...nonNull[0], nullable: true };
835
+ }
836
+ return { oneOf: schemas };
837
+ }
838
+
839
+ case "map":
840
+ return {
841
+ type: "object",
842
+ additionalProperties: typeNodeToJsonSchema(node.value, defs, parentName, propName),
843
+ };
844
+
845
+ case "set":
846
+ return {
847
+ type: "array",
848
+ items: typeNodeToJsonSchema(node.element, defs, parentName, propName),
849
+ uniqueItems: true,
850
+ };
851
+
852
+ case "promise":
853
+ return typeNodeToJsonSchema(node.resolved, defs, parentName, propName);
854
+
855
+ case "function":
856
+ return { type: "object", description: "function" };
857
+
858
+ case "object": {
859
+ const keys = Object.keys(node.properties);
860
+ if (keys.length === 0) return { type: "object" };
861
+
862
+ // Extract complex objects as $ref to keep schemas readable
863
+ if (keys.length > 2 && propName) {
864
+ const schemaName = toPascalCase(parentName) + toPascalCase(propName);
865
+ if (!defs[schemaName]) {
866
+ // Placeholder to prevent infinite recursion
867
+ defs[schemaName] = {};
868
+ const properties: Record<string, object> = {};
869
+ const required: string[] = [];
870
+ for (const key of keys) {
871
+ properties[key] = typeNodeToJsonSchema(node.properties[key], defs, schemaName, key);
872
+ required.push(key);
873
+ }
874
+ defs[schemaName] = { type: "object", properties, required };
875
+ }
876
+ return { $ref: `#/components/schemas/${schemaName}` };
877
+ }
878
+
879
+ const properties: Record<string, object> = {};
880
+ const required: string[] = [];
881
+ for (const key of keys) {
882
+ properties[key] = typeNodeToJsonSchema(node.properties[key], defs, parentName, key);
883
+ required.push(key);
884
+ }
885
+ return { type: "object", properties, required };
886
+ }
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Generate an OpenAPI 3.0 specification from runtime-observed route types.
892
+ */
893
+ export function generateOpenApiSpec(
894
+ functions: Array<{
895
+ name: string;
896
+ argsType: TypeNode;
897
+ returnType: TypeNode;
898
+ module?: string;
899
+ env?: string;
900
+ observedAt?: string;
901
+ }>,
902
+ options?: { title?: string; version?: string; serverUrl?: string },
903
+ ): object {
904
+ const title = options?.title || "API";
905
+ const version = options?.version || "1.0.0";
906
+ const paths: Record<string, Record<string, object>> = {};
907
+ const schemas: Record<string, object> = {};
908
+
909
+ for (const fn of functions) {
910
+ const parsed = parseRouteName(fn.name);
911
+ if (!parsed) continue;
912
+
913
+ const method = parsed.method.toLowerCase();
914
+ const baseName = parsed.typeName;
915
+
916
+ // Convert Express :param to OpenAPI {param}
917
+ const openApiPath = parsed.path.replace(/:(\w+)/g, "{$1}");
918
+
919
+ // Build response schema
920
+ const responseDefs: Record<string, object> = {};
921
+ const responseSchema = typeNodeToJsonSchema(fn.returnType, responseDefs, baseName + "Output", undefined);
922
+ Object.assign(schemas, responseDefs);
923
+
924
+ // If response is a complex object, extract to a named schema
925
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
926
+ const responseSchemaName = `${baseName}Response`;
927
+ schemas[responseSchemaName] = responseSchema;
928
+ }
929
+
930
+ const responseRef = fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0
931
+ ? { $ref: `#/components/schemas/${baseName}Response` }
932
+ : responseSchema;
933
+
934
+ // Build operation object
935
+ const operation: Record<string, unknown> = {
936
+ operationId: parsed.funcName,
937
+ summary: `${parsed.method} ${parsed.path}`,
938
+ responses: {
939
+ "200": {
940
+ description: "Successful response",
941
+ content: {
942
+ "application/json": {
943
+ schema: responseRef,
944
+ },
945
+ },
946
+ },
947
+ },
948
+ };
949
+
950
+ // Add path parameters
951
+ if (parsed.pathParams.length > 0) {
952
+ operation.parameters = parsed.pathParams.map((param) => ({
953
+ name: param,
954
+ in: "path",
955
+ required: true,
956
+ schema: { type: "string" },
957
+ }));
958
+ }
959
+
960
+ // Add query parameters from argsType
961
+ if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
962
+ const queryNode = fn.argsType.properties["query"];
963
+ if (queryNode.kind === "object") {
964
+ const queryParams = Object.keys(queryNode.properties).map((param) => {
965
+ const paramSchema = typeNodeToJsonSchema(queryNode.properties[param], schemas, baseName, param);
966
+ return {
967
+ name: param,
968
+ in: "query" as const,
969
+ required: false,
970
+ schema: paramSchema,
971
+ };
972
+ });
973
+ const existing = (operation.parameters as Array<object>) || [];
974
+ operation.parameters = [...existing, ...queryParams];
975
+ }
976
+ }
977
+
978
+ // Add request body for POST/PUT/PATCH
979
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
980
+ let bodyNode: TypeNode | undefined;
981
+
982
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
983
+ bodyNode = fn.argsType.properties["body"];
984
+ } else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
985
+ const el = fn.argsType.elements[0];
986
+ if (el.kind === "object" && el.properties["body"]) {
987
+ bodyNode = el.properties["body"];
988
+ }
989
+ }
990
+
991
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
992
+ const bodyDefs: Record<string, object> = {};
993
+ const bodySchema = typeNodeToJsonSchema(bodyNode, bodyDefs, baseName + "Request", undefined);
994
+ Object.assign(schemas, bodyDefs);
995
+
996
+ const requestSchemaName = `${baseName}Request`;
997
+ schemas[requestSchemaName] = bodySchema;
998
+
999
+ operation.requestBody = {
1000
+ required: true,
1001
+ content: {
1002
+ "application/json": {
1003
+ schema: { $ref: `#/components/schemas/${requestSchemaName}` },
1004
+ },
1005
+ },
1006
+ };
1007
+ }
1008
+ }
1009
+
1010
+ // Add tags based on path prefix
1011
+ const pathParts = parsed.path.split("/").filter(Boolean);
1012
+ if (pathParts.length >= 2) {
1013
+ operation.tags = [pathParts[1]]; // e.g., /api/users → "users" tag would be pathParts[1]
1014
+ // But /api is the first meaningful segment, so use the one after /api
1015
+ if (pathParts[0] === "api" && pathParts.length >= 2) {
1016
+ operation.tags = [pathParts[1]];
1017
+ }
1018
+ }
1019
+
1020
+ if (!paths[openApiPath]) {
1021
+ paths[openApiPath] = {};
1022
+ }
1023
+ paths[openApiPath][method] = operation;
1024
+ }
1025
+
1026
+ const spec: Record<string, unknown> = {
1027
+ openapi: "3.0.3",
1028
+ info: { title, version },
1029
+ paths,
1030
+ };
1031
+
1032
+ if (Object.keys(schemas).length > 0) {
1033
+ spec.components = { schemas };
1034
+ }
1035
+
1036
+ if (options?.serverUrl) {
1037
+ spec.servers = [{ url: options.serverUrl }];
1038
+ }
1039
+
1040
+ return spec;
1041
+ }
1042
+
1043
+ // ── Express handler type generation ──
1044
+
1045
+ /**
1046
+ * Generate typed Express handler type aliases from runtime-observed routes.
1047
+ *
1048
+ * For each route like `GET /api/users/:id`, produces:
1049
+ * - `GetApiUsersIdHandler` — a fully typed `RequestHandler` with
1050
+ * `Request<Params, ResBody, ReqBody, Query>` and `Response<ResBody>`
1051
+ *
1052
+ * Developers can use these to type their route handlers:
1053
+ * app.get('/api/users/:id', ((req, res) => { ... }) as GetApiUsersIdHandler);
1054
+ */
1055
+ export function generateHandlerTypes(
1056
+ functions: Array<{
1057
+ name: string;
1058
+ argsType: TypeNode;
1059
+ returnType: TypeNode;
1060
+ module?: string;
1061
+ env?: string;
1062
+ observedAt?: string;
1063
+ }>,
1064
+ ): string {
1065
+ // Filter to route-style functions only
1066
+ const routes: Array<{
1067
+ parsed: ParsedRoute;
1068
+ fn: (typeof functions)[number];
1069
+ }> = [];
1070
+
1071
+ for (const fn of functions) {
1072
+ const parsed = parseRouteName(fn.name);
1073
+ if (parsed) {
1074
+ routes.push({ parsed, fn });
1075
+ }
1076
+ }
1077
+
1078
+ if (routes.length === 0) {
1079
+ return "// No API routes found. Instrument your Express app to generate handler types.\n";
1080
+ }
1081
+
1082
+ const sections: string[] = [];
1083
+ sections.push("// Auto-generated Express handler types by trickle");
1084
+ sections.push(`// Generated at ${new Date().toISOString()}`);
1085
+ sections.push("// Do not edit manually — re-run `trickle codegen --handlers` to update");
1086
+ sections.push("");
1087
+ sections.push('import { Request, Response, NextFunction } from "express";');
1088
+ sections.push("");
1089
+
1090
+ const extracted: ExtractedInterface[] = [];
1091
+
1092
+ for (const { parsed, fn } of routes) {
1093
+ const baseName = parsed.typeName;
1094
+
1095
+ // --- Params type ---
1096
+ let paramsType = "Record<string, string>";
1097
+ if (parsed.pathParams.length > 0) {
1098
+ const paramsName = `${baseName}Params`;
1099
+ // Check if we have observed param types
1100
+ let paramsNode: TypeNode | undefined;
1101
+ if (fn.argsType.kind === "object" && fn.argsType.properties["params"]) {
1102
+ paramsNode = fn.argsType.properties["params"];
1103
+ }
1104
+ if (paramsNode && paramsNode.kind === "object" && Object.keys(paramsNode.properties).length > 0) {
1105
+ sections.push(renderInterface(paramsName, paramsNode as Extract<TypeNode, { kind: "object" }>, extracted));
1106
+ sections.push("");
1107
+ } else {
1108
+ // Generate from path params — all strings
1109
+ const props: string[] = parsed.pathParams.map(p => ` ${p}: string;`);
1110
+ sections.push(`export interface ${paramsName} {`);
1111
+ sections.push(...props);
1112
+ sections.push("}");
1113
+ sections.push("");
1114
+ }
1115
+ paramsType = paramsName;
1116
+ }
1117
+
1118
+ // --- Response body type ---
1119
+ let resBodyType = "unknown";
1120
+ const resBodyName = `${baseName}ResBody`;
1121
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
1122
+ sections.push(renderInterface(resBodyName, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
1123
+ sections.push("");
1124
+ resBodyType = resBodyName;
1125
+ } else {
1126
+ const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
1127
+ sections.push(`export type ${resBodyName} = ${retStr};`);
1128
+ sections.push("");
1129
+ resBodyType = resBodyName;
1130
+ }
1131
+
1132
+ // --- Request body type ---
1133
+ let reqBodyType = "unknown";
1134
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
1135
+ let bodyNode: TypeNode | undefined;
1136
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
1137
+ bodyNode = fn.argsType.properties["body"];
1138
+ } else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
1139
+ const el = fn.argsType.elements[0];
1140
+ if (el.kind === "object" && el.properties["body"]) {
1141
+ bodyNode = el.properties["body"];
1142
+ }
1143
+ }
1144
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
1145
+ const reqBodyName = `${baseName}ReqBody`;
1146
+ sections.push(renderInterface(reqBodyName, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
1147
+ sections.push("");
1148
+ reqBodyType = reqBodyName;
1149
+ }
1150
+ }
1151
+
1152
+ // --- Query type ---
1153
+ let queryType = "qs.ParsedQs";
1154
+ if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
1155
+ const queryNode = fn.argsType.properties["query"];
1156
+ if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
1157
+ const queryName = `${baseName}Query`;
1158
+ sections.push(renderInterface(queryName, queryNode as Extract<TypeNode, { kind: "object" }>, extracted));
1159
+ sections.push("");
1160
+ queryType = queryName;
1161
+ }
1162
+ }
1163
+
1164
+ // --- Handler type alias ---
1165
+ sections.push(`/** ${parsed.method} ${parsed.path} */`);
1166
+ sections.push(
1167
+ `export type ${baseName}Handler = (` +
1168
+ `req: Request<${paramsType}, ${resBodyType}, ${reqBodyType}, ${queryType}>, ` +
1169
+ `res: Response<${resBodyType}>, ` +
1170
+ `next: NextFunction` +
1171
+ `) => void;`,
1172
+ );
1173
+ sections.push("");
1174
+ }
1175
+
1176
+ // Emit extracted sub-interfaces
1177
+ const emitted = new Set<string>();
1178
+ const extractedLines: string[] = [];
1179
+ let cursor = 0;
1180
+ while (cursor < extracted.length) {
1181
+ const iface = extracted[cursor];
1182
+ cursor++;
1183
+ if (emitted.has(iface.name)) continue;
1184
+ emitted.add(iface.name);
1185
+ extractedLines.push(renderInterface(iface.name, iface.node, extracted));
1186
+ extractedLines.push("");
1187
+ }
1188
+
1189
+ if (extractedLines.length > 0) {
1190
+ // Insert after the import line (index 4 = after the blank line after import)
1191
+ sections.splice(5, 0, ...extractedLines);
1192
+ }
1193
+
1194
+ return sections.join("\n").trimEnd() + "\n";
1195
+ }
1196
+
1197
+ // ── Zod schema generation ──
1198
+
1199
+ /**
1200
+ * Convert a TypeNode to a Zod schema expression string.
1201
+ */
1202
+ function typeNodeToZod(node: TypeNode, indent: number): string {
1203
+ const pad = " ".repeat(indent);
1204
+ switch (node.kind) {
1205
+ case "primitive":
1206
+ switch (node.name) {
1207
+ case "string": return "z.string()";
1208
+ case "number": return "z.number()";
1209
+ case "boolean": return "z.boolean()";
1210
+ case "null": return "z.null()";
1211
+ case "undefined": return "z.undefined()";
1212
+ case "bigint": return "z.bigint()";
1213
+ case "symbol": return "z.symbol()";
1214
+ default: return "z.unknown()";
1215
+ }
1216
+
1217
+ case "unknown":
1218
+ return "z.unknown()";
1219
+
1220
+ case "array":
1221
+ return `z.array(${typeNodeToZod(node.element, indent)})`;
1222
+
1223
+ case "tuple": {
1224
+ if (node.elements.length === 0) return "z.tuple([])";
1225
+ const els = node.elements.map((el) => typeNodeToZod(el, indent));
1226
+ return `z.tuple([${els.join(", ")}])`;
1227
+ }
1228
+
1229
+ case "union": {
1230
+ const members = node.members.map((m) => typeNodeToZod(m, indent));
1231
+ if (members.length === 1) return members[0];
1232
+ return `z.union([${members.join(", ")}])`;
1233
+ }
1234
+
1235
+ case "map": {
1236
+ return `z.map(${typeNodeToZod(node.key, indent)}, ${typeNodeToZod(node.value, indent)})`;
1237
+ }
1238
+
1239
+ case "set": {
1240
+ return `z.set(${typeNodeToZod(node.element, indent)})`;
1241
+ }
1242
+
1243
+ case "promise": {
1244
+ return `z.promise(${typeNodeToZod(node.resolved, indent)})`;
1245
+ }
1246
+
1247
+ case "function": {
1248
+ return "z.function()";
1249
+ }
1250
+
1251
+ case "object": {
1252
+ const keys = Object.keys(node.properties);
1253
+ if (keys.length === 0) return "z.object({})";
1254
+
1255
+ const innerPad = " ".repeat(indent + 1);
1256
+ const entries = keys.map((key) => {
1257
+ const val = typeNodeToZod(node.properties[key], indent + 1);
1258
+ return `${innerPad}${key}: ${val},`;
1259
+ });
1260
+ return `z.object({\n${entries.join("\n")}\n${pad}})`;
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * Generate Zod validation schemas from runtime-observed types.
1267
+ *
1268
+ * For each function/route, generates a named Zod schema that can be used for:
1269
+ * - Runtime validation of API inputs/outputs
1270
+ * - TypeScript type inference via `z.infer<typeof schema>`
1271
+ * - Form validation, config parsing, etc.
1272
+ */
1273
+ export function generateZodSchemas(
1274
+ functions: Array<{
1275
+ name: string;
1276
+ argsType: TypeNode;
1277
+ returnType: TypeNode;
1278
+ module?: string;
1279
+ env?: string;
1280
+ observedAt?: string;
1281
+ }>,
1282
+ ): string {
1283
+ if (functions.length === 0) {
1284
+ return "// No functions found. Instrument your app to generate Zod schemas.\n";
1285
+ }
1286
+
1287
+ const sections: string[] = [];
1288
+ sections.push("// Auto-generated Zod schemas by trickle");
1289
+ sections.push(`// Generated at ${new Date().toISOString()}`);
1290
+ sections.push("// Do not edit manually — re-run `trickle codegen --zod` to update");
1291
+ sections.push("");
1292
+ sections.push('import { z } from "zod";');
1293
+ sections.push("");
1294
+
1295
+ // Check if any are route-style functions
1296
+ const hasRoutes = functions.some((fn) => parseRouteName(fn.name) !== null);
1297
+
1298
+ for (const fn of functions) {
1299
+ const parsed = parseRouteName(fn.name);
1300
+
1301
+ if (parsed) {
1302
+ // Route-style: generate Input/Output schemas
1303
+ const baseName = toCamelCase(parsed.typeName);
1304
+ const BaseNamePascal = parsed.typeName;
1305
+
1306
+ // --- Response schema ---
1307
+ sections.push(`/** ${parsed.method} ${parsed.path} — response */`);
1308
+ sections.push(`export const ${baseName}ResponseSchema = ${typeNodeToZod(fn.returnType, 0)};`);
1309
+ sections.push(`export type ${BaseNamePascal}Response = z.infer<typeof ${baseName}ResponseSchema>;`);
1310
+ sections.push("");
1311
+
1312
+ // --- Request body schema (for POST/PUT/PATCH) ---
1313
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
1314
+ let bodyNode: TypeNode | undefined;
1315
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
1316
+ bodyNode = fn.argsType.properties["body"];
1317
+ } else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
1318
+ const el = fn.argsType.elements[0];
1319
+ if (el.kind === "object" && el.properties["body"]) {
1320
+ bodyNode = el.properties["body"];
1321
+ }
1322
+ }
1323
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
1324
+ sections.push(`/** ${parsed.method} ${parsed.path} — request body */`);
1325
+ sections.push(`export const ${baseName}RequestSchema = ${typeNodeToZod(bodyNode, 0)};`);
1326
+ sections.push(`export type ${BaseNamePascal}Request = z.infer<typeof ${baseName}RequestSchema>;`);
1327
+ sections.push("");
1328
+ }
1329
+ }
1330
+
1331
+ // --- Path params schema (if route has :params) ---
1332
+ if (parsed.pathParams.length > 0) {
1333
+ let paramsNode: TypeNode | undefined;
1334
+ if (fn.argsType.kind === "object" && fn.argsType.properties["params"]) {
1335
+ paramsNode = fn.argsType.properties["params"];
1336
+ }
1337
+ if (paramsNode && paramsNode.kind === "object" && Object.keys(paramsNode.properties).length > 0) {
1338
+ sections.push(`/** ${parsed.method} ${parsed.path} — path params */`);
1339
+ sections.push(`export const ${baseName}ParamsSchema = ${typeNodeToZod(paramsNode, 0)};`);
1340
+ sections.push(`export type ${BaseNamePascal}Params = z.infer<typeof ${baseName}ParamsSchema>;`);
1341
+ sections.push("");
1342
+ }
1343
+ }
1344
+
1345
+ // --- Query params schema ---
1346
+ if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
1347
+ const queryNode = fn.argsType.properties["query"];
1348
+ if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
1349
+ sections.push(`/** ${parsed.method} ${parsed.path} — query params */`);
1350
+ sections.push(`export const ${baseName}QuerySchema = ${typeNodeToZod(queryNode, 0)};`);
1351
+ sections.push(`export type ${BaseNamePascal}Query = z.infer<typeof ${baseName}QuerySchema>;`);
1352
+ sections.push("");
1353
+ }
1354
+ }
1355
+ } else {
1356
+ // Non-route function: generate Input/Output schemas
1357
+ const baseName = toCamelCase(toPascalCase(fn.name));
1358
+ const BaseNamePascal = toPascalCase(fn.name);
1359
+
1360
+ // Input schema
1361
+ if (fn.argsType.kind !== "unknown") {
1362
+ sections.push(`/** ${fn.name} — input */`);
1363
+ if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
1364
+ sections.push(`export const ${baseName}InputSchema = ${typeNodeToZod(fn.argsType.elements[0], 0)};`);
1365
+ } else {
1366
+ sections.push(`export const ${baseName}InputSchema = ${typeNodeToZod(fn.argsType, 0)};`);
1367
+ }
1368
+ sections.push(`export type ${BaseNamePascal}Input = z.infer<typeof ${baseName}InputSchema>;`);
1369
+ sections.push("");
1370
+ }
1371
+
1372
+ // Output schema
1373
+ sections.push(`/** ${fn.name} — output */`);
1374
+ sections.push(`export const ${baseName}OutputSchema = ${typeNodeToZod(fn.returnType, 0)};`);
1375
+ sections.push(`export type ${BaseNamePascal}Output = z.infer<typeof ${baseName}OutputSchema>;`);
1376
+ sections.push("");
1377
+ }
1378
+ }
1379
+
1380
+ return sections.join("\n").trimEnd() + "\n";
1381
+ }
1382
+
1383
+ // ── React Query hooks generation ──
1384
+
1385
+ /**
1386
+ * Generate fully-typed TanStack Query (React Query) hooks from runtime-observed routes.
1387
+ *
1388
+ * For each route:
1389
+ * - GET → useQuery hook with typed response and query keys
1390
+ * - POST/PUT/PATCH/DELETE → useMutation hook with typed input/output
1391
+ * - Query key factory for cache invalidation
1392
+ * - All request/response interfaces included
1393
+ */
1394
+ export function generateReactQueryHooks(
1395
+ functions: Array<{
1396
+ name: string;
1397
+ argsType: TypeNode;
1398
+ returnType: TypeNode;
1399
+ module?: string;
1400
+ env?: string;
1401
+ observedAt?: string;
1402
+ }>,
1403
+ ): string {
1404
+ // Filter to route-style functions only
1405
+ const routes: Array<{
1406
+ parsed: ParsedRoute;
1407
+ fn: (typeof functions)[number];
1408
+ }> = [];
1409
+
1410
+ for (const fn of functions) {
1411
+ const parsed = parseRouteName(fn.name);
1412
+ if (parsed) {
1413
+ routes.push({ parsed, fn });
1414
+ }
1415
+ }
1416
+
1417
+ if (routes.length === 0) {
1418
+ return "// No API routes found. Instrument your Express/FastAPI app to generate React Query hooks.\n";
1419
+ }
1420
+
1421
+ const sections: string[] = [];
1422
+ sections.push("// Auto-generated React Query hooks by trickle");
1423
+ sections.push(`// Generated at ${new Date().toISOString()}`);
1424
+ sections.push("// Do not edit manually — re-run `trickle codegen --react-query` to update");
1425
+ sections.push("");
1426
+ sections.push('import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";');
1427
+ sections.push('import type { UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";');
1428
+ sections.push("");
1429
+
1430
+ // --- Generate interfaces ---
1431
+ const extracted: ExtractedInterface[] = [];
1432
+
1433
+ for (const { parsed, fn } of routes) {
1434
+ const baseName = parsed.typeName;
1435
+
1436
+ // Response type
1437
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
1438
+ sections.push(renderInterface(`${baseName}Response`, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
1439
+ sections.push("");
1440
+ } else {
1441
+ const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
1442
+ sections.push(`export type ${baseName}Response = ${retStr};`);
1443
+ sections.push("");
1444
+ }
1445
+
1446
+ // Request body type (POST/PUT/PATCH)
1447
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
1448
+ let bodyNode: TypeNode | undefined;
1449
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
1450
+ bodyNode = fn.argsType.properties["body"];
1451
+ } else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
1452
+ const el = fn.argsType.elements[0];
1453
+ if (el.kind === "object" && el.properties["body"]) {
1454
+ bodyNode = el.properties["body"];
1455
+ }
1456
+ }
1457
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
1458
+ sections.push(renderInterface(`${baseName}Input`, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
1459
+ sections.push("");
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ // Emit extracted sub-interfaces
1465
+ const emitted = new Set<string>();
1466
+ const extractedLines: string[] = [];
1467
+ let cursor = 0;
1468
+ while (cursor < extracted.length) {
1469
+ const iface = extracted[cursor];
1470
+ cursor++;
1471
+ if (emitted.has(iface.name)) continue;
1472
+ emitted.add(iface.name);
1473
+ extractedLines.push(renderInterface(iface.name, iface.node, extracted));
1474
+ extractedLines.push("");
1475
+ }
1476
+ if (extractedLines.length > 0) {
1477
+ sections.push(...extractedLines);
1478
+ }
1479
+
1480
+ // --- Internal fetch helper ---
1481
+ sections.push("// ── Internal fetch helper ──");
1482
+ sections.push("");
1483
+ sections.push("let _baseUrl = \"\";");
1484
+ sections.push("");
1485
+ sections.push("/** Set the base URL for all API requests. Call once at app startup. */");
1486
+ sections.push("export function configureTrickleHooks(baseUrl: string) {");
1487
+ sections.push(" _baseUrl = baseUrl;");
1488
+ sections.push("}");
1489
+ sections.push("");
1490
+ sections.push("async function _fetch<T>(method: string, path: string, body?: unknown): Promise<T> {");
1491
+ sections.push(" const opts: RequestInit = { method, headers: { \"Content-Type\": \"application/json\" } };");
1492
+ sections.push(" if (body !== undefined) opts.body = JSON.stringify(body);");
1493
+ sections.push(" const res = await fetch(`${_baseUrl}${path}`, opts);");
1494
+ sections.push(" if (!res.ok) throw new Error(`${method} ${path}: HTTP ${res.status}`);");
1495
+ sections.push(" return res.json() as Promise<T>;");
1496
+ sections.push("}");
1497
+ sections.push("");
1498
+
1499
+ // --- Query key factory ---
1500
+ sections.push("// ── Query Keys ──");
1501
+ sections.push("");
1502
+ sections.push("export const queryKeys = {");
1503
+
1504
+ // Group routes by resource (first path segment after /api/)
1505
+ const resources = new Set<string>();
1506
+ for (const { parsed } of routes) {
1507
+ const parts = parsed.path.split("/").filter(Boolean);
1508
+ // /api/users → "users", /products → "products"
1509
+ const resource = parts[0] === "api" && parts.length >= 2 ? parts[1] : parts[0];
1510
+ resources.add(resource);
1511
+ }
1512
+
1513
+ for (const resource of resources) {
1514
+ sections.push(` ${resource}: {`);
1515
+ sections.push(` all: ["${resource}"] as const,`);
1516
+
1517
+ // Add specific keys for routes in this resource
1518
+ const resourceRoutes = routes.filter(({ parsed }) => {
1519
+ const parts = parsed.path.split("/").filter(Boolean);
1520
+ const r = parts[0] === "api" && parts.length >= 2 ? parts[1] : parts[0];
1521
+ return r === resource;
1522
+ });
1523
+
1524
+ for (const { parsed } of resourceRoutes) {
1525
+ if (parsed.method === "GET") {
1526
+ if (parsed.pathParams.length > 0) {
1527
+ const paramArgs = parsed.pathParams.map((p) => `${p}: string`).join(", ");
1528
+ const paramKeys = parsed.pathParams.map((p) => p).join(", ");
1529
+ sections.push(` detail: (${paramArgs}) => ["${resource}", ${paramKeys}] as const,`);
1530
+ } else {
1531
+ sections.push(` list: () => ["${resource}", "list"] as const,`);
1532
+ }
1533
+ }
1534
+ }
1535
+ sections.push(" },");
1536
+ }
1537
+ sections.push("} as const;");
1538
+ sections.push("");
1539
+
1540
+ // --- Generate hooks ---
1541
+ sections.push("// ── Hooks ──");
1542
+ sections.push("");
1543
+
1544
+ for (const { parsed, fn } of routes) {
1545
+ const baseName = parsed.typeName;
1546
+ const hookName = `use${baseName}`;
1547
+ const responseType = `${baseName}Response`;
1548
+
1549
+ // Determine resource for query keys
1550
+ const parts = parsed.path.split("/").filter(Boolean);
1551
+ const resource = parts[0] === "api" && parts.length >= 2 ? parts[1] : parts[0];
1552
+
1553
+ if (parsed.method === "GET") {
1554
+ // --- useQuery hook ---
1555
+ const hasPathParams = parsed.pathParams.length > 0;
1556
+
1557
+ // Build params
1558
+ const fnParams: string[] = [];
1559
+ if (hasPathParams) {
1560
+ for (const p of parsed.pathParams) {
1561
+ fnParams.push(`${p}: string`);
1562
+ }
1563
+ }
1564
+ fnParams.push(`options?: Omit<UseQueryOptions<${responseType}, Error>, "queryKey" | "queryFn">`);
1565
+
1566
+ // Build path expression
1567
+ let pathExpr: string;
1568
+ if (hasPathParams) {
1569
+ pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${${param}}`) + "`";
1570
+ } else {
1571
+ pathExpr = `"${parsed.path}"`;
1572
+ }
1573
+
1574
+ // Build query key
1575
+ let queryKeyExpr: string;
1576
+ if (hasPathParams) {
1577
+ queryKeyExpr = `queryKeys.${resource}.detail(${parsed.pathParams.join(", ")})`;
1578
+ } else {
1579
+ queryKeyExpr = `queryKeys.${resource}.list()`;
1580
+ }
1581
+
1582
+ sections.push(`/** ${parsed.method} ${parsed.path} */`);
1583
+ sections.push(`export function ${hookName}(${fnParams.join(", ")}) {`);
1584
+ sections.push(` return useQuery({`);
1585
+ sections.push(` queryKey: ${queryKeyExpr},`);
1586
+ sections.push(` queryFn: () => _fetch<${responseType}>("GET", ${pathExpr}),`);
1587
+ sections.push(` ...options,`);
1588
+ sections.push(` });`);
1589
+ sections.push(`}`);
1590
+ sections.push("");
1591
+ } else {
1592
+ // --- useMutation hook ---
1593
+ const hasBody = ["POST", "PUT", "PATCH"].includes(parsed.method);
1594
+ let inputType = "void";
1595
+
1596
+ // Check if we have a typed input
1597
+ if (hasBody) {
1598
+ let bodyNode: TypeNode | undefined;
1599
+ if (fn.argsType.kind === "object") {
1600
+ bodyNode = fn.argsType.properties["body"];
1601
+ } else if (fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1) {
1602
+ const el = fn.argsType.elements[0];
1603
+ if (el.kind === "object") bodyNode = el.properties["body"];
1604
+ }
1605
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
1606
+ inputType = `${baseName}Input`;
1607
+ }
1608
+ }
1609
+
1610
+ // For mutations with path params, create a combined variables type
1611
+ const hasPathParams = parsed.pathParams.length > 0;
1612
+ let variablesType: string;
1613
+ if (hasPathParams && inputType !== "void") {
1614
+ variablesType = `{ ${parsed.pathParams.map((p) => `${p}: string`).join("; ")}; input: ${inputType} }`;
1615
+ } else if (hasPathParams) {
1616
+ variablesType = `{ ${parsed.pathParams.map((p) => `${p}: string`).join("; ")} }`;
1617
+ } else {
1618
+ variablesType = inputType;
1619
+ }
1620
+
1621
+ // Build path expression
1622
+ let pathExpr: string;
1623
+ if (hasPathParams) {
1624
+ pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param) => `\${vars.${param}}`) + "`";
1625
+ } else {
1626
+ pathExpr = `"${parsed.path}"`;
1627
+ }
1628
+
1629
+ // Build body expression
1630
+ let bodyExpr: string;
1631
+ if (hasPathParams && inputType !== "void") {
1632
+ bodyExpr = "vars.input";
1633
+ } else if (inputType !== "void") {
1634
+ bodyExpr = "vars";
1635
+ } else {
1636
+ bodyExpr = "undefined";
1637
+ }
1638
+
1639
+ const optionsType = `UseMutationOptions<${responseType}, Error, ${variablesType}>`;
1640
+
1641
+ sections.push(`/** ${parsed.method} ${parsed.path} */`);
1642
+ sections.push(`export function ${hookName}(options?: Omit<${optionsType}, "mutationFn">) {`);
1643
+ sections.push(` const queryClient = useQueryClient();`);
1644
+ sections.push(` return useMutation({`);
1645
+ if (variablesType === "void") {
1646
+ sections.push(` mutationFn: () => _fetch<${responseType}>("${parsed.method}", ${pathExpr}, ${bodyExpr}),`);
1647
+ } else {
1648
+ sections.push(` mutationFn: (vars: ${variablesType}) => _fetch<${responseType}>("${parsed.method}", ${pathExpr}, ${bodyExpr}),`);
1649
+ }
1650
+ sections.push(` onSuccess: (...args) => {`);
1651
+ sections.push(` queryClient.invalidateQueries({ queryKey: queryKeys.${resource}.all });`);
1652
+ sections.push(` options?.onSuccess?.(...args);`);
1653
+ sections.push(` },`);
1654
+ sections.push(` ...options,`);
1655
+ sections.push(` });`);
1656
+ sections.push(`}`);
1657
+ sections.push("");
1658
+ }
1659
+ }
1660
+
1661
+ return sections.join("\n").trimEnd() + "\n";
1662
+ }
1663
+
1664
+ // ── Type guard generation ──
1665
+
1666
+ /**
1667
+ * Generate a runtime type guard check expression for a TypeNode.
1668
+ * Returns a string that evaluates to boolean when `varName` is the variable.
1669
+ */
1670
+ function typeNodeToGuardCheck(node: TypeNode, varName: string, depth: number = 0): string {
1671
+ if (depth > 4) return "true"; // Limit recursion depth
1672
+
1673
+ switch (node.kind) {
1674
+ case "primitive": {
1675
+ if (node.name === "null") return `${varName} === null`;
1676
+ if (node.name === "undefined") return `${varName} === undefined`;
1677
+ return `typeof ${varName} === "${node.name}"`;
1678
+ }
1679
+
1680
+ case "unknown":
1681
+ return "true";
1682
+
1683
+ case "array": {
1684
+ const elemCheck = typeNodeToGuardCheck(node.element, `${varName}[0]`, depth + 1);
1685
+ if (elemCheck === "true") {
1686
+ return `Array.isArray(${varName})`;
1687
+ }
1688
+ return `(Array.isArray(${varName}) && (${varName}.length === 0 || ${elemCheck}))`;
1689
+ }
1690
+
1691
+ case "tuple": {
1692
+ const checks = [`Array.isArray(${varName})`];
1693
+ checks.push(`${varName}.length === ${node.elements.length}`);
1694
+ node.elements.forEach((el, i) => {
1695
+ const c = typeNodeToGuardCheck(el, `${varName}[${i}]`, depth + 1);
1696
+ if (c !== "true") checks.push(c);
1697
+ });
1698
+ return checks.join(" && ");
1699
+ }
1700
+
1701
+ case "union": {
1702
+ const memberChecks = node.members.map(
1703
+ (m) => typeNodeToGuardCheck(m, varName, depth + 1),
1704
+ );
1705
+ return `(${memberChecks.join(" || ")})`;
1706
+ }
1707
+
1708
+ case "object": {
1709
+ const keys = Object.keys(node.properties);
1710
+ if (keys.length === 0) {
1711
+ return `(typeof ${varName} === "object" && ${varName} !== null)`;
1712
+ }
1713
+
1714
+ const checks: string[] = [
1715
+ `typeof ${varName} === "object"`,
1716
+ `${varName} !== null`,
1717
+ ];
1718
+
1719
+ for (const key of keys) {
1720
+ checks.push(`"${key}" in ${varName}`);
1721
+ if (depth < 3) {
1722
+ const propCheck = typeNodeToGuardCheck(
1723
+ node.properties[key],
1724
+ `(${varName} as any).${key}`,
1725
+ depth + 1,
1726
+ );
1727
+ if (propCheck !== "true") {
1728
+ checks.push(propCheck);
1729
+ }
1730
+ }
1731
+ }
1732
+
1733
+ return checks.join(" && ");
1734
+ }
1735
+
1736
+ case "map":
1737
+ return `${varName} instanceof Map`;
1738
+
1739
+ case "set":
1740
+ return `${varName} instanceof Set`;
1741
+
1742
+ case "promise":
1743
+ return `${varName} instanceof Promise`;
1744
+
1745
+ case "function":
1746
+ return `typeof ${varName} === "function"`;
1747
+ }
1748
+ }
1749
+
1750
+ /**
1751
+ * Generate TypeScript type guard functions from runtime-observed types.
1752
+ *
1753
+ * For each route:
1754
+ * - `isGetApiUsersResponse(value): value is GetApiUsersResponse`
1755
+ * - `isPostApiUsersRequest(value): value is PostApiUsersRequest`
1756
+ *
1757
+ * Type guards perform structural checks: verify typeof, key existence,
1758
+ * array shapes, and nested object structure.
1759
+ */
1760
+ export function generateTypeGuards(
1761
+ functions: Array<{
1762
+ name: string;
1763
+ argsType: TypeNode;
1764
+ returnType: TypeNode;
1765
+ module?: string;
1766
+ env?: string;
1767
+ observedAt?: string;
1768
+ }>,
1769
+ ): string {
1770
+ if (functions.length === 0) {
1771
+ return "// No functions found. Instrument your app to generate type guards.\n";
1772
+ }
1773
+
1774
+ const sections: string[] = [];
1775
+ sections.push("// Auto-generated type guards by trickle");
1776
+ sections.push(`// Generated at ${new Date().toISOString()}`);
1777
+ sections.push("// Do not edit manually — re-run `trickle codegen --guards` to update");
1778
+ sections.push("");
1779
+
1780
+ // First, collect all interfaces we'll need to reference
1781
+ const interfaces: string[] = [];
1782
+ const guards: string[] = [];
1783
+
1784
+ for (const fn of functions) {
1785
+ const parsed = parseRouteName(fn.name);
1786
+
1787
+ if (parsed) {
1788
+ const basePascal = parsed.typeName;
1789
+ const baseCamel = toCamelCase(basePascal);
1790
+
1791
+ // Response type + guard
1792
+ const responseTypeName = `${basePascal}Response`;
1793
+ const responseInterface = generateGuardInterface(responseTypeName, fn.returnType);
1794
+ if (responseInterface) interfaces.push(responseInterface);
1795
+
1796
+ const responseCheck = typeNodeToGuardCheck(fn.returnType, "value");
1797
+ guards.push(`/** Type guard for ${parsed.method} ${parsed.path} response */`);
1798
+ guards.push(`export function is${responseTypeName}(value: unknown): value is ${responseTypeName} {`);
1799
+ guards.push(` return ${responseCheck};`);
1800
+ guards.push(`}`);
1801
+ guards.push("");
1802
+
1803
+ // Request body type + guard (for POST/PUT/PATCH)
1804
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
1805
+ let bodyNode: TypeNode | undefined;
1806
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
1807
+ bodyNode = fn.argsType.properties["body"];
1808
+ }
1809
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
1810
+ const requestTypeName = `${basePascal}Request`;
1811
+ const requestInterface = generateGuardInterface(requestTypeName, bodyNode);
1812
+ if (requestInterface) interfaces.push(requestInterface);
1813
+
1814
+ const requestCheck = typeNodeToGuardCheck(bodyNode, "value");
1815
+ guards.push(`/** Type guard for ${parsed.method} ${parsed.path} request body */`);
1816
+ guards.push(`export function is${requestTypeName}(value: unknown): value is ${requestTypeName} {`);
1817
+ guards.push(` return ${requestCheck};`);
1818
+ guards.push(`}`);
1819
+ guards.push("");
1820
+ }
1821
+ }
1822
+ } else {
1823
+ // Non-route function
1824
+ const basePascal = toPascalCase(fn.name);
1825
+
1826
+ // Output guard
1827
+ const outputTypeName = `${basePascal}Output`;
1828
+ const outputInterface = generateGuardInterface(outputTypeName, fn.returnType);
1829
+ if (outputInterface) interfaces.push(outputInterface);
1830
+
1831
+ const outputCheck = typeNodeToGuardCheck(fn.returnType, "value");
1832
+ guards.push(`/** Type guard for ${fn.name} output */`);
1833
+ guards.push(`export function is${outputTypeName}(value: unknown): value is ${outputTypeName} {`);
1834
+ guards.push(` return ${outputCheck};`);
1835
+ guards.push(`}`);
1836
+ guards.push("");
1837
+
1838
+ // Input guard (if non-trivial)
1839
+ if (fn.argsType.kind !== "unknown" && fn.argsType.kind !== "object" || (fn.argsType.kind === "object" && Object.keys(fn.argsType.properties).length > 0)) {
1840
+ const inputTypeName = `${basePascal}Input`;
1841
+ const inputNode = fn.argsType.kind === "tuple" && fn.argsType.elements.length === 1
1842
+ ? fn.argsType.elements[0]
1843
+ : fn.argsType;
1844
+ const inputInterface = generateGuardInterface(inputTypeName, inputNode);
1845
+ if (inputInterface) interfaces.push(inputInterface);
1846
+
1847
+ const inputCheck = typeNodeToGuardCheck(inputNode, "value");
1848
+ guards.push(`/** Type guard for ${fn.name} input */`);
1849
+ guards.push(`export function is${inputTypeName}(value: unknown): value is ${inputTypeName} {`);
1850
+ guards.push(` return ${inputCheck};`);
1851
+ guards.push(`}`);
1852
+ guards.push("");
1853
+ }
1854
+ }
1855
+ }
1856
+
1857
+ // Output: interfaces first, then guards
1858
+ sections.push("// ── Type Interfaces ──");
1859
+ sections.push("");
1860
+ sections.push(...interfaces);
1861
+ sections.push("// ── Type Guards ──");
1862
+ sections.push("");
1863
+ sections.push(...guards);
1864
+
1865
+ return sections.join("\n").trimEnd() + "\n";
1866
+ }
1867
+
1868
+ /**
1869
+ * Generate a simple interface declaration for use with type guards.
1870
+ */
1871
+ function generateGuardInterface(name: string, node: TypeNode): string | null {
1872
+ if (node.kind !== "object") {
1873
+ // For non-object types, generate a type alias
1874
+ const extracted: ExtractedInterface[] = [];
1875
+ const tsType = typeNodeToTS(node, extracted, name, undefined, 0);
1876
+ return `export type ${name} = ${tsType};\n`;
1877
+ }
1878
+
1879
+ const keys = Object.keys(node.properties);
1880
+ if (keys.length === 0) return `export type ${name} = Record<string, unknown>;\n`;
1881
+
1882
+ const extracted: ExtractedInterface[] = [];
1883
+ const lines: string[] = [];
1884
+ lines.push(`export interface ${name} {`);
1885
+ for (const key of keys) {
1886
+ const val = typeNodeToTS(node.properties[key], extracted, name, key, 1);
1887
+ lines.push(` ${key}: ${val};`);
1888
+ }
1889
+ lines.push(`}`);
1890
+
1891
+ // Include any extracted sub-interfaces
1892
+ const subInterfaces: string[] = [];
1893
+ for (const ext of extracted) {
1894
+ if (ext.node.kind === "object") {
1895
+ subInterfaces.push(renderInterface(ext.name, ext.node, extracted));
1896
+ }
1897
+ }
1898
+
1899
+ return [...subInterfaces, lines.join("\n")].join("\n\n") + "\n";
1900
+ }
1901
+
1902
+ // ── Express validation middleware generation ──
1903
+
1904
+ /**
1905
+ * Generate a validation expression that returns an array of error strings.
1906
+ * `varName` is the variable being checked.
1907
+ */
1908
+ function typeNodeToValidation(node: TypeNode, varName: string, path: string, depth: number = 0): string[] {
1909
+ if (depth > 3) return [];
1910
+ const checks: string[] = [];
1911
+
1912
+ switch (node.kind) {
1913
+ case "primitive": {
1914
+ if (node.name === "null") {
1915
+ checks.push(`if (${varName} !== null) errors.push(\`${path} must be null\`);`);
1916
+ } else if (node.name === "undefined") {
1917
+ // Don't validate undefined — field just shouldn't be required
1918
+ } else {
1919
+ checks.push(`if (typeof ${varName} !== "${node.name}") errors.push(\`${path} must be a ${node.name}\`);`);
1920
+ }
1921
+ break;
1922
+ }
1923
+
1924
+ case "array": {
1925
+ checks.push(`if (!Array.isArray(${varName})) errors.push(\`${path} must be an array\`);`);
1926
+ if (node.element.kind !== "unknown" && depth < 3) {
1927
+ checks.push(`else if (${varName}.length > 0) {`);
1928
+ checks.push(...typeNodeToValidation(node.element, `${varName}[0]`, `${path}[0]`, depth + 1).map(l => " " + l));
1929
+ checks.push(`}`);
1930
+ }
1931
+ break;
1932
+ }
1933
+
1934
+ case "object": {
1935
+ const keys = Object.keys(node.properties);
1936
+ checks.push(`if (typeof ${varName} !== "object" || ${varName} === null) errors.push(\`${path} must be an object\`);`);
1937
+ if (keys.length > 0 && depth < 3) {
1938
+ checks.push(`else {`);
1939
+ for (const key of keys) {
1940
+ const propPath = path ? `${path}.${key}` : key;
1941
+ const propAccess = `${varName}["${key}"]`;
1942
+ // Check key existence
1943
+ checks.push(` if (!("${key}" in ${varName})) errors.push(\`${propPath} is required\`);`);
1944
+ // Type check if present
1945
+ const innerChecks = typeNodeToValidation(node.properties[key], propAccess, propPath, depth + 1);
1946
+ if (innerChecks.length > 0) {
1947
+ checks.push(` else {`);
1948
+ checks.push(...innerChecks.map(l => " " + l));
1949
+ checks.push(` }`);
1950
+ }
1951
+ }
1952
+ checks.push(`}`);
1953
+ }
1954
+ break;
1955
+ }
1956
+
1957
+ case "union": {
1958
+ // For unions, value must match at least one member — skip detailed validation
1959
+ break;
1960
+ }
1961
+
1962
+ default:
1963
+ break;
1964
+ }
1965
+
1966
+ return checks;
1967
+ }
1968
+
1969
+ /**
1970
+ * Generate Express validation middleware from runtime-observed types.
1971
+ *
1972
+ * For each POST/PUT/PATCH route, generates middleware that:
1973
+ * - Validates request body structure and types
1974
+ * - Returns 400 with structured errors on failure
1975
+ * - Passes through to next() on success
1976
+ *
1977
+ * Self-contained — no external validation library required.
1978
+ */
1979
+ export function generateMiddleware(
1980
+ functions: Array<{
1981
+ name: string;
1982
+ argsType: TypeNode;
1983
+ returnType: TypeNode;
1984
+ module?: string;
1985
+ env?: string;
1986
+ observedAt?: string;
1987
+ }>,
1988
+ ): string {
1989
+ // Filter to routes with request bodies
1990
+ const routes: Array<{
1991
+ parsed: ParsedRoute;
1992
+ fn: (typeof functions)[number];
1993
+ bodyNode: TypeNode;
1994
+ }> = [];
1995
+
1996
+ for (const fn of functions) {
1997
+ const parsed = parseRouteName(fn.name);
1998
+ if (!parsed) continue;
1999
+ if (!["POST", "PUT", "PATCH"].includes(parsed.method)) continue;
2000
+
2001
+ let bodyNode: TypeNode | undefined;
2002
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
2003
+ bodyNode = fn.argsType.properties["body"];
2004
+ }
2005
+ if (!bodyNode || bodyNode.kind === "unknown") continue;
2006
+ if (bodyNode.kind === "object" && Object.keys(bodyNode.properties).length === 0) continue;
2007
+
2008
+ routes.push({ parsed, fn, bodyNode });
2009
+ }
2010
+
2011
+ if (routes.length === 0) {
2012
+ return "// No POST/PUT/PATCH routes with request bodies found.\n// Instrument your app and make some requests first.\n";
2013
+ }
2014
+
2015
+ const sections: string[] = [];
2016
+ sections.push("// Auto-generated Express validation middleware by trickle");
2017
+ sections.push(`// Generated at ${new Date().toISOString()}`);
2018
+ sections.push("// Do not edit manually — re-run `trickle codegen --middleware` to update");
2019
+ sections.push("");
2020
+ sections.push('import { Request, Response, NextFunction } from "express";');
2021
+ sections.push("");
2022
+
2023
+ // Generate interfaces for request body types
2024
+ const extracted: ExtractedInterface[] = [];
2025
+
2026
+ for (const { parsed, bodyNode } of routes) {
2027
+ const typeName = `${parsed.typeName}Body`;
2028
+
2029
+ if (bodyNode.kind === "object") {
2030
+ sections.push(renderInterface(typeName, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
2031
+ } else {
2032
+ const tsType = typeNodeToTS(bodyNode, extracted, typeName, undefined, 0);
2033
+ sections.push(`export type ${typeName} = ${tsType};`);
2034
+ }
2035
+ sections.push("");
2036
+ }
2037
+
2038
+ // Render any extracted sub-interfaces
2039
+ for (const ext of extracted) {
2040
+ if (ext.node.kind === "object") {
2041
+ sections.push(renderInterface(ext.name, ext.node, extracted));
2042
+ sections.push("");
2043
+ }
2044
+ }
2045
+
2046
+ // Generate validation middleware for each route
2047
+ for (const { parsed, bodyNode } of routes) {
2048
+ const middlewareName = `validate${parsed.typeName}`;
2049
+ const typeName = `${parsed.typeName}Body`;
2050
+
2051
+ sections.push(`/**`);
2052
+ sections.push(` * Validates request body for ${parsed.method} ${parsed.path}`);
2053
+ sections.push(` * Returns 400 with structured errors if validation fails.`);
2054
+ sections.push(` */`);
2055
+ sections.push(`export function ${middlewareName}(req: Request, res: Response, next: NextFunction): void {`);
2056
+ sections.push(` const errors: string[] = [];`);
2057
+ sections.push(` const body = req.body;`);
2058
+ sections.push("");
2059
+
2060
+ // Null/undefined check
2061
+ sections.push(` if (body === null || body === undefined || typeof body !== "object") {`);
2062
+ sections.push(` res.status(400).json({ error: "Request body is required", errors: ["body must be an object"] });`);
2063
+ sections.push(` return;`);
2064
+ sections.push(` }`);
2065
+ sections.push("");
2066
+
2067
+ // Generate field validations
2068
+ if (bodyNode.kind === "object") {
2069
+ const keys = Object.keys(bodyNode.properties);
2070
+ for (const key of keys) {
2071
+ const propNode = bodyNode.properties[key];
2072
+ sections.push(` // Validate ${key}`);
2073
+ sections.push(` if (!("${key}" in body)) {`);
2074
+ sections.push(` errors.push("${key} is required");`);
2075
+ sections.push(` }`);
2076
+
2077
+ const innerChecks = typeNodeToValidation(propNode, `body["${key}"]`, key, 1);
2078
+ if (innerChecks.length > 0) {
2079
+ sections.push(` else {`);
2080
+ for (const check of innerChecks) {
2081
+ sections.push(` ${check}`);
2082
+ }
2083
+ sections.push(` }`);
2084
+ }
2085
+ sections.push("");
2086
+ }
2087
+ }
2088
+
2089
+ sections.push(` if (errors.length > 0) {`);
2090
+ sections.push(` res.status(400).json({ error: "Validation failed", errors });`);
2091
+ sections.push(` return;`);
2092
+ sections.push(` }`);
2093
+ sections.push("");
2094
+ sections.push(` next();`);
2095
+ sections.push(`}`);
2096
+ sections.push("");
2097
+ }
2098
+
2099
+ // Export a combined middleware map for convenience
2100
+ sections.push("/** Map of route patterns to their validation middleware */");
2101
+ sections.push("export const validators = {");
2102
+ for (const { parsed } of routes) {
2103
+ const middlewareName = `validate${parsed.typeName}`;
2104
+ sections.push(` "${parsed.method} ${parsed.path}": ${middlewareName},`);
2105
+ }
2106
+ sections.push("} as const;");
2107
+ sections.push("");
2108
+
2109
+ return sections.join("\n").trimEnd() + "\n";
2110
+ }
2111
+
2112
+ // ── MSW Handler Generation ──
2113
+
2114
+ /**
2115
+ * Generate a sample value literal string from a TypeNode.
2116
+ * Used to create realistic mock responses in MSW handlers.
2117
+ */
2118
+ function typeNodeToSampleLiteral(node: TypeNode, indent: number = 0): string {
2119
+ const pad = " ".repeat(indent);
2120
+ const innerPad = " ".repeat(indent + 1);
2121
+
2122
+ switch (node.kind) {
2123
+ case "primitive":
2124
+ switch (node.name) {
2125
+ case "string": return '""';
2126
+ case "number": return "0";
2127
+ case "boolean": return "true";
2128
+ case "null": return "null";
2129
+ case "undefined": return "undefined";
2130
+ case "bigint": return "0";
2131
+ case "symbol": return '"symbol"';
2132
+ default: return "null";
2133
+ }
2134
+
2135
+ case "array":
2136
+ return `[${typeNodeToSampleLiteral(node.element, indent)}]`;
2137
+
2138
+ case "tuple": {
2139
+ const elements = node.elements.map((el) => typeNodeToSampleLiteral(el, indent));
2140
+ return `[${elements.join(", ")}]`;
2141
+ }
2142
+
2143
+ case "object": {
2144
+ const keys = Object.keys(node.properties);
2145
+ if (keys.length === 0) return "{}";
2146
+ const entries = keys.map(
2147
+ (key) => `${innerPad}${key}: ${typeNodeToSampleLiteral(node.properties[key], indent + 1)}`
2148
+ );
2149
+ return `{\n${entries.join(",\n")}\n${pad}}`;
2150
+ }
2151
+
2152
+ case "union":
2153
+ // Use the first non-null/undefined member
2154
+ for (const m of node.members) {
2155
+ if (m.kind === "primitive" && (m.name === "null" || m.name === "undefined")) continue;
2156
+ return typeNodeToSampleLiteral(m, indent);
2157
+ }
2158
+ return "null";
2159
+
2160
+ case "map":
2161
+ return "new Map()";
2162
+
2163
+ case "set":
2164
+ return "new Set()";
2165
+
2166
+ case "promise":
2167
+ return typeNodeToSampleLiteral(node.resolved, indent);
2168
+
2169
+ case "function":
2170
+ return "() => {}";
2171
+
2172
+ case "unknown":
2173
+ return "null";
2174
+
2175
+ default:
2176
+ return "null";
2177
+ }
2178
+ }
2179
+
2180
+ /**
2181
+ * Generate Mock Service Worker (MSW) request handlers from observed API routes.
2182
+ *
2183
+ * Output:
2184
+ * - Import from 'msw'
2185
+ * - Response type interfaces for each route
2186
+ * - Individual handler exports (e.g. getApiUsersHandler)
2187
+ * - A combined `handlers` array for setupServer/setupWorker
2188
+ */
2189
+ export function generateMswHandlers(
2190
+ functions: Array<{
2191
+ name: string;
2192
+ argsType: TypeNode;
2193
+ returnType: TypeNode;
2194
+ module?: string;
2195
+ env?: string;
2196
+ observedAt?: string;
2197
+ }>,
2198
+ ): string {
2199
+ const routes: Array<{
2200
+ parsed: ParsedRoute;
2201
+ fn: (typeof functions)[number];
2202
+ }> = [];
2203
+
2204
+ for (const fn of functions) {
2205
+ const parsed = parseRouteName(fn.name);
2206
+ if (parsed) {
2207
+ routes.push({ parsed, fn });
2208
+ }
2209
+ }
2210
+
2211
+ if (routes.length === 0) {
2212
+ return "// No API routes found. Instrument your Express app to generate MSW handlers.\n";
2213
+ }
2214
+
2215
+ const sections: string[] = [];
2216
+ sections.push("// Auto-generated MSW request handlers by trickle");
2217
+ sections.push(`// Generated at ${new Date().toISOString()}`);
2218
+ sections.push("// Do not edit manually — re-run `trickle codegen --msw` to update");
2219
+ sections.push("");
2220
+ sections.push('import { http, HttpResponse } from "msw";');
2221
+ sections.push("");
2222
+
2223
+ // Generate response type interfaces
2224
+ const extracted: ExtractedInterface[] = [];
2225
+
2226
+ for (const { parsed, fn } of routes) {
2227
+ const responseName = `${parsed.typeName}Response`;
2228
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
2229
+ sections.push(renderInterface(responseName, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
2230
+ } else {
2231
+ const tsType = typeNodeToTS(fn.returnType, extracted, responseName, undefined, 0);
2232
+ sections.push(`export type ${responseName} = ${tsType};`);
2233
+ }
2234
+ sections.push("");
2235
+ }
2236
+
2237
+ // Render extracted sub-interfaces
2238
+ for (const ext of extracted) {
2239
+ if (ext.node.kind === "object") {
2240
+ sections.push(renderInterface(ext.name, ext.node, extracted));
2241
+ sections.push("");
2242
+ }
2243
+ }
2244
+
2245
+ // Generate individual handler exports
2246
+ const handlerNames: string[] = [];
2247
+
2248
+ for (const { parsed, fn } of routes) {
2249
+ const handlerName = `${parsed.funcName}Handler`;
2250
+ handlerNames.push(handlerName);
2251
+ const method = parsed.method.toLowerCase();
2252
+
2253
+ // Convert Express-style :param to MSW-style :param (same format, already compatible)
2254
+ const mswPath = parsed.path;
2255
+
2256
+ // Generate sample response from returnType
2257
+ const sampleResponse = typeNodeToSampleLiteral(fn.returnType, 1);
2258
+
2259
+ sections.push(`/**`);
2260
+ sections.push(` * Mock handler for ${parsed.method} ${parsed.path}`);
2261
+ sections.push(` */`);
2262
+ sections.push(`export const ${handlerName} = http.${method}("${mswPath}", () => {`);
2263
+ sections.push(` return HttpResponse.json(${sampleResponse} satisfies ${parsed.typeName}Response);`);
2264
+ sections.push(`});`);
2265
+ sections.push("");
2266
+ }
2267
+
2268
+ // Export combined handlers array
2269
+ sections.push("/** All mock handlers — use with setupServer(...handlers) or setupWorker(...handlers) */");
2270
+ sections.push("export const handlers = [");
2271
+ for (const name of handlerNames) {
2272
+ sections.push(` ${name},`);
2273
+ }
2274
+ sections.push("];");
2275
+ sections.push("");
2276
+
2277
+ return sections.join("\n").trimEnd() + "\n";
2278
+ }
2279
+
2280
+ // ── JSON Schema Generation ──
2281
+
2282
+ /**
2283
+ * Convert a TypeNode to a standalone JSON Schema (Draft 2020-12).
2284
+ * Unlike the OpenAPI variant, this produces proper JSON Schema with
2285
+ * $defs, oneOf, prefixItems, and nullable via type arrays.
2286
+ */
2287
+ function typeNodeToStandaloneSchema(node: TypeNode): Record<string, unknown> {
2288
+ switch (node.kind) {
2289
+ case "primitive":
2290
+ switch (node.name) {
2291
+ case "string": return { type: "string" };
2292
+ case "number": return { type: "number" };
2293
+ case "boolean": return { type: "boolean" };
2294
+ case "null": return { type: "null" };
2295
+ case "undefined": return { type: "null" };
2296
+ case "bigint": return { type: "integer" };
2297
+ case "symbol": return { type: "string" };
2298
+ default: return {};
2299
+ }
2300
+
2301
+ case "array":
2302
+ return { type: "array", items: typeNodeToStandaloneSchema(node.element) };
2303
+
2304
+ case "tuple":
2305
+ return {
2306
+ type: "array",
2307
+ prefixItems: node.elements.map(typeNodeToStandaloneSchema),
2308
+ minItems: node.elements.length,
2309
+ maxItems: node.elements.length,
2310
+ };
2311
+
2312
+ case "object": {
2313
+ const properties: Record<string, unknown> = {};
2314
+ const required: string[] = [];
2315
+ for (const [key, val] of Object.entries(node.properties)) {
2316
+ properties[key] = typeNodeToStandaloneSchema(val);
2317
+ required.push(key);
2318
+ }
2319
+ const schema: Record<string, unknown> = { type: "object", properties };
2320
+ if (required.length > 0) schema.required = required;
2321
+ return schema;
2322
+ }
2323
+
2324
+ case "union": {
2325
+ const nonNull = node.members.filter(
2326
+ (m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined"))
2327
+ );
2328
+ const hasNull = nonNull.length < node.members.length;
2329
+
2330
+ if (nonNull.length === 1) {
2331
+ const inner = typeNodeToStandaloneSchema(nonNull[0]);
2332
+ if (hasNull) {
2333
+ if (typeof inner.type === "string") {
2334
+ return { ...inner, type: [inner.type, "null"] };
2335
+ }
2336
+ return { oneOf: [inner, { type: "null" }] };
2337
+ }
2338
+ return inner;
2339
+ }
2340
+
2341
+ const schemas = nonNull.map(typeNodeToStandaloneSchema);
2342
+ if (hasNull) schemas.push({ type: "null" });
2343
+ return { oneOf: schemas };
2344
+ }
2345
+
2346
+ case "map":
2347
+ return {
2348
+ type: "object",
2349
+ additionalProperties: typeNodeToStandaloneSchema(node.value),
2350
+ };
2351
+
2352
+ case "set":
2353
+ return {
2354
+ type: "array",
2355
+ uniqueItems: true,
2356
+ items: typeNodeToStandaloneSchema(node.element),
2357
+ };
2358
+
2359
+ case "promise":
2360
+ return typeNodeToStandaloneSchema(node.resolved);
2361
+
2362
+ case "function":
2363
+ return {};
2364
+
2365
+ case "unknown":
2366
+ return {};
2367
+
2368
+ default:
2369
+ return {};
2370
+ }
2371
+ }
2372
+
2373
+ /**
2374
+ * Generate JSON Schema definitions from observed runtime types.
2375
+ *
2376
+ * Output is a single JSON object with $defs for each route/function's
2377
+ * request and response types, suitable for use with ajv, joi, or any
2378
+ * JSON Schema-compatible validator.
2379
+ */
2380
+ export function generateJsonSchemas(
2381
+ functions: Array<{
2382
+ name: string;
2383
+ argsType: TypeNode;
2384
+ returnType: TypeNode;
2385
+ module?: string;
2386
+ env?: string;
2387
+ observedAt?: string;
2388
+ }>,
2389
+ ): string {
2390
+ const defs: Record<string, unknown> = {};
2391
+
2392
+ for (const fn of functions) {
2393
+ const parsed = parseRouteName(fn.name);
2394
+ const baseName = parsed ? parsed.typeName : toPascalCase(fn.name);
2395
+
2396
+ // For routes, generate request body + response schemas
2397
+ if (parsed) {
2398
+ // Request body (only for methods with body)
2399
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
2400
+ let bodyNode: TypeNode | undefined;
2401
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
2402
+ bodyNode = fn.argsType.properties["body"];
2403
+ }
2404
+ if (bodyNode && bodyNode.kind !== "unknown") {
2405
+ const bodySchema = typeNodeToStandaloneSchema(bodyNode);
2406
+ defs[`${baseName}Request`] = {
2407
+ description: `Request body for ${parsed.method} ${parsed.path}`,
2408
+ ...bodySchema,
2409
+ };
2410
+ }
2411
+ }
2412
+
2413
+ // Response
2414
+ if (fn.returnType.kind !== "unknown") {
2415
+ const responseSchema = typeNodeToStandaloneSchema(fn.returnType);
2416
+ defs[`${baseName}Response`] = {
2417
+ description: `Response for ${parsed.method} ${parsed.path}`,
2418
+ ...responseSchema,
2419
+ };
2420
+ }
2421
+ } else {
2422
+ // Non-route function: generate input/output schemas
2423
+ if (fn.argsType.kind !== "unknown") {
2424
+ defs[`${baseName}Input`] = typeNodeToStandaloneSchema(fn.argsType);
2425
+ }
2426
+ if (fn.returnType.kind !== "unknown") {
2427
+ defs[`${baseName}Output`] = typeNodeToStandaloneSchema(fn.returnType);
2428
+ }
2429
+ }
2430
+ }
2431
+
2432
+ const schema = {
2433
+ $schema: "https://json-schema.org/draft/2020-12/schema",
2434
+ title: "API Schemas",
2435
+ description: "Auto-generated JSON Schema definitions by trickle",
2436
+ $defs: defs,
2437
+ };
2438
+
2439
+ return JSON.stringify(schema, null, 2) + "\n";
2440
+ }
2441
+
2442
+ // ── SWR Hook Generation ──
2443
+
2444
+ /**
2445
+ * Generate typed SWR hooks from observed API routes.
2446
+ *
2447
+ * Output:
2448
+ * - Import from 'swr' and 'swr/mutation'
2449
+ * - Response/input type interfaces
2450
+ * - A configurable fetcher
2451
+ * - useSWR hooks for GET routes
2452
+ * - useSWRMutation hooks for POST/PUT/PATCH/DELETE routes
2453
+ */
2454
+ export function generateSwrHooks(
2455
+ functions: Array<{
2456
+ name: string;
2457
+ argsType: TypeNode;
2458
+ returnType: TypeNode;
2459
+ module?: string;
2460
+ env?: string;
2461
+ observedAt?: string;
2462
+ }>,
2463
+ ): string {
2464
+ const routes: Array<{
2465
+ parsed: ParsedRoute;
2466
+ fn: (typeof functions)[number];
2467
+ }> = [];
2468
+
2469
+ for (const fn of functions) {
2470
+ const parsed = parseRouteName(fn.name);
2471
+ if (parsed) {
2472
+ routes.push({ parsed, fn });
2473
+ }
2474
+ }
2475
+
2476
+ if (routes.length === 0) {
2477
+ return "// No API routes found. Instrument your Express/FastAPI app to generate SWR hooks.\n";
2478
+ }
2479
+
2480
+ const sections: string[] = [];
2481
+ sections.push("// Auto-generated SWR hooks by trickle");
2482
+ sections.push(`// Generated at ${new Date().toISOString()}`);
2483
+ sections.push("// Do not edit manually — re-run `trickle codegen --swr` to update");
2484
+ sections.push("");
2485
+ sections.push('import useSWR from "swr";');
2486
+ sections.push('import useSWRMutation from "swr/mutation";');
2487
+ sections.push('import type { SWRConfiguration, SWRResponse } from "swr";');
2488
+ sections.push('import type { SWRMutationConfiguration, SWRMutationResponse } from "swr/mutation";');
2489
+ sections.push("");
2490
+
2491
+ // Generate interfaces
2492
+ const extracted: ExtractedInterface[] = [];
2493
+
2494
+ for (const { parsed, fn } of routes) {
2495
+ const baseName = parsed.typeName;
2496
+
2497
+ // Response type
2498
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
2499
+ sections.push(renderInterface(`${baseName}Response`, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
2500
+ } else {
2501
+ const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
2502
+ sections.push(`export type ${baseName}Response = ${retStr};`);
2503
+ }
2504
+ sections.push("");
2505
+
2506
+ // Request body type (POST/PUT/PATCH)
2507
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
2508
+ let bodyNode: TypeNode | undefined;
2509
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
2510
+ bodyNode = fn.argsType.properties["body"];
2511
+ }
2512
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
2513
+ sections.push(renderInterface(`${baseName}Input`, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
2514
+ sections.push("");
2515
+ }
2516
+ }
2517
+ }
2518
+
2519
+ // Emit extracted sub-interfaces
2520
+ const emitted = new Set<string>();
2521
+ let cursor = 0;
2522
+ while (cursor < extracted.length) {
2523
+ const iface = extracted[cursor];
2524
+ cursor++;
2525
+ if (emitted.has(iface.name)) continue;
2526
+ emitted.add(iface.name);
2527
+ sections.push(renderInterface(iface.name, iface.node, extracted));
2528
+ sections.push("");
2529
+ }
2530
+
2531
+ // Fetcher setup
2532
+ sections.push("// ── Fetcher ──");
2533
+ sections.push("");
2534
+ sections.push("let _baseUrl = \"\";");
2535
+ sections.push("");
2536
+ sections.push("/** Set the base URL for all API requests. Call once at app startup. */");
2537
+ sections.push("export function configureSwrHooks(baseUrl: string) {");
2538
+ sections.push(" _baseUrl = baseUrl;");
2539
+ sections.push("}");
2540
+ sections.push("");
2541
+ sections.push("const fetcher = <T>(path: string): Promise<T> =>");
2542
+ sections.push(" fetch(`${_baseUrl}${path}`).then((res) => {");
2543
+ sections.push(" if (!res.ok) throw new Error(`GET ${path}: HTTP ${res.status}`);");
2544
+ sections.push(" return res.json() as Promise<T>;");
2545
+ sections.push(" });");
2546
+ sections.push("");
2547
+ sections.push("async function mutationFetcher<T>(");
2548
+ sections.push(" url: string,");
2549
+ sections.push(" { arg }: { arg: { method: string; body?: unknown } },");
2550
+ sections.push("): Promise<T> {");
2551
+ sections.push(" const opts: RequestInit = {");
2552
+ sections.push(" method: arg.method,");
2553
+ sections.push(' headers: { "Content-Type": "application/json" },');
2554
+ sections.push(" };");
2555
+ sections.push(" if (arg.body !== undefined) opts.body = JSON.stringify(arg.body);");
2556
+ sections.push(" const res = await fetch(`${_baseUrl}${url}`, opts);");
2557
+ sections.push(" if (!res.ok) throw new Error(`${arg.method} ${url}: HTTP ${res.status}`);");
2558
+ sections.push(" return res.json() as Promise<T>;");
2559
+ sections.push("}");
2560
+ sections.push("");
2561
+
2562
+ // Generate hooks
2563
+ sections.push("// ── Hooks ──");
2564
+ sections.push("");
2565
+
2566
+ for (const { parsed, fn } of routes) {
2567
+ const baseName = parsed.typeName;
2568
+ const hookName = `use${baseName}`;
2569
+ const responseType = `${baseName}Response`;
2570
+
2571
+ if (parsed.method === "GET") {
2572
+ // useSWR hook
2573
+ const hasPathParams = parsed.pathParams.length > 0;
2574
+ const fnParams: string[] = [];
2575
+
2576
+ if (hasPathParams) {
2577
+ for (const p of parsed.pathParams) {
2578
+ fnParams.push(`${p}: string`);
2579
+ }
2580
+ }
2581
+ fnParams.push(`config?: SWRConfiguration<${responseType}, Error>`);
2582
+
2583
+ let pathExpr: string;
2584
+ if (hasPathParams) {
2585
+ pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param: string) => `\${${param}}`) + "`";
2586
+ } else {
2587
+ pathExpr = `"${parsed.path}"`;
2588
+ }
2589
+
2590
+ sections.push(`/** ${parsed.method} ${parsed.path} */`);
2591
+ sections.push(`export function ${hookName}(${fnParams.join(", ")}): SWRResponse<${responseType}, Error> {`);
2592
+ sections.push(` return useSWR<${responseType}, Error>(${pathExpr}, fetcher<${responseType}>, config);`);
2593
+ sections.push("}");
2594
+ sections.push("");
2595
+ } else {
2596
+ // useSWRMutation hook for POST/PUT/PATCH/DELETE
2597
+ const method = parsed.method;
2598
+ const hasPathParams = parsed.pathParams.length > 0;
2599
+ const fnParams: string[] = [];
2600
+
2601
+ if (hasPathParams) {
2602
+ for (const p of parsed.pathParams) {
2603
+ fnParams.push(`${p}: string`);
2604
+ }
2605
+ }
2606
+
2607
+ // Check if route has input body
2608
+ let inputType: string | undefined;
2609
+ if (["POST", "PUT", "PATCH"].includes(method)) {
2610
+ let bodyNode: TypeNode | undefined;
2611
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
2612
+ bodyNode = fn.argsType.properties["body"];
2613
+ }
2614
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
2615
+ inputType = `${baseName}Input`;
2616
+ }
2617
+ }
2618
+
2619
+ const triggerArgType = inputType || "void";
2620
+ fnParams.push(`config?: SWRMutationConfiguration<${responseType}, Error, string, ${triggerArgType}>`);
2621
+
2622
+ let pathExpr: string;
2623
+ if (hasPathParams) {
2624
+ pathExpr = "`" + parsed.path.replace(/:(\w+)/g, (_, param: string) => `\${${param}}`) + "`";
2625
+ } else {
2626
+ pathExpr = `"${parsed.path}"`;
2627
+ }
2628
+
2629
+ sections.push(`/** ${method} ${parsed.path} */`);
2630
+ sections.push(`export function ${hookName}(${fnParams.join(", ")}): SWRMutationResponse<${responseType}, Error, string, ${triggerArgType}> {`);
2631
+ sections.push(` return useSWRMutation<${responseType}, Error, string, ${triggerArgType}>(`);
2632
+ sections.push(` ${pathExpr},`);
2633
+
2634
+ if (inputType) {
2635
+ sections.push(` (url, { arg }) => mutationFetcher<${responseType}>(url, { arg: { method: "${method}", body: arg } }),`);
2636
+ } else {
2637
+ sections.push(` (url) => mutationFetcher<${responseType}>(url, { arg: { method: "${method}" } }),`);
2638
+ }
2639
+
2640
+ sections.push(" config,");
2641
+ sections.push(" );");
2642
+ sections.push("}");
2643
+ sections.push("");
2644
+ }
2645
+ }
2646
+
2647
+ return sections.join("\n").trimEnd() + "\n";
2648
+ }
2649
+
2650
+ // ── Pydantic Model Generation ──
2651
+
2652
+ /**
2653
+ * Convert a TypeNode to a Pydantic-compatible Python type string.
2654
+ */
2655
+ function typeNodeToPydantic(
2656
+ node: TypeNode,
2657
+ extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }>,
2658
+ parentName: string,
2659
+ propName: string | undefined,
2660
+ ): string {
2661
+ switch (node.kind) {
2662
+ case "primitive":
2663
+ switch (node.name) {
2664
+ case "string": return "str";
2665
+ case "number": return "float";
2666
+ case "boolean": return "bool";
2667
+ case "null": return "None";
2668
+ case "undefined": return "None";
2669
+ case "bigint": return "int";
2670
+ case "symbol": return "str";
2671
+ default: return "Any";
2672
+ }
2673
+
2674
+ case "unknown":
2675
+ return "Any";
2676
+
2677
+ case "array": {
2678
+ const inner = typeNodeToPydantic(node.element, extracted, parentName, propName);
2679
+ return `List[${inner}]`;
2680
+ }
2681
+
2682
+ case "tuple": {
2683
+ const elements = node.elements.map((el, i) =>
2684
+ typeNodeToPydantic(el, extracted, parentName, `el${i}`)
2685
+ );
2686
+ return `Tuple[${elements.join(", ")}]`;
2687
+ }
2688
+
2689
+ case "union": {
2690
+ const members = node.members.map((m) =>
2691
+ typeNodeToPydantic(m, extracted, parentName, propName)
2692
+ );
2693
+ if (members.length === 2 && members.includes("None")) {
2694
+ const nonNone = members.find((m) => m !== "None");
2695
+ return `Optional[${nonNone}]`;
2696
+ }
2697
+ return `Union[${members.join(", ")}]`;
2698
+ }
2699
+
2700
+ case "map": {
2701
+ const v = typeNodeToPydantic(node.value, extracted, parentName, "value");
2702
+ return `Dict[str, ${v}]`;
2703
+ }
2704
+
2705
+ case "set": {
2706
+ const inner = typeNodeToPydantic(node.element, extracted, parentName, propName);
2707
+ return `Set[${inner}]`;
2708
+ }
2709
+
2710
+ case "promise":
2711
+ return typeNodeToPydantic(node.resolved, extracted, parentName, propName);
2712
+
2713
+ case "function":
2714
+ return "Any";
2715
+
2716
+ case "object": {
2717
+ const keys = Object.keys(node.properties);
2718
+ if (keys.length === 0) return "Dict[str, Any]";
2719
+
2720
+ if (propName) {
2721
+ const className = toPascalCase(parentName) + toPascalCase(propName);
2722
+ if (!extracted.some((e) => e.name === className)) {
2723
+ extracted.push({ name: className, node });
2724
+ }
2725
+ return className;
2726
+ }
2727
+ return "Dict[str, Any]";
2728
+ }
2729
+ }
2730
+ }
2731
+
2732
+ /**
2733
+ * Render a Pydantic BaseModel class from an object TypeNode.
2734
+ */
2735
+ function renderPydanticModel(
2736
+ name: string,
2737
+ node: Extract<TypeNode, { kind: "object" }>,
2738
+ extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }>,
2739
+ ): string {
2740
+ const keys = Object.keys(node.properties);
2741
+ const innerExtracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
2742
+ const lines: string[] = [];
2743
+
2744
+ const entries = keys.map((key) => {
2745
+ const pyType = typeNodeToPydantic(node.properties[key], innerExtracted, name, key);
2746
+ return ` ${toSnakeCase(key)}: ${pyType}`;
2747
+ });
2748
+
2749
+ // Emit nested models first (they must be defined before use)
2750
+ for (const iface of innerExtracted) {
2751
+ lines.push(renderPydanticModel(iface.name, iface.node, innerExtracted));
2752
+ lines.push("");
2753
+ lines.push("");
2754
+ }
2755
+
2756
+ lines.push(`class ${name}(BaseModel):`);
2757
+ if (entries.length === 0) {
2758
+ lines.push(" pass");
2759
+ } else {
2760
+ lines.push(...entries);
2761
+ }
2762
+ return lines.join("\n");
2763
+ }
2764
+
2765
+ /**
2766
+ * Generate Pydantic BaseModel classes from observed runtime types.
2767
+ *
2768
+ * Unlike --python (TypedDict), Pydantic models provide:
2769
+ * - Runtime validation (model_validate)
2770
+ * - JSON serialization (model_dump_json)
2771
+ * - JSON Schema generation (model_json_schema)
2772
+ * - Direct use as FastAPI request/response models
2773
+ */
2774
+ export function generatePydanticModels(
2775
+ functions: Array<{
2776
+ name: string;
2777
+ argsType: TypeNode;
2778
+ returnType: TypeNode;
2779
+ module?: string;
2780
+ env?: string;
2781
+ observedAt?: string;
2782
+ }>,
2783
+ ): string {
2784
+ const sections: string[] = [];
2785
+
2786
+ sections.push("# Auto-generated Pydantic models by trickle");
2787
+ sections.push(`# Generated at ${new Date().toISOString()}`);
2788
+ sections.push("# Do not edit manually — re-run `trickle codegen --pydantic` to update");
2789
+ sections.push("");
2790
+ sections.push("from __future__ import annotations");
2791
+ sections.push("");
2792
+ sections.push("from typing import Any, Dict, List, Optional, Set, Tuple, Union");
2793
+ sections.push("");
2794
+ sections.push("from pydantic import BaseModel");
2795
+ sections.push("");
2796
+ sections.push("");
2797
+
2798
+ for (const fn of functions) {
2799
+ const parsed = parseRouteName(fn.name);
2800
+ const baseName = parsed ? parsed.typeName : toPascalCase(fn.name);
2801
+
2802
+ const metaParts: string[] = [];
2803
+ if (fn.module) metaParts.push(`${fn.module} module`);
2804
+ if (fn.env) metaParts.push(`observed in ${fn.env}`);
2805
+ if (fn.observedAt) metaParts.push(formatTimeAgo(fn.observedAt));
2806
+ if (metaParts.length > 0) {
2807
+ sections.push(`# ${baseName} — ${metaParts.join(", ")}`);
2808
+ }
2809
+
2810
+ if (parsed) {
2811
+ // Route-style: generate Request (body) and Response models
2812
+
2813
+ // Request body (only for methods with body)
2814
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
2815
+ let bodyNode: TypeNode | undefined;
2816
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
2817
+ bodyNode = fn.argsType.properties["body"];
2818
+ }
2819
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
2820
+ const extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
2821
+ sections.push(renderPydanticModel(`${baseName}Request`, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
2822
+ sections.push("");
2823
+ sections.push("");
2824
+ }
2825
+ }
2826
+
2827
+ // Response model
2828
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
2829
+ const extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
2830
+ sections.push(renderPydanticModel(`${baseName}Response`, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
2831
+ } else if (fn.returnType.kind !== "unknown") {
2832
+ const extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
2833
+ const pyType = typeNodeToPydantic(fn.returnType, extracted, baseName, undefined);
2834
+ sections.push(`${baseName}Response = ${pyType}`);
2835
+ }
2836
+ } else {
2837
+ // Non-route: generate Input/Output models
2838
+ if (fn.argsType.kind === "object" && Object.keys(fn.argsType.properties).length > 0) {
2839
+ const extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
2840
+ sections.push(renderPydanticModel(`${baseName}Input`, fn.argsType as Extract<TypeNode, { kind: "object" }>, extracted));
2841
+ sections.push("");
2842
+ sections.push("");
2843
+ }
2844
+
2845
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
2846
+ const extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
2847
+ sections.push(renderPydanticModel(`${baseName}Output`, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
2848
+ } else if (fn.returnType.kind !== "unknown") {
2849
+ const extracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
2850
+ const pyType = typeNodeToPydantic(fn.returnType, extracted, baseName, undefined);
2851
+ sections.push(`${baseName}Output = ${pyType}`);
2852
+ }
2853
+ }
2854
+
2855
+ sections.push("");
2856
+ sections.push("");
2857
+ }
2858
+
2859
+ return sections.join("\n").trimEnd() + "\n";
2860
+ }
2861
+
2862
+ // ── class-validator DTO Generation (NestJS) ──
2863
+
2864
+ /**
2865
+ * Get the class-validator decorator for a TypeNode.
2866
+ * Returns the decorator string(s) and the TypeScript type.
2867
+ */
2868
+ function classValidatorField(
2869
+ node: TypeNode,
2870
+ propName: string,
2871
+ parentName: string,
2872
+ nestedClasses: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }>,
2873
+ ): { decorators: string[]; tsType: string } {
2874
+ switch (node.kind) {
2875
+ case "primitive":
2876
+ switch (node.name) {
2877
+ case "string":
2878
+ return { decorators: ["@IsString()"], tsType: "string" };
2879
+ case "number":
2880
+ return { decorators: ["@IsNumber()"], tsType: "number" };
2881
+ case "boolean":
2882
+ return { decorators: ["@IsBoolean()"], tsType: "boolean" };
2883
+ default:
2884
+ return { decorators: [], tsType: "any" };
2885
+ }
2886
+
2887
+ case "array": {
2888
+ const innerDecorators: string[] = ["@IsArray()"];
2889
+ if (node.element.kind === "object" && Object.keys(node.element.properties).length > 0) {
2890
+ const nestedName = parentName + toPascalCase(propName) + "Item";
2891
+ if (!nestedClasses.some((c) => c.name === nestedName)) {
2892
+ nestedClasses.push({ name: nestedName, node: node.element as Extract<TypeNode, { kind: "object" }> });
2893
+ }
2894
+ innerDecorators.push("@ValidateNested({ each: true })");
2895
+ innerDecorators.push(`@Type(() => ${nestedName})`);
2896
+ return { decorators: innerDecorators, tsType: `${nestedName}[]` };
2897
+ }
2898
+ const inner = classValidatorField(node.element, propName, parentName, nestedClasses);
2899
+ return { decorators: innerDecorators, tsType: `${inner.tsType}[]` };
2900
+ }
2901
+
2902
+ case "object": {
2903
+ const keys = Object.keys(node.properties);
2904
+ if (keys.length === 0) {
2905
+ return { decorators: ["@IsObject()"], tsType: "Record<string, any>" };
2906
+ }
2907
+ const nestedName = parentName + toPascalCase(propName);
2908
+ if (!nestedClasses.some((c) => c.name === nestedName)) {
2909
+ nestedClasses.push({ name: nestedName, node });
2910
+ }
2911
+ return {
2912
+ decorators: ["@ValidateNested()", `@Type(() => ${nestedName})`],
2913
+ tsType: nestedName,
2914
+ };
2915
+ }
2916
+
2917
+ case "union": {
2918
+ // Check for nullable (T | null)
2919
+ const nonNull = node.members.filter(
2920
+ (m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined")),
2921
+ );
2922
+ const isNullable = nonNull.length < node.members.length;
2923
+
2924
+ if (nonNull.length === 1) {
2925
+ const inner = classValidatorField(nonNull[0], propName, parentName, nestedClasses);
2926
+ if (isNullable) {
2927
+ return {
2928
+ decorators: ["@IsOptional()", ...inner.decorators],
2929
+ tsType: `${inner.tsType} | null`,
2930
+ };
2931
+ }
2932
+ return inner;
2933
+ }
2934
+ // Complex union — use basic validation
2935
+ return { decorators: [], tsType: "any" };
2936
+ }
2937
+
2938
+ case "unknown":
2939
+ return { decorators: [], tsType: "any" };
2940
+
2941
+ default:
2942
+ return { decorators: [], tsType: "any" };
2943
+ }
2944
+ }
2945
+
2946
+ /**
2947
+ * Render a class-validator DTO class.
2948
+ */
2949
+ function renderValidatorClass(
2950
+ name: string,
2951
+ node: Extract<TypeNode, { kind: "object" }>,
2952
+ nestedClasses: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }>,
2953
+ ): string {
2954
+ const lines: string[] = [];
2955
+ const keys = Object.keys(node.properties);
2956
+
2957
+ lines.push(`export class ${name} {`);
2958
+ for (let i = 0; i < keys.length; i++) {
2959
+ const key = keys[i];
2960
+ const propNode = node.properties[key];
2961
+ const { decorators, tsType } = classValidatorField(propNode, key, name, nestedClasses);
2962
+
2963
+ for (const dec of decorators) {
2964
+ lines.push(` ${dec}`);
2965
+ }
2966
+ lines.push(` ${key}: ${tsType};`);
2967
+ if (i < keys.length - 1) lines.push("");
2968
+ }
2969
+ lines.push("}");
2970
+ return lines.join("\n");
2971
+ }
2972
+
2973
+ /**
2974
+ * Generate class-validator DTO classes from observed runtime types.
2975
+ *
2976
+ * Output: NestJS-ready DTOs with class-validator decorators for
2977
+ * request validation and class-transformer for nested object support.
2978
+ */
2979
+ export function generateClassValidatorDtos(
2980
+ functions: Array<{
2981
+ name: string;
2982
+ argsType: TypeNode;
2983
+ returnType: TypeNode;
2984
+ module?: string;
2985
+ env?: string;
2986
+ observedAt?: string;
2987
+ }>,
2988
+ ): string {
2989
+ const routes: Array<{
2990
+ parsed: ParsedRoute;
2991
+ fn: (typeof functions)[number];
2992
+ }> = [];
2993
+
2994
+ for (const fn of functions) {
2995
+ const parsed = parseRouteName(fn.name);
2996
+ if (parsed) {
2997
+ routes.push({ parsed, fn });
2998
+ }
2999
+ }
3000
+
3001
+ if (routes.length === 0) {
3002
+ return "// No API routes found. Instrument your Express/NestJS app to generate DTOs.\n";
3003
+ }
3004
+
3005
+ const sections: string[] = [];
3006
+ sections.push("// Auto-generated class-validator DTOs by trickle");
3007
+ sections.push(`// Generated at ${new Date().toISOString()}`);
3008
+ sections.push("// Do not edit manually — re-run `trickle codegen --class-validator` to update");
3009
+ sections.push("");
3010
+
3011
+ // Collect which decorators are needed
3012
+ const usedDecorators = new Set<string>();
3013
+ const needsType = { value: false };
3014
+
3015
+ // Pre-scan to collect all classes and nested classes
3016
+ const allClasses: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }>; comment: string }> = [];
3017
+ const nestedClasses: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
3018
+
3019
+ for (const { parsed, fn } of routes) {
3020
+ // Request body DTO (POST/PUT/PATCH only)
3021
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
3022
+ let bodyNode: TypeNode | undefined;
3023
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
3024
+ bodyNode = fn.argsType.properties["body"];
3025
+ }
3026
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
3027
+ allClasses.push({
3028
+ name: `${parsed.typeName}Body`,
3029
+ node: bodyNode as Extract<TypeNode, { kind: "object" }>,
3030
+ comment: `/** Request body for ${parsed.method} ${parsed.path} */`,
3031
+ });
3032
+ }
3033
+ }
3034
+
3035
+ // Response DTO
3036
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
3037
+ allClasses.push({
3038
+ name: `${parsed.typeName}Response`,
3039
+ node: fn.returnType as Extract<TypeNode, { kind: "object" }>,
3040
+ comment: `/** Response for ${parsed.method} ${parsed.path} */`,
3041
+ });
3042
+ }
3043
+ }
3044
+
3045
+ // Render all classes, collecting nested ones as we go
3046
+ const renderedClasses: Array<{ name: string; code: string; comment: string }> = [];
3047
+ const rendered = new Set<string>();
3048
+
3049
+ for (const cls of allClasses) {
3050
+ if (rendered.has(cls.name)) continue;
3051
+ rendered.add(cls.name);
3052
+ const code = renderValidatorClass(cls.name, cls.node, nestedClasses);
3053
+ renderedClasses.push({ name: cls.name, code, comment: cls.comment });
3054
+ }
3055
+
3056
+ // Render nested classes (may add more to nestedClasses as we process)
3057
+ let cursor = 0;
3058
+ while (cursor < nestedClasses.length) {
3059
+ const nested = nestedClasses[cursor];
3060
+ cursor++;
3061
+ if (rendered.has(nested.name)) continue;
3062
+ rendered.add(nested.name);
3063
+ const code = renderValidatorClass(nested.name, nested.node, nestedClasses);
3064
+ renderedClasses.unshift({ name: nested.name, code, comment: "" });
3065
+ }
3066
+
3067
+ // Scan for used decorators
3068
+ const allCode = renderedClasses.map((c) => c.code).join("\n");
3069
+ const decoratorNames = ["IsString", "IsNumber", "IsBoolean", "IsArray", "IsObject", "IsOptional", "ValidateNested", "IsNotEmpty"];
3070
+ for (const name of decoratorNames) {
3071
+ if (allCode.includes(`@${name}`)) usedDecorators.add(name);
3072
+ }
3073
+ if (allCode.includes("@Type(")) needsType.value = true;
3074
+
3075
+ // Emit imports
3076
+ if (usedDecorators.size > 0) {
3077
+ const sorted = Array.from(usedDecorators).sort();
3078
+ sections.push(`import { ${sorted.join(", ")} } from "class-validator";`);
3079
+ }
3080
+ if (needsType.value) {
3081
+ sections.push('import { Type } from "class-transformer";');
3082
+ }
3083
+ sections.push("");
3084
+
3085
+ // Emit classes
3086
+ for (const cls of renderedClasses) {
3087
+ if (cls.comment) {
3088
+ sections.push(cls.comment);
3089
+ }
3090
+ sections.push(cls.code);
3091
+ sections.push("");
3092
+ }
3093
+
3094
+ return sections.join("\n").trimEnd() + "\n";
3095
+ }
3096
+
3097
+ // ── GraphQL SDL Generation ──
3098
+
3099
+ /**
3100
+ * Convert a TypeNode to a GraphQL type string, accumulating named types.
3101
+ */
3102
+ function typeNodeToGraphQL(
3103
+ node: TypeNode,
3104
+ namedTypes: Map<string, string>,
3105
+ parentName: string,
3106
+ propName?: string,
3107
+ ): string {
3108
+ switch (node.kind) {
3109
+ case "primitive":
3110
+ switch (node.name) {
3111
+ case "string": return "String";
3112
+ case "number": return "Float";
3113
+ case "boolean": return "Boolean";
3114
+ case "bigint": return "String";
3115
+ case "null": return "String";
3116
+ case "undefined": return "String";
3117
+ case "symbol": return "String";
3118
+ default: return "String";
3119
+ }
3120
+ case "array": {
3121
+ const elementType = typeNodeToGraphQL(node.element, namedTypes, parentName, propName);
3122
+ return `[${elementType}]`;
3123
+ }
3124
+ case "object": {
3125
+ const typeName = propName
3126
+ ? parentName + toPascalCase(propName)
3127
+ : parentName;
3128
+
3129
+ const fields: string[] = [];
3130
+ for (const [key, val] of Object.entries(node.properties)) {
3131
+ const fieldType = typeNodeToGraphQL(val, namedTypes, typeName, key);
3132
+ fields.push(` ${key}: ${fieldType}`);
3133
+ }
3134
+
3135
+ if (fields.length === 0) {
3136
+ return "String";
3137
+ }
3138
+
3139
+ const body = `type ${typeName} {\n${fields.join("\n")}\n}`;
3140
+ namedTypes.set(typeName, body);
3141
+ return typeName;
3142
+ }
3143
+ case "union": {
3144
+ // GraphQL unions only work for object types; for primitives, pick the first non-null
3145
+ const nonNull = node.members.filter(
3146
+ (m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined")),
3147
+ );
3148
+ if (nonNull.length === 0) return "String";
3149
+ return typeNodeToGraphQL(nonNull[0], namedTypes, parentName, propName);
3150
+ }
3151
+ case "promise":
3152
+ return typeNodeToGraphQL(node.resolved, namedTypes, parentName, propName);
3153
+ case "map":
3154
+ return "String"; // JSON scalar
3155
+ case "set": {
3156
+ const elementType = typeNodeToGraphQL(node.element, namedTypes, parentName, propName);
3157
+ return `[${elementType}]`;
3158
+ }
3159
+ case "tuple": {
3160
+ if (node.elements.length === 0) return "[String]";
3161
+ const elementType = typeNodeToGraphQL(node.elements[0], namedTypes, parentName, propName);
3162
+ return `[${elementType}]`;
3163
+ }
3164
+ case "function":
3165
+ case "unknown":
3166
+ default:
3167
+ return "String";
3168
+ }
3169
+ }
3170
+
3171
+ /**
3172
+ * Generate a GraphQL SDL schema from runtime-observed API routes.
3173
+ *
3174
+ * Converts REST routes into Query (GET) and Mutation (POST/PUT/PATCH/DELETE)
3175
+ * fields with properly typed inputs and outputs.
3176
+ */
3177
+ export function generateGraphqlSchema(
3178
+ functions: Array<{
3179
+ name: string;
3180
+ argsType: TypeNode;
3181
+ returnType: TypeNode;
3182
+ module?: string;
3183
+ env?: string;
3184
+ observedAt?: string;
3185
+ }>,
3186
+ ): string {
3187
+ const namedTypes = new Map<string, string>();
3188
+ const queryFields: string[] = [];
3189
+ const mutationFields: string[] = [];
3190
+ const inputTypes: string[] = [];
3191
+
3192
+ for (const fn of functions) {
3193
+ const route = parseRouteName(fn.name);
3194
+ if (!route) continue;
3195
+
3196
+ const typeName = route.typeName;
3197
+ const fieldName = toCamelCase(fn.name);
3198
+
3199
+ // Generate return type
3200
+ const returnTypeName = typeName + "Response";
3201
+ typeNodeToGraphQL(fn.returnType, namedTypes, returnTypeName);
3202
+
3203
+ // Build field arguments from argsType
3204
+ const args: string[] = [];
3205
+
3206
+ if (fn.argsType.kind === "object") {
3207
+ const props = fn.argsType.properties;
3208
+
3209
+ // Path params
3210
+ for (const param of route.pathParams) {
3211
+ args.push(`${param}: String!`);
3212
+ }
3213
+
3214
+ // Query params
3215
+ if (props.query && props.query.kind === "object") {
3216
+ for (const [key, val] of Object.entries(props.query.properties)) {
3217
+ const gqlType = typeNodeToGraphQL(val, namedTypes, typeName, key);
3218
+ args.push(`${key}: ${gqlType}`);
3219
+ }
3220
+ }
3221
+
3222
+ // Body → input type for mutations
3223
+ if (props.body && props.body.kind === "object") {
3224
+ const inputName = typeName + "Input";
3225
+ const inputFields: string[] = [];
3226
+ for (const [key, val] of Object.entries(props.body.properties)) {
3227
+ const fieldType = typeNodeToGraphQLInput(val, namedTypes, inputName, key);
3228
+ inputFields.push(` ${key}: ${fieldType}`);
3229
+ }
3230
+ if (inputFields.length > 0) {
3231
+ inputTypes.push(`input ${inputName} {\n${inputFields.join("\n")}\n}`);
3232
+ args.push(`input: ${inputName}!`);
3233
+ }
3234
+ }
3235
+ }
3236
+
3237
+ const argsStr = args.length > 0 ? `(${args.join(", ")})` : "";
3238
+
3239
+ // Determine the return type name — use the named type if it was created
3240
+ const resolvedReturnType = namedTypes.has(returnTypeName) ? returnTypeName : typeNodeToGraphQL(fn.returnType, namedTypes, returnTypeName);
3241
+
3242
+ if (route.method === "GET") {
3243
+ queryFields.push(` ${fieldName}${argsStr}: ${resolvedReturnType}`);
3244
+ } else {
3245
+ mutationFields.push(` ${fieldName}${argsStr}: ${resolvedReturnType}`);
3246
+ }
3247
+ }
3248
+
3249
+ if (queryFields.length === 0 && mutationFields.length === 0) {
3250
+ return "# No API routes found.\n";
3251
+ }
3252
+
3253
+ const sections: string[] = [];
3254
+ sections.push("# Auto-generated GraphQL schema from runtime-observed types");
3255
+ sections.push("# Generated by trickle — https://github.com/yiheinchai/trickle");
3256
+ sections.push("");
3257
+
3258
+ // Emit named types (skip duplicates, emit in order)
3259
+ const emittedTypes = new Set<string>();
3260
+ for (const [name, body] of namedTypes) {
3261
+ if (!emittedTypes.has(name)) {
3262
+ emittedTypes.add(name);
3263
+ sections.push(body);
3264
+ sections.push("");
3265
+ }
3266
+ }
3267
+
3268
+ // Emit input types
3269
+ for (const input of inputTypes) {
3270
+ sections.push(input);
3271
+ sections.push("");
3272
+ }
3273
+
3274
+ // Emit Query
3275
+ if (queryFields.length > 0) {
3276
+ sections.push(`type Query {`);
3277
+ for (const f of queryFields) {
3278
+ sections.push(f);
3279
+ }
3280
+ sections.push("}");
3281
+ sections.push("");
3282
+ }
3283
+
3284
+ // Emit Mutation
3285
+ if (mutationFields.length > 0) {
3286
+ sections.push(`type Mutation {`);
3287
+ for (const f of mutationFields) {
3288
+ sections.push(f);
3289
+ }
3290
+ sections.push("}");
3291
+ sections.push("");
3292
+ }
3293
+
3294
+ return sections.join("\n").trimEnd() + "\n";
3295
+ }
3296
+
3297
+ /**
3298
+ * Convert TypeNode to GraphQL input type string (uses `input` instead of `type` for nested objects).
3299
+ */
3300
+ function typeNodeToGraphQLInput(
3301
+ node: TypeNode,
3302
+ namedTypes: Map<string, string>,
3303
+ parentName: string,
3304
+ propName?: string,
3305
+ ): string {
3306
+ switch (node.kind) {
3307
+ case "primitive":
3308
+ switch (node.name) {
3309
+ case "string": return "String";
3310
+ case "number": return "Float";
3311
+ case "boolean": return "Boolean";
3312
+ default: return "String";
3313
+ }
3314
+ case "array": {
3315
+ const elementType = typeNodeToGraphQLInput(node.element, namedTypes, parentName, propName);
3316
+ return `[${elementType}]`;
3317
+ }
3318
+ case "object": {
3319
+ const typeName = propName
3320
+ ? parentName + toPascalCase(propName)
3321
+ : parentName;
3322
+ // Don't emit as named type — just inline or skip
3323
+ // For nested input objects, the parent handles them
3324
+ return "String"; // Flatten deep nested inputs to JSON string
3325
+ }
3326
+ case "union": {
3327
+ const nonNull = node.members.filter(
3328
+ (m) => !(m.kind === "primitive" && (m.name === "null" || m.name === "undefined")),
3329
+ );
3330
+ if (nonNull.length === 0) return "String";
3331
+ return typeNodeToGraphQLInput(nonNull[0], namedTypes, parentName, propName);
3332
+ }
3333
+ default:
3334
+ return "String";
3335
+ }
3336
+ }
3337
+
3338
+ // ── tRPC Router Generation ──
3339
+
3340
+ /**
3341
+ * Generate a fully-typed tRPC router from runtime-observed API routes.
3342
+ *
3343
+ * - GET routes → t.procedure.query()
3344
+ * - POST/PUT/PATCH/DELETE routes → t.procedure.input(zodSchema).mutation()
3345
+ * - Includes Zod input schemas for request body validation
3346
+ * - Includes TypeScript return type annotations from observed responses
3347
+ * - Exports AppRouter type for client-side type inference
3348
+ */
3349
+ export function generateTrpcRouter(
3350
+ functions: Array<{
3351
+ name: string;
3352
+ argsType: TypeNode;
3353
+ returnType: TypeNode;
3354
+ module?: string;
3355
+ env?: string;
3356
+ observedAt?: string;
3357
+ }>,
3358
+ ): string {
3359
+ if (functions.length === 0) {
3360
+ return "// No API routes found. Instrument your app to generate a tRPC router.\n";
3361
+ }
3362
+
3363
+ const sections: string[] = [];
3364
+ sections.push("// Auto-generated tRPC router by trickle");
3365
+ sections.push(`// Generated at ${new Date().toISOString()}`);
3366
+ sections.push("// Do not edit manually — re-run `trickle codegen --trpc` to update");
3367
+ sections.push("");
3368
+ sections.push('import { initTRPC } from "@trpc/server";');
3369
+ sections.push('import { z } from "zod";');
3370
+ sections.push("");
3371
+ sections.push("const t = initTRPC.create();");
3372
+ sections.push("");
3373
+
3374
+ // Collect route procedures
3375
+ const inputSchemas: string[] = [];
3376
+ const procedures: string[] = [];
3377
+ const returnTypes: string[] = [];
3378
+
3379
+ for (const fn of functions) {
3380
+ const route = parseRouteName(fn.name);
3381
+ if (!route) continue;
3382
+
3383
+ const procedureName = toCamelCase(route.typeName);
3384
+ const typeName = route.typeName;
3385
+
3386
+ // Generate return type interface
3387
+ const extracted: ExtractedInterface[] = [];
3388
+ const returnTypeStr = typeNodeToTS(fn.returnType, extracted, typeName + "Response", undefined, 0);
3389
+ returnTypes.push(`export interface ${typeName}Response ${returnTypeStr.startsWith("{") ? returnTypeStr : `{ data: ${returnTypeStr} }`}`);
3390
+ // Also include any extracted nested interfaces
3391
+ for (const ext of extracted) {
3392
+ returnTypes.push(renderInterface(ext.name, ext.node, []));
3393
+ }
3394
+
3395
+ // Check for body input (POST/PUT/PATCH/DELETE)
3396
+ let hasInput = false;
3397
+ let bodyNode: TypeNode | undefined;
3398
+
3399
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(route.method)) {
3400
+ if (fn.argsType.kind === "object" && fn.argsType.properties["body"]) {
3401
+ bodyNode = fn.argsType.properties["body"];
3402
+ }
3403
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
3404
+ hasInput = true;
3405
+ const schemaName = `${procedureName}Input`;
3406
+ inputSchemas.push(`const ${schemaName} = ${typeNodeToZod(bodyNode, 0)};`);
3407
+ }
3408
+ }
3409
+
3410
+ // Check for query params input (GET)
3411
+ let hasQueryInput = false;
3412
+ let queryNode: TypeNode | undefined;
3413
+ if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
3414
+ queryNode = fn.argsType.properties["query"];
3415
+ if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
3416
+ hasQueryInput = true;
3417
+ const schemaName = `${procedureName}Input`;
3418
+ if (!hasInput) {
3419
+ inputSchemas.push(`const ${schemaName} = ${typeNodeToZod(queryNode, 0)};`);
3420
+ }
3421
+ }
3422
+ }
3423
+
3424
+ // Check for path params
3425
+ const hasPathParams = route.pathParams.length > 0;
3426
+ let pathParamSchema = "";
3427
+ if (hasPathParams && !hasInput && !hasQueryInput) {
3428
+ const pathProps: Record<string, TypeNode> = {};
3429
+ for (const param of route.pathParams) {
3430
+ pathProps[param] = { kind: "primitive", name: "string" } as TypeNode;
3431
+ }
3432
+ const pathNode: TypeNode = { kind: "object", properties: pathProps };
3433
+ const schemaName = `${procedureName}Input`;
3434
+ inputSchemas.push(`const ${schemaName} = ${typeNodeToZod(pathNode, 0)};`);
3435
+ pathParamSchema = schemaName;
3436
+ }
3437
+
3438
+ const inputName = `${procedureName}Input`;
3439
+ const hasAnyInput = hasInput || hasQueryInput || (hasPathParams && pathParamSchema);
3440
+
3441
+ // Build procedure
3442
+ const isQuery = route.method === "GET";
3443
+ let proc = ` ${procedureName}: t.procedure`;
3444
+
3445
+ if (hasAnyInput) {
3446
+ proc += `\n .input(${inputName})`;
3447
+ }
3448
+
3449
+ if (isQuery) {
3450
+ proc += `\n .query(async (${hasAnyInput ? "{ input }" : ""}) => {`;
3451
+ proc += `\n // ${route.method} ${route.path}`;
3452
+ proc += `\n // Return type: ${typeName}Response`;
3453
+ proc += `\n throw new Error("Not implemented — replace with your logic");`;
3454
+ proc += `\n })`;
3455
+ } else {
3456
+ proc += `\n .mutation(async (${hasAnyInput ? "{ input }" : ""}) => {`;
3457
+ proc += `\n // ${route.method} ${route.path}`;
3458
+ proc += `\n // Return type: ${typeName}Response`;
3459
+ proc += `\n throw new Error("Not implemented — replace with your logic");`;
3460
+ proc += `\n })`;
3461
+ }
3462
+
3463
+ procedures.push(proc);
3464
+ }
3465
+
3466
+ if (procedures.length === 0) {
3467
+ return "// No API routes found. Instrument your app to generate a tRPC router.\n";
3468
+ }
3469
+
3470
+ // Emit return type interfaces
3471
+ sections.push("// ── Response types ──");
3472
+ sections.push("");
3473
+ for (const rt of returnTypes) {
3474
+ sections.push(rt);
3475
+ sections.push("");
3476
+ }
3477
+
3478
+ // Emit input schemas
3479
+ if (inputSchemas.length > 0) {
3480
+ sections.push("// ── Input validation schemas ──");
3481
+ sections.push("");
3482
+ for (const schema of inputSchemas) {
3483
+ sections.push(schema);
3484
+ sections.push("");
3485
+ }
3486
+ }
3487
+
3488
+ // Emit router
3489
+ sections.push("// ── Router ──");
3490
+ sections.push("");
3491
+ sections.push("export const appRouter = t.router({");
3492
+ sections.push(procedures.join(",\n\n"));
3493
+ sections.push("});");
3494
+ sections.push("");
3495
+ sections.push("export type AppRouter = typeof appRouter;");
3496
+
3497
+ return sections.join("\n").trimEnd() + "\n";
3498
+ }
3499
+
3500
+ // ── Axios Client Generation ──
3501
+
3502
+ /**
3503
+ * Generate a typed Axios client from runtime-observed API routes.
3504
+ *
3505
+ * For each route:
3506
+ * - Request/response interfaces
3507
+ * - Typed function using axios.get/post/put/patch/delete
3508
+ * - Path parameter interpolation
3509
+ * - Query parameter support
3510
+ * - Configurable base URL and axios instance
3511
+ */
3512
+ export function generateAxiosClient(
3513
+ functions: Array<{
3514
+ name: string;
3515
+ argsType: TypeNode;
3516
+ returnType: TypeNode;
3517
+ module?: string;
3518
+ env?: string;
3519
+ observedAt?: string;
3520
+ }>,
3521
+ ): string {
3522
+ const routes: Array<{
3523
+ parsed: ParsedRoute;
3524
+ fn: (typeof functions)[number];
3525
+ }> = [];
3526
+
3527
+ for (const fn of functions) {
3528
+ const parsed = parseRouteName(fn.name);
3529
+ if (parsed) {
3530
+ routes.push({ parsed, fn });
3531
+ }
3532
+ }
3533
+
3534
+ if (routes.length === 0) {
3535
+ return "// No API routes found. Instrument your app to generate a typed Axios client.\n";
3536
+ }
3537
+
3538
+ const sections: string[] = [];
3539
+ sections.push("// Auto-generated typed Axios client by trickle");
3540
+ sections.push(`// Generated at ${new Date().toISOString()}`);
3541
+ sections.push("// Do not edit manually — re-run `trickle codegen --axios` to update");
3542
+ sections.push("");
3543
+ sections.push('import axios, { AxiosInstance, AxiosRequestConfig } from "axios";');
3544
+ sections.push("");
3545
+
3546
+ // Generate interfaces
3547
+ const extracted: ExtractedInterface[] = [];
3548
+ const interfaceSections: string[] = [];
3549
+
3550
+ for (const { parsed, fn } of routes) {
3551
+ const baseName = parsed.typeName;
3552
+
3553
+ // Body input type (POST/PUT/PATCH)
3554
+ if (["POST", "PUT", "PATCH"].includes(parsed.method)) {
3555
+ if (fn.argsType.kind === "object") {
3556
+ const bodyNode = fn.argsType.properties["body"];
3557
+ if (bodyNode && bodyNode.kind === "object" && Object.keys(bodyNode.properties).length > 0) {
3558
+ interfaceSections.push(renderInterface(`${baseName}Body`, bodyNode as Extract<TypeNode, { kind: "object" }>, extracted));
3559
+ interfaceSections.push("");
3560
+ }
3561
+ }
3562
+ }
3563
+
3564
+ // Query params type
3565
+ if (fn.argsType.kind === "object" && fn.argsType.properties["query"]) {
3566
+ const queryNode = fn.argsType.properties["query"];
3567
+ if (queryNode.kind === "object" && Object.keys(queryNode.properties).length > 0) {
3568
+ interfaceSections.push(renderInterface(`${baseName}Query`, queryNode as Extract<TypeNode, { kind: "object" }>, extracted));
3569
+ interfaceSections.push("");
3570
+ }
3571
+ }
3572
+
3573
+ // Response type
3574
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length > 0) {
3575
+ interfaceSections.push(renderInterface(`${baseName}Response`, fn.returnType as Extract<TypeNode, { kind: "object" }>, extracted));
3576
+ interfaceSections.push("");
3577
+ } else {
3578
+ const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
3579
+ interfaceSections.push(`export type ${baseName}Response = ${retStr};`);
3580
+ interfaceSections.push("");
3581
+ }
3582
+ }
3583
+
3584
+ // Emit extracted sub-interfaces first
3585
+ const emitted = new Set<string>();
3586
+ let cursor = 0;
3587
+ while (cursor < extracted.length) {
3588
+ const iface = extracted[cursor];
3589
+ cursor++;
3590
+ if (emitted.has(iface.name)) continue;
3591
+ emitted.add(iface.name);
3592
+ sections.push(renderInterface(iface.name, iface.node, extracted));
3593
+ sections.push("");
3594
+ }
3595
+
3596
+ // Emit main interfaces
3597
+ sections.push(...interfaceSections);
3598
+
3599
+ // Client setup
3600
+ sections.push("// ── Axios Client ──");
3601
+ sections.push("");
3602
+ sections.push("let _instance: AxiosInstance = axios.create();");
3603
+ sections.push("");
3604
+ sections.push("/**");
3605
+ sections.push(" * Configure the Axios client instance.");
3606
+ sections.push(" * Call this once at app startup with your base URL.");
3607
+ sections.push(" */");
3608
+ sections.push('export function configureAxiosClient(baseURL: string, instance?: AxiosInstance): void {');
3609
+ sections.push(" if (instance) {");
3610
+ sections.push(" _instance = instance;");
3611
+ sections.push(" } else {");
3612
+ sections.push(" _instance = axios.create({ baseURL });");
3613
+ sections.push(" }");
3614
+ sections.push("}");
3615
+ sections.push("");
3616
+
3617
+ // Generate typed functions
3618
+ sections.push("// ── Typed API Functions ──");
3619
+ sections.push("");
3620
+
3621
+ for (const { parsed, fn } of routes) {
3622
+ const baseName = parsed.typeName;
3623
+ const funcName = toCamelCase(baseName);
3624
+ const responseName = `${baseName}Response`;
3625
+
3626
+ // Determine if we have body, query, path params
3627
+ const hasBody = ["POST", "PUT", "PATCH"].includes(parsed.method)
3628
+ && fn.argsType.kind === "object"
3629
+ && fn.argsType.properties["body"]
3630
+ && fn.argsType.properties["body"].kind === "object"
3631
+ && Object.keys(fn.argsType.properties["body"].properties).length > 0;
3632
+
3633
+ const hasQuery = fn.argsType.kind === "object"
3634
+ && fn.argsType.properties["query"]
3635
+ && fn.argsType.properties["query"].kind === "object"
3636
+ && Object.keys(fn.argsType.properties["query"].properties).length > 0;
3637
+
3638
+ const hasPathParams = parsed.pathParams.length > 0;
3639
+
3640
+ // Build function parameters
3641
+ const params: string[] = [];
3642
+ if (hasPathParams) {
3643
+ for (const p of parsed.pathParams) {
3644
+ params.push(`${p}: string`);
3645
+ }
3646
+ }
3647
+ if (hasBody) {
3648
+ params.push(`body: ${baseName}Body`);
3649
+ }
3650
+ if (hasQuery) {
3651
+ params.push(`query?: ${baseName}Query`);
3652
+ }
3653
+ params.push("config?: AxiosRequestConfig");
3654
+
3655
+ // Build URL path with interpolation
3656
+ let urlPath = parsed.path;
3657
+ for (const p of parsed.pathParams) {
3658
+ urlPath = urlPath.replace(`:${p}`, `\${${p}}`);
3659
+ }
3660
+ const urlExpr = hasPathParams ? `\`${urlPath}\`` : `"${parsed.path}"`;
3661
+
3662
+ // Build function body
3663
+ const method = parsed.method.toLowerCase();
3664
+ sections.push(`/** ${parsed.method} ${parsed.path} */`);
3665
+ sections.push(`export async function ${funcName}(${params.join(", ")}): Promise<${responseName}> {`);
3666
+
3667
+ if (hasQuery) {
3668
+ sections.push(" const requestConfig: AxiosRequestConfig = { ...config, params: query };");
3669
+ }
3670
+
3671
+ const configArg = hasQuery ? "requestConfig" : "config";
3672
+
3673
+ if (hasBody) {
3674
+ sections.push(` const { data } = await _instance.${method}<${responseName}>(${urlExpr}, body, ${configArg});`);
3675
+ } else if (method === "delete") {
3676
+ sections.push(` const { data } = await _instance.${method}<${responseName}>(${urlExpr}, ${configArg});`);
3677
+ } else {
3678
+ sections.push(` const { data } = await _instance.${method}<${responseName}>(${urlExpr}, ${configArg});`);
3679
+ }
3680
+
3681
+ sections.push(" return data;");
3682
+ sections.push("}");
3683
+ sections.push("");
3684
+ }
3685
+
3686
+ return sections.join("\n").trimEnd() + "\n";
3687
+ }
3688
+
3689
+ // ── Inline annotation generation ──
3690
+
3691
+ /**
3692
+ * Generate compact inline type annotations for annotating source code.
3693
+ * Unlike generateFunctionTypes which emits full interfaces, this produces
3694
+ * minimal inline annotations suitable for inserting into source files.
3695
+ */
3696
+ export function generateInlineAnnotations(
3697
+ functions: Array<{
3698
+ name: string;
3699
+ argsType: TypeNode;
3700
+ returnType: TypeNode;
3701
+ module?: string;
3702
+ }>,
3703
+ language: "typescript" | "python" = "typescript",
3704
+ ): Record<string, { params: Array<{ name: string; type: string }>; returnType: string }> {
3705
+ const result: Record<string, { params: Array<{ name: string; type: string }>; returnType: string }> = {};
3706
+
3707
+ for (const fn of functions) {
3708
+ const extracted: ExtractedInterface[] = [];
3709
+ const pyExtracted: Array<{ name: string; node: Extract<TypeNode, { kind: "object" }> }> = [];
3710
+
3711
+ // Build param types
3712
+ const params: Array<{ name: string; type: string }> = [];
3713
+
3714
+ if (fn.argsType.kind === "tuple") {
3715
+ for (let i = 0; i < fn.argsType.elements.length; i++) {
3716
+ const el = fn.argsType.elements[i];
3717
+ const typeStr = language === "python"
3718
+ ? typeNodeToPythonInline(el)
3719
+ : typeNodeToTSInline(el);
3720
+ params.push({ name: `arg${i}`, type: typeStr });
3721
+ }
3722
+ } else if (fn.argsType.kind === "object") {
3723
+ for (const key of Object.keys(fn.argsType.properties)) {
3724
+ const typeStr = language === "python"
3725
+ ? typeNodeToPythonInline(fn.argsType.properties[key])
3726
+ : typeNodeToTSInline(fn.argsType.properties[key]);
3727
+ params.push({ name: key, type: typeStr });
3728
+ }
3729
+ }
3730
+
3731
+ // Build return type
3732
+ const returnTypeStr = language === "python"
3733
+ ? typeNodeToPythonInline(fn.returnType)
3734
+ : typeNodeToTSInline(fn.returnType);
3735
+
3736
+ result[fn.name] = { params, returnType: returnTypeStr };
3737
+ }
3738
+
3739
+ return result;
3740
+ }
3741
+
3742
+ /**
3743
+ * Convert a TypeNode to an inline TypeScript type string (no extraction, no interfaces).
3744
+ */
3745
+ function typeNodeToTSInline(node: TypeNode, depth: number = 0): string {
3746
+ if (depth > 5) return "any";
3747
+
3748
+ switch (node.kind) {
3749
+ case "primitive":
3750
+ return node.name;
3751
+ case "unknown":
3752
+ return "unknown";
3753
+ case "array": {
3754
+ const inner = typeNodeToTSInline(node.element, depth + 1);
3755
+ return node.element.kind === "union" || node.element.kind === "function"
3756
+ ? `Array<${inner}>`
3757
+ : `${inner}[]`;
3758
+ }
3759
+ case "tuple": {
3760
+ const elements = node.elements.map(el => typeNodeToTSInline(el, depth + 1));
3761
+ return `[${elements.join(", ")}]`;
3762
+ }
3763
+ case "union": {
3764
+ const members = node.members.map(m => typeNodeToTSInline(m, depth + 1));
3765
+ return members.join(" | ");
3766
+ }
3767
+ case "map": {
3768
+ const k = typeNodeToTSInline(node.key, depth + 1);
3769
+ const v = typeNodeToTSInline(node.value, depth + 1);
3770
+ return `Map<${k}, ${v}>`;
3771
+ }
3772
+ case "set":
3773
+ return `Set<${typeNodeToTSInline(node.element, depth + 1)}>`;
3774
+ case "promise":
3775
+ return `Promise<${typeNodeToTSInline(node.resolved, depth + 1)}>`;
3776
+ case "function": {
3777
+ const params = node.params.map((p, i) =>
3778
+ `arg${i}: ${typeNodeToTSInline(p, depth + 1)}`
3779
+ );
3780
+ return `(${params.join(", ")}) => ${typeNodeToTSInline(node.returnType, depth + 1)}`;
3781
+ }
3782
+ case "object": {
3783
+ const keys = Object.keys(node.properties);
3784
+ if (keys.length === 0) return "Record<string, never>";
3785
+ const entries = keys.map(key =>
3786
+ `${key}: ${typeNodeToTSInline(node.properties[key], depth + 1)}`
3787
+ );
3788
+ return `{ ${entries.join("; ")} }`;
3789
+ }
3790
+ }
3791
+ }
3792
+
3793
+ /**
3794
+ * Convert a TypeNode to an inline Python type string.
3795
+ */
3796
+ function typeNodeToPythonInline(node: TypeNode, depth: number = 0): string {
3797
+ if (depth > 5) return "Any";
3798
+
3799
+ switch (node.kind) {
3800
+ case "primitive":
3801
+ switch (node.name) {
3802
+ case "string": return "str";
3803
+ case "number": return "float";
3804
+ case "boolean": return "bool";
3805
+ case "null": return "None";
3806
+ case "undefined": return "None";
3807
+ case "bigint": return "int";
3808
+ case "symbol": return "str";
3809
+ default: return "Any";
3810
+ }
3811
+ case "unknown":
3812
+ return "Any";
3813
+ case "array":
3814
+ return `list[${typeNodeToPythonInline(node.element, depth + 1)}]`;
3815
+ case "tuple": {
3816
+ const elements = node.elements.map(el => typeNodeToPythonInline(el, depth + 1));
3817
+ return `tuple[${elements.join(", ")}]`;
3818
+ }
3819
+ case "union": {
3820
+ const members = node.members.map(m => typeNodeToPythonInline(m, depth + 1));
3821
+ if (members.length === 2 && members.includes("None")) {
3822
+ const nonNone = members.find(m => m !== "None");
3823
+ return `${nonNone} | None`;
3824
+ }
3825
+ return members.join(" | ");
3826
+ }
3827
+ case "map": {
3828
+ const k = typeNodeToPythonInline(node.key, depth + 1);
3829
+ const v = typeNodeToPythonInline(node.value, depth + 1);
3830
+ return `dict[${k}, ${v}]`;
3831
+ }
3832
+ case "set":
3833
+ return `set[${typeNodeToPythonInline(node.element, depth + 1)}]`;
3834
+ case "promise":
3835
+ return typeNodeToPythonInline(node.resolved, depth + 1);
3836
+ case "function": {
3837
+ const params = node.params.map(p => typeNodeToPythonInline(p, depth + 1));
3838
+ const ret = typeNodeToPythonInline(node.returnType, depth + 1);
3839
+ return `Callable[[${params.join(", ")}], ${ret}]`;
3840
+ }
3841
+ case "object": {
3842
+ const keys = Object.keys(node.properties);
3843
+ if (keys.length === 0) return "dict[str, Any]";
3844
+ const entries = keys.map(key =>
3845
+ `"${key}": ${typeNodeToPythonInline(node.properties[key], depth + 1)}`
3846
+ );
3847
+ return `TypedDict("_", {${entries.join(", ")}})`;
3848
+ }
3849
+ }
3850
+ }
3851
+
3852
+ // Public re-export for single-node conversion (used in tests)
3853
+ export { typeNodeToTS as typeNodeToTSPublic };