# Android Build Quality Gates - Patterns

Full, copy-ready code for the gates summarized in
[../SKILL.md](../SKILL.md). Each section mirrors a heading there. Adapt names to
the project's module conventions; nothing here hardcodes a product.

## Contents

- [Layer-dependency gate](#layer-dependency-gate)
- [Coverage floor with Kover](#coverage-floor-with-kover)
- [Compose-stability gate](#compose-stability-gate)
- [Lint on diff](#lint-on-diff)
- [Screenshot validation task](#screenshot-validation-task)
- [Wiring gates into check](#wiring-gates-into-check)

## Layer-dependency gate

Convention plugin `build-logic/convention/src/main/kotlin/quality/LayerGate.kt`.
It derives a module's layer from its path, resolves the allowed set for that
layer, and fails on any project dependency outside it.

```kotlin
package quality

import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.artifacts.ProjectDependency
import org.gradle.kotlin.dsl.withType

enum class Layer {
    APP, FEATURE, DOMAIN, DATA, CORE, UNKNOWN;

    fun allows(depPath: String): Boolean {
        val depLayer = layerOf(depPath)
        return when (this) {
            APP -> true
            FEATURE -> depLayer in setOf(DOMAIN, CORE)
            DOMAIN -> depLayer in setOf(DOMAIN, CORE)
            DATA -> depLayer in setOf(DOMAIN, CORE, DATA)
            CORE -> depLayer in setOf(CORE)
            UNKNOWN -> true
        }
    }

    companion object {
        fun layerOf(path: String): Layer {
            val name = path.substringAfterLast(":")
            return when {
                name.startsWith("app") -> APP
                name.startsWith("feature") -> FEATURE
                name.startsWith("domain") -> DOMAIN
                name.startsWith("data") -> DATA
                name.startsWith("core") -> CORE
                else -> UNKNOWN
            }
        }
    }
}

fun Project.moduleLayer(): Layer = Layer.layerOf(path)

fun Project.registerLayerGate() {
    val layer = moduleLayer()
    val projectDeps = configurations
        .matching { it.name in setOf("implementation", "api") }
        .flatMap { conf -> conf.dependencies.withType<ProjectDependency>().map { it.path } }
        .distinct()

    val checkLayerDependencies = tasks.register("checkLayerDependencies") {
        group = "verification"
        description = "Fails when this module depends on a forbidden layer"
        doLast {
            val forbidden = projectDeps.filterNot { layer.allows(it) }
            if (forbidden.isNotEmpty()) {
                throw GradleException(
                    buildString {
                        appendLine("$path ($layer) declares forbidden dependencies:")
                        forbidden.forEach { appendLine("  - $it (${Layer.layerOf(it)})") }
                        append("Allowed targets for $layer are enforced in Layer.allows().")
                    }
                )
            }
        }
    }
    tasks.named("check").configure { dependsOn(checkLayerDependencies) }
}
```

`projectDeps` is materialized into a `List<String>` at configuration time, so
`doLast` never touches the live `configurations` container and the task is
configuration-cache safe. Apply from a module's `build.gradle.kts` with
`registerLayerGate()` after the plugin block, or fold the call into a broader
`AndroidLibraryConventionPlugin`.

For a temporary accepted exception, prefer a narrow per-edge allowlist over
widening `allows`:

```kotlin
private val acceptedEdges = setOf(
    ":feature-legacy" to ":data-legacy", // TRACKING-1234, remove after migration
)

fun Layer.allowsWithExceptions(fromPath: String, depPath: String): Boolean =
    allows(depPath) || (fromPath to depPath) in acceptedEdges
```

## Coverage floor with Kover

Per-module: apply the plugin and exclude generated code.

```kotlin
plugins {
    id("org.jetbrains.kotlinx.kover")
}

kover {
    reports {
        filters {
            excludes {
                classes(
                    "*_Factory",
                    "*_Factory\$*",
                    "*.BuildConfig",
                    "*Args",
                    "*Directions",
                    "hilt_aggregated_deps.*",
                    "*_HiltModules*",
                    "dagger.hilt.*",
                )
                annotatedBy("androidx.compose.runtime.Composable")
            }
        }
    }
}
```

Aggregation module `coverage/build.gradle.kts` depends on every measured module
and owns the single verify rule:

```kotlin
plugins {
    id("org.jetbrains.kotlinx.kover")
}

dependencies {
    kover(project(":feature-a"))
    kover(project(":feature-b"))
    kover(project(":domain"))
    kover(project(":data"))
}

kover {
    reports {
        total {
            verify {
                rule("Aggregate line coverage floor") {
                    bound {
                        minValue = 60 // seed at measured value, ratchet up
                        coverageUnits = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.LINE
                        aggregationForGroup =
                            kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE
                    }
                }
            }
        }
    }
}
```

Gate behind a property so local builds skip instrumented coverage:

```kotlin
tasks.named("check").configure {
    if (providers.gradleProperty("enableCoverageVerification").isPresent) {
        dependsOn("koverVerify")
    }
}
```

CI: `./gradlew :coverage:check -PenableCoverageVerification`.

## Compose-stability gate

Point the compiler at a metrics dir (per-module, in the Android/compose
convention plugin):

```kotlin
composeCompiler {
    val metricsDir = layout.buildDirectory.dir("compose-metrics")
    metricsDestination.set(metricsDir)
    reportsDestination.set(metricsDir)
}
```

The check task parses `*-composables.txt`. A composable line the compiler emits
looks like `restartable skippable fun HomeScreen(...)` or, when it cannot skip,
`restartable fun HomeScreen(...)` (no `skippable`). The gate fails on the second
shape unless the composable is in the ignore-list.

```kotlin
package quality

import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction

abstract class ComposeStabilityCheck : DefaultTask() {

    @get:InputDirectory
    abstract val reportDir: DirectoryProperty

    @get:InputFile
    @get:Optional
    abstract val ignoreFile: RegularFileProperty

    private val restartableNotSkippable =
        Regex("""^restartable (?!skippable)fun (\w+)""")

    @TaskAction
    fun check() {
        val dir = reportDir.get().asFile
        if (!dir.exists()) return

        val ignored: Set<String> = ignoreFile.orNull?.asFile
            ?.takeIf { it.exists() }
            ?.readLines()
            ?.map { it.substringBefore('#').trim() }
            ?.filter { it.isNotEmpty() }
            ?.toSet()
            ?: emptySet()

        val offenders = dir.walkTopDown()
            .filter { it.name.endsWith("-composables.txt") }
            .flatMap { file -> file.readLines().asSequence() }
            .mapNotNull { line -> restartableNotSkippable.find(line.trim())?.groupValues?.get(1) }
            .filterNot { it in ignored }
            .distinct()
            .toList()

        if (offenders.isNotEmpty()) {
            throw GradleException(
                buildString {
                    appendLine("Unskippable composables (restartable but not skippable):")
                    offenders.forEach { appendLine("  - $it") }
                    appendLine("Fix the unstable parameter, or add the name to")
                    append(ignoreFile.orNull?.asFile?.path ?: "the stability ignore-list")
                }
            )
        }
    }
}
```

Register it and finalize each Kotlin compile so the report is fresh:

```kotlin
val checkComposeStability = tasks.register("checkComposeStability", ComposeStabilityCheck::class.java) {
    group = "verification"
    reportDir.set(layout.buildDirectory.dir("compose-metrics"))
    ignoreFile.set(layout.projectDirectory.file("compose-stability-ignore.txt"))
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach {
    finalizedBy(checkComposeStability)
}
tasks.named("check").configure { dependsOn(checkComposeStability) }
```

`compose-stability-ignore.txt` (per module), seeded with today's offenders:

```
# Accepted exceptions. Each line: composable name. Shrink over time; additions reviewed.
LegacyChartView   # takes third-party unstable ChartModel, TRACKING-2201
```

## Lint on diff

Pre-commit hook `.git/hooks/pre-commit` (or a managed hook via a Gradle plugin):

```bash
#!/usr/bin/env bash
set -euo pipefail

CHANGED=$(git diff --cached --name-only --diff-filter=d -- '*.kt' '*.kts')
if [ -z "$CHANGED" ]; then
  exit 0
fi

./gradlew detektDiff -PdetektInput="$CHANGED" --quiet
```

`detektDiff` task, in the quality convention plugin, points detekt at only the
changed files while keeping the shared config and baseline:

```kotlin
plugins {
    id("io.gitlab.arturbosch.detekt")
}

detekt {
    buildUponDefaultConfig = true
    config.setFrom(rootProject.files("config/detekt/detekt.yml"))
    baseline = rootProject.file("config/detekt/baseline.xml")
}

tasks.register("detektDiff", io.gitlab.arturbosch.detekt.Detekt::class.java) {
    group = "verification"
    description = "Runs detekt over only the files passed in -PdetektInput"
    val input = providers.gradleProperty("detektInput").orElse("")
    setSource(files(input.get().split(Regex("\\s+")).filter { it.isNotBlank() }))
    config.setFrom(rootProject.files("config/detekt/detekt.yml"))
    baseline.set(rootProject.file("config/detekt/baseline.xml"))
    buildUponDefaultConfig = true
    include("**/*.kt", "**/*.kts")
}
```

CI runs the full task with the same config and baseline:

```yaml
- name: Detekt (full)
  run: ./gradlew detekt --continue
```

Regenerate the baseline deliberately (`./gradlew detektBaseline`) and review the
diff; a baseline change that buries a new finding is a review smell.

## Screenshot validation task

Author the tests per `compose-testing`. Here, register the record/verify pair
and the umbrella task. Example with Roborazzi:

```kotlin
tasks.register("recordScreenshots") {
    group = "verification"
    description = "Regenerates golden images; run deliberately, review the diff"
    dependsOn(tasks.matching { it.name == "recordRoborazziDebug" })
}

tasks.register("verifyScreenshots") {
    group = "verification"
    description = "Compares against goldens and fails on mismatch or missing golden"
    dependsOn(tasks.matching { it.name == "verifyRoborazziDebug" })
}

tasks.named("check").configure { dependsOn("verifyScreenshots") }
```

Configure the tool so a missing golden fails rather than records:

```kotlin
roborazzi {
    outputDir.set(layout.projectDirectory.dir("src/test/screenshots"))
    verify {
        // do NOT enable a record-on-missing fallback in the verify task
    }
}
```

CI uploads diff images on failure so the reviewer sees the pixel change:

```yaml
- name: Verify screenshots
  run: ./gradlew verifyScreenshots
- name: Upload diffs
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: screenshot-diffs
    path: '**/build/outputs/roborazzi/*_compare.png'
```

## Wiring gates into check

Assemble every gate in one quality convention plugin and expose a single
aggregate task. This is what a module gets by applying the plugin.

```kotlin
package quality

import org.gradle.api.Plugin
import org.gradle.api.Project

class QualityGatesConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        registerLayerGate()

        val checkComposeStability = tasks.register(
            "checkComposeStability", ComposeStabilityCheck::class.java
        ) {
            group = "verification"
            reportDir.set(layout.buildDirectory.dir("compose-metrics"))
            ignoreFile.set(layout.projectDirectory.file("compose-stability-ignore.txt"))
        }
        tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java)
            .configureEach { finalizedBy(checkComposeStability) }

        val qualityGates = tasks.register("qualityGates") {
            group = "verification"
            description = "Runs all fast, always-on build quality gates"
            dependsOn(
                "checkLayerDependencies",
                "checkComposeStability",
            )
            if (tasks.findByName("verifyScreenshots") != null) {
                dependsOn("verifyScreenshots")
            }
        }

        tasks.named("check").configure {
            dependsOn(qualityGates)
            if (providers.gradleProperty("enableCoverageVerification").isPresent) {
                dependsOn("koverVerify")
            }
        }
    }
}
```

Register the plugin in `build-logic/convention/build.gradle.kts`:

```kotlin
gradlePlugin {
    plugins {
        register("qualityGates") {
            id = "convention.quality.gates"
            implementationClass = "quality.QualityGatesConventionPlugin"
        }
    }
}
```

Apply from any module: `plugins { id("convention.quality.gates") }`. A single
`./gradlew check` then runs the fast gates locally; CI adds `-P` flags for the
expensive ones.
