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
3 changes: 2 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
plugins {
id("app")
id("org.jetbrains.kotlin.plugin.compose")
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.metro)
id("formatting")
id("compose.stability")
}

android {
Expand Down
3 changes: 2 additions & 1 deletion apple/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ plugins {
id("org.jetbrains.kotlin.multiplatform")
// Molecule recomposes `presenter.present()`, which is a @Composable call, so this module needs
// the Compose compiler even though it renders nothing.
id("org.jetbrains.kotlin.plugin.compose")
alias(libs.plugins.kotlin.compose)
// So createGraph<CoreGraph>() resolves, exactly as in :app, :web and :desktop. The graph itself,
// and every contribution to it, is aggregated on :shared's compile classpath — not here.
alias(libs.plugins.metro)
// For `toString()` and nothing else. @Redacted rewrites the generated toString of a data class;
// it does NOT touch equals or hashCode, so it is not what keeps the event sinks out of the
// equality that lets `StateFlow` conflate a frame — see `EventSink` in AppleUiState.kt for that.
alias(libs.plugins.redacted)
id("compose.stability")
}

// Matches the `import CountriesKit` in the Swift sources. Changing it means changing both.
Expand Down
5 changes: 2 additions & 3 deletions build-logic/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ dependencies {

compileOnly(libs.plugins.dependency.sorter.toDep())
compileOnly(libs.plugins.detekt.toDep())
compileOnly(libs.plugins.kotlin.compose.toDep())
compileOnly(libs.plugins.ktfmt.toDep())

detektPlugins(
libs.detekt.compose.rules
) // required in order to use same detekt.yml as main project
detektPlugins(libs.detekt.compose.rules)
}

kotlin { jvmToolchain(25) }
Expand Down
50 changes: 50 additions & 0 deletions build-logic/src/main/kotlin/compose.stability.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import io.github.solcott.countries.build.ComposeStabilityCheckTask
import io.github.solcott.countries.build.composeReportCompileTask
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension

// Turns on the Compose compiler's own metrics and reports and registers the check that reads them.
// Gated on the compiler plugin rather than applying it, the same way kmp.library reacts to
// `org.jetbrains.compose` -- the six Compose modules each declare their own alias.
//
// The destinations are always set rather than hidden behind a `-P` flag, so `composeStabilityCheck`
// is a plain `dependsOn` with nothing to remember on CI. The cost is three small text files per
// compilation.
plugins.withId("org.jetbrains.kotlin.plugin.compose") {
// Not `build/compose`: the Compose Multiplatform plugin already unpacks the skiko runtime there.
val composeDirectory = layout.buildDirectory.dir("reports/compose")
val reportsDirectory = composeDirectory.map { it.dir("reports") }

configure<ComposeCompilerGradlePluginExtension> {
reportsDestination = reportsDirectory
metricsDestination = composeDirectory.map { it.dir("metrics") }
}

// The Compose plugin hands `reportsDestination` to the compiler as an *input* option, so a
// compile task whose reports have been deleted still counts as up to date and quietly writes
// nothing -- the check would then fail with "no reports found" on a perfectly healthy tree.
// Declaring the directory as an output of the one task the check reads models what the task
// actually produces and fixes that. It buys a second thing too: after a full `build`, where
// every target writes over the shared directory, this task is out of date again and re-runs, so
// it stays the last writer and the check stays deterministic.
afterEvaluate { composeReportCompileTask().configure { outputs.dir(reportsDirectory) } }

tasks.register<ComposeStabilityCheckTask>("composeStabilityCheck") {
group = "verification"
description = "Fails on Compose stability regressions reported by the Compose compiler."

// Resolved lazily: Android compilations do not exist when this plugin is applied.
dependsOn(provider { composeReportCompileTask() })

reports.from(
reportsDirectory.map {
it.asFileTree.matching { include("*-classes.txt", "*-composables.txt") }
}
)
unstableClassAllowlist =
rootProject.layout.projectDirectory.file("config/compose/unstable-classes.txt")
unskippableComposableAllowlist =
rootProject.layout.projectDirectory.file("config/compose/unskippable-composables.txt")
moduleName = project.name
summary = composeDirectory.map { it.file("stability-check.txt") }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.github.solcott.countries.build

import org.gradle.api.Project
import org.gradle.api.tasks.TaskProvider
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType

/**
* The one compile task whose Compose reports the stability check reads.
*
* `reportsDestination` is a project-level setting the Compose plugin hands to every compilation
* verbatim -- unlike `metricsDestination`, it gets no per-target subdirectory -- and a KMP module's
* targets share a Kotlin module name (`ui/build/classes/kotlin/jvm/main` and `.../android/main`
* both hold `Recipes_ui.kotlin_module`). So all six targets write the same `Recipes_ui-classes.txt`
* and the last one to run wins. Depending on exactly one compile task leaves a single writer, which
* is what makes `composeStabilityCheck` deterministic. Which target hardly matters: stability
* inference for `commonMain` is identical on all of them, so prefer the JVM for compile speed.
*/
internal fun Project.composeReportCompileTask(): TaskProvider<*> {
val kotlin =
extensions.findByName("kotlin") as? KotlinProjectExtension
?: error(
"No Kotlin extension in :$name -- compose.stability needs one to find a compilation."
)
val targets =
when (kotlin) {
is KotlinMultiplatformExtension -> kotlin.targets.toList()
is KotlinSingleTargetExtension<*> -> listOf(kotlin.target)
else -> emptyList()
}
val target =
PLATFORM_PREFERENCE.firstNotNullOfOrNull { platform ->
targets.firstOrNull { it.platformType == platform }
} ?: error("No Kotlin target in :$name can produce Compose reports.")
val compilation =
target.compilations.findByName("main")
// KotlinAndroidTarget names its compilations after build types, so there is no "main".
?: target.compilations.findByName("debug")
?: error("Target ${target.name} in :$name has neither a main nor a debug compilation.")
return compilation.compileTaskProvider
}

/** Metadata is deliberately absent: it compiles no bodies, so the compiler reports nothing. */
private val PLATFORM_PREFERENCE =
listOf(
KotlinPlatformType.jvm,
KotlinPlatformType.androidJvm,
KotlinPlatformType.js,
KotlinPlatformType.wasm,
KotlinPlatformType.native,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package io.github.solcott.countries.build

import java.io.File
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction

/**
* Turns the Compose compiler's own stability reports into a pass or a fail.
*
* Reads the `-classes.txt` and `-composables.txt` files the compiler writes to `reportsDestination`
* and fails on two things:
* * a class the compiler inferred as `unstable` -- every recomposition re-runs any composable that
* takes one;
* * a `restartable` composable that is not `skippable`. Strong skipping is on by default in Kotlin
* 2.4, so what survives is genuine: inline functions, non-`Unit` returns, and
* `@NonSkippableComposable`.
*
* Both are overridable through an allowlist file, one `<module>:<SimpleName>` per line. The module
* prefix is what lets each project's copy of this task tell its own entries from another module's.
*/
@CacheableTask
abstract class ComposeStabilityCheckTask : DefaultTask() {

/** The `*-classes.txt` and `*-composables.txt` files written to `reportsDestination`. */
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
abstract val reports: ConfigurableFileCollection

/** Classes allowed to stay unstable. */
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.NONE)
abstract val unstableClassAllowlist: RegularFileProperty

/** Restartable composables allowed to stay unskippable. */
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.NONE)
abstract val unskippableComposableAllowlist: RegularFileProperty

/** The project name, which is the prefix this task's allowlist entries must carry. */
@get:Input abstract val moduleName: Property<String>

@get:OutputFile abstract val summary: RegularFileProperty

@TaskAction
fun check() {
val reportFiles = reports.files.filter { it.isFile }
if (reportFiles.isEmpty()) {
throw GradleException(
"No Compose reports found for :${moduleName.get()}. The compiler writes them only when " +
"composeCompiler.reportsDestination is set -- check the compose.stability convention " +
"plugin is applied to this project."
)
}

val unstableClasses =
reportFiles.filter { it.name.endsWith(CLASSES_SUFFIX) }.flatMap(::parseUnstableClasses)
val unskippable =
reportFiles
.filter { it.name.endsWith(COMPOSABLES_SUFFIX) }
.flatMap(::parseUnskippableComposables)

val allowedClasses = readAllowlist(unstableClassAllowlist.orNull?.asFile)
val allowedComposables = readAllowlist(unskippableComposableAllowlist.orNull?.asFile)

warnAboutStaleEntries(allowedClasses, unstableClasses, unstableClassAllowlist.orNull?.asFile)
warnAboutStaleEntries(
allowedComposables,
unskippable,
unskippableComposableAllowlist.orNull?.asFile,
)

val classFailures = unstableClasses.filterNot { it.name in allowedClasses }
val composableFailures = unskippable.filterNot { it.name in allowedComposables }

writeSummary(reportFiles, unstableClasses, unskippable, classFailures, composableFailures)

if (classFailures.isNotEmpty() || composableFailures.isNotEmpty()) {
throw GradleException(
buildFailureMessage(classFailures, composableFailures).also { logger.error(it) }
)
}
}

private fun buildFailureMessage(
classFailures: List<Finding>,
composableFailures: List<Finding>,
): String = buildString {
val module = moduleName.get()
appendLine("Compose stability check failed for :$module.")
if (classFailures.isNotEmpty()) {
appendLine()
appendLine(" Unstable classes (${classFailures.size}):")
classFailures.forEach { appendLine(" ${it.name} [${it.report.name}]") }
appendLine(
" Make every property a val of a stable type, or annotate the class @Immutable " +
"(nothing ever changes) or @Stable (mutable properties are backed by snapshot state)."
)
appendLine(" To accept one, add to ${allowlistPath(unstableClassAllowlist.orNull?.asFile)}:")
classFailures.forEach { appendLine(" $module:${it.name}") }
}
if (composableFailures.isNotEmpty()) {
appendLine()
appendLine(" Restartable but not skippable (${composableFailures.size}):")
composableFailures.forEach { appendLine(" ${it.name} [${it.report.name}]") }
appendLine(
" Every unskippable composable re-runs on each recomposition of its parent. Make its " +
"parameters stable, or accept it below with a comment saying why."
)
appendLine(
" To accept one, add to ${allowlistPath(unskippableComposableAllowlist.orNull?.asFile)}:"
)
composableFailures.forEach { appendLine(" $module:${it.name}") }
}
}

private fun warnAboutStaleEntries(allowed: Set<String>, found: List<Finding>, file: File?) {
// Only ever a warning: the reports cover one target, so an entry for a class declared in, say,
// iosMain legitimately goes unmatched when the check reads the JVM report.
val foundNames = found.mapTo(mutableSetOf()) { it.name }
val stale = allowed - foundNames
if (stale.isNotEmpty()) {
logger.warn(
"Compose stability allowlist for :${moduleName.get()} has ${stale.size} entries that no " +
"longer appear in the report (${stale.sorted().joinToString()}). Remove them from " +
"${allowlistPath(file)} unless they are declared in a source set this target does not " +
"compile."
)
}
}

private fun writeSummary(
reportFiles: List<File>,
unstableClasses: List<Finding>,
unskippable: List<Finding>,
classFailures: List<Finding>,
composableFailures: List<Finding>,
) {
val out = summary.get().asFile
out.parentFile.mkdirs()
out.writeText(
buildString {
appendLine("Compose stability -- :${moduleName.get()}")
appendLine("reports read: ${reportFiles.joinToString { it.name }}")
appendLine(
"unstable classes: ${unstableClasses.size} (${classFailures.size} not allowlisted)"
)
unstableClasses.forEach { appendLine(" ${it.name}") }
appendLine(
"restartable, not skippable: ${unskippable.size} " +
"(${composableFailures.size} not allowlisted)"
)
unskippable.forEach { appendLine(" ${it.name}") }
}
)
}

/**
* Entries for this module, with the `<module>:` prefix stripped. Others are another's problem.
*/
private fun readAllowlist(file: File?): Set<String> {
if (file == null || !file.isFile) return emptySet()
val prefix = "${moduleName.get()}:"
return file
.readLines()
.map { it.substringBefore('#').trim() }
.filter { it.startsWith(prefix) }
.mapTo(mutableSetOf()) { it.removePrefix(prefix).trim() }
}

private fun parseUnstableClasses(report: File): List<Finding> =
report.readLines().mapNotNull { line ->
// Entries start at column 0; properties inside a class body are indented.
UNSTABLE_CLASS.find(line)?.let { Finding(it.groupValues[1], report) }
}

private fun parseUnskippableComposables(report: File): List<Finding> =
report.readLines().mapNotNull { line ->
val match = COMPOSABLE.find(line) ?: return@mapNotNull null
val flags = match.groupValues[1].trim().split(WHITESPACE)
// `runtime`/`readonly`/inline functions are not restartable and cost nothing to skip.
if (RESTARTABLE !in flags || SKIPPABLE in flags) return@mapNotNull null
Finding(match.groupValues[2], report)
}

private fun allowlistPath(file: File?): String = file?.invariantSeparatorsPath ?: "the allowlist"

private data class Finding(val name: String, val report: File)

private companion object {
const val CLASSES_SUFFIX = "-classes.txt"
const val COMPOSABLES_SUFFIX = "-composables.txt"
const val RESTARTABLE = "restartable"
const val SKIPPABLE = "skippable"

val UNSTABLE_CLASS = Regex("""^unstable class ([^\s{]+)""")
// e.g. `restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun RecipeGrid(`.
// Names come out fully qualified and may carry angle brackets -- a property getter reads
// `fun com.scottolcott.recipe.domain.<get-isCupertino>()` -- so the optional type-parameter
// group excludes `-` rather than matching any `<...>`, which would swallow those.
val COMPOSABLE =
Regex("""^([a-z ]*(?:scheme\("[^"]*"\)\s*)?)fun\s+(?:<[\w, ]+>\s+)?([^(\s]+)\(""")
val WHITESPACE = Regex("""\s+""")
}
}
14 changes: 1 addition & 13 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
import com.ncorti.ktfmt.gradle.KtfmtExtension
import org.gradle.buildconfiguration.tasks.UpdateDaemonJvm
import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack

// AGP 9.x has built-in Kotlin support, pinned to an older Kotlin Gradle Plugin (KGP) version.
// Metro 1.3.2 needs Kotlin >= 2.3.20 and Compose needs a matching compiler plugin, so we override
// the built-in KGP (and provide the Compose compiler plugin) on the buildscript classpath.
// The Kotlin-family plugins are then applied in each module via `id(...)` with no version,
// picking up these classpath versions. This is the migration path recommended in AGP 9.0's notes.
buildscript {
dependencies {
classpath(libs.kotlin.gradle.plugin)
classpath(libs.kotlin.compose.compiler.plugin)
}
}

plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
Expand All @@ -23,6 +10,7 @@ plugins {
alias(libs.plugins.ktfmt) apply false
alias(libs.plugins.kmp.parcelize) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.kotlin.compose) apply false
}

// Pins the Gradle daemon's JVM. `./gradlew updateDaemonJvm` writes the criteria to
Expand Down
Loading
Loading