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,208 @@
1
+ import { TypeNode } from './types';
2
+ import { inferType } from './type-inference';
3
+
4
+ /**
5
+ * Creates a deep Proxy around a value to track property accesses.
6
+ * Returns the proxy and a function to retrieve all accessed paths with their inferred types.
7
+ *
8
+ * The proxy is designed to be fully transparent:
9
+ * - Array.isArray() returns true for proxied arrays
10
+ * - JSON.stringify works correctly
11
+ * - typeof, ===, iteration, spread, Object.keys all work identically
12
+ * - Symbol.toPrimitive, Symbol.iterator, Symbol.toStringTag are forwarded
13
+ */
14
+ export function createTracker(value: unknown): {
15
+ proxy: unknown;
16
+ getAccessedPaths: () => Map<string, TypeNode>;
17
+ } {
18
+ const accessedPaths = new Map<string, TypeNode>();
19
+ const proxyCache = new WeakMap<object, unknown>();
20
+
21
+ function wrap(val: unknown, path: string): unknown {
22
+ // Only proxy objects and arrays (not primitives or functions at top level)
23
+ if (val === null || val === undefined) return val;
24
+ if (typeof val !== 'object' && typeof val !== 'function') return val;
25
+
26
+ const obj = val as object;
27
+
28
+ // Check cache to prevent double-wrapping
29
+ if (proxyCache.has(obj)) {
30
+ return proxyCache.get(obj);
31
+ }
32
+
33
+ // For arrays, the proxy target must be an array so Array.isArray() works
34
+ const target = Array.isArray(obj) ? obj : obj;
35
+
36
+ const proxy = new Proxy(target, {
37
+ get(target: any, prop: string | symbol, receiver: any): any {
38
+ // Forward well-known symbols transparently
39
+ if (typeof prop === 'symbol') {
40
+ // Forward Symbol.toPrimitive, Symbol.iterator, Symbol.toStringTag, Symbol.hasInstance
41
+ const raw = Reflect.get(target, prop, target);
42
+ // For iterator, bind to target so iteration works on original
43
+ if (prop === Symbol.iterator && typeof raw === 'function') {
44
+ return raw.bind(target);
45
+ }
46
+ return raw;
47
+ }
48
+
49
+ const raw = Reflect.get(target, prop, target);
50
+
51
+ // Don't track internal/meta properties
52
+ if (prop === 'constructor' || prop === 'prototype' || prop === '__proto__') {
53
+ return raw;
54
+ }
55
+
56
+ // toJSON: return original value's toJSON or the target itself for JSON.stringify
57
+ if (prop === 'toJSON') {
58
+ if (typeof raw === 'function') {
59
+ return raw.bind(target);
60
+ }
61
+ return raw;
62
+ }
63
+
64
+ // For arrays, forward array methods transparently
65
+ if (Array.isArray(target)) {
66
+ if (prop === 'length') {
67
+ // Record that length was accessed
68
+ const fullPath = path ? `${path}.length` : 'length';
69
+ accessedPaths.set(fullPath, { kind: 'primitive', name: 'number' });
70
+ return raw;
71
+ }
72
+
73
+ // Array index access
74
+ if (isArrayIndex(prop)) {
75
+ const fullPath = path ? `${path}[${prop}]` : `[${prop}]`;
76
+ const val = raw;
77
+ if (val !== undefined) {
78
+ accessedPaths.set(fullPath, inferType(val, 3));
79
+ }
80
+ // Recursively wrap object elements
81
+ if (val !== null && val !== undefined && typeof val === 'object') {
82
+ return wrap(val, fullPath);
83
+ }
84
+ return val;
85
+ }
86
+
87
+ // Array methods that take callbacks - wrap to track callback args
88
+ if (typeof raw === 'function') {
89
+ if (isCallbackMethod(prop)) {
90
+ return createTrackedArrayMethod(target, prop, path, raw, accessedPaths, wrap);
91
+ }
92
+ // Other array methods: bind to original
93
+ return raw.bind(target);
94
+ }
95
+ }
96
+
97
+ // Record the access path and type
98
+ const fullPath = path ? `${path}.${prop}` : prop;
99
+ if (raw !== undefined) {
100
+ accessedPaths.set(fullPath, inferType(raw, 3));
101
+ }
102
+
103
+ // Recursively wrap sub-objects
104
+ if (raw !== null && raw !== undefined && typeof raw === 'object') {
105
+ return wrap(raw, fullPath);
106
+ }
107
+
108
+ // Bind functions to original target
109
+ if (typeof raw === 'function') {
110
+ return raw.bind(target);
111
+ }
112
+
113
+ return raw;
114
+ },
115
+
116
+ set(target: any, prop: string | symbol, value: any, receiver: any): boolean {
117
+ return Reflect.set(target, prop, value, target);
118
+ },
119
+
120
+ has(target: any, prop: string | symbol): boolean {
121
+ return Reflect.has(target, prop);
122
+ },
123
+
124
+ ownKeys(target: any): (string | symbol)[] {
125
+ return Reflect.ownKeys(target);
126
+ },
127
+
128
+ getOwnPropertyDescriptor(target: any, prop: string | symbol): PropertyDescriptor | undefined {
129
+ return Reflect.getOwnPropertyDescriptor(target, prop);
130
+ },
131
+
132
+ getPrototypeOf(target: any): object | null {
133
+ return Reflect.getPrototypeOf(target);
134
+ },
135
+
136
+ isExtensible(target: any): boolean {
137
+ return Reflect.isExtensible(target);
138
+ },
139
+
140
+ preventExtensions(target: any): boolean {
141
+ return Reflect.preventExtensions(target);
142
+ },
143
+
144
+ defineProperty(target: any, prop: string | symbol, descriptor: PropertyDescriptor): boolean {
145
+ return Reflect.defineProperty(target, prop, descriptor);
146
+ },
147
+
148
+ deleteProperty(target: any, prop: string | symbol): boolean {
149
+ return Reflect.deleteProperty(target, prop);
150
+ },
151
+ });
152
+
153
+ proxyCache.set(obj, proxy);
154
+ return proxy;
155
+ }
156
+
157
+ const proxy = wrap(value, '');
158
+ return {
159
+ proxy,
160
+ getAccessedPaths: () => new Map(accessedPaths),
161
+ };
162
+ }
163
+
164
+ function isArrayIndex(prop: string): boolean {
165
+ const num = Number(prop);
166
+ return Number.isInteger(num) && num >= 0 && String(num) === prop;
167
+ }
168
+
169
+ const CALLBACK_METHODS = new Set([
170
+ 'map', 'filter', 'forEach', 'find', 'findIndex', 'some', 'every',
171
+ 'reduce', 'reduceRight', 'flatMap', 'sort',
172
+ ]);
173
+
174
+ function isCallbackMethod(prop: string): boolean {
175
+ return CALLBACK_METHODS.has(prop);
176
+ }
177
+
178
+ /**
179
+ * Creates a tracked version of an array method that takes a callback.
180
+ * Wraps callback arguments (the element) in proxies so property accesses within
181
+ * the callback are also tracked.
182
+ */
183
+ function createTrackedArrayMethod(
184
+ target: any[],
185
+ method: string,
186
+ basePath: string,
187
+ rawFn: Function,
188
+ accessedPaths: Map<string, TypeNode>,
189
+ wrap: (val: unknown, path: string) => unknown,
190
+ ): Function {
191
+ return function (this: any, ...args: any[]) {
192
+ if (args.length > 0 && typeof args[0] === 'function') {
193
+ const originalCb = args[0];
194
+ args[0] = function (element: any, index: number, array: any[]) {
195
+ const elementPath = basePath ? `${basePath}[${index}]` : `[${index}]`;
196
+ // Wrap the element so accesses inside the callback are tracked
197
+ let wrappedElement = element;
198
+ if (element !== null && element !== undefined && typeof element === 'object') {
199
+ wrappedElement = wrap(element, elementPath);
200
+ } else if (element !== undefined) {
201
+ accessedPaths.set(elementPath, inferType(element, 3));
202
+ }
203
+ return originalCb.call(this, wrappedElement, index, array);
204
+ };
205
+ }
206
+ return rawFn.apply(target, args);
207
+ };
208
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Zero-code auto-instrumentation for Node.js applications.
3
+ *
4
+ * Usage — just add a flag to your start command:
5
+ *
6
+ * node -r trickle/register app.js
7
+ *
8
+ * Or with environment variables:
9
+ *
10
+ * TRICKLE_BACKEND_URL=http://localhost:4888 node -r trickle/register app.js
11
+ *
12
+ * This module patches Node's module loader to intercept `require('express')`
13
+ * and automatically instrument any Express app created — no code changes needed.
14
+ *
15
+ * Supported environment variables:
16
+ * TRICKLE_BACKEND_URL — Backend URL (default: http://localhost:4888)
17
+ * TRICKLE_ENABLED — Set to "0" or "false" to disable (default: enabled)
18
+ * TRICKLE_DEBUG — Set to "1" or "true" for debug logging
19
+ * TRICKLE_ENV — Override detected environment name
20
+ */
21
+
22
+ import Module from 'module';
23
+ import { configure } from './transport';
24
+ import { instrumentExpress } from './express';
25
+ import { detectEnvironment } from './env-detect';
26
+
27
+ const M = Module as any;
28
+ const originalLoad = M._load;
29
+ const patched = new Set<string>();
30
+
31
+ // Read config from environment
32
+ const backendUrl = process.env.TRICKLE_BACKEND_URL || 'http://localhost:4888';
33
+ const enabled = process.env.TRICKLE_ENABLED !== '0' && process.env.TRICKLE_ENABLED !== 'false';
34
+ const debug = process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true';
35
+ const envOverride = process.env.TRICKLE_ENV || undefined;
36
+
37
+ if (enabled) {
38
+ // Configure the transport
39
+ configure({
40
+ backendUrl,
41
+ batchIntervalMs: 2000,
42
+ debug,
43
+ enabled: true,
44
+ environment: envOverride || detectEnvironment(),
45
+ });
46
+
47
+ if (debug) {
48
+ console.log(`[trickle] Auto-instrumentation enabled (backend: ${backendUrl})`);
49
+ }
50
+
51
+ // Patch Module._load to intercept framework requires
52
+ M._load = function hookedLoad(request: string, parent: any, isMain: boolean): any {
53
+ const exports = originalLoad.apply(this, arguments);
54
+
55
+ // Intercept require('express')
56
+ if (request === 'express' && !patched.has('express')) {
57
+ patched.add('express');
58
+ return patchExpress(exports, request, parent);
59
+ }
60
+
61
+ return exports;
62
+ };
63
+ } else if (debug) {
64
+ console.log('[trickle] Auto-instrumentation disabled (TRICKLE_ENABLED=false)');
65
+ }
66
+
67
+ /**
68
+ * Wrap the Express factory function so every app created is auto-instrumented.
69
+ * Preserves all static properties (express.json, express.static, etc.).
70
+ */
71
+ function patchExpress(originalExpress: any, request: string, parent: any): any {
72
+ function wrappedExpress(this: any, ...args: any[]): any {
73
+ const app = originalExpress.apply(this, args);
74
+ try {
75
+ instrumentExpress(app, {
76
+ environment: envOverride || detectEnvironment(),
77
+ });
78
+ if (debug) {
79
+ console.log('[trickle] Auto-instrumented Express app');
80
+ }
81
+ } catch (err: unknown) {
82
+ if (debug) {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ console.warn(`[trickle] Failed to auto-instrument Express: ${msg}`);
85
+ }
86
+ }
87
+ return app;
88
+ }
89
+
90
+ // Copy all static properties (express.json, express.static, express.Router, etc.)
91
+ for (const key of Object.keys(originalExpress)) {
92
+ (wrappedExpress as any)[key] = originalExpress[key];
93
+ }
94
+
95
+ // Preserve prototype chain
96
+ Object.setPrototypeOf(wrappedExpress, Object.getPrototypeOf(originalExpress));
97
+
98
+ // Update require cache so subsequent require('express') returns the patched version
99
+ try {
100
+ const resolvedPath = M._resolveFilename(request, parent);
101
+ if (require.cache[resolvedPath]) {
102
+ require.cache[resolvedPath]!.exports = wrappedExpress;
103
+ }
104
+ } catch {
105
+ // Cache update failed — first require still returns patched, but subsequent
106
+ // requires from other modules may get the original. This is rare.
107
+ }
108
+
109
+ return wrappedExpress;
110
+ }
@@ -0,0 +1,207 @@
1
+ import * as fs from 'fs';
2
+ import * as pathMod from 'path';
3
+ import { IngestPayload, GlobalOpts } from './types';
4
+
5
+ const MAX_RETRIES = 3;
6
+ const INITIAL_RETRY_DELAY_MS = 1000;
7
+ const DEFAULT_BATCH_INTERVAL_MS = 2000;
8
+ const DEFAULT_MAX_BATCH_SIZE = 50;
9
+
10
+ let backendUrl = 'http://localhost:4888';
11
+ let batchIntervalMs = DEFAULT_BATCH_INTERVAL_MS;
12
+ let maxBatchSize = DEFAULT_MAX_BATCH_SIZE;
13
+ let enabled = true;
14
+ let debug = false;
15
+ let localMode = process.env.TRICKLE_LOCAL === '1';
16
+ let localFilePath = '';
17
+
18
+ let queue: IngestPayload[] = [];
19
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
20
+ let isFlushing = false;
21
+
22
+ /**
23
+ * Configure the transport layer with global options.
24
+ */
25
+ export function configure(opts: GlobalOpts): void {
26
+ backendUrl = opts.backendUrl || backendUrl;
27
+ batchIntervalMs = opts.batchIntervalMs || DEFAULT_BATCH_INTERVAL_MS;
28
+ maxBatchSize = opts.maxBatchSize || DEFAULT_MAX_BATCH_SIZE;
29
+ enabled = opts.enabled !== false;
30
+ debug = opts.debug === true;
31
+
32
+ // Check for local/file-based mode
33
+ if (process.env.TRICKLE_LOCAL === '1') {
34
+ localMode = true;
35
+ const dir = process.env.TRICKLE_LOCAL_DIR || pathMod.join(process.cwd(), '.trickle');
36
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
37
+ localFilePath = pathMod.join(dir, 'observations.jsonl');
38
+ if (debug) {
39
+ console.log(`[trickle] Local mode: writing to ${localFilePath}`);
40
+ }
41
+ return; // no timer needed for file mode
42
+ }
43
+
44
+ // Restart the flush timer with new interval
45
+ stopTimer();
46
+ startTimer();
47
+ }
48
+
49
+ /**
50
+ * Enqueue a payload for batched sending.
51
+ */
52
+ export function enqueue(payload: IngestPayload): void {
53
+ if (!enabled) return;
54
+
55
+ // Local file mode: append directly to JSONL file
56
+ if (localMode && localFilePath) {
57
+ try {
58
+ fs.appendFileSync(localFilePath, JSON.stringify(payload) + '\n');
59
+ } catch {
60
+ // Never crash user's app
61
+ }
62
+ return;
63
+ }
64
+
65
+ queue.push(payload);
66
+
67
+ // Flush immediately if batch is full
68
+ if (queue.length >= maxBatchSize) {
69
+ flush().catch(silentError);
70
+ }
71
+
72
+ // Ensure timer is running
73
+ if (!flushTimer) {
74
+ startTimer();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Flush all queued payloads to the backend.
80
+ * Returns a promise that resolves when the flush completes.
81
+ */
82
+ export async function flush(): Promise<void> {
83
+ if (queue.length === 0) return;
84
+ if (isFlushing) return;
85
+
86
+ isFlushing = true;
87
+ const batch = queue.splice(0);
88
+
89
+ try {
90
+ await sendBatch(batch);
91
+ } catch {
92
+ // Batch is already dropped after max retries — nothing more to do
93
+ if (debug) {
94
+ console.warn('[trickle] Failed to flush batch, data dropped');
95
+ }
96
+ } finally {
97
+ isFlushing = false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Send a batch with exponential backoff retry.
103
+ */
104
+ async function sendBatch(batch: IngestPayload[]): Promise<void> {
105
+ let delay = INITIAL_RETRY_DELAY_MS;
106
+
107
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
108
+ try {
109
+ const response = await fetch(`${backendUrl}/api/ingest/batch`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ payloads: batch }),
113
+ signal: AbortSignal.timeout(10000), // 10 second timeout
114
+ });
115
+
116
+ if (response.ok) {
117
+ if (debug) {
118
+ console.log(`[trickle] Sent batch of ${batch.length} events`);
119
+ }
120
+ return;
121
+ }
122
+
123
+ // Server error — retry
124
+ if (response.status >= 500 && attempt < MAX_RETRIES) {
125
+ await sleep(delay);
126
+ delay *= 2;
127
+ continue;
128
+ }
129
+
130
+ // Client error (4xx) or final attempt — drop
131
+ if (debug) {
132
+ console.warn(`[trickle] Backend returned ${response.status}, dropping batch`);
133
+ }
134
+ return;
135
+ } catch (err: unknown) {
136
+ if (attempt < MAX_RETRIES) {
137
+ await sleep(delay);
138
+ delay *= 2;
139
+ continue;
140
+ }
141
+
142
+ // Final attempt failed
143
+ if (debug) {
144
+ const msg = err instanceof Error ? err.message : String(err);
145
+ console.warn(`[trickle] Could not reach backend after ${MAX_RETRIES + 1} attempts: ${msg}`);
146
+ }
147
+ return;
148
+ }
149
+ }
150
+ }
151
+
152
+ function startTimer(): void {
153
+ if (flushTimer) return;
154
+ flushTimer = setInterval(() => {
155
+ flush().catch(silentError);
156
+ }, batchIntervalMs);
157
+
158
+ // Don't keep the process alive just for trickle
159
+ if (flushTimer && typeof flushTimer === 'object' && 'unref' in flushTimer) {
160
+ flushTimer.unref();
161
+ }
162
+ }
163
+
164
+ function stopTimer(): void {
165
+ if (flushTimer) {
166
+ clearInterval(flushTimer);
167
+ flushTimer = null;
168
+ }
169
+ }
170
+
171
+ function sleep(ms: number): Promise<void> {
172
+ return new Promise(resolve => setTimeout(resolve, ms));
173
+ }
174
+
175
+ function silentError(): void {
176
+ // Intentionally empty — never crash user's app
177
+ }
178
+
179
+ // Register process exit handler to flush remaining events
180
+ if (typeof process !== 'undefined' && process.on) {
181
+ process.on('beforeExit', () => {
182
+ flush().catch(silentError);
183
+ });
184
+
185
+ // SIGTERM / SIGINT: attempt a sync-ish flush
186
+ const exitHandler = () => {
187
+ flush().catch(silentError);
188
+ };
189
+ process.on('SIGTERM', exitHandler);
190
+ process.on('SIGINT', exitHandler);
191
+ }
192
+
193
+ /**
194
+ * Get the current queue length (for testing/debugging).
195
+ */
196
+ export function getQueueLength(): number {
197
+ return queue.length;
198
+ }
199
+
200
+ /**
201
+ * Reset the transport state (for testing).
202
+ */
203
+ export function reset(): void {
204
+ queue = [];
205
+ stopTimer();
206
+ isFlushing = false;
207
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from 'crypto';
2
+ import { TypeNode } from './types';
3
+
4
+ /**
5
+ * Canonicalize a TypeNode for deterministic hashing.
6
+ * - Object properties are sorted alphabetically by key at all levels.
7
+ * - Union members are sorted by their canonical string representation.
8
+ */
9
+ function canonicalize(node: TypeNode): unknown {
10
+ switch (node.kind) {
11
+ case 'primitive':
12
+ return { kind: 'primitive', name: node.name };
13
+
14
+ case 'array':
15
+ return { kind: 'array', element: canonicalize(node.element) };
16
+
17
+ case 'object': {
18
+ const sortedKeys = Object.keys(node.properties).sort();
19
+ const properties: Record<string, unknown> = {};
20
+ for (const key of sortedKeys) {
21
+ properties[key] = canonicalize(node.properties[key]);
22
+ }
23
+ return { kind: 'object', properties };
24
+ }
25
+
26
+ case 'union': {
27
+ const members = node.members
28
+ .map(m => canonicalize(m))
29
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
30
+ return { kind: 'union', members };
31
+ }
32
+
33
+ case 'function': {
34
+ return {
35
+ kind: 'function',
36
+ params: node.params.map(canonicalize),
37
+ returnType: canonicalize(node.returnType),
38
+ };
39
+ }
40
+
41
+ case 'promise':
42
+ return { kind: 'promise', resolved: canonicalize(node.resolved) };
43
+
44
+ case 'map':
45
+ return { kind: 'map', key: canonicalize(node.key), value: canonicalize(node.value) };
46
+
47
+ case 'set':
48
+ return { kind: 'set', element: canonicalize(node.element) };
49
+
50
+ case 'tuple':
51
+ return { kind: 'tuple', elements: node.elements.map(canonicalize) };
52
+
53
+ case 'unknown':
54
+ return { kind: 'unknown' };
55
+
56
+ default:
57
+ return { kind: 'unknown' };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Hash the combined args + return type into a deterministic 16-hex-char string.
63
+ */
64
+ export function hashType(argsType: TypeNode, returnType: TypeNode): string {
65
+ const canonical = JSON.stringify({
66
+ args: canonicalize(argsType),
67
+ ret: canonicalize(returnType),
68
+ });
69
+
70
+ return createHash('sha256').update(canonical).digest('hex').substring(0, 16);
71
+ }