Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ See [README.md](README.md) for the format and routing rules.

## Project (durable context & rationale)

*(no entries yet)*
- [codegen-coverage-blind-spot](project/codegen-coverage-blind-spot.md) — Why the `java`/`context` codegen modules report ~0% coverage — they run out-of-process in the forked Spine Compiler JVM, which JaCoCo/Kover do not instrument.

## Reference (external systems)

Expand Down
32 changes: 32 additions & 0 deletions .agents/memory/project/codegen-coverage-blind-spot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: codegen-coverage-blind-spot
description: Why the `java`/`context` codegen modules report ~0% coverage — they run out-of-process in the forked Spine Compiler JVM, which JaCoCo/Kover do not instrument.
metadata:
type: project
since: 2026-07-06
---

The `java` and `context` modules are Spine Compiler plugins/renderers (`JavaValidationPlugin`,
the `*Generator` classes, the `expression/*` builders, the ProtoData plugin). They execute in the
**forked `launch*SpineCompiler` (`JavaExec`) process** during the build, not in the `test` JVM.
Kover/JaCoCo instrument only the test JVM, so this code reports **~0% line coverage** even though
the `tests/*` and `context-tests` `.proto` fixtures exercise it on every build. The lone generator
with real coverage — `UnsignedIntegerWarnings` (~82%) — has it because one spec instantiates it
**in-process**. This is the main reason the repo total sits around ~12%.

**Why:** So a future session seeing an alarmingly low total, or a codegen module at 0%, does not
misread it as neglected/untested code or a quality regression and waste time re-deriving the cause.
It is a measurement blind spot inherent to out-of-process code generation, not missing tests.

**How to apply:** When coverage looks structurally low here, do **not** reach for sibling
attribution (`SiblingCoverage.creditTestCoverageFrom`) or expect root Kover aggregation to fix it —
those re-attribute execution data that already exists in-process, and for compiler-executed code no
such data exists anywhere. Closing the gap requires **instrumenting the forked compiler JVM**:
attach the standalone JaCoCo agent to the `launch*SpineCompiler` tasks and merge the resulting
`.exec` via `KoverConfig`'s `additionalBinaryReports` — the same pattern
`io.spine.gradle.testing.enableTestKitCoverage()` already uses for Gradle TestKit workers. The
JVM-args/agent hook is best owned by the `SpineEventEngine/compiler` repo (the launch tasks are
`JavaExec`, so `jvmArgs` is the natural injection point; the `compiler-fat-cli` does **not**
relocate plugin classes, so JaCoCo class IDs match and `additionalBinaryReports` credits them
correctly). Complement with in-process unit tests for logic-heavy
generators, and report codegen vs. runtime under separate Codecov flags/components.
4 changes: 2 additions & 2 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2024, TeamDev. All rights reserved.
* Copyright 2026, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -43,6 +43,7 @@ import io.spine.gradle.repo.standardToSpineSdk
import io.spine.gradle.report.coverage.KoverConfig
import io.spine.gradle.report.license.LicenseReporter
import io.spine.gradle.report.pom.PomGenerator
import io.spine.gradle.testing.enableSpineCompilerCoverage

