trickle-cli 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 (125) hide show
  1. package/dist/api-client.d.ts +208 -0
  2. package/dist/api-client.js +237 -0
  3. package/dist/commands/annotate.d.ts +6 -0
  4. package/dist/commands/annotate.js +433 -0
  5. package/dist/commands/audit.d.ts +7 -0
  6. package/dist/commands/audit.js +82 -0
  7. package/dist/commands/auto.d.ts +8 -0
  8. package/dist/commands/auto.js +268 -0
  9. package/dist/commands/capture.d.ts +14 -0
  10. package/dist/commands/capture.js +271 -0
  11. package/dist/commands/check.d.ts +6 -0
  12. package/dist/commands/check.js +408 -0
  13. package/dist/commands/codegen.d.ts +21 -0
  14. package/dist/commands/codegen.js +129 -0
  15. package/dist/commands/coverage.d.ts +13 -0
  16. package/dist/commands/coverage.js +126 -0
  17. package/dist/commands/dashboard.d.ts +1 -0
  18. package/dist/commands/dashboard.js +83 -0
  19. package/dist/commands/dev.d.ts +14 -0
  20. package/dist/commands/dev.js +319 -0
  21. package/dist/commands/diff.d.ts +7 -0
  22. package/dist/commands/diff.js +79 -0
  23. package/dist/commands/docs.d.ts +13 -0
  24. package/dist/commands/docs.js +383 -0
  25. package/dist/commands/errors.d.ts +7 -0
  26. package/dist/commands/errors.js +180 -0
  27. package/dist/commands/export.d.ts +18 -0
  28. package/dist/commands/export.js +238 -0
  29. package/dist/commands/functions.d.ts +6 -0
  30. package/dist/commands/functions.js +71 -0
  31. package/dist/commands/infer.d.ts +14 -0
  32. package/dist/commands/infer.js +275 -0
  33. package/dist/commands/init.d.ts +5 -0
  34. package/dist/commands/init.js +395 -0
  35. package/dist/commands/mock.d.ts +5 -0
  36. package/dist/commands/mock.js +232 -0
  37. package/dist/commands/openapi.d.ts +8 -0
  38. package/dist/commands/openapi.js +82 -0
  39. package/dist/commands/overview.d.ts +11 -0
  40. package/dist/commands/overview.js +266 -0
  41. package/dist/commands/pack.d.ts +11 -0
  42. package/dist/commands/pack.js +133 -0
  43. package/dist/commands/proxy.d.ts +13 -0
  44. package/dist/commands/proxy.js +312 -0
  45. package/dist/commands/replay.d.ts +14 -0
  46. package/dist/commands/replay.js +289 -0
  47. package/dist/commands/run.d.ts +17 -0
  48. package/dist/commands/run.js +997 -0
  49. package/dist/commands/sample.d.ts +13 -0
  50. package/dist/commands/sample.js +260 -0
  51. package/dist/commands/search.d.ts +5 -0
  52. package/dist/commands/search.js +80 -0
  53. package/dist/commands/stubs.d.ts +6 -0
  54. package/dist/commands/stubs.js +187 -0
  55. package/dist/commands/tail.d.ts +4 -0
  56. package/dist/commands/tail.js +76 -0
  57. package/dist/commands/test-gen.d.ts +13 -0
  58. package/dist/commands/test-gen.js +237 -0
  59. package/dist/commands/trace.d.ts +14 -0
  60. package/dist/commands/trace.js +417 -0
  61. package/dist/commands/types.d.ts +7 -0
  62. package/dist/commands/types.js +128 -0
  63. package/dist/commands/unpack.d.ts +11 -0
  64. package/dist/commands/unpack.js +166 -0
  65. package/dist/commands/validate.d.ts +13 -0
  66. package/dist/commands/validate.js +310 -0
  67. package/dist/commands/watch.d.ts +9 -0
  68. package/dist/commands/watch.js +267 -0
  69. package/dist/config.d.ts +1 -0
  70. package/dist/config.js +66 -0
  71. package/dist/formatters/diff-formatter.d.ts +5 -0
  72. package/dist/formatters/diff-formatter.js +43 -0
  73. package/dist/formatters/type-formatter.d.ts +22 -0
  74. package/dist/formatters/type-formatter.js +135 -0
  75. package/dist/index.d.ts +2 -0
  76. package/dist/index.js +419 -0
  77. package/dist/local-codegen.d.ts +22 -0
  78. package/dist/local-codegen.js +762 -0
  79. package/dist/ui/badges.d.ts +16 -0
  80. package/dist/ui/badges.js +71 -0
  81. package/dist/ui/helpers.d.ts +13 -0
  82. package/dist/ui/helpers.js +85 -0
  83. package/package.json +23 -0
  84. package/src/api-client.ts +407 -0
  85. package/src/commands/annotate.ts +450 -0
  86. package/src/commands/audit.ts +103 -0
  87. package/src/commands/auto.ts +268 -0
  88. package/src/commands/capture.ts +257 -0
  89. package/src/commands/check.ts +437 -0
  90. package/src/commands/codegen.ts +128 -0
  91. package/src/commands/coverage.ts +170 -0
  92. package/src/commands/dashboard.ts +46 -0
  93. package/src/commands/dev.ts +323 -0
  94. package/src/commands/diff.ts +99 -0
  95. package/src/commands/docs.ts +392 -0
  96. package/src/commands/errors.ts +205 -0
  97. package/src/commands/export.ts +287 -0
  98. package/src/commands/functions.ts +81 -0
  99. package/src/commands/infer.ts +260 -0
  100. package/src/commands/init.ts +419 -0
  101. package/src/commands/mock.ts +220 -0
  102. package/src/commands/openapi.ts +53 -0
  103. package/src/commands/overview.ts +310 -0
  104. package/src/commands/pack.ts +139 -0
  105. package/src/commands/proxy.ts +314 -0
  106. package/src/commands/replay.ts +356 -0
  107. package/src/commands/run.ts +1190 -0
  108. package/src/commands/sample.ts +259 -0
  109. package/src/commands/search.ts +107 -0
  110. package/src/commands/stubs.ts +211 -0
  111. package/src/commands/tail.ts +94 -0
  112. package/src/commands/test-gen.ts +236 -0
  113. package/src/commands/trace.ts +440 -0
  114. package/src/commands/types.ts +161 -0
  115. package/src/commands/unpack.ts +179 -0
  116. package/src/commands/validate.ts +368 -0
  117. package/src/commands/watch.ts +277 -0
  118. package/src/config.ts +38 -0
  119. package/src/formatters/diff-formatter.ts +51 -0
  120. package/src/formatters/type-formatter.ts +161 -0
  121. package/src/index.ts +454 -0
  122. package/src/local-codegen.ts +859 -0
  123. package/src/ui/badges.ts +66 -0
  124. package/src/ui/helpers.ts +80 -0
  125. package/tsconfig.json +8 -0
