mcpField
V
validating-compose-stability

validating-compose-stability

github.com/skydovesTesting & quality33958 installsUpdated 22w ago
Repository

About validating-compose-stability

A development skill for teams using Jetpack Compose that adds CI validation for composable and parameter stability through committed baselines and Gradle checks. It focuses on detecting regressions, not teaching how to fix unstable code.

Key features

  1. 01Generate human-readable .stability baselines
  2. 02Check stability changes with Gradle tasks
  3. 03Fail CI on stability regressions
  4. 04Exclude packages, classes, and projects
  5. 05Support warning-only local checks

Use cases

  1. 01Flag a composable that becomes unskippable
  2. 02Review stability changes in pull requests
  3. 03Run stability validation after Kotlin compilation
  4. 04Keep previews out of tracked baselines

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/skydoves/android-testing-skills --skill validating-compose-stability

After installing, type/validating-compose-stabilityin Claude Code to confirm it loaded.

Details

From the project README

Validating Compose Stability — A CI Gate On Skippability And Parameter Stability

Compose stability — whether a composable is skippable/restartable and whether its parameters are stable — is invisible until a recomposition profiler catches it in production. This skill makes it a build gate: snapshot the current stability of every composable into a committed .stability baseline, and fail CI when a change makes anything less stable. The tool is the Compose Stability Analyzer Gradle plugin. (Authoring composables for stability — @Immutable/@Stable, the compiler config file, deferred reads — is covered in the compose-performance-skills repo; this skill is purely the validation/CI layer.)

When to use this skill

  • The user wants ./gradlew stabilityCheck to fail CI when a composable becomes unskippable or a parameter flips stable → unstable.
  • The user wants a committed, reviewable baseline of every composable's stability (.stability files) so changes show up in PR diffs.
  • A recomposition regression shipped because nothing flagged a List parameter or a non-@Immutable data class added to a hot composable.
  • The user mentions stabilityDump, stabilityCheck, composeStabilityAnalyzer, @IgnoreStabilityReport, or "stability baseline".
  • The user wants warning-only locally and hard-fail in CI.

When NOT to use this skill

  • The user wants to fix an unstable parameter (@Immutable/@Stable, kotlinx.collections.immutable, stability_config.conf for external types, deferred state reads, derivedStateOf) — that is Compose performance authoring; see the compose-performance-skills repo's stability skills. This skill only validates and gates.
  • The user wants to find which recompositions are firing at runtime (Layout Inspector recomposition counts, Recomposer metrics, composition tracing) — different tool, runtime not build time.
  • The user is doing UI behavioral or screenshot testing — see ../../setup/choosing-test-rule-vs-runtest/SKILL.md and ../../preview/capturing-preview-screenshots-in-ci/SKILL.md.

