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()
+ }
+}