trickle-observe 0.2.75 → 0.2.77

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.
@@ -629,6 +629,139 @@ function findCatchVars(source) {
629
629
  }
630
630
  return results;
631
631
  }
632
+ /**
633
+ * Find simple JSX text expressions and return insertions for tracing.
634
+ * Only traces simple expressions in text content positions (after > or between tags):
635
+ * <p>{count}</p> → trace count
636
+ * <span>{user.name}</span> → trace user.name
637
+ * <div>{a + b}</div> → trace a + b (simple binary)
638
+ * <p>{x ? 'a' : 'b'}</p> → trace ternary
639
+ * Skips: attribute expressions, .map() calls, JSX elements, spread, complex calls.
640
+ * Uses comma operator: {(__trickle_tv(expr, name, line), expr)} — safe, returns original value.
641
+ */
642
+ function findJsxExpressions(source) {
643
+ const results = [];
644
+ // Find JSX text expressions: characters `>` followed (with optional whitespace/text) by `{expr}`
645
+ // We look for `{` that follows `>` (possibly with whitespace or text between)
646
+ // and is NOT preceded by `=` (which would be an attribute value)
647
+ const jsxExprRegex = /\{/g;
648
+ let match;
649
+ while ((match = jsxExprRegex.exec(source)) !== null) {
650
+ const bracePos = match.index;
651
+ // Skip if inside a string or comment
652
+ // Simple check: look at the character before `{`
653
+ const charBefore = bracePos > 0 ? source[bracePos - 1] : '';
654
+ // Skip if this is an attribute value: preceded by `=` (with optional whitespace)
655
+ const beforeSlice = source.slice(Math.max(0, bracePos - 5), bracePos).trimEnd();
656
+ if (beforeSlice.endsWith('='))
657
+ continue;
658
+ // Skip if this looks like a template literal expression `${`
659
+ if (charBefore === '$')
660
+ continue;
661
+ // Skip if preceded by `(` or `,` (function arguments, not JSX)
662
+ if (charBefore === '(' || charBefore === ',')
663
+ continue;
664
+ // Must be in a JSX context: look backward for `>` or `}` (closing tag bracket or prev expression)
665
+ // before hitting structural JS characters like `{`, `(`, `;`
666
+ let inJsx = false;
667
+ let scanPos = bracePos - 1;
668
+ while (scanPos >= 0) {
669
+ const ch = source[scanPos];
670
+ if (ch === '>') {
671
+ inJsx = true;
672
+ break;
673
+ }
674
+ if (ch === '}') {
675
+ inJsx = true;
676
+ break;
677
+ } // After a previous JSX expression
678
+ if (ch === '{' || ch === '(' || ch === ';')
679
+ break;
680
+ // `=` only breaks if NOT preceded by other text (could be JSX text like "count = 5")
681
+ if (ch === '=' && scanPos > 0 && /\s/.test(source[scanPos - 1])) {
682
+ // Check if this `=` is a JSX attribute assignment: look further back for tag
683
+ const attrCheck = source.slice(Math.max(0, scanPos - 30), scanPos).trim();
684
+ if (/^[a-zA-Z]/.test(attrCheck))
685
+ break; // Likely an attribute
686
+ }
687
+ if (ch === '\n') {
688
+ // Check context of previous lines
689
+ const lineAbove = source.slice(Math.max(0, scanPos - 100), scanPos);
690
+ if (/<|>|\}/.test(lineAbove)) {
691
+ inJsx = true;
692
+ break;
693
+ }
694
+ break;
695
+ }
696
+ scanPos--;
697
+ }
698
+ if (!inJsx)
699
+ continue;
700
+ // Find the matching closing `}` for this expression
701
+ let depth = 1;
702
+ let pos = bracePos + 1;
703
+ while (pos < source.length && depth > 0) {
704
+ const ch = source[pos];
705
+ if (ch === '{')
706
+ depth++;
707
+ else if (ch === '}')
708
+ depth--;
709
+ else if (ch === '"' || ch === "'" || ch === '`') {
710
+ const q = ch;
711
+ pos++;
712
+ while (pos < source.length && source[pos] !== q) {
713
+ if (source[pos] === '\\')
714
+ pos++;
715
+ pos++;
716
+ }
717
+ }
718
+ pos++;
719
+ }
720
+ if (depth !== 0)
721
+ continue;
722
+ const exprEnd = pos - 1; // position of closing `}`
723
+ const exprText = source.slice(bracePos + 1, exprEnd).trim();
724
+ // Skip empty expressions
725
+ if (!exprText)
726
+ continue;
727
+ // Skip complex expressions that we don't want to trace:
728
+ // - JSX elements: contains `<` (could be a component)
729
+ // - .map/.filter/.reduce calls (return arrays of elements)
730
+ // - Spread: starts with `...`
731
+ // - Arrow functions: contains `=>`
732
+ // - Already traced: starts with `__trickle`
733
+ if (exprText.includes('<') || exprText.includes('=>'))
734
+ continue;
735
+ if (exprText.startsWith('...'))
736
+ continue;
737
+ if (exprText.startsWith('__trickle'))
738
+ continue;
739
+ if (/\.(map|filter|reduce|forEach|flatMap)\s*\(/.test(exprText))
740
+ continue;
741
+ // Skip function calls with parens (complex expressions) — but allow property access
742
+ // Allow: user.name, count, x ? 'a' : 'b', a + b
743
+ // Skip: fn(), obj.method(), Component()
744
+ if (/\w\s*\(/.test(exprText) && !exprText.includes('?'))
745
+ continue;
746
+ // Only trace if it's a "simple" expression:
747
+ // - Identifier: count, name
748
+ // - Property access: user.name, item.price.formatted
749
+ // - Simple ternary: x ? 'a' : 'b'
750
+ // - Simple arithmetic: count * 2, price + tax
751
+ const isSimple = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(exprText) || // identifier/property
752
+ /^[a-zA-Z_$]/.test(exprText); // starts with identifier (covers ternary, arithmetic)
753
+ if (!isSimple)
754
+ continue;
755
+ // Calculate line number
756
+ let lineNo = 1;
757
+ for (let i = 0; i < bracePos; i++) {
758
+ if (source[i] === '\n')
759
+ lineNo++;
760
+ }
761
+ results.push({ exprStart: bracePos + 1, exprEnd, exprText, lineNo });
762
+ }
763
+ return results;
764
+ }
632
765
  function findForLoopVars(source) {
633
766
  const results = [];
634
767
  // Match: for (const/let/var ...
@@ -1260,7 +1393,9 @@ ingestUrl) {
1260
1393
  const catchInsertions = traceVars ? findCatchVars(source) : [];
1261
1394
  // Find function parameter names for tracing
1262
1395
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1263
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1396
+ // Find JSX text expressions for tracing (React files only)
1397
+ const jsxExprInsertions = (traceVars && isReactFile) ? findJsxExpressions(source) : [];
1398
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && jsxExprInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1264
1399
  return source;
1265
1400
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1266
1401
  // Map transformed line numbers to original source line numbers.
@@ -1329,7 +1464,7 @@ ingestUrl) {
1329
1464
  }
1330
1465
  }
1331
1466
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1332
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1467
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1333
1468
  const importLines = [];
1334
1469
  if (isSSR) {
1335
1470
  // SSR/Node.js — import trickle-observe for function wrapping + file system for writing
@@ -1364,8 +1499,8 @@ ingestUrl) {
1364
1499
  }
1365
1500
  }
