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
54 changes: 54 additions & 0 deletions .agents/tasks/fix-test-source-set-duplicates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
slug: fix-test-source-set-duplicates
branch: claude/vibrant-jang-fa76b9
owner: claude
status: in-review
started: 2026-06-30
---

## Goal

Fix [issue #19](https://github.com/SpineEventEngine/compiler/issues/19):
generated source sets in tests may contain duplicates. The Compiler reads the
`protoc` output and writes processed code into a separate directory; both must
not reach the Java/Kotlin compiler at once, or compilation fails with
`duplicate class` errors. This "sometimes does not work for the test source
set."

## Context

- `protoc` writes to `build/generated/sources/proto/<sourceSet>/{java,kotlin}`;
the Compiler writes to `generated/<sourceSet>/{java,kotlin}`.
- Deduplication lived in `configureCompileTasks` (live compile-task filtering) in
ProtoData until a 2023 commit ("Improve filtering of duplicated generated
sources") replaced it with an eager, one-time source-set rewrite, now in
tool-base's `GeneratedSourcePlugin.configureSourceSetDirs`.
- The eager rewrite is fragile: plugin-application order, a symlinked project
path (the old `residesIn` compared a canonical path to a merely absolute one),
or a consumer plugin re-adding the directory can leave the `protoc` output in
the source set.

## Plan

- [x] Reproduce: `test-source-set` fixture with protos in `main` + `test`; an
`afterEvaluate` re-adds the `protoc` output dir to the `test` source set,
reproducing the leaked state. Confirmed `compileTestJava` fails with
`duplicate class`.
- [x] Fix (compiler-side, order-independent): re-introduce live compile-task
filtering in `Plugin.excludeProtocOutputFromCompilation()` — re-set
`JavaCompile.source` to a filtered view and add an `exclude` spec to
`KotlinCompile`. Skips the deprecated in-place mode.
- [x] Harden `Paths.residesIn` to canonicalize both operands (symlink-safe).
- [x] Regression test `keep duplicate generated classes out of the 'test'
compilation` asserts `testClasses` succeeds.
- [x] Version bump `2.0.0-SNAPSHOT.057` → `.058`.
- [x] Full `PluginSpec` green: 16 tests, 0 failures (1 pre-existing skip).
`:gradle-plugin:detekt` (the `check` gate) passes.

## Log
- 2026-06-30 — reproduced RED (duplicate class in `compileTestJava`), implemented
the fix, verified GREEN with a marker proving the fixed plugin code executed in
the inner testkit build.
- Note: local incremental/build-cache is stale in this checkout; verifying
functional tests needs `--rerun-tasks --no-build-cache` and `arch -arm64`
(gradle-doctor Rosetta check). See auto-memory `gradle-arch-arm64`.
6 changes: 3 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ KoverConfig.applyTo(project)
* publishing in the root project.
*/
val projectsToPublish: Set<String> = the<SpinePublishing>().modules
val localPublish by tasks.registering {
val localPublish = tasks.register("localPublish") {
/*
Integration tests need the plugin subproject published to Maven Local too
because they apply the plugin.
Expand Down Expand Up @@ -187,15 +187,15 @@ fun materializeTestsLink(linkName: String) {
* This build should run _only_ if all tests of all modules passed.
* Otherwise, integration tests make little sense.
*/
val integrationTest by tasks.registering(RunBuild::class) {
val integrationTest = tasks.register<RunBuild>("integrationTest") {
directory = "$rootDir/tests"
/* The `tests` build consumes the Compiler published to Maven Local by `localPublish`,
so the build cache in that build exercises the plugin under development. */
task("clean", "build", "--build-cache")
dependsOn(localPublish)
subprojects.forEach {
it.tasks.findByName("test")?.let { testTask ->
this@registering.dependsOn(testTask)
this@register.dependsOn(testTask)
}
}
doFirst {
Expand Down
9 changes: 7 additions & 2 deletions buildSrc/src/main/kotlin/io/spine/gradle/fs/LazyTempPath.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2025, 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 @@ -41,11 +41,16 @@ import java.nio.file.WatchService
*
* After the first usage, the instances of this type delegate all calls to the internally
* created instance of [Path] created with [createTempDirectory].
*
* The directory is created under the [shared base directory][SpineTempDir], which is removed
* when the JVM — the Gradle daemon — shuts down. Build tasks delete their own directories
* eagerly as the primary cleanup; this shutdown removal is a safety net, so a directory does
* not outlive the daemon even when a build fails before its eager cleanup runs.
*/
@Suppress("TooManyFunctions")
class LazyTempPath(private val prefix: String) : Path {

private val delegate: Path by lazy { createTempDirectory(prefix) }
private val delegate: Path by lazy { createTempDirectory(SpineTempDir.path, prefix) }

override fun compareTo(other: Path): Int = delegate.compareTo(other)

Expand Down
92 changes: 92 additions & 0 deletions buildSrc/src/main/kotlin/io/spine/gradle/fs/SpineTempDir.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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.fs

import java.nio.file.Files.createDirectories
import java.nio.file.Files.createTempDirectory
import java.nio.file.Path

/**
* A per-JVM parent directory for the temporary directories created by the build.
*
* The directory is created [lazily][path] under a common, attributable namespace —
* `<java.io.tmpdir>/io.spine.gradle.fs`, named after the package of [LazyTempPath] — so
* that leftover files are easy to attribute. Within that namespace, each JVM gets its own
* subdirectory named after the process id, so concurrent Gradle daemons never delete one
* another's temporary files.
*
* Upon creation, the per-JVM directory is scheduled for recursive removal when the JVM
* shuts down. This is a safety net should the explicit cleanup performed by the build
* tasks not run — for example, when a build fails before reaching it. The shared namespace
* directory itself is intentionally left in place: deleting it on shutdown could wipe
* directories still in use by another JVM running on the same machine.
*
* @see LazyTempPath
*/
internal object SpineTempDir {

/**
* The per-JVM directory, created on the first access and removed on JVM shutdown.
*/
val path: Path by lazy { createPerJvmDir() }

private fun createPerJvmDir(): Path {
val namespace = Path.of(systemTempDir(), LazyTempPath::class.java.packageName)
createDirectories(namespace)
// A per-JVM directory keeps concurrent Gradle daemons from deleting one another's
// files when their shutdown hooks fire. The PID makes a leftover directory easy
// to attribute; `createTempDirectory` adds a random suffix so that a reused PID
// still yields a unique directory.
val pid = ProcessHandle.current().pid()
val jvmDir = createTempDirectory(namespace, "$pid-")
deleteRecursivelyOnShutdown(jvmDir)
return jvmDir
}

/**
* Obtains the value of the system property pointing to the temporary directory.
*/
private fun systemTempDir(): String =
checkNotNull(System.getProperty("java.io.tmpdir")) {
"The `java.io.tmpdir` system property is not set."
}

/**
* Requests the recursive removal of the given [directory] when the JVM shuts down.
*
* @see Runtime.addShutdownHook
*/
private fun deleteRecursivelyOnShutdown(directory: Path) {
val runtime = Runtime.getRuntime()
runtime.addShutdownHook(Thread {
val deleted = directory.toFile().deleteRecursively()
if (!deleted) {
System.err.println("Unable to delete the temporary directory `$directory`.")
}
})
}
}
69 changes: 69 additions & 0 deletions buildSrc/src/test/kotlin/io/spine/gradle/fs/LazyTempPathSpec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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.fs

import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

@DisplayName("`LazyTempPath` should")
class LazyTempPathSpec {

@Test
fun `create the directory on the first use`() {
val directory = LazyTempPath("created").toFile()

directory.exists() shouldBe true
directory.isDirectory shouldBe true
}

@Test
fun `create the directory under the system temporary directory`() {
val path = LazyTempPath("under-tmp").toString()

path shouldContain systemTempDir()
}

@Test
fun `create the directory under a folder named after its package`() {
val path = LazyTempPath("under-base").toString()

path shouldContain LazyTempPath::class.java.packageName
}

@Test
fun `place all instances under the same base directory`() {
val first = LazyTempPath("first").toFile()
val second = LazyTempPath("second").toFile()

first.parentFile shouldBe second.parentFile
first.parentFile.toString() shouldBe SpineTempDir.path.toString()
}
}

private fun systemTempDir(): String = System.getProperty("java.io.tmpdir")
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2025, 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 All @@ -24,36 +24,31 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

plugins {
java
}
package io.spine.gradle.fs

val versionDir = layout.buildDirectory.dir("version").get().asFile.path
import io.kotest.matchers.shouldBe
import java.nio.file.Path
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

/**
* This file, containing the version of ProtoData, is generated at build time and included into
* the project's resources.
*
* Please search for the usages of "version.txt" when making changes.
*/
val versionFile = "$versionDir/version.txt"
@DisplayName("`SpineTempDir` should")
class SpineTempDirSpec {

sourceSets {
main {
resources.srcDir(versionDir)
}
}
@Test
fun `place its per-JVM directory under the package-named namespace`() {
val namespace = Path.of(
System.getProperty("java.io.tmpdir"),
LazyTempPath::class.java.packageName
)

val createVersionFile by tasks.registering {
SpineTempDir.path.parent shouldBe namespace
}

inputs.property("version", project.version)
outputs.file(versionFile)
@Test
fun `create the directory on access`() {
val directory = SpineTempDir.path.toFile()

doLast {
file(versionFile).writeText(project.version.toString())
directory.exists() shouldBe true
directory.isDirectory shouldBe true
}
}

tasks.processResources {
dependsOn(createVersionFile)
}
1 change: 0 additions & 1 deletion cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import io.spine.gradle.publish.setup
plugins {
module
application
`version-to-resources`
`write-manifest`
`build-proto-model`
`maven-publish`
Expand Down
Loading
Loading