Skip to content

Navigation Menu

Sign in
Sign up

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

Open
xiongjianxu wants to merge 6 commits into
Graphify-Labs:v8 from
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 6 commits into
Graphify-Labs:v8 from
xiongjianxu:feat/kotlin-cross-repo-member-calls

Conversation

@xiongjianxu

@xiongjianxu xiongjianxu commented Sep 7, 2026

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
edited
Loading

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():

@graphify-labs graphify-labs Bot Sep 7, 2026

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.

xuxiongjian added 3 commits September 7, 2026 20:07
A corpus-wide type index let a same-named class in an unrelated language answer a
Kotlin receiver, and it hid from the parking branch that nothing on the classpath
declares the type; `.java` stays in, since interop makes it a real answer. The
INFERRED score was 0.8, off the discrete scale the spec fixes.

Copy link
Copy Markdown
Contributor Author

Self-audit pass over the bot review. Three fixes pushed, one finding I am not acting on.

1. The declaration index was corpus-wide. A same-named class in an unrelated language could answer a Kotlin receiver, and its presence made type_defs non-empty so the parking branch never ran — the call was dropped instead of handed to the merge. Now gated with _lang_family(...) == "jvm", the interop-family map the shared cross-file resolver already uses. .java stays in on purpose: one classpath means a Kotlin module over a Java library is a real answer, not a collision — the dominant shape in a part-migrated Android codebase. Two tests pin the pair: test_a_java_class_answers_a_kotlin_receiver and test_a_class_from_an_unrelated_language_never_answers_a_kotlin_receiver. _LANG_SUFFIXES["kotlin"] in cross_repo_calls now carries the same family, so a call resolves the same way locally and at merge time.

2. The INFERRED score was off the rubric. It emitted 0.8, which references/extraction-spec.md rules out; tests/test_inferred_confidence_rubric.py only greps the bare literal, so the conditional expression slipped past it. Now 0.85, the high-confidence rung, matching the sibling resolvers.

3. _kotlin_constructor_type read like a bug. The continue-then-unconditional-return None was correct — only the first call_expression child counts — but nothing said so. Rewritten as a next(...) pick over the children; same behaviour, no control flow to decode.

Not acting on — method_index keeps the last member of a name per type. _resolve_csharp_member_calls does exactly the same, and for Kotlin the collision is an overload, where the graph has no basis to prefer one signature. Refusing to emit anything would lose a genuine call edge in the common case; picking arbitrarily among true overloads keeps it. (Go's resolver in the sibling PR requires len(targets) == 1 instead, because Go has no overloads and a collision there means two different types were folded.)

Verification: ruff check graphify tests clean; full suite 5324 passed / 93 skipped, with only the four pre-existing tests/test_ollama_retry_cap.py env failures that v8 also shows here.

@graphify-labs graphify-labs Bot left a comment
edited
Loading

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 receiver-typed member-call resolution: _resolve_kotlin_member_calls looks a receiver up in the per-file kotlin_type_table, and when exactly one JVM-family class/object declares that type, emits a calls edge to its member — EXTRACTED for a source-named type like Registry.register(), INFERRED (0.85) for a table-inferred receiver. Treats a receiver whose type is declared nowhere in the corpus by parking the call on the caller for a later merge to finish, and skips builtin, ambiguous (multiple-definition), and self-referential targets. Widens the cross-repo Kotlin suffix set to the whole JVM family (.java, .scala, .groovy, .gradle) so a Kotlin call can bind to a Java/JVM declaration on the shared classpath.

Worth a look

  • Overloaded Kotlin methods resolve to an arbitrary targetgraphify/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
  • Kotlin member resolver picks an arbitrary overloadgraphify/extract.py:4473 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Non-call edges suppress Kotlin call emissiongraphify/extract.py:4505 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • DFS pop ordering breaks documented 'first binding wins' semanticsgraphify/extractors/engine.py:883 · 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 — 2169 functions depend on the 526 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 — 2169 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: 2004 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():

@graphify-labs graphify-labs Bot Sep 7, 2026

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.

... wins
_kotlin_local_var_types pushed children onto a LIFO stack in document order,
so siblings popped reversed and the LAST same-named local won — the opposite
of the documented "first binding wins". A call in one if-branch bound to the
type declared in the sibling else-branch.

@graphify-labs graphify-labs Bot left a comment
edited
Loading

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.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

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


Graphify review — findings

Adds Kotlin receiver-typed member-call resolution: a new _resolve_kotlin_member_calls resolver looks each greeter.greet() receiver up in the per-file kotlin_type_table, and when exactly one JVM-family class/object declares that type, emits a calls edge to its member (EXTRACTED when the receiver spells the type, INFERRED when it comes from the table); ambiguous or builtin types are skipped, and a receiver whose type is declared nowhere in the corpus is parked on the caller for a later merged graph. Treats Kotlin's cross-repo call surface as the whole JVM classpath by resolving .kt/.kts calls against .java, .scala, .groovy, and .gradle declarations too. Adds Kotlin type/name node helpers (_kotlin_property_name, _kotlin_head_type_name) and a _KOTLIN_BUILTIN_TYPES set feeding the builtin filter.

No blocking issues surfaced. 16 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2170 functions depend on the 527 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 — 2170 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: 2005 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():

@graphify-labs graphify-labs Bot Sep 7, 2026

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.

Copy link
Copy Markdown
Contributor Author

Thanks — the ordering finding was real and is now fixed in 2489847.

_kotlin_local_var_types pop order (engine.py:883) — fixed. The helper pushed children
onto a LIFO stack in document order, so siblings popped reversed and the last same-named
local won, the opposite of the "first binding wins" the docstring promises. Reproduced with two
svc bindings in sibling branches of one body, the candidate classes in separate files so only
the receiver pass can answer:

fun run(flag: Boolean) {
 if (flag) {
 val svc: Alpha = Alpha()
 svc.doThing() // before: bound to Beta.doThing
 } else {
 val svc: Beta = Beta()
 }
}

Fix is stack.extend(reversed(n.children)), pinned by
test_the_first_binding_of_a_name_wins_over_a_later_sibling_branch, which fails on the previous
commit and passes now. The pre-existing _cpp_local_var_types / _swift_local_var_types carry
the same latent pattern and the same docstring claim; they are out of this PR's scope and left
untouched.

The other three findings are unchanged from the previous round and remain deliberate:

  • arbitrary overload pick (extract.py:4467/4473) and relation-agnostic existing_pairs
    (4505)
    — both follow _resolve_csharp_member_calls, the pass this one is modelled on. A
    single-target requirement per (type, name) is the god-node guard; picking among overloads
    would need parameter types the extractor does not record, so bailing is the honest behaviour.
  • Full suite green: 5325 passed, 93 skipped; ruff clean.

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

Reviewers

@graphify-labs graphify-labs[bot] graphify-labs[bot] left review comments

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

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

AltStyle によって変換されたページ (->オリジナル) /