From 0c12be033f5be450eb6e894fff627ea32b527761 Mon Sep 17 00:00:00 2001 From: stylianosgakis Date: Thu, 12 Nov 2026 11:34:07 +0100 Subject: [PATCH] Add checkCommonMainImports guard against JVM and Android imports in commonMain Fails with one :: line per java.*, javax.* or android.* import found under app/ and micro-apps/ src/commonMain, skipping paths listed in config/commonMain-import-allowlist.txt. Runs in the PR ktlint job as its own step. --- .github/workflows/pr.yml | 3 + build-logic/convention/build.gradle.kts | 3 + .../hedvig/android/CommonMainImportGuard.kt | 88 +++++++++++++++++++ .../android/CommonMainImportGuardTest.kt | 25 ++++++ build.gradle.kts | 4 + config/commonMain-import-allowlist.txt | 2 + 6 files changed, 125 insertions(+) create mode 100644 build-logic/convention/src/main/kotlin/com/hedvig/android/CommonMainImportGuard.kt create mode 100644 build-logic/convention/src/test/kotlin/com/hedvig/android/CommonMainImportGuardTest.kt create mode 100644 config/commonMain-import-allowlist.txt diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2638ea30de..d58cac0306 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -86,6 +86,9 @@ jobs: uses: yutailang0119/action-ktlint@v6 with: report-path: "**/build/reports/ktlint/*.xml" + - name: check commonMain imports + if: ${{ !cancelled() }} + run: ./gradlew checkCommonMainImports build: runs-on: ${{ github.event_name == 'push' && 'ubuntu-latest' || 'ubuntu-latest-8-vcpu' }} steps: diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 59cd337529..8ccb8c0025 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -52,6 +52,9 @@ dependencies { // Enables using type-safe accessors to reference plugins from the [plugins] block defined in version catalogs. // Context: https://github.com/gradle/gradle/issues/15383#issuecomment-779893192 implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) + + testImplementation(libs.assertK) + testImplementation(libs.junit) } gradlePlugin { diff --git a/build-logic/convention/src/main/kotlin/com/hedvig/android/CommonMainImportGuard.kt b/build-logic/convention/src/main/kotlin/com/hedvig/android/CommonMainImportGuard.kt new file mode 100644 index 0000000000..0ae1ae7b1e --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/hedvig/android/CommonMainImportGuard.kt @@ -0,0 +1,88 @@ +package com.hedvig.android + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.register + +private val forbiddenImport = Regex("""^\s*import\s+((?:java|javax|android)\.[\w.`*]+)""") + +/** + * Returns the 1-based line number and imported name of every `java.*`, `javax.*` or `android.*` import in [source]. + */ +fun findForbiddenImports(source: String): List> { + return source.lineSequence().mapIndexedNotNull { index, line -> + forbiddenImport.find(line)?.let { index + 1 to it.groupValues[1] } + }.toList() +} + +/** + * Registers `checkCommonMainImports` on the root project. `commonMain` of a module with only JVM and Android targets + * still compiles against JVM API, so this is what keeps such code portable to iOS. + */ +fun Project.registerCommonMainImportGuard() { + check(this == rootProject) { "checkCommonMainImports is a root project task" } + tasks.register("checkCommonMainImports") { + group = "verification" + description = "Fails when a commonMain Kotlin file imports java.*, javax.* or android.*." + for (dir in listOf("app", "micro-apps")) { + sources.from( + fileTree(dir) { + include("**/src/commonMain/**/*.kt") + exclude("**/build/**") + }, + ) + } + allowList.set(layout.projectDirectory.file("config/commonMain-import-allowlist.txt")) + rootDirectory.set(layout.projectDirectory) + marker.set(layout.buildDirectory.file("checkCommonMainImports/ok")) + } +} + +abstract class CheckCommonMainImportsTask : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sources: ConfigurableFileCollection + + /** Root-relative paths, one per line, each preceded by a `#` comment giving the reason. */ + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val allowList: RegularFileProperty + + @get:Internal + abstract val rootDirectory: DirectoryProperty + + @get:OutputFile + abstract val marker: RegularFileProperty + + @TaskAction + fun check() { + val allowed = allowList.get().asFile.readLines() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .toSet() + val root = rootDirectory.get().asFile + val violations = sources.files.sorted().flatMap { file -> + val path = file.relativeTo(root).invariantSeparatorsPath + if (path in allowed) return@flatMap emptyList() + findForbiddenImports(file.readText()).map { (line, import) -> "$path:$line: $import" } + } + if (violations.isNotEmpty()) { + throw GradleException( + "commonMain must not import java.*, javax.* or android.*. Move the code behind a platform seam, or add the " + + "file to config/commonMain-import-allowlist.txt with a reason.\n" + violations.joinToString("\n"), + ) + } + marker.get().asFile.writeText("OK\n") + } +} diff --git a/build-logic/convention/src/test/kotlin/com/hedvig/android/CommonMainImportGuardTest.kt b/build-logic/convention/src/test/kotlin/com/hedvig/android/CommonMainImportGuardTest.kt new file mode 100644 index 0000000000..07740fe9e8 --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/hedvig/android/CommonMainImportGuardTest.kt @@ -0,0 +1,25 @@ +package com.hedvig.android + +import assertk.assertThat +import assertk.assertions.containsExactly +import org.junit.Test + +class CommonMainImportGuardTest { + @Test + fun `flags java javax and android imports only`() { + val source = """ + package a + import java.util.Locale + import javax.inject.Inject + import android.content.Context + import androidx.compose.runtime.Composable + import kotlin.time.Clock + import javafoo.Bar + """.trimIndent() + assertThat(findForbiddenImports(source)).containsExactly( + 2 to "java.util.Locale", + 3 to "javax.inject.Inject", + 4 to "android.content.Context", + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 15048bc17c..974861f9a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import com.hedvig.android.registerCommonMainImportGuard + plugins { // Every other plugin's classpath is supplied by the `hedvig.settings` convention plugin (see // build-logic/convention/build.gradle.kts), so project scripts apply them by id without a version. @@ -17,6 +19,8 @@ tasks.register("accessibilityChecks") { dependsOn(":accessibility-test:connectedAndroidTest") } +registerCommonMainImportGuard() + dependencyAnalysis { issues { all { diff --git a/config/commonMain-import-allowlist.txt b/config/commonMain-import-allowlist.txt new file mode 100644 index 0000000000..6277ad4135 --- /dev/null +++ b/config/commonMain-import-allowlist.txt @@ -0,0 +1,2 @@ +# Files under src/commonMain that checkCommonMainImports skips, as paths relative to the repository root. +# Precede every entry with a # comment giving the reason it may import java.*, javax.* or android.*.