Repository navigation
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
Description
Summary
Confirmed mechanism, read directly from graphify/extractors/engine.py and graphify/extract.py:
- 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_callslist for possible cross-file resolution instead of resolved on the spot. - The generic cross-file resolver then explicitly skips every member call, by design —
extract.py:4817-4818:The comment states the rationale: resolving a bare method name like# 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
logwithout 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. - Scala never captures a call's receiver at all —
_SCALA_CONFIGhas nocall_accessor_object_field
set, somember_receiverstays 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 inraw_callswhere point 2's skip drops them. - 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 edgecalls_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
- 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. - 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. - 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 livegraphify update --no-clusterruns. - Cross-language check: Rust via
rustc(rustup) + livegraphify update; Scala via scala-cli 1.15.0/Scala
3.8.4 + livegraphify update.
Activity