Repository: detekt/detekt (kotlin)

Issue:
CoroutineLaunchedInTestWithoutRunTest | Fails to detect coroutine usage in methods referenced from classpath
## Expected Behavior
CoroutineLaunchedInTestWithoutRunTest should detect coroutines launched both within the test file and within methods the test references outside of the target module.

## Observed Behavior
It will properly catch a coroutine launched within the same test and within the same source set, but it will not detect one in a provided jar which the test references a method within. This was tested and reproduced with the detekt jar on the CLI, but should be reproduced with 2 gradle modules and a dependency.

## Steps to Reproduce

Create file TestCoroutineRunTestDetekt.kt and compile to jar
```
class TestCoroutineRunTestDetekt {
  fun testCoroutine() {
    GlobalScope.launch {
      val foo = "foo"
      println(foo)
    }
  }
}
```

Create test file TestCoroutineRunTest.kt
```
class TestCoroutineRunTest {
  @Test
  fun testNoRunTest() {
    TestCoroutineRunTestDetekt().testCoroutine()
  }
}
```

Run detekt on test source with jar passed in for classpath. Note the rule doesn't catch it.

## Context
The majority of our use-cases are devs with corotuines in feature code and the test doesn't enforce runTest, leading to flakey tests.

## Your Environment
This was tested with the detekt.jar which we have wired up with Buck and Bazel, using type resolution.
Detekt 1.23.3 with backported rule.
Kotlin 1.9.20

Pull request description:
Fixes https://github.com/detekt/detekt/issues/7200 and fixes https://github.com/detekt/detekt/issues/7192

Patch:
diff --git a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiver.kt b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiver.kt
index eb73b2ec40f2..1f5a1dd71ff2 100644
--- a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiver.kt
+++ b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiver.kt
@@ -6,12 +6,10 @@ import io.gitlab.arturbosch.detekt.api.Config
 import io.gitlab.arturbosch.detekt.api.Entity
 import io.gitlab.arturbosch.detekt.api.RequiresFullAnalysis
 import io.gitlab.arturbosch.detekt.api.Rule
-import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
+import io.gitlab.arturbosch.detekt.rules.coroutines.utils.isCoroutineScope
 import org.jetbrains.kotlin.lexer.KtTokens
 import org.jetbrains.kotlin.psi.KtNamedFunction
 import org.jetbrains.kotlin.resolve.BindingContext
