ucn 4.0.1 → 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.
Files changed (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +334 -316
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +67 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/doctor.js +13 -3
  21. package/core/output/endpoints.js +8 -3
  22. package/core/output/extraction.js +12 -1
  23. package/core/output/find.js +23 -9
  24. package/core/output/graph.js +32 -9
  25. package/core/output/refactoring.js +147 -49
  26. package/core/output/reporting.js +104 -5
  27. package/core/output/search.js +30 -3
  28. package/core/output/shared.js +31 -4
  29. package/core/output/tracing.js +22 -11
  30. package/core/parser.js +8 -6
  31. package/core/project.js +167 -7
  32. package/core/registry.js +20 -16
  33. package/core/reporting.js +220 -84
  34. package/core/search.js +270 -55
  35. package/core/shared.js +285 -1
  36. package/core/stacktrace.js +23 -1
  37. package/core/tracing.js +278 -36
  38. package/core/verify.js +352 -349
  39. package/languages/go.js +29 -17
  40. package/languages/index.js +56 -0
  41. package/languages/java.js +133 -16
  42. package/languages/javascript.js +215 -27
  43. package/languages/python.js +113 -47
  44. package/languages/rust.js +89 -26
  45. package/languages/utils.js +35 -7
  46. package/mcp/server.js +72 -31
  47. 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 || '').localeCompare(b.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 || '').localeCompare(b.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 (!call.receiver) continue;
625
- if (!matchesSubstring(call.receiver, receiver, options.caseSensitive)) continue;
646
+ if (!callReceiver) continue;
647
+ if (!matchesSubstring(callReceiver, receiver, options.caseSensitive)) continue;
626
648
  }
627
649
  results.push({
628
650
  kind: 'call',
629
- name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
651
+ name: callReceiver ? `${callReceiver}.${call.name}` : call.name,
630
652
  file: fileEntry.relativePath,
631
653
  line: call.line,
632
- receiver: call.receiver || null,
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
- const functionTypes = new Set(['function', 'constructor', 'method', 'arrow', 'static', 'classmethod', 'abstract']);
640
- const classTypes = new Set(['class', 'struct', 'interface', 'impl', 'trait']);
641
- const typeTypes = new Set(['type', 'enum', 'interface', 'trait']);
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
- if (index.calleeIndex.has(symbolName)) continue;
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.localeCompare(b.file) || a.line - b.line);
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.localeCompare(b.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 || '').localeCompare(b.relativePath || b.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 source files.
963
- const hasInlineTests = fileEntry.symbols?.some(s =>
964
- s.modifiers?.includes('test')
965
- );
966
- if (hasInlineTests) {
967
- testFiles.push({ path: filePath, entry: fileEntry });
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 don't reference the class at all
993
- if (className && !content.includes(className)) continue;
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 === null) continue; // no parser available — skip
1003
-
1004
- if (astUsages.length === 0) continue;
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 = _buildInstanceTypeMap(index, testPath, content, className);
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, className, instanceTypeMap, lineContent, searchTerm)) continue;
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 !== className &&
1048
- !(instanceTypeMap && instanceTypeMap.get(usage.receiver) === className)) {
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
- // Language-aware test-case detection
1061
- _addTestCaseMatches(index, testPath, entry, searchTerm, className, instanceTypeMap, matches);
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, className, instanceTypeMap, lineContent, searchTerm) {
1296
- // Direct receiver: ClassName.method() or ClassName.staticMethod()
1297
- if (usage.receiver === className) return true;
1298
- // Instance variable: check if receiver is bound to the target class
1299
- if (usage.receiver && instanceTypeMap && instanceTypeMap.get(usage.receiver) === className) return true;
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 className is the direct
1303
- // receiver expression — e.g., `new B().save()`, `B().save()`, `B{}.save()`.
1304
- // Reject cases like `svc = B(); save()` where className is elsewhere on the line.
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
- // Check for chained call: ClassName followed by constructor/call then .methodName(
1307
- const pat = new RegExp(
1308
- '\\b' + escapeRegExp(className) + '\\s*(?:(?:\\([^)]*\\)|\\{[^}]*\\})\\s*\\.\\s*' +
1309
- escapeRegExp(searchTerm) + '\\s*\\(|' +
1310
- 'new\\s+' + escapeRegExp(className) + '\\s*\\([^)]*\\)\\s*\\.\\s*' +
1311
- escapeRegExp(searchTerm) + '\\s*\\()'
1312
- );
1313
- if (pat.test(lineContent)) return true;
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 (lineContent.includes(searchTerm) && !matchLines.has(call.line)) {
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.