ucn 3.8.26 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/callers.js CHANGED
@@ -10,8 +10,9 @@ 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 } = require('./shared');
14
- const { scoreEdge } = require('./confidence');
13
+ const { NON_CALLABLE_TYPES, isOverrideMarked } = require('./shared');
14
+ const { scoreEdge, tierForResolution, TIER } = require('./confidence');
15
+ const { findGoModule } = require('./imports');
15
16
 
16
17
  /** Set.some() helper — like Array.some() but for Sets */
17
18
  function setSome(set, predicate) {
@@ -148,6 +149,22 @@ function findCallers(index, name, options = {}) {
148
149
  const callers = [];
149
150
  const stats = options.stats;
150
151
 
152
+ // Conservation accounting (tiered caller contract): when collectAccount
153
+ // is set (context/about/impact only — trace/blast/verify paths must stay
154
+ // byte-identical), candidates that the legacy flags would silently drop are
155
+ // RETAINED as unverified-tier entries (rendered in their own output
156
+ // section), and candidates positively excluded (call targets a different
157
+ // symbol) are recorded with a reason for the account arithmetic.
158
+ const collectAccount = !!options.collectAccount;
159
+ const accountRaw = collectAccount ? { unverifiedLines: [], excludedEntries: [] } : null;
160
+ // Cap on how many unverified entries get full enrichment (content + caller
161
+ // lookup); the rest stay as shadow-style records. Display caps are handled
162
+ // by formatters — this only bounds file reads.
163
+ const unverifiedEnrichLimit = options.unverifiedEnrichLimit ?? 10;
164
+ const recordExcluded = (filePath, line, reason) => {
165
+ if (accountRaw) accountRaw.excludedEntries.push({ file: filePath, line, reason });
166
+ };
167
+
151
168
  // Get definition lines to exclude them
152
169
  const definitions = index.symbols.get(name) || [];
153
170
  const definitionLines = new Set();
@@ -155,10 +172,183 @@ function findCallers(index, name, options = {}) {
155
172
  definitionLines.add(`${def.file}:${def.startLine}`);
156
173
  }
157
174
 
175
+ // Possible-dispatch tiering inputs (nominal contract surface) — all fixed
176
+ // per query, computed lazily once. targetTypes mirrors the receiver-class
177
+ // disambiguation set (target classes + non-overriding subtypes); owner
178
+ // keys are the distinct types defining a same-name method project-wide.
179
+ let _dispatchTargetTypes = null;
180
+ const dispatchTargetTypes = (targetDefs) => {
181
+ if (!_dispatchTargetTypes) _dispatchTargetTypes = _buildTargetTypeSet(index, targetDefs, definitions);
182
+ return _dispatchTargetTypes;
183
+ };
184
+ let _methodOwnerKeys = null;
185
+ const methodOwnerKeys = () => {
186
+ if (!_methodOwnerKeys) {
187
+ _methodOwnerKeys = new Set();
188
+ for (const d of definitions) {
189
+ if (NON_CALLABLE_TYPES.has(d.type)) {
190
+ // Function-typed FIELDS are callable owners (fix #219):
191
+ // `effect.transform(...)` may be ZodType.transform OR the
192
+ // $ZodTransformDef.transform property — single-method-owner
193
+ // confirmation is a lie when an interface declares the same
194
+ // name as a callable property. Structural only: Java/Rust
195
+ // cannot call a field by name (obj.f() is always a method
196
+ // there); Go CAN (func fields) but no measured Go board
197
+ // carries the family — deferred until measured.
198
+ if (d.type === 'field' && d.className && _callableFieldDef(index, d)) {
199
+ _methodOwnerKeys.add(d.className);
200
+ }
201
+ continue;
202
+ }
203
+ const o = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
204
+ if (o) _methodOwnerKeys.add(o);
205
+ }
206
+ }
207
+ return _methodOwnerKeys;
208
+ };
209
+ const _dispatchCountCache = new Map(); // via type -> candidate count
210
+ const countDispatchCandidates = (via) => {
211
+ if (!_dispatchCountCache.has(via)) {
212
+ _dispatchCountCache.set(via, _countDispatchCandidates(index, via, definitions));
213
+ }
214
+ return _dispatchCountCache.get(via);
215
+ };
216
+ // External-contract target (fix #210, gson-measured): the pinned method
217
+ // carries an explicit override marker (@Override / TS `override` / typing
218
+ // @override / Rust `impl Trait for X`) and has a SINGLE project-wide
219
+ // owner — so the overridden definition is not in the project (a visible
220
+ // project supertype defining the method would be a second owner). The
221
+ // method name provably exists on an external contract (java.lang.Number,
222
+ // std Iterator, ...): any external-typed receiver satisfies the same
223
+ // call, and unique project ownership stops being identity evidence.
224
+ // Receiver-evidence-free calls route possible-dispatch instead of
225
+ // confirming. Lazily computed once per query; null = not external.
226
+ let _extContract; // undefined → not yet computed
227
+ const externalContractTarget = () => {
228
+ if (_extContract !== undefined) return _extContract;
229
+ _extContract = null;
230
+ if (methodOwnerKeys().size === 1) {
231
+ const tDefs = (options.targetDefinitions || definitions).filter(d =>
232
+ !NON_CALLABLE_TYPES.has(d.type) && (d.className || d.receiver));
233
+ // `some`, not `every`: one marked overload proves the NAME exists
234
+ // on an external contract — receiver identity is then unprovable
235
+ // for every call shape (external signatures are invisible).
236
+ const marked = tDefs.find(d => isOverrideMarked(d));
237
+ if (marked) _extContract = { via: _externalContractVia(index, marked) };
238
+ }
239
+ return _extContract;
240
+ };
241
+
242
+ // ---- Rename-alias surfaces (import/export renames) ----
243
+ // Other surface names that can denote this symbol:
244
+ // import-side: `import { _gt as gt }` — fileEntry.importAliases, valid
245
+ // only inside the renaming file;
246
+ // export-side: `export { _enum as enum }` / Rust `pub use a as b` —
247
+ // exportDetails entries carrying an alias, valid in files importing the
248
+ // renaming module (or in that module itself).
249
+ // A renaming file must sit on an import path to the target (or define it)
250
+ // — otherwise it renames an unrelated same-name symbol. Matched call
251
+ // sites are beyond-text claims: the line does not contain the target name
252
+ // (account.js classifies them in the beyondText bucket).
253
+ const aliasTargetFiles = new Set((options.targetDefinitions || definitions)
254
+ .map(d => d.file).filter(Boolean));
255
+ const importAliasLocals = new Map(); // filePath -> Set<localName>
256
+ const exportAliasRenamers = new Map(); // aliasName -> Set<renamingFilePath>
257
+ for (const [fp, fe] of index.files) {
258
+ const hasImportRenames = fe.importAliases &&
259
+ fe.importAliases.some(a => a && a.original === name && a.local && a.local !== name);
260
+ const exportRenames = fe.exportDetails
261
+ ? fe.exportDetails.filter(e => e && e.alias && e.name === name && e.alias !== name)
262
+ : [];
263
+ if (!hasImportRenames && exportRenames.length === 0) continue;
264
+ const fpImports = index.importGraph.get(fp);
265
+ let linksTarget = aliasTargetFiles.has(fp) ||
266
+ (fpImports && setSome(fpImports, imp => aliasTargetFiles.has(imp)));
267
+ // One barrel hop: `export { _gt as gt } from './core/index.js'` where
268
+ // the barrel re-exports the defining file.
269
+ if (!linksTarget && fpImports) {
270
+ for (const imp of fpImports) {
271
+ const trans = index.importGraph.get(imp);
272
+ if (trans && setSome(trans, ti => aliasTargetFiles.has(ti))) {
273
+ linksTarget = true;
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ if (!linksTarget) continue;
279
+ if (hasImportRenames) {
280
+ for (const a of fe.importAliases) {
281
+ if (a && a.original === name && a.local && a.local !== name) {
282
+ if (!importAliasLocals.has(fp)) importAliasLocals.set(fp, new Set());
283
+ importAliasLocals.get(fp).add(a.local);
284
+ }
285
+ }
286
+ }
287
+ for (const e of exportRenames) {
288
+ // If the renaming file defines the name itself, the rename refers
289
+ // to that local definition (classic/schemas.ts's `export { _enum
290
+ // as enum }` renames ITS _enum wrapper, not core's _enum) — only
291
+ // credit it when the pinned target IS that local definition.
292
+ if (!aliasTargetFiles.has(fp) && definitions.some(d => d.file === fp)) continue;
293
+ if (!exportAliasRenamers.has(e.alias)) exportAliasRenamers.set(e.alias, new Set());
294
+ exportAliasRenamers.get(e.alias).add(fp);
295
+ }
296
+ }
297
+ // Files that can reach each renaming module through imports — re-export
298
+ // chains run deep (test → mini/index → external → schemas), so a fixed
299
+ // hop count misses real surfaces. Bounded reverse-import BFS; matching
300
+ // stays name+path specific, and tiering still demands its own evidence.
301
+ const aliasReachers = new Map(); // aliasName -> Set<filePath> (renamers + transitive importers)
302
+ for (const [aliasName, renamers] of exportAliasRenamers) {
303
+ const reach = new Set(renamers);
304
+ let frontier = [...renamers];
305
+ for (let depth = 0; depth < 4 && frontier.length && reach.size <= 5000; depth++) {
306
+ const next = [];
307
+ for (const f of frontier) {
308
+ const importers = index.exportGraph.get(f);
309
+ if (!importers) continue;
310
+ for (const importer of importers) {
311
+ if (!reach.has(importer)) {
312
+ reach.add(importer);
313
+ next.push(importer);
314
+ }
315
+ }
316
+ }
317
+ frontier = next;
318
+ }
319
+ aliasReachers.set(aliasName, reach);
320
+ }
321
+ const aliasNames = new Set(exportAliasRenamers.keys());
322
+ for (const locals of importAliasLocals.values()) {
323
+ for (const local of locals) aliasNames.add(local);
324
+ }
325
+ const hasAliasSurfaces = aliasNames.size > 0;
326
+
158
327
  // Phase 1: Find matching calls without reading file content.
159
328
  // Collect pending callers keyed by file — content is read only in Phase 2.
160
329
  const pendingByFile = new Map(); // filePath -> [{ call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver }]
161
330
  let pendingCount = 0;
331
+ // Route a would-be-dropped candidate into the pending pipeline as an
332
+ // unverified-tier entry (tiered caller contract: shown in its own
333
+ // section, never silently hidden). Does NOT count toward pendingCount —
334
+ // totals describe the confirmed answer.
335
+ const routeUnverified = (filePath, fileEntry, call, reason, calledAs, meta) => {
336
+ if (!collectAccount) return; // non-account paths (trace/blast/verify) keep the plain drop
337
+ if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
338
+ pendingByFile.get(filePath).push({
339
+ call, fileEntry, callerSymbol: null,
340
+ isMethod: call.isMethod || false,
341
+ isFunctionReference: !!call.isFunctionReference,
342
+ receiver: call.receiver, receiverType: call.receiverType,
343
+ calledAs,
344
+ _tier: TIER.UNVERIFIED, _reason: reason, _meta: meta,
345
+ // Dispatch-tiered routes carry their own resolution so JSON output
346
+ // distinguishes "possible virtual dispatch" from a bare uncertain.
347
+ _evidence: reason === 'possible-dispatch' ? { possibleDispatch: true }
348
+ : reason === 'method-ambiguous' ? { methodAmbiguous: true }
349
+ : { isUncertain: true },
350
+ });
351
+ };
162
352
  const maxResults = options.maxResults;
163
353
  // BUG-H1: when consumers (like `about`) need an accurate truncation header
164
354
  // ("showing N of <total>"), they pass needsTotal:true so Phase 1 runs to
@@ -166,9 +356,19 @@ function findCallers(index, name, options = {}) {
166
356
  // file reads stay bounded, but the candidate count reflects the true total.
167
357
  const needsTotal = !!options.needsTotal;
168
358
  const localTypeCache = new Map(); // `${filePath}:${startLine}` -> localTypes Map or null
359
+ const returnFlowCache = new Map(); // filePath -> return-type-flow map (see _buildReturnTypeFlowMap)
169
360
 
170
361
  // Use inverted callee index to skip files that don't contain calls to this name
171
- const calleeFiles = index.getCalleeFiles(name);
362
+ let calleeFiles = index.getCalleeFiles(name);
363
+ if (hasAliasSurfaces) {
364
+ // Alias surfaces are indexed under their own names — union their files in.
365
+ const union = new Set(calleeFiles || []);
366
+ for (const aliasName of aliasNames) {
367
+ const aliasFiles = index.getCalleeFiles(aliasName);
368
+ if (aliasFiles) for (const f of aliasFiles) union.add(f);
369
+ }
370
+ if (union.size > 0) calleeFiles = union;
371
+ }
172
372
  const fileIterator = calleeFiles
173
373
  ? [...calleeFiles].map(fp => [fp, index.files.get(fp)]).filter(([, fe]) => fe)
174
374
  : index.files;
@@ -180,50 +380,303 @@ function findCallers(index, name, options = {}) {
180
380
  const calls = getCachedCalls(index, filePath);
181
381
  if (!calls) continue;
182
382
 
183
- for (const call of calls) {
383
+ for (let call of calls) {
184
384
  // Skip if not matching our target name (also check alias resolution)
385
+ let calledAs = null; // surface name when matched via an import/export rename
185
386
  if (call.name !== name && call.resolvedName !== name &&
186
- !(call.resolvedNames && call.resolvedNames.includes(name))) continue;
387
+ !(call.resolvedNames && call.resolvedNames.includes(name))) {
388
+ if (!hasAliasSurfaces) continue;
389
+ const locals = importAliasLocals.get(filePath);
390
+ if (locals && locals.has(call.name)) {
391
+ calledAs = call.name;
392
+ } else {
393
+ const reach = aliasReachers.get(call.name);
394
+ if (reach && reach.has(filePath)) calledAs = call.name;
395
+ }
396
+ if (!calledAs) continue;
397
+ }
398
+
399
+ // Return-type flow: an untyped method receiver may be a
400
+ // variable assigned from a call with a known return annotation
401
+ // (response = client.get(...) with Client.get() -> Response).
402
+ // Structural languages get this everywhere (fix #199). Nominal
403
+ // languages get it on the account surface only (fix #207):
404
+ // a flow-typed interface receiver must reroute to visible
405
+ // possible-dispatch on mismatch, and that routing is
406
+ // collectAccount-gated — legacy commands would silently drop
407
+ // the edge instead. Real method calls only — the callback/
408
+ // reference branch keeps its own #206 routing.
409
+ // Copy-on-enrich: cached call objects stay parser-pure — the flow
410
+ // type derives from OTHER files' annotations, so it must never be
411
+ // persisted with this file's calls.
412
+ if (call.isMethod && call.receiver && !call.receiverType &&
413
+ (langTraits(fileEntry.language)?.typeSystem === 'structural' ||
414
+ (collectAccount && !call.isPotentialCallback && !call.isPathCall &&
415
+ langTraits(fileEntry.language)?.typeSystem === 'nominal'))) {
416
+ let flowMap = returnFlowCache.get(filePath);
417
+ if (flowMap === undefined) {
418
+ flowMap = _buildReturnTypeFlowMap(index, filePath, calls);
419
+ returnFlowCache.set(filePath, flowMap);
420
+ }
421
+ const flowEntry = flowMap && _lookupReturnTypeFlow(flowMap, call);
422
+ if (flowEntry && flowEntry.externalVia) {
423
+ // External producer (fix #220) — typed outside the
424
+ // project; blocks single-owner confirmation, routes
425
+ // possible-dispatch in the gate. Nominal-only entries.
426
+ call = { ...call, receiverExternalFlow: flowEntry.externalVia };
427
+ } else if (flowEntry) {
428
+ call = { ...call, receiverType: flowEntry.type,
429
+ ...(flowEntry.fromFile && { receiverTypeFlowFile: flowEntry.fromFile }) };
430
+ }
431
+ }
432
+
433
+ // Chained-receiver typing (fix #219): the receiver IS a call —
434
+ // `me._def.args.parseAsync(args, params).catch(...)` — so the
435
+ // producer's DECLARED return annotation types it (Promise →
436
+ // builtin → exclusion-grade under the trust gate; the target's
437
+ // own class validates; anything else attributes dispatch).
438
+ // Method producers must AGREE project-wide (the #207
439
+ // discipline); plain producers follow #199's unique-def rule.
440
+ // Structural only: nominal parsers don't capture receiverCall —
441
+ // their chained calls stay under the #204 dispatch tiering
442
+ // (visible, honest) until a measured family justifies the
443
+ // #207 origin-pinning rails there.
444
+ if (call.isMethod && !call.receiver && !call.receiverType && call.receiverCall &&
445
+ langTraits(fileEntry.language)?.typeSystem === 'structural') {
446
+ const chainedType = _chainedReceiverType(index, call, fileEntry.language);
447
+ if (chainedType) call = { ...call, receiverType: chainedType };
448
+ } else if (call.isMethod && !call.receiver && !call.receiverType && call.receiverCall &&
449
+ collectAccount && !call.isPotentialCallback &&
450
+ langTraits(fileEntry.language)?.typeSystem === 'nominal') {
451
+ // Nominal chained receivers (fix #220, cobra-measured):
452
+ // account-gated like the #207 nominal flow — mismatch
453
+ // reroutes are account-only; legacy would silently drop.
454
+ const flowEntry = _nominalChainedReceiverType(index, call, fileEntry, filePath);
455
+ if (flowEntry && flowEntry.externalVia) {
456
+ call = { ...call, receiverExternalFlow: flowEntry.externalVia };
457
+ } else if (flowEntry) {
458
+ call = { ...call, receiverType: flowEntry.type,
459
+ ...(flowEntry.fromFile && { receiverTypeFlowFile: flowEntry.fromFile }) };
460
+ }
461
+ }
187
462
 
188
463
  // For potential callbacks (function passed as arg), validate against symbol table
189
464
  // and skip complex binding resolution — just check the name exists
190
465
  if (call.isPotentialCallback) {
466
+ // Go closure-entry marker: a composite-field func literal
467
+ // records the ENCLOSING function's name at the closure line
468
+ // (deadcode reachability for RunE-style closures) — it is a
469
+ // self-line artifact, never a caller edge.
470
+ if (!call.isFunctionReference && !call.isMethod && call.fieldName &&
471
+ call.enclosingFunction && call.enclosingFunction.name === call.name) {
472
+ continue;
473
+ }
191
474
  const syms = definitions;
192
475
  if (!syms || syms.length === 0) continue;
476
+ const cbTargetDefs = options.targetDefinitions || definitions;
193
477
 
194
478
  // Go unexported visibility: lowercase functions are package-private.
195
- // Only allow callers from the same package directory.
479
+ // Only allow callers from the same package directory. Recorded
480
+ // with reason (not a silent drop) — same disposition as the
481
+ // plain-call visibility gate below.
196
482
  if (langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[a-z]/.test(name)) {
197
- const targetDefs = options.targetDefinitions || definitions;
198
483
  const targetPkgDirs = new Set(
199
- targetDefs.filter(d => d.file).map(d => path.dirname(d.file))
484
+ cbTargetDefs.filter(d => d.file).map(d => path.dirname(d.file))
200
485
  );
201
486
  if (targetPkgDirs.size > 0 && !targetPkgDirs.has(path.dirname(filePath))) {
487
+ recordExcluded(filePath, call.line, 'out-of-scope-package');
202
488
  continue;
203
489
  }
204
490
  }
205
491
 
206
- // Nominal type receiver disambiguation for callbacks (e.g. dc.worker)
492
+ // Package-qualified reference (Go): `pkg.Name` passed as a
493
+ // value denotes a symbol IN package pkg — never the current
494
+ // package (Go cannot self-import), never an unrelated
495
+ // same-name target. grpc-go-measured: `balancer.Get(priority.Name)`
496
+ // references the CONST priority.Name, not a pinned method
497
+ // `Name` — the target's own same-file/same-package evidence
498
+ // says nothing about what a qualified name resolves to.
207
499
  if (call.isMethod && call.receiver &&
500
+ langTraits(fileEntry.language)?.hasReceiverPackageCalls) {
501
+ const cbPkgRes = _receiverPackageResolution(index, fileEntry, call.receiver, cbTargetDefs);
502
+ if (cbPkgRes) {
503
+ if (cbPkgRes.singleSegment) {
504
+ // Single-segment import — Go stdlib, always external
505
+ recordExcluded(filePath, call.line, 'external-package');
506
+ continue;
507
+ }
508
+ if (!cbPkgRes.targetInPkg) {
509
+ recordExcluded(filePath, call.line, 'other-definition');
510
+ continue;
511
+ }
512
+ }
513
+ }
514
+
515
+ // A bare identifier can never denote a METHOD where bare
516
+ // names don't reach methods (fix #220, grpc-go-measured:
517
+ // `balancer.Get(Name)` references each package's const
518
+ // Name, never the pinned method Name() — Go method values
519
+ // require receivers, Rust `use` cannot import associated
520
+ // functions). Java exempt (static imports). Compiler-grade
521
+ // kind evidence — excluded with reason, all surfaces.
522
+ if (!call.isMethod && !call.receiver &&
523
+ langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
524
+ !langTraits(fileEntry.language)?.bareCallReachesMethods) {
525
+ const allMethodTargets = cbTargetDefs.length > 0 && cbTargetDefs.every(d =>
526
+ !NON_CALLABLE_TYPES.has(d.type) && (d.className || d.receiver));
527
+ if (allMethodTargets) {
528
+ recordExcluded(filePath, call.line, 'method-kind-mismatch');
529
+ continue;
530
+ }
531
+ }
532
+
533
+ // A paren-less member access is ALWAYS a field in Rust —
534
+ // method values are path-only (Type::method), so
535
+ // `self.paths.has_implicit_path` provably denotes the bool
536
+ // FIELD, never the method (fix #220, ripgrep-measured).
537
+ if (call.isMethod && call.isFunctionReference &&
538
+ langTraits(fileEntry.language)?.memberAccessNeverMethod) {
539
+ const allMethodTargets = cbTargetDefs.length > 0 && cbTargetDefs.every(d =>
540
+ !NON_CALLABLE_TYPES.has(d.type) && (d.className || d.receiver));
541
+ if (allMethodTargets) {
542
+ recordExcluded(filePath, call.line, 'method-kind-mismatch');
543
+ continue;
544
+ }
545
+ }
546
+
547
+ // Nominal type receiver disambiguation for callbacks (e.g. dc.worker)
548
+ if (call.isMethod &&
208
549
  langTraits(fileEntry.language)?.typeSystem === 'nominal') {
209
- const targetDefs = options.targetDefinitions || definitions;
210
550
  const targetTypes = new Set();
211
- for (const td of targetDefs) {
551
+ for (const td of cbTargetDefs) {
212
552
  if (td.className) targetTypes.add(td.className);
213
553
  if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
214
554
  }
215
- if (targetTypes.size > 0 && call.receiverType) {
216
- if (!targetTypes.has(call.receiverType)) continue;
555
+ if (targetTypes.size > 0 && call.receiver && call.receiverType &&
556
+ !targetTypes.has(call.receiverType)) {
557
+ // Raw-set mismatch — check the CLOSED set (aliases +
558
+ // non-overriding subtypes incl. Go embedding) before
559
+ // disposing: a reference through a promoting outer
560
+ // type or a type alias IS the target's method.
561
+ if (!dispatchTargetTypes(cbTargetDefs).has(call.receiverType)) {
562
+ // A method VALUE binds at the receiver's static
563
+ // type: a typed receiver that is neither the
564
+ // target type nor below it denotes ANOTHER
565
+ // type's method — excluded with reason, unless
566
+ // the type can virtually dispatch into the
567
+ // target (interface receiver), which routes
568
+ // visible possible-dispatch. Was a silent drop:
569
+ // the ground line surfaced as call-not-resolved
570
+ // (grpc-go/cursive-measured).
571
+ if (collectAccount) {
572
+ if (_dispatchCapableSupertype(index, fileEntry.language, call.receiverType, cbTargetDefs, definitions)) {
573
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
574
+ dispatchVia: call.receiverType,
575
+ dispatchCandidates: countDispatchCandidates(call.receiverType),
576
+ });
577
+ } else {
578
+ recordExcluded(filePath, call.line, 'receiver-type-mismatch');
579
+ }
580
+ }
581
+ continue;
582
+ }
583
+ }
584
+ // Under the account contract, a qualified reference
585
+ // whose receiver is neither an imported package
586
+ // (resolved above), the target type itself
587
+ // (type-qualified method reference), nor a validated
588
+ // type is a name match through an UNKNOWN owner — that
589
+ // includes receivers the parser could not capture at
590
+ // all (indexed/chained selectors: `xs[0].Name`).
591
+ // Mirrors the #204 method-call gate: a unique
592
+ // project-wide owner still confirms; multiple owners
593
+ // route to visible 'method-ambiguous' — never confirmed
594
+ // via the target's bare-identifier scope evidence below.
595
+ // A pinned TYPE target additionally routes regardless
596
+ // of owner count: `m.ResourceType` is a member access
597
+ // on a value — it cannot denote the type itself (only
598
+ // a package-qualified name can, and that resolved
599
+ // above; an alias-imported package receiver stays
600
+ // visible here rather than excluded).
601
+ if (collectAccount) {
602
+ const cbTypes = dispatchTargetTypes(cbTargetDefs);
603
+ const cbTypeQualified = call.receiver && cbTypes.has(call.receiver);
604
+ const cbTypedMatch = call.receiverType && cbTypes.has(call.receiverType);
605
+ const cbAllTypeTargets = cbTargetDefs.length > 0 &&
606
+ cbTargetDefs.every(d => IDENTITY_TYPE_KINDS.has(d.type));
607
+ if (!cbTypeQualified && !cbTypedMatch &&
608
+ (methodOwnerKeys().size > 1 || cbAllTypeTargets)) {
609
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs,
610
+ { dispatchCandidates: methodOwnerKeys().size });
611
+ continue;
612
+ }
613
+ }
614
+ }
615
+
616
+ // Resolution evidence for a bare-identifier function reference:
617
+ // the name reaches the target via module scope (same file),
618
+ // same-package scope (nominal languages), or an import edge
619
+ // (direct or one barrel hop). Argument position alone is a name
620
+ // match, not evidence — a local variable or an unrelated
621
+ // same-name symbol shadows it invisibly.
622
+ const cbTargetFiles = new Set(cbTargetDefs.map(d => d.file).filter(Boolean));
623
+ const cbSameFile = cbTargetFiles.has(filePath);
624
+ const cbSamePackage = !cbSameFile &&
625
+ langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
626
+ cbTargetDefs.some(d => d.file && path.dirname(d.file) === path.dirname(filePath));
627
+ let cbImportLink = false;
628
+ if (!cbSameFile && !cbSamePackage) {
629
+ const cbImports = index.importGraph.get(filePath);
630
+ cbImportLink = !!(cbImports && setSome(cbImports, imp => cbTargetFiles.has(imp)));
631
+ if (!cbImportLink && cbImports) {
632
+ for (const imp of cbImports) {
633
+ const trans = index.importGraph.get(imp);
634
+ if (trans && setSome(trans, ti => cbTargetFiles.has(ti))) {
635
+ cbImportLink = true;
636
+ break;
637
+ }
638
+ }
639
+ }
640
+ if (!cbImportLink) {
641
+ // Positive mis-link evidence: the name resolves to a
642
+ // same-name definition in this file (or one this file
643
+ // imports) that is NOT the target — same disposition
644
+ // as the import-graph disambiguation for plain calls.
645
+ const cbOtherDefFiles = new Set(definitions
646
+ .map(d => d.file).filter(f => f && !cbTargetFiles.has(f)));
647
+ if (cbOtherDefFiles.has(filePath) ||
648
+ (cbImports && setSome(cbImports, imp => cbOtherDefFiles.has(imp)))) {
649
+ recordExcluded(filePath, call.line, 'other-definition-import');
650
+ continue;
651
+ }
217
652
  }
218
653
  }
219
654
 
220
655
  // Find the enclosing function
221
656
  const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
657
+ // A parameter of the enclosing function with the same name
658
+ // shadows the target: `disposeEffect(effect)` inside
659
+ // `function disposeEffect(effect)` references the parameter,
660
+ // not a same-name module-scope symbol. Same for let/const
661
+ // locals and inner-arrow params (fix #203 — parser-side
662
+ // lexical scope walk sets call.localShadow; JS block-accurate,
663
+ // Python function-wide assignment semantics).
664
+ if (call.localShadow ||
665
+ (callerSymbol && Array.isArray(callerSymbol.paramsStructured) &&
666
+ callerSymbol.paramsStructured.some(p => p && p.name === call.name))) {
667
+ recordExcluded(filePath, call.line, 'local-shadow');
668
+ continue;
669
+ }
222
670
  if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
223
671
  pendingByFile.get(filePath).push({
224
672
  call, fileEntry, callerSymbol,
225
673
  isMethod: false, isFunctionReference: true, receiver: undefined,
226
- _evidence: { isFunctionReference: true }
674
+ calledAs,
675
+ _evidence: {
676
+ isFunctionReference: true,
677
+ hasImportEvidence: cbSameFile || cbImportLink,
678
+ hasSamePackageEvidence: cbSamePackage,
679
+ }
227
680
  });
228
681
  pendingCount++;
229
682
  continue;
@@ -232,6 +685,18 @@ function findCallers(index, name, options = {}) {
232
685
  // Resolve binding within this file (without mutating cached call objects)
233
686
  let bindingId = call.bindingId;
234
687
  let isUncertain = call.uncertain;
688
+ // Parser-detected lexical shadow (fix #203, hoisted by #222 —
689
+ // express-measured): a local let/var/param of the same name
690
+ // shadows the target at this reference, whatever record shape
691
+ // carried it. The callback fast path already excluded its own;
692
+ // an isFunctionReference-only argument ref (`router.use(path,
693
+ // f)` inside `use(fn)`'s forEach closure) used to slip past to
694
+ // binding resolution and exact-confirm on the shadowed name.
695
+ if (call.localShadow && !call.isPotentialCallback) {
696
+ recordExcluded(filePath, call.line, 'local-shadow');
697
+ continue;
698
+ }
699
+
235
700
  // Skip binding resolution for calls with non-self/this/cls receivers:
236
701
  // e.g., analyzer.analyze_instrument() should NOT resolve to a local
237
702
  // standalone function def `analyze_instrument` — they're different symbols.
@@ -240,6 +705,27 @@ function findCallers(index, name, options = {}) {
240
705
  const skipLocalBinding = call.receiver && !selfReceivers.has(call.receiver);
241
706
  if (!bindingId && !skipLocalBinding) {
242
707
  let bindings = (fileEntry.bindings || []).filter(b => b.name === call.name);
708
+ // A bare call cannot bind to a METHOD def where bare names
709
+ // never reach methods (fix #220, cobra-measured): Go's
710
+ // func (c *Command) MarkFlagDirname and func MarkFlagDirname
711
+ // coexist in one package — the bare call denotes the
712
+ // FUNCTION. Java keeps both (implicit this-calls). Fix
713
+ // #222 (rich-measured) extends this to structural: the
714
+ // bindings table lists class members, but Python bare-name
715
+ // lookup never enters class scope (`cell_len(self.plain)`
716
+ // inside Text binds the module-level import of
717
+ // cells.cell_len, not Text.cell_len) and a JS class
718
+ // member is not a file binding either — the structural
719
+ // 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);
726
+ return !(sym && (sym.className || sym.receiver));
727
+ });
728
+ }
243
729
  // For Go, also check sibling files in same directory (same package scope)
244
730
  if (bindings.length === 0 && langTraits(fileEntry.language)?.packageScope === 'directory') {
245
731
  const dir = path.dirname(filePath);
@@ -256,6 +742,15 @@ function findCallers(index, name, options = {}) {
256
742
  }
257
743
  if (bindings.length === 1) {
258
744
  bindingId = bindings[0].id;
745
+ } else if (bindings.length > 1 && !call.isMethod &&
746
+ call.isConstructor &&
747
+ bindings.filter(b => b.type === 'class' || b.type === 'function').length === 1) {
748
+ // Constructor calls bind to constructable symbols: `new ZodArray()`
749
+ // must resolve to the class binding, not a same-name field/const
750
+ // elsewhere in the file (TS declaration merging, bottom-of-file
751
+ // namespace aliases). All `new`-style languages mark these
752
+ // (JS/TS `new`, Java `new`, Go/Rust composite/struct literals).
753
+ bindingId = bindings.find(b => b.type === 'class' || b.type === 'function').id;
259
754
  } else if (bindings.length > 1 && !call.isMethod) {
260
755
  // For implicit same-class calls (Java: execute() means this.execute()),
261
756
  // try to resolve via caller's className before marking uncertain
@@ -325,19 +820,64 @@ function findCallers(index, name, options = {}) {
325
820
  // self/this.method() calls can be resolved by same-class matching
326
821
  // even when binding is ambiguous (e.g. method exists in multiple classes)
327
822
  let resolvedBySameClass = false;
823
+ // Receiver/path type known to mismatch the target: such an edge can
824
+ // never tier as confirmed even when legacy includeUncertain keeps it
825
+ // visible (scoreEdge checks hasReceiverType before isUncertain, so
826
+ // without this flag a known mismatch would score receiver-hint 0.80).
827
+ let typeMismatch = false;
828
+ // Structural languages: receiver-hint requires a VALIDATED match
829
+ // (receiverType ∈ target class + subtypes). Ancestor-kept and
830
+ // trust-gate-passed types fall back to import/scope evidence —
831
+ // an unvalidated annotation must not upgrade the tier.
832
+ let receiverTypeValidated = false;
833
+ // Nominal local-inference match (receiver typed via
834
+ // _buildTypedLocalTypeMap ∈ target types) — receiver evidence
835
+ // for the dispatch tiering below.
836
+ let nominalInferredMatch = false;
837
+ // Identity discipline (fix #206): the receiver's type NAME
838
+ // matches the target's type, but several distinct types share
839
+ // that name and none is resolvable from this file's scope —
840
+ // not confirmation evidence, not exclusion evidence. Routed
841
+ // method-ambiguous under the account contract.
842
+ let receiverTypeUnresolved = false;
328
843
  if (call.isMethod) {
329
844
  if (call.selfAttribute && fileEntry.language === 'python') {
330
845
  // self.attr.method() — resolve via attribute type inference
331
846
  const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
332
847
  if (!callerSymbol?.className) {
333
848
  // Can't resolve — include only if includeMethods requested
334
- if (!options.includeMethods) continue;
849
+ if (!options.includeMethods) {
850
+ routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
851
+ continue;
852
+ }
335
853
  } else {
336
854
  const attrTypes = getInstanceAttributeTypes(index, filePath, callerSymbol.className);
337
855
  const targetClass = attrTypes?.get(call.selfAttribute);
338
856
  if (targetClass && definitions.some(d => d.className === targetClass)) {
857
+ // fix #202b: the resolved class must be the
858
+ // TARGET's class (or an ancestor — dynamic
859
+ // dispatch). self.attr.m() resolving to class X
860
+ // is not a caller of a pinned target Y.m.
861
+ const tDefs = options.targetDefinitions || definitions;
862
+ const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
863
+ if (!targetClasses.has(targetClass) &&
864
+ !_isAncestorOfTargetClass(index, targetClass, tDefs)) {
865
+ recordExcluded(filePath, call.line, 'other-definition');
866
+ continue;
867
+ }
868
+ // fix #218: attribute typed as a STRICT ancestor
869
+ // of the pinned target's class — reaching the
870
+ // subclass override is dynamic dispatch (#204
871
+ // physics). Demote-only, account-gated.
872
+ if (collectAccount && !targetClasses.has(targetClass)) {
873
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
874
+ dispatchVia: targetClass,
875
+ });
876
+ continue;
877
+ }
339
878
  resolvedBySameClass = true;
340
879
  } else if (!options.includeMethods) {
880
+ routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
341
881
  continue;
342
882
  }
343
883
  }
@@ -345,24 +885,27 @@ function findCallers(index, name, options = {}) {
345
885
  // self/this/super.method() — resolve to same-class or parent method
346
886
  const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
347
887
  if (!callerSymbol?.className) {
348
- if (!options.includeMethods) continue;
888
+ if (!options.includeMethods) {
889
+ routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
890
+ continue;
891
+ }
349
892
  } else {
350
893
  // For super(), skip same-class — only check parent chain
351
- let matchesDef = call.receiver === 'super'
352
- ? false
353
- : definitions.some(d => d.className === callerSymbol.className);
894
+ let matchedClass = call.receiver !== 'super' &&
895
+ definitions.some(d => d.className === callerSymbol.className)
896
+ ? callerSymbol.className : null;
354
897
  // Walk inheritance chain using BFS if not found in same class
355
- if (!matchesDef) {
898
+ if (!matchedClass) {
356
899
  const visited = new Set([callerSymbol.className]);
357
900
  const callerFile = callerSymbol.file || filePath;
358
901
  const startParents = index._getInheritanceParents(callerSymbol.className, callerFile) || [];
359
902
  const queue = startParents.map(p => ({ name: p, contextFile: callerFile }));
360
- while (queue.length > 0 && !matchesDef) {
903
+ while (queue.length > 0 && !matchedClass) {
361
904
  const { name: current, contextFile } = queue.shift();
362
905
  if (visited.has(current)) continue;
363
906
  visited.add(current);
364
- matchesDef = definitions.some(d => d.className === current);
365
- if (!matchesDef) {
907
+ if (definitions.some(d => d.className === current)) matchedClass = current;
908
+ if (!matchedClass) {
366
909
  const resolvedFile = index._resolveClassFile(current, contextFile);
367
910
  const grandparents = index._getInheritanceParents(current, resolvedFile) || [];
368
911
  for (const gp of grandparents) {
@@ -371,9 +914,75 @@ function findCallers(index, name, options = {}) {
371
914
  }
372
915
  }
373
916
  }
374
- if (matchesDef) {
917
+ if (matchedClass) {
918
+ // fix #202b: same-class resolution must land on the
919
+ // TARGET's class (or an ancestor — dynamic dispatch
920
+ // may run the target override). self.path() inside
921
+ // StandardImpl resolves to StandardImpl::path — not
922
+ // a caller of a pinned target Haystack::path.
923
+ // NOMINAL languages + Python: the exclusion is sound
924
+ // only when the inheritance graph is complete; TS
925
+ // hierarchies hide edges UCN can't see (zod's
926
+ // `declare class` merging — measured: the structural
927
+ // guard excluded true callers). Python's recorded
928
+ // bases are reliable, but MRO adds a trap nominal
929
+ // languages lack: `self.method()` inside Mixin can
930
+ // dispatch to a CO-PARENT's method through a common
931
+ // subclass (class C(Target, Mixin) — C's MRO finds
932
+ // Target.method before Mixin's). Exclusion therefore
933
+ // also requires that the matched class and the
934
+ // target's class share no project descendant.
935
+ const sameClassTraits = langTraits(fileEntry.language);
936
+ if (sameClassTraits?.typeSystem === 'nominal' ||
937
+ fileEntry.language === 'python') {
938
+ const tDefs = options.targetDefinitions || definitions;
939
+ const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
940
+ if (!targetClasses.has(matchedClass) &&
941
+ !_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
942
+ !(fileEntry.language === 'python' &&
943
+ _shareProjectDescendant(index, matchedClass, targetClasses))) {
944
+ recordExcluded(filePath, call.line, 'other-definition');
945
+ continue;
946
+ }
947
+ // fix #218 (rich-measured): the match landed on
948
+ // a STRICT ancestor of the pinned target's class
949
+ // (or a Python co-parent via shared descendant) —
950
+ // `self.render()` inside abstract ProgressColumn
951
+ // lexically binds the ancestor's def; reaching the
952
+ // pinned SUBCLASS override is dynamic dispatch,
953
+ // possible but not confirmable (#204 physics).
954
+ // Demote-only, account-gated; when the pinned
955
+ // target IS the declaring class, matchedClass ∈
956
+ // targetClasses and confirmation stands.
957
+ if (collectAccount && !targetClasses.has(matchedClass)) {
958
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
959
+ dispatchVia: matchedClass,
960
+ });
961
+ continue;
962
+ }
963
+ } else if (collectAccount) {
964
+ // fix #213 (JS/TS, zod seed-B-measured): the same
965
+ // pinning check, but ROUTED visible instead of
966
+ // excluded — `this.min()` inside ZodString lexically
967
+ // binds ZodString.min or a subclass override, never
968
+ // a pinned sibling ZodNumber.min (cross-sibling
969
+ // spray was ~23 of 38 FP edges). Exclusion stays
970
+ // off: TS `declare class` merging hides extends
971
+ // edges, so an unrelated-looking class may still be
972
+ // an ancestor (the original #202b structural revert).
973
+ // Legacy keeps confirming (drop-vs-route asymmetry).
974
+ const tDefs = options.targetDefinitions || definitions;
975
+ const targetClasses = new Set(tDefs.map(d => d.className).filter(Boolean));
976
+ if (!targetClasses.has(matchedClass) &&
977
+ !_isAncestorOfTargetClass(index, matchedClass, tDefs) &&
978
+ !_shareProjectDescendant(index, matchedClass, targetClasses)) {
979
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs);
980
+ continue;
981
+ }
982
+ }
375
983
  resolvedBySameClass = true;
376
984
  } else if (!options.includeMethods) {
985
+ routeUnverified(filePath, fileEntry, call, 'method-no-evidence', calledAs);
377
986
  continue;
378
987
  }
379
988
  }
@@ -381,14 +990,58 @@ function findCallers(index, name, options = {}) {
381
990
  // Go doesn't use this/self/cls - always include Go method calls
382
991
  // Java method calls are always obj.method() - include by default
383
992
  // Rust Type::method() calls - include by default (associated functions)
384
- // For other languages, skip method calls unless explicitly requested
385
- if (langTraits(fileEntry.language)?.methodCallInclusion === 'explicit' && !options.includeMethods) continue;
993
+ // For other languages, skip method calls unless explicitly requested.
994
+ // Under collectAccount the gate falls through instead: receiver
995
+ // evidence computed at the push site decides the tier (a require'd
996
+ // module receiver earns scope-match/confirmed; an unknown receiver
997
+ // is marked uncertain in the binding block and routes below).
998
+ if (langTraits(fileEntry.language)?.methodCallInclusion === 'explicit' && !options.includeMethods) {
999
+ if (!collectAccount) continue;
1000
+ }
386
1001
  }
387
1002
  }
388
1003
 
1004
+ // Declared-field receiver typing (fix #202, extended to
1005
+ // structural by fix #219): one-hop field receivers
1006
+ // (self.dent.path() / h.inner.Run() / this._map.has()) resolve
1007
+ // through the field's DECLARED type. Computed before binding
1008
+ // checks — name-bindings don't model receivers, so a same-file
1009
+ // `path` binding must not claim a call whose receiver field is
1010
+ // typed elsewhere. JS/TS `this`-rooted hops resolve their root
1011
+ // type here (the enclosing class — the parser's walk does not
1012
+ // track class context; arrows keep lexical `this`, and nested
1013
+ // function declarations are their own symbols WITHOUT
1014
+ // className, so dynamic-this shapes resolve to nothing).
1015
+ let fieldHopType = null;
1016
+ let fieldHopRootType = call.receiverRootType;
1017
+ if (!fieldHopRootType && call.receiverField && call.receiverRoot === 'this' &&
1018
+ !resolvedBySameClass &&
1019
+ langTraits(fileEntry.language)?.typeSystem === 'structural') {
1020
+ const hopEnclosing = index.findEnclosingFunction(filePath, call.line, true);
1021
+ if (hopEnclosing?.className) fieldHopRootType = hopEnclosing.className;
1022
+ }
1023
+ if (call.isMethod && !call.receiverType && call.receiverField && fieldHopRootType &&
1024
+ !resolvedBySameClass) {
1025
+ fieldHopType = _declaredFieldType(index, fieldHopRootType, call.receiverField, fileEntry.language);
1026
+ }
1027
+ // Dispatch attribution (contract surface only): a field DECLARED
1028
+ // as a project interface/trait carries no exclusion evidence
1029
+ // (_declaredFieldType returns null — any implementor may receive
1030
+ // the call), but it IS positive evidence of possible dispatch.
1031
+ // Resolved separately so the unverified tier can attribute the
1032
+ // edge: "possible-dispatch via <Interface> — 1 of N impls".
1033
+ let fieldDispatchType = null;
1034
+ if (collectAccount && fieldHopType === null &&
1035
+ call.isMethod && !call.receiverType && call.receiverField && fieldHopRootType &&
1036
+ !resolvedBySameClass) {
1037
+ fieldDispatchType = _declaredFieldInterfaceType(index, fieldHopRootType, call.receiverField, fileEntry.language);
1038
+ }
1039
+
389
1040
  // Skip uncertain calls unless resolved by same-class matching or explicitly requested
390
1041
  if (isUncertain && !resolvedBySameClass && !options.includeUncertain) {
391
1042
  if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
1043
+ routeUnverified(filePath, fileEntry, call,
1044
+ call.isMethod ? 'method-no-evidence' : 'ambiguous-binding', calledAs);
392
1045
  continue;
393
1046
  }
394
1047
 
@@ -400,7 +1053,110 @@ function findCallers(index, name, options = {}) {
400
1053
  const targetDefs = options.targetDefinitions || definitions;
401
1054
  const targetBindingIds = new Set(targetDefs.map(d => d.bindingId).filter(Boolean));
402
1055
  if (targetBindingIds.size > 0 && bindingId && !targetBindingIds.has(bindingId)) {
403
- continue;
1056
+ // fix #202: a declared-field receiver type that VALIDATES
1057
+ // against the target overrides name-binding evidence —
1058
+ // self.dent.path() name-binds to a same-file `path` def,
1059
+ // but the field's declared type says the call is the
1060
+ // target's (receiver-typed edges fall through to the
1061
+ // receiver-class disambiguation below).
1062
+ const fieldHopMatchesTarget = fieldHopType && targetDefs.some(d =>
1063
+ (d.className || (d.receiver || '').replace(/^\*/, '')) === fieldHopType);
1064
+ if (!fieldHopMatchesTarget) {
1065
+ recordExcluded(filePath, call.line, 'other-definition');
1066
+ continue;
1067
+ }
1068
+ }
1069
+
1070
+ // Name-level import shadowing (fix #209, httpx-measured): an
1071
+ // explicit import binding of the NAME rebinds it for the whole
1072
+ // file — `from urllib.parse import unquote` makes every bare
1073
+ // `unquote(...)` urllib's, regardless of which project files
1074
+ // this file also imports (file-level import edges are not
1075
+ // name-level evidence; httpx/_urls.py imports ._utils for
1076
+ // OTHER names while unquote comes from urllib). A bare call
1077
+ // only reaches the project def when SOME import binding of the
1078
+ // name resolves to a target file (directly or one barrel hop),
1079
+ // or the target is defined in this file. Mis-resolved project
1080
+ // modules must not exclude: a binding to an unresolved module
1081
+ // whose first segment matches a project directory routes
1082
+ // visible instead.
1083
+ if (!bindingId && !call.isMethod && !calledAs &&
1084
+ langTraits(fileEntry.language)?.typeSystem === 'structural' &&
1085
+ (fileEntry.importBindings || []).length > 0) {
1086
+ const nameBindings = fileEntry.importBindings.filter(b => b.name === call.name);
1087
+ const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
1088
+ // fix #215 (rich-measured: 225 builtin `print(...)` calls
1089
+ // confirmed against rich's def via file-level import edges):
1090
+ // a bare name in a module file resolves to a local binding,
1091
+ // an import binding of THAT name, or a builtin/global — it
1092
+ // can never reach an unimported project def. No local
1093
+ // binding (bindingId), no import binding of the name, no
1094
+ // star import that could inject it → the call provably
1095
+ // does not denote the target. Same correctness family as
1096
+ // the all-external shadow exclusion below; the
1097
+ // importBindings.length precondition keeps script files
1098
+ // (no module discipline) out.
1099
+ // `resolvedName` means the parser already resolved a local
1100
+ // alias to the original through a real import binding
1101
+ // (`const { parse: csvParse } = require(...)`) — name-level
1102
+ // evidence by construction; importBindings store the
1103
+ // ORIGINAL name, so the local alias must not look unbound.
1104
+ if (nameBindings.length === 0 && !call.resolvedName &&
1105
+ tFiles.size > 0 && !tFiles.has(filePath) &&
1106
+ !(fileEntry.importNames || []).includes('*')) {
1107
+ recordExcluded(filePath, call.line, 'name-not-in-scope');
1108
+ continue;
1109
+ }
1110
+ // A same-file pin does not put a bare name in scope when
1111
+ // every pinned def is class-scoped (fix #222, rich-measured):
1112
+ // the class member is outside the bare-name lookup chain,
1113
+ // so the file's import bindings own the name.
1114
+ const samefilePinsOutOfScope = targetDefs.length > 0 &&
1115
+ targetDefs.every(d => d.className);
1116
+ if (nameBindings.length > 0 && (!tFiles.has(filePath) || samefilePinsOutOfScope)) {
1117
+ // Name-level export-chain ownership (fix #217): each
1118
+ // binding is chased by NAME, not by file — `from
1119
+ // .render import render` pins to tests/render.py's own
1120
+ // def and cannot denote markup.render no matter what
1121
+ // tests/render.py imports (file-level reach said yes
1122
+ // through console.py — 24 rich FP edges). Exclusion
1123
+ // requires EVERY binding to be a definitive dead end:
1124
+ // external pins (#209c) or chains that provably
1125
+ // terminate away from the targets; any un-modelable
1126
+ // surface (CJS, star imports, module assignments,
1127
+ // resolver gaps) routes 'unknown' and blocks exclusion.
1128
+ let reaches = false;
1129
+ let undetermined = false;
1130
+ for (const b of nameBindings) {
1131
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
1132
+ if (!rel) {
1133
+ // Unresolved module: external — unless it is
1134
+ // relative (project-internal by construction)
1135
+ // or its first segment names a project path
1136
+ // (resolution gap, not externality evidence)
1137
+ const mod = String(b.module);
1138
+ const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
1139
+ if (mod.startsWith('.') ||
1140
+ (firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
1141
+ undetermined = true;
1142
+ }
1143
+ continue;
1144
+ }
1145
+ const resolvedAbs = path.join(index.root, rel);
1146
+ const verdict = _nameBindingReaches(index, resolvedAbs, b.name, tFiles);
1147
+ if (verdict === 'yes') { reaches = true; break; }
1148
+ if (verdict === 'unknown') undetermined = true;
1149
+ }
1150
+ if (!reaches && !undetermined) {
1151
+ // Every import binding of this name pins away from
1152
+ // the pinned targets (external module, or a project
1153
+ // def the name-chase resolved with certainty) — the
1154
+ // bare name is rebound away from the target
1155
+ // (compiler-checked module semantics).
1156
+ recordExcluded(filePath, call.line, 'other-definition-import');
1157
+ continue;
1158
+ }
1159
+ }
404
1160
  }
405
1161
 
406
1162
  // Import-graph disambiguation for JS/TS/Python: when multiple definitions of
@@ -424,7 +1180,25 @@ function findCallers(index, name, options = {}) {
424
1180
  break;
425
1181
  }
426
1182
  }
427
- if (!foundViaReexport) continue;
1183
+ if (!foundViaReexport) {
1184
+ // Disposition depends on what the caller DOES import:
1185
+ // - imports a DIFFERENT same-name def's file → positive
1186
+ // mis-link evidence → excluded other-definition-import
1187
+ // - imports neither def → pure ambiguity, no positive
1188
+ // evidence → unverified tier (visible), per the contract
1189
+ const otherDefFiles = new Set((index.symbols.get(name) || [])
1190
+ .map(d => d.file).filter(f => f && !targetFiles.has(f)));
1191
+ const importsOtherDef = imports && setSome(imports, imp => otherDefFiles.has(imp));
1192
+ if (importsOtherDef || otherDefFiles.has(filePath)) {
1193
+ recordExcluded(filePath, call.line, 'other-definition-import');
1194
+ continue;
1195
+ }
1196
+ if (collectAccount) {
1197
+ routeUnverified(filePath, fileEntry, call, 'no-import-link', calledAs);
1198
+ continue;
1199
+ }
1200
+ continue;
1201
+ }
428
1202
  }
429
1203
  }
430
1204
  }
@@ -436,6 +1210,7 @@ function findCallers(index, name, options = {}) {
436
1210
  targetDefs.filter(d => d.file).map(d => path.dirname(d.file))
437
1211
  );
438
1212
  if (targetPkgDirs.size > 0 && !targetPkgDirs.has(path.dirname(filePath))) {
1213
+ recordExcluded(filePath, call.line, 'out-of-scope-package');
439
1214
  continue;
440
1215
  }
441
1216
  }
@@ -445,15 +1220,131 @@ function findCallers(index, name, options = {}) {
445
1220
  // and cli.Run() (package call, isMethod:false) from matching DeploymentController.Run.
446
1221
  // Rust path calls (module::func(), Type::new()) bypass this filter — they're
447
1222
  // scoped_identifier calls that can target both standalone functions and impl methods.
448
- if (!bindingId && !resolvedBySameClass && !call.isPathCall &&
1223
+ // The binding guard is per-direction (fix #220): a name binding
1224
+ // is receiver-blind, so it cannot make x.f() reach a standalone
1225
+ // function in languages whose dot-calls provably never do (Rust
1226
+ // needs (s.f)() parens — ripgrep's `.preprocessor_globs(...)`
1227
+ // bound the same-file FUNCTION def). Go keeps !bindingId there:
1228
+ // func-typed fields ARE name-callable. The bare-call direction
1229
+ // keeps !bindingId — the upstream binding filter already
1230
+ // re-resolves those to function defs where methods are
1231
+ // unreachable.
1232
+ if ((!bindingId || (call.isMethod &&
1233
+ !langTraits(fileEntry.language)?.methodCallReachesFunctions)) &&
1234
+ !resolvedBySameClass && !call.isPathCall &&
449
1235
  langTraits(fileEntry.language)?.typeSystem === 'nominal') {
450
1236
  const targetHasClass = targetDefs.some(d => d.className);
451
1237
  if (call.isMethod && !targetHasClass) {
452
1238
  // Method call but target is a standalone function — skip
1239
+ recordExcluded(filePath, call.line, 'method-kind-mismatch');
453
1240
  continue;
454
1241
  }
455
1242
  if (!call.isMethod && targetHasClass) {
456
1243
  // Non-method call but target is a class method — skip
1244
+ recordExcluded(filePath, call.line, 'method-kind-mismatch');
1245
+ continue;
1246
+ }
1247
+ }
1248
+
1249
+ // Module receiver: httpx.get() / ns.helper() dispatches to a
1250
+ // module export — it can never be a CLASS METHOD call. Applies
1251
+ // only when every target is a class method; standalone-function
1252
+ // and class (constructor) targets keep flowing on import evidence.
1253
+ if (!bindingId && !resolvedBySameClass && call.isMethod && call.receiverIsModule &&
1254
+ langTraits(fileEntry.language)?.typeSystem === 'structural' &&
1255
+ targetDefs.length > 0 && targetDefs.every(d => d.className)) {
1256
+ isUncertain = true;
1257
+ typeMismatch = true;
1258
+ if (collectAccount) {
1259
+ recordExcluded(filePath, call.line, 'module-receiver');
1260
+ continue;
1261
+ }
1262
+ if (!options.includeUncertain) {
1263
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
1264
+ continue;
1265
+ }
1266
+ }
1267
+
1268
+ // Module-qualified ownership, structural (fix #209 — the #206
1269
+ // Go rule transferred): `httpcore.URL(...)` denotes URL IN the
1270
+ // httpcore module — an EXTERNAL module's attribute can never be
1271
+ // the project's URL class. Resolve the receiver's own import
1272
+ // binding (name-level — the file importing the target for
1273
+ // other names proves nothing): binding module external →
1274
+ // excluded; resolves to a project file that doesn't reach a
1275
+ // target (directly or one re-export hop) → visible, not
1276
+ // excluded (deep barrel chains exceed the hop budget);
1277
+ // unresolved-but-project-looking → visible (resolver gap).
1278
+ if (!bindingId && !resolvedBySameClass && call.isMethod && call.receiverIsModule &&
1279
+ call.receiver && langTraits(fileEntry.language)?.typeSystem === 'structural' &&
1280
+ (fileEntry.importBindings || []).length > 0) {
1281
+ const recvBindings = fileEntry.importBindings.filter(b => b.name === call.receiver);
1282
+ const tFiles = new Set(targetDefs.map(d => d.file).filter(Boolean));
1283
+ if (recvBindings.length > 0 && !tFiles.has(filePath)) {
1284
+ let reaches = false;
1285
+ let projectish = false;
1286
+ for (const b of recvBindings) {
1287
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
1288
+ if (!rel) {
1289
+ const mod = String(b.module);
1290
+ const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
1291
+ if (mod.startsWith('.') ||
1292
+ (firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
1293
+ projectish = true;
1294
+ }
1295
+ continue;
1296
+ }
1297
+ projectish = true;
1298
+ const resolvedAbs = path.join(index.root, rel);
1299
+ // Name-level ownership (fix #217 applied to module
1300
+ // receivers — zod family D): `z._default(...)` asks
1301
+ // for the MODULE's `_default` attribute; with three
1302
+ // project defs of the name, only the one the export
1303
+ // chain actually exposes can be the callee. The
1304
+ // chase is definitive only on fully-modeled ESM/
1305
+ // Python surfaces — 'unknown' (CJS, stars, module
1306
+ // assignments) falls back to file-level reach.
1307
+ const verdict = _nameBindingReaches(index, resolvedAbs, call.name, tFiles);
1308
+ if (verdict === 'yes' ||
1309
+ (verdict === 'unknown' && _importReaches(index, resolvedAbs, tFiles))) {
1310
+ reaches = true; break;
1311
+ }
1312
+ }
1313
+ if (!reaches) {
1314
+ if (!projectish) {
1315
+ recordExcluded(filePath, call.line, 'external-package');
1316
+ continue;
1317
+ }
1318
+ if (collectAccount) {
1319
+ routeUnverified(filePath, fileEntry, call, 'no-import-link', calledAs);
1320
+ continue;
1321
+ }
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ // Structural typed-receiver kind filter: a method call on a
1327
+ // receiver with a known class type can only target that class's
1328
+ // methods — never a standalone function. Module receivers are
1329
+ // never typed (localVarTypes only types constructor results,
1330
+ // annotations, and literals), so module-qualified calls to
1331
+ // standalone functions keep flowing on import evidence. Class
1332
+ // targets are exempt: their own type matching runs below.
1333
+ // Trust gate: only builtin/project-class types are positive
1334
+ // evidence — an alias/interface annotation can wrap the target
1335
+ // (`const x: Fetcher = { fetch }`), so it must not exclude.
1336
+ if (!bindingId && !resolvedBySameClass && call.isMethod && call.receiverType &&
1337
+ langTraits(fileEntry.language)?.typeSystem === 'structural' &&
1338
+ _receiverTypeTrustedForExclusion(index, call.receiverType) &&
1339
+ !targetDefs.some(d => d.className || d.receiver || NON_CALLABLE_TYPES.has(d.type))) {
1340
+ isUncertain = true;
1341
+ typeMismatch = true;
1342
+ if (collectAccount) {
1343
+ recordExcluded(filePath, call.line, 'receiver-type-mismatch');
1344
+ continue;
1345
+ }
1346
+ if (!options.includeUncertain) {
1347
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
457
1348
  continue;
458
1349
  }
459
1350
  }
@@ -464,16 +1355,36 @@ function findCallers(index, name, options = {}) {
464
1355
  // like "fmt", "os") and third-party calls (import graph has no edge to target).
465
1356
  if (!call.isMethod && call.receiver && !bindingId &&
466
1357
  langTraits(fileEntry.language)?.hasReceiverPackageCalls) {
467
- const callerFileImports = fileEntry.imports || [];
468
- const importModule = callerFileImports.find(mod => {
469
- const parts = mod.split('/');
470
- const last = parts[parts.length - 1];
471
- const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
472
- return pkgName === call.receiver;
473
- });
474
- if (importModule) {
475
- if (!importModule.includes('/')) {
1358
+ const pkgRes = _receiverPackageResolution(index, fileEntry, call.receiver, targetDefs);
1359
+ if (pkgRes) {
1360
+ if (pkgRes.singleSegment) {
476
1361
  // Single-segment import — Go stdlib, always external
1362
+ recordExcluded(filePath, call.line, 'external-package');
1363
+ continue;
1364
+ }
1365
+ // A package-qualified name can never denote the
1366
+ // caller's own FILE's package (Go cannot self-import):
1367
+ // a pinned target defined only in this very file is
1368
+ // positively a different symbol — measured:
1369
+ // &certprovider.KeyMaterial{...} inside KeyMaterial()
1370
+ // claiming a self-edge through a local binding.
1371
+ if (targetDefs.length > 0 && targetDefs.every(d => d.file === filePath)) {
1372
+ recordExcluded(filePath, call.line, 'other-definition');
1373
+ continue;
1374
+ }
1375
+ // Receiver-package identity (fix #206b): an import edge
1376
+ // to the target's file proves the caller USES the
1377
+ // target's package, not that THIS qualified name
1378
+ // resolves there. The qualified name denotes a symbol
1379
+ // in the RECEIVER's module — the target must live in
1380
+ // that module's package (project-relative module-path
1381
+ // suffix, or conventional package-segment match).
1382
+ // grpc-go measured: `&v3corepb.Locality{...}` (aliased
1383
+ // EXTERNAL envoy proto) and `xdsresource.Locality{...}`
1384
+ // confirmed for clients.Locality because the caller
1385
+ // also imported clients/config.go.
1386
+ if (!pkgRes.targetInPkg) {
1387
+ recordExcluded(filePath, call.line, 'other-definition');
477
1388
  continue;
478
1389
  }
479
1390
  // Multi-segment import — verify via import graph
@@ -485,46 +1396,162 @@ function findCallers(index, name, options = {}) {
485
1396
  // No import edge — allow same-package (same directory) calls
486
1397
  const callerDir = path.dirname(filePath);
487
1398
  const samePackage = targetDefs.some(d => d.file && path.dirname(d.file) === callerDir);
488
- if (!samePackage) continue;
1399
+ if (!samePackage) {
1400
+ recordExcluded(filePath, call.line, 'external-package');
1401
+ continue;
1402
+ }
489
1403
  }
490
1404
  }
491
1405
  }
492
1406
  }
493
1407
 
1408
+ // Alias-matched method call on a TYPED receiver: the receiver's
1409
+ // class owns the method dispatch — it cannot be a renamed
1410
+ // standalone function (`numberSchema.gt()` is ZodNumber.gt, not
1411
+ // `export { _gt as gt }`). Namespace receivers (`import * as
1412
+ // checks; checks.gt()`) carry no receiverType and keep flowing
1413
+ // on import evidence.
1414
+ if (calledAs && call.isMethod && call.receiverType &&
1415
+ !targetDefs.some(td => td.className || td.receiver)) {
1416
+ isUncertain = true;
1417
+ typeMismatch = true;
1418
+ if (collectAccount) {
1419
+ recordExcluded(filePath, call.line, 'receiver-type-mismatch');
1420
+ continue;
1421
+ }
1422
+ if (!options.includeUncertain) {
1423
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
1424
+ continue;
1425
+ }
1426
+ }
1427
+
494
1428
  // Receiver-class disambiguation:
495
1429
  // When the target definition has a class/receiver type, filter callers
496
1430
  // whose receiverType is known to be a different type.
497
1431
  // All languages use receiverType when available (constructor/annotation inference).
498
1432
  // Go/Java/Rust additionally fall back to variable name matching.
499
- if (call.isMethod && call.receiver && !resolvedBySameClass && !bindingId &&
500
- (call.receiverType || langTraits(fileEntry.language)?.typeSystem === 'nominal')) {
501
- // Build target type set from both className (Java) and receiver (Go/Rust)
502
- const targetTypes = new Set();
503
- for (const td of targetDefs) {
504
- if (td.className) targetTypes.add(td.className);
505
- if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
506
- }
507
- // Expand targetTypes with types that embed the target (Go/Java/Rust)
508
- // e.g., if target is Base.Start() and Child embeds Base, accept Child.Start() callers
509
- if (targetTypes.size > 0 && langTraits(fileEntry.language)?.typeSystem === 'nominal') {
510
- for (const tt of [...targetTypes]) {
511
- const children = index.extendedByGraph?.get(tt);
512
- if (children) {
513
- for (const child of children) {
514
- const cName = typeof child === 'string' ? child : child.name;
515
- if (cName) targetTypes.add(cName);
516
- }
517
- }
518
- }
519
- }
1433
+ // A declared-field receiver type (fix #202) enters even when a
1434
+ // name-binding matched bindings don't model receivers. Same
1435
+ // for a BUILTIN-typed receiver (fix #209): `"".join(...)` in
1436
+ // the file that defines URL.join name-binds to the method def,
1437
+ // but the receiver IS a str — the literal type outranks the
1438
+ // name binding (str/dict/Array are never project classes).
1439
+ const builtinReceiverOverride = !!(call.receiverType &&
1440
+ BUILTIN_RECEIVER_TYPES.has(call.receiverType) &&
1441
+ langTraits(fileEntry.language)?.typeSystem === 'structural');
1442
+ if (call.isMethod && (call.receiver || call.receiverType || fieldHopType) && !resolvedBySameClass &&
1443
+ (!bindingId || fieldHopType || builtinReceiverOverride) &&
1444
+ (call.receiverType || fieldHopType || langTraits(fileEntry.language)?.typeSystem === 'nominal')) {
1445
+ // Target type set: target classes + non-overriding subtypes
1446
+ // (a Child receiver calling an inherited Base method IS a
1447
+ // caller of Base.method). Memoized — fixed per query.
1448
+ const targetTypes = dispatchTargetTypes(targetDefs);
520
1449
  if (targetTypes.size > 0) {
521
1450
  // Use inferred receiverType when available (Go/Java/Rust parameter type tracking)
522
- const knownType = call.receiverType;
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.
1457
+ 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;
523
1460
  if (knownType) {
524
- const matchesTarget = targetTypes.has(knownType);
525
- if (!matchesTarget) {
526
- // Known type doesn't match target skip directly
1461
+ const viaFieldHop = !call.receiverType; // declared-field hop (fix #202)
1462
+ // Exclusion requires an UNRELATED type. A receiver typed
1463
+ // as an ANCESTOR of the target's class may dynamically
1464
+ // dispatch to the target override (x: Base; x.parse()
1465
+ // can run Child.parse) — structural languages only;
1466
+ // Go embedding has no virtual dispatch. Field-hop types
1467
+ // get the ancestor guard too (Java virtual dispatch).
1468
+ const structural = langTraits(fileEntry.language)?.typeSystem === 'structural';
1469
+ if (targetTypes.has(knownType)) {
1470
+ receiverTypeValidated = true;
1471
+ // Identity discipline (nominal, fix #206): a
1472
+ // NAME match is only identity when the
1473
+ // unqualified type name resolves (same file →
1474
+ // same package directory → import edge) to the
1475
+ // target's package. grpc-go defines ~20 structs
1476
+ // all named `bb` — leastrequest's `parser :=
1477
+ // bb{}` validating against cdsbalancer's
1478
+ // bb.ParseConfig is name conflation, not
1479
+ // receiver evidence. Only DIRECT target type
1480
+ // names are disciplined — subtype names entered
1481
+ // targetTypes via the inheritance walk, whose
1482
+ // edges already carry package context.
1483
+ if (!structural &&
1484
+ targetDefs.some(d => (d.className || (d.receiver || '').replace(/^\*/, '')) === knownType)) {
1485
+ // Flow-typed receivers (fix #207) resolve identity
1486
+ // from the producing annotation's scope — the name
1487
+ // was written THERE, not in the consuming file.
1488
+ const identity = _resolveReceiverTypeIdentity(
1489
+ index, call.receiverTypeFlowFile || filePath, knownType, targetDefs);
1490
+ if (identity === 'other') {
1491
+ receiverTypeValidated = false;
1492
+ } else if (identity === 'unknown') {
1493
+ receiverTypeValidated = false;
1494
+ receiverTypeUnresolved = true;
1495
+ }
1496
+ }
1497
+ }
1498
+ const matchesTarget = receiverTypeValidated ||
1499
+ ((structural || viaFieldHop) && _isAncestorOfTargetClass(index, knownType, targetDefs));
1500
+ // Structural trust gate: a name that is neither a
1501
+ // builtin nor a project class (type alias, interface,
1502
+ // external type) tracks no hierarchy UCN can check —
1503
+ // not positive evidence against the target.
1504
+ // Field-hop exclusion additionally demands the field's
1505
+ // type DEFINE the method itself — otherwise Go
1506
+ // promotion, Rust Deref, or Java inheritance could
1507
+ // still route the call to the target. Exception: an
1508
+ // EXTERNAL field type (no project class/struct def,
1509
+ // e.g. Map/StringBuilder) excludes without that —
1510
+ // external code cannot Deref/promote/inherit INTO
1511
+ // project types, so the only dispatch path back is a
1512
+ // project subtype of the external type, which the
1513
+ // ancestor guard above already keeps.
1514
+ // (1-2 char ALL-CAPS names are generic type params by
1515
+ // convention — T, K, V, T1 — never external evidence:
1516
+ // T may be instantiated WITH the target class. And the
1517
+ // external rule needs the target's ancestor chain to be
1518
+ // FULLY project-resolvable: a chain that dead-ends at an
1519
+ // external ancestor (LinkedTreeMap extends AbstractMap)
1520
+ // may reach knownType through ancestry UCN can't see —
1521
+ // measured on gson: 6 true edges lost without this.)
1522
+ const fieldHopDefinesMethod = !viaFieldHop || definitions.some(d =>
1523
+ (d.className || (d.receiver || '').replace(/^\*/, '')) === knownType) ||
1524
+ (!/^[A-Z][A-Z0-9]?$/.test(knownType) &&
1525
+ !(index.symbols.get(knownType) || []).some(d =>
1526
+ d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait') &&
1527
+ _targetAncestryFullyResolved(index, targetDefs));
1528
+ const exclusionTrusted = (!structural ||
1529
+ _receiverTypeTrustedForExclusion(index, knownType)) && fieldHopDefinesMethod &&
1530
+ !receiverTypeUnresolved; // unresolvable identity is not positive evidence either way
1531
+ if (!matchesTarget && exclusionTrusted) {
1532
+ // Known type doesn't match target — positive evidence the
1533
+ // call targets a DIFFERENT symbol. Under the account contract
1534
+ // this is excluded-with-reason, not a revealable uncertain.
527
1535
  isUncertain = true;
1536
+ typeMismatch = true;
1537
+ if (collectAccount) {
1538
+ // ...unless the type can VIRTUALLY dispatch into
1539
+ // the target: an interface/trait receiver that
1540
+ // declares the method, or (Java — all instance
1541
+ // methods virtual) a superclass of the target.
1542
+ // Not evidence against — visible possible-dispatch.
1543
+ // Go struct embedding binds statically and stays
1544
+ // excluded.
1545
+ if (_dispatchCapableSupertype(index, fileEntry.language, knownType, targetDefs, definitions)) {
1546
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
1547
+ dispatchVia: knownType,
1548
+ dispatchCandidates: countDispatchCandidates(knownType),
1549
+ });
1550
+ continue;
1551
+ }
1552
+ recordExcluded(filePath, call.line, 'receiver-type-mismatch');
1553
+ continue;
1554
+ }
528
1555
  if (!options.includeUncertain) {
529
1556
  if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
530
1557
  continue;
@@ -551,7 +1578,22 @@ function findCallers(index, name, options = {}) {
551
1578
  const inferredType = localTypes.get(call.receiver);
552
1579
  if (inferredType) {
553
1580
  if (targetTypes.has(inferredType)) {
554
- inferredMatch = true;
1581
+ // Identity discipline (fix #206) — same as the
1582
+ // parser-typed branch above: a name match on a
1583
+ // DIRECT target type must resolve to the
1584
+ // target's package, not a same-named foreign type.
1585
+ let identity = 'target';
1586
+ if (targetDefs.some(d => (d.className || (d.receiver || '').replace(/^\*/, '')) === inferredType)) {
1587
+ identity = _resolveReceiverTypeIdentity(index, filePath, inferredType, targetDefs);
1588
+ }
1589
+ if (identity === 'target') {
1590
+ inferredMatch = true;
1591
+ nominalInferredMatch = true;
1592
+ } else if (identity === 'other') {
1593
+ inferredMismatch = true;
1594
+ } else {
1595
+ receiverTypeUnresolved = true;
1596
+ }
555
1597
  } else {
556
1598
  inferredMismatch = true;
557
1599
  }
@@ -561,20 +1603,72 @@ function findCallers(index, name, options = {}) {
561
1603
  }
562
1604
  if (inferredMismatch) {
563
1605
  isUncertain = true;
1606
+ typeMismatch = true;
1607
+ if (collectAccount) {
1608
+ recordExcluded(filePath, call.line, 'receiver-type-mismatch');
1609
+ continue;
1610
+ }
564
1611
  if (!options.includeUncertain) {
565
1612
  if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
566
1613
  continue;
567
1614
  }
568
1615
  }
569
- // Still no type — fall back to receiver name matching when multiple defs exist
570
- if (!inferredMatch && !inferredMismatch && definitions.length > 1) {
1616
+ // Still no type — fall back to receiver name matching when
1617
+ // multiple defs exist. A field-declared interface/trait type
1618
+ // (fieldDispatchType, contract surface only) outranks the name
1619
+ // heuristic: `storage.save()` on a field declared `Storage`
1620
+ // is a dispatch edge, not a case-insensitive name accident —
1621
+ // skip the fallback and let the dispatch tiering route it.
1622
+ // call.receiver guard: a generic-param knownType
1623
+ // (fix #220) reaches here receiver-less — there is
1624
+ // no receiver NAME to match against.
1625
+ if (call.receiver && !inferredMatch && !inferredMismatch && definitions.length > 1 && !fieldDispatchType) {
571
1626
  const receiverLower = call.receiver.toLowerCase();
572
1627
  const matchesTarget = [...targetTypes].some(cn => cn.toLowerCase() === receiverLower);
1628
+ // Type-qualified identity discipline (fix #220,
1629
+ // ripgrep-measured): a path-call receiver that
1630
+ // matches the target type's NAME must also
1631
+ // resolve (same file → same dir → import edge)
1632
+ // to the target's package — every ripgrep crate
1633
+ // defines its own `Config`, and printer's
1634
+ // Config::default() name-matches core's Config
1635
+ // while provably denoting the same-file struct.
1636
+ // Path style only: a Go/Java receiver named
1637
+ // like the type may be a VARIABLE (#206b) —
1638
+ // its type is unknown, identity proves nothing.
1639
+ if (matchesTarget && call.isPathCall &&
1640
+ langTraits(fileEntry.language)?.typeQualifiedCallStyle === 'path') {
1641
+ const identity = _resolveReceiverTypeIdentity(index, filePath, call.receiver, targetDefs);
1642
+ if (identity === 'other') {
1643
+ isUncertain = true;
1644
+ typeMismatch = true;
1645
+ if (collectAccount) {
1646
+ recordExcluded(filePath, call.line, 'path-type-mismatch');
1647
+ continue;
1648
+ }
1649
+ if (!options.includeUncertain) {
1650
+ if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
1651
+ continue;
1652
+ }
1653
+ } else if (identity === 'unknown' && collectAccount) {
1654
+ // Unresolvable identity never confirms,
1655
+ // never excludes (#206).
1656
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
1657
+ dispatchCandidates: methodOwnerKeys().size,
1658
+ });
1659
+ continue;
1660
+ }
1661
+ }
573
1662
  if (!matchesTarget) {
574
1663
  // Rust/Go path calls (Type::method() / pkg.Method()): receiver IS the type name
575
1664
  // If it doesn't match target, it's definitely a different type — filter it
576
1665
  if (call.isPathCall && /^[A-Z]/.test(call.receiver)) {
577
1666
  isUncertain = true;
1667
+ typeMismatch = true;
1668
+ if (collectAccount) {
1669
+ recordExcluded(filePath, call.line, 'path-type-mismatch');
1670
+ continue;
1671
+ }
578
1672
  if (!options.includeUncertain) {
579
1673
  if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
580
1674
  continue;
@@ -588,6 +1682,26 @@ function findCallers(index, name, options = {}) {
588
1682
  const matchesOther = [...nonTargetClasses].some(cn => cn.toLowerCase() === receiverLower);
589
1683
  if (matchesOther) {
590
1684
  isUncertain = true;
1685
+ typeMismatch = true;
1686
+ if (collectAccount) {
1687
+ // The matched class may be a dispatch-capable
1688
+ // supertype of the target (a receiver named
1689
+ // after the interface it is typed as) — that
1690
+ // is a possible dispatch edge, not evidence
1691
+ // against the target.
1692
+ const dispatchSuper = [...nonTargetClasses]
1693
+ .filter(cn => cn.toLowerCase() === receiverLower)
1694
+ .find(cn => _dispatchCapableSupertype(index, fileEntry.language, cn, targetDefs, definitions));
1695
+ if (dispatchSuper) {
1696
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
1697
+ dispatchVia: dispatchSuper,
1698
+ dispatchCandidates: countDispatchCandidates(dispatchSuper),
1699
+ });
1700
+ continue;
1701
+ }
1702
+ recordExcluded(filePath, call.line, 'receiver-other-class');
1703
+ continue;
1704
+ }
591
1705
  if (!options.includeUncertain) {
592
1706
  if (stats) stats.uncertain = (stats.uncertain || 0) + 1;
593
1707
  continue;
@@ -599,90 +1713,543 @@ function findCallers(index, name, options = {}) {
599
1713
  }
600
1714
  }
601
1715
 
1716
+ // Arity pruning (nominal contract surface, fix #205): a call
1717
+ // whose argument count cannot fit ANY pinned definition's
1718
+ // parameter range is positive evidence the call binds a
1719
+ // different symbol — excluded-with-reason. Static-arity
1720
+ // languages only: their compilers enforce arity, so a mismatch
1721
+ // IS evidence. JS pads/ignores extra args legally and Python
1722
+ // decorators reshape signatures invisibly — never prune there.
1723
+ // Go tuple expansion (f(g()) filling two params) means too-FEW
1724
+ // syntactic args is not evidence in Go — only too-many prunes.
1725
+ // Binding/same-class evidence outranks the count (then a
1726
+ // mismatch more likely means our param parse is wrong).
1727
+ if (collectAccount && !bindingId && !resolvedBySameClass &&
1728
+ call.argCount != null && !call.argSpread &&
1729
+ langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
1730
+ !_callArityCompatible(call, targetDefs, fileEntry.language)) {
1731
+ recordExcluded(filePath, call.line, 'arity-mismatch');
1732
+ continue;
1733
+ }
1734
+
1735
+ // Overload discipline (fix #205, languages with arity/type
1736
+ // overloading — Java): when the pinned target shares its name
1737
+ // with sibling overloads in the same class, a call site only
1738
+ // CONFIRMS the pinned overload if its static argument shape
1739
+ // (count + literal kinds) binds it:
1740
+ // - kinds prove a DIFFERENT overload → excluded 'overload-mismatch'
1741
+ // - kinds prove the pinned one uniquely → flows on (confirmable)
1742
+ // - undecidable (variable args) → visible 'overload-ambiguous'
1743
+ // jdtls-measured: class-level receiver evidence said "some add()
1744
+ // overload", which is not evidence for THIS add(Number).
1745
+ if (collectAccount && options.targetDefinitions &&
1746
+ langTraits(fileEntry.language)?.hasArityOverloads &&
1747
+ call.argCount != null && !call.argSpread && !call.isConstructor) {
1748
+ const overloadVerdict = _overloadDiscipline(index, call, targetDefs, definitions);
1749
+ if (overloadVerdict === 'other-overload') {
1750
+ recordExcluded(filePath, call.line, 'overload-mismatch');
1751
+ continue;
1752
+ }
1753
+ if (overloadVerdict && overloadVerdict.ambiguous) {
1754
+ routeUnverified(filePath, fileEntry, call, 'overload-ambiguous', calledAs, {
1755
+ dispatchCandidates: overloadVerdict.candidates,
1756
+ });
1757
+ continue;
1758
+ }
1759
+ }
1760
+
602
1761
  // Find the enclosing function (get full symbol info)
603
1762
  const callerSymbol = index.findEnclosingFunction(filePath, call.line, true);
604
1763
 
1764
+ // Method call whose receiver has no binding evidence in this file's
1765
+ // scope (structural languages only) — receiver-evidence-free.
1766
+ // Hoisted because it also limits what counts as import evidence.
1767
+ const uncertainMethodReceiver = skipLocalBinding && call.isMethod && !resolvedBySameClass &&
1768
+ langTraits(fileEntry.language)?.typeSystem === 'structural' &&
1769
+ !(call.receiver && (fileEntry.bindings || []).some(b => b.name === call.receiver));
1770
+
605
1771
  // Check import graph evidence: does this file import from the target definition's file?
606
1772
  const targetDefs2 = options.targetDefinitions || definitions;
607
1773
  const targetFiles2 = new Set(targetDefs2.map(d => d.file).filter(Boolean));
608
1774
  const callerImports = index.importGraph.get(filePath);
609
- let hasImportLink = targetFiles2.has(filePath) || (callerImports && setSome(callerImports, imp => targetFiles2.has(imp)));
1775
+ let importEdgeLink = !!(callerImports && setSome(callerImports, imp => targetFiles2.has(imp)));
610
1776
  // Check one level of re-exports (barrel files) for import evidence
611
- if (!hasImportLink && callerImports) {
1777
+ if (!importEdgeLink && callerImports) {
612
1778
  for (const imp of callerImports) {
613
1779
  const transImports = index.importGraph.get(imp);
614
1780
  if (transImports && setSome(transImports, ti => targetFiles2.has(ti))) {
615
- hasImportLink = true;
1781
+ importEdgeLink = true;
616
1782
  break;
617
1783
  }
618
1784
  }
619
1785
  }
1786
+ // Same-file membership is module-scope evidence for plain calls,
1787
+ // but says nothing about a method receiver: `foo.map()` sharing a
1788
+ // file with `function map()` must not confirm while foo's type is
1789
+ // unknown. Real import edges keep counting — importing the
1790
+ // defining module is evidence the file uses its API.
1791
+ const hasImportLink = importEdgeLink ||
1792
+ (targetFiles2.has(filePath) && !uncertainMethodReceiver);
620
1793
 
621
- if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
622
- pendingByFile.get(filePath).push({
623
- call, fileEntry, callerSymbol,
624
- isMethod: call.isMethod || false, isFunctionReference: false,
625
- receiver: call.receiver,
626
- receiverType: call.receiverType,
627
- _evidence: {
628
- hasBindingId: !!bindingId,
629
- resolvedBySameClass: !!resolvedBySameClass,
630
- isUncertain: !!isUncertain || (
631
- // Method calls where binding resolution was skipped (non-self receiver)
632
- // and the receiver has no binding evidence → uncertain (JS/TS/Python only)
633
- skipLocalBinding && call.isMethod && !resolvedBySameClass &&
634
- langTraits(fileEntry.language)?.typeSystem === 'structural' &&
635
- !(call.receiver && (fileEntry.bindings || []).some(b => b.name === call.receiver))
636
- ),
637
- hasReceiverType: !!call.receiverType,
638
- hasReceiverEvidence: !!(call.receiver &&
639
- (fileEntry.bindings || []).some(b => b.name === call.receiver)),
640
- hasImportEvidence: !!bindingId || hasImportLink,
641
- }
642
- });
643
- pendingCount++;
644
- }
645
- } catch (e) {
646
- // Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
647
- // These are not actionable errors — silently skip.
648
- }
649
- }
650
-
651
- // True total candidate count from Phase 1 (before any Phase 2 truncation).
652
- // Used by callers that need accurate "showing N of <total>" headers.
653
- const totalCount = pendingCount;
654
- // When needsTotal is set with a maxResults cap, only enrich the first
655
- // `maxResults` candidates in Phase 2 — file reads stay bounded.
656
- const enrichLimit = (needsTotal && maxResults) ? maxResults : Infinity;
657
- let enrichedCount = 0;
1794
+ // Same-package evidence (nominal type systems): Java/Rust/Go
1795
+ // resolve same-package/module names without import statements,
1796
+ // so a target defined in the caller's directory is real scope
1797
+ // evidence, not a bare name match.
1798
+ const hasSamePackageEvidence = !hasImportLink &&
1799
+ langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
1800
+ targetDefs2.some(d => d.file && path.dirname(d.file) === path.dirname(filePath));
658
1801
 
659
- // BUG-H1: shadow records for un-enriched candidates so post-call filters
660
- // (exclude / minConfidence) can produce an accurate total without forcing
661
- // a Phase-2 file read for every candidate. Each shadow has just enough
662
- // info to drive the filter predicates: relativePath + confidence.
663
- const shadowEntries = [];
1802
+ // Possible-dispatch tiering (nominal languages, contract surface
1803
+ // only): methodCallInclusion='auto' confirms method calls with
1804
+ // ZERO receiver evidence right when the name is unique
1805
+ // project-wide (cobra), a lie when dozens of types implement it
1806
+ // (gson TypeAdapter.read). The confirmed tier keeps only
1807
+ // evidence-backed edges: validated/inferred receiver type,
1808
+ // same-class resolution, binding, a type-qualified receiver
1809
+ // (Type::method / Type.method), or a name with a single
1810
+ // project-wide owner. The rest stay VISIBLE as unverified — a
1811
+ // known-but-unvalidated receiver type is 'possible-dispatch'
1812
+ // (attributed via the declared supertype), an untyped receiver
1813
+ // against multiple owners is 'method-ambiguous'. Nothing is
1814
+ // dropped: conservation holds, the entries move tiers.
1815
+ // A binding matched from a bare-name lookup is receiver-blind:
1816
+ // method calls resolve through their RECEIVER in every supported
1817
+ // language, never through file scope — a same-file def or import
1818
+ // of the name says nothing about what `parse_hex(v).map(...)` or
1819
+ // `self.inner.next()` dispatches to (cursive-measured: 9 of 11
1820
+ // method FPs were chained/field-rooted calls confirmed
1821
+ // exact-binding against Rgb::map / Iterator-impl next / V::draw).
1822
+ // Such calls must earn the confirmed tier through receiver
1823
+ // evidence — route them through the dispatch tiering below.
1824
+ // Self-receiver calls are not affected (same-class resolution
1825
+ // owns them); captured-receiver calls never bound (skipLocalBinding).
1826
+ // Local-alias calls (fix #218): `get_style = console.get_style;
1827
+ // get_style(x)` is a TRUE edge with compiler-grade evidence,
1828
+ // but it reaches the target through a local variable — the
1829
+ // line's name resolves to the alias, not the def, so reference
1830
+ // oracles place nothing here and grep-parity verification is
1831
+ // impossible. Visible unverified, never confirmed (not even by
1832
+ // same-class/type-qualified/single-owner evidence); the
1833
+ // exclusion-grade checks (typed-receiver mismatch, same-class
1834
+ // pinning, arity) already fired above and win.
1835
+ if (collectAccount && call.aliasCall) {
1836
+ routeUnverified(filePath, fileEntry, call, 'alias-call', calledAs, {
1837
+ ...(call.receiver && { dispatchVia: call.receiver }),
1838
+ });
1839
+ continue;
1840
+ }
664
1841
 
665
- // Phase 2: Read content only for files with matching calls (eliminates ~98% of file reads)
666
- outer: for (const [filePath, pending] of pendingByFile) {
667
- let content = null;
668
- for (const { call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver, receiverType, _evidence } of pending) {
669
- const scored = scoreEdge(_evidence || {});
670
- if (enrichedCount >= enrichLimit) {
671
- // Push shadow only no file read needed.
672
- shadowEntries.push({
673
- file: filePath,
674
- relativePath: fileEntry.relativePath,
675
- line: call.line,
676
- confidence: scored.confidence,
677
- resolution: scored.resolution,
678
- isMethod: call.isMethod || false,
679
- ...(isFunctionReference && { isFunctionReference: true }),
680
- ...(receiver !== undefined && { receiver }),
681
- ...(receiverType && { receiverType }),
682
- });
683
- continue;
684
- }
685
- // First time we hit this file's enrichment loop — read the file once.
1842
+ const receiverBlindBinding = !!bindingId && call.isMethod && !call.receiver;
1843
+ if (collectAccount && call.isMethod && (!bindingId || receiverBlindBinding) && !resolvedBySameClass &&
1844
+ !receiverTypeValidated && !nominalInferredMatch &&
1845
+ langTraits(fileEntry.language)?.typeSystem === 'nominal') {
1846
+ const tTypes = dispatchTargetTypes(targetDefs2);
1847
+ // `use X as Y` import rename (fix #222b, ripgrep-measured:
1848
+ // `use ContextSeparator as Separator; Separator::disabled()`
1849
+ // — the alias names the TARGET type locally): judge path
1850
+ // receivers by the ORIGINAL name. Only fires when the
1851
+ // import's last path segment IS a target type — package
1852
+ // aliases and unrelated imports stay untouched.
1853
+ let receiverName = call.receiver;
1854
+ if (receiverName && !tTypes.has(receiverName)) {
1855
+ for (const im of (fileEntry.importBindings || [])) {
1856
+ if (im.name !== receiverName) continue;
1857
+ const orig = String(im.module || '').split('::').pop();
1858
+ if (orig && orig !== receiverName && tTypes.has(orig)) {
1859
+ receiverName = orig;
1860
+ break;
1861
+ }
1862
+ }
1863
+ }
1864
+ // A receiver that shares the target type's NAME is only
1865
+ // type-qualified when the call matches the language's
1866
+ // qualified-call syntax (typeQualifiedCallStyle trait):
1867
+ // Rust requires Type::method (a dot-call receiver matching
1868
+ // a type name is a variable); Go method expressions
1869
+ // T.M(recv, ...) pass the receiver as the first argument,
1870
+ // so a zero-arg call on a type-named receiver is a
1871
+ // variable, not the type (grpc-go's `bb` collision).
1872
+ let typeQualifiedReceiver = !!(receiverName && tTypes.has(receiverName));
1873
+ if (typeQualifiedReceiver) {
1874
+ const qualStyle = langTraits(fileEntry.language)?.typeQualifiedCallStyle;
1875
+ if (qualStyle === 'path') typeQualifiedReceiver = !!call.isPathCall;
1876
+ else if (qualStyle === 'method-expr') typeQualifiedReceiver = call.argCount == null || call.argCount >= 1;
1877
+ }
1878
+ // Identity discipline on the qualified shape itself (fix
1879
+ // #220): a genuinely type-qualified call still only NAMES
1880
+ // the type — the name must resolve to the target's package
1881
+ // (every ripgrep crate defines a `Config`). 'other' is
1882
+ // compiler-grade evidence for a different type; 'unknown'
1883
+ // never confirms and never excludes (#206). The receiver-
1884
+ // name fallback above handles multi-definition names; this
1885
+ // covers single-definition targets that skip it.
1886
+ if (typeQualifiedReceiver) {
1887
+ const identity = _resolveReceiverTypeIdentity(index, filePath, receiverName, targetDefs2);
1888
+ if (identity === 'other') {
1889
+ recordExcluded(filePath, call.line, 'path-type-mismatch');
1890
+ continue;
1891
+ }
1892
+ if (identity === 'unknown') {
1893
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
1894
+ dispatchCandidates: methodOwnerKeys().size,
1895
+ });
1896
+ continue;
1897
+ }
1898
+ }
1899
+ if (!typeQualifiedReceiver) {
1900
+ // External-producer receiver (fix #220): the variable
1901
+ // was assigned from a call into an external package
1902
+ // (av := reflect.ValueOf(a)) — its type was decided
1903
+ // outside the project, so unique project ownership is
1904
+ // not identity evidence. Visible, never excluded
1905
+ // (external generic identity functions can return
1906
+ // project values).
1907
+ if (call.receiverExternalFlow) {
1908
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
1909
+ dispatchVia: call.receiverExternalFlow,
1910
+ externalContract: true,
1911
+ });
1912
+ continue;
1913
+ }
1914
+ // Unresolvable type-name identity (fix #206): the
1915
+ // receiver is typed with a name several distinct types
1916
+ // share, and none resolves from this file's scope —
1917
+ // visible ambiguous, not confirmable receiver evidence.
1918
+ if (receiverTypeUnresolved) {
1919
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
1920
+ dispatchCandidates: methodOwnerKeys().size,
1921
+ });
1922
+ continue;
1923
+ }
1924
+ // Type-qualified path calls naming a NON-target type
1925
+ // (fix #222, seed-C-measured — the #220(2) fallback
1926
+ // only ran with multiple same-name definitions, so
1927
+ // single-owner names bypassed the whole discipline):
1928
+ // `Vec::<String>::new()` inside assert_eq! names std's
1929
+ // Vec — same-package scope cannot make it the project
1930
+ // `new`. Generic-param receivers (`T::zero()` — T is
1931
+ // instantiable with ANY type satisfying its bound)
1932
+ // route VISIBLE, never excluded; concrete non-target
1933
+ // type names are compiler-grade evidence for a
1934
+ // different type. `Self` keeps its current scope
1935
+ // resolution (a true same-impl call). Alias-qualified
1936
+ // receivers are in the #208-closed tTypes and never
1937
+ // reach here.
1938
+ if (call.isPathCall && call.receiver &&
1939
+ /^[A-Z]/.test(call.receiver) && call.receiver !== 'Self') {
1940
+ if (/^[A-Z][A-Z0-9]?$/.test(call.receiver) &&
1941
+ !(index.symbols.get(call.receiver) || []).some(d => IDENTITY_TYPE_KINDS.has(d.type))) {
1942
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
1943
+ dispatchCandidates: methodOwnerKeys().size,
1944
+ });
1945
+ continue;
1946
+ }
1947
+ recordExcluded(filePath, call.line, 'path-type-mismatch');
1948
+ continue;
1949
+ }
1950
+ const knownDispatchType = call.receiverType || fieldHopType || fieldDispatchType;
1951
+ if (knownDispatchType && !tTypes.has(knownDispatchType)) {
1952
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
1953
+ dispatchVia: knownDispatchType,
1954
+ dispatchCandidates: countDispatchCandidates(knownDispatchType),
1955
+ });
1956
+ continue;
1957
+ }
1958
+ if (!knownDispatchType && methodOwnerKeys().size > 1) {
1959
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
1960
+ dispatchCandidates: methodOwnerKeys().size,
1961
+ });
1962
+ continue;
1963
+ }
1964
+ // Single project-wide owner, but the method provably
1965
+ // implements an EXTERNAL contract (fix #210): the
1966
+ // receiver could be any external subtype
1967
+ // (((Long) obj).intValue() vs LazilyParsedNumber's
1968
+ // @Override intValue) — unique ownership is not
1969
+ // identity evidence here. Visible, never excluded.
1970
+ const extContract = !knownDispatchType && externalContractTarget();
1971
+ if (extContract) {
1972
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
1973
+ ...(extContract.via && { dispatchVia: extContract.via }),
1974
+ externalContract: true,
1975
+ });
1976
+ continue;
1977
+ }
1978
+ }
1979
+ }
1980
+
1981
+ // Sibling-impl overload ambiguity (fix #220, cursive-measured —
1982
+ // the #205 jdtls insight for languages WITHOUT arity-overload
1983
+ // discipline): Rust defines same-name methods on the SAME type
1984
+ // across impl blocks (`impl From<Color> for ColorStyle` ×4;
1985
+ // `impl Rgb<f32>` vs `impl Rgb<u8>` both with as_color).
1986
+ // Class-level receiver evidence — type-qualified path calls,
1987
+ // name-validated receiver types — proves "some ColorStyle::from",
1988
+ // never the pinned one; with an arity-indistinguishable
1989
+ // same-class sibling the call routes visible. Alias-qualified
1990
+ // receivers are exempt: `StyledString::plain` names ONE
1991
+ // instantiation by construction (#208 — the alias carries the
1992
+ // type argument even though UCN's closure is name-level).
1993
+ // Go cannot compile same-class same-name siblings; Java runs
1994
+ // its own #205 argKinds discipline (hasArityOverloads).
1995
+ if (collectAccount && call.isMethod && !resolvedBySameClass &&
1996
+ (!bindingId || receiverBlindBinding) &&
1997
+ langTraits(fileEntry.language)?.typeSystem === 'nominal' &&
1998
+ !langTraits(fileEntry.language)?.hasArityOverloads &&
1999
+ options.targetDefinitions && options.targetDefinitions.length > 0 &&
2000
+ !(call.receiver && (index.symbols.get(call.receiver) || []).some(d => d.aliasOf))) {
2001
+ const pinnedCallable = options.targetDefinitions.filter(d => !NON_CALLABLE_TYPES.has(d.type));
2002
+ const pinnedClasses = new Set(pinnedCallable
2003
+ .map(d => d.className || (d.receiver || '').replace(/^\*/, ''))
2004
+ .filter(Boolean));
2005
+ if (pinnedClasses.size > 0) {
2006
+ const pinnedKeys = new Set(pinnedCallable.map(d => `${d.file}:${d.startLine}`));
2007
+ // Same-FILE constraint: a same-name class in another
2008
+ // package is a DIFFERENT type, not a sibling impl
2009
+ // (Go's per-package `bb` structs). The measured Rust
2010
+ // families (From impls, generic instantiations) live
2011
+ // in the type's own file.
2012
+ const pinnedFiles = new Set(pinnedCallable.map(d => d.file).filter(Boolean));
2013
+ const sibling = definitions.find(d =>
2014
+ !NON_CALLABLE_TYPES.has(d.type) &&
2015
+ pinnedClasses.has(d.className || (d.receiver || '').replace(/^\*/, '')) &&
2016
+ pinnedFiles.has(d.file) &&
2017
+ !pinnedKeys.has(`${d.file}:${d.startLine}`) &&
2018
+ _callArityCompatible(call, [d], fileEntry.language));
2019
+ if (sibling) {
2020
+ routeUnverified(filePath, fileEntry, call, 'overload-ambiguous', calledAs, {
2021
+ dispatchCandidates: definitions.filter(d =>
2022
+ !NON_CALLABLE_TYPES.has(d.type) &&
2023
+ pinnedClasses.has(d.className || (d.receiver || '').replace(/^\*/, ''))).length,
2024
+ });
2025
+ continue;
2026
+ }
2027
+ }
2028
+ }
2029
+
2030
+ // Structural dispatch tiering (fix #209, httpx-measured — the
2031
+ // #204 discipline applied to structural languages): file-level
2032
+ // import/scope evidence speaks for a bare NAME reaching this
2033
+ // file, not for a method call's receiver — `key.decode(enc)`
2034
+ // in a file that imports _decoders.py is bytes.decode, not
2035
+ // ContentDecoder.decode. An untyped-receiver method call
2036
+ // confirms only via binding, same-class, a validated receiver
2037
+ // type, a type-qualified receiver (Class.method static style),
2038
+ // or a single project-wide owner. Multi-owner name matches
2039
+ // route VISIBLE method-ambiguous — never dropped. Same for a
2040
+ // bare call against pure method targets (a bare name cannot
2041
+ // denote a method in JS/TS/Python — only a rebound alias can,
2042
+ // which has no evidence here either).
2043
+ if (collectAccount && (!bindingId || receiverBlindBinding) && !resolvedBySameClass &&
2044
+ !receiverTypeValidated &&
2045
+ langTraits(fileEntry.language)?.typeSystem === 'structural') {
2046
+ // Module-qualified calls (z.string(), ns.helper()) are
2047
+ // exempt: the module IS name-level evidence, and the
2048
+ // module-ownership block above already routed the ones
2049
+ // whose module doesn't reach the target.
2050
+ if (call.isMethod && !call.receiverIsModule) {
2051
+ const tTypes = dispatchTargetTypes(targetDefs2);
2052
+ const typeQualifiedReceiver = !!(call.receiver && tTypes.has(call.receiver));
2053
+ // External-producer receiver (fix #222, httpx-measured
2054
+ // — the #220 Go rule for structural languages): the
2055
+ // variable was assigned from a call into an external
2056
+ // module (logger = logging.getLogger(...)), so its
2057
+ // type was decided outside the project and unique
2058
+ // project ownership is not identity evidence.
2059
+ // Visible, never excluded.
2060
+ if (!typeQualifiedReceiver && call.receiverExternalFlow) {
2061
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
2062
+ dispatchVia: call.receiverExternalFlow,
2063
+ externalContract: true,
2064
+ });
2065
+ continue;
2066
+ }
2067
+ // A method call cannot denote a standalone function
2068
+ // (fix #218, rich-measured: `console.print(...)`
2069
+ // confirmed scope-match against module-level print):
2070
+ // only an attribute assignment could rebind one onto a
2071
+ // receiver, which is beyond name-level evidence. Typed
2072
+ // receivers are excluded above (#198); untyped ones
2073
+ // route visible. Module receivers stay exempt
2074
+ // (rich.print(...) IS the module function).
2075
+ if (!typeQualifiedReceiver && targetDefs2.length > 0 &&
2076
+ targetDefs2.every(d => !d.className && !d.receiver)) {
2077
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
2078
+ dispatchCandidates: methodOwnerKeys().size,
2079
+ });
2080
+ continue;
2081
+ }
2082
+ // External-contract single owner (fix #210): same
2083
+ // physics as the nominal gate above — an override
2084
+ // marker proves the name exists on a contract UCN
2085
+ // cannot see, so the receiver could be any external
2086
+ // subtype. Checked before the multi-owner branch
2087
+ // only via owner count (===1) being its precondition.
2088
+ const extContract = !typeQualifiedReceiver &&
2089
+ externalContractTarget();
2090
+ if (extContract) {
2091
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
2092
+ ...(extContract.via && { dispatchVia: extContract.via }),
2093
+ externalContract: true,
2094
+ });
2095
+ continue;
2096
+ }
2097
+ if (!typeQualifiedReceiver && methodOwnerKeys().size > 1) {
2098
+ const knownDispatchType = call.receiverType || fieldHopType || fieldDispatchType;
2099
+ if (knownDispatchType) {
2100
+ // Known-but-unvalidated type (supertype of the
2101
+ // target — dynamic dispatch — or an alias/
2102
+ // interface name UCN can't validate, or a
2103
+ // declared-field hop type, fix #219): a
2104
+ // possible dispatch edge, attributed via the
2105
+ // receiver's declared type (#204 physics).
2106
+ routeUnverified(filePath, fileEntry, call, 'possible-dispatch', calledAs, {
2107
+ dispatchVia: knownDispatchType,
2108
+ dispatchCandidates: countDispatchCandidates(knownDispatchType),
2109
+ });
2110
+ } else {
2111
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
2112
+ dispatchCandidates: methodOwnerKeys().size,
2113
+ });
2114
+ }
2115
+ continue;
2116
+ }
2117
+ } else if (!calledAs && !call.isConstructor &&
2118
+ targetDefs2.length > 0 && targetDefs2.every(d => d.className)) {
2119
+ routeUnverified(filePath, fileEntry, call, 'method-ambiguous', calledAs, {
2120
+ dispatchCandidates: methodOwnerKeys().size,
2121
+ });
2122
+ continue;
2123
+ }
2124
+ }
2125
+
2126
+ if (!pendingByFile.has(filePath)) pendingByFile.set(filePath, []);
2127
+ pendingByFile.get(filePath).push({
2128
+ call, fileEntry, callerSymbol,
2129
+ isMethod: call.isMethod || false,
2130
+ // Function references can resolve through the plain binding
2131
+ // path too (e.g. JS `arr.map(helper)` with a local binding) —
2132
+ // surface the parser's marker on the edge (fix #221).
2133
+ isFunctionReference: !!call.isFunctionReference,
2134
+ receiver: call.receiver,
2135
+ receiverType: call.receiverType,
2136
+ calledAs,
2137
+ _evidence: {
2138
+ hasBindingId: !!bindingId,
2139
+ resolvedBySameClass: !!resolvedBySameClass,
2140
+ hasSamePackageEvidence,
2141
+ // Method calls where binding resolution was skipped (non-self receiver)
2142
+ // and the receiver has no binding evidence → uncertain (JS/TS/Python only)
2143
+ isUncertain: !!isUncertain || uncertainMethodReceiver,
2144
+ hasReceiverType: langTraits(fileEntry.language)?.typeSystem === 'structural'
2145
+ ? receiverTypeValidated
2146
+ : !!call.receiverType,
2147
+ hasReceiverEvidence: !!(call.receiver &&
2148
+ (fileEntry.bindings || []).some(b => b.name === call.receiver)),
2149
+ hasImportEvidence: !!bindingId || hasImportLink,
2150
+ ...(typeMismatch && { typeMismatch: true }),
2151
+ }
2152
+ });
2153
+ pendingCount++;
2154
+ }
2155
+ } catch (e) {
2156
+ // Expected: minified files exceed tree-sitter buffer, binary files fail to parse.
2157
+ // These are not actionable errors — silently skip.
2158
+ }
2159
+ }
2160
+
2161
+ // True total candidate count from Phase 1 (before any Phase 2 truncation).
2162
+ // Used by callers that need accurate "showing N of <total>" headers.
2163
+ const totalCount = pendingCount;
2164
+ // When needsTotal is set with a maxResults cap, only enrich the first
2165
+ // `maxResults` candidates in Phase 2 — file reads stay bounded.
2166
+ const enrichLimit = (needsTotal && maxResults) ? maxResults : Infinity;
2167
+ let enrichedCount = 0;
2168
+
2169
+ // BUG-H1: shadow records for un-enriched candidates so post-call filters
2170
+ // (exclude / minConfidence) can produce an accurate total without forcing
2171
+ // a Phase-2 file read for every candidate. Each shadow has just enough
2172
+ // info to drive the filter predicates: relativePath + confidence.
2173
+ const shadowEntries = [];
2174
+ // Unverified-tier entries (collectAccount only): retained drops, rendered
2175
+ // in their own section. First `unverifiedEnrichLimit` get content + caller
2176
+ // lookup; the rest stay shadow-style (file/line/reason only).
2177
+ const unverifiedEntries = [];
2178
+ let unverifiedEnriched = 0;
2179
+
2180
+ // Phase 2: Read content only for files with matching calls (eliminates ~98% of file reads)
2181
+ outer: for (const [filePath, pending] of pendingByFile) {
2182
+ let content = null;
2183
+ for (const { call, fileEntry, callerSymbol, isMethod, isFunctionReference, receiver, receiverType, calledAs, _evidence, _tier, _reason, _meta } of pending) {
2184
+ const scored = scoreEdge(_evidence || {});
2185
+ // Family B contract field (fix #221): a bind/call/apply site reaches
2186
+ // the target through Function.prototype indirection, not direct call
2187
+ // syntax — label the edge calledAs:'bound'. Rename aliases keep their
2188
+ // surface name (they describe the same slot and are rarer). Label
2189
+ // only, computed at edge construction: routing logic never sees it.
2190
+ const edgeCalledAs = calledAs || (call.boundCall ? 'bound' : undefined);
2191
+ if (_tier) {
2192
+ // Routed unverified entry — never competes with the main
2193
+ // answer for maxResults/enrichLimit slots.
2194
+ const base = {
2195
+ file: filePath,
2196
+ relativePath: fileEntry.relativePath,
2197
+ line: call.line,
2198
+ confidence: scored.confidence,
2199
+ resolution: scored.resolution,
2200
+ tier: _tier,
2201
+ reason: _reason,
2202
+ ...(_meta || {}),
2203
+ isMethod: call.isMethod || false,
2204
+ ...(isFunctionReference && { isFunctionReference: true }),
2205
+ ...(receiver !== undefined && { receiver }),
2206
+ ...(receiverType && { receiverType }),
2207
+ ...(edgeCalledAs && { calledAs: edgeCalledAs }),
2208
+ };
2209
+ if (unverifiedEnriched < unverifiedEnrichLimit) {
2210
+ if (content === null) {
2211
+ try { content = fs.readFileSync(filePath, 'utf-8'); }
2212
+ catch (e) { content = ''; }
2213
+ }
2214
+ const enclosing = index.findEnclosingFunction(filePath, call.line, true);
2215
+ unverifiedEntries.push({
2216
+ ...base,
2217
+ content: getLine(content, call.line),
2218
+ callerName: enclosing ? enclosing.name : null,
2219
+ callerFile: enclosing ? filePath : null,
2220
+ callerStartLine: enclosing ? enclosing.startLine : null,
2221
+ callerEndLine: enclosing ? enclosing.endLine : null,
2222
+ });
2223
+ unverifiedEnriched++;
2224
+ } else {
2225
+ unverifiedEntries.push(base);
2226
+ }
2227
+ continue;
2228
+ }
2229
+ // Tier stamped ONLY under collectAccount so trace/blast/verify
2230
+ // results stay byte-identical. A known type mismatch can never
2231
+ // tier as confirmed, whatever its resolution score says.
2232
+ const tier = collectAccount
2233
+ ? (_evidence && _evidence.typeMismatch ? TIER.UNVERIFIED : tierForResolution(scored.resolution))
2234
+ : undefined;
2235
+ if (enrichedCount >= enrichLimit) {
2236
+ // Push shadow only — no file read needed.
2237
+ shadowEntries.push({
2238
+ file: filePath,
2239
+ relativePath: fileEntry.relativePath,
2240
+ line: call.line,
2241
+ confidence: scored.confidence,
2242
+ resolution: scored.resolution,
2243
+ ...(tier && { tier }),
2244
+ isMethod: call.isMethod || false,
2245
+ ...(isFunctionReference && { isFunctionReference: true }),
2246
+ ...(receiver !== undefined && { receiver }),
2247
+ ...(receiverType && { receiverType }),
2248
+ ...(edgeCalledAs && { calledAs: edgeCalledAs }),
2249
+ });
2250
+ continue;
2251
+ }
2252
+ // First time we hit this file's enrichment loop — read the file once.
686
2253
  if (content === null) {
687
2254
  try { content = fs.readFileSync(filePath, 'utf-8'); }
688
2255
  catch (e) { content = ''; /* deleted/unreadable; skip enrichment for rest */ break; }
@@ -700,8 +2267,10 @@ function findCallers(index, name, options = {}) {
700
2267
  ...(isFunctionReference && { isFunctionReference: true }),
701
2268
  ...(receiver !== undefined && { receiver }),
702
2269
  ...(receiverType && { receiverType }),
2270
+ ...(edgeCalledAs && { calledAs: edgeCalledAs }),
703
2271
  confidence: scored.confidence,
704
2272
  resolution: scored.resolution,
2273
+ ...(tier && { tier }),
705
2274
  });
706
2275
  enrichedCount++;
707
2276
  }
@@ -724,6 +2293,29 @@ function findCallers(index, name, options = {}) {
724
2293
  writable: true,
725
2294
  configurable: true,
726
2295
  });
2296
+ // Conservation raw data (collectAccount only): dropped-candidate lines with
2297
+ // reasons, consumed by composeAccount in analysis.js. Non-enumerable so
2298
+ // JSON.stringify of results is unaffected.
2299
+ if (accountRaw) {
2300
+ Object.defineProperty(callers, 'accountRaw', {
2301
+ value: accountRaw,
2302
+ enumerable: false,
2303
+ writable: true,
2304
+ configurable: true,
2305
+ });
2306
+ // Retained unverified-tier entries, sorted (relativePath, line) per the
2307
+ // output ordering contract.
2308
+ unverifiedEntries.sort((a, b) => {
2309
+ if (a.relativePath !== b.relativePath) return a.relativePath.localeCompare(b.relativePath);
2310
+ return (a.line || 0) - (b.line || 0);
2311
+ });
2312
+ Object.defineProperty(callers, 'unverifiedEntries', {
2313
+ value: unverifiedEntries,
2314
+ enumerable: false,
2315
+ writable: true,
2316
+ configurable: true,
2317
+ });
2318
+ }
727
2319
 
728
2320
  return callers;
729
2321
  } finally { index._endOp(); }
@@ -774,6 +2366,66 @@ function findCallees(index, def, options = {}) {
774
2366
  let selfAttrCalls = null; // collected for Python self.attr.method() resolution
775
2367
  let selfMethodCalls = null; // collected for Python self.method() resolution
776
2368
 
2369
+ // Callee conservation account (trace-down contract): every call record
2370
+ // in the def's scope lands in exactly one bucket — confirmed callee
2371
+ // edge, retained unverified entry (visible, with reason), external/
2372
+ // builtin, excluded-with-reason, or display-filtered. The unit is the
2373
+ // call RECORD (a line may hold several); siteIds (record ordinals)
2374
+ // keep the arithmetic exact when one record yields multiple edges
2375
+ // (same-name overload fan-out). collectAccount-gated: legacy callers
2376
+ // of findCallees (context/about/smart) see byte-identical results.
2377
+ const collectAccount = !!options.collectAccount;
2378
+ const calleeAccount = collectAccount ? {
2379
+ totalSites: 0,
2380
+ confirmed: 0,
2381
+ unverified: 0,
2382
+ external: { count: 0, sample: [] },
2383
+ excluded: { total: 0, byReason: {} },
2384
+ filtered: { count: 0, byReason: {} },
2385
+ } : null;
2386
+ const claimedSiteIds = collectAccount ? new Set() : null;
2387
+ const unverifiedCallees = collectAccount ? new Map() : null; // name|reason -> entry
2388
+ const noteSite = (siteId, bucket, reason, call) => {
2389
+ if (!calleeAccount || claimedSiteIds.has(siteId)) return;
2390
+ claimedSiteIds.add(siteId);
2391
+ if (bucket === 'confirmed') {
2392
+ calleeAccount.confirmed++;
2393
+ } else if (bucket === 'unverified') {
2394
+ calleeAccount.unverified++;
2395
+ } else if (bucket === 'external') {
2396
+ calleeAccount.external.count++;
2397
+ if (call && calleeAccount.external.sample.length < 3) {
2398
+ calleeAccount.external.sample.push({ name: call.name, line: call.line });
2399
+ }
2400
+ } else if (bucket === 'excluded') {
2401
+ const r = reason || 'excluded';
2402
+ calleeAccount.excluded.total++;
2403
+ if (!calleeAccount.excluded.byReason[r]) calleeAccount.excluded.byReason[r] = 0;
2404
+ calleeAccount.excluded.byReason[r]++;
2405
+ } else if (bucket === 'filtered') {
2406
+ const r = reason || 'filtered';
2407
+ calleeAccount.filtered.count++;
2408
+ if (!calleeAccount.filtered.byReason[r]) calleeAccount.filtered.byReason[r] = 0;
2409
+ calleeAccount.filtered.byReason[r]++;
2410
+ }
2411
+ };
2412
+ // Retain an uncertain/unresolved call as a visible unverified callee
2413
+ // entry (aggregated by name+reason) and claim its site.
2414
+ const noteUnverified = (siteId, call, reason) => {
2415
+ if (!collectAccount || claimedSiteIds.has(siteId)) return;
2416
+ noteSite(siteId, 'unverified', reason, call);
2417
+ const key = `${call.name}|${reason}`;
2418
+ let entry = unverifiedCallees.get(key);
2419
+ if (!entry) {
2420
+ const defs = index.symbols.get(call.name) || [];
2421
+ const owners = defs.filter(s => !NON_CALLABLE_TYPES.has(s.type)).length;
2422
+ entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners };
2423
+ unverifiedCallees.set(key, entry);
2424
+ }
2425
+ entry.callCount++;
2426
+ entry.sites.push(call.line);
2427
+ };
2428
+
777
2429
  // Build local variable type map for receiver resolution
778
2430
  // Scans for patterns like: bt = Backtester(...) → bt maps to Backtester
779
2431
  let localTypes = null;
@@ -783,7 +2435,10 @@ function findCallees(index, def, options = {}) {
783
2435
  localTypes = _buildTypedLocalTypeMap(index, def, calls);
784
2436
  }
785
2437
 
2438
+ let siteOrdinal = -1;
786
2439
  for (const call of calls) {
2440
+ siteOrdinal++;
2441
+ const siteId = siteOrdinal;
787
2442
  // Filter to calls within this function's scope
788
2443
  // Method 1: Direct match via enclosingFunction (fast path for direct calls)
789
2444
  const isDirectMatch = call.enclosingFunction &&
@@ -796,6 +2451,7 @@ function findCallees(index, def, options = {}) {
796
2451
  const isNestedCallback = isInRange && !isInInnerSymbol && !isDirectMatch;
797
2452
 
798
2453
  if (!isDirectMatch && !isNestedCallback) continue;
2454
+ if (calleeAccount) calleeAccount.totalSites++;
799
2455
 
800
2456
  // Smart method call handling:
801
2457
  // - Go: include all method calls (Go doesn't use this/self/cls)
@@ -839,9 +2495,15 @@ function findCallees(index, def, options = {}) {
839
2495
  const existing = callees.get(key);
840
2496
  if (existing) {
841
2497
  existing.count += 1;
2498
+ if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
842
2499
  } else {
843
- callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1 });
2500
+ callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
2501
+ ...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
844
2502
  }
2503
+ } else if (collectAccount) {
2504
+ // Locally-typed receiver, but the type defines no such
2505
+ // method in the index — visible, never silently dropped.
2506
+ noteUnverified(siteId, call, 'uncertain-receiver');
845
2507
  }
846
2508
  continue;
847
2509
  } else if (call.receiverType) {
@@ -875,8 +2537,10 @@ function findCallees(index, def, options = {}) {
875
2537
  const existing = callees.get(key);
876
2538
  if (existing) {
877
2539
  existing.count += 1;
2540
+ if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
878
2541
  } else {
879
- callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1 });
2542
+ callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
2543
+ ...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
880
2544
  }
881
2545
  continue;
882
2546
  }
@@ -924,22 +2588,29 @@ function findCallees(index, def, options = {}) {
924
2588
  const existing = callees.get(key);
925
2589
  if (existing) {
926
2590
  existing.count += 1;
2591
+ if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
927
2592
  } else {
928
- callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1 });
2593
+ callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1,
2594
+ ...(collectAccount && { sites: [call.line], siteIds: [siteId] }) });
929
2595
  }
