Skip to content

fix(kotlin): type receivers from declarations; parse fun interface - #1933

Open
danusha2345 wants to merge 2 commits into
colbymchenry:mainfrom
danusha2345:fix/kotlin-property-receiver-type
Open

danusha2345 wants to merge 2 commits into
colbymchenry:mainfrom
danusha2345:fix/kotlin-property-receiver-type

Conversation

@danusha2345

@danusha2345 danusha2345 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem

inferJavaFieldReceiverType reads a field's type from its signature, in the Java shape Type name. Kotlin can't give it a type there:

  • a class-body property (private lateinit var probe: HttpProbe) is indexed with signature: null;
  • a primary-constructor property (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):

class ProbeTest {
    private lateinit var probe: HttpProbe          // HttpProbe : Probe, Probe : Closeable
    @After fun tearDown() { probe.close() }        // main: → an unrelated DirectProbe::close
    @Test fun ready() { probe.probe("relay", …) }  // main: → Probe::probe (the interface)
}

Change

For Kotlin, inferJavaFieldReceiverType now reads the declared type from the declaration itself:

  • a property that is a node: its own lines, val|var name: Type or val|var name = Type(…);
  • a primary-constructor property: the class header, i.e. the class's lines before its first member. A same-named local inside a method body is never read.
  • a nested type keeps its outer type: HardwareLock.Lease becomes HardwareLock::Lease, so it matches that Lease and not another class's. A lowercase package prefix is dropped.
  • a nullable type (HttpProbe?) reads as HttpProbe.

resolveMethodOnType still 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 main and with this branch:

app call edges retargeted
app A 29
app B 0
app C 0 (+1 new edge)

I checked each retargeted edge against the source:

  • tearDown → close and five probe.probe(…) calls: from an unrelated class / the interface to HttpProbe.
  • 14 coordinator calls: from the concrete SDK adapter (found by method name) to the FlightSdkPort the property is declared as.
  • Three control.execute() calls: from a self-edge / a sibling capability to the declared …Control.
  • gate.begin(): from an unrelated StatsPollGate to the declared StartupFallbackGate.

In app B the nested HardwareLock.Lease property now resolves by type, 0.9 instead of 0.65, and to the same target as before.

Tests

New __tests__/kotlin-property-receiver.test.ts covers four cases:

  • a lateinit property with an interface + implementation;
  • a primary-constructor property, plus a nullable one;
  • a property initialized by a constructor call;
  • a nested Outer.Inner type, with two classes sharing the inner name.

On current main the four original cases already pass: the lateinit property, the primary-constructor property (nullable included), the property initialized by a constructor call, and the nested Outer.Inner type. main has 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 in kotlin-property-receiver.test.ts, 12 fail on main (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:kernel and tsc are clean.

I didn't bump EXTRACTION_VERSION: existing Kotlin indexes pick this up on index -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, an interface ControlSessionEvents went missing (its methods surfaced as top-level functions), and so did a class DirectRouteSelector 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, so it no longer defers these files. The parity pin that asserted the defer now asserts parity, and that the following declaration survives.
  • Call-typed receivers and library types (ecc67f0).
    • val lease = HardwareLock.tryBegin() and private val schema = Checker.load() are typed by the callee's declared return type, read from its signature. A nested return type keeps its owner.
    • A receiver of a type the project does not declare (Regex, Properties, a call on Executors) now gets no edge instead of the name-only guess. That guess produced regex.find() → an unrelated find, socket.connect() → a project connect, and x.close() → itself.

Across the three apps (main → this PR):

app name-guessed calls (confidence ≤ 0.7) type-resolved calls (≥ 0.9)
A 932 → 748 1413 → 1573
B 280 → 189 1007 → 1092
C 135 → 92 146 → 176

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 interface followed by a doc comment and an interface, and library receivers (Regex.find, Executors…shutdown). On main those three fail. The full suite passes (4725 passed), and kernel-kotlin-parity is green with CODEGRAPH_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:

  • val and companion properties. A val property is indexed as a constant and a companion-object property as a variable; the lookup only took field.
  • Enum entries. var mode = Mode.ON is typed by its enum. Project enums only: Limits.MAX is an Int.
  • Trailing-lambda constructors. Thread { … } counts as a constructor call.
  • Aliases. val old = current ?: return, current!!, and requireNotNull(x) take the aliased value's type.
  • Parameters. A typed parameter of the enclosing function or overridden callback is used when nothing else names a type.
  • Stdlib factories. listOf, lazy, thread and 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):

