diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md index 3e1d3d659..8a882be88 100644 --- a/.agents/skills/bump-version/SKILL.md +++ b/.agents/skills/bump-version/SKILL.md @@ -24,7 +24,9 @@ under these constraints: - Stage only `version.gradle.kts`. Any other modified files are out of scope for this skill's commit and must remain unstaged. - Use the exact subject `` Bump version -> `` `` (see step 4 of the - Checklist) with the actual new version value substituted. + Checklist) with the actual new version value substituted. Keep the + backticks around the version literal (for example, ``... -> `2.0.0``` ) and + do not escape them as ``\````. - No `git push`, `git tag`, `git rebase`, `git commit --amend`, or any other history-writing operation. Those require a separate authorization (`.agents/safety-rules.md` → *Commits and history-writing*). @@ -81,6 +83,12 @@ create the commit. Bump version -> `2.0.0-SNAPSHOT.183` ``` + Shell-safe example (no escaped backticks in the commit subject): + + ```bash + git commit -m 'Bump version -> `2.0.0-SNAPSHOT.183`' -- version.gradle.kts + ``` + Use the actual new version in the subject. Do not include unrelated files in this commit. diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt index 3cf7c7e19..4437b70db 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvmCompiler.kt @@ -46,12 +46,12 @@ object CoreJvmCompiler { /** * The version used in the build classpath. */ - const val dogfoodingVersion = "2.0.0-SNAPSHOT.065" + const val dogfoodingVersion = "2.0.0-SNAPSHOT.067" /** * The version to be used for integration tests. */ - const val version = "2.0.0-SNAPSHOT.065" + const val version = "2.0.0-SNAPSHOT.067" /** * The ID of the Gradle plugin. diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt index 4a1f79c15..cd4fc4a5c 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt @@ -36,7 +36,7 @@ object Validation { /** * The version of the Validation library artifacts. */ - const val version = "2.0.0-SNAPSHOT.444" + const val version = "2.0.0-SNAPSHOT.445" /** * The last version of Validation compatible with ProtoData. diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/CodebaseFilter.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/CodebaseFilter.kt index efdf6059b..b6451d9c2 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/CodebaseFilter.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/CodebaseFilter.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. @@ -35,8 +35,8 @@ import org.gradle.api.file.FileTree import org.gradle.api.tasks.SourceSetOutput /** - * Serves to distinguish the `.java` and `.class` files built on top of the Protobuf definitions - * from the human-created production code. + * Serves to distinguish the generated `.java` and `.kt` files (and the `.class` files + * compiled from them) from the human-created production code. * * Works on top of the passed [source][srcDirs] and [output][outputDirs] directories, by analyzing * the source file names and finding the corresponding compiler output. @@ -70,26 +70,15 @@ internal class CodebaseFilter( return humanProducedTree } - private fun generatedClassNames(): List { - val generatedSourceFiles = generatedOnly(srcDirs) - val generatedNames = mutableListOf() - generatedSourceFiles + private fun generatedClassNames(): List = + generatedOnly(srcDirs) .filter { it.exists() && it.isDirectory } - .forEach { folder -> - folder.walk() + .flatMap { root -> + root.walk() .filter { !it.isDirectory } - .forEach { file -> - file.parseName( - File::asJavaClassName, - File::asGrpcClassName, - File::asSpineClassName - )?.let { clsName -> - generatedNames.add(clsName) - } - } + .flatMap { it.classNamesIn(root) } + .toList() } - return generatedNames - } private fun log(message: String) { project.logger.info(message) diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtension.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtension.kt index ae4734c11..6b97c7baa 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtension.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtension.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. @@ -36,6 +36,11 @@ internal enum class FileExtension(val value: String) { */ JAVA_SOURCE(".java"), + /** + * Extension of a Kotlin source file. + */ + KOTLIN_SOURCE(".kt"), + /** * Extension of a Java compiled file. */ diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtensions.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtensions.kt index 89c87890d..069356213 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtensions.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/FileExtensions.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. @@ -20,7 +20,7 @@ * 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 + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF TE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ @@ -28,24 +28,34 @@ package io.spine.gradle.report.coverage import io.spine.gradle.report.coverage.FileExtension.COMPILED_CLASS import io.spine.gradle.report.coverage.FileExtension.JAVA_SOURCE +import io.spine.gradle.report.coverage.FileExtension.KOTLIN_SOURCE import io.spine.gradle.report.coverage.PathMarker.ANONYMOUS_CLASS import io.spine.gradle.report.coverage.PathMarker.GENERATED -import io.spine.gradle.report.coverage.PathMarker.GRPC_SRC_FOLDER -import io.spine.gradle.report.coverage.PathMarker.JAVA_OUTPUT_FOLDER -import io.spine.gradle.report.coverage.PathMarker.JAVA_SRC_FOLDER -import io.spine.gradle.report.coverage.PathMarker.SPINE_JAVA_SRC_FOLDER +import io.spine.gradle.report.coverage.PathMarker.MAIN_OUTPUT_FOLDER import java.io.File /** * This file contains extension methods and properties for `java.io.File`. */ +/** + * The two-part extension used by `protoc-gen-kotlin` for proto-file-scoped Kotlin + * helpers (e.g., `FooProtoKt.proto.kt`). + */ +private const val PROTO_KOTLIN_SUFFIX = ".proto.kt" + +/** + * Suffix that the Kotlin compiler appends to the file name when generating the + * synthetic file class for top-level declarations. + */ +private const val KOTLIN_FILE_CLASS_SUFFIX = "Kt" + /** * Parses the name of a class from the absolute path of this file. * * Treats the fragment between the [precedingMarker] and [extension] as the value to look for. * In case the fragment is located and it contains `/` symbols, they are treated - * as Java package delimiters and are replaced by `.` symbols before returning the value. + * as package delimiters and are replaced by `.` symbols before returning the value. * * If the absolute path of this file has either no [precedingMarker] or no [extension], * returns `null`. @@ -69,39 +79,14 @@ internal fun File.parseClassName( } /** - * Attempts to parse the file name with either of the specified [parsers], - * in their respective order. - * - * Returns the first non-`null` parsed value. + * Attempts to parse the fully-qualified class name from the absolute path of this file, + * treating it as a path to a compiled `.class` file produced by either `javac` or `kotlinc`. * - * If none of the parsers returns non-`null` value, returns `null`. - */ -internal fun File.parseName(vararg parsers: (file: File) -> String?): String? { - for (parser in parsers) { - val className = parser.invoke(this) - if (className != null) { - return className - } - } - return null -} - -/** - * Attempts to parse the Java fully-qualified class name from the absolute path of this file, - * treating it as a path to a human-produced `.java` file. - */ -internal fun File.asJavaClassName(): String? = - this.parseClassName(JAVA_SRC_FOLDER, JAVA_SOURCE) - -/** - * Attempts to parse the Java fully-qualified class name from the absolute path of this file, - * treating it as a path to a compiled `.class` file. - * - * If the `.class` file corresponds to the anonymous class, only the name of the parent - * class is returned. + * If the `.class` file corresponds to the anonymous or nested class, only the name of the + * top-level enclosing class is returned. */ internal fun File.asJavaCompiledClassName(): String? { - var className = this.parseClassName(JAVA_OUTPUT_FOLDER, COMPILED_CLASS) + var className = this.parseClassName(MAIN_OUTPUT_FOLDER, COMPILED_CLASS) if (className != null && className.contains(ANONYMOUS_CLASS.infix)) { className = className.split(ANONYMOUS_CLASS.infix)[0] } @@ -109,18 +94,44 @@ internal fun File.asJavaCompiledClassName(): String? { } /** - * Attempts to parse the Java fully-qualified class name from the absolute path of this file, - * treating it as a path to a gRPC-generated `.java` file. + * Returns the fully-qualified names of compiled JVM classes that originate from this + * source file, assuming [sourceRoot] is the source-set root under which the file was + * discovered. + * + * The shape of the returned list depends on the source file extension: + * + * - `.java` — a single FQN derived from the path relative to [sourceRoot]. + * - `.kt` — two FQNs: the declared file/class name, and the same name with `Kt` + * appended, which is the synthetic file class that Kotlin emits for top-level + * declarations. + * - `.proto.kt` — the two-part extension is stripped first; otherwise behaves + * like `.kt`. This is the convention used by `protoc-gen-kotlin` for files + * holding proto-file-scoped helpers. + * - Any other extension — an empty list. + * + * Returns an empty list if this file is not located under [sourceRoot]. */ -internal fun File.asGrpcClassName(): String? = - this.parseClassName(GRPC_SRC_FOLDER, JAVA_SOURCE) +internal fun File.classNamesIn(sourceRoot: File): List { + if (!this.startsWith(sourceRoot)) { + return emptyList() + } + val relative = this.toRelativeString(sourceRoot) + return when { + relative.endsWith(PROTO_KOTLIN_SUFFIX) -> { + val base = relative.removeSuffix(PROTO_KOTLIN_SUFFIX).toFqn() + listOf(base, base + KOTLIN_FILE_CLASS_SUFFIX) + } + relative.endsWith(KOTLIN_SOURCE.value) -> { + val base = relative.removeSuffix(KOTLIN_SOURCE.value).toFqn() + listOf(base, base + KOTLIN_FILE_CLASS_SUFFIX) + } + relative.endsWith(JAVA_SOURCE.value) -> + listOf(relative.removeSuffix(JAVA_SOURCE.value).toFqn()) + else -> emptyList() + } +} -/** - * Attempts to parse the Java fully-qualified class name from the absolute path of this file, - * treating it as a path to a Spine-generated `.java` file. - */ -internal fun File.asSpineClassName(): String? = - this.parseClassName(SPINE_JAVA_SRC_FOLDER, JAVA_SOURCE) +private fun String.toFqn(): String = this.replace(File.separatorChar, '.') /** * Tells whether this file is a part of the generated sources, and not produced by a human. diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/JacocoConfig.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/JacocoConfig.kt index 9684a92fa..06f14c431 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/JacocoConfig.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/JacocoConfig.kt @@ -38,6 +38,7 @@ import java.io.File import java.util.* import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.SourceDirectorySet import org.gradle.api.plugins.BasePlugin import org.gradle.api.tasks.Copy import org.gradle.api.tasks.SourceSetContainer @@ -150,13 +151,13 @@ class JacocoConfig( copyReports: TaskProvider ): TaskProvider { val allSourceSets = Projects(projects).sourceSets() - val mainJavaSrcDirs = allSourceSets.mainJavaSrcDirs() + val mainSrcDirs = allSourceSets.mainSrcDirs() val humanProducedSourceFolders = - FileFilter.producedByHuman(mainJavaSrcDirs) + FileFilter.producedByHuman(mainSrcDirs) val filter = CodebaseFilter( rootProject, - mainJavaSrcDirs, + mainSrcDirs, allSourceSets.mainOutputs() ) val humanProducedCompiledFiles = filter.humanProducedCompiledFiles() @@ -228,12 +229,26 @@ private class SourceSets( ) { /** - * Returns all Java source folders corresponding to the `main` source set type. + * Returns the union of Java and Kotlin source folders corresponding to the `main` + * source set across all underlying [SourceSetContainer]s. + * + * Kotlin source directories are registered as a separate [SourceDirectorySet] + * extension on the source set, not exposed via [allJava][org.gradle.api.tasks.SourceSet.getAllJava]. + * They are surfaced explicitly here so that generated Kotlin code (for example, + * the output of `protoc-gen-kotlin`) is visible to the coverage filter alongside + * the Java sources. */ - fun mainJavaSrcDirs(): Set { + fun mainSrcDirs(): Set { return sourceSets .asSequence() - .flatMap { it["main"].allJava.srcDirs } + .flatMap { container -> + val main = container["main"] + val javaDirs = main.allJava.srcDirs + val kotlinDirs = (main.extensions.findByName("kotlin") as? SourceDirectorySet) + ?.srcDirs + ?: emptySet() + javaDirs + kotlinDirs + } .toSet() } diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/PathMarker.kt b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/PathMarker.kt index 26bb13564..f91b83f17 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/PathMarker.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/report/coverage/PathMarker.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. @@ -36,26 +36,11 @@ internal enum class PathMarker(val infix: String) { */ GENERATED("generated"), - /** - * Files produced by humans and written in Java. - */ - JAVA_SRC_FOLDER("/java/"), - - /** - * Java source files generated by Spine framework. - */ - SPINE_JAVA_SRC_FOLDER("main/spine/"), - - /** - * Java source files generated by gRPC plugin. - */ - GRPC_SRC_FOLDER("/main/grpc/"), - /** * Among compiler output folders, highlights those containing the compilation result - * of human-produced Java files. + * for the `main` source set, whether produced by `javac` or `kotlinc`. */ - JAVA_OUTPUT_FOLDER("/main/"), + MAIN_OUTPUT_FOLDER("/main/"), /** * Anonymous class. diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/report/coverage/FileExtensionsTest.kt b/buildSrc/src/test/kotlin/io/spine/gradle/report/coverage/FileExtensionsTest.kt new file mode 100644 index 000000000..c53d8025a --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/report/coverage/FileExtensionsTest.kt @@ -0,0 +1,128 @@ +/* + * 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.coverage + +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.shouldBe +import java.io.File +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("`File.classNamesIn` should") +class FileExtensionsTest { + + @TempDir + lateinit var sourceRoot: File + + @Nested + inner class `for Java sources` { + + @Test + fun `return a single FQN`() { + val file = sourceRoot.touch("io/spine/example/Foo.java") + + file.classNamesIn(sourceRoot) shouldBe listOf("io.spine.example.Foo") + } + + @Test + fun `handle files placed directly under the source root`() { + val file = sourceRoot.touch("Top.java") + + file.classNamesIn(sourceRoot) shouldBe listOf("Top") + } + } + + @Nested + inner class `for Kotlin sources` { + + @Test + fun `return both the declared class and the synthetic file class`() { + val file = sourceRoot.touch("io/spine/example/Foo.kt") + + file.classNamesIn(sourceRoot) shouldContainExactlyInAnyOrder listOf( + "io.spine.example.Foo", + "io.spine.example.FooKt" + ) + } + + @Test + fun `handle the 'Kt'-suffixed file names emitted by 'protoc-gen-kotlin'`() { + val file = sourceRoot.touch("io/spine/example/ValidationErrorKt.kt") + + file.classNamesIn(sourceRoot) shouldContainExactlyInAnyOrder listOf( + "io.spine.example.ValidationErrorKt", + "io.spine.example.ValidationErrorKtKt" + ) + } + } + + @Nested + inner class `for proto-file-scoped Kotlin sources` { + + @Test + fun `strip the two-part 'proto-kt' suffix`() { + val file = sourceRoot.touch("io/spine/example/ValidationErrorProtoKt.proto.kt") + + file.classNamesIn(sourceRoot) shouldContainExactlyInAnyOrder listOf( + "io.spine.example.ValidationErrorProtoKt", + "io.spine.example.ValidationErrorProtoKtKt" + ) + } + } + + @Nested + inner class `for unsupported inputs` { + + @Test + fun `return an empty list for non-source files`() { + val file = sourceRoot.touch("io/spine/example/notes.txt") + + file.classNamesIn(sourceRoot) shouldBe emptyList() + } + + @Test + fun `return an empty list for files outside the source root`() { + val outsideRoot = File(sourceRoot.parentFile, "outside-${System.nanoTime()}") + try { + val file = outsideRoot.touch("io/spine/example/Foo.java") + + file.classNamesIn(sourceRoot) shouldBe emptyList() + } finally { + outsideRoot.deleteRecursively() + } + } + } +} + +private fun File.touch(relativePath: String): File { + val file = this.resolve(relativePath) + file.parentFile.mkdirs() + file.createNewFile() + return file +}