Add LongToDuration lint check mirroring the IDE-only inspection

Android Studio's "Long overload to Duration conversion" inspection
(ConvertLongToDurationInspection) only runs inside the IDE, so its
findings never fail ./gradlew lint. Port it as a custom lint check in a
new lint-rules module: calls to the kotlinx.coroutines timing functions
(delay, withTimeout, debounce, ...) are flagged when the time argument
is a Long/Int rather than a kotlin.time.Duration. Overloads cannot be
told apart at the JVM level (Duration is a value class backed by long),
so the check classifies the argument's Kotlin type via the bundled K2
analysis API.

With warningsAsErrors already enabled, the pre-commit/pre-push hooks and
CI now fail on any new legacy-Long call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho6LW6eEBkVnnpFvr5tvpi
This commit is contained in:
Claude
2026-07-08 21:05:34 +00:00
parent 8f509d0e73
commit fb984b8db4
6 changed files with 139 additions and 0 deletions
+2
View File
@@ -212,6 +212,8 @@ ksp {
}
dependencies {
lintChecks(project(":lint-rules"))
implementation(libs.quartz) {
exclude(group = "net.java.dev.jna")
}
+3
View File
@@ -33,6 +33,8 @@ coroutinesTest = "1.11.0"
kmpTor = "2.6.0"
kmpTorResource = "409.5.0"
secp256k1Jni = "0.23.0"
# Android Lint API version follows AGP: lint = agp major + 23
lintApi = "32.2.1"
[libraries]
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
@@ -80,6 +82,7 @@ paging-compose = { group = "androidx.paging", name = "paging-compose", version.r
paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "pagingCommon" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" }
lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lintApi" }
kmptor-runtime = { module = "io.matthewnelson.kmp-tor:runtime", version.ref = "kmpTor" }
kmptor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", version.ref = "kmpTorResource" }
secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", version.ref = "secp256k1Jni" }
+24
View File
@@ -0,0 +1,24 @@
plugins {
// No version: AGP's built-in Kotlin already puts the plugin on the build classpath
id("org.jetbrains.kotlin.jvm")
alias(libs.plugins.gradle.ktlint) version libs.versions.ktlint.get()
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
jvmToolchain(21)
}
dependencies {
compileOnly(libs.lint.api)
}
tasks.jar {
manifest {
attributes("Lint-Registry-v2" to "com.greenart7c3.nostrsigner.lint.AmberIssueRegistry")
}
}
@@ -0,0 +1,19 @@
package com.greenart7c3.nostrsigner.lint
import com.android.tools.lint.client.api.IssueRegistry
import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
import com.android.tools.lint.detector.api.Issue
class AmberIssueRegistry : IssueRegistry() {
override val issues: List<Issue> = listOf(
LongToDurationDetector.ISSUE,
)
override val api: Int = CURRENT_API
override val vendor: Vendor = Vendor(
vendorName = "Amber",
feedbackUrl = "https://github.com/greenart7c3/Amber/issues",
)
}
@@ -0,0 +1,90 @@
package com.greenart7c3.nostrsigner.lint
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import com.android.tools.lint.detector.api.SourceCodeScanner
import com.intellij.psi.PsiMethod
import com.intellij.psi.PsiTypes
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.analysis.api.types.KaClassType
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.uast.UCallExpression
/**
* Build-time port of Android Studio's "Long overload to Duration conversion" IDE
* inspection (ConvertLongToDurationInspection), which only runs inside the IDE.
* Flags calls that resolve to the legacy Long-millisecond overloads of the
* kotlinx.coroutines timing functions so they get migrated to the
* kotlin.time.Duration overloads.
*/
class LongToDurationDetector :
Detector(),
SourceCodeScanner {
override fun getApplicableMethodNames(): List<String> = listOf(
"delay",
"withTimeout",
"withTimeoutOrNull",
"debounce",
"sample",
"throttle",
"timeout",
"onTimeout",
"advanceTimeBy",
)
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
val packageName = context.evaluator.getPackage(method)?.qualifiedName ?: return
if (!packageName.startsWith("kotlinx.coroutines")) return
// The legacy overloads take the time value as a Long. Duration is a value
// class backed by a JVM long, so the Duration overloads erase to the same
// JVM signature and PSI resolution cannot tell them apart — classify by the
// Kotlin type of the argument at the call site instead.
if (method.parameterList.parameters.none { it.type == PsiTypes.longType() }) return
for (argument in node.valueArguments) {
val ktExpression = argument.sourcePsi as? KtExpression ?: continue
val argumentClassFqName = try {
analyze(ktExpression) {
(ktExpression.expressionType as? KaClassType)?.classId?.asFqNameString()
}
} catch (_: Throwable) {
// Analysis session unavailable — cannot classify the overload
return
}
when (argumentClassFqName) {
// Already migrated to the Duration overload
"kotlin.time.Duration" -> return
"kotlin.Long", "kotlin.Int" -> {
context.report(
ISSUE,
node,
context.getCallLocation(node, includeReceiver = false, includeArguments = true),
"Legacy `Long` overload can be converted to `Duration`",
)
return
}
}
}
}
companion object {
val ISSUE = Issue.create(
id = "LongToDuration",
briefDescription = "Legacy Long overload can be converted to Duration",
explanation = """
kotlinx.coroutines timing functions (`delay`, `withTimeout`, `debounce`, …) \
have `kotlin.time.Duration` overloads that are preferred over the legacy \
Long-millisecond overloads. Replace e.g. `delay(3000)` with \
`delay(3.seconds)` using `kotlin.time.Duration.Companion` extensions.
""",
category = Category.CORRECTNESS,
priority = 5,
severity = Severity.WARNING,
implementation = Implementation(LongToDurationDetector::class.java, Scope.JAVA_FILE_SCOPE),
)
}
}
+1
View File
@@ -17,3 +17,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Nostr Signer"
include(":app")
include(":lint-rules")