Skip to content

fix(kotlin): resolve calls through typed class properties (#1555) - #2682

Merged
DeusData merged 2 commits into
mainfrom
fix/issue-1555-kotlin-property-receivers
Oct 18, 2026
Merged

DeusData merged 2 commits into
mainfrom
fix/issue-1555-kotlin-property-receivers

Conversation

@DeusData

Copy link
Copy Markdown
Owner

What

Kotlin member calls through a class property — a primary-constructor val, a body property, or this.prop — now bind to the property's declared type, on both the sequential and the parallel resolver. Fixes #1555.

Why it was wrong

The cross-file Kotlin walk resolved a call on a typed method parameter by type, but a call on a class property fell to the name guess. The cross registry held each class of the current file from its extraction defs, and their properties carry no type (constructor vals none at all). The walk's typed AST registration of the same class was added behind that entry under the same QN, so every lookup returned the field-less one. Constructor injection is the common Kotlin dependency shape, so repository.inserir(...) on an injected port was bound to whichever same-named method the guess found, including an implementation of another port.

The walk now registers the file's own declarations into an overlay that is chained to the cross registry and checked first. The overlay is indexed once the top-level declarations are collected. The sole-definer fallback walks the whole chain, so its candidate set is unchanged.

Typing many more receivers exposed resolution gaps that untyped receivers had hidden. Each would otherwise have turned a newly typed call into a wrong name guess, so each is handled in this PR:

Shape Before Now
member vs same-named extension in the same file extension could win member wins (Kotlin resolution order)
x.hashCode() on a receiver typed by another file per-file kotlin.Any placeholder and cross-pass override both claimed the occurrence → ambiguous → name guess placeholder dropped when the cross pass resolves the occurrence
count.toString() on Int / String / JDK types seeded stdlib member with no graph node → name guess the kotlin.Any builtin (external, no project edge)
property read on a typed receiver whose property QN the graph spells differently (nested class) fabricated CALL_REFERENCE to a same-named function elsewhere falls back by name to non-callable targets only → the property's own node
private val handshake: Handshake? next to override fun handshake() the value shadowed the member → name guess a value without invoke yields to the member function
bare call to an enclosing-class member inside repo.let { … } or a member extension resolved against the lambda receiver only → name guess the enclosing class is checked next (implicit-receiver chain); a call naming a class stays a constructor call

Evidence

Measured on 14 open-source Kotlin repositories (base = current main), indexed with and without the change. The changed edges were hand-judged against the source in a stratified sample: 20 per strategy stratum, 366 edges.

Per edge type (parallel resolver, all 14 repositories):

type base this PR correct gained wrong added wrong removed correct lost
CALLS 169,089 169,349 2,702 5 2,420 27
CALL_REFERENCE 407 295 29 0 141 0
USAGE 292,816 293,119 303 0 0 0
TESTS (derived) 15,128 15,120 54 10 72 0

Another 9,817 CALLS keep their target and are now resolved by the Kotlin resolver instead of a name guess.

CALLS per repository (estimates from the judged strata):

repo added removed correct gained wrong added wrong removed correct lost dTP dFP unjudged
InsertKoinIO/koin 27 33 27 0 31 2 +25 -31 0
JetBrains/Exposed 182 127 182 0 121 6 +176 -121 0
Kotlin/kotlinx.serialization 91 83 91 0 82 1 +90 -82 0
android/nowinandroid 83 81 83 0 81 0 +83 -81 0
apollographql/apollo-kotlin 260 219 260 0 218 1 +259 -218 0
arrow-kt/arrow 32 31 28 4 26 5 +23 -22 0
detekt/detekt 37 36 37 0 36 0 +37 -36 0
google/ksp 70 17 70 0 16 1 +69 -16 0
gotson/komga 138 127 138 0 127 0 +138 -127 0
home-assistant/android 1120 1105 1120 0 1097 8 +1112 -1097 0
lysine-dev/okhttp 448 383 448 0 382 1 +447 -382 0
sqldelight/sqldelight 79 80 79 0 80 0 +79 -80 0
square/moshi 8 10 8 0 9 1 +7 -9 0
square/wire 132 115 131 1 114 1 +130 -113 0
total 2707 2447 2702 5 2420 27 +2675 -2415 0

Judged strata (op × strategy; C = correct, W = wrong, U = undecidable from the source):

op strategy edges C W U P(correct)
+ lsp_kt_method 2144 20 0 0 1.00
+ lsp_kt_any 242 20 0 0 1.00
+ lsp_kt_super 160 20 0 0 1.00
+ lsp_kt_this 84 20 0 0 1.00
+ lsp_kt_operator 66 20 0 0 1.00
+ suffix_match 5 0 5 0 0.00
+ lsp_kt_extension 2 2 0 0 1.00
+ lsp_kt_lambda_it 1 1 0 0 1.00
+ lsp_kt_constructor 1 1 0 0 1.00
+ lsp_kt_top_level 1 1 0 0 1.00
+ lsp_kt_iterator 1 1 0 0 1.00
- field_type_hint 1528 0 20 0 0.00
- suffix_match 728 0 18 2 0.00
- same_module 144 0 15 5 0.00
- lsp_kt_operator 24 20 0 0 1.00
- lsp_kt_method 13 0 13 0 0.00
- lsp_kt_any 6 0 6 0 0.00
- lsp_kt_this 3 3 0 0 1.00
- lsp_kt_lambda_it 1 0 1 0 0.00
  • Determinism: two runs per repository, identical edge sets on every edge type (14/14).
  • Sequential resolver (6 of the repositories): USAGE and CALL_REFERENCE changes are identical to the parallel ones. CALLS move in the same direction with the same strata. The par/seq symmetric difference shrinks from 7,317 to 5,691 edges.
  • Known residuals:
    • == on a project type that declares no equals no longer gets an edge to the kotlin.Any.equals builtin (24 edges; an external target, no project edge involved).
    • 5 suffix-match guesses remain where a bare call sits in an implicit-receiver shape the chain does not cover yet (arrow raise() in member extensions, apollo connectToAndroidComponent in a component lambda).

Tests

tests/test_kotlin_typed_calls.c runs one fixture through both resolvers (CBM_INDEX_SINGLE_THREAD=1 and the parallel path). Every shape in the table above has an assertion with a same-named decoy elsewhere. Each assertion checks the target and, where relevant, that a resolver strategy (not a name guess) produced it. On main the test fails for each of the original #1555 shapes. With only the receiver-chain step reverted, exactly its two assertions fail.

Local legs: macOS arm64, kotlin_typed_calls, kotlin_lsp, complexity, usages and the parallel suites; lint-ci (cppcheck 2.20, clang-format, NOLINT, memory-core).

The cross-file Kotlin walk resolved a call on a typed method parameter by
type, but a call on a class property - a primary-constructor val, a body
property or `this.prop` - fell to the name guess. The cross registry held
each own-file class from its extraction defs, whose properties carry no type
(constructor vals none at all), and the walk's typed AST registration of the
same class was added behind that entry under the same QN, so every lookup
returned the field-less one. Constructor injection is the common Kotlin
dependency shape, so `repository.inserir(...)` on an injected port bound to
whichever same-named method the guess found, including an implementation of
another port (#1555).

The walk now registers the file's own declarations into an overlay chained
to the cross registry and checked first, indexed once the top-level
declarations are collected. The sole-definer fallback walks the whole chain
so its candidate set is unchanged.

Typing more receivers exposed resolution gaps that the untyped receivers had
hidden; each would otherwise turn a typed call into a wrong name guess:
- a member of the receiver class wins over a same-named extension of the
  same file (Kotlin resolution order; the overlay holds both);
- `x.hashCode()` / `toString()` / `equals()` on a receiver typed by another
  file: the per-file pass emits the kotlin.Any placeholder, the cross pass
  the declared override, and two targets at one occurrence are ambiguous to
  the call join. The per-file placeholder is dropped when the cross pass
  resolves the same occurrence;
- the same members on a stdlib or JDK type (`count.toString()` on an Int)
  resolve to the kotlin.Any builtin instead of a seeded stdlib member that
  has no graph node;
- a property READ on a typed receiver whose QN the graph spells differently
  (a nested class's property) never becomes a CALL_REFERENCE to a
  same-named function: the reference join falls back by name to
  non-callable targets only, which binds the property's own node;
- a typed value without an `invoke` operator (`private val handshake:
  Handshake?`) no longer shadows the same-named member function;
- inside a lambda whose typed receiver becomes `this` (`repo.let { .. }`),
  and inside a member extension, the enclosing class's members stay
  callable bare (the implicit-receiver chain); a call naming a class stays
  a constructor call.

Fixes #1555

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
… up the Smart CI selection fix #2684)

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData
DeusData merged commit bddacfe into main Oct 18, 2026
40 checks passed
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.

Call resolution picks the wrong target (or none) for common method names in a Kotlin monorepo

1 participant