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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "webApp-wasm",
"runtimeExecutable": "./gradlew",
"runtimeArgs": [":webApp:wasmJsBrowserDevelopmentRun", "-PopenBrowser=false"],
"port": 8080
}
]
}
21 changes: 19 additions & 2 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,24 @@ the suppression is clearly justified.
If it comes back with more than a handful of findings, hand off to the **`detekt-triage`** agent
instead of reading the raw output — it returns them grouped by rule with counts.

### 4. Build each touched module
### 4. Compose stability

```
./gradlew composeStabilityCheck
```

Only if a touched module is one of the six that apply the Compose compiler plugin — `:ui`,
`:domain`, `:shared`, `:app`, `:webApp`, `:desktopApp`. It reads the Compose compiler's own
reports and fails on a class inferred `unstable` or a `restartable` composable that is not
`skippable`.

Run it as its own invocation, never folded into `./gradlew build ...`: every target of a module
writes over the same reports directory, and this task's single designated compile task is what
makes the result deterministic. The failure names each offender and the allowlist line to add.
Prefer fixing the type over allowlisting — CLAUDE.md, *Compose stability*, says which annotation
belongs where.

### 5. Build each touched module

```
./gradlew :<module>:build
Expand All @@ -60,7 +77,7 @@ One invocation per touched module. This covers all targets (android, jvm, iosArm
iosSimulatorArm64, js, wasmJs), which is the point — a change that compiles on JVM can still break
`expect`/`actual` or a web target.

### 5. Tests
### 6. Tests

```
./gradlew :domain:jvmTest
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,40 @@ jobs:
- name: Detekt
run: ./gradlew detektAll

# Before Build on purpose. `reportsDestination` is a project-level Compose compiler setting
# that every target of a module writes to, so only this invocation -- which compiles the one
# designated target -- leaves a single writer and a deterministic report.
- name: Compose stability
run: ./gradlew composeStabilityCheck

# The Compose compiler names its reports after the Kotlin module, which here is the Gradle
# project path -- `Recipes:domain-classes.txt`. upload-artifact rejects a colon in a path, so
# stage a renamed copy. Done immediately after the check rather than at the end of the job, so
# the artifact is exactly what the check read: `build` below recompiles the other targets over
# the same directory.
- name: Stage Compose reports
if: always()
run: |
mkdir -p compose-reports
for src in */build/reports/compose; do
[ -d "$src" ] || continue
mkdir -p "compose-reports/${src%%/*}"
cp -R "$src/." "compose-reports/${src%%/*}/"
done
find compose-reports -depth -name '*:*' | while read -r path; do
mv "$path" "$(dirname "$path")/$(basename "$path" | tr ':' '_')"
done

# continue-on-error: this is diagnostics. A bad upload must not fail the job and skip `Build`,
# which is exactly what happened the first time this workflow ran.
- name: Upload Compose reports
if: always()
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: compose-reports
path: compose-reports
if-no-files-found: warn

- name: Build
run: ./gradlew build
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ local.properties
# Per-user scheme and window state; Xcode rewrites these on every open.
xcuserdata/

.DS_Store
.DS_Store

# CI stages a renamed copy of the Compose stability reports here before uploading them.
compose-reports/
65 changes: 65 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,71 @@ Two things are easy to get wrong:
`ListContent.kt` bridges the two, and belongs in `:ui` with the rest of the user-facing copy once
these get localized.

### Compose stability

Everything a presenter hands to a composable has to be *stable*, or Compose re-runs the UI on every
recomposition. Two rules keep it that way:

- **Collections headed for the UI are `ImmutableList`**, from the repository's public signature
onward — `SourceOfTruth.reader`, `toModel()`, the `Flow<Outcome<…>>` return type, the producer's
`ContentState<ImmutableList<T>>`, and the screen state's field. Entities and DTOs stay `List`:
Store's local type is `List<Entity>` and nothing below `toModel()` ever reaches composition, so
converting there is a copy that buys nothing. `:model` carries
`api(libs.kotlinx.collections.immutable)` for this.
- **`@Immutable` when it is true, `@Stable` when it is not.** `@Immutable` promises no public
property ever changes: right for the `:model` classes and for a screen state whose fields are all
values (`AreasState`, `RecipesState`, `RecipeDetailsState`, `SearchTabState`, `RecipesScreen`).
A state that carries an observable holder — `SearchBarState` and `TextFieldState` in `SearchState`,
`NavStack`/`Navigator` in `RecipeScaffoldState` and `HomeState`, the Metro graph in
`DesktopAppGraph` — gets `@Stable`. Both make the type skippable; only one of them is honest, and
a class marked `@Stable` must back its mutable properties with snapshot state (this is why
`BackShortcutHost.onBack` is a `MutableState`).

Annotate the *declared parameter type*: `@Stable` on a supertype does not propagate, so
`DesktopAppGraph` carries its own annotation rather than inheriting one from `AppGraph`.

A sealed `CircuitUiState` interface is unstable until annotated — the compiler cannot see the
implementations — so every one of them carries a marker.

**None of the above is taken on trust — `./gradlew composeStabilityCheck` enforces it.** The
`compose.stability` convention plugin turns on the Compose compiler's own metrics and reports for
the six modules that apply the compiler plugin (`:ui`, `:domain`, `:shared`, `:app`, `:webApp`,
`:desktopApp`) and registers a per-project check that reads them. It fails on a class the compiler
inferred as `unstable`, and on a `restartable` composable that is not `skippable`. A `runtime`
class passes: its stability depends on a generic argument and is settled at run time. There is no
aggregate task — Gradle's cross-project name matching fans `composeStabilityCheck` out to all six.

Exceptions live in `config/compose/unstable-classes.txt` and
`config/compose/unskippable-composables.txt`, one `<module>:<fully.qualified.Name>` per line, each
with a comment saying why. Today only the Metro graph impls and the two Android entry points are
listed, none of which is ever a composable parameter.

Reports land in `<module>/build/reports/compose/reports/` (`-classes.txt`, `-composables.txt`) with
`-module.json` counts under `.../metrics/<target>/main/`. Read them for *why* something failed —
the entry lists each property or parameter and its verdict. The compiler names them after the
Kotlin module, which is the Gradle project path — so they carry a colon, `Recipes:domain-classes.txt`,
and CI stages a renamed copy before uploading them because `upload-artifact` rejects one.

Two traps in those reports:

- **Only the metrics path is per-target; the reports path is not.** The Compose plugin appends
`<target>/<compilation>` to `metricsDestination` but hands `reportsDestination` to every
compilation verbatim, and a KMP module's targets share a Kotlin module name. So all six targets
write the same `Recipes_ui-classes.txt` and the last to run wins. After a full `./gradlew build`
you are reading whichever target finished last. `composeStabilityCheck` is the invocation to
trust: it depends on exactly one compile task (JVM where there is one, `compileDebugKotlin` for
`:app`, `compileKotlinJs` for `:webApp`), so it has a single writer. That is also why it is not
wired into `check`, and why CI runs it as its own step *before* `build`.
- **`reportsDestination` is a compiler *input*, not an output.** A compile task whose reports have
been deleted still counts as up to date and quietly writes nothing. `compose.stability` declares
the directory as an output of that one designated compile task to fix it, which also means the
task re-runs after a full `build` and stays the last writer.

Detekt's `UnstableCollections` covers the same ground at the source level and is on. It is right
about composables that emit UI and wrong about ones that return a value — those are neither
restartable nor skippable, so a parameter's stability is inert. `RecipesProducer.produceByIngredients`
is the single `@Suppress` for that reason.

**A test fake for an in-flight request must not complete.** `produceRetainedContentState` settles a still
loading status when its source completes, so a spinner can never hang — which means
`flowOf(Outcome.Loading)` is a *settled empty result*, not a pending one. Store streams never
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.metro)
alias(libs.plugins.dependency.sorter)
id("compose.stability")
id("dependency.analysis")
id("detekt")
id("formatting")
Expand Down
1 change: 1 addition & 0 deletions build-logic/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {
compileOnly(libs.plugins.dependency.sorter.toDep())
compileOnly(libs.plugins.detekt.toDep())
compileOnly(libs.plugins.kmp.parcelize.toDep())
compileOnly(libs.plugins.kotlin.compose.toDep())
compileOnly(libs.plugins.ktfmt.toDep())

detektPlugins(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.scottolcott.gradle

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,
)
Loading
Loading