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.
- package/auto-env.js +13 -0
- package/auto-esm.mjs +128 -0
- package/auto.js +3 -0
- package/dist/auto-codegen.d.ts +29 -0
- package/dist/auto-codegen.js +999 -0
- package/dist/auto-register.d.ts +16 -0
- package/dist/auto-register.js +99 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.js +52 -0
- package/dist/env-detect.d.ts +5 -0
- package/dist/env-detect.js +35 -0
- package/dist/express.d.ts +44 -0
- package/dist/express.js +342 -0
- package/dist/fetch-observer.d.ts +24 -0
- package/dist/fetch-observer.js +217 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +172 -0
- package/dist/observe-register.d.ts +29 -0
- package/dist/observe-register.js +455 -0
- package/dist/observe.d.ts +44 -0
- package/dist/observe.js +109 -0
- package/dist/proxy-tracker.d.ts +15 -0
- package/dist/proxy-tracker.js +172 -0
- package/dist/register.d.ts +21 -0
- package/dist/register.js +105 -0
- package/dist/transport.d.ts +22 -0
- package/dist/transport.js +228 -0
- package/dist/type-hash.d.ts +5 -0
- package/dist/type-hash.js +60 -0
- package/dist/type-inference.d.ts +14 -0
- package/dist/type-inference.js +259 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +2 -0
- package/dist/wrap.d.ts +10 -0
- package/dist/wrap.js +247 -0
- package/observe-esm-hooks.mjs +367 -0
- package/observe-esm.mjs +40 -0
- package/observe.js +2 -0
- package/package.json +26 -0
- package/register.js +2 -0
- package/src/auto-codegen.ts +1058 -0
- package/src/auto-register.ts +102 -0
- package/src/cache.ts +53 -0
- package/src/env-detect.ts +22 -0
- package/src/express.ts +386 -0
- package/src/fetch-observer.ts +226 -0
- package/src/index.ts +199 -0
- package/src/observe-register.ts +453 -0
- package/src/observe.ts +127 -0
- package/src/proxy-tracker.ts +208 -0
- package/src/register.ts +110 -0
- package/src/transport.ts +207 -0
- package/src/type-hash.ts +71 -0
- package/src/type-inference.ts +285 -0
- package/src/types.ts +61 -0
- package/src/wrap.ts +289 -0
- 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
|
+
}
|