Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<Int, String>> {
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<CheckCommonMainImportsTask>("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")
}
}
Original file line number Diff line number Diff line change
@@ -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",
)
}
}
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,6 +19,8 @@ tasks.register("accessibilityChecks") {
dependsOn(":accessibility-test:connectedAndroidTest")
}

registerCommonMainImportGuard()

dependencyAnalysis {
issues {
all {
Expand Down
2 changes: 2 additions & 0 deletions config/commonMain-import-allowlist.txt
Original file line number Diff line number Diff line change
@@ -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.*.
Loading