diff --git a/.github/workflows/build-on-ubuntu.yml b/.github/workflows/build-on-ubuntu.yml index 27d9302b9d..516f347e14 100644 --- a/.github/workflows/build-on-ubuntu.yml +++ b/.github/workflows/build-on-ubuntu.yml @@ -19,9 +19,13 @@ jobs: - uses: gradle/actions/setup-gradle@v6 - - name: Build project and run tests + # `build` does not run Dokka — its tasks are gated to the publishing + # graph — so `dokkaGenerate` is appended to surface documentation + # warnings on each push, before merge, instead of only in the post-merge + # `Publish` job. `failOnWarning` is enabled in the Dokka setup. + - name: Build project, run tests, and check documentation shell: bash - run: ./gradlew build --stacktrace + run: ./gradlew build dokkaGenerate --stacktrace # See: https://github.com/marketplace/actions/junit-report-action - name: Publish Test Report diff --git a/buildSrc/src/main/kotlin/BuildExtensions.kt b/buildSrc/src/main/kotlin/BuildExtensions.kt index 27c03b4e91..eefdb23e10 100644 --- a/buildSrc/src/main/kotlin/BuildExtensions.kt +++ b/buildSrc/src/main/kotlin/BuildExtensions.kt @@ -33,7 +33,6 @@ import io.spine.dependency.build.PluginPublishPlugin import io.spine.dependency.lib.Protobuf import io.spine.dependency.local.Compiler import io.spine.dependency.local.CoreJvmCompiler -import io.spine.dependency.local.McJava import io.spine.dependency.local.ProtoTap import io.spine.dependency.test.Kotest import io.spine.dependency.test.Kover @@ -88,12 +87,6 @@ val ScriptHandlerScope.protobuf: Protobuf val ScriptHandlerScope.coreJvmCompiler: CoreJvmCompiler get() = CoreJvmCompiler -/** - * Shortcut to [McJava] dependency object for using under `buildScript`. - */ -val ScriptHandlerScope.mcJava: McJava - get() = McJava - /** * Shortcut to [CoreJvmCompiler] dependency object. * @@ -112,10 +105,10 @@ val ScriptHandlerScope.spineCompiler: Compiler /** * Shortcut to [Compiler] dependency object. * - * This plugin is published at Gradle Plugin Portal. - * But when used in a pair with [mcJava], it cannot be applied directly to a project. - * It is so, because [mcJava] uses [spineCompiler] as its dependency. - * And the buildscript's classpath ends up with both of them. + * This plugin is published at Gradle Plugin Portal. But when another plugin that + * depends on the Compiler is also on the buildscript's classpath, the Compiler + * cannot be applied directly to a project. In such a case, declare it here so that + * it is added to the classpath, then apply it by ID. */ val PluginDependenciesSpec.spineCompiler: Compiler get() = Compiler diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/McJava.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/McJava.kt deleted file mode 100644 index 6ab0a95656..0000000000 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/McJava.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2025, 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.dependency.local - -/** - * Dependencies on Spine Model Compiler for Java. - * - * See [mc-java](https://github.com/SpineEventEngine/mc-java). - */ -@Suppress( - "MemberVisibilityCanBePrivate" /* `pluginLib()` is used by subprojects. */, - "ConstPropertyName", - "unused" -) -object McJava { - const val group = Spine.toolsGroup - - /** - * The version used to in the build classpath. - */ - const val dogfoodingVersion = "2.0.0-SNAPSHOT.320" - - /** - * The version to be used for integration tests. - */ - const val version = "2.0.0-SNAPSHOT.320" - - /** - * The ID of the Gradle plugin. - */ - const val pluginId = "io.spine.mc-java" - - /** - * The library with the [dogfoodingVersion]. - */ - val pluginLib = pluginLib(dogfoodingVersion) - - /** - * The library with the given [version]. - */ - fun pluginLib(version: String): String = "$group:spine-mc-java-plugins:$version:all" - - /** The artifact reference for forcing in configurations. */ - const val pluginsArtifact: String = "$group:spine-mc-java-plugins:$version" - - /** - * The `mc-java-base` artifact with the [version]. - */ - val base = base(version) - - /** - * The `mc-java-base` artifact with the given [version]. - */ - fun base(version: String): String = "$group:spine-mc-java-base:$version" -} diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoData.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoData.kt deleted file mode 100644 index c4f06c8fd2..0000000000 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoData.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright 2025, 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.dependency.local - -/** - * Dependencies on ProtoData modules. - * - * To use a locally published ProtoData version instead of the version from a public plugin - * registry, set the `PROTODATA_VERSION` and/or the `PROTODATA_DF_VERSION` environment variables - * and stop the Gradle daemons so that Gradle observes the env change: - * ``` - * export PROTODATA_VERSION=0.43.0-local - * export PROTODATA_DF_VERSION=0.41.0 - * - * ./gradle --stop - * ./gradle build # Conduct the intended checks. - * ``` - * - * Then, to reset the console to run the usual versions again, remove the values of - * the environment variables and stop the daemon: - * ``` - * export PROTODATA_VERSION="" - * export PROTODATA_DF_VERSION="" - * - * ./gradle --stop - * ``` - * - * See [`SpineEventEngine/ProtoData`](https://github.com/SpineEventEngine/ProtoData/). - */ -@Suppress( - "unused" /* Some subprojects do not use ProtoData directly. */, - "ConstPropertyName" /* We use custom convention for artifact properties. */, - "MemberVisibilityCanBePrivate" /* The properties are used directly by other subprojects. */, -) -object ProtoData { - const val pluginGroup = Spine.group - const val group = "io.spine.protodata" - const val pluginId = "io.spine.protodata" - - /** - * Identifies ProtoData as a `classpath` dependency under `buildScript` block. - * - * The dependency is obtained from https://plugins.gradle.org/m2/. - */ - const val module = "io.spine:protodata" - - /** - * The version of ProtoData dependencies. - */ - val version: String - private const val fallbackVersion = "0.96.4" - - /** - * The distinct version of ProtoData used by other build tools. - * - * When ProtoData is used both for building the project and as a part of the Project's - * transitional dependencies, this is the version used to build the project itself. - */ - val dogfoodingVersion: String - private const val fallbackDfVersion = "0.96.4" - - /** - * The artifact for the ProtoData Gradle plugin. - */ - val pluginLib: String - - /** - * The artifact to be used during experiments when publishing locally. - * - * @see ProtoData - */ - private fun pluginLib(version: String): String = - "$group:gradle-plugin:$version" - - fun api(version: String): String = - "$group:protodata-api:$version" - - val api - get() = api(version) - - val backend - get() = "$group:protodata-backend:$version" - - val params - get() = "$group:protodata-params:$version" - - val protocPlugin - get() = "$group:protodata-protoc:$version" - - val gradleApi - get() = "$group:protodata-gradle-api:$version" - - val cliApi - get() = "$group:protodata-cli-api:$version" - - val javaModule = "$group:protodata-java" - - fun java(version: String): String = - "$javaModule:$version" - - val java - get() = java(version) - - val fatCli - get() = "$group:protodata-fat-cli:$version" - - val testlib - get() = "$group:protodata-testlib:$version" - - /** - * An env variable storing a custom [version]. - */ - private const val VERSION_ENV = "PROTODATA_VERSION" - - /** - * An env variable storing a custom [dogfoodingVersion]. - */ - private const val DF_VERSION_ENV = "PROTODATA_DF_VERSION" - - /** - * Sets up the versions and artifacts for the build to use. - * - * If either [VERSION_ENV] or [DF_VERSION_ENV] is set, those versions are used instead of - * the hardcoded ones. Also, in this mode, the [pluginLib] coordinates are changed so that - * it points at a locally published artifact. Otherwise, it points at an artifact that would be - * published to a public plugin registry. - */ - init { - val experimentVersion = System.getenv(VERSION_ENV) - val experimentDfVersion = System.getenv(DF_VERSION_ENV) - if (experimentVersion?.isNotBlank() == true || experimentDfVersion?.isNotBlank() == true) { - version = experimentVersion ?: fallbackVersion - dogfoodingVersion = experimentDfVersion ?: fallbackDfVersion - - pluginLib = pluginLib(version) - println(""" - - ❗ Running an experiment with ProtoData. ❗ - ----------------------------------------- - Regular version = v$version - Dogfooding version = v$dogfoodingVersion - - ProtoData Gradle plugin can now be loaded from Maven Local. - - To reset the versions, erase the `$$VERSION_ENV` and `$$DF_VERSION_ENV` environment variables. - - """.trimIndent()) - } else { - version = fallbackVersion - dogfoodingVersion = fallbackDfVersion - pluginLib = "$pluginGroup:protodata:$version" - } - } -} diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt index 21a6031052..0110a32f75 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/ProtoTap.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. @@ -38,7 +38,7 @@ package io.spine.dependency.local ) object ProtoTap { const val group = Spine.toolsGroup - const val version = "0.14.0" + const val version = "0.15.0" const val gradlePluginId = "io.spine.prototap" const val api = "$group:prototap-api:$version" const val gradlePlugin = "$group:prototap-gradle-plugin:$version" diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt b/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt index 6c63a31248..c25868fc11 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt @@ -82,8 +82,8 @@ private fun Project.readGitHubToken(): String { } /** - * Read the personal access token for the `developers@spine.io` account that - * has only the permission to read public GitHub packages. + * Reads the personal access token for the `developers@spine.io` account. + * The token grants only read access to public GitHub packages. * * The token is extracted from the archive called `aus.weis` stored under `buildSrc`. * The archive has such an unusual name to avoid scanning for tokens placed in repositories 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 79d00c6299..db2bf761ae 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 @@ -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. @@ -54,7 +54,9 @@ import org.gradle.kotlin.dsl.withGroovyBuilder * ``` * * When there are several versions of the same dependency, only the one with - * the newest version is retained. + * the newest version is retained. If the retained version is used in several + * configurations, the highest-ranking Maven scope is reported, e.g. `compile` + * wins over `test`. * * @see PomGenerator */ @@ -65,7 +67,7 @@ private constructor( internal companion object { /** - * Creates the `ProjectDependenciesAsXml` for the passed [project]. + * Creates the `DependencyWriter` for the passed [project]. */ fun of(project: Project): DependencyWriter { return DependencyWriter(project.dependencies()) @@ -75,7 +77,7 @@ private constructor( /** * Writes the dependencies in their `pom.xml` format to the passed [out] writer. * - *

