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.coverage → IConfig
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:
IConfig$Companion.class — the actual method body (fully covered by tests).
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.
Steps to reproduce
./gradlew test jacocoTestReport)gradle wrapper --gradle-version 8.12./gradlew test jacocoTestReportbuild/reports/jacoco/test/html/index.html→ navigate tocom.example.coverage→IConfigAdditional environment info:
Expected behaviour
IConfig.forUsershould be reported as covered. All 4 tests pass and directly invokeIConfig.forUser(...). The method body is fully executed during the test run.Actual behaviour
JaCoCo reports
IConfig.forUseras 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 inIConfig.kt). Lines 43–46 (the function declaration and return statement) have no coverage entry underIConfig.class— they appear only underIConfig$Companion.classwhere they are correctly shown as fully covered.Source:
IConfig.ktTest call sites (all Kotlin — 4 tests, all pass)
XML output —
IConfig.classentry (the bridge — 0% covered)XML output —
IConfig$Companion.classentry (real implementation — fully covered)XML output —
sourcefileentry forIConfig.ktjavapoutput for the bridge method onIConfig.classThe
LineNumberTablehas a single entry:line 47: 7. Only theareturnat 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
@JvmStaticinside an interface companion object, it generates two class files:IConfig$Companion.class— the actual method body (fully covered by tests).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 toIConfig$Companion.forUser(...), bypassing the bridge. Only a Java call site goes through it. All tests are Kotlin, so the bridge'sareturninstruction (mapped to line 47) is never executed.Relation to existing issues
$DefaultImplsfor default method bodies on JVM < Java 8, not@JvmStaticfactory methods@JvmStaticin an interface companionThe specific combination:
@JvmStatic+companion object+ interface (not class) + Kotlin-only callers.