Author's Profile Picture Avatar Jadarma's Blog

Convenient Detekt Conventions

2025-04-09 2007 words 10 min

Detekt is an amazing static analysis tool for Kotlin that easily integrates within your workflows. However, since it needs to be flexible enough to support as many use-cases as possible, it’s not as straightforward to decide how to go about it. Here’s how I choose to configure Detekt for my Kotlin projects.

Project Scope #

First, let’s agree on what we want to achieve in this guide. This will be a good fit for you if:

Gradle Setup #

We will be using Gradle’s build logic / precompiled script plugins because it’s a really cool way to share logic between multiple subprojects, and you’re missing out if not using it. There are plenty of resources to learn how it works and how you can set up your project to use it, so for the purposes of this guide, I will assume you know the bare essentials and focus on explaining the Detekt-related stuff.

Let’s assume the following project structure:

my-project
├── gradle
│   ├── build-logic
│   ├── wrapper
│   └── libs.versions.toml
├── module-A
├── module-B
├── module-C
└── settings.gradle.kts

The build-logic is our shared config, and it can be placed anywhere, and imported via relative path from the top-level settings.gradle.kts file. Sometimes I like putting it under gradle directory because it’s always there and to me, it means “here’s where we put all build-related stuff” and also reduces the amount of top-level directories. This is entirely subjective!

Let’s declare our dependencies:

# In gradle/libs.versions.toml:
[versions]
detekt = "1.23.8"

[libraries]
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
gradlePlugin-detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }

[bundles]
buildLogicGradlePlugins = [
    "gradlePlugin-detekt",
]

Notice I added the gradle plugins as Maven notation, and not plugin IDs. This is because we need them as dependencies in order to work with the precompiled script plugins! I also made a bundle for them, so we can import them in bulk.

NOTE: If you decided to add the build-logic under the gradle directory, you should manually import the version catalog, since it won’t be in the expected relative position to the project, just something to keep in mind.

Then, let’s load the plugins:

// In gradle/build-logic/build.gradle.kts
plugins {
    `kotlin-dsl`
}

dependencies {
    implementation(libs.bundles.buildLogicGradlePlugins)
}

Pro Tip: How to enable the use of version catalogs from within the precompiled build scripts:

Unfortunately, at the time of writing, we need to do a bit of a workaround so we can use the libs accessor in our precompiled scripts1. Fortunately, the workaround is pretty simple, in the above dependencies block, also add:

implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))

And create this util somewhere in the build-logic/src/main/kotlin package:

package util

internal val Project.libs: LibrariesForLibs
    get() =
        (rootProject.project as ExtensionAware)
            .extensions
            .getByName("libs")
            .let { it as LibrariesForLibs }

Not pretty, but it is what it is for the time being. That’s enough of that, we can now focus on the Detekt plugin!

Detekt Convention Plugin #

Let’s declare a barebones conventions plugin:

// In gradle/build-logic/src/main/kotlin/conventions/detekt.gradle.kts
package conventions

import util.libs

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

dependencies {
    detektPlugins(libs.detekt.formatting)
}

Kotlin configs are not declared here, those belong in a separate convention! We can now go to all the subprojects we want Detekt to analyze, and apply the convention:

// In module-(A|B|C)/build.gradle.kts
plugins {
    id("conventions.detekt")
}

After a Gradle Sync, the Detekt tasks should be available in all the subprojects, but it’s not very useful just yet. Let’s configure it further!

The Configuration Convention #

This is where I stray from the usual tutorials on Detekt. In essence, Detekt uses two files:

Thing is, I want them out of my way, and also neatly organised, and with a high degree of flexibility. And since it’s a convention plugin, all projects will use the same logic. Remember earlier when I said my philosophy for the gradle directory is to keep files related to the build? This is exactly that, so my proposed file structure is this:

gradle/detekt
├── baseline
├── config
│   ├── module-A.yml
│   ├── module-B.yml
│   └── module-C.yml
└── detekt.yml

The detekt.yml is the common configuration. This is where you’d put your rules, and would apply to all projects. Then, in the config directory, you have one YML file per subproject, where you may provide overrides for that module only. A good example would be enforcing package structure:

# In gradle/detekt/config/module-A.yml
naming:
  InvalidPackageDeclaration:
    active: true
    rootPackage: 'example.project.module.a'
    requireRootInDeclaration: true

The baseline is where we will dump existing code smells or assumed debt. (More on this later).

Now that we have the mental model of how we want it to work, we should tell Gradle too.

// In detekt.gradle.kts convention plugin file
detekt {
    buildUponDefaultConfig = true
    parallel = true
    autoCorrect = false

    baseline = file("$rootDir/gradle/detekt/baseline/${project.name}.xml")
    config.from(
        files(
            "$rootDir/gradle/detekt/detekt.yml",
            "$rootDir/gradle/detekt/config/${project.name}.yml",
        )
    )
}

In our example, we have a flat structure (i.e.: all subprojects are a single level nest from the root) so this works. If you have a more complicated structure, you may want to either replace the ${project.name} to some util that converts the Project instance to its path to ensure no collisions, or think of a different path convention to mine, and store only the common detekt.yml in the rootDir and keep everything else project-local. Again, purely subjective!

Disable Config Generation #

Detekt has a QoL task that generates a sample config for you, which is great, but I prefer to do that manually since I’m using a custom structure. We can disable the task to ensure we don’t modify anything even if running it by accident (unfortunately Gradle won’t let us unregister it completely):

// In detekt.gradle.kts convention plugin file
tasks.withType<DetektGenerateConfigTask>().configureEach {
    enabled = false
}

Sync With Kotlin #

You will probably have a separate convention for applying Kotlin settings. You should also tell Detekt to use the same JVM target. For this, I recommend extracting that as a util property, which you can reference from both:

// In detekt.gradle.kts convention plugin file
import util.CompileOptions

tasks.withType<Detekt>().configureEach {
    jvmTarget = CompileOptions.Kotlin.jvmTarget.target
}
tasks.withType<DetektBaseline>().configureEach {
    jvmTarget = CompileOptions.Kotlin.jvmTarget.target
}

Local vs. CI #

Detekt will, by default, hook itself up to the :check task, which may fail your build if any code smells are detected. This is great for CI, but might be annoying for local development. Another thing Detekt does by default is enable all the reporting formats. Some are not useful for humans, we could ignore them locally.

We can fix these by again leveraging Gradle letting us run arbitrary Kotlin code for our configuration!

You can declare a simple util that checks if we are running in CI:

package util

/** Whether the build is run on a CI worker. */
val onCI: Boolean get() = System.getenv("CI").toBoolean()

Then, back to our convention:

// In detekt.gradle.kts convention plugin file
detekt {
    // ...
    ignoreFailures = !onCI
}

tasks.withType<Detekt>().configureEach {
    reports {
        html.required = !onCI
        md.required = !onCI
        sarif.required = true
        txt.required = false
        xml.required = onCI
    }
}

Now, Detekt will still run and generate reports, but will not fail the local build, while still failing the PR if we accidentally forget to fix all outstanding issues.

Similarly, you may choose what reports you want where. For me, Sarif is my favorite because it integrates nicely with IntelliJ, and I disable HTML and MD in CI because I don’t care if my CI reports are human-readable (those are usually consumed by other tools).

Ignore Generated Files #

If you use KSP or any other code generation tools, you might find they are included in the Detekt scans and produce many false positives. Since the Detekt task is itself an extension of Gradle’s SourceTask, we can use Gradle’s DSL to exclude them. I came across this trick on an issue thread2.

// In detekt.gradle.kts convention plugin file
tasks.withType<Detekt>().configureEach {
    exclude { it.file.invariantSeparatorsPath.contains("/build/generated/") }
}
tasks.withType<DetektCreateBaselineTask>().configureEach {
    exclude { it.file.invariantSeparatorsPath.contains("/build/generated/") }
}

Enabling Type Resolution #

The more advanced (and more useful) rules require using type resolution to work3. The default detekt task runs without it, and instead you have many other tasks depending on the platform(s) you are targeting. This is especially relevant in Kotlin Multiplatform projects!

This behavior might change in Detekt 2.0! But until then, personally I never want to run Detekt without type resolution, and the non-resolution task is the only one hooked to the :check tasks. What to do then? Many people suggest making a custom detektAll task4, but I want to go a step further and make the default detekt task behave like it instead!

