Skip to content

fix(jvm): index Kotlin class properties + resolve same-name classes by import (#314) - #539

Closed
arttttt wants to merge 3 commits into
colbymchenry:mainfrom
arttttt:fix/kotlin-class-property-extraction
Closed

arttttt wants to merge 3 commits into
colbymchenry:mainfrom
arttttt:fix/kotlin-class-property-extraction

Conversation

@arttttt

@arttttt arttttt commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #314 / #472. #472 fixed same-name class disambiguation for Java call sites (@Autowired FooConverter f; f.convert() resolves to the imported FooConverter, not whichever was found first). The equivalent Kotlin path was still broken — and the root cause turned out to be three stacked gaps, only the last of which #472 addressed.

Probed the actual graph to confirm (not guessed): on a synthetic two-module Kotlin repro, fooConverter.convert() with import com.example.b.FooConverter resolved to com.example.a — and there was no field node for fooConverter at all, so inferJavaFieldReceiverType returned null and #472's preferredFqn machinery never even ran for Kotlin.

The three gaps

  1. Kotlin class properties weren't indexed. extractField handled Java variable_declarator, C# variable_declaration → variable_declarator, and PHP property_element — but Kotlin property_declaration parses as variable_declaration → simple_identifier (+ user_type), matching none of them (and the identifier-only fallback misses it too, since Kotlin's grammar has no field names). Result: zero field nodes for Kotlin classes, so a val x: Foo receiver's declared type was invisible to the resolver.
  2. Kotlin imports weren't parsed. extractJavaImports required a trailing ;, which Java mandates but Kotlin omits (import com.example.Foo). So Kotlin produced zero import mappings and the receiver-FQN disambiguation signal never fired.
  3. Disambiguation assumed file-name = class-name. Even with type + import known, resolveMethodOnType matched candidates by file-path suffix (com/example/b/FooConverter.kt) — fine for Java, but Kotlin commonly puts FooConverter in Converters.kt, so the suffix never matched. Now it prefers the candidate whose namespace-qualified name (com.example.b::FooConverter, from feat(jvm): resolve Java/Kotlin imports by fully-qualified name #412's package-directive wrapper) matches the import, falling back to the file-path suffix for Java's layout.

Validation

Synthetic repro (the disambiguation proof): two modules each declaring class FooConverter in Converters.kt. Swapping only the caller's import line swaps the resolved target — import …a.FooConverter → a::FooConverter::convert, import …b.FooConverter → b::FooConverter::convert. Import-driven, not lexical-order-driven. Before the fix both resolved to a.

Real repos (nickbutcher/plaid 258 .kt, android/nowinandroid 310 .kt), indexed on main vs this branch:

plaid main→fix nia main→fix
Kotlin field nodes 0 → 485 0 → 329
Kotlin methods 753 = 753 772 = 772
Kotlin→Kotlin calls edges 1118 → 1162 1110 → 1159

The field-extraction fix is decisively validated — hundreds of previously-missing field nodes, with no node/edge explosion (methods stable, +~4% call edges, not multiples). On nia the +49 edges include call sites now correctly resolving to their test doubles (NewsResourceDao::getNewsResources → TestNewsResourceDao::getNewsResources, UserDataRepository::… → FakeUserDataRepository::…) — a real precision gain that fell out of seeing the field's declared type.

Honest scope note: the import-FQN disambiguation (gap 3) is proven on the synthetic repro. Neither real repo contained a true same-name-class-across-packages collision reached through the import + field-receiver shape, so its real-world impact is unmeasured there — it's correct-by-construction and showed zero regressions (no spurious target changes), but I'm not claiming a measured win for it on these two repos. Gaps 1 & 2 are the real-repo-proven part.

Test plan

  • New extraction test: Kotlin property_declaration → field node with "<Type> <name>" signature (incl. nullable Foo? → Foo).
  • New resolution test: Kotlin same-name classes in non-eponymous files (Converters.kt) — call resolves to the imported package's class, not the lexically-first one.
  • Full suite green (tsc --noEmit clean; one pre-existing mcp-roots tinypool/Node-24 worker-crash flake, passes in isolation).
  • Real-repo before/after on plaid + nowinandroid (numbers above).

🤖 Generated with Claude Code

@arttttt
arttttt force-pushed the fix/kotlin-class-property-extraction branch from 5478eb1 to 5b4aba4 Compare May 29, 2026 19:46
arttttt and others added 3 commits June 7, 2026 23:16
Kotlin `property_declaration` inside a class parses as
`variable_declaration → simple_identifier (+ user_type)`, which matches
neither the `variable_declarator` shape Java uses, the C#
`variable_declaration → variable_declarator` wrapper, nor PHP's
`property_element` — and the identifier-only fallback misses it too
(Kotlin's grammar has no field names). The result: Kotlin class
properties produced NO node at all, so a `val x: Foo` receiver's declared
type was invisible to the resolver and `x.method()` calls couldn't be
typed.

Add a Kotlin branch to extractField that reads the name from the
`simple_identifier` and the type from its sibling, emitting a
`"<Type> <name>"` signature (matching the Java/C# field shape that
inferJavaFieldReceiverType already reads). Kotlin nullable `Foo?` is
normalized to `Foo` so it resolves like the non-null type.

Groundwork for same-name class disambiguation on Kotlin call sites (colbymchenry#314).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
extractJavaImports required a trailing `;`, which Java mandates but Kotlin
omits (`import com.example.Foo`). So Kotlin produced ZERO import mappings
and the receiver-FQN disambiguation signal (the `importedFqn` passed to
resolveMethodOnType) never fired — same-name classes fell back to lexical
order. Make the `;` optional; Java's mandatory semicolon still matches
unchanged, and a Kotlin `as Alias` suffix still captures the FQN (alias
unhandled, as in the existing Java path). Part of colbymchenry#314.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chenry#314)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arttttt
arttttt force-pushed the fix/kotlin-class-property-extraction branch from 5b4aba4 to 96a6fd1 Compare June 7, 2026 20:21
@arttttt

arttttt commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Rebased this branch onto current main — applies cleanly with no conflicts, even though main has since heavily reworked the same files (tree-sitter.ts, import-resolver.ts, name-matcher.ts). tsc --noEmit is clean and the tests pass (resolution.test.ts 61/61; both new tests green). The one error in extraction.test.ts is the pre-existing tinypool/Node-24 V8 worker crash — it reproduces on plain main too, unrelated to this change.

I re-checked that all three fixes are still relevant and not superseded by recent main:

  • Imports without a trailing ; — extractJavaImports's regex on main still requires ;, so Kotlin imports still produce zero mappings without this. Still needed.
  • Same-name disambiguation by namespace-qualified name — resolveMethodOnType on main still only matches by file-path suffix, no ::-qualified match. Still needed.
  • Kotlin property → field node — worth flagging: main now routes property_declaration to extractField via the new kotlin.ts config (fieldTypes/variableTypes), so the original framing ("nothing routes Kotlin properties to extractField") is now slightly stale. But extractField still can't parse the Kotlin variable_declaration → simple_identifier shape, so it produces no field node without the branch here. Verified by removing this block and re-running the test — it fails (expected undefined to be defined) on current main. So this fix is still required, and it now also completes that half-wired routing. Happy to update the PR description to reflect this if you'd prefer.

@colbymchenry would you mind giving this a review when you get a chance? 🙏

colbymchenry pushed a commit that referenced this pull request Oct 29, 2026
…when two share a name

When several classes share a name, resolveMethodOnType picks the one the
call file imports by matching the import's path against the candidate's
file path (com/example/b/FooConverter.kt). That holds for Java, where a
public class names its file, but a Kotlin class often lives in a file
named otherwise (FooConverter in Converters.kt), so no candidate matched
and the call went to whichever class came first.

The imported name is now matched against each candidate's qualified
name first: import com.example.b.FooConverter expects the method
com.example.b::FooConverter::convert, which the package directive gives
every JVM declaration whatever its file is called. The file-path match
stays as the fallback.

The Java calls this once fixed, a Java file calling a Kotlin class it
imports (okhttp's Java tests and mockwebserver3.MockWebServer,
SystemUI's BlurUtils), are now resolved on main by the receiver's
declared type (#1942), so on today's main the change shows in Kotlin.

From #539 we take only this change and its test. Its other two parts,
indexing Kotlin class properties and reading Kotlin imports without a
trailing semicolon, have since landed on main another way.

Validation:
- resolution.test.ts: the Kotlin same-name case (#314's shape in
  Converters.kt files) is red on main, green here.
- A/B against main 40f5851 (with the Java receiver typing of #1942
  and the Kotlin changes of #1933, #1761 and #2258): no edge changes
  on okhttp, SystemUI, nowinandroid, mihon, jextract, spring-modulith,
  Minestom, spring-ai, kestra-java, playframework, click or gin; none
  of them has the test's shape on a resolved call. Against main
  4eb8c2a, before #1942, it moved the 8 Java calls above onto the
  Kotlin class their file imports; main now reaches them at 0.9.
- Cost (mihon, median of 3 interleaved indexes, on main 4eb8c2a): CPU
  25.7 s on main vs 25.2 s, wall 8.2 s vs 8.3 s.

Original PR #539 by @arttttt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry

Copy link
Copy Markdown
Owner

Thanks @arttttt, and sorry this one waited so long! It landed in #3013, with you as the commit author: a Kotlin call now picks the class its file imports when two packages share a class name, even when that class lives in a file named something else, and your test covers it. Its other two parts had since landed on main another way, and the Java calls it also fixed are now handled by #1942, which types a Java call by its declared type, so we kept just the lookup change and its test.

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