trickle-observe 0.2.2 → 0.2.3

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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Variable-level tracing — captures the runtime type and sample value
3
+ * of variable assignments within function bodies.
4
+ *
5
+ * This is injected by the Module._compile source transform. After each
6
+ * `const/let/var x = expr;` statement, the transform inserts:
7
+ *
8
+ * __trickle_tv(x, 'x', 42, 'my-module', '/path/to/file.ts');
9
+ *
10
+ * The traceVar function:
11
+ * 1. Infers the TypeNode from the runtime value
12
+ * 2. Captures a sanitized sample value
13
+ * 3. Appends to .trickle/variables.jsonl
14
+ * 4. Caches by (file:line:varName + typeHash) to avoid duplicates
15
+ */
16
+
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { TypeNode } from './types';
20
+ import { inferType } from './type-inference';
21
+ import { hashType } from './type-hash';
22
+
23
+ /** Where to write variable observations */
24
+ let varsFilePath = '';
25
+ let debugMode = false;
26
+
27
+ /** Cache: "file:line:varName:typeHash" → already written */
28
+ const varCache = new Set<string>();
29
+
30
+ /** Batch buffer for writing — avoids one fs.appendFileSync per variable */
31
+ let varBuffer: string[] = [];
32
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
33
+ const FLUSH_INTERVAL_MS = 1000;
34
+ const MAX_BUFFER_SIZE = 100;
35
+
36
+ export interface VariableObservation {
37
+ kind: 'variable';
38
+ varName: string;
39
+ line: number;
40
+ module: string;
41
+ file: string;
42
+ type: TypeNode;
43
+ typeHash: string;
44
+ sample: unknown;
45
+ }
46
+
47
+ /**
48
+ * Initialize the variable tracer.
49
+ * Called once during observe-register setup.
50
+ */
51
+ export function initVarTracer(opts: { debug?: boolean } = {}): void {
52
+ debugMode = opts.debug === true;
53
+ const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
54
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
55
+ varsFilePath = path.join(dir, 'variables.jsonl');
56
+
57
+ if (debugMode) {
58
+ console.log(`[trickle/vars] Variable tracing enabled → ${varsFilePath}`);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Trace a variable's runtime value.
64
+ * Called by injected code after each variable declaration.
65
+ *
66
+ * @param value - The variable's current value (already computed)
67
+ * @param varName - The variable name in source
68
+ * @param line - Line number in source file
69
+ * @param moduleName - Module name (derived from filename)
70
+ * @param filePath - Absolute path to source file
71
+ */
72
+ export function traceVar(
73
+ value: unknown,
74
+ varName: string,
75
+ line: number,
76
+ moduleName: string,
77
+ filePath: string,
78
+ ): void {
79
+ // Auto-initialize if not yet done (needed for Vite/Vitest worker processes)
80
+ if (!varsFilePath) {
81
+ initVarTracer();
82
+ if (!varsFilePath) return;
83
+ }
84
+
85
+ try {
86
+ const type = inferType(value, 3);
87
+
88
+ // Create a stable hash for dedup
89
+ const dummyArgs: TypeNode = { kind: 'tuple', elements: [] };
90
+ const typeHash = hashType(dummyArgs, type);
91
+
92
+ const cacheKey = `${filePath}:${line}:${varName}:${typeHash}`;
93
+ if (varCache.has(cacheKey)) return;
94
+ varCache.add(cacheKey);
95
+
96
+ const sample = sanitizeVarSample(value);
97
+
98
+ const observation: VariableObservation = {
99
+ kind: 'variable',
100
+ varName,
101
+ line,
102
+ module: moduleName,
103
+ file: filePath,
104
+ type,
105
+ typeHash,
106
+ sample,
107
+ };
108
+
109
+ // Buffer the write
110
+ varBuffer.push(JSON.stringify(observation));
111
+
112
+ if (varBuffer.length >= MAX_BUFFER_SIZE) {
113
+ flushVarBuffer();
114
+ } else if (!flushTimer) {
115
+ flushTimer = setTimeout(() => {
116
+ flushVarBuffer();
117
+ }, FLUSH_INTERVAL_MS);
118
+ if (flushTimer && typeof flushTimer === 'object' && 'unref' in flushTimer) {
119
+ flushTimer.unref();
120
+ }
121
+ }
122
+ } catch {
123
+ // Never crash user's app
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Flush buffered variable observations to disk.
129
+ */
130
+ function flushVarBuffer(): void {
131
+ if (flushTimer) {
132
+ clearTimeout(flushTimer);
133
+ flushTimer = null;
134
+ }
135
+ if (varBuffer.length === 0) return;
136
+
137
+ const lines = varBuffer.join('\n') + '\n';
138
+ varBuffer = [];
139
+
140
+ try {
141
+ fs.appendFileSync(varsFilePath, lines);
142
+ } catch {
143
+ // Never crash user's app
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Sanitize a variable value for safe serialization.
149
+ * More aggressive truncation than function samples since there are many more variables.
150
+ */
151
+ function sanitizeVarSample(value: unknown, depth: number = 2): unknown {
152
+ if (depth <= 0) return '[truncated]';
153
+ if (value === null || value === undefined) return value;
154
+
155
+ const t = typeof value;
156
+ if (t === 'string') {
157
+ const s = value as string;
158
+ return s.length > 100 ? s.substring(0, 100) + '...' : s;
159
+ }
160
+ if (t === 'number' || t === 'boolean') return value;
161
+ if (t === 'bigint') return String(value);
162
+ if (t === 'symbol') return String(value);
163
+ if (t === 'function') return `[Function: ${(value as Function).name || 'anonymous'}]`;
164
+
165
+ if (Array.isArray(value)) {
166
+ return value.slice(0, 3).map(item => sanitizeVarSample(item, depth - 1));
167
+ }
168
+
169
+ if (t === 'object') {
170
+ if (value instanceof Date) return value.toISOString();
171
+ if (value instanceof RegExp) return String(value);
172
+ if (value instanceof Error) return { error: value.message };
173
+ if (value instanceof Map) return `[Map: ${value.size} entries]`;
174
+ if (value instanceof Set) return `[Set: ${value.size} items]`;
175
+ if (value instanceof Promise) return '[Promise]';
176
+
177
+ const obj = value as Record<string, unknown>;
178
+ const result: Record<string, unknown> = {};
179
+ const keys = Object.keys(obj).slice(0, 10);
180
+ for (const key of keys) {
181
+ try {
182
+ result[key] = sanitizeVarSample(obj[key], depth - 1);
183
+ } catch {
184
+ result[key] = '[unreadable]';
185
+ }
186
+ }
187
+ return result;
188
+ }
189
+
190
+ return String(value);
191
+ }
192
+
193
+ // Flush on process exit — use 'exit' event (synchronous, fires even on process.exit())
194
+ // because Vitest workers and forked processes may exit without 'beforeExit'.
195
+ // flushVarBuffer uses fs.appendFileSync so it's safe in the 'exit' handler.
196
+ if (typeof process !== 'undefined' && process.on) {
197
+ const exitFlush = () => { flushVarBuffer(); };
198
+ process.on('exit', exitFlush);
199
+ process.on('beforeExit', exitFlush);
200
+ process.on('SIGTERM', exitFlush);
201
+ process.on('SIGINT', exitFlush);
202
+ }
@@ -187,11 +187,21 @@ function inferArray(arr: unknown[], depth: number, seen: WeakSet<object>): TypeN
187
187
  return { kind: 'array', element: unifyTypes(elementTypes) };
188
188
  }
189
189
 
190
+ const MAX_PROPERTIES = 20;
191
+
190
192
  function inferPlainObject(obj: object, depth: number, seen: WeakSet<object>): TypeNode {
191
193
  const properties: Record<string, TypeNode> = {};
192
194
  const keys = Object.keys(obj);
193
195
 
194
- for (const key of keys) {
196
+ // Skip internal/private properties (common in Node.js built-in objects like
197
+ // http.Server, streams, etc.) — they make generated types unusably verbose.
198
+ const publicKeys = keys.filter(k => !k.startsWith('_'));
199
+ // If filtering removed everything, use all keys (it's a plain data object with _ keys)
200
+ const effectiveKeys = publicKeys.length > 0 ? publicKeys : keys;
201
+ // Cap the number of properties to keep types manageable
202
+ const cappedKeys = effectiveKeys.slice(0, MAX_PROPERTIES);
203
+
204
+ for (const key of cappedKeys) {
195
205
  try {
196
206
  properties[key] = infer((obj as Record<string, unknown>)[key], depth - 1, seen);
197
207
  } catch {