Skip to content

fix(resolution): a caller of an imported expect/actual reaches the actual its source set holds (#3033) - #3136

Open
johnhldiver-beep wants to merge 1 commit into
colbymchenry:mainfrom
johnhldiver-beep:fix/3033-kotlin-expect-actual
Open

johnhldiver-beep wants to merge 1 commit into
colbymchenry:mainfrom
johnhldiver-beep:fix/3033-kotlin-expect-actual

Conversation

@johnhldiver-beep

Copy link
Copy Markdown

Problem

A Java call to a Kotlin Multiplatform object links the commonMain
expect declaration instead of the platform actual. An expect does not
exist in JVM bytecode, so a Java caller can only ever reach the actual.
Regression after #3023: at 01e0ac3 the same call linked the androidMain
actual. Real case: JakeWharton/timber's Timber.plant / Timber.tag
moved from androidMain into a commonMain expect.

Fixes #3033.

Root cause

resolveJavaImportedReference (the JVM import resolution #3023 added)
reads the imported declaration's fully qualified name through
jvmImportedDeclarations and picks its owner with pickClosestJvmCandidate.
That rule was written for the common-side Kotlin import ref case and
prefers expect when the evidence is tied. A Java caller from
app/src/main/java shares no source set with either declaration, so the
tie-break fires and picks expect.

Fix

In src/resolution/import-resolver.ts (+29 lines):

  • kmpSourceSet reads the src/<set>/ path segment, so a declaration
    knows which source set holds it.
  • jvmExpectActualPick: when the candidates for one declaration span
    expect and actual, the pick becomes caller-aware — a Java caller
    (JVM bytecode holds no expect) and a Kotlin caller outside
    commonMain take the actual (the one from the caller's own source
    set when several exist, otherwise by proximity); a commonMain
    caller keeps the expect.
  • The import itself (resolveJvmImport) still keeps its common-side
    resolution; only the owner pick changes.

No public API touched; no unrelated code reformatted.

Regression tests

New __tests__/kotlin-expect-actual.test.ts, 5 end-to-end cases: the
issue's Util.go repro (Java caller → androidMain actual), timber's
Timber.plant (companion member of the actual), the issue's
extends Timber.Tree (the actual's nested type), a Kotlin androidMain
caller in another package (own source set's actual), and a commonMain
caller keeping the expect.

Red-to-green on main: the two Java-caller cases failed before the fix
and pass after; the two Kotlin-caller cases already passed on main
and still pass. After the fix: 5/5.

Verification (disk- and log-verified)

  • npm run build green; npx tsc --noEmit clean after every edit.
  • JVM + Kotlin suites post-fix: jvm-cross-language-imports (the fix(jvm): a call through an import reaches the Java or Kotlin class it names #3023
    regression surface) + java/* + kotlin/* all green (51 + 51).
  • Full suite on base 2008e2f: 9856 passed / 8 failed / 439 skipped.
    All 8 failures are environmental or load flakes, proven unrelated to
    this diff: the 6 known ones from earlier the same day (gitignore x4,
    busiest-symbol x1, vitest-worker-flags x1, ui-package collect — all
    reproduced on bare main), plus mcp-daemon.test.ts x2 (socket-timing
    tests) which pass 24/24 in isolation on BOTH the branch and bare
    main.
  • Self-review caught and removed two leftover DBG console.error lines
    and a stray blank line; re-verified after cleanup (tsc, build,
    expect-actual + cross-import suites 13/13).
  • Upstream did not move during the work (base 2008e2f still current at
    filing); no rebase needed. One commit, 3 files, +167/-1.

RFC note

An RFC comment was posted on issue #3033 before implementation
(comment 6538257515, 2026-10-31) describing the reproduction, the root
cause in src/resolution/import-resolver.ts, and the planned fix. No
maintainer reply arrived after a full review round, so this PR is filed
for review with the RFC on record.

Risk

Callers that previously (wrongly) linked expect now link actual.
commonMain callers are unchanged by design. The change is gated to the
expect/actual owner pick only; ordinary Java/Kotlin import resolution is
untouched, and the #3023 regression surface stays green.

…tual its source set holds (colbymchenry#3033)

resolveJavaImportedReference (colbymchenry#3023) reads an imported Kotlin
Multiplatform declaration by qualified name, which returns BOTH the
commonMain expect and the platform actuals, and picks the owner with
pickClosestJvmCandidate — a rule written for a commonSide import ref,
which prefers an expect on a directory-proximity tie. A Java caller in
an app module shares no directory segments with either, so the tie
fired and the call landed on the expect — a declaration JVM bytecode
never contains — instead of the actual: timber's Java sample calling
Timber.plant(...) or extending Timber.Tree reached the commonMain
declarations.

The pick is now caller-aware: a Java caller, and a Kotlin caller in a
platform source set, take the actuals — the one in the caller's own
source set when there are several, proximity among them otherwise —
while a commonMain caller keeps the expect its compile sees. The
import-ref itself (resolveJvmImport) keeps the commonSide reading.
Kotlin callers in a source set the library holds were already right
through proximity; the tie-break was what betrayed callers outside it.

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.

Kotlin Multiplatform: a Java call to a Kotlin object links the commonMain expect instead of the platform actual

1 participant