Skip to content

Kotlin: call on the result of an unqualified helper (repository().m()) resolves to a same-named decoy in another package #2805

Closed

Description

@mindcipherrr

Version: codegraph 1.6.2 (darwin-arm64), fresh codegraph init -y .

Related: #750 / #752 fixed Foo.factory().bar() for Kotlin. This is the neighbouring shape where the factory is an unqualified call (a same-class private helper, implicit this), plus its local-variable form. The helper declares its return type explicitly, so the information #752 relies on is there.

Repro

// src/main/kotlin/demo/domain/Repo.kt
package demo.domain

interface Repo {
    fun pending(id: Long): Int
}
// src/main/kotlin/demo/data/RealRepo.kt
package demo.data

import demo.domain.Repo

class RealRepo : Repo {
    override fun pending(id: Long): Int = 1
}
// src/test/kotlin/demo/testutil/FakeRepo.kt
package demo.testutil

import demo.domain.Repo

class FakeRepo : Repo {
    override fun pending(id: Long): Int = 2
}
// src/test/kotlin/demo/data/RealRepoTest.kt
package demo.data

class RealRepoTest {
    private fun repository(): RealRepo = RealRepo()

    fun viaConstructor() {
        val repo = RealRepo()
        repo.pending(1)
    }

    fun viaHelperLocal() {
        val repo = repository()
        repo.pending(1)
    }

    fun viaHelperChain() {
        repository().pending(1)
    }
}

codegraph init -y . && codegraph callees <fn>:

Function Call Expected Actual
viaConstructor val repo = RealRepo(); repo.pending(1) RealRepo::pending RealRepo::pending ✅
viaHelperLocal val repo = repository(); repo.pending(1) RealRepo::pending Repo::pending (interface) ⚠️
viaHelperChain repository().pending(1) RealRepo::pending FakeRepo::pending ❌
  • The wrong edge needs the decoy in a different package. With all three classes in one package, both helper calls land on the interface method instead (imprecise, but not wrong).
  • Same results with the calls inside a lambda (runTest { … }), for / if blocks, a block-bodied helper, and suspend functions.

Impact

callers RealRepo::pending misses the helper-based call sites, and callers FakeRepo::pending gains a call it never receives. So impact / affected on a repository implementation under-reports its tests and points at the fake. This is the common Android test setup: a private repository() builder in the test class, and a shared fake in a testutil package.

In a larger private Kotlin codebase (~1.1k Kotlin files) the local-variable form went further: val repo = repository(); for (…) { repo.m(…) } inside runTest { } produced no calls edge to m at all (the test's callees listed repository but not m). I couldn't reduce that variant: the toy project above resolves it to the interface. Treat it as an extra data point.

Expected

Infer the receiver type from the helper's declared return type for unqualified calls and for val x = helper() locals, the same way it already works for Foo.factory() (#752) and constructor-initialised locals. Per #750's invariant, when the type can't be validated, emit no edge rather than one to a same-named method on an unrelated class.

Happy to turn the repro into a fixture PR if that helps.

Activity

  1. colbymchenry commented on Oct 23, 2026

    @colbymchenry
    Owner

    Thanks for the clear report and the four-file example, @mindcipherrr.

    #2822 is merged to main and fixes both forms. With private fun repository(): RealRepo, both repository().pending(1) and val repo = repository(); repo.pending(1) now link to RealRepo.pending. That holds whether the helper is declared in the class, inherited from a base class or declared at the top level. When the declared type has no such method, the call now links to nothing instead of a same-named method elsewhere, and types from outside your project are still matched by name as before.

    It ships in the next release; re-index your Kotlin project to pick it up. If the val repo = repository() local inside runTest { for (…) { … } } in your larger codebase still shows no edge after that, a small snippet with that shape would help us track it down.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions