191 lines
8.7 KiB
Plaintext
191 lines
8.7 KiB
Plaintext
// 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<String, Set<String>> = 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<String, Set<String>>()
|
|
val observedAndroidPlugins = mutableMapOf<String, List<String>>()
|
|
val containerProjects = mutableSetOf<String>()
|
|
|
|
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<ProjectDependency>() }
|
|
.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<String>()
|
|
|
|
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"))
|
|
}
|
|
}
|