// In detekt.gradle.kts convention plugin file
tasks.named<Detekt>("detekt").configure {
    exclude("**")
    dependsOn(tasks.withType<Detekt>().filter { it.name != this.name })
}
tasks.named<DetektCreateBaselineTask>("detektBaseline").configure {
    exclude("**")
    dependsOn(tasks.withType<DetektCreateBaselineTask>().filter { it.name != this.name })
}

We use dependsOn to tell Gradle it needs to execute the other Detekt tasks. Here, I choose to depend on all others (filter out to avoid a circular reference), but you may pick and choose, such as giving them as a whitelist, ignoring the ones ending in Test, and so on. This is, say it together with me: purely subjective!

What about that exclude("**")? Well, since the non-type-resolution task is registered by the plugin, we cannot disable or swap it, but we can tell it to ignore everything, and having no source, there are no files to inspect, meaning no rules will be evaluated, and as such the non-type-resolution task will become a NOOP, instead simply acting as glue logic to call the other specialized tasks that it depends on.

By this point, you can run ./gradlew detektBaseline to generate baseline files according to our conventions. Note that they will only be created if there are any rule violations.

All-In-One #

For convenience, here’s our complete convention:

package conventions

import io.gitlab.arturbosch.detekt.Detekt
import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask
import io.gitlab.arturbosch.detekt.DetektGenerateConfigTask
import util.CompileOptions // The utils are YOUR files in `build-logic` project.
import util.onCI
import util.libs

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

dependencies {
    detektPlugins(libs.detekt.formatting)
}

detekt {
    buildUponDefaultConfig = true
    parallel = true
    autoCorrect = false

    ignoreFailures = !onCI

    baseline = file("$rootDir/gradle/detekt/baseline/${project.name}.xml")
    config.from(
        files(
            "$rootDir/gradle/detekt/detekt.yml",
            "$rootDir/gradle/detekt/config/${project.name}.yml",
        )
    )
}

tasks {
    withType<Detekt>().configureEach {
        jvmTarget = CompileOptions.Kotlin.jvmTarget.target

        exclude { it.file.invariantSeparatorsPath.contains("/build/generated/") }

        reports {
            html.required = !onCI
            md.required = !onCI
            sarif.required = true
            txt.required = false
            xml.required = onCI
        }
    }

    withType<DetektCreateBaselineTask>().configureEach {
        jvmTarget = CompileOptions.Kotlin.jvmTarget.target

        exclude { it.file.invariantSeparatorsPath.contains("/build/generated/") }
    }

    withType<DetektGenerateConfigTask>().configureEach {
        enabled = false
    }

    named<Detekt>("detekt").configure {
        exclude("**")
        dependsOn(tasks.withType<Detekt>().filter { it.name != this.name })
    }

    named<DetektCreateBaselineTask>("detektBaseline").configure {
        exclude("**")
        dependsOn(tasks.withType<DetektCreateBaselineTask>().filter { it.name != this.name })
    }
}

Usage #

All your configs are conveniently stored in one place, you always know where to find them.

To run, all you need is:

./gradlew detekt

You will find reports, as usual, in the build/reports/detekt of every subproject. If you enabled Sarif reports, double-clicking them should open them in your IDE for very easy navigation.

To update the baseline and mark all smells as debt, simply run the task then commit the XMLs.

./gradlew detektBaseline

Finally, if you wish to do a local sanity check, pretend you’re a CI:

CI=true ./gradlew detekt

If you have Gradle caching enabled, you might need to pass the --rerun-tasks option if you want to force re-evaluating the rules without having made any source changes.

That’s all there is to it! Neat, isn’t it?

Conclusion #

This takes a bit to set up, and perhaps a few comments to explain, but the end result is really convenient and I quite like it. I hope it proves useful when you develop your Kotlin projects, providing support while staying out of the way.

And while Detekt is a great showcase for this, the real takeaway here is embracing Gradle as a build tool, and using its flexible DSLs to create powerful abstractions for your projects. I recommend you use a similar approach to configuring most Gradle plugins!


Category: programming
Tags: kotlin tutorial