diff --git a/build.gradle.kts b/build.gradle.kts index a732502ba..b78dc0b06 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,61 +35,42 @@ plugins { alias(libs.plugins.kover) } -allprojects { - configurations.all { - exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk7") - resolutionStrategy { - force(libs.kotlinx.serialization.core.get().toString()) - force(libs.kotlinx.serialization.json.get().toString()) - force(libs.kotlin.metadata.jvm.get().toString()) - } - } +// NOTE: The per-project `allprojects { }` configuration that used to live here +// (kotlin-stdlib-jdk7 exclusion, serialization/metadata version forcing, and +// disabling stray kapt tasks) has moved to `settings.gradle.kts` as a +// `gradle.lifecycle.beforeProject { }` hook. Isolated Projects forbids the root +// project from configuring other projects, and that hook is its isolated +// replacement. - tasks.matching { it.name.contains("kapt") }.configureEach { - enabled = false - } +tasks.register("clean", Delete::class) { + delete(rootProject.layout.buildDirectory) } +// ---- Coverage aggregation (Kover) ---- +// The set of modules whose coverage is merged is computed in `settings.gradle.kts` +// (from the actual included projects) and handed over via this extra property, +// because Isolated Projects forbids the root from discovering them by iterating +// `subprojects`. `kover(project(path))` is just a dependency edge, which IP allows. +@Suppress("UNCHECKED_CAST") +val koverModules = rootProject.extra["flipcash.koverModules"] as List + dependencies { - subprojects.forEach { subproject -> - subproject.afterEvaluate { - if (subproject.plugins.hasPlugin("org.jetbrains.kotlinx.kover") - && (subproject.path.startsWith(":apps:flipcash") - || subproject.path.startsWith(":services:flipcash") - || subproject.path.startsWith(":services:opencode") - || subproject.path.startsWith(":libs:") - || subproject.path.startsWith(":ui:") - || subproject.path.startsWith(":definitions:")) - ) { - kover(subproject) - } - } - } + koverModules.forEach { kover(project(it)) } } -tasks.register("clean", Delete::class) { - delete(rootProject.layout.buildDirectory) -} +// ---- Aggregate unit-test task ---- +// Replaces a `subprojects { afterEvaluate { rootProject.tasks... } }` wiring that +// Isolated Projects forbids. The module lists come from `settings.gradle.kts`; the +// task depends on each module's test task by *path* — a lazy, cross-project-safe +// dependency that never configures the other project at configuration time. +// Android modules expose `testDebugUnitTest`; pure-JVM modules expose `test`. +@Suppress("UNCHECKED_CAST") +val androidUnitTestModules = rootProject.extra["flipcash.androidUnitTestModules"] as List +@Suppress("UNCHECKED_CAST") +val jvmUnitTestModules = rootProject.extra["flipcash.jvmUnitTestModules"] as List tasks.register("flipcashTestDebug") { description = "Run testDebug for all Flipcash modules" -} - -subprojects.filter { - it.path.startsWith(":apps:flipcash") - || it.path == ":services:flipcash" - || it.path == ":services:opencode" -}.forEach { sub -> - sub.afterEvaluate { - val taskName = when { - sub.plugins.hasPlugin("com.android.library") || sub.plugins.hasPlugin("com.android.application") -> "testDebugUnitTest" - sub.plugins.hasPlugin("org.jetbrains.kotlin.jvm") -> "test" - else -> null - } - if (taskName != null) { - rootProject.tasks.named("flipcashTestDebug").configure { - dependsOn(tasks.named(taskName)) - } - } - } + dependsOn(androidUnitTestModules.map { "$it:testDebugUnitTest" }) + dependsOn(jvmUnitTestModules.map { "$it:test" }) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0fd255631..5e8b4e5dd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.initialization.ProjectDescriptor + pluginManagement { includeBuild("build-logic") repositories { @@ -190,3 +192,99 @@ include( ":vendor:tipkit:tipkit-m2", ":vendor:opencv:sdk", ) + +// ---- Cross-module aggregation wiring (single source of truth) ---- +// The root build script can no longer discover modules by iterating `subprojects` +// (Isolated Projects forbids it), so the module sets used for coverage and the +// aggregate unit-test task are derived here from the actual included projects and +// handed to the root project via extra properties. New modules are picked up +// automatically; only modules that break the usual conventions need to be listed +// in the small denylists below (their plugin type isn't knowable until project +// configuration, which IP forbids the root from inspecting). +// Only real modules, not the intermediate container projects that Gradle +// auto-creates for nested paths (e.g. `:apps:flipcash:shared`) — those have no +// build script and must not receive a `kover(project(...))` / test dependency. +val includedProjectPaths = buildList { + fun collect(descriptor: ProjectDescriptor) { + val hasBuildScript = descriptor.projectDir.resolve("build.gradle.kts").exists() || + descriptor.projectDir.resolve("build.gradle").exists() + if (descriptor.path != ":" && hasBuildScript) { + add(descriptor.path) + } + descriptor.children.forEach(::collect) + } + collect(rootProject) +} + +// Coverage: every module under these paths applies a `flipcash.android.*` +// convention plugin (which pulls in Kover); :apps:flipcash:app applies Kover +// directly. The denylist holds the modules under those paths that have no Kover. +val koverPaths = listOf(":apps:flipcash", ":libs", ":ui", ":definitions", ":services:flipcash", ":services:opencode") +val nonKoverModules = setOf( + ":apps:flipcash:benchmark", // com.android.test — no Kover + ":apps:flipcash:shared:ksp", // pure-JVM helper — no Kover + ":definitions:opencode:protos", // java-library proto jar — no Kover + ":definitions:flipcash:protos", // java-library proto jar — no Kover +) +val koverModules = includedProjectPaths.filter { path -> + koverPaths.any { path == it || path.startsWith("$it:") } && path !in nonKoverModules +} + +// Aggregate unit tests: :apps:flipcash plus the two service modules. Android +// modules expose `testDebugUnitTest`, pure-JVM modules expose `test`, and the +// androidTest `:benchmark` module has no unit-test task. +val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:opencode") +val jvmUnitTestModules = setOf(":apps:flipcash:shared:ksp") +val noUnitTestModules = setOf(":apps:flipcash:benchmark") +val unitTestCandidates = includedProjectPaths.filter { path -> + unitTestPaths.any { path == it || path.startsWith("$it:") } && path !in noUnitTestModules +} +val androidUnitTestModules = unitTestCandidates.filter { it !in jvmUnitTestModules } + +// Forced dependency versions for the per-project configuration below. The +// version catalog isn't registered on projects yet at `beforeProject` time, so +// the coordinates are resolved here from the catalog's TOML (keeping them in sync +// with `gradle/libs.versions.toml`) and captured as plain strings. +val versionsToml = rootDir.resolve("gradle/libs.versions.toml").readText() +fun catalogVersion(key: String): String = + Regex("""(?m)^\s*${Regex.escape(key)}\s*=\s*"([^"]+)"""") + .find(versionsToml)?.groupValues?.get(1) + ?: error("Version '$key' not found in gradle/libs.versions.toml") +val kotlinVersion = catalogVersion("kotlin") +val serializationVersion = catalogVersion("kotlinx-serialization") + +// Per-project configuration that previously lived in the root build script's +// `allprojects { }` block, plus handing the aggregation lists above to the root +// project. Isolated Projects forbids a project (the root) configuring other +// projects, so this runs via the isolated `gradle.lifecycle.beforeProject` hook, +// which applies per-project setup to every project (including the root) without +// crossing project boundaries. +// +// The action is isolated (serialized), so it must not capture a reference to this +// settings script. Everything it needs is therefore copied into local `val`s in +// the block below — captured by value rather than via the script object. +run { + val koverModulesForRoot = koverModules.toList() + val androidUnitTestForRoot = androidUnitTestModules.toList() + val jvmUnitTestForRoot = jvmUnitTestModules.toList() + val forcedDependencies = listOf( + "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion", + "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion", + "org.jetbrains.kotlin:kotlin-metadata-jvm:$kotlinVersion", + ) + gradle.lifecycle.beforeProject { + if (path == ":") { + extra["flipcash.koverModules"] = koverModulesForRoot + extra["flipcash.androidUnitTestModules"] = androidUnitTestForRoot + extra["flipcash.jvmUnitTestModules"] = jvmUnitTestForRoot + } + + configurations.configureEach { + exclude(mapOf("group" to "org.jetbrains.kotlin", "module" to "kotlin-stdlib-jdk7")) + resolutionStrategy.force(*forcedDependencies.toTypedArray()) + } + tasks.matching { task -> task.name.contains("kapt") }.configureEach { + enabled = false + } + } +}