Prerequisites

  • The Compose Stability Analyzer Gradle plugin applied to each Compose module (apply via the plugins block; coordinates and version from the project's catalog / the plugin docs).
  • Compose compiler metrics available — the plugin analyzes compiled output, so the module's Kotlin compilation must run before stabilityCheck/stabilityDump. In CI this means a needs: build (or running :module:compileDebugKotlin first) — running the check before compilation produces wrong results or fails.
  • A baseline committed to version control: <module>/stability/<module>.stability (generated by stabilityDump).

Workflow

  • 1. Configure the validation block. In each Compose module's build.gradle.kts:
composeStabilityAnalyzer {
    stabilityValidation {
        enabled.set(true)
        outputDir.set(layout.projectDirectory.dir("stability"))   // where the .stability baseline lives
        includeTests.set(false)
        ignoredPackages.set(listOf("com.example.internal"))
        ignoredClasses.set(listOf("PreviewComposables"))
        ignoredProjects.set(listOf("benchmarks", "examples"))
        failOnStabilityChange.set(true)            // build fails on stability changes; false => warning-only
        ignoreNonRegressiveChanges.set(false)      // true => only regressions count; new-stable / improvements ignored
        allowMissingBaseline.set(false)            // true => no baseline yet is not a failure (bootstrap only)
        stabilityConfigurationFiles.add(           // same format as the Compose compiler's stability config
            rootProject.layout.projectDirectory.file("stability_config.conf")
        )
    }
}

Key knobs: failOnStabilityChange (the gate), ignoreNonRegressiveChanges (report only regressions, ignore improvements and newly-added stable composables), stabilityConfigurationFiles (the Compose-compiler-format file declaring external types stable), and the ignored* lists for packages/classes/modules outside the contract.

  • 2. Generate the baseline once and commit it. With the module compiled:
./gradlew :app:compileDebugKotlin
./gradlew :app:stabilityDump          # writes app/stability/app.stability
git add app/stability/app.stability
git commit -m "Add Compose stability baseline"

The .stability file is human-readable: per composable it lists the fully-qualified signature, skippable/restartable status, and each parameter's stability classification with the reason. Android projects get variant-specific tasks (debugStabilityDump, releaseStabilityCheck, …); multi-module projects get one .stability file per module — ./gradlew stabilityDump runs them all, or target a module.

  • 3. Run the check in CI, after compilation. stabilityCheck compares the current compilation against the committed baseline and reports three change kinds: ~ stability regression (a composable/parameter became less stable), + a composable added, - a composable removed. On a regression the build fails with a message naming the affected composables and how their stability changed, and tells you to run stabilityDump if the change was intentional.
# .github/workflows/ci.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'zulu' }
      - run: ./gradlew :app:compileDebugKotlin

  stability_check:
    name: Compose Stability Check
    runs-on: ubuntu-latest
    needs: build                       # MANDATORY — the analysis reads compiled output
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'zulu' }
      - run: ./gradlew stabilityCheck
  • 4. Accept an intentional stability change deliberately. When a regression is real and accepted (a justified List parameter, an external type that genuinely can't be stable), update the baseline as a documented commit — never silently:
./gradlew :app:compileDebugKotlin
./gradlew :app:stabilityDump
git add app/stability/app.stability
git commit -m "Update stability baseline: PokemonList now takes List<Pokemon> — justified by [reason]"

The diff in the .stability file is the review artifact; the commit message is the audit trail.

  • 5. Exclude things that are not part of the contract. Annotate composables that should never be tracked — @Preview functions, internal scaffolding — with @IgnoreStabilityReport; they drop out of both the dump and the check. For whole packages/classes/modules, use ignoredPackages/ignoredClasses/ignoredProjects.
@IgnoreStabilityReport
@Preview
@Composable
fun UserCardPreview() { UserCard(user = User("John", 30)) }
  • 6. (Optional) strict in CI, warning-only locally. Gate hard on the CI server, stay non-blocking on a developer machine:
composeStabilityAnalyzer {
    stabilityValidation { failOnStabilityChange.set(System.getenv("CI") == "true") }
}

Most CI platforms set CI=true.

Patterns

Pattern: running stabilityCheck before compilation
# WRONG
stability_check:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: ./gradlew stabilityCheck            # no compiled output yet
# WRONG because: the analyzer reads the Compose compiler's output. With nothing compiled it
# either fails or reports incorrect results. The check must depend on a build step.
# RIGHT
stability_check:
  runs-on: ubuntu-latest
  needs: build                                  # or: - run: ./gradlew :app:compileDebugKotlin stabilityCheck
  steps:
    - uses: actions/checkout@v4
    - run: ./gradlew stabilityCheck
Pattern: regenerating the baseline to "make CI green"
# WRONG — CI failed on a stability regression, so:
./gradlew stabilityDump && git commit -am "fix stability check"
# WRONG because: stabilityDump rewrites the baseline to whatever the code currently produces, so
# the check trivially passes — the regression ships, undocumented. The dump is for *accepting* a
# change you understand, with a commit message that says why; it is not a way to silence the gate.
# RIGHT — investigate the ~ entries first; only then, if the change is justified:
./gradlew :app:compileDebugKotlin :app:stabilityDump
git add app/stability/app.stability
git commit -m "Update stability baseline: <composable> <what changed> — justified by <reason>"
Pattern: @Preview functions polluting the baseline
// WRONG — preview functions in the .stability baseline, churning it on every preview edit
@Preview @Composable fun ProfilePreview() { Profile(sampleUser) }
// WRONG because: previews are tooling entry points, not API. Tracking them means the baseline
// diffs every time someone tweaks a preview, drowning real regressions.
// RIGHT — annotate them out (or ignoredClasses = listOf("...Preview...") for a naming convention)
@IgnoreStabilityReport @Preview @Composable fun ProfilePreview() { Profile(sampleUser) }

Mandatory rules

  • MUST commit the generated .stability baseline files to version control — they are the shared contract; an uncommitted baseline gates nothing.
  • MUST run stabilityCheck only after Kotlin compilation (CI: needs: a build job, or compile in the same Gradle invocation). The analyzer consumes compiled output.
  • MUST treat a stabilityDump baseline update as a deliberate, reviewed commit with a justification in the message — never as a way to clear a failing check.
  • MUST exclude @Preview and other non-API composables from the contract via @IgnoreStabilityReport (or ignoredClasses/ignoredPackages) so the baseline diffs only on real changes.
  • MUST NOT set failOnStabilityChange = false (or allowMissingBaseline = true) permanently — those are for bootstrapping; once a baseline exists, the gate must fail on regressions.
  • MUST NOT use this skill as the place to fix instability — that is Compose authoring (@Immutable/@Stable, immutable collections, the compiler stability config); see the compose-performance-skills repo.
  • PREFERRED: ignoreNonRegressiveChanges = true if the team only cares about regressions and finds +/new-stable noise distracting.
  • PREFERRED: strict in CI, warning-only locally via failOnStabilityChange.set(System.getenv("CI") == "true").

Verification

  • ./gradlew stabilityDump produces <module>/stability/<module>.stability and it is committed (git status clean after a no-op dump).
  • ./gradlew stabilityCheck passes on the committed baseline and fails when a composable is deliberately made unstable (e.g. add a List<T> parameter to a tracked composable) with a message naming it and a ~ entry.
  • The CI workflow's stability job has needs: build (or compiles before stabilityCheck).
  • @Preview / scaffolding composables carry @IgnoreStabilityReport (or match an ignoredClasses pattern) and do not appear in the .stability file.
  • failOnStabilityChange is true (or CI-gated to true) and allowMissingBaseline is false once the baseline exists.

References

  • skydoves.github.io/compose-stability-analyzer/gradle-plugin/stability-validation/ — stabilityDump / stabilityCheck tasks, the composeStabilityAnalyzer { stabilityValidation { … } } DSL and every option, .stability baseline format, ~/+/- change types, @IgnoreStabilityReport, multi-module behavior.
  • skydoves.github.io/compose-stability-analyzer/gradle-plugin/ci-cd/ — GitHub Actions wiring, the mandatory needs: build dependency, build-failure behavior, the baseline-update workflow, failOnStabilityChange.set(System.getenv("CI") == "true").
  • github.com/skydoves/compose-stability-analyzer — the plugin source, coordinates, and full configuration reference.
  • developer.android.com/develop/ui/compose/performance/stability — Compose stability concepts (skippable/restartable, stable parameters) the baseline is built on.
  • developer.android.com/develop/ui/compose/performance/stability/fix — fixing instability (@Immutable/@Stable, immutable collections, the compiler stability_configuration_path file) — the authoring side this gate protects.
  • Sibling skill: ../../preview/capturing-preview-screenshots-in-ci/SKILL.md — another Compose CI gate (device-rendered preview catalog); same needs: build-style ordering concerns.

Comments

Markdown supported