930
2596
  continue;
931
2597
  }
932
2598
  }
933
2599
  // Import resolved but no project definition matches — external call, skip
2600
+ noteSite(siteId, 'external', null, call);
934
2601
  continue;
935
2602
  }
936
2603
  } else if (langTraits(language)?.methodCallInclusion === 'explicit' && !options.includeMethods) {
2604
+ noteSite(siteId, 'filtered', 'method-calls-excluded', call);
937
2605
  continue;
938
2606
  }
939
2607
  }
940
2608
 
941
2609
  // Skip keywords and built-ins
942
- if (index.isKeyword(call.name, language)) continue;
2610
+ if (index.isKeyword(call.name, language)) {
2611
+ noteSite(siteId, 'external', null, call);
2612
+ continue;
2613
+ }
943
2614
 
944
2615
  // Use resolved name (from alias tracking) if available
945
2616
  // For multi-target aliases (ternary), pick the first that exists in symbol table
@@ -959,11 +2630,15 @@ function findCallees(index, def, options = {}) {
959
2630
  const syms = index.symbols.get(effectiveName);
960
2631
  if (!syms || !syms.some(s =>
961
2632
  ['function', 'method', 'constructor', 'static', 'public', 'abstract'].includes(s.type))) {
2633
+ // Argument-position name with no function definition — a
2634
+ // local variable or data, positively not a callee edge.
2635
+ noteSite(siteId, 'excluded', 'callback-no-evidence', call);
962
2636
  continue;
963
2637
  }
964
2638
  const hasBinding = fileEntry?.bindings?.some(b => b.name === call.name);
965
2639
  const inSameFile = syms.some(s => s.file === def.file);
966
2640
  if (!hasBinding && !inSameFile) {
2641
+ noteSite(siteId, 'excluded', 'callback-no-evidence', call);
967
2642
  continue;
968
2643
  }
969
2644
  }
