diff --git a/CLAUDE.md b/CLAUDE.md index 986df70b..2530b72e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,13 +81,7 @@ Two plugins, two roles: **Enum-flag classpath gotcha.** `featuredAggregation(project(":foo"))` only pulls the manifest variant — not `:foo`'s compile classpath. If `:foo` declares an `enum` flag whose enum type lives in `:foo`, the aggregator module must also declare `implementation(project(":foo"))` so the enum class is visible at compile time. Primitive-only modules need no extra dependency. -**Auto-wiring policy.** The aggregator does **not** auto-wire its output into a source set — the consumer module wires it manually because the plugin can't safely assume KMP vs. AGP vs. plain JVM: - -```kotlin -kotlin.sourceSets.getByName("commonMain").kotlin.srcDir( - tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile } -) -``` +**Auto-wiring policy.** Both `generateConfigParam` (from `dev.androidbroadcast.featured`) and `generateFeaturedRegistry` (from the aggregator plugin) auto-wire their generated output directory into the consumer module's compilation — consumers need **zero** manual `srcDir` / `dependsOn`. They write to **distinct** directories so the two outputs never overlap when a module applies both plugins: `generateConfigParam` → `build/generated/featured/commonMain`, `generateFeaturedRegistry` → `build/generated/featured/registry`. The plugin detects the applied Kotlin/Android plugin and wires the right source set: KMP `commonMain` and Kotlin/JVM `main` via a `srcDir(Provider)` (Gradle auto-infers the task dependency); plain AGP via `sourceSets["main"].kotlin.directories.add()` plus an explicit `dependsOn` on every `compile*Kotlin` / `ksp*` task (the AGP source set rejects `Provider`s at configuration time). The three branches are mutually exclusive in AGP 9, so exactly one fires per module. ## Multi-Module Pattern (canonical, demonstrated in `:sample`) diff --git a/featured-gradle-plugin/CLAUDE.md b/featured-gradle-plugin/CLAUDE.md index 62ccb67c..25dd9dee 100644 --- a/featured-gradle-plugin/CLAUDE.md +++ b/featured-gradle-plugin/CLAUDE.md @@ -53,6 +53,26 @@ To resolve flags across all modules at once, use Gradle's name-matched task invo `./gradlew resolveFeatureFlags` — Gradle runs the task in every module that applies the plugin. The plugin holds no `rootProject` access and is compatible with Gradle Project Isolation. +## Auto-wiring generated sources + +`generateConfigParam` (this plugin) and `generateFeaturedRegistry` (the +`dev.androidbroadcast.featured.application` aggregator) auto-wire their generated output into the +consumer module's compilation — consumers write **zero** manual `srcDir` / `dependsOn`. They write to +**distinct** directories so the two outputs never overlap when a module applies both plugins: +`generateConfigParam` → `build/generated/featured/commonMain`, `generateFeaturedRegistry` → +`build/generated/featured/registry`. +The plugin reacts to the applied Kotlin/Android plugin and picks the right source set +(`GeneratedSourceWiring.kt`): + +- KMP `org.jetbrains.kotlin.multiplatform` → `commonMain` via `srcDir(Provider)`; Gradle auto-infers the + task dependency. Covers `com.android.kotlin.multiplatform.library` (it co-requires the KMP plugin). +- Kotlin/JVM `org.jetbrains.kotlin.jvm` → `main` via `srcDir(Provider)`. +- Plain AGP `com.android.application` / `com.android.library` → `sourceSets["main"].kotlin.directories.add()` + plus an explicit `dependsOn` on every `compile*Kotlin` / `ksp*` task. AGP's `AndroidSourceDirectorySet` + rejects a `Provider` at configuration time, so a resolved path is used and ordering is wired by hand. + +The three branches are mutually exclusive in AGP 9, so exactly one fires per module. + ## Tests Tests use Gradle TestKit. diff --git a/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts index d8230eea..ffbacc24 100644 --- a/featured-gradle-plugin/build.gradle.kts +++ b/featured-gradle-plugin/build.gradle.kts @@ -107,6 +107,10 @@ tasks.pluginUnderTestMetadata { dependencies { compileOnly("com.android.tools.build:gradle:9.1.0") + // Kotlin Gradle plugin DSL (KotlinMultiplatformExtension / KotlinJvmProjectExtension), needed + // at compile time to auto-wire generated sources. compileOnly — the consuming build supplies + // it at runtime when it applies the Kotlin plugin, same pattern as AGP above. + compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") implementation(libs.kotlinx.serialization.json) // Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured // plugin can access AndroidComponentsExtension when wireProguardToVariants() is called. diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt index 8b173886..24de2818 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt @@ -2,7 +2,6 @@ package dev.androidbroadcast.featured.gradle import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CONFIGURATION_NAME -import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_OBJECT import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_PACKAGE import dev.androidbroadcast.featured.gradle.aggregation.GENERATE_FEATURED_REGISTRY_TASK_NAME import dev.androidbroadcast.featured.gradle.aggregation.GenerateFeaturedRegistryTask @@ -35,14 +34,11 @@ import org.gradle.api.attributes.Usage * ``` * * The generated file is written to - * `build/generated/featured/commonMain/GeneratedFeaturedRegistry.kt`. - * Wire the output directory into your source set manually — the plugin does not auto-wire - * to avoid assumptions about whether the consuming module is KMP, AGP, or plain JVM: - * ```kotlin - * kotlin.sourceSets.getByName("commonMain").kotlin.srcDir( - * tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile } - * ) - * ``` + * `build/generated/featured/registry/GeneratedFeaturedRegistry.kt` (a dedicated directory, distinct + * from the per-module `generated/featured/commonMain` used by `generateConfigParam`, so the two + * tasks never overlap when a module applies both plugins). The plugin auto-wires that directory into + * the consuming module's compilation (KMP `commonMain`, Kotlin/JVM `main`, or the AGP `main` Kotlin + * source set) — no manual `srcDir` / `dependsOn` is required. * * **Enum flag classpath requirement.** A `featuredAggregation(project(":feature:foo"))` dependency * resolves only the `featured-manifest` Gradle variant — it does NOT put the producer's enum types @@ -97,23 +93,52 @@ internal class FeaturedApplicationPlugin : Plugin { } } - target.tasks.register( - GENERATE_FEATURED_REGISTRY_TASK_NAME, - GenerateFeaturedRegistryTask::class.java, - ) { task -> - task.group = "featured" - task.description = - "Aggregates featured-manifest.json artifacts and generates GeneratedFeaturedRegistry.kt." - // Lazy artifact view — resolved at execution time, CC-compatible. - task.manifestFiles.from( - classpath.map { it.incoming.artifactView { view -> view.isLenient = false }.files }, - ) - task.outputPackage.set(FEATURED_REGISTRY_PACKAGE) - task.outputFile.convention( - target.layout.buildDirectory.file( - "generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt", - ), - ) + val registryTask = + target.tasks.register( + GENERATE_FEATURED_REGISTRY_TASK_NAME, + GenerateFeaturedRegistryTask::class.java, + ) { task -> + task.group = "featured" + task.description = + "Aggregates featured-manifest.json artifacts and generates GeneratedFeaturedRegistry.kt." + // Lazy artifact view — resolved at execution time, CC-compatible. + task.manifestFiles.from( + classpath.map { it.incoming.artifactView { view -> view.isLenient = false }.files }, + ) + task.outputPackage.set(FEATURED_REGISTRY_PACKAGE) + // Own dedicated directory — NOT the per-module `generated/featured/commonMain` that + // `generateConfigParam` uses. A module that applies both `dev.androidbroadcast.featured` + // and this aggregator would otherwise have two tasks declaring the same @OutputDirectory, + // which Gradle rejects as overlapping outputs. + task.outputDir.convention( + target.layout.buildDirectory.dir("generated/featured/registry"), + ) + } + + // Auto-wire the generated registry source into compilation. KMP / Kotlin-JVM receive the + // task-carrying @OutputDirectory provider `registryTask.flatMap { it.outputDir }`; passing it + // to `srcDir(Provider)` records the registry task as the dir's builder, so Gradle auto-infers + // the dependency for EVERY consumer (compileAndroidMain, compileKotlinJvm, sourcesJar, + // metadata, …) with no name-matched dependsOn. AGP cannot take a provider (its source set + // rejects one at configuration time), so it gets a resolved File from the pure layout path + // plus the explicit [registryTask] ordering. Only the matching branch fires. + val registryDir = registryTask.flatMap { it.outputDir } + val registryDirFile = + target.layout.buildDirectory + .dir("generated/featured/registry") + .get() + .asFile + target.plugins.withId("org.jetbrains.kotlin.multiplatform") { + wireGeneratedSourcesToKmp(target, registryDir) + } + target.plugins.withId("org.jetbrains.kotlin.jvm") { + wireGeneratedSourcesToKotlinJvm(target, registryDir) + } + target.plugins.withId("com.android.application") { + wireGeneratedSourcesToAndroid(target, registryDirFile, registryTask) + } + target.plugins.withId("com.android.library") { + wireGeneratedSourcesToAndroid(target, registryDirFile, registryTask) } } } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt index b695b0a0..c67f6e12 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt @@ -45,7 +45,7 @@ public class FeaturedPlugin : Plugin { val resolveTask = registerResolveFlagsTask(target, extension) val verifyTask = registerVerifyExpiredFlagsTask(target, extension, resolveTask) - registerConfigParamTask(target, extension, resolveTask, verifyTask) + val configParamTask = registerConfigParamTask(target, extension, resolveTask, verifyTask) val proguardTask = registerProguardTask(target, extension, resolveTask, verifyTask) registerIosConstValTask(target, extension, resolveTask, verifyTask) registerXcconfigTask(target, resolveTask, verifyTask) @@ -60,6 +60,36 @@ public class FeaturedPlugin : Plugin { target.plugins.withId("com.android.kotlin.multiplatform.library") { wireProguardToKmpLibraryVariants(target, proguardTask) } + + // Auto-wire the generated ConfigParam sources into compilation so consumer modules need + // zero manual srcDir / dependsOn boilerplate. Only the matching branch fires (the three + // plugin sets are mutually exclusive). + // + // KMP / Kotlin-JVM receive the TASK-CARRYING directory provider + // `configParamTask.flatMap { it.outputDir }` (the task's native @OutputDirectory + // DirectoryProperty). srcDir(Provider) records generateConfigParam as the dir's builder, so + // Gradle auto-infers the dependency for EVERY consumer — compileAndroidMain, + // compileKotlinJvm, jvmSourcesJar, sourcesJar, metadata, lint — without a name-matched + // dependsOn. AGP additionally needs a resolved File because its source set rejects providers + // at configuration time, so it keeps the explicit dependsOn path. + val generatedSrcDir = configParamTask.flatMap { it.outputDir } + val generatedDirFile = + target.layout.buildDirectory + .dir("generated/featured/commonMain") + .get() + .asFile + target.plugins.withId("org.jetbrains.kotlin.multiplatform") { + wireGeneratedSourcesToKmp(target, generatedSrcDir) + } + target.plugins.withId("org.jetbrains.kotlin.jvm") { + wireGeneratedSourcesToKotlinJvm(target, generatedSrcDir) + } + target.plugins.withId("com.android.application") { + wireGeneratedSourcesToAndroid(target, generatedDirFile, configParamTask) + } + target.plugins.withId("com.android.library") { + wireGeneratedSourcesToAndroid(target, generatedDirFile, configParamTask) + } } private fun registerResolveFlagsTask( @@ -96,7 +126,7 @@ public class FeaturedPlugin : Plugin { extension: FeaturedExtension, resolveTask: TaskProvider, verifyTask: TaskProvider, - ) { + ): TaskProvider = target.tasks.register(GENERATE_CONFIG_PARAM_TASK_NAME, GenerateConfigParamTask::class.java) { task -> task.group = "featured" task.description = @@ -116,7 +146,6 @@ public class FeaturedPlugin : Plugin { task.dependsOn(resolveTask) task.dependsOn(verifyTask) } - } private fun registerProguardTask( target: Project, diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt index 3d34a991..9c198aa9 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt @@ -27,15 +27,9 @@ import org.gradle.api.tasks.TaskAction * carried by the `local*` / `remote*` properties; [FeaturedPlugin] resolves the section / * module-wide fallback chain before wiring them. * - * All files are written to [outputDir] (`build/generated/featured/commonMain/`). - * Add [outputDir] to the Kotlin compilation source set: - * ```kotlin - * kotlin { - * sourceSets.commonMain.get().kotlin.srcDir( - * tasks.named("generateConfigParam").map { it.outputDir } - * ) - * } - * ``` + * All files are written to [outputDir] (`build/generated/featured/commonMain/`). [FeaturedPlugin] + * auto-wires this directory into the consumer module's compilation (KMP `commonMain`, Kotlin/JVM + * `main`, or the AGP `main` Kotlin source set) — no manual `srcDir` / `dependsOn` is needed. */ @CacheableTask public abstract class GenerateConfigParamTask : DefaultTask() { diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GeneratedSourceWiring.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GeneratedSourceWiring.kt new file mode 100644 index 00000000..f9882117 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GeneratedSourceWiring.kt @@ -0,0 +1,140 @@ +package dev.androidbroadcast.featured.gradle + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import java.io.File + +/* + * Wires a directory of plugin-generated Kotlin sources into compilation so consumer modules need + * zero manual `srcDir` / `dependsOn` boilerplate. Each helper is called lazily — only when the + * matching plugin is present (`plugins.withId`) — so the three branches never co-fire (AGP 9 makes + * `com.android.application` / `com.android.library` + `org.jetbrains.kotlin.multiplatform` a hard + * error, and Android Kotlin uses `org.jetbrains.kotlin.android`, never `.jvm`). + * + * KMP / Kotlin-JVM receive the producer task's native @OutputDirectory provider — a + * `Provider` obtained via `task.flatMap { it.outputDir }`. Passing it to + * `SourceDirectorySet.srcDir(Provider)` records the producer task as the directory's builder, so + * Gradle auto-infers the task dependency for EVERY consumer (compile*, sourcesJar, metadata, lint, …) + * with no name-matched `dependsOn`. AGP cannot take a provider (its source set rejects one at + * configuration time), so it gets a resolved [File] plus the explicit [dependOnProducer] ordering. + */ + +/** + * Wires [generatedDir] into the KMP `commonMain` source set. + * + * `KotlinSourceSet.kotlin` is Gradle's [org.gradle.api.file.SourceDirectorySet], whose + * `srcDir(Object)` accepts a [Provider]/[Directory]. `com.android.kotlin.multiplatform.library` + * co-requires `org.jetbrains.kotlin.multiplatform` (`commonMain` exists), so this branch covers it + * too. + * + * [generatedDir] is the producer task's @OutputDirectory provider (`task.flatMap { it.outputDir }`). + * `srcDir(Provider)` records the producer task as the directory's builder, so Gradle auto-infers the + * task dependency for every consumer — no explicit `dependsOn` needed. + */ +internal fun wireGeneratedSourcesToKmp( + project: Project, + generatedDir: Provider, +) { + val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) + kotlin.sourceSets + .getByName("commonMain") + .kotlin + .srcDir(generatedDir) +} + +/** + * Wires [generatedDir] into the Kotlin/JVM `main` source set. + * + * As with [wireGeneratedSourcesToKmp], `KotlinSourceSet.kotlin` is Gradle's + * [org.gradle.api.file.SourceDirectorySet]. [generatedDir] is the producer task's @OutputDirectory + * provider (`task.flatMap { it.outputDir }`); `srcDir(Provider)` records the producer task as the + * directory's builder, so Gradle auto-infers the task dependency for every consumer — no explicit + * `dependsOn` needed. + */ +internal fun wireGeneratedSourcesToKotlinJvm( + project: Project, + generatedDir: Provider, +) { + val kotlin = project.extensions.getByType(KotlinJvmProjectExtension::class.java) + kotlin.sourceSets + .getByName("main") + .kotlin + .srcDir(generatedDir) +} + +/** + * Adds an explicit `dependsOn([producer])` to every Kotlin-compile, `ksp*`, and AGP + * annotation-extraction (`extract*Annotations`) task so the generated sources are produced before + * any task that consumes the generated source dir. This is AGP-only — its + * `AndroidSourceDirectorySet` takes a resolved [File] that carries no task dependency. The KMP and + * Kotlin/JVM branches do not call this: their `srcDir(Provider)` auto-infers the dependency. + * AGP's `extractDebugAnnotations` / `extractReleaseAnnotations` read the generated source directory + * and fail validation ("uses output of ':generateConfigParam' without declaring an explicit + * dependency") if the ordering is not wired here. + * + * The Kotlin clause matches `name.contains("Kotlin")`, NOT `endsWith("Kotlin")`: KMP target compile + * tasks end with the target, not "Kotlin" — `compileKotlinJvm`, `compileDebugKotlinAndroid`, + * `compileReleaseKotlinAndroid`, `compileCommonMainKotlinMetadata`, `compileKotlinIosX64`, etc. An + * `endsWith("Kotlin")` check only catches the plain Kotlin/JVM `compileKotlin` and wires nothing for + * KMP modules, leaving the generated dir un-produced before compile. + */ +private fun dependOnProducer( + project: Project, + producer: TaskProvider<*>, +) { + project.tasks.configureEach { task: Task -> + if ((task.name.startsWith("compile") && task.name.contains("Kotlin")) || + task.name.startsWith("ksp") || + (task.name.startsWith("extract") && task.name.endsWith("Annotations")) + ) { + task.dependsOn(producer) + } + } +} + +/** + * Wires [generatedDirFile] into the plain-AGP `main` Kotlin source set. + * + * AGP's `AndroidSourceSet.kotlin` is [com.android.build.api.dsl.AndroidSourceDirectorySet] — NOT + * Gradle's `SourceDirectorySet`. It REJECTS a [Provider] at configuration time (guard + * `DISALLOW_PROVIDER_IN_ANDROID_SOURCE_SET`, default-on, hard error in AGP 10), so a resolved + * [File] path is added instead and the task ordering is wired separately via an explicit + * `dependsOn` on the Kotlin-compile and KSP tasks. `srcDir(Any)` is `@Deprecated` in AGP 9, so we + * mutate the `directories` set directly. + * + * `addGeneratedSourceDirectory(...)` is deliberately NOT used: it is a per-variant convention, and + * reusing one global [producer] task across `debug` + `release` would share a single dir and bypass + * AGP path generation. + * + * Warns and no-ops on an Android module with no Kotlin `main` source set — typically + * `com.android.library` applied without `org.jetbrains.kotlin.android`, so the generated objects + * would never compile anyway. + */ +internal fun wireGeneratedSourcesToAndroid( + project: Project, + generatedDirFile: File, + producer: TaskProvider<*>, +) { + val android = project.extensions.getByType(CommonExtension::class.java) + val mainSourceSet = + android.sourceSets.findByName("main") ?: run { + project.logger.warn( + "Featured: ${project.path} has no Android 'main' Kotlin source set — generated " + + "sources will NOT be wired into compilation. Apply the Kotlin Android plugin " + + "(org.jetbrains.kotlin.android) to this module.", + ) + return + } + // Resolved absolute path, not a Provider — the Android source set rejects providers at + // configuration time. directories is a MutableSet (srcDir(Any) is @Deprecated). + mainSourceSet.kotlin.directories.add(generatedDirFile.absolutePath) + // The File path carries no task dependency, so order the producer before the Kotlin-compile + // and KSP tasks explicitly. + dependOnProducer(project, producer) +} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt index 3804c115..858faaa8 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt @@ -7,12 +7,12 @@ import dev.androidbroadcast.featured.gradle.manifest.ValueType import kotlinx.serialization.decodeFromString import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.RegularFileProperty +import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -62,11 +62,19 @@ internal abstract class GenerateFeaturedRegistryTask : DefaultTask() { abstract val outputPackage: Property /** - * Destination for the generated `GeneratedFeaturedRegistry.kt` source file. - * Convention: `build/generated/featured/commonMain/GeneratedFeaturedRegistry.kt`. + * Output directory receiving the generated `GeneratedFeaturedRegistry.kt` source file. + * Convention (set by [FeaturedApplicationPlugin]): `build/generated/featured/registry/`. + * + * A dedicated `registry/` directory — not the per-module `commonMain/` dir used by + * `generateConfigParam` — avoids an overlapping-output conflict when a module applies both + * `dev.androidbroadcast.featured` and `dev.androidbroadcast.featured.application`. + * + * This is a [DirectoryProperty] (not a single-file output) so [FeaturedApplicationPlugin] can + * pass the task-carrying provider straight to `srcDir(Provider)`; Gradle then auto-infers the + * task dependency for every KMP/Kotlin-JVM consumer (compile*, sourcesJar, metadata, …). */ - @get:OutputFile - abstract val outputFile: RegularFileProperty + @get:OutputDirectory + abstract val outputDir: DirectoryProperty @TaskAction fun generate() { @@ -97,7 +105,7 @@ internal abstract class GenerateFeaturedRegistryTask : DefaultTask() { packageName = pkg, ) - val outFile = outputFile.get().asFile + val outFile = outputDir.get().asFile.resolve("$FEATURED_REGISTRY_OBJECT.kt") outFile.parentFile.mkdirs() outFile.writeText(source) diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/WiringProof.kt b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/WiringProof.kt new file mode 100644 index 00000000..d1661a08 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/WiringProof.kt @@ -0,0 +1,8 @@ +import dev.androidbroadcast.featured.generated.GeneratedFeaturedRegistry + +// References the auto-wired aggregator output so this fixture only compiles if the application +// plugin wired build/generated/featured/commonMain into the AGP `main` Kotlin source set AND ran +// generateFeaturedRegistry first. The compile succeeding IS the wiring assertion (stronger than a +// probe). `.all` also exercises the inlined enum reference (com.example.CheckoutVariant) from the +// aggregated feature-checkout manifest. +internal val registeredFlagCount: Int = GeneratedFeaturedRegistry.all.size diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/com/example/CheckoutVariant.kt b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/com/example/CheckoutVariant.kt new file mode 100644 index 00000000..0a67de1b --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/com/example/CheckoutVariant.kt @@ -0,0 +1,13 @@ +package com.example + +// The aggregated `feature-checkout` module declares an enum flag whose type is +// `com.example.CheckoutVariant`. `featuredAggregation(project(...))` pulls only the manifest +// variant, NOT the feature module's compile classpath, so the generated +// `GeneratedFeaturedRegistry` — which references `com.example.CheckoutVariant.LEGACY` inline — only +// compiles if this enum is visible on the app's own classpath. Defining it here (instead of +// `implementation(project(":feature-checkout"))`) keeps the fixture self-contained: the feature +// modules carry no Kotlin source, so they never need their own `:core` stub. +enum class CheckoutVariant { + LEGACY, + MODERN, +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..58d55e91 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,54 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The aggregator plugin auto-wires its generated `GeneratedFeaturedRegistry` into this app's + * compilation. That generated source `import`s `dev.androidbroadcast.featured.ConfigParam` from + * `:core`, which is a sibling Gradle project not resolvable from a TestKit fixture. This stub + * provides exactly the symbols the registry generator emits so the generated source compiles, + * exercising generated-code compilation as proof the wiring happened. The API surface is derived + * from GeneratedFeaturedRegistryGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // Public (not internal) so the inline reified factory below can call it — a public inline + // function cannot reference a non-public-API constructor. This is a test-fixture stub, not + // real :core API, so the wider visibility is harmless. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/fixtures/android-library-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/android-library-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..0aee8692 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/android-library-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,55 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The Featured plugin auto-wires its generated `ConfigParam` objects and `ConfigValues` + * extensions into this module's compilation. Those generated sources `import + * dev.androidbroadcast.featured.ConfigParam` / `ConfigValues` / `ConfigValue` from `:core`, which is + * a sibling Gradle project not resolvable from a TestKit fixture. This stub provides exactly the + * symbols the generator emits so the generated sources compile during `bundleReleaseAar`, exercising + * generated-code compilation as part of the integration test. The API surface is derived from + * ConfigParamGenerator / ExtensionFunctionGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // Public (not internal) so the inline reified factory below can call it — a public inline + // function cannot reference a non-public-API constructor. This is a test-fixture stub, not + // real :core API, so the wider visibility is harmless. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..ccd3fe14 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,55 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The Featured plugin auto-wires its generated `ConfigParam` objects and `ConfigValues` + * extensions into this module's compilation. Those generated sources `import + * dev.androidbroadcast.featured.ConfigParam` / `ConfigValues` / `ConfigValue` from `:core`, which is + * a sibling Gradle project not resolvable from a TestKit fixture. This stub provides exactly the + * symbols the generator emits so the generated sources compile during `assembleRelease`, exercising + * generated-code compilation as part of the integration test. The API surface is derived from + * ConfigParamGenerator / ExtensionFunctionGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // Public (not internal) so the inline reified factory below can call it — a public inline + // function cannot reference a non-public-API constructor. This is a test-fixture stub, not + // real :core API, so the wider visibility is harmless. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..d5ff411e --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,54 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The Featured plugin auto-wires its generated `ConfigParam` objects and `ConfigValues` + * extensions into this module's `commonMain` compilation. Those generated sources `import + * dev.androidbroadcast.featured.ConfigParam` / `ConfigValues` / `ConfigValue` from `:core`, which is + * a sibling Gradle project not resolvable from a TestKit fixture. This stub provides exactly the + * symbols the generator emits so the generated sources compile during publication, exercising + * generated-code compilation as part of the integration test. The API surface is derived from + * ConfigParamGenerator / ExtensionFunctionGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // public (not internal) so generated `ConfigParam(...)` factory calls in this + // TestKit fixture compile — mirrors the android-project / android-library stub copies. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/build.gradle.kts new file mode 100644 index 00000000..6f4759dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + // AGP 9.1.0 brings the Kotlin plugin itself; applying a separate + // org.jetbrains.kotlin.android plugin would register the `kotlin` extension twice + // ("Cannot add extension 'kotlin', already registered"). Mirror android-project. + // AGP keeps its explicit version: AGP is NOT on the plugin classpath (compileOnly), so + // withPluginClasspath() cannot supply it — only the featured plugin comes from there. + id("com.android.application") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "dev.androidbroadcast.featured.wiringapp" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + targetSdk = 36 + } +} + +featured { + localFlags { + boolean("dark_mode", default = false) + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/settings.gradle.kts new file mode 100644 index 00000000..4a9bab56 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/settings.gradle.kts @@ -0,0 +1,31 @@ +// The Featured plugin is injected via GradleRunner.withPluginClasspath(); AGP and the Kotlin +// Android plugin are resolved from the repositories declared in pluginManagement below. +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "wiring-android-app-project" diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/AndroidManifest.xml new file mode 100644 index 00000000..94cbbcfc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/kotlin/WiringProof.kt b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/kotlin/WiringProof.kt new file mode 100644 index 00000000..7da7a4dd --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/kotlin/WiringProof.kt @@ -0,0 +1,6 @@ +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsRoot + +// References the auto-wired generated object so this fixture only compiles if the plugin wired +// build/generated/featured/commonMain into the AGP `main` Kotlin source set AND ran +// generateConfigParam first. The compile succeeding IS the wiring assertion (stronger than a probe). +internal val darkModeKey: String = GeneratedLocalFlagsRoot.darkMode.key diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..5a1f12e4 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-app-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,55 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The Featured plugin auto-wires its generated `ConfigParam` objects and `ConfigValues` + * extensions into this module's compilation. Those generated sources `import + * dev.androidbroadcast.featured.ConfigParam` / `ConfigValues` / `ConfigValue` from `:core`, which is + * a sibling Gradle project not resolvable from a TestKit fixture. This stub provides exactly the + * symbols the generator emits so the generated sources compile, exercising generated-code + * compilation as proof the wiring happened. The API surface is derived from ConfigParamGenerator / + * ExtensionFunctionGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // Public (not internal) so the inline reified factory below can call it — a public inline + // function cannot reference a non-public-API constructor. This is a test-fixture stub, not + // real :core API, so the wider visibility is harmless. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/build.gradle.kts new file mode 100644 index 00000000..238210c1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + // AGP 9.1.0 brings the Kotlin plugin itself; applying a separate + // org.jetbrains.kotlin.android plugin would register the `kotlin` extension twice + // ("Cannot add extension 'kotlin', already registered"). Mirror android-project. + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "dev.androidbroadcast.featured.wiringlib" + compileSdk = 36 + defaultConfig { minSdk = 24 } +} + +featured { + localFlags { + boolean("dark_mode", default = false) + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/settings.gradle.kts new file mode 100644 index 00000000..cb150c84 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/settings.gradle.kts @@ -0,0 +1,31 @@ +// The Featured plugin is injected via GradleRunner.withPluginClasspath(); AGP and the Kotlin +// Android plugin are resolved from the repositories declared in pluginManagement below. +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "wiring-android-library-project" diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/AndroidManifest.xml new file mode 100644 index 00000000..94cbbcfc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/kotlin/WiringProof.kt b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/kotlin/WiringProof.kt new file mode 100644 index 00000000..7da7a4dd --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/kotlin/WiringProof.kt @@ -0,0 +1,6 @@ +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsRoot + +// References the auto-wired generated object so this fixture only compiles if the plugin wired +// build/generated/featured/commonMain into the AGP `main` Kotlin source set AND ran +// generateConfigParam first. The compile succeeding IS the wiring assertion (stronger than a probe). +internal val darkModeKey: String = GeneratedLocalFlagsRoot.darkMode.key diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..5a1f12e4 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-android-library-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,55 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The Featured plugin auto-wires its generated `ConfigParam` objects and `ConfigValues` + * extensions into this module's compilation. Those generated sources `import + * dev.androidbroadcast.featured.ConfigParam` / `ConfigValues` / `ConfigValue` from `:core`, which is + * a sibling Gradle project not resolvable from a TestKit fixture. This stub provides exactly the + * symbols the generator emits so the generated sources compile, exercising generated-code + * compilation as proof the wiring happened. The API surface is derived from ConfigParamGenerator / + * ExtensionFunctionGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // Public (not internal) so the inline reified factory below can call it — a public inline + // function cannot reference a non-public-API constructor. This is a test-fixture stub, not + // real :core API, so the wider visibility is harmless. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/build.gradle.kts new file mode 100644 index 00000000..fe96fa81 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + // No explicit Kotlin version: withPluginClasspath() supplies the single Kotlin copy, so the + // fixture compiles against the same Kotlin the plugin was built with. A separate `version` + // would pull a second Kotlin from the fixture's repos and split the classloader in TestKit. + id("org.jetbrains.kotlin.multiplatform") + id("dev.androidbroadcast.featured") +} + +kotlin { + jvm() + + sourceSets { + commonMain {} + } +} + +featured { + localFlags { + boolean("dark_mode", default = false) + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/settings.gradle.kts new file mode 100644 index 00000000..84087f67 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/settings.gradle.kts @@ -0,0 +1,16 @@ +// The Featured plugin and Kotlin Multiplatform plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + } +} + +rootProject.name = "wiring-kmp-project" diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/src/commonMain/kotlin/WiringProof.kt b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/src/commonMain/kotlin/WiringProof.kt new file mode 100644 index 00000000..c7a63362 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/src/commonMain/kotlin/WiringProof.kt @@ -0,0 +1,6 @@ +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsRoot + +// References the auto-wired generated object so this fixture only compiles if the plugin wired +// build/generated/featured/commonMain into the `commonMain` source set AND ran generateConfigParam +// first. The compile succeeding IS the wiring assertion (stronger than reading a src-dir probe). +internal val darkModeKey: String = GeneratedLocalFlagsRoot.darkMode.key diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..5a1f12e4 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kmp-project/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,55 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The Featured plugin auto-wires its generated `ConfigParam` objects and `ConfigValues` + * extensions into this module's compilation. Those generated sources `import + * dev.androidbroadcast.featured.ConfigParam` / `ConfigValues` / `ConfigValue` from `:core`, which is + * a sibling Gradle project not resolvable from a TestKit fixture. This stub provides exactly the + * symbols the generator emits so the generated sources compile, exercising generated-code + * compilation as proof the wiring happened. The API surface is derived from ConfigParamGenerator / + * ExtensionFunctionGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // Public (not internal) so the inline reified factory below can call it — a public inline + // function cannot reference a non-public-API constructor. This is a test-fixture stub, not + // real :core API, so the wider visibility is harmless. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/build.gradle.kts new file mode 100644 index 00000000..896e4e38 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + // No explicit Kotlin version: withPluginClasspath() supplies the single Kotlin copy, so the + // fixture compiles against the same Kotlin the plugin was built with. A separate `version` + // would pull a second Kotlin from the fixture's repos and split the classloader in TestKit. + id("org.jetbrains.kotlin.jvm") + id("dev.androidbroadcast.featured") +} + +featured { + localFlags { + boolean("dark_mode", default = false) + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/settings.gradle.kts new file mode 100644 index 00000000..24276aaf --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/settings.gradle.kts @@ -0,0 +1,16 @@ +// The Featured plugin and Kotlin/JVM plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + } +} + +rootProject.name = "wiring-kotlin-jvm-project" diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/src/main/kotlin/WiringProof.kt b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/src/main/kotlin/WiringProof.kt new file mode 100644 index 00000000..4f4ed9ec --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/src/main/kotlin/WiringProof.kt @@ -0,0 +1,6 @@ +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsRoot + +// References the auto-wired generated object so this fixture only compiles if the plugin wired +// build/generated/featured/commonMain into the `main` source set AND ran generateConfigParam +// first. The compile succeeding IS the wiring assertion (stronger than reading a src-dir probe). +internal val darkModeKey: String = GeneratedLocalFlagsRoot.darkMode.key diff --git a/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt new file mode 100644 index 00000000..5a1f12e4 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/wiring-kotlin-jvm-project/src/main/kotlin/dev/androidbroadcast/featured/FeaturedRuntimeStub.kt @@ -0,0 +1,55 @@ +package dev.androidbroadcast.featured + +import kotlin.reflect.KClass + +/* + * Minimal stand-in for the `:core` Featured runtime, scoped to this TestKit fixture only. + * + * The Featured plugin auto-wires its generated `ConfigParam` objects and `ConfigValues` + * extensions into this module's compilation. Those generated sources `import + * dev.androidbroadcast.featured.ConfigParam` / `ConfigValues` / `ConfigValue` from `:core`, which is + * a sibling Gradle project not resolvable from a TestKit fixture. This stub provides exactly the + * symbols the generator emits so the generated sources compile, exercising generated-code + * compilation as proof the wiring happened. The API surface is derived from ConfigParamGenerator / + * ExtensionFunctionGenerator and kept in lockstep with the real `:core` types. + */ + +class ConfigParam + // Public (not internal) so the inline reified factory below can call it — a public inline + // function cannot reference a non-public-API constructor. This is a test-fixture stub, not + // real :core API, so the wider visibility is harmless. + constructor( + val key: String, + val defaultValue: T, + val valueType: KClass, + val description: String? = null, + val category: String? = null, + val since: String? = null, + val enumConstants: List? = null, + ) + +inline fun ConfigParam( + key: String, + defaultValue: T, + description: String? = null, + category: String? = null, + since: String? = null, + enumConstants: List? = null, +): ConfigParam = + ConfigParam( + key = key, + defaultValue = defaultValue, + valueType = T::class, + description = description, + category = category, + since = since, + enumConstants = enumConstants, + ) + +class ConfigValue( + val value: T, +) + +class ConfigValues { + fun getValueCached(param: ConfigParam): ConfigValue = ConfigValue(param.defaultValue) +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GeneratedSourceWiringTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GeneratedSourceWiringTest.kt new file mode 100644 index 00000000..52322097 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GeneratedSourceWiringTest.kt @@ -0,0 +1,191 @@ +package dev.androidbroadcast.featured.gradle + +import dev.androidbroadcast.featured.gradle.manifest.androidSdkDirOrNull +import dev.androidbroadcast.featured.gradle.manifest.copyManifestFixture +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertTrue + +/** + * Verifies that `dev.androidbroadcast.featured` (per-module `generateConfigParam`) and + * `dev.androidbroadcast.featured.application` (`generateFeaturedRegistry`) auto-wire their generated + * `build/generated/featured/commonMain` directory into the consumer module's compilation, across + * each supported plugin shape: Kotlin/JVM, KMP, plain-AGP application, plain-AGP library, and the + * aggregator. Consumers should need ZERO manual `srcDir` / `dependsOn`. + * + * **Why compilation instead of a src-dir probe.** Each fixture ships a `WiringProof.kt` that + * references the auto-wired generated object (`GeneratedLocalFlagsRoot` / `GeneratedFeaturedRegistry`) + * plus a minimal `FeaturedRuntimeStub.kt` standing in for the `:core` types the generated sources + * import (`:core` is a sibling project, not resolvable from a TestKit fixture). Compiling the module + * therefore succeeds ONLY if the plugin wired the generated directory into the compiled source set + * AND ordered the producer task ahead of compilation. A green compile is the wiring assertion — + * strictly stronger than reading a source-set directory list, which could list a directory that is + * never actually compiled. + * + * The generated directory depends on the plugin: `build/generated/featured/commonMain` for + * `generateConfigParam` (per-module, see [GenerateConfigParamTask]) and + * `build/generated/featured/registry` for `generateFeaturedRegistry` (the aggregator, see + * GenerateFeaturedRegistryTask) — distinct directories so the two outputs never overlap when a + * module applies both plugins. + * + * AGP-based cases skip when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is unset, matching the rest of the + * integration suite. + */ +class GeneratedSourceWiringTest { + @get:Rule + val tempFolder = TemporaryFolder() + + // ── Kotlin/JVM ──────────────────────────────────────────────────────────── + + @Test + fun `kotlin-jvm compiles auto-wired generated sources`() { + val projectDir = prepareFixture("wiring-kotlin-jvm-project") + + val result = + gradleRunner(projectDir) + .withArguments("compileKotlin", "--stacktrace", "--no-build-cache") + .build() + + assertGeneratedSourceTaskRan(result, ":$GENERATE_CONFIG_PARAM_TASK_NAME") + assertTaskSucceeded(result, ":compileKotlin") + } + + // ── KMP ───────────────────────────────────────────────────────────────── + + @Test + fun `kmp compiles auto-wired generated sources in commonMain`() { + val projectDir = prepareFixture("wiring-kmp-project") + + val result = + gradleRunner(projectDir) + .withArguments("compileKotlinJvm", "--stacktrace", "--no-build-cache") + .build() + + assertGeneratedSourceTaskRan(result, ":$GENERATE_CONFIG_PARAM_TASK_NAME") + assertTaskSucceeded(result, ":compileKotlinJvm") + } + + // ── Plain AGP application ───────────────────────────────────────────────── + + @Test + fun `android application compiles auto-wired generated sources`() { + assumeAndroidSdk() + val projectDir = prepareAndroidFixture("wiring-android-app-project") + + val result = + gradleRunner(projectDir) + .withArguments("compileDebugKotlin", "--stacktrace", "--no-build-cache") + .build() + + assertGeneratedSourceTaskRan(result, ":$GENERATE_CONFIG_PARAM_TASK_NAME") + assertTaskSucceeded(result, ":compileDebugKotlin") + } + + // ── Plain AGP library ───────────────────────────────────────────────────── + + @Test + fun `android library compiles auto-wired generated sources`() { + assumeAndroidSdk() + val projectDir = prepareAndroidFixture("wiring-android-library-project") + + val result = + gradleRunner(projectDir) + .withArguments("compileDebugKotlin", "--stacktrace", "--no-build-cache") + .build() + + assertGeneratedSourceTaskRan(result, ":$GENERATE_CONFIG_PARAM_TASK_NAME") + assertTaskSucceeded(result, ":compileDebugKotlin") + } + + // ── Aggregator (registry) ───────────────────────────────────────────────── + + @Test + fun `aggregator compiles auto-wired generated registry without manual srcDir`() { + assumeAndroidSdk() + val projectDir = prepareAndroidFixture("aggregator-multi-module-project") + + val result = + gradleRunner(projectDir) + .withArguments(":app:compileDebugKotlin", "--stacktrace", "--no-build-cache") + .build() + + // The aggregator plugin must auto-wire generateFeaturedRegistry; compiling the app must + // therefore pull that producer task in as a transitive dependency and compile its output. + assertGeneratedSourceTaskRan(result, ":app:generateFeaturedRegistry") + assertTaskSucceeded(result, ":app:compileDebugKotlin") + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun prepareFixture(name: String): File { + val dir = tempFolder.newFolder(name) + copyManifestFixture(name, dir) + return dir + } + + private fun prepareAndroidFixture(name: String): File { + val dir = prepareFixture(name) + val sdkDir = androidSdkDirOrNull()!! + dir.resolve("local.properties").writeText("sdk.dir=${sdkDir.invariantSeparatorsPath}\n") + return dir + } + + private fun assumeAndroidSdk() { + assumeTrue( + "ANDROID_HOME or ANDROID_SDK_ROOT must be set to run Android wiring tests", + androidSdkDirOrNull() != null, + ) + } + + /** + * Asserts the generated-source producer task ([taskPath]) actually ran as a dependency of the + * compile task — proving the wiring registered the source dir AS a producer-backed input, not as + * a bare path. A null outcome means the task was not in the graph (wiring failed). + */ + private fun assertGeneratedSourceTaskRan( + result: BuildResult, + taskPath: String, + ) { + val outcome = result.task(taskPath)?.outcome + assertTrue( + outcome == TaskOutcome.SUCCESS || + outcome == TaskOutcome.UP_TO_DATE || + outcome == TaskOutcome.FROM_CACHE, + "Expected producer task $taskPath to run as a dependency of compilation (got $outcome). " + + "A null outcome means the generated dir was not wired with a task dependency.\n${result.output}", + ) + } + + /** + * Asserts the compile task ([taskPath]) ran and succeeded — i.e. the auto-wired generated + * sources were on the compiled source set and compiled cleanly against the fixture's `:core` + * stub. A failed or absent compile means the generated directory was not wired into the + * compilation. + */ + private fun assertTaskSucceeded( + result: BuildResult, + taskPath: String, + ) { + val outcome = result.task(taskPath)?.outcome + assertTrue( + outcome == TaskOutcome.SUCCESS || + outcome == TaskOutcome.UP_TO_DATE || + outcome == TaskOutcome.FROM_CACHE, + "Expected compile task $taskPath to succeed, proving the generated dir was compiled " + + "(got $outcome).\n${result.output}", + ) + } + + private fun gradleRunner(projectDir: File): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt index 84de5c33..fbf32e91 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt @@ -64,7 +64,7 @@ class FeaturedAggregationIntegrationTest { val generatedFile = projectDir.resolve( - "app/build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt", + "app/build/generated/featured/registry/${FEATURED_REGISTRY_OBJECT}.kt", ) assertTrue(generatedFile.exists(), "Expected generated file at ${generatedFile.path}") } @@ -77,7 +77,7 @@ class FeaturedAggregationIntegrationTest { val source = projectDir - .resolve("app/build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt") + .resolve("app/build/generated/featured/registry/${FEATURED_REGISTRY_OBJECT}.kt") .readText() assertTrue(source.contains("object $FEATURED_REGISTRY_OBJECT"), "Missing object declaration") diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt index bb4f85bd..c61d353c 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt @@ -23,7 +23,7 @@ class FeaturedAggregationParseErrorTest { File(tempDir, "featured-manifest.json").also { it.writeText("""{ "broken": json""") } - val outputFile = File(tempDir, "GeneratedFeaturedRegistry.kt") + val outputDir = File(tempDir, "registry") val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured.application") @@ -31,7 +31,7 @@ class FeaturedAggregationParseErrorTest { val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as GenerateFeaturedRegistryTask task.manifestFiles.from(badManifest) task.outputPackage.set(FEATURED_REGISTRY_PACKAGE) - task.outputFile.set(outputFile) + task.outputDir.set(outputDir) val ex = assertFailsWith { task.generate() } assertContains( diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt index 0cde118e..1a10d192 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt @@ -57,19 +57,19 @@ class GenerateFeaturedRegistryTaskRegistrationTest { } @Test - fun `generateFeaturedRegistry task outputFile path follows convention`() { + fun `generateFeaturedRegistry task outputDir path follows convention`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured.application") val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as? GenerateFeaturedRegistryTask assertNotNull(task) val outputPath = - task.outputFile + task.outputDir .get() .asFile.path assertTrue( - outputPath.endsWith("build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt"), - "Expected outputFile to end with 'build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt', got: $outputPath", + outputPath.endsWith("build/generated/featured/registry"), + "Expected outputDir to end with 'build/generated/featured/registry', got: $outputPath", ) } diff --git a/sample/CLAUDE.md b/sample/CLAUDE.md index 204e552e..ed1c4232 100644 --- a/sample/CLAUDE.md +++ b/sample/CLAUDE.md @@ -30,7 +30,7 @@ under the hood. ## Aggregation -`:sample:shared` declares `featuredAggregation(project(":sample:feature-*"))` for all three modules and wires the `generateFeaturedRegistry` task output into `commonMain`. The resulting `GeneratedFeaturedRegistry.all` is passed to `FeatureFlagsDebugScreen`. +`:sample:shared` declares `featuredAggregation(project(":sample:feature-*"))` for all three modules. The `generateFeaturedRegistry` task output is auto-wired into `commonMain` by the aggregator plugin (no manual `srcDir`). The resulting `GeneratedFeaturedRegistry.all` is passed to `FeatureFlagsDebugScreen`. ## Multi-module wiring diff --git a/sample/feature-checkout/build.gradle.kts b/sample/feature-checkout/build.gradle.kts index 7e479ef3..4bdcbc13 100644 --- a/sample/feature-checkout/build.gradle.kts +++ b/sample/feature-checkout/build.gradle.kts @@ -38,10 +38,6 @@ kotlin { api(libs.androidx.lifecycle.viewmodel) } } - - sourceSets.commonMain.get().kotlin.srcDir( - tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, - ) } featured { diff --git a/sample/feature-promotions/build.gradle.kts b/sample/feature-promotions/build.gradle.kts index e35f1b11..b801517f 100644 --- a/sample/feature-promotions/build.gradle.kts +++ b/sample/feature-promotions/build.gradle.kts @@ -38,10 +38,6 @@ kotlin { api(libs.androidx.lifecycle.viewmodel) } } - - sourceSets.commonMain.get().kotlin.srcDir( - tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, - ) } featured { diff --git a/sample/feature-ui/build.gradle.kts b/sample/feature-ui/build.gradle.kts index b0e7a591..1eeb32a0 100644 --- a/sample/feature-ui/build.gradle.kts +++ b/sample/feature-ui/build.gradle.kts @@ -38,10 +38,6 @@ kotlin { api(libs.androidx.lifecycle.viewmodel) } } - - sourceSets.commonMain.get().kotlin.srcDir( - tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, - ) } featured { diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index e9c00a98..264e4a15 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -68,10 +68,6 @@ kotlin { api(project(":sample:feature-ui")) } } - - sourceSets.commonMain.get().kotlin.srcDir( - tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile }, - ) } dependencies {