Repository navigation
fix(kotlin): resolve overloaded and init-block calls, drop unreachable inherited calls (#4935) - #4956
Deepjyoti-Sarmah wants to merge 4 commits into
Conversation
…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.
|
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. |
There was a problem hiding this comment.
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— impacttests/test_affected_overrides.py— impacttests/test_alias_folding.py— impacttests/test_analyze.py— impacttests/test_astro_extraction.py— impacttests/test_astro_import_ids.py— impacttests/test_blade_extractor.py— impacttests/test_build.py— impacttests/test_build_located_semantic_identity.py— impacttests/test_build_merge_dedup_scope.py— impacttests/test_build_merge_hyperedges_and_prune.py— impacttests/test_build_merge_shrink_guard.py— impacttests/test_builtin_global_type_refs.py— impacttests/test_c_include_resolution.py— impacttests/test_cache.py— impacttests/test_carried_hyperedge_remap.py— impacttests/test_case_only_decl_ids_4904.py— impacttests/test_case_sensitive_resolution.py— impacttests/test_chunking.py— impacttests/test_cjs_module_extension.py— impacttests/test_cli_export.py— impacttests/test_cluster.py— impacttests/test_cobol_extractor.py— impacttests/test_confidence.py— impacttests/test_confidence_score_output.py— impacttests/test_corrupt_graph_json.py— impacttests/test_cpp_local_callable_shadowing.py— impacttests/test_cpp_member_call_by_receiver_type.py— impacttests/test_cpp_method_declarations.py— impacttests/test_cpp_nested_and_cli.py— impacttests/test_cpp_objc_cross_file_calls.py— impacttests/test_cpp_receiver_parameters.py— impacttests/test_cpp_split_header_owner.py— impacttests/test_cpp_template_type_params.py— impacttests/test_cpp_type_alias.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_cross_language_call_resolution.py— impacttests/test_cross_repo_external_call_guards.py— impacttests/test_cross_repo_member_calls.py— impacttests/test_csharp_call_site_generic_args.py— impacttests/test_csharp_delegate_declarations.py— impacttests/test_csharp_enum_members.py— impacttests/test_csharp_field_generic_args.py— impacttests/test_csharp_foreach_loop_variable.py— impacttests/test_csharp_generic_callsites.py— impacttests/test_csharp_interface_dispatch.py— impacttests/test_csharp_member_calls.py— impacttests/test_csharp_member_chain_calls.py— impacttests/test_csharp_member_nodes.py— impacttests/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)
CHANGELOG.md§ 0.9.5 (2026-07-02) (lines 1447-1470): references changed symbolsLogger
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).
What does this PR do?
Fixes #4935.
Three Kotlin/Java
callsedges 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
methodedges to one target._resolve_kotlin_member_callscounted list entries, read the two as an ambiguous name, and wrote no edge. It now counts distinct ids.2. Calls inside
init { }. The block parses asanonymous_initializer, which the engine never visited, so its calls had no source to attach to. It now goes toinitializer_nodesthe way a property initializer does (#2565), with the same owner: the enclosing class, or the file node for a nested one.3. Inherited external methods.
class KotlinScreen : Activity()callinggetString(1)bound to the onlygetStringin 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, andActivityis outside the corpus, so nothing says wheregetStringlives.This mirrors the unresolved-base guard the C# resolver already has.
Type of change
Evidence
Before: the repro from the issue on
upstream/v8(8e6a6d21).After: the same repro.
The full suite ran serially with
PYTHONPATHset to this worktree, so subprocess CLI tests imported this code and not the installed package.Verification & Invariants
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:
objectmethod.init: the call attaches to the class, a call to an overloaded method, a class with both a property and aninit, a nested class (checked against a property initializer of the same shape), andobjectand companion blocks.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 insidewith(p) { getString() }) and failedtest_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:
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. Awith(p) { ... }receiver lambda in such a class loses it too.KotlinInitowner for a companioninit, which depends on layout.getStringedges were not checked; I do not have their project.initedges until the usual maintainer bump.How was this tested?
Branch
fix/4935-kotlin-java-call-edgesis based onupstream/v8(8e6a6d21). It has four commits:Merge Danger
Door: Two-way.
Each fix is its own commit and reverts alone. No graph format change.
Blast Radius: Kotlin and Java
callsedges.Fixes 1 and 2 add edges, so
graphify affectedand 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
uv run python -m tools.skillgen --bless) when changing their source fragments. No source fragments changed.