@@ -971,21 +2646,21 @@ function findCallees(index, def, options = {}) {
971
2646
  // Collect selfAttribute calls for second-pass resolution
972
2647
  if (call.selfAttribute && language === 'python') {
973
2648
  if (!selfAttrCalls) selfAttrCalls = [];
974
- selfAttrCalls.push(call);
2649
+ selfAttrCalls.push({ call, siteId });
975
2650
  continue;
976
2651
  }
977
2652
 
978
2653
  // Collect self/this.method() calls for same-class resolution
979
2654
  if (call.isMethod && ['self', 'cls', 'this'].includes(call.receiver)) {
980
2655
  if (!selfMethodCalls) selfMethodCalls = [];
981
- selfMethodCalls.push(call);
2656
+ selfMethodCalls.push({ call, siteId });
982
2657
  continue;
983
2658
  }
984
2659
 
985
2660
  // Collect super().method() calls for parent-class resolution
986
2661
  if (call.isMethod && call.receiver === 'super') {
987
2662
  if (!selfMethodCalls) selfMethodCalls = [];
988
- selfMethodCalls.push(call);
2663
+ selfMethodCalls.push({ call, siteId });
989
2664
  continue;
990
2665
  }
991
2666
 
@@ -993,6 +2668,7 @@ function findCallees(index, def, options = {}) {
993
2668
  let calleeKey = call.bindingId || effectiveName;
994
2669
  let bindingResolved = call.bindingId;
995
2670
  let isUncertain = call.uncertain;
2671
+ let uncertainReason = null; // account-mode reason for the unverified bucket
996
2672
  if (!call.bindingId && fileEntry?.bindings) {
997
2673
  let bindings = fileEntry.bindings.filter(b => b.name === call.name);
998
2674
  // For Go, also check sibling files in same directory (same package scope)
@@ -1043,9 +2719,11 @@ function findCallees(index, def, options = {}) {
1043
2719
  bindingResolved = matchingDef.bindingId;
1044
2720
  } else {
1045
2721
  isUncertain = true;
2722
+ uncertainReason = 'method-ambiguous';
1046
2723
  }
1047
2724
  } else {
1048
2725
  isUncertain = true;
2726
+ uncertainReason = 'method-ambiguous';
1049
2727
  }
1050
2728
  }
1051
2729
  }
