Repository navigation
fix(kotlin): type receivers from declarations; parse fun interface - #1933
danusha2345 wants to merge 2 commits into
Conversation
|
Follow-up to The separate Java |
Integrate Kotlin property receiver resolution from upstream colbymchenry#1933
|
Thanks for splitting these out. I reviewed #1948 against the Android corpus and approved it, and it is in the build I run alongside this PR. The two touch different receiver shapes: #1948 covers explicitly imported types and aliases, while this PR types receivers from their own declarations. So they should land in either order. |
b04da73 to
beaea10
Compare
9217f66 to
caeab08
Compare
b2f8b9a to
7520143
Compare
c0aa994 to
ef1b437
Compare
cf2ccdd to
3d36a1d
Compare
…tion tree-sitter-kotlin has no `fun interface` (Kotlin 1.4 functional interfaces). The declaration parsed as a broken function, and when a doc comment followed it, error recovery swallowed the NEXT declaration too: an interface and its methods went missing, surfacing as top-level functions, or a class vanished with its members. The Kotlin extractor's preParse now blanks the `fun` of a `fun interface` declaration (three spaces for three letters, so offsets hold); the kernel receives the same bytes through the preParse hoist and no longer defers these files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…guessed Kotlin properties carry no signature and primary-constructor properties are no nodes, and upstream's member inference (colbymchenry#2171) reads only a written type annotation (`val x: T`). A Kotlin receiver typed any other way fell to name-only guessing: the interface method, a same-named method in the caller's file, or any project method when the receiver is a library type. The resolver now reads a Kotlin receiver's type from its declaration: - a property's own lines or the class header (a constructor property), a `val` (indexed as a constant) and a companion property (a variable); - a constructor call, including a trailing lambda (`Thread { … }`) and type arguments (`Crate<Int>()`); a call, typed by the callee's declared return type (a nested return type keeps its owner: `HardwareLock::Lease`); a project enum entry (`var mode = Mode.ON`); an alias (`val old = current ?: return`, `current!!`, `requireNotNull(x)`); a typed parameter; - a property inherited from a project supertype (breadth-first, four levels; a library or ambiguous supertype is not walked, so the receiver keeps the name-only resolution); - a local of the outer function captured by an anonymous object's member. A receiver chain (`engine.pump.drain()`, `this.engine.drain()`, `a?.b?.c()`, up to four segments) is now kept by the extractor (wasm path and kernel, in parity) and typed segment by segment; an untyped chain resolves as the bare method name, exactly as before. A type the project does not declare (`Regex`, `Executors`, stdlib factories such as `mutableSetOf`) gets no edge, unless the project declares an extension of that name on a library type (`fun Thread.label()`). Rebased onto the 2026-09-30 resolution wave; squashes the PR's receiver commits (648e02c, 230c91c, 2a47c9b, a956373, beaea10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3d36a1d to
d5ffe2e
Compare
…n its declared type Kotlin indexes a class property without a signature, and a primary-constructor property (class A(private val repo: Repo)) is no node at all, so a call through either (probe.close() on a lateinit var probe: HttpProbe) found no receiver type and fell to the name-only strategies: the interface's method, or any same-named method elsewhere. A call through a chain (engine.pump.drain()) was emitted as its bare method name, and a local bound to a qualified call (val lease = HardwareLock.tryBegin()) was untyped. tree-sitter-kotlin also predates fun interface: such a declaration parsed as a broken function and, with a doc comment after it, its error recovery swallowed the next declaration too. The declared type is now read from the declaration itself: a property's own lines, the class header for a constructor property, a callee's declared return type for a local bound to a call, an enum entry's enum, each segment of a receiver chain in turn (the chain is kept in the reference name, by the wasm extractor and the native kernel alike), a superclass's property, and an outer function's local inside an anonymous object. A receiver whose type the project does not declare (Regex, Properties, a JDK class) gets no edge instead of a name-only guess, unless the project extends that type. The extractor's preParse blanks the fun of a fun interface, offset-preserving, so the file parses cleanly in both paths. Validation: - kotlin-property-receiver.test.ts: 17 tests, 12 red on main, all green here; kernel-kotlin-parity.test.ts pins the fun interface fixture. - Kernel parity (kotlin), 0 diffs: okhttp 588/619 files byte-identical (main's kernel 578, the rest deferred to wasm on parse errors), mihon 891/940 (887), nowinandroid 340/350 (339): the fun interface files no longer defer. - A/B against main with #1942 (positions only: the extractor now keeps a chain in the reference name): okhttp +299/-257, 2,554 retargeted, 2,158 of them test calls moving from the deprecated okhttp3.mockwebserver.MockWebServer (a 0.65 name guess) to the mockwebserver3.MockWebServer each file imports; mihon +82/-118, 337 retargeted (Preference get/changes/set/register onto the declared Preference); nowinandroid -9, 23 retargeted (properties onto their concrete test doubles); SystemUI's Kotlin +1/-104, 7 retargeted; jextract, click and gin identical. The removed are name guesses on library types (okio sinks, collections' filter and clear, OkHttp bodies, MutableStateFlow.update, Kotlin's apply). 7 right edges are lost where a chain passes through a library wrapper (Lazy.value, StateFlow.value, Pair.first, a ViewBinding's view). - Cost (mihon, median of 3 interleaved indexes): CPU 25.8 s on main with #1942 vs 26.7 s, wall 8.3 vs 8.4 s. Original PR #1933 by @danusha2345. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @danusha2345! This landed in #2972, with you as the commit author. Typing Kotlin receivers through properties and chains moved over 2,000 of okhttp's test calls onto the |
…mchenry#2972; 10 landed PRs yield to main; EXTRACTION_VERSION 56) Main landed ten more of our PRs as our own commits with the maintainer's additions (colbymchenry#2923 ← colbymchenry#1936, colbymchenry#2924 ← colbymchenry#1951, colbymchenry#2925 ← colbymchenry#1939, colbymchenry#2930 ← colbymchenry#2921, colbymchenry#2940 ← colbymchenry#1736, colbymchenry#2942 ← colbymchenry#1947, colbymchenry#2950 ← colbymchenry#1952, colbymchenry#2961 ← colbymchenry#1954, colbymchenry#2965 ← colbymchenry#1942, colbymchenry#2972 ← colbymchenry#1933) and the module-path half of colbymchenry#2308 (colbymchenry#2955). Where the integration carried our version, main's wins: the enclosing-scope member pick and its tests, the Go and Java receiver typers, the delegating-call self-loop rule, the duplicated-module copy confidence, the C #if collapse, the module-path resolution of Rust paths, the trail/path hardening in utils.ts. Kept beside main: colbymchenry#1875's Verilog hooks and status fields, colbymchenry#2839's implementations command, colbymchenry#1923, colbymchenry#2922, colbymchenry#2258, colbymchenry#1761, colbymchenry#1583, the bare-call half of colbymchenry#2308 (rust-use-bindings.ts). CHANGELOG is main's plus the open PRs' bullets. EXTRACTION_VERSION 56: the C #if group handling (colbymchenry#2940) and the kernel change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stay in their file A Kotlin property was indexed without its visibility modifier, so the Kotlin file-local rule (#1745), which declines a private fun in another file, read every property as public: a call like token(...) could link to another file's @volatile private var token, and JavaScript, Go or Python callers to a Kotlin test fixture's private val. A public member of a private class or object was reachable from other files too, though nothing outside its file can name it. Both extractors (wasm and the native kernel) now read a property's modifiers the way they read a function's, and a candidate inside a private Kotlin class or object is declined from another file. That second rule is Kotlin's alone: a Scala private[pkg] class, or one with a private constructor, is its package's, and a C# private nested class serves every file of its partial class. Applied to every language where private is file-local, as the PR had it, it removed 742 edges on http4s (right ones among them, like stream.receiveHeaders() on a private[h2] class H2Stream), 91 on scalachess and 10 on serilog (9 of them right typed edges). Validation: - cross-file-visibility.test.ts: 2 cases, red on main, green here; the kernel parity fixture gains a private val and an internal var. - Kernel parity (kotlin), 0 diffs: okhttp 588/619 files, mihon 891/940, nowinandroid 340/350, SystemUI's Kotlin 188/198 byte-identical, the same files deferred to wasm as with main's kernel; torture 1/1. - A/B against main 01e0ac3 (with the Kotlin receiver typing of #1933): okhttp -274, mihon -177, SystemUI -94, every one a call from another file onto a private Kotlin property or a member of a private class or object (okhttp's private inner AndroidQuery.enqueue and RealWebSocketTest's private TestStreams, mihon's private IntentFilter filter that collections' filter { } reached, SystemUI's private MyExecutor.execute); nothing added. nowinandroid, jextract, click and gin identical; against main 4eb8c2a, http4s, scalachess and serilog were identical too. - Cost (mihon, median of 3 interleaved indexes, on main 4eb8c2a): CPU 25.7 s on main vs 25.5 s, wall 8.2 s on both. Original PR #1761 by @danusha2345. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n on it A Kotlin import from outside the project owns its name (#2196), so Modifier.fillMaxSize() after import androidx.compose.ui.Modifier never lands on a same-named project method. That rule returns before any other strategy, so a project extension on the imported type, Compose's fun Modifier.pad() (indexed as Modifier::pad), could not be reached by Modifier.pad() either, nor Color.fromHex() on a fun Color.Companion.fromHex(). Inside the rule, a Kotlin Type.member call now resolves to a project extension Type::member that the calling file can see (same file or package, or imported by name or with .*, as for other top-level Kotlin declarations) and whose own file imports the same type. Anything else stays unlinked, as before. Validation: - kotlin-extension-on-imported-type.test.ts: 2 cases, red on main, green here. - A/B against main 13f983d (with the Kotlin receiver typing of #1933 and #1761's private rule): mihon +26, all Compose Modifier extensions the project declares (secondaryItemAlpha 15, clickableNoIndication 5, selectedBackground 2, two scrollbars, keepInSection), nowinandroid +2 (Modifier.notificationDot, DeviceConfigurationOverride.WindowInsets), all right; okhttp, SystemUI, jextract, click and gin identical. The same sites as against main 4eb8c2a, before #1933. - Cost (mihon, median of 3 interleaved indexes, on main 4eb8c2a): CPU 25.7 s on main vs 25.6 s, wall 8.2 s vs 8.4 s. Original PR #2258 by @danusha2345. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…when two share a name When several classes share a name, resolveMethodOnType picks the one the call file imports by matching the import's path against the candidate's file path (com/example/b/FooConverter.kt). That holds for Java, where a public class names its file, but a Kotlin class often lives in a file named otherwise (FooConverter in Converters.kt), so no candidate matched and the call went to whichever class came first. The imported name is now matched against each candidate's qualified name first: import com.example.b.FooConverter expects the method com.example.b::FooConverter::convert, which the package directive gives every JVM declaration whatever its file is called. The file-path match stays as the fallback. The Java calls this once fixed, a Java file calling a Kotlin class it imports (okhttp's Java tests and mockwebserver3.MockWebServer, SystemUI's BlurUtils), are now resolved on main by the receiver's declared type (#1942), so on today's main the change shows in Kotlin. From #539 we take only this change and its test. Its other two parts, indexing Kotlin class properties and reading Kotlin imports without a trailing semicolon, have since landed on main another way. Validation: - resolution.test.ts: the Kotlin same-name case (#314's shape in Converters.kt files) is red on main, green here. - A/B against main 40f5851 (with the Java receiver typing of #1942 and the Kotlin changes of #1933, #1761 and #2258): no edge changes on okhttp, SystemUI, nowinandroid, mihon, jextract, spring-modulith, Minestom, spring-ai, kestra-java, playframework, click or gin; none of them has the test's shape on a resolved call. Against main 4eb8c2a, before #1942, it moved the 8 Java calls above onto the Kotlin class their file imports; main now reaches them at 0.9. - Cost (mihon, median of 3 interleaved indexes, on main 4eb8c2a): CPU 25.7 s on main vs 25.2 s, wall 8.2 s vs 8.3 s. Original PR #539 by @arttttt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
inferJavaFieldReceiverTypereads a field's type from itssignature, in the Java shapeType name. Kotlin can't give it a type there:private lateinit var probe: HttpProbe) is indexed withsignature: null;class A(private val repo: Repo)) is not indexed at all.So every Kotlin call through a property got no receiver type and fell through to name-only matching (
instance-method, confidence 0.65–0.8). That returns the interface method, or any same-named method in another class. On a real Android app (anonymized here):Change
For Kotlin,
inferJavaFieldReceiverTypenow reads the declared type from the declaration itself:val|var name: Typeorval|var name = Type(…);HardwareLock.LeasebecomesHardwareLock::Lease, so it matches thatLeaseand not another class's. A lowercase package prefix is dropped.HttpProbe?) reads asHttpProbe.resolveMethodOnTypestill validates the method on that type, so a wrong read yields no edge rather than a wrong one. Java is unchanged: it keeps the signature path.Measured
I indexed three real Kotlin Android apps with
mainand with this branch:I checked each retargeted edge against the source:
tearDown → closeand fiveprobe.probe(…)calls: from an unrelated class / the interface toHttpProbe.FlightSdkPortthe property is declared as.control.execute()calls: from a self-edge / a sibling capability to the declared…Control.gate.begin(): from an unrelatedStatsPollGateto the declaredStartupFallbackGate.In app B the nested
HardwareLock.Leaseproperty now resolves by type, 0.9 instead of 0.65, and to the same target as before.Tests
New
__tests__/kotlin-property-receiver.test.tscovers four cases:lateinitproperty with an interface + implementation;Outer.Innertype, with two classes sharing the inner name.On current
mainthe four original cases already pass: thelateinitproperty, the primary-constructor property (nullable included), the property initialized by a constructor call, and the nestedOuter.Innertype.mainhas read class-level member declarations for Kotlin since #2171, so those four are kept here as regression guards. What this PR still adds is everything else: of the 17 cases inkotlin-property-receiver.test.ts, 12 fail onmain(ed199e6) and 5 pass (those four plus the control where a receiver not found up to a library base class keeps the name-only resolution). The full suite passes (4722 passed),build:kernelandtscare clean.I didn't bump
EXTRACTION_VERSION: existing Kotlin indexes pick this up onindex -f. Happy to add the bump if you'd rather.Follow-ups in this PR
Two more commits close the gaps the first one exposed on the same apps:
fun interface(c3aeada). tree-sitter-kotlin has no functional interfaces.fun interface Reply { … }parsed as a broken function, and when a doc comment followed it, error recovery swallowed the next declaration too. On the app above, aninterface ControlSessionEventswent missing (its methods surfaced as top-level functions), and so did aclass DirectRouteSelectorwith its members.preParsenow blanks thefunof afun interfacedeclaration: three spaces for three letters, so offsets hold.val lease = HardwareLock.tryBegin()andprivate val schema = Checker.load()are typed by the callee's declared return type, read from its signature. A nested return type keeps its owner.Regex,Properties, a call onExecutors) now gets no edge instead of the name-only guess. That guess producedregex.find()→ an unrelatedfind,socket.connect()→ a projectconnect, andx.close()→ itself.Across the three apps (
main→ this PR):Every removed edge I checked was a self-loop or a call on a library type.
The tests now also cover a call-bound property and local, a
fun interfacefollowed by a doc comment and an interface, and library receivers (Regex.find,Executors…shutdown). Onmainthose three fail. The full suite passes (4725 passed), andkernel-kotlin-parityis green withCODEGRAPH_KERNEL_EXPECT=1.Third follow-up: remaining receiver shapes
On the same apps I went through what was still name-guessed and fixed each shape:
valand companion properties. Avalproperty is indexed as aconstantand a companion-object property as avariable; the lookup only tookfield.var mode = Mode.ONis typed by its enum. Project enums only:Limits.MAXis an Int.Thread { … }counts as a constructor call.val old = current ?: return,current!!, andrequireNotNull(x)take the aliased value's type.listOf,lazy,threadand the like return a library type. Any other library function leaves the type unknown, so it keeps the old behaviour.Final numbers against
main: call edges through a receiver that are still name-guessed (confidence ≤ 0.7), and call edges resolved by type (≥ 0.9):Every removed edge I checked was either a self-loop or a call on a library type bound to a same-named project method or a test fake:
thread.join(),process.destroy(),webSocket.send(),input.read(),text.isEmpty(). Tests: 11 cases inkotlin-property-receiver.test.ts. The full suite passes (4729).Fourth follow-up: receiver chains (8c4dc2b)
a.b.method(),this.a.method()anda?.b?.c()could not be typed at all. For a chained receiver, the Kotlin extractor kept only the bare method name, in both the wasm extractor and the kernel.engine.pump.drain); anything else still emits the bare name. A chain block was added totorture.kt, and kernel parity holds.matchKotlinReceiverChaintypes the first segment with the receiver logic above (thisis the enclosing class, a capitalized segment is an object or companion, an enum entry takes its enum's type). It then walks each later segment as a property declared in the previous type's class.fun Context.dp()), which keeps the old resolution.Session<Ice>(…),mutableListOf<T>()) now types its variable.On app A, every call edge whose confidence is ≤ 0.7 (receiver or not): 642 → 523; calls resolved by type: 1656 → 1750.
ArrayDeque,LinkedBlockingQueue,mutableListOf<…>) andScheduledFuture.cancelhad been bound to same-named project methods, including self-edges.runtime.session.isCurrentPeer→WebRtcPlaneSession::isCurrentPeer,reconnectPolicy.reset→SignalingReconnectPolicy::reset.Tests: 14 cases in
kotlin-property-receiver.test.ts, with the kernel on and off. The full suite passes (4732).Fifth follow-up: inherited properties and captured outer locals (b04da73)
On the apps above this changed 7 call edges, all correct:
stop()calls reached a single implementation (DjiTelemetrySource) by name, and now reach the declared interfaceTelemetrySource;lateinit vars used inside an anonymousControlEndpoint.The inherited-property walk didn't fire on these apps, which have no calls through an inherited property, so it is covered only by the test cases.
🤖 Generated with Claude Code