ucn 4.0.2 → 4.1.0
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 +31 -7
- package/README.md +89 -50
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +322 -304
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +52 -33
- package/core/output/check.js +4 -1
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +131 -13
- package/core/search.js +270 -55
- package/core/shared.js +240 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +69 -30
- package/package.json +4 -1
package/core/search.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { escapeRegExp } = require('./shared');
|
|
11
|
+
const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames } = require('./shared');
|
|
12
12
|
const { isTestFile } = require('./discovery');
|
|
13
13
|
const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
|
|
14
14
|
const { getCachedCalls } = require('./callers');
|
|
15
|
+
const { extractImports } = require('./imports');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Build a glob-style matcher: * matches any sequence, ? matches one char.
|
|
@@ -59,7 +60,7 @@ function find(index, name, options = {}) {
|
|
|
59
60
|
all.push({ ...sym, _fuzzyScore: 800 });
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
|
-
all.sort((a, b) => (a.name || '')
|
|
63
|
+
all.sort((a, b) => codeUnitCompare((a.name || ''), b.name || ''));
|
|
63
64
|
return _applyFindFilters(index, all, options);
|
|
64
65
|
}
|
|
65
66
|
const globRegex = new RegExp('^' + name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
|
@@ -71,7 +72,7 @@ function find(index, name, options = {}) {
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
matches.sort((a, b) => (a.name || '')
|
|
75
|
+
matches.sort((a, b) => codeUnitCompare((a.name || ''), b.name || ''));
|
|
75
76
|
return _applyFindFilters(index, matches, options);
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -233,6 +234,16 @@ function usages(index, name, options = {}) {
|
|
|
233
234
|
return _importedHasDef;
|
|
234
235
|
};
|
|
235
236
|
|
|
237
|
+
// A qualified usage whose RECEIVER is a symbol defined in this
|
|
238
|
+
// file is never external-package noise — `Geometry.area(3, 4)`
|
|
239
|
+
// in the file declaring namespace Geometry was dropped by the
|
|
240
|
+
// receiver filter while find's usageCounts counted it (fix
|
|
241
|
+
// #241). Keyed on the receiver, not the target name: a file
|
|
242
|
+
// defining its own `Separator` while using external
|
|
243
|
+
// `Ns.Separator` must still filter the latter (bug #23).
|
|
244
|
+
const receiverDefinedHere = (recv) =>
|
|
245
|
+
!!recv && fileEntry.symbols && fileEntry.symbols.some(s => s.name === recv);
|
|
246
|
+
|
|
236
247
|
for (const u of astUsages) {
|
|
237
248
|
// Skip if this is a definition line (already added above)
|
|
238
249
|
if (definitions.some(d => d.file === filePath && d.startLine === u.line)) {
|
|
@@ -241,12 +252,13 @@ function usages(index, name, options = {}) {
|
|
|
241
252
|
|
|
242
253
|
// Filter member expressions with unrelated receivers in JS/TS/Python.
|
|
243
254
|
// Keeps: standalone usages, self/this/cls/super, method calls on known types,
|
|
255
|
+
// qualified usages whose receiver this file defines,
|
|
244
256
|
// and module access (output.fn()) when the imported file defines the name.
|
|
245
257
|
// Filters: namespace access to external packages (DropdownMenuPrimitive.Separator).
|
|
246
258
|
if (u.receiver && !['self', 'this', 'cls', 'super'].includes(u.receiver) &&
|
|
247
259
|
fileEntry.language !== 'go' && fileEntry.language !== 'java' && fileEntry.language !== 'rust') {
|
|
248
260
|
const hasMethodDef = definitions.some(d => d.className);
|
|
249
|
-
if (!hasMethodDef && !importedFileHasDef()) {
|
|
261
|
+
if (!hasMethodDef && !receiverDefinedHere(u.receiver) && !importedFileHasDef()) {
|
|
250
262
|
continue;
|
|
251
263
|
}
|
|
252
264
|
}
|
|
@@ -563,6 +575,8 @@ function structuralSearch(index, options = {}) {
|
|
|
563
575
|
index._beginOp();
|
|
564
576
|
try {
|
|
565
577
|
const { term, param, receiver, returns: returnType, decorator, exported, unused } = options;
|
|
578
|
+
let unusedDecoratorNames = null; // lazy — built once per --unused search (fix #234)
|
|
579
|
+
let selfRecursiveMemo = null; // lazy — per-name self-recursion verdicts (fix #253c)
|
|
566
580
|
// Auto-infer type: --receiver implies type=call
|
|
567
581
|
const type = options.type || (receiver ? 'call' : undefined);
|
|
568
582
|
const results = [];
|
|
@@ -620,25 +634,38 @@ function structuralSearch(index, options = {}) {
|
|
|
620
634
|
if (!calls) continue;
|
|
621
635
|
for (const call of calls) {
|
|
622
636
|
if (nameMatcher && !nameMatcher(call.name)) continue;
|
|
637
|
+
// Field-hop receivers (`tm.service.Save()`) carry
|
|
638
|
+
// receiverRoot/receiverField instead of receiver —
|
|
639
|
+
// expose the dotted form so --receiver can match them
|
|
640
|
+
// and the call renders with its receiver (fix #237).
|
|
641
|
+
const callReceiver = call.receiver ||
|
|
642
|
+
(call.receiverField
|
|
643
|
+
? (call.receiverRoot ? `${call.receiverRoot}.${call.receiverField}` : call.receiverField)
|
|
644
|
+
: null);
|
|
623
645
|
if (receiver) {
|
|
624
|
-
if (!
|
|
625
|
-
if (!matchesSubstring(
|
|
646
|
+
if (!callReceiver) continue;
|
|
647
|
+
if (!matchesSubstring(callReceiver, receiver, options.caseSensitive)) continue;
|
|
626
648
|
}
|
|
627
649
|
results.push({
|
|
628
650
|
kind: 'call',
|
|
629
|
-
name:
|
|
651
|
+
name: callReceiver ? `${callReceiver}.${call.name}` : call.name,
|
|
630
652
|
file: fileEntry.relativePath,
|
|
631
653
|
line: call.line,
|
|
632
|
-
receiver:
|
|
654
|
+
receiver: callReceiver,
|
|
633
655
|
isMethod: call.isMethod || false,
|
|
634
656
|
});
|
|
635
657
|
}
|
|
636
658
|
}
|
|
637
659
|
} else {
|
|
638
|
-
// Search symbols (functions, classes, methods, types)
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
660
|
+
// Search symbols (functions, classes, methods, types).
|
|
661
|
+
// Every indexed symbol kind must be reachable through some type
|
|
662
|
+
// filter (fix #237): 'private'/'property' are function defs
|
|
663
|
+
// (Python underscore methods, @property members — both already
|
|
664
|
+
// match --type method via isMethod); Java records and enums are
|
|
665
|
+
// classes, and records are types too.
|
|
666
|
+
const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod', 'abstract', 'private', 'property']);
|
|
667
|
+
const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait', 'record', 'enum']);
|
|
668
|
+
const typeTypes = new Set(['type', 'enum', 'interface', 'trait', 'record', 'namespace']);
|
|
642
669
|
const methodTypes = new Set(['method', 'constructor']);
|
|
643
670
|
|
|
644
671
|
for (const [symbolName, definitions] of index.symbols) {
|
|
@@ -698,7 +725,57 @@ function structuralSearch(index, options = {}) {
|
|
|
698
725
|
// Unused filter (expensive — last check)
|
|
699
726
|
if (unused) {
|
|
700
727
|
index.buildCalleeIndex();
|
|
701
|
-
|
|
728
|
+
// A name whose every call site is its own recursion
|
|
729
|
+
// has zero callers (fix #253c — the deadcode
|
|
730
|
+
// carve-out, applied here). Class-kind names are
|
|
731
|
+
// exempt: class liveness is reference-based
|
|
732
|
+
// (`Color.RED` keeps enum Color alive) and --unused
|
|
733
|
+
// has no text scan to see references — deadcode is
|
|
734
|
+
// the command with that safety net. Memoized per search.
|
|
735
|
+
if (!selfRecursiveMemo) selfRecursiveMemo = new Map();
|
|
736
|
+
const selfOnly = (n) => {
|
|
737
|
+
if (!selfRecursiveMemo.has(n)) {
|
|
738
|
+
const defs = index.symbols.get(n) || [];
|
|
739
|
+
const classLike = defs.some(d => classTypes.has(d.type) || d.type === 'namespace');
|
|
740
|
+
const { nameOnlySelfRecursive } = require('./deadcode');
|
|
741
|
+
selfRecursiveMemo.set(n, !classLike && nameOnlySelfRecursive(index, n));
|
|
742
|
+
}
|
|
743
|
+
return selfRecursiveMemo.get(n);
|
|
744
|
+
};
|
|
745
|
+
if (index.calleeIndex.has(symbolName) && !selfOnly(symbolName)) continue;
|
|
746
|
+
// Constructor members are invoked through the CLASS
|
|
747
|
+
// name (fix #239): `new Widget()` indexes under
|
|
748
|
+
// 'Widget', never under 'constructor'/'__init__'.
|
|
749
|
+
if (def.className &&
|
|
750
|
+
(def.type === 'constructor' || symbolName === 'constructor' || symbolName === '__init__') &&
|
|
751
|
+
index.calleeIndex.has(def.className) && !selfOnly(def.className)) continue;
|
|
752
|
+
// Runtime-invoked entry points are never unused (fix
|
|
753
|
+
// #234, campaign G2 ×4 languages: Go main/init, Java
|
|
754
|
+
// main, Rust main/#[test] all listed — the deadcode
|
|
755
|
+
// protection, applied here).
|
|
756
|
+
const langModule = fileEntry && getLanguageModule(fileEntry.language);
|
|
757
|
+
if (langModule?.isEntryPoint?.(def)) continue;
|
|
758
|
+
// A bare decorator application (@with_logging) invokes
|
|
759
|
+
// the decorator at import time but is recorded as a
|
|
760
|
+
// reference, not a call — a name decorating any def is
|
|
761
|
+
// used (G2-python BUG-1).
|
|
762
|
+
if (!unusedDecoratorNames) {
|
|
763
|
+
unusedDecoratorNames = new Set();
|
|
764
|
+
for (const [, dDefs] of index.symbols) {
|
|
765
|
+
for (const d of dDefs) {
|
|
766
|
+
for (const dec of d.decorators || []) {
|
|
767
|
+
const base = String(dec).replace(/^@/, '').split('(')[0].trim();
|
|
768
|
+
unusedDecoratorNames.add(base);
|
|
769
|
+
// Dotted decorators (@bus.subscribe) invoke the
|
|
770
|
+
// METHOD — protect the last segment too (fix
|
|
771
|
+
// #243, the deadcode false-dead's twin here).
|
|
772
|
+
const lastSeg = base.split('.').pop();
|
|
773
|
+
if (lastSeg && lastSeg !== base) unusedDecoratorNames.add(lastSeg);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (unusedDecoratorNames.has(symbolName)) continue;
|
|
702
779
|
}
|
|
703
780
|
|
|
704
781
|
// Merge decorators from both Python-style decorators and Java-style modifiers
|
|
@@ -720,7 +797,7 @@ function structuralSearch(index, options = {}) {
|
|
|
720
797
|
}
|
|
721
798
|
|
|
722
799
|
// Sort by file, then line
|
|
723
|
-
results.sort((a, b) => a.file
|
|
800
|
+
results.sort((a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line);
|
|
724
801
|
|
|
725
802
|
// Apply top limit
|
|
726
803
|
const total = results.length;
|
|
@@ -765,6 +842,9 @@ function example(index, name, options = {}) {
|
|
|
765
842
|
const usageResults = usages(index, name, {
|
|
766
843
|
codeOnly: true,
|
|
767
844
|
className: options.className,
|
|
845
|
+
// Scope examples to files matching --file (the flag was accepted but
|
|
846
|
+
// silently ignored before).
|
|
847
|
+
file: options.file,
|
|
768
848
|
exclude,
|
|
769
849
|
context: 5
|
|
770
850
|
});
|
|
@@ -778,6 +858,7 @@ function example(index, name, options = {}) {
|
|
|
778
858
|
const allUsages = usages(index, name, {
|
|
779
859
|
codeOnly: true,
|
|
780
860
|
className: options.className,
|
|
861
|
+
file: options.file,
|
|
781
862
|
exclude: [],
|
|
782
863
|
context: 0,
|
|
783
864
|
});
|
|
@@ -832,8 +913,10 @@ function example(index, name, options = {}) {
|
|
|
832
913
|
const best = scored[0];
|
|
833
914
|
|
|
834
915
|
// Default behavior: one best representative.
|
|
916
|
+
// Advisory command (v4 two-tier surface): scored example selection, not
|
|
917
|
+
// a verified "best" claim.
|
|
835
918
|
if (!options.diverse) {
|
|
836
|
-
return { best, totalCalls: calls.length };
|
|
919
|
+
return { advisory: 'scored-selection', best, totalCalls: calls.length };
|
|
837
920
|
}
|
|
838
921
|
|
|
839
922
|
// --diverse: cluster call sites by AST argument-shape and return one
|
|
@@ -869,7 +952,7 @@ function example(index, name, options = {}) {
|
|
|
869
952
|
|
|
870
953
|
// Order clusters: largest first, ties broken by shapeKey for determinism.
|
|
871
954
|
const clusterList = [...clusterMap.values()].sort((a, b) =>
|
|
872
|
-
(b.count - a.count) || a.shapeKey
|
|
955
|
+
(b.count - a.count) || codeUnitCompare(a.shapeKey, b.shapeKey)
|
|
873
956
|
);
|
|
874
957
|
|
|
875
958
|
// Pick representative per cluster: highest-scoring member; ties broken
|
|
@@ -877,7 +960,7 @@ function example(index, name, options = {}) {
|
|
|
877
960
|
const clusters = clusterList.slice(0, top).map(cluster => {
|
|
878
961
|
const sortedMembers = [...cluster.members].sort((a, b) =>
|
|
879
962
|
(b.score - a.score) ||
|
|
880
|
-
(a.relativePath || a.file || '')
|
|
963
|
+
codeUnitCompare((a.relativePath || a.file || ''), b.relativePath || b.file || '') ||
|
|
881
964
|
(a.line || 0) - (b.line || 0)
|
|
882
965
|
);
|
|
883
966
|
const rep = sortedMembers[0];
|
|
@@ -891,6 +974,7 @@ function example(index, name, options = {}) {
|
|
|
891
974
|
});
|
|
892
975
|
|
|
893
976
|
return {
|
|
977
|
+
advisory: 'scored-selection',
|
|
894
978
|
best,
|
|
895
979
|
totalCalls: calls.length,
|
|
896
980
|
clusters,
|
|
@@ -908,7 +992,7 @@ function example(index, name, options = {}) {
|
|
|
908
992
|
* @returns {Array} Matching type definitions
|
|
909
993
|
*/
|
|
910
994
|
function typedef(index, name, options = {}) {
|
|
911
|
-
const typeKinds = ['type', 'interface', 'enum', 'struct', 'trait', 'class', 'record'];
|
|
995
|
+
const typeKinds = ['type', 'interface', 'enum', 'struct', 'trait', 'class', 'record', 'namespace'];
|
|
912
996
|
const matches = find(index, name, options);
|
|
913
997
|
|
|
914
998
|
return matches.filter(m => typeKinds.includes(m.type)).map(m => ({
|
|
@@ -959,12 +1043,13 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
959
1043
|
if (isTestFile(fileEntry.relativePath, fileEntry.language)) {
|
|
960
1044
|
testFiles.push({ path: filePath, entry: fileEntry });
|
|
961
1045
|
} else if (fileEntry.language === 'rust') {
|
|
962
|
-
// Rust idiomatically puts tests in #[cfg(test)] modules inside
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
)
|
|
966
|
-
|
|
967
|
-
|
|
1046
|
+
// Rust idiomatically puts tests in #[cfg(test)] modules inside
|
|
1047
|
+
// source files. Such a file is test code ONLY within its inline
|
|
1048
|
+
// test ranges — matching its production lines claimed false
|
|
1049
|
+
// coverage (fix #244).
|
|
1050
|
+
const ranges = inlineTestRanges(fileEntry);
|
|
1051
|
+
if (ranges.length > 0) {
|
|
1052
|
+
testFiles.push({ path: filePath, entry: fileEntry, testRanges: ranges });
|
|
968
1053
|
}
|
|
969
1054
|
}
|
|
970
1055
|
}
|
|
@@ -973,24 +1058,62 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
973
1058
|
? path.basename(nameOrFile, path.extname(nameOrFile))
|
|
974
1059
|
: nameOrFile;
|
|
975
1060
|
|
|
1061
|
+
// File-path target: module specifiers are string literals, so the
|
|
1062
|
+
// basename usage scan is blind to import-linked tests in JS/TS
|
|
1063
|
+
// (fix #246). Resolve the target file(s) so each test file's resolved
|
|
1064
|
+
// imports can be checked against them.
|
|
1065
|
+
let targetRels = null;
|
|
1066
|
+
if (isFilePath) {
|
|
1067
|
+
const norm = nameOrFile.replace(/\\/g, '/');
|
|
1068
|
+
targetRels = new Set();
|
|
1069
|
+
for (const [, fe] of index.files) {
|
|
1070
|
+
const rp = fe.relativePath;
|
|
1071
|
+
if (rp === norm || rp.endsWith('/' + norm) || path.basename(rp) === norm) {
|
|
1072
|
+
targetRels.add(rp);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (targetRels.size === 0) targetRels = null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
976
1078
|
const className = options.className || null;
|
|
1079
|
+
// className scoping accepts the class plus its non-overriding
|
|
1080
|
+
// descendants — a subclass instance without its own override dispatches
|
|
1081
|
+
// to the target class's method (fix #246).
|
|
1082
|
+
const dispatchNames = className ? classDispatchNames(index, className, searchTerm) : null;
|
|
977
1083
|
// Pre-compile string-ref pattern (only regex left — used on single AST-identified lines)
|
|
978
1084
|
const strPattern = new RegExp("['\"`]" + escapeRegExp(searchTerm) + "['\"`]");
|
|
979
1085
|
|
|
980
1086
|
// --exclude filtering
|
|
981
1087
|
const excludeArr = options.exclude ? (Array.isArray(options.exclude) ? options.exclude : [options.exclude]) : [];
|
|
982
1088
|
|
|
983
|
-
for (const { path: testPath, entry } of testFiles) {
|
|
1089
|
+
for (const { path: testPath, entry, testRanges } of testFiles) {
|
|
984
1090
|
try {
|
|
985
1091
|
// Apply exclude filters
|
|
986
1092
|
if (excludeArr.length > 0 && !index.matchesFilters(entry.relativePath, { exclude: excludeArr })) continue;
|
|
987
1093
|
|
|
988
1094
|
const content = index._readFile(testPath);
|
|
989
1095
|
|
|
1096
|
+
// File-path target: import records resolving to the target file
|
|
1097
|
+
// link this test regardless of what the basename scan sees.
|
|
1098
|
+
let linkedRecords = null;
|
|
1099
|
+
if (targetRels && entry.moduleResolved) {
|
|
1100
|
+
const linkedModules = new Set();
|
|
1101
|
+
for (const [mod, rel] of Object.entries(entry.moduleResolved)) {
|
|
1102
|
+
if (targetRels.has(rel)) linkedModules.add(mod);
|
|
1103
|
+
}
|
|
1104
|
+
if (linkedModules.size > 0) {
|
|
1105
|
+
const { imports: recs } = extractImports(content, entry.language);
|
|
1106
|
+
const hits = (recs || []).filter(r => linkedModules.has(r.module));
|
|
1107
|
+
if (hits.length > 0) linkedRecords = hits;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
990
1111
|
// Fast pre-check: skip if searchTerm doesn't appear in file
|
|
991
|
-
if (!content.includes(searchTerm)) continue;
|
|
992
|
-
// className scoping: skip test files that
|
|
993
|
-
|
|
1112
|
+
if (!content.includes(searchTerm) && !linkedRecords) continue;
|
|
1113
|
+
// className scoping: skip test files that reference neither the
|
|
1114
|
+
// class nor any non-overriding subclass (fix #246 — a test
|
|
1115
|
+
// holding only a Circle exercises Shape.describe).
|
|
1116
|
+
if (className && ![...dispatchNames].some(dn => content.includes(dn))) continue;
|
|
994
1117
|
|
|
995
1118
|
// --file scoping: only include test files that import from the target source
|
|
996
1119
|
if (sourceFileFilter && !sourceFileFilter.has(testPath)) {
|
|
@@ -998,17 +1121,37 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
998
1121
|
}
|
|
999
1122
|
|
|
1000
1123
|
// AST-based usage detection
|
|
1001
|
-
const astUsages = index._getCachedUsages(testPath, searchTerm);
|
|
1002
|
-
if (astUsages ===
|
|
1003
|
-
|
|
1004
|
-
|
|
1124
|
+
const astUsages = index._getCachedUsages(testPath, searchTerm) || [];
|
|
1125
|
+
if (astUsages.length === 0 && !linkedRecords) continue;
|
|
1126
|
+
|
|
1127
|
+
// A test file that DEFINES searchTerm itself owns bare-name
|
|
1128
|
+
// calls of it (fix #246 — the #244 affectedTests rule, tests()
|
|
1129
|
+
// twin): such calls bind the test's own helper, not the project
|
|
1130
|
+
// symbol. Exception: inline-test-promoted files whose PRODUCTION
|
|
1131
|
+
// ranges define the symbol — the file defines AND tests it, so
|
|
1132
|
+
// bare calls in test ranges bind the project symbol.
|
|
1133
|
+
let localShadow = false;
|
|
1134
|
+
if (!isFilePath) {
|
|
1135
|
+
const localDefs = (entry.symbols || []).filter(s => s.name === searchTerm);
|
|
1136
|
+
if (localDefs.length > 0 &&
|
|
1137
|
+
!(entry.importBindings || []).some(b => b.name === searchTerm)) {
|
|
1138
|
+
localShadow = testRanges
|
|
1139
|
+
? localDefs.every(s => lineInRanges(s.startLine, testRanges))
|
|
1140
|
+
: true;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1005
1143
|
|
|
1006
1144
|
// Build instance variable → className map from getCachedCalls()
|
|
1007
1145
|
// for receiver-precise className scoping.
|
|
1008
1146
|
// e.g., `const svc = new B()` → svc maps to 'B'
|
|
1009
1147
|
let instanceTypeMap = null; // lazily built
|
|
1010
1148
|
if (className) {
|
|
1011
|
-
instanceTypeMap =
|
|
1149
|
+
instanceTypeMap = new Map();
|
|
1150
|
+
for (const dn of dispatchNames) {
|
|
1151
|
+
for (const [recv, cls] of _buildInstanceTypeMap(index, testPath, content, dn)) {
|
|
1152
|
+
if (!instanceTypeMap.has(recv)) instanceTypeMap.set(recv, cls);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1012
1155
|
}
|
|
1013
1156
|
|
|
1014
1157
|
const matches = [];
|
|
@@ -1016,6 +1159,12 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1016
1159
|
|
|
1017
1160
|
for (const usage of astUsages) {
|
|
1018
1161
|
if (usage.usageType === 'definition') continue; // not relevant in test files
|
|
1162
|
+
// Inline-test-promoted file: only lines inside the test
|
|
1163
|
+
// ranges are test code (fix #244).
|
|
1164
|
+
if (testRanges && !lineInRanges(usage.line, testRanges)) continue;
|
|
1165
|
+
// Local same-name shadow: bare usages bind the test file's
|
|
1166
|
+
// own definition (fix #246).
|
|
1167
|
+
if (localShadow && !usage.receiver && usage.usageType !== 'import') continue;
|
|
1019
1168
|
|
|
1020
1169
|
const lineKey = `${usage.line}:${usage.usageType}`;
|
|
1021
1170
|
if (seenLines.has(lineKey)) continue;
|
|
@@ -1035,7 +1184,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1035
1184
|
|
|
1036
1185
|
// className scoping for calls: check receiver
|
|
1037
1186
|
if (className && matchType === 'call') {
|
|
1038
|
-
if (!_receiverMatchesClass(usage,
|
|
1187
|
+
if (!_receiverMatchesClass(usage, dispatchNames, instanceTypeMap, lineContent, searchTerm)) continue;
|
|
1039
1188
|
}
|
|
1040
1189
|
|
|
1041
1190
|
// className scoping for references: require class-associated receiver
|
|
@@ -1044,8 +1193,8 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1044
1193
|
// association — skip them. Only keep member-access references
|
|
1045
1194
|
// where the receiver matches the target class.
|
|
1046
1195
|
if (!usage.receiver) continue;
|
|
1047
|
-
if (usage.receiver
|
|
1048
|
-
!(instanceTypeMap && instanceTypeMap.get(usage.receiver)
|
|
1196
|
+
if (!dispatchNames.has(usage.receiver) &&
|
|
1197
|
+
!(instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver)))) {
|
|
1049
1198
|
continue;
|
|
1050
1199
|
}
|
|
1051
1200
|
}
|
|
@@ -1057,8 +1206,47 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1057
1206
|
});
|
|
1058
1207
|
}
|
|
1059
1208
|
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1209
|
+
// File-path target: surface the linking import lines and usages
|
|
1210
|
+
// of each name imported from the target file (fix #246).
|
|
1211
|
+
if (linkedRecords) {
|
|
1212
|
+
for (const rec of linkedRecords) {
|
|
1213
|
+
if (rec.line != null && !(testRanges && !lineInRanges(rec.line, testRanges))) {
|
|
1214
|
+
const lineKey = `${rec.line}:import`;
|
|
1215
|
+
if (!seenLines.has(lineKey)) {
|
|
1216
|
+
seenLines.add(lineKey);
|
|
1217
|
+
matches.push({
|
|
1218
|
+
line: rec.line,
|
|
1219
|
+
content: (index.getLineContent(testPath, rec.line) || '').trim(),
|
|
1220
|
+
matchType: 'import'
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
for (const nm of rec.names || []) {
|
|
1225
|
+
if (nm === searchTerm) continue; // basename scan covered it
|
|
1226
|
+
const nmUsages = index._getCachedUsages(testPath, nm) || [];
|
|
1227
|
+
for (const u of nmUsages) {
|
|
1228
|
+
if (u.usageType === 'definition' || u.usageType === 'import') continue;
|
|
1229
|
+
if (testRanges && !lineInRanges(u.line, testRanges)) continue;
|
|
1230
|
+
const lineKey = `${u.line}:${u.usageType}:${nm}`;
|
|
1231
|
+
if (seenLines.has(lineKey)) continue;
|
|
1232
|
+
seenLines.add(lineKey);
|
|
1233
|
+
const lc = index.getLineContent(testPath, u.line);
|
|
1234
|
+
matches.push({
|
|
1235
|
+
line: u.line,
|
|
1236
|
+
content: lc.trim(),
|
|
1237
|
+
matchType: u.usageType === 'call' ? 'call' : 'reference'
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Language-aware test-case detection. Under a local same-name
|
|
1245
|
+
// shadow the term on a test line is the file's OWN helper —
|
|
1246
|
+
// only anchor test cases to matches that survived the shadow.
|
|
1247
|
+
if (!localShadow || matches.some(m => m.matchType !== 'import')) {
|
|
1248
|
+
_addTestCaseMatches(index, testPath, entry, searchTerm, className, instanceTypeMap, matches);
|
|
1249
|
+
}
|
|
1062
1250
|
|
|
1063
1251
|
// Deduplicate: if a line already has a 'call' or 'import', don't also add 'test-case'
|
|
1064
1252
|
let finalMatches = _deduplicateMatches(matches);
|
|
@@ -1192,6 +1380,25 @@ function _buildSourceFileImporters(index, defs) {
|
|
|
1192
1380
|
continue;
|
|
1193
1381
|
}
|
|
1194
1382
|
|
|
1383
|
+
// Java: same-package tests need no import statement either — a
|
|
1384
|
+
// test file in the same directory sees the source's package
|
|
1385
|
+
// members directly (fix #244: tests --file silently dropped
|
|
1386
|
+
// MathIntegrationTest.java while affected-tests found it).
|
|
1387
|
+
if (srcEntry.language === 'java' && testDir === srcDir) {
|
|
1388
|
+
importers.add(absPath);
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Rust: Cargo integration tests live in <crate-root>/tests/ and
|
|
1393
|
+
// import via the crate's PACKAGE name, which the import resolver
|
|
1394
|
+
// does not map to project files (fix #244).
|
|
1395
|
+
if (srcEntry.language === 'rust' &&
|
|
1396
|
+
path.basename(testDir) === 'tests' &&
|
|
1397
|
+
srcPath.startsWith(path.dirname(testDir) + path.sep)) {
|
|
1398
|
+
importers.add(absPath);
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1195
1402
|
// Rust: inline tests in the source file itself
|
|
1196
1403
|
if (srcPath === absPath) {
|
|
1197
1404
|
importers.add(absPath);
|
|
@@ -1292,25 +1499,30 @@ function _buildInstanceTypeMap(index, filePath, content, targetClassName) {
|
|
|
1292
1499
|
* @param {Map} instanceTypeMap - varName → className map
|
|
1293
1500
|
* @param {string} [lineContent] - Line content for fallback checks
|
|
1294
1501
|
*/
|
|
1295
|
-
function _receiverMatchesClass(usage,
|
|
1296
|
-
// Direct receiver: ClassName.method() or ClassName.staticMethod()
|
|
1297
|
-
|
|
1298
|
-
//
|
|
1299
|
-
if (usage.receiver &&
|
|
1502
|
+
function _receiverMatchesClass(usage, dispatchNames, instanceTypeMap, lineContent, searchTerm) {
|
|
1503
|
+
// Direct receiver: ClassName.method() or ClassName.staticMethod().
|
|
1504
|
+
// dispatchNames is the target class plus its non-overriding descendants
|
|
1505
|
+
// (fix #246) — an instance of such a subclass runs the target's method.
|
|
1506
|
+
if (usage.receiver && dispatchNames.has(usage.receiver)) return true;
|
|
1507
|
+
// Instance variable: check if receiver is bound to a dispatching class
|
|
1508
|
+
if (usage.receiver && instanceTypeMap && dispatchNames.has(instanceTypeMap.get(usage.receiver))) return true;
|
|
1300
1509
|
// Receiver is some other known identifier — doesn't match
|
|
1301
1510
|
if (usage.receiver) return false;
|
|
1302
|
-
// No receiver: bare function call. Only match if
|
|
1303
|
-
// receiver expression — e.g., `new B().save()`, `B().save()`,
|
|
1304
|
-
// Reject cases like `svc = B(); save()` where
|
|
1511
|
+
// No receiver: bare function call. Only match if a dispatching class is
|
|
1512
|
+
// the direct receiver expression — e.g., `new B().save()`, `B().save()`,
|
|
1513
|
+
// `B{}.save()`. Reject cases like `svc = B(); save()` where the class
|
|
1514
|
+
// name is elsewhere on the line.
|
|
1305
1515
|
if (lineContent && searchTerm) {
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1516
|
+
for (const className of dispatchNames) {
|
|
1517
|
+
// Check for chained call: ClassName followed by constructor/call then .methodName(
|
|
1518
|
+
const pat = new RegExp(
|
|
1519
|
+
'\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
|
|
1520
|
+
escapeRegExp(searchTerm) + '\\s*\\(|' +
|
|
1521
|
+
'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
|
|
1522
|
+
escapeRegExp(searchTerm) + '\\s*\\()'
|
|
1523
|
+
);
|
|
1524
|
+
if (pat.test(lineContent)) return true;
|
|
1525
|
+
}
|
|
1314
1526
|
}
|
|
1315
1527
|
return false;
|
|
1316
1528
|
}
|
|
@@ -1333,11 +1545,14 @@ function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className,
|
|
|
1333
1545
|
const calls = getCachedCalls(index, filePath);
|
|
1334
1546
|
if (!calls) return;
|
|
1335
1547
|
const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
|
|
1548
|
+
// Word-boundary match: `describe("untargeted zone")` is not a test
|
|
1549
|
+
// case for `target` (fix #246 — raw substring matched mid-word).
|
|
1550
|
+
const termPattern = new RegExp('(^|[^\\w$])' + escapeRegExp(searchTerm) + '([^\\w$]|$)');
|
|
1336
1551
|
for (const call of calls) {
|
|
1337
1552
|
if (!testFrameworkCalls.has(call.name)) continue;
|
|
1338
1553
|
const lineContent = index.getLineContent(filePath, call.line);
|
|
1339
1554
|
// Check if searchTerm appears in the description string on this line
|
|
1340
|
-
if (
|
|
1555
|
+
if (termPattern.test(lineContent) && !matchLines.has(call.line)) {
|
|
1341
1556
|
// className scoping: only add test-case if the test body has a
|
|
1342
1557
|
// class-scoped match (call or class-receiver reference) — not just
|
|
1343
1558
|
// any mention of className.
|