Skip to content

fix: preserve Java and Kotlin anonymous inheritance - #1927

Closed
mixxer wants to merge 7 commits into
colbymchenry:mainfrom
mixxer:fix/java-kotlin-inheritance
Closed

mixxer wants to merge 7 commits into
colbymchenry:mainfrom
mixxer:fix/java-kotlin-inheritance

Conversation

@mixxer

@mixxer mixxer commented Sep 24, 2026 •

Copy link
Copy Markdown

Summary

Preserve Java anonymous classes and Kotlin object-literal members, resolve nested supertypes in source context, and resolve Kotlin property and chained receivers by declared type. Missing external types and ambiguous owners remain unresolved. The original Kotlin receiver commits and authorship from #1933 are retained.

Validation

  • Rebased on upstream 8a5ba3d02c833f205b5453305b47fffee0008a6b; validated head 3bfb25c07ef5d41df097e861786042b920c71927.
  • macOS portable backend: full suite passed on Node 22.20.0 and 24.19.0; 6,603 passed and 348 skipped on each runtime.
  • macOS native kernel: build passed; parity and relevant Java/Kotlin regression suites passed (1,299 tests).
  • Linux final-head CI: all 3 jobs passed; the workflow checks out this exact head SHA for Node 22/24 portable tests and native/language regressions.
  • Reviewed latest fix(kotlin): type receivers from declarations; parse fun interface #1933 head eda54a121cbc06b13a5f97d215f239e639a22b87: its receiver helpers match the implementation retained here; original authorship and receiver regressions remain preserved.

@mixxer
mixxer marked this pull request as ready for review September 24, 2026 04:32
@danusha2345

Copy link
Copy Markdown
Contributor

Checked 9a41129 against main (ba3c21e).

Build and tests: build:kernel, build, and tsc are clean. The full suite passes: 275/275 files, 4,736 tests, with CODEGRAPH_KERNEL_EXPECT=1 and --liftoff-only for the #1779 worker.

Edge diff, main vs this PR. Same sources indexed with both builds:

project change
Python (flask + a private repo), Go, Rust ×2, PHP (Slim), C# (MediatR) identical, no edge added or removed
method-as-value fixture: this.onClick, self.handle, this::fmt, ::twice, this.Tick across TS/Python/Java/Kotlin/C# identical, so the new calls-only guards don't drop these
playframework (its Java part) false inheritance removed: new play.api.mvc.Filter() {…} and new play.api.db.Database() {…} no longer bind to the Java play.mvc.Filter / play.db.Database, along with the override calls bridged through them. Nested supertypes now resolve (BodyParser.Text, Formatters.StringFormatConverter)
playframework .routes 16 route → method edges gone, all false: FooController.foo in test .routes resources had resolved to an unrelated Application::foo (no FooController exists in the repo). The other 500 route edges are unchanged. This is a side effect of matchMethodCall refusing non-calls refs
two Kotlin Android apps 309 of 315 removed edges come back as members of the new <…$anon@…> class (object-literal members re-nested). Calls into object : Stub() {…} implementations now land on those members

One regression. In a private Kotlin project, five test methods call a field typed as the concrete class:

private lateinit var probe: HttpProbeImpl   // implements the Probe interface
fun testA() { probe.probe("relay", "drone", "secret") }
fun testB() { … probe.probe(…) … }
fun tearDown() { probe.close() }
fun otherTest() {
    val fake = object : Probe { override fun probe(…) = …; override fun close() = Unit }
    …
}
  • On main those calls resolve to Probe::probe.
  • With this PR they resolve to ProbeTest::otherTest::<Probe$anon@108:19>::probe / ::close (resolvedBy: instance-method, confidence 0.65). That's the local object declared inside a different test function, which isn't reachable from testA/tearDown.
  • The typed path fails there for an unrelated reason. The name-only fallback then prefers a same-file candidate, and the newly extracted anonymous object is one.
  • I couldn't cut it down to a small repro: in a trimmed copy the typed path succeeds and the fallback isn't reached.

