trickle-observe 0.2.69 → 0.2.70

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,230 @@ function extractDestructuredNames(pattern) {
480
480
  }
481
481
  return names;
482
482
  }
483
+ /**
484
+ * Find for-loop variable declarations and return insertions for tracing.
485
+ * Handles:
486
+ * for (const item of items) { ... } → trace item
487
+ * for (const [key, val] of entries) { ... } → trace key, val
488
+ * for (const { a, b } of items) { ... } → trace a, b
489
+ * for (const key in obj) { ... } → trace key
490
+ * for (let i = 0; i < n; i++) { ... } → trace i
491
+ * Inserts trace calls at the start of the loop body.
492
+ */
493
+ function findForLoopVars(source) {
494
+ const results = [];
495
+ // Match: for (const/let/var ...
496
+ const forRegex = /\bfor\s*\(/g;
497
+ let match;
498
+ while ((match = forRegex.exec(source)) !== null) {
499
+ const afterParen = match.index + match[0].length;
500
+ // Skip whitespace
501
+ let pos = afterParen;
502
+ while (pos < source.length && /\s/.test(source[pos]))
503
+ pos++;
504
+ // Expect const/let/var
505
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
506
+ if (!declMatch)
507
+ continue;
508
+ pos += declMatch[0].length;
509
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
510
+ const varNames = [];
511
+ const patternStart = pos;
512
+ if (source[pos] === '{' || source[pos] === '[') {
513
+ // Destructured: find matching brace/bracket
514
+ const open = source[pos];
515
+ const close = open === '{' ? '}' : ']';
516
+ let depth = 1;
517
+ let end = pos + 1;
518
+ while (end < source.length && depth > 0) {
519
+ if (source[end] === open)
520
+ depth++;
521
+ else if (source[end] === close)
522
+ depth--;
523
+ end++;
524
+ }
525
+ const pattern = source.slice(pos, end);
526
+ const names = extractDestructuredNames(pattern);
527
+ varNames.push(...names);
528
+ pos = end;
529
+ }
530
+ else {
531
+ // Simple identifier
532
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
533
+ if (!idMatch)
534
+ continue;
535
+ varNames.push(idMatch[1]);
536
+ pos += idMatch[0].length;
537
+ }
538
+ if (varNames.length === 0)
539
+ continue;
540
+ // Skip trickle internals
541
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c'))
542
+ continue;
543
+ // Now find the opening `{` of the loop body
544
+ // Skip everything until the `)` that closes the for(...)
545
+ let parenDepth = 1; // We're inside the for(
546
+ while (pos < source.length && parenDepth > 0) {
547
+ const ch = source[pos];
548
+ if (ch === '(')
549
+ parenDepth++;
550
+ else if (ch === ')')
551
+ parenDepth--;
552
+ else if (ch === '"' || ch === "'" || ch === '`') {
553
+ const q = ch;
554
+ pos++;
555
+ while (pos < source.length && source[pos] !== q) {
556
+ if (source[pos] === '\\')
557
+ pos++;
558
+ pos++;
559
+ }
560
+ }
561
+ pos++;
562
+ }
563
+ // Now find the `{` after the closing `)`
564
+ while (pos < source.length && /\s/.test(source[pos]))
565
+ pos++;
566
+ if (pos >= source.length || source[pos] !== '{')
567
+ continue;
568
+ const bodyBrace = pos;
569
+ // Calculate line number
570
+ let lineNo = 1;
571
+ for (let i = 0; i < match.index; i++) {
572
+ if (source[i] === '\n')
573
+ lineNo++;
574
+ }
575
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
576
+ }
577
+ return results;
578
+ }
579
+ /**
580
+ * Find function parameter names and return insertions for tracing at the start
581
+ * of function bodies. Traces the runtime values of all parameters.
582
+ * Handles: function declarations, arrow functions, method definitions.
583
+ * Skips: React components (already tracked via __trickle_rc with props).
584
+ */
585
+ function findFunctionParams(source, isReactFile) {
586
+ const results = [];
587
+ // Match function declarations: function name(params) {
588
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
589
+ let match;
590
+ while ((match = funcDeclRegex.exec(source)) !== null) {
591
+ const name = match[1];
592
+ if (name === 'require' || name === 'exports' || name === 'module')
593
+ continue;
594
+ if (name.startsWith('__trickle'))
595
+ continue;
596
+ // Skip React components (uppercase) in React files — already tracked
597
+ if (isReactFile && /^[A-Z]/.test(name))
598
+ continue;
599
+ const afterParen = match.index + match[0].length;
600
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
601
+ if (bodyBrace === -1)
602
+ continue;
603
+ // Extract parameter names from between ( and )
604
+ const paramStr = source.slice(afterParen, bodyBrace);
605
+ const closeParen = paramStr.indexOf(')');
606
+ if (closeParen === -1)
607
+ continue;
608
+ const rawParams = paramStr.slice(0, closeParen).trim();
609
+ const paramNames = extractParamNames(rawParams);
610
+ if (paramNames.length === 0)
611
+ continue;
612
+ let lineNo = 1;
613
+ for (let i = 0; i < match.index; i++) {
614
+ if (source[i] === '\n')
615
+ lineNo++;
616
+ }
617
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
618
+ }
619
+ // Match arrow functions: const name = (params) => {
620
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
621
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
622
+ const name = match[1];
623
+ if (name.startsWith('__trickle'))
624
+ continue;
625
+ // Skip React components in React files
626
+ if (isReactFile && /^[A-Z]/.test(name))
627
+ continue;
628
+ const rawParams = match[2].trim();
629
+ const paramNames = extractParamNames(rawParams);
630
+ if (paramNames.length === 0)
631
+ continue;
632
+ // Find the { position
633
+ const bracePos = match.index + match[0].length - 1;
634
+ let lineNo = 1;
635
+ for (let i = 0; i < match.index; i++) {
636
+ if (source[i] === '\n')
637
+ lineNo++;
638
+ }
639
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
640
+ }
641
+ return results;
642
+ }
643
+ /**
644
+ * Extract parameter names from a function parameter string.
645
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
646
+ * Skips type annotations.
647
+ */
648
+ function extractParamNames(rawParams) {
649
+ if (!rawParams)
650
+ return [];
651
+ const names = [];
652
+ // Split by commas at depth 0
653
+ const parts = [];
654
+ let depth = 0;
655
+ let current = '';
656
+ for (const ch of rawParams) {
657
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<')
658
+ depth++;
659
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>')
660
+ depth--;
661
+ else if (ch === ',' && depth === 0) {
662
+ parts.push(current.trim());
663
+ current = '';
664
+ continue;
665
+ }
666
+ current += ch;
667
+ }
668
+ if (current.trim())
669
+ parts.push(current.trim());
670
+ for (const part of parts) {
671
+ const trimmed = part.trim();
672
+ if (!trimmed)
673
+ continue;
674
+ // Destructured params — extract individual names
675
+ if (trimmed.startsWith('{')) {
676
+ const closeBrace = trimmed.indexOf('}');
677
+ if (closeBrace !== -1) {
678
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
679
+ names.push(...destructNames);
680
+ }
681
+ continue;
682
+ }
683
+ if (trimmed.startsWith('[')) {
684
+ const closeBracket = trimmed.indexOf(']');
685
+ if (closeBracket !== -1) {
686
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
687
+ names.push(...destructNames);
688
+ }
689
+ continue;
690
+ }
691
+ // Rest parameter: ...args
692
+ if (trimmed.startsWith('...')) {
693
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
694
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
695
+ names.push(restName);
696
+ }
697
+ continue;
698
+ }
699
+ // Simple param: name or name: Type or name = default
700
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
701
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
702
+ names.push(paramName);
703
+ }
704
+ }
705
+ return names;
706
+ }
483
707
  /**
484
708
  * Transform ESM source code to wrap function declarations and trace variables.
485
709
  *
@@ -887,7 +1111,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
887
1111
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
888
1112
  // Find destructured variable declarations for tracing
889
1113
  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)
1114
+ // Find for-loop variable declarations for tracing
1115
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1116
+ // Find function parameter names for tracing
1117
+ 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)
891
1119
  return source;
892
1120
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
893
1121
  // Map transformed line numbers to original source line numbers.
@@ -907,9 +1135,50 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
907
1135
  di.lineNo = origLine;
908
1136
  }
909
1137
  }
1138
+ // Fix for-loop var line numbers
1139
+ for (const fi of forLoopInsertions) {
1140
+ if (fi.varNames.length > 0) {
1141
+ // Search for 'for' keyword near the expected line
1142
+ const pattern = /\bfor\s*\(/;
1143
+ for (let delta = 0; delta <= 80; delta++) {
1144
+ const fwd = fi.lineNo - 1 + delta;
1145
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1146
+ fi.lineNo = fwd + 1;
1147
+ break;
1148
+ }
1149
+ if (delta > 0 && delta <= 10) {
1150
+ const bwd = fi.lineNo - 1 - delta;
1151
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1152
+ fi.lineNo = bwd + 1;
1153
+ break;
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ // Fix function param line numbers
1160
+ for (const fp of funcParamInsertions) {
1161
+ if (fp.paramNames.length > 0) {
1162
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1163
+ for (let delta = 0; delta <= 80; delta++) {
1164
+ const fwd = fp.lineNo - 1 + delta;
1165
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1166
+ fp.lineNo = fwd + 1;
1167
+ break;
1168
+ }
1169
+ if (delta > 0 && delta <= 10) {
1170
+ const bwd = fp.lineNo - 1 - delta;
1171
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1172
+ fp.lineNo = bwd + 1;
1173
+ break;
1174
+ }
1175
+ }
1176
+ }
1177
+ }
1178
+ }
910
1179
  }
911
1180
  // 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;
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;
913
1182
  const importLines = [
914
1183
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
915
1184
  ];
@@ -947,7 +1216,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
947
1216
  }
948
1217
  }
949
1218
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
950
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
1219
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
951
1220
  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
1221
  }
953
1222
  // Add React component render tracker if needed
@@ -985,6 +1254,22 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
985
1254
  code: `\n;try{${calls}}catch(__e){}\n`,
986
1255
  });
987
1256
  }
1257
+ // For-loop variable insertions: insert trace at start of loop body
1258
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1259
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1260
+ allInsertions.push({
1261
+ position: bodyStart,
1262
+ code: `\ntry{${calls}}catch(__e){}\n`,
1263
+ });
1264
+ }
1265
+ // Function parameter insertions: insert trace at start of function body
1266
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1267
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1268
+ allInsertions.push({
1269
+ position: bodyStart,
1270
+ code: `\ntry{${calls}}catch(__e){}\n`,
1271
+ });
1272
+ }
988
1273
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
989
1274
  allInsertions.push({
990
1275
  position,
@@ -473,6 +473,230 @@ function extractDestructuredNames(pattern) {
473
473
  }
474
474
  return names;
475
475
  }
476
+ /**
477
+ * Find for-loop variable declarations and return insertions for tracing.
478
+ * Handles:
479
+ * for (const item of items) { ... } → trace item
480
+ * for (const [key, val] of entries) { ... } → trace key, val
481
+ * for (const { a, b } of items) { ... } → trace a, b
482
+ * for (const key in obj) { ... } → trace key
483
+ * for (let i = 0; i < n; i++) { ... } → trace i
484
+ * Inserts trace calls at the start of the loop body.
485
+ */
486
+ function findForLoopVars(source) {
487
+ const results = [];
488
+ // Match: for (const/let/var ...
489
+ const forRegex = /\bfor\s*\(/g;
490
+ let match;
491
+ while ((match = forRegex.exec(source)) !== null) {
492
+ const afterParen = match.index + match[0].length;
493
+ // Skip whitespace
494
+ let pos = afterParen;
495
+ while (pos < source.length && /\s/.test(source[pos]))
496
+ pos++;
497
+ // Expect const/let/var
498
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
499
+ if (!declMatch)
500
+ continue;
501
+ pos += declMatch[0].length;
502
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
503
+ const varNames = [];
504
+ const patternStart = pos;
505
+ if (source[pos] === '{' || source[pos] === '[') {
506
+ // Destructured: find matching brace/bracket
507
+ const open = source[pos];
508
+ const close = open === '{' ? '}' : ']';
509
+ let depth = 1;
510
+ let end = pos + 1;
511
+ while (end < source.length && depth > 0) {
512
+ if (source[end] === open)
513
+ depth++;
514
+ else if (source[end] === close)
515
+ depth--;
516
+ end++;
517
+ }
518
+ const pattern = source.slice(pos, end);
519
+ const names = extractDestructuredNames(pattern);
520
+ varNames.push(...names);
521
+ pos = end;
522
+ }
523
+ else {
524
+ // Simple identifier
525
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
526
+ if (!idMatch)
527
+ continue;
528
+ varNames.push(idMatch[1]);
529
+ pos += idMatch[0].length;
530
+ }
531
+ if (varNames.length === 0)
532
+ continue;
533
+ // Skip trickle internals
534
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c'))
535
+ continue;
536
+ // Now find the opening `{` of the loop body
537
+ // Skip everything until the `)` that closes the for(...)
538
+ let parenDepth = 1; // We're inside the for(
539
+ while (pos < source.length && parenDepth > 0) {
540
+ const ch = source[pos];
541
+ if (ch === '(')
542
+ parenDepth++;
543
+ else if (ch === ')')
544
+ parenDepth--;
545
+ else if (ch === '"' || ch === "'" || ch === '`') {
546
+ const q = ch;
547
+ pos++;
548
+ while (pos < source.length && source[pos] !== q) {
549
+ if (source[pos] === '\\')
550
+ pos++;
551
+ pos++;
552
+ }
553
+ }
554
+ pos++;
555
+ }
556
+ // Now find the `{` after the closing `)`
557
+ while (pos < source.length && /\s/.test(source[pos]))
558
+ pos++;
559
+ if (pos >= source.length || source[pos] !== '{')
560
+ continue;
561
+ const bodyBrace = pos;
562
+ // Calculate line number
563
+ let lineNo = 1;
564
+ for (let i = 0; i < match.index; i++) {
565
+ if (source[i] === '\n')
566
+ lineNo++;
567
+ }
568
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
569
+ }
570
+ return results;
571
+ }
572
+ /**
573
+ * Find function parameter names and return insertions for tracing at the start
574
+ * of function bodies. Traces the runtime values of all parameters.
575
+ * Handles: function declarations, arrow functions, method definitions.
576
+ * Skips: React components (already tracked via __trickle_rc with props).
577
+ */
578
+ function findFunctionParams(source, isReactFile) {
579
+ const results = [];
580
+ // Match function declarations: function name(params) {
581
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
582
+ let match;
583
+ while ((match = funcDeclRegex.exec(source)) !== null) {
584
+ const name = match[1];
585
+ if (name === 'require' || name === 'exports' || name === 'module')
586
+ continue;
587
+ if (name.startsWith('__trickle'))
588
+ continue;
589
+ // Skip React components (uppercase) in React files — already tracked
590
+ if (isReactFile && /^[A-Z]/.test(name))
591
+ continue;
592
+ const afterParen = match.index + match[0].length;
593
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
594
+ if (bodyBrace === -1)
595
+ continue;
596
+ // Extract parameter names from between ( and )
597
+ const paramStr = source.slice(afterParen, bodyBrace);
598
+ const closeParen = paramStr.indexOf(')');
599
+ if (closeParen === -1)
600
+ continue;
601
+ const rawParams = paramStr.slice(0, closeParen).trim();
602
+ const paramNames = extractParamNames(rawParams);
603
+ if (paramNames.length === 0)
604
+ continue;
605
+ let lineNo = 1;
606
+ for (let i = 0; i < match.index; i++) {
607
+ if (source[i] === '\n')
608
+ lineNo++;
609
+ }
610
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
611
+ }
612
+ // Match arrow functions: const name = (params) => {
613
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
614
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
615
+ const name = match[1];
616
+ if (name.startsWith('__trickle'))
617
+ continue;
618
+ // Skip React components in React files
619
+ if (isReactFile && /^[A-Z]/.test(name))
620
+ continue;
621
+ const rawParams = match[2].trim();
622
+ const paramNames = extractParamNames(rawParams);
623
+ if (paramNames.length === 0)
624
+ continue;
625
+ // Find the { position
626
+ const bracePos = match.index + match[0].length - 1;
627
+ let lineNo = 1;
628
+ for (let i = 0; i < match.index; i++) {
629
+ if (source[i] === '\n')
630
+ lineNo++;
631
+ }
632
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
633
+ }
634
+ return results;
635
+ }
636
+ /**
637
+ * Extract parameter names from a function parameter string.
638
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
639
+ * Skips type annotations.
640
+ */
641
+ function extractParamNames(rawParams) {
642
+ if (!rawParams)
643
+ return [];
644
+ const names = [];
645
+ // Split by commas at depth 0
646
+ const parts = [];
647
+ let depth = 0;
648
+ let current = '';
649
+ for (const ch of rawParams) {
650
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<')
651
+ depth++;
652
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>')
653
+ depth--;
654
+ else if (ch === ',' && depth === 0) {
655
+ parts.push(current.trim());
656
+ current = '';
657
+ continue;
658
+ }
659
+ current += ch;
660
+ }
661
+ if (current.trim())
662
+ parts.push(current.trim());
663
+ for (const part of parts) {
664
+ const trimmed = part.trim();
665
+ if (!trimmed)
666
+ continue;
667
+ // Destructured params — extract individual names
668
+ if (trimmed.startsWith('{')) {
669
+ const closeBrace = trimmed.indexOf('}');
670
+ if (closeBrace !== -1) {
671
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
672
+ names.push(...destructNames);
673
+ }
674
+ continue;
675
+ }
676
+ if (trimmed.startsWith('[')) {
677
+ const closeBracket = trimmed.indexOf(']');
678
+ if (closeBracket !== -1) {
679
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
680
+ names.push(...destructNames);
681
+ }
682
+ continue;
683
+ }
684
+ // Rest parameter: ...args
685
+ if (trimmed.startsWith('...')) {
686
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
687
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
688
+ names.push(restName);
689
+ }
690
+ continue;
691
+ }
692
+ // Simple param: name or name: Type or name = default
693
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
694
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
695
+ names.push(paramName);
696
+ }
697
+ }
698
+ return names;
699
+ }
476
700
  /**
477
701
  * Transform ESM source code to wrap function declarations and trace variables.
478
702
  *
@@ -880,7 +1104,11 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
880
1104
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
881
1105
  // Find destructured variable declarations for tracing
882
1106
  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)
1107
+ // Find for-loop variable declarations for tracing
1108
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1109
+ // Find function parameter names for tracing
1110
+ 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)
884
1112
  return source;
885
1113
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
886
1114
  // Map transformed line numbers to original source line numbers.
@@ -900,9 +1128,50 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
900
1128
  di.lineNo = origLine;
901
1129
  }
902
1130
  }
1131
+ // Fix for-loop var line numbers
1132
+ for (const fi of forLoopInsertions) {
1133
+ if (fi.varNames.length > 0) {
1134
+ // Search for 'for' keyword near the expected line
1135
+ const pattern = /\bfor\s*\(/;
1136
+ for (let delta = 0; delta <= 80; delta++) {
1137
+ const fwd = fi.lineNo - 1 + delta;
1138
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1139
+ fi.lineNo = fwd + 1;
1140
+ break;
1141
+ }
1142
+ if (delta > 0 && delta <= 10) {
1143
+ const bwd = fi.lineNo - 1 - delta;
1144
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1145
+ fi.lineNo = bwd + 1;
1146
+ break;
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ // Fix function param line numbers
1153
+ for (const fp of funcParamInsertions) {
1154
+ if (fp.paramNames.length > 0) {
1155
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1156
+ for (let delta = 0; delta <= 80; delta++) {
1157
+ const fwd = fp.lineNo - 1 + delta;
1158
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1159
+ fp.lineNo = fwd + 1;
1160
+ break;
1161
+ }
1162
+ if (delta > 0 && delta <= 10) {
1163
+ const bwd = fp.lineNo - 1 - delta;
1164
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1165
+ fp.lineNo = bwd + 1;
1166
+ break;
1167
+ }
1168
+ }
1169
+ }
1170
+ }
1171
+ }
903
1172
  }
904
1173
  // 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;
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;
906
1175
  const importLines = [
907
1176
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
908
1177
  ];
@@ -940,7 +1209,7 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
940
1209
  }
941
1210
  }
942
1211
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
943
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
1212
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
944
1213
  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
1214
  }
946
1215
  // Add React component render tracker if needed
@@ -978,6 +1247,22 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
978
1247
  code: `\n;try{${calls}}catch(__e){}\n`,
979
1248
  });
980
1249
  }
1250
+ // For-loop variable insertions: insert trace at start of loop body
1251
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1252
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1253
+ allInsertions.push({
1254
+ position: bodyStart,
1255
+ code: `\ntry{${calls}}catch(__e){}\n`,
1256
+ });
1257
+ }
1258
+ // Function parameter insertions: insert trace at start of function body
1259
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1260
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1261
+ allInsertions.push({
1262
+ position: bodyStart,
1263
+ code: `\ntry{${calls}}catch(__e){}\n`,
1264
+ });
1265
+ }
981
1266
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
982
1267
  allInsertions.push({
983
1268
  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.70",
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,236 @@ function extractDestructuredNames(pattern: string): string[] {
461
461
  return names;
462
462
  }
463
463
 
464
+ /**
465
+ * Find for-loop variable declarations and return insertions for tracing.
466
+ * Handles:
467
+ * for (const item of items) { ... } → trace item
468
+ * for (const [key, val] of entries) { ... } → trace key, val
469
+ * for (const { a, b } of items) { ... } → trace a, b
470
+ * for (const key in obj) { ... } → trace key
471
+ * for (let i = 0; i < n; i++) { ... } → trace i
472
+ * Inserts trace calls at the start of the loop body.
473
+ */
474
+ function findForLoopVars(source: string): Array<{ bodyStart: number; varNames: string[]; lineNo: number }> {
475
+ const results: Array<{ bodyStart: number; varNames: string[]; lineNo: number }> = [];
476
+
477
+ // Match: for (const/let/var ...
478
+ const forRegex = /\bfor\s*\(/g;
479
+ let match;
480
+
481
+ while ((match = forRegex.exec(source)) !== null) {
482
+ const afterParen = match.index + match[0].length;
483
+
484
+ // Skip whitespace
485
+ let pos = afterParen;
486
+ while (pos < source.length && /\s/.test(source[pos])) pos++;
487
+
488
+ // Expect const/let/var
489
+ const declMatch = source.slice(pos).match(/^(const|let|var)\s+/);
490
+ if (!declMatch) continue;
491
+ pos += declMatch[0].length;
492
+
493
+ // Now we have the variable pattern — could be identifier, {destructure}, or [destructure]
494
+ const varNames: string[] = [];
495
+ const patternStart = pos;
496
+
497
+ if (source[pos] === '{' || source[pos] === '[') {
498
+ // Destructured: find matching brace/bracket
499
+ const open = source[pos];
500
+ const close = open === '{' ? '}' : ']';
501
+ let depth = 1;
502
+ let end = pos + 1;
503
+ while (end < source.length && depth > 0) {
504
+ if (source[end] === open) depth++;
505
+ else if (source[end] === close) depth--;
506
+ end++;
507
+ }
508
+ const pattern = source.slice(pos, end);
509
+ const names = extractDestructuredNames(pattern);
510
+ varNames.push(...names);
511
+ pos = end;
512
+ } else {
513
+ // Simple identifier
514
+ const idMatch = source.slice(pos).match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
515
+ if (!idMatch) continue;
516
+ varNames.push(idMatch[1]);
517
+ pos += idMatch[0].length;
518
+ }
519
+
520
+ if (varNames.length === 0) continue;
521
+
522
+ // Skip trickle internals
523
+ if (varNames.every(n => n.startsWith('__trickle') || n === '_a' || n === '_b' || n === '_c')) continue;
524
+
525
+ // Now find the opening `{` of the loop body
526
+ // Skip everything until the `)` that closes the for(...)
527
+ let parenDepth = 1; // We're inside the for(
528
+ while (pos < source.length && parenDepth > 0) {
529
+ const ch = source[pos];
530
+ if (ch === '(') parenDepth++;
531
+ else if (ch === ')') parenDepth--;
532
+ else if (ch === '"' || ch === "'" || ch === '`') {
533
+ const q = ch; pos++;
534
+ while (pos < source.length && source[pos] !== q) {
535
+ if (source[pos] === '\\') pos++;
536
+ pos++;
537
+ }
538
+ }
539
+ pos++;
540
+ }
541
+
542
+ // Now find the `{` after the closing `)`
543
+ while (pos < source.length && /\s/.test(source[pos])) pos++;
544
+ if (pos >= source.length || source[pos] !== '{') continue;
545
+
546
+ const bodyBrace = pos;
547
+
548
+ // Calculate line number
549
+ let lineNo = 1;
550
+ for (let i = 0; i < match.index; i++) {
551
+ if (source[i] === '\n') lineNo++;
552
+ }
553
+
554
+ results.push({ bodyStart: bodyBrace + 1, varNames: varNames.filter(n => !n.startsWith('__trickle')), lineNo });
555
+ }
556
+
557
+ return results;
558
+ }
559
+
560
+ /**
561
+ * Find function parameter names and return insertions for tracing at the start
562
+ * of function bodies. Traces the runtime values of all parameters.
563
+ * Handles: function declarations, arrow functions, method definitions.
564
+ * Skips: React components (already tracked via __trickle_rc with props).
565
+ */
566
+ function findFunctionParams(source: string, isReactFile: boolean): Array<{ bodyStart: number; paramNames: string[]; lineNo: number }> {
567
+ const results: Array<{ bodyStart: number; paramNames: string[]; lineNo: number }> = [];
568
+
569
+ // Match function declarations: function name(params) {
570
+ const funcDeclRegex = /\b(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
571
+ let match;
572
+
573
+ while ((match = funcDeclRegex.exec(source)) !== null) {
574
+ const name = match[1];
575
+ if (name === 'require' || name === 'exports' || name === 'module') continue;
576
+ if (name.startsWith('__trickle')) continue;
577
+
578
+ // Skip React components (uppercase) in React files — already tracked
579
+ if (isReactFile && /^[A-Z]/.test(name)) continue;
580
+
581
+ const afterParen = match.index + match[0].length;
582
+ const bodyBrace = findFunctionBodyBrace(source, afterParen);
583
+ if (bodyBrace === -1) continue;
584
+
585
+ // Extract parameter names from between ( and )
586
+ const paramStr = source.slice(afterParen, bodyBrace);
587
+ const closeParen = paramStr.indexOf(')');
588
+ if (closeParen === -1) continue;
589
+ const rawParams = paramStr.slice(0, closeParen).trim();
590
+ const paramNames = extractParamNames(rawParams);
591
+ if (paramNames.length === 0) continue;
592
+
593
+ let lineNo = 1;
594
+ for (let i = 0; i < match.index; i++) {
595
+ if (source[i] === '\n') lineNo++;
596
+ }
597
+
598
+ results.push({ bodyStart: bodyBrace + 1, paramNames, lineNo });
599
+ }
600
+
601
+ // Match arrow functions: const name = (params) => {
602
+ const arrowFuncRegex = /(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+?)?\s*=>\s*\{/g;
603
+
604
+ while ((match = arrowFuncRegex.exec(source)) !== null) {
605
+ const name = match[1];
606
+ if (name.startsWith('__trickle')) continue;
607
+ // Skip React components in React files
608
+ if (isReactFile && /^[A-Z]/.test(name)) continue;
609
+
610
+ const rawParams = match[2].trim();
611
+ const paramNames = extractParamNames(rawParams);
612
+ if (paramNames.length === 0) continue;
613
+
614
+ // Find the { position
615
+ const bracePos = match.index + match[0].length - 1;
616
+
617
+ let lineNo = 1;
618
+ for (let i = 0; i < match.index; i++) {
619
+ if (source[i] === '\n') lineNo++;
620
+ }
621
+
622
+ results.push({ bodyStart: bracePos + 1, paramNames, lineNo });
623
+ }
624
+
625
+ return results;
626
+ }
627
+
628
+ /**
629
+ * Extract parameter names from a function parameter string.
630
+ * Handles: simple names, destructured { a, b }, defaults (a = 1), rest (...args).
631
+ * Skips type annotations.
632
+ */
633
+ function extractParamNames(rawParams: string): string[] {
634
+ if (!rawParams) return [];
635
+ const names: string[] = [];
636
+
637
+ // Split by commas at depth 0
638
+ const parts: string[] = [];
639
+ let depth = 0;
640
+ let current = '';
641
+ for (const ch of rawParams) {
642
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<') depth++;
643
+ else if (ch === '}' || ch === ']' || ch === ')' || ch === '>') depth--;
644
+ else if (ch === ',' && depth === 0) {
645
+ parts.push(current.trim());
646
+ current = '';
647
+ continue;
648
+ }
649
+ current += ch;
650
+ }
651
+ if (current.trim()) parts.push(current.trim());
652
+
653
+ for (const part of parts) {
654
+ const trimmed = part.trim();
655
+ if (!trimmed) continue;
656
+
657
+ // Destructured params — extract individual names
658
+ if (trimmed.startsWith('{')) {
659
+ const closeBrace = trimmed.indexOf('}');
660
+ if (closeBrace !== -1) {
661
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBrace + 1));
662
+ names.push(...destructNames);
663
+ }
664
+ continue;
665
+ }
666
+ if (trimmed.startsWith('[')) {
667
+ const closeBracket = trimmed.indexOf(']');
668
+ if (closeBracket !== -1) {
669
+ const destructNames = extractDestructuredNames(trimmed.slice(0, closeBracket + 1));
670
+ names.push(...destructNames);
671
+ }
672
+ continue;
673
+ }
674
+
675
+ // Rest parameter: ...args
676
+ if (trimmed.startsWith('...')) {
677
+ const restName = trimmed.slice(3).split(/[\s:=]/)[0].trim();
678
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(restName)) {
679
+ names.push(restName);
680
+ }
681
+ continue;
682
+ }
683
+
684
+ // Simple param: name or name: Type or name = default
685
+ const paramName = trimmed.split(/[\s:=]/)[0].trim();
686
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
687
+ names.push(paramName);
688
+ }
689
+ }
690
+
691
+ return names;
692
+ }
693
+
464
694
  /**
465
695
  * Transform ESM source code to wrap function declarations and trace variables.
466
696
  *
@@ -878,7 +1108,13 @@ export function transformEsmSource(
878
1108
  // Find destructured variable declarations for tracing
879
1109
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
880
1110
 
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;
1111
+ // Find for-loop variable declarations for tracing
1112
+ const forLoopInsertions = traceVars ? findForLoopVars(source) : [];
1113
+
1114
+ // Find function parameter names for tracing
1115
+ const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1116
+
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;
882
1118
 
883
1119
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
884
1120
  // Map transformed line numbers to original source line numbers.
@@ -897,10 +1133,51 @@ export function transformEsmSource(
897
1133
  if (origLine !== -1) di.lineNo = origLine;
898
1134
  }
899
1135
  }
1136
+ // Fix for-loop var line numbers
1137
+ for (const fi of forLoopInsertions) {
1138
+ if (fi.varNames.length > 0) {
1139
+ // Search for 'for' keyword near the expected line
1140
+ const pattern = /\bfor\s*\(/;
1141
+ for (let delta = 0; delta <= 80; delta++) {
1142
+ const fwd = fi.lineNo - 1 + delta;
1143
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1144
+ fi.lineNo = fwd + 1;
1145
+ break;
1146
+ }
1147
+ if (delta > 0 && delta <= 10) {
1148
+ const bwd = fi.lineNo - 1 - delta;
1149
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1150
+ fi.lineNo = bwd + 1;
1151
+ break;
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ // Fix function param line numbers
1158
+ for (const fp of funcParamInsertions) {
1159
+ if (fp.paramNames.length > 0) {
1160
+ const pattern = /\bfunction\s+\w+\s*\(|=>\s*\{/;
1161
+ for (let delta = 0; delta <= 80; delta++) {
1162
+ const fwd = fp.lineNo - 1 + delta;
1163
+ if (fwd >= 0 && fwd < origLines.length && pattern.test(origLines[fwd])) {
1164
+ fp.lineNo = fwd + 1;
1165
+ break;
1166
+ }
1167
+ if (delta > 0 && delta <= 10) {
1168
+ const bwd = fp.lineNo - 1 - delta;
1169
+ if (bwd >= 0 && bwd < origLines.length && pattern.test(origLines[bwd])) {
1170
+ fp.lineNo = bwd + 1;
1171
+ break;
1172
+ }
1173
+ }
1174
+ }
1175
+ }
1176
+ }
900
1177
  }
901
1178
 
902
1179
  // 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;
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;
904
1181
  const importLines: string[] = [
905
1182
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
906
1183
  ];
@@ -970,7 +1247,7 @@ export function transformEsmSource(
970
1247
  }
971
1248
 
972
1249
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
973
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
1250
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || forLoopInsertions.length > 0 || funcParamInsertions.length > 0) {
974
1251
  prefixLines.push(
975
1252
  `if (!globalThis.__trickle_var_tracer) {`,
976
1253
  ` const _cache = new Set();`,
@@ -1141,6 +1418,24 @@ export function transformEsmSource(
1141
1418
  });
1142
1419
  }
1143
1420
 
1421
+ // For-loop variable insertions: insert trace at start of loop body
1422
+ for (const { bodyStart, varNames, lineNo } of forLoopInsertions) {
1423
+ const calls = varNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1424
+ allInsertions.push({
1425
+ position: bodyStart,
1426
+ code: `\ntry{${calls}}catch(__e){}\n`,
1427
+ });
1428
+ }
1429
+
1430
+ // Function parameter insertions: insert trace at start of function body
1431
+ for (const { bodyStart, paramNames, lineNo } of funcParamInsertions) {
1432
+ const calls = paramNames.map(n => `__trickle_tv(${n},${JSON.stringify(n)},${lineNo})`).join(';');
1433
+ allInsertions.push({
1434
+ position: bodyStart,
1435
+ code: `\ntry{${calls}}catch(__e){}\n`,
1436
+ });
1437
+ }
1438
+
1144
1439
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1145
1440
  allInsertions.push({
1146
1441
  position,