Kotlin: cross-file import + receiver-typed member-call resolution - #2322
Kotlin: cross-file import + receiver-typed member-call resolution#2322sigarone wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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.
6de284e to
c8079bd
Compare
There was a problem hiding this comment.
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).
Summary
Kotlin extraction currently produces almost no usable cross-file structure -- measured on a real ~1170-file Kotlin/Android production module:
callsedgesimportsedgesimports_fromThis 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 buildstarget = _make_id(module_name)from the bare imported name, but Kotlin symbol ids are file/class-qualified (e.g.crypto_messagecrypto_messagecrypto, notmessagecrypto).build.py's dangling-edge filter silently drops the mismatch. On the measured corpus: 14616 sourceimportlines -> 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, resolveimport a.b.C), plus a same-package pass Java doesn't need -- Kotlin requires noimportfor same-package visibility, so every same-package file pair gets one file-to-fileimports_fromedge (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_kotlinsetsis_member_call = Trueforrecv.method()but never capturesrecvintomember_receiver-- unlike the Swift/Java/C#/C++/ObjC/TS branches right next to it. The shared cross-file pass'sif rc.get("is_member_call"): continuethen 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 orthisreceiver; a chained receiver (a.b().c()) is left untyped rather than guessed, same policy as everywhere else. A Kotlintype_table(property declarations -- explicit type or constructor-call/static-factory-call inference -- primary-constructorval/varparameters, function-body locals) is threaded out askotlin_type_table, and_resolve_swift_member_callsis 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/.ktsadded 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 resetsparent_class_nid. This meantgetInstance()got extracted with nomethodedge from the outer class whatsoever, so even a correctly receiver-typed call toFoo.getInstance()could never resolve past the class itself (areferencesedge, notcalls).Fix: treat
companion_objectas a transparent wrapper -- but recurse into its own body's children (mirroring exactly how theclass_typesbranch recurses via_find_body/body.children), not intocompanion_object's direct children, which include the nestedclass_bodynode 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 invisibleTwo extremely common Kotlin idioms hold their initializer call in a place the existing initializer-walk (which only looks at direct
call_expression/navigation_expressionsiblings) 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_nodesthe same way Swift's#1356property initializers already are --walk_callsnaturally recurses through either node on its own, no special unwrapping needed.Verification
by lazy) -- all resolve EXTRACTED/INFERRED as expected, reproduced bit-identically from a from-scratch checkout (clean clone -> patch -> build -> same edges every time).MessageCrypto.encrypt()(line 140) callingvault.listKeys()resolves to the correct target with the correct source line.BCryptoSecureStorage.getInstance()(a companion-object singleton) is now correctlymethod-owned byBCryptoSecureStorage, and a real cross-file caller (VoicePrintVault, via a primary-constructor default-value call) now resolves it ascalls/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.type_tableis 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/.ktsto when the pass runs, doesn't change Swift-only corpora behavior at all (emptykotlin_type_tablecontributes nothing).Test plan
pytestfull 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)py_compileon all three touched files)Happy to split into smaller PRs if that's easier to review -- the five pieces share the same
type_table/initializer_nodesinfra 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.