diff --git a/base/src/main/kotlin/io/spine/compare/ComparatorRegistry.kt b/base/src/main/kotlin/io/spine/compare/ComparatorRegistry.kt index 336bb2f238..7803370ce2 100644 --- a/base/src/main/kotlin/io/spine/compare/ComparatorRegistry.kt +++ b/base/src/main/kotlin/io/spine/compare/ComparatorRegistry.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,6 +78,15 @@ public object ComparatorRegistry { @JvmStatic public fun contains(clazz: Class<*>): Boolean = map.containsKey(clazz) + /** + * Returns the types for which comparators are currently registered. + * + * The returned set is a snapshot; subsequent registrations do not + * affect it, and modifying it does not affect the registry. + */ + @JvmStatic + public fun types(): Set> = map.keys.toSet() + private fun loadServiceProviders() { ServiceLoader.load(ComparatorProvider::class.java) .forEach { it.registerIn(this) } diff --git a/base/src/test/kotlin/io/spine/compare/ComparatorRegistrySpec.kt b/base/src/test/kotlin/io/spine/compare/ComparatorRegistrySpec.kt index c76f5b8907..f1f0179e24 100644 --- a/base/src/test/kotlin/io/spine/compare/ComparatorRegistrySpec.kt +++ b/base/src/test/kotlin/io/spine/compare/ComparatorRegistrySpec.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,7 +29,10 @@ package io.spine.compare import com.google.protobuf.Duration import com.google.protobuf.Timestamp import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.shouldBe +import java.util.UUID import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -76,4 +79,26 @@ internal class ComparatorRegistrySpec { registry.register(comparator) registry.find() shouldBe comparator } + + @Test + fun `expose the supported types`() { + registry.types() shouldContain Timestamp::class.java + registry.types() shouldContain Duration::class.java + } + + @Test + fun `include a newly registered type among the supported types`() { + val comparator = compareBy { it.length } + registry.types() shouldNotContain CharSequence::class.java + registry.register(comparator) + registry.types() shouldContain CharSequence::class.java + } + + @Test + fun `return a snapshot of the supported types`() { + val snapshot = registry.types() + snapshot shouldNotContain UUID::class.java + registry.register(compareBy { it.toString() }) + snapshot shouldNotContain UUID::class.java + } } 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 e3b4d0aa4d..8dbffcdc46 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt @@ -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. @@ -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. @@ -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. 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/io/spine/gradle/fs/LazyTempPath.kt b/buildSrc/src/main/kotlin/io/spine/gradle/fs/LazyTempPath.kt index 0344819f22..b31cd3087f 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/fs/LazyTempPath.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/fs/LazyTempPath.kt @@ -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. @@ -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) diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/fs/SpineTempDir.kt b/buildSrc/src/main/kotlin/io/spine/gradle/fs/SpineTempDir.kt new file mode 100644 index 0000000000..d6e856e01f --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/gradle/fs/SpineTempDir.kt @@ -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 — + * `/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`.") + } + }) + } +} diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt index 64e46ef75b..2a2a4c1c32 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/DependencyWriter.kt @@ -34,6 +34,7 @@ import kotlin.reflect.full.isSubclassOf import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.result.ResolvedComponentResult import org.gradle.api.internal.artifacts.dependencies.AbstractExternalModuleDependency import org.gradle.kotlin.dsl.withGroovyBuilder @@ -54,6 +55,11 @@ import org.gradle.kotlin.dsl.withGroovyBuilder * * ``` * + * The version reported for each dependency is the one selected by Gradle's + * dependency resolution — the version actually placed on the classpath — rather + * than the version requested in the build script. This reflects `force(...)` + * directives, platform/BOM constraints, and conflict resolution. + * * When there are several versions of the same dependency, only the one with * the newest version is retained. If the retained version is used in several * configurations, the highest-ranking Maven scope is reported, e.g. `compile` @@ -107,38 +113,70 @@ private constructor( /** * Returns the [scoped dependencies][ScopedDependency] of a Gradle project. + * + * The version of each dependency is the one selected by dependency resolution + * for the project it comes from. See [resolvedVersions]. + */ +fun Project.dependencies(): SortedSet = + collectScopedDependencies { it.resolvedVersions() } + +/** + * Returns the [scoped dependencies][ScopedDependency] of a Gradle project, taking + * the version of each dependency from the given [resolvedVersions] map instead of + * resolving the project's own configurations. + * + * This overload exists for tests: a project created with `ProjectBuilder` cannot + * resolve its configurations against real repositories, so the resolved versions + * are supplied directly. The keys are the `"group:name"` of the modules. */ -fun Project.dependencies(): SortedSet { +internal fun Project.dependencies( + resolvedVersions: Map +): SortedSet = + collectScopedDependencies { resolvedVersions } + +/** + * Collects the [scoped dependencies][ScopedDependency] of this project and its + * subprojects, deduplicates them, and returns them in the conventional Maven order. + * + * The version of each dependency is taken from the map returned by the supplied + * `resolvedVersionsOf` function for the project the dependency comes from. + */ +private fun Project.collectScopedDependencies( + resolvedVersionsOf: (Project) -> Map +): SortedSet { val dependencies = mutableSetOf() - dependencies.addAll(this.depsFromAllConfigurations()) + dependencies.addAll(depsFromAllConfigurations(resolvedVersionsOf(this))) - this.subprojects.forEach { subproject -> - val subprojectDeps = subproject.depsFromAllConfigurations() + subprojects.forEach { subproject -> + val subprojectDeps = subproject.depsFromAllConfigurations(resolvedVersionsOf(subproject)) dependencies.addAll(subprojectDeps) } - val result = deduplicate(dependencies) + return deduplicate(dependencies) .map { it.scoped } .toSortedSet() - return result } /** * Returns the external dependencies of the project from all the project configurations. + * + * The version of each returned dependency is taken from [resolvedVersions] by its + * `"group:name"` key, falling back to the declared version when the module is on no + * resolvable configuration — for example, a version managed by a BOM, which carries + * no explicit version of its own. */ -private fun Project.depsFromAllConfigurations(): Set { +private fun Project.depsFromAllConfigurations( + resolvedVersions: Map +): Set { val result = mutableSetOf() - this.configurations.forEach { configuration -> + configurations.forEach { configuration -> configuration.dependencies .filter { it.isExternal() } .forEach { dependency -> - val forcedVersion = configuration.forcedVersionOf(dependency) + val version = resolvedVersions[moduleKey(dependency.group, dependency.name)] + ?: dependency.version val moduleDependency = - if (forcedVersion != null) { - ModuleDependency(project, configuration, dependency, forcedVersion) - } else { - ModuleDependency(project, configuration, dependency) - } + ModuleDependency(this, configuration, dependency, factualVersion = version) result.add(moduleDependency) } } @@ -146,20 +184,55 @@ private fun Project.depsFromAllConfigurations(): Set { } /** - * Searches for a forced version of given [dependency] in this [Configuration]. + * Returns the versions selected by dependency resolution for this project, keyed + * by the `"group:name"` of each module. + * + * The declared version of a dependency is what the build script *requested*, which + * may differ from what the build *uses*: a `force(...)`, a platform/BOM constraint, + * or Gradle's conflict resolution can all select another version. Reading the + * resolution result captures the selected version, so the report describes the + * dependencies actually on the classpath rather than the requested ones. * - * Returns `null`, if it wasn't forced. + * Only resolvable configurations contribute. When a module resolves to different + * versions across configurations, the newest one (by [VersionComparator]) is kept, + * matching the deduplication applied afterwards. A configuration that fails to + * resolve in isolation is skipped and logged, so the report never breaks the build. */ -private fun Configuration.forcedVersionOf(dependency: Dependency): String? { - val forcedModules = resolutionStrategy.forcedModules - val maybeForced = forcedModules.firstOrNull { - it.group == dependency.group - && it.name == dependency.name - && it.version != null - } - return maybeForced?.version +private fun Project.resolvedVersions(): Map { + // Resolving an individual configuration may fail for reasons unrelated to the + // report — missing repositories for a niche configuration, an unsatisfiable + // constraint, and the like. Such a configuration contributes no versions. + @Suppress("TooGenericExceptionCaught") // Any resolution failure is non-fatal here. + fun componentsOf(configuration: Configuration): Set = + try { + configuration.incoming.resolutionResult.allComponents + } catch (e: Exception) { + logger.info( + "Skipping configuration `${configuration.name}` " + + "while collecting resolved dependency versions.", + e + ) + emptySet() + } + + return configurations + .filter { it.isCanBeResolved } + .flatMap { componentsOf(it) } + .mapNotNull { it.moduleVersion } + .groupBy { moduleKey(it.group, it.name) } + .mapValues { (_, versions) -> versions.maxOfWith(VersionComparator) { it.version } } } +/** + * Builds the `"group:name"` key under which a module's resolved version is recorded + * and looked up. + * + * Forming the key in one place keeps the lookup in [depsFromAllConfigurations] + * consistent with what [resolvedVersions] records and with the grouping done by + * [deduplicate]. + */ +private fun moduleKey(group: String?, name: String): String = "$group:$name" + /** * Tells whether the dependency is an external module dependency. */ @@ -193,7 +266,7 @@ private fun Dependency.isExternal(): Boolean { * The rejected duplicates are logged. */ private fun Project.deduplicate(dependencies: Set): List { - val groups = dependencies.groupBy { it.run { "$group:$name" } } + val groups = dependencies.groupBy { moduleKey(it.group, it.name) } logDuplicates(groups.mapValues { (_, deps) -> deps.distinctBy { it.gav } }) diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ProjectMetadata.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ProjectMetadata.kt index ffb89a263f..66e3400e28 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ProjectMetadata.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ProjectMetadata.kt @@ -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. @@ -30,9 +30,7 @@ import groovy.xml.MarkupBuilder import java.io.StringWriter import kotlin.reflect.KProperty import org.gradle.api.Project -import org.gradle.kotlin.dsl.PropertyDelegate import org.gradle.kotlin.dsl.extra -import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.withGroovyBuilder /** @@ -90,10 +88,10 @@ private fun Project.nonEmptyValue(prop: Any): NonEmptyValue { private class NonEmptyValue( private val defaultValue: String, private val project: Project -) : PropertyDelegate { +) { @Suppress("UNCHECKED_CAST") - override fun getValue(receiver: Any?, property: KProperty<*>): T { + operator fun getValue(receiver: Any?, property: KProperty<*>): T { if (defaultValue.isNotEmpty()) { return defaultValue as T } diff --git a/buildSrc/src/main/kotlin/jvm-module.gradle.kts b/buildSrc/src/main/kotlin/jvm-module.gradle.kts index 8e777c6aca..1438116b77 100644 --- a/buildSrc/src/main/kotlin/jvm-module.gradle.kts +++ b/buildSrc/src/main/kotlin/jvm-module.gradle.kts @@ -154,7 +154,7 @@ fun Module.forceConfigurations() { fun Module.setTaskDependencies(generatedDir: String) { tasks { - val cleanGenerated by registering(Delete::class) { + val cleanGenerated = register("cleanGenerated") { group = SpineTaskGroup.name description = "Deletes the directory with generated sources" delete(generatedDir) diff --git a/buildSrc/src/main/kotlin/kmp-module.gradle.kts b/buildSrc/src/main/kotlin/kmp-module.gradle.kts index 636a84d22f..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. @@ -126,9 +127,8 @@ kotlin { // Dependencies are specified per-target. // Please note, common sources are implicitly available in all targets. - @Suppress("unused") // source set `val`s are used implicitly. sourceSets { - val commonTest by getting { + getByName("commonTest") { dependencies { implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) @@ -136,7 +136,7 @@ kotlin { implementation(Kotest.frameworkEngine) } } - val jvmTest by getting { + getByName("jvmTest") { dependencies { implementation(dependencies.enforcedPlatform(JUnit.bom)) implementation(TestLib.lib) @@ -162,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/main/kotlin/uber-jar-module.gradle.kts b/buildSrc/src/main/kotlin/uber-jar-module.gradle.kts index 0cace2ebe3..e59e30ef82 100644 --- a/buildSrc/src/main/kotlin/uber-jar-module.gradle.kts +++ b/buildSrc/src/main/kotlin/uber-jar-module.gradle.kts @@ -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. @@ -70,11 +70,8 @@ publishing { } } -/** - * Declare dependency explicitly to address the Gradle error. - */ -@Suppress("unused") -val publishFatJarPublicationToMavenLocal: Task by tasks.getting { +// Declare dependency explicitly to address the Gradle error. +tasks.getByName("publishFatJarPublicationToMavenLocal") { dependsOn(tasks.shadowJar) } diff --git a/buildSrc/src/main/kotlin/write-manifest.gradle.kts b/buildSrc/src/main/kotlin/write-manifest.gradle.kts index 49130c0c4b..2c49daa7cc 100644 --- a/buildSrc/src/main/kotlin/write-manifest.gradle.kts +++ b/buildSrc/src/main/kotlin/write-manifest.gradle.kts @@ -104,7 +104,7 @@ val manifestAttributes = mapOf( * when running tests. We cannot depend on the `Jar` from `resources` because it would * form a circular dependency. */ -val exposeManifestForTests by tasks.registering { +val exposeManifestForTests = tasks.register("exposeManifestForTests") { group = SpineTaskGroup.name description = "Writes a `MANIFEST.MF` to `resources/main` so that it is visible to tests" 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/buildSrc/src/test/kotlin/io/spine/gradle/fs/LazyTempPathSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/fs/LazyTempPathSpec.kt new file mode 100644 index 0000000000..260e852fe2 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/fs/LazyTempPathSpec.kt @@ -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") diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/fs/SpineTempDirSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/fs/SpineTempDirSpec.kt new file mode 100644 index 0000000000..8bf42500a8 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/fs/SpineTempDirSpec.kt @@ -0,0 +1,54 @@ +/* + * 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 java.nio.file.Path +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`SpineTempDir` should") +class SpineTempDirSpec { + + @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 + ) + + SpineTempDir.path.parent shouldBe namespace + } + + @Test + fun `create the directory on access`() { + val directory = SpineTempDir.path.toFile() + + directory.exists() shouldBe true + directory.isDirectory shouldBe true + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt index 0c4b23355f..baec7877dd 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt @@ -31,12 +31,16 @@ import io.kotest.matchers.ints.shouldBeLessThan import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain +import java.io.File import java.io.StringWriter +import org.gradle.api.Action import org.gradle.api.Project +import org.gradle.api.artifacts.repositories.MavenArtifactRepository import org.gradle.testfixtures.ProjectBuilder import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir @DisplayName("`DependencyWriter` should") internal class DependencyWriterSpec { @@ -246,6 +250,112 @@ internal class DependencyWriterSpec { } } + @Nested inner class + `report the version selected by dependency resolution` { + + /** + * A `force(...)` pins an artifact to a version that is *older* than one of + * the declared ones. The report must show the resolved version — the one + * actually on the classpath — and not the newest of the declared ones, + * which the deduplication would otherwise pick. + */ + @Test + fun `preferring it over a newer declared version`() { + val older = "$VALIDATION_RUNTIME:2.0.0-SNAPSHOT.40" + val newer = "$VALIDATION_RUNTIME:2.0.0-SNAPSHOT.61" + subproject("a-text").declare("implementation", newer) + subproject("b-text").declare("implementation", older) + + val resolved = mapOf(VALIDATION_RUNTIME to "2.0.0-SNAPSHOT.40") + val dependency = rootProject.dependencies(resolved).single() + + dependency.dependency().version shouldBe "2.0.0-SNAPSHOT.40" + } + + /** + * Two versions of the same artifact declared in a single module and + * configuration — the case that used to log a spurious "several versions" + * warning — collapse to the single resolved version. + */ + @Test + fun `collapsing several declarations within one configuration`() { + val text = subproject("text") + text.declare("implementation", "$VALIDATION_RUNTIME:2.0.0-SNAPSHOT.61") + text.declare("implementation", "$VALIDATION_RUNTIME:2.0.0-SNAPSHOT.40") + + val resolved = mapOf(VALIDATION_RUNTIME to "2.0.0-SNAPSHOT.61") + val dependency = rootProject.dependencies(resolved).single() + + dependency.dependency().version shouldBe "2.0.0-SNAPSHOT.61" + } + + @Test + fun `falling back to the declared version when it is not resolved`() { + subproject("lib").declare("api", SPINE_BASE) + + val dependency = rootProject.dependencies(emptyMap()).single() + + dependency.dependency().version shouldBe "2.0.0" + } + } + + @Nested inner class + `read the version from a resolved configuration` { + + /** + * Drives the real (non-injected) `dependencies()` entry point against an + * actually resolved configuration. A module is declared at one version but + * `force`d to an older one; the report must show the forced, i.e. resolved, + * version. The forcing is observable only through resolution, so the module + * is resolved from a local repository of metadata-only POMs. + */ + @Test + fun `honoring a forced version over the declared one`(@TempDir repoDir: File) { + val group = "io.spine.validation" + val name = "spine-validation-java-runtime" + publishPom(repoDir, group, name, "1.0.40") + publishPom(repoDir, group, name, "1.0.61") + + val text = subproject("text") + text.addMavenRepository(repoDir) + val api = text.configurations.create("api") + api.isCanBeResolved = true + api.resolutionStrategy.force("$group:$name:1.0.40") + text.dependencies.add("api", "$group:$name:1.0.61") + + val dependency = rootProject.dependencies().single() + + dependency.dependency().version shouldBe "1.0.40" + } + + /** Writes a metadata-only Maven POM for the module under [repoDir]. */ + private fun publishPom(repoDir: File, group: String, name: String, version: String) { + val dir = File(repoDir, "${group.replace('.', '/')}/$name/$version") + dir.mkdirs() + File(dir, "$name-$version.pom").writeText( + """ + + 4.0.0 + $group + $name + $version + + """.trimIndent() + ) + } + + private fun Project.addMavenRepository(dir: File) { + // The `org.gradle.kotlin.dsl` `maven { }` accessors are not on the + // `buildSrc` test compile classpath, so the core `Action` overload is + // used directly rather than the DSL lambda. + repositories.maven(object : Action { + override fun execute(repository: MavenArtifactRepository) { + repository.setUrl(dir.toURI()) + } + }) + } + } + @Test fun `omit the scope of a dependency coming only from an unknown configuration`() { subproject("lib").declare("spineCompiler", SPINE_BASE) @@ -308,5 +418,8 @@ internal class DependencyWriterSpec { private companion object { const val SPINE_BASE = "io.spine:spine-base:2.0.0" const val SPINE_BASE_NEWER = "io.spine:spine-base:2.0.1" + + /** The `"group:name"` of the validation runtime artifact, without a version. */ + const val VALIDATION_RUNTIME = "io.spine.validation:spine-validation-java-runtime" } } diff --git a/config b/config index 3f13608b3b..c2aae6e746 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 3f13608b3bb1a8bc736fbb748b95ec3dc11843a2 +Subproject commit c2aae6e74662ddbe60e3e8695cc6a1b8b104f0a3 diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index 91bc369b57..5fca22028a 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine:spine-annotations:2.0.0-SNAPSHOT.422` +# Dependencies of `io.spine:spine-annotations:2.0.0-SNAPSHOT.423` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 26.1.0. @@ -772,14 +772,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 29 20:03:32 WEST 2026** using +This report was generated on **Thu Jul 02 16:32:02 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:spine-base:2.0.0-SNAPSHOT.422` +# Dependencies of `io.spine:spine-base:2.0.0-SNAPSHOT.423` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -1648,14 +1648,14 @@ This report was generated on **Mon Jun 29 20:03:32 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 29 20:03:32 WEST 2026** using +This report was generated on **Thu Jul 02 16:32:02 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:spine-environment:2.0.0-SNAPSHOT.422` +# Dependencies of `io.spine:spine-environment:2.0.0-SNAPSHOT.423` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -2486,14 +2486,14 @@ This report was generated on **Mon Jun 29 20:03:32 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 29 20:03:32 WEST 2026** using +This report was generated on **Thu Jul 02 16:32:02 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:spine-format:2.0.0-SNAPSHOT.422` +# Dependencies of `io.spine:spine-format:2.0.0-SNAPSHOT.423` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -3404,6 +3404,6 @@ This report was generated on **Mon Jun 29 20:03:32 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 29 20:03:32 WEST 2026** using +This report was generated on **Thu Jul 02 16:32:02 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 47208a1141..d20c6979b2 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 base-libraries -2.0.0-SNAPSHOT.422 +2.0.0-SNAPSHOT.423 2015 @@ -32,26 +32,31 @@ all modules and does not describe the project structure per-subproject. com.fasterxml.jackson.core jackson-databind + 2.22.0 compile com.fasterxml.jackson.dataformat jackson-dataformat-yaml + 2.22.0 compile com.fasterxml.jackson.datatype jackson-datatype-guava + 2.22.0 compile com.fasterxml.jackson.datatype jackson-datatype-jdk8 + 2.22.0 compile com.fasterxml.jackson.datatype jackson-datatype-jsr310 + 2.22.0 compile @@ -129,7 +134,7 @@ all modules and does not describe the project structure per-subproject. com.google.errorprone error_prone_annotations - 2.36.0 + 2.47.0 provided @@ -147,6 +152,7 @@ all modules and does not describe the project structure per-subproject. com.fasterxml.jackson.module jackson-module-kotlin + 2.22.0 runtime @@ -294,6 +300,11 @@ all modules and does not describe the project structure per-subproject. templating-plugin 2.2.0 + + org.jetbrains.kotlin + abi-tools + 2.3.21 + org.jetbrains.kotlin kotlin-build-tools-compat @@ -304,6 +315,11 @@ all modules and does not describe the project structure per-subproject. kotlin-build-tools-impl 2.3.21 + + org.jetbrains.kotlin + kotlin-klib-commonizer-embeddable + 2.3.21 + org.jetbrains.kotlin kotlin-scripting-compiler-embeddable diff --git a/version.gradle.kts b/version.gradle.kts index 42a0dc25b4..c3bd036c91 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -24,4 +24,4 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -val versionToPublish: String by extra("2.0.0-SNAPSHOT.422") +extra.set("versionToPublish", "2.0.0-SNAPSHOT.423")