@@ -1072,22 +2750,49 @@ function findCallees(index, def, options = {}) {
1072
2750
  } else if (bindings.length > 1) {
1073
2751
  if (call.name === def.name) {
1074
2752
  // Calling same-name function (e.g., Java overloads)
1075
- // Add ALL other overloads as potential callees
1076
- const otherBindings = bindings.filter(b =>
2753
+ // Add ALL other overloads as potential callees.
2754
+ // A RECEIVER-QUALIFIED same-name call names its type
2755
+ // (Rust `Patterns::from_low_args(...)`, Go `T.M(...)`)
2756
+ // — resolve to the matching class's binding instead of
2757
+ // spraying every same-name def (#223, ripgrep-measured
2758
+ // on the callee eval arm: HiArgs::from_low_args calls
2759
+ // 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
2762
+ // implicit-this overloads) keep fanning out.
2763
+ let otherBindings = bindings.filter(b =>
1077
2764
  b.startLine !== def.startLine
1078
2765
  );
2766
+ const fanReceiver = call.receiver || call.receiverType;
2767
+ if (fanReceiver && otherBindings.length > 1) {
2768
+ const symsForName = index.symbols.get(call.name) || [];
2769
+ const classMatched = otherBindings.filter(b => {
2770
+ const bSym = symsForName.find(s => s.bindingId === b.id);
2771
+ const cls = bSym && (bSym.className ||
2772
+ (bSym.receiver && bSym.receiver.replace(/^\*/, '')));
2773
+ return cls === fanReceiver;
2774
+ });
2775
+ if (classMatched.length > 0) otherBindings = classMatched;
2776
+ }
1079
2777
  for (const ob of otherBindings) {
1080
2778
  const existing = callees.get(ob.id);
1081
2779
  if (existing) {
1082
2780
  existing.count += 1;
2781
+ if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
1083
2782
  } else {
1084
2783
  callees.set(ob.id, {
1085
2784
  name: effectiveName,
1086
2785
  bindingId: ob.id,
1087
- count: 1
2786
+ count: 1,
2787
+ ...(collectAccount && { sites: [call.line], siteIds: [siteId] })
1088
2788
  });
1089
2789
  }
1090
2790
  }
2791
+ if (otherBindings.length === 0) {
2792
+ // All same-name bindings are the def itself — a
2793
+ // recursive self-call, never a callee edge.
2794
+ noteSite(siteId, 'excluded', 'self-recursion', call);
2795
+ }
1091
2796
  continue; // Already added all overloads, skip normal add
1092
2797
  } else if (def.className && !call.isMethod) {
1093
2798
  // Implicit same-class call (Java: execute() means this.execute())
@@ -1107,9 +2812,11 @@ function findCallees(index, def, options = {}) {
1107
2812
  }
1108
2813
  } else {
1109
2814
  isUncertain = true;
2815
+ uncertainReason = 'binding-ambiguous';
1110
2816
  }
1111
2817
  } else {
1112
2818
  isUncertain = true;
2819
+ uncertainReason = 'binding-ambiguous';
1113
2820
  }
1114
2821
  } else {
1115
2822
  // Try to resolve to a binding defined within the parent function's
@@ -1122,25 +2829,47 @@ function findCallees(index, def, options = {}) {
1122
2829
  calleeKey = bindingResolved;
1123
2830
  } else {
1124
2831
  isUncertain = true;
2832
+ uncertainReason = 'binding-ambiguous';
1125
2833
  }
1126
2834
  }
