Skip to content

Kotlin: receiver-typed member calls, and park them for cross-repo merges - #3389

Open
xiongjianxu wants to merge 2 commits into
Graphify-Labs:v8from
xiongjianxu:feat/kotlin-cross-repo-member-calls
Open

Kotlin: receiver-typed member calls, and park them for cross-repo merges#3389
xiongjianxu wants to merge 2 commits into
Graphify-Labs:v8from
xiongjianxu:feat/kotlin-cross-repo-member-calls

Conversation

@xiongjianxu

Copy link
Copy Markdown
Contributor

Fixes #3388. Two cherry-pickable commits: the single-repo resolver, then the park that
lets a merged graph finish what one build cannot.

Commit 1 — resolve Kotlin member calls through the receiver's declared type

Kotlin had no receiver typing at all, so greeter.greet() across files produced no edge.
This builds the per-file table (kotlin_type_table) from every place a receiver's type is
written, and adds _resolve_kotlin_member_calls, which takes the single class/object
declaring that type and emits calls to its member — EXTRACTED when the receiver names
the type in source (Registry.register()), INFERRED when the type came from the table.

Table sources, all four needed:

source why it cannot be dropped
primary constructor parameter class App(private val greeter: Greeter) declares no property, so nothing else in the walk ever names the type
property declaration private val greeter: Greeter = Greeter()
function parameter fun run(greeter: Greeter)
local binding val greeter = Greeter() — the type is only implied by the capitalized call head

