ru.kode.android.app-quality.foundation is a Gradle plugin for Android/Kotlin repositories that centralizes static analysis and formatting checks.
It configures Detekt for eligible modules, runs ktlint through CLI, and provides aggregate verification tasks for local development and CI.
- Registers aggregate quality tasks:
pipelineCheckandprePushCheck - Registers
ktlintCheckandktlintFormattasks at the root project - Configures Detekt in subprojects and merges platform-specific Detekt configs
- Sets Git hooks path via
gitHooksSetup - Supports configurable logging and Detekt JVM target
- Provides bundled default config files when project-level files are missing
- Runs detekt 1 (default) or detekt 2 (
ru.kode.appQuality.detektEngine=2)
- Java 17+
mavenCentral()in the project repositories (thekoderule sets are resolved from it)- Version catalog named
libs(optional, see below)
Tested minimums and the latest combination (./gradlew -p plugin-test :foundation:matrixTest):
| Engine | Gradle | AGP | Kotlin (KGP) | JDK |
|---|---|---|---|---|
| 1 (detekt 1.23.8) — minimum | 8.14 | 8.7.3 | 2.0.21 | 17 |
| 2 (detekt 2.0.0-alpha.6) — minimum | 8.14 | 8.7.3 | 2.1.21 | 17 |
| both — latest tested | 9.8.0 | 9.4.1 | 2.4.20 | 17, 22 |
AGP 9 needs Gradle 9 and KGP 2.2.10+. Kotlin-DSL and Groovy build scripts are both covered.
The plugin looks up these aliases in the libs catalog. None are required — any alias
missing (or the catalog itself missing) falls back to the plugin's bundled default version:
[versions]
detekt = "1.23.8"
detekt2 = "2.0.0-alpha.6"
ktlintCli = "1.8.0"
kodeDetektRules = "2.0.0"
kodeDetektComposeRules = "2.1.1"
[libraries]
ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlintCli" }
# engine 1
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
detekt-rules = { module = "ru.kode:detekt-rules", version.ref = "kodeDetektRules" }
detekt-compose-rules = { module = "ru.kode:detekt-rules-compose", version.ref = "kodeDetektComposeRules" }
# engine 2
detekt-rules-ktlint-wrapper = { module = "dev.detekt:detekt-rules-ktlint-wrapper", version.ref = "detekt2" }
detekt-rules-detekt2 = { module = "ru.kode:detekt-rules-detekt2", version.ref = "kodeDetektRules" }
detekt-rules-compose-detekt2 = { module = "ru.kode:detekt-rules-compose-detekt2", version.ref = "kodeDetektComposeRules" }Only the aliases of the selected engine are looked up.
Catalog lookups are lazy: a missing alias fails the first task that needs it (with an explanatory message), not the plugin apply, so unrelated tasks keep working.
In settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
google()
}
}In root build.gradle.kts:
plugins {
id("ru.kode.android.app-quality.foundation") version "3.2.3"
}Use the latest published version for your project.
Publish plugin artifacts locally:
./gradlew --project-dir plugin-build publishToMavenLocalThen ensure your consumer project has mavenLocal() in pluginManagement.repositories, and apply:
plugins {
id("ru.kode.android.app-quality.foundation") version "<local-version>"
}The plugin runs detekt 1 (io.gitlab.arturbosch.detekt 1.23.8) by default. To run detekt 2,
put its Gradle plugin on the classpath in the root build and select engine 2:
// root build.gradle.kts
plugins {
id("dev.detekt") version "2.0.0-alpha.6" apply false
id("ru.kode.android.app-quality.foundation") version "3.2.3"
}# gradle.properties
ru.kode.appQuality.detektEngine=2Both engines share the extension DSL, the tasks and the config discovery. Differences on engine 2:
- detekt 2 rejects the
build:config key and renames some rules (e.g.UnusedImports→UnusedImport); the bundled Kotlin config has a detekt 2 flavour. UnusedPrivatePropertyandUseDataClassreport only withdetekt.typeResolutionon.- Engine 1 cannot read class metadata of project dependencies compiled with a Kotlin language version newer than 2.1, so type resolution misses their types; engine 2 reads them.
- The task's
basePathis absolute, so a checkout at another path misses the build cache; with no report enabled the task has no outputs and is not cached. - Custom rule sets must be built against
dev.detekt:detekt-api.
See CHANGELOG.md "Upgrading from 2.x".
detekt.typeResolution (default false) decides which detekt tasks pipelineCheck and
prePushCheck run:
| module | off | on |
|---|---|---|
JVM (kotlin.jvm) |
detekt |
detektMain, detektTest |
| Android on engine 2 or AGP 8 + kotlin-android | detekt |
detekt<C> per analysed component C |
| Android, engine 1 on AGP 9 built-in Kotlin | detekt |
detekt, with the analysed components' classpath (without src/androidTest* unless androidTest is analysed) |
| Kotlin Multiplatform | detekt |
detekt without type resolution, plus a warning |
| Android without Kotlin | detekt |
detekt without type resolution |
- Off: nothing is compiled.
- On: the module and its dependencies compile first. detekt's own
detektMain/detektTestare not used on Android: they cover every variant not in detekt's exact-match ignored build types, which compiles release and androidTest of every module. - Android components are
<variant>,<variant>UnitTestand<variant>AndroidTest. A component is skipped when its variant name or build type contains adetekt.ignoredBuildTypesentry, or its name contains adetekt.ignoredTypeResolutionVariantsentry (default["AndroidTest"]). Both match by substring, ignoring case. With nothing left, the module runs the plaindetekttask without type resolution and warns. Substrings match flavor names too: the defaultdemo,internalandexternalalso skip ademoflavor.--infologs the analysed and skipped components per module. - By default that is
detektDebuganddetektDebugUnitTest.releaseGoogleis skipped byrelease; custom build types likepreprodare analysed until added toignoredBuildTypes. A flavored module analyses each flavor's debug variant; skip a flavor withdetekt.ignoredTypeResolutionVariants.addAll("ruStore"). Set that list empty to analyse androidTest. - Running
./gradlew :module:detektby hand is always without type resolution, except for engine 1 on AGP 9 built-in Kotlin. detekt.sourcesapplies only to the plaindetekttask. detekt's own tasks analyse their compilation's source dirs, minus anything under the module's build directory (generated code such as KSP output or an OpenAPI client inbuild/openapi); exclude other generated code with rule-setexcludes:patterns in the detekt config.
Run the main quality pipeline:
./gradlew pipelineCheckPre-push formatting + static analysis:
./gradlew prePushCheckgitHooksSetup: runsgit config core.hooksPath <path>(default<root>/.githooks); skipped automatically when the root project is not a git repository, or whengitHooksEnabledis set tofalsektlintCheck: runs ktlint checks for Kotlin sources (up-to-date aware: skipped when sources and config did not change)ktlintFormat: runs ktlint auto-format for Kotlin sourcespipelineCheck: depends ongitHooksSetup,ktlintCheck,detektCheck(if eligible modules exist), andandroidLintCheck(ifandroidLint.enabledistrue)prePushCheck: depends ongitHooksSetup,ktlintFormat,detektCheck(if eligible modules exist), andandroidLintCheck(ifandroidLint.enabledistrue)androidLintCheck: runs Android Gradle Plugin lint checks; skipped unlessandroidLint.enabledistrueprintRequiredGradleJvmargs: prints the current Gradle JVM input argumentsgenerateDefaultDetektKotlinConfig/...AndroidConfig/...ComposeConfig/generateDefaultKtlintEditorconfig: materialize bundled default configs into<root>/build/app-quality/; run automatically only when a default is actually used
Extension name:
appQualityFoundation { ... }Example:
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
appQualityFoundation {
verboseLogging.set(false)
jvmTarget.set(JvmTarget.JVM_17)
gitHooks.set(rootProject.layout.projectDirectory.file(".githooks"))
gitHooksEnabled.set(true) // set false to opt out of git hooks setup entirely
ktlint {
projectConfig.set(rootProject.layout.projectDirectory.file(".editorconfig"))
sources {
include.set(listOf("**/src/*/kotlin/**/*.kts"))
exclude.set(listOf("**/build-logic/**"))
// Also available as vararg sugar: include("**/src/*/kotlin/**/*.kts"); exclude("**/build-logic/**")
}
}
detekt {
// addAll("preprod") appends to the defaults, set replaces them
ignoredBuildTypes.set(listOf("release", "internal", "external", "demo"))
sources {
include.set(listOf("src/custom/kotlin"))
exclude.set(listOf("tmpGenerated"))
}
typeResolution.set(false) // true: compile and run detekt's type-resolved tasks, see "Type resolution"
ignoredTypeResolutionVariants.set(listOf("AndroidTest")) // Android components skipped by type resolution, by substring
buildUponDefaultConfig.set(false)
// Only the filename is used — it's re-resolved per subproject, so this is safe to set
// once here even when app-quality-plugin is applied at the root only.
baseline.set(layout.projectDirectory.file("detekt-baseline.xml"))
xmlReportEnabled.set(true)
sarifReportEnabled.set(false)
kotlin {
projectConfig.set(layout.projectDirectory.file("detekt-kotlin-config.yml"))
rules {
from("com.example:my-detekt-rules:1.0.0")
}
}
android {
projectConfig.set(layout.projectDirectory.file("detekt-android-config.yml"))
}
compose {
projectConfig.set(layout.projectDirectory.file("detekt-compose-config.yml"))
}
}
androidLint {
enabled.set(true)
}
}| Property | Default |
|---|---|
verboseLogging |
false |
jvmTarget |
JVM_17 |
gitHooks |
<root>/.githooks |
gitHooksEnabled |
true |
ktlint.sources.include |
["**/src/*/java/**/*.kt", "**/src/*/kotlin/**/*.kt"] (while useDefaults is true) |
ktlint.sources.exclude |
["**/build/**", "**/generated/**", "**/templates/**", "**/src/test/**", "**/src/androidTest/**", "**/src/commonTest/**", "templates/**", "**/schema/**/*.kt"] (while useDefaults is true) |
detekt.ignoredBuildTypes |
["release", "internal", "external", "demo"] (build types skipped by type resolution; on Android matched by substring against variant name and build type) |
detekt.sources.include |
per-platform Kotlin/Java source dirs (while useDefaults is true) |
detekt.sources.exclude |
[] |
detekt.typeResolution |
false (see Type resolution) |
detekt.ignoredTypeResolutionVariants |
["AndroidTest"] (Android components skipped by type resolution, by substring) |
detekt.buildUponDefaultConfig |
false |
detekt.additionalConfigs |
empty; extra detekt config files merged after the resolved per-platform configs in every module and task, so their values win (lists such as ForbiddenImport.forbiddenImports, imports on detekt 1, replace the bundled ones) |
detekt.baseline |
unset (no baseline); when set, resolved per-subproject by filename — safe to configure once regardless of where the plugin is applied |
detekt.xmlReportEnabled |
false; engine 2 has no xml report, so this enables detekt 2's checkstyle report (the same checkstyle XML, still build/reports/detekt/<task>.xml) |
detekt.sarifReportEnabled |
false |
androidLint.enabled |
false |
ktlint.cli |
libs.ktlint-cli, falling back to the plugin's own baked-in com.pinterest.ktlint:ktlint-cli coordinate if no matching catalog alias exists (while useDefaults is true) |
detekt.kotlin.rules |
engine 1: libs.detekt-formatting, else io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8; engine 2: libs.detekt-rules-ktlint-wrapper, else dev.detekt:detekt-rules-ktlint-wrapper:2.0.0-alpha.6 (while useDefaults is true) |
detekt.android.rules |
engine 1: libs.detekt-rules, else ru.kode:detekt-rules:2.0.0; engine 2: libs.detekt-rules-detekt2, else ru.kode:detekt-rules-detekt2:2.0.0 (while useDefaults is true) |
detekt.compose.rules |
engine 1: libs.detekt-compose-rules, else ru.kode:detekt-rules-compose:2.1.1; engine 2: libs.detekt-rules-compose-detekt2, else ru.kode:detekt-rules-compose-detekt2:2.1.1 (while useDefaults is true) |
Every external dependency of the plugin lives in a uniform slot (ktlint.cli,
detekt.<platform>.rules) configurable from ANY source through one from(...) API —
version-catalog accessors, string coordinates (e.g. your own published rule sets), or jar
files. Additions always stack ON TOP of the slot's default; disable the default with
useDefaults.set(false).
For every slot, a matching alias in your own libs catalog (if present) always wins; the
plugin's baked-in coordinate is only a fallback, so the plugin works with zero catalog setup
too. See CHANGELOG.md and MIGRATION.md for upgrade notes.
appQualityFoundation {
ktlint.cli {
from(deps.ktlint.cli) // typed accessor from any catalog
// from("com.pinterest.ktlint:ktlint-cli:1.8.0") // or coordinates
// from(fileTree("tools/ktlint") { include("*.jar") }) // or checked-in jars
useDefaults.set(false) // drop the `libs` catalog default
}
detekt.kotlin.rules {
from(files("tools/my-rules.jar")) // stacks on detekt-formatting
}
detekt.android.rules {
from("ru.kode:detekt-rules:2.0.0") // pin another published version
useDefaults.set(false) // replace the default entirely
}
}A configured-but-missing file in any slot fails the build with an explanatory message naming the slot.
All configuration blocks (ktlint { }, detekt { }, detekt.kotlin { }, cli { },
rules { }) have both Action and Groovy Closure overloads, so the same block syntax
works identically in build.gradle.kts and Groovy build.gradle scripts.
Detekt configs are resolved and merged per module: each module independently walks the priority chain for every platform layer that applies to it (see Module Coverage below):
- Extension override — set once at the root, forces that file for ALL modules
- Module-local file — e.g.
<module>/detekt-kotlin-config.yml, lets a module customize its own rules; other modules are unaffected - Bundled default — used by any module without an override or a local file
Only the storage location of the bundled defaults is root-level: since their content is
identical for every module, they are generated once under <root>/build/app-quality/ by
dedicated tasks instead of being copied into every module. Nothing is written at
configuration time, so the configuration cache stays reusable and creating a module file
later is picked up correctly.
- Ktlint (runs once at the root over all modules, so its whole chain is root-level):
- Extension override:
ktlint.projectConfig - Project file lookup:
<root>/.editorconfig - Bundled default (generated):
<root>/build/app-quality/ktlint/.editorconfig
- Extension override:
- Detekt Kotlin (per module):
- Extension override:
detekt.kotlin.projectConfig - Module file lookup:
<module>/detekt-kotlin-config.yml - Bundled default (generated):
<root>/build/app-quality/detekt/kotlin-config.yml
- Extension override:
- Detekt Android (per module):
- Extension override:
detekt.android.projectConfig - Module file lookup:
<module>/detekt-android-config.yml - Bundled default (generated):
<root>/build/app-quality/detekt/android-config.yml
- Extension override:
- Detekt Compose (per module):
- Extension override:
detekt.compose.projectConfig - Module file lookup:
<module>/detekt-compose-config.yml - Bundled default (generated):
<root>/build/app-quality/detekt/compose-config.yml
- Extension override:
Every dependency slot falls back to a baked-in coordinate default when no matching libs
catalog alias exists (see Defaults above). A configured-but-missing file in any slot fails the build with an explanatory
message naming the slot (see "Configuring dependencies" above).
Detekt is applied only to subprojects that use a matching plugin (plain Java modules are left untouched). Config layers merged per module:
- Kotlin config —
org.jetbrains.kotlin.jvm,org.jetbrains.kotlin.multiplatform,org.jetbrains.kotlin.android,com.android.application,com.android.library - Android config (additionally) —
org.jetbrains.kotlin.android,com.android.application,com.android.library - Compose config (additionally) —
org.jetbrains.compose,org.jetbrains.kotlin.plugin.compose
- Full checks:
./gradlew preMerge - Plugin checks only:
./gradlew --project-dir plugin-build preMerge - Test suite (PR tier):
./gradlew --project-dir plugin-test test - Full compatibility matrix:
./gradlew --project-dir plugin-test :foundation:matrixTest - Published-artifact smoke test:
./gradlew --project-dir plugin-build publishToMavenLocal, then./gradlew --project-dir samples/consumer pipelineCheck [-Pru.kode.appQuality.detektEngine=2] - Example app quality run:
./gradlew --project-dir example-project pipelineCheck
Set environment variables:
GRADLE_PUBLISH_KEYGRADLE_PUBLISH_SECRET
Then publish:
./gradlew --project-dir plugin-build setupPluginUploadFromEnvironment publishPluginsThis project is licensed under the MIT License. See LICENSE.