Repository: MiSikora/laboratory (kotlin)

Issue:
Multi-module: providing sources without feature factories
### Problem

Feature sources from library modules can't be provided through dependency without also providing their factories.

### Context

* An app module without any sourced flags, but owns the singleton `Laboratory` instance along with its (sourced) storage.
* Libraries with sourced flags. There's at most 1 non-local source and it's always named `"Remote"`.

Sources can be provided to app with `dependency(...)`. This way the app's sourced storage builder knows that it needs to configure a remote storage. 

However, depending on a project also pulls in feature factories and option factories, which get merged into app's ones.

1. For **option factories** it shouldn't matter whether all options are in app's option factory or split into separate ones and then combined manually. I'd argue the former is easier because I don't have to combine them myself.
2. For **feature factories** it does matter because they're UI-facing through the inspector module. Depending on a library's flags adds them to the app's feature factory, so they all land in the same inspector tab.

### Workarounds

#### 1) Dependencies-only

1. All modules with feature flags added through `dependency(...)`
2. No combined feature or option factories, only app's "monolithic" one (con: all flags lumped into one inspector tab)

#### 2) No dependencies + dummy sourced flag in app

1. No dependencies defined in Gradle (no `dependency(...)` calls)
2. Dummy feature flag in app that defines a source named `"Remote"` to force plugin to generate sourced storage builder step (con: unnecessary flag)
3. Feature factories combined manually during inspector configuration
4. Option factories combined manually during remote source configuration

#### 3) No dependencies + stringly-typed remote source

Ignore the Gradle plugin here and use `FeatureStorage.sourced` that accepts a `Map<String, FeatureStorage>`. Con: referring to `"Remote"` source by string.

---

Any other ideas? Maybe I'm misusing the library? If not, can anything be done/changed in laboratory to accommodate this use case?

Pull request:
## :bulb: Motivation

Closes #309.

