Skip to content

fix: preserve Java and Kotlin anonymous inheritance - #1927

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

mixxer wants to merge 15 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 from 1ea3c1c to ca3bb2e Compare October 10, 2026 13:07
danusha2345 and others added 15 commits October 11, 2026 19:04
inferJavaFieldReceiverType reads a field's type from its signature, in the
Java shape `Type name`. Kotlin properties are indexed without a signature, and
primary-constructor properties (`class A(private val repo: Repo)`) are not
indexed at all, so every Kotlin `prop.method()` got no type and fell through to
name-only guessing: the interface method, or any same-named method elsewhere
(`probe.close()` on a `lateinit var probe: HttpProbe` resolved to an unrelated
class's `close`).

For Kotlin the type is now read from the declaration: the property's own lines
(`name: Type`, or `name = Type(…)`), or the class header before its first
member for a constructor property. A nested type keeps its outer type
(`HardwareLock.Lease` → `HardwareLock::Lease`), so it matches that `Lease` and
not another class's. resolveMethodOnType still validates the method.

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

Two more receivers the Kotlin resolver could not type:
- A local or property bound to a call (`val lease = HardwareLock.tryBegin()`,
  `private val schema = Checker.load()`) is typed by the callee's declared
  return type, from its signature. A return type nested in the callee's
  owner keeps it (`Lease` in `HardwareLock` → `HardwareLock::Lease`).
- A receiver whose type the project does not declare (`Regex`,
  `Properties`, a call on `Executors`) now gets no edge. The name-only
  fallback used to bind it to any project method of the same name: a
  `regex.find()` to an unrelated `find`, `socket.connect()` to a
  `connect` elsewhere, a `close()` to itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…railing-lambda constructors

More Kotlin receivers the resolver could not type, found on real apps:
- A `val` property is indexed as a `constant` and a companion-object
  property as a `variable`; the property lookup only took `field`.
- `var mode = Mode.ON` is typed by its enum (project enums only; `Limits.MAX`
  is an Int).
- `val t = Thread { … }` is a constructor too (trailing lambda, no parens).
- `val old = current ?: return` / `current!!` / `val x = current` take the
  aliased value's type; `requireNotNull(x)` / `checkNotNull(x)` take x's.
- A typed parameter of the enclosing function or overridden callback
  (`onOpen(webSocket: WebSocket, …)`) is used when nothing else names one.
- Known stdlib factories (`listOf`, `mutableMapOf`, `lazy`, `thread`, …)
  return a library type; any other library function leaves the type unknown.
A library type still gets no edge: `thread.join()`, `process.destroy()`,
`webSocket.send()` no longer bind to project methods (or a test fake) of the
same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Kotlin call through a receiver chain (`engine.pump.drain()`,
`this.engine.drain()`, `a?.b?.c()`) was extracted as the bare method
name, so it could only be name-guessed: a same-named method in the
caller's file, or any project method when the chain ended in a library
type (`runtime.reconnectTask?.cancel()` on a `ScheduledFuture`).

The extractor (wasm path and kernel, in parity) now keeps a chain of up
to four identifier segments. The resolver types the first segment as a
single receiver (`this` is the enclosing class, a type name its object
or companion), then each next segment as a property or enum entry
declared in the class of the type before it, read from that class's own
file. A typed chain resolves on its type; a chain through a library
type gets no edge unless the project declares an extension of that name
on a library type; an untyped chain resolves as the bare method name,
exactly as before. A constructor call with type arguments
(`Crate<Int>()`, `mutableListOf<T>()`) now types its value too.

The alias test now picks the anonymous object's `onOpen` and ignores
synthesized override edges, so it holds once object-literal members are
extracted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nonymous objects

A receiver (or chain segment) that the enclosing class does not declare is
looked up in its project supertypes, read from each class header (breadth-
first, at most four levels); a library or ambiguous supertype is not walked,
so the receiver stays unknown and keeps the name-only resolution.

The local declaration scan continues from a function nested in another (an
anonymous object's member, a local function) into the outer function above
it, skipping sibling functions whose locals are not visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mixxer
mixxer force-pushed the fix/java-kotlin-inheritance branch from ca3bb2e to 3bfb25c Compare October 11, 2026 10:13

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