[PM-34837] Add Detekt rule requiring named FontWeight constants

Add a detekt-rules module with a NamedFontWeight rule that reports
numeric weights such as FontWeight.W600 or FontWeight(600) and suggests
the named constant used in the Figma designs, such as FontWeight.SemiBold.

The module is added as a non-transitive detektPlugins dependency so
Detekt keeps its own Kotlin standard library and the root lockfile is
unchanged.
This commit is contained in:
André Bispo
2026-11-06 14:52:17 +00:00
parent 35725cca68
commit 72f4874495
11 changed files with 205 additions and 0 deletions
+3
View File
@@ -35,6 +35,8 @@ configurations.configureEach {
dependencies {
detektPlugins(libs.detekt.detekt.formatting)
detektPlugins(libs.detekt.detekt.rules)
// Bitwarden custom rules. Not transitive, so Detekt keeps its own Kotlin standard library.
detektPlugins(project(":detekt-rules")) { isTransitive = false }
kover(project(":annotation"))
kover(project(":app"))
@@ -59,6 +61,7 @@ detekt {
"core/src",
"cxf/src",
"data/src",
"detekt-rules/src",
"network/src",
"testharness/src",
"ui/src",
+5
View File
@@ -667,3 +667,8 @@ style:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]
excludeImports: [ 'java.util.*', 'kotlinx.android.synthetic.*' ]
bitwarden:
active: true
NamedFontWeight:
active: true
+1
View File
@@ -0,0 +1 @@
/build
+27
View File
@@ -0,0 +1,27 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
// The Kotlin Gradle plugin is already on the root build classpath, so no version is needed.
id("org.jetbrains.kotlin.jvm")
}
java {
sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get())
targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get())
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
}
}
dependencies {
compileOnly(libs.detekt.detekt.api)
testImplementation(libs.detekt.detekt.api)
testImplementation(libs.detekt.detekt.test)
testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
}
@@ -0,0 +1,20 @@
package com.bitwarden.detekt
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.RuleSet
import io.gitlab.arturbosch.detekt.api.RuleSetProvider
/**
* Provides Bitwarden's custom Detekt rules.
*/
class BitwardenRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "bitwarden"
override fun instance(config: Config): RuleSet =
RuleSet(
id = ruleSetId,
rules = listOf(
NamedFontWeight(config),
),
)
}
@@ -0,0 +1,75 @@
package com.bitwarden.detekt
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.KtCallExpression
import org.jetbrains.kotlin.psi.KtConstantExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtExpression
/**
* Reports numeric `FontWeight` values, such as `FontWeight.W600` or `FontWeight(600)`, and
* suggests the named constant used in the Figma designs, such as `FontWeight.SemiBold`.
*/
class NamedFontWeight(config: Config) : Rule(config) {
override val issue: Issue = Issue(
id = javaClass.simpleName,
severity = Severity.Style,
description = "Use named FontWeight constants to match the Figma designs.",
debt = Debt.FIVE_MINS,
)
override fun visitDotQualifiedExpression(expression: KtDotQualifiedExpression) {
super.visitDotQualifiedExpression(expression)
if (!expression.receiverExpression.text.isFontWeight()) return
val selector = expression.selectorExpression?.text ?: return
if (!selector.startsWith(prefix = "W")) return
val weight = selector.removePrefix(prefix = "W").toIntOrNull() ?: return
reportIfNamed(expression = expression, current = "FontWeight.$selector", weight = weight)
}
override fun visitCallExpression(expression: KtCallExpression) {
super.visitCallExpression(expression)
if (expression.calleeExpression?.text?.isFontWeight() != true) return
val argument = expression.valueArguments.singleOrNull()?.getArgumentExpression()
val weight = (argument as? KtConstantExpression)?.text?.toIntOrNull() ?: return
reportIfNamed(expression = expression, current = "FontWeight($weight)", weight = weight)
}
private fun reportIfNamed(expression: KtExpression, current: String, weight: Int) {
val name = NAMED_WEIGHTS[weight] ?: return
report(
CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "Use FontWeight.$name instead of $current.",
),
)
}
private fun String.isFontWeight(): Boolean =
this == FONT_WEIGHT || endsWith(suffix = ".$FONT_WEIGHT")
private companion object {
const val FONT_WEIGHT: String = "FontWeight"
@Suppress("MagicNumber")
val NAMED_WEIGHTS: Map<Int, String> = mapOf(
100 to "Thin",
200 to "ExtraLight",
300 to "Light",
400 to "Normal",
500 to "Medium",
600 to "SemiBold",
700 to "Bold",
800 to "ExtraBold",
900 to "Black",
)
}
}
@@ -0,0 +1 @@
com.bitwarden.detekt.BitwardenRuleSetProvider
@@ -0,0 +1,4 @@
bitwarden:
active: true
NamedFontWeight:
active: true
@@ -0,0 +1,66 @@
package com.bitwarden.detekt
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.test.lint
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class NamedFontWeightTest {
private val rule = NamedFontWeight(Config.empty)
@Test
fun `numeric constant should be reported with the named replacement`() {
val findings = rule.lint("val weight = FontWeight.W600")
assertEquals(1, findings.size)
assertEquals(
"Use FontWeight.SemiBold instead of FontWeight.W600.",
findings.single().message,
)
}
@Test
fun `fully qualified numeric constant should be reported`() {
val findings = rule.lint("val weight = androidx.compose.ui.text.font.FontWeight.W400")
assertEquals(1, findings.size)
assertEquals(
"Use FontWeight.Normal instead of FontWeight.W400.",
findings.single().message,
)
}
@Test
fun `numeric constructor should be reported with the named replacement`() {
val findings = rule.lint("val weight = FontWeight(700)")
assertEquals(1, findings.size)
assertEquals("Use FontWeight.Bold instead of FontWeight(700).", findings.single().message)
}
@Test
fun `named constant should not be reported`() {
assertEquals(0, rule.lint("val weight = FontWeight.SemiBold").size)
}
@Test
fun `weight without a named constant should not be reported`() {
assertEquals(0, rule.lint("val weight = FontWeight(650)").size)
}
@Test
fun `weight property should not be reported`() {
assertEquals(0, rule.lint("val value = FontWeight.Bold.weight").size)
}
@Test
fun `numeric constants in a text style should each be reported`() {
val code = """
val style = TextStyle(fontWeight = FontWeight.W500)
val family = FontFamily(Font(1, weight = FontWeight.W900))
""".trimIndent()
assertEquals(2, rule.lint(code).size)
}
}
+2
View File
@@ -101,8 +101,10 @@ coil-coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
coil-coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
detekt-detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" }
detekt-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
detekt-detekt-rules = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" }
detekt-detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" }
google-billing = { module = "com.android.billingclient:billing", version.ref = "googleBilling" }
google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
google-firebase-cloud-messaging = { module = "com.google.firebase:firebase-messaging" }
+1
View File
@@ -48,6 +48,7 @@ include(
":core",
":cxf",
":data",
":detekt-rules",
":network",
":testharness",
":ui",