Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions docs/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions gradle-plugin/api/gradle-plugin.api
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public final class software/ralf/app/platform/gradle/ModuleStructureDependencyCh
public class software/ralf/app/platform/gradle/ModuleStructureOptions {
public fun <init> (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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -161,15 +162,23 @@ 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 =
tasks.register(
"$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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> =
objects.property(Boolean::class.java).convention(true)

private val allowLibraryImplToImplDependencies: Property<Boolean> =
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.
Expand All @@ -18,6 +27,8 @@ public open class ModuleStructureOptions @Inject constructor(objects: ObjectFact
allowLibraryImplToImplDependencies.finalizeValueOnRead()
}

internal fun isDependencyCheckEnabled(): Property<Boolean> = enableDependencyCheck

internal fun isLibraryImplToImplDependenciesAllowed(): Property<Boolean> =
allowLibraryImplToImplDependencies
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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()
}
Expand All @@ -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()
}
Expand All @@ -55,13 +64,15 @@ class AppPlatformExtensionTest {
.evaluate(
"""
appPlatform.enableModuleStructure {
enableDependencyCheck false
allowLibraryImplToImplDependencies true
}
"""
.trimIndent()
)

assertThat(extension.isModuleStructureEnabled().get()).isTrue()
assertThat(extension.moduleStructureOptions().isDependencyCheckEnabled().get()).isFalse()
assertThat(extension.moduleStructureOptions().isLibraryImplToImplDependenciesAllowed().get())
.isTrue()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProjectDependency>().map {
dependency ->
dependency.path
}

assertThat(projectDependencies).contains(":library:public")
}
}
Loading