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 anonymous Java classes and Kotlin object-literal members in both extraction backends, resolve nested supertypes within their source/package context, and resolve Kotlin calls through declared property types. Missing external types and ambiguous anonymous owners remain unresolved instead of creating unrelated edges.

This branch includes the Kotlin receiver work from #1933 with its original authorship. It is tested independently on current upstream; the preferred merge order or treatment of overlapping #1933 commits remains a maintainer decision.

Validation

  • Rebased on upstream 6560052a6f856855d3f71eee838fd66ccfa4285d; tested head fb7ff1df904955bcdec12da26cf8638060603f69.
  • macOS / Node 24.21.0: build passed; full suite: 459 files; 5,997 passed, 28 skipped (native kernel built).
  • TypeScript language server: no new diagnostics relative to upstream; changed source files have no errors or warnings. Existing upstream test diagnostics are tracked separately.
  • Rust language server healthy with no diagnostics.
  • Linux CI: all 11 jobs passed, including this exact PR head on Node 24. Native kernel checks also passed for fix: preserve Java and Kotlin anonymous inheritance #1927 and fix(java): resolve calls through static fields #1949; integrated main passed on Node 22 and 24.
  • Public AOSP Java/Kotlin extraction: 7,062 files matched byte-for-byte; 109 files deferred to WASM; no differing native results across 7,171 files. The 240-file TypeScript control produced byte-identical full graph dumps (SHA-256 5685cc97ba8ed8e08b81658b526b46485c3c4fe3e0ea2d4307f2d27855445e25).
  • The reported LegacyActivityStarterInternalImpl call remains unresolved without the plugin declaration and resolves to ActivityStarter.OnDismissAction.willRunAnimationOnKeyguard when the declaration is indexed.

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

danusha2345 and others added 5 commits October 5, 2026 00:13
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>
danusha2345 and others added 10 commits October 5, 2026 00:14
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>

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