Skip to content

Kotlin: cross-file import + receiver-typed member-call resolution - #2322

Open
sigarone wants to merge 1 commit into
Graphify-Labs:v8from
sigarone:add-kotlin-cross-file-resolution
Open

Kotlin: cross-file import + receiver-typed member-call resolution#2322
sigarone wants to merge 1 commit into
Graphify-Labs:v8from
sigarone:add-kotlin-cross-file-resolution

Conversation

@sigarone

@sigarone sigarone commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Kotlin extraction currently produces almost no usable cross-file structure -- measured on a real ~1170-file Kotlin/Android production module:

before after
cross-file calls edges 2576 7543+
...of which EXTRACTED confidence 7 1600+ (200x+)
resolved imports edges 691 ~2900 explicit + ~34.7k same-package imports_from

This PR bundles five fixes, all discovered while verifying against real production Android/Kotlin code (not just synthetic cases) -- each one closes a gap that made an otherwise-correctly-typed receiver still fail to resolve.

1. Import resolution was silently broken

_import_kotlin's edge target never matches a real node id -- it builds target = _make_id(module_name) from the bare imported name, but Kotlin symbol ids are file/class-qualified (e.g. crypto_messagecrypto_messagecrypto, not messagecrypto). build.py's dangling-edge filter silently drops the mismatch. On the measured corpus: 14616 source import lines -> 691 surviving edges.

Fix: new graphify/extractors/kotlin.py -- _resolve_cross_file_kotlin_imports, same two-pass strategy as _resolve_cross_file_java_imports (global name index, re-parse per file, resolve import a.b.C), plus a same-package pass Java doesn't need -- Kotlin requires no import for same-package visibility, so every same-package file pair gets one file-to-file imports_from edge (not one edge per symbol -- that's the exact granularity _has_import_evidence's module-import check already consumes, ~3x smaller than symbol-level for identical promotion power).

2. Kotlin member calls never carried a receiver

The call-walk branch for tree_sitter_kotlin sets is_member_call = True for recv.method() but never captures recv into member_receiver -- unlike the Swift/Java/C#/C++/ObjC/TS branches right next to it. The shared cross-file pass's if rc.get("is_member_call"): continue then drops every Kotlin member call with no receiver-typed resolver to pick them back up.

