Skip to content

calls edges are never resolved for member/dotted calls (obj.method(), Type.method()) by deliberate design — only bare (undotted) calls resolve, via a fragile label-text match that isn't scoped to real definitions #2041

Open

Description

@sub4biz

Summary

Confirmed mechanism, read directly from graphify/extractors/engine.py and graphify/extract.py:

  1. Any member/dotted call whose receiver starts with an uppercase letter (Type.method(), Obj.method())
    is deferred during single-file extraction
    , in languages that capture a call's receiver (engine.py:
    if is_member_call and member_receiver and member_receiver[:1].isupper(): tgt_nid = None) — pushed into a
    raw_calls list for possible cross-file resolution instead of resolved on the spot.
  2. The generic cross-file resolver then explicitly skips every member call, by design —
    extract.py:4817-4818:
    # Skip member-call callees: obj.log() → "log" has no import evidence
    # and collides with any top-level function named "log" in the corpus.
    if rc.get("is_member_call"):
        continue
    The comment states the rationale: resolving a bare method name like log without import evidence risks
    binding it to an unrelated same-named function anywhere else in the corpus. Only a handful of languages
    (Python, C#, Swift, Ruby-mixins) have a dedicated, receiver-typed resolver
    (_resolve_python_member_calls, _resolve_csharp_member_calls, etc.) that reintroduces member calls
    safely by tracking the receiver's actual declared type.
  3. Scala never captures a call's receiver at all — _SCALA_CONFIG has no call_accessor_object_field
    set, so member_receiver stays unset for every Scala call, dotted or not. This means Scala's dotted calls
    don't even reach the capitalized-receiver deferral in point 1 — they fall straight through to the same
    label_to_nid.get(callee_name) text-label match that bare calls use, succeeding only when the callee
    name happens to match an existing node's label in the same file (a real definition or, spuriously, a
    dangling type-reference placeholder), and otherwise landing in raw_calls where point 2's skip drops them.
  4. Bare (undotted) calls resolve the same way — via label_to_nid.get(callee_name) (same-file) or
    global_label_to_nids.get(callee) (cross-file), a lookup by the callee identifier's text matching an
    existing node's label, not by any real scope/identity check. This label index includes dangling
    type-reference placeholder nodes, not just real definitions, so a call can spuriously "resolve" against an
    unrelated placeholder that merely shares a name.

Practical impact

cats-effect-style Scala code (built almost entirely from dotted method chains — .flatMap, .use,
Resource.make, Async[F].unit) produces a call graph that is nearly empty, whether the callee is local or
external, because:

  • A dotted call to a local, same-file target resolves only by accident, via the receiver-blind label
    match described in point 3/4 above — not because Scala has any real resolution for it.
  • A dotted call to anything not matching a local label (the common case for library-heavy code) is
    deferred and then unconditionally dropped by the shared cross-file skip.

