Skip to content

Instance calls through a variable receiver never resolve (all tags languages) #8

Description

@stephane-segning

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions