diff --git a/.agents/memory/project/porting-buildsrc-from-consumer-repos.md b/.agents/memory/project/porting-buildsrc-from-consumer-repos.md index 0ad8c557e..439edc2ca 100644 --- a/.agents/memory/project/porting-buildsrc-from-consumer-repos.md +++ b/.agents/memory/project/porting-buildsrc-from-consumer-repos.md @@ -26,3 +26,15 @@ filter) and repo-local convention plugins/helpers living in X's `buildSrc` `PatchGeneratedTemplateString.kt`). Cross-check direction on each remaining file — if `config` HEAD is newer (version bumps), the consumer copy is stale, not improved. + +Two port-time adaptations recur (both hit the validation#317 port): + +- `config` runs detekt over `buildSrc`; consumer repos do not. A port that + was green in the consumer repo can fail here (e.g. `TooManyFunctions`, + top-level threshold 11 per file). Fix by refactoring — merge copy-paste + helpers, reuse an existing `config` helper the consumer-side author did + not know about (e.g. `consumesCoverageBinaryReports()` from + `SiblingCoverage.kt`) — never by suppressing. +- Generalize consumer-specific KDoc (module names such as `java`/`context`, + plugin names such as `JavaValidationPlugin`): `config` distributes these + files to every repo, where such references mean nothing. diff --git a/.agents/tasks/port-compiler-coverage.md b/.agents/tasks/port-compiler-coverage.md new file mode 100644 index 000000000..5ff02a81b --- /dev/null +++ b/.agents/tasks/port-compiler-coverage.md @@ -0,0 +1,72 @@ +--- +slug: port-compiler-coverage +branch: port-compiler-coverage +owner: claude +status: in-review +started: 2026-07-06 +related-memories: + - porting-buildsrc-from-consumer-repos + - config-build-verification +--- + +## Goal + +Upstream the Spine Compiler coverage helpers introduced in +[validation#317][pr-317] into `config`'s `buildSrc`, so every consumer +repo receives them via `./config/pull` and can credit forked-compiler +codegen execution to its root Kover report. + +## Context + +The `launch*SpineCompiler` tasks fork a JVM that Kover/JaCoCo do not +instrument, so codegen plugins report ~0% coverage in consumer repos. +[validation#317][pr-317] fixed this in `validation` (root coverage +12% → 86%) and its description names upstreaming the `buildSrc` helpers +to `config` as the follow-up. Only `buildSrc` files port; validation's +root `build.gradle.kts` wiring +(`subprojects { enableSpineCompilerCoverage() }`) stays consumer-owned. + +## Plan + +- [x] Fetch the four changed `buildSrc` files from the PR branch + `credit-codegen-coverage` and diff against `config` HEAD + (confirmed purely additive; no reverse drift). +- [x] `io/spine/dependency/test/Jacoco.kt` — add the `agent` coordinate + constant. +- [x] `io/spine/gradle/testing/SpineCompilerCoverage.kt` — new file: + `enableSpineCompilerCoverage()`, cacheable per-task exec output. +- [x] `io/spine/gradle/testing/TestKitCoverage.kt` — replace the inline + agent coordinate with `Jacoco.agent`. +- [x] `io/spine/gradle/report/coverage/KoverConfig.kt` — feed compiler + exec files into the root report's `additionalBinaryReports`. +- [x] Verify: `JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew + :buildSrc:build detekt`. +- [x] Review diff with `spine-code-review`, `kotlin-engineer`, + `dependency-audit`, and `review-docs`. +- [x] Commit on branch `port-compiler-coverage`, run the pre-PR gate, + open the PR. + +## Log + +- 2026-07-06 — drafted and started; user prompt authorizes the port + (edits only, no commits). +- 2026-07-06 — KDoc generalized while porting: validation-specific + references (`java`/`context` modules, `JavaValidationPlugin`) removed, + since `config` distributes these files to all consumer repos. +- 2026-07-06 — detekt (which covers `buildSrc` only here in `config`) + flagged `TooManyFunctions` in `KoverConfig.kt` after the port; merged + the duplicate `testKitExecFiles`/`compilerExecFiles` helpers into one + `execFiles(project, dirName)`. Build + detekt green afterwards. +- 2026-07-06 — all four reviewers APPROVE. Applied their improvements + on top of the verbatim port: reuse `consumesCoverageBinaryReports()` + from `SiblingCoverage.kt` (promoted to `internal`) instead of a + duplicate predicate; early-return guard makes + `enableSpineCompilerCoverage()` truly idempotent (a second call would + have double-loaded the agent); `dependsOn` comment no longer reads as + ordering-only; KDoc polish (grammar, `testFixtures` spelling, + `[Jacoco.agent]` links in `TestKitCoverage.kt`). +- 2026-07-06 — work complete; committed on branch + `port-compiler-coverage` (two commits), pre-PR gate re-run green (all + four reviewers APPROVE), PR opened for human review. + +[pr-317]: https://github.com/SpineEventEngine/validation/pull/317 diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/test/Jacoco.kt b/buildSrc/src/main/kotlin/io/spine/dependency/test/Jacoco.kt index 478ae1b96..04a888889 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/test/Jacoco.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/test/Jacoco.kt @@ -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" } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/KoverConfig.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/KoverConfig.kt index e3bd5b98d..53f518bda 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/KoverConfig.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/KoverConfig.kt @@ -29,12 +29,14 @@ package io.spine.gradle.report.coverage import io.spine.dependency.test.Jacoco import io.spine.dependency.test.Kover import io.spine.gradle.testing.TESTKIT_COVERAGE_DIR +import io.spine.gradle.testing.isSpineCompilerLaunchTask import java.io.File import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension import org.gradle.api.Project import org.gradle.api.file.SourceDirectorySet import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.provider.Provider +import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.SourceSet import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @@ -104,6 +106,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 code-generation + * plugins — 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 @@ -198,6 +207,7 @@ class KoverConfig private constructor( onCheck.set(true) } additionalBinaryReports.addAll(rootTestKitExecFilesProvider()) + additionalBinaryReports.addAll(rootCompilerExecFilesProvider()) } filters { excludes { @@ -239,7 +249,43 @@ class KoverConfig private constructor( rootProject.provider { rootProject.subprojects.asSequence() .filter { it.pluginManager.hasPlugin(Kover.id) } - .flatMap { testKitExecFiles(it).asSequence() } + .flatMap { execFiles(it, TESTKIT_COVERAGE_DIR).asSequence() } + .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 — + * renderers and generators that run inside the compiler fork — 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 may be produced by a module that does not itself apply Kover + * (typically a test-only module), while the classes it credits belong to the + * aggregated production modules. Resolved at task-graph time, after the + * launch tasks have written them. + * + * The files are the `.exec` outputs that each subproject's **currently + * configured** `launch*SpineCompiler` tasks still declare — read from the task + * outputs, not by scanning the coverage directory. A stale exec left in an + * un-`clean`ed workspace is therefore never credited, whether its launch task + * was removed or the module simply stopped enabling the helper (in which case + * the task no longer declares the exec as an output). Filtering by the `exec` + * extension drops any non-coverage outputs the compiler task may also declare. + * + * @see io.spine.gradle.testing.enableSpineCompilerCoverage + */ + private fun rootCompilerExecFilesProvider(): Provider> = + rootProject.provider { + rootProject.subprojects.asSequence() + .flatMap { sub -> + sub.tasks.withType(JavaExec::class.java) + .matching { it.isSpineCompilerLaunchTask() } + .asSequence() + .flatMap { it.outputs.files.asSequence() } + } + .filter { it.isFile && it.extension == "exec" } .toList() } @@ -248,7 +294,7 @@ class KoverConfig private constructor( * TestKit workers of [sub]. See [rootTestKitExecFilesProvider] for timing notes. */ private fun testKitExecFilesProvider(sub: Project): Provider> = - sub.provider { testKitExecFiles(sub) } + sub.provider { execFiles(sub, TESTKIT_COVERAGE_DIR) } /** * Lazy `Provider` of the generated-class FQN exclusion patterns @@ -284,15 +330,20 @@ class KoverConfig private constructor( } /** - * Returns the JaCoCo execution-data (`.exec`) files written by the Gradle TestKit - * workers of the [project], or an empty list if the module produced none. + * Returns the JaCoCo execution-data (`.exec`) files found in the [dirName] + * directory under the [project]'s `build` directory, or an empty list if the + * module produced none. * - * The files reside under `build/`[TESTKIT_COVERAGE_DIR] and are created by the - * `plugin-testlib` test harness when a module opts in via - * [io.spine.gradle.testing.enableTestKitCoverage]. + * Used for the Gradle TestKit workers, which write into [TESTKIT_COVERAGE_DIR] + * when a module opts in via [io.spine.gradle.testing.enableTestKitCoverage]. A + * directory scan is safe there because the exec directory is wiped once per build + * (see `enableTestKitCoverage`); the Spine Compiler collector instead gathers its + * files from the current launch tasks (see + * [io.spine.gradle.testing.enableSpineCompilerCoverage]), because its cacheable + * tasks cannot wipe the directory. */ -private fun testKitExecFiles(project: Project): List { - val dir = project.layout.buildDirectory.dir(TESTKIT_COVERAGE_DIR).get().asFile +private fun execFiles(project: Project, dirName: String): List { + val dir = project.layout.buildDirectory.dir(dirName).get().asFile if (!dir.isDirectory) { return emptyList() } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/SiblingCoverage.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/SiblingCoverage.kt index 1c9d1f54d..69a42c5a4 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/SiblingCoverage.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/SiblingCoverage.kt @@ -101,8 +101,10 @@ private fun Project.execFilesOf(testTasks: TaskCollection): Provider]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:=destfile=build/`[COMPILER_COVERAGE_DIR]`/.exec,append=false` + * to the forked JVM. Each launch variant (`main`, `test`, `testFixtures`) + * 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 workers flush their + * exec only *after* the `Test` task completes, so the file cannot be declared + * as a task output — 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* — machine-specific, under the Gradle user home — + * stays out of the task's input fingerprint, where it would break build-cache reuse + * across machines. The agent *coordinate* ([Jacoco.agent]) is instead declared as an + * `inputs.property`, so bumping the JaCoCo version invalidates the launch tasks and + * regenerates the exec — a stale exec from an incompatible agent can never survive an + * `UP-TO-DATE` skip or a `FROM-CACHE` hit. The exec content otherwise depends on the + * compiler's own inputs, which already key up-to-dateness. + * + * 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 project's own renderers and 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 — a repeated call on the same project returns early + * instead of attaching the agent twice — and may be called on every subproject; + * it is a no-op for modules that declare no `launch*SpineCompiler` task. + */ +fun Project.enableSpineCompilerCoverage() { + if (configurations.findByName(AGENT_CONFIGURATION) != null) { + return + } + 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().matching { it.isSpineCompilerLaunchTask() } + + launchTasks.configureEach { + val taskName = name + val execFile = execDir.map { it.file("$taskName.exec") } + // Track the agent *coordinate*, not the resolved absolute path, so a JaCoCo + // version bump invalidates the task and regenerates the exec while the cache + // key stays machine-independent — see the KDoc. + inputs.property(JACOCO_AGENT_INPUT, Jacoco.agent) + // 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. Make them depend on the launch tasks — + // a hard `dependsOn`, not mere ordering, so that running a report task by + // itself still triggers the launch tasks that write the exec files. + rootProject.tasks + .matching { it.consumesCoverageBinaryReports() } + .configureEach { dependsOn(launchTasks) } +} + +/** + * Tells whether this is one of the `launch[]SpineCompiler` tasks that + * fork the Spine Compiler — matched by name to avoid a compile-time dependency on + * the compiler Gradle plugin's task types. + * + * `internal` so [io.spine.gradle.report.coverage.KoverConfig] can collect the exec + * files of the **current** launch tasks (rather than scanning the directory) and + * thereby ignore stale execs from removed tasks. + */ +internal fun Task.isSpineCompilerLaunchTask(): Boolean = + name.startsWith(LAUNCH_TASK_PREFIX) && name.endsWith(LAUNCH_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" + +/** + * The name of the task input property that records the JaCoCo agent coordinate + * ([Jacoco.agent]), so a version bump invalidates the `launch*SpineCompiler` tasks + * and their cached `.exec` outputs. + */ +private const val JACOCO_AGENT_INPUT: String = "jacocoAgentCoordinate" + +private const val LAUNCH_TASK_PREFIX: String = "launch" +private const val LAUNCH_TASK_SUFFIX: String = "SpineCompiler" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/testing/TestKitCoverage.kt b/buildSrc/src/main/kotlin/io/spine/gradle/testing/TestKitCoverage.kt index 9682596d8..6c21a6b0e 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/testing/TestKitCoverage.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/testing/TestKitCoverage.kt @@ -43,8 +43,8 @@ import org.gradle.kotlin.dsl.withType * * This method: * - * 1. Resolves the standalone JaCoCo agent JAR pinned to [Jacoco.version] - * through a dedicated [AGENT_CONFIGURATION] configuration. + * 1. Resolves the standalone JaCoCo agent JAR ([Jacoco.agent]) through a + * dedicated [AGENT_CONFIGURATION] configuration. * 2. Passes the agent JAR path and a per-module exec directory * (`build/`[TESTKIT_COVERAGE_DIR]) to the test JVM as system properties. * The `plugin-testlib` harness reads these and writes a `gradle.properties` @@ -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) @@ -150,8 +150,7 @@ private const val EXEC_DIR_PROPERTY: String = /** * The name of the dedicated, resolvable configuration that holds the standalone - * JaCoCo agent JAR (`org.jacoco:org.jacoco.agent::runtime`) attached to - * TestKit worker JVMs. + * JaCoCo agent JAR ([Jacoco.agent]) attached to TestKit worker JVMs. * * The configuration is hidden and non-consumable; it exists only to resolve the * agent JAR and to register it as an input of the `test` tasks.