From b98db0470e8545817b4ca4be433a06a5db69fb21 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Fri, 28 Aug 2026 14:44:45 +0200 Subject: [PATCH 1/2] chore: add a ktlint namespace-import rule for KMP source sets The NamespaceImport Android Lint check cannot reach KMP modules, so the 19 design system violations it just cleaned up were ones it could never have caught. AGP's com.android.kotlin.multiplatform.library registers a lint {} DSL but no task that runs it, and it sits outside com.android.base, so plugin logic keying off the Android plugins does nothing there. Applying the standalone com.android.lint plugin alongside it does produce a working lint that reads commonMain, but every generateAndroidMainLintModel then depends on itself and the build fails with a circular dependency the moment two such modules depend on each other. Neither checkDependencies=false nor android.experimental.lint.analysisPerComponent=false avoids it, and com.android.internal.lint only ever analyses the JVM components, which do not include commonMain. Google issue 246751841 has tracked this since 2022. kotlinter already runs ktlint over every source set, so the same rule lives here as a custom ruleset and covers commonMain, iosMain and the rest. ktlint has no type resolution, so unlike the Lint check this cannot ask whether an owner is a class or a package. CAPITALIZED_PACKAGES carries that cost: Kotlin/Native interop packages are named after the framework they bind, so platform.Foundation.systemLocale is shaped exactly like a member import and has to be excluded by prefix. Both checks are kept. Lint stays the better signal on Android modules, where it resolves types and reports inline in the IDE. --- .../src/main/kotlin/HedvigGradlePlugin.kt | 8 +++ gradle/libs.versions.toml | 4 ++ hedvig-ktlint/build.gradle.kts | 9 +++ .../android/ktlint/HedvigRuleSetProvider.kt | 13 ++++ .../android/ktlint/NamespaceImportRule.kt | 68 +++++++++++++++++++ ...int.cli.ruleset.core.api.RuleSetProviderV3 | 1 + .../lint-baseline-hedvig-ktlint.xml | 4 ++ settings.gradle.kts | 1 + 8 files changed, 108 insertions(+) create mode 100644 hedvig-ktlint/build.gradle.kts create mode 100644 hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/HedvigRuleSetProvider.kt create mode 100644 hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/NamespaceImportRule.kt create mode 100644 hedvig-ktlint/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 create mode 100644 hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml diff --git a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt index d5e62a119d..773d36609e 100644 --- a/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt +++ b/build-logic/convention/src/main/kotlin/HedvigGradlePlugin.kt @@ -38,6 +38,14 @@ private fun Project.configureKtlint(libs: LibrariesForLibs) { reporters = arrayOf(ReporterType.checkstyle.name) } + // Our own rules run on every source set, which is what gives KMP modules the coverage that + // Android Lint cannot reach. + if (name != "hedvig-ktlint") { + dependencies { + add("ktlint", project(":hedvig-ktlint")) + } + } + tasks.withType().configureEach { exclude { it.file.path.contains("generated/") } reports.set( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3bd52e4e1..0e33cc1aae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,8 @@ kmpNativeCoroutines = "1.0.5" kotlin = "2.4.10" kotlinpoet = "2.3.0" kotlinter = "5.6.0" +# Must match the ktlint that kotlinter bundles, so the custom ruleset links against the same API +ktlintCore = "1.8.0" ksp = "2.3.10" ktor = "3.5.1" license = "0.9.9" @@ -271,6 +273,8 @@ kmpNativeCoroutines-gradlePlugin = { module = "com.rickclephas.kmp.nativecorouti kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinSerialization-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } kotlinter-gradlePlugin = { module = "org.jmailen.gradle:kotlinter-gradle", version.ref = "kotlinter" } +ktlint-ruleEngineCore = { module = "com.pinterest.ktlint:ktlint-rule-engine-core", version.ref = "ktlintCore" } +ktlint-cliRulesetCore = { module = "com.pinterest.ktlint:ktlint-cli-ruleset-core", version.ref = "ktlintCore" } ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } license-gradlePlugin = { module = "com.jaredsburrows.license:com.jaredsburrows.license.gradle.plugin", version.ref = "license" } metro-gradlePlugin = { module = "dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin", version.ref = "metro" } diff --git a/hedvig-ktlint/build.gradle.kts b/hedvig-ktlint/build.gradle.kts new file mode 100644 index 0000000000..d22714ecab --- /dev/null +++ b/hedvig-ktlint/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("hedvig.jvm.library") + id("hedvig.gradle.plugin") +} + +dependencies { + compileOnly(libs.ktlint.ruleEngineCore) + compileOnly(libs.ktlint.cliRulesetCore) +} diff --git a/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/HedvigRuleSetProvider.kt b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/HedvigRuleSetProvider.kt new file mode 100644 index 0000000000..9e599aab53 --- /dev/null +++ b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/HedvigRuleSetProvider.kt @@ -0,0 +1,13 @@ +package com.hedvig.android.ktlint + +import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 +import com.pinterest.ktlint.rule.engine.core.api.RuleProvider +import com.pinterest.ktlint.rule.engine.core.api.RuleSetId + +internal const val CUSTOM_RULE_SET_ID = "hedvig" + +class HedvigRuleSetProvider : RuleSetProviderV3(RuleSetId(CUSTOM_RULE_SET_ID)) { + override fun getRuleProviders(): Set = setOf( + RuleProvider { NamespaceImportRule() }, + ) +} diff --git a/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/NamespaceImportRule.kt b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/NamespaceImportRule.kt new file mode 100644 index 0000000000..2eda8d8d7e --- /dev/null +++ b/hedvig-ktlint/src/main/kotlin/com/hedvig/android/ktlint/NamespaceImportRule.kt @@ -0,0 +1,68 @@ +package com.hedvig.android.ktlint + +import com.pinterest.ktlint.rule.engine.core.api.ElementType +import com.pinterest.ktlint.rule.engine.core.api.Rule +import com.pinterest.ktlint.rule.engine.core.api.RuleId +import org.jetbrains.kotlin.com.intellij.lang.ASTNode + +/** + * Reports imports that shorten a qualified reference past the point where the short name still says + * what it is, such as `import hedvig.resources.Res.string` turning `Res.string.FOO` into `string.FOO`. + * + * This is the multiplatform counterpart of the `NamespaceImport` Android Lint check, which cannot run + * on KMP source sets. ktlint has no type resolution, so an owner is recognized by the shape of the + * import path rather than by resolving it, and [CAPITALIZED_PACKAGES] carries the exceptions that + * costs us. + */ +internal class NamespaceImportRule : + Rule( + ruleId = RuleId("$CUSTOM_RULE_SET_ID:namespace-import"), + about = About( + maintainer = "Hedvig", + repositoryUrl = "https://github.com/HedvigInsurance/android", + issueTrackerUrl = "https://github.com/HedvigInsurance/android/issues", + ), + ) { + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + if (node.elementType != ElementType.IMPORT_DIRECTIVE) return + val text = node.text + // An alias is a deliberate act of renaming, and gives the use site a name of its own. + if (text.contains(" as ")) return + val qualifiedName = text.removePrefix("import").trim() + if (qualifiedName.isEmpty() || qualifiedName.endsWith("*")) return + + val importedName = qualifiedName.substringAfterLast('.') + val ownerPath = qualifiedName.substringBeforeLast('.', "") + val ownerName = ownerPath.substringAfterLast('.') + if (importedName.isEmpty() || ownerName.isEmpty()) return + if (!ownerName.first().isUpperCase()) return + // `Duration.Companion.seconds` and friends exist to enable the `5.seconds` receiver idiom. + if (ownerName == "Companion") return + if (CAPITALIZED_PACKAGES.any { qualifiedName.startsWith(it) }) return + + val importsAMember = importedName.first().isLowerCase() + if (!importsAMember && qualifiedName !in DENIED_IMPORTS) return + + emit( + node.startOffset, + "Import $ownerName and write $ownerName.$importedName at the use site. " + + "On its own, $importedName no longer says what it is.", + false, + ) + } + + private companion object { + /** + * Kotlin/Native interop packages are capitalized after the framework they bind, so their + * top-level declarations look identical to members of a class. + */ + val CAPITALIZED_PACKAGES = listOf("platform.") + + /** Imports that read as a type but still leave nothing meaningful at the use site. */ + val DENIED_IMPORTS = setOf("kotlin.time.Clock.System") + } +} diff --git a/hedvig-ktlint/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 b/hedvig-ktlint/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 new file mode 100644 index 0000000000..61cf4aff39 --- /dev/null +++ b/hedvig-ktlint/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 @@ -0,0 +1 @@ +com.hedvig.android.ktlint.HedvigRuleSetProvider diff --git a/hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml b/hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml new file mode 100644 index 0000000000..661c123ac6 --- /dev/null +++ b/hedvig-lint/lint-baseline/lint-baseline-hedvig-ktlint.xml @@ -0,0 +1,4 @@ + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 0c0abfeb19..cfe8b6f178 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -69,3 +69,4 @@ include("design-showcase-desktop") project(":design-showcase-desktop").projectDir = rootProject.projectDir.resolve("micro-apps").resolve("design-showcase-desktop") include("hedvig-lint") +include("hedvig-ktlint") From c315509d72fcdc533ac4a011ac09020fff7801f5 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Wed, 2 Sep 2026 14:55:09 +0200 Subject: [PATCH 2/2] chore: stop lint from reporting version catalog staleness Renovate owns dependency freshness in this repo, so lint's GradleDependency and NewerVersionAvailable notices are duplicate signal that nobody acts on. They resolve against the shared root gradle/libs.versions.toml rather than the module being linted, so they report identically regardless of which module runs lint, and :hedvig-ktlint surfaced 77 of them for dependencies it does not declare. --- hedvig-lint/lint.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hedvig-lint/lint.xml b/hedvig-lint/lint.xml index 5152aacc2b..036ff3c1f0 100644 --- a/hedvig-lint/lint.xml +++ b/hedvig-lint/lint.xml @@ -8,6 +8,12 @@ config entry for a check absent from a given module isn't flagged. --> + + + +