@@ -0,0 +1,859 @@
1
+ /**
2
+ * Local codegen: reads .trickle/observations.jsonl and generates
3
+ * type stubs without needing the backend running.
4
+ *
5
+ * Used by `trickle run` in offline/local mode.
6
+ */
7
+
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+
11
+ interface TypeNode {
12
+ kind: string;
13
+ name?: string;
14
+ element?: TypeNode;
15
+ elements?: TypeNode[];
16
+ properties?: Record<string, TypeNode>;
17
+ members?: TypeNode[];
18
+ params?: TypeNode[];
19
+ returnType?: TypeNode;
20
+ resolved?: TypeNode;
21
+ key?: TypeNode;
22
+ value?: TypeNode;
23
+ }
24
+
25
+ interface IngestPayload {
26
+ functionName: string;
27
+ module: string;
28
+ language: string;
29
+ environment: string;
30
+ typeHash: string;
31
+ argsType: TypeNode;
32
+ returnType: TypeNode;
33
+ paramNames?: string[];
34
+ sampleInput?: unknown;
35
+ sampleOutput?: unknown;
36
+ }
37
+
38
+ interface TypeVariant {
39
+ argsType: TypeNode;
40
+ returnType: TypeNode;
41
+ paramNames?: string[];
42
+ }
43
+
44
+ interface FunctionTypeData {
45
+ name: string;
46
+ argsType: TypeNode;
47
+ returnType: TypeNode;
48
+ module?: string;
49
+ paramNames?: string[];
50
+ variants?: TypeVariant[];
51
+ }
52
+
53
+ // ── Type merging ──
54
+
55
+ /**
56
+ * Merge two TypeNodes into a single type that represents both.
57
+ *
58
+ * - Same primitive → keep as-is
59
+ * - Different primitives → union
60
+ * - Two objects → merge properties (missing props become optional via union with undefined)
61
+ * - Two arrays → merge element types
62
+ * - Two tuples with same length → merge positionally
63
+ * - Anything else → union
64
+ */
65
+ function mergeTypeNodes(a: TypeNode, b: TypeNode): TypeNode {
66
+ // Identical nodes
67
+ if (typeNodeKey(a) === typeNodeKey(b)) return a;
68
+
69
+ // Both objects: merge properties
70
+ if (a.kind === "object" && b.kind === "object") {
71
+ const aProps = a.properties || {};
72
+ const bProps = b.properties || {};
73
+ const allKeys = new Set([...Object.keys(aProps), ...Object.keys(bProps)]);
74
+ const merged: Record<string, TypeNode> = {};
75
+
76
+ for (const key of allKeys) {
77
+ const inA = key in aProps;
78
+ const inB = key in bProps;
79
+
80
+ if (inA && inB) {
81
+ // Property exists in both — merge their types
82
+ merged[key] = mergeTypeNodes(aProps[key], bProps[key]);
83
+ } else if (inA) {
84
+ // Only in A — mark as optional (union with undefined)
85
+ merged[key] = makeOptional(aProps[key]);
86
+ } else {
87
+ // Only in B — mark as optional
88
+ merged[key] = makeOptional(bProps[key]);
89
+ }
90
+ }
91
+
92
+ return { kind: "object", properties: merged };
93
+ }
94
+
95
+ // Both arrays: merge element types
96
+ if (a.kind === "array" && b.kind === "array" && a.element && b.element) {
97
+ return { kind: "array", element: mergeTypeNodes(a.element, b.element) };
98
+ }
99
+
100
+ // Both tuples: merge positionally, handle different lengths
101
+ if (a.kind === "tuple" && b.kind === "tuple") {
102
+ const aEls = a.elements || [];
103
+ const bEls = b.elements || [];
104
+ if (aEls.length === bEls.length) {
105
+ return {
106
+ kind: "tuple",
107
+ elements: aEls.map((el, i) => mergeTypeNodes(el, bEls[i])),
108
+ };
109
+ }
110
+ // Different lengths: merge common prefix, make extra elements optional
111
+ const shorter = aEls.length < bEls.length ? aEls : bEls;
112
+ const longer = aEls.length < bEls.length ? bEls : aEls;
113
+ const merged: TypeNode[] = [];
114
+ for (let i = 0; i < longer.length; i++) {
115
+ if (i < shorter.length) {
116
+ merged.push(mergeTypeNodes(shorter[i], longer[i]));
117
+ } else {
118
+ merged.push(makeOptional(longer[i]));
119
+ }
120
+ }
121
+ return { kind: "tuple", elements: merged };
122
+ }
123
+
124
+ // Both unions: flatten and deduplicate
125
+ if (a.kind === "union" && b.kind === "union") {
126
+ return deduplicateUnion([...(a.members || []), ...(b.members || [])]);
127
+ }
128
+
129
+ // One is a union: add the other as a member
130
+ if (a.kind === "union") {
131
+ return deduplicateUnion([...(a.members || []), b]);
132
+ }
133
+ if (b.kind === "union") {
134
+ return deduplicateUnion([a, ...(b.members || [])]);
135
+ }
136
+
137
+ // Different kinds: create union
138
+ return deduplicateUnion([a, b]);
139
+ }
140
+
141
+ /**
142
+ * Make a type optional by adding undefined to it (for properties that
143
+ * don't appear in every observation).
144
+ */
145
+ function makeOptional(node: TypeNode): TypeNode {
146
+ // Already has undefined
147
+ if (node.kind === "primitive" && node.name === "undefined") return node;
148
+ if (node.kind === "union") {
149
+ const members = node.members || [];
150
+ if (members.some((m) => m.kind === "primitive" && m.name === "undefined")) {
151
+ return node;
152
+ }
153
+ return {
154
+ kind: "union",
155
+ members: [...members, { kind: "primitive", name: "undefined" }],
156
+ };
157
+ }
158
+ return {
159
+ kind: "union",
160
+ members: [node, { kind: "primitive", name: "undefined" }],
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Create a union type with deduplicated members.
166
+ */
167
+ function deduplicateUnion(members: TypeNode[]): TypeNode {
168
+ const seen = new Set<string>();
169
+ const unique: TypeNode[] = [];
170
+ for (const m of members) {
171
+ // Flatten nested unions
172
+ if (m.kind === "union") {
173
+ for (const inner of m.members || []) {
174
+ const key = typeNodeKey(inner);
175
+ if (!seen.has(key)) {
176
+ seen.add(key);
177
+ unique.push(inner);
178
+ }
179
+ }
180
+ } else {
181
+ const key = typeNodeKey(m);
182
+ if (!seen.has(key)) {
183
+ seen.add(key);
184
+ unique.push(m);
185
+ }
186
+ }
187
+ }
188
+ if (unique.length === 1) return unique[0];
189
+ return { kind: "union", members: unique };
190
+ }
191
+
192
+ /**
193
+ * Generate a string key for a TypeNode (for deduplication).
194
+ */
195
+ function typeNodeKey(node: TypeNode): string {
196
+ switch (node.kind) {
197
+ case "primitive":
198
+ return `p:${node.name}`;
199
+ case "unknown":
200
+ return "unknown";
201
+ case "array":
202
+ return `a:${typeNodeKey(node.element!)}`;
203
+ case "tuple":
204
+ return `t:[${(node.elements || []).map(typeNodeKey).join(",")}]`;
205
+ case "object": {
206
+ const props = node.properties || {};
207
+ const entries = Object.keys(props)
208
+ .sort()
209
+ .map((k) => `${k}:${typeNodeKey(props[k])}`);
210
+ return `o:{${entries.join(",")}}`;
211
+ }
212
+ case "union": {
213
+ const members = (node.members || []).map(typeNodeKey).sort();
214
+ return `u:(${members.join("|")})`;
215
+ }
216
+ default:
217
+ return JSON.stringify(node);
218
+ }
219
+ }
220
+
221
+ // ── Read and merge observations ──
222
+
223
+ function readObservations(jsonlPath: string): FunctionTypeData[] {
224
+ if (!fs.existsSync(jsonlPath)) return [];
225
+
226
+ const content = fs.readFileSync(jsonlPath, "utf-8");
227
+ const lines = content.trim().split("\n").filter(Boolean);
228
+
229
+ // Collect all observations per function, then merge types
230
+ const byFunction = new Map<string, { payloads: IngestPayload[] }>();
231
+ for (const line of lines) {
232
+ try {
233
+ const payload = JSON.parse(line) as IngestPayload;
234
+ if (payload.functionName && payload.argsType && payload.returnType) {
235
+ if (!byFunction.has(payload.functionName)) {
236
+ byFunction.set(payload.functionName, { payloads: [] });
237
+ }
238
+ byFunction.get(payload.functionName)!.payloads.push(payload);
239
+ }
240
+ } catch {
241
+ // Skip malformed lines
242
+ }
243
+ }
244
+
245
+ const results: FunctionTypeData[] = [];
246
+ for (const [name, { payloads }] of byFunction) {
247
+ // Start with the first observation, merge subsequent ones
248
+ let mergedArgs = payloads[0].argsType;
249
+ let mergedReturn = payloads[0].returnType;
250
+
251
+ for (let i = 1; i < payloads.length; i++) {
252
+ // Only merge if the type hash differs (different shape)
253
+ if (payloads[i].typeHash !== payloads[0].typeHash) {
254
+ mergedArgs = mergeTypeNodes(mergedArgs, payloads[i].argsType);
255
+ mergedReturn = mergeTypeNodes(mergedReturn, payloads[i].returnType);
256
+ }
257
+ }
258
+
259
+ // Use paramNames from the latest payload that has them
260
+ const paramNames = payloads.reduce<string[] | undefined>(
261
+ (acc, p) => p.paramNames && p.paramNames.length > 0 ? p.paramNames : acc,
262
+ undefined,
263
+ );
264
+
265
+ // Collect unique type variants (by typeHash) for overload generation
266
+ const seenHashes = new Set<string>();
267
+ const variants: TypeVariant[] = [];
268
+ for (const p of payloads) {
269
+ if (!seenHashes.has(p.typeHash)) {
270
+ seenHashes.add(p.typeHash);
271
+ variants.push({
272
+ argsType: p.argsType,
273
+ returnType: p.returnType,
274
+ paramNames: p.paramNames,
275
+ });
276
+ }
277
+ }
278
+
279
+ results.push({
280
+ name,
281
+ argsType: mergedArgs,
282
+ returnType: mergedReturn,
283
+ module: payloads[payloads.length - 1].module, // use latest module
284
+ paramNames,
285
+ variants: variants.length >= 2 && variants.length <= 5 ? variants : undefined,
286
+ });
287
+ }
288
+
289
+ return results;
290
+ }
291
+
292
+ // ── Naming helpers ──
293
+
294
+ function toPascalCase(name: string): string {
295
+ return name
296
+ .replace(/[^a-zA-Z0-9]+/g, " ")
297
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
298
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
299
+ .trim()
300
+ .split(/\s+/)
301
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
302
+ .join("");
303
+ }
304
+
305
+ function toSnakeCase(name: string): string {
306
+ return name
307
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
308
+ .replace(/[-\s]+/g, "_")
309
+ .toLowerCase();
310
+ }
311
+
312
+ // ── TypeScript generation ──
313
+
314
+ interface ExtractedInterface {
315
+ name: string;
316
+ node: TypeNode;
317
+ }
318
+
319
+ function typeNodeToTS(
320
+ node: TypeNode,
321
+ extracted: ExtractedInterface[],
322
+ parentName: string,
323
+ propName: string | undefined,
324
+ indent: number,
325
+ ): string {
326
+ switch (node.kind) {
327
+ case "primitive":
328
+ return node.name || "unknown";
329
+ case "unknown":
330
+ return "unknown";
331
+ case "array": {
332
+ const inner = typeNodeToTS(node.element!, extracted, parentName, propName, indent);
333
+ return node.element!.kind === "union" || node.element!.kind === "function"
334
+ ? `Array<${inner}>`
335
+ : `${inner}[]`;
336
+ }
337
+ case "tuple": {
338
+ const elements = (node.elements || []).map((el, i) =>
339
+ typeNodeToTS(el, extracted, parentName, `${propName || "el"}${i}`, indent),
340
+ );
341
+ return `[${elements.join(", ")}]`;
342
+ }
343
+ case "union": {
344
+ const members = (node.members || []).map((m) =>
345
+ typeNodeToTS(m, extracted, parentName, propName, indent),
346
+ );
347
+ return members.join(" | ");
348
+ }
349
+ case "map": {
350
+ const k = typeNodeToTS(node.key!, extracted, parentName, "key", indent);
351
+ const v = typeNodeToTS(node.value!, extracted, parentName, "value", indent);
352
+ return `Map<${k}, ${v}>`;
353
+ }
354
+ case "set":
355
+ return `Set<${typeNodeToTS(node.element!, extracted, parentName, propName, indent)}>`;
356
+ case "promise":
357
+ return `Promise<${typeNodeToTS(node.resolved!, extracted, parentName, propName, indent)}>`;
358
+ case "function": {
359
+ const params = (node.params || []).map(
360
+ (p, i) => `arg${i}: ${typeNodeToTS(p, extracted, parentName, `param${i}`, indent)}`,
361
+ );
362
+ const ret = typeNodeToTS(node.returnType!, extracted, parentName, "return", indent);
363
+ return `(${params.join(", ")}) => ${ret}`;
364
+ }
365
+ case "object": {
366
+ const keys = Object.keys(node.properties || {});
367
+ if (keys.length === 0) return "Record<string, never>";
368
+ if (keys.length > 2 && propName) {
369
+ const ifaceName = toPascalCase(parentName) + toPascalCase(propName);
370
+ if (!extracted.some((e) => e.name === ifaceName)) {
371
+ extracted.push({ name: ifaceName, node });
372
+ }
373
+ return ifaceName;
374
+ }
375
+ const pad = " ".repeat(indent + 1);
376
+ const closePad = " ".repeat(indent);
377
+ const entries = keys.map((key) => {
378
+ const val = typeNodeToTS(node.properties![key], extracted, parentName, key, indent + 1);
379
+ return `${pad}${key}: ${val};`;
380
+ });
381
+ return `{\n${entries.join("\n")}\n${closePad}}`;
382
+ }
383
+ default:
384
+ return "unknown";
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Check if a TypeNode is optional (union containing undefined).
390
+ * Returns { isOptional, innerType } where innerType has undefined stripped.
391
+ */
392
+ function extractOptional(node: TypeNode): { isOptional: boolean; innerType: TypeNode } {
393
+ if (node.kind !== "union") return { isOptional: false, innerType: node };
394
+ const members = node.members || [];
395
+ const hasUndefined = members.some(
396
+ (m) => m.kind === "primitive" && m.name === "undefined",
397
+ );
398
+ if (!hasUndefined) return { isOptional: false, innerType: node };
399
+
400
+ const withoutUndefined = members.filter(
401
+ (m) => !(m.kind === "primitive" && m.name === "undefined"),
402
+ );
403
+ if (withoutUndefined.length === 0) {
404
+ return { isOptional: true, innerType: { kind: "primitive", name: "undefined" } };
405
+ }
406
+ if (withoutUndefined.length === 1) {
407
+ return { isOptional: true, innerType: withoutUndefined[0] };
408
+ }
409
+ return { isOptional: true, innerType: { kind: "union", members: withoutUndefined } };
410
+ }
411
+
412
+ function renderInterface(
413
+ name: string,
414
+ node: TypeNode,
415
+ allExtracted: ExtractedInterface[],
416
+ ): string {
417
+ const keys = Object.keys(node.properties || {});
418
+ const lines: string[] = [`export interface ${name} {`];
419
+ for (const key of keys) {
420
+ const propType = node.properties![key];
421
+ const { isOptional, innerType } = extractOptional(propType);
422
+ const val = typeNodeToTS(innerType, allExtracted, name, key, 1);
423
+ if (isOptional) {
424
+ lines.push(` ${key}?: ${val};`);
425
+ } else {
426
+ lines.push(` ${key}: ${val};`);
427
+ }
428
+ }
429
+ lines.push(`}`);
430
+ return lines.join("\n");
431
+ }
432
+
433
+ function generateTsForFunction(fn: FunctionTypeData): string {
434
+ const baseName = toPascalCase(fn.name);
435
+ const extracted: ExtractedInterface[] = [];
436
+ const lines: string[] = [];
437
+
438
+ // Determine args
439
+ let argEntries: Array<{ paramName: string; typeNode: TypeNode }> = [];
440
+ if (fn.argsType.kind === "tuple") {
441
+ const names = fn.paramNames || [];
442
+ argEntries = (fn.argsType.elements || []).map((el, i) => ({
443
+ paramName: names[i] || `arg${i}`,
444
+ typeNode: el,
445
+ }));
446
+ } else if (fn.argsType.kind === "object") {
447
+ for (const key of Object.keys(fn.argsType.properties || {})) {
448
+ argEntries.push({ paramName: key, typeNode: fn.argsType.properties![key] });
449
+ }
450
+ } else {
451
+ argEntries = [{ paramName: "input", typeNode: fn.argsType }];
452
+ }
453
+
454
+ const singleObjectArg =
455
+ argEntries.length === 1 && argEntries[0].typeNode.kind === "object";
456
+
457
+ // Input type
458
+ if (singleObjectArg) {
459
+ const inputName = `${baseName}Input`;
460
+ lines.push(`/**`);
461
+ lines.push(` * Input type for \`${fn.name}\``);
462
+ lines.push(` */`);
463
+ lines.push(renderInterface(inputName, argEntries[0].typeNode, extracted));
464
+ lines.push("");
465
+ } else if (argEntries.length > 1) {
466
+ for (const entry of argEntries) {
467
+ if (entry.typeNode.kind === "object" && Object.keys(entry.typeNode.properties || {}).length > 0) {
468
+ const typeName = `${baseName}${toPascalCase(entry.paramName)}`;
469
+ lines.push(renderInterface(typeName, entry.typeNode, extracted));
470
+ lines.push("");
471
+ }
472
+ }
473
+ }
474
+
475
+ // Output type
476
+ const outputName = `${baseName}Output`;
477
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties || {}).length > 0) {
478
+ lines.push(`/**`);
479
+ lines.push(` * Output type for \`${fn.name}\``);
480
+ lines.push(` */`);
481
+ lines.push(renderInterface(outputName, fn.returnType, extracted));
482
+ lines.push("");
483
+ } else {
484
+ const retStr = typeNodeToTS(fn.returnType, extracted, baseName, undefined, 0);
485
+ lines.push(`export type ${outputName} = ${retStr};`);
486
+ lines.push("");
487
+ }
488
+
489
+ // Extracted interfaces
490
+ const emitted = new Set<string>();
491
+ const extractedLines: string[] = [];
492
+ let cursor = 0;
493
+ while (cursor < extracted.length) {
494
+ const iface = extracted[cursor];
495
+ cursor++;
496
+ if (emitted.has(iface.name)) continue;
497
+ emitted.add(iface.name);
498
+ extractedLines.push(renderInterface(iface.name, iface.node, extracted));
499
+ extractedLines.push("");
500
+ }
501
+
502
+ // Function declaration
503
+ const funcIdent = baseName.charAt(0).toLowerCase() + baseName.slice(1);
504
+
505
+ const result: string[] = [];
506
+ if (extractedLines.length > 0) result.push(...extractedLines);
507
+ result.push(...lines);
508
+
509
+ // Generate overloads if we have multiple distinct type patterns
510
+ if (fn.variants && fn.variants.length >= 2) {
511
+ for (const variant of fn.variants) {
512
+ const vExt: ExtractedInterface[] = [];
513
+ const vRet = typeNodeToTS(variant.returnType, vExt, baseName, undefined, 0);
514
+ const vNames = variant.paramNames || fn.paramNames || [];
515
+ let vArgEntries: Array<{ paramName: string; typeNode: TypeNode }> = [];
516
+ if (variant.argsType.kind === "tuple") {
517
+ vArgEntries = (variant.argsType.elements || []).map((el, i) => ({
518
+ paramName: vNames[i] || `arg${i}`,
519
+ typeNode: el,
520
+ }));
521
+ }
522
+ const vParams = vArgEntries.map(e =>
523
+ `${e.paramName}: ${typeNodeToTS(e.typeNode, vExt, baseName, e.paramName, 0)}`
524
+ );
525
+ result.push(`export declare function ${funcIdent}(${vParams.join(", ")}): ${vRet};`);
526
+ }
527
+ } else {
528
+ let funcDecl: string;
529
+ if (singleObjectArg) {
530
+ funcDecl = `export declare function ${funcIdent}(input: ${baseName}Input): ${outputName};`;
531
+ } else {
532
+ const params = argEntries.map((entry) => {
533
+ if (entry.typeNode.kind === "object" && Object.keys(entry.typeNode.properties || {}).length > 0) {
534
+ return `${entry.paramName}: ${baseName}${toPascalCase(entry.paramName)}`;
535
+ }
536
+ // Check if parameter is optional (union with undefined)
537
+ const { isOptional, innerType } = extractOptional(entry.typeNode);
538
+ if (isOptional) {
539
+ return `${entry.paramName}?: ${typeNodeToTS(innerType, extracted, baseName, entry.paramName, 0)}`;
540
+ }
541
+ return `${entry.paramName}: ${typeNodeToTS(entry.typeNode, extracted, baseName, entry.paramName, 0)}`;
542
+ });
543
+ funcDecl = `export declare function ${funcIdent}(${params.join(", ")}): ${outputName};`;
544
+ }
545
+ result.push(funcDecl);
546
+ }
547
+ return result.join("\n");
548
+ }
549
+
550
+ // ── Python stub generation ──
551
+
552
+ function typeNodeToPython(
553
+ node: TypeNode,
554
+ extracted: Array<{ name: string; node: TypeNode }>,
555
+ parentName: string,
556
+ propName: string | undefined,
557
+ ): string {
558
+ switch (node.kind) {
559
+ case "primitive":
560
+ switch (node.name) {
561
+ case "string": return "str";
562
+ case "number": return "float";
563
+ case "boolean": return "bool";
564
+ case "null":
565
+ case "undefined": return "None";
566
+ case "bigint": return "int";
567
+ default: return "Any";
568
+ }
569
+ case "unknown": return "Any";
570
+ case "array":
571
+ return `List[${typeNodeToPython(node.element!, extracted, parentName, propName)}]`;
572
+ case "tuple": {
573
+ const els = (node.elements || []).map((el, i) =>
574
+ typeNodeToPython(el, extracted, parentName, `el${i}`),
575
+ );
576
+ return `Tuple[${els.join(", ")}]`;
577
+ }
578
+ case "union": {
579
+ const members = (node.members || []).map((m) =>
580
+ typeNodeToPython(m, extracted, parentName, propName),
581
+ );
582
+ if (members.length === 2 && members.includes("None")) {
583
+ const nonNone = members.find((m) => m !== "None");
584
+ return `Optional[${nonNone}]`;
585
+ }
586
+ return `Union[${members.join(", ")}]`;
587
+ }
588
+ case "map": {
589
+ const k = typeNodeToPython(node.key!, extracted, parentName, "key");
590
+ const v = typeNodeToPython(node.value!, extracted, parentName, "value");
591
+ return `Dict[${k}, ${v}]`;
592
+ }
593
+ case "set":
594
+ return `Set[${typeNodeToPython(node.element!, extracted, parentName, propName)}]`;
595
+ case "promise":
596
+ return `Awaitable[${typeNodeToPython(node.resolved!, extracted, parentName, propName)}]`;
597
+ case "function": {
598
+ const params = (node.params || []).map((p) =>
599
+ typeNodeToPython(p, extracted, parentName, undefined),
600
+ );
601
+ const ret = typeNodeToPython(node.returnType!, extracted, parentName, "return");
602
+ return `Callable[[${params.join(", ")}], ${ret}]`;
603
+ }
604
+ case "object": {
605
+ const keys = Object.keys(node.properties || {});
606
+ if (keys.length === 0) return "Dict[str, Any]";
607
+ if (propName) {
608
+ const className = toPascalCase(parentName) + toPascalCase(propName);
609
+ if (!extracted.some((e) => e.name === className)) {
610
+ extracted.push({ name: className, node });
611
+ }
612
+ return className;
613
+ }
614
+ return "Dict[str, Any]";
615
+ }
616
+ default: return "Any";
617
+ }
618
+ }
619
+
620
+ function renderPythonTypedDict(
621
+ name: string,
622
+ node: TypeNode,
623
+ extracted: Array<{ name: string; node: TypeNode }>,
624
+ ): string {
625
+ const keys = Object.keys(node.properties || {});
626
+ const lines: string[] = [];
627
+
628
+ // Check if we have any optional fields — if so, use total=False pattern
629
+ const hasOptional = keys.some((key) => {
630
+ const { isOptional } = extractOptional(node.properties![key]);
631
+ return isOptional;
632
+ });
633
+
634
+ if (hasOptional) {
635
+ // Separate required and optional fields
636
+ const required: string[] = [];
637
+ const optional: string[] = [];
638
+
639
+ for (const key of keys) {
640
+ const propType = node.properties![key];
641
+ const { isOptional, innerType } = extractOptional(propType);
642
+ const pyType = isOptional
643
+ ? typeNodeToPython(innerType, extracted, name, key)
644
+ : typeNodeToPython(propType, extracted, name, key);
645
+
646
+ if (isOptional) {
647
+ optional.push(` ${toSnakeCase(key)}: ${pyType}`);
648
+ } else {
649
+ required.push(` ${toSnakeCase(key)}: ${pyType}`);
650
+ }
651
+ }
652
+
653
+ // Use TypedDict with total=False for optional fields
654
+ if (required.length > 0 && optional.length > 0) {
655
+ // Need two TypedDicts: one for required, inherit for optional
656
+ const baseName = `_${name}Required`;
657
+ lines.push(`class ${baseName}(TypedDict):`);
658
+ lines.push(...required);
659
+ lines.push("");
660
+ lines.push("");
661
+ lines.push(`class ${name}(${baseName}, total=False):`);
662
+ lines.push(...optional);
663
+ } else if (optional.length > 0) {
664
+ lines.push(`class ${name}(TypedDict, total=False):`);
665
+ lines.push(...optional);
666
+ } else {
667
+ lines.push(`class ${name}(TypedDict):`);
668
+ lines.push(...required);
669
+ }
670
+ } else {
671
+ const entries = keys.map((key) => {
672
+ const pyType = typeNodeToPython(node.properties![key], extracted, name, key);
673
+ return ` ${toSnakeCase(key)}: ${pyType}`;
674
+ });
675
+
676
+ lines.push(`class ${name}(TypedDict):`);
677
+ if (entries.length === 0) {
678
+ lines.push(" pass");
679
+ } else {
680
+ lines.push(...entries);
681
+ }
682
+ }
683
+ return lines.join("\n");
684
+ }
685
+
686
+ function generatePyForFunction(fn: FunctionTypeData): string {
687
+ const baseName = toPascalCase(fn.name);
688
+ const extracted: Array<{ name: string; node: TypeNode }> = [];
689
+ const sections: string[] = [];
690
+
691
+ // Input type
692
+ if (fn.argsType.kind === "tuple" && fn.argsType.elements?.length === 1 && fn.argsType.elements[0].kind === "object") {
693
+ sections.push(renderPythonTypedDict(`${baseName}Input`, fn.argsType.elements[0], extracted));
694
+ } else if (fn.argsType.kind === "object") {
695
+ sections.push(renderPythonTypedDict(`${baseName}Input`, fn.argsType, extracted));
696
+ } else {
697
+ const pyType = typeNodeToPython(fn.argsType, extracted, baseName, undefined);
698
+ sections.push(`${baseName}Input = ${pyType}`);
699
+ }
700
+ sections.push("");
701
+ sections.push("");
702
+
703
+ // Output type
704
+ if (fn.returnType.kind === "object" && Object.keys(fn.returnType.properties || {}).length > 0) {
705
+ sections.push(renderPythonTypedDict(`${baseName}Output`, fn.returnType, extracted));
706
+ } else {
707
+ const pyType = typeNodeToPython(fn.returnType, extracted, baseName, undefined);
708
+ sections.push(`${baseName}Output = ${pyType}`);
709
+ }
710
+
711
+ return sections.join("\n");
712
+ }
713
+
714
+ // ── Public API ──
715
+
716
+ /**
717
+ * Generate type stubs from a .trickle/observations.jsonl file.
718
+ * Returns { ts, python } content strings, grouped by module.
719
+ */
720
+ export function generateFromJsonl(jsonlPath: string): Record<string, { ts: string; python: string }> {
721
+ const functions = readObservations(jsonlPath);
722
+ if (functions.length === 0) return {};
723
+
724
+ // Group by module
725
+ const byModule = new Map<string, FunctionTypeData[]>();
726
+ for (const fn of functions) {
727
+ const mod = fn.module || "_default";
728
+ if (!byModule.has(mod)) byModule.set(mod, []);
729
+ byModule.get(mod)!.push(fn);
730
+ }
731
+
732
+ const result: Record<string, { ts: string; python: string }> = {};
733
+
734
+ for (const [mod, fns] of byModule) {
735
+ // TypeScript
736
+ const tsSections: string[] = [
737
+ "// Auto-generated by trickle from runtime type observations (local mode)",
738
+ `// Generated at ${new Date().toISOString()}`,
739
+ "// Do not edit manually — re-run your code with trickle to update",
740
+ "",
741
+ ];
742
+ for (const fn of fns) {
743
+ tsSections.push(generateTsForFunction(fn));
744
+ tsSections.push("");
745
+ }
746
+
747
+ // Python
748
+ const pySections: string[] = [
749
+ "# Auto-generated by trickle from runtime type observations (local mode)",
750
+ `# Generated at ${new Date().toISOString()}`,
751
+ "# Do not edit manually — re-run your code with trickle to update",
752
+ "",
753
+ "from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, TypedDict, Union, overload",
754
+ "",
755
+ "",
756
+ ];
757
+ for (const fn of fns) {
758
+ pySections.push(generatePyForFunction(fn));
759
+ pySections.push("");
760
+ pySections.push("");
761
+ }
762
+
763
+ result[mod] = {
764
+ ts: tsSections.join("\n").trimEnd() + "\n",
765
+ python: pySections.join("\n").trimEnd() + "\n",
766
+ };
767
+ }
768
+
769
+ return result;
770
+ }
771
+
772
+ /**
773
+ * Generate sidecar type files from local observations.
774
+ * Writes .d.ts or .pyi files next to the source file.
775
+ */
776
+ export function generateLocalStubs(
777
+ sourceFile: string,
778
+ jsonlPath?: string,
779
+ ): { written: string[]; functionCount: number } {
780
+ const trickleDir = jsonlPath
781
+ ? path.dirname(jsonlPath)
782
+ : path.join(process.cwd(), ".trickle");
783
+ const obsPath = jsonlPath || path.join(trickleDir, "observations.jsonl");
784
+
785
+ const stubs = generateFromJsonl(obsPath);
786
+ const written: string[] = [];
787
+ let functionCount = 0;
788
+
789
+ const ext = path.extname(sourceFile).toLowerCase();
790
+ const isPython = ext === ".py";
791
+ const dir = path.dirname(sourceFile);
792
+ const baseName = path.basename(sourceFile, ext);
793
+ const normalizedBase = baseName.replace(/[-_]/g, "").toLowerCase();
794
+
795
+ for (const [mod, content] of Object.entries(stubs)) {
796
+ const normalizedMod = mod.replace(/[-_]/g, "").toLowerCase();
797
+
798
+ // Match module name to source file name
799
+ if (normalizedMod === normalizedBase || mod === "_default") {
800
+ const stubExt = isPython ? ".pyi" : ".d.ts";
801
+ const stubPath = path.join(dir, `${baseName}${stubExt}`);
802
+ const stubContent = isPython ? content.python : content.ts;
803
+
804
+ fs.writeFileSync(stubPath, stubContent, "utf-8");
805
+ written.push(stubPath);
806
+
807
+ // Count functions from observations
808
+ const functions = readObservations(obsPath);
809
+ functionCount = functions.length;
810
+ break;
811
+ }
812
+ }
813
+
814
+ // If no module matched but we have stubs, write them all under the source file name
815
+ if (written.length === 0 && Object.keys(stubs).length > 0) {
816
+ const allFunctions = readObservations(obsPath);
817
+ functionCount = allFunctions.length;
818
+
819
+ if (allFunctions.length > 0) {
820
+ const stubExt = isPython ? ".pyi" : ".d.ts";
821
+ const stubPath = path.join(dir, `${baseName}${stubExt}`);
822
+
823
+ // Generate combined stubs for all functions
824
+ if (isPython) {
825
+ const sections = [
826
+ "# Auto-generated by trickle from runtime type observations (local mode)",
827
+ `# Generated at ${new Date().toISOString()}`,
828
+ "# Do not edit manually — re-run your code with trickle to update",
829
+ "",
830
+ "from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, TypedDict, Union, overload",
831
+ "",
832
+ "",
833
+ ];
834
+ for (const fn of allFunctions) {
835
+ sections.push(generatePyForFunction(fn));
836
+ sections.push("");
837
+ sections.push("");
838
+ }
839
+ fs.writeFileSync(stubPath, sections.join("\n").trimEnd() + "\n", "utf-8");
840
+ } else {
841
+ const sections = [
842
+ "// Auto-generated by trickle from runtime type observations (local mode)",
843
+ `// Generated at ${new Date().toISOString()}`,
844
+ "// Do not edit manually — re-run your code with trickle to update",
845
+ "",
846
+ ];
847
+ for (const fn of allFunctions) {
848
+ sections.push(generateTsForFunction(fn));
849
+ sections.push("");
850
+ }
851
+ fs.writeFileSync(stubPath, sections.join("\n").trimEnd() + "\n", "utf-8");
852
+ }
853
+
854
+ written.push(stubPath);
855
+ }
856
+ }
857
+
858
+ return { written, functionCount };
859
+ }