1366
1501
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1367
- if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0) {
1368
- 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) {} }`);
1502
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0) {
1503
+ prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Map();`, ` 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 sample = _sanitize(v, 2);`, ` const sv = typeof v === 'object' && v !== null ? JSON.stringify(sample).substring(0, 60) : String(v).substring(0, 60);`, ` const ck = file + ':' + l + ':' + n;`, ` const prev = _cache.get(ck);`, ` const now = Date.now();`, ` if (prev && prev.sv === sv && now - prev.ts < 5000) return;`, ` _cache.set(ck, { sv: sv, ts: now });`, ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: sample }));`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
1369
1504
  }
1370
1505
  // Add React component render tracker if needed
1371
1506
  if (bodyInsertions.length > 0 || conciseBodyInsertions.length > 0) {
@@ -1433,6 +1568,20 @@ ingestUrl) {
1433
1568
  code: `\ntry{${calls}}catch(__e){}\n`,
1434
1569
  });
1435
1570
  }
1571
+ // JSX expression insertions: wrap with comma operator to trace without changing value
1572
+ // {expr} → {(__trickle_tv(expr, "expr", lineNo), expr)}
1573
+ for (const { exprStart, exprEnd, exprText, lineNo } of jsxExprInsertions) {
1574
+ // Use a display name: truncate long expressions, use the raw text
1575
+ const displayName = exprText.length > 30 ? exprText.slice(0, 27) + '...' : exprText;
1576
+ allInsertions.push({
1577
+ position: exprStart,
1578
+ code: `(__trickle_tv(`,
1579
+ });
1580
+ allInsertions.push({
1581
+ position: exprEnd,
1582
+ code: `,${JSON.stringify(displayName)},${lineNo}),${exprText})`,
1583
+ });
1584
+ }
1436
1585
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1437
1586
  allInsertions.push({
1438
1587
  position,
@@ -622,6 +622,139 @@ function findCatchVars(source) {
622
622
  }
623
623
  return results;
624
624
  }
625
+ /**
626
+ * Find simple JSX text expressions and return insertions for tracing.
627
+ * Only traces simple expressions in text content positions (after > or between tags):
628
+ * <p>{count}</p> → trace count
629
+ * <span>{user.name}</span> → trace user.name
630
+ * <div>{a + b}</div> → trace a + b (simple binary)
631
+ * <p>{x ? 'a' : 'b'}</p> → trace ternary
632
+ * Skips: attribute expressions, .map() calls, JSX elements, spread, complex calls.
633
+ * Uses comma operator: {(__trickle_tv(expr, name, line), expr)} — safe, returns original value.
634
+ */
635
+ function findJsxExpressions(source) {
636
+ const results = [];
637
+ // Find JSX text expressions: characters `>` followed (with optional whitespace/text) by `{expr}`
638
+ // We look for `{` that follows `>` (possibly with whitespace or text between)
639
+ // and is NOT preceded by `=` (which would be an attribute value)
640
+ const jsxExprRegex = /\{/g;
641
+ let match;
642
+ while ((match = jsxExprRegex.exec(source)) !== null) {
643
+ const bracePos = match.index;
644
+ // Skip if inside a string or comment
645
+ // Simple check: look at the character before `{`
646
+ const charBefore = bracePos > 0 ? source[bracePos - 1] : '';
647
+ // Skip if this is an attribute value: preceded by `=` (with optional whitespace)
648
+ const beforeSlice = source.slice(Math.max(0, bracePos - 5), bracePos).trimEnd();
649
+ if (beforeSlice.endsWith('='))
650
+ continue;
651
+ // Skip if this looks like a template literal expression `${`
652
+ if (charBefore === '$')
653
+ continue;
654
+ // Skip if preceded by `(` or `,` (function arguments, not JSX)
655
+ if (charBefore === '(' || charBefore === ',')
656
+ continue;
657
+ // Must be in a JSX context: look backward for `>` or `}` (closing tag bracket or prev expression)
658
+ // before hitting structural JS characters like `{`, `(`, `;`
659
+ let inJsx = false;
660
+ let scanPos = bracePos - 1;
661
+ while (scanPos >= 0) {
662
+ const ch = source[scanPos];
663
+ if (ch === '>') {
664
+ inJsx = true;
665
+ break;
666
+ }
667
+ if (ch === '}') {
668
+ inJsx = true;
669
+ break;
670
+ } // After a previous JSX expression
671
+ if (ch === '{' || ch === '(' || ch === ';')
672
+ break;
673
+ // `=` only breaks if NOT preceded by other text (could be JSX text like "count = 5")
674
+ if (ch === '=' && scanPos > 0 && /\s/.test(source[scanPos - 1])) {
675
+ // Check if this `=` is a JSX attribute assignment: look further back for tag
676
+ const attrCheck = source.slice(Math.max(0, scanPos - 30), scanPos).trim();
677
+ if (/^[a-zA-Z]/.test(attrCheck))
678
+ break; // Likely an attribute
679
+ }
680
+ if (ch === '\n') {
681
+ // Check context of previous lines
682
+ const lineAbove = source.slice(Math.max(0, scanPos - 100), scanPos);
683
+ if (/<|>|\}/.test(lineAbove)) {
684
+ inJsx = true;
685
+ break;
686
+ }
687
+ break;
688
+ }
689
+ scanPos--;
690
+ }
691
+ if (!inJsx)
692
+ continue;
693
+ // Find the matching closing `}` for this expression
694
+ let depth = 1;
695
+ let pos = bracePos + 1;
696
+ while (pos < source.length && depth > 0) {
697
+ const ch = source[pos];
698
+ if (ch === '{')
699
+ depth++;
700
+ else if (ch === '}')
701
+ depth--;
702
+ else if (ch === '"' || ch === "'" || ch === '`') {
703
+ const q = ch;
704
+ pos++;
705
+ while (pos < source.length && source[pos] !== q) {
706
+ if (source[pos] === '\\')
707
+ pos++;
708
+ pos++;
709
+ }
710
+ }
711
+ pos++;
712
+ }
713
+ if (depth !== 0)
714
+ continue;
715
+ const exprEnd = pos - 1; // position of closing `}`
716
+ const exprText = source.slice(bracePos + 1, exprEnd).trim();
717
+ // Skip empty expressions
718
+ if (!exprText)
719
+ continue;
720
+ // Skip complex expressions that we don't want to trace:
721
+ // - JSX elements: contains `<` (could be a component)
722
+ // - .map/.filter/.reduce calls (return arrays of elements)
723
+ // - Spread: starts with `...`
724
+ // - Arrow functions: contains `=>`
725
+ // - Already traced: starts with `__trickle`
726
+ if (exprText.includes('<') || exprText.includes('=>'))
727
+ continue;
728
+ if (exprText.startsWith('...'))
729
+ continue;
730
+ if (exprText.startsWith('__trickle'))
731
+ continue;
732
+ if (/\.(map|filter|reduce|forEach|flatMap)\s*\(/.test(exprText))
733
+ continue;
734
+ // Skip function calls with parens (complex expressions) — but allow property access
735
+ // Allow: user.name, count, x ? 'a' : 'b', a + b
736
+ // Skip: fn(), obj.method(), Component()
737
+ if (/\w\s*\(/.test(exprText) && !exprText.includes('?'))
738
+ continue;
739
+ // Only trace if it's a "simple" expression:
740
+ // - Identifier: count, name
741
+ // - Property access: user.name, item.price.formatted
742
+ // - Simple ternary: x ? 'a' : 'b'
743
+ // - Simple arithmetic: count * 2, price + tax
744
+ const isSimple = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(exprText) || // identifier/property
745
+ /^[a-zA-Z_$]/.test(exprText); // starts with identifier (covers ternary, arithmetic)
746
+ if (!isSimple)
747
+ continue;
748
+ // Calculate line number
749
+ let lineNo = 1;
750
+ for (let i = 0; i < bracePos; i++) {
751
+ if (source[i] === '\n')
752
+ lineNo++;
753
+ }
754
+ results.push({ exprStart: bracePos + 1, exprEnd, exprText, lineNo });
755
+ }
756
+ return results;
757
+ }
625
758
  function findForLoopVars(source) {
626
759
  const results = [];
627
760
  // Match: for (const/let/var ...
@@ -1253,7 +1386,9 @@ ingestUrl) {
1253
1386
  const catchInsertions = traceVars ? findCatchVars(source) : [];
1254
1387
  // Find function parameter names for tracing
1255
1388
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1256
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1389
+ // Find JSX text expressions for tracing (React files only)
1390
+ const jsxExprInsertions = (traceVars && isReactFile) ? findJsxExpressions(source) : [];
1391
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && jsxExprInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1257
1392
  return source;
1258
1393
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1259
1394
  // Map transformed line numbers to original source line numbers.
@@ -1322,7 +1457,7 @@ ingestUrl) {
1322
1457
  }
1323
1458
  }
1324
1459
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1325
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1460
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1326
1461
  const importLines = [];
1327
1462
  if (isSSR) {
1328
1463
  // SSR/Node.js — import trickle-observe for function wrapping + file system for writing
@@ -1357,8 +1492,8 @@ ingestUrl) {
1357
1492
  }
1358
1493
  }
