ucn 3.8.18 → 3.8.20

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/search.js CHANGED
@@ -10,7 +10,8 @@
10
10
  const path = require('path');
11
11
  const { escapeRegExp } = require('./shared');
12
12
  const { isTestFile } = require('./discovery');
13
- const { detectLanguage, getParser, langTraits } = require('../languages');
13
+ const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
14
+ const { getCachedCalls } = require('./callers');
14
15
 
15
16
  /**
16
17
  * Build a glob-style matcher: * matches any sequence, ? matches one char.
@@ -799,11 +800,15 @@ function typedef(index, name, options = {}) {
799
800
  }
800
801
 
801
802
  /**
802
- * Find tests for a function or file
803
+ * Find tests for a function or file (AST-based).
804
+ *
805
+ * Uses _getCachedUsages() for AST-based detection of imports, calls, and references.
806
+ * className scoping uses the AST receiver field from findUsagesInCode() instead of
807
+ * regex heuristics. Test-case detection is language-aware via isEntryPoint().
803
808
  *
804
809
  * @param {object} index - ProjectIndex instance
805
810
  * @param {string} nameOrFile - Function name or file path
806
- * @param {object} options - { callsOnly }
811
+ * @param {object} options - { callsOnly, className, file, exclude }
807
812
  * @returns {Array} Test files and matches
808
813
  */
