trickle-observe 0.2.69 → 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,334 @@ 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
+ }
587
+ /**
588
+ * Find for-loop variable declarations and return insertions for tracing.
589
+ * Handles:
590
+ * for (const item of items) { ... } → trace item
591
+ * for (const [key, val] of entries) { ... } → trace key, val
592
+ * for (const { a, b } of items) { ... } → trace a, b
593
+ * for (const key in obj) { ... } → trace key
594
+ * for (let i = 0; i < n; i++) { ... } → trace i
595
+ * Inserts trace calls at the start of the loop body.
596
+ */
597
+ function findForLoopVars(source) {
598
+ const results = [];
599
+ // Match: for (const/let/var ...
600
+ const forRegex = /\bfor\s*\(/g;
601
+ let match;
602
+ while ((match = forRegex.exec(source)) !== null) {
603
+ const afterParen = match.index + match[0].length;
604
+ // Skip whitespace
605
+ let pos = afterParen;
606
+ while (pos < source.length && /\s/.test(source[pos]))
607
+ pos++;
608
+ // Expect const/let/var
609
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
610
+ if (!declMatch)
611
+ continue;
612
+ pos += declMatch[0].length;
613
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
614
+ const varNames = [];
615
+ const patternStart = pos;
616
+ if (source[pos] === '{' || source[pos] === '[') {
617
+ // Destructured: find matching brace/bracket
618
+ const open = source[pos];
619
+ const close = open === '{' ? '}' : ']';
620
+ let depth = 1;
621
+ let end = pos + 1;
622
+ while (end < source.length && depth > 0) {
623
+ if (source[end] === open)
624
+ depth++;
625
+ else if (source[end] === close)
626
+ depth--;
627
+ end++;
628
+ }
629
+ const pattern = source.slice(pos, end);
630
+ const names = extractDestructuredNames(pattern);
631
+ varNames.push(...names);
632
+ pos = end;
633
+ }
634
+ else {
635
+ // Simple identifier
636
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
637
+ if (!idMatch)
638
+ continue;
639
+ varNames.push(idMatch[1]);
640
+ pos += idMatch[0].length;
641
+ }
642
+ if (varNames.length === 0)
643
+ continue;
644
+ // Skip trickle internals
645
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c'))
646
+ continue;
647
+ // Now find the opening `{` of the loop body
648
+ // Skip everything until the `)` that closes the for(...)
649
+ let parenDepth = 1; // We're inside the for(
650
+ while (pos < source.length && parenDepth > 0) {
651
+ const ch = source[pos];
652
+ if (ch === '(')
653
+ parenDepth++;
654
+ else if (ch === ')')
655
+ parenDepth--;
656
+ else if (ch === '"' || ch === "'" || ch === '`') {
657
+ const q = ch;
658
+ pos++;
659
+ while (pos < source.length && source[pos] !== q) {
660
+ if (source[pos] === '\\')
661
+ pos++;
662
+ pos++;
663
+ }
664
+ }
665
+ pos++;
666
+ }
667
+ // Now find the `{` after the closing `)`
668
+ while (pos < source.length && /\s/.test(source[pos]))
669
+ pos++;
670
+ if (pos >= source.length || source[pos] !== '{')
671
+ continue;
672
+ const bodyBrace = pos;
673
+ // Calculate line number
674
+ let lineNo = 1;
675
+ for (let i = 0; i < match.index; i++) {
676
+ if (source[i] === '\n')
677
+ lineNo++;
678
+ }
679
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
680
+ }
681
+ return results;
682
+ }
683
+ /**
684
+ * Find function parameter names and return insertions for tracing at the start
685
+ * of function bodies. Traces the runtime values of all parameters.
686
+ * Handles: function declarations, arrow functions, method definitions.
687
+ * Skips: React components (already tracked via __trickle_rc with props).
688
+ */
689
+ function findFunctionParams(source, isReactFile) {
690
+ const results = [];
691
+ // Match function declarations: function name(params) {
692
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
693
+ let match;
694
+ while ((match = funcDeclRegex.exec(source)) !== null) {
695
+ const name = match[1];
696
+ if (name === 'require' || name === 'exports' || name === 'module')
697
+ continue;
698
+ if (name.startsWith('__trickle'))
699
+ continue;
700
+ // Skip React components (uppercase) in React files — already tracked
701
+ if (isReactFile && /^[A-Z]/.test(name))
702
+ continue;
703
+ const afterParen = match.index + match[0].length;
704
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
705
+ if (bodyBrace === -1)
706
+ continue;
707
+ // Extract parameter names from between ( and )
708
+ const paramStr = source.slice(afterParen, bodyBrace);
709
+ const closeParen = paramStr.indexOf(')');
710
+ if (closeParen === -1)
711
+ continue;
712
+ const rawParams = paramStr.slice(0, closeParen).trim();
713
+ const paramNames = extractParamNames(rawParams);
714
+ if (paramNames.length === 0)
715
+ continue;
716
+ let lineNo = 1;
717
+ for (let i = 0; i < match.index; i++) {
718
+ if (source[i] === '\n')
719
+ lineNo++;
720
+ }
721
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
722
+ }
723
+ // Match arrow functions: const name = (params) => {
724
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
725
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
726
+ const name = match[1];
727
+ if (name.startsWith('__trickle'))
728
+ continue;
729
+ // Skip React components in React files
730
+ if (isReactFile && /^[A-Z]/.test(name))
731
+ continue;
732
+ const rawParams = match[2].trim();
733
+ const paramNames = extractParamNames(rawParams);
734
+ if (paramNames.length === 0)
735
+ continue;
736
+ // Find the { position
737
+ const bracePos = match.index + match[0].length - 1;
738
+ let lineNo = 1;
739
+ for (let i = 0; i < match.index; i++) {
740
+ if (source[i] === '\n')
741
+ lineNo++;
742
+ }
743
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
744
+ }
745
+ return results;
746
+ }
747
+ /**
748
+ * Extract parameter names from a function parameter string.
749
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
750
+ * Skips type annotations.
751
+ */
752
+ function extractParamNames(rawParams) {
753
+ if (!rawParams)
754
+ return [];
755
+ const names = [];
756
+ // Split by commas at depth 0
757
+ const parts = [];
758
+ let depth = 0;
759
+ let current = '';
760
+ for (const ch of rawParams) {
761
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<')
762
+ depth++;
763
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>')
764
+ depth--;
765
+ else if (ch === ',' && depth === 0) {
766
+ parts.push(current.trim());
767
+ current = '';
768
+ continue;
769
+ }
770
+ current += ch;
771
+ }
772
+ if (current.trim())
773
+ parts.push(current.trim());
774
+ for (const part of parts) {
775
+ const trimmed = part.trim();
776
+ if (!trimmed)
777
+ continue;
778
+ // Destructured params — extract individual names
779
+ if (trimmed.startsWith('{')) {
780
+ const closeBrace = trimmed.indexOf('}');
781
+ if (closeBrace !== -1) {
782
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
783
+ names.push(...destructNames);
784
+ }
785
+ continue;
786
+ }
787
+ if (trimmed.startsWith('[')) {
788
+ const closeBracket = trimmed.indexOf(']');
789
+ if (closeBracket !== -1) {
790
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
791
+ names.push(...destructNames);
792
+ }
793
+ continue;
794
+ }
795
+ // Rest parameter: ...args
796
+ if (trimmed.startsWith('...')) {
797
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
798
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
799
+ names.push(restName);
800
+ }
801
+ continue;
802
+ }
803
+ // Simple param: name or name: Type or name = default
804
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
805
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
806
+ names.push(paramName);
807
+ }
808
+ }
809
+ return names;
810
+ }
483
811
  /**
484
812
  * Transform ESM source code to wrap function declarations and trace variables.
485
813
  *
@@ -887,7 +1215,13 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
887
1215
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
888
1216
  // Find destructured variable declarations for tracing
889
1217
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
890
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1218
+ // Find variable reassignments for tracing
1219
+ const reassignInsertions = traceVars ? findReassignments(source) : [];
1220
+ // Find for-loop variable declarations for tracing
1221
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1222
+ // Find function parameter names for tracing
1223
+ const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
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)
891
1225
  return source;
892
1226
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
893
1227
  // Map transformed line numbers to original source line numbers.
@@ -907,9 +1241,56 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
907
1241
  di.lineNo = origLine;
908
1242
  }
909
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
+ }
1250
+ // Fix for-loop var line numbers
1251
+ for (const fi of forLoopInsertions) {
1252
+ if (fi.varNames.length > 0) {
1253
+ // Search for 'for' keyword near the expected line
1254
+ const pattern = /\bfor\s*\(/;
1255
+ for (let delta = 0; delta <= 80; delta++) {
1256
+ const fwd = fi.lineNo - 1 + delta;
1257
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1258
+ fi.lineNo = fwd + 1;
1259
+ break;
1260
+ }
1261
+ if (delta > 0 && delta <= 10) {
1262
+ const bwd = fi.lineNo - 1 - delta;
1263
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1264
+ fi.lineNo = bwd + 1;
1265
+ break;
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+ // Fix function param line numbers
1272
+ for (const fp of funcParamInsertions) {
1273
+ if (fp.paramNames.length > 0) {
1274
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1275
+ for (let delta = 0; delta <= 80; delta++) {
1276
+ const fwd = fp.lineNo - 1 + delta;
1277
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1278
+ fp.lineNo = fwd + 1;
1279
+ break;
1280
+ }
1281
+ if (delta > 0 && delta <= 10) {
1282
+ const bwd = fp.lineNo - 1 - delta;
1283
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1284
+ fp.lineNo = bwd + 1;
1285
+ break;
1286
+ }
1287
+ }
1288
+ }
1289
+ }
1290
+ }
910
1291
  }
911
1292
  // Build prefix — ALL imports first (ESM requires imports before any statements)
912
- const needsTracing = varInsertions.length > 0 || destructInsertions.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;
913
1294
  const importLines = [
914
1295
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
915
1296
  ];
@@ -947,7 +1328,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
947
1328
  }
948
1329
  }
949
1330
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
950
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
1331
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
951
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) {} }`);
952
1333
  }
953
1334
  // Add React component render tracker if needed
@@ -985,6 +1366,29 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
985
1366
  code: `\n;try{${calls}}catch(__e){}\n`,
986
1367
  });
987
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
+ }
1376
+ // For-loop variable insertions: insert trace at start of loop body
1377
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1378
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1379
+ allInsertions.push({
1380
+ position: bodyStart,
1381
+ code: `\ntry{${calls}}catch(__e){}\n`,
1382
+ });
1383
+ }
1384
+ // Function parameter insertions: insert trace at start of function body
1385
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1386
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1387
+ allInsertions.push({
1388
+ position: bodyStart,
1389
+ code: `\ntry{${calls}}catch(__e){}\n`,
1390
+ });
1391
+ }
988
1392
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
989
1393
  allInsertions.push({
990
1394
  position,
@@ -473,6 +473,334 @@ 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
+ }
580
+ /**
581
+ * Find for-loop variable declarations and return insertions for tracing.
582
+ * Handles:
583
+ * for (const item of items) { ... } → trace item
584
+ * for (const [key, val] of entries) { ... } → trace key, val
585
+ * for (const { a, b } of items) { ... } → trace a, b
586
+ * for (const key in obj) { ... } → trace key
587
+ * for (let i = 0; i < n; i++) { ... } → trace i
588
+ * Inserts trace calls at the start of the loop body.
589
+ */
590
+ function findForLoopVars(source) {
591
+ const results = [];
592
+ // Match: for (const/let/var ...
593
+ const forRegex = /\bfor\s*\(/g;
594
+ let match;
595
+ while ((match = forRegex.exec(source)) !== null) {
596
+ const afterParen = match.index + match[0].length;
597
+ // Skip whitespace
598
+ let pos = afterParen;
599
+ while (pos < source.length && /\s/.test(source[pos]))
600
+ pos++;
601
+ // Expect const/let/var
602
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
603
+ if (!declMatch)
604
+ continue;
605
+ pos += declMatch[0].length;
606
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
607
+ const varNames = [];
608
+ const patternStart = pos;
609
+ if (source[pos] === '{' || source[pos] === '[') {
610
+ // Destructured: find matching brace/bracket
611
+ const open = source[pos];
612
+ const close = open === '{' ? '}' : ']';
613
+ let depth = 1;
614
+ let end = pos + 1;
615
+ while (end < source.length && depth > 0) {
616
+ if (source[end] === open)
617
+ depth++;
618
+ else if (source[end] === close)
619
+ depth--;
620
+ end++;
621
+ }
622
+ const pattern = source.slice(pos, end);
623
+ const names = extractDestructuredNames(pattern);
624
+ varNames.push(...names);
625
+ pos = end;
626
+ }
627
+ else {
628
+ // Simple identifier
629
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
630
+ if (!idMatch)
631
+ continue;
632
+ varNames.push(idMatch[1]);
633
+ pos += idMatch[0].length;
634
+ }
635
+ if (varNames.length === 0)
636
+ continue;
637
+ // Skip trickle internals
638
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c'))
639
+ continue;
640
+ // Now find the opening `{` of the loop body
641
+ // Skip everything until the `)` that closes the for(...)
642
+ let parenDepth = 1; // We're inside the for(
643
+ while (pos < source.length && parenDepth > 0) {
644
+ const ch = source[pos];
645
+ if (ch === '(')
646
+ parenDepth++;
647
+ else if (ch === ')')
648
+ parenDepth--;
649
+ else if (ch === '"' || ch === "'" || ch === '`') {
650
+ const q = ch;
651
+ pos++;
652
+ while (pos < source.length && source[pos] !== q) {
653
+ if (source[pos] === '\\')
654
+ pos++;
655
+ pos++;
656
+ }
657
+ }
658
+ pos++;
659
+ }
660
+ // Now find the `{` after the closing `)`
661
+ while (pos < source.length && /\s/.test(source[pos]))
662
+ pos++;
663
+ if (pos >= source.length || source[pos] !== '{')
664
+ continue;
665
+ const bodyBrace = pos;
666
+ // Calculate line number
667
+ let lineNo = 1;
668
+ for (let i = 0; i < match.index; i++) {
669
+ if (source[i] === '\n')
670
+ lineNo++;
671
+ }
672
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
673
+ }
674
+ return results;
675
+ }
676
+ /**
677
+ * Find function parameter names and return insertions for tracing at the start
678
+ * of function bodies. Traces the runtime values of all parameters.
679
+ * Handles: function declarations, arrow functions, method definitions.
680
+ * Skips: React components (already tracked via __trickle_rc with props).
681
+ */
682
+ function findFunctionParams(source, isReactFile) {
683
+ const results = [];
684
+ // Match function declarations: function name(params) {
685
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
686
+ let match;
687
+ while ((match = funcDeclRegex.exec(source)) !== null) {
688
+ const name = match[1];
689
+ if (name === 'require' || name === 'exports' || name === 'module')
690
+ continue;
691
+ if (name.startsWith('__trickle'))
692
+ continue;
693
+ // Skip React components (uppercase) in React files — already tracked
694
+ if (isReactFile && /^[A-Z]/.test(name))
695
+ continue;
696
+ const afterParen = match.index + match[0].length;
697
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
698
+ if (bodyBrace === -1)
699
+ continue;
700
+ // Extract parameter names from between ( and )
701
+ const paramStr = source.slice(afterParen, bodyBrace);
702
+ const closeParen = paramStr.indexOf(')');
703
+ if (closeParen === -1)
704
+ continue;
705
+ const rawParams = paramStr.slice(0, closeParen).trim();
706
+ const paramNames = extractParamNames(rawParams);
707
+ if (paramNames.length === 0)
708
+ continue;
709
+ let lineNo = 1;
710
+ for (let i = 0; i < match.index; i++) {
711
+ if (source[i] === '\n')
712
+ lineNo++;
713
+ }
714
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
715
+ }
716
+ // Match arrow functions: const name = (params) => {
717
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
718
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
719
+ const name = match[1];
720
+ if (name.startsWith('__trickle'))
721
+ continue;
722
+ // Skip React components in React files
723
+ if (isReactFile && /^[A-Z]/.test(name))
724
+ continue;
725
+ const rawParams = match[2].trim();
726
+ const paramNames = extractParamNames(rawParams);
727
+ if (paramNames.length === 0)
728
+ continue;
729
+ // Find the { position
730
+ const bracePos = match.index + match[0].length - 1;
731
+ let lineNo = 1;
732
+ for (let i = 0; i < match.index; i++) {
733
+ if (source[i] === '\n')
734
+ lineNo++;
735
+ }
736
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
737
+ }
738
+ return results;
739
+ }
740
+ /**
741
+ * Extract parameter names from a function parameter string.
742
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
743
+ * Skips type annotations.
744
+ */
745
+ function extractParamNames(rawParams) {
746
+ if (!rawParams)
747
+ return [];
748
+ const names = [];
749
+ // Split by commas at depth 0
750
+ const parts = [];
751
+ let depth = 0;
752
+ let current = '';
753
+ for (const ch of rawParams) {
754
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<')
755
+ depth++;
756
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>')
757
+ depth--;
758
+ else if (ch === ',' && depth === 0) {
759
+ parts.push(current.trim());
760
+ current = '';
761
+ continue;
762
+ }
763
+ current += ch;
764
+ }
765
+ if (current.trim())
766
+ parts.push(current.trim());
767
+ for (const part of parts) {
768
+ const trimmed = part.trim();
769
+ if (!trimmed)
770
+ continue;
771
+ // Destructured params — extract individual names
772
+ if (trimmed.startsWith('{')) {
773
+ const closeBrace = trimmed.indexOf('}');
774
+ if (closeBrace !== -1) {
775
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
776
+ names.push(...destructNames);
777
+ }
778
+ continue;
779
+ }
780
+ if (trimmed.startsWith('[')) {
781
+ const closeBracket = trimmed.indexOf(']');
782
+ if (closeBracket !== -1) {
783
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
784
+ names.push(...destructNames);
785
+ }
786
+ continue;
787
+ }
788
+ // Rest parameter: ...args
789
+ if (trimmed.startsWith('...')) {
790
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
791
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
792
+ names.push(restName);
793
+ }
794
+ continue;
795
+ }
796
+ // Simple param: name or name: Type or name = default
797
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
798
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
799
+ names.push(paramName);
800
+ }
801
+ }
802
+ return names;
803
+ }
476
804
  /**
477
805
  * Transform ESM source code to wrap function declarations and trace variables.
478
806
  *
@@ -880,7 +1208,13 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
880
1208
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
881
1209
  // Find destructured variable declarations for tracing
882
1210
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
883
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1211
+ // Find variable reassignments for tracing
1212
+ const reassignInsertions = traceVars ? findReassignments(source) : [];
1213
+ // Find for-loop variable declarations for tracing
1214
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1215
+ // Find function parameter names for tracing
1216
+ const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
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)
884
1218
  return source;
885
1219
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
886
1220
  // Map transformed line numbers to original source line numbers.
@@ -900,9 +1234,56 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
900
1234
  di.lineNo = origLine;
901
1235
  }
902
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
+ }
1243
+ // Fix for-loop var line numbers
1244
+ for (const fi of forLoopInsertions) {
1245
+ if (fi.varNames.length > 0) {
1246
+ // Search for 'for' keyword near the expected line
1247
+ const pattern = /\bfor\s*\(/;
1248
+ for (let delta = 0; delta <= 80; delta++) {
1249
+ const fwd = fi.lineNo - 1 + delta;
1250
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1251
+ fi.lineNo = fwd + 1;
1252
+ break;
1253
+ }
1254
+ if (delta > 0 && delta <= 10) {
1255
+ const bwd = fi.lineNo - 1 - delta;
1256
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1257
+ fi.lineNo = bwd + 1;
1258
+ break;
1259
+ }
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+ // Fix function param line numbers
1265
+ for (const fp of funcParamInsertions) {
1266
+ if (fp.paramNames.length > 0) {
1267
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1268
+ for (let delta = 0; delta <= 80; delta++) {
1269
+ const fwd = fp.lineNo - 1 + delta;
1270
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1271
+ fp.lineNo = fwd + 1;
1272
+ break;
1273
+ }
1274
+ if (delta > 0 && delta <= 10) {
1275
+ const bwd = fp.lineNo - 1 - delta;
1276
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1277
+ fp.lineNo = bwd + 1;
1278
+ break;
1279
+ }
1280
+ }
1281
+ }
1282
+ }
1283
+ }
903
1284
  }
904
1285
  // Build prefix — ALL imports first (ESM requires imports before any statements)
905
- const needsTracing = varInsertions.length > 0 || destructInsertions.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;
906
1287
  const importLines = [
907
1288
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
908
1289
  ];
@@ -940,7 +1321,7 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
940
1321
  }
941
1322
  }
942
1323
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
943
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
1324
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
944
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) {} }`);
945
1326
  }
946
1327
  // Add React component render tracker if needed
@@ -978,6 +1359,29 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
978
1359
  code: `\n;try{${calls}}catch(__e){}\n`,
979
1360
  });
980
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
+ }
1369
+ // For-loop variable insertions: insert trace at start of loop body
1370
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1371
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1372
+ allInsertions.push({
1373
+ position: bodyStart,
1374
+ code: `\ntry{${calls}}catch(__e){}\n`,
1375
+ });
1376
+ }
1377
+ // Function parameter insertions: insert trace at start of function body
1378
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1379
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1380
+ allInsertions.push({
1381
+ position: bodyStart,
1382
+ code: `\ntry{${calls}}catch(__e){}\n`,
1383
+ });
1384
+ }
981
1385
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
982
1386
  allInsertions.push({
983
1387
  position,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.69",
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,331 @@ 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
+
559
+ /**
560
+ * Find for-loop variable declarations and return insertions for tracing.
561
+ * Handles:
562
+ * for (const item of items) { ... } → trace item
563
+ * for (const [key, val] of entries) { ... } → trace key, val
564
+ * for (const { a, b } of items) { ... } → trace a, b
565
+ * for (const key in obj) { ... } → trace key
566
+ * for (let i = 0; i < n; i++) { ... } → trace i
567
+ * Inserts trace calls at the start of the loop body.
568
+ */
569
+ function findForLoopVars(source: string): Array<{ bodyStart: number; varNames: string[]; lineNo: number }> {
570
+ const results: Array<{ bodyStart: number; varNames: string[]; lineNo: number }> = [];
571
+
572
+ // Match: for (const/let/var ...
573
+ const forRegex = /\bfor\s*\(/g;
574
+ let match;
575
+
576
+ while ((match = forRegex.exec(source)) !== null) {
577
+ const afterParen = match.index + match[0].length;
578
+
579
+ // Skip whitespace
580
+ let pos = afterParen;
581
+ while (pos < source.length && /\s/.test(source[pos])) pos++;
582
+
583
+ // Expect const/let/var
584
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
585
+ if (!declMatch) continue;
586
+ pos += declMatch[0].length;
587
+
588
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
589
+ const varNames: string[] = [];
590
+ const patternStart = pos;
591
+
592
+ if (source[pos] === '{' || source[pos] === '[') {
593
+ // Destructured: find matching brace/bracket
594
+ const open = source[pos];
595
+ const close = open === '{' ? '}' : ']';
596
+ let depth = 1;
597
+ let end = pos + 1;
598
+ while (end < source.length && depth > 0) {
599
+ if (source[end] === open) depth++;
600
+ else if (source[end] === close) depth--;
601
+ end++;
602
+ }
603
+ const pattern = source.slice(pos, end);
604
+ const names = extractDestructuredNames(pattern);
605
+ varNames.push(...names);
606
+ pos = end;
607
+ } else {
608
+ // Simple identifier
609
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
610
+ if (!idMatch) continue;
611
+ varNames.push(idMatch[1]);
612
+ pos += idMatch[0].length;
613
+ }
614
+
615
+ if (varNames.length === 0) continue;
616
+
617
+ // Skip trickle internals
618
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c')) continue;
619
+
620
+ // Now find the opening `{` of the loop body
621
+ // Skip everything until the `)` that closes the for(...)
622
+ let parenDepth = 1; // We're inside the for(
623
+ while (pos < source.length && parenDepth > 0) {
624
+ const ch = source[pos];
625
+ if (ch === '(') parenDepth++;
626
+ else if (ch === ')') parenDepth--;
627
+ else if (ch === '"' || ch === "'" || ch === '`') {
628
+ const q = ch; pos++;
629
+ while (pos < source.length && source[pos] !== q) {
630
+ if (source[pos] === '\\') pos++;
631
+ pos++;
632
+ }
633
+ }
634
+ pos++;
635
+ }
636
+
637
+ // Now find the `{` after the closing `)`
638
+ while (pos < source.length && /\s/.test(source[pos])) pos++;
639
+ if (pos >= source.length || source[pos] !== '{') continue;
640
+
641
+ const bodyBrace = pos;
642
+
643
+ // Calculate line number
644
+ let lineNo = 1;
645
+ for (let i = 0; i < match.index; i++) {
646
+ if (source[i] === '\n') lineNo++;
647
+ }
648
+
649
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
650
+ }
651
+
652
+ return results;
653
+ }
654
+
655
+ /**
656
+ * Find function parameter names and return insertions for tracing at the start
657
+ * of function bodies. Traces the runtime values of all parameters.
658
+ * Handles: function declarations, arrow functions, method definitions.
659
+ * Skips: React components (already tracked via __trickle_rc with props).
660
+ */
661
+ function findFunctionParams(source: string, isReactFile: boolean): Array<{ bodyStart: number; paramNames: string[]; lineNo: number }> {
662
+ const results: Array<{ bodyStart: number; paramNames: string[]; lineNo: number }> = [];
663
+
664
+ // Match function declarations: function name(params) {
665
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
666
+ let match;
667
+
668
+ while ((match = funcDeclRegex.exec(source)) !== null) {
669
+ const name = match[1];
670
+ if (name === 'require' || name === 'exports' || name === 'module') continue;
671
+ if (name.startsWith('__trickle')) continue;
672
+
673
+ // Skip React components (uppercase) in React files — already tracked
674
+ if (isReactFile && /^[A-Z]/.test(name)) continue;
675
+
676
+ const afterParen = match.index + match[0].length;
677
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
678
+ if (bodyBrace === -1) continue;
679
+
680
+ // Extract parameter names from between ( and )
681
+ const paramStr = source.slice(afterParen, bodyBrace);
682
+ const closeParen = paramStr.indexOf(')');
683
+ if (closeParen === -1) continue;
684
+ const rawParams = paramStr.slice(0, closeParen).trim();
685
+ const paramNames = extractParamNames(rawParams);
686
+ if (paramNames.length === 0) continue;
687
+
688
+ let lineNo = 1;
689
+ for (let i = 0; i < match.index; i++) {
690
+ if (source[i] === '\n') lineNo++;
691
+ }
692
+
693
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
694
+ }
695
+
696
+ // Match arrow functions: const name = (params) => {
697
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
698
+
699
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
700
+ const name = match[1];
701
+ if (name.startsWith('__trickle')) continue;
702
+ // Skip React components in React files
703
+ if (isReactFile && /^[A-Z]/.test(name)) continue;
704
+
705
+ const rawParams = match[2].trim();
706
+ const paramNames = extractParamNames(rawParams);
707
+ if (paramNames.length === 0) continue;
708
+
709
+ // Find the { position
710
+ const bracePos = match.index + match[0].length - 1;
711
+
712
+ let lineNo = 1;
713
+ for (let i = 0; i < match.index; i++) {
714
+ if (source[i] === '\n') lineNo++;
715
+ }
716
+
717
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
718
+ }
719
+
720
+ return results;
721
+ }
722
+
723
+ /**
724
+ * Extract parameter names from a function parameter string.
725
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
726
+ * Skips type annotations.
727
+ */
728
+ function extractParamNames(rawParams: string): string[] {
729
+ if (!rawParams) return [];
730
+ const names: string[] = [];
731
+
732
+ // Split by commas at depth 0
733
+ const parts: string[] = [];
734
+ let depth = 0;
735
+ let current = '';
736
+ for (const ch of rawParams) {
737
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<') depth++;
738
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>') depth--;
739
+ else if (ch === ',' && depth === 0) {
740
+ parts.push(current.trim());
741
+ current = '';
742
+ continue;
743
+ }
744
+ current += ch;
745
+ }
746
+ if (current.trim()) parts.push(current.trim());
747
+
748
+ for (const part of parts) {
749
+ const trimmed = part.trim();
750
+ if (!trimmed) continue;
751
+
752
+ // Destructured params — extract individual names
753
+ if (trimmed.startsWith('{')) {
754
+ const closeBrace = trimmed.indexOf('}');
755
+ if (closeBrace !== -1) {
756
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
757
+ names.push(...destructNames);
758
+ }
759
+ continue;
760
+ }
761
+ if (trimmed.startsWith('[')) {
762
+ const closeBracket = trimmed.indexOf(']');
763
+ if (closeBracket !== -1) {
764
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
765
+ names.push(...destructNames);
766
+ }
767
+ continue;
768
+ }
769
+
770
+ // Rest parameter: ...args
771
+ if (trimmed.startsWith('...')) {
772
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
773
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
774
+ names.push(restName);
775
+ }
776
+ continue;
777
+ }
778
+
779
+ // Simple param: name or name: Type or name = default
780
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
781
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
782
+ names.push(paramName);
783
+ }
784
+ }
785
+
786
+ return names;
787
+ }
788
+
464
789
  /**
465
790
  * Transform ESM source code to wrap function declarations and trace variables.
466
791
  *
@@ -878,7 +1203,16 @@ export function transformEsmSource(
878
1203
  // Find destructured variable declarations for tracing
879
1204
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
880
1205
 
881
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
1206
+ // Find variable reassignments for tracing
1207
+ const reassignInsertions = traceVars ? findReassignments(source) : [];
1208
+
1209
+ // Find for-loop variable declarations for tracing
1210
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1211
+
1212
+ // Find function parameter names for tracing
1213
+ const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1214
+
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;
882
1216
 
883
1217
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
884
1218
  // Map transformed line numbers to original source line numbers.
@@ -897,10 +1231,56 @@ export function transformEsmSource(
897
1231
  if (origLine !== -1) di.lineNo = origLine;
898
1232
  }
899
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
+ }
1239
+ // Fix for-loop var line numbers
1240
+ for (const fi of forLoopInsertions) {
1241
+ if (fi.varNames.length > 0) {
1242
+ // Search for 'for' keyword near the expected line
1243
+ const pattern = /\bfor\s*\(/;
1244
+ for (let delta = 0; delta <= 80; delta++) {
1245
+ const fwd = fi.lineNo - 1 + delta;
1246
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1247
+ fi.lineNo = fwd + 1;
1248
+ break;
1249
+ }
1250
+ if (delta > 0 && delta <= 10) {
1251
+ const bwd = fi.lineNo - 1 - delta;
1252
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1253
+ fi.lineNo = bwd + 1;
1254
+ break;
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ }
1260
+ // Fix function param line numbers
1261
+ for (const fp of funcParamInsertions) {
1262
+ if (fp.paramNames.length > 0) {
1263
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1264
+ for (let delta = 0; delta <= 80; delta++) {
1265
+ const fwd = fp.lineNo - 1 + delta;
1266
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1267
+ fp.lineNo = fwd + 1;
1268
+ break;
1269
+ }
1270
+ if (delta > 0 && delta <= 10) {
1271
+ const bwd = fp.lineNo - 1 - delta;
1272
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1273
+ fp.lineNo = bwd + 1;
1274
+ break;
1275
+ }
1276
+ }
1277
+ }
1278
+ }
1279
+ }
900
1280
  }
901
1281
 
902
1282
  // Build prefix — ALL imports first (ESM requires imports before any statements)
903
- const needsTracing = varInsertions.length > 0 || destructInsertions.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;
904
1284
  const importLines: string[] = [
905
1285
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
906
1286
  ];
@@ -970,7 +1350,7 @@ export function transformEsmSource(
970
1350
  }
971
1351
 
972
1352
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
973
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
1353
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
974
1354
  prefixLines.push(
975
1355
  `if (!globalThis.__trickle_var_tracer) {`,
976
1356
  ` const _cache = new Set();`,
@@ -1141,6 +1521,32 @@ export function transformEsmSource(
1141
1521
  });
1142
1522
  }
1143
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
+
1532
+ // For-loop variable insertions: insert trace at start of loop body
1533
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1534
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1535
+ allInsertions.push({
1536
+ position: bodyStart,
1537
+ code: `\ntry{${calls}}catch(__e){}\n`,
1538
+ });
1539
+ }
1540
+
1541
+ // Function parameter insertions: insert trace at start of function body
1542
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1543
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1544
+ allInsertions.push({
1545
+ position: bodyStart,
1546
+ code: `\ntry{${calls}}catch(__e){}\n`,
1547
+ });
1548
+ }
1549
+
1144
1550
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1145
1551
  allInsertions.push({
1146
1552
  position,