Skip to content

fix(jvm): a call through an import reaches the Java or Kotlin class it names - #3023

Merged
colbymchenry merged 1 commit into
mainfrom
claude/jvm-cross-language-imports
Oct 29, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/jvm-cross-language-imports

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

A Java or Kotlin call through an import now reaches the class the import names, whichever of the two languages declares it: Kotlin's JUtil.make(), JUtil.Nested.deep() and Outer.Inner() on an imported Java class, and Java's KUtil.INSTANCE.kmake() on a Kotlin object, Factory.Companion.create() on a companion or UtilsKt.helper() on a Kotlin file's top-level function. A Kotlin extension declared in another package is now in scope only through an import of it or of its package.

Root cause

resolveJavaImportedReference matched an import against a file path built with the calling file's own extension (com/example/Foo.java from a Java file, .kt from a Kotlin one) and only took declarations in the caller's language, so an import across the two languages never bound. The call then fell to the name strategies: Kotlin's JUtil.make() went to a same-named other.JUtil, and Java's KUtil.INSTANCE.kmake() to another package's object. Nested types (JUtil.Nested.deep()), INSTANCE, Companion and FileKt were never walked at all. Separately, the instance-method and class-name strategies took a Kotlin extension (fun Modes.hidden(), indexed as Modes::hidden) as a member of Modes from any file, imported or not.

What changed (our implementation)

  • The import is read by qualified name across Java and Kotlin (import p.Outer.Inner is p::Outer::Inner); several declarations of the name (Kotlin Multiplatform) go to the closest. The rest of the reference walks the imported type's nested types; INSTANCE and Companion step onto the type, whose companion members the index keeps on the class. A call takes the member's method over a same-named field or nested class, the overload its argument count fits (the existing retargetSelfOverload), and from Kotlin a nested Java class's own constructor (Outer.Inner()), else the class. From Java, p.UtilsKt is the facade of p/Utils.kt: its top-level functions and properties, and the import links the file.
  • Only an imported type takes this path. An imported function or property (an extension among them) stays with the name strategies, which know a Kotlin call's receiver: an untyped chain's last call (x.toImmutableList()) is no call of an imported top-level function of that name, and a name that is a function and a type at once (Compose's fun AppBar(…) beside sealed interface AppBar) is called as the function.
  • A Kotlin chain rooted at an imported type (JUtil.Nested.deep()) is walked through the import before the receiver-chain typing, which only knows Kotlin types.
  • isKotlinExtensionInScope (name-matcher) drops an extension the calling file cannot see from resolveMethodOnType, the class-name strategies and the one-method guess, using the existing isKotlinTopLevelVisible; a member of a class in its file's package always passes.
  • The old file-path match stays as the fallback, now with either extension.

Reimplements #1948 by @mixxer, independently: the PR's diff and test were read, not run or merged. From its test we reused the cases (nested, unimportedExtension, crossFileExtension and the rest pass), plus the T5 triage fixtures jvm-interop and pr1948b.

Validation

Tests. __tests__/jvm-cross-language-imports.test.ts: 8 tests, all red on main, green here. The JVM resolution test files (23 files, 411 tests) pass.

A/B against main 86c85cff, fresh indexes with main's kernel, edges compared by site and target. The mains since, through the base 8bd55fa7, change no JVM resolution: 8bd55fa7 gives graphs identical to 86c85cff on jextract and nowinandroid, 4955eac9 on those and okhttp, where this change also makes the same sites:

repo edge sites added removed retargeted confidence raised
square/okhttp c317930 (Kotlin + Java) 172 0 30 167
LineageOS SystemUI (Java + Kotlin) 83 0 25 642
mihonapp/mihon d001e6f (Kotlin) 21 26 28 13
android/nowinandroid 2982e63 (Kotlin) 24 0 5 21
openjdk/jextract a2bc4e4 (Java) 96 96 18 96
spring-projects/spring-modulith e744475 (Java) 0 0 16 37
Minestom 704be78 (Java) 0 0 843 14
spring-projects/spring-ai b90fb6f (Java + Kotlin tests) 14 12 73 571
kestra-io/kestra bad4d5b (Java only) 0 0 302 76
playframework f3eb322 (Java + Scala) 0 0 37 89
pallets/click, gin-gonic/gin (controls) identical

Triage:

  • Added: Java calls into Kotlin companions and objects (SystemUI's PhysicsAnimator.getInstance ×22, okhttp's Java samples' RequestBody.create, MediaType.get, Certificates.decodeCertificatePem through @file:JvmName), Kotlin calls into Java classes (SystemUI's Assert.isMainThread ×15, Utils.useMediaResumption), nested Kotlin types written through their outer type (nowinandroid's NewsFeedUiState.Success ×20, mihon's CheckboxState.State.None, okhttp's Dns.Record.IpAddress), and type references across the two languages (okhttp's Java samples referencing Call, Response, Callback).
  • Retargeted: onto the imported class where a same-named one elsewhere was guessed (mihon's Page.State.Error was RefreshResult.Error; okhttp's Handshaker.Result.Failure was WebSocketRecorder.Failure), onto the method rather than a field or constant of its name (TlsUtil.localhost(), mihon's UiPreferences.timeFormat(…) companion function, jextract's Index_h.CXDiagnostic_Ignored()), onto the overload the argument count fits (checked by counting arguments and parameters: Minestom 799 of 843, kestra 290 of 302, spring-ai 50 of 73, playframework 33 of 37, none against; Minestom's other 44 are varargs overloads the count can't tell, Check.argCondition(cond, "msg {0}", x), read by hand, and a field or constant's name moved to its method), onto the provider class the file imports (spring-ai's ResponseFormat.builder() ×13 and MetadataField.text(…) ×9 had reached another provider's nested class), and spring-modulith's example modules, which each declare example.order.Order, now link their own module's class. Two calls on untyped GraphQL results in mihon (toTrack, toTrackSearch) move from one wrong extension to another.
  • Kind changed at the same place: spring-ai's Kotlin tests now call the Java constructors Prompt(…) and UserMessage(…) (12 sites, 0.9) where a framework rule recorded an instantiation at 0.7; from Kotlin, a class that declares its own constructor is reached through it, as main already did for SystemUI's InflationException(…).
  • Removed: guesses onto Kotlin extensions the calling file cannot see (mihon's key.toByteArray() on a String was JsonObject::toByteArray, f.use {} was Array::use, animatable.asState() was Preference::asState). The rest are kind changes at the same place, counted once as removed and once as added: jextract's 96 calls like Index_h.clang_getRange() were instantiations of a same-named holder class and now call the static method, and mihon's calls like CheckboxState.State.None and UpdatesUiModel.Item(…), which reached a wrong enum constant or method, now instantiate the nested class.
  • Confidence: hundreds of import-resolved calls keep their target at 0.9 instead of a name guess.

Cost (3 interleaved fresh indexes, medians): mihon process CPU 27.7 s on main vs 27.5 s, wall 8.6 vs 8.7 s; okhttp CPU 31.5 s on both, wall 10.0 vs 9.8 s.

Suite: CODEGRAPH_KERNEL_EXPECT=1 npx vitest run --maxWorkers=2 --minWorkers=1 on f3f7393f (Windows 11, kernel built; 2 workers under the day's memory rule): 780 files passed, 6 skipped; 9,301 tests passed, 85 skipped, 0 failed.

Follow-ups (not in this PR)

  • A Java or Kotlin import of a Scala type keeps the old file-path match: this change reads Java and Kotlin declarations only.
  • kestra's OssAuthEvent.builder() (a Lombok @Builder on a constructor, which the index doesn't synthesize) moves from one wrong target to another.
  • A Kotlin one-line nested class (class A { class B { fun f() {} } } on one line) indexes f as a top-level function and drops A and B in both engines: a grammar quirk, found while building the fixtures.

Reimplements #1948 by @mixxer.

🤖 Generated with Claude Code

…t names

A Java or Kotlin import was matched against a file path written with the
calling file's own extension (com/example/Foo.java from Java, .kt from
Kotlin), so an import across the two languages never bound: Kotlin's
JUtil.make() on an imported Java class went to a same-named class in
another package, JUtil.Nested.deep() and Outer.Inner() linked nothing,
and Java's KUtil.INSTANCE.kmake() went to another package's object.

The import is now read by qualified name in either language (the
package is some prefix of the name, so import p.Outer.Inner is
p::Outer::Inner), and the rest of the reference walks the imported
type's nested types: a Kotlin object's INSTANCE and a class's Companion,
as Java writes them, step onto the type, whose companion members the
index keeps on the class. Java's UtilsKt.helper() reaches the top-level
function of p/Utils.kt, and import p.UtilsKt links that file. A call
picks the member's method over a same-named field or nested class
(jextract's Index_h.clang_getRange() is the static method, not the
holder class of that name), the overload its argument count fits
(Minestom's NetworkBufferTemplate.template(a, g, b, h, ctor)), and, from
Kotlin, a nested Java class's own constructor. A Kotlin chain rooted at
an imported type is walked before the receiver-chain typing. Only an
imported type takes this path; an imported function or property is left
to the name strategies, which know a call's receiver.

A Kotlin extension declared in another package (fun Modes.hidden(),
indexed as Modes::hidden) is now a candidate only from a file that
imports it or its package, as Kotlin has it.

Validation:
- jvm-cross-language-imports.test.ts: 8 tests, all red on main, green
  here (cases from #1948's test and the T5 triage fixtures).
- A/B against main 86c85cf (the base, 8bd55fa, gives identical graphs
  on jextract and nowinandroid, as 4955eac did there and on okhttp),
  sites added/removed/retargeted, with confidence raised at the same
  target:
  okhttp +172/-0/30 (167 raised), SystemUI +83/-0/25 (642), mihon
  +21/-26/28, nowinandroid +24/-0/5, jextract +96/-96/18 (calls that
  were instantiations of a same-named holder class now call the static
  method), spring-modulith 0/0/16, Minestom 0/0/843 (799 onto the
  overload their argument count fits, none against it), spring-ai
  +14/-12/73 (50 overloads; 22 onto the imported provider's nested
  class), playframework 0/0/37, kestra 0/0/302 (290 overloads); click
  and gin identical. Added: Java calls into Kotlin companions and objects
  (SystemUI's PhysicsAnimator.getInstance, okhttp's RequestBody.create),
  Kotlin calls into Java classes, type references across the two.
  Removed: guesses onto extensions a file cannot see (mihon's
  JsonObject.toByteArray on a String) and calls that become
  instantiations of a nested type.
- Cost (3 interleaved fresh indexes): mihon CPU 27.7 s on main vs
  27.5 s, wall 8.6 vs 8.7 s; okhttp CPU 31.5 s on both, wall 10.0 vs
  9.8 s.

Reimplements #1948 by @mixxer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 787eafb into main Oct 29, 2026
@colbymchenry
colbymchenry deleted the claude/jvm-cross-language-imports branch October 29, 2026 20:09
inth3shadows added a commit to inth3shadows/codegraph that referenced this pull request Oct 29, 2026
Brings 4955eac..787eafb (colbymchenry#3014, colbymchenry#3017, colbymchenry#3020, colbymchenry#3018, colbymchenry#3023). Clean
auto-merge. README: drop colbymchenry#2871 (closed upstream; colbymchenry#2934 merged in its
place, and name-matcher already matches upstream).
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.

1 participant