diff --git a/CHANGELOG.md b/CHANGELOG.md index 240177b2..e83a99bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add the advanced `blueprints/list-detail` Kotlin Multiplatform blueprint with a modular architecture, adaptive phone and tablet navigation, bundled character portraits, four platform shells, and dedicated CI. - Publish the list-detail blueprint as a horizontally resizable, interactive WebAssembly example in the documentation site. +- Add an `enableDependencyCheck` module structure option that skips per-target dependency checks while preserving default module dependencies and lifecycle tasks. ### Changed diff --git a/docs/module-structure.md b/docs/module-structure.md index 04fe21f9..99ca17bc 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -293,6 +293,30 @@ appPlatform { } ``` +Dependency rules are checked by default. An application graph assembly module can explicitly opt +out of dependency checks while retaining the module structure's default dependencies, naming +conventions, and Android namespace: + +=== "build.gradle" + + ```groovy + appPlatform { + enableModuleStructure { + enableDependencyCheck false + } + } + ``` + +=== "build.gradle.kts" + + ```kotlin + appPlatform { + enableModuleStructure { + enableDependencyCheck(false) + } + } + ``` + By default, `:impl` modules cannot depend on other `:impl` modules. This can be relaxed for dependencies within the same library while preserving the cross-library boundary: diff --git a/gradle-plugin/api/gradle-plugin.api b/gradle-plugin/api/gradle-plugin.api index 7c5064a1..e04c267e 100644 --- a/gradle-plugin/api/gradle-plugin.api +++ b/gradle-plugin/api/gradle-plugin.api @@ -43,6 +43,7 @@ public final class software/ralf/app/platform/gradle/ModuleStructureDependencyCh public class software/ralf/app/platform/gradle/ModuleStructureOptions { public fun (Lorg/gradle/api/model/ObjectFactory;)V public final fun allowLibraryImplToImplDependencies (Z)V + public final fun enableDependencyCheck (Z)V } public class software/ralf/app/platform/gradle/ModuleStructurePlugin : org/gradle/api/Plugin { diff --git a/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/AppPlatformExtension.kt b/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/AppPlatformExtension.kt index aebd6066..cba254da 100644 --- a/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/AppPlatformExtension.kt +++ b/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/AppPlatformExtension.kt @@ -31,6 +31,7 @@ import software.ralf.app.platform.gradle.ModuleStructurePlugin.Companion.testing * enableMoleculePresenterBackstack true // false is the default * enableModuleStructure true // false is the default * enableModuleStructure { + * enableDependencyCheck false // true is the default * allowLibraryImplToImplDependencies true // false is the default * } * enableComposeUi true // false is the default diff --git a/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureDependencyCheckTask.kt b/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureDependencyCheckTask.kt index 95cf8bbd..b999181d 100644 --- a/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureDependencyCheckTask.kt +++ b/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureDependencyCheckTask.kt @@ -13,6 +13,7 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction +import org.gradle.language.base.plugins.LifecycleBasePlugin import software.ralf.app.platform.gradle.AppPlatformExtension.Companion.appPlatform /** Checks that our module structure dependency rules are followed. */ @@ -161,8 +162,13 @@ public abstract class ModuleStructureDependencyCheckTask : DefaultTask() { it.description = "Checks that our module structure dependency rules for all targets." it.group = "Verification" } + val dependencyChecksEnabled = appPlatform.moduleStructureOptions().isDependencyCheckEnabled() - afterEvaluate { tasks.namedOptional("check") { it.dependsOn(baseTask) } } + plugins.withType(LifecycleBasePlugin::class.java).configureEach { + tasks.named(LifecycleBasePlugin.CHECK_TASK_NAME).configure { + it.dependsOn(baseTask) + } + } fun registerForConfiguration(taskSuffix: String, configuration: () -> Configuration) { val checkTask = @@ -170,6 +176,9 @@ public abstract class ModuleStructureDependencyCheckTask : DefaultTask() { "$baseTaskName${taskSuffix.capitalize()}", ModuleStructureDependencyCheckTask::class.java, ) { task -> + task.onlyIf("Module structure dependency checks are enabled") { + dependencyChecksEnabled.get() + } task.modulePath = path task.allowLibraryImplToImplDependencies.set( appPlatform.moduleStructureOptions().isLibraryImplToImplDependenciesAllowed() diff --git a/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureOptions.kt b/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureOptions.kt index 0025a865..be8b6f59 100644 --- a/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureOptions.kt +++ b/gradle-plugin/src/main/kotlin/software/ralf/app/platform/gradle/ModuleStructureOptions.kt @@ -6,9 +6,18 @@ import org.gradle.api.provider.Property /** Options for configuring the App Platform module structure. */ public open class ModuleStructureOptions @Inject constructor(objects: ObjectFactory) { + private val enableDependencyCheck: Property = + objects.property(Boolean::class.java).convention(true) + private val allowLibraryImplToImplDependencies: Property = objects.property(Boolean::class.java).convention(false) + /** Enables or disables dependency rule enforcement while retaining module structure defaults. */ + public fun enableDependencyCheck(enable: Boolean) { + enableDependencyCheck.set(enable) + enableDependencyCheck.finalizeValueOnRead() + } + /** * Allows an `:impl` module to depend on another `:impl` module in the same library. Dependencies * on `:impl` modules from other libraries remain forbidden. @@ -18,6 +27,8 @@ public open class ModuleStructureOptions @Inject constructor(objects: ObjectFact allowLibraryImplToImplDependencies.finalizeValueOnRead() } + internal fun isDependencyCheckEnabled(): Property = enableDependencyCheck + internal fun isLibraryImplToImplDependenciesAllowed(): Property = allowLibraryImplToImplDependencies } diff --git a/gradle-plugin/src/test/kotlin/software/ralf/app/platform/gradle/AppPlatformExtensionTest.kt b/gradle-plugin/src/test/kotlin/software/ralf/app/platform/gradle/AppPlatformExtensionTest.kt index fc39abe4..1593c367 100644 --- a/gradle-plugin/src/test/kotlin/software/ralf/app/platform/gradle/AppPlatformExtensionTest.kt +++ b/gradle-plugin/src/test/kotlin/software/ralf/app/platform/gradle/AppPlatformExtensionTest.kt @@ -12,10 +12,11 @@ import org.gradle.testfixtures.ProjectBuilder class AppPlatformExtensionTest { @Test - fun `module structure options are disabled by default`() { + fun `module structure is disabled and dependency enforcement defaults to enabled`() { val extension = createExtension() assertThat(extension.isModuleStructureEnabled().get()).isFalse() + assertThat(extension.moduleStructureOptions().isDependencyCheckEnabled().get()).isTrue() assertThat(extension.moduleStructureOptions().isLibraryImplToImplDependenciesAllowed().get()) .isFalse() } @@ -25,10 +26,14 @@ class AppPlatformExtensionTest { val extension = createExtension() extension.enableModuleStructure( - Action { options -> options.allowLibraryImplToImplDependencies(true) } + Action { options -> + options.enableDependencyCheck(false) + options.allowLibraryImplToImplDependencies(true) + } ) assertThat(extension.isModuleStructureEnabled().get()).isTrue() + assertThat(extension.moduleStructureOptions().isDependencyCheckEnabled().get()).isFalse() assertThat(extension.moduleStructureOptions().isLibraryImplToImplDependenciesAllowed().get()) .isTrue() } @@ -39,9 +44,13 @@ class AppPlatformExtensionTest { extension.enableModuleStructure(true) extension.enableModuleStructure( - Action { options -> options.allowLibraryImplToImplDependencies(true) } + Action { options -> + options.enableDependencyCheck(false) + options.allowLibraryImplToImplDependencies(true) + } ) + assertThat(extension.moduleStructureOptions().isDependencyCheckEnabled().get()).isFalse() assertThat(extension.moduleStructureOptions().isLibraryImplToImplDependenciesAllowed().get()) .isTrue() } @@ -55,6 +64,7 @@ class AppPlatformExtensionTest { .evaluate( """ appPlatform.enableModuleStructure { + enableDependencyCheck false allowLibraryImplToImplDependencies true } """ @@ -62,6 +72,7 @@ class AppPlatformExtensionTest { ) assertThat(extension.isModuleStructureEnabled().get()).isTrue() + assertThat(extension.moduleStructureOptions().isDependencyCheckEnabled().get()).isFalse() assertThat(extension.moduleStructureOptions().isLibraryImplToImplDependenciesAllowed().get()) .isTrue() } diff --git a/gradle-plugin/src/test/kotlin/software/ralf/app/platform/gradle/ModuleStructurePluginTest.kt b/gradle-plugin/src/test/kotlin/software/ralf/app/platform/gradle/ModuleStructurePluginTest.kt new file mode 100644 index 00000000..1adba709 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/software/ralf/app/platform/gradle/ModuleStructurePluginTest.kt @@ -0,0 +1,127 @@ +package software.ralf.app.platform.gradle + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlin.test.Test +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.internal.project.ProjectInternal +import org.gradle.language.base.plugins.LifecycleBasePlugin +import org.gradle.testfixtures.ProjectBuilder +import software.ralf.app.platform.gradle.AppPlatformExtension.Companion.appPlatform + +class ModuleStructurePluginTest { + + @Test + fun `module structure dependency checks are enabled by default`() { + val project = createImplModule() + + project.appPlatform.enableModuleStructure(true) + project.evaluate() + + val task = project.dependencyCheckTask() + assertThat(task.enabled).isTrue() + assertThat(task.onlyIf.isSatisfiedBy(task)).isTrue() + project.assertHasPublicModuleDependency() + } + + @Test + fun `module structure can be enabled from a Kotlin plugin callback`() { + val project = createImplModule() + + project.plugins.withId(PluginIds.KOTLIN_JVM) { + project.appPlatform.enableModuleStructure(true) + } + project.evaluate() + + val task = project.dependencyCheckTask() + assertThat(task.enabled).isTrue() + assertThat(task.onlyIf.isSatisfiedBy(task)).isTrue() + project.assertHasPublicModuleDependency() + } + + @Test + fun `disabled dependency checks are skipped and default module dependencies remain`() { + val project = createImplModule() + project.dependencies.add("compileClasspath", "com.example:forbidden-impl:1.0") + + project.appPlatform.enableModuleStructure( + Action { options -> options.enableDependencyCheck(false) } + ) + project.evaluate() + + val task = project.dependencyCheckTask() + assertThat(project.tasks.names).contains("checkModuleStructureDependenciesJvm") + assertThat(project.tasks.names).contains("checkModuleStructureDependencies") + assertThat(task.onlyIf.isSatisfiedBy(task)).isFalse() + project.assertHasPublicModuleDependency() + } + + @Test + fun `dependency checks can be disabled after module structure was enabled`() { + val project = createImplModule() + + project.appPlatform.enableModuleStructure(true) + project.appPlatform.enableModuleStructure( + Action { options -> options.enableDependencyCheck(false) } + ) + project.evaluate() + + val task = project.dependencyCheckTask() + assertThat(project.tasks.names).contains("checkModuleStructureDependenciesJvm") + assertThat(project.tasks.names).contains("checkModuleStructureDependencies") + assertThat(task.onlyIf.isSatisfiedBy(task)).isFalse() + project.assertHasPublicModuleDependency() + } + + @Test + fun `the lifecycle check task depends on module structure checks`() { + val project = createImplModule() + project.plugins.apply(LifecycleBasePlugin::class.java) + + project.appPlatform.enableModuleStructure(true) + project.evaluate() + + val checkTask = project.tasks.named(LifecycleBasePlugin.CHECK_TASK_NAME).get() + val moduleStructureCheckTask = project.tasks.named("checkModuleStructureDependencies").get() + + assertThat(checkTask.taskDependencies.getDependencies(checkTask)) + .contains(moduleStructureCheckTask) + project.assertHasPublicModuleDependency() + } + + private fun createImplModule(): Project { + val rootProject = ProjectBuilder.builder().withName("root").build() + val libraryProject = + ProjectBuilder.builder().withName("library").withParent(rootProject).build() + ProjectBuilder.builder().withName("public").withParent(libraryProject).build() + + val project = ProjectBuilder.builder().withName("impl").withParent(libraryProject).build() + project.plugins.apply(PluginIds.KOTLIN_JVM) + project.configurations.maybeCreate("compileClasspath") + project.plugins.apply(AppPlatformPlugin::class.java) + return project + } + + private fun Project.dependencyCheckTask(): ModuleStructureDependencyCheckTask = + tasks + .named("checkModuleStructureDependenciesJvm", ModuleStructureDependencyCheckTask::class.java) + .get() + + private fun Project.evaluate() { + (this as ProjectInternal).evaluate() + } + + private fun Project.assertHasPublicModuleDependency() { + val projectDependencies = + configurations.getByName("api").dependencies.filterIsInstance().map { + dependency -> + dependency.path + } + + assertThat(projectDependencies).contains(":library:public") + } +}