// AGP 9 has built-in Kotlin support, so there is no org.jetbrains.kotlin.android // plugin here — applying it is now an error rather than a redundancy. // The Compose compiler plugin is still applied separately. // See https://developer.android.com/build/migrate-to-built-in-kotlin plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.room) apply false } // =========================================================================== // Module boundaries // =========================================================================== // // The dependency table in docs/architecture/README.md, as a check. // // Two boundaries in this project are load-bearing and neither can be held up by // people remembering them: // // 1. `domain:*` must not see the Android SDK. It is why the prediction // engine's tests run in a second instead of on an emulator, and one // contributor reaching for a convenient Android API would end that with no // test failing. // 2. The advertising subsystem must never reach cycle data. PRODUCT_PLAN.md // §34 states it as non-negotiable: no menstrual date, cycle length, // fertility status, ovulation estimate, prediction confidence, prediction // history or spotting record may reach advertising, ever. // // The second is written down before `core:ads` exists on purpose. A guard added // alongside the code it constrains is a guard that was shaped around whatever // exception somebody wanted at the time. // // Run: ./gradlew checkModuleBoundaries (also wired into `check`) // Prove it fails: bash scripts/prove-guard.sh // // Exit is non-zero with every violation listed, not just the first — a guard // that reports one problem per run turns a five-minute fix into five runs. /** Project dependencies each module is permitted. Anything else fails. */ val allowedProjectDependencies: Map> = mapOf( ":app" to setOf(":core:designsystem", ":core:data", ":core:datastore", ":domain:cycle", ":domain:prediction"), ":core:designsystem" to emptySet(), ":core:database" to setOf(":domain:cycle", ":domain:prediction"), ":core:datastore" to emptySet(), ":core:data" to setOf(":core:database", ":domain:cycle", ":domain:prediction"), ":domain:cycle" to emptySet(), ":domain:prediction" to setOf(":domain:cycle"), // Batch 07. Empty, and that is the whole point: the ads module may reach // NOTHING in this project. It talks to the UI through an AdProvider // interface owned by :app. ":core:ads" to emptySet(), ) /** Modules that must never see the Android SDK, by never applying an Android plugin. */ val mustStayPureJvm = setOf(":domain:cycle", ":domain:prediction") /** * Configurations that describe what SHIPS. Test-only dependencies are not a * product boundary — a test may reach for a fake or an in-memory database that * production must not — so they are deliberately not examined here. */ val shippingConfigurations = setOf("implementation", "api", "compileOnly", "runtimeOnly", "ksp") // --------------------------------------------------------------------------- // Collected in afterEvaluate, and that is not a detail. // // The first version of this read `subprojects.configurations` directly in the // root build script. The root project is configured BEFORE its subprojects, so // every configuration was empty, every module had no dependencies, and the task // printed "7 modules checked, no violations" while checking nothing at all. // // It was caught by `scripts/prove-guard.sh` on the first run — a deliberate // forbidden dependency was added to :domain:prediction and the guard stayed // green. That is precisely the failure GUARDS.md §1 exists for, and it is why // no guard here is believed until it has been watched failing. // --------------------------------------------------------------------------- val observedProjectDependencies = mutableMapOf>() val observedAndroidPlugins = mutableMapOf>() val containerProjects = mutableSetOf() subprojects { afterEvaluate { if (!buildFile.exists()) { // `include(":core:database")` makes Gradle create an intermediate // `:core` project with nothing to build. Containers, not modules — // named in the output rather than dropped, because "skipped" and // "passed" must not look the same. containerProjects += path return@afterEvaluate } observedProjectDependencies[path] = configurations .filter { it.name in shippingConfigurations } .flatMap { conf -> conf.dependencies.filterIsInstance() } .map { it.path } .toSet() observedAndroidPlugins[path] = plugins.mapNotNull { plugin -> plugin::class.qualifiedName?.takeIf { it.contains("com.android.build") } } } } tasks.register("checkModuleBoundaries") { group = "verification" description = "Every module's project dependencies must match docs/architecture/README.md." val allowed = allowedProjectDependencies val observed = observedProjectDependencies val androidPlugins = observedAndroidPlugins val pureJvm = mustStayPureJvm val containers = containerProjects // Providers, not values: these maps are filled during afterEvaluate, which // has not run when this task is being registered. Reading them eagerly here // is the same mistake as reading them in the root script. inputs.property("allowed", allowed.toString()) inputs.property("observed", provider { observed.toString() }) inputs.property("androidPlugins", provider { androidPlugins.toString() }) doLast { val violations = mutableListOf() val modules = observed.keys // Refuse to report a pass over nothing. An empty map here means the // collection above did not run, which is exactly how this guard was // green while checking nothing. if (modules.isEmpty()) { throw GradleException( "no modules were examined, so nothing was checked. This is not a pass — " + "see the afterEvaluate note in build.gradle.kts.", ) } // A module nobody declared a rule for is not "allowed anything" — it is // unmeasured, and reporting it as a pass is how a boundary quietly stops // covering half the project. (modules - allowed.keys).sorted().forEach { violations += "$it has no entry in allowedProjectDependencies, so its dependencies were never checked." } observed.forEach { (module, deps) -> if (module in containers) return@forEach val permitted = allowed[module] ?: return@forEach (deps - permitted).sorted().forEach { dep -> violations += "$module depends on $dep, which the architecture does not permit." } } pureJvm.forEach { module -> androidPlugins[module]?.takeIf { it.isNotEmpty() }?.let { plugins -> violations += "$module applies an Android plugin (${plugins.first()}). " + "It must stay pure JVM so the prediction engine is testable without an emulator." } } if (violations.isNotEmpty()) { logger.error("") logger.error("Module boundary violations:") violations.forEach { logger.error(" - $it") } logger.error("") logger.error("These are the rules in docs/architecture/README.md. If the architecture") logger.error("changed on purpose, change that table and the map in build.gradle.kts in") logger.error("the same commit. If it did not, this dependency is the mistake.") throw GradleException("${violations.size} module boundary violation(s).") } logger.lifecycle( "module boundaries: ${modules.size} module(s) checked, " + "${pureJvm.size} required to stay pure JVM, no violations.", ) if (containers.isNotEmpty()) { logger.lifecycle( " (skipped ${containers.size} container project(s) with no build file: " + "${containers.sorted().joinToString(", ")})", ) } } } // Wired into `check` so it runs with the tests rather than only when remembered. subprojects { tasks.matching { it.name == "check" }.configureEach { dependsOn(rootProject.tasks.named("checkModuleBoundaries")) } }