trickle-observe 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 (57) hide show
  1. package/auto-env.js +13 -0
  2. package/auto-esm.mjs +128 -0
  3. package/auto.js +3 -0
  4. package/dist/auto-codegen.d.ts +29 -0
  5. package/dist/auto-codegen.js +999 -0
  6. package/dist/auto-register.d.ts +16 -0
  7. package/dist/auto-register.js +99 -0
  8. package/dist/cache.d.ts +27 -0
  9. package/dist/cache.js +52 -0
  10. package/dist/env-detect.d.ts +5 -0
  11. package/dist/env-detect.js +35 -0
  12. package/dist/express.d.ts +44 -0
  13. package/dist/express.js +342 -0
  14. package/dist/fetch-observer.d.ts +24 -0
  15. package/dist/fetch-observer.js +217 -0
  16. package/dist/index.d.ts +64 -0
  17. package/dist/index.js +172 -0
  18. package/dist/observe-register.d.ts +29 -0
  19. package/dist/observe-register.js +455 -0
  20. package/dist/observe.d.ts +44 -0
  21. package/dist/observe.js +109 -0
  22. package/dist/proxy-tracker.d.ts +15 -0
  23. package/dist/proxy-tracker.js +172 -0
  24. package/dist/register.d.ts +21 -0
  25. package/dist/register.js +105 -0
  26. package/dist/transport.d.ts +22 -0
  27. package/dist/transport.js +228 -0
  28. package/dist/type-hash.d.ts +5 -0
  29. package/dist/type-hash.js +60 -0
  30. package/dist/type-inference.d.ts +14 -0
  31. package/dist/type-inference.js +259 -0
  32. package/dist/types.d.ts +78 -0
  33. package/dist/types.js +2 -0
  34. package/dist/wrap.d.ts +10 -0
  35. package/dist/wrap.js +247 -0
  36. package/observe-esm-hooks.mjs +367 -0
  37. package/observe-esm.mjs +40 -0
  38. package/observe.js +2 -0
  39. package/package.json +26 -0
  40. package/register.js +2 -0
  41. package/src/auto-codegen.ts +1058 -0
  42. package/src/auto-register.ts +102 -0
  43. package/src/cache.ts +53 -0
  44. package/src/env-detect.ts +22 -0
  45. package/src/express.ts +386 -0
  46. package/src/fetch-observer.ts +226 -0
  47. package/src/index.ts +199 -0
  48. package/src/observe-register.ts +453 -0
  49. package/src/observe.ts +127 -0
  50. package/src/proxy-tracker.ts +208 -0
  51. package/src/register.ts +110 -0
  52. package/src/transport.ts +207 -0
  53. package/src/type-hash.ts +71 -0
  54. package/src/type-inference.ts +285 -0
  55. package/src/types.ts +61 -0
  56. package/src/wrap.ts +289 -0
  57. package/tsconfig.json +8 -0
