ucn 3.8.18 → 3.8.19

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.
package/core/tracing.js CHANGED
@@ -10,6 +10,8 @@
10
10
  const path = require('path');
11
11
  const { escapeRegExp } = require('./shared');
12
12
  const { isTestFile } = require('./discovery');
13
+ const { getCachedCalls } = require('./callers');
14
+ const { detectLanguage, getLanguageModule } = require('../languages');
13
15
 
14
16
  /**
15
17
  * Trace execution flow — build a tree of callees (down), callers (up), or both.
@@ -541,19 +543,11 @@ function affectedTests(index, name, options = {}) {
541
543
  };
542
544
  collectNames(blastResult.tree);
543
545
 
544
- // Step 3: Build regex patterns for all names
545
- const namePatterns = new Map();
546
- for (const n of affectedNames) {
547
- const escaped = escapeRegExp(n);
548
- namePatterns.set(n, {
549
- regex: new RegExp('\\b' + escaped + '\\b'),
550
- callPattern: new RegExp(escaped + '\\s*\\('),
551
- });
552
- }
553
-
554
- // Step 4: Scan test files once for all affected names
546
+ // Step 3: Scan test files for all affected names using AST
547
+ // Only count call and test-case matches as real coverage — not imports or bare references.
555
548
  const exclude = options.exclude;
556
549
  const excludeArr = exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [];
550
+ const className = options.className || null;
557
551
  const results = [];
558
552
  for (const [filePath, fileEntry] of index.files) {
559
553
  let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
@@ -565,40 +559,100 @@ function affectedTests(index, name, options = {}) {
565
559
  if (excludeArr.length > 0 && !index.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
566
560
  try {
567
561
  const content = index._readFile(filePath);
568
- const lines = content.split('\n');
569
562
  const fileMatches = new Map();
570
563
 
571
- lines.forEach((line, idx) => {
572
- for (const [funcName, patterns] of namePatterns) {
573
- if (patterns.regex.test(line)) {
574
- let matchType = 'reference';
575
- if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
576
- matchType = 'test-case';
577
- } else if (/\b(import|require|from)\b/.test(line)) {
578
- matchType = 'import';
579
- } else if (patterns.callPattern.test(line)) {
580
- matchType = 'call';
564
+ for (const funcName of affectedNames) {
565
+ // Fast pre-check
566
+ if (!content.includes(funcName)) continue;
567
+
568
+ // AST-based usage detection
569
+ const astUsages = index._getCachedUsages(filePath, funcName);
570
+ if (!astUsages || astUsages.length === 0) continue;
571
+
572
+ // Build instance type map for className scoping (if applicable)
573
+ let instanceTypeMap = null;
574
+ if (className) {
575
+ instanceTypeMap = _buildInstanceTypeMapForTracing(index, filePath, content, className);
576
+ }
577
+
578
+ const seenLines = new Set();
579
+ for (const usage of astUsages) {
580
+ if (usage.usageType === 'definition') continue;
581
+ const lineKey = `${usage.line}:${usage.usageType}`;
582
+ if (seenLines.has(lineKey)) continue;
583
+ seenLines.add(lineKey);
584
+
585
+ let matchType;
586
+ if (usage.usageType === 'import') {
587
+ matchType = 'import';
588
+ } else if (usage.usageType === 'call') {
589
+ matchType = 'call';
590
+ } else {
591
+ matchType = 'reference';
592
+ }
593
+
594
+ // className scoping for calls: check receiver
595
+ if (className && matchType === 'call') {
596
+ if (!_receiverMatchesClassTracing(usage, className, instanceTypeMap,
597
+ index.getLineContent(filePath, usage.line), funcName)) continue;
598
+ }
599
+
600
+ // className scoping for references: require class-associated receiver
601
+ if (className && matchType === 'reference') {
602
+ if (!usage.receiver) continue;
603
+ if (usage.receiver !== className &&
604
+ !(instanceTypeMap && instanceTypeMap.get(usage.receiver) === className)) {
605
+ continue;
581
606
  }
582
- if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
583
- fileMatches.get(funcName).push({
584
- line: idx + 1, content: line.trim(),
585
- matchType, functionName: funcName
586
- });
587
607
  }
608
+
609
+ const lineContent = index.getLineContent(filePath, usage.line);
610
+ if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
611
+ fileMatches.get(funcName).push({
612
+ line: usage.line, content: lineContent.trim(),
613
+ matchType, functionName: funcName
614
+ });
588
615
  }
589
- });
616
+
617
+ // Language-aware test-case detection
618
+ _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches);
619
+ }
590
620
 
591
621
  if (fileMatches.size > 0) {
592
622
  const coveredFunctions = [...fileMatches.keys()];
593
623
  const allMatches = [];
594
624
  for (const matches of fileMatches.values()) allMatches.push(...matches);
595
- allMatches.sort((a, b) => a.line - b.line);
596
- results.push({
597
- file: fileEntry.relativePath,
598
- coveredFunctions,
599
- matchCount: allMatches.length,
600
- matches: allMatches
625
+ // Deduplicate same line+function (test-case line might overlap with call line)
626
+ const dedupMap = new Map();
627
+ for (const m of allMatches) {
628
+ const key = `${m.line}:${m.functionName}`;
629
+ const existing = dedupMap.get(key);
630
+ if (!existing || _matchPriority(m.matchType) > _matchPriority(existing.matchType)) {
631
+ dedupMap.set(key, m);
632
+ }
633
+ }
634
+ const deduped = [...dedupMap.values()].sort((a, b) => a.line - b.line);
635
+
636
+ // Only count functions with call or test-case matches as covered.
637
+ // Import-only or reference-only functions are not real coverage.
638
+ const realCoveredFunctions = coveredFunctions.filter(fn => {
639
+ const fnMatches = deduped.filter(m => m.functionName === fn);
640
+ return fnMatches.some(m => m.matchType === 'call' || m.matchType === 'test-case');
601
641
  });
642
+
643
+ // Only include file if it has real coverage
644
+ const realMatches = deduped.filter(m =>
645
+ m.matchType === 'call' || m.matchType === 'test-case' ||
646
+ realCoveredFunctions.includes(m.functionName)
647
+ );
648
+ if (realCoveredFunctions.length > 0) {
649
+ results.push({
650
+ file: fileEntry.relativePath,
651
+ coveredFunctions: realCoveredFunctions,
652
+ matchCount: realMatches.length,
653
+ matches: realMatches
654
+ });
655
+ }
602
656
  }
603
657
  } catch (e) { /* skip unreadable */ }
