Skip to content

fix(kotlin): resolve overloaded and init-block calls, drop unreachable inherited calls (#4935) - #4956

Open
Deepjyoti-Sarmah wants to merge 4 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4935-kotlin-java-call-edges
Open

Deepjyoti-Sarmah wants to merge 4 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4935-kotlin-java-call-edges

Conversation

@Deepjyoti-Sarmah

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #4935.

Three Kotlin/Java calls edges were missing or wrong, each for a different reason. They are three commits that do not depend on each other.

1. Calls to overloaded methods. Java gives every overload the same node id, because the id has no parameter types, so a class holds two method edges to one target. _resolve_kotlin_member_calls counted list entries, read the two as an ambiguous name, and wrote no edge. It now counts distinct ids.

-        candidates = [m for m in methods_by_type.get(type_nids[0], []) if label matches]
+        candidates = list(dict.fromkeys(m for m in methods_by_type.get(type_nids[0], []) if label matches))
         if len(candidates) != 1:
             continue

2. Calls inside init { }. The block parses as anonymous_initializer, which the engine never visited, so its calls had no source to attach to. It now goes to initializer_nodes the way a property initializer does (#2565), with the same owner: the enclosing class, or the file node for a nested one.

+        if kotlin and t == "anonymous_initializer":
+            initializer_nodes.append((parent_class_nid or file_nid, node))
+            return
         if kotlin and t == "property_declaration":
             ...
             initializer_nodes.append((owner_nid, child))

3. Inherited external methods. class KotlinScreen : Activity() calling getString(1) bound to the only getString in the corpus, Prefs.getString, a method of an unrelated class. A bare call can only name a member of the caller's own class, a superclass or an enclosing class, and Activity is outside the corpus, so nothing says where getString lives.

bare call in a Kotlin/Java class
  walk the caller's class chain: own class, in-repo bases, enclosing classes
  does any base in the chain have no source file (declared outside the repo)?
    no  -> unchanged
    yes -> keep a candidate only if it
             is not a class member (a top-level function), or
             belongs to a class in the chain, or
             is imported by name (import pkg.Obj.member, import static)

This mirrors the unresolved-base guard the C# resolver already has.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Evidence

Before: the repro from the issue on upstream/v8 (8e6a6d21).

KotlinCaller.kt  .callSingle()  -> Logger.java  .single()      [EXTRACTED]
KotlinScreen.kt  .title()       -> Prefs.java   .getString()   [INFERRED]   (unrelated class)
missing: KotlinCaller.callOverloaded -> Logger.overloaded
missing: KotlinInit (init block)     -> Logger.single
tests/test_kotlin_java_call_edges_4935.py
10 failed, 7 passed

After: the same repro.

JavaCaller.java  .callOverloaded()  -> Logger.java  .overloaded()  [EXTRACTED]
JavaCaller.java  .callSingle()      -> Logger.java  .single()      [EXTRACTED]
KotlinCaller.kt  .callOverloaded()  -> Logger.java  .overloaded()  [EXTRACTED]
KotlinCaller.kt  .callSingle()      -> Logger.java  .single()      [EXTRACTED]
KotlinInit.kt    KotlinInit         -> Logger.java  .single()      [EXTRACTED]
(no edge to Prefs.getString)

tests/test_kotlin_java_call_edges_4935.py
17 passed

full suite
9580 passed, 18 skipped, 1 xfailed in 794.61s

The full suite ran serially with PYTHONPATH set to this worktree, so subprocess CLI tests imported this code and not the installed package.

Verification & Invariants

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

The invariant: a call edge points at a method the caller can reach, and a resolver guard that counts candidates counts distinct methods, not list entries.

The commits go in this order, and each fix commit's tests fail without it:

  • Controls first: a call to a non-overloaded Java method, a property-initializer call, two same-named classes staying ambiguous, and inherited in-repo members in Kotlin and Java. All pass before and after.
  • Overloads: a Kotlin call to an overloaded Java method and to an overloaded Kotlin object method.
  • init: the call attaches to the class, a call to an overloaded method, a class with both a property and an init, a nested class (checked against a property initializer of the same shape), and object and companion blocks.
  • Inherited external methods: Kotlin and Java classes with an external base, an in-repo base that itself extends an external class, a top-level function and a member imported by name (Kotlin and Java), which must survive the guard.

Before choosing, I ran five variants (each fix alone, fixes 1 and 2 together, and two rules for fix 3) against 21 call shapes. The alternative for fix 3 dropped every class member the caller's chain does not contain, with no external-base condition. It also fixed a class with no base calling an unrelated getString, but it removed two edges that exist today (an object member imported by name, and a call inside with(p) { getString() }) and failed test_jvm_interop_kotlin_call_to_java_still_resolves. The external-base condition fixes the three shapes in the issue and breaks none of those.

Known limitations:

  • A class with no external base that calls an unrelated class's method by bare name still gets the edge. It is not in the issue and not changed.
  • A wildcard import (import pkg.Obj.*) of an object member, called bare from a class with an external base, loses its edge, because nothing in the graph records which names the wildcard brings in. A with(p) { ... } receiver lambda in such a class loses it too.
  • Three older gaps remain: a bare call to an own member inside an initializer, calls in a secondary constructor body, and the KotlinInit owner for a companion init, which depends on layout.
  • The reporter's 121 getString edges were not checked; I do not have their project.
  • No cache schema bump. Fix 2 changes extractor output, so cached Kotlin results keep missing init edges until the usual maintainer bump.

How was this tested?

PYTHONPATH=. .venv/bin/python -m pytest tests/test_kotlin_java_call_edges_4935.py -q
PYTHONPATH=. .venv/bin/python -m pytest tests -q -p no:cacheprovider --basetemp=/var/tmp/pt4935-full2
.venv/bin/python -m ruff check graphify/extract.py graphify/extractors/engine.py tests/test_kotlin_java_call_edges_4935.py

Branch fix/4935-kotlin-java-call-edges is based on upstream/v8 (8e6a6d21). It has four commits:

fc13ed5a test(kotlin): pin the Kotlin/Java call edges that already resolve (#4935)
55f54d30 fix(kotlin): resolve a Kotlin call to an overloaded method (#4935)
21b79abe fix(kotlin): attribute calls inside an init block to the class (#4935)
f4723706 fix(resolve): don't bind a bare call to a class its caller can't reach (#4935)

Merge Danger

Door: Two-way.

Each fix is its own commit and reverts alone. No graph format change.

Blast Radius: Kotlin and Java calls edges.

Fixes 1 and 2 add edges, so graphify affected and god-node counts for Kotlin callers of overloaded Java classes go up. Fix 3 removes INFERRED edges, but only from classes that extend a class outside the repo, and only to class members the caller cannot reach. Other languages go through different paths. The edge removal is the part to review: a real edge from such a class survives only if its target is a top-level function, a member of the caller's own chain, or imported by name.

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. No source fragments changed.
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…aphify-Labs#4935)

Before changing the resolvers, pin the shapes the fixes must leave alone: a
Kotlin call to a non-overloaded Java method, a property-initializer call
(Graphify-Labs#2565), two same-named classes staying ambiguous, and members inherited from
an in-repo base in Kotlin and Java.
…Labs#4935)

Java gives every overload of a method the same node id, because the id carries
no parameter types, so a type holds two `method` edges to one target.
`_resolve_kotlin_member_calls` counted list entries instead of distinct ids, so
`Logger.overloaded("a")` found two candidates, read them as an ambiguous name,
and wrote no edge. The same happens for overloads in a Kotlin `object`.

Count distinct ids. Two different methods of one name on one type still cannot
exist, and two same-named classes still stop at the receiver lookup, so the
god-node guard is as strict as before.
…ify-Labs#4935)

An `init { ... }` block parses as `anonymous_initializer`, which the engine
never visited, so a call inside it produced no raw call at all. The block has
no node of its own, which left nothing to attach the edge to.

Property initializers already solved this in Graphify-Labs#2565 by pushing the expression
onto `initializer_nodes` with the enclosing class as owner. Do the same for
`init`, including the file-node fallback a property initializer in a nested
class gets, so the two ways of running code at construction agree.

This changes extractor output, so the AST cache schema needs the usual
maintainer bump; cached Kotlin results keep missing these edges until then.
Graphify-Labs#4935)

`class Screen : Activity() { fun title() = getString(1) }` got an INFERRED
`calls` edge to `Prefs.getString`, a method of an unrelated class. A bare call
can only name a member of the caller's own class, a superclass or an enclosing
class, but the corpus-wide name match accepted the only `getString` it found.
Activity is outside the corpus, so nothing in the graph says it declares
getString, and the guess landed on the wrong class.

When a class in the caller's chain extends a class with no source file, keep
only candidates that are not class members, that belong to the chain, or that
the file imports by name (`import pkg.Obj.member`, `import static`). Callers
whose whole chain is in the corpus are untouched, so a receiver lambda such as
`with(p) { getString() }` keeps the edge it has today, and top-level functions
still resolve. This mirrors the unresolved-base guard the C# resolver already
uses.
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

Thanks for the pull request, @Deepjyoti-Sarmah. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Nov 2, 2026

@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 1 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 3 changed function(s) (1 sampled, not proven, 2 not verified).

Formal verification. PR-changed functions: 1/3 verified (0 proven, 1 may-equivalent, 0 distinguished) · 2 not verified (1 vacuous, 1 unsupported).

Not verified on this run: \_resolve\_kotlin\_member\_calls (vacuous: never exercised), \_extract\_generic (unsupported).


Graphify review — findings

Fixes three causes of missing or wrong Kotlin/Java calls edges (#4935). Overloaded methods no longer read as ambiguous in the Kotlin member-call resolver, because candidates are now deduplicated by node id. Kotlin init { ... } blocks now feed walk_calls under the enclosing class (or the file node for a nested one) the way property initializers do. The early return that hands the block off could drop declarations nested inside it. Tightens bare-call resolution for JVM callers: _jvm_reachable_candidates drops same-named methods the caller's class chain cannot reach when that chain extends a class outside the corpus, unless the file imports the member by name. A fully in-corpus chain is untouched. The result depends on _jvm_scope_index building the enclosing-class map and detecting external bases through stub nodes, so a wrong map or a missing stub weakens the filter.

Worth a look

  • Early return on Kotlin init block skips its nested declarations — graphify/extractors/engine.py:7811 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 4548 functions depend on the 767 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 1117 callers, 68 callees

Verification — 4548 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: 3897 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

289 of 502 test file(s) selected (58%) via static blast radius.

  • tests/test_4874_jvm_resolution.py — impact
  • tests/test_affected_overrides.py — impact
  • tests/test_alias_folding.py — impact
  • tests/test_analyze.py — impact
  • tests/test_astro_extraction.py — impact
  • tests/test_astro_import_ids.py — impact
  • tests/test_blade_extractor.py — impact
  • tests/test_build.py — impact
  • tests/test_build_located_semantic_identity.py — impact
  • tests/test_build_merge_dedup_scope.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_builtin_global_type_refs.py — impact
  • tests/test_c_include_resolution.py — impact
  • tests/test_cache.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_case_only_decl_ids_4904.py — impact
  • tests/test_case_sensitive_resolution.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cluster.py — impact
  • tests/test_cobol_extractor.py — impact
  • tests/test_confidence.py — impact
  • tests/test_confidence_score_output.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cpp_local_callable_shadowing.py — impact
  • tests/test_cpp_member_call_by_receiver_type.py — impact
  • tests/test_cpp_method_declarations.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cpp_receiver_parameters.py — impact
  • tests/test_cpp_split_header_owner.py — impact
  • tests/test_cpp_template_type_params.py — impact
  • tests/test_cpp_type_alias.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_cross_language_call_resolution.py — impact
  • tests/test_cross_repo_external_call_guards.py — impact
  • tests/test_cross_repo_member_calls.py — impact
  • tests/test_csharp_call_site_generic_args.py — impact
  • tests/test_csharp_delegate_declarations.py — impact
  • tests/test_csharp_enum_members.py — impact
  • tests/test_csharp_field_generic_args.py — impact
  • tests/test_csharp_foreach_loop_variable.py — impact
  • tests/test_csharp_generic_callsites.py — impact
  • tests/test_csharp_interface_dispatch.py — impact
  • tests/test_csharp_member_calls.py — impact
  • tests/test_csharp_member_chain_calls.py — impact
  • tests/test_csharp_member_nodes.py — impact
  • tests/test_csharp_method_owner_labels.py — impact
  • … and 239 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Docs that may be stale (advisory)

Formal verification

Could not verify: Could not verify \_resolve\_kotlin\_member\_calls.

The verifier did not have enough to check \_resolve\_kotlin\_member\_calls, 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: no capturable inputs from the test suite; property tier: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

No difference found (not proven): No behavior difference found in extract (not a proof).

The verifier ran both versions of extract on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

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: no capturable inputs from the test suite; property tier: parameter `config` is annotated `LanguageConfig` — outside the synthesizable primitive/collection set

· 1 more finding(s) on lines outside this diff (see the check run).

This branch has not been deployed

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

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Kotlin calls to overloaded Java methods and from init blocks get no edge; inherited external calls bind to unrelated classes (follow-up to #4874)

1 participant