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
75 changes: 75 additions & 0 deletions .agents/tasks/kmp-jvmtest-junit-platform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Enable JUnit Platform for KMP `jvmTest` in the `kmp-module` convention

## Problem

`kmp-module.gradle.kts` never applies `module-testing` (it cannot: that plugin
applies `java-library`, which conflicts with `kotlin("multiplatform")`), so the
KMP `jvmTest` task runs without the JUnit Platform and silently discovers zero
JUnit 5/Kotest tests. Verified: `./gradlew :logging:jvmTest --rerun` passed in
~1 s with no test reports while 13 `*Spec.kt` files exist under
`logging/src/jvmTest/`. `backends/otel-backend/build.gradle.kts` carries a
module-local workaround.

Recorded as the "Collateral finding" in
`.agents/tasks/otel-backend-validation.md` (main checkout).

## Plan

1. In the sibling `config` checkout, extend `kmp-module.gradle.kts`:
configure `tasks.named<Test>("jvmTest")` with `useJUnitPlatform()` and
`configureLogging()` — mirroring `module-testing.setupTests()` but without
`includeEngines("junit-jupiter")`, because `kmp-module` itself adds the
Kotest runner (engine `kotest`) to `jvmTest` dependencies.
2. Apply the identical change to this repo's `buildSrc` copy (anticipating the
config float; `./config/pull` will overwrite with the same content once the
config change lands).
3. Remove the module-local workaround from
`backends/otel-backend/build.gradle.kts` (the `tasks.named<Test>("jvmTest")`
block). Keep its `registerTestTasks()` call — the convention does not
register `fastTest`/`slowTest`.
4. Verify with JDK 17: `:logging:jvmTest --rerun` executes the 13 jvm specs
(plus commonTest specs and Java tests), and `:backends:otel-backend:jvmTest`
still executes its tests without the workaround.

## Status

- [x] Config repo edited — committed by the user as `512c1068` on the
`address-logging-audit-finding` branch of the `config` checkout
- [x] Local `buildSrc` copy updated
- [x] otel-backend workaround removed
- [x] `:logging:jvmTest` executes the 13 jvm specs — 232 tests ran
(previously zero); all 13 spec classes have result files
- [x] `:otel-backend:jvmTest` still executes its 22 tests (all pass);
`:logging-testlib:jvmTest` now runs 3 tests (pass);
`:tests:fixtures` has no test sources (NO-SOURCE)

## Latent-failure triage (resolved)

Enabling the platform surfaced **25 latent test failures** (232 run,
207 pass) — behavior drift accumulated while the task silently ran
nothing. They were triaged in a dedicated session; its fixes were
adopted onto this branch. `:logging:jvmTest` now passes 232/232.

Production bugs found and fixed by the triage (tests were right):

- `AbstractLogger.atConfig()` delegated to `Level.INFO` instead of
`Level.CONFIG`.
- `MetadataHandler.Builder.addRepeatedHandler` had inverted validation
(rejected repeatable keys instead of requiring them; drift from the
`custom-metadata` PR #144).
- `MetadataKey.cast()` returned `null` instead of throwing the
documented `ClassCastException`; `checkCannotRepeat` threw
`IllegalStateException` where callers expect `IllegalArgumentException`.
- `SimpleProcessor` "wrapped" repeated-value lists with a no-op cast;
handlers could mutate them. Replaced with an unmodifiable iterator.
- `ScopedLoggingContext.Builder` lacked a member `run(Runnable)`, so
Kotlin's stdlib `run` extension executed blocks *without installing
the context*.
- Lazy log messages were evaluated outside the recursion guard of
`AbstractLogger.write`, so throwing/reentrant `toString()` escaped
error handling; timestamps lacked millis and UTC offset.

Test-side fixes (production was right): logger-name expectations
(`kotlin.String` vs `java.lang.String`), synthetic lambda method names,
eager invocation counting for rate-limiter specs, Kotlin spread
operator for `logVarargs`, updated message-text expectations.
17 changes: 3 additions & 14 deletions backends/otel-backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import io.spine.dependency.lib.OpenTelemetryKotlin
import io.spine.gradle.publish.SpinePublishing
import io.spine.gradle.publish.spinePublishing
import io.spine.gradle.testing.registerTestTasks
import org.gradle.api.tasks.testing.Test

plugins {
`kmp-module`
Expand All @@ -48,9 +47,8 @@ spinePublishing {
}

kotlin {
@Suppress("unused") // Source set `val`s are used implicitly.
sourceSets {
val commonMain by getting {
getByName("commonMain") {
dependencies {
// The Spine logging backend SPI.
api(project(":logging"))
Expand All @@ -63,13 +61,13 @@ kotlin {
implementation(OpenTelemetryKotlin.noop)
}
}
val jvmMain by getting {
getByName("jvmMain") {
dependencies {
// `@AutoService` registers the JVM `BackendFactory` for `ServiceLoader`.
implementation(AutoService.annotations)
}
}
val jvmTest by getting {
getByName("jvmTest") {
dependencies {
// The native Kotlin OpenTelemetry SDK, used only by tests to build an
// `OpenTelemetry` instance with a recording log-record processor.
Expand All @@ -95,12 +93,3 @@ dependencies {
// Registers the `fastTest`/`slowTest` tasks and the `*Spec`/`*Test` filter,
// matching the core `logging` module.
tasks.registerTestTasks()

// The `kmp-module` convention does not put the JUnit Platform on the JVM test
// task (it configures only the `jvm-module` `test` task), so enable it here.
tasks.named<Test>("jvmTest") {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
}
}
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,6 @@ gradle.projectsEvaluated {

dependencies {
productionModules.forEach {
dokka(it)
dokka(project(it.path))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@
package io.spine.dependency.local

import io.spine.dependency.Dependency
import io.spine.dependency.local.Compiler.DF_VERSION_ENV
import io.spine.dependency.local.Compiler.VERSION_ENV

/**
* Dependencies on the Spine Compiler modules.
Expand Down Expand Up @@ -74,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.
Expand All @@ -83,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.
Expand Down
33 changes: 24 additions & 9 deletions buildSrc/src/main/kotlin/io/spine/gradle/VersionGradleFile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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*"([^"]+)"""")

Expand All @@ -68,12 +83,12 @@ internal object VersionGradleFile {
* string value.
*/
private fun parse(content: String): Map<String, String> {
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) {
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`.")
}
})
}
}
Loading
Loading