Suggested guard: treat members of an anonymous class that sits inside a function the way isLexicallyReachable treats nested functions (#1230). Keep them as candidates only when the ref originates inside that enclosing function. Today the check returns early for anything that isn't kind === 'function', so these method members pass through.

Everything else matches the numbers in the description. Nice split of #1872.

@mixxer

mixxer commented Sep 24, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed edge comparison and the concrete Kotlin example. I reproduced the name-only fallback in a focused regression test: before the fix, probe.probe in a sibling function chose the same-file otherTest::<Probe$anon@…>::probe method. The test also checks that a call inside otherTest can still choose that local method.

I pushed 4fedc76. isLexicallyReachable now checks the enclosing function for anonymous-class methods, and matchMethodCall excludes unreachable methods before its name-based, same-file ranking. The regression test fails on the old code and passes with the fix.

npm run build and npm run build:kernel pass. Focused resolution/Kotlin tests pass (234/234), and extraction.test.ts passes separately (656/656) with the kernel enabled. The kernel-enabled full run completed 274 files and 4,721 tests without an assertion failure, then hit the known #1779 V8 worker exit in extraction.test.ts; the PR description records that limitation.

I cannot access the private Kotlin corpus. Could you recheck the five probe/close call edges there? They should no longer target the anonymous object inside otherTest from sibling functions.

@danusha2345

Copy link
Copy Markdown
Contributor

Verified 4fedc76 on the same private Kotlin app, plus two others.

  • The reported case is fixed. All six calls (probe.probe(…) ×5, probe.close()) no longer land on otherTest::<Probe$anon@…> members. They are back to main's targets, so there's no regression vs main there.
  • It also removed 21 more false edges on that app that I hadn't reported. Calls from unrelated classes had been binding to onSuccess/onFailure of a CompletionCallback anonymous object inside another function, and to onFailure of a WebSocketListener object. On a second app, 14 test calls were going to read of an InputStream object declared inside a different test.
  • Where the name-only fallback still picks a target, it now picks the same one main does. The fallback's own guesses on Kotlin property receivers are a separate issue: fix(kotlin): type receivers from declarations; parse fun interface #1933 types those receivers from their declarations.
  • A third app: no edge changes vs the previous revision.
  • build:kernel, build, and tsc are clean. The full suite passes: 275/275 files, 4,737 tests.

LGTM from my side.

@mixxer

mixxer commented Sep 24, 2026

Copy link
Copy Markdown
Author

I repeated the post-4fedc76 differential on the pinned public Android 17/AAOS/AndroidX source corpus used earlier (frameworks/base 94b4c163b7df, hardware/interfaces 0162af698935, Car 9f04df65daa8, AndroidX 6cdbceb4ce99; Node 24.21.0, CODEGRAPH_KERNEL=0). Compared with the pre-fix #1927 index, both arms have 8,485 indexed files and 292,709 nodes. The new index has 1,687 removed and 696 added calls edges (net -991); 1,680 removed edges had targeted anonymous-object/class methods. The 240-file TypeScript control has identical nodes and edges, including metadata.

The private Kotlin repro is fixed per the recheck above. I found one separate, source-confirmed false edge in this public corpus: LegacyActivityStarterInternalImpl.kt:591 calls action.willRunAnimationOnKeyguard(), where action is a parameter of type ActivityStarter.OnDismissAction. main and the pre-fix #1927 index leave that call unresolved; 4fedc76 links it to OccludingAppDeviceEntryInteractor.kt:159, the method of an anonymous OnDismissAction constructed in a different file's fingerprintLockoutEvents.collect lambda. That particular object cannot be the call target. This appears to be residual name-only receiver matching, potentially related to the separately scoped #1933, rather than a failure of the reported sibling-function fix. I have not classified the other new edges as regressions.

Focused resolution.test.ts and kotlin-object-literal.test.ts pass 234/234 on 4fedc76. Sharing the concrete edge before merge so the receiver fallback can be scoped here or tracked with #1933.

@danusha2345

Copy link
Copy Markdown
Contributor

Reproduced with the three real files at 94b4c163b7df: LegacyActivityStarterInternalImpl.kt, OccludingAppDeviceEntryInteractor.kt and plugin/.../ActivityStarter.java. The edge depends on whether ActivityStarter.java is in the index:

build with ActivityStarter.java without it
main (ba3c21e) ActivityStarter::OnDismissAction::willRunAnimationOnKeyguard (0.9) no edge
#1927 (4fedc76) same as main <OnDismissAction$anon@153:20>::willRunAnimationOnKeyguard (0.7)
#1933 (b04da73) same as main no edge
#1927 + #1933 same as main no edge

My guess is that your corpus doesn't include packages/SystemUI/plugin. If so, the declared type ActivityStarter.OnDismissAction isn't defined anywhere in the index. The call then falls to instance-method matching, and the only class-like node whose name starts with OnDismissAction is the anonymous object.

#1933 already covers this: once the receiver's declared type is known and isn't a project type, it emits no edge. With both PRs applied, the result is the same as main's.

A narrower guard could also go in #1927, independent of #1933: don't let an $anon node stand in for its supertype's name when matching a receiver type. An anonymous object is never the declared type of a parameter.

@mixxer

mixxer commented Sep 24, 2026

Copy link
Copy Markdown
Author

Follow-up to the 4fedc76 Android corpus review: I separated the additional call-resolution findings into two PRs based directly on main:

These are independent of #1927 and #1933, so this PR's existing commits remain unchanged. Each new PR has a focused regression test, a passing build, and a passing standalone full suite.

@mixxer
mixxer marked this pull request as draft September 26, 2026 00:37
@mixxer

mixxer commented Sep 26, 2026

Copy link
Copy Markdown
Author

The three-file AOSP reproduction still shows a false edge on this PR alone when ActivityStarter.java is outside the indexed source set. #1933 removes it when composed, but this branch should not be treated as an independent precision-safe change yet. I am marking it draft while I check a narrow guard against valid anonymous-object calls; I will return it to review once that regression is resolved or the merge dependency is explicit.

@mixxer
mixxer force-pushed the fix/java-kotlin-inheritance branch from 4fedc76 to 58c04ea Compare September 26, 2026 01:28
@mixxer
mixxer marked this pull request as ready for review September 26, 2026 01:28
@mixxer

mixxer commented Sep 26, 2026

Copy link
Copy Markdown
Author

Rebased this PR onto #1933 (b04da739); the new head is 58c04ea. The earlier ActivityStarter.OnDismissAction false edge is absent on the combined head when the plugin Java file is outside the index, and resolves to the declared interface method when it is included. I verified both layouts with the three public AOSP files at 94b4c163b7df. The full Node 24/macOS suite passed (276 files; 4,752 passed, 11 skipped) with --liftoff-only; build and native kernel build passed. The PR description has the exact scope and notes that #1933 should merge first. Marking this ready for stacked review.

@mixxer
mixxer force-pushed the fix/java-kotlin-inheritance branch 2 times, most recently from ca3bb2e to 3bfb25c Compare October 11, 2026 10:13
@mixxer
mixxer force-pushed the fix/java-kotlin-inheritance branch 4 times, most recently from 7afee83 to e817803 Compare October 28, 2026 04:58
@mixxer
mixxer force-pushed the fix/java-kotlin-inheritance branch from e817803 to 0e8a08d Compare October 30, 2026 03:14
colbymchenry added a commit that referenced this pull request Oct 30, 2026
…ct expression is an anonymous class (#3051)

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>
@colbymchenry

Copy link
Copy Markdown
Owner

Thanks @mixxer! We landed this in #3051, reimplemented independently on main's current resolver, with you credited in the changelog. A supertype written through its outer type (Outer.Inner, Api.Listener) now links to the nested type in Java, Kotlin and C#, and a Kotlin object expression is indexed as an anonymous class whose overrides a call through the interface reaches. Your test cases, including header calls, by-delegates and nested object expressions, are in the new test file. Closing this in favor of #3051; it ships in the next release.

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.

3 participants