buildscript {
standardSpineSdkRepositories()
Expand Down Expand Up @@ -140,5 +141,14 @@ allprojects {
}

KoverConfig.applyTo(rootProject)

// Attach the JaCoCo agent to the forked Spine Compiler JVMs so that the
// out-of-process execution of code-generation plugins (the `java` and `context`
// modules' renderers and generators) is credited to the root coverage report.
// A no-op for modules that run no `launch*SpineCompiler` task.
subprojects {
enableSpineCompilerCoverage()
}

LicenseReporter.mergeAllReports(project)
PomGenerator.applyTo(project)
8 changes: 8 additions & 0 deletions buildSrc/src/main/kotlin/io/spine/dependency/test/Jacoco.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,12 @@ package io.spine.dependency.test
@Suppress("ConstPropertyName")
object Jacoco {
const val version = "0.8.15"

/**
* The Maven coordinates of the standalone JaCoCo agent JAR (the `runtime`
* classifier), attached via `-javaagent:` to forked JVMs — Gradle TestKit
* workers and the Spine Compiler process — so their execution is credited
* to coverage.
*/
const val agent = "org.jacoco:org.jacoco.agent:$version:runtime"
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ package io.spine.gradle.report.coverage

import io.spine.dependency.test.Jacoco
import io.spine.dependency.test.Kover
import io.spine.gradle.testing.COMPILER_COVERAGE_DIR
import io.spine.gradle.testing.TESTKIT_COVERAGE_DIR
import java.io.File
import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
Expand Down Expand Up @@ -104,6 +105,13 @@ private const val KOTLIN_FILE_CLASS_SUFFIX: String = "Kt"
* the probe level against each module's actual bytecode: a line hit both
* in-process and out-of-process is counted once. Summing pre-aggregated XML
* reports instead would double-count such lines.
* - Feeds the JaCoCo execution data produced by the forked Spine Compiler JVMs
* (see [io.spine.gradle.testing.enableSpineCompilerCoverage]) into the **root**
* `total` report as `additionalBinaryReports`. This credits the code-generation
* plugins in the `java` and `context` modules — renderers and generators that
* run out-of-process in the compiler fork and are otherwise reported at ~0%.
* Fed only at the root: Codecov ingests the root report, whose aggregation owns
* those classes, so a per-module feed would be redundant.
*
* This is the Kover-based successor to the deprecated JaCoCo-based
* coverage aggregation pipeline. The behaviour mirrors what
Expand Down Expand Up @@ -198,6 +206,7 @@ class KoverConfig private constructor(
onCheck.set(true)
}
additionalBinaryReports.addAll(rootTestKitExecFilesProvider())
additionalBinaryReports.addAll(rootCompilerExecFilesProvider())
}
filters {
excludes {
Expand Down Expand Up @@ -243,6 +252,27 @@ class KoverConfig private constructor(
.toList()
}

/**
* Lazy `Provider` of the JaCoCo execution-data files produced by the forked
* Spine Compiler JVMs across every subproject.
*
* These files credit the out-of-process execution of compiler plugins — the
* renderers and generators in the `java` and `context` modules — to the root
* coverage rollup. Unlike the TestKit files, they are collected from **all**
* subprojects regardless of whether the producing module applies Kover: the
* exec is produced by a test module's `launch*SpineCompiler` task, but the
* classes it credits belong to the aggregated production modules. Resolved at
* task-graph time, after the launch tasks have written them.
*
* @see io.spine.gradle.testing.enableSpineCompilerCoverage
*/
private fun rootCompilerExecFilesProvider(): Provider<Iterable<File>> =
rootProject.provider {
rootProject.subprojects.asSequence()
.flatMap { compilerExecFiles(it).asSequence() }
.toList()
}

/**
* Lazy `Provider` of the JaCoCo execution-data files produced by the Gradle
* TestKit workers of [sub]. See [rootTestKitExecFilesProvider] for timing notes.
Expand Down Expand Up @@ -301,6 +331,23 @@ private fun testKitExecFiles(project: Project): List<File> {
?: emptyList()
}

/**
* Returns the JaCoCo execution-data (`.exec`) files written by the forked Spine
* Compiler JVMs of the [project], or an empty list if the module produced none.
*
* The files reside under `build/`[COMPILER_COVERAGE_DIR] and are created when a
* module opts in via [io.spine.gradle.testing.enableSpineCompilerCoverage].
*/
private fun compilerExecFiles(project: Project): List<File> {
val dir = project.layout.buildDirectory.dir(COMPILER_COVERAGE_DIR).get().asFile
if (!dir.isDirectory) {
return emptyList()
}
return dir.listFiles { file -> file.isFile && file.extension == "exec" }
?.sorted()
?: emptyList()
}

private fun generatedSrcDirs(project: Project): Set<File> {
val javaDirs = javaMainSourceSet(project)
?.let(::generatedSrcDirs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2026, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.gradle.testing

import io.spine.dependency.test.Jacoco
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.tasks.JavaExec
import org.gradle.kotlin.dsl.withType

/**
* Configures the `launch*SpineCompiler` tasks of this project so that the JaCoCo
* agent is attached to the forked JVM that runs the Spine Compiler, and the
* resulting execution data is captured as a task output.
*
* The Spine Compiler executes plugin code — renderers, option generators, and
* ProtoData plugins such as `JavaValidationPlugin` — in a **separate JVM** spawned
* by the `launch[<SourceSet>]SpineCompiler`
* [JavaExec][org.gradle.api.tasks.JavaExec] tasks. Kover (and JaCoCo) instrument
* only the `test` JVM, so this out-of-process execution is otherwise not credited
* to coverage, even though the module's `.proto` fixtures exercise it on every build.
*
* For every such launch task, this method:
*
* 1. Resolves the standalone JaCoCo agent JAR ([Jacoco.agent]) through a dedicated
* [AGENT_CONFIGURATION] configuration.
* 2. Attaches
* `-javaagent:<agent>=destfile=build/`[COMPILER_COVERAGE_DIR]`/<task>.exec,append=false`
* to the forked JVM. Each launch variant (main, test, test-fixtures) writes its
* own per-task file, and `append=false` makes every run overwrite it, so a
* re-run never accumulates a previous run's probes.
* 3. Declares that `.exec` file as a **task output**, which keeps the launch tasks
* cacheable. A `JavaExec` task blocks until the forked JVM exits, and the agent
* flushes the exec on that exit — so the file is already on disk when the task
* action returns and *can* be a declared output. Declaring it lets Gradle's
* build cache store and restore it, so the coverage survives both an
* `UP-TO-DATE` skip (the file stays on disk from the previous run) and a
* `FROM-CACHE` hit (the file is restored from the cache). This is the key
* difference from [enableTestKitCoverage], whose TestKit worker daemon flushes
* its exec only *after* the `Test` task completes and therefore cannot declare
* it — which is why that helper must instead disable caching.
*
* The agent is attached from a `doFirst` action rather than a `jvmArgumentProviders`
* entry so that its absolute path stays out of the task's input fingerprint: the
* exec content depends on the compiler's own inputs (which drive what code runs), so
* keying up-to-dateness and the cache on those inputs is exactly right.
*
* The produced `.exec` files are merged into the **root** Kover report by
* [io.spine.gradle.report.coverage.KoverConfig]. Only classes the root aggregation
* owns (the `java` and `context` modules' renderers/generators) are credited from
* them; the compiler loads those classes as their original, un-relocated artifacts,
* so JaCoCo's class IDs match. The agent emits binary execution data because Kover
* merges binary data at the probe level — see `KoverConfig` for why binary, not XML.
*
* The method is idempotent and may be called on every subproject; it is a no-op for
* modules that declare no `launch*SpineCompiler` task.
*/
fun Project.enableSpineCompilerCoverage() {
val agent = configurations.maybeCreate(AGENT_CONFIGURATION).apply {
isCanBeConsumed = false
isCanBeResolved = true
}
dependencies.add(agent.name, Jacoco.agent)

val agentPath = agent.elements.map { it.single().asFile.absolutePath }
val execDir = layout.buildDirectory.dir(COMPILER_COVERAGE_DIR)

val launchTasks = tasks.withType<JavaExec>().matching { it.isSpineCompilerLaunchTask() }

launchTasks.configureEach {
val taskName = name
val execFile = execDir.map { it.file("$taskName.exec") }
// Captured as a task output so the build cache stores and restores it —
// see the KDoc for why a synchronous `JavaExec` can declare its agent exec
// while a TestKit worker cannot.
outputs.file(execFile)
doFirst {
val file = execFile.get().asFile
file.parentFile.mkdirs()
jvmArgs(
"-javaagent:${agentPath.get()}=destfile=${file.absolutePath},append=false"
)
}
}

// The root Kover report/verification tasks read these exec files as
// `additionalBinaryReports`, but do not otherwise depend on the (non-Kover)
// test modules that produce them. Order them explicitly after the launch
// tasks — otherwise the report may run before the exec is written.
rootProject.tasks
.matching { it.isCoverageReportTask() }
.configureEach { dependsOn(launchTasks) }
}

/**
* Tells whether this is one of the `launch[<SourceSet>]SpineCompiler` tasks that
* fork the Spine Compiler — matched by name to avoid a compile-time dependency on
* the compiler Gradle plugin's task types.
*/
private fun Task.isSpineCompilerLaunchTask(): Boolean =
name.startsWith(LAUNCH_TASK_PREFIX) && name.endsWith(LAUNCH_TASK_SUFFIX)

/**
* Tells whether this is a Kover report or verification task — one that reads the
* binary exec files and must therefore run only after the launch tasks that write
* them. Matches `koverXmlReport`, `koverHtmlReport`, `koverVerify`, and their
* `Cached*` companions.
*/
private fun Task.isCoverageReportTask(): Boolean =
name.startsWith(KOVER_TASK_PREFIX) &&
(name.endsWith(REPORT_TASK_SUFFIX) || name.endsWith(VERIFY_TASK_SUFFIX))

/**
* The name of the directory under a module's `build` directory where the coverage
* of forked Spine Compiler JVMs is collected.
*
* The directory holds JaCoCo execution-data (`.exec`) files — one per launch task —
* written by the JaCoCo agent attached to the compiler fork. `KoverConfig` picks
* these files up and feeds them into the root Kover report as additional binary
* reports.
*
* @see io.spine.gradle.report.coverage.KoverConfig
*/
internal const val COMPILER_COVERAGE_DIR: String = "jacoco-compiler"

/**
* The name of the dedicated, resolvable configuration that holds the standalone
* JaCoCo agent JAR ([Jacoco.agent]) attached to the forked Spine Compiler JVMs.
*
* The configuration is hidden and non-consumable; it exists only to resolve the
* agent JAR.
*/
private const val AGENT_CONFIGURATION: String = "spineCompilerJacocoAgent"

private const val LAUNCH_TASK_PREFIX: String = "launch"
private const val LAUNCH_TASK_SUFFIX: String = "SpineCompiler"

private const val KOVER_TASK_PREFIX: String = "kover"
private const val REPORT_TASK_SUFFIX: String = "Report"
private const val VERIFY_TASK_SUFFIX: String = "Verify"
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ fun Project.enableTestKitCoverage() {
isCanBeConsumed = false
isCanBeResolved = true
}
dependencies.add(agent.name, "org.jacoco:org.jacoco.agent:${Jacoco.version}:runtime")
dependencies.add(agent.name, Jacoco.agent)

val agentPath = agent.elements.map { it.single().asFile.absolutePath }
val execDir = layout.buildDirectory.dir(TESTKIT_COVERAGE_DIR)
Expand Down
Loading
Loading