
Enforces structured concurrency for coroutines via compiler checks, static analyzers, IDE inspections, lint rules and annotations — compile-time errors, quick fixes, tool window, zero runtime overhead.
A comprehensive toolkit for enforcing structured concurrency in Kotlin Coroutines, inspired by Swift Concurrency. It provides multiple layers of protection through compile-time checks and static analysis.
| Module | Status | Documentation |
|---|---|---|
| Compiler Plugin | ✅ Complete (12 rules) | gradle-plugin/README.md |
| Gradle Plugin | ✅ Complete | gradle-plugin/README.md |
| Detekt Rules | ✅ Complete (18 rules) | detekt-rules/README.md |
| Android Lint | ✅ Complete (21 rules) | lint-rules/README.md |
| IntelliJ Plugin | ✅ Complete (13 inspections, 12 quick fixes, 6 intentions, tool window) | intellij-plugin/README.md |
| Annotations | ✅ Complete | annotations/README.md |
| Sample | ✅ Compilation examples per rule | compilation/README |
| Sample (Detekt) | ✅ Detekt rule validation (19 examples) | sample-detekt/README.md |
| Kotlin Coroutines Agent Skill | ✅ AI/agent guidance | kotlin-coroutines-skill/README.md |
Kotlin Coroutines are powerful but can be misused, leading to:
GlobalScope
runBlocking in suspend functionsCancellationException
This toolkit enforces structured concurrency best practices through:
| Module | Purpose | When |
|---|---|---|
compiler |
K2/FIR Compiler Plugin | Compile-time errors |
detekt-rules |
Detekt custom rules | Static analysis |
lint-rules |
Android Lint rules | Android projects |
intellij-plugin |
IntelliJ/Android Studio Plugin | Real-time IDE analysis |
annotations |
@StructuredScope annotation |
Runtime/Compile |
gradle-plugin |
Gradle integration | Build configuration |
@StructuredScope annotationviewModelScope, lifecycleScope, rememberCoroutineScope()
sample-detekt module with one example per Detekt rule (
./gradlew :sample-detekt:detekt)| Rule | Description |
|---|---|
GLOBAL_SCOPE_USAGE |
Prohibits GlobalScope.launch/async
|
INLINE_COROUTINE_SCOPE |
Prohibits CoroutineScope(Dispatchers.X).launch
|
UNSTRUCTURED_COROUTINE_LAUNCH |
Requires structured scope |
RUN_BLOCKING_IN_SUSPEND |
Prohibits runBlocking in suspend functions |
JOB_IN_BUILDER_CONTEXT |
Prohibits Job()/SupervisorJob() in builders |
CANCELLATION_EXCEPTION_SUBCLASS |
Prohibits extending CancellationException
|
UNUSED_DEFERRED |
Prohibits async without .await()
|
| Rule | Description |
|---|---|
DISPATCHERS_UNCONFINED_USAGE |
Warns about Dispatchers.Unconfined
|
SUSPEND_IN_FINALLY_WITHOUT_NON_CANCELLABLE |
Warns about unprotected suspend in finally |
CANCELLATION_EXCEPTION_SWALLOWED |
Warns about catch(Exception) in suspend |
REDUNDANT_LAUNCH_IN_COROUTINE_SCOPE |
Warns about single launch in coroutineScope { }
|
LOOP_WITHOUT_YIELD |
Warns about loops in suspend functions without cooperation points (yield/ensureActive/delay) |
| Rule | Description |
|---|---|
GlobalScopeUsage |
Detects GlobalScope.launch/async
|
InlineCoroutineScope |
Detects CoroutineScope(...).launch/async and property initialization |
RunBlockingInSuspend |
Detects runBlocking in suspend functions |
DispatchersUnconfined |
Detects Dispatchers.Unconfined usage |
CancellationExceptionSubclass |
Detects classes extending CancellationException
|
| Rule | Description |
|---|---|
BlockingCallInCoroutine |
Detects Thread.sleep, JDBC, sync HTTP in coroutines |
RunBlockingWithDelayInTest |
Detects runBlocking + delay in tests |
ExternalScopeLaunch |
Detects launch on external scope from suspend |
LoopWithoutYield |
Detects loops without cooperation points |
ScopeReuseAfterCancel |
Detects scope cancelled then reused |
ChannelNotClosed |
Detects manual Channel() without close() (CHANNEL_001) |
ConsumeEachMultipleConsumers |
Detects same channel with consumeEach from multiple coroutines (CHANNEL_002) |
FlowBlockingCall |
Detects blocking calls inside flow { } (FLOW_001) |
Total: 18 Detekt Rules (10 from Compiler Plugin + 8 Detekt-only)
| Rule | Description |
|---|---|
GlobalScopeUsage |
Detects GlobalScope.launch/async
|
InlineCoroutineScope |
Detects CoroutineScope(...).launch/async and property initialization |
RunBlockingInSuspend |
Detects runBlocking in suspend functions |
DispatchersUnconfined |
Detects Dispatchers.Unconfined usage |
CancellationExceptionSubclass |
Detects classes extending CancellationException
|
JobInBuilderContext |
Detects Job()/SupervisorJob() in builders |
SuspendInFinally |
Detects suspend calls in finally without NonCancellable |
CancellationExceptionSwallowed |
Detects catch(Exception) that may swallow CancellationException |
AsyncWithoutAwait |
Detects async without await()
|
| Rule | Description |
|---|---|
MainDispatcherMisuse |
Detects blocking code on Dispatchers.Main (can cause ANRs) |
ViewModelScopeLeak |
Detects incorrect ViewModel scope usage |
LifecycleAwareScope |
Validates correct lifecycle-aware scope usage |
LifecycleAwareFlowCollection |
Detects Flow collect in lifecycleScope without repeatOnLifecycle/flowWithLifecycle (ARCH_002) |
| Rule | Description |
|---|---|
UnstructuredLaunch |
Detects launch/async without structured scope |
RedundantLaunchInCoroutineScope |
Detects redundant launch in coroutineScope |
RunBlockingWithDelayInTest |
Detects runBlocking + delay in tests |
LoopWithoutYield |
Detects loops without cooperation points |
ScopeReuseAfterCancel |
Detects scope cancelled and reused |
ChannelNotClosed |
Detects manual Channel without close (CHANNEL_001) |
ConsumeEachMultipleConsumers |
Detects same channel with consumeEach in multiple coroutines (CHANNEL_002) |
FlowBlockingCall |
Detects blocking calls inside flow { } (FLOW_001) |
Total: 21 Android Lint Rules (9 from Compiler Plugin + 4 Android-specific + 8 additional)
The IDE plugin provides real-time inspections, quick fixes, intentions, and gutter icons.
| Rule | Severity | Description |
|---|---|---|
GlobalScopeUsage |
ERROR | Detects GlobalScope.launch/async
|
MainDispatcherMisuse |
WARNING | Detects blocking code on Dispatchers.Main
|
ScopeReuseAfterCancel |
WARNING | Detects scope cancelled and then reused |
RunBlockingInSuspend |
ERROR | Detects runBlocking in suspend functions |
UnstructuredLaunch |
WARNING | Detects launch without structured scope |
AsyncWithoutAwait |
WARNING | Detects async without await()
|
InlineCoroutineScope |
ERROR | Detects CoroutineScope(...).launch
|
JobInBuilderContext |
ERROR | Detects Job()/SupervisorJob() in builders |
SuspendInFinally |
WARNING | Detects suspend calls in finally without NonCancellable |
CancellationExceptionSwallowed |
WARNING | Detects catch(Exception) swallowing cancellation |
CancellationExceptionSubclass |
ERROR | Detects classes extending CancellationException
|
DispatchersUnconfined |
WARNING | Detects Dispatchers.Unconfined usage |
LoopWithoutYield |
WARNING | Detects loops in suspend functions without cooperation points (CANCEL_001); quick fixes to add ensureActive/yield/delay |
LifecycleAwareFlowCollection |
WARNING | Detects Flow collect in lifecycleScope without repeatOnLifecycle/flowWithLifecycle (ARCH_002) |
| Quick Fix | Description |
|---|---|
| Replace with viewModelScope | Replace GlobalScope with viewModelScope |
| Replace with lifecycleScope | Replace GlobalScope with lifecycleScope |
| Wrap with coroutineScope | Replace GlobalScope with coroutineScope { } |
| Wrap with Dispatchers.IO | Move blocking code to IO dispatcher |
| Replace cancel with cancelChildren | Allow scope reuse after cancelling children |
| Remove runBlocking | Unwrap runBlocking in suspend functions |
| Add await | Add .await() to async call |
| Convert to launch | Convert unused async to launch |
| Wrap with NonCancellable | Protect suspend calls in finally |
| Add cooperation point in loop | Insert ensureActive(), yield(), or delay(0) in loops (CANCEL_001) |
| Change superclass to Exception | Replace CancellationException with Exception for domain errors (EXCEPT_002) |
| Intention | Description |
|---|---|
| Migrate to viewModelScope | Convert scope to viewModelScope in ViewModels |
| Migrate to lifecycleScope | Convert scope to lifecycleScope in Activities/Fragments |
| Wrap with coroutineScope | Add coroutineScope builder to suspend function |
| Convert launch to async | Change launch to async for returning Deferred |
| Extract suspend function | Extract coroutine lambda to suspend function |
| Convert to runTest | Replace runBlocking with runTest when body contains delay() (TEST_001) |
@StructuredScope
on parameters and properties, so annotated scopes are not reported as unstructured.// settings.gradle.kts
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
gradlePluginPortal()
}
}
// build.gradle.kts
plugins {
kotlin("jvm") version "2.3.0"
id("io.github.santimattius.structured-coroutines") version "0.1.0"
}
dependencies {
implementation("io.github.santimattius:structured-coroutines-annotations:0.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}// build.gradle.kts
plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.7"
}
dependencies {
detektPlugins("io.github.santimattius:structured-coroutines-detekt-rules:0.1.0")
}// build.gradle.kts (Android project)
dependencies {
lintChecks("io.github.santimattius:structured-coroutines-lint-rules:0.1.0")
}Note: Android Lint Rules are only available for Android projects. For multiplatform projects, use the Compiler Plugin or Detekt Rules.
Install from JetBrains Marketplace or build from source:
# Build the plugin
./gradlew :intellij-plugin:build
# Run IDE sandbox for testing
./gradlew :intellij-plugin:runIdeOr install manually:
intellij-plugin/build/distributions/intellij-plugin-*.zip
plugins {
kotlin("multiplatform") version "2.3.0"
id("io.github.santimattius.structured-coroutines") version "0.1.0"
}
kotlin {
jvm()
iosArm64()
iosSimulatorArm64()
js(IR) { browser(); nodejs() }
sourceSets {
commonMain {
dependencies {
implementation("io.github.santimattius:structured-coroutines-annotations:0.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}
}
}
}import io.github.santimattius.structured.annotations.StructuredScope
// Function parameter
fun loadData(@StructuredScope scope: CoroutineScope) {
scope.launch { fetchData() }
}
// Constructor injection
class UserService(
@property:StructuredScope
private val scope: CoroutineScope
) {
fun fetchUser(id: String) {
scope.launch { /* ... */ }
}
}
// Class property
class Repository {
@StructuredScope
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun fetchData() {
scope.launch { /* ... */ }
}
}The plugin automatically recognizes lifecycle-aware framework scopes:
// ✅ Android ViewModel - No annotation needed
class MyViewModel : ViewModel() {
fun load() {
viewModelScope.launch { fetchData() }
}
}
// ✅ Android Activity/Fragment - No annotation needed
class MyActivity : AppCompatActivity() {
fun load() {
lifecycleScope.launch { fetchData() }
}
}
// ✅ Jetpack Compose - No annotation needed
@Composable
fun MyScreen() {
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { doWork() } }) {
Text("Click")
}
}Recognized Framework Scopes:
| Scope | Framework | Package |
|---|---|---|
viewModelScope |
Android ViewModel | androidx.lifecycle |
lifecycleScope |
Android Lifecycle | androidx.lifecycle |
rememberCoroutineScope() |
Jetpack Compose | androidx.compose.runtime |
// ❌ ERROR
GlobalScope.launch { work() }
// ✅ Use framework scopes or @StructuredScope
viewModelScope.launch { work() }// ❌ ERROR
CoroutineScope(Dispatchers.IO).launch { work() }
// ✅ Use a managed scope
class MyClass(@StructuredScope private val scope: CoroutineScope) {
fun doWork() = scope.launch { work() }
}// ❌ ERROR
suspend fun bad() {
runBlocking { delay(1000) }
}
// ✅ Just suspend
suspend fun good() {
delay(1000)
}// ❌ ERROR
scope.launch(Job()) { work() }
scope.launch(SupervisorJob()) { work() }
// ✅ Use supervisorScope
suspend fun process() = supervisorScope {
launch { task1() }
launch { task2() }
}// ❌ ERROR
class MyError : CancellationException()
// ✅ Use regular Exception
class MyError : Exception()// ⚠️ WARNING - May swallow cancellation
suspend fun bad() {
try {
work()
} catch (e: Exception) {
log(e)
}
}
// ✅ Handle separately
suspend fun good() {
try {
work()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(e)
}
}// ⚠️ WARNING - May not execute
try {
work()
} finally {
saveToDb() // Suspend call
}
// ✅ Protected
try {
work()
} finally {
withContext(NonCancellable) {
saveToDb()
}
}// ⚠️ Detekt WARNING
scope.launch {
Thread.sleep(1000) // Blocking!
inputStream.read() // Blocking I/O!
jdbcStatement.execute() // Blocking JDBC!
}
// ✅ Use non-blocking alternatives
scope.launch {
delay(1000)
withContext(Dispatchers.IO) {
inputStream.read()
}
}// ⚠️ Detekt WARNING - Slow test
@Test
fun test() = runBlocking {
delay(1000) // Real delay!
}
// ✅ Fast test with virtual time
@Test
fun test() = runTest {
delay(1000) // Instant!
}// ⚠️ Detekt WARNING - Can't cancel
suspend fun process(items: List<Item>) {
for (item in items) {
heavyWork(item) // No cooperation point
}
}
// ✅ Can be cancelled
suspend fun process(items: List<Item>) {
for (item in items) {
ensureActive()
heavyWork(item)
}
}structured-coroutines:
# Compiler Plugin Rules
GlobalScopeUsage:
active: true
severity: error
InlineCoroutineScope:
active: true
severity: error
RunBlockingInSuspend:
active: true
severity: warning
DispatchersUnconfined:
active: true
severity: warning
CancellationExceptionSubclass:
active: true
severity: error
# Detekt-Only Rules
BlockingCallInCoroutine:
active: true
excludes: [ 'commonMain', 'iosMain' ] # JVM-only
RunBlockingWithDelayInTest:
active: true
ExternalScopeLaunch:
active: true
LoopWithoutYield:
active: true📖 Full Documentation: Detekt Rules Documentation
structured-coroutines/
├── annotations/ # @StructuredScope (Multiplatform)
├── compiler/ # K2/FIR Compiler Plugin
│ ├── UnstructuredLaunchChecker
│ ├── RunBlockingInSuspendChecker
│ ├── JobInBuilderContextChecker
│ ├── DispatchersUnconfinedChecker
│ ├── CancellationExceptionSubclassChecker
│ ├── SuspendInFinallyChecker
│ ├── CancellationExceptionSwallowedChecker
│ ├── UnusedDeferredChecker
│ ├── RedundantLaunchInCoroutineScopeChecker
│ └── LoopWithoutYieldChecker
├── detekt-rules/ # Detekt Custom Rules
│ ├── GlobalScopeUsageRule
│ ├── InlineCoroutineScopeRule
│ ├── RunBlockingInSuspendRule
│ ├── DispatchersUnconfinedRule
│ ├── CancellationExceptionSubclassRule
│ ├── BlockingCallInCoroutineRule
│ ├── RunBlockingWithDelayInTestRule
│ ├── ExternalScopeLaunchRule
│ ├── LoopWithoutYieldRule
│ ├── ScopeReuseAfterCancelRule
│ ├── ChannelNotClosedRule
│ ├── ConsumeEachMultipleConsumersRule
│ └── FlowBlockingCallRule
├── lint-rules/ # Android Lint Rules
│ ├── GlobalScopeUsageDetector
│ ├── MainDispatcherMisuseDetector
│ ├── ViewModelScopeLeakDetector
│ └── ... (21 rules total)
├── intellij-plugin/ # IntelliJ/Android Studio Plugin
│ ├── inspections/ # 13 real-time inspections (incl. LoopWithoutYield, LifecycleAwareFlowCollection)
│ ├── quickfixes/ # 12 automatic quick fixes
│ ├── intentions/ # 6 refactoring intentions (incl. Convert to runTest)
│ ├── guttericons/ # Scope & dispatcher visualization
│ └── view/ # Tool window (findings list, runner, tree visitor)
├── gradle-plugin/ # Gradle Integration
├── sample/ # Examples
│ └── compilation/ # One example per compiler rule (errors & warnings)
└── kotlin-coroutines-skill/ # AI/agent skill for coroutine best practices
| Platform | Compiler Plugin | Detekt Rules | Android Lint | IDE Plugin |
|---|---|---|---|---|
| JVM | ✅ | ✅ | ❌ | ✅ |
| Android | ✅ | ✅ | ✅ | ✅ |
| iOS | ✅ | ✅ | ❌ | ✅ |
| macOS | ✅ | ✅ | ❌ | ✅ |
| watchOS | ✅ | ✅ | ❌ | ✅ |
| tvOS | ✅ | ✅ | ❌ | ✅ |
| Linux | ✅ | ✅ | ❌ | ✅ |
| Windows | ✅ | ✅ | ❌ | ✅ |
| JS | ✅ | ✅ | ❌ | ✅ |
| WASM | ✅ | ✅ | ❌ | ✅ |
# Publish locally
./gradlew publishToMavenLocal
# Run compiler plugin tests
./gradlew :compiler:test
# Run detekt rules tests
./gradlew :detekt-rules:test
# Run all tests
./gradlew test| Approach | When | Errors | Warnings | CI | Real-time | Platform |
|---|---|---|---|---|---|---|
| Compiler Plugin | Compile | ✅ 6 rules | ✅ 3 rules | ✅ | ❌ | All (KMP) |
| Detekt Rules | Analysis | ✅ 3 rules | ✅ 6 rules | ✅ | ❌ | All (KMP) |
| Android Lint Rules | Analysis | ✅ 9 rules | ✅ 8 rules | ✅ | ❌ | Android only |
| IDE Plugin | Editing | ✅ 4 rules | ✅ 7 rules | ❌ | ✅ | All |
| Combined (All) | All | ✅ 9 rules | ✅ 17 rules | ✅ | ✅ | - |
| Code Review | Manual | ❌ | ❌ | ❌ | ❌ | - |
| Runtime | Late | ❌ | ❌ | ❌ | ❌ | - |
Notes:
Copyright 2026 Santiago Mattiauda
Licensed under the Apache License, Version 2.0
Contributions welcome! Please submit a Pull Request.
git clone https://github.com/santimattius/structured-coroutines.git
cd structured-coroutines
./gradlew publishToMavenLocal
./gradlew testEach module contains its own detailed documentation:
| Module | Documentation | Description |
|---|---|---|
| Gradle Plugin | gradle-plugin/README.md | Installation, configuration, severity settings |
| Detekt Rules | detekt-rules/README.md | All 9 rules with examples and configuration |
| Android Lint | lint-rules/README.md | All 17 rules, Android-specific detection |
| IntelliJ Plugin | intellij-plugin/README.md | Inspections, quick fixes, intentions, K2 support |
| Annotations | annotations/README.md | @StructuredScope usage and multiplatform support |
| Compiler | compiler/README.md | K2/FIR checker implementation details |
| Sample (compilation) | compilation/README.md | One example per compiler rule (errors and warnings) |
| Sample (Detekt) | sample-detekt/README.md | One example per Detekt rule; run :sample-detekt:detekt to validate |
| Kotlin Coroutines Agent Skill | kotlin-coroutines-skill/README.md | AI/agent skill for coroutine best practices |
| Best Practices | docs/BEST_PRACTICES_COROUTINES.md | Canonical guide to coroutine good/bad practices |
| Suppressing Rules | docs/SUPPRESSING_RULES.md | Unified suppression IDs (Compiler, Detekt, Lint, IntelliJ) by rule code |
All user-facing text is externalized for localization:
compiler/src/main/resources/messages/CompilerBundle*.properties. Default language is English
so builds and CI are predictable. To use Spanish (or the JVM default locale), set the system
property:
-Dstructured.coroutines.compiler.locale=es (e.g. in gradle.properties:
org.gradle.jvmargs=... -Dstructured.coroutines.compiler.locale=es)-Dstructured.coroutines.compiler.locale=default
StructuredCoroutinesBundle.properties; the IDE uses the platform
language. Spanish: StructuredCoroutinesBundle_es.properties.To add a new language, add a _<locale> properties file (e.g. CompilerBundle_de.properties) with
the same keys.
All user-facing text is externalized for localization:
compiler/src/main/resources/messages/CompilerBundle*.properties. Default language is English so builds and CI are predictable. To use Spanish (or the JVM default locale), set the system property:
-Dstructured.coroutines.compiler.locale=es (e.g. in gradle.properties: org.gradle.jvmargs=... -Dstructured.coroutines.compiler.locale=es)-Dstructured.coroutines.compiler.locale=default
StructuredCoroutinesBundle.properties; the IDE uses the platform language. Spanish: StructuredCoroutinesBundle_es.properties.To add a new language, add a _<locale> properties file (e.g. CompilerBundle_de.properties) with the same keys.
A comprehensive toolkit for enforcing structured concurrency in Kotlin Coroutines, inspired by Swift Concurrency. It provides multiple layers of protection through compile-time checks and static analysis.
| Module | Status | Documentation |
|---|---|---|
| Compiler Plugin | ✅ Complete (12 rules) | gradle-plugin/README.md |
| Gradle Plugin | ✅ Complete | gradle-plugin/README.md |
| Detekt Rules | ✅ Complete (18 rules) | detekt-rules/README.md |
| Android Lint | ✅ Complete (21 rules) | lint-rules/README.md |
| IntelliJ Plugin | ✅ Complete (13 inspections, 12 quick fixes, 6 intentions, tool window) | intellij-plugin/README.md |
| Annotations | ✅ Complete | annotations/README.md |
| Sample | ✅ Compilation examples per rule | compilation/README |
| Sample (Detekt) | ✅ Detekt rule validation (19 examples) | sample-detekt/README.md |
| Kotlin Coroutines Agent Skill | ✅ AI/agent guidance | kotlin-coroutines-skill/README.md |
Kotlin Coroutines are powerful but can be misused, leading to:
GlobalScope
runBlocking in suspend functionsCancellationException
This toolkit enforces structured concurrency best practices through:
| Module | Purpose | When |
|---|---|---|
compiler |
K2/FIR Compiler Plugin | Compile-time errors |
detekt-rules |
Detekt custom rules | Static analysis |
lint-rules |
Android Lint rules | Android projects |
intellij-plugin |
IntelliJ/Android Studio Plugin | Real-time IDE analysis |
annotations |
@StructuredScope annotation |
Runtime/Compile |
gradle-plugin |
Gradle integration | Build configuration |
@StructuredScope annotationviewModelScope, lifecycleScope, rememberCoroutineScope()
sample-detekt module with one example per Detekt rule (
./gradlew :sample-detekt:detekt)| Rule | Description |
|---|---|
GLOBAL_SCOPE_USAGE |
Prohibits GlobalScope.launch/async
|
INLINE_COROUTINE_SCOPE |
Prohibits CoroutineScope(Dispatchers.X).launch
|
UNSTRUCTURED_COROUTINE_LAUNCH |
Requires structured scope |
RUN_BLOCKING_IN_SUSPEND |
Prohibits runBlocking in suspend functions |
JOB_IN_BUILDER_CONTEXT |
Prohibits Job()/SupervisorJob() in builders |
CANCELLATION_EXCEPTION_SUBCLASS |
Prohibits extending CancellationException
|
UNUSED_DEFERRED |
Prohibits async without .await()
|
| Rule | Description |
|---|---|
DISPATCHERS_UNCONFINED_USAGE |
Warns about Dispatchers.Unconfined
|
SUSPEND_IN_FINALLY_WITHOUT_NON_CANCELLABLE |
Warns about unprotected suspend in finally |
CANCELLATION_EXCEPTION_SWALLOWED |
Warns about catch(Exception) in suspend |
REDUNDANT_LAUNCH_IN_COROUTINE_SCOPE |
Warns about single launch in coroutineScope { }
|
LOOP_WITHOUT_YIELD |
Warns about loops in suspend functions without cooperation points (yield/ensureActive/delay) |
| Rule | Description |
|---|---|
GlobalScopeUsage |
Detects GlobalScope.launch/async
|
InlineCoroutineScope |
Detects CoroutineScope(...).launch/async and property initialization |
RunBlockingInSuspend |
Detects runBlocking in suspend functions |
DispatchersUnconfined |
Detects Dispatchers.Unconfined usage |
CancellationExceptionSubclass |
Detects classes extending CancellationException
|
| Rule | Description |
|---|---|
BlockingCallInCoroutine |
Detects Thread.sleep, JDBC, sync HTTP in coroutines |
RunBlockingWithDelayInTest |
Detects runBlocking + delay in tests |
ExternalScopeLaunch |
Detects launch on external scope from suspend |
LoopWithoutYield |
Detects loops without cooperation points |
ScopeReuseAfterCancel |
Detects scope cancelled then reused |
ChannelNotClosed |
Detects manual Channel() without close() (CHANNEL_001) |
ConsumeEachMultipleConsumers |
Detects same channel with consumeEach from multiple coroutines (CHANNEL_002) |
FlowBlockingCall |
Detects blocking calls inside flow { } (FLOW_001) |
Total: 18 Detekt Rules (10 from Compiler Plugin + 8 Detekt-only)
| Rule | Description |
|---|---|
GlobalScopeUsage |
Detects GlobalScope.launch/async
|
InlineCoroutineScope |
Detects CoroutineScope(...).launch/async and property initialization |
RunBlockingInSuspend |
Detects runBlocking in suspend functions |
DispatchersUnconfined |
Detects Dispatchers.Unconfined usage |
CancellationExceptionSubclass |
Detects classes extending CancellationException
|
JobInBuilderContext |
Detects Job()/SupervisorJob() in builders |
SuspendInFinally |
Detects suspend calls in finally without NonCancellable |
CancellationExceptionSwallowed |
Detects catch(Exception) that may swallow CancellationException |
AsyncWithoutAwait |
Detects async without await()
|
| Rule | Description |
|---|---|
MainDispatcherMisuse |
Detects blocking code on Dispatchers.Main (can cause ANRs) |
ViewModelScopeLeak |
Detects incorrect ViewModel scope usage |
LifecycleAwareScope |
Validates correct lifecycle-aware scope usage |
LifecycleAwareFlowCollection |
Detects Flow collect in lifecycleScope without repeatOnLifecycle/flowWithLifecycle (ARCH_002) |
| Rule | Description |
|---|---|
UnstructuredLaunch |
Detects launch/async without structured scope |
RedundantLaunchInCoroutineScope |
Detects redundant launch in coroutineScope |
RunBlockingWithDelayInTest |
Detects runBlocking + delay in tests |
LoopWithoutYield |
Detects loops without cooperation points |
ScopeReuseAfterCancel |
Detects scope cancelled and reused |
ChannelNotClosed |
Detects manual Channel without close (CHANNEL_001) |
ConsumeEachMultipleConsumers |
Detects same channel with consumeEach in multiple coroutines (CHANNEL_002) |
FlowBlockingCall |
Detects blocking calls inside flow { } (FLOW_001) |
Total: 21 Android Lint Rules (9 from Compiler Plugin + 4 Android-specific + 8 additional)
The IDE plugin provides real-time inspections, quick fixes, intentions, and gutter icons.
| Rule | Severity | Description |
|---|---|---|
GlobalScopeUsage |
ERROR | Detects GlobalScope.launch/async
|
MainDispatcherMisuse |
WARNING | Detects blocking code on Dispatchers.Main
|
ScopeReuseAfterCancel |
WARNING | Detects scope cancelled and then reused |
RunBlockingInSuspend |
ERROR | Detects runBlocking in suspend functions |
UnstructuredLaunch |
WARNING | Detects launch without structured scope |
AsyncWithoutAwait |
WARNING | Detects async without await()
|
InlineCoroutineScope |
ERROR | Detects CoroutineScope(...).launch
|
JobInBuilderContext |
ERROR | Detects Job()/SupervisorJob() in builders |
SuspendInFinally |
WARNING | Detects suspend calls in finally without NonCancellable |
CancellationExceptionSwallowed |
WARNING | Detects catch(Exception) swallowing cancellation |
CancellationExceptionSubclass |
ERROR | Detects classes extending CancellationException
|
DispatchersUnconfined |
WARNING | Detects Dispatchers.Unconfined usage |
LoopWithoutYield |
WARNING | Detects loops in suspend functions without cooperation points (CANCEL_001); quick fixes to add ensureActive/yield/delay |
LifecycleAwareFlowCollection |
WARNING | Detects Flow collect in lifecycleScope without repeatOnLifecycle/flowWithLifecycle (ARCH_002) |
| Quick Fix | Description |
|---|---|
| Replace with viewModelScope | Replace GlobalScope with viewModelScope |
| Replace with lifecycleScope | Replace GlobalScope with lifecycleScope |
| Wrap with coroutineScope | Replace GlobalScope with coroutineScope { } |
| Wrap with Dispatchers.IO | Move blocking code to IO dispatcher |
| Replace cancel with cancelChildren | Allow scope reuse after cancelling children |
| Remove runBlocking | Unwrap runBlocking in suspend functions |
| Add await | Add .await() to async call |
| Convert to launch | Convert unused async to launch |
| Wrap with NonCancellable | Protect suspend calls in finally |
| Add cooperation point in loop | Insert ensureActive(), yield(), or delay(0) in loops (CANCEL_001) |
| Change superclass to Exception | Replace CancellationException with Exception for domain errors (EXCEPT_002) |
| Intention | Description |
|---|---|
| Migrate to viewModelScope | Convert scope to viewModelScope in ViewModels |
| Migrate to lifecycleScope | Convert scope to lifecycleScope in Activities/Fragments |
| Wrap with coroutineScope | Add coroutineScope builder to suspend function |
| Convert launch to async | Change launch to async for returning Deferred |
| Extract suspend function | Extract coroutine lambda to suspend function |
| Convert to runTest | Replace runBlocking with runTest when body contains delay() (TEST_001) |
@StructuredScope
on parameters and properties, so annotated scopes are not reported as unstructured.// settings.gradle.kts
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
gradlePluginPortal()
}
}
// build.gradle.kts
plugins {
kotlin("jvm") version "2.3.0"
id("io.github.santimattius.structured-coroutines") version "0.1.0"
}
dependencies {
implementation("io.github.santimattius:structured-coroutines-annotations:0.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}// build.gradle.kts
plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.7"
}
dependencies {
detektPlugins("io.github.santimattius:structured-coroutines-detekt-rules:0.1.0")
}// build.gradle.kts (Android project)
dependencies {
lintChecks("io.github.santimattius:structured-coroutines-lint-rules:0.1.0")
}Note: Android Lint Rules are only available for Android projects. For multiplatform projects, use the Compiler Plugin or Detekt Rules.
Install from JetBrains Marketplace or build from source:
# Build the plugin
./gradlew :intellij-plugin:build
# Run IDE sandbox for testing
./gradlew :intellij-plugin:runIdeOr install manually:
intellij-plugin/build/distributions/intellij-plugin-*.zip
plugins {
kotlin("multiplatform") version "2.3.0"
id("io.github.santimattius.structured-coroutines") version "0.1.0"
}
kotlin {
jvm()
iosArm64()
iosSimulatorArm64()
js(IR) { browser(); nodejs() }
sourceSets {
commonMain {
dependencies {
implementation("io.github.santimattius:structured-coroutines-annotations:0.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}
}
}
}import io.github.santimattius.structured.annotations.StructuredScope
// Function parameter
fun loadData(@StructuredScope scope: CoroutineScope) {
scope.launch { fetchData() }
}
// Constructor injection
class UserService(
@property:StructuredScope
private val scope: CoroutineScope
) {
fun fetchUser(id: String) {
scope.launch { /* ... */ }
}
}
// Class property
class Repository {
@StructuredScope
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun fetchData() {
scope.launch { /* ... */ }
}
}The plugin automatically recognizes lifecycle-aware framework scopes:
// ✅ Android ViewModel - No annotation needed
class MyViewModel : ViewModel() {
fun load() {
viewModelScope.launch { fetchData() }
}
}
// ✅ Android Activity/Fragment - No annotation needed
class MyActivity : AppCompatActivity() {
fun load() {
lifecycleScope.launch { fetchData() }
}
}
// ✅ Jetpack Compose - No annotation needed
@Composable
fun MyScreen() {
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { doWork() } }) {
Text("Click")
}
}Recognized Framework Scopes:
| Scope | Framework | Package |
|---|---|---|
viewModelScope |
Android ViewModel | androidx.lifecycle |
lifecycleScope |
Android Lifecycle | androidx.lifecycle |
rememberCoroutineScope() |
Jetpack Compose | androidx.compose.runtime |
// ❌ ERROR
GlobalScope.launch { work() }
// ✅ Use framework scopes or @StructuredScope
viewModelScope.launch { work() }// ❌ ERROR
CoroutineScope(Dispatchers.IO).launch { work() }
// ✅ Use a managed scope
class MyClass(@StructuredScope private val scope: CoroutineScope) {
fun doWork() = scope.launch { work() }
}// ❌ ERROR
suspend fun bad() {
runBlocking { delay(1000) }
}
// ✅ Just suspend
suspend fun good() {
delay(1000)
}// ❌ ERROR
scope.launch(Job()) { work() }
scope.launch(SupervisorJob()) { work() }
// ✅ Use supervisorScope
suspend fun process() = supervisorScope {
launch { task1() }
launch { task2() }
}// ❌ ERROR
class MyError : CancellationException()
// ✅ Use regular Exception
class MyError : Exception()// ⚠️ WARNING - May swallow cancellation
suspend fun bad() {
try {
work()
} catch (e: Exception) {
log(e)
}
}
// ✅ Handle separately
suspend fun good() {
try {
work()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(e)
}
}// ⚠️ WARNING - May not execute
try {
work()
} finally {
saveToDb() // Suspend call
}
// ✅ Protected
try {
work()
} finally {
withContext(NonCancellable) {
saveToDb()
}
}// ⚠️ Detekt WARNING
scope.launch {
Thread.sleep(1000) // Blocking!
inputStream.read() // Blocking I/O!
jdbcStatement.execute() // Blocking JDBC!
}
// ✅ Use non-blocking alternatives
scope.launch {
delay(1000)
withContext(Dispatchers.IO) {
inputStream.read()
}
}// ⚠️ Detekt WARNING - Slow test
@Test
fun test() = runBlocking {
delay(1000) // Real delay!
}
// ✅ Fast test with virtual time
@Test
fun test() = runTest {
delay(1000) // Instant!
}// ⚠️ Detekt WARNING - Can't cancel
suspend fun process(items: List<Item>) {
for (item in items) {
heavyWork(item) // No cooperation point
}
}
// ✅ Can be cancelled
suspend fun process(items: List<Item>) {
for (item in items) {
ensureActive()
heavyWork(item)
}
}structured-coroutines:
# Compiler Plugin Rules
GlobalScopeUsage:
active: true
severity: error
InlineCoroutineScope:
active: true
severity: error
RunBlockingInSuspend:
active: true
severity: warning
DispatchersUnconfined:
active: true
severity: warning
CancellationExceptionSubclass:
active: true
severity: error
# Detekt-Only Rules
BlockingCallInCoroutine:
active: true
excludes: [ 'commonMain', 'iosMain' ] # JVM-only
RunBlockingWithDelayInTest:
active: true
ExternalScopeLaunch:
active: true
LoopWithoutYield:
active: true📖 Full Documentation: Detekt Rules Documentation
structured-coroutines/
├── annotations/ # @StructuredScope (Multiplatform)
├── compiler/ # K2/FIR Compiler Plugin
│ ├── UnstructuredLaunchChecker
│ ├── RunBlockingInSuspendChecker
│ ├── JobInBuilderContextChecker
│ ├── DispatchersUnconfinedChecker
│ ├── CancellationExceptionSubclassChecker
│ ├── SuspendInFinallyChecker
│ ├── CancellationExceptionSwallowedChecker
│ ├── UnusedDeferredChecker
│ ├── RedundantLaunchInCoroutineScopeChecker
│ └── LoopWithoutYieldChecker
├── detekt-rules/ # Detekt Custom Rules
│ ├── GlobalScopeUsageRule
│ ├── InlineCoroutineScopeRule
│ ├── RunBlockingInSuspendRule
│ ├── DispatchersUnconfinedRule
│ ├── CancellationExceptionSubclassRule
│ ├── BlockingCallInCoroutineRule
│ ├── RunBlockingWithDelayInTestRule
│ ├── ExternalScopeLaunchRule
│ ├── LoopWithoutYieldRule
│ ├── ScopeReuseAfterCancelRule
│ ├── ChannelNotClosedRule
│ ├── ConsumeEachMultipleConsumersRule
│ └── FlowBlockingCallRule
├── lint-rules/ # Android Lint Rules
│ ├── GlobalScopeUsageDetector
│ ├── MainDispatcherMisuseDetector
│ ├── ViewModelScopeLeakDetector
│ └── ... (21 rules total)
├── intellij-plugin/ # IntelliJ/Android Studio Plugin
│ ├── inspections/ # 13 real-time inspections (incl. LoopWithoutYield, LifecycleAwareFlowCollection)
│ ├── quickfixes/ # 12 automatic quick fixes
│ ├── intentions/ # 6 refactoring intentions (incl. Convert to runTest)
│ ├── guttericons/ # Scope & dispatcher visualization
│ └── view/ # Tool window (findings list, runner, tree visitor)
├── gradle-plugin/ # Gradle Integration
├── sample/ # Examples
│ └── compilation/ # One example per compiler rule (errors & warnings)
└── kotlin-coroutines-skill/ # AI/agent skill for coroutine best practices
| Platform | Compiler Plugin | Detekt Rules | Android Lint | IDE Plugin |
|---|---|---|---|---|
| JVM | ✅ | ✅ | ❌ | ✅ |
| Android | ✅ | ✅ | ✅ | ✅ |
| iOS | ✅ | ✅ | ❌ | ✅ |
| macOS | ✅ | ✅ | ❌ | ✅ |
| watchOS | ✅ | ✅ | ❌ | ✅ |
| tvOS | ✅ | ✅ | ❌ | ✅ |
| Linux | ✅ | ✅ | ❌ | ✅ |
| Windows | ✅ | ✅ | ❌ | ✅ |
| JS | ✅ | ✅ | ❌ | ✅ |
| WASM | ✅ | ✅ | ❌ | ✅ |
# Publish locally
./gradlew publishToMavenLocal
# Run compiler plugin tests
./gradlew :compiler:test
# Run detekt rules tests
./gradlew :detekt-rules:test
# Run all tests
./gradlew test| Approach | When | Errors | Warnings | CI | Real-time | Platform |
|---|---|---|---|---|---|---|
| Compiler Plugin | Compile | ✅ 6 rules | ✅ 3 rules | ✅ | ❌ | All (KMP) |
| Detekt Rules | Analysis | ✅ 3 rules | ✅ 6 rules | ✅ | ❌ | All (KMP) |
| Android Lint Rules | Analysis | ✅ 9 rules | ✅ 8 rules | ✅ | ❌ | Android only |
| IDE Plugin | Editing | ✅ 4 rules | ✅ 7 rules | ❌ | ✅ | All |
| Combined (All) | All | ✅ 9 rules | ✅ 17 rules | ✅ | ✅ | - |
| Code Review | Manual | ❌ | ❌ | ❌ | ❌ | - |
| Runtime | Late | ❌ | ❌ | ❌ | ❌ | - |
Notes:
Copyright 2026 Santiago Mattiauda
Licensed under the Apache License, Version 2.0
Contributions welcome! Please submit a Pull Request.
git clone https://github.com/santimattius/structured-coroutines.git
cd structured-coroutines
./gradlew publishToMavenLocal
./gradlew testEach module contains its own detailed documentation:
| Module | Documentation | Description |
|---|---|---|
| Gradle Plugin | gradle-plugin/README.md | Installation, configuration, severity settings |
| Detekt Rules | detekt-rules/README.md | All 9 rules with examples and configuration |
| Android Lint | lint-rules/README.md | All 17 rules, Android-specific detection |
| IntelliJ Plugin | intellij-plugin/README.md | Inspections, quick fixes, intentions, K2 support |
| Annotations | annotations/README.md | @StructuredScope usage and multiplatform support |
| Compiler | compiler/README.md | K2/FIR checker implementation details |
| Sample (compilation) | compilation/README.md | One example per compiler rule (errors and warnings) |
| Sample (Detekt) | sample-detekt/README.md | One example per Detekt rule; run :sample-detekt:detekt to validate |
| Kotlin Coroutines Agent Skill | kotlin-coroutines-skill/README.md | AI/agent skill for coroutine best practices |
| Best Practices | docs/BEST_PRACTICES_COROUTINES.md | Canonical guide to coroutine good/bad practices |
| Suppressing Rules | docs/SUPPRESSING_RULES.md | Unified suppression IDs (Compiler, Detekt, Lint, IntelliJ) by rule code |
All user-facing text is externalized for localization:
compiler/src/main/resources/messages/CompilerBundle*.properties. Default language is English
so builds and CI are predictable. To use Spanish (or the JVM default locale), set the system
property:
-Dstructured.coroutines.compiler.locale=es (e.g. in gradle.properties:
org.gradle.jvmargs=... -Dstructured.coroutines.compiler.locale=es)-Dstructured.coroutines.compiler.locale=default
StructuredCoroutinesBundle.properties; the IDE uses the platform
language. Spanish: StructuredCoroutinesBundle_es.properties.To add a new language, add a _<locale> properties file (e.g. CompilerBundle_de.properties) with
the same keys.
All user-facing text is externalized for localization:
compiler/src/main/resources/messages/CompilerBundle*.properties. Default language is English so builds and CI are predictable. To use Spanish (or the JVM default locale), set the system property:
-Dstructured.coroutines.compiler.locale=es (e.g. in gradle.properties: org.gradle.jvmargs=... -Dstructured.coroutines.compiler.locale=es)-Dstructured.coroutines.compiler.locale=default
StructuredCoroutinesBundle.properties; the IDE uses the platform language. Spanish: StructuredCoroutinesBundle_es.properties.To add a new language, add a _<locale> properties file (e.g. CompilerBundle_de.properties) with the same keys.