1127
2835
  }
1128
2836
  }
1129
2837
 
1130
- if (isUncertain && !options.includeUncertain) {
1131
- if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
1132
- continue;
2838
+ if (isUncertain) {
2839
+ if (collectAccount) {
2840
+ // Contract mode: uncertain callee edges are never silently
2841
+ // dropped NOR silently confirmed — visible unverified
2842
+ // entries with a reason. --include-uncertain is an implied
2843
+ // no-op here (the caller-contract precedent).
2844
+ if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
2845
+ noteUnverified(siteId, call, uncertainReason || 'uncertain-receiver');
2846
+ continue;
2847
+ }
2848
+ if (!options.includeUncertain) {
2849
+ if (options.stats) options.stats.uncertain = (options.stats.uncertain || 0) + 1;
2850
+ continue;
2851
+ }
1133
2852
  }
1134
2853
 
1135
2854
  const existing = callees.get(calleeKey);
1136
2855
  if (existing) {
1137
2856
  existing.count += 1;
2857
+ if (collectAccount) {
2858
+ existing.sites.push(call.line);
2859
+ existing.siteIds.push(siteId);
2860
+ if (call.isPotentialCallback || call.isFunctionReference) existing.isFunctionReference = true;
2861
+ }
1138
2862
  } else {
1139
2863
  callees.set(calleeKey, {
1140
2864
  name: effectiveName,
1141
2865
  bindingId: bindingResolved,
1142
2866
  count: 1,
1143
- ...(call.isConstructor && { isConstructor: true })
2867
+ ...(call.isConstructor && { isConstructor: true }),
2868
+ ...(collectAccount && {
2869
+ sites: [call.line],
2870
+ siteIds: [siteId],
2871
+ ...((call.isPotentialCallback || call.isFunctionReference) && { isFunctionReference: true }),
2872
+ })
1144
2873
  });
1145
2874
  }
1146
2875
  }