809
814
  function tests(index, nameOrFile, options = {}) {
@@ -817,6 +822,19 @@ function tests(index, nameOrFile, options = {}) {
817
822
  nameOrFile.endsWith('.py') || nameOrFile.endsWith('.go') ||
818
823
  nameOrFile.endsWith('.java') || nameOrFile.endsWith('.rs');
819
824
 
825
+ // Resolve --file scoping: find the source file that defines this symbol
826
+ // and only include test files that import from it (directly or via re-exports).
827
+ let sourceFileFilter = null;
828
+ if (options.file && !isFilePath) {
829
+ const defs = index.find(nameOrFile, { exact: true, file: options.file, className: options.className });
830
+ if (defs.length > 0) {
831
+ sourceFileFilter = _buildSourceFileImporters(index, defs);
832
+ }
833
+ // If no defs found, sourceFileFilter stays null → no file scoping applied.
834
+ // The execute handler validates before calling, so this path means
835
+ // the file matched but no exact symbol — fall through gracefully.
836
+ }
837
+
820
838
  // Find all test files
821
839
  const testFiles = [];
822
840
  for (const [filePath, fileEntry] of index.files) {
@@ -824,7 +842,6 @@ function tests(index, nameOrFile, options = {}) {
824
842
  testFiles.push({ path: filePath, entry: fileEntry });
825
843
  } else if (fileEntry.language === 'rust') {
826
844
  // Rust idiomatically puts tests in #[cfg(test)] modules inside source files.
827
- // Check if file has any symbols with 'test' modifier (#[test] attribute).
828
845
  const hasInlineTests = fileEntry.symbols?.some(s =>
829
846
  s.modifiers?.includes('test')
830
847
  );
@@ -838,155 +855,109 @@ function tests(index, nameOrFile, options = {}) {
838
855
  ? path.basename(nameOrFile, path.extname(nameOrFile))
839
856
  : nameOrFile;
840
857
 
841
- // Note: no 'g' flag - we only need to test for presence per line
842
- // The 'i' flag is kept for case-insensitive matching
843
- const regex = new RegExp('\\b' + escapeRegExp(searchTerm) + '\\b', 'i');
844
- // Pre-compile patterns used inside per-line loop
845
- const callPattern = new RegExp(escapeRegExp(searchTerm) + '\\s*\\(');
858
+ const className = options.className || null;
859
+ // Pre-compile string-ref pattern (only regex left — used on single AST-identified lines)
846
860
  const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
847
861
 
848
- // When className is provided, build patterns for match-level scoping.
849
- const classNameFilter = options.className
850
- ? new RegExp('\\b' + escapeRegExp(options.className) + '\\b')
851
- : null;
852
- // Match-level class scoping: for calls, check receiver or nearby context;
853
- // e.g., "new ClassName().method()" or "instance.method()" where instance is typed.
854
- const classReceiverPattern = options.className
855
- ? new RegExp('\\b' + escapeRegExp(options.className) + '\\s*[\\.\\(]')
856
- : null;
862
+ // --exclude filtering
863
+ const excludeArr = options.exclude ? (Array.isArray(options.exclude) ? options.exclude : [options.exclude]) : [];
857
864
 
858
865
  for (const { path: testPath, entry } of testFiles) {
859
866
  try {
867
+ // Apply exclude filters
868
+ if (excludeArr.length > 0 && !index.matchesFilters(entry.relativePath, { exclude: excludeArr })) continue;
869
+
860
870
  const content = index._readFile(testPath);
861
871
 
872
+ // Fast pre-check: skip if searchTerm doesn't appear in file
873
+ if (!content.includes(searchTerm)) continue;
862
874
  // className scoping: skip test files that don't reference the class at all
863
- if (classNameFilter && !classNameFilter.test(content)) {
875
+ if (className && !content.includes(className)) continue;
876
+
877
+ // --file scoping: only include test files that import from the target source
878
+ if (sourceFileFilter && !sourceFileFilter.has(testPath)) {
864
879
  continue;
865
880
  }
866
881
 
867
- const lines = content.split('\n');
882
+ // AST-based usage detection
883
+ const astUsages = index._getCachedUsages(testPath, searchTerm);
884
+ if (astUsages === null) continue; // no parser available — skip
868
885
 
869
- // Build a map of variable names → line ranges where they're bound to the target class.
870
- // Tracks reassignment: a variable is only an instance of the target class between
871
- // its assignment to that class and any subsequent reassignment to something else.
872
- // Pre-compiles receiver regexes for O(1) per-line checks.
873
- const instanceVarReceiverRegexes = []; // [{regex, fromLine, toLine}]
874
- if (options.className) {
875
- const cn = escapeRegExp(options.className);
876
- const bindingPattern = new RegExp(
877
- '(?:const|let|var|)\\s+(\\w+)\\s*=\\s*(?:new\\s+' + cn + '|' + cn + '\\.\\w+)\\s*\\(', 'g'
878
- );
879
- // Also detect reassignment to a DIFFERENT class/value
880
- const reassignPattern = /(\w+)\s*=\s*(?:new\s+\w|[^=])/g;
881
-
882
- // First pass: find all bindings to the target class
883
- const bindings = []; // [{varName, line}]
884
- for (let i = 0; i < lines.length; i++) {
885
- let m;
886
- bindingPattern.lastIndex = 0;
887
- while ((m = bindingPattern.exec(lines[i])) !== null) {
888
- bindings.push({ varName: m[1], line: i });
889
- }
890
- }
886
+ if (astUsages.length === 0) continue;
891
887
 
892
- // Second pass: for each binding, find where the var is reassigned (scope end)
893
- const escapedTerm = escapeRegExp(searchTerm);
894
- for (const b of bindings) {
895
- let toLine = lines.length; // default: valid until end of file
896
- for (let i = b.line + 1; i < lines.length; i++) {
897
- // Check if this variable is reassigned to something else
898
- const line = lines[i];
899
- if (new RegExp('\\b' + escapeRegExp(b.varName) + '\\s*=\\s*(?!\\s*=)').test(line)
900
- && !bindingPattern.test(line)) {
901
- toLine = i;
902
- break;
903
- }
904
- }
905
- instanceVarReceiverRegexes.push({
906
- regex: new RegExp('\\b' + escapeRegExp(b.varName) + '\\.' + escapedTerm + '\\s*\\('),
907
- nameRegex: new RegExp('\\b' + escapeRegExp(b.varName) + '\\b'),
908
- fromLine: b.line,
909
- toLine,
910
- });
911
- }
888
+ // Build instance variable className map from getCachedCalls()
889
+ // for receiver-precise className scoping.
890
+ // e.g., `const svc = new B()` → svc maps to 'B'
891
+ let instanceTypeMap = null; // lazily built
892
+ if (className) {
893
+ instanceTypeMap = _buildInstanceTypeMap(index, testPath, content, className);
912
894
  }
913
895
 
914
- // Check if a line's receiver is the target class (direct or via bound variable).
915
- function lineHasClassReceiver(line, lineIdx) {
916
- if (classNameFilter.test(line)) return true;
917
- // Check pre-compiled instance variable regexes (scoped to assignment range)
918
- for (const entry of instanceVarReceiverRegexes) {
919
- if (lineIdx >= entry.fromLine && lineIdx < entry.toLine) {
920
- if (entry.regex.test(line) || entry.nameRegex.test(line)) return true;
921
- }
896
+ const matches = [];
897
+ const seenLines = new Set(); // deduplicate same-line matches
898
+
899
+ for (const usage of astUsages) {
900
+ if (usage.usageType === 'definition') continue; // not relevant in test files
901
+
902
+ const lineKey = `${usage.line}:${usage.usageType}`;
903
+ if (seenLines.has(lineKey)) continue;
904
+ seenLines.add(lineKey);
905
+
906
+ const lineContent = index.getLineContent(testPath, usage.line);
907
+
908
+ let matchType;
909
+ if (usage.usageType === 'import') {
910
+ matchType = 'import';
911
+ } else if (usage.usageType === 'call') {
912
+ matchType = 'call';
913
+ } else {
914
+ // 'reference' — check if inside string literal
915
+ matchType = strPattern.test(lineContent) ? 'string-ref' : 'reference';
922
916
  }
923
- return false;
924
- }
925
917
 
926
- const matches = [];
918
+ // className scoping for calls: check receiver
919
+ if (className && matchType === 'call') {
920
+ if (!_receiverMatchesClass(usage, className, instanceTypeMap, lineContent, searchTerm)) continue;
921
+ }
927
922
 
928
- lines.forEach((line, idx) => {
929
- if (regex.test(line)) {
930
- let matchType = 'reference';
931
- if (/\b(describe|it|test|spec)\s*\(/.test(line)) {
932
- matchType = 'test-case';
933
- } else if (/\b(import|require|from)\b/.test(line)) {
934
- matchType = 'import';
935
- } else if (callPattern.test(line)) {
936
- matchType = 'call';
937
- }
938
- // Detect if the match is inside a string literal (e.g., 'parseFile' or "parseFile")
939
- if (matchType === 'reference' || matchType === 'call') {
940
- if (strPattern.test(line)) {
941
- matchType = 'string-ref';
942
- }
923
+ // className scoping for references: require class-associated receiver
924
+ if (className && (matchType === 'reference' || matchType === 'string-ref')) {
925
+ // Bare references (no receiver) like `fn = save` have no class
926
+ // association — skip them. Only keep member-access references
927
+ // where the receiver matches the target class.
928
+ if (!usage.receiver) continue;
929
+ if (usage.receiver !== className &&
930
+ !(instanceTypeMap && instanceTypeMap.get(usage.receiver) === className)) {
931
+ continue;
943
932
  }
933
+ }
944
934
 
945
- // Match-level className scoping: for call matches,
946
- // the class name or a bound instance variable must appear
947
- // on the same line as the method call.
948
- if (classReceiverPattern && matchType === 'call') {
949
- if (!lineHasClassReceiver(line, idx)) return; // skip — different receiver
950
- }
935
+ matches.push({
936
+ line: usage.line,
937
+ content: lineContent.trim(),
938
+ matchType
939
+ });
940
+ }
951
941
 
952
- // For reference matches, check same line or ±1 line.
953
- if (classReceiverPattern && matchType === 'reference') {
954
- let classNearby = lineHasClassReceiver(line, idx);
955
- if (!classNearby && idx > 0) classNearby = lineHasClassReceiver(lines[idx - 1], idx - 1);
956
- if (!classNearby && idx + 1 < lines.length) classNearby = lineHasClassReceiver(lines[idx + 1], idx + 1);
957
- if (!classNearby) return; // skip this match
958
- }
942
+ // Language-aware test-case detection
943
+ _addTestCaseMatches(index, testPath, entry, searchTerm, className, instanceTypeMap, matches);
959
944
 
960
- // For test-case matches with className, keep if the test
961
- // description mentions the class or the test body
962
- // references the class (directly or via bound instance).
963
- if (classNameFilter && matchType === 'test-case') {
964
- let classInContext = classNameFilter.test(line);
965
- if (!classInContext) {
966
- // Look forward into the test body for class usage
967
- for (let d = 1; d <= 5 && !classInContext; d++) {
968
- if (idx + d < lines.length) {
969
- const bodyLine = lines[idx + d];
970
- // Stop at next test-case boundary
971
- if (/\b(describe|it|test|spec)\s*\(/.test(bodyLine)) break;
972
- if (lineHasClassReceiver(bodyLine, idx + d)) classInContext = true;
973
- }
974
- }
975
- }
976
- if (!classInContext) return; // skip test-case not related to this class
977
- }
945
+ // Deduplicate: if a line already has a 'call' or 'import', don't also add 'test-case'
946
+ let finalMatches = _deduplicateMatches(matches);
978
947
 
979
- matches.push({
980
- line: idx + 1,
981
- content: line.trim(),
982
- matchType
983
- });
948
+ // className scoping: only include imports if the file has class-scoped
949
+ // call/reference/test-case matches. An import of the searchTerm alone
950
+ // (e.g., `from app import B, save`) is not evidence of B.save() usage.
951
+ if (className) {
952
+ const hasClassScopedMatch = finalMatches.some(m => m.matchType !== 'import');
953
+ if (!hasClassScopedMatch) {
954
+ finalMatches = [];
984
955
  }
985
- });
956
+ }
986
957
 
987
958
  const filtered = options.callsOnly
988
- ? matches.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
989
- : matches;
959
+ ? finalMatches.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
960
+ : finalMatches;
990
961
  if (filtered.length > 0) {
991
962
  results.push({
992
963
  file: entry.relativePath,
@@ -1002,4 +973,388 @@ function tests(index, nameOrFile, options = {}) {
1002
973
  } finally { index._endOp(); }
1003
974
  }
1004
975
 
976
+ /**
977
+ * Build a map of instance variable names → class names from call objects and AST usages.
978
+ * Language-generic: uses receiverType from getCachedCalls() (already inferred
979
+ * by _buildTypedLocalTypeMap for Go/Java/Rust and binding analysis for JS/TS/Python),
980
+ * plus AST usages of targetClassName for assignment patterns not captured by calls
981
+ * (e.g., Rust `let svc = B;` inside macro bodies).
982
+ *
983
+ * Three sources:
984
+ /**
985
+ * Build a set of absolute file paths that import (directly or transitively via
986
+ * re-exports) from the source files where the symbol is defined.
987
+ * Uses the index's resolved importGraph/exportGraph for path-precise matching.
988
+ */
989
+ function _buildSourceFileImporters(index, defs) {
990
+ const symbolName = defs[0]?.name;
991
+ const sourceAbsPaths = new Set();
992
+ for (const d of defs) {
993
+ for (const [absPath, fe] of index.files) {
994
+ if (fe.relativePath === d.relativePath) {
995
+ sourceAbsPaths.add(absPath);
996
+ break;
997
+ }
998
+ }
999
+ }
1000
+
1001
+ // BFS through the export graph: walk from source files outward through
1002
+ // re-export chains. At each hop, verify the intermediate file actually
1003
+ // exports the target symbol (prevents overmatching barrels that import
1004
+ // the source file for a different symbol).
1005
+ const importers = new Set();
1006
+ const queue = [...sourceAbsPaths];
1007
+ const visited = new Set(sourceAbsPaths);
1008
+
1009
+ while (queue.length > 0) {
1010
+ const current = queue.shift();
1011
+ const directImporters = index.exportGraph?.get(current) || [];
1012
+ for (const imp of directImporters) {
1013
+ importers.add(imp);
1014
+ // Check if this importer re-exports the symbol (barrel pattern).
1015
+ // If so, add it to the queue so its importers are also discovered.
1016
+ if (!visited.has(imp)) {
1017
+ const fe = index.files.get(imp);
1018
+ if (fe && _fileReExportsSymbol(fe, symbolName, current)) {
1019
+ visited.add(imp);
1020
+ queue.push(imp);
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ // Language-aware test file discovery: add test files matched by naming
1027
+ // convention or same-package membership, which don't use import statements.
1028
+ // Go: same directory (package-scoped), Java: *Test.java convention,
1029
+ // Rust: inline #[cfg(test)] in the source file itself.
1030
+ for (const srcPath of sourceAbsPaths) {
1031
+ const srcEntry = index.files.get(srcPath);
1032
+ if (!srcEntry) continue;
1033
+ const traits = langTraits(srcEntry.language);
1034
+ if (!traits?.testFileCandidates) continue;
1035
+
1036
+ const srcBase = path.basename(srcPath, path.extname(srcPath));
1037
+ const srcExt = path.extname(srcPath);
1038
+ const srcDir = path.dirname(srcPath);
1039
+ const candidates = traits.testFileCandidates(srcBase, srcExt);
1040
+
1041
+ // Build set of directories to check for convention-based test files:
1042
+ // same directory + configured testDirs (e.g., __tests__, tests/)
1043
+ // + Java src/main→src/test mirror convention
1044
+ const candidateDirs = new Set([srcDir]);
1045
+ for (const td of (traits.testDirs || [])) {
1046
+ candidateDirs.add(path.join(srcDir, td));
1047
+ }
1048
+ // Java convention: src/main/java/com/pkg → src/test/java/com/pkg
1049
+ if (srcDir.includes(path.sep + 'main' + path.sep)) {
1050
+ candidateDirs.add(srcDir.replace(path.sep + 'main' + path.sep, path.sep + 'test' + path.sep));
1051
+ }
1052
+
1053
+ for (const [absPath, fe] of index.files) {
1054
+ if (importers.has(absPath)) continue; // already included
1055
+ if (!isTestFile(fe.relativePath, fe.language) &&
1056
+ !(fe.language === 'rust' && fe.symbols?.some(s => s.modifiers?.includes('test')))) {
1057
+ continue; // not a test file
1058
+ }
1059
+
1060
+ const testDir = path.dirname(absPath);
1061
+
1062
+ // Check naming convention match — must be in same dir or a test subdir
1063
+ if (candidateDirs.has(testDir)) {
1064
+ const testBaseName = path.basename(absPath);
1065
+ if (candidates.some(c => testBaseName === c)) {
1066
+ importers.add(absPath);
1067
+ continue;
1068
+ }
1069
+ }
1070
+
1071
+ // Go: same-directory tests (package-scoped, no imports needed)
1072
+ if (traits.packageScope === 'directory' && testDir === srcDir) {
1073
+ importers.add(absPath);
1074
+ continue;
1075
+ }
1076
+
1077
+ // Rust: inline tests in the source file itself
1078
+ if (srcPath === absPath) {
1079
+ importers.add(absPath);
1080
+ }
1081
+ }
1082
+ }
1083
+
1084
+ return importers;
1085
+ }
1086
+
1087
+ /**
1088
+ * Check if a file re-exports a symbol from a source file.
1089
+ * Handles: named re-exports, `module.exports = require(...)` blanket re-exports,
1090
+ * `export * from ...`, and files that both import from source and export the symbol.
1091
+ */
1092
+ function _fileReExportsSymbol(fileEntry, symbolName, sourceAbsPath) {
1093
+ if (!fileEntry.exports || fileEntry.exports.length === 0) return false;
1094
+ // Check if any export matches the symbol name
1095
+ if (symbolName && fileEntry.exports.some(exp => exp.name === symbolName)) return true;
1096
+ // Blanket re-exports: module.exports = require(...), export * from ...
1097
+ // These have undefined or generic names but re-export everything from the imported module
1098
+ const hasBlanketExport = fileEntry.exports.some(exp =>
1099
+ !exp.name || exp.type === 'module.exports' || exp.type === 're-export' || exp.type === 'export-all'
1100
+ );
1101
+ if (hasBlanketExport) return true;
1102
+ return false;
1103
+ }
1104
+
1105
+ /**
1106
+ * Build a map of instance variable names → class names from call objects and AST usages.
1107
+ * Language-generic: uses receiverType from getCachedCalls() (already inferred
1108
+ * by _buildTypedLocalTypeMap for Go/Java/Rust and binding analysis for JS/TS/Python),
1109
+ * plus AST usages of targetClassName for assignment patterns not captured by calls
1110
+ * (e.g., Rust `let svc = B;` inside macro bodies).
1111
+ *
1112
+ * Three sources:
1113
+ * 1. receiverType on method calls: `svc.Save()` with receiverType=B → svc maps to B
1114
+ * 2. Constructor calls: `new B()`, `B()`, `&B{}` assigned to a variable
1115
+ * 3. AST usages of className on assignment lines: `let svc = B;` or `svc = B{}`
1116
+ */
1117
+ function _buildInstanceTypeMap(index, filePath, content, targetClassName) {
1118
+ const typeMap = new Map(); // varName → className
1119
+
1120
+ const calls = getCachedCalls(index, filePath);
1121
+ if (calls) {
1122
+ for (const call of calls) {
1123
+ // Source 1: receiverType from method calls (works across all languages)
1124
+ // e.g., svc.Save() where receiverType='B' → svc maps to 'B'
1125
+ if (call.isMethod && call.receiver && call.receiverType === targetClassName) {
1126
+ typeMap.set(call.receiver, targetClassName);
1127
+ }
1128
+
1129
+ // Source 2: Constructor/factory calls assigned to a variable
1130
+ // e.g., `const svc = new B()` (JS), `svc = B()` (Python), `svc := &B{}` (Go)
1131
+ if (call.name === targetClassName && !call.isMethod) {
1132
+ const lineContent = index.getLineContent(filePath, call.line);
1133
+ const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
1134
+ if (assignMatch) {
1135
+ typeMap.set(assignMatch[1], targetClassName);
1136
+ }
1137
+ }
1138
+
1139
+ // Source 3: Factory methods — ClassName.create(), ClassName.build(), etc.
1140
+ if (call.isMethod && call.receiver === targetClassName) {
1141
+ const lineContent = index.getLineContent(filePath, call.line);
1142
+ const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
1143
+ if (assignMatch) {
1144
+ typeMap.set(assignMatch[1], targetClassName);
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // Source 4: AST usages of targetClassName on assignment lines.
1151
+ // Catches patterns not visible to getCachedCalls (e.g., Rust macro bodies
1152
+ // where `let svc = B;` is inside a token_tree, or Go `svc := B{}`).
1153
+ const classUsages = index._getCachedUsages(filePath, targetClassName);
1154
+ if (classUsages) {
1155
+ for (const u of classUsages) {
1156
+ if (u.usageType === 'import' || u.usageType === 'definition') continue;
1157
+ const lineContent = index.getLineContent(filePath, u.line);
1158
+ // Match: `let/const/var varName = ClassName` or `varName := ClassName`
1159
+ const assignMatch = lineContent.match(/(?:const|let|var|)\s*(\w+)\s*:?=\s/);
1160
+ if (assignMatch && assignMatch[1] !== targetClassName) {
1161
+ typeMap.set(assignMatch[1], targetClassName);
1162
+ }
1163
+ }
1164
+ }
1165
+
1166
+ return typeMap;
1167
+ }
1168
+
1169
+ /**
1170
+ * Check if a usage's receiver matches the target className.
1171
+ * Uses direct receiver check and instance type map for indirect bindings.
1172
+ * @param {object} usage - AST usage with optional receiver field
1173
+ * @param {string} className - Target class name
1174
+ * @param {Map} instanceTypeMap - varName → className map
1175
+ * @param {string} [lineContent] - Line content for fallback checks
1176
+ */
1177
+ function _receiverMatchesClass(usage, className, instanceTypeMap, lineContent, searchTerm) {
1178
+ // Direct receiver: ClassName.method() or ClassName.staticMethod()
1179
+ if (usage.receiver === className) return true;
1180
+ // Instance variable: check if receiver is bound to the target class
1181
+ if (usage.receiver && instanceTypeMap && instanceTypeMap.get(usage.receiver) === className) return true;
1182
+ // Receiver is some other known identifier — doesn't match
1183
+ if (usage.receiver) return false;
1184
+ // No receiver: bare function call. Only match if className is the direct
1185
+ // receiver expression — e.g., `new B().save()`, `B().save()`, `B{}.save()`.
1186
+ // Reject cases like `svc = B(); save()` where className is elsewhere on the line.
1187
+ if (lineContent && searchTerm) {
1188
+ // Check for chained call: ClassName followed by constructor/call then .methodName(
1189
+ const pat = new RegExp(
1190
+ '\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
1191
+ escapeRegExp(searchTerm) + '\\s*\\(|' +
1192
+ 'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
1193
+ escapeRegExp(searchTerm) + '\\s*\\()'
1194
+ );
1195
+ if (pat.test(lineContent)) return true;
1196
+ }
1197
+ return false;
1198
+ }
1199
+
1200
+ /**
1201
+ * Language-aware test-case detection.
1202
+ *
1203
+ * JS/TS: describe, it, test, spec calls with searchTerm in the description.
1204
+ * Go: TestXxx, BenchmarkXxx, ExampleXxx functions containing a usage of searchTerm.
1205
+ * Python: test_ functions or TestCase methods containing a usage of searchTerm.
1206
+ * Java: @Test-annotated methods containing a usage of searchTerm.
1207
+ * Rust: #[test]-attributed functions containing a usage of searchTerm.
1208
+ */
1209
+ function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className, instanceTypeMap, matches) {
1210
+ const matchLines = new Set(matches.map(m => m.line));
1211
+ const lang = fileEntry.language;
1212
+
1213
+ if (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') {
1214
+ // JS/TS: find describe/it/test/spec calls from getCachedCalls
1215
+ const calls = getCachedCalls(index, filePath);
1216
+ if (!calls) return;
1217
+ const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
1218
+ for (const call of calls) {
1219
+ if (!testFrameworkCalls.has(call.name)) continue;
1220
+ const lineContent = index.getLineContent(filePath, call.line);
1221
+ // Check if searchTerm appears in the description string on this line
1222
+ if (lineContent.includes(searchTerm) && !matchLines.has(call.line)) {
1223
+ // className scoping: only add test-case if the test body has a
1224
+ // class-scoped match (call or class-receiver reference) — not just
1225
+ // any mention of className.
1226
+ if (className) {
1227
+ const endLine = _estimateTestBlockEnd(index, filePath, call.line);
1228
+ const hasClassScopedMatch = matches.some(m =>
1229
+ m.line >= call.line && m.line <= endLine &&
1230
+ m.matchType !== 'import'
1231
+ );
1232
+ if (!hasClassScopedMatch) continue;
1233
+ }
1234
+ matches.push({
1235
+ line: call.line,
1236
+ content: lineContent.trim(),
1237
+ matchType: 'test-case'
1238
+ });
1239
+ matchLines.add(call.line);
1240
+ }
1241
+ }
1242
+ } else {
1243
+ // Go/Python/Java/Rust: check if any AST usage falls within a test function's range
1244
+ if (!fileEntry.symbols) return;
1245
+ try {
1246
+ const langModule = getLanguageModule(lang);
1247
+ if (!langModule || !langModule.isEntryPoint) return;
1248
+
1249
+ // Find test symbols
1250
+ for (const symbol of fileEntry.symbols) {
1251
+ if (!langModule.isEntryPoint(symbol)) continue;
1252
+ // Check if any non-import usage of searchTerm falls within this test function
1253
+ const usageInRange = matches.some(m =>
1254
+ m.line >= symbol.startLine && m.line <= symbol.endLine &&
1255
+ m.matchType !== 'import'
1256
+ );
1257
+ if (usageInRange && !matchLines.has(symbol.startLine)) {
1258
+ // className scoping: verify the test body has class-scoped matches
1259
+ if (className) {
1260
+ const hasClassScopedMatch = matches.some(m =>
1261
+ m.line >= symbol.startLine && m.line <= symbol.endLine &&
1262
+ m.matchType !== 'import'
1263
+ );
1264
+ if (!hasClassScopedMatch) continue;
1265
+ }
1266
+ const lineContent = index.getLineContent(filePath, symbol.startLine);
1267
+ matches.push({
1268
+ line: symbol.startLine,
1269
+ content: lineContent.trim(),
1270
+ matchType: 'test-case'
1271
+ });
1272
+ matchLines.add(symbol.startLine);
1273
+ }
1274
+ }
1275
+ } catch (e) {
1276
+ // Skip if language module unavailable
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ /**
1282
+ * Check if a test body references the target class (directly or via instance variable).
1283
+ * Looks at AST usages of className within the test function's line range.
1284
+ */
1285
+ function _testBodyReferencesClass(index, filePath, fileEntry, testLine, className, instanceTypeMap) {
1286
+ // Find the enclosing test function to get its line range
1287
+ const enclosing = index.findEnclosingFunction(filePath, testLine, true);
1288
+ let startLine, endLine;
1289
+ if (enclosing) {
1290
+ startLine = enclosing.startLine;
1291
+ endLine = enclosing.endLine;
1292
+ } else {
1293
+ // No enclosing function found (common for JS/TS it/test callbacks which
1294
+ // aren't in the symbol table). Estimate range from file content.
1295
+ startLine = testLine;
1296
+ endLine = _estimateTestBlockEnd(index, filePath, testLine);
1297
+ }
1298
+
1299
+ // Check if className appears as AST usage in the range
1300
+ const classUsages = index._getCachedUsages(filePath, className);
1301
+ if (classUsages) {
1302
+ for (const u of classUsages) {
1303
+ if (u.line >= startLine && u.line <= endLine) return true;
1304
+ }
1305
+ }
1306
+
1307
+ // Check if any instance variable bound to className is used in the range
1308
+ if (instanceTypeMap) {
1309
+ for (const [varName, cls] of instanceTypeMap) {
1310
+ if (cls !== className) continue;
1311
+ const varUsages = index._getCachedUsages(filePath, varName);
1312
+ if (varUsages) {
1313
+ for (const u of varUsages) {
1314
+ if (u.line >= startLine && u.line <= endLine) return true;
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ return false;
1321
+ }
1322
+
1323
+ /**
1324
+ * Estimate the end line of a test block (it/test/describe callback) by tracking
1325
+ * brace nesting from the start line.
1326
+ */
1327
+ function _estimateTestBlockEnd(index, filePath, startLine) {
1328
+ const content = index._readFile(filePath);
1329
+ if (!content) return startLine + 5;
1330
+ const lines = content.split('\n');
1331
+ let depth = 0;
1332
+ let started = false;
1333
+ for (let i = startLine - 1; i < lines.length; i++) {
1334
+ const line = lines[i];
1335
+ for (const ch of line) {
1336
+ if (ch === '{' || ch === '(') { depth++; started = true; }
1337
+ else if (ch === '}' || ch === ')') { depth--; }
1338
+ }
1339
+ if (started && depth <= 0) return i + 1; // 1-based
1340
+ }
1341
+ return Math.min(startLine + 10, lines.length);
1342
+ }
1343
+
1344
+ /**
1345
+ * Deduplicate matches: prefer more specific matchTypes on the same line.
1346
+ * Priority: test-case > call > import > string-ref > reference
1347
+ */
1348
+ function _deduplicateMatches(matches) {
1349
+ const byLine = new Map();
1350
+ const priority = { 'test-case': 5, 'call': 4, 'import': 3, 'string-ref': 2, 'reference': 1 };
1351
+ for (const m of matches) {
1352
+ const existing = byLine.get(m.line);
1353
+ if (!existing || (priority[m.matchType] || 0) > (priority[existing.matchType] || 0)) {
1354
+ byLine.set(m.line, m);
1355
+ }
1356
+ }
1357
+ return [...byLine.values()].sort((a, b) => a.line - b.line);
1358
+ }
1359
+
1005
1360
  module.exports = { find, _applyFindFilters, usages, search, structuralSearch, example, typedef, tests };