@@ -0,0 +1,1058 @@
1
+ /**
2
+ * Lightweight inline codegen for `trickle/auto`.
3
+ *
4
+ * Reads .trickle/observations.jsonl and generates .d.ts sidecar files
5
+ * next to source files. Runs entirely inside the user's process —
6
+ * no CLI or backend required.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+
12
+ interface TypeNode {
13
+ kind: string;
14
+ name?: string;
15
+ element?: TypeNode;
16
+ elements?: TypeNode[];
17
+ properties?: Record<string, TypeNode>;
18
+ members?: TypeNode[];
19
+ params?: TypeNode[];
20
+ returnType?: TypeNode;
21
+ resolved?: TypeNode;
22
+ key?: TypeNode;
23
+ value?: TypeNode;
24
+ }
25
+
26
+ interface Observation {
27
+ functionName: string;
28
+ module: string;
29
+ language: string;
30
+ typeHash: string;
31
+ argsType: TypeNode;
32
+ returnType: TypeNode;
33
+ isAsync?: boolean;
34
+ paramNames?: string[];
35
+ sampleInput?: unknown;
36
+ sampleOutput?: unknown;
37
+ }
38
+
39
+ interface TypeVariant {
40
+ argsType: TypeNode;
41
+ returnType: TypeNode;
42
+ paramNames?: string[];
43
+ isAsync?: boolean;
44
+ }
45
+
46
+ interface FunctionData {
47
+ name: string;
48
+ argsType: TypeNode;
49
+ returnType: TypeNode;
50
+ module: string;
51
+ isAsync?: boolean;
52
+ paramNames?: string[];
53
+ sampleInput?: unknown;
54
+ sampleOutput?: unknown;
55
+ variants?: TypeVariant[];
56
+ }
57
+
58
+ // ── Type merging (same logic as CLI local-codegen) ──
59
+
60
+ function typeNodeKey(node: TypeNode): string {
61
+ switch (node.kind) {
62
+ case 'primitive': return `p:${node.name}`;
63
+ case 'unknown': return 'unknown';
64
+ case 'array': return `a:${typeNodeKey(node.element!)}`;
65
+ case 'tuple': return `t:[${(node.elements || []).map(typeNodeKey).join(',')}]`;
66
+ case 'object': {
67
+ const props = node.properties || {};
68
+ const entries = Object.keys(props).sort().map(k => `${k}:${typeNodeKey(props[k])}`);
69
+ return `o:{${entries.join(',')}}`;
70
+ }
71
+ case 'union': return `u:(${(node.members || []).map(typeNodeKey).sort().join('|')})`;
72
+ default: return JSON.stringify(node);
73
+ }
74
+ }
75
+
76
+ function mergeTypeNodes(a: TypeNode, b: TypeNode): TypeNode {
77
+ if (typeNodeKey(a) === typeNodeKey(b)) return a;
78
+
79
+ if (a.kind === 'object' && b.kind === 'object') {
80
+ const aP = a.properties || {}, bP = b.properties || {};
81
+ const allKeys = new Set([...Object.keys(aP), ...Object.keys(bP)]);
82
+ const merged: Record<string, TypeNode> = {};
83
+ for (const k of allKeys) {
84
+ const inA = k in aP, inB = k in bP;
85
+ if (inA && inB) merged[k] = mergeTypeNodes(aP[k], bP[k]);
86
+ else if (inA) merged[k] = makeOptional(aP[k]);
87
+ else merged[k] = makeOptional(bP[k]);
88
+ }
89
+ return { kind: 'object', properties: merged };
90
+ }
91
+
92
+ if (a.kind === 'array' && b.kind === 'array' && a.element && b.element) {
93
+ return { kind: 'array', element: mergeTypeNodes(a.element, b.element) };
94
+ }
95
+
96
+ if (a.kind === 'tuple' && b.kind === 'tuple') {
97
+ const aE = a.elements || [], bE = b.elements || [];
98
+ if (aE.length === bE.length) {
99
+ return { kind: 'tuple', elements: aE.map((el, i) => mergeTypeNodes(el, bE[i])) };
100
+ }
101
+ // Different lengths: merge common prefix, make extra elements optional
102
+ const shorter = aE.length < bE.length ? aE : bE;
103
+ const longer = aE.length < bE.length ? bE : aE;
104
+ const merged: TypeNode[] = [];
105
+ for (let i = 0; i < longer.length; i++) {
106
+ if (i < shorter.length) {
107
+ merged.push(mergeTypeNodes(shorter[i], longer[i]));
108
+ } else {
109
+ merged.push(makeOptional(longer[i]));
110
+ }
111
+ }
112
+ return { kind: 'tuple', elements: merged };
113
+ }
114
+
115
+ return deduplicateUnion([
116
+ ...(a.kind === 'union' ? (a.members || []) : [a]),
117
+ ...(b.kind === 'union' ? (b.members || []) : [b]),
118
+ ]);
119
+ }
120
+
121
+ function makeOptional(node: TypeNode): TypeNode {
122
+ if (node.kind === 'primitive' && node.name === 'undefined') return node;
123
+ if (node.kind === 'union') {
124
+ const members = node.members || [];
125
+ if (members.some(m => m.kind === 'primitive' && m.name === 'undefined')) return node;
126
+ return { kind: 'union', members: [...members, { kind: 'primitive', name: 'undefined' }] };
127
+ }
128
+ return { kind: 'union', members: [node, { kind: 'primitive', name: 'undefined' }] };
129
+ }
130
+
131
+ function deduplicateUnion(members: TypeNode[]): TypeNode {
132
+ const seen = new Set<string>();
133
+ const unique: TypeNode[] = [];
134
+ for (const m of members) {
135
+ if (m.kind === 'union') {
136
+ for (const inner of m.members || []) {
137
+ const k = typeNodeKey(inner);
138
+ if (!seen.has(k)) { seen.add(k); unique.push(inner); }
139
+ }
140
+ } else {
141
+ const k = typeNodeKey(m);
142
+ if (!seen.has(k)) { seen.add(k); unique.push(m); }
143
+ }
144
+ }
145
+ return unique.length === 1 ? unique[0] : { kind: 'union', members: unique };
146
+ }
147
+
148
+ // ── Read + merge observations ──
149
+
150
+ function readAndMerge(jsonlPath: string): FunctionData[] {
151
+ if (!fs.existsSync(jsonlPath)) return [];
152
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
153
+ const lines = content.trim().split('\n').filter(Boolean);
154
+
155
+ const byFunc = new Map<string, Observation[]>();
156
+ for (const line of lines) {
157
+ try {
158
+ const obs = JSON.parse(line) as Observation;
159
+ if (obs.functionName && obs.argsType && obs.returnType) {
160
+ if (!byFunc.has(obs.functionName)) byFunc.set(obs.functionName, []);
161
+ byFunc.get(obs.functionName)!.push(obs);
162
+ }
163
+ } catch { /* skip */ }
164
+ }
165
+
166
+ const results: FunctionData[] = [];
167
+ for (const [name, observations] of byFunc) {
168
+ let args = observations[0].argsType;
169
+ let ret = observations[0].returnType;
170
+ for (let i = 1; i < observations.length; i++) {
171
+ if (observations[i].typeHash !== observations[0].typeHash) {
172
+ args = mergeTypeNodes(args, observations[i].argsType);
173
+ ret = mergeTypeNodes(ret, observations[i].returnType);
174
+ }
175
+ }
176
+ // Use paramNames from the latest observation that has them
177
+ const paramNames = observations.reduce<string[] | undefined>(
178
+ (acc, obs) => obs.paramNames && obs.paramNames.length > 0 ? obs.paramNames : acc,
179
+ undefined,
180
+ );
181
+ // Use sample data from the first observation that has it
182
+ const sampleObs = observations.find(obs => obs.sampleInput != null || obs.sampleOutput != null);
183
+ // isAsync if any observation marked it as async
184
+ const isAsync = observations.some(obs => obs.isAsync === true);
185
+
186
+ // Collect unique type variants (by typeHash) for overload generation
187
+ const seenHashes = new Set<string>();
188
+ const variants: TypeVariant[] = [];
189
+ for (const obs of observations) {
190
+ if (!seenHashes.has(obs.typeHash)) {
191
+ seenHashes.add(obs.typeHash);
192
+ variants.push({
193
+ argsType: obs.argsType,
194
+ returnType: obs.returnType,
195
+ paramNames: obs.paramNames,
196
+ isAsync: obs.isAsync || undefined,
197
+ });
198
+ }
199
+ }
200
+
201
+ results.push({
202
+ name, argsType: args, returnType: ret,
203
+ module: observations[observations.length - 1].module,
204
+ isAsync: isAsync || undefined,
205
+ paramNames,
206
+ sampleInput: sampleObs?.sampleInput,
207
+ sampleOutput: sampleObs?.sampleOutput,
208
+ // Only include variants if there are 2-5 distinct patterns (overload-worthy)
209
+ variants: variants.length >= 2 && variants.length <= 5 ? variants : undefined,
210
+ });
211
+ }
212
+ return results;
213
+ }
214
+
215
+ // ── TypeScript generation ──
216
+
217
+ function toPascalCase(name: string): string {
218
+ return name
219
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
220
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
221
+ .trim().split(/\s+/)
222
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
223
+ .join('');
224
+ }
225
+
226
+ function extractOptional(node: TypeNode): { isOptional: boolean; innerType: TypeNode } {
227
+ if (node.kind !== 'union') return { isOptional: false, innerType: node };
228
+ const members = node.members || [];
229
+ const hasUndef = members.some(m => m.kind === 'primitive' && m.name === 'undefined');
230
+ if (!hasUndef) return { isOptional: false, innerType: node };
231
+ const without = members.filter(m => !(m.kind === 'primitive' && m.name === 'undefined'));
232
+ if (without.length === 0) return { isOptional: true, innerType: { kind: 'primitive', name: 'undefined' } };
233
+ if (without.length === 1) return { isOptional: true, innerType: without[0] };
234
+ return { isOptional: true, innerType: { kind: 'union', members: without } };
235
+ }
236
+
237
+ interface Extracted { name: string; node: TypeNode; }
238
+
239
+ function typeToTS(node: TypeNode, ext: Extracted[], parent: string, prop: string | undefined, indent: number): string {
240
+ switch (node.kind) {
241
+ case 'primitive': return node.name === 'integer' ? 'number' : (node.name || 'unknown');
242
+ case 'unknown': return 'unknown';
243
+ case 'array': {
244
+ const inner = typeToTS(node.element!, ext, parent, prop, indent);
245
+ return node.element!.kind === 'union' ? `Array<${inner}>` : `${inner}[]`;
246
+ }
247
+ case 'tuple':
248
+ return `[${(node.elements || []).map((e, i) => typeToTS(e, ext, parent, `${prop || 'el'}${i}`, indent)).join(', ')}]`;
249
+ case 'union':
250
+ return (node.members || []).map(m => typeToTS(m, ext, parent, prop, indent)).join(' | ');
251
+ case 'map': return `Map<${typeToTS(node.key!, ext, parent, 'key', indent)}, ${typeToTS(node.value!, ext, parent, 'value', indent)}>`;
252
+ case 'set': return `Set<${typeToTS(node.element!, ext, parent, prop, indent)}>`;
253
+ case 'promise': return `Promise<${typeToTS(node.resolved!, ext, parent, prop, indent)}>`;
254
+ case 'function': {
255
+ const params = (node.params || []).map((p, i) => `arg${i}: ${typeToTS(p, ext, parent, `p${i}`, indent)}`);
256
+ return `(${params.join(', ')}) => ${typeToTS(node.returnType!, ext, parent, 'ret', indent)}`;
257
+ }
258
+ case 'object': {
259
+ const keys = Object.keys(node.properties || {});
260
+ if (keys.length === 0) return 'Record<string, never>';
261
+ if (keys.length > 2 && prop) {
262
+ const iName = toPascalCase(parent) + toPascalCase(prop);
263
+ if (!ext.some(e => e.name === iName)) ext.push({ name: iName, node });
264
+ return iName;
265
+ }
266
+ const pad = ' '.repeat(indent + 1), close = ' '.repeat(indent);
267
+ const entries = keys.map(k => {
268
+ const { isOptional, innerType } = extractOptional(node.properties![k]);
269
+ const val = typeToTS(innerType, ext, parent, k, indent + 1);
270
+ return isOptional ? `${pad}${k}?: ${val};` : `${pad}${k}: ${val};`;
271
+ });
272
+ return `{\n${entries.join('\n')}\n${close}}`;
273
+ }
274
+ default: return 'unknown';
275
+ }
276
+ }
277
+
278
+ function renderInterface(name: string, node: TypeNode, ext: Extracted[]): string {
279
+ const keys = Object.keys(node.properties || {});
280
+ const lines = [`export interface ${name} {`];
281
+ for (const k of keys) {
282
+ const { isOptional, innerType } = extractOptional(node.properties![k]);
283
+ const val = typeToTS(innerType, ext, name, k, 1);
284
+ lines.push(isOptional ? ` ${k}?: ${val};` : ` ${k}: ${val};`);
285
+ }
286
+ lines.push('}');
287
+ return lines.join('\n');
288
+ }
289
+
290
+ function formatSampleValue(val: unknown, depth = 0): string {
291
+ if (val === null || val === undefined) return String(val);
292
+ if (typeof val === 'string') return depth === 0 && val.length > 60 ? `"${val.slice(0, 57)}..."` : `"${val}"`;
293
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
294
+ if (Array.isArray(val)) {
295
+ if (val.length === 0) return '[]';
296
+ if (depth > 1) return '[...]';
297
+ const items = val.slice(0, 5).map(v => formatSampleValue(v, depth + 1));
298
+ return val.length > 5 ? `[${items.join(', ')}, ...]` : `[${items.join(', ')}]`;
299
+ }
300
+ if (typeof val === 'object') {
301
+ const entries = Object.entries(val as Record<string, unknown>);
302
+ if (entries.length === 0) return '{}';
303
+ if (depth > 1) return '{...}';
304
+ const items = entries.slice(0, 6).map(([k, v]) => `${k}: ${formatSampleValue(v, depth + 1)}`);
305
+ return entries.length > 6 ? `{ ${items.join(', ')}, ... }` : `{ ${items.join(', ')} }`;
306
+ }
307
+ return String(val);
308
+ }
309
+
310
+ function buildExampleComment(fn: FunctionData): string[] {
311
+ if (fn.sampleInput == null && fn.sampleOutput == null) return [];
312
+
313
+ const paramNames = fn.paramNames || [];
314
+ const lines: string[] = [];
315
+
316
+ // Format args
317
+ let argsStr = '';
318
+ if (Array.isArray(fn.sampleInput)) {
319
+ argsStr = fn.sampleInput.map(v => formatSampleValue(v)).join(', ');
320
+ } else if (fn.sampleInput != null) {
321
+ argsStr = formatSampleValue(fn.sampleInput);
322
+ }
323
+
324
+ // Format return value
325
+ const retStr = fn.sampleOutput != null ? formatSampleValue(fn.sampleOutput) : undefined;
326
+
327
+ lines.push('/**');
328
+ lines.push(` * @example`);
329
+ if (retStr) {
330
+ lines.push(` * ${fn.name}(${argsStr})`);
331
+ lines.push(` * // => ${retStr}`);
332
+ } else {
333
+ lines.push(` * ${fn.name}(${argsStr})`);
334
+ }
335
+ lines.push(' */');
336
+ return lines;
337
+ }
338
+
339
+ function generateClassDts(className: string, methods: FunctionData[]): string[] {
340
+ const sections: string[] = [];
341
+ const ext: Extracted[] = [];
342
+ const classLines: string[] = [`export declare class ${className} {`];
343
+
344
+ for (const fn of methods) {
345
+ const methodName = fn.name.split('.')[1] || fn.name;
346
+ const base = toPascalCase(className) + toPascalCase(methodName);
347
+
348
+ // Generate overloads if we have multiple distinct type patterns
349
+ if (fn.variants && fn.variants.length >= 2) {
350
+ for (const variant of fn.variants) {
351
+ const vNames = variant.paramNames || fn.paramNames || [];
352
+ let vArgEntries: Array<{ paramName: string; typeNode: TypeNode }> = [];
353
+ if (variant.argsType.kind === 'tuple') {
354
+ vArgEntries = (variant.argsType.elements || []).map((el, i) => ({
355
+ paramName: vNames[i] || `arg${i}`,
356
+ typeNode: el,
357
+ })).filter(e => e.paramName !== 'this' && e.paramName !== 'self');
358
+ }
359
+ let vRet = typeToTS(variant.returnType, ext, base, undefined, 1);
360
+ if (variant.isAsync) vRet = `Promise<${vRet}>`;
361
+ const vParams = vArgEntries.map(e =>
362
+ `${e.paramName}: ${typeToTS(e.typeNode, ext, base, e.paramName, 1)}`
363
+ );
364
+ classLines.push(` ${methodName}(${vParams.join(', ')}): ${vRet};`);
365
+ }
366
+ } else {
367
+ // Build params (skip 'this' if present)
368
+ let argEntries: Array<{ paramName: string; typeNode: TypeNode }> = [];
369
+ if (fn.argsType.kind === 'tuple') {
370
+ const names = fn.paramNames || [];
371
+ argEntries = (fn.argsType.elements || []).map((el, i) => ({
372
+ paramName: names[i] || `arg${i}`,
373
+ typeNode: el,
374
+ })).filter(e => e.paramName !== 'this');
375
+ }
376
+
377
+ // Return type
378
+ let retType = typeToTS(fn.returnType, ext, base, undefined, 1);
379
+ if (fn.isAsync) retType = `Promise<${retType}>`;
380
+
381
+ // Build params string (handle optional params)
382
+ const params = argEntries.map(e => {
383
+ const { isOptional, innerType } = extractOptional(e.typeNode);
384
+ if (isOptional) {
385
+ return `${e.paramName}?: ${typeToTS(innerType, ext, base, e.paramName, 1)}`;
386
+ }
387
+ return `${e.paramName}: ${typeToTS(e.typeNode, ext, base, e.paramName, 1)}`;
388
+ });
389
+
390
+ classLines.push(` ${methodName}(${params.join(', ')}): ${retType};`);
391
+ }
392
+ }
393
+
394
+ classLines.push('}');
395
+
396
+ // Emit extracted interfaces before the class
397
+ const emitted = new Set<string>();
398
+ let cursor = 0;
399
+ while (cursor < ext.length) {
400
+ const iface = ext[cursor++];
401
+ if (emitted.has(iface.name)) continue;
402
+ emitted.add(iface.name);
403
+ sections.push(renderInterface(iface.name, iface.node, ext));
404
+ sections.push('');
405
+ }
406
+
407
+ sections.push(...classLines);
408
+ sections.push('');
409
+ return sections;
410
+ }
411
+
412
+ function generateDts(functions: FunctionData[]): string {
413
+ const sections: string[] = [
414
+ '// Auto-generated by trickle/auto from runtime observations',
415
+ `// Generated at ${new Date().toISOString()}`,
416
+ '// Do not edit — types update automatically as your code runs',
417
+ '',
418
+ ];
419
+
420
+ // Separate class methods from standalone functions
421
+ const classMethods = new Map<string, FunctionData[]>();
422
+ const standaloneFunctions: FunctionData[] = [];
423
+
424
+ for (const fn of functions) {
425
+ if (fn.name.includes('.')) {
426
+ const className = fn.name.split('.')[0];
427
+ if (!classMethods.has(className)) classMethods.set(className, []);
428
+ classMethods.get(className)!.push(fn);
429
+ } else {
430
+ standaloneFunctions.push(fn);
431
+ }
432
+ }
433
+
434
+ // Generate class declarations
435
+ for (const [className, methods] of classMethods) {
436
+ sections.push(...generateClassDts(className, methods));
437
+ }
438
+
439
+ for (const fn of standaloneFunctions) {
440
+ const base = toPascalCase(fn.name);
441
+ const ext: Extracted[] = [];
442
+ const lines: string[] = [];
443
+
444
+ // Args
445
+ let argEntries: Array<{ paramName: string; typeNode: TypeNode }> = [];
446
+ if (fn.argsType.kind === 'tuple') {
447
+ const names = fn.paramNames || [];
448
+ argEntries = (fn.argsType.elements || []).map((el, i) => ({
449
+ paramName: names[i] || `arg${i}`,
450
+ typeNode: el,
451
+ }));
452
+ } else if (fn.argsType.kind === 'object') {
453
+ argEntries = Object.keys(fn.argsType.properties || {}).map(k => ({ paramName: k, typeNode: fn.argsType.properties![k] }));
454
+ } else {
455
+ argEntries = [{ paramName: 'input', typeNode: fn.argsType }];
456
+ }
457
+
458
+ const singleObj = argEntries.length === 1 && argEntries[0].typeNode.kind === 'object';
459
+ if (singleObj) {
460
+ lines.push(renderInterface(`${base}Input`, argEntries[0].typeNode, ext));
461
+ lines.push('');
462
+ }
463
+
464
+ // Return
465
+ const outName = `${base}Output`;
466
+ if (fn.returnType.kind === 'object' && Object.keys(fn.returnType.properties || {}).length > 0) {
467
+ lines.push(renderInterface(outName, fn.returnType, ext));
468
+ lines.push('');
469
+ } else {
470
+ lines.push(`export type ${outName} = ${typeToTS(fn.returnType, ext, base, undefined, 0)};`);
471
+ lines.push('');
472
+ }
473
+
474
+ // Extracted interfaces
475
+ const emitted = new Set<string>();
476
+ const extLines: string[] = [];
477
+ let cursor = 0;
478
+ while (cursor < ext.length) {
479
+ const iface = ext[cursor++];
480
+ if (emitted.has(iface.name)) continue;
481
+ emitted.add(iface.name);
482
+ extLines.push(renderInterface(iface.name, iface.node, ext));
483
+ extLines.push('');
484
+ }
485
+
486
+ // Function declaration
487
+ const ident = base.charAt(0).toLowerCase() + base.slice(1);
488
+
489
+ if (extLines.length > 0) sections.push(...extLines);
490
+ sections.push(...lines);
491
+
492
+ // Add @example JSDoc if sample data is available
493
+ const exampleLines = buildExampleComment(fn);
494
+ if (exampleLines.length > 0) sections.push(...exampleLines);
495
+
496
+ // Generate overloads if we have multiple distinct type patterns
497
+ if (fn.variants && fn.variants.length >= 2) {
498
+ for (const variant of fn.variants) {
499
+ const vExt: Extracted[] = [];
500
+ const vRet = typeToTS(variant.returnType, vExt, base, undefined, 0);
501
+ const vRetDecl = variant.isAsync ? `Promise<${vRet}>` : vRet;
502
+ const vNames = variant.paramNames || fn.paramNames || [];
503
+ let vArgEntries: Array<{ paramName: string; typeNode: TypeNode }> = [];
504
+ if (variant.argsType.kind === 'tuple') {
505
+ vArgEntries = (variant.argsType.elements || []).map((el, i) => ({
506
+ paramName: vNames[i] || `arg${i}`,
507
+ typeNode: el,
508
+ }));
509
+ }
510
+ const vParams = vArgEntries.map(e =>
511
+ `${e.paramName}: ${typeToTS(e.typeNode, vExt, base, e.paramName, 0)}`
512
+ );
513
+ sections.push(`export declare function ${ident}(${vParams.join(', ')}): ${vRetDecl};`);
514
+ }
515
+ } else {
516
+ const retDecl = fn.isAsync ? `Promise<${outName}>` : outName;
517
+ let decl: string;
518
+ if (singleObj) {
519
+ decl = `export declare function ${ident}(input: ${base}Input): ${retDecl};`;
520
+ } else {
521
+ const params = argEntries.map(e => {
522
+ if (e.typeNode.kind === 'object' && Object.keys(e.typeNode.properties || {}).length > 0)
523
+ return `${e.paramName}: ${base}${toPascalCase(e.paramName)}`;
524
+ // Check if parameter is optional (union with undefined)
525
+ const { isOptional, innerType } = extractOptional(e.typeNode);
526
+ if (isOptional) {
527
+ return `${e.paramName}?: ${typeToTS(innerType, ext, base, e.paramName, 0)}`;
528
+ }
529
+ return `${e.paramName}: ${typeToTS(e.typeNode, ext, base, e.paramName, 0)}`;
530
+ });
531
+ decl = `export declare function ${ident}(${params.join(', ')}): ${retDecl};`;
532
+ }
533
+ sections.push(decl);
534
+ }
535
+ sections.push('');
536
+ }
537
+
538
+ return sections.join('\n').trimEnd() + '\n';
539
+ }
540
+
541
+ // ── JSDoc type formatting ──
542
+
543
+ function typeToJSDoc(node: TypeNode): string {
544
+ switch (node.kind) {
545
+ case 'primitive': return node.name === 'integer' ? 'number' : (node.name || '*');
546
+ case 'unknown': return '*';
547
+ case 'array': {
548
+ const inner = typeToJSDoc(node.element!);
549
+ return `${inner}[]`;
550
+ }
551
+ case 'tuple':
552
+ return `[${(node.elements || []).map(typeToJSDoc).join(', ')}]`;
553
+ case 'union':
554
+ return (node.members || []).map(typeToJSDoc).join(' | ');
555
+ case 'map': return `Map<${typeToJSDoc(node.key!)}, ${typeToJSDoc(node.value!)}>`;
556
+ case 'set': return `Set<${typeToJSDoc(node.element!)}>`;
557
+ case 'promise': return `Promise<${typeToJSDoc(node.resolved!)}>`;
558
+ case 'function': {
559
+ const params = (node.params || []).map((p, i) => `arg${i}: ${typeToJSDoc(p)}`);
560
+ return `function(${params.join(', ')}): ${typeToJSDoc(node.returnType!)}`;
561
+ }
562
+ case 'object': {
563
+ const props = node.properties || {};
564
+ const keys = Object.keys(props);
565
+ if (keys.length === 0) return 'Object';
566
+ const entries = keys.map(k => {
567
+ const { isOptional, innerType } = extractOptional(props[k]);
568
+ return isOptional ? `${k}?: ${typeToJSDoc(innerType)}` : `${k}: ${typeToJSDoc(innerType)}`;
569
+ });
570
+ return `{ ${entries.join(', ')} }`;
571
+ }
572
+ default: return '*';
573
+ }
574
+ }
575
+
576
+ // ── JSDoc injection into source files ──
577
+
578
+ const funcDeclRe = /^(\s*(?:export\s+)?(?:async\s+)?function\s+)(\w+)\s*(?:<[^>]*>)?\s*\(/;
579
+ const arrowRe = /^(\s*(?:export\s+)?(?:const|let|var)\s+)(\w+)\s*=\s*(?:async\s+)?\(/;
580
+ const methodRe = /^(\s+)(\w+)\s*\(([^)]*)\)\s*\{/;
581
+
582
+ function injectJSDocIntoFile(filePath: string, functions: FunctionData[]): boolean {
583
+ const source = fs.readFileSync(filePath, 'utf-8');
584
+ const lines = source.split('\n');
585
+ const fnMap = new Map(functions.map(f => [f.name, f]));
586
+ const result: string[] = [];
587
+ let changed = false;
588
+
589
+ for (let i = 0; i < lines.length; i++) {
590
+ const line = lines[i];
591
+ const trimmed = line.trimStart();
592
+
593
+ // Try to match a function declaration
594
+ let fnName: string | null = null;
595
+ let m = trimmed.match(funcDeclRe);
596
+ if (m) fnName = m[2];
597
+ if (!fnName) {
598
+ m = trimmed.match(arrowRe);
599
+ if (m) fnName = m[2];
600
+ }
601
+ if (!fnName) {
602
+ m = trimmed.match(methodRe);
603
+ if (m) fnName = m[2];
604
+ }
605
+
606
+ if (!fnName || !fnMap.has(fnName)) {
607
+ result.push(line);
608
+ continue;
609
+ }
610
+
611
+ // Check if there's already a JSDoc comment above
612
+ const prevIdx = result.length - 1;
613
+ if (prevIdx >= 0) {
614
+ const prev = result[prevIdx].trim();
615
+ if (prev === '*/' || prev.endsWith('*/')) {
616
+ let j = prevIdx;
617
+ while (j >= 0 && !result[j].trim().startsWith('/**')) j--;
618
+ if (j >= 0) {
619
+ const block = result.slice(j, prevIdx + 1).join('\n');
620
+ if (block.includes('@param') || block.includes('@returns') || block.includes('@trickle')) {
621
+ result.push(line);
622
+ continue;
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ const fn = fnMap.get(fnName)!;
629
+ const indent = line.match(/^(\s*)/)?.[1] || '';
630
+
631
+ // Build JSDoc
632
+ const jsdocLines: string[] = [`${indent}/** @trickle — auto-generated from runtime observations`];
633
+
634
+ // Params
635
+ const argElements = fn.argsType.kind === 'tuple' ? (fn.argsType.elements || []) : [];
636
+ const paramNames = fn.paramNames || [];
637
+ for (let pi = 0; pi < argElements.length; pi++) {
638
+ const pName = paramNames[pi] || `arg${pi}`;
639
+ const pType = typeToJSDoc(argElements[pi]);
640
+ jsdocLines.push(`${indent} * @param {${pType}} ${pName}`);
641
+ }
642
+
643
+ // Return type
644
+ const retType = typeToJSDoc(fn.returnType);
645
+ if (retType !== 'undefined' && retType !== 'void') {
646
+ jsdocLines.push(`${indent} * @returns {${retType}}`);
647
+ }
648
+
649
+ jsdocLines.push(`${indent} */`);
650
+
651
+ if (jsdocLines.length > 2) {
652
+ result.push(...jsdocLines);
653
+ changed = true;
654
+ }
655
+
656
+ result.push(line);
657
+ }
658
+
659
+ if (changed) {
660
+ fs.writeFileSync(filePath, result.join('\n'), 'utf-8');
661
+ }
662
+ return changed;
663
+ }
664
+
665
+ /**
666
+ * Inject JSDoc comments into JS source files based on observations.
667
+ * Only runs when TRICKLE_INJECT=1.
668
+ */
669
+ export function injectTypes(): number {
670
+ if (process.env.TRICKLE_INJECT !== '1') return 0;
671
+
672
+ const trickleDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
673
+ const jsonlPath = path.join(trickleDir, 'observations.jsonl');
674
+ if (!fs.existsSync(jsonlPath)) return 0;
675
+
676
+ const functions = readAndMerge(jsonlPath);
677
+ if (functions.length === 0) return 0;
678
+
679
+ const byModule = new Map<string, FunctionData[]>();
680
+ for (const fn of functions) {
681
+ const mod = fn.module || '_default';
682
+ if (!byModule.has(mod)) byModule.set(mod, []);
683
+ byModule.get(mod)!.push(fn);
684
+ }
685
+
686
+ let injected = 0;
687
+ for (const [mod, fns] of byModule) {
688
+ if (mod.includes('.') && !mod.includes('/') && !mod.includes('\\')) continue;
689
+
690
+ const sourceFile = findSourceFile(mod);
691
+ if (!sourceFile) continue;
692
+
693
+ const ext = path.extname(sourceFile);
694
+ // Only inject into JS files (not .ts — those already have types)
695
+ if (!['.js', '.jsx', '.mjs', '.cjs'].includes(ext)) continue;
696
+
697
+ try {
698
+ if (injectJSDocIntoFile(sourceFile, fns)) {
699
+ injected += fns.length;
700
+ }
701
+ } catch { /* don't crash user's app */ }
702
+ }
703
+
704
+ return injected;
705
+ }
706
+
707
+ // ── Public API ──
708
+
709
+ let lastSize = 0;
710
+ let lastContent = '';
711
+
712
+ /**
713
+ * Read observations and generate .d.ts files next to source files.
714
+ * Returns the number of functions typed.
715
+ */
716
+ export function generateTypes(): number {
717
+ const trickleDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
718
+ const jsonlPath = path.join(trickleDir, 'observations.jsonl');
719
+
720
+ if (!fs.existsSync(jsonlPath)) return 0;
721
+
722
+ // Skip if file hasn't changed
723
+ try {
724
+ const stat = fs.statSync(jsonlPath);
725
+ if (stat.size === lastSize) return -1; // -1 = no change
726
+ lastSize = stat.size;
727
+ } catch { return 0; }
728
+
729
+ const functions = readAndMerge(jsonlPath);
730
+ if (functions.length === 0) return 0;
731
+
732
+ // Group by module and generate .d.ts next to source files
733
+ const byModule = new Map<string, FunctionData[]>();
734
+ for (const fn of functions) {
735
+ const mod = fn.module || '_default';
736
+ if (!byModule.has(mod)) byModule.set(mod, []);
737
+ byModule.get(mod)!.push(fn);
738
+ }
739
+
740
+ let totalFunctions = 0;
741
+ for (const [mod, fns] of byModule) {
742
+ // Skip HTTP route observations (module is hostname like "localhost")
743
+ if (mod.includes('.') && !mod.includes('/') && !mod.includes('\\')) continue;
744
+
745
+ const dts = generateDts(fns);
746
+ if (dts === lastContent) continue;
747
+
748
+ // Find source file for this module
749
+ const sourceFile = findSourceFile(mod);
750
+ if (!sourceFile) continue;
751
+
752
+ const ext = path.extname(sourceFile);
753
+ const dir = path.dirname(sourceFile);
754
+ const baseName = path.basename(sourceFile, ext);
755
+ // For .ts/.tsx files, use .trickle.d.ts to avoid conflicts (TS ignores .d.ts next to .ts)
756
+ const isTs = ext === '.ts' || ext === '.tsx';
757
+ const dtsPath = path.join(dir, `${baseName}${isTs ? '.trickle' : ''}.d.ts`);
758
+
759
+ try {
760
+ fs.writeFileSync(dtsPath, dts, 'utf-8');
761
+ lastContent = dts;
762
+ totalFunctions += fns.length;
763
+ } catch { /* don't crash user's app */ }
764
+ }
765
+
766
+ return totalFunctions;
767
+ }
768
+
769
+ // ── Type coverage report ──
770
+
771
+ const jsFuncDeclRe = /^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\(/;
772
+ const jsArrowRe = /^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(/;
773
+ const jsMethodRe = /^\s+(\w+)\s*\([^)]*\)\s*\{/;
774
+
775
+ function extractFunctionNames(source: string, ext: string): string[] {
776
+ const names: string[] = [];
777
+ const lines = source.split('\n');
778
+ for (const line of lines) {
779
+ const trimmed = line.trimStart();
780
+ // Skip comments
781
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
782
+
783
+ let m = trimmed.match(jsFuncDeclRe);
784
+ if (m && m[1] !== 'constructor') { names.push(m[1]); continue; }
785
+ m = trimmed.match(jsArrowRe);
786
+ if (m) { names.push(m[1]); continue; }
787
+ // Methods only for class-based files
788
+ m = trimmed.match(jsMethodRe);
789
+ if (m && m[1] !== 'constructor' && m[1] !== 'if' && m[1] !== 'for' && m[1] !== 'while' && m[1] !== 'switch' && m[1] !== 'catch') {
790
+ names.push(m[1]);
791
+ }
792
+ }
793
+ return [...new Set(names)];
794
+ }
795
+
796
+ interface CoverageEntry {
797
+ file: string;
798
+ total: number;
799
+ typed: number;
800
+ untyped: string[];
801
+ }
802
+
803
+ /**
804
+ * Generate a type coverage report comparing observed types against
805
+ * all function declarations found in source files.
806
+ * Only runs when TRICKLE_COVERAGE=1.
807
+ */
808
+ export function generateCoverageReport(): string | null {
809
+ if (process.env.TRICKLE_COVERAGE !== '1') return null;
810
+
811
+ const trickleDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
812
+ const jsonlPath = path.join(trickleDir, 'observations.jsonl');
813
+ if (!fs.existsSync(jsonlPath)) return null;
814
+
815
+ const functions = readAndMerge(jsonlPath);
816
+ if (functions.length === 0) return null;
817
+
818
+ // Group observed functions by module
819
+ const observedByModule = new Map<string, Set<string>>();
820
+ for (const fn of functions) {
821
+ const mod = fn.module || '_default';
822
+ if (!observedByModule.has(mod)) observedByModule.set(mod, new Set());
823
+ observedByModule.get(mod)!.add(fn.name);
824
+ }
825
+
826
+ const entries: CoverageEntry[] = [];
827
+ let totalAll = 0;
828
+ let typedAll = 0;
829
+
830
+ for (const [mod, observedNames] of observedByModule) {
831
+ // Skip HTTP route observations
832
+ if (mod.includes('.') && !mod.includes('/') && !mod.includes('\\')) continue;
833
+
834
+ const sourceFile = findSourceFile(mod);
835
+ if (!sourceFile) continue;
836
+
837
+ let source: string;
838
+ try {
839
+ source = fs.readFileSync(sourceFile, 'utf-8');
840
+ } catch { continue; }
841
+
842
+ const ext = path.extname(sourceFile);
843
+ const allFunctions = extractFunctionNames(source, ext);
844
+ if (allFunctions.length === 0) continue;
845
+
846
+ const typed = allFunctions.filter(n => observedNames.has(n));
847
+ const untyped = allFunctions.filter(n => !observedNames.has(n));
848
+
849
+ totalAll += allFunctions.length;
850
+ typedAll += typed.length;
851
+
852
+ const relPath = path.relative(process.cwd(), sourceFile);
853
+ entries.push({
854
+ file: relPath,
855
+ total: allFunctions.length,
856
+ typed: typed.length,
857
+ untyped,
858
+ });
859
+ }
860
+
861
+ if (entries.length === 0) return null;
862
+
863
+ // Sort: incomplete files first, then by coverage
864
+ entries.sort((a, b) => {
865
+ const aRatio = a.typed / a.total;
866
+ const bRatio = b.typed / b.total;
867
+ return aRatio - bRatio;
868
+ });
869
+
870
+ const lines: string[] = ['[trickle/auto] Type coverage:'];
871
+ for (const entry of entries) {
872
+ const pct = Math.round((entry.typed / entry.total) * 100);
873
+ const marker = pct === 100 ? ' ✓' : '';
874
+ lines.push(` ${entry.file}: ${entry.typed}/${entry.total} (${pct}%)${marker}`);
875
+ if (entry.untyped.length > 0) {
876
+ lines.push(` Untyped: ${entry.untyped.join(', ')}`);
877
+ }
878
+ }
879
+ const totalPct = totalAll > 0 ? Math.round((typedAll / totalAll) * 100) : 0;
880
+ lines.push(` Total: ${typedAll}/${totalAll} functions (${totalPct}%)`);
881
+
882
+ return lines.join('\n');
883
+ }
884
+
885
+ // ── Type summary with change detection ──
886
+
887
+ const SNAPSHOT_FILE = '.trickle/type-snapshot.json';
888
+
889
+ function typeToCompact(node: TypeNode, depth = 0): string {
890
+ if (depth > 2) return '...';
891
+ switch (node.kind) {
892
+ case 'primitive': return node.name === 'integer' ? 'number' : (node.name || 'unknown');
893
+ case 'unknown': return 'unknown';
894
+ case 'array': return `${typeToCompact(node.element!, depth + 1)}[]`;
895
+ case 'tuple': return `[${(node.elements || []).map(e => typeToCompact(e, depth + 1)).join(', ')}]`;
896
+ case 'union': return (node.members || []).map(m => typeToCompact(m, depth)).join(' | ');
897
+ case 'promise': return `Promise<${typeToCompact(node.resolved!, depth + 1)}>`;
898
+ case 'map': return `Map<${typeToCompact(node.key!, depth + 1)}, ${typeToCompact(node.value!, depth + 1)}>`;
899
+ case 'set': return `Set<${typeToCompact(node.element!, depth + 1)}>`;
900
+ case 'function': return `(...) => ${typeToCompact(node.returnType!, depth + 1)}`;
901
+ case 'object': {
902
+ const props = node.properties || {};
903
+ const keys = Object.keys(props);
904
+ if (keys.length === 0) return '{}';
905
+ if (keys.length > 4) {
906
+ const shown = keys.slice(0, 3).map(k => `${k}: ${typeToCompact(props[k], depth + 1)}`);
907
+ return `{ ${shown.join(', ')}, ... }`;
908
+ }
909
+ return `{ ${keys.map(k => `${k}: ${typeToCompact(props[k], depth + 1)}`).join(', ')} }`;
910
+ }
911
+ default: return 'unknown';
912
+ }
913
+ }
914
+
915
+ function buildSignature(fn: FunctionData): string {
916
+ const paramNames = fn.paramNames || [];
917
+ let params = '';
918
+
919
+ if (fn.argsType.kind === 'tuple') {
920
+ const elements = fn.argsType.elements || [];
921
+ params = elements.map((el, i) => {
922
+ const name = paramNames[i] || `arg${i}`;
923
+ return `${name}: ${typeToCompact(el)}`;
924
+ }).join(', ');
925
+ }
926
+
927
+ const ret = typeToCompact(fn.returnType);
928
+ const retDisplay = fn.isAsync ? `Promise<${ret}>` : ret;
929
+ const prefix = fn.isAsync ? 'async ' : '';
930
+ return `${prefix}${fn.name}(${params}) → ${retDisplay}`;
931
+ }
932
+
933
+ interface TypeSnapshot {
934
+ functions: Record<string, string>; // name -> signature
935
+ }
936
+
937
+ function loadSnapshot(): TypeSnapshot | null {
938
+ const trickleDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
939
+ const snapshotPath = path.join(trickleDir, SNAPSHOT_FILE.split('/').pop()!);
940
+ try {
941
+ const content = fs.readFileSync(snapshotPath, 'utf-8');
942
+ return JSON.parse(content);
943
+ } catch {
944
+ return null;
945
+ }
946
+ }
947
+
948
+ function saveSnapshot(snapshot: TypeSnapshot): void {
949
+ const trickleDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
950
+ const snapshotPath = path.join(trickleDir, SNAPSHOT_FILE.split('/').pop()!);
951
+ try {
952
+ fs.mkdirSync(trickleDir, { recursive: true });
953
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf-8');
954
+ } catch { /* don't crash */ }
955
+ }
956
+
957
+ /**
958
+ * Generate a type summary showing discovered function signatures.
959
+ * When a previous snapshot exists, highlights new and changed functions.
960
+ * Only runs when TRICKLE_SUMMARY=1.
961
+ */
962
+ export function generateTypeSummary(): string | null {
963
+ if (process.env.TRICKLE_SUMMARY !== '1') return null;
964
+
965
+ const trickleDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
966
+ const jsonlPath = path.join(trickleDir, 'observations.jsonl');
967
+ if (!fs.existsSync(jsonlPath)) return null;
968
+
969
+ const functions = readAndMerge(jsonlPath);
970
+ if (functions.length === 0) return null;
971
+
972
+ // Filter out HTTP route observations
973
+ const userFunctions = functions.filter(fn => {
974
+ const mod = fn.module || '';
975
+ return !(mod.includes('.') && !mod.includes('/') && !mod.includes('\\'));
976
+ });
977
+ if (userFunctions.length === 0) return null;
978
+
979
+ // Build current signatures
980
+ const currentSigs: Record<string, string> = {};
981
+ for (const fn of userFunctions) {
982
+ currentSigs[fn.name] = buildSignature(fn);
983
+ }
984
+
985
+ // Load previous snapshot for diff
986
+ const prevSnapshot = loadSnapshot();
987
+ const prevSigs = prevSnapshot?.functions || {};
988
+
989
+ // Compute diff
990
+ let newCount = 0;
991
+ let changedCount = 0;
992
+ const entries: Array<{ sig: string; status: 'new' | 'changed' | 'same' }> = [];
993
+
994
+ // Sort functions by module then name for clean output
995
+ const sorted = [...userFunctions].sort((a, b) => {
996
+ if (a.module !== b.module) return a.module.localeCompare(b.module);
997
+ return a.name.localeCompare(b.name);
998
+ });
999
+
1000
+ for (const fn of sorted) {
1001
+ const sig = currentSigs[fn.name];
1002
+ if (!(fn.name in prevSigs)) {
1003
+ entries.push({ sig, status: 'new' });
1004
+ newCount++;
1005
+ } else if (prevSigs[fn.name] !== sig) {
1006
+ entries.push({ sig, status: 'changed' });
1007
+ changedCount++;
1008
+ } else {
1009
+ entries.push({ sig, status: 'same' });
1010
+ }
1011
+ }
1012
+
1013
+ // Save new snapshot
1014
+ saveSnapshot({ functions: currentSigs });
1015
+
1016
+ // Build output
1017
+ const header = newCount > 0 || changedCount > 0
1018
+ ? `[trickle/auto] Discovered types (${newCount} new, ${changedCount} changed):`
1019
+ : `[trickle/auto] Discovered types:`;
1020
+
1021
+ const lines: string[] = [header];
1022
+ for (const entry of entries) {
1023
+ const prefix = entry.status === 'new' ? ' + ' : entry.status === 'changed' ? ' ~ ' : ' ';
1024
+ const suffix = entry.status === 'new' ? ' NEW' : entry.status === 'changed' ? ' CHANGED' : '';
1025
+ lines.push(`${prefix}${entry.sig}${suffix}`);
1026
+ }
1027
+
1028
+ return lines.join('\n');
1029
+ }
1030
+
1031
+ /**
1032
+ * Try to find the source file for a given module name.
1033
+ */
1034
+ function findSourceFile(moduleName: string): string | null {
1035
+ const cwd = process.cwd();
1036
+ const exts = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
1037
+
1038
+ // Try direct match in cwd
1039
+ for (const ext of exts) {
1040
+ const candidate = path.join(cwd, moduleName + ext);
1041
+ if (fs.existsSync(candidate)) return candidate;
1042
+ }
1043
+
1044
+ // Try with dashes/underscores replaced
1045
+ const normalized = moduleName.replace(/[-_]/g, '');
1046
+ for (const ext of exts) {
1047
+ const candidate = path.join(cwd, normalized + ext);
1048
+ if (fs.existsSync(candidate)) return candidate;
1049
+ }
1050
+
1051
+ // Try as subdirectory/index
1052
+ for (const ext of exts) {
1053
+ const candidate = path.join(cwd, moduleName, `index${ext}`);
1054
+ if (fs.existsSync(candidate)) return candidate;
1055
+ }
1056
+
1057
+ return null;
1058
+ }