@@ -1149,7 +2878,7 @@ function findCallees(index, def, options = {}) {
1149
2878
  // Respect includeMethods=false — skip self/this method resolution entirely
1150
2879
  if (selfAttrCalls && def.className && options.includeMethods !== false) {
1151
2880
  const attrTypes = getInstanceAttributeTypes(index, def.file, def.className);
1152
- for (const call of selfAttrCalls) {
2881
+ for (const { call, siteId } of selfAttrCalls) {
1153
2882
  let targetClass = attrTypes ? attrTypes.get(call.selfAttribute) : null;
1154
2883
  // Unique method heuristic: if attr type unknown but method exists on exactly one class
1155
2884
  if (!targetClass) {
@@ -1164,36 +2893,45 @@ function findCallees(index, def, options = {}) {
1164
2893
  }
1165
2894
  }
1166
2895
  }
1167
- if (!targetClass) continue;
2896
+ if (!targetClass) { noteUnverified(siteId, call, 'self-attr-unresolved'); continue; }
1168
2897
 
1169
2898
  // Find method in symbol table where className matches
1170
2899
  const symbols = index.symbols.get(call.name);
1171
- if (!symbols) continue;
2900
+ if (!symbols) { noteUnverified(siteId, call, 'self-attr-unresolved'); continue; }
1172
2901
 
1173
2902
  const match = symbols.find(s => s.className === targetClass);
1174
- if (!match) continue;
2903
+ if (!match) { noteUnverified(siteId, call, 'self-attr-unresolved'); continue; }
1175
2904
 
1176
2905
  const key = match.bindingId || `${targetClass}.${call.name}`;
1177
2906
  const existing = callees.get(key);
1178
2907
  if (existing) {
1179
2908
  existing.count += 1;
2909
+ if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
1180
2910
  } else {
1181
2911
  callees.set(key, {
1182
2912
  name: call.name,
1183
2913
  bindingId: match.bindingId,
1184
- count: 1
2914
+ count: 1,
2915
+ ...(collectAccount && { sites: [call.line], siteIds: [siteId] })
1185
2916
  });
1186
2917
  }
1187
2918
  }
2919
+ } else if (selfAttrCalls && collectAccount) {
2920
+ // Pass skipped (no class context, or methods display-filtered):
2921
+ // claim the sites so the account stays conserved.
2922
+ for (const { call, siteId } of selfAttrCalls) {
2923
+ if (options.includeMethods === false) noteSite(siteId, 'filtered', 'method-calls-excluded', call);
2924
+ else noteUnverified(siteId, call, 'self-attr-unresolved');
2925
+ }
1188
2926
  }
1189
2927
 
1190
2928
  // Third pass: resolve self/this/super.method() calls to same-class or parent methods
1191
2929
  // Falls back to walking the inheritance chain if not found in same class
1192
2930
  // Respect includeMethods=false — skip self/this method resolution entirely
1193
2931
  if (selfMethodCalls && def.className && options.includeMethods !== false) {
1194
- for (const call of selfMethodCalls) {
2932
+ for (const { call, siteId } of selfMethodCalls) {
1195
2933
  const symbols = index.symbols.get(call.name);
1196
- if (!symbols) continue;
2934
+ if (!symbols) { noteUnverified(siteId, call, 'inherited-unresolved'); continue; }
1197
2935
 
1198
2936
  // For super().method(), skip same-class — start from parent
1199
2937
  let match = call.receiver === 'super'
@@ -1221,20 +2959,27 @@ function findCallees(index, def, options = {}) {
1221
2959
  }
1222
2960
  }
1223
2961
 
1224
- if (!match) continue;
2962
+ if (!match) { noteUnverified(siteId, call, 'inherited-unresolved'); continue; }
1225
2963
 
1226
2964
  const key = match.bindingId || `${match.className}.${call.name}`;
1227
2965
  const existing = callees.get(key);
1228
2966
  if (existing) {
1229
2967
  existing.count += 1;
2968
+ if (collectAccount) { existing.sites.push(call.line); existing.siteIds.push(siteId); }
1230
2969
  } else {
1231
2970
  callees.set(key, {
1232
2971
  name: call.name,
1233
2972
  bindingId: match.bindingId,
1234
- count: 1
2973
+ count: 1,
2974
+ ...(collectAccount && { sites: [call.line], siteIds: [siteId] })
1235
2975
  });
1236
2976
  }
1237
2977
  }
2978
+ } else if (selfMethodCalls && collectAccount) {
2979
+ for (const { call, siteId } of selfMethodCalls) {
2980
+ if (options.includeMethods === false) noteSite(siteId, 'filtered', 'method-calls-excluded', call);
2981
+ else noteUnverified(siteId, call, 'inherited-unresolved');
2982
+ }
1238
2983
  }
1239
2984
 
1240
2985
  // Look up each callee in the symbol table
@@ -1248,9 +2993,21 @@ function findCallees(index, def, options = {}) {
1248
2993
  // Pre-compute import graph for callee confidence scoring
1249
2994
  const callerImportSet = index.importGraph.get(def.file) || new Set();
1250
2995
 
1251
- for (const { name: calleeName, bindingId, count, isConstructor } of callees.values()) {
2996
+ for (const { name: calleeName, bindingId, count, isConstructor, sites, siteIds, isFunctionReference } of callees.values()) {
2997
+ const claimSites = (bucket, reason) => {
2998
+ if (!collectAccount || !siteIds) return;
2999
+ for (let i = 0; i < siteIds.length; i++) {
3000
+ noteSite(siteIds[i], bucket, reason, { name: calleeName, line: sites[i] });
3001
+ }
3002
+ };
1252
3003
  const symbols = index.symbols.get(calleeName);
1253
- if (symbols && symbols.length > 0) {
3004
+ if (!symbols || symbols.length === 0) {
3005
+ // Name not in the symbol table — external library, builtin, or
3006
+ // unindexed code. Visible in the callee account, not an edge.
3007
+ claimSites('external', null);
3008
+ continue;
3009
+ }
3010
+ if (symbols.length > 0) {
1254
3011
  let callee = symbols[0];
1255
3012
 
1256
3013
  // If we have a binding ID, find the exact matching symbol
@@ -1348,7 +3105,10 @@ function findCallees(index, def, options = {}) {
1348
3105
  const isFuncField = callee.type === 'field' && callee.fieldType &&
1349
3106
  /^func\b/.test(callee.fieldType);
1350
3107
  // Constructor calls (new Foo()) are always callable regardless of type
1351
- if (!isFuncField && !isConstructor) continue;
3108
+ if (!isFuncField && !isConstructor) {
3109
+ claimSites('excluded', 'non-callable-shadow');
3110
+ continue;
3111
+ }
1352
3112
  }
1353
3113
 
1354
3114
  // Skip test-file callees when caller is production code and
@@ -1356,6 +3116,7 @@ function findCallees(index, def, options = {}) {
1356
3116
  if (!callerIsTest && !bindingId) {
1357
3117
  const calleeFileEntry = index.files.get(callee.file);
1358
3118
  if (calleeFileEntry && isTestFile(calleeFileEntry.relativePath, calleeFileEntry.language)) {
3119
+ claimSites('excluded', 'test-file-no-import-link');
1359
3120
  continue;
1360
3121
  }
1361
3122
  }
@@ -1366,12 +3127,18 @@ function findCallees(index, def, options = {}) {
1366
3127
  (callee.file === def.file) || callerImportSet.has(callee.file),
1367
3128
  isUncertain: false, // uncertain callees already filtered above
1368
3129
  });
3130
+ claimSites('confirmed', null);
1369
3131
  result.push({
1370
3132
  ...callee,
1371
3133
  callCount: count,
1372
3134
  weight: index.calculateWeight(count),
1373
3135
  confidence: calleeScored.confidence,
1374
3136
  resolution: calleeScored.resolution,
3137
+ ...(collectAccount && {
3138
+ tier: TIER.CONFIRMED,
3139
+ sites: [...sites].sort((a, b) => a - b),
3140
+ ...(isFunctionReference && { functionReference: true }),
3141
+ }),
1375
3142
  });
1376
3143
  }
1377
3144
  }
@@ -1379,6 +3146,24 @@ function findCallees(index, def, options = {}) {
1379
3146
  // Sort by call count (core dependencies first)
1380
3147
  result.sort((a, b) => b.callCount - a.callCount);
1381
3148
 
3149
+ if (calleeAccount) {
3150
+ const claimed = calleeAccount.confirmed + calleeAccount.unverified +
3151
+ calleeAccount.external.count + calleeAccount.excluded.total +
3152
+ calleeAccount.filtered.count;
3153
+ calleeAccount.unaccounted = calleeAccount.totalSites - claimed;
3154
+ calleeAccount.conserved = calleeAccount.unaccounted === 0;
3155
+ // Stable ordering (output contract): by name, then reason.
3156
+ const unverifiedList = [...unverifiedCallees.values()]
3157
+ .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));
3159
+ Object.defineProperty(result, 'calleeAccount', {
3160
+ value: calleeAccount, enumerable: false, writable: true, configurable: true,
3161
+ });
3162
+ Object.defineProperty(result, 'unverifiedCallees', {
3163
+ value: unverifiedList, enumerable: false, writable: true, configurable: true,
3164
+ });
3165
+ }
3166
+
1382
3167
  return result;
1383
3168
  } catch (e) {
1384
3169
  // Expected: file read/parse failures (minified, binary, buffer exceeded).
@@ -1491,6 +3276,1483 @@ function _buildLocalTypeMap(index, def, calls) {
1491
3276
  * variable types for method resolution. Not used by JS/TS/Python -- structural
1492
3277
  * languages use import evidence via _buildLocalTypeMap instead.
1493
3278
  */
3279
+ /**
3280
+ * Single concrete type name from a return-annotation STRING (symbols store
3281
+ * returnType as text). Conservative: ambiguous shapes return undefined.
3282
+ * Handles: Foo · pkg.Foo · "Foo" · Foo | None · Optional[Foo] · Promise<Foo> ·
3283
+ * list[Item] (→ list — the value IS a list) · Foo<T> / Foo[T] (→ Foo).
3284
+ */
3285
+ function _typeNameFromReturnAnnotation(text) {
3286
+ if (!text || typeof text !== 'string') return undefined;
3287
+ let t = text.trim().replace(/^["']|["']$/g, '').trim();
3288
+ // X | None / X | null / X | undefined → X (single real member only)
3289
+ if (t.includes('|')) {
3290
+ const parts = t.split('|').map(s => s.trim())
3291
+ .filter(s => !['None', 'null', 'undefined'].includes(s));
3292
+ if (parts.length !== 1) return undefined;
3293
+ t = parts[0];
3294
+ }
3295
+ // unwrap value-transparent wrappers: Optional[X], Promise<X>, Awaitable[X]
3296
+ let m;
3297
+ while ((m = t.match(/^(?:typing\.)?(Optional|Annotated|Final|Promise|Awaitable)\s*[[<]\s*([^,]+?)\s*[\]>]$/))) {
3298
+ t = m[2].trim();
3299
+ }
3300
+ // generic base: Foo[...] / Foo<...> → Foo (the value is a Foo)
3301
+ m = t.match(/^([\w.]+)\s*[[<]/);
3302
+ if (m) t = m[1];
3303
+ // dotted → last segment; validate a bare identifier remains
3304
+ const last = t.split('.').pop();
3305
+ return /^[A-Za-z_]\w*$/.test(last) ? last : undefined;
3306
+ }
3307
+
3308
+ /**
3309
+ * Per-file return-type-flow map: variables typed by what the assigned call
3310
+ * returns. Key `${enclosingFnStartLine||''}:${varName}` → [{ line, type,
3311
+ * fromFile? }] (all assignments, so lookups can pick the nearest preceding
3312
+ * one). All producer resolutions are conservative.
3313
+ *
3314
+ * Structural shapes (fix #199 — unchanged):
3315
+ * - typed-receiver method call: receiverType class (or an ancestor walk is
3316
+ * NOT attempted — exact className match only) defines the method with a
3317
+ * return annotation
3318
+ * - self/this/cls method call: the enclosing class (walking up its
3319
+ * inheritance chain for inherited methods) defines the method with a
3320
+ * return annotation
3321
+ * - plain call with exactly ONE project definition carrying a return annotation
3322
+ *
3323
+ * Nominal shapes (fix #207 — compiler-checked annotations, so resolution
3324
+ * confidence carries; same-named owners must AGREE on the return type):
3325
+ * - Go package-qualified producer: bb := balancer.Get(n) — defs resolved
3326
+ * strictly into the imported package (no root-package trust)
3327
+ * - Rust path producer: let c = Config::load()? — last path segment as the
3328
+ * impl type; Result/Option unwrap via assignedUnwrap; Self → the impl type
3329
+ * - Java static producer (typeQualifiedCallStyle 'static'): var c =
3330
+ * Config.parse(...) — receiver as className
3331
+ * - plain producer: Go resolves same-package ONLY (an unqualified Go call
3332
+ * cannot reach another package); Rust/Java add a same-file narrowing on
3333
+ * top of the global-unique rule
3334
+ * Nominal entries carry fromFile — the TYPE's defining file resolved from
3335
+ * the PRODUCER's scope (_resolveFlowTypeOrigin) — so the #206 identity
3336
+ * discipline resolves the name where the annotation lives, not where the
3337
+ * consuming call happens to be.
3338
+ */
3339
+ function _buildReturnTypeFlowMap(index, filePath, calls) {
3340
+ const fileEntry = index.files.get(filePath);
3341
+ const language = fileEntry?.language;
3342
+ const nominal = langTraits(language)?.typeSystem === 'nominal';
3343
+ let map = null;
3344
+ for (const call of calls) {
3345
+ if (!call.assignedTo) continue;
3346
+ let returnType, fromFile, selfClass;
3347
+ if (call.isMethod && call.receiverType) {
3348
+ const defs = index.symbols.get(call.name) || [];
3349
+ if (nominal) {
3350
+ const matches = defs.filter(d => d.className === call.receiverType && d.returnType);
3351
+ if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
3352
+ returnType = matches[0].returnType;
3353
+ fromFile = matches[0].file;
3354
+ selfClass = matches[0].className;
3355
+ }
3356
+ } else {
3357
+ const def = defs.find(d => d.className === call.receiverType && d.returnType);
3358
+ returnType = def && def.returnType;
3359
+ }
3360
+ } else if (call.isMethod && ['self', 'this', 'cls'].includes(call.receiver)) {
3361
+ const enclosing = index.findEnclosingFunction(filePath, call.line, true);
3362
+ let cls = enclosing && enclosing.className;
3363
+ let ctxFile = filePath;
3364
+ const visited = new Set();
3365
+ while (cls && !visited.has(cls)) {
3366
+ visited.add(cls);
3367
+ const def = (index.symbols.get(call.name) || [])
3368
+ .find(d => d.className === cls && d.returnType);
3369
+ if (def) { returnType = def.returnType; fromFile = def.file; selfClass = cls; break; }
3370
+ const parents = index._getInheritanceParents(cls, ctxFile) || [];
3371
+ const next = parents[0]; // single chain; diamond bases stay untyped
3372
+ if (next && index._resolveClassFile) {
3373
+ ctxFile = index._resolveClassFile(next, ctxFile) || ctxFile;
3374
+ }
3375
+ cls = next;
3376
+ }
3377
+ } else if (nominal && call.isMethod && call.isPathCall && call.receiver) {
3378
+ // Rust: let c = config::Config::load()? — the last path segment
3379
+ // names the impl type (module-path producers stay untyped)
3380
+ const seg = call.receiver.split('::').pop();
3381
+ const matches = (index.symbols.get(call.name) || [])
3382
+ .filter(d => d.className === seg && d.returnType);
3383
+ if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
3384
+ returnType = matches[0].returnType;
3385
+ fromFile = matches[0].file;
3386
+ selfClass = seg;
3387
+ }
3388
+ } else if (nominal && call.isMethod && call.receiver &&
3389
+ langTraits(language)?.typeQualifiedCallStyle === 'static') {
3390
+ // Java static factory: var c = Config.parse(...) — only sound for
3391
+ // the static call style; a Go receiver named like a type is a
3392
+ // VARIABLE (fix #206 typeQualifiedCallStyle discipline)
3393
+ const matches = (index.symbols.get(call.name) || [])
3394
+ .filter(d => d.className === call.receiver && d.returnType);
3395
+ if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
3396
+ returnType = matches[0].returnType;
3397
+ fromFile = matches[0].file;
3398
+ selfClass = call.receiver;
3399
+ }
3400
+ } else if (nominal && !call.isMethod && call.receiver &&
3401
+ langTraits(language)?.hasReceiverPackageCalls) {
3402
+ // Go package-qualified producer: bb := balancer.Get(n) — Get
3403
+ // resolves IN the imported package (fix #206 name ownership)
3404
+ const cands = (index.symbols.get(call.name) || [])
3405
+ .filter(d => !NON_CALLABLE_TYPES.has(d.type) && d.returnType);
3406
+ const inPkg = fileEntry && _qualifiedProducerDefs(index, fileEntry, call.receiver, cands);
3407
+ if (inPkg && inPkg.length > 0 && new Set(inPkg.map(d => d.returnType)).size === 1) {
3408
+ returnType = inPkg[0].returnType;
3409
+ fromFile = inPkg[0].file;
3410
+ } else {
3411
+ // External producer (fix #220, cobra-measured): the parser
3412
+ // marked this call package-qualified (receiver ∈ imports),
3413
+ // and the package resolves to no project def — the variable's
3414
+ // type was decided OUTSIDE the project (av := reflect.ValueOf).
3415
+ // Not positive evidence for any type, but compiler-grade
3416
+ // evidence AGAINST single-owner confirmation: route visible.
3417
+ // EVERY tuple element is external-decided (tmpFile, err := …),
3418
+ // unlike typed flow which pairs only element 0 (#207).
3419
+ const scope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
3420
+ if (!map) map = new Map();
3421
+ for (const lhs of [call.assignedTo, ...(call.assignedTupleRest || [])]) {
3422
+ const key = `${scope}:${lhs}`;
3423
+ if (!map.has(key)) map.set(key, []);
3424
+ map.get(key).push({ line: call.line, externalVia: `${call.receiver}.${call.name}` });
3425
+ }
3426
+ continue;
3427
+ }
3428
+ } else if (!nominal && call.isMethod && call.receiver && call.receiverIsModule) {
3429
+ // Structural module-qualified producer (fix #209): schema =
3430
+ // z.string() — the module alias resolves through the file's
3431
+ // import bindings to its file (one re-export hop for barrels),
3432
+ // and the producer's return annotation types the variable.
3433
+ // Standalone exports only (className-less): a module attr is
3434
+ // never a class method.
3435
+ const binding = (fileEntry?.importBindings || []).find(b => b.name === call.receiver);
3436
+ const rel = binding && fileEntry.moduleResolved && fileEntry.moduleResolved[binding.module];
3437
+ if (binding && !rel) {
3438
+ // External module producer (fix #222, httpx-measured — the
3439
+ // #220 Go external-producer rule for structural languages):
3440
+ // logger = logging.getLogger(...) / thread = threading.Thread()
3441
+ // types the variable OUTSIDE the project, so unique project
3442
+ // ownership of a later method name (logger.info vs the only
3443
+ // project `info`) is not identity evidence. Same externality
3444
+ // test as #209 module ownership: relative or project-ish
3445
+ // modules are resolver gaps, never externality evidence.
3446
+ const mod = String(binding.module);
3447
+ const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
3448
+ if (!mod.startsWith('.') &&
3449
+ !(firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
3450
+ const scope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
3451
+ if (!map) map = new Map();
3452
+ const key = `${scope}:${call.assignedTo}`;
3453
+ if (!map.has(key)) map.set(key, []);
3454
+ map.get(key).push({ line: call.line, externalVia: `${call.receiver}.${call.name}` });
3455
+ }
3456
+ continue;
3457
+ }
3458
+ if (rel) {
3459
+ const modFile = path.join(index.root, rel);
3460
+ const cands = (index.symbols.get(call.name) || [])
3461
+ .filter(d => !NON_CALLABLE_TYPES.has(d.type) && d.returnType && !d.className);
3462
+ let matches = cands.filter(d => d.file === modFile);
3463
+ if (matches.length === 0) {
3464
+ const hop = index.importGraph.get(modFile);
3465
+ if (hop) matches = cands.filter(d => hop.has(d.file));
3466
+ }
3467
+ if (matches.length > 0 && new Set(matches.map(d => d.returnType)).size === 1) {
3468
+ returnType = matches[0].returnType;
3469
+ }
3470
+ }
3471
+ } else if (!call.isMethod && !call.receiver) {
3472
+ const defs = (index.symbols.get(call.name) || [])
3473
+ .filter(d => !NON_CALLABLE_TYPES.has(d.type));
3474
+ let chosen = null;
3475
+ if (nominal && langTraits(language)?.packageScope === 'directory') {
3476
+ // Go: an unqualified call resolves within the package — a
3477
+ // globally-unique def in ANOTHER package is unreachable
3478
+ const dir = path.dirname(filePath);
3479
+ const samePkg = defs.filter(d => d.file && path.dirname(d.file) === dir);
3480
+ if (samePkg.length === 1) chosen = samePkg[0];
3481
+ } else if (defs.length === 1) {
3482
+ chosen = defs[0];
3483
+ } else if (nominal && defs.length > 1) {
3484
+ const sameFile = defs.filter(d => d.file === filePath);
3485
+ if (sameFile.length === 1) chosen = sameFile[0];
3486
+ }
3487
+ if (chosen) { returnType = chosen.returnType; fromFile = chosen.file; }
3488
+ }
3489
+ if (!returnType) continue;
3490
+ let typeName, entryFromFile;
3491
+ if (nominal) {
3492
+ const parsed = _returnTypeNameNominal(returnType, language, {
3493
+ unwrapped: call.assignedUnwrap, tuple: call.assignedTuple, selfClass,
3494
+ });
3495
+ if (!parsed) continue;
3496
+ const origin = _resolveFlowTypeOrigin(index, fromFile || filePath, parsed.name, parsed.qualifier);
3497
+ if (!origin) continue; // identity unpinnable — don't type at all
3498
+ typeName = parsed.name;
3499
+ entryFromFile = origin.fromFile;
3500
+ } else {
3501
+ typeName = _typeNameFromReturnAnnotation(returnType);
3502
+ }
3503
+ if (!typeName) continue;
3504
+ const scope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
3505
+ const key = `${scope}:${call.assignedTo}`;
3506
+ if (!map) map = new Map();
3507
+ if (!map.has(key)) map.set(key, []);
3508
+ map.get(key).push({ line: call.line, type: typeName,
3509
+ ...(entryFromFile && { fromFile: entryFromFile }) });
3510
+ }
3511
+ return map;
3512
+ }
3513
+
3514
+ /** Nearest preceding flow assignment for this call's receiver (fn scope, then module). */
3515
+ function _lookupReturnTypeFlow(map, call) {
3516
+ const fnScope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
3517
+ for (const scope of fnScope === '' ? [''] : [fnScope, '']) {
3518
+ const entries = map.get(`${scope}:${call.receiver}`);
3519
+ if (!entries) continue;
3520
+ let best = null;
3521
+ for (const e of entries) {
3522
+ if (e.line < call.line && (!best || e.line > best.line)) best = e;
3523
+ }
3524
+ if (best) return best;
3525
+ }
3526
+ return undefined;
3527
+ }
3528
+
3529
+ // Return-annotation names that must never type a receiver in nominal flow:
3530
+ // builtin interfaces/primitives whose project implementors UCN cannot see —
3531
+ // a receiver typed `error` CAN dispatch into a project type's Error() method,
3532
+ // so excluding on it would lose true edges. (Rust primitives are safe: project
3533
+ // extension impls put the primitive name in dispatchTargetTypes.)
3534
+ const _GO_FLOW_REJECT = new Set([
3535
+ 'error', 'any', 'string', 'bool', 'byte', 'rune', 'uintptr',
3536
+ 'int', 'int8', 'int16', 'int32', 'int64',
3537
+ 'uint', 'uint8', 'uint16', 'uint32', 'uint64',
3538
+ 'float32', 'float64', 'complex64', 'complex128',
3539
+ ]);
3540
+ const _JAVA_FLOW_REJECT = new Set([
3541
+ 'Object', 'void', 'int', 'long', 'short', 'byte', 'char',
3542
+ 'boolean', 'float', 'double', 'var',
3543
+ ]);
3544
+
3545
+ /** Split generic-argument text on commas at angle/paren/bracket depth 0. */
3546
+ function _splitTopLevelGenericArgs(s) {
3547
+ const out = [];
3548
+ let depth = 0, cur = '';
3549
+ for (const ch of s) {
3550
+ if (ch === '<' || ch === '(' || ch === '[') depth++;
3551
+ else if (ch === '>' || ch === ')' || ch === ']') depth--;
3552
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; }
3553
+ else cur += ch;
3554
+ }
3555
+ out.push(cur);
3556
+ return out;
3557
+ }
3558
+
3559
+ /**
3560
+ * Single concrete type name from a NOMINAL return annotation (fix #207).
3561
+ * Returns { name, qualifier } or undefined. Conservative: ambiguous or
3562
+ * non-nominal shapes (slices, maps, chans, fn types, dyn/impl traits,
3563
+ * generic type params, builtin interfaces) return undefined.
3564
+ * - Go: `*Builder` → Builder; tuple `(T, error)` pairs its FIRST element
3565
+ * with a tuple-unpacking assignment (`v, err := f()`) — tuple/assignment
3566
+ * shapes must agree or the parse is wrong; `pkg.Type` keeps the qualifier
3567
+ * - Rust: Self → the impl type; Result<T,_>/Option<T> unwrap ONLY under
3568
+ * assignedUnwrap (`?` / .unwrap() / .expect()); Box/Rc/Arc auto-deref via
3569
+ * _normalizeFieldTypeName
3570
+ * - Java: plain names and generic bases via _normalizeFieldTypeName
3571
+ */
3572
+ function _returnTypeNameNominal(text, language, opts = {}) {
3573
+ if (!text || typeof text !== 'string') return undefined;
3574
+ let t = text.trim();
3575
+ if (language === 'go') {
3576
+ if (t.startsWith('(')) {
3577
+ if (!opts.tuple) return undefined;
3578
+ const inner = t.slice(1, -1);
3579
+ if (inner.includes('func(') || inner.includes('func (')) return undefined;
3580
+ const first = inner.split(',')[0].trim();
3581
+ const parts = first.split(/\s+/);
3582
+ t = parts[parts.length - 1]; // named return `n int` → int
3583
+ } else if (opts.tuple) {
3584
+ return undefined; // v, err := f() needs a multi-return producer
3585
+ }
3586
+ } else if (opts.tuple) {
3587
+ return undefined;
3588
+ }
3589
+ if (language === 'rust') {
3590
+ if (/^&?\s*(mut\s+)?Self$/.test(t)) {
3591
+ return opts.selfClass ? { name: opts.selfClass } : undefined;
3592
+ }
3593
+ if (opts.unwrapped) {
3594
+ const m = t.match(/^(?:[A-Za-z_][A-Za-z0-9_]*\s*::\s*)*(Result|Option)\s*<(.*)>$/s);
3595
+ if (!m) return undefined; // unwrap on a non-Result/Option annotation — alias or parse gap
3596
+ t = (_splitTopLevelGenericArgs(m[2])[0] || '').trim();
3597
+ if (/^&?\s*(mut\s+)?Self$/.test(t)) {
3598
+ return opts.selfClass ? { name: opts.selfClass } : undefined;
3599
+ }
3600
+ }
3601
+ } else if (opts.unwrapped) {
3602
+ return undefined;
3603
+ }
3604
+ // Qualifier survives only the Go shape (`pkg.Type`); Rust paths and Java
3605
+ // dotted names lose theirs in normalization — capture it first.
3606
+ let qualifier;
3607
+ if (language === 'go') {
3608
+ const qm = t.replace(/^\*+/, '').match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/);
3609
+ if (qm) qualifier = qm[1];
3610
+ } else {
3611
+ const stripped = t.replace(/^&+\s*/, '').replace(/^mut\s+/, '');
3612
+ if (/^[A-Za-z_$][\w$]*\s*(::|\.)/.test(stripped)) qualifier = '<unresolvable>';
3613
+ }
3614
+ const norm = _normalizeFieldTypeName(t, language);
3615
+ if (!norm) return undefined;
3616
+ if (/^[A-Z][A-Z0-9]?$/.test(norm)) return undefined; // generic type param (T, K, V1)
3617
+ if (language === 'go' && _GO_FLOW_REJECT.has(norm)) return undefined;
3618
+ if (language === 'java' && _JAVA_FLOW_REJECT.has(norm)) return undefined;
3619
+ return { name: norm, qualifier };
3620
+ }
3621
+
3622
+ /**
3623
+ * Pin a flow type name to its defining file from the PRODUCER's scope
3624
+ * (fix #207 — the #206 identity lesson applied to annotations: `Builder` in
3625
+ * balancer/base.go means balancer.Builder; resolving it from the consuming
3626
+ * file's scope could hit an unrelated same-name type). Returns { fromFile }
3627
+ * or null when identity cannot be pinned:
3628
+ * - Go-qualified (`pkg.Type`): the qualifier must resolve through the
3629
+ * producer's imports to exactly one project package — else null
3630
+ * - Rust/Java-qualified annotations: external paths — only acceptable when
3631
+ * NO project type shares the name (the name then can't conflate)
3632
+ * - unqualified: same file → same dir → import edge; unique-anywhere is NOT
3633
+ * trusted (a use/import of an external type can shadow it invisibly)
3634
+ * - no project type def at all: external name — safe, can't conflate
3635
+ */
3636
+ function _resolveFlowTypeOrigin(index, producerFile, typeName, qualifier) {
3637
+ const typeDefs = (index.symbols.get(typeName) || [])
3638
+ .filter(d => IDENTITY_TYPE_KINDS.has(d.type) && d.file);
3639
+ if (typeDefs.length === 0) return { fromFile: producerFile };
3640
+ if (qualifier === '<unresolvable>') return null;
3641
+ if (qualifier) {
3642
+ const fe = index.files.get(producerFile);
3643
+ const inPkg = fe && _qualifiedProducerDefs(index, fe, qualifier, typeDefs);
3644
+ if (inPkg && inPkg.length > 0 && new Set(inPkg.map(d => d.file)).size === 1) {
3645
+ return { fromFile: inPkg[0].file };
3646
+ }
3647
+ return null;
3648
+ }
3649
+ const sameFile = typeDefs.find(d => d.file === producerFile);
3650
+ if (sameFile) return { fromFile: sameFile.file };
3651
+ const dir = path.dirname(producerFile);
3652
+ const sameDir = typeDefs.filter(d => path.dirname(d.file) === dir);
3653
+ if (sameDir.length === 1) return { fromFile: sameDir[0].file };
3654
+ if (sameDir.length > 1) return null;
3655
+ const imports = index.importGraph.get(producerFile);
3656
+ if (imports) {
3657
+ const imported = typeDefs.filter(d => imports.has(d.file));
3658
+ if (imported.length === 1) return { fromFile: imported[0].file };
3659
+ }
3660
+ return null;
3661
+ }
3662
+
3663
+ /**
3664
+ * Defs that live in the package an import-qualified receiver names, resolved
3665
+ * through the producer file's imports (alias-aware — importNames pairs 1:1
3666
+ * with imports for Go). STRICT counterpart of _receiverPackageResolution:
3667
+ * used for POSITIVE typing (fix #207), so root-package defs ("." — package
3668
+ * identity unverifiable from paths) never match here, while over there they
3669
+ * must never be excluded. Returns null when the receiver names no import.
3670
+ */
3671
+ function _qualifiedProducerDefs(index, fileEntry, receiver, defs) {
3672
+ const modules = fileEntry.imports || [];
3673
+ const names = fileEntry.importNames || [];
3674
+ let importModule = null;
3675
+ if (names.length === modules.length) {
3676
+ const i = names.indexOf(receiver);
3677
+ if (i >= 0) importModule = modules[i];
3678
+ }
3679
+ if (!importModule) {
3680
+ importModule = modules.find(mod => {
3681
+ const parts = mod.split('/');
3682
+ const last = parts[parts.length - 1];
3683
+ const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
3684
+ return pkgName === receiver;
3685
+ }) || null;
3686
+ }
3687
+ if (!importModule || !importModule.includes('/')) return null;
3688
+ const parts = importModule.split('/');
3689
+ const last = parts[parts.length - 1];
3690
+ const pkgSeg = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
3691
+ return defs.filter(d => {
3692
+ if (!d.file) return false;
3693
+ const dir = path.dirname(d.file);
3694
+ const relDir = index.root ? path.relative(index.root, dir) : '';
3695
+ if (!relDir || relDir === '.' || relDir.startsWith('..')) return false;
3696
+ if (importModule === relDir || importModule.endsWith('/' + relDir)) return true;
3697
+ const base = path.basename(dir);
3698
+ return base === pkgSeg || base === receiver;
3699
+ });
3700
+ }
3701
+
3702
+ // Builtin receiver types from literal/annotation inference (Python builtins,
3703
+ // JS globals, TS predefined types). Definitionally not project classes, so a
3704
+ // mismatch against a project class target is always positive evidence.
3705
+ const BUILTIN_RECEIVER_TYPES = new Set([
3706
+ 'dict', 'list', 'set', 'tuple', 'str', 'int', 'float', 'bool', 'bytes', 'frozenset',
3707
+ 'Array', 'String', 'Object', 'RegExp', 'Number', 'Boolean', 'Map', 'Set', 'Promise',
3708
+ 'WeakMap', 'WeakSet',
3709
+ 'string', 'number', 'boolean', 'bigint', 'symbol',
3710
+ ]);
3711
+
3712
+ /**
3713
+ * Can this receiverType justify EXCLUDING a caller (structural languages)?
3714
+ * True for builtins and names that resolve to a project class/struct — types
3715
+ * whose identity and hierarchy UCN tracks. False for type aliases, interfaces,
3716
+ * and external types: those can wrap or alias the target, so a name mismatch
3717
+ * is not evidence against it.
3718
+ */
3719
+ function _receiverTypeTrustedForExclusion(index, typeName) {
3720
+ if (BUILTIN_RECEIVER_TYPES.has(typeName)) return true;
3721
+ const defs = index.symbols.get(typeName);
3722
+ return !!defs && defs.some(d => d.type === 'class' || d.type === 'struct');
3723
+ }
3724
+
3725
+ /**
3726
+ * Resolve which same-name TYPE an unqualified receiver-type name denotes from
3727
+ * a caller file's scope, and compare it against the pinned target's package
3728
+ * (fix #206 — cross-package type-name conflation: grpc-go defines ~20 structs
3729
+ * all named `bb`; a receiver typed `bb` in package leastrequest is not
3730
+ * evidence for cdsbalancer's bb.ParseConfig).
3731
+ *
3732
+ * Nearest-scope resolution: same file → same directory (Go packages, Java
3733
+ * packages, Rust sibling modules) → an import edge to the defining file.
3734
+ *
3735
+ * Returns:
3736
+ * 'target' — the name resolves to the target's package, or only one type
3737
+ * definition exists project-wide (name IS identity)
3738
+ * 'other' — positive evidence it denotes a DIFFERENT same-name type
3739
+ * 'unknown' — several same-name types exist and none is resolvable from
3740
+ * this file's scope (not evidence either way)
3741
+ */
3742
+ /**
3743
+ * Resolve a Go package-qualified receiver to its import module and decide
3744
+ * whether the pinned targets can live in that module's package (fix #206b).
3745
+ * Alias-aware: importNames[i] pairs 1:1 with imports[i] for Go (one package
3746
+ * name per import), so `v3corepb "github.com/.../core/v3"` resolves from the
3747
+ * alias, not the path segment.
3748
+ *
3749
+ * targetInPkg accepts a target whose project-relative directory is a SUFFIX
3750
+ * of the module path (robust when dir names diverge), or whose directory
3751
+ * basename matches the module's package segment / the receiver (conventional
3752
+ * fallback — also covers root-package projects, where relative dir is '').
3753
+ *
3754
+ * Returns null when the receiver names no import (likely a variable).
3755
+ */
3756
+ function _receiverPackageResolution(index, fileEntry, receiver, targetDefs) {
3757
+ const modules = fileEntry.imports || [];
3758
+ const names = fileEntry.importNames || [];
3759
+ let importModule = null;
3760
+ if (names.length === modules.length) {
3761
+ const i = names.indexOf(receiver);
3762
+ if (i >= 0) importModule = modules[i];
3763
+ }
3764
+ if (!importModule) {
3765
+ importModule = modules.find(mod => {
3766
+ const parts = mod.split('/');
3767
+ const last = parts[parts.length - 1];
3768
+ const pkgName = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
3769
+ return pkgName === receiver;
3770
+ }) || null;
3771
+ }
3772
+ if (!importModule) return null;
3773
+ if (!importModule.includes('/')) return { importModule, singleSegment: true, targetInPkg: false };
3774
+ const parts = importModule.split('/');
3775
+ const last = parts[parts.length - 1];
3776
+ const pkgSeg = (/^v\d+$/.test(last) && parts.length > 1) ? parts[parts.length - 2] : last;
3777
+ const targetInPkg = targetDefs.some(d => {
3778
+ if (!d.file) return false;
3779
+ const dir = path.dirname(d.file);
3780
+ const relDir = index.root ? path.relative(index.root, dir) : '';
3781
+ // Root-package target: its directory is the clone dir — package
3782
+ // identity is unverifiable from PATHS, and exclusion requires
3783
+ // POSITIVE evidence. go.mod's module line IS that identity (fix
3784
+ // #220, cobra-measured): `exec.Command(...)` on import "os/exec"
3785
+ // can never denote the root package's Command, while the root
3786
+ // self-import `cobra "github.com/spf13/cobra"` matches exactly.
3787
+ // Without a go.mod, never exclude (checkout-dir-name luck).
3788
+ if (!relDir || relDir === '.') {
3789
+ const goMod = findGoModule(index.root);
3790
+ if (goMod && goMod.modulePath) {
3791
+ // index.root may be a subtree of the go.mod root (grpc-go's
3792
+ // internal/xds target): the root package's import path is
3793
+ // modulePath + the subtree's relative path.
3794
+ let effective = goMod.modulePath;
3795
+ const sub = goMod.root && path.relative(goMod.root, index.root);
3796
+ if (sub && sub !== '.' && !sub.startsWith('..')) {
3797
+ effective = effective + '/' + sub.split(path.sep).join('/');
3798
+ }
3799
+ return importModule === effective;
3800
+ }
3801
+ return true;
3802
+ }
3803
+ if (!relDir.startsWith('..') &&
3804
+ (importModule === relDir || importModule.endsWith('/' + relDir))) return true;
3805
+ const base = path.basename(dir);
3806
+ return base === pkgSeg || base === receiver;
3807
+ });
3808
+ return { importModule, singleSegment: false, targetInPkg };
3809
+ }
3810
+
3811
+ /**
3812
+ * Name-level export-chain reachability (fix #217, rich-measured: 24 test-file
3813
+ * `render(bar)` calls confirmed against markup.render although the binding
3814
+ * `from .render import render` pins to tests/render.py's OWN def — file-level
3815
+ * _importReaches chased on through console.py's imports).
3816
+ *
3817
+ * A binding of NAME resolved to a module file can only denote a def in a
3818
+ * target file if the NAME itself flows there: through the file being a target
3819
+ * file, a re-export of the name (`export {x} from` / `export * from` /
3820
+ * Python `from .x import name`), or surfaces the chase cannot model. Verdicts:
3821
+ * 'yes' — some chain reaches a target file (confirmable, as before)
3822
+ * 'no' — every chain terminates away from the targets (exclusion-grade:
3823
+ * the bare name provably denotes a different def)
3824
+ * 'unknown' — un-modelable surface on a live path: CJS exports (assignment-
3825
+ * based, attribute re-exports indistinguishable from local
3826
+ * values), star imports, module-scope assignments of the name,
3827
+ * module-level __getattr__ (PEP 562), unresolved project-ish
3828
+ * modules, depth exhaustion. Never exclusion evidence.
3829
+ * Pinned targets are defs NAMED `name`, so single renames along the chain
3830
+ * cannot fool a 'no' (a rename changes the exposed attribute name; re-renames
3831
+ * back to the original route through records this chase follows or flags).
3832
+ */
3833
+ function _nameBindingReaches(index, startAbs, name, targetFiles, maxDepth = 4) {
3834
+ let unknown = false;
3835
+ const visited = new Set();
3836
+ let frontier = [[startAbs, name]];
3837
+ for (let d = 0; d <= maxDepth && frontier.length > 0; d++) {
3838
+ const next = [];
3839
+ for (const [abs, attr] of frontier) {
3840
+ if (targetFiles.has(abs)) return 'yes';
3841
+ const stateKey = `${abs}\x00${attr}`;
3842
+ if (visited.has(stateKey)) continue;
3843
+ visited.add(stateKey);
3844
+ const fe = index.files.get(abs);
3845
+ if (!fe) { unknown = true; continue; }
3846
+
3847
+ const enqueue = (module, nextAttr) => {
3848
+ const rel = fe.moduleResolved && fe.moduleResolved[module];
3849
+ if (!rel) {
3850
+ // Unresolved: relative or project-ish → resolver gap, not
3851
+ // a terminal; clearly external → that path pins outside
3852
+ // the project (dead end, consistent with #209c).
3853
+ const mod = String(module);
3854
+ const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
3855
+ if (mod.startsWith('.') ||
3856
+ (firstSeg && _projectTopLevelNames(index).has(firstSeg))) unknown = true;
3857
+ return;
3858
+ }
3859
+ next.push([path.join(index.root, rel), nextAttr]);
3860
+ };
3861
+
3862
+ // CJS export surface is assignment-based (`exports.x = require(..).x`,
3863
+ // `module.exports = require(..)`) and recorded indistinguishably from
3864
+ // local values — a CJS file can never produce a definitive dead end.
3865
+ if ((fe.exportDetails || []).some(e => e.type === 'exports' || e.type === 'module.exports')) {
3866
+ unknown = true;
3867
+ }
3868
+ // JS/TS re-export records: `export {x as y} from './src'` exposes y,
3869
+ // chase continues under the SOURCE-side name; `export * from`
3870
+ // exposes everything the source does. `export * as ns from`
3871
+ // (alias on the re-export-all) exposes ONLY `ns` — a module
3872
+ // namespace object, unmodelable when asked for — never the
3873
+ // source's flattened names.
3874
+ for (const e of (fe.exportDetails || [])) {
3875
+ if (!e.source) continue;
3876
+ if (e.type === 're-export' && (e.alias || e.name) === attr) enqueue(e.source, e.name);
3877
+ else if (e.type === 're-export-all') {
3878
+ if (e.alias) { if (e.alias === attr) unknown = true; }
3879
+ else enqueue(e.source, attr);
3880
+ }
3881
+ }
3882
+ // Import bindings of the attr (Python re-export idiom `from .x import
3883
+ // name`, JS import-then-export). importBindings store ORIGINAL names;
3884
+ // importAliases is a flat list (pairing to its import lost), so a
3885
+ // renamed import is followed under BOTH its original and local names —
3886
+ // over-following errs toward 'yes'/'unknown', never toward exclusion.
3887
+ const aliases = fe.importAliases || [];
3888
+ for (const b of (fe.importBindings || [])) {
3889
+ const exposed = [b.name, ...aliases.filter(a => a.original === b.name).map(a => a.local)];
3890
+ if (exposed.includes(attr)) enqueue(b.module, b.name);
3891
+ }
3892
+ // Un-modelable name sources on this file:
3893
+ if ((fe.importNames || []).includes('*')) unknown = true; // star import
3894
+ if ((fe.moduleAssignedNames || []).includes(attr)) unknown = true; // module-scope `attr = ...`
3895
+ if ((index.symbols.get('__getattr__') || []).some(s => s.file === abs && !s.className)) {
3896
+ unknown = true; // PEP 562 dynamic attrs
3897
+ }
3898
+ }
3899
+ frontier = next;
3900
+ }
3901
+ if (frontier.length > 0) unknown = true; // depth exhausted with live paths
3902
+ return unknown ? 'unknown' : 'no';
3903
+ }
3904
+
3905
+ /**
3906
+ * Bounded-depth reachability over the import graph: can `fromAbs` reach any
3907
+ * target file through re-export/import chains? Barrel hierarchies routinely
3908
+ * run 2-3 hops (zod: v4/index → classic/index → schemas), so name-level
3909
+ * module checks must not use a 1-hop budget (fix #209).
3910
+ */
3911
+ function _importReaches(index, fromAbs, targetFiles, maxDepth = 4) {
3912
+ if (targetFiles.has(fromAbs)) return true;
3913
+ const visited = new Set([fromAbs]);
3914
+ let frontier = [fromAbs];
3915
+ for (let d = 0; d < maxDepth; d++) {
3916
+ const next = [];
3917
+ for (const f of frontier) {
3918
+ const edges = index.importGraph.get(f);
3919
+ if (!edges) continue;
3920
+ for (const e of edges) {
3921
+ if (visited.has(e)) continue;
3922
+ if (targetFiles.has(e)) return true;
3923
+ visited.add(e);
3924
+ next.push(e);
3925
+ }
3926
+ }
3927
+ if (next.length === 0) break;
3928
+ frontier = next;
3929
+ }
3930
+ return false;
3931
+ }
3932
+
3933
+ /**
3934
+ * Top-level path segments of the project (dir names + module names of root
3935
+ * files). Used to tell "module failed to resolve because it is EXTERNAL"
3936
+ * from "module failed to resolve because our resolver has a gap" — only the
3937
+ * former is exclusion evidence (fix #209). Memoized on the index.
3938
+ */
3939
+ function _projectTopLevelNames(index) {
3940
+ if (index._projectTopLevelNames) return index._projectTopLevelNames;
3941
+ const names = new Set();
3942
+ for (const [, fe] of index.files) {
3943
+ const seg = (fe.relativePath || '').split(/[\\/]/)[0];
3944
+ if (!seg) continue;
3945
+ names.add(seg);
3946
+ const dot = seg.lastIndexOf('.');
3947
+ if (dot > 0) names.add(seg.slice(0, dot)); // utils.py → utils
3948
+ }
3949
+ index._projectTopLevelNames = names;
3950
+ return names;
3951
+ }
3952
+
3953
+ const IDENTITY_TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'enum']);
3954
+ function _resolveReceiverTypeIdentity(index, filePath, knownType, targetDefs) {
3955
+ const typeDefs = (index.symbols.get(knownType) || []).filter(d => IDENTITY_TYPE_KINDS.has(d.type));
3956
+ if (typeDefs.length <= 1) return 'target';
3957
+ const targetDirs = new Set(targetDefs.map(d => d.file && path.dirname(d.file)).filter(Boolean));
3958
+ const inTargetPkg = (d) => d.file && targetDirs.has(path.dirname(d.file));
3959
+ const sameFile = typeDefs.filter(d => d.file === filePath);
3960
+ if (sameFile.length > 0) return sameFile.some(inTargetPkg) ? 'target' : 'other';
3961
+ const callerDir = path.dirname(filePath);
3962
+ const sameDir = typeDefs.filter(d => d.file && path.dirname(d.file) === callerDir);
3963
+ if (sameDir.length > 0) return sameDir.some(inTargetPkg) ? 'target' : 'other';
3964
+ const imports = index.importGraph.get(filePath);
3965
+ if (imports) {
3966
+ const imported = typeDefs.filter(d => d.file && imports.has(d.file));
3967
+ if (imported.length > 0) return imported.some(inTargetPkg) ? 'target' : 'other';
3968
+ }
3969
+ return 'unknown';
3970
+ }
3971
+
3972
+ /**
3973
+ * Is typeName an ancestor (transitively) of any target definition's class?
3974
+ * Used by receiver-class disambiguation: a receiver typed as a SUPERTYPE of
3975
+ * the target's class is not evidence against the target — dynamic dispatch
3976
+ * may run the target override at that site.
3977
+ */
3978
+ function _isAncestorOfTargetClass(index, typeName, targetDefs) {
3979
+ const visited = new Set();
3980
+ const queue = [];
3981
+ for (const td of targetDefs) {
3982
+ const cls = td.className || (td.receiver && td.receiver.replace(/^\*/, ''));
3983
+ if (cls) queue.push({ name: cls, file: td.file });
3984
+ }
3985
+ while (queue.length > 0) {
3986
+ const { name, file } = queue.shift();
3987
+ if (visited.has(name)) continue;
3988
+ visited.add(name);
3989
+ const parents = index._getInheritanceParents(name, file) || [];
3990
+ for (const parent of parents) {
3991
+ if (parent === typeName) return true;
3992
+ if (!visited.has(parent)) {
3993
+ const parentFile = index._resolveClassFile ? index._resolveClassFile(parent, file) : file;
3994
+ queue.push({ name: parent, file: parentFile });
3995
+ }
3996
+ }
3997
+ }
3998
+ return false;
3999
+ }
4000
+
4001
+ /**
4002
+ * Do two classes share a project descendant (Python #202b guard)? With
4003
+ * multiple inheritance, `self.method()` inside Mixin dispatches through
4004
+ * type(self).__mro__ — a class C(Target, Mixin) looks the method up on
4005
+ * Target BEFORE Mixin, so a sibling-class exclusion is only sound when no
4006
+ * project class inherits from both sides. Conservative: any common
4007
+ * descendant keeps the edge regardless of MRO order.
4008
+ */
4009
+ function _collectDescendants(index, className, cap = 256) {
4010
+ const out = new Set([className]);
4011
+ const queue = [className];
4012
+ while (queue.length > 0 && out.size < cap) {
4013
+ const children = index.extendedByGraph?.get(queue.pop());
4014
+ if (!children) continue;
4015
+ for (const child of children) {
4016
+ const cName = typeof child === 'string' ? child : child.name;
4017
+ if (!cName || out.has(cName)) continue;
4018
+ out.add(cName);
4019
+ queue.push(cName);
4020
+ }
4021
+ }
4022
+ return out;
4023
+ }
4024
+
4025
+ function _shareProjectDescendant(index, className, targetClasses) {
4026
+ if (!targetClasses || targetClasses.size === 0) return false;
4027
+ const mine = _collectDescendants(index, className);
4028
+ for (const t of targetClasses) {
4029
+ const theirs = _collectDescendants(index, t);
4030
+ // matchedClass BELOW the target: every descendant's MRO finds the
4031
+ // matched override before the target (subclass precedes superclass
4032
+ // in C3) — the target def is unreachable from this site, exclusion
4033
+ // stands. Not an MRO trap.
4034
+ if (theirs.has(className)) continue;
4035
+ for (const d of theirs) {
4036
+ if (mine.has(d)) return true;
4037
+ }
4038
+ }
4039
+ return false;
4040
+ }
4041
+
4042
+ /**
4043
+ * Resolve a one-hop field receiver to the field's DECLARED type (fix #202):
4044
+ * rootType.fieldName → the field's declared type from the struct/class body
4045
+ * (Rust/Go/Java parsers emit field members with fieldType). Returns null —
4046
+ * never a wrong type — when: no such field, the declared type doesn't
4047
+ * normalize to a plain nominal name (slices, fn types, wrappers), same-named
4048
+ * classes disagree, or the type is a trait/interface (dynamic dispatch —
4049
+ * a trait-typed field is not evidence against any implementor).
4050
+ */
4051
+ function _declaredFieldType(index, rootType, fieldName, language) {
4052
+ const defs = index.symbols.get(fieldName);
4053
+ if (!defs) return null;
4054
+ // 'private field' (JS #-fields, fix #219): equally compiler-true, and
4055
+ // safer — nothing outside the class can rebind them.
4056
+ const fields = defs.filter(d =>
4057
+ (d.type === 'field' || d.memberType === 'field' || d.memberType === 'private field') &&
4058
+ d.className === rootType && d.fieldType);
4059
+ if (fields.length === 0) return null;
4060
+ const normalized = new Set();
4061
+ for (const f of fields) {
4062
+ const t = _normalizeFieldTypeName(f.fieldType, language);
4063
+ if (t) normalized.add(t);
4064
+ else return null; // any un-normalizable declaration → no evidence
4065
+ }
4066
+ if (normalized.size !== 1) return null; // same-named classes disagree
4067
+ const typeName = [...normalized][0];
4068
+ // Generic type parameters by convention (T, K, V1 — fix #220,
4069
+ // cursive-measured): `view: T` declares the field as WHATEVER the
4070
+ // instantiation chose — not a type identity. Without this, the hop
4071
+ // "validated" against Rust blanket impls (`impl<T: ViewWrapper> View
4072
+ // for T` records className 'T'), confirming self.view.layout() for
4073
+ // every wrapper view. A short-caps name with a real project type def
4074
+ // (class A in a fixture) is a class, not a generic param.
4075
+ if (/^[A-Z][A-Z0-9]?$/.test(typeName) &&
4076
+ !(index.symbols.get(typeName) || []).some(d => IDENTITY_TYPE_KINDS.has(d.type))) return null;
4077
+ const typeDefs = index.symbols.get(typeName);
4078
+ if (typeDefs && typeDefs.some(d => d.type === 'trait' || d.type === 'interface')) return null;
4079
+ return typeName;
4080
+ }
4081
+
4082
+ /**
4083
+ * Can this call's argument count fit any target definition's parameter
4084
+ * range? (Nominal languages only — their compilers enforce arity, so a
4085
+ * mismatch is positive evidence the call binds a different symbol.)
4086
+ * Accepts both the bound form (obj.m(a)) and the unbound/UFCS form
4087
+ * (Type::m(&obj, a) / Class.m(obj, a)) for method targets. Returns true
4088
+ * whenever the signature is unknown, variadic, or the target is not a
4089
+ * plain callable — unknown never excludes.
4090
+ */
4091
+ function _callArityCompatible(call, targetDefs, language) {
4092
+ const traits = langTraits(language);
4093
+ const selfNames = new Set((traits?.selfParam || [])
4094
+ .map(s => String(s).replace(/&|mut\s*/g, '').trim()));
4095
+ let sawComparable = false;
4096
+ for (const def of targetDefs) {
4097
+ if (NON_CALLABLE_TYPES.has(def.type)) return true;
4098
+ const ps = def.paramsStructured;
4099
+ if (!Array.isArray(ps)) return true;
4100
+ if (ps.some(p => p && p.rest)) return true;
4101
+ const params = ps.filter((p, i) => !(i === 0 && p &&
4102
+ selfNames.has(String(p.name || '').replace(/&|mut\s*/g, '').trim())));
4103
+ const isMethodDef = !!(def.className || def.receiver);
4104
+ // The receiver-as-first-arg shift applies only to call shapes that
4105
+ // can actually be unbound: Rust UFCS (Type::method(&x)) and Go
4106
+ // method expressions (Type.Method(recv)) — the receiver text IS the
4107
+ // type. Java has no unbound instance-call form.
4108
+ const defType = def.className || (def.receiver || '').replace(/^\*/, '');
4109
+ const unboundForm = call.isPathCall || (!!call.receiver && call.receiver === defType);
4110
+ const max = params.length + (isMethodDef && unboundForm ? 1 : 0);
4111
+ const min = params.filter(p => p && !p.optional && p.default === undefined).length;
4112
+ sawComparable = true;
4113
+ if (langTraits(language)?.packageScope === 'directory') {
4114
+ // Go: f(g()) tuple expansion can fill several params with one
4115
+ // syntactic arg — too-few never excludes, only too-many.
4116
+ if (call.argCount <= max) return true;
4117
+ } else if (call.argCount >= min && call.argCount <= max) {
4118
+ return true;
4119
+ }
4120
+ }
4121
+ return sawComparable ? false : true;
4122
+ }
4123
+
4124
+ const JAVA_PRIMITIVES = new Set(['int', 'long', 'short', 'byte', 'char', 'float', 'double', 'boolean']);
4125
+
4126
+ // Which parameter types a call-site literal kind can bind (Java overload
4127
+ // resolution: identity, widening, boxing — plus the boxed types' interfaces).
4128
+ // Anything not provably incompatible MATCHES: only certainty excludes.
4129
+ const JAVA_KIND_TYPES = {
4130
+ string: ['String', 'CharSequence', 'Comparable', 'Serializable'],
4131
+ char: ['char', 'Character', 'int', 'long', 'float', 'double', 'Comparable', 'Serializable'],
4132
+ int: ['int', 'long', 'float', 'double', 'Integer', 'Number', 'Comparable', 'Serializable'],
4133
+ long: ['long', 'float', 'double', 'Long', 'Number', 'Comparable', 'Serializable'],
4134
+ float: ['float', 'double', 'Float', 'Number', 'Comparable', 'Serializable'],
4135
+ double: ['double', 'Double', 'Number', 'Comparable', 'Serializable'],
4136
+ boolean: ['boolean', 'Boolean', 'Comparable', 'Serializable'],
4137
+ };
4138
+
4139
+ /**
4140
+ * Can an argument of static kind `kind` (from the Java parser's argKinds)
4141
+ * bind a parameter declared as `paramType`? Unknown kinds ('expr',
4142
+ * 'lambda'), unknown/generic param types, and unresolvable hierarchies all
4143
+ * match — a mismatch must be provable to count.
4144
+ */
4145
+ function _javaArgKindMatches(index, kind, paramType) {
4146
+ if (!kind || kind === 'expr' || kind === 'lambda') return true;
4147
+ if (!paramType) return true;
4148
+ const bare = String(paramType).replace(/<.*$/s, '').trim()
4149
+ .replace(/\.\.\.$/, '').replace(/\[\]$/, '').split('.').pop();
4150
+ if (!bare || bare === 'Object') return true;
4151
+ if (/^[A-Z][0-9]?$/.test(bare)) return true; // generic type variable (T, E, K1...)
4152
+ if (kind === 'null') return !JAVA_PRIMITIVES.has(bare);
4153
+ if (kind.startsWith('new:') || kind.startsWith('cast:')) {
4154
+ const t = kind.slice(kind.indexOf(':') + 1);
4155
+ if (t === bare) return true;
4156
+ const tDefs = (index.symbols.get(t) || [])
4157
+ .filter(d => d.type === 'class' || d.type === 'interface');
4158
+ if (tDefs.length === 0) return true; // external arg type — unknowable
4159
+ const asTarget = [{ className: t, file: tDefs[0].file }];
4160
+ if (_isDispatchAncestor(index, bare, asTarget)) return true;
4161
+ // Deny only when t's ancestry is fully project-visible — a chain
4162
+ // that dead-ends external may still reach paramType.
4163
+ return !_targetAncestryFullyResolved(index, asTarget);
4164
+ }
4165
+ const allowed = JAVA_KIND_TYPES[kind];
4166
+ if (!allowed) return true;
4167
+ return allowed.includes(bare);
4168
+ }
4169
+
4170
+ /** Is overload `def` applicable to this call's static argument shape? */
4171
+ function _overloadApplicable(index, call, def) {
4172
+ const ps = def.paramsStructured;
4173
+ if (!Array.isArray(ps)) return true;
4174
+ const hasRest = ps.some(p => p && p.rest);
4175
+ const min = ps.filter(p => p && !p.optional && p.default === undefined && !p.rest).length;
4176
+ if (call.argCount < min) return false;
4177
+ if (!hasRest && call.argCount > ps.length) return false;
4178
+ const kinds = call.argKinds;
4179
+ if (!Array.isArray(kinds)) return true;
4180
+ for (let i = 0; i < kinds.length && i < ps.length; i++) {
4181
+ const p = ps[i];
4182
+ if (!p || p.rest) break;
4183
+ if (!_javaArgKindMatches(index, kinds[i], p.type)) return false;
4184
+ }
4185
+ return true;
4186
+ }
4187
+
4188
+ /**
4189
+ * Overload discipline (Java): when the pinned target has same-class sibling
4190
+ * overloads, decide what the call site's static argument shape proves.
4191
+ * Returns 'other-overload' (binds a sibling — exclusion evidence),
4192
+ * {ambiguous, candidates} (cannot tell — visible unverified), or null
4193
+ * (no siblings / uniquely the pinned one / model has no opinion).
4194
+ */
4195
+ function _overloadDiscipline(index, call, targetDefs, definitions) {
4196
+ const targetOwners = new Set(targetDefs.map(d => d.className).filter(Boolean));
4197
+ if (targetOwners.size === 0) return null;
4198
+ const family = definitions.filter(d => !NON_CALLABLE_TYPES.has(d.type) &&
4199
+ d.className && targetOwners.has(d.className));
4200
+ if (family.length <= 1) return null;
4201
+ const pinnedKeys = new Set(targetDefs.map(d => `${d.file}:${d.startLine}`));
4202
+ if (family.every(d => pinnedKeys.has(`${d.file}:${d.startLine}`))) return null;
4203
+ const applicable = family.filter(d => _overloadApplicable(index, call, d));
4204
+ if (applicable.length === 0) return null; // shape fits nothing we model — no claim
4205
+ const pinnedApplicable = applicable.some(d => pinnedKeys.has(`${d.file}:${d.startLine}`));
4206
+ if (!pinnedApplicable) return 'other-overload';
4207
+ if (applicable.length === 1) return null; // uniquely the pinned overload
4208
+ return { ambiguous: true, candidates: applicable.length };
4209
+ }
4210
+
4211
+ /**
4212
+ * Build the target type set for receiver-class disambiguation: target
4213
+ * classes/receivers + their non-overriding subtypes (transitively). A Child
4214
+ * receiver calling an inherited Base method IS a caller of Base.method;
4215
+ * children that define the method themselves dispatch to the override.
4216
+ */
4217
+ function _buildTargetTypeSet(index, targetDefs, definitions) {
4218
+ const targetTypes = new Set();
4219
+ for (const td of targetDefs) {
4220
+ if (td.className) targetTypes.add(td.className);
4221
+ if (td.receiver) targetTypes.add(td.receiver.replace(/^\*/, ''));
4222
+ }
4223
+ if (targetTypes.size > 0) {
4224
+ const queue = [...targetTypes];
4225
+ while (queue.length > 0) {
4226
+ const children = index.extendedByGraph?.get(queue.pop());
4227
+ if (!children) continue;
4228
+ for (const child of children) {
4229
+ const cName = typeof child === 'string' ? child : child.name;
4230
+ if (!cName || targetTypes.has(cName)) continue;
4231
+ const overrides = definitions.some(d => d.className === cName);
4232
+ if (overrides) continue;
4233
+ targetTypes.add(cName);
4234
+ queue.push(cName);
4235
+ }
4236
+ }
4237
+ }
4238
+ // Type aliases are the SAME type (Rust `pub type StyledString =
4239
+ // SpannedString<Style>`, Go `type A = B`) — compiler-checked identity,
4240
+ // not a subtype edge. Close over them in BOTH directions: a method on
4241
+ // the base must accept alias-qualified receivers (cursive-measured: 24
4242
+ // StyledString::plain edges wrongly excluded as path-type-mismatch),
4243
+ // and a method on an inherent alias impl must accept base receivers.
4244
+ // Sound only when EVERY type-kind def of the name is an alias agreeing
4245
+ // on one base — a same-name alias to a different type in another
4246
+ // package must not confirm foreign receivers (#206 discipline). The
4247
+ // parser records aliasOf for Rust/Go; names without it never close.
4248
+ if (targetTypes.size > 0) {
4249
+ const aliasPairs = [];
4250
+ for (const [aliasName, defs] of index.symbols) {
4251
+ let base = null;
4252
+ let pure = true;
4253
+ for (const d of defs) {
4254
+ if (d.type !== 'type' && !IDENTITY_TYPE_KINDS.has(d.type)) continue;
4255
+ if (d.type === 'type' && d.aliasOf) {
4256
+ if (base === null) base = d.aliasOf;
4257
+ else if (base !== d.aliasOf) { pure = false; break; }
4258
+ } else { pure = false; break; }
4259
+ }
4260
+ if (pure && base) aliasPairs.push([aliasName, base]);
4261
+ }
4262
+ let changed = aliasPairs.length > 0;
4263
+ while (changed) {
4264
+ changed = false;
4265
+ for (const [a, b] of aliasPairs) {
4266
+ if (targetTypes.has(b) && !targetTypes.has(a)) { targetTypes.add(a); changed = true; }
4267
+ if (targetTypes.has(a) && !targetTypes.has(b)) { targetTypes.add(b); changed = true; }
4268
+ }
4269
+ }
4270
+ }
4271
+ return targetTypes;
4272
+ }
4273
+
4274
+ /**
4275
+ * Can a receiver typed as `typeName` VIRTUALLY dispatch into the target
4276
+ * definition? True when typeName is a project interface/trait that declares
4277
+ * the method or sits above the target's class, or — in languages where every
4278
+ * instance method is virtual (Java) — any supertype of the target. Go struct
4279
+ * embedding binds statically and never qualifies; Go interfaces qualify via
4280
+ * the declares-the-method check (satisfaction is implicit — there is no
4281
+ * recorded edge to walk). Used only to decide possible-dispatch ROUTING
4282
+ * (visible unverified), never to exclude.
4283
+ */
4284
+ function _dispatchCapableSupertype(index, language, typeName, targetDefs, definitions) {
4285
+ const traits = langTraits(language);
4286
+ if (traits?.typeSystem !== 'nominal') return false;
4287
+ // The implicit root supertype (Java `Object`) sits above EVERY class
4288
+ // without a declared extends edge — `void show(Object o) { o.size() }`
4289
+ // can dispatch into any project override, but the ancestry walk below
4290
+ // cannot see the implicit edge (fix #212). Bare-name compare on the last
4291
+ // segment covers `java.lang.Object` annotations; a project class that
4292
+ // shadows the root name only ever gains routing (demote-only), never
4293
+ // loses an exclusion it was entitled to.
4294
+ if (traits.universalSupertype &&
4295
+ String(typeName).split('.').pop() === traits.universalSupertype) {
4296
+ return true;
4297
+ }
4298
+ const typeDefs = index.symbols.get(typeName) || [];
4299
+ const isIface = typeDefs.some(d => d.type === 'interface' || d.type === 'trait');
4300
+ if (isIface) {
4301
+ // The interface/trait declares this method → any implementor
4302
+ // (recorded or implicit) may receive the call.
4303
+ if (definitions.some(d => d.className === typeName)) return true;
4304
+ return _isDispatchAncestor(index, typeName, targetDefs);
4305
+ }
4306
+ if (traits.allMethodsVirtual) {
4307
+ return _isDispatchAncestor(index, typeName, targetDefs);
4308
+ }
4309
+ return false;
4310
+ }
4311
+
4312
+ /**
4313
+ * Like _isAncestorOfTargetClass, but walks `implements` records (Java
4314
+ * implements clauses, Rust `impl Trait for Type` surfaced as implements)
4315
+ * in addition to extends edges — the inheritance graph only stores extends,
4316
+ * yet virtual dispatch flows through interface/trait edges too. Routing
4317
+ * decision only (possible-dispatch vs excluded), never exclusion evidence.
4318
+ */
4319
+ function _isDispatchAncestor(index, typeName, targetDefs) {
4320
+ const visited = new Set();
4321
+ const queue = [];
4322
+ for (const td of targetDefs) {
4323
+ const cls = td.className || (td.receiver && td.receiver.replace(/^\*/, ''));
4324
+ if (cls) queue.push({ name: cls, file: td.file });
4325
+ }
4326
+ while (queue.length > 0) {
4327
+ const { name, file } = queue.shift();
4328
+ if (visited.has(name)) continue;
4329
+ visited.add(name);
4330
+ const parents = [
4331
+ ...(index._getInheritanceParents(name, file) || []),
4332
+ ..._implementsParents(index, name),
4333
+ ];
4334
+ for (const parent of parents) {
4335
+ if (parent === typeName) return true;
4336
+ if (!visited.has(parent)) {
4337
+ const parentFile = index._resolveClassFile ? index._resolveClassFile(parent, file) : file;
4338
+ queue.push({ name: parent, file: parentFile });
4339
+ }
4340
+ }
4341
+ }
4342
+ return false;
4343
+ }
4344
+
4345
+ /** Interface/trait names a class declares it implements (generics stripped). */
4346
+ function _implementsParents(index, className) {
4347
+ const defs = index.symbols.get(className);
4348
+ if (!defs) return [];
4349
+ const out = [];
4350
+ for (const d of defs) {
4351
+ if (!Array.isArray(d.implements)) continue;
4352
+ for (const p of d.implements) {
4353
+ const bare = String(p).replace(/<.*$/s, '').trim().split(/[.:]+/).pop();
4354
+ if (bare) out.push(bare);
4355
+ }
4356
+ }
4357
+ return out;
4358
+ }
4359
+
4360
+ /**
4361
+ * How many same-name method definitions could a call through `via` dispatch
4362
+ * to? Counts distinct owner types among the definitions that sit at or below
4363
+ * `via` (extends edges + implements records). Languages with implicit
4364
+ * interface satisfaction (Go) record no edges at all — fall back to the full
4365
+ * owner count. Display/routing enrichment only ("1 of N implementations").
4366
+ */
4367
+ function _countDispatchCandidates(index, via, definitions) {
4368
+ const ownerFiles = new Map(); // owner type -> defining file
4369
+ for (const d of definitions) {
4370
+ if (NON_CALLABLE_TYPES.has(d.type)) continue;
4371
+ const o = d.className || (d.receiver && d.receiver.replace(/^\*/, ''));
4372
+ if (o && !ownerFiles.has(o)) ownerFiles.set(o, d.file);
4373
+ }
4374
+ if (ownerFiles.size === 0) return 0;
4375
+ // Interface/trait owners hold the abstract declaration, not a landing
4376
+ // site — "implementations" counts the concrete methods dispatch can run.
4377
+ const isIface = (o) => (index.symbols.get(o) || [])
4378
+ .some(d => d.type === 'interface' || d.type === 'trait');
4379
+ let count = 0;
4380
+ let concrete = 0;
4381
+ for (const [owner, file] of ownerFiles) {
4382
+ if (isIface(owner)) continue;
4383
+ concrete++;
4384
+ if (owner === via || _isDispatchAncestor(index, via, [{ className: owner, file }])) count++;
4385
+ }
4386
+ // No recorded edges below `via` (Go interfaces are satisfied implicitly)
4387
+ // → any concrete owner is a candidate.
4388
+ return count > 0 ? count : (concrete > 0 ? concrete : ownerFiles.size);
4389
+ }
4390
+
4391
+ /**
4392
+ * Resolve a one-hop field receiver to a declared project INTERFACE/TRAIT
4393
+ * type — exactly the case _declaredFieldType refuses (a trait-typed field is
4394
+ * not exclusion evidence against any implementor). Dispatch attribution
4395
+ * only: lets the unverified tier say "possible-dispatch via <Interface>".
4396
+ * Rust `dyn Trait` / `Box<dyn Trait>` / `&dyn Trait` resolve to Trait here.
4397
+ */
4398
+ function _declaredFieldInterfaceType(index, rootType, fieldName, language) {
4399
+ const defs = index.symbols.get(fieldName);
4400
+ if (!defs) return null;
4401
+ const fields = defs.filter(d =>
4402
+ (d.type === 'field' || d.memberType === 'field') &&
4403
+ d.className === rootType && d.fieldType);
4404
+ if (fields.length === 0) return null;
4405
+ const normalized = new Set();
4406
+ for (const f of fields) {
4407
+ const t = _normalizeFieldTypeName(f.fieldType, language) ||
4408
+ (language === 'rust' ? _normalizeRustDynTypeName(f.fieldType) : null);
4409
+ if (t) normalized.add(t);
4410
+ else return null; // un-normalizable declaration → no attribution
4411
+ }
4412
+ if (normalized.size !== 1) return null; // same-named classes disagree
4413
+ const typeName = [...normalized][0];
4414
+ const typeDefs = index.symbols.get(typeName);
4415
+ if (!typeDefs || !typeDefs.some(d => d.type === 'trait' || d.type === 'interface')) return null;
4416
+ return typeName;
4417
+ }
4418
+
4419
+ /** Rust dyn-trait declarations: `dyn Flag`, `&dyn Flag`, `Box<dyn Flag>` → Flag. */
4420
+ function _normalizeRustDynTypeName(raw) {
4421
+ let t = String(raw).trim();
4422
+ let prev;
4423
+ do {
4424
+ prev = t;
4425
+ t = t.replace(/^&+\s*/, '').replace(/^'[A-Za-z_][A-Za-z0-9_]*\s*/, '').replace(/^mut\s+/, '');
4426
+ const wrap = t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*<(.*)>$/s);
4427
+ if (wrap && _RUST_DEREF_WRAPPERS.has(wrap[1])) t = wrap[2].trim();
4428
+ } while (t !== prev);
4429
+ const m = t.match(/^dyn\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*::\s*[A-Za-z_][A-Za-z0-9_]*)*)$/s);
4430
+ if (!m) return null;
4431
+ return m[1].split('::').pop().trim();
4432
+ }
4433
+
4434
+ /**
4435
+ * Is every ancestor in the targets' inheritance closure a project-resolvable
4436
+ * class? A chain that dead-ends at an EXTERNAL ancestor may continue into
4437
+ * supertypes UCN can't see, so absence-of-knownType in the visible chain is
4438
+ * not evidence (fix #202: external-type exclusion gate).
4439
+ */
4440
+ function _targetAncestryFullyResolved(index, targetDefs) {
4441
+ const visited = new Set();
4442
+ const queue = [];
4443
+ for (const td of targetDefs) {
4444
+ const cls = td.className || (td.receiver && td.receiver.replace(/^\*/, ''));
4445
+ if (cls) queue.push({ name: cls, file: td.file });
4446
+ }
4447
+ while (queue.length > 0) {
4448
+ const { name, file } = queue.shift();
4449
+ if (visited.has(name)) continue;
4450
+ visited.add(name);
4451
+ const parents = index._getInheritanceParents(name, file) || [];
4452
+ for (const parent of parents) {
4453
+ const defs = index.symbols.get(parent);
4454
+ const isProject = !!defs && defs.some(d =>
4455
+ d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait');
4456
+ if (!isProject) return false; // external ancestor — chain invisible beyond here
4457
+ if (!visited.has(parent)) {
4458
+ const parentFile = index._resolveClassFile ? index._resolveClassFile(parent, file) : file;
4459
+ queue.push({ name: parent, file: parentFile });
4460
+ }
4461
+ }
4462
+ }
4463
+ return true;
4464
+ }
4465
+
4466
+ // _externalContractMarker moved to core/shared.js as isOverrideMarked (shared
4467
+ // with deadcode's out-of-tree override suppression — one source of truth).
4468
+
4469
+ /**
4470
+ * Name of the external contract a marked method implements, for dispatch
4471
+ * attribution ("possible-dispatch via Number — external contract"). Rust
4472
+ * impls name the trait directly; Java/TS/Python derive it from the class's
4473
+ * own extends/implements entries that do NOT resolve to project types.
4474
+ * Returns null when the contract type is not uniquely attributable —
4475
+ * the demotion still applies, only the label loses its `via`.
4476
+ */
4477
+ function _externalContractVia(index, def) {
4478
+ if (def.traitName) {
4479
+ // rust `impl fmt::Display for X` → Display (strip path + generics)
4480
+ const bare = String(def.traitName).replace(/<.*$/, '').split('::').pop().trim();
4481
+ return bare || null;
4482
+ }
4483
+ const cls = def.className;
4484
+ if (!cls) return null;
4485
+ const classDefs = (index.symbols.get(cls) || []).filter(d =>
4486
+ d.file === def.file &&
4487
+ (d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait'));
4488
+ const supers = [];
4489
+ for (const cd of classDefs) {
4490
+ if (cd.extends) supers.push(...(Array.isArray(cd.extends) ? cd.extends : [cd.extends]));
4491
+ if (cd.implements) supers.push(...cd.implements);
4492
+ }
4493
+ const externals = [];
4494
+ for (const raw of supers) {
4495
+ const bare = String(raw).replace(/<.*$/, '').split('.').pop().trim();
4496
+ if (!bare) continue;
4497
+ const defs = index.symbols.get(bare);
4498
+ const isProject = !!defs && defs.some(d =>
4499
+ d.type === 'class' || d.type === 'struct' || d.type === 'interface' || d.type === 'trait');
4500
+ if (!isProject && !externals.includes(bare)) externals.push(bare);
4501
+ }
4502
+ if (externals.length === 1) return externals[0];
4503
+ if (externals.length === 0 && supers.length === 0 &&
4504
+ def.modifiers && def.modifiers.includes('override')) {
4505
+ // java: @Override with no explicit supertypes can only override
4506
+ // java.lang.Object (toString/equals/hashCode) — in compiling code.
4507
+ return 'Object';
4508
+ }
4509
+ return null; // several external candidates — attribution unknowable
4510
+ }
4511
+
4512
+ /** Rust deref-transparent wrappers: Box<X>/Rc<X>/Arc<X> auto-deref to X for method calls. */
4513
+ const _RUST_DEREF_WRAPPERS = new Set(['Box', 'Rc', 'Arc']);
4514
+
4515
+ /**
4516
+ * Normalize a declared field type to a bare nominal type name, or null when
4517
+ * the declaration carries no usable single-type evidence.
4518
+ * rust: `&'a mut ignore::DirEntry` → DirEntry; `Box<DirEntry>` → DirEntry;
4519
+ * `Box<dyn Flag>`/`dyn Flag`/`impl Trait` → null; tuples/fns → null
4520
+ * go: `*ignore.Ig` → Ig; slices/maps/chans/funcs → null
4521
+ * java: `java.util.List<Foo>` → List; arrays → null
4522
+ */
4523
+ function _normalizeFieldTypeName(raw, language) {
4524
+ let t = String(raw).trim();
4525
+ if (language === 'rust') {
4526
+ let prev;
4527
+ do {
4528
+ prev = t;
4529
+ t = t.replace(/^&+\s*/, '').replace(/^'[A-Za-z_][A-Za-z0-9_]*\s*/, '').replace(/^mut\s+/, '');
4530
+ } while (t !== prev);
4531
+ if (/^(dyn|impl)\b/.test(t)) return null;
4532
+ const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*(?:\s*::\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*(?:<(.*)>)?$/s);
4533
+ if (!m) return null;
4534
+ const base = m[1].split('::').pop().trim();
4535
+ if (m[2] !== undefined && _RUST_DEREF_WRAPPERS.has(base)) {
4536
+ return _normalizeFieldTypeName(m[2], 'rust');
4537
+ }
4538
+ return base;
4539
+ }
4540
+ if (language === 'go') {
4541
+ t = t.replace(/^\*+/, '');
4542
+ const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?$/);
4543
+ if (!m) return null;
4544
+ return m[2] || m[1];
4545
+ }
4546
+ if (language === 'java') {
4547
+ const m = t.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*(?:<.*>)?$/s);
4548
+ if (!m) return null;
4549
+ return m[1].split('.').pop();
4550
+ }
4551
+ if (langTraits(language)?.typeSystem === 'structural') {
4552
+ // JS/TS/Python (fix #219): compiler-true annotation heads, value-
4553
+ // position semantics — a field declared Promise<X> HOLDS a Promise.
4554
+ return _structuralTypeHead(t);
4555
+ }
4556
+ return null;
4557
+ }
4558
+
4559
+ // typing-module aliases for builtin containers — normalized to the runtime
4560
+ // type so BUILTIN_RECEIVER_TYPES and the trust gate see one name.
4561
+ const _PY_TYPING_BUILTINS = {
4562
+ Dict: 'dict', List: 'list', Set: 'set', Tuple: 'tuple',
4563
+ FrozenSet: 'frozenset', Text: 'str',
4564
+ };
4565
+
4566
+ /**
4567
+ * Single concrete type name from a STRUCTURAL annotation in value position
4568
+ * (fix #219): field declarations and chained-receiver producer returns.
4569
+ * Unlike _typeNameFromReturnAnnotation, Promise/Awaitable are NOT unwrapped
4570
+ * by default — the value IS the promise (`parseAsync(...).catch` dispatches
4571
+ * on Promise). opts.unwrapAsync handles `(await f()).m()`: TS annotations
4572
+ * unwrap their Promise/Awaitable head; a Python async producer's annotation
4573
+ * already names the awaited value, so it passes through unchanged.
4574
+ * Conservative: unions of two real types, function types, object literals,
4575
+ * and tuples return null — a wrong head would exclude true callers.
4576
+ */
4577
+ function _structuralTypeHead(text, opts = {}) {
4578
+ if (!text || typeof text !== 'string') return null;
4579
+ let t = text.trim().replace(/^readonly\s+/, '').replace(/^["']|["']$/g, '').trim();
4580
+ if (t.includes('|')) {
4581
+ const parts = t.split('|').map(s => s.trim())
4582
+ .filter(s => s && !['None', 'null', 'undefined'].includes(s));
4583
+ if (parts.length !== 1) return null;
4584
+ t = parts[0];
4585
+ }
4586
+ let m;
4587
+ // type-transparent wrappers (the value's runtime type is the argument)
4588
+ while ((m = t.match(/^(?:typing\.)?(Optional|Annotated|Final)\s*[[<]\s*(.+)\s*[\]>]$/s))) {
4589
+ t = (_splitTopLevelGenericArgs(m[2])[0] || '').trim(); // Annotated[X, meta] → X
4590
+ }
4591
+ if (opts.unwrapAsync) {
4592
+ m = t.match(/^(?:typing\.)?(Promise|Awaitable|Coroutine)\s*[[<]\s*(.*)\s*[\]>]$/s);
4593
+ if (m) {
4594
+ const args = _splitTopLevelGenericArgs(m[2]).map(s => s.trim());
4595
+ // Coroutine[Y, S, R] resolves to its RETURN (last) argument
4596
+ t = (m[1] === 'Coroutine' ? args[args.length - 1] : args[0]) || '';
4597
+ }
4598
+ }
4599
+ if (/\[\]$/.test(t)) return 'Array'; // TS Foo[] — the value is an array
4600
+ m = t.match(/^([\w$.]+)\s*[[<]/s); // generic head: Foo<...> / dict[...]
4601
+ if (m) t = m[1];
4602
+ const last = t.split('.').pop();
4603
+ if (!/^[A-Za-z_$][\w$]*$/.test(last)) return null; // fn types, object literals, tuples
4604
+ return _PY_TYPING_BUILTINS[last] || last;
4605
+ }
4606
+
4607
+ // Structural annotation heads that carry no receiver identity: TS escape
4608
+ // hatches, the receiver-polymorphic `this`/`Self`, and Python's object root.
4609
+ const _STRUCTURAL_FLOW_REJECT = new Set([
4610
+ 'any', 'unknown', 'object', 'void', 'never', 'undefined', 'null',
4611
+ 'this', 'Self', 'Object', 'None',
4612
+ ]);
4613
+
4614
+ /**
4615
+ * Type a chained receiver from its producer's declared return annotation
4616
+ * (fix #219): `parseAsync(args).catch(...)` — the receiver of .catch IS the
4617
+ * parseAsync(...) call, so its return annotation is compiler-true receiver
4618
+ * evidence. Method producers follow the #207 agreement discipline: EVERY
4619
+ * same-name method def project-wide must carry a return annotation and all
4620
+ * heads must agree (whichever class the producer dispatches to, the type is
4621
+ * the same). Plain producers follow #199's unique-project-def rule. Python
4622
+ * async producers type only AWAITED chains — the bare value is a coroutine,
4623
+ * not the annotation's type (TS annotations already SAY Promise, so they
4624
+ * type either way).
4625
+ */
4626
+ /**
4627
+ * Nominal chained-receiver typing (fix #220, cobra-measured — #219's part 2
4628
+ * extended past the structural gate now that a family is measured):
4629
+ * `rootCmd.Flags().String(...)` — the producer's compiler-checked return
4630
+ * annotation types the receiver. Reuses the #207 rails verbatim: method
4631
+ * producers must AGREE project-wide, plain producers are same-package-only
4632
+ * for Go (an unqualified call cannot cross packages), package-qualified
4633
+ * producers resolve strictly through the file's imports
4634
+ * (_qualifiedProducerDefs), and the type NAME pins to its defining file from
4635
+ * the PRODUCER's scope (_resolveFlowTypeOrigin). External producer packages
4636
+ * and reject-set returns stay untyped — no evidence either way.
4637
+ */
4638
+ function _nominalChainedReceiverType(index, call, fileEntry, filePath) {
4639
+ const language = fileEntry.language;
4640
+ const defs = (index.symbols.get(call.receiverCall) || [])
4641
+ .filter(d => !NON_CALLABLE_TYPES.has(d.type));
4642
+ let producer = null;
4643
+ let selfClass;
4644
+ if (call.receiverCallReceiver) {
4645
+ // Package-qualified producer: os.CreateTemp().Name(). A package that
4646
+ // resolves to no project def decided the type OUTSIDE the project —
4647
+ // external-flow marker (blocks single-owner confirmation, routes
4648
+ // possible-dispatch; never excludes).
4649
+ const cands = defs.filter(d => d.returnType);
4650
+ const inPkg = _qualifiedProducerDefs(index, fileEntry, call.receiverCallReceiver, cands);
4651
+ if (!inPkg || inPkg.length === 0 ||
4652
+ new Set(inPkg.map(d => d.returnType)).size !== 1) {
4653
+ return { externalVia: `${call.receiverCallReceiver}.${call.receiverCall}` };
4654
+ }
4655
+ producer = inPkg[0];
4656
+ } else if (call.receiverCallIsMethod) {
4657
+ // Method producer: every same-name method def project-wide must carry
4658
+ // an annotation and agree (#219 discipline — whichever class
4659
+ // dispatches, the type is the same).
4660
+ const methodDefs = defs.filter(d => d.className || d.receiver);
4661
+ if (methodDefs.length === 0) return null;
4662
+ if (!methodDefs.every(d => d.returnType)) return null;
4663
+ if (new Set(methodDefs.map(d => d.returnType)).size !== 1) return null;
4664
+ producer = methodDefs[0];
4665
+ const classes = new Set(methodDefs.map(d =>
4666
+ d.className || (d.receiver || '').replace(/^\*/, '')));
4667
+ selfClass = classes.size === 1 ? [...classes][0] : undefined;
4668
+ } else {
4669
+ // Plain producer: Go resolves within the package; others same-file
4670
+ // narrowing, then global-unique (#199/#207 rules). Where bare calls
4671
+ // reach methods (Java), a bare producer is this.getConfig() — the
4672
+ // enclosing class's own method wins.
4673
+ if (langTraits(language)?.bareCallReachesMethods) {
4674
+ const enclosing = index.findEnclosingFunction(filePath, call.line, true);
4675
+ if (enclosing?.className) {
4676
+ const sameClass = defs.find(d => d.className === enclosing.className && d.returnType);
4677
+ if (sameClass) {
4678
+ const parsedSC = _returnTypeNameNominal(sameClass.returnType, language, {
4679
+ selfClass: enclosing.className,
4680
+ });
4681
+ if (!parsedSC) return null;
4682
+ const originSC = _resolveFlowTypeOrigin(index, sameClass.file || filePath, parsedSC.name, parsedSC.qualifier);
4683
+ if (!originSC) return null;
4684
+ return { type: parsedSC.name, ...(originSC.fromFile && { fromFile: originSC.fromFile }) };
4685
+ }
4686
+ }
4687
+ }
4688
+ const cands = defs.filter(d => !(d.className || d.receiver));
4689
+ let chosen = null;
4690
+ if (langTraits(language)?.packageScope === 'directory') {
4691
+ const dir = path.dirname(filePath);
4692
+ const samePkg = cands.filter(d => d.file && path.dirname(d.file) === dir);
4693
+ if (samePkg.length === 1) chosen = samePkg[0];
4694
+ } else if (cands.length === 1) {
4695
+ chosen = cands[0];
4696
+ } else {
4697
+ const sameFile = cands.filter(d => d.file === filePath);
4698
+ if (sameFile.length === 1) chosen = sameFile[0];
4699
+ }
4700
+ if (!chosen || !chosen.returnType) return null;
4701
+ producer = chosen;
4702
+ }
4703
+ const parsed = _returnTypeNameNominal(producer.returnType, language, { selfClass });
4704
+ if (!parsed) return null;
4705
+ const origin = _resolveFlowTypeOrigin(index, producer.file || filePath, parsed.name, parsed.qualifier);
4706
+ if (!origin) return null;
4707
+ return { type: parsed.name, ...(origin.fromFile && { fromFile: origin.fromFile }) };
4708
+ }
4709
+
4710
+ function _chainedReceiverType(index, call, language) {
4711
+ const defs = (index.symbols.get(call.receiverCall) || [])
4712
+ .filter(d => !NON_CALLABLE_TYPES.has(d.type));
4713
+ let producers;
4714
+ if (call.receiverCallIsMethod) {
4715
+ producers = defs.filter(d => d.className);
4716
+ if (producers.length === 0) return null;
4717
+ if (!producers.every(d => d.returnType)) return null;
4718
+ } else {
4719
+ if (defs.length !== 1 || !defs[0].returnType) return null;
4720
+ producers = defs;
4721
+ }
4722
+ if (language === 'python' && !call.receiverCallAwaited &&
4723
+ producers.some(d => d.isAsync)) return null;
4724
+ const heads = new Set();
4725
+ for (const d of producers) {
4726
+ const h = _structuralTypeHead(d.returnType, { unwrapAsync: call.receiverCallAwaited });
4727
+ if (!h) return null;
4728
+ heads.add(h);
4729
+ if (heads.size > 1) return null;
4730
+ }
4731
+ const head = [...heads][0];
4732
+ if (/^[A-Z][A-Z0-9]?$/.test(head)) return null; // generic type param (T, K, V1)
4733
+ if (_STRUCTURAL_FLOW_REJECT.has(head)) return null;
4734
+ return head;
4735
+ }
4736
+
4737
+ /**
4738
+ * Is this field symbol callable by its own name (obj.f(...) reaches the
4739
+ * field's function value)? Arrow-function class fields are callable by
4740
+ * construction; annotation-typed fields qualify via a function-type shape.
4741
+ * Structural languages only — Java needs .apply()/.run() on a functional
4742
+ * field and Rust needs (s.f)(…) parens, so their fields never own a
4743
+ * method-call name; Go func fields DO but stay under the existing owner
4744
+ * rules until a measured family justifies the churn.
4745
+ */
4746
+ function _callableFieldDef(index, d) {
4747
+ const lang = index.files.get(d.file)?.language;
4748
+ if (langTraits(lang)?.typeSystem !== 'structural') return false;
4749
+ if (d.isMethod) return true; // arrow-function class fields
4750
+ if (!d.fieldType) return false;
4751
+ return /=>/.test(d.fieldType) ||
4752
+ /^(?:typing\.)?Callable\b/.test(d.fieldType.trim()) ||
4753
+ /^Function\b/.test(d.fieldType.trim());
4754
+ }
4755
+
1494
4756
  function _buildTypedLocalTypeMap(index, def, calls) {
1495
4757
  const localTypes = new Map();
1496
4758
  let _cachedLines = null;