Repository: MiSikora/laboratory
Pull request #312 (2024-05-21 00:55:03)

Modernize Gradle plugin
## :bulb: Motivation
<!-- Why did you change something? Is there an issue to link here? Or an external link? -->

Improve the Gradle plugin to use "newer" APIs.

## :pencil: Checklist
<!-- Please make sure to go through the checklist and select check-boxes appropriate for your changes. -->
- [x] I updated the [changelog](https://github.com/MiSikora/laboratory/blob/trunk/docs/changelog.md).
- [x] I updated the [documentation](https://github.com/MiSikora/laboratory/tree/trunk/docs).
- [x] ~I updated the [samples](https://github.com/MiSikora/laboratory/tree/trunk/samples).~


Patch:
diff --git a/.editorconfig b/.editorconfig
index 94c348a..715c822 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,6 +12,8 @@ max_line_length = off
 ktlint_standard_property-naming = disabled
 # I don't like it
 ktlint_standard_multiline-expression-wrapping = disabled
+# https://github.com/pinterest/ktlint/issues/2138
+ktlint_standard_annotation = disabled
 
 # Don't allow any wildcard imports
 ij_kotlin_packages_to_use_import_on_demand = unset
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 1a4186d..2280d12 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -6,4 +6,3 @@
 - [ ] I updated the [changelog](https://github.com/MiSikora/laboratory/blob/trunk/docs/changelog.md).
 - [ ] I updated the [documentation](https://github.com/MiSikora/laboratory/tree/trunk/docs).
 - [ ] I updated the [samples](https://github.com/MiSikora/laboratory/tree/trunk/samples).
-- 
\ No newline at end of file
diff --git a/docs/changelog.md b/docs/changelog.md
index ff45e71..91ce2a8 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,11 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+- Gradle tasks are now cacheable.
+- Support for typesafe project accessors when declaring dependencies.
+
 ### Changed
+- Gradle tasks are now always registered when plugin is applied in a project. If there is nothing to generate tasks will clear their respective output directories. 
 - Upgrade to Kotlin `1.9.24`.
 - Upgrade to Coroutines `1.8.1`.
 - Upgrade to Gradle `8.7`.
-- Upgrade to AGP to `8.4.0`.
+- Upgrade to AGP to `8.4.1`.
 - Upgrade to Wire `4.9.9`.
 - Upgrade to KotlinPoet `1.16.0`.
 - Upgrade to DataStore `1.1.1`.
@@ -23,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Downgrade target and source compatibilities versions to Java 11.
 - Change compile and target SDK to 34.
 
+### Fixed
+- Gradle tasks are now correctly marked as `internal`.
+
 ## [1.1.0] - 2023-06-13
 
 ### Changed
diff --git a/docs/gradle-plugin.md b/docs/gradle-plugin.md
index 3c3b884..fca6752 100644
--- a/docs/gradle-plugin.md
+++ b/docs/gradle-plugin.md
@@ -634,5 +634,7 @@ laboratory {
 
   // Includes feature flags that are used for generation of feature factories, sourced storage and option factory.
   dependency(project(":some-project"))
+  // If typesafe project accessors are enabled.
+  dependency(projects.someProject)
 }
 ```
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 80e5562..8347757 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
 [versions]
-android = "8.4.0"
+android = "8.4.1"
 coroutines = "1.8.1"
 kotest = "5.8.1"
 kotlin = "1.9.24"
@@ -22,6 +22,7 @@ androidx-viewModelKtx = { group = "androidx.lifecycle", name = "lifecycle-viewmo
 androidx-fragmentKtx = { group = "androidx.fragment", name = "fragment-ktx", version = "1.7.1" }
 androidx-viewPager2 = { group = "androidx.viewpager2", name = "viewpager2", version = "1.1.0" }
 androidx-recyclerView = { group = "androidx.recyclerview", name = "recyclerview", version = "1.3.2" }
+android-gradle-api = { group = "com.android.tools.build", name = "gradle-api", version.ref = "android" }
 android-material = { group = "com.google.android.material", name = "material", version = "1.12.0" }
 hyperion-core = { group = "com.willowtreeapps.hyperion", name = "hyperion-core", version.ref = "hyperion" }
 hyperion-plugin = { group = "com.willowtreeapps.hyperion", name = "hyperion-plugin", version.ref = "hyperion" }
@@ -37,7 +38,7 @@ androidx-test-orchestrator = { group = "androidx.test", name = "orchestrator", v
 androidx-test-runner = { group = "androidx.test", name = "runner", version = "1.5.2" }
 androidx-testExt-junitKtx = { group = "androidx.test.ext", name = "junit-ktx", version = "1.1.5" }
 
-android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android" } #
+android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android" }
 kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
 
 [plugins]
diff --git a/laboratory/gradle-plugin/api/gradle-plugin.api b/laboratory/gradle-plugin/api/gradle-plugin.api
index 2c87b7e..688f2f5 100644
--- a/laboratory/gradle-plugin/api/gradle-plugin.api
+++ b/laboratory/gradle-plugin/api/gradle-plugin.api
@@ -1,4 +1,4 @@
-public final class io/mehow/laboratory/gradle/ChildFeatureFlagsInput {
+public final class io/mehow/laboratory/gradle/ChildFeatureFlagsInput : java/io/Serializable {
 	public final fun feature (Ljava/lang/String;Lorg/gradle/api/Action;)V
 }
 
@@ -11,19 +11,14 @@ public final class io/mehow/laboratory/gradle/DeprecationLevel : java/lang/Enum
 	public static fun values ()[Lio/mehow/laboratory/gradle/DeprecationLevel;
 }
 
-public final class io/mehow/laboratory/gradle/FeatureFactoryInput {
+public final class io/mehow/laboratory/gradle/FeatureFactoryInput : java/io/Serializable {
 	public final fun getPackageName ()Ljava/lang/String;
 	public final fun isPublic ()Z
 	public final fun setPackageName (Ljava/lang/String;)V
 	public final fun setPublic (Z)V
 }
 
-public class io/mehow/laboratory/gradle/FeatureFactoryTask : org/gradle/api/DefaultTask {
-	public fun <init> ()V
-	public final fun generateFeatureFactory ()V
-}
-
-public final class io/mehow/laboratory/gradle/FeatureFlagInput {
+public final class io/mehow/laboratory/gradle/FeatureFlagInput : java/io/Serializable {
 	public final fun deprecated (Ljava/lang/String;)V
 	public final fun deprecated (Ljava/lang/String;Lio/mehow/laboratory/gradle/DeprecationLevel;)V
 	public static synthetic fun deprecated$default (Lio/mehow/laboratory/gradle/FeatureFlagInput;Ljava/lang/String;Lio/mehow/laboratory/gradle/DeprecationLevel;ILjava/lang/Object;)V
@@ -43,14 +38,10 @@ public final class io/mehow/laboratory/gradle/FeatureFlagInput {
 	public final fun withSource (Ljava/lang/String;)V
 }
 
-public class io/mehow/laboratory/gradle/FeatureFlagsTask : org/gradle/api/DefaultTask {
-	public fun <init> ()V
-	public final fun generateFeatureFlags ()V
-}
-
 public abstract class io/mehow/laboratory/gradle/LaboratoryExtension {
 	public fun <init> ()V
 	public final fun dependency (Lorg/gradle/api/Project;)V
+	public final fun dependency (Lorg/gradle/api/internal/catalog/DelegatingProjectDependency;)V
 	public final fun feature (Ljava/lang/String;Lorg/gradle/api/Action;)V
 	public final fun featureFactory ()V
 	public final fun featureFactory (Lorg/gradle/api/Action;)V
@@ -70,27 +61,17 @@ public final class io/mehow/laboratory/gradle/LaboratoryPlugin : org/gradle/api/
 	public fun apply (Lorg/gradle/api/Project;)V
 }
 
-public final class io/mehow/laboratory/gradle/OptionFactoryInput {
+public final class io/mehow/laboratory/gradle/OptionFactoryInput : java/io/Serializable {
 	public final fun getPackageName ()Ljava/lang/String;
 	public final fun isPublic ()Z
 	public final fun setPackageName (Ljava/lang/String;)V
 	public final fun setPublic (Z)V
 }
 
-public class io/mehow/laboratory/gradle/OptionFactoryTask : org/gradle/api/DefaultTask {
-	public fun <init> ()V
-	public final fun generateSourcedFeatureStorage ()V
-}
-
-public final class io/mehow/laboratory/gradle/SourcedFeatureStorageInput {
+public final class io/mehow/laboratory/gradle/SourcedFeatureStorageInput : java/io/Serializable {
 	public final fun getPackageName ()Ljava/lang/String;
 	public final fun isPublic ()Z
 	public final fun setPackageName (Ljava/lang/String;)V
 	public final fun setPublic (Z)V
 }
 
-public class io/mehow/laboratory/gradle/SourcedFeatureStorageTask : org/gradle/api/DefaultTask {
-	public fun <init> ()V
-	public final fun generateSourcedFeatureStorage ()V
-}
-
diff --git a/laboratory/gradle-plugin/build.gradle.kts b/laboratory/gradle-plugin/build.gradle.kts
index 738d663..087c408 100644
--- a/laboratory/gradle-plugin/build.gradle.kts
+++ b/laboratory/gradle-plugin/build.gradle.kts
@@ -40,7 +40,7 @@ tasks.withType<PluginUnderTestMetadata>().configureEach {
 }
 
 dependencies {
-  compileOnly(libs.android.gradlePlugin)
+  compileOnly(libs.android.gradle.api)
 
   implementation(projects.laboratory.generator)
   implementation(libs.kotlin.gradlePlugin)
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/ChildFeatureFlagsInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/ChildFeatureFlagsInput.kt
index b3d5f31..cfe62d9 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/ChildFeatureFlagsInput.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/ChildFeatureFlagsInput.kt
@@ -1,15 +1,16 @@
 package io.mehow.laboratory.gradle
 
-import io.mehow.laboratory.generator.Supervisor
+import io.mehow.laboratory.generator.FeatureFlagModel
 import org.gradle.api.Action
+import java.io.Serializable
 
 /**
  * An entry point for configuration of supervised feature flags code generation.
  */
 public class ChildFeatureFlagsInput internal constructor(
-  private val packageNameProvider: () -> String,
-  private val supervisor: () -> Supervisor,
-) {
+  private val packageNameProvider: PackageNameProvider,
+  private val supervisor: SupervisorInput,
+) : Serializable {
   private val mutableFeatureInputs = mutableListOf<FeatureFlagInput>()
 
   private val featureInputs: List<FeatureFlagInput> = mutableFeatureInputs
@@ -27,5 +28,9 @@ public class ChildFeatureFlagsInput internal constructor(
     }
   }
 
-  internal fun toModels() = featureInputs.flatMap(FeatureFlagInput::toModels)
+  internal fun toModels(): List<FeatureFlagModel> = featureInputs.flatMap(FeatureFlagInput::toModels)
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/DeprecationInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/DeprecationInput.kt
new file mode 100644
index 0000000..4a39d56
--- /dev/null
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/DeprecationInput.kt
@@ -0,0 +1,16 @@
+package io.mehow.laboratory.gradle
+
+import io.mehow.laboratory.generator.Deprecation
+import java.io.Serializable
+import kotlin.DeprecationLevel
+
+internal class DeprecationInput(
+  private val message: String,
+  private val level: DeprecationLevel,
+) : Serializable {
+  fun toModel() = Deprecation(message, level)
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
+}
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryInput.kt
index 71fd215..5985034 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryInput.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryInput.kt
@@ -5,13 +5,14 @@ import io.mehow.laboratory.generator.FeatureFactoryModel
 import io.mehow.laboratory.generator.FeatureFlagModel
 import io.mehow.laboratory.generator.Visibility.Internal
 import io.mehow.laboratory.generator.Visibility.Public
+import java.io.Serializable
 
 /**
  * Representation of a generated feature factory class.
  */
 public class FeatureFactoryInput internal constructor(
-  private val packageNameProvider: () -> String,
-) {
+  packageNameProvider: PackageNameProvider,
+) : Serializable {
   /**
    * Sets whether the generated feature factory should be public or internal.
    */
@@ -20,14 +21,22 @@ public class FeatureFactoryInput internal constructor(
   /**
    * Sets package name of the generated feature factory. Overwrites any previously set values.
    */
-  public var packageName: String? = null
+  public var packageName: String?
+    get() = packageNameProvider.value
+    set(value) = packageNameProvider.setValue(value)
+
+  private val packageNameProvider = PackageNameProvider(packageNameProvider)
 
   internal fun toModel(
     features: List<FeatureFlagModel>,
     simpleName: String,
   ) = FeatureFactoryModel(
     visibility = if (isPublic) Public else Internal,
-    className = ClassName(packageName ?: packageNameProvider(), simpleName),
+    className = ClassName(packageNameProvider.value.orEmpty(), simpleName),
     features = features,
   )
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryTask.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryTask.kt
index 828b2e3..d80e4e7 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryTask.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFactoryTask.kt
@@ -1,28 +1,33 @@
 package io.mehow.laboratory.gradle
 
-import io.mehow.laboratory.generator.FeatureFlagModel
-import org.gradle.api.DefaultTask
-import org.gradle.api.tasks.Internal
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.TaskAction
-import java.io.File
-
-public open class FeatureFactoryTask : DefaultTask() {
-  @get:Internal internal lateinit var factory: FeatureFactoryInput
-
-  @get:Internal internal lateinit var features: List<FeatureFlagInput>
-
-  @get:Internal internal lateinit var codeGenDir: File
-
-  @get:Internal internal lateinit var factoryClassName: String
-
-  @get:Internal internal lateinit var factoryFunctionName: String
-
-  @get:Internal internal lateinit var featureModelsMapper: (List<FeatureFlagModel>) -> List<FeatureFlagModel>
-
-  @TaskAction public fun generateFeatureFactory() {
-    val featureModels = featureModelsMapper(features.flatMap(FeatureFlagInput::toModels))
-    val factoryModel = factory.toModel(featureModels, factoryClassName)
-    codeGenDir.deleteRecursively()
-    factoryModel.prepare(factoryFunctionName).writeTo(codeGenDir)
+import javax.inject.Inject
+
+internal abstract class FeatureFactoryTask @Inject constructor(
+  objects: ObjectFactory,
+) : OutputTask() {
+  @Input @Optional
+  val factory: Property<FeatureFactoryInput?> = objects.property(FeatureFactoryInput::class.java)
+
+  @Input
+  val features: ListProperty<FeatureFlagInput> = objects.listProperty(FeatureFlagInput::class.java)
+
+  @OutputDirectory
+  override val outputDirectory: DirectoryProperty = objects.directoryProperty()
+
+  @TaskAction
+  fun generateFeatureFactory() {
+    outputDirectory.get().asFile.deleteRecursively()
+    factory.orNull
+      ?.toModel(features.get().flatMap(FeatureFlagInput::toModels), "GeneratedFeatureFactory")
+      ?.prepare("featureGenerated")
+      ?.writeTo(outputDirectory.get().asFile)
   }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagInput.kt
index 2c9ec68..5999ab7 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagInput.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagInput.kt
@@ -1,23 +1,19 @@
 package io.mehow.laboratory.gradle
 
 import com.squareup.kotlinpoet.ClassName
-import io.mehow.laboratory.generator.Deprecation
 import io.mehow.laboratory.generator.FeatureFlagModel
-import io.mehow.laboratory.generator.FeatureFlagOption
-import io.mehow.laboratory.generator.Supervisor
-import io.mehow.laboratory.generator.Visibility.Internal
-import io.mehow.laboratory.generator.Visibility.Public
-import io.mehow.laboratory.gradle.DeprecationLevel.Warning
+import io.mehow.laboratory.generator.Visibility
 import org.gradle.api.Action
+import java.io.Serializable
 
 /**
  * Representation of a generated feature flag. It must have at least one value and exactly one default value.
  */
 public class FeatureFlagInput internal constructor(
   private val name: String,
-  private val packageNameProvider: () -> String,
-  private val supervisor: (() -> Supervisor)? = null,
-) {
+  packageNameProvider: PackageNameProvider,
+  private val supervisor: SupervisorInput?,
+) : Serializable {
   /**
    * Sets whether the generated feature flag should be public or internal.
    */
@@ -26,7 +22,11 @@ public class FeatureFlagInput internal constructor(
   /**
    * Sets package name of the generated feature flag. Overwrites any previously set values.
    */
-  public var packageName: String? = null
+  public var packageName: String?
+    get() = packageNameProvider.value
+    set(value) = packageNameProvider.setValue(value)
+
+  private val packageNameProvider = PackageNameProvider(packageNameProvider)
 
   /**
    * Sets description of the generated feature flag.
@@ -38,12 +38,14 @@ public class FeatureFlagInput internal constructor(
    */
   public var key: String? = null
 
-  private val options: MutableList<FeatureFlagOption> = mutableListOf()
+  private val options = mutableListOf<FeatureFlagOptionInput>()
+
+  private val childFeatureInputs = mutableListOf<ChildFeatureFlagsInput>()
 
   /**
    * Adds a feature option.
    */
-  public fun withOption(name: String): Unit = withOption(name) { }
+  public fun withOption(name: String): Unit = withOption(name, action = {})
 
   /**
    * Adds a feature option and configures features flags supervised by it.
@@ -51,14 +53,13 @@ public class FeatureFlagInput internal constructor(
   public fun withOption(
     name: String,
     action: Action<ChildFeatureFlagsInput>,
-  ): Unit =
-    withOption(name, isDefault = false, action)
+  ): Unit = withOption(name, isDefault = false, action = action)
 
   /**
    * Adds a feature value that will be used as a default value.
    * Exactly one value must be set with this method.
    */
-  public fun withDefaultOption(name: String): Unit = withDefaultOption(name) { }
+  public fun withDefaultOption(name: String): Unit = withDefaultOption(name, action = {})
 
   /**
    * Adds a feature value that will be used as a default value and configures features flags supervised by it.
@@ -67,68 +68,68 @@ public class FeatureFlagInput internal constructor(
   public fun withDefaultOption(
     name: String,
     action: Action<ChildFeatureFlagsInput>,
-  ): Unit =
-    withOption(name, isDefault = true, action)
-
-  private val childFeatureInputs = mutableListOf<ChildFeatureFlagsInput>()
+  ): Unit = withOption(name, isDefault = true, action = action)
 
   private fun withOption(
     name: String,
     isDefault: Boolean,
     action: Action<ChildFeatureFlagsInput>,
   ) {
-    val option = FeatureFlagOption(name, isDefault)
+    val option = FeatureFlagOptionInput(name, isDefault)
+    val supervisor = SupervisorInput(this, option)
+    childFeatureInputs += ChildFeatureFlagsInput(packageNameProvider, supervisor).apply(action::execute)
     options += option
-    val packageNameProvider = { packageName ?: packageNameProvider() }
-    val supervisorBuilder = { Supervisor(toModel(), option) }
-    childFeatureInputs += ChildFeatureFlagsInput(packageNameProvider, supervisorBuilder).let { input ->
-      action.execute(input)
-      return@let input
-    }
   }
 
-  private val sources: MutableList<FeatureFlagOption> = mutableListOf()
+  private val sources = mutableListOf<FeatureFlagOptionInput>()
 
   /**
    * Adds a feature flag source. Any sources that are named "Local", or any variation of this word,
    * will be filtered out.
    */
-  public fun withSource(name: String) {
-    sources += FeatureFlagOption(name)
-  }
+  public fun withSource(name: String): Unit = withSource(name, isDefault = false)
 
   /**
    * Adds a feature flag source that will be used a default source. Any sources that are named "Local",
    * or any variation of this word, will be filtered out.
    * At most one value can be set with this method.
    */
-  public fun withDefaultSource(name: String) {
-    sources += FeatureFlagOption(name, isDefault = true)
+  public fun withDefaultSource(name: String): Unit = withSource(name, isDefault = true)
+
+  private fun withSource(
+    name: String,
+    isDefault: Boolean,
+  ) {
+    val option = FeatureFlagOptionInput(name, isDefault)
+    sources += option
   }
 
-  private var deprecation: Deprecation? = null
+  private var deprecation: DeprecationInput? = null
 
   /**
    * Annotates a feature flag as deprecated.
    */
   @JvmOverloads public fun deprecated(
     message: String,
-    level: DeprecationLevel = Warning,
+    level: DeprecationLevel = DeprecationLevel.Warning,
   ) {
-    deprecation = Deprecation(message, level.kotlinLevel)
+    deprecation = DeprecationInput(message, level.kotlinLevel)
   }
 
-  private fun toModel() = FeatureFlagModel(
-    visibility = if (isPublic) Public else Internal,
-    className = ClassName(packageName ?: packageNameProvider(), name),
-    options = options,
-    sourceOptions = sources,
+  internal fun toModel() = FeatureFlagModel(
+    visibility = if (isPublic) Visibility.Public else Visibility.Internal,
+    className = ClassName(packageNameProvider.value.orEmpty(), name),
+    options = options.map(FeatureFlagOptionInput::toModel),
+    sourceOptions = sources.map(FeatureFlagOptionInput::toModel),
     key = key,
     description = description.orEmpty(),
-    deprecation = deprecation,
-    supervisor = supervisor?.invoke(),
+    deprecation = deprecation?.toModel(),
+    supervisor = supervisor?.toModel(),
   )
 
-  internal fun toModels(): List<FeatureFlagModel> =
-    listOf(toModel()) + childFeatureInputs.flatMap(ChildFeatureFlagsInput::toModels)
+  internal fun toModels() = listOf(toModel()) + childFeatureInputs.flatMap(ChildFeatureFlagsInput::toModels)
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagOptionInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagOptionInput.kt
new file mode 100644
index 0000000..ce7fdf4
--- /dev/null
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagOptionInput.kt
@@ -0,0 +1,15 @@
+package io.mehow.laboratory.gradle
+
+import io.mehow.laboratory.generator.FeatureFlagOption
+import java.io.Serializable
+
+internal class FeatureFlagOptionInput(
+  private val name: String,
+  private val isDefault: Boolean,
+) : Serializable {
+  fun toModel() = FeatureFlagOption(name, isDefault)
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
+}
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagsTask.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagsTask.kt
index 75f0954..e135a2d 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagsTask.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureFlagsTask.kt
@@ -1,20 +1,28 @@
 package io.mehow.laboratory.gradle
 
-import org.gradle.api.DefaultTask
-import org.gradle.api.tasks.Internal
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.TaskAction
-import java.io.File
+import javax.inject.Inject
 
-public open class FeatureFlagsTask : DefaultTask() {
-  @get:Internal internal lateinit var features: List<FeatureFlagInput>
+internal abstract class FeatureFlagsTask @Inject constructor(
+  objects: ObjectFactory,
+) : OutputTask() {
+  @Input
+  val inputFlags: ListProperty<FeatureFlagInput> = objects.listProperty(FeatureFlagInput::class.java)
 
-  @get:Internal internal lateinit var codeGenDir: File
+  @OutputDirectory
+  override val outputDirectory: DirectoryProperty = objects.directoryProperty()
 
-  @TaskAction public fun generateFeatureFlags() {
-    val featureFlagModels = features.flatMap(FeatureFlagInput::toModels)
-
-    for (model in featureFlagModels) {
-      model.prepare().writeTo(codeGenDir)
-    }
+  @TaskAction
+  fun generateFeatureFlags() {
+    outputDirectory.get().asFile.deleteRecursively()
+    val outputPath = outputDirectory.get().asFile
+    inputFlags.get()
+      .flatMap(FeatureFlagInput::toModels)
+      .forEach { model -> model.prepare().writeTo(outputPath) }
   }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureSourceFactoryTask.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureSourceFactoryTask.kt
new file mode 100644
index 0000000..8275331
--- /dev/null
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/FeatureSourceFactoryTask.kt
@@ -0,0 +1,39 @@
+package io.mehow.laboratory.gradle
+
+import io.mehow.laboratory.generator.FeatureFlagModel
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.TaskAction
+import javax.inject.Inject
+
+internal abstract class FeatureSourceFactoryTask @Inject constructor(
+  objects: ObjectFactory,
+) : OutputTask() {
+  @Input @Optional
+  val factory: Property<FeatureFactoryInput?> = objects.property(FeatureFactoryInput::class.java)
+
+  @Input
+  val features: ListProperty<FeatureFlagInput> = objects.listProperty(FeatureFlagInput::class.java)
+
+  @OutputDirectory
+  override val outputDirectory: DirectoryProperty = objects.directoryProperty()
+
+  @TaskAction
+  fun generateFeatureFactory() {
+    outputDirectory.get().asFile.deleteRecursively()
+    factory.orNull
+      ?.toModel(
+        features.get()
+          .flatMap(FeatureFlagInput::toModels)
+          .mapNotNull(FeatureFlagModel::source),
+        "GeneratedFeatureSourceFactory",
+      )
+      ?.prepare("featureSourceGenerated")
+      ?.writeTo(outputDirectory.get().asFile)
+  }
+}
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryExtension.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryExtension.kt
index 69a7828..311b787 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryExtension.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryExtension.kt
@@ -2,6 +2,7 @@ package io.mehow.laboratory.gradle
 
 import org.gradle.api.Action
 import org.gradle.api.Project
+import org.gradle.api.internal.catalog.DelegatingProjectDependency
 
 /**
  * An entry point for configuration of feature flags code generation.
@@ -12,34 +13,28 @@ public abstract class LaboratoryExtension {
    * Package names can be individually overwritten in each generating block
    */
   public var packageName: String
-    get() = packageNameProvider()
+    get() = packageNameProvider.value.orEmpty()
     set(value) {
-      packageNameProvider.value = value
+      packageNameProvider.setValue(value)
     }
 
   private val packageNameProvider = PackageNameProvider()
 
-  private val mutableFeatureInputs = mutableListOf<FeatureFlagInput>()
-
-  internal val featureInputs: List<FeatureFlagInput> = mutableFeatureInputs
-
   private val mutableDependencies = mutableListOf<FeatureFlagInput>()
 
-  internal val factoryFeatureInputs get() = featureInputs + mutableDependencies
+  internal val factoryFeatureFlags get() = featureFlags + mutableDependencies
 
   internal lateinit var project: Project
 
-  /**
-   * Generates a new feature in this module.
-   */
+  private val mutableFeatureInputs = mutableListOf<FeatureFlagInput>()
+
+  internal val featureFlags: List<FeatureFlagInput> = mutableFeatureInputs
+
   public fun feature(
     name: String,
     action: Action<FeatureFlagInput>,
   ) {
-    mutableFeatureInputs += FeatureFlagInput(name, packageNameProvider).let { input ->
-      action.execute(input)
-      return@let input
-    }
+    mutableFeatureInputs += FeatureFlagInput(name, packageNameProvider, supervisor = null).apply(action::execute)
   }
 
   internal var factoryInput: FeatureFactoryInput? = null
@@ -127,16 +122,23 @@ public abstract class LaboratoryExtension {
     }
   }
 
+  /**
+   * Includes a [project] during feature flags contribution to [featureFactory], [featureSourcesFactory],
+   * [sourcedStorage] or [optionFactory]. Included project must have Laboratory plugin applied.
+   */
+  public fun dependency(project: DelegatingProjectDependency) {
+    dependency(project.dependencyProject)
+  }
+
   /**
    * Includes a [project] during feature flags contribution to [featureFactory], [featureSourcesFactory],
    * [sourcedStorage] or [optionFactory]. Included project must have Laboratory plugin applied.
    */
   public fun dependency(project: Project) {
     this.project.evaluationDependsOn(project.path)
-    val laboratoryExtension =
-      requireNotNull(project.extensions.findByType(LaboratoryExtension::class.java)) {
-        "Cannot depend on a project without laboratory plugin"
-      }
-    mutableDependencies += laboratoryExtension.featureInputs
+    val laboratoryExtension = requireNotNull(project.extensions.findByType(LaboratoryExtension::class.java)) {
+      "Cannot depend on a project without laboratory plugin"
+    }
+    mutableDependencies += laboratoryExtension.featureFlags
   }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryPlugin.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryPlugin.kt
index 7f5720c..68eb4af 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryPlugin.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/LaboratoryPlugin.kt
@@ -1,157 +1,151 @@
 package io.mehow.laboratory.gradle
 
-import io.mehow.laboratory.generator.FeatureFlagModel
+import com.android.build.api.variant.AndroidComponentsExtension
 import org.gradle.api.Plugin
 import org.gradle.api.Project
 import org.gradle.api.Task
 import org.gradle.api.tasks.TaskProvider
-import java.io.File
-import java.util.concurrent.atomic.AtomicBoolean
+import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetContainer
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
 
 public class LaboratoryPlugin : Plugin<Project> {
-  private val hasAndroid = AtomicBoolean(false)
-  private val hasKotlin = AtomicBoolean(false)
-  private lateinit var extension: LaboratoryExtension
-
-  override fun apply(project: Project) {
-    extension = project.extensions.create(PluginName, LaboratoryExtension::class.java).apply {
-      this.project = project
+  override fun apply(target: Project) {
+    val extension = target.extensions.create(PluginName, LaboratoryExtension::class.java).apply {
+      this.project = target
     }
-    project.setUpKotlinProject()
-    project.setUpAndroidProject()
-  }
 
-  private fun Project.setUpKotlinProject() {
-    val kotlinPluginHandler = { _: Plugin<*> -> hasKotlin.set(true) }
-    plugins.withId("org.jetbrains.kotlin.android", kotlinPluginHandler)
-    plugins.withId("org.jetbrains.kotlin.jvm", kotlinPluginHandler)
-
-    afterEvaluate {
-      registerLaboratoryTasks(afterAndroid = false)
-    }
+    target.checkKotlinPlugin()
+    target.setUpProject(extension)
   }
 
-  private fun Project.setUpAndroidProject() {
-    val androidPluginHandler = { _: Plugin<*> ->
-      hasAndroid.set(true)
-      afterEvaluate {
-        registerLaboratoryTasks(afterAndroid = true)
-      }
+  private fun Project.checkKotlinPlugin() {
+    val hasKotlin = with(plugins) {
+      hasPlugin("org.jetbrains.kotlin.jvm") || hasPlugin("org.jetbrains.kotlin.android")
+    }
+    check(hasKotlin) {
+      "Laboratory Gradle plugin applied in '$path' requires Kotlin plugin."
     }
-    plugins.withId("com.android.application", androidPluginHandler)
-    plugins.withId("com.android.library", androidPluginHandler)
-    plugins.withId("com.android.instantapp", androidPluginHandler)
-    plugins.withId("com.android.feature", androidPluginHandler)
-    plugins.withId("com.android.dynamic-feature", androidPluginHandler)
   }
 
-  private fun Project.registerLaboratoryTasks(afterAndroid: Boolean) {
-    if (hasAndroid.get() && !afterAndroid) return
+  private fun Project.setUpProject(extension: LaboratoryExtension) {
+    addLaboratoryDependency()
 
-    check(hasKotlin.get()) {
-      "Laboratory Gradle plugin requires Kotlin plugin."
-    }
+    val hasAndroid = plugins.hasPlugin("com.android.base")
+    registerFeatureFlagsTask(extension, hasAndroid)
+    registerFeatureFactoryTask(extension, hasAndroid)
+    registerSourcedFeatureStorageTask(extension, hasAndroid)
+    registerOptionFactoryTask(extension, hasAndroid)
+    registerFeatureSourcesFactoryTask(extension, hasAndroid)
+  }
 
-    addLaboratoryDependency()
-    registerFeaturesTask()
-    registerFeatureFactoryTask()
-    registerSourcedFeatureStorageTask()
-    registerFeatureSourcesFactoryTask()
-    registerOptionFactoryTask()
+  private fun Project.addLaboratoryDependency() {
+    dependencies.add("api", "io.mehow.laboratory:laboratory:$LibraryVersion")
   }
 
-  private fun Project.registerFeaturesTask() = afterEvaluate {
-    val codeGenDir = File("${layout.buildDirectory.get()}/generated/laboratory/code/feature-flags")
-    val featuresTask = registerTask<FeatureFlagsTask>("generateFeatureFlags") { task ->
+  private fun Project.registerFeatureFlagsTask(
+    extension: LaboratoryExtension,
+    hasAndroid: Boolean,
+  ) {
+    registerOutputTask<FeatureFlagsTask>("generateFeatureFlags", hasAndroid) { task ->
       task.group = PluginName
-      task.description = "Generate Laboratory features."
-      task.features = extension.featureInputs
-      task.codeGenDir = codeGenDir
+      task.description = "Generate feature flags"
+      task.inputFlags.set(extension.featureFlags)
+      task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/feature-flags"))
     }
-    addSourceSets(featuresTask, codeGenDir)
   }
 
-  private fun Project.registerFeatureFactoryTask() = afterEvaluate {
-    val factoryInput = extension.factoryInput ?: return@afterEvaluate
-
-    val codeGenDir = File("${layout.buildDirectory.get()}/generated/laboratory/code/feature-factory")
-    val factoryTask = registerTask<FeatureFactoryTask>("generateFeatureFactory") { task ->
+  private fun Project.registerFeatureFactoryTask(
+    extension: LaboratoryExtension,
+    hasAndroid: Boolean,
+  ) {
+    registerOutputTask<FeatureFactoryTask>("generateFeatureFactory", hasAndroid) { task ->
       task.group = PluginName
-      task.description = "Generate Laboratory feature factory."
-      task.factory = factoryInput
-      task.features = extension.factoryFeatureInputs
-      task.codeGenDir = codeGenDir
-      task.factoryClassName = "GeneratedFeatureFactory"
-      task.factoryFunctionName = "featureGenerated"
-      task.featureModelsMapper = { it }
+      task.description = "Generate feature factory"
+      task.factory.set(extension.factoryInput)
+      task.features.set(extension.factoryFeatureFlags)
+      task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/feature-factory"))
     }
-    addSourceSets(factoryTask, codeGenDir)
   }
 
-  private fun Project.registerSourcedFeatureStorageTask() = afterEvaluate {
-    val storageInput = extension.storageInput ?: return@afterEvaluate
-
-    val codeGenDir = File("${layout.buildDirectory.get()}/generated/laboratory/code/sourced-storage")
-    val storageTask = registerTask<SourcedFeatureStorageTask>("generateSourcedFeatureStorage") { task ->
+  private fun Project.registerSourcedFeatureStorageTask(
+    extension: LaboratoryExtension,
+    hasAndroid: Boolean,
+  ) {
+    registerOutputTask<SourcedFeatureStorageTask>("generateSourcedFeatureStorage", hasAndroid) { task ->
       task.group = PluginName
-      task.description = "Generate Laboratory sourced feature storage."
-      task.storage = storageInput
-      task.features = extension.factoryFeatureInputs
-      task.codeGenDir = codeGenDir
+      task.description = "Generate sourced feature storage"
+      task.storage.set(extension.storageInput)
+      task.features.set(extension.factoryFeatureFlags)
+      task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/sourced-storage"))
     }
-    addSourceSets(storageTask, codeGenDir)
   }
 
-  private fun Project.registerFeatureSourcesFactoryTask() = afterEvaluate {
-    val factoryInput = extension.featureSourcesFactory ?: return@afterEvaluate
+  private fun Project.registerOptionFactoryTask(
+    extension: LaboratoryExtension,
+    hasAndroid: Boolean,
+  ) {
+    registerOutputTask<OptionFactoryTask>("generateOptionFactory", hasAndroid) { task ->
+      task.group = PluginName
+      task.description = "Generate option factory"
+      task.factory.set(extension.optionFactoryInput)
+      task.features.set(extension.factoryFeatureFlags)
+      task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/option-factory"))
+    }
+  }
 
-    val codeGenDir = File("${layout.buildDirectory.get()}/generated/laboratory/code/feature-source-factory")
-    val factoryTask = registerTask<FeatureFactoryTask>("generateFeatureSourceFactory") { task ->
+  private fun Project.registerFeatureSourcesFactoryTask(
+    extension: LaboratoryExtension,
+    hasAndroid: Boolean,
+  ) {
+    registerOutputTask<FeatureSourceFactoryTask>("generateFeatureSourceFactory", hasAndroid) { task ->
       task.group = PluginName
-      task.description = "Generate Laboratory feature sources factory."
-      task.factory = factoryInput
-      task.features = extension.factoryFeatureInputs
-      task.codeGenDir = codeGenDir
-      task.factoryClassName = "GeneratedFeatureSourceFactory"
-      task.factoryFunctionName = "featureSourceGenerated"
-      task.featureModelsMapper = { it.mapNotNull(FeatureFlagModel::source) }
+      task.description = "Generate feature source factory"
+      task.factory.set(extension.featureSourcesFactory)
+      task.features.set(extension.factoryFeatureFlags)
+      task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/feature-source-factory"))
     }
-    addSourceSets(factoryTask, codeGenDir)
   }
 
-  private fun Project.registerOptionFactoryTask() = afterEvaluate {
-    val factoryInput = extension.optionFactoryInput ?: return@afterEvaluate
+  private inline fun <reified T : OutputTask> Project.registerOutputTask(
+    name: String,
+    hasAndroid: Boolean,
+    crossinline action: (T) -> Unit,
+  ): TaskProvider<T> {
+    val task = tasks.register(name, T::class.java) { action(it) }
+    makeKotlinDependOnTask(task)
+    contributeToSourceSets(task, hasAndroid)
+    return task
+  }
 
-    val codeGenDir = File("${layout.buildDirectory.get()}/generated/laboratory/code/option-factory")
-    val factoryTask = registerTask<OptionFactoryTask>("generateOptionFactory") { task ->
-      task.group = PluginName
-      task.description = "Generate Laboratory option factory."
-      task.factory = factoryInput
-      task.features = extension.factoryFeatureInputs
-      task.codeGenDir = codeGenDir
+  private fun Project.makeKotlinDependOnTask(task: TaskProvider<out Task>) {
+    tasks.withType(KotlinCompile::class.java).configureEach { kotlinTask ->
+      kotlinTask.dependsOn(task)
     }
-    addSourceSets(factoryTask, codeGenDir)
   }
 
-  private fun Project.addLaboratoryDependency() {
-    dependencies.add("api", "io.mehow.laboratory:laboratory:$LibraryVersion")
+  private fun Project.contributeToSourceSets(
+    task: TaskProvider<out OutputTask>,
+    hasAndroid: Boolean,
+  ) = if (hasAndroid) {
+    contributeToAndroid(task)
+  } else {
+    contributeToKotlin(task)
   }
 
-  private inline fun <reified T : Task> Project.registerTask(
-    name: String,
-    crossinline action: (T) -> Unit,
-  ): TaskProvider<out T> {
-    return tasks.register(name, T::class.java) { action(it) }
+  private fun Project.contributeToKotlin(
+    task: TaskProvider<out OutputTask>,
+  ) {
+    val sourceSets = extensions.getByType(KotlinSourceSetContainer::class.java).sourceSets
+    val kotlinSourceSet = sourceSets.getByName("main").kotlin
+    kotlinSourceSet.srcDir(task)
   }
 
-  private fun Project.addSourceSets(
-    task: TaskProvider<out Task>,
-    dir: File,
+  private fun Project.contributeToAndroid(
+    task: TaskProvider<out OutputTask>,
   ) {
-    if (hasAndroid.get()) {
-      task.contributeToAndroidSourceSets(dir, this)
-    } else {
-      task.contributeToSourceSets(dir, this)
+    extensions.getByType(AndroidComponentsExtension::class.java).onVariants { variant ->
+      // 'kotlin' sources do not include
+      variant.sources.java?.addGeneratedSourceDirectory(task, OutputTask::outputDirectory)
     }
   }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryInput.kt
index 551d9ff..d6fc0d0 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryInput.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryInput.kt
@@ -5,13 +5,14 @@ import io.mehow.laboratory.generator.FeatureFlagModel
 import io.mehow.laboratory.generator.OptionFactoryModel
 import io.mehow.laboratory.generator.Visibility.Internal
 import io.mehow.laboratory.generator.Visibility.Public
+import java.io.Serializable
 
 /**
  * Representation of a generated option factory that is aware of feature flags.
  */
 public class OptionFactoryInput internal constructor(
-  private val packageNameProvider: () -> String,
-) {
+  packageNameProvider: PackageNameProvider,
+) : Serializable {
   /**
    * Sets whether the generated option factory should be public or internal.
    */
@@ -20,11 +21,19 @@ public class OptionFactoryInput internal constructor(
   /**
    * Sets package name of the generated option factory. Overwrites any previously set values.
    */
-  public var packageName: String? = null
+  public var packageName: String?
+    get() = packageNameProvider.value
+    set(value) = packageNameProvider.setValue(value)
+
+  private val packageNameProvider = PackageNameProvider(packageNameProvider)
 
   internal fun toModel(features: List<FeatureFlagModel>) = OptionFactoryModel(
     visibility = if (isPublic) Public else Internal,
-    className = ClassName(packageName ?: packageNameProvider(), "GeneratedOptionFactory"),
+    className = ClassName(packageNameProvider.value.orEmpty(), "GeneratedOptionFactory"),
     features = features,
   )
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryTask.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryTask.kt
index 3faacb3..3b51b53 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryTask.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OptionFactoryTask.kt
@@ -1,21 +1,33 @@
 package io.mehow.laboratory.gradle
 
-import org.gradle.api.DefaultTask
-import org.gradle.api.tasks.Internal
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.TaskAction
-import java.io.File
+import javax.inject.Inject
 
-public open class OptionFactoryTask : DefaultTask() {
-  @get:Internal internal lateinit var factory: OptionFactoryInput
+internal abstract class OptionFactoryTask @Inject constructor(
+  objects: ObjectFactory,
+) : OutputTask() {
+  @Input @Optional
+  val factory: Property<OptionFactoryInput?> = objects.property(OptionFactoryInput::class.java)
 
-  @get:Internal internal lateinit var features: List<FeatureFlagInput>
+  @Input
+  val features: ListProperty<FeatureFlagInput> = objects.listProperty(FeatureFlagInput::class.java)
 
-  @get:Internal internal lateinit var codeGenDir: File
+  @OutputDirectory
+  override val outputDirectory: DirectoryProperty = objects.directoryProperty()
 
-  @TaskAction public fun generateSourcedFeatureStorage() {
-    val featureModels = features.flatMap(FeatureFlagInput::toModels)
-    val factoryModel = factory.toModel(featureModels)
-    codeGenDir.deleteRecursively()
-    factoryModel.prepare().writeTo(codeGenDir)
+  @TaskAction
+  fun generateSourcedFeatureStorage() {
+    outputDirectory.get().asFile.deleteRecursively()
+    factory.orNull
+      ?.toModel(features.get().flatMap(FeatureFlagInput::toModels))
+      ?.prepare()
+      ?.writeTo(outputDirectory.get().asFile)
   }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OutputTask.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OutputTask.kt
new file mode 100644
index 0000000..76ef0e4
--- /dev/null
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/OutputTask.kt
@@ -0,0 +1,8 @@
+package io.mehow.laboratory.gradle
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+
+internal abstract class OutputTask : DefaultTask() {
+  abstract val outputDirectory: DirectoryProperty
+}
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/PackageNameProvider.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/PackageNameProvider.kt
index b124e27..5eece8d 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/PackageNameProvider.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/PackageNameProvider.kt
@@ -1,7 +1,19 @@
 package io.mehow.laboratory.gradle
 
-internal class PackageNameProvider : () -> String {
-  var value = ""
+import java.io.Serializable
 
-  override fun invoke() = value
+internal class PackageNameProvider(
+  private val delegate: PackageNameProvider? = null,
+) : Serializable {
+  var value: String? = null
+    get() = field ?: delegate?.value
+    private set
+
+  fun setValue(value: String?) {
+    this.value = value
+  }
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourceSetContribution.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourceSetContribution.kt
deleted file mode 100644
index 55d1e39..0000000
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourceSetContribution.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package io.mehow.laboratory.gradle
-
-import com.android.build.gradle.AppExtension
-import com.android.build.gradle.BaseExtension
-import com.android.build.gradle.LibraryExtension
-import org.gradle.api.DomainObjectSet
-import org.gradle.api.GradleException
-import org.gradle.api.Project
-import org.gradle.api.Task
-import org.gradle.api.tasks.TaskProvider
-import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-import java.io.File
-import java.util.Locale
-
-internal fun TaskProvider<out Task>.contributeToSourceSets(
-  dir: File,
-  project: Project,
-) {
-  makeKotlinDependOnTask(project)
-  contributeToKotlin(dir, project)
-}
-
-internal fun TaskProvider<out Task>.contributeToAndroidSourceSets(
-  dir: File,
-  project: Project,
-) {
-  makeKotlinDependOnTask(project)
-  contributeToAndroid(dir, project)
-}
-
-private fun TaskProvider<out Task>.makeKotlinDependOnTask(project: Project) {
-  project.tasks.withType(KotlinCompile::class.java).configureEach { kotlinTask ->
-    kotlinTask.dependsOn(this)
-  }
-}
-
-private fun contributeToKotlin(
-  dir: File,
-  project: Project,
-) {
-  val sourceSets = project.extensions.getByType(KotlinProjectExtension::class.java).sourceSets
-  val kotlinSourceSet = sourceSets.getByName("main").kotlin
-  kotlinSourceSet.srcDir(dir)
-}
-
-private fun TaskProvider<out Task>.contributeToAndroid(
-  dir: File,
-  project: Project,
-) {
-  val extension = requireNotNull(project.extensions.findByType(BaseExtension::class.java)) {
-    "Did not find BaseExtension in Android project"
-  }
-  val kotlinSourceSets = project.extensions.getByType(KotlinProjectExtension::class.java).sourceSets
-  val sources = extension.sourceSets.associate { set ->
-    set.name to kotlinSourceSets.getByName(set.name).kotlin
-  }
-  for (variant in extension.variants) {
-    val kotlinSourceSet = requireNotNull(sources[variant.name]) {
-      "Did not find Kotlin source set for variant ${variant.name}"
-    }
-    kotlinSourceSet.srcDir(dir.toRelativeString(project.projectDir))
-    variant.addJavaSourceFoldersToModel(dir)
-
-    fun String.titleCaseFirstChar() = replaceFirstChar {
-      if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString()
-    }
-
-    project.tasks.named("generate${variant.name.titleCaseFirstChar()}Sources").configure {
-      it.dependsOn(this)
-    }
-  }
-}
-
-// Copied from SQLDelight with small modifications.
-@Suppress("DEPRECATION")
-private val BaseExtension.variants: DomainObjectSet<out com.android.build.gradle.api.BaseVariant>
-  get() = when (this) {
-    is AppExtension -> applicationVariants
-    is LibraryExtension -> libraryVariants
-    else -> throw GradleException("Unknown Android plugin $this")
-  }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageInput.kt
index d14ae5a..e2d558b 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageInput.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageInput.kt
@@ -4,13 +4,14 @@ import com.squareup.kotlinpoet.ClassName
 import io.mehow.laboratory.generator.SourcedFeatureStorageModel
 import io.mehow.laboratory.generator.Visibility.Internal
 import io.mehow.laboratory.generator.Visibility.Public
+import java.io.Serializable
 
 /**
  * Representation of a generated feature storage that is aware of feature flags sources.
  */
 public class SourcedFeatureStorageInput internal constructor(
-  private val packageNameProvider: () -> String,
-) {
+  packageNameProvider: PackageNameProvider,
+) : Serializable {
   /**
    * Sets whether the generated feature storage should be public or internal.
    */
@@ -19,11 +20,19 @@ public class SourcedFeatureStorageInput internal constructor(
   /**
    * Sets package name of the generated feature storage. Overwrites any previously set values.
    */
-  public var packageName: String? = null
+  public var packageName: String?
+    get() = packageNameProvider.value
+    set(value) = packageNameProvider.setValue(value)
+
+  private val packageNameProvider = PackageNameProvider(packageNameProvider)
 
   internal fun toModel(sourceNames: List<String>) = SourcedFeatureStorageModel(
     visibility = if (isPublic) Public else Internal,
-    className = ClassName(packageName ?: packageNameProvider(), "SourcedGeneratedFeatureStorage"),
+    className = ClassName(packageNameProvider.value.orEmpty(), "SourcedGeneratedFeatureStorage"),
     sourceNames = sourceNames,
   )
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageTask.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageTask.kt
index fdcc188..6c92779 100644
--- a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageTask.kt
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SourcedFeatureStorageTask.kt
@@ -2,27 +2,39 @@ package io.mehow.laboratory.gradle
 
 import io.mehow.laboratory.generator.FeatureFlagModel
 import io.mehow.laboratory.generator.FeatureFlagOption
-import org.gradle.api.DefaultTask
-import org.gradle.api.tasks.Internal
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.TaskAction
-import java.io.File
+import javax.inject.Inject
 
-public open class SourcedFeatureStorageTask : DefaultTask() {
-  @get:Internal internal lateinit var storage: SourcedFeatureStorageInput
+internal abstract class SourcedFeatureStorageTask @Inject constructor(
+  objects: ObjectFactory,
+) : OutputTask() {
+  @Input @Optional
+  val storage: Property<SourcedFeatureStorageInput?> = objects.property(SourcedFeatureStorageInput::class.java)
 
-  @get:Internal internal lateinit var features: List<FeatureFlagInput>
+  @Input
+  val features: ListProperty<FeatureFlagInput> = objects.listProperty(FeatureFlagInput::class.java)
 
-  @get:Internal internal lateinit var codeGenDir: File
+  @OutputDirectory
+  override val outputDirectory: DirectoryProperty = objects.directoryProperty()
 
-  @TaskAction public fun generateSourcedFeatureStorage() {
-    val sourceNames = features.flatMap(FeatureFlagInput::toModels).sourceNames().distinct()
-    val storageModel = storage.toModel(sourceNames)
-    codeGenDir.deleteRecursively()
-    storageModel.prepare().writeTo(codeGenDir)
+  @TaskAction
+  fun generateSourcedFeatureStorage() {
+    outputDirectory.get().asFile.deleteRecursively()
+    storage.orNull
+      ?.toModel(features.get().flatMap(FeatureFlagInput::toModels).sourceNames().distinct())
+      ?.prepare()
+      ?.writeTo(outputDirectory.get().asFile)
   }
 
   private fun List<FeatureFlagModel>.sourceNames(): List<String> = mapNotNull(FeatureFlagModel::source)
     .map(FeatureFlagModel::options)
-    .flatMap { it.toList() }
+    .flatMap(List<FeatureFlagOption>::toList)
     .map(FeatureFlagOption::name)
 }
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SupervisorInput.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SupervisorInput.kt
new file mode 100644
index 0000000..99e75c9
--- /dev/null
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/SupervisorInput.kt
@@ -0,0 +1,15 @@
+package io.mehow.laboratory.gradle
+
+import io.mehow.laboratory.generator.Supervisor
+import java.io.Serializable
+
+internal class SupervisorInput(
+  private val featureFlagInput: FeatureFlagInput,
+  private val optionInput: FeatureFlagOptionInput,
+) : Serializable {
+  fun toModel(): Supervisor = Supervisor(featureFlagInput.toModel(), optionInput.toModel())
+
+  internal companion object {
+    private const val serialVersionUID = 0L
+  }
+}


Test patch:
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFactoryTaskSpec.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFactoryTaskSpec.kt
index e6a50b3..1b6bdf2 100644
--- a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFactoryTaskSpec.kt
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFactoryTaskSpec.kt
@@ -182,7 +182,7 @@ class GenerateFeatureFactoryTaskSpec : FunSpec({
 
     result.task(":generateFeatureFactory")!!.outcome shouldBe SUCCESS
 
-    val factory = fixture.featureFactoryFile("GeneratedFeatureFactory")
+    val factory = fixture.featureFactoryFile("GeneratedFeatureFactory", isAndroid = true)
     factory.shouldExist()
 
     factory.readText() shouldContain """
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFlagsTaskSpec.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFlagsTaskSpec.kt
index 69fd288..219f5e0 100644
--- a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFlagsTaskSpec.kt
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureFlagsTaskSpec.kt
@@ -406,7 +406,7 @@ class GenerateFeatureFlagsTaskSpec : FunSpec({
 
     result.task(":generateFeatureFlags")!!.outcome shouldBe SUCCESS
 
-    val feature = fixture.featureFile("Feature")
+    val feature = fixture.featureFile("Feature", isAndroid = true)
     feature.shouldExist()
 
     feature.readText() shouldContain """
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureSourceFactoryTaskSpec.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureSourceFactoryTaskSpec.kt
index a1227af..0c1e158 100644
--- a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureSourceFactoryTaskSpec.kt
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateFeatureSourceFactoryTaskSpec.kt
@@ -187,7 +187,7 @@ class GenerateFeatureSourceFactoryTaskSpec : FunSpec({
 
     result.task(":generateFeatureSourceFactory")!!.outcome shouldBe SUCCESS
 
-    val factory = fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory")
+    val factory = fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory", isAndroid = true)
     factory.shouldExist()
 
     factory.readText() shouldContain """
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/OptionFactoryTaskSpec.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateOptionFactoryTaskSpec.kt
similarity index 99%
rename from laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/OptionFactoryTaskSpec.kt
rename to laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateOptionFactoryTaskSpec.kt
index fffccef..4f19560 100644
--- a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/OptionFactoryTaskSpec.kt
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateOptionFactoryTaskSpec.kt
@@ -9,7 +9,7 @@ import org.gradle.testkit.runner.GradleRunner
 import org.gradle.testkit.runner.TaskOutcome.FAILED
 import org.gradle.testkit.runner.TaskOutcome.SUCCESS
 
-class OptionFactoryTaskSpec : FunSpec({
+class GenerateOptionFactoryTaskSpec : FunSpec({
   lateinit var gradleRunner: GradleRunner
 
   cleanBuildDirs()
@@ -265,7 +265,7 @@ class OptionFactoryTaskSpec : FunSpec({
 
     result.task(":generateOptionFactory")!!.outcome shouldBe SUCCESS
 
-    val factory = fixture.optionFactoryFile("GeneratedOptionFactory")
+    val factory = fixture.optionFactoryFile("GeneratedOptionFactory", isAndroid = true)
     factory.shouldExist()
 
     factory.readText() shouldContain """
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateSourcedStorageTaskSpec.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateSourcedStorageTaskSpec.kt
index 4ca5502..dd5924d 100644
--- a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateSourcedStorageTaskSpec.kt
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/GenerateSourcedStorageTaskSpec.kt
@@ -259,7 +259,7 @@ class GenerateSourcedStorageTaskSpec : FunSpec({
 
     result.task(":generateSourcedFeatureStorage")!!.outcome shouldBe SUCCESS
 
-    val factory = fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage")
+    val factory = fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage", isAndroid = true)
     factory.shouldExist()
 
     factory.readText() shouldContain """
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/LaboratoryPluginSpec.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/LaboratoryPluginSpec.kt
index 8f0d87f..d068184 100644
--- a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/LaboratoryPluginSpec.kt
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/LaboratoryPluginSpec.kt
@@ -1,5 +1,6 @@
 package io.mehow.laboratory.gradle
 
+import io.kotest.assertions.throwables.shouldNotThrowAny
 import io.kotest.assertions.throwables.shouldThrowAny
 import io.kotest.core.spec.style.FunSpec
 import io.kotest.matchers.nulls.shouldBeNull
@@ -24,7 +25,7 @@ class LaboratoryPluginSpec : FunSpec({
       .buildAndFail()
 
     result.task(":generateFeatureFlags").shouldBeNull()
-    result.output shouldContain "Laboratory Gradle plugin requires Kotlin plugin."
+    result.output shouldContain "Laboratory Gradle plugin applied in ':' requires Kotlin plugin."
   }
 
   test("registers feature flags task for project with Kotlin plugin") {
@@ -45,7 +46,7 @@ class LaboratoryPluginSpec : FunSpec({
       .buildAndFail()
 
     result.task(":generateFeatureFlags").shouldBeNull()
-    result.output shouldContain "Laboratory Gradle plugin requires Kotlin plugin."
+    result.output shouldContain "Laboratory Gradle plugin applied in ':' requires Kotlin plugin."
   }
 
   test("registers feature flags task for project with Kotlin Android plugin") {
@@ -58,24 +59,14 @@ class LaboratoryPluginSpec : FunSpec({
     result.task(":generateFeatureFlags").shouldNotBeNull()
   }
 
-  test("does not register feature flags factory for project without feature flags factory extension") {
-    val fixture = "plugin-factory-missing".toFixture()
-
-    val result = gradleRunner.withProjectDir(fixture)
-      .withArguments("--stacktrace")
-      .build()
-
-    result.task(":generateFeatureFactory").shouldBeNull()
-  }
-
-  test("fails for project without feature flags factory extension with feature flags factory argument") {
+  test("registers feature flags factory for project without feature flags factory extension") {
     val fixture = "plugin-factory-missing".toFixture()
 
     val result = gradleRunner.withProjectDir(fixture)
       .withArguments("generateFeatureFactory", "--stacktrace")
-      .buildAndFail()
+      .build()
 
-    result.task(":generateFeatureFactory").shouldBeNull()
+    result.task(":generateFeatureFactory").shouldNotBeNull()
   }
 
   test("registers feature flags factory for project with feature flags factory extension") {
@@ -88,24 +79,14 @@ class LaboratoryPluginSpec : FunSpec({
     result.task(":generateFeatureFactory").shouldNotBeNull()
   }
 
-  test("does not register sourced storage for project without sourced storage extension") {
-    val fixture = "plugin-sourced-storage-missing".toFixture()
-
-    val result = gradleRunner.withProjectDir(fixture)
-      .withArguments("--stacktrace")
-      .build()
-
-    result.task(":generateSourcedFeatureStorage").shouldBeNull()
-  }
-
-  test("fails for project without sourced storage extension with factory argument") {
+  test("registers sourced storage for project without sourced storage extension") {
     val fixture = "plugin-sourced-storage-missing".toFixture()
 
     val result = gradleRunner.withProjectDir(fixture)
       .withArguments("generateSourcedFeatureStorage", "--stacktrace")
-      .buildAndFail()
+      .build()
 
-    result.task(":generateSourcedFeatureStorage").shouldBeNull()
+    result.task(":generateSourcedFeatureStorage").shouldNotBeNull()
   }
 
   test("registers sourced storage for project with factory extension") {
@@ -118,24 +99,14 @@ class LaboratoryPluginSpec : FunSpec({
     result.task(":generateSourcedFeatureStorage").shouldNotBeNull()
   }
 
-  test("does not register feature flag sources factory for project without feature flag sources factory extension") {
-    val fixture = "plugin-source-factory-missing".toFixture()
-
-    val result = gradleRunner.withProjectDir(fixture)
-      .withArguments("--stacktrace")
-      .build()
-
-    result.task(":generateFeatureSourceFactory").shouldBeNull()
-  }
-
-  test("fails for project without feature sources factory extension with feature flag sources factory argument") {
+  test("registers feature flag sources factory for project without feature flag sources factory extension") {
     val fixture = "plugin-source-factory-missing".toFixture()
 
     val result = gradleRunner.withProjectDir(fixture)
       .withArguments("generateFeatureSourceFactory", "--stacktrace")
-      .buildAndFail()
+      .build()
 
-    result.task(":generateFeatureSourceFactory").shouldBeNull()
+    result.task(":generateFeatureSourceFactory").shouldNotBeNull()
   }
 
   test("registers feature flag sources factory for project with feature flag sources factory extension") {
@@ -148,24 +119,14 @@ class LaboratoryPluginSpec : FunSpec({
     result.task(":generateFeatureSourceFactory").shouldNotBeNull()
   }
 
-  test("does not register option factory for project without option factory extension") {
-    val fixture = "plugin-option-factory-missing".toFixture()
-
-    val result = gradleRunner.withProjectDir(fixture)
-      .withArguments("--stacktrace")
-      .build()
-
-    result.task(":generateOptionFactory").shouldBeNull()
-  }
-
-  test("fails for project without option factory extension with option factory argument") {
+  test("registers option factory for project without option factory extension") {
     val fixture = "plugin-option-factory-missing".toFixture()
 
     val result = gradleRunner.withProjectDir(fixture)
       .withArguments("generateOptionFactory", "--stacktrace")
-      .buildAndFail()
+      .build()
 
-    result.task(":generateOptionFactory").shouldBeNull()
+    result.task(":generateOptionFactory").shouldNotBeNull()
   }
 
   test("registers option factory for project with option factory extension") {
@@ -178,6 +139,12 @@ class LaboratoryPluginSpec : FunSpec({
     result.task(":generateOptionFactory").shouldNotBeNull()
   }
 
+  test("does not fail for including dependency without laboratory plugin") {
+    val fixture = "plugin-dependency-plugin".toFixture()
+
+    shouldNotThrowAny { gradleRunner.withProjectDir(fixture).build() }
+  }
+
   test("fails for including dependency without laboratory plugin") {
     val fixture = "plugin-dependency-plugin-missing".toFixture()
 
@@ -185,4 +152,18 @@ class LaboratoryPluginSpec : FunSpec({
 
     exception.message shouldContain "Cannot depend on a project without laboratory plugin"
   }
+
+  test("does not fail for including dependency accessor without laboratory plugin") {
+    val fixture = "plugin-dependency-accessor-plugin".toFixture()
+
+    shouldNotThrowAny { gradleRunner.withProjectDir(fixture).build() }
+  }
+
+  test("fails for including dependency accessor without laboratory plugin") {
+    val fixture = "plugin-dependency-accessor-plugin-missing".toFixture()
+
+    val exception = shouldThrowAny { gradleRunner.withProjectDir(fixture).build() }
+
+    exception.message shouldContain "Cannot depend on a project without laboratory plugin"
+  }
 })
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/TestExtensions.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/TestExtensions.kt
index 173b004..ede35ba 100644
--- a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/TestExtensions.kt
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/TestExtensions.kt
@@ -5,20 +5,48 @@ import java.io.File
 
 internal fun String.toFixture() = File("src/test/projects/$this")
 
-internal fun File.featureFile(fqcn: String) = codeGenFile("feature-flags", fqcn)
+internal fun File.featureFile(
+  fqcn: String,
+  isAndroid: Boolean = false,
+) = codeGenFile("feature-flags".expectedOutputDir(isAndroid), fqcn)
 
-internal fun File.featureFactoryFile(fqcn: String) = codeGenFile("feature-factory", fqcn)
+internal fun File.featureFactoryFile(
+  fqcn: String,
+  isAndroid: Boolean = false,
+) = codeGenFile("feature-factory".expectedOutputDir(isAndroid), fqcn)
 
-internal fun File.sourcedStorageFile(fqcn: String) = codeGenFile("sourced-storage", fqcn)
+internal fun File.sourcedStorageFile(
+  fqcn: String,
+  isAndroid: Boolean = false,
+) = codeGenFile("sourced-storage".expectedOutputDir(isAndroid), fqcn)
 
-internal fun File.featureSourceStorageFile(fqcn: String) = codeGenFile("feature-source-factory", fqcn)
+internal fun File.optionFactoryFile(
+  fqcn: String,
+  isAndroid: Boolean = false,
+) = codeGenFile("option-factory".expectedOutputDir(isAndroid), fqcn)
 
-internal fun File.optionFactoryFile(fqcn: String) = codeGenFile("option-factory", fqcn)
+internal fun File.featureSourceStorageFile(
+  fqcn: String,
+  isAndroid: Boolean = false,
+) = codeGenFile("feature-source-factory".expectedOutputDir(isAndroid), fqcn)
+
+private fun String.expectedOutputDir(isAndroid: Boolean) = if (isAndroid) {
+  "java/" + when (this) {
+    "feature-flags" -> "generateFeatureFlags"
+    "feature-factory" -> "generateFeatureFactory"
+    "sourced-storage" -> "generateSourcedFeatureStorage"
+    "option-factory" -> "generateOptionFactory"
+    "feature-source-factory" -> "generateFeatureSourceFactory"
+    else -> error("Unknown output: $this")
+  }
+} else {
+  "laboratory/code/$this"
+}
 
 private fun File.codeGenFile(
   dir: String,
   fqcn: String,
-) = File(this, "build/generated/laboratory/code/$dir/${fqcn.replace(".", "/")}.kt")
+) = File(this, "build/generated/$dir/${fqcn.replace(".", "/")}.kt")
 
 internal fun TestConfiguration.cleanBuildDirs() = beforeSpec {
   File("src/test/projects").getBuildDirs().forEach { it.deleteRecursively() }
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/build.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/build.gradle
new file mode 100644
index 0000000..d826cbc
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/build.gradle
@@ -0,0 +1,8 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  dependency(projects.noPlugin)
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/no-plugin/build.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/no-plugin/build.gradle
new file mode 100644
index 0000000..68805eb
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/no-plugin/build.gradle
@@ -0,0 +1,3 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/settings.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/settings.gradle
new file mode 100644
index 0000000..676fa08
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin-missing/settings.gradle
@@ -0,0 +1,3 @@
+include ":no-plugin"
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/build.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/build.gradle
new file mode 100644
index 0000000..e76a113
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/build.gradle
@@ -0,0 +1,8 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  dependency(projects.plugin)
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/plugin/build.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/plugin/build.gradle
new file mode 100644
index 0000000..e500f04
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/plugin/build.gradle
@@ -0,0 +1,4 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/settings.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/settings.gradle
new file mode 100644
index 0000000..0b6f14a
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-accessor-plugin/settings.gradle
@@ -0,0 +1,3 @@
+include ":plugin"
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/build.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/build.gradle
new file mode 100644
index 0000000..255b8d6
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/build.gradle
@@ -0,0 +1,8 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  dependency(project(":plugin"))
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/plugin/build.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/plugin/build.gradle
new file mode 100644
index 0000000..e500f04
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/plugin/build.gradle
@@ -0,0 +1,4 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/settings.gradle b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/settings.gradle
new file mode 100644
index 0000000..391e52f
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/plugin-dependency-plugin/settings.gradle
@@ -0,0 +1,1 @@
+include ":plugin"