Skip to content

@JvmStatic in Kotlin interface companion object: synthetic Java-interop bridge reported as uncovered when called only from Kotlin #2096

@psancheti6666

Description

@psancheti6666

Steps to reproduce

  • JaCoCo version: 0.8.14 (shown in bottom-right corner of JaCoCo HTML report)
  • Operating system: macOS 15.3
  • Tool integration: Gradle 8.12 (bug is in JaCoCo bytecode analysis, not Gradle-specific — reproduced via ./gradlew test jacocoTestReport)
  • Complete executable reproducer: https://github.com/psancheti6666/jacoco-jvmstatic-interface-bug
  • Steps:
    • gradle wrapper --gradle-version 8.12
    • ./gradlew test jacocoTestReport
    • Open build/reports/jacoco/test/html/index.html → navigate to com.example.coverageIConfig

Additional environment info:

  • Kotlin: 2.1.20
  • JDK: OpenJDK 17

Expected behaviour

IConfig.forUser should be reported as covered. All 4 tests pass and directly invoke IConfig.forUser(...). The method body is fully executed during the test run.

Actual behaviour

JaCoCo reports IConfig.forUser as 0% covered (1 missed instruction, 1 missed line, 1 missed method) despite all 4 tests passing.

The missed line is the closing } of the method (line 47 in IConfig.kt). Lines 43–46 (the function declaration and return statement) have no coverage entry under IConfig.class — they appear only under IConfig$Companion.class where they are correctly shown as fully covered.


Source: IConfig.kt

interface IConfig {
    fun getBaseUrl(): String
    fun processUrl(url: String): String

    companion object {
        @JvmStatic
        fun forUser(isConsumer: Boolean): IConfig {            // line 43 — no entry in IConfig.class
            return ConfigProvider(                             // line 44 — no entry in IConfig.class
                enterpriseProvider = { EnterpriseConfig() },
                consumerProvider   = { ConsumerConfig() }
            ).provideFor(isConsumer)
        }                                                      // line 47 — mi="1" ci="0" (false miss)
    }
}

Test call sites (all Kotlin — 4 tests, all pass)

val config = IConfig.forUser(isConsumer = false)  // resolves to IConfig$Companion.forUser(...)
val config = IConfig.forUser(isConsumer = true)   // resolves to IConfig$Companion.forUser(...)

XML output — IConfig.class entry (the bridge — 0% covered)

<class name="com/example/coverage/IConfig" sourcefilename="IConfig.kt">
  <method name="forUser" desc="(Z)Lcom/example/coverage/IConfig;" line="47">
    <counter type="INSTRUCTION" missed="1" covered="0"/>
    <counter type="LINE"        missed="1" covered="0"/>
    <counter type="COMPLEXITY"  missed="1" covered="0"/>
    <counter type="METHOD"      missed="1" covered="0"/>
  </method>
</class>

XML output — IConfig$Companion.class entry (real implementation — fully covered)

<class name="com/example/coverage/IConfig$Companion" sourcefilename="IConfig.kt">
  <method name="forUser" desc="(Z)Lcom/example/coverage/IConfig;" line="43">
    <counter type="INSTRUCTION" missed="0" covered="8"/>
    <counter type="METHOD"      missed="0" covered="1"/>
  </method>
</class>

XML output — sourcefile entry for IConfig.kt

<sourcefile name="IConfig.kt">
  <line nr="43" mi="0" ci="6" mb="0" cb="0"/>
  <line nr="44" mi="0" ci="5" mb="0" cb="0"/>
  <line nr="45" mi="0" ci="5" mb="0" cb="0"/>
  <line nr="46" mi="0" ci="2" mb="0" cb="0"/>
  <line nr="47" mi="1" ci="0" mb="0" cb="0"/>
</sourcefile>

javap output for the bridge method on IConfig.class

public static com.example.coverage.IConfig forUser(boolean);
  descriptor: (Z)Lcom/example/coverage/IConfig;
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=1, args_size=1
       0: getstatic     #16   // Field Companion:Lcom/example/coverage/IConfig$Companion;
       3: iload_0
       4: invokevirtual #20   // Method IConfig$Companion.forUser:(Z)Lcom/example/coverage/IConfig;
       7: areturn
    LineNumberTable:
      line 47: 7

The LineNumberTable has a single entry: line 47: 7. Only the areturn at offset 7 carries a line number (the closing brace). Offsets 0, 3, and 4 have no line number entry and are not counted in the line coverage data.


Root cause

When Kotlin compiles @JvmStatic inside an interface companion object, it generates two class files:

  1. IConfig$Companion.class — the actual method body (fully covered by tests).
  2. IConfig.class — a synthetic static bridge for Java interop: return Companion.forUser(isConsumer).

A Kotlin call site (IConfig.forUser(...)) is resolved by the compiler directly to IConfig$Companion.forUser(...), bypassing the bridge. Only a Java call site goes through it. All tests are Kotlin, so the bridge's areturn instruction (mapped to line 47) is never executed.


Relation to existing issues

Issue Why this is different
#978 Concerns $DefaultImpls for default method bodies on JVM < Java 8, not @JvmStatic factory methods
#1905 Concerns binary-compat bridges from Kotlin 2.2.0; not the Java-interop bridge unconditionally emitted by @JvmStatic in an interface companion

The specific combination: @JvmStatic + companion object + interface (not class) + Kotlin-only callers.

Metadata

Metadata

Assignees

Labels

language: KotlinTickets about Kotlin language supporttype: bug 🐛Something isn't working

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions