Skip to content

fix(jvm): a dotted supertype names its nested type, and a Kotlin object expression is an anonymous class - #3051

Merged
colbymchenry merged 1 commit into
mainfrom
claude/jvm-dotted-supertypes-object-literals
Oct 30, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/jvm-dotted-supertypes-object-literals

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

A supertype written through its enclosing type now links to that nested type: Java's class Named extends Outer.Inner, implements Api.Listener and new Api.Listener() { … }, Kotlin's class K : Outer.Inner() and C#'s class N : Outer.IListener, when the file can name the outer type. A Kotlin object expression, object : Listener { … }, is now indexed as an anonymous class, as Java's new Listener() { … } is, so a call through the interface reaches its overrides. An anonymous class's members, Java's and Kotlin's, are now named only inside the declaration that holds the class, and a method a Java or Kotlin class inherits through a nested supertype is that type's (a builder's build() from its own AbstractBuilder), not a namesake's.

Root cause

  • Java and C# kept the dotted path in an extends/implements reference, but matchByQualifiedName compared it verbatim (Outer.Inner against p::Outer::Inner, whose scope boundary is ::), so it stayed unresolved.
  • A Java anonymous class's extends reference was cut to the last segment (Listener for new Api.Listener() {…}) in both engines (extractAnonymousClass, java.rs), a name no nested type is found by.
  • Kotlin's supertype reference took the FIRST segment of a dotted path (kotlin.rs, and the wasm extractor), so class K : Outer.Inner() extended Outer.
  • resolveMethodOnType's walk up the supertypes asks each by its simple name (context.getSupertypes), so a method inherited through a nested supertype was looked up on every type of that name: spring-ai's OpenAiChatOptions.builder()…build() reached an MCP annotation's AbstractBuilder.build.
  • Neither engine handled object_literal: an object expression's overrides became local functions of the code around it, which no call through the interface reached, and its properties made no nodes.

What changed (our implementation)

  • Resolution (matchDottedSupertype, name-matcher): a dotted Java, Kotlin or C# inheritance reference resolves to the type whose qualified name ends with the path (or equals it, written in full: com.x.Outer.Inner). Unless written in full, the path's first type must be one the file can name: Java its package, a single-type import or an on-demand import; Kotlin its package, an import or a star import; C# its namespaces and usings (isCsharpTypeVisible). Of several, the one closest in the tree, and none on a tie. Other languages are untouched, so MyBatis's mixed dotted names keep their matching. A Kotlin object expression's instantiates goes the same way, and like an extends reference takes only a type that can be a supertype (okhttp's object : CookieJar is no OverrideParam.CookieJar enum entry).
  • Inherited methods (jvmSupertypeChains, in resolveMethodOnType's supertype walk, Java and Kotlin): each supertype is asked first by the chain of types that names it, package dropped (AssistantMessage::Builder), then by its simple name as before, so an extends edge resolved to a namesake (Minestom's Player extends LivingEntity to a nested EntityStatuses.LivingEntity) still reaches Entity.getPosition through the name.
  • Extraction, both engines (tree-sitter.ts, java.rs, kotlin.rs): a Java anonymous class's extends reference keeps the written path, type arguments cut; Kotlin joins every segment of a supertype's user_type (Outer.Inner, type arguments left out); a Kotlin object_literal with a body becomes a class <Listener$anon@line> (named after the class it builds, else its first supertype), with an extends reference per supertype, an instantiates reference from the code around it to that supertype as written (resolved by matchDottedSupertype too, so object : mockwebserver3.Dispatcher() is not okhttp3.Dispatcher), and its body's declarations as members. Its header (constructor arguments, a by delegate) is walked first, in the enclosing scope, by the walker that met it.
  • Scope (isLexicallyReachable): a member of an anonymous class (its qualified name passes through <T$anon@N>) is reachable by name only from the declaration around line N that holds the class (found by its qualified name, else, for a Kotlin extension function named by its receiver like Call::asObservable, as the innermost declaration around that line). Without this, mihon's block() calls on a lambda parameter reached an object : MarkdownPadding's override val block at 0.9.
  • The code in an object expression keeps the scope it is written in, as when its overrides were local functions (each found by the A/B, each with a test case):
    • a receiver that is a property of the class around the object keeps its type: inferJavaFieldReceiverType and inferMemberReceiverType walk out past anonymous classes, an object's own property first; kotlinReceiverDeclaredType reads inherited properties from the first named class. okhttp's server.url() in an object : Callback had become a 0.65 guess at the deprecated okhttp3.mockwebserver.MockWebServer.
    • an override is not what a call of its own name inside it means (lexicalKotlinMembers): original!!.contentLength() in override fun contentLength(), this@MediaCarouselScrollHandler.onScroll(…), an extension receiver's onDataChanged(…). It stays a candidate, so a call that lands on it is still declined as a collapsed receiver or a super. call.
    • an object's member outside its reach no longer makes a by-name guess unsure (scopedOut in the method-by-name strategy, Kotlin only): okhttp's it.isReady() is still the SerialTask interface's.

