diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index a7e33675f5..b562ccee24 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -11,6 +11,7 @@ See [README.md](README.md) for the format and routing rules. - [which-fixer applied](which-fixer-applied.md) — bulk sweep done; skill now runs in incremental mode - [integration tests stale compiler after version bump](project/integration-tests-stale-compiler-after-version-bump.md) — after a version bump, clean-build `integrationTest` or it may launch the old compiler +- [functional-test fixtures](project/functional-test-fixtures.md) — authoring `gradle-plugin` TestKit fixtures: copied buildSrc, import rules, Maven Local prerequisite, `tuneRunner()` ## Reference (external systems) diff --git a/.agents/memory/project/functional-test-fixtures.md b/.agents/memory/project/functional-test-fixtures.md new file mode 100644 index 0000000000..41f9c9ab2c --- /dev/null +++ b/.agents/memory/project/functional-test-fixtures.md @@ -0,0 +1,31 @@ +--- +name: functional-test-fixtures +description: Authoring `gradle-plugin` functionalTest fixture projects — copied buildSrc, + import rules, Maven Local prerequisite, runner tuning. +metadata: + type: project + since: 2026-07-06 +--- + +Fixture projects under `gradle-plugin/src/functionalTest/resources//` are TestKit +builds materialized via `GradleProject.setupAt(dir).fromResources("")` — see +`PluginSpec` and `LaunchTaskCoverageSpec` for the setup chain. + +**Why:** The fixtures compile against a copied `buildSrc` and resolve the plugin from +Maven Local, so failures surface only at fixture-build time inside a running test — +a slow feedback loop when the wiring is wrong (one missed import costs a full +`functionalTest` cycle). + +**How to apply:** + +- `copyBuildSrc()` copies the repo's `buildSrc` into the fixture, so fixture build + scripts may use `io.spine.dependency.*` objects (e.g. `Jacoco.version`) directly. +- Top-level buildSrc helpers (`standardSpineSdkRepositories()`) resolve without an + import, but `standardToSpineSdk()` needs + `import io.spine.gradle.repo.standardToSpineSdk` — copy the imports of an existing + fixture (`launch-test`) when creating a new one. +- The `functionalTest` task depends on `publishToMavenLocal` of every production + module; fixtures resolve `id("io.spine.compiler") version "@COMPILER_VERSION@"` + (replaced with `Plugin.version`) from `mavenLocal()`. +- After `builder.create()`, call `project.tuneRunner()` (shared helper in the + `functionalTest` source set) instead of tuning the TestKit runner inline. diff --git a/.agents/tasks/compiler-coverage-plan.md b/.agents/tasks/compiler-coverage-plan.md new file mode 100644 index 0000000000..3308b63811 --- /dev/null +++ b/.agents/tasks/compiler-coverage-plan.md @@ -0,0 +1,377 @@ +# Capturing test coverage of Spine Compiler–executed code + +**Problem overview & implementation plan** + +> Target repo: **`SpineEventEngine/compiler`** (the `io.spine.compiler` Gradle plugin / +> `compiler-gradle-plugin`, run via `compiler-fat-cli`). +> Written from an investigation in `SpineEventEngine/validation`. Self-contained: everything a +> fresh session needs is below, but every claim about the compiler's *internals* is stated as an +> assumption to **verify in this repo** (it was inferred from the consumer side). + +--- + +## 1. TL;DR + +Code that runs **inside the forked compiler JVM** — ProtoData/Compiler plugins, renderers, option +generators, and the Compiler's own backend — is invisible to JaCoCo/Kover, which instrument only +the Gradle `test` JVM. In the `validation` repo this leaves the two code-generation modules +(`java`, 44 source files; `context`, 25 source files) sitting at **~0% line coverage** even though +every build exercises them heavily against real `.proto` fixtures. The only generator class with +any coverage (`UnsignedIntegerWarnings`, ~82%) has it purely because one spec instantiates it +**in-process**. + +The fix is a well-established pattern: **attach the standalone JaCoCo agent to the forked compiler +JVM**, harvest its execution data, and merge it into the consumer's Kover report. The Spine build +already does exactly this for Gradle TestKit worker JVMs (`enableTestKitCoverage()` + +`KoverConfig`); this plan **retargets that same mechanism from TestKit workers to the +`launch*SpineCompiler` tasks**. + +The launch tasks are owned by **this repo's** Gradle plugin, so the reusable, upgrade-safe hook +belongs here. Consumers (validation, and any future plugin repo) then opt in. + +--- + +## 2. Background: how Spine measures coverage + +- Modules use **Kover with the JaCoCo engine** (`kover { useJacoco(...) }`). Each module's `test` + task writes `build/kover/bin-reports/test.exec`; Kover renders per-module and a **root + aggregated** XML report. **Codecov ingests the root report.** +- JaCoCo/Kover instrument **only the JVM that the `test` task runs in**. Any code executed in a + *different* JVM (a Gradle TestKit worker, or the forked Compiler process) produces **no execution + data** unless the JaCoCo agent is explicitly attached to that JVM. +- Two reusable helpers already exist in the shared build (`buildSrc` / the `config` submodule) and + are the direct templates for this work: + - **`io.spine.gradle.testing.TestKitCoverage.enableTestKitCoverage()`** — resolves the standalone + JaCoCo agent JAR, injects `-javaagent:…` into Gradle **TestKit worker** JVMs, and collects their + `.exec` files under `build/jacoco-testkit/`. + - **`io.spine.gradle.report.coverage.KoverConfig`** — sets up the root aggregation and **merges + out-of-process `.exec` files into the `total` reports via `additionalBinaryReports`**, both + per-module and at the root. It already consumes the TestKit exec files + (`testKitExecFilesProvider` / `rootTestKitExecFilesProvider`). + +**Key property of `additionalBinaryReports`:** a Kover report is scoped to the owning project's +classes, so feeding in a `.exec` that contains coverage for *many* classes credits **only this +module's own classes** from it. JaCoCo matches exec data to classes by **class ID** (a hash of the +compiled bytecode), so the merge is correct as long as the instrumented bytecode is identical to +the module's compiled classes. + +--- + +## 3. The problem, precisely + +### 3.1 Execution model (verified from the consumer side) + +A consumer module that generates code declares, e.g. (`validation/tests/validator/build.gradle.kts`): + +```kotlin +buildscript { forceCodegenPlugins() } // forces io.spine.compiler + core-jvm-compiler +dependencies { spineCompiler(project(":java")) } // the plugin classes to run in the compiler +spine { + compiler { + plugins( + "io.spine.validation.java.JavaValidationPlugin", + "io.spine.tools.compiler.jvm.style.JavaCodeStyleFormatterPlugin" + ) + } +} +``` + +At build time the compiler plugin registers **`JavaExec`** tasks that run the `compiler-fat-cli` in +a **forked JVM**: + +- `launchSpineCompiler` +- `launchTestSpineCompiler` +- `launchTestFixturesSpineCompiler` + +These are confirmed to be plain `JavaExec` tasks: the consumer helpers `spineCompilerRemoteDebug()` +/ `testSpineCompilerRemoteDebug()` set remote debugging through the **standard Gradle +`JavaExec.debugOptions`** (`check(task is JavaExec)`), which means their **fork options — +including `jvmArgs` — are already reachable**. The plugin classes named in `plugins(...)` (validation's +renderers/generators) load in *this* forked JVM, run against the module's `.proto` files, and emit +generated sources. **None of that execution reaches the `test` JVM's coverage agent.** + +### 3.2 Evidence (validation @ HEAD, from Codecov) + +| Module | Role | Source files | Coverage | +|--------------------------------------------|--------------------------------------------------------|--------------|----------| +| `java` | Java-code generators / renderers (run in compiler JVM) | 44 | **~0%** | +| `context` | ProtoData/Compiler plugin (runs in compiler JVM) | 25 | **~0%** | +| `jvm-runtime` | Runtime library (runs in the test JVM) | 21 | measured | +| — `UnsignedIntegerWarnings.kt` (in `java`) | has a direct in-process spec | — | **~82%** | + +Repo total ≈ **12%**. Nearly every 0% file is a generator/renderer/expression-builder that only +ever runs inside the forked compiler JVM. (Separately, validation also suffered a ~6-month coverage +**reporting outage** in late 2025–May 2026 from a broken JaCoCo root-report task dependency; that is +already fixed and is *not* what this plan addresses. This plan is about the **structural** blind +spot, which caps achievable coverage regardless of that outage.) + +### 3.3 Why the obvious fixes do **not** apply + +- **`SiblingCoverage.creditTestCoverageFrom(contributor)`** (from `tool-base` + [PR #180](https://github.com/SpineEventEngine/tool-base/pull/180)) re-attributes a sibling + module's **existing** in-process `test.exec` to another module's per-module report. It requires + the execution data to **already exist**. For compiler-executed code it does not exist anywhere, so + there is nothing to credit. (Also note PR #180's own conclusion: `creditTestCoverageFrom` fixed + `psi`'s *per-module* number 31%→70% but did **not** change the Codecov total, because the root + report already cross-credits in-process execution. The real Codecov mover in that PR was simply + **writing in-process unit tests**.) +- **Root Kover aggregation** already cross-credits in-process execution across modules (proven in + validation: `UnsignedIntegerWarnings` is credited from a spec in a *different* module). There is + simply no in-process execution of the generators to aggregate. + +**Conclusion:** this is an **instrumentation** gap (a JVM with no agent), not an **attribution** +gap. The only way to close it without rewriting every generator as an in-process unit test is to +instrument the forked compiler JVM. + +--- + +## 4. Solution approach + +Attach the JaCoCo agent to the forked compiler JVM and merge the result into Kover: + +``` +compiler JVM (launch*SpineCompiler, JavaExec running compiler-fat-cli) + └─ -javaagent:org.jacoco.agent.jar=destfile=/jacoco-compiler/.exec,append=true,output=file + → writes .exec for every class executed (compiler core + plugin classes) + +consumer module (KoverConfig) + └─ kover { reports { total { additionalBinaryReports.add(.exec) } } } + → credits ONLY this module's own classes (class-ID match) → generators light up + └─ koverXmlReport / check / koverVerify dependsOn launch*SpineCompiler +``` + +This is the **same mechanism** as `enableTestKitCoverage()`, pointed at a different forked JVM. + +### Two decisions that shape the size of the change + +1. **Where the JVM-arg hook lives.** If the compiler plugin **preserves consumer-set `jvmArgs`** on + the launch tasks, the entire feature can be *consumer-side* and this repo's job is only to + **guarantee** that (add a regression test) plus document/convenience. If the plugin **overwrites + or manages** `jvmArgs` (e.g. via `setJvmArgs`, `allJvmArgs`, `argumentProviders`, or the Gradle + Worker API), then this repo must expose a **supported hook** to contribute JVM args / a javaagent. + → **Phase 0 decides this.** + +2. **What is credited.** Only the consumer's plugin classes get credited to the consumer's report + (class-ID scoping). The Compiler's **own** backend classes are also exercised — this repo can + optionally capture *that* the same way in its own integration tests (Phase 3). + +--- + +## 5. Why (part of) this belongs in the `compiler` repo + +- **Ownership:** `launch*SpineCompiler` are registered by `compiler-gradle-plugin`. A fragile + consumer-side `-javaagent` hack could break on any compiler upgrade; a supported hook + a + locking test here is upgrade-safe. +- **Reuse:** validation is the pilot, but every repo that ships Compiler plugins (and the Compiler + itself) has the identical blind spot. One hook here serves all of them; the consumer glue is then + upstreamed to `config` once. +- **Compiler self-coverage:** the Compiler's own end-to-end tests spawn the fat-cli; the same hook + lets this repo measure its backend's real coverage (Phase 3). + +--- + +## 6. Implementation plan + +Ordered, with explicit decision gates. Phases 0–1 & 3 are **compiler-repo** work; Phase 2 is the +**consumer (validation)** reference wiring, included so the hook is designed against a real caller. + +### Phase 0 — Feasibility spike (compiler repo) — ✅ DONE (2026-07-06) + +Goal: learn how `compiler-gradle-plugin` configures the launch tasks and whether consumer `jvmArgs` +survive. + +- [x] Find where the launch tasks are registered. → `Project.createLaunchTask()` in + `gradle-plugin/src/main/kotlin/io/spine/tools/compiler/gradle/plugin/Plugin.kt` registers + `LaunchSpineCompiler` per source set (name built by `CompilerTask.nameFor()`: + `launch[]SpineCompiler`). Both code paths — the eager `createTasks()` loop and + the `afterEvaluate` fallback in `handleLaunchTaskDependency()` for late-added source sets — + go through `createLaunchTask()`. +- [x] Confirm the task type is `JavaExec` in **all** code paths. → Confirmed: + `LaunchSpineCompiler : JavaExec()` (`LaunchSpineCompiler.kt`). No Worker API anywhere + (`WorkerExecutor`/`workerExecutor`: zero hits). **`jvmArgs` survive:** the task's `init` + block calls the *additive* `jvmArgs(...)` overload (four `--add-opens` for the Palantir + formatter); `compileCommandLine()` (a `doFirst`) touches only `classpath`, `mainClass`, and + program `args`. No `setJvmArgs`/`allJvmArgs`/`jvmArgumentProviders` manipulation anywhere in + the plugin. +- [x] Confirm the fork runs the fat CLI and plugin classes are on the fork's classpath verbatim. → + The fork's classpath is two configurations, side by side (`compileCommandLine()`): + `spineCompilerRawArtifact` (= `io.spine.tools:compiler-cli-all`, the shadow JAR of the `cli` + module) and `spineCompiler` (the user classpath with consumer plugin classes as their + **original artifacts**). The shadow config (`ShadowJarExts.setup()`) does **no `relocate()`** + — only service-file merging and first-copy-wins dedup. Consumer classes never enter the fat + JAR at all, so **class IDs match by construction**. +- [x] **Manual proof:** captured as the permanent functional test `LaunchTaskCoverageSpec` + (see Phase 1). Its fixture `coverage-agent-test` attaches + `-javaagent:=destfile=build/jacoco-compiler/.exec,append=true` via + `jvmArgumentProviders`, runs codegen, and asserts: (a) a non-empty `.exec`; + (b) JaCoCo `Analyzer` over the very `compiler-test-env` jar the fork loaded reports + **covered lines > 0** on `UnderscorePrefixRenderer` (class-ID match proven); + (c) an `UP-TO-DATE` re-launch leaves the `.exec` intact. + **Executed green** on 2026-07-06: `./gradlew :gradle-plugin:functionalTest + --tests "...LaunchTaskCoverageSpec"` → `BUILD SUCCESSFUL` (also + `LaunchTaskJvmArgsSpec` via `:gradle-plugin:test`). + +**Decision gate: A.** Consumer `jvmArgs`/`jvmArgumentProviders` survive; the whole feature is +consumer-side wiring. This repo adds the regression lock + docs (Phase 1); no new DSL is needed. +- **A. `jvmArgs` survive** → Phase 1 is light: add a regression test that locks the behavior in, + a short doc note, and (optional) a convenience accessor. Most of the feature is Phase 2. +- ~~B. `jvmArgs` are managed/clobbered~~ — not the case. + +Additional facts for Phase 2 (consumer wiring), verified here: +- `LaunchSpineCompiler` is `@CacheableTask`; its inputs/outputs are declared explicitly and do + **not** include JVM args, so toggling the agent neither invalidates up-to-dateness nor pollutes + cache keys. Consequences: (1) an `UP-TO-DATE`/`FROM-CACHE` launch produces no fresh `.exec` — + keep the previous file (do not wire a `dependsOn`-style cleaner); (2) a full re-measure needs + `clean` or `--rerun-tasks`. +- The `.exec` must **not** be declared a task output (it would leak into the build cache key + space and break cache-hit restores; same reasoning as `TestKitCoverage` step 4). +- The user classpath configuration is named `spineCompiler` + (`Names.USER_CLASSPATH_CONFIGURATION`); the launch tasks are found by type + `io.spine.tools.compiler.gradle.plugin.LaunchSpineCompiler` or by name via + `io.spine.tools.compiler.gradle.api.CompilerTask`. + +### Phase 1 — Provide a supported coverage / JVM-args hook (compiler repo) — ✅ DONE (option A) + +Design a minimal, engine-neutral way for a consumer to contribute JVM args (a javaagent) to the +launch tasks, applied uniformly to `launchSpineCompiler`, `launchTestSpineCompiler`, and +`launchTestFixturesSpineCompiler`. + +Implemented (option A per the Phase-0 gate): +- [x] **A. Guarantee + document** that consumer `jvmArgs` / `jvmArgumentProviders` on the launch + tasks are preserved: + - `LaunchTaskJvmArgsSpec` (`gradle-plugin/src/test`) — fast in-process lock: the launch task + is a plain `JavaExec`; a consumer-added JVM arg coexists with the task's own + `--add-opens` defaults. + - `LaunchTaskCoverageSpec` (`gradle-plugin/src/functionalTest`) + the `coverage-agent-test` + fixture — end-to-end lock: a real JaCoCo agent attached via `jvmArgumentProviders` + reaches the forked JVM, records `UnderscorePrefixRenderer` execution, the data matches + the fork's own jars (class IDs), and an `UP-TO-DATE` launch keeps the `.exec`. + The fixture doubles as the reference wiring for consumers (Phase 2). + - KDoc on `LaunchSpineCompiler` now declares the fork options part of the task's public + contract (“Attaching instrumentation to the forked JVM”). +- **B. First-class DSL** — deliberately **not** added: standard `JavaExec` fork options are + the hook; a DSL would duplicate them. Revisit only if the launch tasks ever stop + being `JavaExec`. Consumers should prefer `jvmArgumentProviders` + (a `CommandLineArgumentProvider`) over eager `jvmArgs` strings so paths resolve lazily + and stay configuration-cache-friendly. + +Cross-cutting requirements (learned from `TestKitCoverage`). Under option A the lifecycle is +owned by the **consumer** helper (`enableSpineCompilerCoverage()`, Phase 2) — these become its +requirements; the fixture demonstrates the per-task destfile and lazy-provider parts: +- [ ] **Opt-in / gated** behind a property or the presence of an agent path, so normal builds pay no + cost. Mirror how remote-debug is opt-in. *(Consumer-side, Phase 2.)* +- [x] **Unique destfile per task and per module** — the fixture uses + `build/jacoco-compiler/.exec` with `append=true`. +- [x] **Do not break incremental builds:** when a launch task is `UP-TO-DATE`, no new `.exec` is + produced and the previous one is kept — asserted by `LaunchTaskCoverageSpec`. Do **not** + declare the `.exec` a task output, and do not wipe it via a `dependsOn` cleaner (guarded + one-shot wipe if needed, per `TestKitCoverage`). *(Wipe lifecycle: consumer-side, Phase 2.)* +- [x] **Configuration cache & task-output validation friendly**: the reference wiring uses + a `CommandLineArgumentProvider` (lazy agent path & destfile), no eager files + at configuration time. +- [x] Keep it **engine-agnostic**: the agent emits binary `.exec`; nothing here assumes XML. + +### Phase 2 — Consumer reference wiring (validation) — the pilot that proves the number moves + +Implement in validation's `buildSrc` (then upstream to `config` in Phase 4): + +- [ ] Add `enableSpineCompilerCoverage()` mirroring `enableTestKitCoverage()`: + resolve `org.jacoco:org.jacoco.agent::runtime`, compute a per-module + `build/jacoco-compiler/` dir, contribute the `-javaagent:` via the Phase-1 hook to the launch + tasks. +- [ ] Extend `KoverConfig` to feed those `.exec` files into the `total` reports via + `additionalBinaryReports` (per-module **and** root), exactly like the TestKit exec providers + already do. +- [ ] Wire ordering: `koverXmlReport` / `check` / `koverVerify` (and their `Cached*` variants) + **must `dependsOn` the `launch*SpineCompiler` tasks** so the exec exists before a report runs. + (See `SiblingCoverage.consumesCoverageBinaryReports()` for the task-name predicate to reuse.) +- [ ] Apply to the modules that run codegen (`context`, `java`, and the `tests/*` fixtures that host + the generated code). +- [ ] **Validate:** `java` and `context` jump from ~0% toward their real exercised percentage; the + repo total rises materially from ~12%. Capture before/after in the PR. + +### Phase 3 — (Optional) Compiler self-coverage (compiler repo) + +- [ ] Apply the same hook to the Compiler's **own** integration tests that spawn the fat-cli, so the + backend's real end-to-end coverage is credited to the compiler modules' reports. + +### Phase 4 — Rollout, transparency, and guardrails + +- [ ] Upstream `enableSpineCompilerCoverage()` + the `KoverConfig` change into the **`config`** + submodule so all consumer repos inherit it. +- [ ] **Report codegen coverage transparently.** Crediting build-time codegen execution as + "coverage" is legitimate for a code generator (the fixtures genuinely exercise these paths) but + it is *execution* coverage, not assertion-backed. Recommend a **separate Codecov + flag/component** (`codegen` vs `runtime`) so the two are never conflated. (validation currently + has `components: []` — nothing configured.) +- [ ] Pair with **targeted in-process unit tests** for logic-heavy generators (the + `UnsignedFieldWarningSpec` model) — this is what actually moved tool-base's number and it + catches real bugs. +- [ ] Add a guard so a coverage collapse is noticed early (a Codecov status on absolute drop) — + validation's 6-month silent outage is the cautionary tale. + +--- + +## 7. Design considerations & risks + +| Risk / consideration | Notes & mitigation | +|--------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Class-ID mismatch (shading/relocation)** | If the fat-cli relocates plugin classes, agent exec won't match the consumer's compiled classes and coverage silently reads 0%. **Verify in Phase 0.** This is the top risk. | +| **Worker API vs JavaExec** | Confirmed `JavaExec` via `debugOptions`; verify no worker-based path exists. Worker API would need a different injection point. | +| **Incremental / UP-TO-DATE launches** | No new `.exec` on up-to-date runs. Don't delete prior exec; keep the producing task's outputs sane. Reuse `TestKitCoverage`'s guarded-wipe + non-cacheable reasoning. | +| **Build cost** | Agent instrumentation slows every codegen launch. Gate behind a property/CI flag; off by default for local dev. | +| **Double counting** | Merge **binary** exec via `additionalBinaryReports` (probe-level), never merge generated XML reports. `KoverConfig` already documents why binary. | +| **Conceptual validity** | Execution coverage ≠ assertion coverage. Report under a distinct flag and complement with unit tests (Phase 4). | +| **Multi-launch collisions** | `launchSpineCompiler` + `launchTestSpineCompiler` + `launchTestFixturesSpineCompiler` may all run per module → unique destfiles. | +| **Configuration cache** | Use lazy providers / `CommandLineArgumentProvider`; avoid capturing `Project` at execution time. | + +--- + +## 8. Pointers & symbols (grep targets) + +**In the `compiler` repo (verified):** +- Plugin id `io.spine.compiler`; artifacts `io.spine.tools:compiler-gradle-plugin`, + `compiler-backend`, `compiler-jvm`, `compiler-api`, `compiler-gradle-api`. The fat CLI artifact + is **`io.spine.tools:compiler-cli-all`** (shadow JAR of the `cli` module; the plan's working + name `compiler-fat-cli` refers to it — see `Artifacts.fatCli()` in `gradle-api`). +- Task registration: `Plugin.kt` → `createLaunchTask()`; task class `LaunchSpineCompiler` + (`JavaExec`); fork classpath assembled in `LaunchSpineCompiler.compileCommandLine()`. +- The `spine { compiler { plugins(...) } }` extension implementation: `Extension` / + `CompilerDslSpec` in `gradle-plugin`. + +**Reusable templates (in `validation`/`config` `buildSrc` — copy the pattern):** +- `io.spine.gradle.testing.TestKitCoverage.enableTestKitCoverage()` — agent resolution + injection + + exec-dir lifecycle (the closest analog; retarget it from TestKit workers to the launch tasks). +- `io.spine.gradle.report.coverage.KoverConfig` — root aggregation + `additionalBinaryReports` + merge; `testKitExecFilesProvider` / `rootTestKitExecFilesProvider` are the providers to mirror. +- `io.spine.gradle.report.coverage.SiblingCoverage.creditTestCoverageFrom` + + `consumesCoverageBinaryReports()` — the report/verify task-name predicate for ordering. +- Consumer-side debug hook precedent: `spineCompilerRemoteDebug()` / `setRemoteDebug()` / + `JavaExec.remoteDebug()` in `buildSrc/src/main/kotlin/BuildExtensions.kt` — shows the launch tasks + are `JavaExec` and how a consumer reaches them by name. + +**Reference material:** +- tool-base coverage PR (attribution helper + the "root already aggregates" lesson): + https://github.com/SpineEventEngine/tool-base/pull/180 +- Codecov (validation), for motivation/before-after: + https://app.codecov.io/gh/SpineEventEngine/validation + +--- + +## 9. Acceptance criteria + +- [x] Phase 0 conclusion documented: task type, jvmArgs behavior, class-ID match — with the + `.exec` proof captured as `LaunchTaskCoverageSpec` (see Phase 0 section above). +- [x] A supported way exists for a consumer to attach a javaagent to the launch tasks + (standard `JavaExec` fork options, declared part of the public contract in + the `LaunchSpineCompiler` KDoc), covered by **regression tests** in this + repo (`LaunchTaskJvmArgsSpec`, `LaunchTaskCoverageSpec`). +- [x] Feature is **off by default** — nothing in the plugin changes unless a consumer wires the + agent; the `coverage-agent-test` fixture shows the opt-in wiring producing a non-empty + `.exec`. *(The consumer-side flag gating lands with Phase 2.)* +- [ ] In validation (pilot): `java` and `context` report **>0%** and the repo total rises materially + from ~12%, with codegen coverage under its **own flag/component**. + *(Phase 2 — validation repo.)* +- [ ] Normal (coverage-off) builds show no measurable slowdown and remain configuration-cache clean. + *(Verify in the Phase 2 pilot; this repo adds no always-on cost.)* +- [ ] Consumer glue upstreamed to `config`; docs updated. *(Phase 4.)* diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt index 8667b50e4d..8dbffcdc46 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt @@ -72,7 +72,7 @@ object Compiler : Dependency() { * The version of the Compiler dependencies. */ override val version: String - private const val fallbackVersion = "2.0.0-SNAPSHOT.057" + private const val fallbackVersion = "2.0.0-SNAPSHOT.059" /** * The distinct version of the Compiler used by other build tools. @@ -81,7 +81,7 @@ object Compiler : Dependency() { * transitive dependencies, this is the version used to build the project itself. */ val dogfoodingVersion: String - private const val fallbackDfVersion = "2.0.0-SNAPSHOT.057" + private const val fallbackDfVersion = "2.0.0-SNAPSHOT.059" /** * The artifact for the Compiler Gradle plugin. diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/VersionGradleFile.kt b/buildSrc/src/main/kotlin/io/spine/gradle/VersionGradleFile.kt index 7e86ea94ec..c84bb6e09b 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/VersionGradleFile.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/VersionGradleFile.kt @@ -33,14 +33,20 @@ import org.gradle.api.GradleException * Reads a `version.gradle.kts` file and resolves the `extra` properties it declares. * * [contentUnder] and [contentInBase] read the file (from the working tree or a base branch); - * [keyForValue] and [valueForKey] resolve its `extra` properties. The following declaration - * shapes are handled (see the `bump-version` skill): + * [keyForValue] and [valueForKey] resolve its `extra` properties. Each property is registered + * either with the current `extra.set("name", …)` call or the legacy `by extra(…)` property + * delegate that Gradle deprecated (the `bump-version` skill migrates the latter to the former); + * both spellings are recognized, so a file is read correctly whether or not it has migrated. + * The following value shapes are handled: * - * 1. a literal: `val versionToPublish: String by extra("2.0.0-SNAPSHOT.182")`; - * 2. an alias to another `extra`: `val versionToPublish by extra(compilerVersion)` paired - * with `val compilerVersion: String by extra("2.0.0-SNAPSHOT.043")`; - * 3. an alias to a plain `val`: `val versionToPublish by extra(base)` paired with - * `val base = "2.0.0-SNAPSHOT.043"`. + * 1. a literal: + * `extra.set("versionToPublish", "2.0.0-SNAPSHOT.182")`, or the legacy + * `val versionToPublish: String by extra("2.0.0-SNAPSHOT.182")`; + * 2. an alias to a plain `val` (or, in the legacy spelling, to another `extra`): + * `extra.set("versionToPublish", compilerVersion)` paired with + * `val compilerVersion = "2.0.0-SNAPSHOT.043"`, or the legacy + * `val versionToPublish by extra(compilerVersion)` paired with + * `val compilerVersion: String by extra("2.0.0-SNAPSHOT.043")`. * * The publishing-version property is identified by [keyForValue] using the already-resolved * project version as an oracle, so the specific property name (`versionToPublish`, @@ -56,10 +62,19 @@ internal object VersionGradleFile { */ const val NAME = "version.gradle.kts" + // Legacy `by extra(…)` property-delegate spellings (deprecated by Gradle). private val literalExtra = Regex("""val\s+(\w+)\s*(?::\s*String)?\s+by\s+extra\(\s*"([^"]+)"\s*\)""") private val aliasExtra = Regex("""val\s+(\w+)\s*(?::\s*String)?\s+by\s+extra\(\s*([A-Za-z_]\w*)\s*\)""") + + // Current `extra.set("name", …)` spellings. + private val literalSet = + Regex("""extra\.set\(\s*"(\w+)"\s*,\s*"([^"]+)"\s*\)""") + private val aliasSet = + Regex("""extra\.set\(\s*"(\w+)"\s*,\s*([A-Za-z_]\w*)\s*\)""") + + // A plain `val name = "value"` that an alias may reference. private val plainAssignment = Regex("""val\s+(\w+)\s*(?::\s*String)?\s*=\s*"([^"]+)"""") @@ -68,12 +83,12 @@ internal object VersionGradleFile { * string value. */ private fun parse(content: String): Map { - val literals = literalExtra.findAll(content) + val literals = (literalExtra.findAll(content) + literalSet.findAll(content)) .associate { it.groupValues[1] to it.groupValues[2] } val plains = plainAssignment.findAll(content) .associate { it.groupValues[1] to it.groupValues[2] } val resolved = literals.toMutableMap() - aliasExtra.findAll(content).forEach { match -> + (aliasExtra.findAll(content) + aliasSet.findAll(content)).forEach { match -> val name = match.groupValues[1] val source = match.groupValues[2] if (name !in resolved) { diff --git a/buildSrc/src/main/kotlin/kmp-module.gradle.kts b/buildSrc/src/main/kotlin/kmp-module.gradle.kts index 089722e612..4db37f1085 100644 --- a/buildSrc/src/main/kotlin/kmp-module.gradle.kts +++ b/buildSrc/src/main/kotlin/kmp-module.gradle.kts @@ -38,6 +38,7 @@ import io.spine.gradle.javac.configureJavac import io.spine.gradle.kotlin.setFreeCompilerArgs import io.spine.gradle.publish.IncrementGuard import io.spine.gradle.report.license.LicenseReporter +import io.spine.gradle.testing.configureLogging /** * Configures this [Project] as a Kotlin Multiplatform module. @@ -161,11 +162,22 @@ java { * * Also, Kotlin and Java share the same test executor (JUnit), so tests * configuration is for both. + * + * The `jvmTest` task mirrors the setup made by `module-testing` for + * the `test` task of a `jvm-module` (`module-testing` itself cannot be + * applied here because it brings `java-library`, which conflicts with + * the Kotlin Multiplatform plugin). Unlike `module-testing`, no engine + * filter is imposed: `jvmTest` dependencies include the Kotest runner, + * which is a JUnit Platform engine of its own. */ tasks { withType().configureEach { configureJavac() } + named("jvmTest") { + useJUnitPlatform() + configureLogging() + } } /** diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/VersionGradleFileSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/VersionGradleFileSpec.kt index e76febe211..dd5e2798e6 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/VersionGradleFileSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/VersionGradleFileSpec.kt @@ -64,6 +64,28 @@ internal class VersionGradleFileSpec { VersionGradleFile.valueForKey(content, "versionToPublish") shouldBe "2.0.0-SNAPSHOT.043" } + @Test + fun `declared as a literal via 'extra set'`() { + val content = """ + extra.set("versionToPublish", "2.0.0-SNAPSHOT.182") + """.trimIndent() + + VersionGradleFile.keyForValue(content, "2.0.0-SNAPSHOT.182") shouldBe "versionToPublish" + VersionGradleFile.valueForKey(content, "versionToPublish") shouldBe "2.0.0-SNAPSHOT.182" + } + + @Test + fun `declared as an alias via 'extra set'`() { + val content = """ + val compilerVersion = "2.0.0-SNAPSHOT.043" + extra.set("compilerVersion", compilerVersion) + extra.set("versionToPublish", compilerVersion) + """.trimIndent() + + VersionGradleFile.valueForKey(content, "versionToPublish") shouldBe "2.0.0-SNAPSHOT.043" + VersionGradleFile.valueForKey(content, "compilerVersion") shouldBe "2.0.0-SNAPSHOT.043" + } + @Test fun `identified by the resolved project version, not a hard-coded name`() { val content = """ diff --git a/config b/config index 73d246ad7e..c2aae6e746 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 73d246ad7ebe4e3b8e3f0c14335152435daa1b20 +Subproject commit c2aae6e74662ddbe60e3e8695cc6a1b8b104f0a3 diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index 8726f5f5e8..7ed6601f86 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.tools:compiler-api:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-api:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -1099,14 +1099,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-api-tests:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-api-tests:2.0.0-SNAPSHOT.060` ## Runtime ## Compile, tests, and tooling @@ -1476,14 +1476,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:16 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:29 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-backend:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-backend:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -2586,14 +2586,14 @@ This report was generated on **Wed Jul 01 16:19:16 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-cli:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-cli:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -3855,14 +3855,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-gradle-api:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-gradle-api:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -4879,14 +4879,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-gradle-plugin:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-gradle-plugin:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -5947,14 +5947,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-jvm:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-jvm:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -7074,14 +7074,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-params:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-params:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -8172,14 +8172,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-protoc-plugin:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-protoc-plugin:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -9012,14 +9012,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-test-env:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-test-env:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -10118,14 +10118,14 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:compiler-testlib:2.0.0-SNAPSHOT.059` +# Dependencies of `io.spine.tools:compiler-testlib:2.0.0-SNAPSHOT.060` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -11331,6 +11331,6 @@ This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Jul 01 16:19:17 WEST 2026** using +This report was generated on **Mon Jul 06 18:11:30 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/docs/dependencies/pom.xml b/docs/dependencies/pom.xml index b2e383144b..a9038ee3ad 100644 --- a/docs/dependencies/pom.xml +++ b/docs/dependencies/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine.tools compiler -2.0.0-SNAPSHOT.059 +2.0.0-SNAPSHOT.060 2015 @@ -381,12 +381,12 @@ all modules and does not describe the project structure per-subproject. io.spine.tools compiler-cli-all - 2.0.0-SNAPSHOT.057 + 2.0.0-SNAPSHOT.059 io.spine.tools compiler-protoc-plugin - 2.0.0-SNAPSHOT.057 + 2.0.0-SNAPSHOT.059 io.spine.tools @@ -418,6 +418,11 @@ all modules and does not describe the project structure per-subproject. org.jacoco.agent 0.8.15 + + org.jacoco + org.jacoco.core + 0.8.15 + org.jacoco org.jacoco.report diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts index d5583ad6a8..0b58e1b5d1 100644 --- a/gradle-plugin/build.gradle.kts +++ b/gradle-plugin/build.gradle.kts @@ -31,6 +31,7 @@ import io.spine.dependency.local.Spine import io.spine.dependency.local.TestLib import io.spine.dependency.local.ToolBase import io.spine.dependency.test.JUnit +import io.spine.dependency.test.Jacoco import io.spine.gradle.isSnapshot import io.spine.gradle.publish.PublishingRepos.cloudArtifactRegistry import io.spine.gradle.publish.PublishingRepos.gitHub @@ -91,6 +92,10 @@ testing { implementation(TestLib.lib) implementation(ToolBase.pluginTestlib) implementation(project(":gradle-plugin")) + // Reads and analyzes the JaCoCo execution data produced by + // the agent attached to the forked Compiler JVM. + // See `LaunchTaskCoverageSpec`. + implementation("org.jacoco:org.jacoco.core:${Jacoco.version}") } } } diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchTaskCoverageSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchTaskCoverageSpec.kt new file mode 100644 index 0000000000..dd91000ebb --- /dev/null +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchTaskCoverageSpec.kt @@ -0,0 +1,155 @@ +/* + * 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.tools.compiler.gradle.plugin + +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.spine.testing.SlowTest +import io.spine.testing.assertExists +import io.spine.tools.code.SourceSetName +import io.spine.tools.compiler.gradle.api.CompilerTaskName +import io.spine.tools.compiler.gradle.api.Names.GRADLE_PLUGIN_ID +import io.spine.tools.gradle.task.TaskName +import io.spine.tools.gradle.testing.GradleProject +import io.spine.tools.gradle.testing.get +import java.io.File +import org.gradle.api.logging.LogLevel +import org.gradle.testkit.runner.TaskOutcome.SUCCESS +import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE +import org.jacoco.core.analysis.Analyzer +import org.jacoco.core.analysis.CoverageBuilder +import org.jacoco.core.tools.ExecFileLoader +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * Verifies that a consumer build can attach the JaCoCo agent to the JVM forked + * by a [LaunchSpineCompiler] task and capture the coverage of the Compiler + * plugins executed inside that JVM. + * + * The Compiler plugins named in the `compiler { plugins(...) }` block run in + * the forked JVM, out of reach of the coverage agent of a consumer's `test` + * task. The supported way to measure their coverage is via the standard fork + * options of [JavaExec][org.gradle.api.tasks.JavaExec]: the + * `coverage-agent-test` fixture project adds `-javaagent:` via + * `jvmArgumentProviders`. The in-process counterpart of this contract is + * `LaunchTaskJvmArgsSpec` in the `test` suite. + * + * This spec locks three facts at once: + * 1. Consumer-supplied JVM arguments reach the forked JVM (the agent runs and + * writes execution data). + * 2. The plugin classes from the `spineCompiler` configuration are executed in + * the fork as-is, not relocated: the recorded class IDs match the jars the + * fork loaded, so the JaCoCo report shows covered lines. + * 3. An up-to-date launch does not erase previously collected data. + */ +@SlowTest +@DisplayName("`LaunchSpineCompiler` should") +internal class LaunchTaskCoverageSpec { + + private val launchSpineCompiler: TaskName = CompilerTaskName(SourceSetName.main) + + private lateinit var project: GradleProject + private lateinit var projectDir: File + + @BeforeEach + fun prepareProject(@TempDir projectDir: File) { + this.projectDir = projectDir + val builder = GradleProject.setupAt(projectDir) + .fromResources("coverage-agent-test") + .withSharedTestKitDirectory() + .replace("@COMPILER_PLUGIN_ID@", GRADLE_PLUGIN_ID) + .replace("@COMPILER_VERSION@", Plugin.version) + .withLoggingLevel(LogLevel.INFO) + .copyBuildSrc() + project = builder.create() + project.tuneRunner() + } + + @Test + fun `let a consumer capture coverage of plugins executed in the forked JVM`() { + val result = project.executeTask(launchSpineCompiler) + result[launchSpineCompiler] shouldBe SUCCESS + + // The agent attached via `jvmArgumentProviders` wrote execution data. + val execFile = projectDir.resolve( + "build/jacoco-compiler/${launchSpineCompiler.name()}.exec" + ) + assertExists(execFile) + execFile.length() shouldBeGreaterThan 0L + + // The fork executed the plugin classes, and the agent recorded that. + val loader = ExecFileLoader().apply { load(execFile) } + val rendererData = loader.executionDataStore.contents.find { + it.name == RENDERER_CLASS + }.shouldNotBeNull() + rendererData.hasHits() shouldBe true + + // The execution data matches the classes of the jar the fork loaded + // the plugins from — class IDs align — and shows covered lines. + val coverage = analyzeUserClasspath(loader) + val renderer = coverage.classes.find { + it.name == RENDERER_CLASS + }.shouldNotBeNull() + renderer.lineCounter.coveredCount shouldBeGreaterThan 0 + + // An up-to-date launch must leave the collected data intact. + val sizeAfterFirstRun = execFile.length() + val secondRun = project.executeTask(launchSpineCompiler) + secondRun[launchSpineCompiler] shouldBe UP_TO_DATE + assertExists(execFile) + execFile.length() shouldBe sizeAfterFirstRun + } + + /** + * Runs the JaCoCo [Analyzer] over the `compiler-test-env` jar resolved by + * the fixture project, matching it against the loaded execution data. + * + * The jar path comes from the `user-classpath.txt` file written by the + * fixture build, so the analysis runs over the exact artifact the forked + * JVM had on its classpath. + */ + private fun analyzeUserClasspath(loader: ExecFileLoader): CoverageBuilder { + val userClasspath = projectDir.resolve("user-classpath.txt").readText() + val testEnvJar = userClasspath.split(File.pathSeparator) + .map(::File) + .find { it.name.startsWith("compiler-test-env") } + .shouldNotBeNull() + val coverage = CoverageBuilder() + Analyzer(loader.executionDataStore, coverage).analyzeAll(testEnvJar) + return coverage + } +} + +/** + * The VM name of the test renderer class executed inside the forked JVM. + */ +private const val RENDERER_CLASS = "io/spine/tools/compiler/test/UnderscorePrefixRenderer" diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/PluginSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/PluginSpec.kt index 0d31200598..4da51def47 100644 --- a/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/PluginSpec.kt +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/PluginSpec.kt @@ -47,7 +47,6 @@ import org.gradle.testkit.runner.TaskOutcome.FROM_CACHE import org.gradle.testkit.runner.TaskOutcome.SKIPPED import org.gradle.testkit.runner.TaskOutcome.SUCCESS import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE -import org.gradle.testkit.runner.internal.DefaultGradleRunner import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.DisplayName @@ -97,14 +96,7 @@ class PluginSpec { //.enableRunnerDebug() .copyBuildSrc() project = builder.create() - (project.runner as DefaultGradleRunner).withJvmArguments( - "-Xmx8g", - "-XX:MaxMetaspaceSize=1512m", - "-XX:+UseParallelGC", - "-XX:+HeapDumpOnOutOfMemoryError" - ).withEnvironment( - mapOf("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK" to "true") - ) + project.tuneRunner() } private fun createEmptyProject() { diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/RunnerTuning.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/RunnerTuning.kt new file mode 100644 index 0000000000..b0e1e03712 --- /dev/null +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/tools/compiler/gradle/plugin/RunnerTuning.kt @@ -0,0 +1,56 @@ +/* + * 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.tools.compiler.gradle.plugin + +import io.spine.tools.gradle.testing.GradleProject +import org.gradle.testkit.runner.internal.DefaultGradleRunner + +/** + * Tunes the TestKit runner of this project for the functional tests of + * the Compiler Gradle plugin. + * + * Grants the build JVM generous memory limits — a fixture build compiles + * Kotlin, runs `protoc`, and launches the Compiler — and disables + * the Protobuf version check, which is not relevant to the fixture projects. + */ +internal fun GradleProject.tuneRunner() { + // `withJvmArguments` is not exposed on the public `GradleRunner` API. + val gradleRunner = runner as? DefaultGradleRunner + ?: error( + "Expected the TestKit runner to be" + + " `${DefaultGradleRunner::class.qualifiedName}`," + + " got `${runner::class.qualifiedName}`." + ) + gradleRunner.withJvmArguments( + "-Xmx8g", + "-XX:MaxMetaspaceSize=1512m", + "-XX:+UseParallelGC", + "-XX:+HeapDumpOnOutOfMemoryError" + ).withEnvironment( + mapOf("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK" to "true") + ) +} diff --git a/gradle-plugin/src/functionalTest/resources/coverage-agent-test/build.gradle.kts b/gradle-plugin/src/functionalTest/resources/coverage-agent-test/build.gradle.kts new file mode 100644 index 0000000000..0fd7104663 --- /dev/null +++ b/gradle-plugin/src/functionalTest/resources/coverage-agent-test/build.gradle.kts @@ -0,0 +1,100 @@ +/* + * 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. + */ + +import io.spine.dependency.test.Jacoco +import io.spine.gradle.repo.standardToSpineSdk +import io.spine.tools.compiler.gradle.plugin.LaunchSpineCompiler +import org.gradle.process.CommandLineArgumentProvider + +buildscript { + standardSpineSdkRepositories() +} + +group = "io.spine.tools.test" +version = "1.0.0-SNAPSHOT" + +plugins { + java + kotlin("jvm") + id("com.google.protobuf") + id("@COMPILER_PLUGIN_ID@") version "@COMPILER_VERSION@" +} + +repositories { + mavenLocal() // Must come first for `compiler-test-env`. + standardToSpineSdk() +} + +configurations.all { + resolutionStrategy { + force( + io.spine.dependency.local.Base.lib, + ) + } +} + +dependencies { + spineCompiler("io.spine.tools:compiler-test-env:+") +} + +spine { + compiler { + plugins( + "io.spine.tools.compiler.test.UnderscorePrefixRendererPlugin", + "io.spine.tools.compiler.test.TestPlugin" + ) + } +} + +/** + * The wiring below is the reference for a consumer build capturing the coverage + * of the code executed inside the forked Compiler JVM. + * + * It resolves the standalone JaCoCo agent and attaches it to each + * [LaunchSpineCompiler] task via the standard `JavaExec` fork options. + * The agent writes a per-task `.exec` file under `build/jacoco-compiler/`. + */ +val jacocoAgent: Configuration by configurations.creating + +dependencies { + jacocoAgent("org.jacoco:org.jacoco.agent:${Jacoco.version}:runtime") +} + +tasks.withType().configureEach { + val execFile = layout.buildDirectory.file("jacoco-compiler/$name.exec") + jvmArgumentProviders.add(CommandLineArgumentProvider { + val agentJar = jacocoAgent.singleFile.absolutePath + val destFile = execFile.get().asFile.absolutePath + listOf("-javaagent:$agentJar=destfile=$destFile,append=true") + }) + // Record the user classpath, letting the functional test analyze + // the very jars the forked JVM loaded the Compiler plugins from. + doLast { + file("user-classpath.txt").writeText( + configurations["spineCompiler"].asPath + ) + } +} diff --git a/gradle-plugin/src/functionalTest/resources/coverage-agent-test/settings.gradle.kts b/gradle-plugin/src/functionalTest/resources/coverage-agent-test/settings.gradle.kts new file mode 100644 index 0000000000..94bafe1b21 --- /dev/null +++ b/gradle-plugin/src/functionalTest/resources/coverage-agent-test/settings.gradle.kts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +pluginManagement { + repositories { + mavenLocal() + } +} diff --git a/gradle-plugin/src/functionalTest/resources/coverage-agent-test/src/main/proto/test.proto b/gradle-plugin/src/functionalTest/resources/coverage-agent-test/src/main/proto/test.proto new file mode 100644 index 0000000000..60012c389a --- /dev/null +++ b/gradle-plugin/src/functionalTest/resources/coverage-agent-test/src/main/proto/test.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package spine.compiler.test; + +option java_package = "io.spine.tools.compiler.test"; +option java_outer_classname = "TestProto"; +option java_multiple_files = true; + +// We need there to be at least some definitions. We don't care which ones for this test. + +message Test { +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchSpineCompiler.kt b/gradle-plugin/src/main/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchSpineCompiler.kt index f26734d048..6ad9c85634 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchSpineCompiler.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchSpineCompiler.kt @@ -81,6 +81,36 @@ import org.gradle.api.tasks.SourceSet * * Users should NOT change the CLI command, user directory, etc. directly. * Please refer to the `compiler { }` extension configuring the Compiler. + * + * ## Attaching instrumentation to the forked JVM + * + * The fork options of this task are part of its public contract: JVM arguments + * a consumer build appends — via the additive [jvmArgs] function or via + * `jvmArgumentProviders` — are preserved and passed to the forked JVM next to + * the defaults added by the task itself. Assigning the `jvmArgs` property + * (`setJvmArgs(...)`) is outside the contract: it replaces the defaults, + * including the `--add-opens` arguments the Compiler relies on. + * Consumer builds use the appending forms to attach instrumentation — for + * example, the JaCoCo agent (`-javaagent:`) capturing the coverage of the + * Compiler plugins executed inside the fork, or a remote-debug agent. + * The contract is locked by regression tests. + * + * For example, to capture the coverage of the Compiler plugins: + * + * ```kotlin + * val jacocoAgent: Configuration by configurations.creating + * + * dependencies { + * jacocoAgent("org.jacoco:org.jacoco.agent::runtime") + * } + * + * tasks.withType().configureEach { + * val execFile = layout.buildDirectory.file("jacoco-compiler/$name.exec") + * jvmArgumentProviders.add(CommandLineArgumentProvider { + * listOf("-javaagent:${jacocoAgent.singleFile}=destfile=${execFile.get().asFile}") + * }) + * } + * ``` */ @CacheableTask public abstract class LaunchSpineCompiler : JavaExec() { diff --git a/gradle-plugin/src/test/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchTaskJvmArgsSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchTaskJvmArgsSpec.kt new file mode 100644 index 0000000000..5f97996895 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/tools/compiler/gradle/plugin/LaunchTaskJvmArgsSpec.kt @@ -0,0 +1,98 @@ +/* + * 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.tools.compiler.gradle.plugin + +import com.google.protobuf.gradle.ProtobufPlugin +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldExist +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.types.shouldBeInstanceOf +import io.spine.tools.code.SourceSetName +import io.spine.tools.compiler.gradle.api.CompilerTaskName +import java.io.File +import org.gradle.api.Task +import org.gradle.api.tasks.JavaExec +import org.gradle.kotlin.dsl.apply +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * Verifies the contract of [LaunchSpineCompiler] fork options on which + * consumer builds rely for attaching instrumentation, such as a coverage + * agent, to the forked Compiler JVM. + * + * The task must remain a plain [JavaExec], and the JVM arguments set by + * a consumer must survive next to the defaults added by the task itself. + * The end-to-end counterpart of this contract — a real JaCoCo agent reaching + * the forked JVM — is locked by `LaunchTaskCoverageSpec` in + * the `functionalTest` suite. + */ +@DisplayName("`LaunchSpineCompiler` should") +internal class LaunchTaskJvmArgsSpec { + + companion object { + + private lateinit var task: Task + + @Suppress("unused") // False positive: JUnit calls this method. + @BeforeAll + @JvmStatic + fun prepareProject(@TempDir projectDir: File) { + val project = ProjectBuilder.builder() + .withProjectDir(projectDir) + .build() + with(project) { + apply(plugin = "java") + apply() + apply() + repositories.mavenLocal() + } + val taskName = CompilerTaskName(SourceSetName.main).value() + task = project.tasks.getByName(taskName) + } + } + + @Test + fun `be a standard 'JavaExec' task`() { + task.shouldBeInstanceOf() + } + + @Test + fun `preserve JVM arguments added by a consumer build`() { + val launchTask = task.shouldBeInstanceOf() + val marker = "-Dconsumer.supplied.marker=on" + launchTask.jvmArgs(marker) + + val args = launchTask.jvmArgs.shouldNotBeNull() + args shouldContain marker + // The defaults added by the task itself are still in place. + args shouldExist { it.startsWith("--add-opens") } + } +} diff --git a/version.gradle.kts b/version.gradle.kts index a31766223d..5783573e03 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -30,7 +30,7 @@ * This version is also used by integration test projects. * E.g. see `tests/consumer/build.gradle.kts`. */ -private val compilerVersion = "2.0.0-SNAPSHOT.059" +private val compilerVersion = "2.0.0-SNAPSHOT.060" extra.set("compilerVersion", compilerVersion) /**