app name-guessed type-resolved
A 527 → 237 1413 → 1656
B 174 → 21 1007 → 1138
C 80 → 33 146 → 176
D 133 → 73 162 → 234

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 in kotlin-property-receiver.test.ts. The full suite passes (4729).

Fourth follow-up: receiver chains (8c4dc2b)

a.b.method(), this.a.method() and a?.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.

  • Extraction, both arms, identical output. A chain of up to four plain identifiers is kept (engine.pump.drain); anything else still emits the bare name. A chain block was added to torture.kt, and kernel parity holds.
  • Resolution. matchKotlinReceiverChain types the first segment with the receiver logic above (this is 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.
    • A known type resolves the call on that type.
    • A library type anywhere in the chain means no edge. The exception is an extension function the project defines on that library type (fun Context.dp()), which keeps the old resolution.
    • An unknown type falls back to the bare-name resolution, as before.
  • Generics. A generic constructor call (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.

  • The 57 removed edges were checked against source. Library collections (ArrayDeque, LinkedBlockingQueue, mutableListOf<…>) and ScheduledFuture.cancel had been bound to same-named project methods, including self-edges.
  • The 29 added ones were checked too: e.g. 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)

  • Inherited properties. When the enclosing class doesn't declare the receiver, its project supertypes, read from the class headers, are walked up to 4 levels, taking the nearest declaring class; cycles are skipped. That covers a property declared in the base body or in its primary constructor, in the same file or another. A library supertype ends the walk and leaves the type unknown, so the old behaviour stands.
  • Outer locals captured by an anonymous object or local function. The local scan continues from the object's method into the outer function above it, and the nearest declaration wins. Sibling member functions are skipped.

On the apps above this changed 7 call edges, all correct:

  • two stop() calls reached a single implementation (DjiTelemetrySource) by name, and now reach the declared interface TelemetrySource;
  • two new edges come from outer lateinit vars used inside an anonymous ControlEndpoint.

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

@mixxer

mixxer commented Sep 24, 2026

Copy link
Copy Markdown

Follow-up to b04da739 and the Android corpus validation: I separated the explicit-import case into #1948 (commit 8f44c58; fork mirror). Kotlin calls through imported types and aliases now resolve to the imported declaration, while calls to external types absent from the index stay unresolved instead of matching an unrelated local method.

The separate Java Type.FIELD.method() case is in #1949 (commit 126a2e6; fork mirror). Both PRs are based directly on main, independent of #1933, with focused regressions, passing builds, and passing standalone full suites. No change to this PR's existing commits is needed for these follow-ups.

@danusha2345

Copy link
Copy Markdown
Contributor Author

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.

@danusha2345
danusha2345 force-pushed the fix/kotlin-property-receiver-type branch from b04da73 to beaea10 Compare September 27, 2026 15:19
mixxer added a commit to mixxer/codegraph-aosp that referenced this pull request Sep 27, 2026
@danusha2345
danusha2345 force-pushed the fix/kotlin-property-receiver-type branch 5 times, most recently from 3e8fdd6 to 9217f66 Compare October 7, 2026 11:18
danusha2345 and others added 2 commits October 7, 2026 18:29
…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>
@danusha2345
danusha2345 force-pushed the fix/kotlin-property-receiver-type branch from 9217f66 to caeab08 Compare October 7, 2026 16:05

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants