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.
Files changed (46) 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 +322 -304
  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 +52 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/endpoints.js +8 -3
  21. package/core/output/extraction.js +12 -1
  22. package/core/output/find.js +23 -9
  23. package/core/output/graph.js +32 -9
  24. package/core/output/refactoring.js +147 -49
  25. package/core/output/reporting.js +104 -5
  26. package/core/output/search.js +30 -3
  27. package/core/output/shared.js +31 -4
  28. package/core/output/tracing.js +22 -11
  29. package/core/parser.js +8 -6
  30. package/core/project.js +167 -7
  31. package/core/registry.js +20 -16
  32. package/core/reporting.js +131 -13
  33. package/core/search.js +270 -55
  34. package/core/shared.js +240 -1
  35. package/core/stacktrace.js +23 -1
  36. package/core/tracing.js +278 -36
  37. package/core/verify.js +352 -349
  38. package/languages/go.js +29 -17
  39. package/languages/index.js +56 -0
  40. package/languages/java.js +133 -16
  41. package/languages/javascript.js +215 -27
  42. package/languages/python.js +113 -47
  43. package/languages/rust.js +89 -26
  44. package/languages/utils.js +35 -7
  45. package/mcp/server.js +69 -30
  46. package/package.json +4 -1
package/core/callers.js CHANGED
@@ -10,7 +10,7 @@ const path = require('path');
10
10
  const crypto = require('crypto');
11
11
  const { detectLanguage, getParser, getLanguageModule, langTraits } = require('../languages');
12
12
  const { isTestFile } = require('./discovery');
13
- const { NON_CALLABLE_TYPES, isOverrideMarked } = require('./shared');
13
+ const { NON_CALLABLE_TYPES, isOverrideMarked, codeUnitCompare, isTestPath } = require('./shared');
14
14
  const { scoreEdge, tierForResolution, TIER } = require('./confidence');
15
15
  const { findGoModule } = require('./imports');
16
16
 
@@ -460,6 +460,17 @@ function findCallers(index, name, options = {}) {
460
460
  }
461
461
  }
462
462
 
463
+ // Intra-class constructor mechanics are never caller edges
464
+ // (fix #238, jdtls-measured): an enum CONSTANT is part of the
465
+ // enum's own declaration (JsonToken's 10 constants confirmed
466
+ // 10 self-callers), and a `this(...)` delegation names the
467
+ // ENCLOSING class by construction. Both stay in the calls
468
+ // cache for deadcode/--unused reachability; `super(...)`
469
+ // names a DIFFERENT class and keeps its caller edge.
470
+ if (call.enumConstant || call.ctorDelegation === 'this') {
471
+ continue;
472
+ }
473
+
463
474
  // For potential callbacks (function passed as arg), validate against symbol table
464
475
  // and skip complex binding resolution — just check the name exists
465
476
  if (call.isPotentialCallback) {
@@ -623,7 +634,8 @@ function findCallers(index, name, options = {}) {
623
634
  const cbSameFile = cbTargetFiles.has(filePath);
624
635
  const cbSamePackage = !cbSameFile &&
625
636
  langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
626
- cbTargetDefs.some(d => d.file && path.dirname(d.file) === path.dirname(filePath));
637
+ cbTargetDefs.some(d => d.file &&
638
+ _sameNominalPackageDir(path.dirname(d.file), path.dirname(filePath), fileEntry.language));
627
639
  let cbImportLink = false;
628
640
  if (!cbSameFile && !cbSamePackage) {
629
641
  const cbImports = index.importGraph.get(filePath);
@@ -701,10 +713,14 @@ function findCallers(index, name, options = {}) {
701
713
  // e.g., analyzer.analyze_instrument() should NOT resolve to a local
702
714
  // standalone function def `analyze_instrument` — they're different symbols.
703
715
  // Also skip for Go package-qualified calls (isMethod:false but has receiver like 'cli')
704
- const selfReceivers = new Set(['self', 'cls', 'this', 'super']);
716
+ // `super` skips too (fix #238): a super call targets the PARENT
717
+ // class's member by definition — the local binding of the name
718
+ // is the enclosing class's own member, provably the wrong def
719
+ // (super(config) bound to the subclass's OWN constructor).
720
+ // Super records resolve only through the parent-chain walk.
721
+ const selfReceivers = new Set(['self', 'cls', 'this']);
705
722
  const skipLocalBinding = call.receiver && !selfReceivers.has(call.receiver);
706
723
  if (!bindingId && !skipLocalBinding) {
707
- let bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
708
724
  // A bare call cannot bind to a METHOD def where bare names
709
725
  // never reach methods (fix #220, cobra-measured): Go's
710
726
  // func (c *Command) MarkFlagDirname and func MarkFlagDirname
@@ -717,15 +733,24 @@ function findCallers(index, name, options = {}) {
717
733
  // cells.cell_len, not Text.cell_len) and a JS class
718
734
  // member is not a file binding either — the structural
719
735
  // dispatch gates can't own this case because a matched
720
- // bindingId bypasses them.
721
- if (!call.isMethod && bindings.length > 0 &&
722
- !langTraits(fileEntry.language)?.bareCallReachesMethods) {
723
- const defsOfName = index.symbols.get(call.name) || [];
724
- bindings = bindings.filter(b => {
725
- const sym = defsOfName.find(s => s.file === filePath && s.startLine === b.startLine);
736
+ // bindingId bypasses them. Fix #229: the filter applies
737
+ // per source file — sibling-file bindings from Go's
738
+ // package-scope concat below carry interface members and
739
+ // methods too (`type Notifier interface { Notify() }`
740
+ // next to `func Notify()` stole the bindingId and
741
+ // excluded the true caller as other-definition).
742
+ const bareNeverMethod = !call.isMethod &&
743
+ !langTraits(fileEntry.language)?.bareCallReachesMethods;
744
+ const defsOfName = bareNeverMethod ? (index.symbols.get(call.name) || []) : null;
745
+ const dropMethodBindings = (list, file) => {
746
+ if (!bareNeverMethod || list.length === 0) return list;
747
+ return list.filter(b => {
748
+ const sym = defsOfName.find(s => s.file === file && s.startLine === b.startLine);
726
749
  return !(sym && (sym.className || sym.receiver));
727
750
  });
728
- }
751
+ };
752
+ let bindings = dropMethodBindings(
753
+ (fileEntry.bindings || []).filter(b => b.name === call.name), filePath);
729
754
  // For Go, also check sibling files in same directory (same package scope)
730
755
  if (bindings.length === 0 && langTraits(fileEntry.language)?.packageScope === 'directory') {
731
756
  const dir = path.dirname(filePath);
@@ -734,7 +759,8 @@ function findCallers(index, name, options = {}) {
734
759
  if (fp !== filePath) {
735
760
  const fe = index.files.get(fp);
736
761
  if (fe) {
737
- const sibling = (fe.bindings || []).filter(b => b.name === call.name);
762
+ const sibling = dropMethodBindings(
763
+ (fe.bindings || []).filter(b => b.name === call.name), fp);
738
764
  bindings = bindings.concat(sibling);
739
765
  }
740
766
  }
@@ -846,7 +872,7 @@ function findCallers(index, name, options = {}) {
846
872
  const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
847
873
  if (!callerSymbol?.className) {
848
874
  // Can't resolve — include only if includeMethods requested
849
- if (!options.includeMethods) {
875
+ if (options.collectAccount || !options.includeMethods) {
850
876
  routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
851
877
  continue;
852
878
  }
@@ -876,16 +902,23 @@ function findCallers(index, name, options = {}) {
876
902
  continue;
877
903
  }
878
904
  resolvedBySameClass = true;
879
- } else if (!options.includeMethods) {
905
+ } else if (options.collectAccount || !options.includeMethods) {
880
906
  routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
881
907
  continue;
882
908
  }
883
909
  }
884
- } else if (['self', 'cls', 'this', 'super'].includes(call.receiver)) {
885
- // self/this/super.method() resolve to same-class or parent method
910
+ } else if (['self', 'cls', 'this', 'super'].includes(call.receiver) ||
911
+ (call.receiver === 'Self' && fileEntry.language === 'rust')) {
912
+ // self/this/super.method() — resolve to same-class or parent method.
913
+ // Rust `Self::method()` (fix #232) is the path-call same-class form:
914
+ // Self IS the enclosing impl's type, so the #202b pinning check
915
+ // confirms it for the impl's class and excludes it for a pinned
916
+ // sibling — it must never reach the uppercase path-receiver
917
+ // discipline below (which excluded it as path-type-mismatch
918
+ // whenever the method name had several project-wide owners).
886
919
  const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
887
920
  if (!callerSymbol?.className) {
888
- if (!options.includeMethods) {
921
+ if (options.collectAccount || !options.includeMethods) {
889
922
  routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
890
923
  continue;
891
924
  }
@@ -937,10 +970,16 @@ function findCallers(index, name, options = {}) {
937
970
  fileEntry.language === 'python') {
938
971
  const tDefs = options.targetDefinitions || definitions;
939
972
  const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
973
+ // `super` dispatches statically UP the chain — the
974
+ // ancestor/descendant dynamic-dispatch exemptions
975
+ // are inverted for it: a super call can never bind
976
+ // a def below the matched parent (fix #238).
977
+ const superSkipsExemptions = call.receiver === 'super';
940
978
  if (!targetClasses.has(matchedClass) &&
941
- !_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
942
- !(fileEntry.language === 'python' &&
943
- _shareProjectDescendant(index, matchedClass, targetClasses))) {
979
+ (superSkipsExemptions ||
980
+ (!_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
981
+ !(fileEntry.language === 'python' &&
982
+ _shareProjectDescendant(index, matchedClass, targetClasses))))) {
944
983
  recordExcluded(filePath, call.line, 'other-definition');
945
984
  continue;
946
985
  }
@@ -973,15 +1012,20 @@ function findCallers(index, name, options = {}) {
973
1012
  // Legacy keeps confirming (drop-vs-route asymmetry).
974
1013
  const tDefs = options.targetDefinitions || definitions;
975
1014
  const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
1015
+ // super: static upward dispatch — no ancestor/
1016
+ // descendant exemptions (fix #238), routed visible
1017
+ // like the rest of the #213 branch (TS declare-class
1018
+ // merging can hide the true parent edge).
976
1019
  if (!targetClasses.has(matchedClass) &&
977
- !_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
978
- !_shareProjectDescendant(index, matchedClass, targetClasses)) {
1020
+ (call.receiver === 'super' ||
1021
+ (!_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
1022
+ !_shareProjectDescendant(index, matchedClass, targetClasses)))) {
979
1023
  routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs);
980
1024
  continue;
981
1025
  }
982
1026
  }
983
1027
  resolvedBySameClass = true;
984
- } else if (!options.includeMethods) {
1028
+ } else if (options.collectAccount || !options.includeMethods) {
985
1029
  routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
986
1030
  continue;
987
1031
  }
@@ -1037,8 +1081,23 @@ function findCallers(index, name, options = {}) {
1037
1081
  fieldDispatchType = _declaredFieldInterfaceType(index, fieldHopRootType, call.receiverField, fileEntry.language);
1038
1082
  }
1039
1083
 
1040
- // Skip uncertain calls unless resolved by same-class matching or explicitly requested
1041
- if (isUncertain && !resolvedBySameClass && !options.includeUncertain) {
1084
+ // Skip uncertain calls unless resolved by same-class matching or
1085
+ // explicitly requested. A declared-field hop type (fix #219) or
1086
+ // interface-field dispatch type IS receiver evidence — those
1087
+ // calls flow to the receiver-class disambiguation below, which
1088
+ // confirms (receiver-hint), excludes (mismatch), or attributes
1089
+ // possible-dispatch. Before fix #229 this gate fired first
1090
+ // whenever the method name had no same-file binding, so the
1091
+ // tier of `this.logger.info()` depended on file LAYOUT (same
1092
+ // file confirmed, cross-file routed method-no-evidence).
1093
+ // A parser-typed receiver defers too (fix #232): `b?.ping()`
1094
+ // carries the optionality `uncertain` flag AND receiverType 'A'
1095
+ // — the ?. is a null guard, not evidence uncertainty, so the
1096
+ // record gets plain-call physics (validated match confirms,
1097
+ // trusted mismatch excludes). Bare `foo?.()` has no receiver
1098
+ // evidence and keeps routing here.
1099
+ if (isUncertain && !resolvedBySameClass && !options.includeUncertain &&
1100
+ !fieldHopType && !fieldDispatchType && !call.receiverType) {
1042
1101
  if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
1043
1102
  routeUnverified(filePath, fileEntry, call,
1044
1103
  call.isMethod ? 'method-no-evidence' : 'ambiguous-binding', calledAs);
@@ -1239,13 +1298,32 @@ function findCallers(index, name, options = {}) {
1239
1298
  recordExcluded(filePath, call.line, 'method-kind-mismatch');
1240
1299
  continue;
1241
1300
  }
1242
- if (!call.isMethod && targetHasClass) {
1243
- // Non-method call but target is a class method — skip
1301
+ if (!call.isMethod && targetHasClass &&
1302
+ !(!call.receiver && langTraits(fileEntry.language)?.bareCallReachesMethods)) {
1303
+ // Non-method call but target is a class method — skip.
1304
+ // Bare-call direction honors bareCallReachesMethods
1305
+ // (fix #229, same as the callback-path twin): a Java
1306
+ // bare call CAN denote a method — static imports
1307
+ // (`import static app.U.twice; twice(21)`) and
1308
+ // inherited implicit this-calls. Package-qualified
1309
+ // calls (receiver set) keep the exclusion.
1244
1310
  recordExcluded(filePath, call.line, 'method-kind-mismatch');
1245
1311
  continue;
1246
1312
  }
1247
1313
  }
1248
1314
 
1315
+ // From-import submodule receiver (fix #224): `from . import
1316
+ // jobs` + `jobs.submit(...)` — the receiver resolves to a
1317
+ // project module FILE (graph-build composed the submodule
1318
+ // specifier), so it behaves as a module receiver below.
1319
+ // Confirm/route-enabling only: the class-method exclusion
1320
+ // branch keeps its parser-marked receiverIsModule condition
1321
+ // (a rare package attribute/submodule name collision must not
1322
+ // become exclusion evidence).
1323
+ const recvSubmoduleRel = (!call.receiverIsModule && call.isMethod && call.receiver &&
1324
+ langTraits(fileEntry.language)?.typeSystem === 'structural')
1325
+ ? _submoduleReceiverModule(index, fileEntry, call.receiver) : null;
1326
+
1249
1327
  // Module receiver: httpx.get() / ns.helper() dispatches to a
1250
1328
  // module export — it can never be a CLASS METHOD call. Applies
1251
1329
  // only when every target is a class method; standalone-function
@@ -1275,7 +1353,8 @@ function findCallers(index, name, options = {}) {
1275
1353
  // target (directly or one re-export hop) → visible, not
1276
1354
  // excluded (deep barrel chains exceed the hop budget);
1277
1355
  // unresolved-but-project-looking → visible (resolver gap).
1278
- if (!bindingId && !resolvedBySameClass && call.isMethod && call.receiverIsModule &&
1356
+ if (!bindingId && !resolvedBySameClass && call.isMethod &&
1357
+ (call.receiverIsModule || recvSubmoduleRel) &&
1279
1358
  call.receiver && langTraits(fileEntry.language)?.typeSystem === 'structural' &&
1280
1359
  (fileEntry.importBindings || []).length > 0) {
1281
1360
  const recvBindings = fileEntry.importBindings.filter(b => b.name === call.receiver);
@@ -1284,7 +1363,8 @@ function findCallers(index, name, options = {}) {
1284
1363
  let reaches = false;
1285
1364
  let projectish = false;
1286
1365
  for (const b of recvBindings) {
1287
- const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
1366
+ const rel = (fileEntry.moduleResolved && fileEntry.moduleResolved[b.module]) ||
1367
+ recvSubmoduleRel;
1288
1368
  if (!rel) {
1289
1369
  const mod = String(b.module);
1290
1370
  const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
@@ -1448,15 +1528,17 @@ function findCallers(index, name, options = {}) {
1448
1528
  const targetTypes = dispatchTargetTypes(targetDefs);
1449
1529
  if (targetTypes.size > 0) {
1450
1530
  // Use inferred receiverType when available (Go/Java/Rust parameter type tracking)
1451
- // Generic type parameters by convention are not type
1452
- // identity in EITHER direction (fix #220): a receiver
1453
- // typed 'T' neither validates against a blanket-impl
1454
- // target named 'T' nor excludes a concrete target —
1455
- // T may be instantiated with anything. A short-caps
1456
- // name with a real project type def is a class.
1531
+ // Generic type parameters are not type identity in
1532
+ // EITHER direction (fix #220, made precise by #229):
1533
+ // a receiver typed 'T' or 'TStore' neither validates
1534
+ // against a blanket-impl target nor excludes a
1535
+ // concrete one — T may be instantiated with anything,
1536
+ // including the target class. Declared-in-enclosing-
1537
+ // scope check first (`fn f<TStore: Wipe>(t: &TStore)`
1538
+ // shadows even a same-named project type), 1-2-char
1539
+ // ALL-CAPS convention as fallback.
1457
1540
  let knownType = call.receiverType || fieldHopType;
1458
- if (knownType && /^[A-Z][A-Z0-9]?$/.test(knownType) &&
1459
- !(index.symbols.get(knownType) || []).some(d => IDENTITY_TYPE_KINDS.has(d.type))) knownType = null;
1541
+ if (knownType && _isGenericParamReceiverType(index, filePath, call.line, knownType)) knownType = null;
1460
1542
  if (knownType) {
1461
1543
  const viaFieldHop = !call.receiverType; // declared-field hop (fix #202)
1462
1544
  // Exclusion requires an UNRELATED type. A receiver typed
@@ -1575,7 +1657,14 @@ function findCallers(index, name, options = {}) {
1575
1657
  localTypeCache.set(cacheKey, localTypes);
1576
1658
  }
1577
1659
  if (localTypes) {
1578
- const inferredType = localTypes.get(call.receiver);
1660
+ // The inference map re-derives receiver types
1661
+ // from the calls cache — the same generic-param
1662
+ // guard applies (fix #229: `t.wipe()` on
1663
+ // `t: &T` used to re-infer 'T' here and
1664
+ // exclude after the typed branch nulled it).
1665
+ let inferredType = localTypes.get(call.receiver);
1666
+ if (inferredType && _isGenericParamReceiverType(
1667
+ index, filePath, call.line, inferredType)) inferredType = null;
1579
1668
  if (inferredType) {
1580
1669
  if (targetTypes.has(inferredType)) {
1581
1670
  // Identity discipline (fix #206) — same as the
@@ -1661,8 +1750,11 @@ function findCallers(index, name, options = {}) {
1661
1750
  }
1662
1751
  if (!matchesTarget) {
1663
1752
  // Rust/Go path calls (Type::method() / pkg.Method()): receiver IS the type name
1664
- // If it doesn't match target, it's definitely a different type — filter it
1665
- if (call.isPathCall && /^[A-Z]/.test(call.receiver)) {
1753
+ // If it doesn't match target, it's definitely a different type — filter it.
1754
+ // `Self` exempt (fix #232, the #222(2) rule): Self names the
1755
+ // enclosing impl's type, not a foreign one — same-class
1756
+ // resolution above owns it.
1757
+ if (call.isPathCall && /^[A-Z]/.test(call.receiver) && call.receiver !== 'Self') {
1666
1758
  isUncertain = true;
1667
1759
  typeMismatch = true;
1668
1760
  if (collectAccount) {
@@ -1724,12 +1816,35 @@ function findCallers(index, name, options = {}) {
1724
1816
  // syntactic args is not evidence in Go — only too-many prunes.
1725
1817
  // Binding/same-class evidence outranks the count (then a
1726
1818
  // mismatch more likely means our param parse is wrong).
1819
+ let arityNoFit = false;
1727
1820
  if (collectAccount && !bindingId && !resolvedBySameClass &&
1728
1821
  call.argCount != null && !call.argSpread &&
1729
1822
  langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
1730
1823
  !_callArityCompatible(call, targetDefs, fileEntry.language)) {
1731
- recordExcluded(filePath, call.line, 'arity-mismatch');
1732
- continue;
1824
+ // Fits-elsewhere carve-out (fix #229): "binds a different
1825
+ // symbol" needs a different symbol the call COULD bind.
1826
+ // When the argument count also fits no OTHER callable def
1827
+ // of the name project-wide, a wrong-arity call at an
1828
+ // EVIDENCE-BACKED site is a BROKEN CALL SITE (or a parse
1829
+ // gap) — the thing verify/diff-impact exist to surface
1830
+ // after a signature change. Marked and allowed to flow:
1831
+ // receiver/type-qualified evidence confirms it into
1832
+ // verify's arg-check (mismatch band). The exclusion stays
1833
+ // when a sibling overload or another definition fits the
1834
+ // count (jdtls-measured), and the dispatch gate below
1835
+ // re-excludes marked calls that would only confirm via the
1836
+ // single-owner rule — that rule presumes no other
1837
+ // candidate, which the wrong arity disproves toward
1838
+ // EXTERNAL code (Arrays.asList(1,2,3) vs a project 0-param
1839
+ // asList: unique project ownership is not evidence here).
1840
+ const pinnedKeys = new Set(targetDefs.map(d => `${d.file}:${d.startLine}`));
1841
+ const otherDefs = definitions.filter(d =>
1842
+ !NON_CALLABLE_TYPES.has(d.type) && !pinnedKeys.has(`${d.file}:${d.startLine}`));
1843
+ if (otherDefs.length > 0 && _callArityCompatible(call, otherDefs, fileEntry.language)) {
1844
+ recordExcluded(filePath, call.line, 'arity-mismatch');
1845
+ continue;
1846
+ }
1847
+ arityNoFit = true;
1733
1848
  }
1734
1849
 
1735
1850
  // Overload discipline (fix #205, languages with arity/type
@@ -1797,7 +1912,8 @@ function findCallers(index, name, options = {}) {
1797
1912
  // evidence, not a bare name match.
1798
1913
  const hasSamePackageEvidence = !hasImportLink &&
1799
1914
  langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
1800
- targetDefs2.some(d => d.file && path.dirname(d.file) === path.dirname(filePath));
1915
+ targetDefs2.some(d => d.file &&
1916
+ _sameNominalPackageDir(path.dirname(d.file), path.dirname(filePath), fileEntry.language));
1801
1917
 
1802
1918
  // Possible-dispatch tiering (nominal languages, contract surface
1803
1919
  // only): methodCallInclusion='auto' confirms method calls with
@@ -1975,6 +2091,75 @@ function findCallers(index, name, options = {}) {
1975
2091
  });
1976
2092
  continue;
1977
2093
  }
2094
+ // Wrong-arity call that fits no project def (fix #229
2095
+ // carve-out marker): the single-owner rule presumes no
2096
+ // other candidate exists, but the arity disproves the
2097
+ // project-side match — the call binds EXTERNAL code
2098
+ // (Arrays.asList(1,2,3)). Only receiver/type-qualified
2099
+ // evidence may carry a wrong-arity call to the
2100
+ // mismatch band; ownership alone re-excludes here.
2101
+ if (arityNoFit) {
2102
+ recordExcluded(filePath, call.line, 'arity-mismatch');
2103
+ continue;
2104
+ }
2105
+ }
2106
+ }
2107
+
2108
+ // Bare-call name ownership, Java (fix #229): where
2109
+ // bareCallReachesMethods a bare call CAN denote a method —
2110
+ // via a static import or an inherited implicit this-call —
2111
+ // but file-level scope evidence cannot CONFIRM one: a bare
2112
+ // name in Java resolves through the class scope (own +
2113
+ // inherited members) or a static import, never through
2114
+ // package-mate visibility. Confirmed tier: the enclosing
2115
+ // class is a dispatch-capable receiver type for the target
2116
+ // (inherited this-call), or a static import of the name /
2117
+ // wildcard static import resolves to a target file
2118
+ // (compiler-grade name evidence, the #217 rule). A static
2119
+ // import resolving to a DIFFERENT project file owns the name
2120
+ // (other-definition-import); everything else routes VISIBLE
2121
+ // (external static imports and unresolved ancestry are not
2122
+ // exclusion evidence — JLS scope nesting also lets inherited
2123
+ // members shadow imports, so 'unknown' never excludes).
2124
+ if (collectAccount && !call.isMethod && !call.receiver && !bindingId &&
2125
+ !resolvedBySameClass &&
2126
+ langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
2127
+ langTraits(fileEntry.language)?.bareCallReachesMethods &&
2128
+ targetDefs2.length > 0 && targetDefs2.every(d =>
2129
+ !NON_CALLABLE_TYPES.has(d.type) && (d.className || d.receiver))) {
2130
+ const tTypes = dispatchTargetTypes(targetDefs2);
2131
+ const enclosingClass = callerSymbol && callerSymbol.className;
2132
+ if (!(enclosingClass && tTypes.has(enclosingClass))) {
2133
+ const targetFiles = new Set(targetDefs2.map(d => d.file).filter(Boolean));
2134
+ let verdict = null; // 'target' | 'other' | 'unknown'
2135
+ for (const im of (fileEntry.importBindings || [])) {
2136
+ const mod = String(im.module || '');
2137
+ if (im.name === call.name && mod.endsWith('.' + call.name)) {
2138
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[mod];
2139
+ verdict = rel
2140
+ ? (targetFiles.has(path.join(index.root, rel)) ? 'target' : 'other')
2141
+ : 'unknown';
2142
+ break;
2143
+ }
2144
+ }
2145
+ if (verdict === null && fileEntry.moduleResolved) {
2146
+ for (const [mod, rel] of Object.entries(fileEntry.moduleResolved)) {
2147
+ if (mod.endsWith('.*') && targetFiles.has(path.join(index.root, rel))) {
2148
+ verdict = 'target';
2149
+ break;
2150
+ }
2151
+ }
2152
+ }
2153
+ if (verdict === 'other') {
2154
+ recordExcluded(filePath, call.line, 'other-definition-import');
2155
+ continue;
2156
+ }
2157
+ if (verdict !== 'target') {
2158
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
2159
+ dispatchCandidates: methodOwnerKeys().size,
2160
+ });
2161
+ continue;
2162
+ }
1978
2163
  }
1979
2164
  }
1980
2165
 
@@ -2046,8 +2231,9 @@ function findCallers(index, name, options = {}) {
2046
2231
  // Module-qualified calls (z.string(), ns.helper()) are
2047
2232
  // exempt: the module IS name-level evidence, and the
2048
2233
  // module-ownership block above already routed the ones
2049
- // whose module doesn't reach the target.
2050
- if (call.isMethod && !call.receiverIsModule) {
2234
+ // whose module doesn't reach the target. Submodule
2235
+ // receivers (fix #224) are module receivers too.
2236
+ if (call.isMethod && !call.receiverIsModule && !recvSubmoduleRel) {
2051
2237
  const tTypes = dispatchTargetTypes(targetDefs2);
2052
2238
  const typeQualifiedReceiver = !!(call.receiver && tTypes.has(call.receiver));
2053
2239
  // External-producer receiver (fix #222, httpx-measured
@@ -2064,6 +2250,27 @@ function findCallers(index, name, options = {}) {
2064
2250
  });
2065
2251
  continue;
2066
2252
  }
2253
+ // Builtin-global receiver (fix #232, campaign-measured:
2254
+ // console.log() confirmed scope-match against a private
2255
+ // Logger.log — its single project-wide owner). console/
2256
+ // window/process/... name HOST objects, so unique
2257
+ // project ownership is not identity evidence for the
2258
+ // receiver. Shadowing keeps normal physics: a project
2259
+ // def, file binding, or parser-typed receiver of the
2260
+ // name wins. Demote-only (`window.fn = projectFn`
2261
+ // attachment is a real pattern — #222(4) name-knowledge
2262
+ // rule): visible possible-dispatch, never excluded.
2263
+ if (!typeQualifiedReceiver && call.receiver && !call.receiverType &&
2264
+ ['javascript', 'typescript', 'tsx', 'html'].includes(fileEntry.language) &&
2265
+ JS_GLOBAL_RECEIVERS.has(call.receiver) &&
2266
+ (index.symbols.get(call.receiver) || []).length === 0 &&
2267
+ !fileEntry.bindings?.some(b => b.name === call.receiver)) {
2268
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
2269
+ dispatchVia: `${call.receiver} — builtin global`,
2270
+ externalContract: true,
2271
+ });
2272
+ continue;
2273
+ }
2067
2274
  // A method call cannot denote a standalone function
2068
2275
  // (fix #218, rich-measured: `console.print(...)`
2069
2276
  // confirmed scope-match against module-level print):
@@ -2072,12 +2279,28 @@ function findCallers(index, name, options = {}) {
2072
2279
  // receivers are excluded above (#198); untyped ones
2073
2280
  // route visible. Module receivers stay exempt
2074
2281
  // (rich.print(...) IS the module function).
2282
+ // EXCEPTION (fix #254, W8 BUG-4 — verify's BUG-BX rule
2283
+ // in the engine, range-based): a receiver naming a
2284
+ // namespace/module block that CONTAINS the pinned def
2285
+ // is a qualified function call — containment is
2286
+ // identity evidence, and the #215 scope check ties
2287
+ // the receiver to the containing file. Falls through
2288
+ // to confirm with its import/scope evidence.
2075
2289
  if (!typeQualifiedReceiver && targetDefs2.length > 0 &&
2076
2290
  targetDefs2.every(d => !d.className && !d.receiver)) {
2077
- routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
2078
- dispatchCandidates: methodOwnerKeys().size,
2079
- });
2080
- continue;
2291
+ if (!_namespaceContainedDef(index, fileEntry, filePath,
2292
+ call.receiver, call.name, targetDefs2)) {
2293
+ // Candidates here are the standalone defs the call
2294
+ // MIGHT reach through an unmodeled module receiver
2295
+ // (dynamic import) — methodOwnerKeys counts method
2296
+ // owners only and reported a contradictory 0
2297
+ // (fix #230).
2298
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
2299
+ dispatchCandidates: methodOwnerKeys().size ||
2300
+ targetDefs2.filter(d => !NON_CALLABLE_TYPES.has(d.type)).length,
2301
+ });
2302
+ continue;
2303
+ }
2081
2304
  }
2082
2305
  // External-contract single owner (fix #210): same
2083
2306
  // physics as the nominal gate above — an override
@@ -2123,6 +2346,31 @@ function findCallers(index, name, options = {}) {
2123
2346
  }
2124
2347
  }
2125
2348
 
2349
+ // Receiver-less counterpart of the gate's arityNoFit guard
2350
+ // (fix #229): a bare/package-qualified wrong-arity call may
2351
+ // reach the mismatch band only on compiler-grade NAME evidence
2352
+ // — an import binding of the name resolving to a target file
2353
+ // (a `use`/static-import pins the name, so the wrong arity is
2354
+ // a broken call site, not another target). Scope-match alone
2355
+ // cannot carry it: the arity disproves the project-side match,
2356
+ // so the call more likely binds code UCN cannot see.
2357
+ if (arityNoFit && !call.isMethod) {
2358
+ let arityNameEvidence = false;
2359
+ const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
2360
+ for (const im of (fileEntry.importBindings || [])) {
2361
+ if (im.name !== call.name) continue;
2362
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[String(im.module || '')];
2363
+ if (rel && tFiles.has(path.join(index.root, rel))) {
2364
+ arityNameEvidence = true;
2365
+ break;
2366
+ }
2367
+ }
2368
+ if (!arityNameEvidence) {
2369
+ recordExcluded(filePath, call.line, 'arity-mismatch');
2370
+ continue;
2371
+ }
2372
+ }
2373
+
2126
2374
  if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
2127
2375
  pendingByFile.get(filePath).push({
2128
2376
  call, fileEntry, callerSymbol,
@@ -2306,7 +2554,7 @@ function findCallers(index, name, options = {}) {
2306
2554
  // Retained unverified-tier entries, sorted (relativePath, line) per the
2307
2555
  // output ordering contract.
2308
2556
  unverifiedEntries.sort((a, b) => {
2309
- if (a.relativePath !== b.relativePath) return a.relativePath.localeCompare(b.relativePath);
2557
+ if (a.relativePath !== b.relativePath) return codeUnitCompare(a.relativePath, b.relativePath);
2310
2558
  return (a.line || 0) - (b.line || 0);
2311
2559
  });
2312
2560
  Object.defineProperty(callers, 'unverifiedEntries', {
@@ -2435,6 +2683,14 @@ function findCallees(index, def, options = {}) {
2435
2683
  localTypes = _buildTypedLocalTypeMap(index, def, calls);
2436
2684
  }
2437
2685
 
2686
+ // Return-type flow map (lazy — only built if a single-owner
2687
+ // resolution needs the external-producer/typed-receiver defeater).
2688
+ let _flowMap;
2689
+ const flowMap = () => {
2690
+ if (_flowMap === undefined) _flowMap = _buildReturnTypeFlowMap(index, def.file, calls);
2691
+ return _flowMap;
2692
+ };
2693
+
2438
2694
  let siteOrdinal = -1;
2439
2695
  for (const call of calls) {
2440
2696
  siteOrdinal++;
@@ -2453,6 +2709,55 @@ function findCallees(index, def, options = {}) {
2453
2709
  if (!isDirectMatch && !isNestedCallback) continue;
2454
2710
  if (calleeAccount) calleeAccount.totalSites++;
2455
2711
 
2712
+ // Declared-field receiver hop (fix #231 — callee-side parity
2713
+ // with the caller side's #202/#219): `tm.service.Save()` /
2714
+ // `this._map.has()` records carry receiverRoot/receiverField —
2715
+ // resolve the field's DECLARED type and treat it exactly like a
2716
+ // parser-inferred receiverType. this-rooted structural hops
2717
+ // resolve the root at query time (the enclosing class — arrows
2718
+ // keep lexical `this`; nested function declarations are their
2719
+ // own symbols without className, so dynamic-this shapes resolve
2720
+ // to nothing). _declaredFieldType's guards apply: interface/
2721
+ // trait-typed and generic-param fields return null.
2722
+ let fieldHopType = null;
2723
+ if (call.isMethod && !call.receiverType && call.receiverField) {
2724
+ let hopRoot = call.receiverRootType;
2725
+ if (!hopRoot && call.receiverRoot === 'this' &&
2726
+ langTraits(language)?.typeSystem === 'structural') {
2727
+ hopRoot = index.findEnclosingFunction(def.file, call.line, true)?.className;
2728
+ }
2729
+ if (hopRoot) fieldHopType = _declaredFieldType(index, hopRoot, call.receiverField, language);
2730
+ }
2731
+
2732
+ // Go package-qualified receiver: resolve the import module up
2733
+ // front so the dispatch chain can tell package calls apart from
2734
+ // type-qualified method expressions (fix #236 — a receiver that
2735
+ // is neither stays eligible for type-qualified resolution below).
2736
+ let goImportModule = null;
2737
+ if (call.isMethod && call.receiver && langTraits(language)?.hasReceiverPackageCalls) {
2738
+ const goImports = fileEntry?.imports || [];
2739
+ // Handle Go version suffixes: k8s.io/klog/v2 → klog, not v2
2740
+ goImportModule = goImports.find(mod => {
2741
+ const parts = mod.split('/');
2742
+ const last = parts[parts.length - 1];
2743
+ const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
2744
+ return pkgName === call.receiver;
2745
+ }) || null;
2746
+ }
2747
+
2748
+ // Type-qualified receiver resolution (fix #236): the receiver
2749
+ // NAMES a type — Foo::new() / Kit.make() / Helper.process().
2750
+ // Only consulted when no stronger evidence (local type, parser
2751
+ // receiverType, field hop, import package) claims the call.
2752
+ let typeQual = null;
2753
+ if (call.isMethod && !call.isConstructor && call.receiver &&
2754
+ !call.receiverType && !fieldHopType && !goImportModule &&
2755
+ !call.receiverIsModule && !call.selfAttribute &&
2756
+ !['self', 'cls', 'this', 'super', 'Self'].includes(call.receiver) &&
2757
+ !(localTypes && localTypes.has(call.receiver))) {
2758
+ typeQual = _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language);
2759
+ }
2760
+
2456
2761
  // Smart method call handling:
2457
2762
  // - Go: include all method calls (Go doesn't use this/self/cls)
2458
2763
  // - self/this.method(): resolve to same-class method (handled below)
@@ -2461,8 +2766,10 @@ function findCallees(index, def, options = {}) {
2461
2766
  if (call.isMethod) {
2462
2767
  if (call.selfAttribute && language === 'python') {
2463
2768
  // Will be resolved in second pass below
2464
- } else if (['self', 'cls', 'this'].includes(call.receiver)) {
2769
+ } else if (['self', 'cls', 'this'].includes(call.receiver) ||
2770
+ (call.receiver === 'Self' && language === 'rust')) {
2465
2771
  // self.method() / cls.method() / this.method() — resolve to same-class method below
2772
+ // Rust Self::method() resolves same-impl the same way (fix #236, the #232 callee analog)
2466
2773
  } else if (call.receiver === 'super') {
2467
2774
  // super().method() — resolve to parent class method below
2468
2775
  } else if (localTypes && localTypes.has(call.receiver)) {
@@ -2500,18 +2807,29 @@ function findCallees(index, def, options = {}) {
2500
2807
  callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
2501
2808
  ...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
2502
2809
  }
2810
+ } else if (_nonCallableFieldMember(index, typeName, call.name, language)) {
2811
+ // The known receiver type declares the name as its own
2812
+ // non-callable FIELD — a member reference, never a
2813
+ // callee (fix #231: `delete(cs.cache, key)` captured
2814
+ // cs.cache as a method-value callee; cache is
2815
+ // CacheService's map-typed field, which shadows any
2816
+ // same-named project function through this receiver).
2817
+ noteSite(siteId, 'excluded', 'member-reference', call);
2503
2818
  } else if (collectAccount) {
2504
2819
  // Locally-typed receiver, but the type defines no such
2505
2820
  // method in the index — visible, never silently dropped.
2506
2821
  noteUnverified(siteId, call, 'uncertain-receiver');
2507
2822
  }
2508
2823
  continue;
2509
- } else if (call.receiverType) {
2824
+ } else if (call.receiverType || fieldHopType) {
2510
2825
  // Use parser-inferred receiverType for method resolution
2511
2826
  // Go/Java/Rust: from param/receiver type declarations
2512
2827
  // JS/TS: from `new Foo()` assignments or TypeScript type annotations
2513
2828
  // Python: from constructor calls or type annotations
2514
- const typeName = call.receiverType;
2829
+ // fieldHopType: the declared type of a one-hop field
2830
+ // receiver (fix #231 — tm.service.Save() resolves Save
2831
+ // through the `service *DataService` declaration)
2832
+ const typeName = call.receiverType || fieldHopType;
2515
2833
  const symbols = index.symbols.get(call.name);
2516
2834
  const isCallableRT = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
2517
2835
  (s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
@@ -2544,20 +2862,26 @@ function findCallees(index, def, options = {}) {
2544
2862
  }
2545
2863
  continue;
2546
2864
  }
2865
+ // No match on the typed receiver. A name the type declares
2866
+ // as its own non-callable FIELD is a member reference,
2867
+ // never a callee (fix #231); a builtin hop type with no
2868
+ // project match is an external call (this._map.has on
2869
+ // `_map: WeakMap<...>` — the #219 caller-side analog).
2870
+ if (_nonCallableFieldMember(index, typeName, call.name, language)) {
2871
+ noteSite(siteId, 'excluded', 'member-reference', call);
2872
+ continue;
2873
+ }
2874
+ if (fieldHopType && BUILTIN_RECEIVER_TYPES.has(typeName)) {
2875
+ noteSite(siteId, 'external', null, call);
2876
+ continue;
2877
+ }
2547
2878
  // No match found with inferred type — fall through to include as unresolved
2548
- } else if (langTraits(language)?.hasReceiverPackageCalls && call.receiver) {
2879
+ } else if (goImportModule) {
2549
2880
  // Go package-qualified calls: klog.Infof(), wait.UntilWithContext()
2550
- // Check if receiver is an import alias and resolve to correct package
2551
- const goImports = fileEntry?.imports || [];
2552
- // Find import whose package name matches the receiver
2553
- // Handle Go version suffixes: k8s.io/klog/v2 → klog, not v2
2554
- const importModule = goImports.find(mod => {
2555
- const parts = mod.split('/');
2556
- const last = parts[parts.length - 1];
2557
- const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
2558
- return pkgName === call.receiver;
2559
- });
2560
- if (importModule) {
2881
+ // The receiver is an import alias (resolved above) find
2882
+ // definitions from that package.
2883
+ const importModule = goImportModule;
2884
+ {
2561
2885
  // Receiver is an import alias — resolve to definitions from that package
2562
2886
  const symbols = index.symbols.get(call.name);
2563
2887
  if (symbols) {
@@ -2600,14 +2924,47 @@ function findCallees(index, def, options = {}) {
2600
2924
  noteSite(siteId, 'external', null, call);
2601
2925
  continue;
2602
2926
  }
2927
+ } else if (typeQual) {
2928
+ // Type-qualified receiver (fix #236): the receiver NAMES a
2929
+ // type, so the type owns the call — Foo::new() is Foo's
2930
+ // new; String::new() / Math.max() are external and must
2931
+ // never confirm a project method through a bare name
2932
+ // binding (the caller side excludes the identical edges
2933
+ // as path-type-mismatch — the two directions now agree).
2934
+ if (typeQual.match) {
2935
+ const match = typeQual.match;
2936
+ const key = match.bindingId || `${typeQual.typeName}.${call.name}`;
2937
+ const existing = callees.get(key);
2938
+ if (existing) {
2939
+ existing.count += 1;
2940
+ if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
2941
+ } else {
2942
+ callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
2943
+ ...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
2944
+ }
2945
+ continue;
2946
+ }
2947
+ if (typeQual.external) {
2948
+ noteSite(siteId, 'external', null, call);
2949
+ continue;
2950
+ }
2951
+ noteUnverified(siteId, call, typeQual.unverified);
2952
+ continue;
2603
2953
  } else if (langTraits(language)?.methodCallInclusion === 'explicit' && !options.includeMethods) {
2604
2954
  noteSite(siteId, 'filtered', 'method-calls-excluded', call);
2605
2955
  continue;
2606
2956
  }
2607
2957
  }
2608
2958
 
2609
- // Skip keywords and built-ins
2610
- if (index.isKeyword(call.name, language)) {
2959
+ // Skip keywords and built-ins — EXCEPT self/super-received method
2960
+ // calls, which the same-class/super passes below resolve to real
2961
+ // definitions (fix #238: `super().__init__(x)` and the JS/TS
2962
+ // `super(...)` 'constructor' record were routed external here
2963
+ // because __init__/constructor sit in the builtin name sets).
2964
+ const selfShaped = call.isMethod &&
2965
+ (['self', 'cls', 'this', 'super'].includes(call.receiver) ||
2966
+ (call.receiver === 'Self' && language === 'rust'));
2967
+ if (!selfShaped && index.isKeyword(call.name, language)) {
2611
2968
  noteSite(siteId, 'external', null, call);
2612
2969
  continue;
2613
2970
  }
@@ -2651,7 +3008,9 @@ function findCallees(index, def, options = {}) {
2651
3008
  }
2652
3009
 
2653
3010
  // Collect self/this.method() calls for same-class resolution
2654
- if (call.isMethod && ['self', 'cls', 'this'].includes(call.receiver)) {
3011
+ // (Rust Self::method() resolves the same way — fix #236)
3012
+ if (call.isMethod && (['self', 'cls', 'this'].includes(call.receiver) ||
3013
+ (call.receiver === 'Self' && language === 'rust'))) {
2655
3014
  if (!selfMethodCalls) selfMethodCalls = [];
2656
3015
  selfMethodCalls.push({ call, siteId });
2657
3016
  continue;
@@ -2685,6 +3044,25 @@ function findCallees(index, def, options = {}) {
2685
3044
  // Different strategies by language family:
2686
3045
  if (bindings.length === 0 && call.isMethod) {
2687
3046
  if (langTraits(language)?.typeSystem === 'structural') {
3047
+ // A KNOWN receiver type routes before any name heuristic
3048
+ // (fix #257 — the caller side's #198 trust rule brought to
3049
+ // findCallees): `canonicalSymbols.set(...)` on a `new Map()`
3050
+ // local resolved exact-binding into a test fixture's
3051
+ // CacheService.set. Builtin-typed receivers are host calls;
3052
+ // a receiver typed to a project class resolves to that class
3053
+ // (or an ancestor defining the method), never by bare name.
3054
+ const route = _calleeReceiverTypeRoute(index, call, localTypes, language);
3055
+ if (route?.external) {
3056
+ noteSite(siteId, 'external', null, call);
3057
+ continue;
3058
+ } else if (route?.resolve) {
3059
+ bindingResolved = route.resolve.bindingId;
3060
+ calleeKey = bindingResolved ||
3061
+ `${route.resolve.className}.${effectiveName}`;
3062
+ } else if (route?.uncertain) {
3063
+ isUncertain = true;
3064
+ uncertainReason = 'uncertain-receiver';
3065
+ } else {
2688
3066
  // JS/TS/Python: mark uncertain unless receiver has import/binding
2689
3067
  // evidence in file scope AND that binding can plausibly have this method.
2690
3068
  // Prevents false positives like m.get() → repository.get() when m is
@@ -2700,6 +3078,7 @@ function findCallees(index, def, options = {}) {
2700
3078
  // Functions don't have user-defined methods (return value is unknown)
2701
3079
  isUncertain = true;
2702
3080
  }
3081
+ }
2703
3082
  } else {
2704
3083
  // Go/Java/Rust: nominal type systems make single-def method links
2705
3084
  // reliable. Only mark uncertain when multiple definitions exist
@@ -2708,11 +3087,12 @@ function findCallees(index, def, options = {}) {
2708
3087
  if (defs && defs.length > 1) {
2709
3088
  // Go: if receiverType is known, check if it matches exactly one def
2710
3089
  // This resolves ambiguity like Framework.Run vs Scheduler.Run
2711
- const rType = call.receiverType || localTypes?.get(call.receiver);
3090
+ const rType = call.receiverType || fieldHopType || localTypes?.get(call.receiver);
2712
3091
  if (rType && langTraits(language)?.typeSystem === 'nominal') {
2713
3092
  const matchingDef = defs.find(d =>
2714
- d.className === rType ||
2715
- (d.receiver && d.receiver.replace(/^\*/, '') === rType));
3093
+ (d.className === rType ||
3094
+ (d.receiver && d.receiver.replace(/^\*/, '') === rType)) &&
3095
+ _calleeLanguageCompatible(index, d, language));
2716
3096
  if (matchingDef) {
2717
3097
  // Resolved to specific type — not uncertain
2718
3098
  calleeKey = matchingDef.bindingId || `${rType}.${call.name}`;
@@ -2757,14 +3137,13 @@ function findCallees(index, def, options = {}) {
2757
3137
  // spraying every same-name def (#223, ripgrep-measured
2758
3138
  // on the callee eval arm: HiArgs::from_low_args calls
2759
3139
  // three sibling types' from_low_args — every def was
2760
- // claimed at all three sites). Variable receivers match
2761
- // no class → unchanged full fan-out; bare calls (Java
3140
+ // claimed at all three sites). Bare calls (Java
2762
3141
  // implicit-this overloads) keep fanning out.
2763
3142
  let otherBindings = bindings.filter(b =>
2764
3143
  b.startLine !== def.startLine
2765
3144
  );
2766
3145
  const fanReceiver = call.receiver || call.receiverType;
2767
- if (fanReceiver && otherBindings.length > 1) {
3146
+ if (fanReceiver && otherBindings.length > 0) {
2768
3147
  const symsForName = index.symbols.get(call.name) || [];
2769
3148
  const classMatched = otherBindings.filter(b => {
2770
3149
  const bSym = symsForName.find(s => s.bindingId === b.id);
@@ -2772,8 +3151,24 @@ function findCallees(index, def, options = {}) {
2772
3151
  (bSym.receiver && bSym.receiver.replace(/^\*/, '')));
2773
3152
  return cls === fanReceiver;
2774
3153
  });
2775
- if (classMatched.length > 0) otherBindings = classMatched;
3154
+ if (classMatched.length > 0) {
3155
+ otherBindings = classMatched;
3156
+ } else if (call.isMethod && call.receiver) {
3157
+ // Untyped-receiver same-name method call:
3158
+ // name-equality with the enclosing def is not
3159
+ // receiver evidence (fix #237 — CacheService
3160
+ // .get's `cache.get(key)` sprayed a confirmed
3161
+ // edge onto ApiClient.get, leaking reachability
3162
+ // credit across classes). Route through the
3163
+ // uncertain machinery: multi-owner names stay
3164
+ // visible method-ambiguous; the single-owner
3165
+ // rule (with its defeaters) may still confirm.
3166
+ isUncertain = true;
3167
+ uncertainReason = 'method-ambiguous';
3168
+ otherBindings = null;
3169
+ }
2776
3170
  }
3171
+ if (otherBindings) {
2777
3172
  for (const ob of otherBindings) {
2778
3173
  const existing = callees.get(ob.id);
2779
3174
  if (existing) {
@@ -2794,6 +3189,9 @@ function findCallees(index, def, options = {}) {
2794
3189
  noteSite(siteId, 'excluded', 'self-recursion', call);
2795
3190
  }
2796
3191
  continue; // Already added all overloads, skip normal add
3192
+ }
3193
+ // otherBindings === null: fall through to the
3194
+ // single-owner check + uncertain handling below.
2797
3195
  } else if (def.className && !call.isMethod) {
2798
3196
  // Implicit same-class call (Java: execute() means this.execute())
2799
3197
  // Try to resolve to a binding in the same class via symbol lookup
@@ -2835,6 +3233,24 @@ function findCallees(index, def, options = {}) {
2835
3233
  }
2836
3234
  }
2837
3235
 
3236
+ // Single project-wide owner (fix #236 — the caller side's
3237
+ // #204/#209 rule on the callee side): an untyped-receiver method
3238
+ // call whose name has exactly ONE owner type resolves to that
3239
+ // owner's method — `k.run()` where only Kit defines run. Without
3240
+ // it, trace trees stopped expanding at statically-resolvable
3241
+ // calls the caller direction confirms.
3242
+ if (isUncertain && call.isMethod && call.receiver && !bindingResolved) {
3243
+ const fm = flowMap();
3244
+ const flowEntry = fm ? _lookupReturnTypeFlow(fm, call) : undefined;
3245
+ const owner = _calleeSingleOwnerMatch(index, def, fileEntry, call, effectiveName, language, flowEntry);
3246
+ if (owner) {
3247
+ isUncertain = false;
3248
+ bindingResolved = owner.bindingId;
3249
+ calleeKey = owner.bindingId ||
3250
+ `${owner.className || (owner.receiver || '').replace(/^\*/, '')}.${effectiveName}`;
3251
+ }
3252
+ }
3253
+
2838
3254
  if (isUncertain) {
2839
3255
  if (collectAccount) {
2840
3256
  // Contract mode: uncertain callee edges are never silently
@@ -3155,7 +3571,7 @@ function findCallees(index, def, options = {}) {
3155
3571
  // Stable ordering (output contract): by name, then reason.
3156
3572
  const unverifiedList = [...unverifiedCallees.values()]
3157
3573
  .map(e => ({ ...e, sites: [...e.sites].sort((a, b) => a - b) }))
3158
- .sort((a, b) => a.name.localeCompare(b.name) || a.reason.localeCompare(b.reason));
3574
+ .sort((a, b) => codeUnitCompare(a.name, b.name) || codeUnitCompare(a.reason, b.reason));
3159
3575
  Object.defineProperty(result, 'calleeAccount', {
3160
3576
  value: calleeAccount, enumerable: false, writable: true, configurable: true,
3161
3577
  });
@@ -3702,6 +4118,16 @@ function _qualifiedProducerDefs(index, fileEntry, receiver, defs) {
3702
4118
  // Builtin receiver types from literal/annotation inference (Python builtins,
3703
4119
  // JS globals, TS predefined types). Definitionally not project classes, so a
3704
4120
  // mismatch against a project class target is always positive evidence.
4121
+ // ECMAScript host/ambient OBJECT globals (fix #232): a method call on one of
4122
+ // these names — unshadowed by any project def or file binding — reaches host
4123
+ // code, not a project method. Name-knowledge only, so demote-only: routes
4124
+ // possible-dispatch, never excludes (window.fn = projectFn is a real pattern).
4125
+ const JS_GLOBAL_RECEIVERS = new Set([
4126
+ 'console', 'window', 'document', 'globalThis', 'process', 'navigator',
4127
+ 'Math', 'JSON', 'Reflect', 'Intl', 'localStorage', 'sessionStorage',
4128
+ 'crypto', 'performance', 'history', 'location', 'screen',
4129
+ ]);
4130
+
3705
4131
  const BUILTIN_RECEIVER_TYPES = new Set([
3706
4132
  'dict', 'list', 'set', 'tuple', 'str', 'int', 'float', 'bool', 'bytes', 'frozenset',
3707
4133
  'Array', 'String', 'Object', 'RegExp', 'Number', 'Boolean', 'Map', 'Set', 'Promise',
@@ -3902,6 +4328,28 @@ function _nameBindingReaches(index, startAbs, name, targetFiles, maxDepth = 4) {
3902
4328
  return unknown ? 'unknown' : 'no';
3903
4329
  }
3904
4330
 
4331
+ /**
4332
+ * From-import submodule receivers (fix #224): `from . import jobs` binds
4333
+ * jobs.py as a plain NAME — the parser can't mark it a module alias (a
4334
+ * from-import name may be a symbol), but the resolver proved it at build
4335
+ * time: graph-build records the composed submodule specifier ('.jobs') in
4336
+ * fileEntry.moduleResolved when it resolves to a project file. A hit makes
4337
+ * the receiver a MODULE receiver at query time. Returns the ROOT-RELATIVE
4338
+ * module file or null. Trait-gated (`submoduleImports` — Python only).
4339
+ */
4340
+ function _submoduleReceiverModule(index, fileEntry, receiverName) {
4341
+ if (!receiverName || !fileEntry || !fileEntry.moduleResolved) return null;
4342
+ if (!langTraits(fileEntry.language)?.submoduleImports) return null;
4343
+ for (const b of (fileEntry.importBindings || [])) {
4344
+ if (!b || b.name !== receiverName || b.module == null) continue;
4345
+ const mod = String(b.module);
4346
+ const spec = mod.endsWith('.') ? mod + receiverName : mod + '.' + receiverName;
4347
+ const rel = fileEntry.moduleResolved[spec];
4348
+ if (rel) return rel;
4349
+ }
4350
+ return null;
4351
+ }
4352
+
3905
4353
  /**
3906
4354
  * Bounded-depth reachability over the import graph: can `fromAbs` reach any
3907
4355
  * target file through re-export/import chains? Barrel hierarchies routinely
@@ -3951,6 +4399,96 @@ function _projectTopLevelNames(index) {
3951
4399
  }
3952
4400
 
3953
4401
  const IDENTITY_TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'enum']);
4402
+
4403
+ /**
4404
+ * Parse a generics/type-parameter list text (`<T: Wipe, U>`, `<T extends X>`,
4405
+ * Go `[T any, U comparable]`) into the set of declared type-parameter NAMES.
4406
+ * Rust lifetimes (`'a`) and const params (`const N: usize`) are not receiver
4407
+ * types and are skipped.
4408
+ */
4409
+ function _genericParamNames(genericsText) {
4410
+ if (!genericsText || typeof genericsText !== 'string') return null;
4411
+ const inner = genericsText.trim().replace(/^[<[]/, '').replace(/[>\]]$/, '');
4412
+ const names = new Set();
4413
+ let depth = 0, start = 0;
4414
+ const parts = [];
4415
+ for (let i = 0; i < inner.length; i++) {
4416
+ const ch = inner[i];
4417
+ if (ch === '<' || ch === '[' || ch === '(') depth++;
4418
+ else if (ch === '>' || ch === ']' || ch === ')') depth--;
4419
+ else if (ch === ',' && depth === 0) { parts.push(inner.slice(start, i)); start = i + 1; }
4420
+ }
4421
+ parts.push(inner.slice(start));
4422
+ for (let p of parts) {
4423
+ p = p.trim();
4424
+ if (!p || p.startsWith("'") || p.startsWith('const ')) continue;
4425
+ const m = p.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
4426
+ if (m) names.add(m[1]);
4427
+ }
4428
+ return names.size > 0 ? names : null;
4429
+ }
4430
+
4431
+ /**
4432
+ * Is typeName a declared GENERIC TYPE PARAMETER in scope at this call site —
4433
+ * on the enclosing function itself (`fn f<TStore: Wipe>(t: &TStore)`) or on
4434
+ * its class/struct (`impl<T> Processor<T>` methods see the struct's `<T>`)?
4435
+ * A generic param is never type identity in either direction (fix #229, the
4436
+ * #220(1) convention rule made precise): it may be instantiated with the
4437
+ * target class, so it neither validates nor excludes — and the declaration
4438
+ * shadows any same-named project type inside the function.
4439
+ */
4440
+ function _isEnclosingGenericParam(index, filePath, line, typeName) {
4441
+ const enclosing = index.findEnclosingFunction(filePath, line, true);
4442
+ if (!enclosing) return false;
4443
+ const own = _genericParamNames(enclosing.generics);
4444
+ if (own && own.has(typeName)) return true;
4445
+ if (enclosing.className) {
4446
+ const classDefs = (index.symbols.get(enclosing.className) || []).filter(d =>
4447
+ IDENTITY_TYPE_KINDS.has(d.type) && d.file === filePath);
4448
+ for (const cd of classDefs) {
4449
+ const cg = _genericParamNames(cd.generics);
4450
+ if (cg && cg.has(typeName)) return true;
4451
+ }
4452
+ }
4453
+ return false;
4454
+ }
4455
+
4456
+ /**
4457
+ * Receiver-type identity guard shared by the parser-typed branch and the
4458
+ * local-inference fallback: a name is NOT usable as type identity when it is
4459
+ * a generic type param — declared in the enclosing scope, or matching the
4460
+ * 1-2-char ALL-CAPS convention (T, K, V, T1) with no project type def (the
4461
+ * declaring scope may be outside what UCN parsed).
4462
+ */
4463
+ function _isGenericParamReceiverType(index, filePath, line, typeName) {
4464
+ if (!typeName) return false;
4465
+ if (/^[A-Z][A-Z0-9]?$/.test(typeName) &&
4466
+ !(index.symbols.get(typeName) || []).some(d => IDENTITY_TYPE_KINDS.has(d.type))) return true;
4467
+ return _isEnclosingGenericParam(index, filePath, line, typeName);
4468
+ }
4469
+
4470
+ /**
4471
+ * Java same-package check across Maven/Gradle source roots (fix #246):
4472
+ * src/main/java/<pkg> and src/test/java/<pkg> hold the SAME package —
4473
+ * javac compiles both source sets onto one classpath, so a test file sees
4474
+ * the main tree's package members without an import. Two dirs are
4475
+ * same-package when equal, or (Java only) when both sit under a
4476
+ * `src/<set>/java/` source root with the same module prefix and the same
4477
+ * package-relative path. Different modules keep distinct prefixes, so
4478
+ * same-named packages across a monorepo stay separate.
4479
+ */
4480
+ function _sameNominalPackageDir(dirA, dirB, language) {
4481
+ if (dirA === dirB) return true;
4482
+ if (language !== 'java') return false;
4483
+ const norm = (d) => {
4484
+ const m = d.match(/^(.*?)[\/\\]src[\/\\][^\/\\]+[\/\\]java(?:[\/\\](.*))?$/);
4485
+ return m ? `${m[1]}${m[2] || ''}` : null;
4486
+ };
4487
+ const a = norm(dirA);
4488
+ if (a === null) return false;
4489
+ return a === norm(dirB);
4490
+ }
4491
+
3954
4492
  function _resolveReceiverTypeIdentity(index, filePath, knownType, targetDefs) {
3955
4493
  const typeDefs = (index.symbols.get(knownType) || []).filter(d => IDENTITY_TYPE_KINDS.has(d.type));
3956
4494
  if (typeDefs.length <= 1) return 'target';
@@ -4079,6 +4617,360 @@ function _declaredFieldType(index, rootType, fieldName, language) {
4079
4617
  return typeName;
4080
4618
  }
4081
4619
 
4620
+ /**
4621
+ * Is a member reference `<recv>.name` on a KNOWN receiver type provably a
4622
+ * non-callable FIELD — never an edge to any project callable? (fix #231:
4623
+ * `delete(cs.cache, key)` captures cs.cache as a potential method-value
4624
+ * callee, but `cache` is CacheService's own map-typed field.) The member
4625
+ * access resolves to the MEMBER — a same-named function elsewhere in the
4626
+ * project is unreachable through this receiver (Go field names shadow
4627
+ * promoted methods; Java field/method namespaces are separate but a
4628
+ * paren-less member access is always the field). Only certainty excludes:
4629
+ * every same-type member of the name must be a field whose declared type is
4630
+ * present, not a function type (Go `func(...)`, Rust fn/Fn*, structural
4631
+ * arrow/Callable/Function — the #219 callable-owner shapes), and — for
4632
+ * structural languages — trusted for exclusion (#198: builtin or project
4633
+ * class; `any`/alias/interface heads prove nothing, and an untyped JS field
4634
+ * could hold a same-named function via `this.cb = cb`, the #218c
4635
+ * member-alias family).
4636
+ */
4637
+ function _nonCallableFieldMember(index, typeName, name, language) {
4638
+ const defs = index.symbols.get(name);
4639
+ if (!defs || defs.length === 0) return false;
4640
+ const onType = defs.filter(d => d.className === typeName ||
4641
+ (d.receiver && d.receiver.replace(/^\*/, '') === typeName));
4642
+ if (onType.length === 0) return false;
4643
+ for (const d of onType) {
4644
+ if (d.type !== 'field' && d.memberType !== 'field' && d.memberType !== 'private field') return false;
4645
+ if (!d.fieldType) return false;
4646
+ if (_callableFieldDef(index, d)) return false;
4647
+ const raw = String(d.fieldType).trim();
4648
+ if (/^func\b/.test(raw)) return false;
4649
+ if (/\bfn\s*\(|\b(?:Fn|FnMut|FnOnce)\s*[(<]/.test(raw)) return false;
4650
+ if (langTraits(language)?.typeSystem === 'structural') {
4651
+ const head = _normalizeFieldTypeName(raw, language);
4652
+ if (!head || _STRUCTURAL_FLOW_REJECT.has(head) ||
4653
+ !_receiverTypeTrustedForExclusion(index, head)) return false;
4654
+ }
4655
+ }
4656
+ return true;
4657
+ }
4658
+
4659
+ /**
4660
+ * Callee-side type-qualified receiver resolution (fix #236 — the caller
4661
+ * side's #206/#208/#220/#222 identity discipline brought to findCallees).
4662
+ * A receiver that NAMES a type owns the call: `Foo::new()` is Foo's new and
4663
+ * nothing else's, `String::new()` can never be a project method, `Kit.make()`
4664
+ * through an imported class binding is Kit's make. Returns:
4665
+ * { match, typeName } — confirm this definition
4666
+ * { external: true } — type-qualified call on a builtin/external type
4667
+ * { unverified: reason } — visible, never confirmed through a name binding
4668
+ * null — receiver is not provably a type; no opinion
4669
+ * Shape gates follow typeQualifiedCallStyle (#206): Rust requires the path
4670
+ * form (a dot-call receiver matching a type name is a variable); Go method
4671
+ * expressions pass the receiver instance as the first argument, so zero-arg
4672
+ * calls on type-named receivers are variables. `use X as Y` aliases are
4673
+ * judged by the original name (#222b); pure type-alias sets close over their
4674
+ * base (#208). Structural class receivers additionally need scope evidence
4675
+ * (#215): the class defined in this file or a file binding of the name —
4676
+ * an unbound capitalized receiver may be a parameter or local.
4677
+ */
4678
+ /**
4679
+ * Namespace/module-container resolution (fix #254, W8 BUG-4 — verify's
4680
+ * BUG-BX rule brought into the engine, range-based): `Utils.slug()` where a
4681
+ * `namespace Utils` block CONTAINS a definition of `slug` is a qualified
4682
+ * FUNCTION call, not a method call — containment is identity evidence, not
4683
+ * a naming heuristic. Requires #215 scope evidence tying the receiver to
4684
+ * the containing file: the call site sits in that file, or an import
4685
+ * binding of the receiver name resolves toward it (_importReaches — barrel
4686
+ * chains). Structural languages only; Rust `mod` paths keep the path
4687
+ * machinery.
4688
+ * @param {object} index - ProjectIndex instance
4689
+ * @param {object} fileEntry - The CALL SITE's file entry
4690
+ * @param {string} callFileAbs - The call site's absolute file path
4691
+ * @param {string} receiverName - The call's receiver text
4692
+ * @param {string} calleeName - The called name
4693
+ * @param {Array|null} restrictDefs - Candidate defs to test containment on
4694
+ * (the pinned targets on the caller side); null = all callable defs
4695
+ * @returns {object|null} The contained definition, or null
4696
+ */
4697
+ function _namespaceContainedDef(index, fileEntry, callFileAbs, receiverName, calleeName, restrictDefs) {
4698
+ if (!receiverName || receiverName.includes('.') || receiverName.includes('::')) return null;
4699
+ const nsDefs = (index.symbols.get(receiverName) || []).filter(s =>
4700
+ s.type === 'namespace' || s.type === 'module');
4701
+ if (nsDefs.length === 0) return null;
4702
+ const candidates = restrictDefs ||
4703
+ (index.symbols.get(calleeName) || []).filter(s => !NON_CALLABLE_TYPES.has(s.type));
4704
+ const contained = candidates.filter(d => nsDefs.some(ns =>
4705
+ ns.file === d.file && ns.startLine <= d.startLine &&
4706
+ (ns.endLine ?? Infinity) >= (d.endLine ?? d.startLine)));
4707
+ if (contained.length === 0) return null;
4708
+ const targetAbs = new Set(contained.map(d => d.file));
4709
+ if (targetAbs.has(callFileAbs)) return contained[0];
4710
+ for (const im of (fileEntry?.importBindings || [])) {
4711
+ if (im.name !== receiverName) continue;
4712
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[String(im.module || '')];
4713
+ if (!rel) continue;
4714
+ const abs = path.join(index.root, rel);
4715
+ if (targetAbs.has(abs) || _importReaches(index, abs, targetAbs)) {
4716
+ return contained.find(d => d.file === abs) || contained[0];
4717
+ }
4718
+ }
4719
+ return null;
4720
+ }
4721
+
4722
+ function _calleeTypeQualifiedReceiver(index, def, fileEntry, call, language) {
4723
+ let receiver = call.receiver;
4724
+ if (!receiver) return null;
4725
+ const traits = langTraits(language);
4726
+ const style = traits?.typeQualifiedCallStyle;
4727
+ if (style === 'path' && !call.isPathCall) return null;
4728
+ if (style === 'method-expr' && call.argCount != null && call.argCount < 1) return null;
4729
+
4730
+ // Namespace/module container (fix #254): checked before the
4731
+ // capitalization gate — namespaces resolve by symbol lookup, not case
4732
+ // convention. A hit is a qualified function call, exempt from the
4733
+ // method filter downstream.
4734
+ if (traits?.typeSystem === 'structural') {
4735
+ const nsDef = _namespaceContainedDef(index, fileEntry, def.file, receiver, call.name, null);
4736
+ if (nsDef) return { match: nsDef, typeName: receiver };
4737
+ }
4738
+
4739
+ const typeKindsOf = (name) => (index.symbols.get(name) || [])
4740
+ .filter(d => IDENTITY_TYPE_KINDS.has(d.type) || (d.type === 'type' && d.aliasOf));
4741
+
4742
+ // Multi-segment path receivers (std::sync::Arc::new): the LAST segment
4743
+ // is the type; the qualifier owns it (#206). A crate-internal qualifier
4744
+ // (crate/self/super) resolves by name below; std/core/alloc paths are
4745
+ // provably external even when a project type shares the name; any other
4746
+ // qualified name is unpinnable without a module resolver — visible when
4747
+ // a project type shares the name, external when none does.
4748
+ if (style === 'path' && receiver.includes('::')) {
4749
+ const segs = receiver.split('::');
4750
+ const lastSeg = segs[segs.length - 1];
4751
+ if (!/^[A-Z]/.test(lastSeg)) return null; // module-path function call
4752
+ if (['crate', 'self', 'super'].includes(segs[0])) {
4753
+ receiver = lastSeg;
4754
+ } else if (typeKindsOf(lastSeg).length === 0) {
4755
+ return { external: true };
4756
+ } else if (['std', 'core', 'alloc'].includes(segs[0])) {
4757
+ return { external: true };
4758
+ } else {
4759
+ return { unverified: 'method-ambiguous' };
4760
+ }
4761
+ }
4762
+ if (!/^[A-Z]/.test(receiver)) return null;
4763
+ let typeDefs = typeKindsOf(receiver);
4764
+ if (typeDefs.length === 0) {
4765
+ for (const im of (fileEntry?.importBindings || [])) {
4766
+ if (im.name !== receiver) continue;
4767
+ const orig = String(im.module || '').split('::').pop();
4768
+ if (orig && orig !== receiver && typeKindsOf(orig).length > 0) {
4769
+ receiver = orig;
4770
+ typeDefs = typeKindsOf(orig);
4771
+ break;
4772
+ }
4773
+ }
4774
+ }
4775
+ if (typeDefs.length === 0) {
4776
+ // No project type of this name. Rust path receivers are provably
4777
+ // types (modules are lowercase, variables cannot be path-qualified):
4778
+ // a generic-param name stays visible — its instantiation could be
4779
+ // any project type — everything else (String::new, Arc::new) is
4780
+ // external. Java CamelCase receivers are classes (Math.max) —
4781
+ // external; ALL_CAPS receivers are constants (variables) and keep
4782
+ // normal resolution. Go capitalizes exported package-level VARIABLES
4783
+ // too, and a structural capitalized receiver may be a parameter or
4784
+ // local — neither acts without a project type def.
4785
+ if (style === 'path') {
4786
+ if (_isGenericParamReceiverType(index, def.file, call.line, receiver)) {
4787
+ return { unverified: 'method-ambiguous' };
4788
+ }
4789
+ return { external: true };
4790
+ }
4791
+ if (language === 'java' && /[a-z]/.test(receiver) &&
4792
+ !_isGenericParamReceiverType(index, def.file, call.line, receiver)) {
4793
+ return { external: true };
4794
+ }
4795
+ return null;
4796
+ }
4797
+ if (traits?.typeSystem === 'structural') {
4798
+ const inFile = typeDefs.some(d => d.file === def.file);
4799
+ const bound = (fileEntry?.bindings || []).some(b => b.name === receiver);
4800
+ if (!inFile && !bound) return null;
4801
+ }
4802
+ // Alias closure (#208): a pure alias set agreeing on one base is the
4803
+ // SAME type — the method may live on the base's inherent impl.
4804
+ const candidateTypes = [receiver];
4805
+ if (typeDefs.every(d => d.type === 'type' && d.aliasOf)) {
4806
+ const bases = new Set(typeDefs.map(d => d.aliasOf));
4807
+ if (bases.size === 1) candidateTypes.push(bases.values().next().value);
4808
+ }
4809
+ const symbols = index.symbols.get(call.name) || [];
4810
+ const isCallable = (s) => !NON_CALLABLE_TYPES.has(s.type) ||
4811
+ (s.type === 'field' && s.fieldType && /^func\b/.test(s.fieldType));
4812
+ const matchOn = (tn) => symbols.find(s => isCallable(s) &&
4813
+ (s.className === tn || (s.receiver && s.receiver.replace(/^\*/, '') === tn)));
4814
+ let match = null;
4815
+ let matchedType = null;
4816
+ for (const tn of candidateTypes) {
4817
+ match = matchOn(tn);
4818
+ if (match) { matchedType = tn; break; }
4819
+ }
4820
+ if (!match && traits?.typeSystem === 'nominal') {
4821
+ for (const tn of candidateTypes) {
4822
+ const parentNames = index._getInheritanceParents?.(tn, def.file);
4823
+ if (!parentNames) continue;
4824
+ for (const pName of parentNames) {
4825
+ match = matchOn(pName);
4826
+ if (match) { matchedType = pName; break; }
4827
+ }
4828
+ if (match) break;
4829
+ }
4830
+ }
4831
+ if (match) return { match, typeName: matchedType };
4832
+ // A project type that does not define the method: a trait/interface
4833
+ // receiver dispatches across N impls; otherwise the method comes from a
4834
+ // derive/trait impl/external contract the index cannot pin. Visible —
4835
+ // never confirmed through an unrelated name binding.
4836
+ const dispatchy = typeDefs.some(d => d.type === 'trait' || d.type === 'interface');
4837
+ return { unverified: dispatchy ? 'method-ambiguous' : 'uncertain-receiver' };
4838
+ }
4839
+
4840
+ /**
4841
+ * Single project-wide owner resolution for an untyped-receiver method call
4842
+ * (fix #236 — the caller side's #204/#209 rule on the callee side): when
4843
+ * every callable definition of the name lives on ONE owner type, `k.run()`
4844
+ * can only be that owner's method. Defeaters mirror the caller contract:
4845
+ * builtin-global receivers (#232 — name knowledge, not evidence), module
4846
+ * receivers (#209/#224 — module attribute lookup, not instance dispatch),
4847
+ * a standalone function sharing the name (rebinding can route the call
4848
+ * there, #218b), callable fields as second owners (#219), external-contract
4849
+ * override markers (#210 — the overridden definition lives OUTSIDE the
4850
+ * project), receivers typed by the flow map to something other than the
4851
+ * owner (#199/#207/#222(4)), nominal arity mismatch (#205), and a test-file
4852
+ * owner for a production caller. Returns the owner's definition or null.
4853
+ */
4854
+ // Languages whose symbols are mutually callable — a JS/TS call site can bind
4855
+ // a symbol defined in any of these; every other language only binds its own
4856
+ // (fix #257: java/python/rust fixture defs of CacheService.set counted as ONE
4857
+ // owner for a JavaScript call — cross-language edges are never callable).
4858
+ const _JS_CALLABLE_FAMILY = new Set(['javascript', 'typescript', 'tsx', 'html']);
4859
+
4860
+ function _calleeLanguageCompatible(index, def, callerLanguage) {
4861
+ if (!callerLanguage) return true;
4862
+ const defLang = index.files.get(def.file)?.language;
4863
+ if (!defLang || defLang === callerLanguage) return true;
4864
+ return _JS_CALLABLE_FAMILY.has(defLang) && _JS_CALLABLE_FAMILY.has(callerLanguage);
4865
+ }
4866
+
4867
+ /**
4868
+ * Ancestor-name closure of a type via the extends graph (bounded BFS) — a
4869
+ * receiver typed Child legitimately reaches methods defined on Base (the
4870
+ * #198 ancestor rule, callee direction). Includes the type itself.
4871
+ */
4872
+ function _receiverTypeAncestors(index, typeName, maxHops = 6) {
4873
+ const seen = new Set([typeName]);
4874
+ let frontier = [typeName];
4875
+ for (let hop = 0; hop < maxHops && frontier.length; hop++) {
4876
+ const next = [];
4877
+ for (const cls of frontier) {
4878
+ const parents = index._getInheritanceParents?.(cls) || [];
4879
+ for (const p of parents) {
4880
+ const pName = typeof p === 'string' ? p : p?.name;
4881
+ if (pName && !seen.has(pName)) { seen.add(pName); next.push(pName); }
4882
+ }
4883
+ }
4884
+ frontier = next;
4885
+ }
4886
+ return seen;
4887
+ }
4888
+
4889
+ /**
4890
+ * Route a structural method call by its KNOWN receiver type (fix #257).
4891
+ * Returns:
4892
+ * { resolve: def } — exactly one language-compatible project class (the
4893
+ * type itself or an ancestor) defines the method
4894
+ * { external } — builtin receiver type with no project match
4895
+ * (Map.set is host code)
4896
+ * { uncertain } — known type matching nothing or ambiguously: visible,
4897
+ * never confirmed by bare-name resolution
4898
+ * null — receiver type unknown; existing heuristics decide
4899
+ */
4900
+ function _calleeReceiverTypeRoute(index, call, localTypes, language) {
4901
+ const raw = call.receiverType || localTypes?.get(call.receiver);
4902
+ if (!raw || typeof raw !== 'string') return null;
4903
+ const head = _structuralTypeHead(raw) || raw;
4904
+ const norm = _PY_TYPING_BUILTINS[head] || head;
4905
+ const defs = (index.symbols.get(call.name) || []).filter(d =>
4906
+ !NON_CALLABLE_TYPES.has(d.type) && d.className &&
4907
+ _calleeLanguageCompatible(index, d, language));
4908
+ let matches = defs.filter(d => d.className === head || d.className === norm);
4909
+ if (matches.length === 0 && defs.length > 0) {
4910
+ const ancestors = _receiverTypeAncestors(index, head);
4911
+ matches = defs.filter(d => ancestors.has(d.className));
4912
+ }
4913
+ if (matches.length === 1) return { resolve: matches[0] };
4914
+ if (matches.length > 1) return { uncertain: true }; // same-name classes — identity unresolvable (#206)
4915
+ if (BUILTIN_RECEIVER_TYPES.has(norm)) return { external: true };
4916
+ return { uncertain: true }; // known non-builtin type with no project method
4917
+ }
4918
+
4919
+ function _calleeSingleOwnerMatch(index, def, fileEntry, call, name, language, flowEntry) {
4920
+ if (JS_GLOBAL_RECEIVERS.has(call.receiver)) return null;
4921
+ if (call.receiverIsModule) return null;
4922
+ const traits = langTraits(language);
4923
+ if (traits?.typeSystem === 'structural' &&
4924
+ _submoduleReceiverModule(index, fileEntry, call.receiver)) return null;
4925
+ const defs = index.symbols.get(name) || [];
4926
+ const ownerKeys = new Set();
4927
+ const ownerDefs = [];
4928
+ for (const d of defs) {
4929
+ if (!_calleeLanguageCompatible(index, d, language)) continue; // fix #257
4930
+ if (NON_CALLABLE_TYPES.has(d.type)) {
4931
+ if (d.type === 'field' && d.className && _callableFieldDef(index, d)) {
4932
+ ownerKeys.add(d.className);
4933
+ }
4934
+ continue;
4935
+ }
4936
+ const o = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
4937
+ if (!o) return null;
4938
+ ownerKeys.add(o);
4939
+ ownerDefs.push(d);
4940
+ }
4941
+ if (ownerKeys.size !== 1 || ownerDefs.length === 0) return null;
4942
+ if (ownerDefs.length > 1 && traits?.hasArityOverloads) return null;
4943
+ if (ownerDefs.some(d => isOverrideMarked(d))) return null;
4944
+ if (flowEntry) {
4945
+ const owner = ownerKeys.values().next().value;
4946
+ if (flowEntry.externalVia || (flowEntry.type && flowEntry.type !== owner)) return null;
4947
+ }
4948
+ // Parser-typed receiver disagreeing with the owner defeats the match
4949
+ // unless the owner is an ancestor of the receiver's type (fix #257 —
4950
+ // the flow-map defeater above, extended to #198 parser-typed receivers:
4951
+ // a `new Map()` local can never single-owner-confirm a project method).
4952
+ if (call.receiverType && typeof call.receiverType === 'string') {
4953
+ const owner = ownerKeys.values().next().value;
4954
+ const head = _structuralTypeHead(call.receiverType) || call.receiverType;
4955
+ const norm = _PY_TYPING_BUILTINS[head] || head;
4956
+ if (head !== owner && norm !== owner &&
4957
+ !_receiverTypeAncestors(index, head).has(owner)) return null;
4958
+ }
4959
+ if (traits?.typeSystem === 'nominal' && call.argCount != null &&
4960
+ !_callArityCompatible(call, ownerDefs, language)) return null;
4961
+ const match = ownerDefs[0];
4962
+ // The owner's def IS the querying def: an untyped-receiver call cannot
4963
+ // prove self-recursion (a true one resolves via self/this/Self) — the
4964
+ // receiver is more likely an external value (fix #237).
4965
+ if (match.file === def.file && match.startLine === def.startLine) return null;
4966
+ const callerFe = index.files.get(def.file);
4967
+ const matchFe = index.files.get(match.file);
4968
+ if (matchFe && callerFe &&
4969
+ (isTestFile(matchFe.relativePath, matchFe.language) || isTestPath(matchFe.relativePath)) &&
4970
+ !(isTestFile(callerFe.relativePath, callerFe.language) || isTestPath(callerFe.relativePath))) return null;
4971
+ return match;
4972
+ }
4973
+
4082
4974
  /**
4083
4975
  * Can this call's argument count fit any target definition's parameter
4084
4976
  * range? (Nominal languages only — their compilers enforce arity, so a
@@ -4833,4 +5725,4 @@ function findCallbackUsages(index, name) {
4833
5725
  return usages;
4834
5726
  }
4835
5727
 
4836
- module.exports = { getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages };
5728
+ module.exports = { getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _declaredFieldType, _projectTopLevelNames };