Used writer will not be closed. + * The used writer will not be closed. */ fun writeXmlTo(out: Writer) { val xml = MarkupBuilder(out) @@ -86,7 +88,12 @@ private constructor( "dependency" { "groupId" { xml.text(dependency.group) } "artifactId" { xml.text(dependency.name) } - "version" { xml.text(dependency.version) } + // A BOM-managed dependency carries no explicit version. + // Omit the element rather than emit `null`, + // since `null` is not a valid Maven version. + dependency.version?.let { version -> + "version" { xml.text(version) } + } if (scopedDep.hasDefinedScope()) { "scope" { xml.text(scopedDep.scopeName()) } } @@ -170,17 +177,30 @@ private fun Dependency.isExternal(): Boolean { * But for our `pom.xml`, which has clearly representative character, a single version * of a dependency is quite enough. * + * Versions are compared by [VersionComparator] rather than as plain text, so `10.0.0` + * is recognized as newer than `9.2.0`, and `2.0.0-SNAPSHOT.100` — as newer + * than `2.0.0-SNAPSHOT.99`. + * + * When the newest version comes from several configurations, the occurrence with + * the highest-ranking Maven scope (as defined by [ScopedDependency.dependencyPriority]) + * is retained. For example, a dependency declared via `api` in one module and via + * `testImplementation` in another is reported with the `compile` scope, so a production + * dependency is not misrepresented as a test-scoped one. Likewise, an artifact coming + * from `compileOnly` or `annotationProcessor` in one module and from a test + * configuration in another is reported as `provided`. + * * The rejected duplicates are logged. */ private fun Project.deduplicate(dependencies: Set): List { - val groups = dependencies.distinctBy { it.gav } - .groupBy { it.run { "$group:$name" } } + val groups = dependencies.groupBy { it.run { "$group:$name" } } - logDuplicates(groups) + logDuplicates(groups.mapValues { (_, deps) -> deps.distinctBy { it.gav } }) - val filtered = groups.map { group -> - group.value.maxByOrNull { dep -> dep.version ?: "" } - }.filterNotNull() + val filtered = groups.values.map { sameArtifact -> + val newest = sameArtifact.maxWith(compareBy(VersionComparator) { it.version ?: "" }) + sameArtifact.filter { it.version == newest.version } + .minBy { it.scoped.dependencyPriority() } + } return filtered } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ScopedDependency.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ScopedDependency.kt index 7c67a32ca4..c969ce5008 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ScopedDependency.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/ScopedDependency.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. @@ -29,6 +29,7 @@ package io.spine.gradle.report.pom import io.spine.gradle.report.pom.DependencyScope.compile import io.spine.gradle.report.pom.DependencyScope.provided import io.spine.gradle.report.pom.DependencyScope.runtime +import io.spine.gradle.report.pom.DependencyScope.system import io.spine.gradle.report.pom.DependencyScope.test import io.spine.gradle.report.pom.DependencyScope.undefined import org.gradle.api.artifacts.Configuration @@ -106,17 +107,18 @@ private constructor( configurationName.startsWith("test", ignoreCase = true) /** - * Performs comparison of {@code DependencyWithScope} instances according to these rules: + * Performs comparison of `ScopedDependency` instances according to these rules: * - * * Compares the scope of the dependency first. Dependency with lower scope priority - * number goes first. + * * Compares the scope of the dependency first. Dependency with a lower scope priority + * number goes first. * - * * For dependencies with same scope, does the lexicographical group name comparison. + * * For dependencies with the **same scope** does the lexicographical group + * name comparison. * - * * For dependencies within the same group, does the lexicographical artifact + * * For dependencies within the **same group**, does the lexicographical artifact * name comparison. * - * * For dependencies with the same artifact name, does the lexicographical artifact + * * For dependencies with the **same artifact name**, does the lexicographical artifact * version comparison. */ private val COMPARATOR: Comparator = @@ -138,7 +140,7 @@ private constructor( return dependency } - /** Obtains the scope name of this dependency .*/ + /** Obtains the scope name of this dependency. */ fun scopeName(): String { return scope.name } @@ -147,14 +149,24 @@ private constructor( * Obtains the layout priority of a scope. * * Layout priority determines what scopes come first in the generated `pom.xml` file. - * Dependencies with a lower priority number go on top. + * Dependencies with a lower priority number go on top, following the conventional + * Maven scope order: `compile`, `provided`, `runtime`, `test`, and `system`. + * Dependencies with an undefined scope go last. + * + * The same ordering also drives the scope selection when the same dependency + * comes from several configurations: the occurrence with the lowest priority + * number is reported. So, a scope required by production code wins over `test`, + * and a known scope wins over an undefined one. */ + @Suppress("MagicNumber") // Reason: the values encode the relative scope order. internal fun dependencyPriority(): Int { return when (scope) { compile -> 0 - runtime -> 1 - test -> 2 - else -> 3 + provided -> 1 + runtime -> 2 + test -> 3 + system -> 4 + undefined -> 5 } } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/VersionComparator.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/VersionComparator.kt new file mode 100644 index 0000000000..c6984bb65c --- /dev/null +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/pom/VersionComparator.kt @@ -0,0 +1,115 @@ +/* + * 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.report.pom + +/** + * Compares dependency version strings by their meaning rather than lexicographically. + * + * Numeric segments are ordered as numbers, so `10.0.0` is newer than `9.2.0`, and + * `2.0.0-SNAPSHOT.100` is newer than `2.0.0-SNAPSHOT.99`. A plain `String` comparison + * would order both pairs the other way around. + * + * The rules follow Semantic Versioning where it applies: + * + * 1. A version consists of a release part and an optional qualifier, separated by + * the first `-`: for `2.0.0-SNAPSHOT.100` these are `2.0.0` and `SNAPSHOT.100`. + * 2. Both parts are compared segment by segment, as split by `.`, and also by `-` + * within a qualifier. Two numeric segments are compared as numbers, two textual + * ones as case-insensitive text, and a numeric segment is older than a textual one. + * 3. When one version runs out of segments, it is the older one: `1.0.1` is newer + * than `1.0`, and `1.0.0-RC.1` is newer than `1.0.0-RC`. + * 4. When the release parts are equal, a version without a qualifier is newer than + * a version with one: `2.0.0` is newer than `2.0.0-SNAPSHOT.100`. + * + * Unlike full Maven semantics, qualifiers carry no special meaning: `RC`, `SNAPSHOT`, + * and the like are ordered as plain text. This keeps the comparison simple and + * predictable for the report, where only the relative recency of the versions + * of the same artifact matters. + */ +internal object VersionComparator : Comparator { + + override fun compare(left: String, right: String): Int { + val (leftRelease, leftQualifier) = left.parse() + val (rightRelease, rightQualifier) = right.parse() + val byRelease = compareSegments(leftRelease, rightRelease) + if (byRelease != 0) { + return byRelease + } + return when { + leftQualifier == null && rightQualifier == null -> 0 + leftQualifier == null -> 1 + rightQualifier == null -> -1 + else -> compareSegments(leftQualifier, rightQualifier) + } + } + + /** + * Splits this version into the segments of its release part and the segments + * of its qualifier, the latter being `null` when the version has no qualifier. + */ + private fun String.parse(): Pair, List?> { + val release = substringBefore('-') + val qualifier = if ('-' in this) substringAfter('-') else null + return release.split('.') to qualifier?.split('.', '-') + } + + private fun compareSegments(left: List, right: List): Int { + for (index in 0 until maxOf(left.size, right.size)) { + val bySegment = compareSegment( + left.getOrElse(index) { "" }, + right.getOrElse(index) { "" } + ) + if (bySegment != 0) { + return bySegment + } + } + return 0 + } + + /** + * Compares single segments, ordering an absent (empty) segment below any present + * one, a numeric segment below a textual one, numbers by their value, and text + * case-insensitively. + * + * Keeping the empty, numeric, and textual segments in distinct buckets makes + * the order transitive: comparing a numeric pair as numbers, but a mixed pair + * as text, would order `2` < `10` < `1a` < `2`. + */ + private fun compareSegment(left: String, right: String): Int { + if (left.isEmpty() || right.isEmpty()) { + return left.length.compareTo(right.length) + } + val leftNumber = left.toLongOrNull() + val rightNumber = right.toLongOrNull() + return when { + leftNumber != null && rightNumber != null -> leftNumber.compareTo(rightNumber) + leftNumber != null -> -1 + rightNumber != null -> 1 + else -> left.compareTo(right, ignoreCase = 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 new file mode 100644 index 0000000000..3a9da7294b --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/DependencyWriterSpec.kt @@ -0,0 +1,312 @@ +/* + * 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.report.pom + +import io.kotest.matchers.ints.shouldBeGreaterThan +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.StringWriter +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("`DependencyWriter` should") +internal class DependencyWriterSpec { + + private val rootProject: Project = ProjectBuilder.builder().withName("root").build() + + /** + * Creates a subproject of the [rootProject] with the given name. + * + * The names of the subprojects in the tests below are chosen so that + * a module using a dependency in a lower-ranked scope — as defined by + * [ScopedDependency.dependencyPriority] — sorts first, and is thus + * encountered first when the dependencies are collected. This way, + * the tests prove that the merged scope does not depend on the order + * in which project modules are traversed. + */ + private fun subproject(name: String): Project = + ProjectBuilder.builder().withParent(rootProject).withName(name).build() + + /** + * Declares a dependency with the given [notation] in the named [configuration], + * creating it if it does not exist. + */ + private fun Project.declare(configuration: String, notation: String) { + configurations.maybeCreate(configuration) + dependencies.add(configuration, notation) + } + + @Nested inner class + `merge an artifact duplicated across modules` { + + @Test + fun `preferring the 'compile' scope over the 'test' one`() { + subproject("a-tests").declare("testImplementation", SPINE_BASE) + subproject("b-lib").declare("api", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.compile.name + } + + @Test + fun `preferring the 'runtime' scope over the 'test' one`() { + subproject("a-tests").declare("testImplementation", SPINE_BASE) + subproject("b-lib").declare("runtimeOnly", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.runtime.name + } + + @Test + fun `preferring the 'compile' scope over the 'runtime' one`() { + subproject("a-run").declare("runtimeOnly", SPINE_BASE) + subproject("b-lib").declare("implementation", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.compile.name + } + + @Test + fun `preferring the 'provided' scope over the 'test' one`() { + subproject("a-tests").declare("testImplementation", SPINE_BASE) + subproject("b-lib").declare("compileOnly", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.provided.name + } + + @Test + fun `reporting 'annotationProcessor' and 'testAnnotationProcessor' usages as 'provided'`() { + subproject("a-tests").declare("testAnnotationProcessor", SPINE_BASE) + subproject("b-codegen").declare("annotationProcessor", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.provided.name + } + + @Test + fun `preferring the 'compile' scope over the 'provided' one`() { + subproject("a-tools").declare("compileOnly", SPINE_BASE) + subproject("b-lib").declare("implementation", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.compile.name + } + + @Test + fun `preferring the 'provided' scope over the 'runtime' one`() { + subproject("a-run").declare("runtimeOnly", SPINE_BASE) + subproject("b-tools").declare("compileOnly", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.provided.name + } + + @Test + fun `retaining the newest version with the widest of its scopes`() { + subproject("a-tests").declare("testImplementation", SPINE_BASE_NEWER) + subproject("b-lib").declare("api", SPINE_BASE_NEWER) + subproject("c-old").declare("api", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.dependency().version shouldBe "2.0.1" + dependency.scopeName() shouldBe DependencyScope.compile.name + } + + @Test + fun `comparing versions semantically rather than as text`() { + subproject("a-lib").declare("api", "io.spine:spine-base:9.2.0") + subproject("b-lib").declare("api", "io.spine:spine-base:10.0.0") + + val dependency = rootProject.dependencies().single() + + dependency.dependency().version shouldBe "10.0.0" + } + + @Test + fun `ordering pre-release increments numerically`() { + subproject("a-old").declare("api", "io.spine:spine-base:2.0.0-SNAPSHOT.99") + subproject("b-new").declare("api", "io.spine:spine-base:2.0.0-SNAPSHOT.100") + + val dependency = rootProject.dependencies().single() + + dependency.dependency().version shouldBe "2.0.0-SNAPSHOT.100" + } + + @Test + fun `preferring a release over its pre-release`() { + subproject("a-snapshot").declare("api", "io.spine:spine-base:2.0.0-SNAPSHOT.100") + subproject("b-release").declare("api", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.dependency().version shouldBe "2.0.0" + } + + /** + * The `api` usage of the older `9.2.0` must affect neither the version + * nor the scope: both come from the usages of the newest `10.0.0`, + * which would lose to `9.2.0` in a plain text comparison. + */ + @Test + fun `taking the widest scope from the usages of the numerically newest version`() { + subproject("a-lib").declare("api", "io.spine:spine-base:9.2.0") + subproject("b-tests").declare("testImplementation", "io.spine:spine-base:10.0.0") + subproject("c-run").declare("runtimeOnly", "io.spine:spine-base:10.0.0") + + val dependency = rootProject.dependencies().single() + + dependency.dependency().version shouldBe "10.0.0" + dependency.scopeName() shouldBe DependencyScope.runtime.name + } + + /** + * When the newest version of an artifact occurs only in test configurations, + * the `test` scope is reported even if an older version is a production + * dependency: the report describes the retained version as it is used. + */ + @Test + fun `taking the scope only from the usages of the newest version`() { + subproject("a-tests").declare("testImplementation", SPINE_BASE_NEWER) + subproject("b-lib").declare("api", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.dependency().version shouldBe "2.0.1" + dependency.scopeName() shouldBe DependencyScope.test.name + } + + @Test + fun `keeping the 'test' scope for an artifact used only in tests`() { + subproject("a-tests").declare("testImplementation", SPINE_BASE) + subproject("b-tests").declare("testRuntimeOnly", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.scopeName() shouldBe DependencyScope.test.name + } + + @Test + fun `preferring a known scope over that of an unknown configuration`() { + subproject("a-tools").declare("protoData", SPINE_BASE) + subproject("b-tests").declare("testImplementation", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.hasDefinedScope() shouldBe true + dependency.scopeName() shouldBe DependencyScope.test.name + } + + @Test + fun `preferring the 'provided' scope over that of an unknown configuration`() { + subproject("a-tools").declare("protoData", SPINE_BASE) + subproject("b-lib").declare("compileOnly", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.hasDefinedScope() shouldBe true + dependency.scopeName() shouldBe DependencyScope.provided.name + } + } + + @Test + fun `omit the scope of a dependency coming only from an unknown configuration`() { + subproject("lib").declare("protoData", SPINE_BASE) + + val dependency = rootProject.dependencies().single() + + dependency.hasDefinedScope() shouldBe false + } + + @Test + fun `omit the version of a dependency that declares none`() { + subproject("a-bom").declare("api", "io.grpc:grpc-stub") + subproject("b-lib").declare("api", SPINE_BASE) + + val out = StringWriter() + DependencyWriter.of(rootProject).writeXmlTo(out) + val xml = out.toString() + + xml shouldContain "grpc-stub" + xml shouldNotContain "null" + xml shouldContain "2.0.0" + } + + @Test + fun `write a production dependency as 'compile' even when it is also used in tests`() { + subproject("a-tests").declare("testImplementation", SPINE_BASE) + subproject("b-lib").declare("api", SPINE_BASE) + + val out = StringWriter() + DependencyWriter.of(rootProject).writeXmlTo(out) + val xml = out.toString() + + xml shouldContain "spine-base" + xml shouldContain "compile" + xml shouldNotContain "test" + } + + @Test + fun `lay out dependencies in the conventional Maven scope order`() { + subproject("a-tests").declare("testImplementation", "io.spine:spine-testlib:2.0.0") + subproject("b-run").declare("runtimeOnly", "io.spine:spine-logging:2.0.0") + subproject("c-tools").declare("annotationProcessor", "io.spine:spine-validate:2.0.0") + subproject("d-lib").declare("api", SPINE_BASE) + + val out = StringWriter() + DependencyWriter.of(rootProject).writeXmlTo(out) + val xml = out.toString() + + val compileAt = xml.indexOf("compile") + val providedAt = xml.indexOf("provided") + val runtimeAt = xml.indexOf("runtime") + val testAt = xml.indexOf("test") + + compileAt shouldBeGreaterThan -1 + compileAt shouldBeLessThan providedAt + providedAt shouldBeLessThan runtimeAt + runtimeAt shouldBeLessThan testAt + } + + 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" + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/VersionComparatorSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/VersionComparatorSpec.kt new file mode 100644 index 0000000000..54b6439505 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/pom/VersionComparatorSpec.kt @@ -0,0 +1,87 @@ +/* + * 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.report.pom + +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.ints.shouldBeLessThan +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`VersionComparator` should") +internal class VersionComparatorSpec { + + /** + * Asserts that [newer] compares above [older], checking both directions. + */ + private fun assertNewer(newer: String, older: String) { + VersionComparator.compare(newer, older) shouldBeGreaterThan 0 + VersionComparator.compare(older, newer) shouldBeLessThan 0 + } + + @Test + fun `compare numeric segments as numbers`() { + assertNewer("10.0.0", "9.2.0") + assertNewer("2.10.0", "2.9.1") + assertNewer("1.0.10", "1.0.9") + } + + @Test + fun `compare numeric qualifier segments as numbers`() { + assertNewer("2.0.0-SNAPSHOT.100", "2.0.0-SNAPSHOT.99") + assertNewer("2.0.0-SNAPSHOT.100", "2.0.0-SNAPSHOT.070") + } + + @Test + fun `treat a release as newer than its pre-release`() { + assertNewer("2.0.0", "2.0.0-SNAPSHOT.100") + assertNewer("1.0.0", "1.0.0-RC.2") + } + + @Test + fun `treat a longer version as newer when the common segments are equal`() { + assertNewer("1.0.1", "1.0") + assertNewer("1.0.0-RC.1", "1.0.0-RC") + } + + @Test + fun `ignore the case of textual segments`() { + assertNewer("1.0.0-snapshot.10", "1.0.0-SNAPSHOT.2") + VersionComparator.compare("1.0.0-RC", "1.0.0-rc") shouldBe 0 + } + + @Test + fun `order a numeric segment before a textual one`() { + assertNewer("1.0.0-alpha", "1.0.0-1") + } + + @Test + fun `treat equal versions as equal`() { + VersionComparator.compare("2.0.0-SNAPSHOT.070", "2.0.0-SNAPSHOT.070") shouldBe 0 + VersionComparator.compare("31.1-jre", "31.1-jre") shouldBe 0 + } +} diff --git a/config b/config index 5a13d5b29d..aea90ae9cf 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 5a13d5b29d04ed414e2bc1ef3a3409a51f10846a +Subproject commit aea90ae9cf4c263d0cf5b1d6817c905923cb025e diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index 061e0b13af..86ed5ce085 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.412` +# Dependencies of `io.spine:spine-annotations:2.0.0-SNAPSHOT.413` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 26.1.0. @@ -760,14 +760,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 15 20:41:55 WEST 2026** using +This report was generated on **Tue Jun 16 17:11:42 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.412` +# Dependencies of `io.spine:spine-base:2.0.0-SNAPSHOT.413` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -1604,14 +1604,14 @@ This report was generated on **Mon Jun 15 20:41:55 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 15 20:41:55 WEST 2026** using +This report was generated on **Tue Jun 16 17:11:42 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.412` +# Dependencies of `io.spine:spine-environment:2.0.0-SNAPSHOT.413` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -2430,14 +2430,14 @@ This report was generated on **Mon Jun 15 20:41:55 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 15 20:41:55 WEST 2026** using +This report was generated on **Tue Jun 16 17:11:42 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.412` +# Dependencies of `io.spine:spine-format:2.0.0-SNAPSHOT.413` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.22.0. @@ -3336,6 +3336,6 @@ This report was generated on **Mon Jun 15 20:41:55 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon Jun 15 20:41:55 WEST 2026** using +This report was generated on **Tue Jun 16 17:11:42 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 0fb5ff07b9..cbff2813ac 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.412 +2.0.0-SNAPSHOT.413 2015 @@ -32,31 +32,26 @@ all modules and does not describe the project structure per-subproject. com.fasterxml.jackson.core jackson-databind - null compile com.fasterxml.jackson.dataformat jackson-dataformat-yaml - null compile com.fasterxml.jackson.datatype jackson-datatype-guava - null compile com.fasterxml.jackson.datatype jackson-datatype-jdk8 - null compile com.fasterxml.jackson.datatype jackson-datatype-jsr310 - null compile @@ -125,10 +120,33 @@ all modules and does not describe the project structure per-subproject. 1.0.0 compile + + com.google.auto.service + auto-service-annotations + 1.1.1 + provided + + + com.google.errorprone + error_prone_annotations + 2.36.0 + provided + + + com.google.errorprone + error_prone_type_annotations + 2.36.0 + provided + + + org.checkerframework + checker-qual + 4.2.0 + provided + com.fasterxml.jackson.module jackson-module-kotlin - null runtime @@ -191,34 +209,16 @@ all modules and does not describe the project structure per-subproject. 6.1.0 test - - com.google.auto.service - auto-service-annotations - 1.1.1 - provided - com.google.devtools.ksp symbol-processing-api 2.3.9 - - com.google.errorprone - error_prone_annotations - 2.36.0 - provided - com.google.errorprone error_prone_core 2.36.0 - - com.google.errorprone - error_prone_type_annotations - 2.36.0 - provided - com.google.protobuf protoc @@ -254,12 +254,6 @@ all modules and does not describe the project structure per-subproject. pmd-java 7.25.0 - - org.checkerframework - checker-qual - 4.2.0 - provided - org.jacoco org.jacoco.agent diff --git a/version.gradle.kts b/version.gradle.kts index de40c109da..edbaca1e74 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.412") +val versionToPublish: String by extra("2.0.0-SNAPSHOT.413")