trickle-observe 0.2.70 → 0.2.71

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.
@@ -480,6 +480,110 @@ function extractDestructuredNames(pattern) {
480
480
  }
481
481
  return names;
482
482
  }
483
+ /**
484
+ * Find variable reassignments (not declarations) and return insertions for tracing.
485
+ * Handles: x = newValue; x += 1; x ||= fallback; etc.
486
+ * Only matches standalone reassignment statements at the start of a line.
487
+ * Skips: property assignments (obj.x = ...), indexed (arr[i] = ...),
488
+ * comparisons (===, !==), arrow functions (=>), declarations (const/let/var).
489
+ */
490
+ function findReassignments(source) {
491
+ const results = [];
492
+ // Match: <identifier> <assignOp>= <value> at the start of a line
493
+ // Compound operators: +=, -=, *=, /=, %=, **=, &&=, ||=, ??=, <<=, >>=, >>>=, &=, |=, ^=
494
+ // Plain: = (but not ==, ===, =>, !=)
495
+ const reassignRegex = /^([ \t]*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:\+|-|\*\*?|\/|%|&&|\|\||<<|>>>?|&|\||\^|\?\?)?=[^=>]/gm;
496
+ let match;
497
+ while ((match = reassignRegex.exec(source)) !== null) {
498
+ const varName = match[2];
499
+ // Skip trickle internals
500
+ if (varName.startsWith('__trickle') || varName.startsWith('_$'))
501
+ continue;
502
+ // Skip common non-variable patterns
503
+ if (varName === '_a' || varName === '_b' || varName === '_c')
504
+ continue;
505
+ // Skip 'this', 'self', 'super' (not reassignable in practice)
506
+ if (varName === 'this' || varName === 'super')
507
+ continue;
508
+ // Skip keywords that could look like identifiers
509
+ if (['if', 'else', 'while', 'for', 'do', 'switch', 'case', 'default', 'return', 'throw',
510
+ 'break', 'continue', 'try', 'catch', 'finally', 'new', 'delete', 'typeof', 'void',
511
+ 'yield', 'await', 'class', 'extends', 'import', 'export', 'from', 'as', 'of', 'in',
512
+ 'const', 'let', 'var', 'function', 'true', 'false', 'null', 'undefined'].includes(varName))
513
+ continue;
514
+ // Check that this line doesn't start with const/let/var (would be a declaration, already handled)
515
+ const lineStart = source.lastIndexOf('\n', match.index) + 1;
516
+ const linePrefix = source.slice(lineStart, match.index + match[1].length).trim();
517
+ if (/^(export\s+)?(const|let|var)\s/.test(source.slice(lineStart).trimStart()))
518
+ continue;
519
+ // Skip if this looks like a property in an object literal (preceded by a key: pattern on same line)
520
+ // or if it's a label (label: ...)
521
+ const beforeOnLine = source.slice(lineStart, match.index).trim();
522
+ if (beforeOnLine.endsWith(':') || beforeOnLine.endsWith(','))
523
+ continue;
524
+ // Calculate line number
525
+ let lineNo = 1;
526
+ for (let i = 0; i < match.index; i++) {
527
+ if (source[i] === '\n')
528
+ lineNo++;
529
+ }
530
+ // Find end of statement (same logic as findVarDeclarations)
531
+ const startPos = match.index + match[0].length - 1;
532
+ let pos = startPos;
533
+ let depth = 0;
534
+ let foundEnd = -1;
535
+ while (pos < source.length) {
536
+ const ch = source[pos];
537
+ if (ch === '(' || ch === '[' || ch === '{') {
538
+ depth++;
539
+ }
540
+ else if (ch === ')' || ch === ']' || ch === '}') {
541
+ depth--;
542
+ if (depth < 0)
543
+ break;
544
+ }
545
+ else if (ch === ';' && depth === 0) {
546
+ foundEnd = pos;
547
+ break;
548
+ }
549
+ else if (ch === '\n' && depth === 0) {
550
+ const nextNonWs = source.slice(pos + 1).match(/^\s*(\S)/);
551
+ if (nextNonWs && !'.+=-|&?:,'.includes(nextNonWs[1])) {
552
+ foundEnd = pos;
553
+ break;
554
+ }
555
+ }
556
+ else if (ch === '"' || ch === "'" || ch === '`') {
557
+ const quote = ch;
558
+ pos++;
559
+ while (pos < source.length) {
560
+ if (source[pos] === '\\') {
561
+ pos++;
562
+ }
563
+ else if (source[pos] === quote)
564
+ break;
565
+ pos++;
566
+ }
567
+ }
568
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
569
+ while (pos < source.length && source[pos] !== '\n')
570
+ pos++;
571
+ continue;
572
+ }
573
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
574
+ pos += 2;
575
+ while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/'))
576
+ pos++;
577
+ pos++;
578
+ }
579
+ pos++;
580
+ }
581
+ if (foundEnd === -1)
582
+ continue;
583
+ results.push({ lineEnd: foundEnd + 1, varName, lineNo });
584
+ }
585
+ return results;
586
+ }
483
587
  /**
484
588
  * Find for-loop variable declarations and return insertions for tracing.
485
589
  * Handles:
@@ -1111,11 +1215,13 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1111
1215
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
1112
1216
  // Find destructured variable declarations for tracing
1113
1217
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
1218
+ // Find variable reassignments for tracing
1219
+ const reassignInsertions = traceVars ? findReassignments(source) : [];
1114
1220
  // Find for-loop variable declarations for tracing
1115
1221
  const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1116
1222
  // Find function parameter names for tracing
1117
1223
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1118
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && forLoopInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1224
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1119
1225
  return source;
1120
1226
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1121
1227
  // Map transformed line numbers to original source line numbers.
@@ -1135,6 +1241,12 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1135
1241
  di.lineNo = origLine;
1136
1242
  }
1137
1243
  }
1244
+ // Fix reassignment line numbers
1245
+ for (const ri of reassignInsertions) {
1246
+ const origLine = findOriginalLine(origLines, ri.varName, ri.lineNo);
1247
+ if (origLine !== -1)
1248
+ ri.lineNo = origLine;
1249
+ }
1138
1250
  // Fix for-loop var line numbers
1139
1251
  for (const fi of forLoopInsertions) {
1140
1252
  if (fi.varNames.length > 0) {
@@ -1178,7 +1290,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1178
1290
  }
1179
1291
  }
1180
1292
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1181
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1293
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1182
1294
  const importLines = [
1183
1295
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
1184
1296
  ];
@@ -1216,7 +1328,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1216
1328
  }
1217
1329
  }
1218
1330
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1219
- if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1331
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1220
1332
  prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Set();`, ` function _inferType(v, d) {`, ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`, ` if (v === null) return { kind: 'primitive', name: 'null' };`, ` if (v === undefined) return { kind: 'primitive', name: 'undefined' };`, ` const t = typeof v;`, ` if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint' || t === 'symbol') return { kind: 'primitive', name: t };`, ` if (t === 'function') return { kind: 'function' };`, ` if (Array.isArray(v)) { return v.length === 0 ? { kind: 'array', element: { kind: 'primitive', name: 'unknown' } } : { kind: 'array', element: _inferType(v[0], d-1) }; }`, ` if (t === 'object') {`, ` if (v instanceof Date) return { kind: 'object', properties: { __date: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof RegExp) return { kind: 'object', properties: { __regexp: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Error) return { kind: 'object', properties: { __error: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Promise) return { kind: 'promise', resolved: { kind: 'primitive', name: 'unknown' } };`, ` const props = {}; const keys = Object.keys(v).slice(0, 20);`, ` for (const k of keys) { try { props[k] = _inferType(v[k], d-1); } catch(e) { props[k] = { kind: 'primitive', name: 'unknown' }; } }`, ` return { kind: 'object', properties: props };`, ` }`, ` return { kind: 'primitive', name: 'unknown' };`, ` }`, ` function _sanitize(v, d) {`, ` if (d <= 0) return '[truncated]'; if (v === null || v === undefined) return v; const t = typeof v;`, ` if (t === 'string') return v.length > 100 ? v.substring(0, 100) + '...' : v;`, ` if (t === 'number' || t === 'boolean') return v; if (t === 'bigint') return String(v);`, ` if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';`, ` if (Array.isArray(v)) return v.slice(0, 3).map(i => _sanitize(i, d-1));`, ` if (t === 'object') { if (v instanceof Date) return v.toISOString(); if (v instanceof RegExp) return String(v); if (v instanceof Error) return { error: v.message }; if (v instanceof Promise) return '[Promise]';`, ` const r = {}; const keys = Object.keys(v).slice(0, 10); for (const k of keys) { try { r[k] = _sanitize(v[k], d-1); } catch(e) { r[k] = '[unreadable]'; } } return r; }`, ` return String(v);`, ` }`, ` globalThis.__trickle_var_tracer = function(v, n, l, mod, file) {`, ` try {`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const ck = file + ':' + l + ':' + n + ':' + th;`, ` if (_cache.has(ck)) return;`, ` _cache.add(ck);`, ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }));`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
1221
1333
  }
1222
1334
  // Add React component render tracker if needed
@@ -1254,6 +1366,13 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
1254
1366
  code: `\n;try{${calls}}catch(__e){}\n`,
1255
1367
  });
1256
1368
  }
1369
+ // Reassignment insertions: trace after the reassignment statement
1370
+ for (const { lineEnd, varName, lineNo } of reassignInsertions) {
1371
+ allInsertions.push({
1372
+ position: lineEnd,
1373
+ code: `\n;try{__trickle_tv(${varName},${JSON.stringify(varName)},${lineNo})}catch(__e){}\n`,
1374
+ });
1375
+ }
1257
1376
  // For-loop variable insertions: insert trace at start of loop body
1258
1377
  for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1259
1378
  const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
@@ -473,6 +473,110 @@ function extractDestructuredNames(pattern) {
473
473
  }
474
474
  return names;
475
475
  }
476
+ /**
477
+ * Find variable reassignments (not declarations) and return insertions for tracing.
478
+ * Handles: x = newValue; x += 1; x ||= fallback; etc.
479
+ * Only matches standalone reassignment statements at the start of a line.
480
+ * Skips: property assignments (obj.x = ...), indexed (arr[i] = ...),
481
+ * comparisons (===, !==), arrow functions (=>), declarations (const/let/var).
482
+ */
483
+ function findReassignments(source) {
484
+ const results = [];
485
+ // Match: <identifier> <assignOp>= <value> at the start of a line
486
+ // Compound operators: +=, -=, *=, /=, %=, **=, &&=, ||=, ??=, <<=, >>=, >>>=, &=, |=, ^=
487
+ // Plain: = (but not ==, ===, =>, !=)
488
+ const reassignRegex = /^([ \t]*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:\+|-|\*\*?|\/|%|&&|\|\||<<|>>>?|&|\||\^|\?\?)?=[^=>]/gm;
489
+ let match;
490
+ while ((match = reassignRegex.exec(source)) !== null) {
491
+ const varName = match[2];
492
+ // Skip trickle internals
493
+ if (varName.startsWith('__trickle') || varName.startsWith('_$'))
494
+ continue;
495
+ // Skip common non-variable patterns
496
+ if (varName === '_a' || varName === '_b' || varName === '_c')
497
+ continue;
498
+ // Skip 'this', 'self', 'super' (not reassignable in practice)
499
+ if (varName === 'this' || varName === 'super')
500
+ continue;
501
+ // Skip keywords that could look like identifiers
502
+ if (['if', 'else', 'while', 'for', 'do', 'switch', 'case', 'default', 'return', 'throw',
503
+ 'break', 'continue', 'try', 'catch', 'finally', 'new', 'delete', 'typeof', 'void',
504
+ 'yield', 'await', 'class', 'extends', 'import', 'export', 'from', 'as', 'of', 'in',
505
+ 'const', 'let', 'var', 'function', 'true', 'false', 'null', 'undefined'].includes(varName))
506
+ continue;
507
+ // Check that this line doesn't start with const/let/var (would be a declaration, already handled)
508
+ const lineStart = source.lastIndexOf('\n', match.index) + 1;
509
+ const linePrefix = source.slice(lineStart, match.index + match[1].length).trim();
510
+ if (/^(export\s+)?(const|let|var)\s/.test(source.slice(lineStart).trimStart()))
511
+ continue;
512
+ // Skip if this looks like a property in an object literal (preceded by a key: pattern on same line)
513
+ // or if it's a label (label: ...)
514
+ const beforeOnLine = source.slice(lineStart, match.index).trim();
515
+ if (beforeOnLine.endsWith(':') || beforeOnLine.endsWith(','))
516
+ continue;
517
+ // Calculate line number
518
+ let lineNo = 1;
519
+ for (let i = 0; i < match.index; i++) {
520
+ if (source[i] === '\n')
521
+ lineNo++;
522
+ }
523
+ // Find end of statement (same logic as findVarDeclarations)
524
+ const startPos = match.index + match[0].length - 1;
525
+ let pos = startPos;
526
+ let depth = 0;
527
+ let foundEnd = -1;
528
+ while (pos < source.length) {
529
+ const ch = source[pos];
530
+ if (ch === '(' || ch === '[' || ch === '{') {
531
+ depth++;
532
+ }
533
+ else if (ch === ')' || ch === ']' || ch === '}') {
534
+ depth--;
535
+ if (depth < 0)
536
+ break;
537
+ }
538
+ else if (ch === ';' && depth === 0) {
539
+ foundEnd = pos;
540
+ break;
541
+ }
542
+ else if (ch === '\n' && depth === 0) {
543
+ const nextNonWs = source.slice(pos + 1).match(/^\s*(\S)/);
544
+ if (nextNonWs && !'.+=-|&?:,'.includes(nextNonWs[1])) {
545
+ foundEnd = pos;
546
+ break;
547
+ }
548
+ }
549
+ else if (ch === '"' || ch === "'" || ch === '`') {
550
+ const quote = ch;
551
+ pos++;
552
+ while (pos < source.length) {
553
+ if (source[pos] === '\\') {
554
+ pos++;
555
+ }
556
+ else if (source[pos] === quote)
557
+ break;
558
+ pos++;
559
+ }
560
+ }
561
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
562
+ while (pos < source.length && source[pos] !== '\n')
563
+ pos++;
564
+ continue;
565
+ }
566
+ else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
567
+ pos += 2;
568
+ while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/'))
569
+ pos++;
570
+ pos++;
571
+ }
572
+ pos++;
573
+ }
574
+ if (foundEnd === -1)
575
+ continue;
576
+ results.push({ lineEnd: foundEnd + 1, varName, lineNo });
577
+ }
578
+ return results;
579
+ }
476
580
  /**
477
581
  * Find for-loop variable declarations and return insertions for tracing.
478
582
  * Handles:
@@ -1104,11 +1208,13 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1104
1208
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
1105
1209
  // Find destructured variable declarations for tracing
1106
1210
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
1211
+ // Find variable reassignments for tracing
1212
+ const reassignInsertions = traceVars ? findReassignments(source) : [];
1107
1213
  // Find for-loop variable declarations for tracing
1108
1214
  const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1109
1215
  // Find function parameter names for tracing
1110
1216
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1111
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && forLoopInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1217
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1112
1218
  return source;
1113
1219
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1114
1220
  // Map transformed line numbers to original source line numbers.
@@ -1128,6 +1234,12 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1128
1234
  di.lineNo = origLine;
1129
1235
  }
1130
1236
  }
1237
+ // Fix reassignment line numbers
1238
+ for (const ri of reassignInsertions) {
1239
+ const origLine = findOriginalLine(origLines, ri.varName, ri.lineNo);
1240
+ if (origLine !== -1)
1241
+ ri.lineNo = origLine;
1242
+ }
1131
1243
  // Fix for-loop var line numbers
1132
1244
  for (const fi of forLoopInsertions) {
1133
1245
  if (fi.varNames.length > 0) {
@@ -1171,7 +1283,7 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1171
1283
  }
1172
1284
  }
1173
1285
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1174
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1286
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1175
1287
  const importLines = [
1176
1288
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
1177
1289
  ];
@@ -1209,7 +1321,7 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1209
1321
  }
1210
1322
  }
1211
1323
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1212
- if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1324
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1213
1325
  prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Set();`, ` function _inferType(v, d) {`, ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`, ` if (v === null) return { kind: 'primitive', name: 'null' };`, ` if (v === undefined) return { kind: 'primitive', name: 'undefined' };`, ` const t = typeof v;`, ` if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint' || t === 'symbol') return { kind: 'primitive', name: t };`, ` if (t === 'function') return { kind: 'function' };`, ` if (Array.isArray(v)) { return v.length === 0 ? { kind: 'array', element: { kind: 'primitive', name: 'unknown' } } : { kind: 'array', element: _inferType(v[0], d-1) }; }`, ` if (t === 'object') {`, ` if (v instanceof Date) return { kind: 'object', properties: { __date: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof RegExp) return { kind: 'object', properties: { __regexp: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Error) return { kind: 'object', properties: { __error: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Promise) return { kind: 'promise', resolved: { kind: 'primitive', name: 'unknown' } };`, ` const props = {}; const keys = Object.keys(v).slice(0, 20);`, ` for (const k of keys) { try { props[k] = _inferType(v[k], d-1); } catch(e) { props[k] = { kind: 'primitive', name: 'unknown' }; } }`, ` return { kind: 'object', properties: props };`, ` }`, ` return { kind: 'primitive', name: 'unknown' };`, ` }`, ` function _sanitize(v, d) {`, ` if (d <= 0) return '[truncated]'; if (v === null || v === undefined) return v; const t = typeof v;`, ` if (t === 'string') return v.length > 100 ? v.substring(0, 100) + '...' : v;`, ` if (t === 'number' || t === 'boolean') return v; if (t === 'bigint') return String(v);`, ` if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';`, ` if (Array.isArray(v)) return v.slice(0, 3).map(i => _sanitize(i, d-1));`, ` if (t === 'object') { if (v instanceof Date) return v.toISOString(); if (v instanceof RegExp) return String(v); if (v instanceof Error) return { error: v.message }; if (v instanceof Promise) return '[Promise]';`, ` const r = {}; const keys = Object.keys(v).slice(0, 10); for (const k of keys) { try { r[k] = _sanitize(v[k], d-1); } catch(e) { r[k] = '[unreadable]'; } } return r; }`, ` return String(v);`, ` }`, ` globalThis.__trickle_var_tracer = function(v, n, l, mod, file) {`, ` try {`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const ck = file + ':' + l + ':' + n + ':' + th;`, ` if (_cache.has(ck)) return;`, ` _cache.add(ck);`, ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }));`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
1214
1326
  }
1215
1327
  // Add React component render tracker if needed
@@ -1247,6 +1359,13 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
1247
1359
  code: `\n;try{${calls}}catch(__e){}\n`,
1248
1360
  });
1249
1361
  }
1362
+ // Reassignment insertions: trace after the reassignment statement
1363
+ for (const { lineEnd, varName, lineNo } of reassignInsertions) {
1364
+ allInsertions.push({
1365
+ position: lineEnd,
1366
+ code: `\n;try{__trickle_tv(${varName},${JSON.stringify(varName)},${lineNo})}catch(__e){}\n`,
1367
+ });
1368
+ }
1250
1369
  // For-loop variable insertions: insert trace at start of loop body
1251
1370
  for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1252
1371
  const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.70",
3
+ "version": "0.2.71",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -461,6 +461,101 @@ function extractDestructuredNames(pattern: string): string[] {
461
461
  return names;
462
462
  }
463
463
 
464
+ /**
465
+ * Find variable reassignments (not declarations) and return insertions for tracing.
466
+ * Handles: x = newValue; x += 1; x ||= fallback; etc.
467
+ * Only matches standalone reassignment statements at the start of a line.
468
+ * Skips: property assignments (obj.x = ...), indexed (arr[i] = ...),
469
+ * comparisons (===, !==), arrow functions (=>), declarations (const/let/var).
470
+ */
471
+ function findReassignments(source: string): Array<{ lineEnd: number; varName: string; lineNo: number }> {
472
+ const results: Array<{ lineEnd: number; varName: string; lineNo: number }> = [];
473
+
474
+ // Match: <identifier> <assignOp>= <value> at the start of a line
475
+ // Compound operators: +=, -=, *=, /=, %=, **=, &&=, ||=, ??=, <<=, >>=, >>>=, &=, |=, ^=
476
+ // Plain: = (but not ==, ===, =>, !=)
477
+ const reassignRegex = /^([ \t]*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:\+|-|\*\*?|\/|%|&&|\|\||<<|>>>?|&|\||\^|\?\?)?=[^=>]/gm;
478
+ let match;
479
+
480
+ while ((match = reassignRegex.exec(source)) !== null) {
481
+ const varName = match[2];
482
+
483
+ // Skip trickle internals
484
+ if (varName.startsWith('__trickle') || varName.startsWith('_$')) continue;
485
+ // Skip common non-variable patterns
486
+ if (varName === '_a' || varName === '_b' || varName === '_c') continue;
487
+ // Skip 'this', 'self', 'super' (not reassignable in practice)
488
+ if (varName === 'this' || varName === 'super') continue;
489
+ // Skip keywords that could look like identifiers
490
+ if (['if', 'else', 'while', 'for', 'do', 'switch', 'case', 'default', 'return', 'throw',
491
+ 'break', 'continue', 'try', 'catch', 'finally', 'new', 'delete', 'typeof', 'void',
492
+ 'yield', 'await', 'class', 'extends', 'import', 'export', 'from', 'as', 'of', 'in',
493
+ 'const', 'let', 'var', 'function', 'true', 'false', 'null', 'undefined'].includes(varName)) continue;
494
+
495
+ // Check that this line doesn't start with const/let/var (would be a declaration, already handled)
496
+ const lineStart = source.lastIndexOf('\n', match.index) + 1;
497
+ const linePrefix = source.slice(lineStart, match.index + match[1].length).trim();
498
+ if (/^(export\s+)?(const|let|var)\s/.test(source.slice(lineStart).trimStart())) continue;
499
+
500
+ // Skip if this looks like a property in an object literal (preceded by a key: pattern on same line)
501
+ // or if it's a label (label: ...)
502
+ const beforeOnLine = source.slice(lineStart, match.index).trim();
503
+ if (beforeOnLine.endsWith(':') || beforeOnLine.endsWith(',')) continue;
504
+
505
+ // Calculate line number
506
+ let lineNo = 1;
507
+ for (let i = 0; i < match.index; i++) {
508
+ if (source[i] === '\n') lineNo++;
509
+ }
510
+
511
+ // Find end of statement (same logic as findVarDeclarations)
512
+ const startPos = match.index + match[0].length - 1;
513
+ let pos = startPos;
514
+ let depth = 0;
515
+ let foundEnd = -1;
516
+
517
+ while (pos < source.length) {
518
+ const ch = source[pos];
519
+ if (ch === '(' || ch === '[' || ch === '{') {
520
+ depth++;
521
+ } else if (ch === ')' || ch === ']' || ch === '}') {
522
+ depth--;
523
+ if (depth < 0) break;
524
+ } else if (ch === ';' && depth === 0) {
525
+ foundEnd = pos;
526
+ break;
527
+ } else if (ch === '\n' && depth === 0) {
528
+ const nextNonWs = source.slice(pos + 1).match(/^\s*(\S)/);
529
+ if (nextNonWs && !'.+=-|&?:,'.includes(nextNonWs[1])) {
530
+ foundEnd = pos;
531
+ break;
532
+ }
533
+ } else if (ch === '"' || ch === "'" || ch === '`') {
534
+ const quote = ch;
535
+ pos++;
536
+ while (pos < source.length) {
537
+ if (source[pos] === '\\') { pos++; }
538
+ else if (source[pos] === quote) break;
539
+ pos++;
540
+ }
541
+ } else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '/') {
542
+ while (pos < source.length && source[pos] !== '\n') pos++;
543
+ continue;
544
+ } else if (ch === '/' && pos + 1 < source.length && source[pos + 1] === '*') {
545
+ pos += 2;
546
+ while (pos < source.length - 1 && !(source[pos] === '*' && source[pos + 1] === '/')) pos++;
547
+ pos++;
548
+ }
549
+ pos++;
550
+ }
551
+
552
+ if (foundEnd === -1) continue;
553
+ results.push({ lineEnd: foundEnd + 1, varName, lineNo });
554
+ }
555
+
556
+ return results;
557
+ }
558
+
464
559
  /**
465
560
  * Find for-loop variable declarations and return insertions for tracing.
466
561
  * Handles:
@@ -1108,13 +1203,16 @@ export function transformEsmSource(
1108
1203
  // Find destructured variable declarations for tracing
1109
1204
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
1110
1205
 
1206
+ // Find variable reassignments for tracing
1207
+ const reassignInsertions = traceVars ? findReassignments(source) : [];
1208
+
1111
1209
  // Find for-loop variable declarations for tracing
1112
1210
  const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1113
1211
 
1114
1212
  // Find function parameter names for tracing
1115
1213
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1116
1214
 
1117
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && forLoopInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
1215
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
1118
1216
 
1119
1217
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1120
1218
  // Map transformed line numbers to original source line numbers.
@@ -1133,6 +1231,11 @@ export function transformEsmSource(
1133
1231
  if (origLine !== -1) di.lineNo = origLine;
1134
1232
  }
1135
1233
  }
1234
+ // Fix reassignment line numbers
1235
+ for (const ri of reassignInsertions) {
1236
+ const origLine = findOriginalLine(origLines, ri.varName, ri.lineNo);
1237
+ if (origLine !== -1) ri.lineNo = origLine;
1238
+ }
1136
1239
  // Fix for-loop var line numbers
1137
1240
  for (const fi of forLoopInsertions) {
1138
1241
  if (fi.varNames.length > 0) {
@@ -1177,7 +1280,7 @@ export function transformEsmSource(
1177
1280
  }
1178
1281
 
1179
1282
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1180
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1283
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1181
1284
  const importLines: string[] = [
1182
1285
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
1183
1286
  ];
@@ -1247,7 +1350,7 @@ export function transformEsmSource(
1247
1350
  }
1248
1351
 
1249
1352
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1250
- if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1353
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
1251
1354
  prefixLines.push(
1252
1355
  `if (!globalThis.__trickle_var_tracer) {`,
1253
1356
  ` const _cache = new Set();`,
@@ -1418,6 +1521,14 @@ export function transformEsmSource(
1418
1521
  });
1419
1522
  }
1420
1523
 
1524
+ // Reassignment insertions: trace after the reassignment statement
1525
+ for (const { lineEnd, varName, lineNo } of reassignInsertions) {
1526
+ allInsertions.push({
1527
+ position: lineEnd,
1528
+ code: `\n;try{__trickle_tv(${varName},${JSON.stringify(varName)},${lineNo})}catch(__e){}\n`,
1529
+ });
1530
+ }
1531
+
1421
1532
  // For-loop variable insertions: insert trace at start of loop body
1422
1533
  for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1423
1534
  const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');