From 604c3a0b3c56f2b2414490259d1f4516e04a5150 Mon Sep 17 00:00:00 2001 From: Oliver Santana Date: Mon, 1 Jun 2026 21:01:40 +0100 Subject: [PATCH 1/2] fix: kotlin compatibility with gradle plugin --- build.gradle.kts | 2 +- .../io/flamingock/gradle/FlamingockPlugin.kt | 13 +++++++++++++ .../internal/CompilerArgsConfigurator.kt | 18 +++++++++++++++++- .../gradle/internal/DependencyConfigurator.kt | 14 ++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index ddabb0e83..2e1620342 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,7 +19,7 @@ plugins { allprojects { group = "io.flamingock" - val declaredVersion = "1.4.0-SNAPSHOT" + val declaredVersion = "1.4.1-SNAPSHOT" version = VersionManager.resolveVersion(declaredVersion, project.hasProperty("release")) extra["generalUtilVersion"] = "1.5.3" diff --git a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt index def6d48f8..f8220916b 100644 --- a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt +++ b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt @@ -45,12 +45,25 @@ import org.gradle.api.Project */ class FlamingockPlugin : Plugin { + companion object { + private const val KOTLIN_JVM_PLUGIN_ID = "org.jetbrains.kotlin.jvm" + private const val KOTLIN_KAPT_PLUGIN_ID = "org.jetbrains.kotlin.kapt" + } + override fun apply(project: Project) { // Apply java plugin if not already applied if (!project.plugins.hasPlugin("java")) { project.plugins.apply("java") } + // Kotlin projects need KAPT so the Flamingock annotation processor runs on Kotlin + // sources. Apply it automatically to preserve the plugin's zero-boilerplate intent. + project.plugins.withId(KOTLIN_JVM_PLUGIN_ID) { + if (!project.plugins.hasPlugin(KOTLIN_KAPT_PLUGIN_ID)) { + project.pluginManager.apply(KOTLIN_KAPT_PLUGIN_ID) + } + } + // Create and register the extension val extension = project.extensions.create( FlamingockConstants.EXTENSION_NAME, diff --git a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt index e944b9150..f17975555 100644 --- a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt +++ b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt @@ -23,6 +23,8 @@ import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.compile.JavaCompile import java.io.File +import java.lang.reflect.Array +import java.lang.reflect.Method /** * Passes Gradle's authoritative source/resource roots to the Flamingock annotation processor @@ -126,6 +128,7 @@ internal object CompilerArgsConfigurator { && it.parameterCount == 1 && Function1::class.java.isAssignableFrom(it.parameterTypes[0]) } ?: return + argumentsMethod.ensureAccessible() val action: (Any) -> Unit = { argsObj -> invokeArg(argsObj, SOURCES_OPTION, sourcesArg) @@ -166,6 +169,7 @@ internal object CompilerArgsConfigurator { && it.parameterTypes[1] == String::class.java } if (stringString != null) { + stringString.ensureAccessible() stringString.invoke(target, name, value) return } @@ -174,7 +178,19 @@ internal object CompilerArgsConfigurator { && it.parameterTypes[1].isArray } if (vararg != null) { - vararg.invoke(target, name, arrayOf(value)) + vararg.ensureAccessible() + vararg.invoke(target, name, singleElementArray(vararg.parameterTypes[1], value)) + } + } + + private fun Method.ensureAccessible() { + isAccessible = true + } + + private fun singleElementArray(arrayType: Class<*>, value: String): Any { + val componentType = arrayType.componentType ?: return arrayOf(value) + return Array.newInstance(componentType, 1).also { array -> + Array.set(array, 0, value) } } } diff --git a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt index f5e9a3307..36ad7d890 100644 --- a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt +++ b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt @@ -26,12 +26,20 @@ internal object DependencyConfigurator { fun configure(project: Project, extension: FlamingockExtension, version: String) { val group = FlamingockConstants.GROUP val dependencies = project.dependencies + val kaptEnabled = project.plugins.hasPlugin("org.jetbrains.kotlin.kapt") + || project.configurations.findByName("kapt") != null // Always add the annotation processor dependencies.add( "annotationProcessor", "$group:flamingock-processor:$version" ) + if (kaptEnabled) { + dependencies.add( + "kapt", + "$group:flamingock-processor:$version" + ) + } // Always add BOM for version management dependencies.add( @@ -62,6 +70,12 @@ internal object DependencyConfigurator { "annotationProcessor", "$group:mongock-support:$version" ) + if (kaptEnabled) { + dependencies.add( + "kapt", + "$group:mongock-support:$version" + ) + } } // Spring Boot integration From a45807f49b23f9d0e7cc1e3ee7249065adad5444 Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Tue, 2 Jun 2026 09:23:51 +0100 Subject: [PATCH 2/2] chore: minor refactor, review, tests and document for nexts additions --- .../docs/KOTLIN_SUPPORT_FOLLOWUPS.md | 180 ++++++++++++++++++ .../io/flamingock/gradle/FlamingockPlugin.kt | 5 +- .../internal/CompilerArgsConfigurator.kt | 26 +-- .../gradle/internal/DependencyConfigurator.kt | 2 +- .../gradle/internal/FlamingockConstants.kt | 6 + .../flamingock/gradle/FlamingockPluginTest.kt | 126 ++++++++++++ .../internal/CompilerArgsConfiguratorTest.kt | 67 +++++++ .../internal/DependencyConfiguratorTest.kt | 89 +++++++++ 8 files changed, 484 insertions(+), 17 deletions(-) create mode 100644 flamingock-gradle-plugin/docs/KOTLIN_SUPPORT_FOLLOWUPS.md create mode 100644 flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/FlamingockPluginTest.kt create mode 100644 flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/DependencyConfiguratorTest.kt diff --git a/flamingock-gradle-plugin/docs/KOTLIN_SUPPORT_FOLLOWUPS.md b/flamingock-gradle-plugin/docs/KOTLIN_SUPPORT_FOLLOWUPS.md new file mode 100644 index 000000000..42e316823 --- /dev/null +++ b/flamingock-gradle-plugin/docs/KOTLIN_SUPPORT_FOLLOWUPS.md @@ -0,0 +1,180 @@ +# Kotlin support — deferred follow-ups + +**Status:** deferred — captured for a follow-up PR. Surfaced during review of the +[#918](https://github.com/flamingock/flamingock-java/issues/918) Kotlin-support fix (auto-apply +`org.jetbrains.kotlin.kapt` when Kotlin JVM is detected, register `flamingock-processor` under +the `kapt` configuration, harden the reflective Kotlin-compile-task arg wiring). + +Two related concerns are intentionally **not** addressed in the bug-fix PR so the patch release +stays focused. They are described here in enough detail that the next PR doesn't have to +re-derive the analysis. + +--- + +## 1. Kapt auto-apply: opt-out and KSP roadmap + +### Current behaviour + +`FlamingockPlugin.apply()` reacts to `org.jetbrains.kotlin.jvm` being present and +unconditionally applies `org.jetbrains.kotlin.kapt` (via `FlamingockConstants.KAPT_PLUGIN_ID`) +unless it is already applied. There is no opt-out. `DependencyConfigurator.configure()` then +adds `flamingock-processor` (and `mongock-support` when Mongock support is enabled) under the +`kapt` configuration alongside `annotationProcessor`. + +### Why this is fine for now + +The plugin's stated positioning is zero-boilerplate (`"id 'io.flamingock'"` and the user is +done). Kotlin users hitting #918 were dropping into raw kapt configuration and getting it +wrong; auto-apply removes that failure mode. As long as `flamingock-processor` ships only as a +`javax.annotation.processing.Processor`, **kapt is the only path** for running it against +Kotlin sources — KSP cannot run a `javax.annotation.processing.Processor` directly. + +### Why it deserves an opt-out + +- **Build cost.** Kapt generates Java stubs from Kotlin sources and runs the AP against the + stubs. This is slower than KSP and historically blocks some Kotlin language features (e.g. + certain inline-class scenarios) from being visible to the AP. +- **Long-term direction.** KSP is Google/JetBrains' recommended replacement for kapt for new + annotation processing in Kotlin projects. Teams that have moved their stack to KSP will + resent kapt being silently force-applied. +- **Composability.** Users who deliberately don't want kapt — for instance, they have their + own kapt setup, or they want to register the processor under `ksp` once that's supported — + have no way to disable the auto-apply today. + +### Deferred items + +**a) Add an extension toggle.** Shape suggestion: + +```kotlin +flamingock { + // Default: "kapt" (current behaviour, no breaking change). + // Future values: "ksp" (once the processor is ported), "none" (caller wires it themselves). + kotlinAnnotationProcessor = "kapt" +} +``` + +A simpler `autoApplyKapt: Boolean` is acceptable if KSP support is genuinely far off; the +three-valued enum is preferred because it makes the eventual KSP migration a value change +rather than an option deprecation. + +Default must remain the current behaviour (`"kapt"` or `autoApplyKapt = true`) so existing +users see no change. + +Implementation touches `FlamingockExtension`, `FlamingockPlugin.apply()` (gate the +`project.plugins.withId(KOTLIN_JVM_PLUGIN_ID) { … }` block on the new value), and +`DependencyConfigurator.configure()` (skip the `kapt` dependency registration when the toggle +is `"none"`). + +**b) KSP port of `flamingock-processor`.** Larger, separate piece of work touching +`core/flamingock-processor/`. Not every javax.annotation.processing API has a direct KSP +equivalent — `RoundDiscovery`, the `Filer`-based incremental cache in `FlamingockMetadataStore`, +and the SPI-file roundtrip in `MetadataModuleIdentity` all need careful porting. Worth a +scoping issue before implementation to enumerate the API gaps. The auto-apply toggle above +should land first so the KSP work has somewhere to plug in. + +--- + +## 2. Mixed Java+Kotlin modules: duplicate same-name default stages + +### Mechanism + +When a single module has **both** Java and Kotlin sources annotated with Flamingock, the +processor runs **twice** per build — once under `compileJava` (via the `annotationProcessor` +configuration) and once under `kaptKotlin` (via the `kapt` configuration). The two invocations +happen with **different `CLASS_OUTPUT` directories**: + +- kapt: `build/tmp/kapt3/classes//` +- javac: `build/classes/java//` + +`MetadataModuleIdentity.resolve()` +(`core/flamingock-processor/src/main/java/io/flamingock/core/processor/util/MetadataModuleIdentity.java`, +the `readExistingProviderFqn` path) reads the SPI registration from `CLASS_OUTPUT` to discover +or generate a per-module suffix. Because the two `CLASS_OUTPUT` dirs are disjoint, the two +invocations each find no existing SPI file and each generate their own random 8-hex suffix. +End result: + +- kapt writes `META-INF/services/io.flamingock.internal.common.core.metadata.FlamingockMetadataProvider` + pointing at `…Impl_aaaa1111`, plus `META-INF/flamingock/metadata_aaaa1111.json`. +- javac writes the same SPI filename pointing at `…Impl_bbbb2222`, plus + `META-INF/flamingock/metadata_bbbb2222.json`. + +Both end up in the packaged JAR. At runtime, +`MetadataLoader.loadAggregated()` +(`core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/metadata/MetadataLoader.java`) +uses `ServiceLoader` to discover **both** providers. + +### Runtime aggregation behaviour + +In `MetadataLoader.CompositePipelineBuilder.buildFrom()`: + +- **System stages**: `mergeSystem()` id-deduplicates and logs at DEBUG. +- **Legacy stages**: `collapseLegacyStagesByName()` + `mergeSameNameLegacyStages()` group by + name and id-deduplicate the change set per group. +- **Default stages**: the loop in `buildFrom()` adds every default stage from every provider + into `defaultStages`. The duplicate-name detection logs a **WARN** (`"Duplicate stage name + '{}' across modules — proceeding with both."`) but **keeps both copies**. There is no + id-dedup at the default-stage level. + +For the kapt + javac case in a single module, both metadata files contain the **same** +default-stage structure (both derive it from the same `@EnableFlamingock`). Within those +stages, each AP only contributes elements visible to its own compiler (kapt: Kotlin sources; +javac: Java sources). So the composite ends up with the same default stage appearing **twice** +— one populated with the module's Kotlin changes, the other with its Java changes. + +### Why this is messy but not catastrophic + +- **No double-execution of the same change.** kapt's metadata has Kotlin changes, javac's has + Java changes; they don't overlap. +- **Audit and reporting are messy.** The execution report and the audit log will reference the + same stage name twice with different change subsets each time, which diverges from what the + user declared in `@EnableFlamingock` and is confusing to read. +- **A WARN fires every startup.** Currently the WARN is genuinely useful for catching + multi-module configuration mistakes; a fix should keep that signal alive while suppressing + it for the legitimate kapt+javac case. + +### Solution directions + +**(a) Merge-layer dedup (recommended).** Extend `MetadataLoader.CompositePipelineBuilder` to +id-union same-name default stages, mirroring `mergeSameNameLegacyStages`. Concretely: refactor +`mergeSameNameLegacyStages` and `collapseLegacyStagesByName` into a stage-type-agnostic helper +(`collapseStagesByName(List)` returning one collapsed stage per name); call it +for both `defaultStages` and `legacyStages`. Preserve the duplicate-name WARN for the +deduplicated-but-still-suspicious case where the contents weren't fully aligned across modules +(e.g. different `sourcesPackage` on stages of the same name) so genuine misconfigurations are +still surfaced. + +Pros: one fix lives in one place (commons), reuses an existing pattern, robust to any future +multi-provider scenario (not just kapt+javac). Cons: weakens the current invariant that +"default stages with the same name across modules is suspicious" — mitigated by keeping the +WARN for content mismatches. + +**(b) Shared `CLASS_OUTPUT` / shared module identity (rejected).** Make kapt and javac in the +same module reuse the same suffix. Conceptually cleaner — one metadata file end-to-end — but +the build-side mechanics are fragile: Gradle's task ordering across compilers, incremental +recompilation scenarios, and clean-build orderings all become surface area for this to drift. +The merge-layer fix achieves the same end result without that fragility. + +### Verification fixture + +A test fixture under `flamingock-gradle-plugin/src/test/resources/` (or as an integration test +under `core/flamingock-core-commons/`) exercising a module with at least one `@Change` in Java +and one in Kotlin, both under the same `@EnableFlamingock` stage. Assertions: + +1. Build succeeds, producing two `META-INF/flamingock/metadata_*.json` files in the JAR (this + is expected — the fix is at the aggregator, not the build). +2. After `MetadataLoader.loadAggregated()`, the composite pipeline contains exactly one + default stage with that name, and its change list contains both changes (one from each + language). +3. No `"Duplicate stage name … proceeding with both."` WARN is emitted when the contents are + compatible. + +--- + +## Tracking + +When the follow-up PR is filed, please open two issues referencing this document: + +1. **Kotlin annotation processing: add opt-out toggle and scope KSP port** — implements + Section 1's deferred items (a) and (b). +2. **MetadataLoader: dedupe same-name default stages across providers** — implements + Section 2's solution direction (a). diff --git a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt index f8220916b..406c0ef09 100644 --- a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt +++ b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/FlamingockPlugin.kt @@ -47,7 +47,6 @@ class FlamingockPlugin : Plugin { companion object { private const val KOTLIN_JVM_PLUGIN_ID = "org.jetbrains.kotlin.jvm" - private const val KOTLIN_KAPT_PLUGIN_ID = "org.jetbrains.kotlin.kapt" } override fun apply(project: Project) { @@ -59,8 +58,8 @@ class FlamingockPlugin : Plugin { // Kotlin projects need KAPT so the Flamingock annotation processor runs on Kotlin // sources. Apply it automatically to preserve the plugin's zero-boilerplate intent. project.plugins.withId(KOTLIN_JVM_PLUGIN_ID) { - if (!project.plugins.hasPlugin(KOTLIN_KAPT_PLUGIN_ID)) { - project.pluginManager.apply(KOTLIN_KAPT_PLUGIN_ID) + if (!project.plugins.hasPlugin(FlamingockConstants.KAPT_PLUGIN_ID)) { + project.pluginManager.apply(FlamingockConstants.KAPT_PLUGIN_ID) } } diff --git a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt index f17975555..26e249bec 100644 --- a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt +++ b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/CompilerArgsConfigurator.kt @@ -54,11 +54,7 @@ import java.lang.reflect.Method */ internal object CompilerArgsConfigurator { - private const val SOURCES_OPTION = "flamingock.sources" - private const val RESOURCES_OPTION = "flamingock.resources" - private const val KAPT_PLUGIN_ID = "org.jetbrains.kotlin.kapt" - private const val KSP_PLUGIN_ID = "com.google.devtools.ksp" fun configure(project: Project) { val sourceSets = project.extensions.findByType(SourceSetContainer::class.java) ?: return @@ -72,9 +68,9 @@ internal object CompilerArgsConfigurator { project.tasks.named(ss.compileJavaTaskName, JavaCompile::class.java) .configure(Action { - options.compilerArgs.add("-A$SOURCES_OPTION=$sourcesArg") + options.compilerArgs.add("-A${FlamingockConstants.SOURCES_OPTION}=$sourcesArg") if (resourcesArg != null) { - options.compilerArgs.add("-A$RESOURCES_OPTION=$resourcesArg") + options.compilerArgs.add("-A${FlamingockConstants.RESOURCES_OPTION}=$resourcesArg") } }) @@ -83,10 +79,10 @@ internal object CompilerArgsConfigurator { // plugins.withId so the configuration kicks in regardless of plugin-application // order; if a plugin is never applied, the callback never fires. if (ss.name == SourceSet.MAIN_SOURCE_SET_NAME) { - project.plugins.withId(KAPT_PLUGIN_ID) { + project.plugins.withId(FlamingockConstants.KAPT_PLUGIN_ID) { configureKapt(project, ss) } - project.plugins.withId(KSP_PLUGIN_ID) { + project.plugins.withId(FlamingockConstants.KSP_PLUGIN_ID) { configureKsp(project, ss) } } @@ -131,8 +127,8 @@ internal object CompilerArgsConfigurator { argumentsMethod.ensureAccessible() val action: (Any) -> Unit = { argsObj -> - invokeArg(argsObj, SOURCES_OPTION, sourcesArg) - if (resourcesArg != null) invokeArg(argsObj, RESOURCES_OPTION, resourcesArg) + invokeArg(argsObj, FlamingockConstants.SOURCES_OPTION, sourcesArg) + if (resourcesArg != null) invokeArg(argsObj, FlamingockConstants.RESOURCES_OPTION, resourcesArg) } argumentsMethod.invoke(kaptExt, action) } @@ -146,8 +142,8 @@ internal object CompilerArgsConfigurator { val resourcesArg = ss.resources.srcDirs.firstOrNull()?.absolutePath val kspExt = project.extensions.findByName("ksp") ?: return - invokeArg(kspExt, SOURCES_OPTION, sourcesArg) - if (resourcesArg != null) invokeArg(kspExt, RESOURCES_OPTION, resourcesArg) + invokeArg(kspExt, FlamingockConstants.SOURCES_OPTION, sourcesArg) + if (resourcesArg != null) invokeArg(kspExt, FlamingockConstants.RESOURCES_OPTION, resourcesArg) } private fun sourcesArgFor(ss: SourceSet): String? { @@ -187,7 +183,11 @@ internal object CompilerArgsConfigurator { isAccessible = true } - private fun singleElementArray(arrayType: Class<*>, value: String): Any { + // Visible for testing: the helper bridges Kotlin's `arrayOf(value)` (which produces + // `Object[]`) and the reflective `vararg.invoke(...)` call against a method whose array + // parameter has a more specific component type (typically `String[]`). Wrong array type + // here is exactly the silent regression the test guards against. + internal fun singleElementArray(arrayType: Class<*>, value: String): Any { val componentType = arrayType.componentType ?: return arrayOf(value) return Array.newInstance(componentType, 1).also { array -> Array.set(array, 0, value) diff --git a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt index 36ad7d890..529770c84 100644 --- a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt +++ b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/DependencyConfigurator.kt @@ -26,7 +26,7 @@ internal object DependencyConfigurator { fun configure(project: Project, extension: FlamingockExtension, version: String) { val group = FlamingockConstants.GROUP val dependencies = project.dependencies - val kaptEnabled = project.plugins.hasPlugin("org.jetbrains.kotlin.kapt") + val kaptEnabled = project.plugins.hasPlugin(FlamingockConstants.KAPT_PLUGIN_ID) || project.configurations.findByName("kapt") != null // Always add the annotation processor diff --git a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/FlamingockConstants.kt b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/FlamingockConstants.kt index 5e1f546f6..861a559b7 100644 --- a/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/FlamingockConstants.kt +++ b/flamingock-gradle-plugin/src/main/kotlin/io/flamingock/gradle/internal/FlamingockConstants.kt @@ -22,6 +22,12 @@ internal object FlamingockConstants { const val GROUP = "io.flamingock" const val EXTENSION_NAME = "flamingock" + const val SOURCES_OPTION = "flamingock.sources" + const val RESOURCES_OPTION = "flamingock.resources" + + const val KAPT_PLUGIN_ID = "org.jetbrains.kotlin.kapt" + const val KSP_PLUGIN_ID = "com.google.devtools.ksp" + val FLAMINGOCK_VERSION: String by lazy { FlamingockConstants::class.java.classLoader .getResourceAsStream("flamingock-plugin.properties") diff --git a/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/FlamingockPluginTest.kt b/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/FlamingockPluginTest.kt new file mode 100644 index 000000000..963e355b3 --- /dev/null +++ b/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/FlamingockPluginTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.gradle + +import io.flamingock.gradle.internal.FlamingockConstants +import org.gradle.api.internal.project.ProjectInternal +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * Unit tests for [FlamingockPlugin] using Gradle's `ProjectBuilder` test fixture. + * + *

Scope intentionally avoids pulling the Kotlin Gradle Plugin onto the test classpath + * (the existing {@code CompilerArgsConfiguratorTest} avoids it for the same reason). Tests + * therefore verify the wiring through {@link DependencyConfigurator}'s fallback path — + * `project.configurations.findByName("kapt") != null` — which is the same branch the real + * `org.jetbrains.kotlin.kapt` plugin triggers at apply time. The positive + * "kotlin-jvm auto-applies kapt" case is best covered by a Gradle TestKit functional test + * if/when stronger guarantees are needed; visual review of the five-line + * {@code plugins.withId(KOTLIN_JVM_PLUGIN_ID) { ... }} block in + * {@link FlamingockPlugin#apply} suffices for now. + */ +class FlamingockPluginTest { + + @TempDir + lateinit var projectDir: Path + + @Test + fun `pure-Java project does not auto-apply kotlin kapt`() { + val project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() + + project.plugins.apply(FlamingockPlugin::class.java) + (project as ProjectInternal).evaluate() + + assertFalse( + project.plugins.hasPlugin(FlamingockConstants.KAPT_PLUGIN_ID), + "Java-only project must not auto-apply org.jetbrains.kotlin.kapt" + ) + assertNull( + project.configurations.findByName("kapt"), + "Java-only project must not have a kapt configuration" + ) + } + + @Test + fun `pure-Java project registers flamingock-processor under annotationProcessor only`() { + val project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() + + project.plugins.apply(FlamingockPlugin::class.java) + (project as ProjectInternal).evaluate() + + val ap = project.configurations.getByName("annotationProcessor").allDependencies + .map { "${it.group}:${it.name}" }.toSet() + assertTrue( + ap.contains("io.flamingock:flamingock-processor"), + "annotationProcessor must include flamingock-processor. Actual: $ap" + ) + // No kapt configuration means no kapt-side registration. Already asserted above; this + // test is the positive complement to keep the failure mode explicit. + assertNull( + project.configurations.findByName("kapt"), + "Java-only project must not have a kapt configuration" + ) + } + + @Test + fun `when a kapt configuration is present, DependencyConfigurator also registers flamingock-processor under kapt`() { + // Simulate a project where kapt is "enabled" without pulling the Kotlin Gradle Plugin + // onto the test classpath: pre-create the `kapt` configuration. DependencyConfigurator + // then takes its fallback `findByName("kapt") != null` branch — the same branch the + // real kotlin.kapt plugin's apply triggers. + val project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() + project.configurations.create("kapt") + + project.plugins.apply(FlamingockPlugin::class.java) + (project as ProjectInternal).evaluate() + + val kapt = project.configurations.getByName("kapt").allDependencies + .map { "${it.group}:${it.name}" }.toSet() + assertTrue( + kapt.contains("io.flamingock:flamingock-processor"), + "kapt configuration must include flamingock-processor when kapt is enabled. Actual: $kapt" + ) + // annotationProcessor still receives it too — the fix adds to kapt, doesn't remove + // from annotationProcessor. + val ap = project.configurations.getByName("annotationProcessor").allDependencies + .map { "${it.group}:${it.name}" }.toSet() + assertTrue( + ap.contains("io.flamingock:flamingock-processor"), + "annotationProcessor must still include flamingock-processor even when kapt is wired. Actual: $ap" + ) + } + + @Test + fun `applying the plugin creates the flamingock extension`() { + val project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() + + project.plugins.apply(FlamingockPlugin::class.java) + + // Extension is registered at apply-time (not afterEvaluate); checked without + // triggering evaluate. + assertNotNull( + project.extensions.findByName(FlamingockConstants.EXTENSION_NAME), + "Applying the plugin must register the '${FlamingockConstants.EXTENSION_NAME}' extension" + ) + } +} diff --git a/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/CompilerArgsConfiguratorTest.kt b/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/CompilerArgsConfiguratorTest.kt index 7af07b3bc..978ddb0ed 100644 --- a/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/CompilerArgsConfiguratorTest.kt +++ b/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/CompilerArgsConfiguratorTest.kt @@ -198,6 +198,53 @@ class CompilerArgsConfiguratorTest { args.firstOrNull { it.startsWith("-Aflamingock.sources=") } ?.removePrefix("-Aflamingock.sources=") + @Test + fun `singleElementArray returns a typed array matching the requested component type`() { + // The original bug: `arrayOf(value)` produces `Object[]`, which fails reflection + // invocation against a `String[]`-typed vararg parameter (the typical case in real + // Kotlin/Gradle plugin signatures). The helper must build an array whose component + // type matches the parameter so reflection can dispatch. + val stringArray = CompilerArgsConfigurator.singleElementArray(Array::class.java, "v") + assertTrue(stringArray is Array<*>, "Helper must return an array") + assertEquals(String::class.java, stringArray.javaClass.componentType, + "Component type must be String, got ${stringArray.javaClass.componentType}") + assertEquals(1, (stringArray as Array<*>).size, "Array must contain exactly one element") + assertEquals("v", stringArray[0]) + } + + @Test + fun `singleElementArray falls back to Object array when componentType is null`() { + // Defensive path for the rare case where the reflected parameter type does not report + // a componentType (e.g. `Object` declared as the array slot). Keeps the helper safe + // against degenerate signatures rather than NPE'ing. + val fallback = CompilerArgsConfigurator.singleElementArray(Object::class.java, "v") + assertTrue(fallback is Array<*>, "Fallback must still produce an array") + assertEquals(1, (fallback as Array<*>).size) + assertEquals("v", fallback[0]) + } + + @Test + fun `configureKapt reflectively dispatches against a String-typed vararg parameter`() { + // Regression guard for the precise signature shape that motivated the fix: a stub + // KaptArguments whose `arg(name, vararg values: String)` declares the values as + // `String[]` at the JVM level — which is what real Kotlin libraries typically expose. + // Before the fix, `arrayOf(value)` would throw IllegalArgumentException on the + // reflective invocation here because the actual argument was an `Object[]`. + val project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() + project.plugins.apply("java") + + val kapt = StubKaptExtensionWithStringVararg() + project.extensions.add(StubKaptExtensionWithStringVararg::class.java, "kapt", kapt) + + val main = project.extensions.getByType(SourceSetContainer::class.java).getByName("main") + CompilerArgsConfigurator.configureKapt(project, main) + + assertEquals(srcDir(project, "main", "java"), + kapt.arguments.collected["flamingock.sources"]) + assertEquals(srcDir(project, "main", "resources"), + kapt.arguments.collected["flamingock.resources"]) + } + private fun resourcesArg(args: List): String? = args.firstOrNull { it.startsWith("-Aflamingock.resources=") } ?.removePrefix("-Aflamingock.resources=") @@ -243,4 +290,24 @@ class CompilerArgsConfiguratorTest { collected[k] = v } } + + /** + * Like [StubKaptArguments] but with the vararg parameter typed `String...` (i.e. JVM + * signature `String[]`) instead of `Object...`. This is the more demanding shape that the + * production reflective dispatch has to support — it's the precise reason the + * `singleElementArray` helper exists. + */ + open class StubKaptArgumentsWithStringVararg { + val collected = mutableMapOf() + fun arg(name: Any, vararg values: String) { + collected[name.toString()] = values.joinToString(" ") + } + } + + open class StubKaptExtensionWithStringVararg { + val arguments = StubKaptArgumentsWithStringVararg() + fun arguments(action: StubKaptArgumentsWithStringVararg.() -> Unit) { + arguments.action() + } + } } diff --git a/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/DependencyConfiguratorTest.kt b/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/DependencyConfiguratorTest.kt new file mode 100644 index 000000000..5025103b6 --- /dev/null +++ b/flamingock-gradle-plugin/src/test/kotlin/io/flamingock/gradle/internal/DependencyConfiguratorTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.gradle.internal + +import io.flamingock.gradle.FlamingockExtension +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * Unit tests for [DependencyConfigurator] focused on the `kaptEnabled` branch — specifically + * the `mongock-support` registration under the `kapt` configuration that the [#918] fix added. + * + *

The Kotlin Gradle Plugin is intentionally not on the test classpath. `kaptEnabled` is + * driven via the fallback `findByName("kapt") != null` path, by pre-creating the configuration + * — the same observable state the real `org.jetbrains.kotlin.kapt` plugin produces. + */ +class DependencyConfiguratorTest { + + @TempDir + lateinit var projectDir: Path + + private val version = "TEST-VERSION" + private val mongockSupport = "io.flamingock:mongock-support" + + @Test + fun `mongock-support is added to kapt when both kapt is enabled and mongock support is requested`() { + val project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() + project.plugins.apply("java") + project.configurations.create("kapt") + val extension = FlamingockExtension().apply { mongock() } + + DependencyConfigurator.configure(project, extension, version) + + val kapt = project.configurations.getByName("kapt").allDependencies + .map { "${it.group}:${it.name}" }.toSet() + assertTrue( + kapt.contains(mongockSupport), + "kapt configuration must include mongock-support when kapt + mongock support are both on. Actual: $kapt" + ) + // The annotationProcessor side also gets it; verifying both keeps the contract explicit. + val ap = project.configurations.getByName("annotationProcessor").allDependencies + .map { "${it.group}:${it.name}" }.toSet() + assertTrue( + ap.contains(mongockSupport), + "annotationProcessor must still include mongock-support alongside the kapt registration. Actual: $ap" + ) + } + + @Test + fun `mongock-support is NOT added to kapt when kapt is enabled but mongock support is not requested`() { + val project = ProjectBuilder.builder().withProjectDir(projectDir.toFile()).build() + project.plugins.apply("java") + project.configurations.create("kapt") + val extension = FlamingockExtension() // mongock() NOT called + + DependencyConfigurator.configure(project, extension, version) + + val kapt = project.configurations.getByName("kapt").allDependencies + .map { "${it.group}:${it.name}" }.toSet() + assertFalse( + kapt.contains(mongockSupport), + "kapt configuration must NOT include mongock-support when mongock is disabled. Actual: $kapt" + ) + // mongock-support also must not appear on annotationProcessor when mongock is disabled. + val ap = project.configurations.getByName("annotationProcessor").allDependencies + .map { "${it.group}:${it.name}" }.toSet() + assertFalse( + ap.contains(mongockSupport), + "annotationProcessor must NOT include mongock-support when mongock is disabled. Actual: $ap" + ) + } +}