Summary
On every tags-driven language (Java, TypeScript, Python), a method call through a variable
receiver — a.helper() — produces no calls edge at all. This is the most common call shape
in idiomatic code in all three languages, so the cross-file call graph is substantially incomplete
on real repositories.
The cause is a type/name confusion. callee.rs records the call's qualifier as the receiver
identifier text (a), while Callable::scope holds the declaring type name (A).
resolve::pick then compares them textually:
if let [only] = candidates {
if let (Some(q), Some(scope)) = (qualifier, only.scope.as_deref())
&& q != scope
{
return Pick::None; // <- "a" != "A", so the only real candidate is rejected
}
return Pick::One(only.node_id.as_str());
}
There is no type inference, so q equals scope only when the variable happens to be named exactly
like its type. In idiomatic code that is almost never true. The single-candidate branch — the easy,
unambiguous case — therefore rejects the one correct answer.
Note the direction of the failure: this is not "ambiguous, so drop" (the documented,
precision-favouring policy). There is exactly one candidate and it is correct, and it is
discarded anyway.
Reproduction
Verified against main, with no interfaces and no annotations involved:
// A.java
class A { int helper() { return 1; } }
// B.java
class B {
void run() {
A a = new A();
a.helper();
}
}
| Call shape |
calls edges |
a.helper() — instance call via a variable |
0 |
A.helper() — type-qualified |
1 |
helper() — bare |
1 |
The same holds for TypeScript and Python:
TS : instance call via variable 0 calls
TS : bare call 1 calls
PY : instance call via variable 0 calls
PY : bare call 1 calls
Why the test suite did not catch this
Every Java call site in tests/fixtures/java-repo/ is type-qualified (Util.caller(),
Widget.build()), this-qualified (this.size()), or bare (build()). Not one is an instance
call through a variable — so the committed golden is blind to the case, and has been since the
fixture was written.
container_repos does not catch it either: it asserts at least one calls edge and gets
hundreds, because this.-qualified and static calls still resolve. A count-based invariant cannot
distinguish "resolved most calls" from "resolved only the easy third of them".
Relationship to #5 and #1
Distinct bug, distinct fix.
The reproduction I originally wrote in #5 (g.greet() through an interface-typed variable) happened
to hit both bugs at once, which made #5 look like it covered this. It does not — that was a poorly
chosen example on my part, and #5 has been corrected.
Suggested direction
The qualifier is currently an untyped string doing two different jobs (type name vs. receiver
variable name). Options, roughly in order of increasing effort:
- Distinguish the two at capture time — record whether the qualifier is a type-like or a
value-like reference (Java/TS convention: capitalised identifier ⇒ type). Then only apply the
q != scope rejection when the qualifier really is a type reference, and treat a value receiver
as "no usable qualifier" — which falls through to the existing single-candidate resolution and
the existing ambiguity policy when there are several. This is small and keeps the precision
policy intact.
- Minimal local type inference — track
Type name = new Type(...) and declared parameter/field
types within a file to map a receiver variable to its type. More precise, considerably more work,
and still only intra-file.
Option 1 looks like the right first move: it converts a guaranteed-miss into the existing,
already-reasoned-about resolution path, without inventing type inference.
Whatever the fix, the fixtures need an instance-call-through-a-variable case in every tags
language, since their absence is what let this survive.
Provenance
Found independently and from opposite directions by two reviews on the same day: an adversarial
review of the #5 fix, and a Spring-support design spike that ran the unmodified extractor over a
realistic Spring fixture set and got zero calls edges — then isolated it to a two-class
fixture with no interfaces and no annotations, proving it is not framework-related. Reproduced a
third time here independently before filing.
Summary
On every tags-driven language (Java, TypeScript, Python), a method call through a variable
receiver —
a.helper()— produces nocallsedge at all. This is the most common call shapein idiomatic code in all three languages, so the cross-file call graph is substantially incomplete
on real repositories.
The cause is a type/name confusion.
callee.rsrecords the call's qualifier as the receiveridentifier text (
a), whileCallable::scopeholds the declaring type name (A).resolve::pickthen compares them textually:There is no type inference, so
qequalsscopeonly when the variable happens to be named exactlylike its type. In idiomatic code that is almost never true. The single-candidate branch — the easy,
unambiguous case — therefore rejects the one correct answer.
Note the direction of the failure: this is not "ambiguous, so drop" (the documented,
precision-favouring policy). There is exactly one candidate and it is correct, and it is
discarded anyway.
Reproduction
Verified against
main, with no interfaces and no annotations involved:callsedgesa.helper()— instance call via a variableA.helper()— type-qualifiedhelper()— bareThe same holds for TypeScript and Python:
Why the test suite did not catch this
Every Java call site in
tests/fixtures/java-repo/is type-qualified (Util.caller(),Widget.build()),this-qualified (this.size()), or bare (build()). Not one is an instancecall through a variable — so the committed golden is blind to the case, and has been since the
fixture was written.
container_reposdoes not catch it either: it asserts at least onecallsedge and getshundreds, because
this.-qualified and static calls still resolve. A count-based invariant cannotdistinguish "resolved most calls" from "resolved only the easy third of them".
Relationship to #5 and #1
Distinct bug, distinct fix.
implementation) with nothing to choose between them, so the call was dropped. Fixed by excluding
declarations from the candidate set.
cannot succeed.
The reproduction I originally wrote in #5 (
g.greet()through an interface-typed variable) happenedto hit both bugs at once, which made #5 look like it covered this. It does not — that was a poorly
chosen example on my part, and #5 has been corrected.
Suggested direction
The qualifier is currently an untyped string doing two different jobs (type name vs. receiver
variable name). Options, roughly in order of increasing effort:
value-like reference (Java/TS convention: capitalised identifier ⇒ type). Then only apply the
q != scoperejection when the qualifier really is a type reference, and treat a value receiveras "no usable qualifier" — which falls through to the existing single-candidate resolution and
the existing ambiguity policy when there are several. This is small and keeps the precision
policy intact.
Type name = new Type(...)and declared parameter/fieldtypes within a file to map a receiver variable to its type. More precise, considerably more work,
and still only intra-file.
Option 1 looks like the right first move: it converts a guaranteed-miss into the existing,
already-reasoned-about resolution path, without inventing type inference.
Whatever the fix, the fixtures need an instance-call-through-a-variable case in every tags
language, since their absence is what let this survive.
Provenance
Found independently and from opposite directions by two reviews on the same day: an adversarial
review of the #5 fix, and a Spring-support design spike that ran the unmodified extractor over a
realistic Spring fixture set and got zero
callsedges — then isolated it to a two-classfixture with no interfaces and no annotations, proving it is not framework-related. Reproduced a
third time here independently before filing.