-import org.jetbrains.kotlin.types.KotlinType
-import org.jetbrains.kotlin.types.typeUtil.supertypes
 
 /**
  * Suspend functions that use `CoroutineScope` as receiver should not be marked as `suspend`.
@@ -72,11 +70,4 @@ class SuspendFunWithCoroutineScopeReceiver(config: Config) : Rule(
             )
         }
     }
-
-    private fun KotlinType.isCoroutineScope() = sequence {
-        yield(this@isCoroutineScope)
-        yieldAll(this@isCoroutineScope.supertypes())
-    }
-        .mapNotNull { it.fqNameOrNull()?.asString() }
-        .contains("kotlinx.coroutines.CoroutineScope")
 }
diff --git a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnType.kt b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnType.kt
index f102dc53e5d5..ba077e6469f0 100644
--- a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnType.kt
+++ b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnType.kt
@@ -6,12 +6,10 @@ import io.gitlab.arturbosch.detekt.api.Config
 import io.gitlab.arturbosch.detekt.api.Entity
 import io.gitlab.arturbosch.detekt.api.RequiresFullAnalysis
 import io.gitlab.arturbosch.detekt.api.Rule
-import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
+import io.gitlab.arturbosch.detekt.rules.coroutines.utils.isCoroutinesFlow
 import org.jetbrains.kotlin.lexer.KtTokens
 import org.jetbrains.kotlin.psi.KtNamedFunction
 import org.jetbrains.kotlin.resolve.BindingContext
-import org.jetbrains.kotlin.types.KotlinType
-import org.jetbrains.kotlin.types.typeUtil.supertypes
 
 /**
  * Functions that return `Flow` from `kotlinx.coroutines.flow` should not be marked as `suspend`.
@@ -78,12 +76,4 @@ class SuspendFunWithFlowReturnType(config: Config) : Rule(
                 )
             }
     }
-
-    private fun KotlinType.isCoroutinesFlow(): Boolean =
-        sequence {
-            yield(this@isCoroutinesFlow)
-            yieldAll(this@isCoroutinesFlow.supertypes())
-        }
-            .mapNotNull { it.fqNameOrNull()?.asString() }
-            .contains("kotlinx.coroutines.flow.Flow")
 }
diff --git a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/utils/TypeUtils.kt b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/utils/TypeUtils.kt
new file mode 100644
index 000000000000..a287eaa5cc0e
--- /dev/null
+++ b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/utils/TypeUtils.kt
@@ -0,0 +1,21 @@
+package io.gitlab.arturbosch.detekt.rules.coroutines.utils
+
+import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
+import org.jetbrains.kotlin.types.KotlinType
+import org.jetbrains.kotlin.types.typeUtil.supertypes
+
+internal fun KotlinType.isCoroutineScope(): Boolean =
+    sequence {
+        yield(this@isCoroutineScope)
+        yieldAll(this@isCoroutineScope.supertypes())
+    }
+        .mapNotNull { it.fqNameOrNull()?.asString() }
+        .contains("kotlinx.coroutines.CoroutineScope")
+
+internal fun KotlinType.isCoroutinesFlow(): Boolean =
+    sequence {
+        yield(this@isCoroutinesFlow)
+        yieldAll(this@isCoroutinesFlow.supertypes())
+    }
+        .mapNotNull { it.fqNameOrNull()?.asString() }
+        .contains("kotlinx.coroutines.flow.Flow")


Test patch:
diff --git a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTest.kt b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTest.kt
index 3a8652827780..9027a049a613 100644
--- a/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTest.kt
+++ b/detekt-rules-coroutines/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTest.kt
@@ -5,7 +5,8 @@ import io.gitlab.arturbosch.detekt.api.Config
 import io.gitlab.arturbosch.detekt.api.Entity
 import io.gitlab.arturbosch.detekt.api.RequiresFullAnalysis
 import io.gitlab.arturbosch.detekt.api.Rule
-import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
+import io.gitlab.arturbosch.detekt.rules.coroutines.utils.isCoroutineScope
+import io.gitlab.arturbosch.detekt.rules.coroutines.utils.isCoroutinesFlow
 import io.gitlab.arturbosch.detekt.rules.hasAnnotation
 import org.jetbrains.kotlin.name.FqName
 import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
@@ -59,7 +60,11 @@ class CoroutineLaunchedInTestWithoutRunTest(config: Config) : Rule(
         if (initialFunction.runsInRunTestBlock()) return
 
         // By this point we know we're inside a test function that is not a `runTest` function.
-        if (funCoroutineLaunchesTraverseHelper.isFunctionLaunchingCoroutines(initialFunction, bindingContext)) {
+        if (funCoroutineLaunchesTraverseHelper.isFunctionLaunchingCoroutines(
+                initialFunction,
+                bindingContext
+            )
+        ) {
             report(CodeSmell(Entity.from(initialFunction), MESSAGE))
         }
     }
@@ -134,11 +139,11 @@ class FunCoroutineLaunchesTraverseHelper {
         anyDescendantOfType<KtDotQualifiedExpression> {
             it.receiverExpression
                 .getType(bindingContext)
-                ?.fqNameOrNull() == COROUTINE_SCOPE_FQ &&
-                it.getCalleeExpressionIfAny()?.text == "launch"
+                ?.isCoroutineScope() == true &&
+                it.getCalleeExpressionIfAny()?.text in listOf("launch", "async") ||
+                it.receiverExpression
+                    .getType(bindingContext)
+                    ?.isCoroutinesFlow() == true &&
+                it.getCalleeExpressionIfAny()?.text == "launchIn"
         }
-
-    companion object {
-        private val COROUTINE_SCOPE_FQ = FqName("kotlinx.coroutines.CoroutineScope")
-    }
 }
diff --git a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt
index a3ff4943f023..f83fa2033b74 100644
--- a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt
+++ b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/CoroutineLaunchedInTestWithoutRunTestSpec.kt
@@ -5,6 +5,7 @@ import io.gitlab.arturbosch.detekt.api.Config
 import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest
 import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext
 import io.gitlab.arturbosch.detekt.test.createBindingContext
+import io.gitlab.arturbosch.detekt.test.lintWithContext
 import io.gitlab.arturbosch.detekt.test.location
 import org.assertj.core.api.Assertions.assertThat
 import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
@@ -39,6 +40,103 @@ class CoroutineLaunchedInTestWithoutRunTestSpec(private val env: KotlinCoreEnvir
         assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
     }
 
+    @Test
+    fun `reports when two coroutine is launched in a test as one violation`() {
+        val code = """
+            import kotlinx.coroutines.CoroutineScope
+            import kotlinx.coroutines.Dispatchers
+            import kotlinx.coroutines.launch
+            import kotlinx.coroutines.async
+
+            class A {
+                annotation class Test
+
+                @Test
+                fun `test that launches a coroutine`() {
+                    val scope = CoroutineScope(Dispatchers.Unconfined)
+                    scope.launch {
+                        throw Exception()
+                    }
+
+                    scope.async {
+                        throw Exception()
+                    }
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
+    @Test
+    fun `reports when coroutine is deferred in test without a runTest block`() {
+        val code = """
+            import kotlinx.coroutines.CoroutineScope
+            import kotlinx.coroutines.Dispatchers
+            import kotlinx.coroutines.async
+
+            class A {
+                annotation class Test
+
+                @Test
+                fun `test that launches a coroutine in async`() {
+                    val scope = CoroutineScope(Dispatchers.Unconfined)
+                    scope.async {
+                        throw Exception()
+                    }
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
+    @Test
+    fun `reports when coroutine is launched using GlobalScope in test without a runTest block`() {
+        val code = """
+            import kotlinx.coroutines.GlobalScope
+            import kotlinx.coroutines.CoroutineScope
+            import kotlinx.coroutines.Dispatchers
+            import kotlinx.coroutines.launch
+
+            class A {
+                annotation class Test
+
+                @Test
+                fun `test that launches a coroutine`() {
+                    GlobalScope.launch {
+                        throw Exception()
+                    }
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
+    @Test
+    fun `reports when coroutine is launched using GlobalScope in test in another fun without a runTest block`() {
+        val code = """
+            import kotlinx.coroutines.GlobalScope
+            import kotlinx.coroutines.CoroutineScope
+            import kotlinx.coroutines.Dispatchers
+            import kotlinx.coroutines.launch
+
+            class A {
+                annotation class Test
+
+                @Test
+                fun `test that launches a coroutine`() {
+                    launchCoroutineInGlobalScope()
+                }
+
+                fun launchCoroutineInGlobalScope() {
+                    GlobalScope.launch {
+                        throw Exception()
+                    }
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
     @Test
     fun `reports when coroutine is launched in test with a runBlocking block`() {
         val code = """
@@ -365,4 +463,160 @@ class CoroutineLaunchedInTestWithoutRunTestSpec(private val env: KotlinCoreEnvir
         """.trimIndent()
         assertThat(subject.compileAndLintWithContext(env, code)).hasSize(2)
     }
+
+    @Test
+    fun `reports correctly scope is present in a class`() {
+        val code = """
+            import kotlinx.coroutines.CoroutineScope
+            import kotlinx.coroutines.Dispatchers
+            import kotlinx.coroutines.launch
+            import kotlinx.coroutines.runBlocking
+
+            class ScopeHolder {
+                val scope = CoroutineScope(Dispatchers.Unconfined)
+            }
+            
+            class A {
+                annotation class Test
+
+                @Test
+                fun `test that launches a coroutine using scope holder`() = runBlocking {
+                    val scopeHolder = ScopeHolder()
+                    scopeHolder.scope.launch {
+                        throw Exception()
+                    }
+                }
+
+                @Test
+                fun `test that launches a coroutine using scope holder in another function`() = runBlocking {
+                    launchCoroutineWithScopeHolder()
+                }
+                
+                fun launchCoroutineWithScopeHolder() {
+                    val scopeHolder = ScopeHolder()
+                    scopeHolder.scope.launch {
+                        throw Exception()
+                    }
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(2)
+    }
+
+    @Test
+    fun `reports TC where flow is launched using launchIn- #7200`() {
+        val code = """
+            import kotlinx.coroutines.GlobalScope
+            import kotlinx.coroutines.flow.flowOf
+            import kotlinx.coroutines.flow.launchIn
+
+            annotation class Test
+
+            class TestCoroutineRunTest {
+                @Test
+                fun testFlowNoRunTest() {
+                    flowOf("foo", "bar").launchIn(GlobalScope)
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
+    @Test
+    fun `reports TC where flow is launched using launchIn in other function`() {
+        val code = """
+            import kotlinx.coroutines.GlobalScope
+            import kotlinx.coroutines.flow.flowOf
+            import kotlinx.coroutines.flow.launchIn
+
+            annotation class Test
+
+            class TestCoroutineRunTest {
+                @Test
+                fun testFlowNoRunTest() {
+                    launchFlow()
+                }
+
+                fun launchFlow() {
+                    flowOf("foo", "bar").launchIn(GlobalScope)
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
+    @Test
+    fun `reports TC where flow is collected inside GlobalScope`() {
+        val code = """
+            import kotlinx.coroutines.GlobalScope
+            import kotlinx.coroutines.flow.flowOf
+            import kotlinx.coroutines.launch
+
+            annotation class Test
+
+            class TestCoroutineRunTest {
+                @Test
+                fun testFlowNoRunTest() {
+                    GlobalScope.launch {
+                        flowOf("foo", "bar").collect { println(it) }
+                    }
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
+    @Test
+    fun `reports TC where flow is collected inside GlobalScope in other function`() {
+        val code = """
+            import kotlinx.coroutines.GlobalScope
+            import kotlinx.coroutines.flow.flowOf
+            import kotlinx.coroutines.launch
+
+            annotation class Test
+
+            class TestCoroutineRunTest {
+                @Test
+                fun testFlowNoRunTest() {
+                    launchFlow()
+                }
+
+                fun launchFlow() {
+                    GlobalScope.launch {
+                        flowOf("foo", "bar").collect { println(it) }
+                    }
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.compileAndLintWithContext(env, code)).hasSize(1)
+    }
+
+    @Test
+    fun `reports TC where flow is collected in GlobalScope in other file - #7192`() {
+        val file = """
+            import kotlinx.coroutines.GlobalScope
+            import kotlinx.coroutines.launch
+
+            annotation class Test
+
+            class TestCoroutineRunTestDetekt {
+                fun testCoroutine() {
+                    GlobalScope.launch {
+                        val foo = "foo"
+                        println(foo)
+                    }
+                }
+            }
+        """.trimIndent()
+
+        val code = """
+            class TestCoroutineRunTest {
+                @Test
+                fun testNoRunTest() {
+                    TestCoroutineRunTestDetekt().testCoroutine()
+                }
+            }
+        """.trimIndent()
+        assertThat(subject.lintWithContext(env, code, file)).hasSize(1)
+    }
 }
diff --git a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiverSpec.kt b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiverSpec.kt
index 2098026260b4..ccd0aa163cca 100644
--- a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiverSpec.kt
+++ b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithCoroutineScopeReceiverSpec.kt
@@ -9,9 +9,9 @@ import org.junit.jupiter.api.Nested
 import org.junit.jupiter.api.Test
 
 @KotlinCoreEnvironmentTest
-class SuspendFunWithCoroutineScopeReceiverSpec(val env: KotlinCoreEnvironment) {
+class SuspendFunWithCoroutineScopeReceiverSpec(private val env: KotlinCoreEnvironment) {
 
-    val subject = SuspendFunWithCoroutineScopeReceiver(Config.empty)
+    private val subject = SuspendFunWithCoroutineScopeReceiver(Config.empty)
 
     @Nested
     inner class `SuspendFunWithCoroutineScopeReceiver rule` {
diff --git a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnTypeSpec.kt b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnTypeSpec.kt
index a424ca099a0a..889524a1a635 100644
--- a/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnTypeSpec.kt
+++ b/detekt-rules-coroutines/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/coroutines/SuspendFunWithFlowReturnTypeSpec.kt
@@ -8,9 +8,9 @@ import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
 import org.junit.jupiter.api.Test
 
 @KotlinCoreEnvironmentTest
-class SuspendFunWithFlowReturnTypeSpec(val env: KotlinCoreEnvironment) {
+class SuspendFunWithFlowReturnTypeSpec(private val env: KotlinCoreEnvironment) {
 
-    val subject = SuspendFunWithFlowReturnType(Config.empty)
+    private val subject = SuspendFunWithFlowReturnType(Config.empty)
 
     @Test
     fun `reports when top-level suspend function has explicit Flow return type`() {