
In motorsport, track limits are the white lines at the edge of the circuit. Nothing physically stops you from crossing them. The car doesn’t stall, the tyres don’t fall off, nothing explodes. You go wide, you gain a little time, and the lap counter ticks over. Then, a few seconds later, a message comes in from the pit wall: lap deleted.
Custom Detekt rules are the white lines of your codebase.
The difference is that in a race, the stewards are always watching. In a codebase, nobody flags the violation unless you set up something to do it.
Color.Red compiles perfectly. 24.dp hardcoded in a composable runs without complaint. The app ships. Then six months later someone opens a screen with a dark theme and a red button appears out of nowhere, because the designer updated the token and the hardcoded value never got the memo. The compiler didn’t flag it. The linter could have, if you’d told it what to look for.
This isn’t a “what is Detekt” article. If you’ve used it, you know the basics. What I want to show is how to write your own rules: the AST visitor pattern, the escape hatch, the yml configuration, and the part that silently breaks everything if you miss it.
The setup
Custom rules live in their own module. You could put them in build-logic (I do in my own project), but a dedicated :detekt-rules module makes the setup easier to follow here. More on build-logic in a future article.
composeApp/
detekt-rules/
src/
main/
kotlin/designsystem/
resources/META-INF/services/
test/kotlin/designsystem/
Add the module to settings.gradle.kts:
// settings.gradle.kts
include(":detekt-rules")
The module needs two dependencies: the Detekt API to write the rules, and the Detekt test utilities to verify them:
# libs.versions.toml
[versions]
detekt = "1.23.8"
[libraries]
detekt-api = { group = "io.gitlab.arturbosch.detekt", name = "detekt-api", version.ref = "detekt" }
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
detekt-test = { group = "io.gitlab.arturbosch.detekt", name = "detekt-test", version.ref = "detekt" }
[plugins]
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
// detekt-rules/build.gradle.kts
plugins {
alias(libs.plugins.detekt)
}
dependencies {
implementation(libs.detekt.api)
implementation(libs.detekt.formatting)
testImplementation(libs.detekt.test)
}
detekt {
config.setFrom(files("$rootDir/detekt.yml"))
autoCorrect = true
}
// This is needed so detekt can catch anything inside these modules
tasks.withType<Detekt>().configureEach {
setSource(
files(
"src/commonMain/kotlin",
"src/androidMain/kotlin",
"src/iosMain/kotlin",
"src/jvmMain/kotlin",
"src/main/kotlin"
)
)
// Exclude generated files
exclude("**/generated/**")
exclude("**/resources/**")
}
The module structure is ready. Now the actual rules. Writing a Detekt rule means working with the Kotlin PSI tree: the abstract representation of your source code that the compiler builds before it does anything else. Each node type maps to something in your code, and each visitor method is how you intercept it.
You need to add the detekt plugin in all modules to ensure that works.
detekt {
config.setFrom(files("$rootDir/detekt.yml"))
autoCorrect = true // This allows detekt to auto correct the small issues.
}
Rule 1: Color — multiple visitors, one boundary
Start with color, because it’s the most instructive. You might think Color.Red is the only thing to catch, but there are actually three ways a raw color can appear in your codebase. Color.Red is obvious. Red imported directly from the Compose package is less obvious. And Color(0xFFFF0000), the constructor with a hardcoded hex value, is the one that slips through if you only watch for the first two.
Three ways to cross the white line, three different PSI nodes, three visitor methods.
// detekt-rules/src/commonMain/kotlin/designsystem/DesignSystemColorRule.kt
import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtReferenceExpression
class DesignSystemColorRule : Rule() {
override val issue = Issue(
id = "DirectColorUsage",
severity = Severity.Maintainability,
description = "Direct use of Color literals is prohibited. Use MaterialTheme.colorScheme.",
debt = Debt.FIVE_MINS
)
override fun visitReferenceExpression(expression: KtReferenceExpression) {
super.visitReferenceExpression(expression)
val text = expression.text
// Catch "Color.Red", "Color.Blue", etc.
if (text.startsWith("Color.")) {
val colorName = text.removePrefix("Color.")
if (colorName != "Transparent" && colorName != "Unspecified") {
reportViolation(expression, "Direct property access '$text' is prohibited.")
}
}
// Catch bare "Red", "Blue" if imported directly
val forbiddenColors = listOf("Red", "Blue", "Green", "Yellow", "Cyan", "Magenta")
if (text in forbiddenColors) {
reportViolation(expression, "Standard color '$text' is prohibited.")
}
}
override fun visitCallExpression(expression: KtCallExpression) {
super.visitCallExpression(expression)
// Catch "Color(0xFF...)" constructor calls
if (expression.calleeExpression?.text == "Color") {
reportViolation(expression, "Direct Color(...) constructor calls are prohibited.")
}
}
private fun reportViolation(expression: KtElement, message: String) {
// Don't flag the theme definition files themselves
val fileName = expression.containingKtFile.name
if (fileName.contains("Color") || fileName.contains("Theme")) return
report(
CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "$message Use MaterialTheme.colorScheme instead."
)
)
}
}
That reportViolation check is the one that will save you on day one. Without it, the rule flags your own theme file, the place where Color.Red is supposed to live, because it’s the token definition. Any file containing “Color” or “Theme” in its name gets a free pass.
Transparent and Unspecified are also exempt. They’re utility values with no semantic token equivalent, and wrapping them in a constant just adds noise.
That’s the color rule done. One class, two visitor methods, one private helper. The pattern feels a bit strange the first time. You’re used to writing logic that runs, not logic that gets called by a tree walker. By the second rule it clicks.
Rule 2: Typography — the escape hatch
The typography rule teaches two things the color rule didn’t need: an allowlist for values that are genuinely valid, and a way for the team to suppress a violation on a specific line without disabling the rule globally.
0.sp is a legitimate value. It explicitly clears a font size, so flagging it would just create noise your team learns to ignore. The escape hatch is for everything else: the genuine one-off where someone knows exactly why they’re hardcoding a value and shouldn’t have to fight the linter to do it.
// detekt-rules/src/commonMain/kotlin/designsystem/DesignSystemTypographyRule.kt
import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtImportDirective
import org.jetbrains.kotlin.psi.KtReferenceExpression
import org.jetbrains.kotlin.psi.psiUtil.getParentOfType
class DesignSystemTypographyRule : Rule() {
override val issue = Issue(
id = "DirectTextStyleUsage",
severity = Severity.Maintainability,
description = "Hardcoded TextStyle or .sp values are prohibited. Use MaterialTheme.typography.",
debt = Debt.FIVE_MINS
)
private val allowedSpValues = listOf("0", "0.0", "0f")
override fun visitReferenceExpression(expression: KtReferenceExpression) {
super.visitReferenceExpression(expression)
// Skip import statements — "sp" appears there too
if (expression.getParentOfType<KtImportDirective>(false) != null) return
val file = expression.containingKtFile
if (file.name.contains("Theme") || file.name.contains("Typography")) return
if (expression.text == "sp") {
// Team can suppress individual lines with this comment
if (expression.getLineContents().contains("// design-ignore")) return
val receiverText = expression.parent.firstChild?.text
if (receiverText !in allowedSpValues) {
report(
CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "Hardcoded font size '$receiverText.sp' detected. Use MaterialTheme.typography instead."
)
)
}
}
}
override fun visitCallExpression(expression: KtCallExpression) {
super.visitCallExpression(expression)
if (expression.calleeExpression?.text == "TextStyle") {
val file = expression.containingKtFile
if (file.name.contains("Theme") || file.name.contains("Typography")) return
if (expression.getLineContents().contains("// design-ignore")) return
report(
CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "Direct TextStyle(...) constructor is prohibited. Use MaterialTheme.typography instead."
)
)
}
}
private fun KtElement.getLineContents(): String {
val doc = containingFile.viewProvider.document ?: return ""
val lineNum = doc.getLineNumber(textRange.startOffset)
val start = doc.getLineStartOffset(lineNum)
val end = doc.getLineEndOffset(lineNum)
return doc.charsSequence.subSequence(start, end).toString()
}
}
getLineContents() reads the full source line at the violation site and checks for // design-ignore. Think of it as a track limits waiver: “yes, I went wide here, I know exactly what I’m doing.”
Color and typography are both all-or-nothing rules. Either you use the theme token or you don’t. Spacing is where that stops being true, and it’s why the third rule needs something neither of the first two did.
Rule 3: Spacing — configurable via detekt.yml
Color and typography are all-or-nothing. Spacing is not. 1.dp for a divider border is fine. 24.dp hardcoded in a column padding is not. And every team draws that line differently. One project allows up to 4.dp for borders and nothing else, another allows 2.dp, another has a designer who insists 8.dp is always safe. Hardcoding that decision into the rule means editing Kotlin every time the conversation changes.
So we don’t hardcode it. We read it from detekt.yml, and Config is what makes that possible.
// detekt-rules/src/commonMain/kotlin/designsystem/DesignSystemSpacingRule.kt
import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtImportDirective
import org.jetbrains.kotlin.psi.KtReferenceExpression
import org.jetbrains.kotlin.psi.psiUtil.getParentOfType
class DesignSystemSpacingRule(config: Config = Config.empty) : Rule(config) {
override val issue = Issue(
id = "DirectSpacingUsage",
severity = Severity.Maintainability,
description = "Hardcoded .dp values are prohibited. Use spacing tokens.",
debt = Debt.FIVE_MINS
)
// Falls back to this list if nothing is defined in detekt.yml
private val allowedValues: List<String>
get() = valueOrDefault("allowedValues", listOf("0", "0.0", "0f", "1", "1f"))
override fun visitReferenceExpression(expression: KtReferenceExpression) {
super.visitReferenceExpression(expression)
if (expression.getParentOfType<KtImportDirective>(false) != null) return
val file = expression.containingKtFile
if (file.name.contains("Theme") || file.name.contains("Dimensions")) return
if (expression.text == "dp") {
if (expression.getLineContents().contains("// design-ignore")) return
val receiverText = expression.parent.firstChild?.text
if (receiverText !in allowedValues) {
report(
CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "Hardcoded spacing '$receiverText.dp' detected. Use spacing tokens instead."
)
)
}
}
}
private fun KtElement.getLineContents(): String {
val doc = containingFile.viewProvider.document ?: return ""
val lineNum = doc.getLineNumber(textRange.startOffset)
val start = doc.getLineStartOffset(lineNum)
val end = doc.getLineEndOffset(lineNum)
return doc.charsSequence.subSequence(start, end).toString()
}
}
valueOrDefault reads from the yml if the key exists, falls back to the hardcoded list if it doesn’t. The rule works out of the box with no yml at all. The yml just lets teams move the white lines without touching the rule code.
# detekt.yml
design-system-rules:
DesignSystemColorRule:
active: true
DesignSystemTypographyRule:
active: true
DesignSystemSpacingRule:
active: true
allowedValues:
- '0'
- '0.0'
- '0f'
- '1'
- '1f'
- '2' # dividers
Three rules written. If you run Detekt now, you’ll get zero violations. Not because your codebase is clean. Detekt has no idea your rules exist. That’s the next part, and it’s the one I got wrong the first time.
Wiring it up — the part that silently breaks everything
Three rules written. Zero violations reported. This is where most people get stuck, and there’s no error to explain why.
Detekt uses Java’s ServiceLoader mechanism to discover rule sets. It scans every jar on the classpath for a specific file. If the file isn’t there, your rules don’t exist as far as Detekt is concerned. The jar loads, the code compiles, and nothing gets flagged.
First, the RuleSetProvider. This is what Detekt actually calls:
// detekt-rules/src/commonMain/kotlin/designsystem/DesignSystemRuleSetProvider.kt
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.RuleSet
import io.gitlab.arturbosch.detekt.api.RuleSetProvider
class DesignSystemRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "design-system-rules"
override fun instance(config: Config): RuleSet {
return RuleSet(
id = ruleSetId,
rules = listOf(
DesignSystemColorRule(),
DesignSystemTypographyRule(),
DesignSystemSpacingRule(config.subConfig("DesignSystemSpacingRule"))
)
)
}
}
config.subConfig("DesignSystemSpacingRule") passes the yml values down into the spacing rule. Color and Typography don’t need it, they have no configurable values.
Now create this file:
detekt-rules/src/commonMain/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider
Its entire content is one line:
com.kmpbits.detektrules.designsystem.DesignSystemRuleSetProvider
The file name is the interface. The content is the implementation. Detekt finds the file, reads the class name, loads it. This is the white line you really don’t want to miss. Unlike track limits in a race, there’s no camera to catch it for you.
Run Detekt now and you’ll see your rules firing. You just need to run ./gradlew :composeApp:detekt on terminal and it will detect the issues from the BadExampleCard.kt.
Caught in the act
With everything wired up, this is what a violation looks like in Android Studio:
design-system-rules - 25min debt
DirectColorUsage - [Standard color 'Red' is prohibited. Use MaterialTheme.colorScheme instead.] at detektCustomRulesDemoKMP/composeApp/src/commonMain/kotlin/com/kmpbits/detektcustomrulesdemokmp/DemoScreen.kt:30:31
DirectColorUsage - [Direct Color(...) constructor calls are prohibited. Use MaterialTheme.colorSchem(...)] at detektCustomRulesDemoKMP/composeApp/src/commonMain/kotlin/com/kmpbits/detektcustomrulesdemokmp/DemoScreen.kt:38:25
DirectTextStyleUsage - [Hardcoded font size '14.sp' detected. Use MaterialTheme.typography instead.] at detektCustomRulesDemoKMP/composeApp/src/commonMain/kotlin/com/kmpbits/detektcustomrulesdemokmp/DemoScreen.kt:31:31
DirectTextStyleUsage - [Hardcoded font size '12.sp' detected. Use MaterialTheme.typography instead.] at detektCustomRulesDemoKMP/composeApp/src/commonMain/kotlin/com/kmpbits/detektcustomrulesdemokmp/DemoScreen.kt:37:49
DirectTextStyleUsage - [Direct TextStyle(...) constructor is prohibited. Use MaterialTheme.typography in(...)] at detektCustomRulesDemoKMP/composeApp/src/commonMain/kotlin/com/kmpbits/detektcustomrulesdemokmp/DemoScreen.kt:37:25
The violation points to the exact line, shows the rule ID, and includes the message you defined in the Issue. Specific enough to know exactly what to fix, and visible enough that it’s hard to ignore.
Testing a rule
A rule without a test is a rule you’ll break without noticing. I’ve been there, you refactor the visitor logic, everything still compiles, and two weeks later someone tells you Color.Red is getting through again.
The Detekt testing API is the least painful part of this whole setup. You give it source code as a string, run the rule against it, and assert on the findings. No mocking, no file system setup.
// detekt-rules/src/test/kotlin/designsystem/DesignSystemColorRuleTest.kt
import io.github.detekt.test.utils.compileContentForTest
import io.gitlab.arturbosch.detekt.test.lint
import org.junit.Assert.assertEquals
import org.junit.Test
class DesignSystemColorRuleTest {
private val rule = DesignSystemColorRule()
@Test
fun `flags Color dot Red`() {
val code = """
val color = Color.Red
""".trimIndent()
val findings = rule.lint(code)
assertEquals(1, findings.size)
}
@Test
fun `allows Color dot Transparent`() {
val code = """
val color = Color.Transparent
""".trimIndent()
val findings = rule.lint(code)
assertEquals(0, findings.size)
}
@Test
fun `flags Color constructor call`() {
val code = """
val color = Color(0xFFFF0000)
""".trimIndent()
val findings = rule.lint(code)
assertEquals(1, findings.size)
}
@Test
fun `does not flag theme files`() {
val code = """
val primary = Color.Red
""".trimIndent()
val findings = rule.lint(compileContentForTest(code, "AppThemeColors.kt"))
assertEquals(0, findings.size)
}
}
Run them with ./gradlew :detekt-rules:test. Each test is a minimal Kotlin snippet that either should or shouldn’t produce a finding.
A note on the filename parameter:
lint(compileContentForTest("code", "..."))lets you simulate different file names without creating real files. That’s how you test the theme file exclusion without setting up a full project structure.
What about iOS?
On the Swift side, SwiftLint does the same job with its own rule system. It has some differences and I will create an article for that topic.
Wrapping up
Three rules, each showing something the previous one didn’t: multiple visitor methods and theme file exclusion on color, an escape hatch and allowlist on typography, and yml-driven configuration on spacing. The RuleSetProvider ties them together, and the META-INF/services file is what makes Detekt find them.
The white lines don’t stop anyone from going wide. They just make sure the lap gets deleted when it happens.
Happy coding, and stay within the white lines. 🏁
The full demo for this article is available on GitHub.
The KMP Bits app is available on App Store and Google Play — built entirely with KMP.
Comments
Loading comments...