Fix: receiver capture in the call-walk (mirrors Swift's #1356) -- only a direct identifier or this receiver; a chained receiver (a.b().c()) is left untyped rather than guessed, same policy as everywhere else. A Kotlin type_table (property declarations -- explicit type or constructor-call/static-factory-call inference -- primary-constructor val/var parameters, function-body locals) is threaded out as kotlin_type_table, and _resolve_swift_member_calls is extended to also read it -- its receiver-typing algorithm doesn't care which language produced the table, so no new resolver function, just a second lookup and .kt/.kts added to the registration's suffix set.

3. Companion-object members had no class ownership at all

companion object { fun getInstance() } -- the standard Kotlin/Android singleton/factory idiom -- fell through the generic walk's default recurse, which resets parent_class_nid. This meant getInstance() got extracted with no method edge from the outer class whatsoever, so even a correctly receiver-typed call to Foo.getInstance() could never resolve past the class itself (a references edge, not calls).

Fix: treat companion_object as a transparent wrapper -- but recurse into its own body's children (mirroring exactly how the class_types branch recurses via _find_body/body.children), not into companion_object's direct children, which include the nested class_body node itself and would otherwise still hit the default-recurse reset one level down. (Caught this by testing an isolated companion-object fixture after the receiver-typing fix above resolved everything except this pattern -- worth flagging in review since it's a subtle two-level indirection.)

4. Constructor-default-value and by lazy {} calls were invisible

Two extremely common Kotlin idioms hold their initializer call in a place the existing initializer-walk (which only looks at direct call_expression/navigation_expression siblings) doesn't see:

  • class Foo(private val bar: Bar = Bar.getInstance()) -- a call as a primary-constructor default parameter value.
  • private val bar: Bar by lazy { Bar.getInstance() } -- a call inside a property delegate's lambda body.

Fix: both get queued into initializer_nodes the same way Swift's #1356 property initializers already are -- walk_calls naturally recurses through either node on its own, no special unwrapping needed.

Verification

  • Synthetic fixture covering all paths (explicit cross-file import, primary-constructor param typing, explicit-typed property, constructor/static-factory-inferred untyped property, function-body local, companion-object singleton via constructor-default AND via by lazy) -- all resolve EXTRACTED/INFERRED as expected, reproduced bit-identically from a from-scratch checkout (clean clone -> patch -> build -> same edges every time).
  • Ground-truthed against real production code:
    • MessageCrypto.encrypt() (line 140) calling vault.listKeys() resolves to the correct target with the correct source line.
    • BCryptoSecureStorage.getInstance() (a companion-object singleton) is now correctly method-owned by BCryptoSecureStorage, and a real cross-file caller (VoicePrintVault, via a primary-constructor default-value call) now resolves it as calls/EXTRACTED -- this exact call site was completely invisible to the graph before fix Wroked out examples missing graph.html #3+Telemetry and data privacy #4 combined.
  • Zero dangling edges introduced by either new Kotlin pass, checked against the final built graph.
  • No cross-language contamination risk: type_table is already shared per-file infra (Swift/C++ both write into it, one language per file in practice); the resolver's suffix-gate change only adds .kt/.kts to when the pass runs, doesn't change Swift-only corpora behavior at all (empty kotlin_type_table contributes nothing).

Test plan

  • pytest full suite (I ran manual functional checks against a from-scratch checkout + real production code, not the project's test harness -- happy to add/run whatever's expected, let me know the convention)
  • Byte-compiles clean (py_compile on all three touched files)
  • Fresh checkout smoke test (synthetic fixture, all paths, all correct, reproduced identically across two independent clean clones)
  • Real-corpus before/after measurement (numbers above)
  • Ground-truth spot checks against multiple real call sites, including one that needed fixes v3: Codex and OpenClaw support #2, Wroked out examples missing graph.html #3, and Telemetry and data privacy #4 together to resolve

Happy to split into smaller PRs if that's easier to review -- the five pieces share the same type_table/initializer_nodes infra and were each found by testing the previous fix against real code and hitting the next gap, so bundling them together is also how they were verified together.

@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.

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

Graphify reviewed this change.

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


Graphify review — findings

This PR adds cross-file symbol resolution for Kotlin, mirroring the existing Java and Swift cross-file passes. It introduces a new _resolve_cross_file_kotlin_imports pass wired into extract() for .kt/.kts files, and reuses the existing Swift receiver-typed member-call resolver for Kotlin by registering it under Kotlin suffixes and populating a parallel kotlin_type_table. In engine.py, it adds helper functions and extraction logic to build Kotlin type tables from property declarations, constructor-call inference, local val/var bindings, and primary-constructor val/var parameters (emitting references edges to their types). Numerous rationale_* and helper symbols were also touched. Surface area spans graphify/extract.py (new import, resolver registration, docstring updates, new resolution pass) and graphify/extractors/engine.py (new Kotlin helpers and generic-extraction branches).

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

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1407 functions depend on the 373 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 345 callers, 29 callees
  • worse: _extract_generic() — 18 callers, 16 callees

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

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

Kotlin extraction previously produced almost no usable cross-file structure.
See the extended PR description for full before/after numbers and root
causes; this commit bundles five fixes discovered together while verifying
against real production Android/Kotlin code, not just synthetic cases:

1. `graphify/extractors/kotlin.py` (new) — cross-file import resolution
   (explicit `import a.b.C` against the corpus symbol index, mirroring Java)
   plus same-package implicit-visibility resolution (`imports_from`,
   file-to-file, Java doesn't need this — Kotlin requires no import within
   a package).

2. Receiver capture in the Kotlin call-walk branch (`extractors/engine.py`),
   mirroring Swift's Graphify-Labs#1356 receiver capture, so `is_member_call` calls
   aren't unconditionally dropped by the shared cross-file pass.

3. A Kotlin `type_table` (property declarations — explicit type or
   constructor-call/static-factory-call inference — primary-constructor
   `val`/`var` parameters, and function-body locals) reusing
   `_resolve_swift_member_calls` via a second `kotlin_type_table` lookup
   (no new resolver function; the receiver-typing algorithm is
   language-agnostic).

4. Companion-object members (`companion object { fun getInstance() }`) were
   extracted with NO class ownership at all — no `method` edge from the
   outer class — because `companion_object` fell through to the generic
   walk's default recurse, which resets `parent_class_nid`. This meant even
   a correctly receiver-typed call to the ubiquitous Kotlin/Android
   singleton idiom `Foo.getInstance()` could never resolve past the class
   itself. Fixed by treating `companion_object` as a transparent wrapper,
   recursing into its own body's children (not its own direct children,
   which include the nested `class_body` node itself and would otherwise
   still hit the default-recurse reset one level down).

5. Constructor-parameter default-value calls and `by lazy { }` property
   delegates hold their initializer call outside the direct
   call_expression/navigation_expression sibling shape the existing
   initializer-walk looks for (`class_parameter`'s `=` default and
   `property_delegate`'s nested lambda respectively) — both are extremely
   common (constructor-injection defaults, deferred Android initialization)
   and were completely invisible to the call graph. Both now get queued
   into `initializer_nodes` the same way Swift's Graphify-Labs#1356 property
   initializers already are.

Verified on real production code (~1170-file Kotlin/Android module):
cross-file `calls` edges 2576 -> 7543+, EXTRACTED-confidence edges 7 -> 1600+.
Ground-truthed against multiple real call sites including a companion-object
singleton accessed through both a constructor default value and a `by lazy`
delegate. No dangling edges introduced by the new Kotlin passes.
@sigarone
sigarone force-pushed the add-kotlin-cross-file-resolution branch from 6de284e to c8079bd Compare July 30, 2026 10:47

@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.

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

Graphify reviewed this change.

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


Graphify review — findings

This PR adds cross-file symbol resolution for Kotlin, mirroring existing Java/Swift passes. In extract.py it wires up a new _resolve_cross_file_kotlin_imports call for Kotlin files and extends the existing Swift member-call resolver to also cover .kt/.kts files by consuming a kotlin_type_table. In engine.py it adds helper functions to infer Kotlin property/constructor-parameter/local-variable types and populate that type table for receiver-typed member-call resolution. The changes touch import resolution, member-call resolution registration, and Kotlin-specific type extraction logic, plus a large volume of associated rationale/comment symbols.

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

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1407 functions depend on the 373 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 345 callers, 29 callees
  • worse: _extract_generic() — 18 callers, 16 callees

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

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

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.

Wroked out examples missing graph.html v3: Codex and OpenClaw support

1 participant