## :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/docs/changelog.md b/docs/changelog.md
index b7d4d367b..96203e539 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -18,6 +18,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
       disabledFeature("Feature")
     }
     ```
+- Dependencies can now selectively contribute to generation process.
+    ```groovy
+    laboratory {
+      featureFactory()
+      featureSourceFactory()
+      sourcedStorage()
+      optionFactory()
+    
+      dependency(project(":feature"), [
+        DependencyContribution.FeatureFactory,
+        DependencyContribution.FeatureSourceFactory, 
+        DependencyContribution.OptionFactory, 
+        DependencyContribution.SourcedStorage,
+      ])
+    }
+    ```
 
 ### 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. 
diff --git a/docs/gradle-plugin.md b/docs/gradle-plugin.md
index 3b974a7de..b4a6429f2 100644
--- a/docs/gradle-plugin.md
+++ b/docs/gradle-plugin.md
@@ -658,6 +658,9 @@ laboratory {
 
   // Includes feature flags that are used for generation of feature factories, sourced storage and option factory.
   dependency(project(":some-project"))
+  // By default dependency contributes to all declared generators but it can be selectively applied
+  // by passing a contribution list.
+  dependency(project(":some-project"), [DependencyContribution.FeatureFactory, DependencyContribution.OptionFactory])
   // If typesafe project accessors are enabled.
   dependency(projects.someProject)
 }
diff --git a/laboratory/gradle-plugin/api/gradle-plugin.api b/laboratory/gradle-plugin/api/gradle-plugin.api
index a91f62f87..3ed5778fd 100644
--- a/laboratory/gradle-plugin/api/gradle-plugin.api
+++ b/laboratory/gradle-plugin/api/gradle-plugin.api
@@ -8,6 +8,16 @@ public final class io/mehow/laboratory/gradle/ChildFeatureFlagsInput : java/io/S
 	public final fun feature (Ljava/lang/String;Lorg/gradle/api/Action;)V
 }
 
+public final class io/mehow/laboratory/gradle/DependencyContribution : java/lang/Enum {
+	public static final field FeatureFactory Lio/mehow/laboratory/gradle/DependencyContribution;
+	public static final field FeatureSourceFactory Lio/mehow/laboratory/gradle/DependencyContribution;
+	public static final field OptionFactory Lio/mehow/laboratory/gradle/DependencyContribution;
+	public static final field SourcedStorage Lio/mehow/laboratory/gradle/DependencyContribution;
+	public static fun getEntries ()Lkotlin/enums/EnumEntries;
+	public static fun valueOf (Ljava/lang/String;)Lio/mehow/laboratory/gradle/DependencyContribution;
+	public static fun values ()[Lio/mehow/laboratory/gradle/DependencyContribution;
+}
+
 public final class io/mehow/laboratory/gradle/DeprecationLevel : java/lang/Enum {
 	public static final field Error Lio/mehow/laboratory/gradle/DeprecationLevel;
 	public static final field Hidden Lio/mehow/laboratory/gradle/DeprecationLevel;
@@ -68,7 +78,11 @@ public final class io/mehow/laboratory/gradle/FeatureFlagInput$MultiOption : io/
 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/Project;Ljava/util/Collection;)V
 	public final fun dependency (Lorg/gradle/api/internal/catalog/DelegatingProjectDependency;)V
+	public final fun dependency (Lorg/gradle/api/internal/catalog/DelegatingProjectDependency;Ljava/util/Collection;)V
+	public static synthetic fun dependency$default (Lio/mehow/laboratory/gradle/LaboratoryExtension;Lorg/gradle/api/Project;Ljava/util/Collection;ILjava/lang/Object;)V
+	public static synthetic fun dependency$default (Lio/mehow/laboratory/gradle/LaboratoryExtension;Lorg/gradle/api/internal/catalog/DelegatingProjectDependency;Ljava/util/Collection;ILjava/lang/Object;)V
 	public final fun disabledFeature (Ljava/lang/String;)V
 	public final fun disabledFeature (Ljava/lang/String;Lorg/gradle/api/Action;)V
 	public static synthetic fun disabledFeature$default (Lio/mehow/laboratory/gradle/LaboratoryExtension;Ljava/lang/String;Lorg/gradle/api/Action;ILjava/lang/Object;)V
diff --git a/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/DependencyContribution.kt b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/DependencyContribution.kt
new file mode 100644
index 000000000..11c179c28
--- /dev/null
+++ b/laboratory/gradle-plugin/src/main/kotlin/io/mehow/laboratory/gradle/DependencyContribution.kt
@@ -0,0 +1,19 @@
+package io.mehow.laboratory.gradle
+
+/**
+ * Possible contributions of a dependency to feature flag generation. See [LaboratoryExtension.dependency]
+ * for more info.
+ */
+public enum class DependencyContribution {
+  /** Contribute to [LaboratoryExtension.featureFactory]. */
+  FeatureFactory,
+
+  /** Contribute to [LaboratoryExtension.featureSourceFactory]. */
+  FeatureSourceFactory,
+
+  /** Contribute to [LaboratoryExtension.optionFactory]. */
+  OptionFactory,
+
+  /** Contribute to [LaboratoryExtension.sourcedStorage]. */
+  SourcedStorage,
+}
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 94d1b033b..105fdf949 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
@@ -20,9 +20,13 @@ public abstract class LaboratoryExtension {
 
   private val packageNameProvider = PackageNameProvider()
 
-  private val mutableDependencies = mutableListOf<FeatureFlagInput>()
+  private val externalDependencies = mutableMapOf<DependencyContribution, List<FeatureFlagInput>>()
 
-  internal val factoryFeatureFlags get() = featureFlags + mutableDependencies
+  internal val factoryFeatureFlags get() = buildMap {
+    DependencyContribution.entries.forEach { entry ->
+      put(entry, featureFlags + externalDependencies[entry].orEmpty())
+    }
+  }
 
   internal lateinit var project: Project
 
@@ -147,21 +151,38 @@ public abstract class LaboratoryExtension {
 
   /**
    * Includes a [project] during feature flags contribution to [featureFactory], [featureSourcesFactory],
-   * [sourcedStorage] or [optionFactory]. Included project must have Laboratory plugin applied.
+   * [sourcedStorage] or [optionFactory]. Contribution can be selective applied by supplying [contributeTo] collection.
+   *
+   * Included project must have Laboratory plugin applied.
    */
-  public fun dependency(project: DelegatingProjectDependency) {
-    dependency(project.dependencyProject)
+  @JvmOverloads
+  public fun dependency(
+    project: DelegatingProjectDependency,
+    contributeTo: Collection<DependencyContribution> = DependencyContribution.entries,
+  ) {
+    dependency(project.dependencyProject, contributeTo)
   }
 
   /**
    * Includes a [project] during feature flags contribution to [featureFactory], [featureSourcesFactory],
-   * [sourcedStorage] or [optionFactory]. Included project must have Laboratory plugin applied.
+   * [sourcedStorage] or [optionFactory]. Contribution can be selective applied by supplying [contributeTo] collection.
+   *
+   * Included project must have Laboratory plugin applied.
    */
-  public fun dependency(project: Project) {
+  @JvmOverloads
+  public fun dependency(
+    project: Project,
+    contributeTo: Collection<DependencyContribution> = DependencyContribution.entries,
+  ) {
+    require(contributeTo.isNotEmpty()) {
+      "Dependency in project '${this.project.name}' on '${project.name}' must have at least one contribution"
+    }
     this.project.evaluationDependsOn(project.path)
     val laboratoryExtension = requireNotNull(project.extensions.findByType(LaboratoryExtension::class.java)) {
       "Cannot depend on a project without laboratory plugin"
     }
-    mutableDependencies += laboratoryExtension.featureFlags
+    contributeTo.forEach { entry ->
+      externalDependencies[entry] = externalDependencies[entry].orEmpty() + 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 68eb4afbf..88ee50653 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
@@ -62,7 +62,7 @@ public class LaboratoryPlugin : Plugin<Project> {
       task.group = PluginName
       task.description = "Generate feature factory"
       task.factory.set(extension.factoryInput)
-      task.features.set(extension.factoryFeatureFlags)
+      task.features.set(extension.factoryFeatureFlags.getValue(DependencyContribution.FeatureFactory))
       task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/feature-factory"))
     }
   }
@@ -75,7 +75,7 @@ public class LaboratoryPlugin : Plugin<Project> {
       task.group = PluginName
       task.description = "Generate sourced feature storage"
       task.storage.set(extension.storageInput)
-      task.features.set(extension.factoryFeatureFlags)
+      task.features.set(extension.factoryFeatureFlags.getValue(DependencyContribution.SourcedStorage))
       task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/sourced-storage"))
     }
   }
@@ -88,7 +88,7 @@ public class LaboratoryPlugin : Plugin<Project> {
       task.group = PluginName
       task.description = "Generate option factory"
       task.factory.set(extension.optionFactoryInput)
-      task.features.set(extension.factoryFeatureFlags)
+      task.features.set(extension.factoryFeatureFlags.getValue(DependencyContribution.OptionFactory))
       task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/option-factory"))
     }
   }
@@ -101,7 +101,7 @@ public class LaboratoryPlugin : Plugin<Project> {
       task.group = PluginName
       task.description = "Generate feature source factory"
       task.factory.set(extension.featureSourcesFactory)
-      task.features.set(extension.factoryFeatureFlags)
+      task.features.set(extension.factoryFeatureFlags.getValue(DependencyContribution.FeatureSourceFactory))
       task.outputDirectory.set(layout.buildDirectory.dir("generated/laboratory/code/feature-source-factory"))
     }
   }


Test patch:
diff --git a/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/DependencyContributionSpec.kt b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/DependencyContributionSpec.kt
new file mode 100644
index 000000000..06238259d
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/kotlin/io/mehow/laboratory/gradle/DependencyContributionSpec.kt
@@ -0,0 +1,364 @@
+package io.mehow.laboratory.gradle
+
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import org.gradle.testkit.runner.GradleRunner
+import org.gradle.testkit.runner.TaskOutcome
+
+class DependencyContributionSpec : FunSpec({
+  lateinit var gradleRunner: GradleRunner
+
+  cleanBuildDirs()
+
+  beforeTest {
+    gradleRunner = GradleRunner.create()
+      .withPluginClasspath()
+      .withArguments(
+        "generateFeatureFactory",
+        "generateSourcedFeatureStorage",
+        "generateOptionFactory",
+        "generateFeatureSourceFactory",
+        "--stacktrace",
+      )
+  }
+
+  test("generates only feature factory") {
+    val fixture = "dependency-contribution-feature-factory".toFixture()
+
+    val result = gradleRunner.withProjectDir(fixture).build()
+
+    result.task(":generateFeatureFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateFeatureSourceFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateOptionFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateSourcedFeatureStorage")!!.outcome shouldBe TaskOutcome.SUCCESS
+
+    fixture.featureFactoryFile("GeneratedFeatureFactory").readText() shouldContain """
+      |private object GeneratedFeatureFactory : FeatureFactory {
+      |  @Suppress("UNCHECKED_CAST")
+      |  override fun create(): Set<Class<out Feature<*>>> = setOf(
+      |    Class.forName("FeatureA")
+      |  ) as Set<Class<out Feature<*>>>
+      |}
+    """.trimMargin()
+
+    fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory").readText() shouldContain """
+      |private object GeneratedFeatureSourceFactory : FeatureFactory {
+      |  override fun create(): Set<Class<out Feature<*>>> = emptySet<Class<out Feature<*>>>()
+      |}
+    """.trimMargin()
+
+    fixture.optionFactoryFile("GeneratedOptionFactory").readText() shouldContain """
+      |private object GeneratedOptionFactory : OptionFactory {
+      |  override fun create(key: String, name: String): Feature<*>? = null
+      |}
+    """.trimMargin()
+
+    fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage").readText() shouldContain """
+      |internal fun FeatureStorage.Companion.sourcedBuilder(localSource: FeatureStorage): BuildingStep =
+      |    Builder(localSource, emptyMap())
+      |
+      |internal interface BuildingStep {
+      |  public fun build(): FeatureStorage
+      |}
+      |
+      |private data class Builder(
+      |  private val localSource: FeatureStorage,
+      |  private val remoteSources: Map<String, FeatureStorage>,
+      |) : BuildingStep {
+      |  override fun build(): FeatureStorage = sourced(localSource, remoteSources)
+      |}
+    """.trimMargin()
+  }
+
+  test("generates only feature source factory") {
+    val fixture = "dependency-contribution-feature-source-factory".toFixture()
+
+    val result = gradleRunner.withProjectDir(fixture).build()
+
+    result.task(":generateFeatureFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateFeatureSourceFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateOptionFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateSourcedFeatureStorage")!!.outcome shouldBe TaskOutcome.SUCCESS
+
+    fixture.featureFactoryFile("GeneratedFeatureFactory").readText() shouldContain """
+      |private object GeneratedFeatureFactory : FeatureFactory {
+      |  override fun create(): Set<Class<out Feature<*>>> = emptySet<Class<out Feature<*>>>()
+      |}
+    """.trimMargin()
+
+    fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory").readText() shouldContain """
+      |private object GeneratedFeatureSourceFactory : FeatureFactory {
+      |  @Suppress("UNCHECKED_CAST")
+      |  override fun create(): Set<Class<out Feature<*>>> = setOf(
+      |    Class.forName("FeatureA${"\${'$'}"}Source")
+      |  ) as Set<Class<out Feature<*>>>
+      |}
+    """.trimMargin()
+
+    fixture.optionFactoryFile("GeneratedOptionFactory").readText() shouldContain """
+      |private object GeneratedOptionFactory : OptionFactory {
+      |  override fun create(key: String, name: String): Feature<*>? = null
+      |}
+    """.trimMargin()
+
+    fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage").readText() shouldContain """
+      |internal fun FeatureStorage.Companion.sourcedBuilder(localSource: FeatureStorage): BuildingStep =
+      |    Builder(localSource, emptyMap())
+      |
+      |internal interface BuildingStep {
+      |  public fun build(): FeatureStorage
+      |}
+      |
+      |private data class Builder(
+      |  private val localSource: FeatureStorage,
+      |  private val remoteSources: Map<String, FeatureStorage>,
+      |) : BuildingStep {
+      |  override fun build(): FeatureStorage = sourced(localSource, remoteSources)
+      |}
+    """.trimMargin()
+  }
+
+  test("generates only option factory") {
+    val fixture = "dependency-contribution-option-factory".toFixture()
+
+    val result = gradleRunner.withProjectDir(fixture).build()
+
+    result.task(":generateFeatureFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateFeatureSourceFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateOptionFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateSourcedFeatureStorage")!!.outcome shouldBe TaskOutcome.SUCCESS
+
+    fixture.featureFactoryFile("GeneratedFeatureFactory").readText() shouldContain """
+      |private object GeneratedFeatureFactory : FeatureFactory {
+      |  override fun create(): Set<Class<out Feature<*>>> = emptySet<Class<out Feature<*>>>()
+      |}
+    """.trimMargin()
+
+    fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory").readText() shouldContain """
+      |private object GeneratedFeatureSourceFactory : FeatureFactory {
+      |  override fun create(): Set<Class<out Feature<*>>> = emptySet<Class<out Feature<*>>>()
+      |}
+    """.trimMargin()
+
+    fixture.optionFactoryFile("GeneratedOptionFactory").readText() shouldContain """
+      |private object GeneratedOptionFactory : OptionFactory {
+      |  override fun create(key: String, name: String): Feature<*>? = when (key) {
+      |    "FeatureA" -> when (name) {
+      |      "First" -> FeatureA.First
+      |      else -> null
+      |    }
+      |    else -> null
+      |  }
+      |}
+    """.trimMargin()
+
+    fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage").readText() shouldContain """
+      |internal fun FeatureStorage.Companion.sourcedBuilder(localSource: FeatureStorage): BuildingStep =
+      |    Builder(localSource, emptyMap())
+      |
+      |internal interface BuildingStep {
+      |  public fun build(): FeatureStorage
+      |}
+      |
+      |private data class Builder(
+      |  private val localSource: FeatureStorage,
+      |  private val remoteSources: Map<String, FeatureStorage>,
+      |) : BuildingStep {
+      |  override fun build(): FeatureStorage = sourced(localSource, remoteSources)
+      |}
+    """.trimMargin()
+  }
+
+  test("generates only sourced storage") {
+    val fixture = "dependency-contribution-sourced-storage".toFixture()
+
+    val result = gradleRunner.withProjectDir(fixture).build()
+
+    result.task(":generateFeatureFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateFeatureSourceFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateOptionFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateSourcedFeatureStorage")!!.outcome shouldBe TaskOutcome.SUCCESS
+
+    fixture.featureFactoryFile("GeneratedFeatureFactory").readText() shouldContain """
+      |private object GeneratedFeatureFactory : FeatureFactory {
+      |  override fun create(): Set<Class<out Feature<*>>> = emptySet<Class<out Feature<*>>>()
+      |}
+    """.trimMargin()
+
+    fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory").readText() shouldContain """
+      |private object GeneratedFeatureSourceFactory : FeatureFactory {
+      |  override fun create(): Set<Class<out Feature<*>>> = emptySet<Class<out Feature<*>>>()
+      |}
+    """.trimMargin()
+
+    fixture.optionFactoryFile("GeneratedOptionFactory").readText() shouldContain """
+      |private object GeneratedOptionFactory : OptionFactory {
+      |  override fun create(key: String, name: String): Feature<*>? = null
+      |}
+    """.trimMargin()
+
+    fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage").readText() shouldContain """
+      |internal fun FeatureStorage.Companion.sourcedBuilder(localSource: FeatureStorage): RemoteStep =
+      |    Builder(localSource, emptyMap())
+      |
+      |internal interface RemoteStep {
+      |  public fun remoteSource(source: FeatureStorage): BuildingStep
+      |}
+      |
+      |internal interface BuildingStep {
+      |  public fun build(): FeatureStorage
+      |}
+      |
+      |private data class Builder(
+      |  private val localSource: FeatureStorage,
+      |  private val remoteSources: Map<String, FeatureStorage>,
+      |) : RemoteStep, BuildingStep {
+      |  override fun remoteSource(source: FeatureStorage): BuildingStep = copy(
+      |    remoteSources = remoteSources + ("Remote" to source)
+      |  )
+      |
+      |  override fun build(): FeatureStorage = sourced(localSource, remoteSources)
+      |}
+    """.trimMargin()
+  }
+
+  test("generates all contributed features explicitly") {
+    val fixture = "dependency-contribution-all-explicit".toFixture()
+
+    val result = gradleRunner.withProjectDir(fixture).build()
+
+    result.task(":generateFeatureFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateFeatureSourceFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateOptionFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateSourcedFeatureStorage")!!.outcome shouldBe TaskOutcome.SUCCESS
+
+    fixture.featureFactoryFile("GeneratedFeatureFactory").readText() shouldContain """
+      |private object GeneratedFeatureFactory : FeatureFactory {
+      |  @Suppress("UNCHECKED_CAST")
+      |  override fun create(): Set<Class<out Feature<*>>> = setOf(
+      |    Class.forName("FeatureA")
+      |  ) as Set<Class<out Feature<*>>>
+      |}
+    """.trimMargin()
+
+    fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory").readText() shouldContain """
+      |private object GeneratedFeatureSourceFactory : FeatureFactory {
+      |  @Suppress("UNCHECKED_CAST")
+      |  override fun create(): Set<Class<out Feature<*>>> = setOf(
+      |    Class.forName("FeatureA${"\${'$'}"}Source")
+      |  ) as Set<Class<out Feature<*>>>
+      |}
+    """.trimMargin()
+
+    fixture.optionFactoryFile("GeneratedOptionFactory").readText() shouldContain """
+      |private object GeneratedOptionFactory : OptionFactory {
+      |  override fun create(key: String, name: String): Feature<*>? = when (key) {
+      |    "FeatureA" -> when (name) {
+      |      "First" -> FeatureA.First
+      |      else -> null
+      |    }
+      |    else -> null
+      |  }
+      |}
+    """.trimMargin()
+
+    fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage").readText() shouldContain """
+      |internal fun FeatureStorage.Companion.sourcedBuilder(localSource: FeatureStorage): RemoteStep =
+      |    Builder(localSource, emptyMap())
+      |
+      |internal interface RemoteStep {
+      |  public fun remoteSource(source: FeatureStorage): BuildingStep
+      |}
+      |
+      |internal interface BuildingStep {
+      |  public fun build(): FeatureStorage
+      |}
+      |
+      |private data class Builder(
+      |  private val localSource: FeatureStorage,
+      |  private val remoteSources: Map<String, FeatureStorage>,
+      |) : RemoteStep, BuildingStep {
+      |  override fun remoteSource(source: FeatureStorage): BuildingStep = copy(
+      |    remoteSources = remoteSources + ("Remote" to source)
+      |  )
+      |
+      |  override fun build(): FeatureStorage = sourced(localSource, remoteSources)
+      |}
+    """.trimMargin()
+  }
+
+  test("generates all contributed features implicitly") {
+    val fixture = "dependency-contribution-all-implicit".toFixture()
+
+    val result = gradleRunner.withProjectDir(fixture).build()
+
+    result.task(":generateFeatureFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateFeatureSourceFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateOptionFactory")!!.outcome shouldBe TaskOutcome.SUCCESS
+    result.task(":generateSourcedFeatureStorage")!!.outcome shouldBe TaskOutcome.SUCCESS
+
+    fixture.featureFactoryFile("GeneratedFeatureFactory").readText() shouldContain """
+      |private object GeneratedFeatureFactory : FeatureFactory {
+      |  @Suppress("UNCHECKED_CAST")
+      |  override fun create(): Set<Class<out Feature<*>>> = setOf(
+      |    Class.forName("FeatureA")
+      |  ) as Set<Class<out Feature<*>>>
+      |}
+    """.trimMargin()
+
+    fixture.featureSourceStorageFile("GeneratedFeatureSourceFactory").readText() shouldContain """
+      |private object GeneratedFeatureSourceFactory : FeatureFactory {
+      |  @Suppress("UNCHECKED_CAST")
+      |  override fun create(): Set<Class<out Feature<*>>> = setOf(
+      |    Class.forName("FeatureA${"\${'$'}"}Source")
+      |  ) as Set<Class<out Feature<*>>>
+      |}
+    """.trimMargin()
+
+    fixture.optionFactoryFile("GeneratedOptionFactory").readText() shouldContain """
+      |private object GeneratedOptionFactory : OptionFactory {
+      |  override fun create(key: String, name: String): Feature<*>? = when (key) {
+      |    "FeatureA" -> when (name) {
+      |      "First" -> FeatureA.First
+      |      else -> null
+      |    }
+      |    else -> null
+      |  }
+      |}
+    """.trimMargin()
+
+    fixture.sourcedStorageFile("SourcedGeneratedFeatureStorage").readText() shouldContain """
+      |internal fun FeatureStorage.Companion.sourcedBuilder(localSource: FeatureStorage): RemoteStep =
+      |    Builder(localSource, emptyMap())
+      |
+      |internal interface RemoteStep {
+      |  public fun remoteSource(source: FeatureStorage): BuildingStep
+      |}
+      |
+      |internal interface BuildingStep {
+      |  public fun build(): FeatureStorage
+      |}
+      |
+      |private data class Builder(
+      |  private val localSource: FeatureStorage,
+      |  private val remoteSources: Map<String, FeatureStorage>,
+      |) : RemoteStep, BuildingStep {
+      |  override fun remoteSource(source: FeatureStorage): BuildingStep = copy(
+      |    remoteSources = remoteSources + ("Remote" to source)
+      |  )
+      |
+      |  override fun build(): FeatureStorage = sourced(localSource, remoteSources)
+      |}
+    """.trimMargin()
+  }
+
+  test("fails to depend on a project with no contributions") {
+    val fixture = "dependency-contribution-none".toFixture()
+
+    val result = gradleRunner.withProjectDir(fixture).buildAndFail()
+
+    @Suppress("MaxLineLength")
+    result.output shouldContain "Dependency in project 'dependency-contribution-none' on 'feature' must have at least one contribution"
+  }
+})
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/build.gradle
new file mode 100644
index 000000000..da111e966
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/build.gradle
@@ -0,0 +1,20 @@
+import io.mehow.laboratory.gradle.DependencyContribution
+
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  featureFactory()
+  featureSourceFactory()
+  sourcedStorage()
+  optionFactory()
+
+  dependency(project(":feature"), [
+    DependencyContribution.FeatureFactory,
+    DependencyContribution.FeatureSourceFactory,
+    DependencyContribution.OptionFactory,
+    DependencyContribution.SourcedStorage,
+  ])
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/feature/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/feature/build.gradle
new file mode 100644
index 000000000..c60290109
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/feature/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  feature("FeatureA") {
+    withDefaultOption("First")
+
+    withSource("Remote")
+  }
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/settings.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/settings.gradle
new file mode 100644
index 000000000..baef9cf87
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-explicit/settings.gradle
@@ -0,0 +1,1 @@
+include ":feature"
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/build.gradle
new file mode 100644
index 000000000..36da2ed07
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/build.gradle
@@ -0,0 +1,15 @@
+import io.mehow.laboratory.gradle.DependencyContribution
+
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  featureFactory()
+  featureSourceFactory()
+  sourcedStorage()
+  optionFactory()
+
+  dependency(project(":feature"), [DependencyContribution.FeatureFactory, DependencyContribution.FeatureSourceFactory, DependencyContribution.OptionFactory, DependencyContribution.SourcedStorage])
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/feature/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/feature/build.gradle
new file mode 100644
index 000000000..c60290109
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/feature/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  feature("FeatureA") {
+    withDefaultOption("First")
+
+    withSource("Remote")
+  }
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/settings.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/settings.gradle
new file mode 100644
index 000000000..baef9cf87
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-all-implicit/settings.gradle
@@ -0,0 +1,1 @@
+include ":feature"
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/build.gradle
new file mode 100644
index 000000000..d82ddd8d0
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/build.gradle
@@ -0,0 +1,15 @@
+import io.mehow.laboratory.gradle.DependencyContribution
+
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  featureFactory()
+  featureSourceFactory()
+  sourcedStorage()
+  optionFactory()
+
+  dependency(project(":feature"), [DependencyContribution.FeatureFactory])
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/feature/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/feature/build.gradle
new file mode 100644
index 000000000..c60290109
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/feature/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  feature("FeatureA") {
+    withDefaultOption("First")
+
+    withSource("Remote")
+  }
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/settings.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/settings.gradle
new file mode 100644
index 000000000..baef9cf87
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-factory/settings.gradle
@@ -0,0 +1,1 @@
+include ":feature"
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/build.gradle
new file mode 100644
index 000000000..e467f8f16
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/build.gradle
@@ -0,0 +1,15 @@
+import io.mehow.laboratory.gradle.DependencyContribution
+
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  featureFactory()
+  featureSourceFactory()
+  sourcedStorage()
+  optionFactory()
+
+  dependency(project(":feature"), [DependencyContribution.FeatureSourceFactory])
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/feature/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/feature/build.gradle
new file mode 100644
index 000000000..c60290109
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/feature/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  feature("FeatureA") {
+    withDefaultOption("First")
+
+    withSource("Remote")
+  }
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/settings.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/settings.gradle
new file mode 100644
index 000000000..baef9cf87
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-feature-source-factory/settings.gradle
@@ -0,0 +1,1 @@
+include ":feature"
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/build.gradle
new file mode 100644
index 000000000..440b3bfa9
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/build.gradle
@@ -0,0 +1,13 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  featureFactory()
+  featureSourceFactory()
+  sourcedStorage()
+  optionFactory()
+
+  dependency(project(":feature"), [])
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/feature/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/feature/build.gradle
new file mode 100644
index 000000000..c60290109
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/feature/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  feature("FeatureA") {
+    withDefaultOption("First")
+
+    withSource("Remote")
+  }
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/settings.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/settings.gradle
new file mode 100644
index 000000000..baef9cf87
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-none/settings.gradle
@@ -0,0 +1,1 @@
+include ":feature"
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/build.gradle
new file mode 100644
index 000000000..ec410a0fd
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/build.gradle
@@ -0,0 +1,15 @@
+import io.mehow.laboratory.gradle.DependencyContribution
+
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  featureFactory()
+  featureSourceFactory()
+  sourcedStorage()
+  optionFactory()
+
+  dependency(project(":feature"), [DependencyContribution.OptionFactory])
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/feature/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/feature/build.gradle
new file mode 100644
index 000000000..c60290109
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/feature/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  feature("FeatureA") {
+    withDefaultOption("First")
+
+    withSource("Remote")
+  }
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/settings.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/settings.gradle
new file mode 100644
index 000000000..baef9cf87
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-option-factory/settings.gradle
@@ -0,0 +1,1 @@
+include ":feature"
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/build.gradle
new file mode 100644
index 000000000..f341c9470
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/build.gradle
@@ -0,0 +1,15 @@
+import io.mehow.laboratory.gradle.DependencyContribution
+
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  featureFactory()
+  featureSourceFactory()
+  sourcedStorage()
+  optionFactory()
+
+  dependency(project(":feature"), [DependencyContribution.SourcedStorage])
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/feature/build.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/feature/build.gradle
new file mode 100644
index 000000000..c60290109
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/feature/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+  id "org.jetbrains.kotlin.jvm"
+  id "io.mehow.laboratory"
+}
+
+laboratory {
+  feature("FeatureA") {
+    withDefaultOption("First")
+
+    withSource("Remote")
+  }
+}
diff --git a/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/settings.gradle b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/settings.gradle
new file mode 100644
index 000000000..baef9cf87
--- /dev/null
+++ b/laboratory/gradle-plugin/src/test/projects/dependency-contribution-sourced-storage/settings.gradle
@@ -0,0 +1,1 @@
+include ":feature"