Introduce MemberExtensionConflictDetector

Bug: 398924424
Test: unit tests added
Change-Id: I1fd4bc1b00fd3106fd1bb7f2aa5ff498b0bfdb62
diff --git a/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/BuiltinIssueRegistry.kt b/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/BuiltinIssueRegistry.kt
index 4896c95..da534bf 100644
--- a/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/BuiltinIssueRegistry.kt
+++ b/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/BuiltinIssueRegistry.kt
@@ -310,6 +310,7 @@
           ManifestResourceDetector.ISSUE,
           ManifestTypoDetector.ISSUE,
           MediaBrowserServiceCompatVersionDetector.ISSUE,
+          MemberExtensionConflictDetector.ISSUE,
           MergeMarkerDetector.ISSUE,
           MergeRootFrameLayoutDetector.ISSUE,
           MissingClassDetector.INNERCLASS,
diff --git a/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/MemberExtensionConflictDetector.kt b/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/MemberExtensionConflictDetector.kt
new file mode 100644
index 0000000..0b08196
--- /dev/null
+++ b/lint/libs/lint-checks/src/main/java/com/android/tools/lint/checks/MemberExtensionConflictDetector.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.lint.checks
+
+import com.android.tools.lint.client.api.UElementHandler
+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.Companion.JAVA_FILE_SCOPE
+import com.android.tools.lint.detector.api.SourceCodeScanner
+import com.android.tools.lint.detector.api.isKotlin
+import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
+import org.jetbrains.kotlin.analysis.api.KaSession
+import org.jetbrains.kotlin.analysis.api.analyze
+import org.jetbrains.kotlin.analysis.api.resolution.KaApplicableCallCandidateInfo
+import org.jetbrains.kotlin.analysis.api.resolution.KaCall
+import org.jetbrains.kotlin.analysis.api.resolution.KaCallCandidateInfo
+import org.jetbrains.kotlin.analysis.api.resolution.KaCallableMemberCall
+import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundArrayAccessCall
+import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundVariableAccessCall
+import org.jetbrains.kotlin.analysis.api.resolution.symbol
+import org.jetbrains.kotlin.analysis.api.symbols.KaDeclarationSymbol
+import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol
+import org.jetbrains.kotlin.psi.KtElement
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.UElement
+import org.jetbrains.uast.USimpleNameReferenceExpression
+
+class MemberExtensionConflictDetector : Detector(), SourceCodeScanner {
+  companion object {
+    private val IMPLEMENTATION =
+      Implementation(MemberExtensionConflictDetector::class.java, JAVA_FILE_SCOPE)
+
+    private const val MSG = "Conflict applicable candidates of member and extension"
+
+    @JvmField
+    val ISSUE =
+      Issue.create(
+        id = "MemberExtensionConflict",
+        briefDescription = MSG,
+        explanation =
+          """
+            When both member and extension declarations are applicable, the resolution takes the member. \
+            This also implies that, if an extension existed first, but then a member is added later, \
+            the same call-site may end up with different call resolutions depending on target environment. \
+            This results in a potential runtime exception if the generated binary (library or app) targets \
+            earlier environment (i.e., without the new member, but only extension). More concrete example \
+            is found at: https://issuetracker.google.com/issues/350432371
+          """,
+        implementation = IMPLEMENTATION,
+      )
+  }
+
+  override fun getApplicableUastTypes(): List<Class<out UElement>> =
+    listOf(UCallExpression::class.java, USimpleNameReferenceExpression::class.java)
+
+  @OptIn(KaExperimentalApi::class)
+  override fun createUastHandler(context: JavaContext): UElementHandler =
+    object : UElementHandler() {
+      override fun visitCallExpression(node: UCallExpression) {
+        // This conflict of member and extension only happens in Kotlin
+        if (!isKotlin(node.lang)) return
+
+        val sourcePsi = node.sourcePsi as? KtElement ?: return
+        analyze(sourcePsi) {
+          if (!isK2()) return
+          checkKtElement(node, sourcePsi)
+        }
+      }
+
+      override fun visitSimpleNameReferenceExpression(node: USimpleNameReferenceExpression) {
+        // This conflict of member and extension only happens in Kotlin
+        if (!isKotlin(node.lang)) return
+
+        val sourcePsi = node.sourcePsi as? KtElement ?: return
+        analyze(sourcePsi) {
+          if (!isK2()) return
+          checkKtElement(node, sourcePsi)
+        }
+      }
+
+      private fun KaSession.isK2(): Boolean {
+        // Collecting multiple applicable candidates only work for K2 AA
+        // Check KaSession name: KaFe10Session v.s. KaFirSession
+        return this::class.simpleName == "KaFirSession"
+      }
+
+      private fun KaSession.checkKtElement(node: UElement, ktElement: KtElement) {
+        val candidates =
+          ktElement
+            .resolveToCallCandidates()
+            // Only applicable candidates
+            .filterIsInstance<KaApplicableCallCandidateInfo>()
+        if (candidates.size <= 1) return
+        val (extensions, members) =
+          candidates.partition { candidateInfo ->
+            val symbol =
+              (candidateInfo.candidate as? KaCallableMemberCall<*, *>)?.partiallyAppliedSymbol
+            symbol?.extensionReceiver != null
+          }
+        if (extensions.isNotEmpty() && members.isNotEmpty()) {
+          reportConflict(this, node, members, extensions)
+        }
+      }
+
+      private fun reportConflict(
+        session: KaSession,
+        node: UElement,
+        members: List<KaCallCandidateInfo>,
+        extensions: List<KaCallCandidateInfo>,
+      ) {
+        val message = buildString {
+          append(MSG)
+          append(": members ")
+          members.joinTo(this, prefix = "{", postfix = "}") { info ->
+            info.candidate.symbols().joinToString { symbol ->
+              with(session) {
+                (symbol as? KaDeclarationSymbol)?.render() ?: symbol.psi?.toString() ?: ""
+              }
+            }
+          }
+          append(", extensions ")
+          extensions.joinTo(this, prefix = "{", postfix = "}") { info ->
+            info.candidate.symbols().joinToString { symbol ->
+              with(session) {
+                (symbol as? KaDeclarationSymbol)?.render() ?: symbol.psi?.toString() ?: ""
+              }
+            }
+          }
+        }
+        context.report(ISSUE, node, context.getLocation(node), message)
+      }
+
+      private fun KaCall.symbols(): List<KaSymbol> =
+        when (this) {
+          is KaCompoundVariableAccessCall ->
+            listOfNotNull(
+              variablePartiallyAppliedSymbol.symbol,
+              compoundOperation.operationPartiallyAppliedSymbol.symbol,
+            )
+          is KaCompoundArrayAccessCall ->
+            listOfNotNull(
+              getPartiallyAppliedSymbol.symbol,
+              setPartiallyAppliedSymbol.symbol,
+              compoundOperation.operationPartiallyAppliedSymbol.symbol,
+            )
+          is KaCallableMemberCall<*, *> -> listOf(symbol)
+        }
+    }
+}
diff --git a/lint/libs/lint-tests/src/test/java/com/android/tools/lint/checks/MemberExtensionConflictDetectorTest.kt b/lint/libs/lint-tests/src/test/java/com/android/tools/lint/checks/MemberExtensionConflictDetectorTest.kt
new file mode 100644
index 0000000..baa1ae43
--- /dev/null
+++ b/lint/libs/lint-tests/src/test/java/com/android/tools/lint/checks/MemberExtensionConflictDetectorTest.kt
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.lint.checks
+
+import com.android.tools.lint.checks.infrastructure.TestMode
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.useFirUast
+
+class MemberExtensionConflictDetectorTest : AbstractCheckTest() {
+  override fun getDetector(): Detector? {
+    return MemberExtensionConflictDetector()
+  }
+
+  fun testDocumentationExample() {
+    // Collecting multiple applicable candidates only work for K2 AA
+    if (!useFirUast()) {
+      return
+    }
+    lint()
+      .files(
+        kotlin(
+            """
+            package my.cool.lib
+            interface MyList {
+              val magicCount: Int
+              fun removeMiddle()
+            }
+          """
+          )
+          .indented(),
+        kotlin(
+            """
+            package users.own
+
+            import my.cool.lib.MyList
+
+            val MyList.magicCount: Int
+              get() = 42
+
+            fun MyList.removeMiddle() {}
+          """
+          )
+          .indented(),
+        kotlin(
+            """
+            import my.cool.lib.MyList
+            import users.own.magicCount
+            import users.own.removeMiddle
+
+            class ListWrapper(
+              private val base: MyList
+            ) : MyList by base
+
+            fun test(l : ListWrapper) {
+              val x = l.magicCount // WARNING 1
+              l.removeMiddle() // WARNING 2
+            }
+          """
+          )
+          .indented(),
+      )
+      // Some test modes change the function signature of interest
+      .skipTestModes(TestMode.JVM_OVERLOADS, TestMode.TYPE_ALIAS)
+      .run()
+      .expect(
+        """
+src/ListWrapper.kt:10: Warning: Conflict applicable candidates of member and extension: members {override val magicCount: kotlin.Int}, extensions {val my.cool.lib.MyList.magicCount: kotlin.Int
+  get()} [MemberExtensionConflict]
+  val x = l.magicCount // WARNING 1
+            ~~~~~~~~~~
+src/ListWrapper.kt:11: Warning: Conflict applicable candidates of member and extension: members {override fun removeMiddle()}, extensions {fun my.cool.lib.MyList.removeMiddle()} [MemberExtensionConflict]
+  l.removeMiddle() // WARNING 2
+  ~~~~~~~~~~~~~~~~
+0 errors, 2 warnings
+        """
+      )
+  }
+
+  fun testConflictsFromBinary() {
+    // Collecting multiple applicable candidates only work for K2 AA
+    if (!useFirUast()) {
+      return
+    }
+    lint()
+      .files(
+        bytecode(
+          "libs/lib1.jar",
+          kotlin(
+              """
+            package my.cool.lib
+            interface MyList {
+              val magicCount: Int
+              fun removeMiddle()
+            }
+            """
+            )
+            .indented(),
+          0x30ac8af8,
+          """
+                META-INF/main.kotlin_module:
+                H4sIAAAAAAAA/2NgYGBmYGBgBGJOBijgEuRiEOL1ySwuCS9KLChILfIu4RLm
+                4iwtTi0q1ssvzxNiC0ktLvEuUWLQYgAASi63EEAAAAA=
+                """,
+          """
+                my/cool/lib/MyList.class:
+                H4sIAAAAAAAA/2VPzU7CQBic3Za2VNCCiMADGL1YJN48GYyxhsYEE2LCqbQr
+                WelPwhYiN57Fgw/hwRCOPpTxK15MTDYzs/N92dn5+v74BHCJDkM9WblhlsVu
+                LCeuvxpIlZtgDM5LsAzcOEin7sPkRYTkagzVqcj9YCrDfrZIcwbt9MxjqMxF
+                ki2FL6MoFjtzxFAbzLI8lqnrizyIgjy4YuDJUqNgVkC5ADCwGfmvsrh1SUUX
+                DDebddPmLW5zZ7O26XDHsrmlEXPrubVZ93iX3VuO0eFd8+5kWHc4Ke1p+65v
+                3wyjo1u6Uyre6jE0Bv8L0k8o2E7+FCn/Ts5npO3HbDEPxa0syrSHNJeJGEkl
+                J7G4TtMsD3KZpcqgBOhFCXCdoQQDIDZhFQ5aOzxGm7hPcWXasMfQPOx5qHio
+                Yp8kDjw4qI3BFOo4HMNSaCgcKTR3WFIwFEzSP4O1Xny1AQAA
+                """,
+        ),
+        bytecode(
+          "libs/lib2.jar",
+          kotlin(
+              """
+            package users.own
+
+            import my.cool.lib.MyList
+
+            val MyList.magicCount: Int
+              get() = 42
+
+            fun MyList.removeMiddle() {}
+            """
+            )
+            .indented(),
+          0x4bf50ed4,
+          """
+                META-INF/main.kotlin_module:
+                H4sIAAAAAAAA/2NgYGBmYGBgBGJOBijgEuRiEOL1ySwuCS9KLChILfIu4RLm
+                4iwtTi0q1ssvzxNiC0ktLvEuUWLQYgAASi63EEAAAAA=
+                """,
+          """
+                users/own/TestKt.class:
+                H4sIAAAAAAAA/3VRTW/TQBB966R2alLqhJa2AQq0gbY54BQ4IAUhUKVKFk6L
+                aJVLTxtnlW7iD8m7CfTW38KZCzfEAVUc+VGI2SaCUECWZ97MzryZt/v9x5ev
+                AJ6iweCNlMiVn71L/WOh9GvtgFF2wMfcj3na9w+7AxFRtsCw0Be6zfsy2stG
+                qWZY2Q6TMz/KstiPZddvn4VS6dZOwLAZZnnfHwjdzblMlc/TNNNcy4zwQaYP
+                RnHcYrCf61OpXpRQYlgfZjqWqT8YJ75MtchTHvtBqnNql5Fy4DIsR6ciGk77
+                3/CcJ4IKGba2w6v7tmYyR4ak39rplFHGgotruE4K62Z2PZmRs/QvNQzlXCTZ
+                WLRlrxeL/4ruMFQnlH+WV8KpsLbQvMc1J0YrGRfo/pkx88aAgQ0NsOjwvTSo
+                Sai3y9C6OK+6F+eu5ZVca9VyrVKBsFVzvbma1bQbVtPaWPYuzilgJni2/+2D
+                bdeKpYJXNBSPGdxZkTTK0fTQj4YUFPeyHu24GMpUHIySrsiPeddsXQ2ziMcd
+                nksTT5P1t8QgExGkY6kkpX49wavfz0vjjrJRHol9aXrWpj2dScdMIXZhoWjE
+                k1/DHGzydYqekGfmZhrV+c9Y9BofL0sekLXpwKbvIeHypAgeKuS36HeYEUdg
+                DVXcmLLtTtmcCdunK1ylGS4HS39zWdi+tJvYIf+Sssu0680TFAKsBFgNaFot
+                wC3cDnAH6ydgCndx7wSOwn2FDYWKwpyCrVCl8CdD0glRewMAAA==
+                """,
+        ),
+        kotlin(
+            """
+            import my.cool.lib.MyList
+            import users.own.magicCount
+            import users.own.removeMiddle
+
+            class ListWrapper(
+              private val base: MyList
+            ) : MyList by base
+
+            fun test(l : ListWrapper) {
+              val x = l.magicCount // WARNING 1
+              l.removeMiddle() // WARNING 2
+            }
+          """
+          )
+          .indented(),
+      )
+      .run()
+      .expect(
+        """
+src/ListWrapper.kt:10: Warning: Conflict applicable candidates of member and extension: members {override val magicCount: kotlin.Int}, extensions {val my.cool.lib.MyList.magicCount: kotlin.Int
+  get()} [MemberExtensionConflict]
+  val x = l.magicCount // WARNING 1
+            ~~~~~~~~~~
+src/ListWrapper.kt:11: Warning: Conflict applicable candidates of member and extension: members {override fun removeMiddle()}, extensions {fun my.cool.lib.MyList.removeMiddle()} [MemberExtensionConflict]
+  l.removeMiddle() // WARNING 2
+  ~~~~~~~~~~~~~~~~
+0 errors, 2 warnings
+        """
+      )
+  }
+
+  fun testOnlyMultipleExtensions() {
+    lint()
+      .files(
+        kotlin(
+            """
+            package my.cool.lib
+            interface MyList {
+              fun removeFirst()
+            }
+          """
+          )
+          .indented(),
+        kotlin(
+            """
+            package another.cool.lib
+
+            import my.cool.lib.MyList
+
+            fun MyList.removeMiddle() {}
+          """
+          )
+          .indented(),
+        kotlin(
+            """
+          package users.own
+
+          import my.cool.lib.MyList
+
+          fun MyList.removeMiddle() {}
+          """
+          )
+          .indented(),
+        kotlin(
+            """
+            import my.cool.lib.MyList
+            import users.own.removeMiddle // explicit
+
+            class ListWrapper(
+              private val base: MyList
+            ) : MyList by base
+
+            fun test(l : ListWrapper) {
+              l.removeMiddle() // OK
+            }
+          """
+          )
+          .indented(),
+      )
+      .run()
+      .expectClean()
+  }
+}