1359
1494
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1360
- if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0) {
1361
- 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) {} }`);
1495
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0) {
1496
+ prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Map();`, ` 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 sample = _sanitize(v, 2);`, ` const sv = typeof v === 'object' && v !== null ? JSON.stringify(sample).substring(0, 60) : String(v).substring(0, 60);`, ` const ck = file + ':' + l + ':' + n;`, ` const prev = _cache.get(ck);`, ` const now = Date.now();`, ` if (prev && prev.sv === sv && now - prev.ts < 5000) return;`, ` _cache.set(ck, { sv: sv, ts: now });`, ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: sample }));`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
1362
1497
  }
1363
1498
  // Add React component render tracker if needed
1364
1499
  if (bodyInsertions.length > 0 || conciseBodyInsertions.length > 0) {
@@ -1426,6 +1561,20 @@ ingestUrl) {
1426
1561
  code: `\ntry{${calls}}catch(__e){}\n`,
1427
1562
  });
1428
1563
  }
1564
+ // JSX expression insertions: wrap with comma operator to trace without changing value
1565
+ // {expr} → {(__trickle_tv(expr, "expr", lineNo), expr)}
1566
+ for (const { exprStart, exprEnd, exprText, lineNo } of jsxExprInsertions) {
1567
+ // Use a display name: truncate long expressions, use the raw text
1568
+ const displayName = exprText.length > 30 ? exprText.slice(0, 27) + '...' : exprText;
1569
+ allInsertions.push({
1570
+ position: exprStart,
1571
+ code: `(__trickle_tv(`,
1572
+ });
1573
+ allInsertions.push({
1574
+ position: exprEnd,
1575
+ code: `,${JSON.stringify(displayName)},${lineNo}),${exprText})`,
1576
+ });
1577
+ }
1429
1578
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1430
1579
  allInsertions.push({
1431
1580
  position,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.75",
3
+ "version": "0.2.77",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -599,6 +599,127 @@ function findCatchVars(source: string): Array<{ bodyStart: number; varNames: str
599
599
  return results;
600
600
  }
601
601
 
602
+ /**
603
+ * Find simple JSX text expressions and return insertions for tracing.
604
+ * Only traces simple expressions in text content positions (after > or between tags):
605
+ * <p>{count}</p> → trace count
606
+ * <span>{user.name}</span> → trace user.name
607
+ * <div>{a + b}</div> → trace a + b (simple binary)
608
+ * <p>{x ? 'a' : 'b'}</p> → trace ternary
609
+ * Skips: attribute expressions, .map() calls, JSX elements, spread, complex calls.
610
+ * Uses comma operator: {(__trickle_tv(expr, name, line), expr)} — safe, returns original value.
611
+ */
612
+ function findJsxExpressions(source: string): Array<{ exprStart: number; exprEnd: number; exprText: string; lineNo: number }> {
613
+ const results: Array<{ exprStart: number; exprEnd: number; exprText: string; lineNo: number }> = [];
614
+
615
+ // Find JSX text expressions: characters `>` followed (with optional whitespace/text) by `{expr}`
616
+ // We look for `{` that follows `>` (possibly with whitespace or text between)
617
+ // and is NOT preceded by `=` (which would be an attribute value)
618
+ const jsxExprRegex = /\{/g;
619
+ let match;
620
+
621
+ while ((match = jsxExprRegex.exec(source)) !== null) {
622
+ const bracePos = match.index;
623
+
624
+ // Skip if inside a string or comment
625
+ // Simple check: look at the character before `{`
626
+ const charBefore = bracePos > 0 ? source[bracePos - 1] : '';
627
+
628
+ // Skip if this is an attribute value: preceded by `=` (with optional whitespace)
629
+ const beforeSlice = source.slice(Math.max(0, bracePos - 5), bracePos).trimEnd();
630
+ if (beforeSlice.endsWith('=')) continue;
631
+
632
+ // Skip if this looks like a template literal expression `${`
633
+ if (charBefore === '$') continue;
634
+
635
+ // Skip if preceded by `(` or `,` (function arguments, not JSX)
636
+ if (charBefore === '(' || charBefore === ',') continue;
637
+
638
+ // Must be in a JSX context: look backward for `>` or `}` (closing tag bracket or prev expression)
639
+ // before hitting structural JS characters like `{`, `(`, `;`
640
+ let inJsx = false;
641
+ let scanPos = bracePos - 1;
642
+ while (scanPos >= 0) {
643
+ const ch = source[scanPos];
644
+ if (ch === '>') { inJsx = true; break; }
645
+ if (ch === '}') { inJsx = true; break; } // After a previous JSX expression
646
+ if (ch === '{' || ch === '(' || ch === ';') break;
647
+ // `=` only breaks if NOT preceded by other text (could be JSX text like "count = 5")
648
+ if (ch === '=' && scanPos > 0 && /\s/.test(source[scanPos - 1])) {
649
+ // Check if this `=` is a JSX attribute assignment: look further back for tag
650
+ const attrCheck = source.slice(Math.max(0, scanPos - 30), scanPos).trim();
651
+ if (/^[a-zA-Z]/.test(attrCheck)) break; // Likely an attribute
652
+ }
653
+ if (ch === '\n') {
654
+ // Check context of previous lines
655
+ const lineAbove = source.slice(Math.max(0, scanPos - 100), scanPos);
656
+ if (/<|>|\}/.test(lineAbove)) { inJsx = true; break; }
657
+ break;
658
+ }
659
+ scanPos--;
660
+ }
661
+ if (!inJsx) continue;
662
+
663
+ // Find the matching closing `}` for this expression
664
+ let depth = 1;
665
+ let pos = bracePos + 1;
666
+ while (pos < source.length && depth > 0) {
667
+ const ch = source[pos];
668
+ if (ch === '{') depth++;
669
+ else if (ch === '}') depth--;
670
+ else if (ch === '"' || ch === "'" || ch === '`') {
671
+ const q = ch; pos++;
672
+ while (pos < source.length && source[pos] !== q) {
673
+ if (source[pos] === '\\') pos++;
674
+ pos++;
675
+ }
676
+ }
677
+ pos++;
678
+ }
679
+ if (depth !== 0) continue;
680
+
681
+ const exprEnd = pos - 1; // position of closing `}`
682
+ const exprText = source.slice(bracePos + 1, exprEnd).trim();
683
+
684
+ // Skip empty expressions
685
+ if (!exprText) continue;
686
+
687
+ // Skip complex expressions that we don't want to trace:
688
+ // - JSX elements: contains `<` (could be a component)
689
+ // - .map/.filter/.reduce calls (return arrays of elements)
690
+ // - Spread: starts with `...`
691
+ // - Arrow functions: contains `=>`
692
+ // - Already traced: starts with `__trickle`
693
+ if (exprText.includes('<') || exprText.includes('=>')) continue;
694
+ if (exprText.startsWith('...')) continue;
695
+ if (exprText.startsWith('__trickle')) continue;
696
+ if (/\.(map|filter|reduce|forEach|flatMap)\s*\(/.test(exprText)) continue;
697
+ // Skip function calls with parens (complex expressions) — but allow property access
698
+ // Allow: user.name, count, x ? 'a' : 'b', a + b
699
+ // Skip: fn(), obj.method(), Component()
700
+ if (/\w\s*\(/.test(exprText) && !exprText.includes('?')) continue;
701
+
702
+ // Only trace if it's a "simple" expression:
703
+ // - Identifier: count, name
704
+ // - Property access: user.name, item.price.formatted
705
+ // - Simple ternary: x ? 'a' : 'b'
706
+ // - Simple arithmetic: count * 2, price + tax
707
+ const isSimple = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(exprText) || // identifier/property
708
+ /^[a-zA-Z_$]/.test(exprText); // starts with identifier (covers ternary, arithmetic)
709
+ if (!isSimple) continue;
710
+
711
+ // Calculate line number
712
+ let lineNo = 1;
713
+ for (let i = 0; i < bracePos; i++) {
714
+ if (source[i] === '\n') lineNo++;
715
+ }
716
+
717
+ results.push({ exprStart: bracePos + 1, exprEnd, exprText, lineNo });
718
+ }
719
+
720
+ return results;
721
+ }
722
+
602
723
  function findForLoopVars(source: string): Array<{ bodyStart: number; varNames: string[]; lineNo: number }> {
603
724
  const results: Array<{ bodyStart: number; varNames: string[]; lineNo: number }> = [];
604
725
 
@@ -1250,7 +1371,10 @@ export function transformEsmSource(
1250
1371
  // Find function parameter names for tracing
1251
1372
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1252
1373
 
1253
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
1374
+ // Find JSX text expressions for tracing (React files only)
1375
+ const jsxExprInsertions = (traceVars && isReactFile) ? findJsxExpressions(source) : [];
1376
+
1377
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && jsxExprInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
1254
1378
 
1255
1379
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1256
1380
  // Map transformed line numbers to original source line numbers.
@@ -1318,7 +1442,7 @@ export function transformEsmSource(
1318
1442
  }
1319
1443
 
1320
1444
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1321
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1445
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1322
1446
  const importLines: string[] = [];
1323
1447
 
1324
1448
  if (isSSR) {
@@ -1420,10 +1544,10 @@ export function transformEsmSource(
1420
1544
  }
1421
1545
 
1422
1546
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1423
- if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0) {
1547
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0) {
1424
1548
  prefixLines.push(
1425
1549
  `if (!globalThis.__trickle_var_tracer) {`,
1426
- ` const _cache = new Set();`,
1550
+ ` const _cache = new Map();`,
1427
1551
  ` function _inferType(v, d) {`,
1428
1552
  ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`,
1429
1553
  ` if (v === null) return { kind: 'primitive', name: 'null' };`,
@@ -1457,10 +1581,14 @@ export function transformEsmSource(
1457
1581
  ` try {`,
1458
1582
  ` const type = _inferType(v, 3);`,
1459
1583
  ` const th = JSON.stringify(type).substring(0, 32);`,
1460
- ` const ck = file + ':' + l + ':' + n + ':' + th;`,
1461
- ` if (_cache.has(ck)) return;`,
1462
- ` _cache.add(ck);`,
1463
- ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }));`,
1584
+ ` const sample = _sanitize(v, 2);`,
1585
+ ` const sv = typeof v === 'object' && v !== null ? JSON.stringify(sample).substring(0, 60) : String(v).substring(0, 60);`,
1586
+ ` const ck = file + ':' + l + ':' + n;`,
1587
+ ` const prev = _cache.get(ck);`,
1588
+ ` const now = Date.now();`,
1589
+ ` if (prev && prev.sv === sv && now - prev.ts < 5000) return;`,
1590
+ ` _cache.set(ck, { sv: sv, ts: now });`,
1591
+ ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: sample }));`,
1464
1592
  ` } catch(e) {}`,
1465
1593
  ` };`,
1466
1594
  `}`,
@@ -1626,6 +1754,21 @@ export function transformEsmSource(
1626
1754
  });
1627
1755
  }
1628
1756
 
1757
+ // JSX expression insertions: wrap with comma operator to trace without changing value
1758
+ // {expr} → {(__trickle_tv(expr, "expr", lineNo), expr)}
1759
+ for (const { exprStart, exprEnd, exprText, lineNo } of jsxExprInsertions) {
1760
+ // Use a display name: truncate long expressions, use the raw text
1761
+ const displayName = exprText.length > 30 ? exprText.slice(0, 27) + '...' : exprText;
1762
+ allInsertions.push({
1763
+ position: exprStart,
1764
+ code: `(__trickle_tv(`,
1765
+ });
1766
+ allInsertions.push({
1767
+ position: exprEnd,
1768
+ code: `,${JSON.stringify(displayName)},${lineNo}),${exprText})`,
1769
+ });
1770
+ }
1771
+
1629
1772
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1630
1773
  allInsertions.push({
1631
1774
  position,