Privacy-Period-Tracker/build.gradle.kts

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"))
}
}