Design calls:

  1. member_receiver is stamped only for the 2-segment navigation case, and Kotlin is
    excluded from the capitalized-receiver deferral.
    A call either resolves in-file or
    falls through to raw_calls; deferring would move today's in-file Foo.bar() hits into
    raw_calls and regress them. Not deferring keeps in-file behaviour byte-identical and
    only enriches the raw_calls entry on an in-file miss — which is the only situation
    where the receiver type is of any use. A >= 3-segment chain is an FQN and still goes
    to _resolve_kotlin_qualified_calls (Kotlin: fully-qualified call expressions produce no calls edge (same-package control isolates the qualified form) #2550); the broader lang="kotlin" tag cannot
    poach its calls, since that pass requires qualified_prefix.
  2. First binding of a name wins, across all three table sites (Swift overwrites).
    Without it fun other(greeter: Other) clobbers the class's own
    private val greeter: Greeter and redirects the property's calls to Other.greet. The
    table is flat per file, so a parameter shadowing a property has to lose.
  3. calls only — no references fallback to the type node when no member matches
    (Swift has one). Smaller blast radius, and such an edge adds nothing the type-reference
    walk already emits.
  4. Both receiver arms resolve, unlike TS/JS where only the table-typed arm does.
    Kotlin imports a class name into scope rather than a module alias, so a capitalized
    receiver genuinely is a type, not a namespace.

Commit 2 — park what this build cannot answer (#3152)

A receiver typed to a class with zero declarations in this corpus is parked on the
caller as a metadata.unresolved_calls entry (names only, never node ids — those are
rewritten by the #1529 remap and again by repo prefixing). > 1 declarations stays
dropped: local ambiguity is not something merging can narrow. The merge pass then binds
the entry when exactly one declaration in another repo answers it.

_LANG_SUFFIXES["kotlin"] is {.kt, .kts, .java}. The JVM classpath is one namespace, so
a Kotlin module calling a Java library in another repo is a genuine member call; excluding
.java would drop the most common Android two-repo shape. The reverse (java accepting
.kt) is left alone here — adding it could make an existing Java↔Java pair ambiguous and
so remove an edge that lands today.

Precision costs, measured

  • The per-file table is flat, so a parameter in one method and a property in another that
    share a name collapse to one entry. First-binding-wins makes the property the winner,
    which is the safer of the two; the loser's calls go unresolved rather than mis-bound.
  • Android/JVM framework receivers (Log.d(), Build.VERSION) are in neither
    _KOTLIN_BUILTIN_TYPES nor _JAVA_BUILTIN_TYPES, so they park. In a merge they can bind
    to a same-named class another repo declares — the single-definition guard limits the
    damage but does not rule it out.

Verification

  • tests/test_kotlin_receiver_member_calls.py — 10 cases, one per type source plus the
    negatives that must stay unresolved (untyped Any receiver, two same-named classes, a
    builtin Regex shadowed by a local class, FQN still reaching Kotlin: fully-qualified call expressions produce no calls edge (same-package control isolates the qualified form) #2550's pass). 6 of the 10
    fail on v8.
  • tests/test_cross_repo_member_calls.py — a kotlin-primary-constructor arm in the
    per-language park→merge test, plus unit cases pinning that a Kotlin call binds to a Java
    declaration and does not bind to a Swift one.
  • Full suite: 5322 passed, 93 skipped, no new failures. ruff check graphify tests clean.

xuxiongjian added 2 commits September 7, 2026 18:25
The shared cross-file pass skips member calls, so `greeter.greet()` on a receiver
whose class is declared in another file produced no edge at all — the Kotlin twin
of the Swift gap in Graphify-Labs#1356. Kotlin had no receiver typing to fall back on: the
engine exported no per-file type table for it, and the call site stamped no
receiver.

The extractor now builds `kotlin_type_table` from the four places a Kotlin name
gets a declared type — a primary-constructor parameter, a property, a function
parameter, and a local `val`/`var` binding (annotated, or constructed with a
capitalized head). First binding wins, so a parameter named like a property
cannot redirect the property's own calls. Two-segment `recv.method()` chains now
stamp `member_receiver`, and every Kotlin raw_call carries `lang="kotlin"`.

`_resolve_kotlin_member_calls` reads the table, takes the single class or object
declaring that type, and emits the `calls` edge to its member. A capitalized
receiver is the type itself (`Registry.register()`), which is exact and stays
EXTRACTED; a table-typed one is INFERRED. Kotlin builtins are excluded so a local
`class Regex` cannot answer for `kotlin.text.Regex`.

Kotlin is excluded from the capitalized-receiver deferral: `Foo.bar()` resolves
in-file today and the receiver type is only usable once the bare name misses
locally, which already leaves the target unresolved. Three-segment chains stay
with the fully-qualified pass (Graphify-Labs#2550).
A Kotlin receiver typed to a class this build declares nowhere is a call into
another repository, not a mistake. The resolver held the receiver type and
dropped the call, so graph.json — the only artifact merge-graphs and global add
read — recorded nothing and no merge-time pass could recover it.

Those calls are now parked on the caller node by name, and the merge pass binds
them when the type resolves to exactly one declaration in another repo. A Kotlin
entry accepts a `.java` declaration as well: the JVM classpath is one namespace,
and a Kotlin module over a Java library is the common Android shape.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 4 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds Kotlin cross-file member-call resolution: _resolve_kotlin_member_calls looks up a receiver's declared type in the per-file kotlin_type_table (or treats a capitalized receiver as the type for companion/object/static calls), then emits a calls edge to the single class/object declaring that method — EXTRACTED when the type is named in source, INFERRED when pulled from the table. Bails on ambiguous multi-definition types and skips Kotlin/Java/global builtin types; a receiver typed to a class declared nowhere in the corpus is parked on the caller for a later merge rather than dropped. Extends the kotlin cross-repo suffix set to include .java so Kotlin calls can bind against Java declarations, and registers the new resolver alongside the existing qualified-call pass.

Worth a look

  • Kotlin overloads are resolved to an arbitrary methodgraphify/extract.py:4467 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Existing non-call edge suppresses required call edgegraphify/extract.py:4496 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • _kotlin_constructor_type returns None after first non-matching call_expression headgraphify/extractors/engine.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Kotlin local shadowing is ignoredgraphify/extractors/engine.py:887 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2167 functions depend on the 524 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 546 callers, 43 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: _extract_generic() — 18 callers, 27 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: _get_extractor() — 26 callers, 6 callees
  • …and 44 more — each is listed as a finding

Verification — 2167 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2002 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

· 1 grounded finding(s) anchored inline below; 51 more finding(s) on lines outside this diff (see the check run).

"line": "L2"}]


def test_a_kotlin_call_binds_to_a_java_declaration():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_a_kotlin_call_binds_to_a_java_declaration()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kotlin: member calls on a typed receiver produce no edge across files, and nothing across repos

1 participant