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/.claude/skills/ucn/SKILL.md +2 -2
- package/cli/index.js +226 -58
- package/core/analysis.js +6 -2
- package/core/execute.js +17 -0
- package/core/output/analysis.js +1 -1
- package/core/registry.js +55 -22
- package/core/search.js +482 -127
- package/core/tracing.js +251 -39
- package/languages/javascript.js +19 -2
- package/languages/rust.js +40 -0
- package/mcp/server.js +73 -44
- package/package.json +1 -1
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
|
-
|
|
842
|
-
//
|
|
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
|
-
//
|
|
849
|
-
const
|
|
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 (
|
|
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
|
-
|
|
882
|
+
// AST-based usage detection
|
|
883
|
+
const astUsages = index._getCachedUsages(testPath, searchTerm);
|
|
884
|
+
if (astUsages === null) continue; // no parser available — skip
|
|
868
885
|
|
|
869
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
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
|
-
|
|
929
|
-
if (
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
935
|
+
matches.push({
|
|
936
|
+
line: usage.line,
|
|
937
|
+
content: lineContent.trim(),
|
|
938
|
+
matchType
|
|
939
|
+
});
|
|
940
|
+
}
|
|
951
941
|
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
961
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
?
|
|
989
|
-
:
|
|
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 };
|