604
658
  }
@@ -606,18 +660,35 @@ function affectedTests(index, name, options = {}) {
606
660
  // Sort by coverage breadth then alphabetically
607
661
  results.sort((a, b) => b.coveredFunctions.length - a.coveredFunctions.length || a.file.localeCompare(b.file));
608
662
 
609
- // Compute coverage stats
663
+ // Compute coverage stats.
664
+ // Filter out test function names from affectedNames — they are callers,
665
+ // not production symbols that need test coverage.
666
+ const productionNames = new Set();
667
+ for (const n of affectedNames) {
668
+ // Check if this name is only found in test files
669
+ let foundInSource = false;
670
+ for (const [fp, fe] of index.files) {
671
+ if (isTestFile(fe.relativePath, fe.language)) continue;
672
+ if (fe.symbols?.some(s => s.name === n)) { foundInSource = true; break; }
673
+ }
674
+ if (foundInSource) productionNames.add(n);
675
+ }
676
+ // Fall back to full set if filtering removed everything (e.g., test-only project)
677
+ const namesForCoverage = productionNames.size > 0 ? productionNames : affectedNames;
678
+
610
679
  const coveredSet = new Set();
611
- for (const r of results) for (const f of r.coveredFunctions) coveredSet.add(f);
612
- const uncovered = [...affectedNames].filter(n => !coveredSet.has(n));
680
+ for (const r of results) for (const f of r.coveredFunctions) {
681
+ if (namesForCoverage.has(f)) coveredSet.add(f);
682
+ }
683
+ const uncovered = [...namesForCoverage].filter(n => !coveredSet.has(n));
613
684
 
614
685
  return {
615
686
  root: blastResult.root, file: blastResult.file, line: blastResult.line,
616
687
  depth: blastResult.maxDepth,
617
- affectedFunctions: [...affectedNames],
688
+ affectedFunctions: [...namesForCoverage],
618
689
  testFiles: results,
619
690
  summary: {
620
- totalAffected: affectedNames.size,
691
+ totalAffected: namesForCoverage.size,
621
692
  totalTestFiles: results.length,
622
693
  coveredFunctions: coveredSet.size,
623
694
  uncoveredCount: uncovered.length,
@@ -628,4 +699,145 @@ function affectedTests(index, name, options = {}) {
628
699
  } finally { index._endOp(); }
629
700
  }
630
701
 
702
+ /**
703
+ * Add test-case matches for a function name in a test file (language-aware).
704
+ * Only adds test-case when the test body has a call match (not just import/reference).
705
+ */
706
+ function _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches) {
707
+ const lang = fileEntry.language;
708
+ const existingMatches = fileMatches.get(funcName) || [];
709
+ const existingLines = new Set(existingMatches.map(m => m.line));
710
+
711
+ if (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') {
712
+ const calls = getCachedCalls(index, filePath);
713
+ if (!calls) return;
714
+ const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
715
+ for (const call of calls) {
716
+ if (!testFrameworkCalls.has(call.name)) continue;
717
+ const lineContent = index.getLineContent(filePath, call.line);
718
+ if (lineContent.includes(funcName) && !existingLines.has(call.line)) {
719
+ // Only add test-case if a call match exists in the test body
720
+ const endLine = _estimateTestBlockEndTracing(index, filePath, call.line);
721
+ const hasCallMatch = existingMatches.some(m =>
722
+ m.line >= call.line && m.line <= endLine &&
723
+ m.matchType === 'call'
724
+ );
725
+ if (!hasCallMatch) continue;
726
+ if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
727
+ fileMatches.get(funcName).push({
728
+ line: call.line, content: lineContent.trim(),
729
+ matchType: 'test-case', functionName: funcName
730
+ });
731
+ existingLines.add(call.line);
732
+ }
733
+ }
734
+ } else {
735
+ if (!fileEntry.symbols) return;
736
+ try {
737
+ const langModule = getLanguageModule(lang);
738
+ if (!langModule || !langModule.isEntryPoint) return;
739
+ for (const symbol of fileEntry.symbols) {
740
+ if (!langModule.isEntryPoint(symbol)) continue;
741
+ // Only add test-case if a call match exists in the test body
742
+ const hasCallInRange = existingMatches.some(m =>
743
+ m.line >= symbol.startLine && m.line <= symbol.endLine &&
744
+ m.matchType === 'call'
745
+ );
746
+ if (hasCallInRange && !existingLines.has(symbol.startLine)) {
747
+ const lineContent = index.getLineContent(filePath, symbol.startLine);
748
+ if (!fileMatches.has(funcName)) fileMatches.set(funcName, []);
749
+ fileMatches.get(funcName).push({
750
+ line: symbol.startLine, content: lineContent.trim(),
751
+ matchType: 'test-case', functionName: funcName
752
+ });
753
+ existingLines.add(symbol.startLine);
754
+ }
755
+ }
756
+ } catch (e) { /* skip */ }
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Build instance type map for className scoping in affectedTests.
762
+ * Same logic as _buildInstanceTypeMap in search.js.
763
+ */
764
+ function _buildInstanceTypeMapForTracing(index, filePath, content, targetClassName) {
765
+ const typeMap = new Map();
766
+ const calls = getCachedCalls(index, filePath);
767
+ if (calls) {
768
+ for (const call of calls) {
769
+ if (call.isMethod && call.receiver && call.receiverType === targetClassName) {
770
+ typeMap.set(call.receiver, targetClassName);
771
+ }
772
+ if (call.name === targetClassName && !call.isMethod) {
773
+ const lineContent = index.getLineContent(filePath, call.line);
774
+ const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
775
+ if (assignMatch) typeMap.set(assignMatch[1], targetClassName);
776
+ }
777
+ if (call.isMethod && call.receiver === targetClassName) {
778
+ const lineContent = index.getLineContent(filePath, call.line);
779
+ const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
780
+ if (assignMatch) typeMap.set(assignMatch[1], targetClassName);
781
+ }
782
+ }
783
+ }
784
+ const classUsages = index._getCachedUsages(filePath, targetClassName);
785
+ if (classUsages) {
786
+ for (const u of classUsages) {
787
+ if (u.usageType === 'import' || u.usageType === 'definition') continue;
788
+ const lineContent = index.getLineContent(filePath, u.line);
789
+ const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
790
+ if (assignMatch && assignMatch[1] !== targetClassName) {
791
+ typeMap.set(assignMatch[1], targetClassName);
792
+ }
793
+ }
794
+ }
795
+ return typeMap;
796
+ }
797
+
798
+ /**
799
+ * Check if a usage's receiver matches the target className (for affectedTests).
800
+ * Same logic as _receiverMatchesClass in search.js.
801
+ */
802
+ function _receiverMatchesClassTracing(usage, className, instanceTypeMap, lineContent, searchTerm) {
803
+ if (usage.receiver === className) return true;
804
+ if (usage.receiver && instanceTypeMap && instanceTypeMap.get(usage.receiver) === className) return true;
805
+ if (usage.receiver) return false;
806
+ if (lineContent && searchTerm) {
807
+ const pat = new RegExp(
808
+ '\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
809
+ escapeRegExp(searchTerm) + '\\s*\\(|' +
810
+ 'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
811
+ escapeRegExp(searchTerm) + '\\s*\\()'
812
+ );
813
+ if (pat.test(lineContent)) return true;
814
+ }
815
+ return false;
816
+ }
817
+
818
+ /**
819
+ * Estimate the end line of a test block by tracking brace/paren nesting.
820
+ */
821
+ function _estimateTestBlockEndTracing(index, filePath, startLine) {
822
+ const content = index._readFile(filePath);
823
+ if (!content) return startLine + 5;
824
+ const lines = content.split('\n');
825
+ let depth = 0;
826
+ let started = false;
827
+ for (let i = startLine - 1; i < lines.length; i++) {
828
+ const line = lines[i];
829
+ for (const ch of line) {
830
+ if (ch === '{' || ch === '(') { depth++; started = true; }
831
+ else if (ch === '}' || ch === ')') { depth--; }
832
+ }
833
+ if (started && depth <= 0) return i + 1;
834
+ }
835
+ return Math.min(startLine + 10, lines.length);
836
+ }
837
+
838
+ function _matchPriority(matchType) {
839
+ const p = { 'test-case': 5, 'call': 4, 'import': 3, 'string-ref': 2, 'reference': 1 };
840
+ return p[matchType] || 0;
841
+ }
842
+
631
843
  module.exports = { trace, blast, reverseTrace, affectedTests };
@@ -2033,8 +2033,10 @@ function findUsagesInCode(code, name, parser) {
2033
2033
 
2034
2034
  traverseTreeCached(tree.rootNode, (node) => {
2035
2035
  // Look for identifier, property_identifier (method names in obj.method() calls),
2036
- // and type_identifier (TypeScript type annotations like `params: MyType`)
2037
- const isIdentifier = node.type === 'identifier' || node.type === 'property_identifier' || node.type === 'type_identifier';
2036
+ // type_identifier (TypeScript type annotations), and shorthand_property_identifier_pattern
2037
+ // (destructured names in `const { name } = require(...)`)
2038
+ const isIdentifier = node.type === 'identifier' || node.type === 'property_identifier' ||
2039
+ node.type === 'type_identifier' || node.type === 'shorthand_property_identifier_pattern';
2038
2040
  if (!isIdentifier || node.text !== name) {
2039
2041
  return true;
2040
2042
  }
@@ -2101,6 +2103,21 @@ function findUsagesInCode(code, name, parser) {
2101
2103
  }
2102
2104
  }
2103
2105
  }
2106
+ // Destructured require: const { name } = require('...')
2107
+ else if (node.type === 'shorthand_property_identifier_pattern' &&
2108
+ parent.type === 'object_pattern') {
2109
+ // Check if the object_pattern is part of a variable_declarator with require()
2110
+ const declarator = parent.parent;
2111
+ if (declarator && declarator.type === 'variable_declarator') {
2112
+ const value = declarator.childForFieldName('value');
2113
+ if (value && value.type === 'call_expression') {
2114
+ const func = value.childForFieldName('function');
2115
+ if (func && func.text === 'require') {
2116
+ usageType = 'import';
2117
+ }
2118
+ }
2119
+ }
2120
+ }
2104
2121
  // Property access (method call): a.name() - the name after dot
2105
2122
  else if (parent.type === 'member_expression' &&
2106
2123
  parent.childForFieldName('property') === node) {
package/languages/rust.js CHANGED
@@ -1319,6 +1319,13 @@ function findExportsInCode(code, parser) {
1319
1319
  * @param {object} parser - Tree-sitter parser instance
1320
1320
  * @returns {Array<{line: number, column: number, usageType: string}>}
1321
1321
  */
1322
+ function _indexInParent(node, parent) {
1323
+ for (let i = 0; i < parent.childCount; i++) {
1324
+ if (parent.child(i) === node) return i;
1325
+ }
1326
+ return -1;
1327
+ }
1328
+
1322
1329
  function findUsagesInCode(code, name, parser) {
1323
1330
  const tree = parseTree(parser, code);
1324
1331
  const usages = [];
@@ -1433,6 +1440,39 @@ function findUsagesInCode(code, name, parser) {
1433
1440
  return true;
1434
1441
  }
1435
1442
  }
1443
+ // Macro body: tree-sitter parses macro arguments as flat token_tree
1444
+ // nodes, so `svc.save()` inside `assert_eq!(svc.save(), 1)` appears
1445
+ // as sibling identifiers: [svc] [.] [save] [()] rather than a
1446
+ // field_expression. Detect the `obj.name(` pattern via siblings.
1447
+ else if (parent.type === 'token_tree') {
1448
+ const idx = _indexInParent(node, parent);
1449
+ // Method call pattern: [obj] [.] [name] [()] inside macro
1450
+ if (idx >= 2) {
1451
+ const dot = parent.child(idx - 1);
1452
+ const obj = parent.child(idx - 2);
1453
+ const next = parent.child(idx + 1);
1454
+ if (dot && dot.text === '.' && obj &&
1455
+ (obj.type === 'identifier' || obj.type === 'self')) {
1456
+ if (next && next.type === 'token_tree' &&
1457
+ next.childCount > 0 && next.child(0).text === '(') {
1458
+ usageType = 'call';
1459
+ }
1460
+ usages.push({ line, column, usageType, receiver: obj.text });
1461
+ return true;
1462
+ }
1463
+ }
1464
+ // Bare function call pattern: [name] [()] inside macro
1465
+ if (idx >= 0) {
1466
+ const next = parent.child(idx + 1);
1467
+ // Check no preceding dot (would be method call handled above)
1468
+ const prev = idx > 0 ? parent.child(idx - 1) : null;
1469
+ if ((!prev || prev.text !== '.') &&
1470
+ next && next.type === 'token_tree' &&
1471
+ next.childCount > 0 && next.child(0).text === '(') {
1472
+ usageType = 'call';
1473
+ }
1474
+ }
1475
+ }
1436
1476
  }
1437
1477
 
1438
1478
  // Filter out enum variant references: Boundary::Grid is NOT a usage of Grid struct