Reimplements #1927 by @mixxer, independently: the PR's diff and tests were read, not run or merged. We reused its cases (header calls in the enclosing scope, by delegates, generic outer and inner segments, nested object expressions) and the T5 triage fixtures (pr1927, pr1927b, anon-dispatch, dotted-super/k, dotted-super/cs). T5's "found while verifying" items 2 (Kotlin's first segment) and 3 (C#'s unresolved dotted base) are fixed here.

Validation

Tests. __tests__/jvm-dotted-supertypes.test.ts: 13 tests; 11 fail on main 60b0d7a9 (the other two guard what must not change: a dotted supertype the file cannot name, and calls inside an anonymous class), all 13 pass here on the wasm path and on the rebuilt kernel.

Kernel parity (scripts/kernel-parity.mjs, the rebuilt kernel against the wasm path), 0 files with diffs: torture.kt 1/1 (137 nodes, with object expressions and dotted supertypes added), Torture.java 1/1 (a dotted anonymous class added), okhttp Kotlin 588/619 and Java 71/71, mihon 891/940, nowinandroid 340/350, SystemUI Kotlin 188/198 and Java 1141/1141, jextract 218/218, Minestom 1859/1894. The deferred files are the ones main's kernel defers, a parse error in the file.

A/B against main 60b0d7a9 (its kernel's Java and Kotlin code is unchanged since 787eafbf), both on their kernels, --no-ref sites (a call's position, not its name, so a local function that became an object's method is a rename), every changed site sorted by tools/triage-1927.cjs:

Repo (languages) Sites added / removed / retargeted Confidence only Same place, renamed
okhttp (Kotlin, Java) +640 / −53 / 149 17 10
SystemUI (Java, Kotlin) +435 / −53 / 116 62 10
Minestom (Java) +165 / −3 / 6 4 0
kestra (Java) +108 / 0 / 6 10 0
spring-ai (Java, Kotlin) +46 / 0 / 38 4 0
playframework (Java, Scala) +27 / −3 / 13 0 0
mihon (Kotlin) +22 / −5 / 6 2 1
jextract (Java) +22 / 0 / 1 0 0
spring-modulith (Java) +9 / 0 / 10 23 0
nowinandroid (Kotlin) 0 / 0 / 0 0 1
serilog (C#), click, gin identical
  • Added. The object expressions and what links to them: an extends or implements per supertype (okhttp 262, SystemUI 86 counting Java's anonymous classes, Minestom 40, mihon 6, jextract 3), the instantiates from the code around each object, which is that object's own supertype in every case (okhttp 261/261, SystemUI 62/62, mihon 6/6), and dispatch from an interface or base method to the object's override (okhttp 63, SystemUI 86). A named class's dotted supertype (SystemUI 64, Minestom 44, kestra 69, spring-ai 13, playframework 20, jextract 7, spring-modulith 1), its dispatch (okhttp 27, SystemUI 115, Minestom 68), and the calls a dotted supertype now types: SystemUI's super.toString() / equals() / copyFrom() in MobileState extends SignalController.State, spring-modulith's 8 through TargetEventPublication extends org.springframework.modulith.events.EventPublication, Minestom's 8 through NetworkBuffer.Type. Wrong among them: 5 okhttp calls at 0.4 that surfaced because an object's member, formerly a local function that won the name match and was then declined, no longer competes (response.body.source() → ForwardingResponseBody.source ×3, a contentLength, a field source), and cacheBody.close() → ResponseBody.close at 0.65 on an okio sink; 7 more of that kind (response.body.close() → ResponseBody.close) are right.
  • Removed. Guesses onto an object's member from outside the declaration that holds it, which were local functions of other functions (okhttp 28, SystemUI 13, mihon 2, Minestom 1); an extends that is now an implements of the nested interface (okhttp 12, SystemUI 9, mihon 3, Minestom 2: new NetworkBuffer.Type<>() {…} had extended the enum ScoreboardObjectivePacket.Type); dispatch a first-segment supertype had made up (okhttp 5: RealConnection : Http2Connection.Listener() "extended" Http2Connection; SystemUI 17: MediaDataManager.Listener, QSPanel.QSTileLayout); Java anonymous classes written with a library type's nested type, which had been guessed onto a project Callback or Listener (SystemUI 14: new MediaController.Callback()); okhttp's 8 toString() guesses on HttpUrl, Int and socket values. playframework's 3: two Java anonymous classes written new play.api.mvc.Filter() {…} and new play.api.db.Database() {…} inside the Java types of the same name had "extended" those enclosing types (wrong; the Scala traits they name carry no package in their qualified names, so nothing links now), and one right edge is lost: new javaguide.testhelpers.MockJavaAction(…) names a Scala class the same way.
  • Retargeted. A dotted supertype from its first segment to the nested type (okhttp 21, SystemUI 6, mihon 2); Java anonymous classes from a guess by the last name to the written nested type (SystemUI 31: new ScrimController.Callback(), new Interaction.Callback()); dispatch fan-outs that now include object overrides or lose a first-segment impostor (okhttp 101, SystemUI 72, Minestom 6, mihon 2); a bare call in an object's method to its sibling member (SystemUI's MoveHelper.canMoveAfter, a local ContentObserver's onChange); jextract's type.equals() on a Type.Function field, Type.equals now (was Declaration.equals, through the namesake Declaration.Function); spring-modulith's 10 typed through the resolved EventPublication (0.65 guesses → 0.9). spring-ai: 33 builder chains (OpenAiChatOptions.builder()…build(), .clone(), .combineWith(), LegacySubclass.builder().content(…)) and 5 dispatch fan-outs now reach the builder their class inherits (OpenAiChatOptions.AbstractBuilder, AssistantMessage.Builder) instead of a namesake in another module (AbstractMcpPromptListChangedMethodCallback.AbstractBuilder.build, OllamaApi.Message.Builder.content); kestra 6 and playframework 13 dispatch fan-outs gain implementers whose dotted supertype now links (Formatters.SimpleFormatter). Wrong to wrong: okhttp 24 guesses that went to a local function of another file now go to another candidate at the same confidence, 3 close() on library streams; SystemUI 3 setTitle (0.7 → 0.5).
  • Confidence only. okhttp 17 bare close() in RealWebSocketTest 0.7 → 0.9 (a namesake local function left the ranking); spring-modulith 23 raised to 0.9 through the resolved supertype; SystemUI 62, mostly anonymous classes' supertypes now found through the import or the written path at the same or higher confidence, and 2 calls in a test object 0.7 → 0.65 on the same target.
  • Renamed (same place): calls of an object's override that was a local function (okhttp 10, SystemUI 10, mihon 1, nowinandroid 1).

Cost (median of 3 interleaved index runs, tools/cputime.ps1): okhttp CPU 27.8 → 29.0 s, wall 8.6 → 8.8 s (it gains 360 object classes, 702 methods and 1,491 edges); mihon CPU 23.6 → 23.8 s, wall 7.8 → 7.7 s.

Suite: the full suite on this commit (7c863e4b, main 3a4cd3bf, kernel rebuilt there), CODEGRAPH_KERNEL_EXPECT=1, CODEGRAPH_NO_DAEMON unset, --maxWorkers=4: 792 test files passed, 6 skipped; 9,506 tests passed, 0 failed, 85 skipped (770 s). main has since moved to 4e7a2210 (#3044, dependency locks only); git merge-tree with it is clean.

Follow-ups (not in this PR)

  • Java's new Outer.Inner() still instantiates by the last name (Inner), as before; the Kotlin object expression's now uses the written path.
  • A Java supertype written with a Scala type's package (new javaguide.testhelpers.MockJavaAction(…), new play.api.mvc.Filter() {…}) links nothing: Scala qualified names carry no package, so the written path matches none (playframework's one lost edge).
  • Two anonymous classes of the same type on one line share a name and id (<T$anon@line>), as Java's always have.
  • A Kotlin one-line nested class (class A { class B { fun f() {} } }) indexes f as a top-level function and drops A and B in both engines (a grammar quirk).

Reimplements #1927 by @mixxer.

🤖 Generated with Claude Code

…ct expression is an anonymous class

A supertype written through its enclosing type was lost in three ways.
Java and C# kept the dotted path (class Named extends Outer.Inner,
class N : Outer.IListener), but the qualified-name match compared it
verbatim with p::Outer::Inner, so it stayed unresolved; a Java anonymous
class (new Api.Listener() {...}) kept only the last segment, which no
nested type is found by; and Kotlin (both engines) took the FIRST
segment, so class K : Outer.Inner() extended Outer. A Kotlin object
expression, object : Listener {...}, made no class at all: its overrides
became local functions of the code around it, which a call through the
interface never reached, and bare calls elsewhere could be guessed onto
them.

A dotted Java, Kotlin or C# supertype now resolves to the type whose
qualified name ends with the path, when the file can name the path's
first type (its own file, package or namespace, an import; C#'s usings);
of several, the one closest in the tree, or none on a tie. The Java
anonymous class keeps the written path in its extends reference, and
Kotlin joins every segment of a supertype (type arguments left out), in
the wasm extractor and the kernel alike. A Kotlin object expression is
indexed as Java's anonymous class is: a class <Listener$anon@line> that
extends each supertype as written, which the code around it
instantiates (the class it builds, else its first supertype, also as
written and only ever a type that can be a supertype), with its body's
declarations as members; its header (constructor arguments, a
by-delegate) stays the enclosing code's. An anonymous class's members,
Java's and Kotlin's, are named only inside the declaration holding the
class: a bare block() elsewhere is never an object's override val block.

The walk up a Java or Kotlin class's supertypes for an inherited method
asks each supertype by the chain of types that names it
(AssistantMessage::Builder) before its simple name, so a builder's
build() is its own AbstractBuilder's and not an MCP annotation's;
the name still answers when an extends edge reached a namesake
(Minestom's Player extends LivingEntity).

The code in an object expression still sees the scope it is written in,
as when its overrides were local functions: a property of the class
around the object types a receiver there (okhttp's server.url() in an
object : Callback is MockWebServer's), an override is not what a call
of its own name in it means (original!!.contentLength(),
this@Outer.onScroll(), an extension receiver's onDataChanged()), and an
object's members out of reach leave a by-name guess as it was
(okhttp's it.isReady() is still the SerialTask interface's).

Validation:
- jvm-dotted-supertypes.test.ts: 13 tests, 11 fail on main 60b0d7a
  (the other two guard a dotted supertype the file cannot name and the
  calls inside an anonymous class), all pass here.
- Kernel parity, 0 files with diffs: torture.kt 1/1, Torture.java 1/1,
  okhttp 588/619 Kotlin and 71/71 Java, mihon 891/940, nowinandroid
  340/350, SystemUI 188/198 Kotlin and 1141/1141 Java, jextract 218/218,
  Minestom 1859/1894 (deferrals are main's).
- A/B against main 60b0d7a, sites added/removed/retargeted: okhttp
  +640/-53/149, SystemUI +435/-53/116, Minestom +165/-3/6, kestra
  +108/-0/6, spring-ai +46/-0/38, playframework +27/-3/13, mihon
  +22/-5/6, jextract +22/-0/1, spring-modulith +9/-0/10, nowinandroid
  0/0/0; serilog, click, gin identical.
- Cost (median of 3 interleaved): okhttp CPU 27.8 -> 29.0 s, wall
  8.6 -> 8.8 s (its 360 object classes and 1,491 more edges); mihon
  23.6 -> 23.8 s, wall 7.8 -> 7.7 s.

Reimplements #1927 by @mixxer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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