This is a documented, deliberate design tradeoff (the code comment explains the false-positive-collision
risk it avoids), not an unexplained accident. Building Scala a dedicated member-call resolver (like Python's
or C#'s) would need receiver-type tracking — substantial work, not a config tweak.

Reproduction

object LocalDefs {
  def helper(x: Int): Int = x + 1
  def callsLocal: Int = helper(5)             // bare call to a local def → resolves
}
object ExternalCalls {
  def callsExternalBare: Unit = println("hi")             // bare call, no local "println" label → no edge
  def callsExternalField: String = "x".toUpperCase        // dotted call, external → deferred, dropped
  def callsExternalGeneric: Option[Int] = Option.apply(1) // dotted call, external → deferred, dropped
}

Only callsLocal → helper produces an edge in this file.

Separately, a dotted call to a local target does resolve — confirmed with:

object A {
  def foo(): Int = 1
}
object B {
  def foo2(): Int = 2
  def bar(): Int = A.foo()       // dotted, local target → resolves via text-label match
  def bareBar(): Int = foo2()    // bare, local target → resolves the same way
}

Both bar() -> A.foo() and bareBar() -> foo2() produce calls edges — via the identical receiver-blind
label lookup, not because the dotted case is specially handled.

Third, independent contributing gap: infix-form calls never enter the call-walk at all

call_types = frozenset({"call_expression"}) in _SCALA_CONFIG — Scala's infix method-call syntax (a plus b, equivalent to a.plus(b)) parses as an infix_expression node, not call_expression, so it never even
reaches the call-resolution logic above, regardless of member-call status or receiver capitalization:

class Num(val v: Int) {
  def plus(other: Int): Int = v + other
}
object InfixCheck {
  val n = new Num(2)
  def callsInfix: Int = n plus 3    // infix call to a locally-defined method → zero calls edges
}

Confirmed: plus is a locally-defined method in the same file, yet this produces zero calls edges
(versus the dotted/bare cases above, which at least enter the resolution logic). This is a third, independent
cause of the same practical symptom, since cats-effect's *>, >>, <*, and similar symbolic combinators are
also infix-form calls — even with the member-call-skip and label-match issues fixed, infix calls would still
need infix_expression added to call_types (or an equivalent dedicated branch) to be seen at all.

Cross-language verification

Tested Rust as a second, independently-coded extractor (extractors/rust.py, which doesn't share
engine.py's code at all), real toolchain first (rustc, compiles clean), then live graphify update:

struct Widget { x: i32 }
impl Widget {
    fn helper(&self) -> i32 { self.x + 1 }
}
fn calls_local_dotted(w: &Widget) -> i32 { w.helper() }              // dotted, local → resolves
fn calls_external_dotted() -> usize { String::from("hi").len() }    // dotted, external → no edge

calls_local_dotted -> helper resolves (same receiver-blind label match Rust's own walker does for bare
calls — no is_member_call gate on its local lookup at all); calls_external_dotted -> len produces no
edge, queued and then dropped by the same shared cross-file is_member_call: continue skip that affects
Scala. Rust and Scala behave identically here — both resolve local dotted calls only via the
receiver-blind label match, and both lose external dotted calls to the same shared skip.

What this confirms, empirically, in two independently-coded extractors, not from one code comment alone:
the cross-file member-call skip in extract.py:4817 is genuinely shared, cross-language infrastructure, and
its practical effect (external dotted calls always drop) is not Scala-specific.

What this does not claim: this hasn't been checked against all ~40 languages the tool supports, and
languages with a dedicated receiver-typed resolver (Python/C#/Java/Ruby) were not re-tested for the
local-dotted-call case — their behavior there is unknown, not assumed to match Scala/Rust.

Documentation gap, independent of the mechanism

The README's feature table (graphifyy package metadata) states calls/imports/inherits/mixes_in are
"resolved across ~40 languages via tree-sitter AST" with no caveat that member/dotted calls are unresolved for
all but a few languages with dedicated resolvers. That framing oversells the feature regardless of whether the
underlying skip is deliberate.

Open questions before proposing anything

  1. Would adding Scala to the receiver-typed resolver family (like Python/C#) be in scope for a config-level
    contribution, or does it require deeper engine changes? The existing resolvers
    (_resolve_python_member_calls, _resolve_csharp_member_calls) weren't read in detail as part of this
    investigation, so their complexity/reusability is unknown.
  2. Is the label-text-match fallback (the false-positive risk of resolving against an unrelated same-named
    node) worth its own separate, smaller report? It's a different failure mode (false positive) from the
    member-call gap (false negative / no edge at all), and — per the cross-language check above — applies to
    dotted calls in both Scala and Rust, not just bare calls.
  3. Framing: is this better filed as "Scala has no dedicated member-call resolver" (a feature gap, comparing
    against Python's/C#'s existing resolvers) rather than "calls edges are dropped" (which reads as a plain
    bug)? The framing affects whether a maintainer treats it as triage-and-fix or a larger feature request —
    not decided here, flagged for whoever files it.

Environment

  • graphifyy 0.9.20, tree_sitter_scala 0.26.0
  • Root-caused via direct reading of graphify/extractors/engine.py (~line 3972-4036) and
    graphify/extract.py (~line 4809-4824), cross-checked against live graphify update --no-cluster runs.
  • Cross-language check: Rust via rustc (rustup) + live graphify update; Scala via scala-cli 1.15.0/Scala
    3.8.4 + live graphify update.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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