Repository: square/anvil (kotlin)

Issue:
Replaced classes must use the same scope 
It's very confusing and bindings hard to debug if a contributed class replaces a class with another module. Both classes should use the same scope.

This applies for modules, component interfaces and contributed bindings.

Pull request description:
Verify that the scope of replaced and excluded classes matches the scope of the merged target. Throw an error otherwise.

While it technically would be possible to support this case it leads to very confusing results and hard to debug missing bindings.

Fixes #107

Patch:
diff --git a/compiler/src/main/java/com/squareup/anvil/compiler/InterfaceMerger.kt b/compiler/src/main/java/com/squareup/anvil/compiler/InterfaceMerger.kt
index 02a42cf4e..2545e0ede 100644
--- a/compiler/src/main/java/com/squareup/anvil/compiler/InterfaceMerger.kt
+++ b/compiler/src/main/java/com/squareup/anvil/compiler/InterfaceMerger.kt
@@ -30,7 +30,10 @@ internal class InterfaceMerger(
       return
     }
 
-    val scope = mergeAnnotation.scope(thisDescriptor.module)
+    val module = thisDescriptor.module
+
+    val scope = mergeAnnotation.scope(module)
+    val scopeFqName = scope.fqNameSafe
 
     if (!DescriptorUtils.isInterface(thisDescriptor)) {
       throw AnvilCompilationException(thisDescriptor, "Dagger components must be interfaces.")
@@ -38,17 +41,17 @@ internal class InterfaceMerger(
 
     val classes = classScanner
         .findContributedClasses(
-            module = thisDescriptor.module,
+            module = module,
             packageName = HINT_CONTRIBUTES_PACKAGE_PREFIX,
             annotation = contributesToFqName,
-            scope = scope.fqNameSafe
+            scope = scopeFqName
         )
         .filter {
           DescriptorUtils.isInterface(it) && it.annotationOrNull(daggerModuleFqName) == null
         }
         .mapNotNull {
           val contributeAnnotation =
-            it.annotationOrNull(contributesToFqName, scope = scope.fqNameSafe)
+            it.annotationOrNull(contributesToFqName, scope = scopeFqName)
                 ?: return@mapNotNull null
           it to contributeAnnotation
         }
@@ -77,6 +80,30 @@ internal class InterfaceMerger(
                           "replaced is not an interface."
                   )
                 }
+
+                val contributesBindingAnnotation = classDescriptorForReplacement
+                    .annotationOrNull(contributesBindingFqName)
+                val contributesToAnnotation = classDescriptorForReplacement
+                    .annotationOrNull(contributesToFqName)
+
+                // Verify that the the replaced classes use the same scope.
+                val scopeOfReplacement = contributesToAnnotation?.scope(module)
+                    ?: contributesBindingAnnotation?.scope(module)
+                    ?: throw AnvilCompilationException(
+                        classDescriptor,
+                        "Could not determine the scope of the replaced class " +
+                            "${classDescriptorForReplacement.fqNameSafe}."
+                    )
+
+                if (scopeOfReplacement.fqNameSafe != scopeFqName) {
+                  throw AnvilCompilationException(
+                      classDescriptor,
+                      "${classDescriptor.fqNameSafe} with scope $scopeFqName wants to replace " +
+                          "${classDescriptorForReplacement.fqNameSafe} with scope " +
+                          "${scopeOfReplacement.fqNameSafe}. The replacement must use the same " +
+                          "scope."
+                  )
+                }
               }
               .map { it.fqNameSafe }
         }
@@ -84,13 +111,34 @@ internal class InterfaceMerger(
 
     val excludedClasses = (mergeAnnotation.getAnnotationValue("exclude") as? ArrayValue)
         ?.value
-        ?.map {
-          it.getType(thisDescriptor.module)
-              .argumentType()
-              .classDescriptorForType()
-        }
+        ?.map { it.getType(module).argumentType().classDescriptorForType() }
         ?.filter { DescriptorUtils.isInterface(it) }
-        ?.map { it.fqNameSafe }
+        ?.map { classDescriptorForExclusion ->
+          val contributesBindingAnnotation = classDescriptorForExclusion
+              .annotationOrNull(contributesBindingFqName)
+          val contributesToAnnotation = classDescriptorForExclusion
+              .annotationOrNull(contributesToFqName)
+
+          // Verify that the the replaced classes use the same scope.
+          val scopeOfExclusion = contributesToAnnotation?.scope(module)
+              ?: contributesBindingAnnotation?.scope(module)
+              ?: throw AnvilCompilationException(
+                  thisDescriptor,
+                  "Could not determine the scope of the excluded class " +
+                      "${classDescriptorForExclusion.fqNameSafe}."
+              )
+
+          if (scopeOfExclusion.fqNameSafe != scopeFqName) {
+            throw AnvilCompilationException(
+                thisDescriptor,
+                "${thisDescriptor.fqNameSafe} with scope $scopeFqName wants to exclude " +
+                    "${classDescriptorForExclusion.fqNameSafe} with scope " +
+                    "${scopeOfExclusion.fqNameSafe}. The exclusion must use the same scope."
+            )
+          }
+
+          classDescriptorForExclusion.fqNameSafe
+        }
         ?: emptyList()
 
     if (excludedClasses.isNotEmpty()) {
diff --git a/compiler/src/main/java/com/squareup/anvil/compiler/ModuleMerger.kt b/compiler/src/main/java/com/squareup/anvil/compiler/ModuleMerger.kt
index 6e9d7a844..3b603361f 100644
--- a/compiler/src/main/java/com/squareup/anvil/compiler/ModuleMerger.kt
+++ b/compiler/src/main/java/com/squareup/anvil/compiler/ModuleMerger.kt
@@ -13,6 +13,7 @@ import org.jetbrains.kotlin.codegen.asmType
 import org.jetbrains.kotlin.codegen.extensions.ExpressionCodegenExtension
 import org.jetbrains.kotlin.descriptors.ClassDescriptor
 import org.jetbrains.kotlin.descriptors.EffectiveVisibility.Public
+import org.jetbrains.kotlin.descriptors.ModuleDescriptor
 import org.jetbrains.kotlin.descriptors.annotations.AnnotationDescriptor
 import org.jetbrains.kotlin.descriptors.effectiveVisibility
 import org.jetbrains.kotlin.name.FqName
@@ -123,6 +124,8 @@ internal class ModuleMerger(
                   )
                 }
 
+                checkSameScope(classDescriptor, classDescriptorForReplacement, module, scopeFqName)
+
                 classDescriptorForReplacement.defaultType.asmType(codegen.typeMapper)
               }
         }
@@ -139,7 +142,12 @@ internal class ModuleMerger(
           if (scopeFqName == annotation.scope(module).fqNameSafe) {
             annotation.replaces(module)
                 .asSequence()
-                .map { it.defaultType.asmType(codegen.typeMapper) }
+                .map { classDescriptorForReplacement ->
+                  checkSameScope(
+                      contributedClass, classDescriptorForReplacement, module, scopeFqName
+                  )
+                  classDescriptorForReplacement.defaultType.asmType(codegen.typeMapper)
+                }
           } else {
             emptySequence()
           }
@@ -147,7 +155,35 @@ internal class ModuleMerger(
 
     val excludedModules = (mergeAnnotation.getAnnotationValue("exclude") as? ArrayValue)
         ?.value
-        ?.map { it.toType(codegen) }
+        ?.map {
+          val argumentType = it.getType(module).argumentType()
+          val classDescriptorForExclusion = argumentType.classDescriptorForType()
+
+          val contributesBindingAnnotation = classDescriptorForExclusion
+              .annotationOrNull(contributesBindingFqName)
+          val contributesToAnnotation = classDescriptorForExclusion
+              .annotationOrNull(contributesToFqName)
+
+          // Verify that the the replaced classes use the same scope.
+          val scopeOfExclusion = contributesToAnnotation?.scope(module)
+              ?: contributesBindingAnnotation?.scope(module)
+              ?: throw AnvilCompilationException(
+                  codegen.descriptor,
+                  "Could not determine the scope of the excluded class " +
+                      "${classDescriptorForExclusion.fqNameSafe}."
+              )
+
+          if (scopeOfExclusion.fqNameSafe != scopeFqName) {
+            throw AnvilCompilationException(
+                codegen.descriptor,
+                "${codegen.descriptor.fqNameSafe} with scope $scopeFqName wants to exclude " +
+                    "${classDescriptorForExclusion.fqNameSafe} with scope " +
+                    "${scopeOfExclusion.fqNameSafe}. The exclusion must use the same scope."
+            )
+          }
+
+          argumentType.asmType(codegen.typeMapper)
+        }
         ?: emptyList()
 
     if (predefinedModules != null) {
@@ -224,6 +260,36 @@ internal class ModuleMerger(
             }
     return FqName(name)
   }
+
+  private fun checkSameScope(
+    contributedClass: ClassDescriptor,
+    classDescriptorForReplacement: ClassDescriptor,
+    module: ModuleDescriptor,
+    scopeFqName: FqName
+  ) {
+    val contributesBindingAnnotation = classDescriptorForReplacement
+        .annotationOrNull(contributesBindingFqName)
+    val contributesToAnnotation = classDescriptorForReplacement
+        .annotationOrNull(contributesToFqName)
+
+    // Verify that the the replaced classes use the same scope.
+    val scopeOfReplacement = contributesToAnnotation?.scope(module)
+        ?: contributesBindingAnnotation?.scope(module)
+        ?: throw AnvilCompilationException(
+            contributedClass,
+            "Could not determine the scope of the replaced class " +
+                "${classDescriptorForReplacement.fqNameSafe}."
+        )
+
+    if (scopeOfReplacement.fqNameSafe != scopeFqName) {
+      throw AnvilCompilationException(
+          contributedClass,
+          "${contributedClass.fqNameSafe} with scope $scopeFqName wants to replace " +
+              "${classDescriptorForReplacement.fqNameSafe} with scope " +
+              "${scopeOfReplacement.fqNameSafe}. The replacement must use the same scope."
+      )
+    }
+  }
 }
 
 @Suppress("DataClassPrivateConstructor")


Test patch:
diff --git a/compiler/src/test/java/com/squareup/anvil/compiler/InterfaceMergerTest.kt b/compiler/src/test/java/com/squareup/anvil/compiler/InterfaceMergerTest.kt
index a735a10ca..7e01e1d97 100644
--- a/compiler/src/test/java/com/squareup/anvil/compiler/InterfaceMergerTest.kt
+++ b/compiler/src/test/java/com/squareup/anvil/compiler/InterfaceMergerTest.kt
@@ -200,6 +200,39 @@ class InterfaceMergerTest(
     }
   }
 
+  @Test fun `replaced interfaces must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.ContributesTo
+        $import
+        
+        @ContributesTo(Unit::class)
+        interface ContributingInterface
+        
+        @ContributesTo(
+            Any::class,
+            replaces = [ContributingInterface::class]
+        )
+        interface SecondContributingInterface        
+
+        $annotation(Any::class)
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class. Unfortunately, a different error is reported that the class is
+      // missing an @Module annotation.
+      assertThat(messages).contains("Source.kt: (13, 11)")
+      assertThat(messages).contains(
+          "com.squareup.test.SecondContributingInterface with scope kotlin.Any wants to replace " +
+              "com.squareup.test.ContributingInterface with scope kotlin.Unit. The replacement " +
+              "must use the same scope."
+      )
+    }
+  }
+
   @Test fun `predefined interfaces are not replaced`() {
     compile(
         """
@@ -226,7 +259,7 @@ class InterfaceMergerTest(
     }
   }
 
-  @Test fun `interface can be excluded excluded`() {
+  @Test fun `interface can be excluded`() {
     compile(
         """
         package com.squareup.test
@@ -254,6 +287,40 @@ class InterfaceMergerTest(
     }
   }
 
+  @Test fun `excluded interfaces must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.ContributesTo
+        $import
+
+        @ContributesTo(Unit::class)
+        interface ContributingInterface
+        
+        @ContributesTo(Any::class)
+        interface SecondContributingInterface
+
+        $annotation(
+            scope = Any::class,
+            exclude = [
+              ContributingInterface::class
+            ]
+        )
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (18, 11)")
+      assertThat(messages).contains(
+          "com.squareup.test.ComponentInterface with scope kotlin.Any wants to exclude " +
+              "com.squareup.test.ContributingInterface with scope kotlin.Unit. The exclusion " +
+              "must use the same scope."
+      )
+    }
+  }
+
   @Test fun `predefined interfaces cannot be excluded`() {
     compile(
         """
@@ -359,18 +426,18 @@ class InterfaceMergerTest(
 
     visibilities.forEach { visibility ->
       compile(
-        """
-        package com.squareup.test
-
-        import com.squareup.anvil.annotations.ContributesTo
-        $import
-
-        @ContributesTo(Any::class)
-        $visibility interface ContributingInterface
-        
-        $annotation(Any::class)
-        interface ComponentInterface
-        """
+          """
+          package com.squareup.test
+  
+          import com.squareup.anvil.annotations.ContributesTo
+          $import
+  
+          @ContributesTo(Any::class)
+          $visibility interface ContributingInterface
+          
+          $annotation(Any::class)
+          interface ComponentInterface
+          """
       ) {
         assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
         // Position to the class.
diff --git a/compiler/src/test/java/com/squareup/anvil/compiler/MergeModulesTest.kt b/compiler/src/test/java/com/squareup/anvil/compiler/MergeModulesTest.kt
index 74eb1123e..ef3fc1c58 100644
--- a/compiler/src/test/java/com/squareup/anvil/compiler/MergeModulesTest.kt
+++ b/compiler/src/test/java/com/squareup/anvil/compiler/MergeModulesTest.kt
@@ -218,6 +218,42 @@ class MergeModulesTest {
     }
   }
 
+  @Test fun `contributed binding can be replaced but must have the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.compat.MergeModules
+        import com.squareup.anvil.annotations.ContributesBinding
+        import com.squareup.anvil.annotations.ContributesTo
+
+        interface ParentInterface
+
+        @ContributesBinding(Unit::class)
+        interface ContributingInterface : ParentInterface
+
+        @ContributesTo(
+            Any::class,
+            replaces = [ContributingInterface::class]
+        )
+        @dagger.Module
+        abstract class DaggerModule2
+
+        @MergeModules(Any::class)
+        class DaggerModule1
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (17, 16)")
+      assertThat(messages).contains(
+          "com.squareup.test.DaggerModule2 with scope kotlin.Any wants to replace " +
+              "com.squareup.test.ContributingInterface with scope kotlin.Unit. The replacement " +
+              "must use the same scope."
+      )
+    }
+  }
+
   @Test fun `module can be replaced by contributed binding`() {
     compile(
         """
@@ -248,6 +284,42 @@ class MergeModulesTest {
     }
   }
 
+  @Test fun `module replaced by contributed binding must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.compat.MergeModules
+        import com.squareup.anvil.annotations.ContributesBinding
+        import com.squareup.anvil.annotations.ContributesTo
+
+        interface ParentInterface
+
+        @ContributesTo(Unit::class)
+        @dagger.Module
+        abstract class DaggerModule2
+
+        @ContributesBinding(
+            Any::class,
+            replaces = [DaggerModule2::class]
+        )
+        interface ContributingInterface : ParentInterface
+
+        @MergeModules(Any::class)
+        class DaggerModule1
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (17, 11)")
+      assertThat(messages).contains(
+          "com.squareup.test.ContributingInterface with scope kotlin.Any wants to replace " +
+              "com.squareup.test.DaggerModule2 with scope kotlin.Unit. The replacement must use " +
+              "the same scope."
+      )
+    }
+  }
+
   @Test fun `replaced modules must be Dagger modules`() {
     compile(
         """
@@ -275,6 +347,40 @@ class MergeModulesTest {
     }
   }
 
+  @Test fun `replaced modules must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.compat.MergeModules
+        import com.squareup.anvil.annotations.ContributesTo
+
+        @dagger.Module
+        @ContributesTo(Unit::class)
+        abstract class DaggerModule3
+
+        @ContributesTo(
+            Any::class,
+            replaces = [DaggerModule3::class]
+        )
+        @dagger.Module
+        abstract class DaggerModule2
+
+        @MergeModules(Any::class)
+        class DaggerModule1
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (15, 16)")
+      assertThat(messages).contains(
+          "com.squareup.test.DaggerModule2 with scope kotlin.Any wants to replace " +
+              "com.squareup.test.DaggerModule3 with scope kotlin.Unit. The replacement must use " +
+              "the same scope."
+      )
+    }
+  }
+
   @Test fun `included modules are not replaced`() {
     compile(
         """
@@ -284,6 +390,7 @@ class MergeModulesTest {
         import com.squareup.anvil.annotations.ContributesTo
 
         @dagger.Module
+        @ContributesTo(Any::class)
         abstract class DaggerModule2
 
         @ContributesTo(
@@ -337,6 +444,38 @@ class MergeModulesTest {
     }
   }
 
+  @Test fun `excluded modules must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.compat.MergeModules
+        import com.squareup.anvil.annotations.ContributesTo
+
+        @ContributesTo(Unit::class)
+        @dagger.Module
+        abstract class DaggerModule2
+
+        @MergeModules(
+            scope = Any::class,
+            exclude = [
+              DaggerModule2::class
+            ]
+        )
+        class DaggerModule1
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (16, 7)")
+      assertThat(messages).contains(
+          "com.squareup.test.DaggerModule1 with scope kotlin.Any wants to exclude " +
+              "com.squareup.test.DaggerModule2 with scope kotlin.Unit. The exclusion must " +
+              "use the same scope."
+      )
+    }
+  }
+
   @Test fun `contributed bindings can be excluded`() {
     compile(
         """
@@ -363,6 +502,39 @@ class MergeModulesTest {
     }
   }
 
+  @Test fun `contributed bindings can be excluded but must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.compat.MergeModules
+        import com.squareup.anvil.annotations.ContributesBinding
+
+        interface ParentInterface
+
+        @ContributesBinding(Unit::class)
+        interface ContributingInterface : ParentInterface
+
+        @MergeModules(
+            scope = Any::class,
+            exclude = [
+              ContributingInterface::class
+            ]
+        )
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (17, 11)")
+      assertThat(messages).contains(
+          "com.squareup.test.ComponentInterface with scope kotlin.Any wants to exclude " +
+              "com.squareup.test.ContributingInterface with scope kotlin.Unit. The exclusion " +
+              "must use the same scope."
+      )
+    }
+  }
+
   @Test fun `modules are added to merged modules with corresponding scope`() {
     compile(
         """
@@ -400,19 +572,19 @@ class MergeModulesTest {
 
     visibilities.forEach { visibility ->
       compile(
-        """
-        package com.squareup.test
-
-        import com.squareup.anvil.annotations.compat.MergeModules
-        import com.squareup.anvil.annotations.ContributesTo
-
-        @ContributesTo(Any::class)
-        @dagger.Module
-        $visibility abstract class DaggerModule2
-
-        @MergeModules(Any::class)
-        class DaggerModule1
-        """
+          """
+          package com.squareup.test
+  
+          import com.squareup.anvil.annotations.compat.MergeModules
+          import com.squareup.anvil.annotations.ContributesTo
+  
+          @ContributesTo(Any::class)
+          @dagger.Module
+          $visibility abstract class DaggerModule2
+  
+          @MergeModules(Any::class)
+          class DaggerModule1
+          """
       ) {
         assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
         // Position to the class.
diff --git a/compiler/src/test/java/com/squareup/anvil/compiler/ModuleMergerTest.kt b/compiler/src/test/java/com/squareup/anvil/compiler/ModuleMergerTest.kt
index da5926c79..390260284 100644
--- a/compiler/src/test/java/com/squareup/anvil/compiler/ModuleMergerTest.kt
+++ b/compiler/src/test/java/com/squareup/anvil/compiler/ModuleMergerTest.kt
@@ -274,6 +274,42 @@ class ModuleMergerTest(
     }
   }
 
+  @Test fun `contributed binding can be replaced but must have the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.ContributesBinding
+        import com.squareup.anvil.annotations.ContributesTo
+        $import
+
+        interface ParentInterface
+
+        @ContributesBinding(Unit::class)
+        interface ContributingInterface : ParentInterface
+
+        @ContributesTo(
+            Any::class,
+            replaces = [ContributingInterface::class]
+        )
+        @dagger.Module
+        abstract class DaggerModule1
+
+        $annotation(Any::class)
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (17, 16)")
+      assertThat(messages).contains(
+          "com.squareup.test.DaggerModule1 with scope kotlin.Any wants to replace " +
+              "com.squareup.test.ContributingInterface with scope kotlin.Unit. The replacement " +
+              "must use the same scope."
+      )
+    }
+  }
+
   @Test fun `module can be replaced by contributed binding`() {
     compile(
         """
@@ -304,6 +340,42 @@ class ModuleMergerTest(
     }
   }
 
+  @Test fun `module replaced by contributed binding must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.ContributesBinding
+        import com.squareup.anvil.annotations.ContributesTo
+        $import
+
+        interface ParentInterface
+
+        @ContributesTo(Unit::class)
+        @dagger.Module
+        abstract class DaggerModule1
+
+        @ContributesBinding(
+            Any::class,
+            replaces = [DaggerModule1::class]
+        )
+        interface ContributingInterface : ParentInterface
+
+        $annotation(Any::class)
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (17, 11)")
+      assertThat(messages).contains(
+          "com.squareup.test.ContributingInterface with scope kotlin.Any wants to replace " +
+              "com.squareup.test.DaggerModule1 with scope kotlin.Unit. The replacement must use " +
+              "the same scope."
+      )
+    }
+  }
+
   @Test fun `replaced modules must be Dagger modules`() {
     compile(
         """
@@ -331,6 +403,40 @@ class ModuleMergerTest(
     }
   }
 
+  @Test fun `replaced modules must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.ContributesTo
+        $import
+
+        @ContributesTo(Unit::class)
+        @dagger.Module
+        abstract class DaggerModule1
+
+        @ContributesTo(
+            Any::class,
+            replaces = [DaggerModule1::class]
+        )
+        @dagger.Module
+        abstract class DaggerModule2 
+
+        $annotation(Any::class)
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (15, 16)")
+      assertThat(messages).contains(
+          "com.squareup.test.DaggerModule2 with scope kotlin.Any wants to replace " +
+              "com.squareup.test.DaggerModule1 with scope kotlin.Unit. The replacement must " +
+              "use the same scope."
+      )
+    }
+  }
+
   @Test fun `predefined modules are not replaced`() {
     compile(
         """
@@ -340,6 +446,7 @@ class ModuleMergerTest(
         $import
 
         @dagger.Module
+        @ContributesTo(Any::class)
         abstract class DaggerModule1
 
         @ContributesTo(
@@ -394,6 +501,42 @@ class ModuleMergerTest(
     }
   }
 
+  @Test fun `excluded modules must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.ContributesTo
+        $import
+
+        @ContributesTo(Unit::class)
+        @dagger.Module
+        abstract class DaggerModule1
+
+        @ContributesTo(Any::class)
+        @dagger.Module
+        abstract class DaggerModule2 
+
+        $annotation(
+            scope = Any::class,
+            exclude = [
+              DaggerModule1::class
+            ]
+        )
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (20, 11)")
+      assertThat(messages).contains(
+          "com.squareup.test.ComponentInterface with scope kotlin.Any wants to exclude " +
+              "com.squareup.test.DaggerModule1 with scope kotlin.Unit. The exclusion must use " +
+              "the same scope."
+      )
+    }
+  }
+
   @Test fun `contributed bindings can be excluded`() {
     compile(
         """
@@ -420,6 +563,39 @@ class ModuleMergerTest(
     }
   }
 
+  @Test fun `contributed bindings can be excluded but must use the same scope`() {
+    compile(
+        """
+        package com.squareup.test
+
+        import com.squareup.anvil.annotations.ContributesBinding
+        $import
+
+        interface ParentInterface
+
+        @ContributesBinding(Unit::class)
+        interface ContributingInterface : ParentInterface
+
+        $annotation(
+            scope = Any::class,
+            exclude = [
+              ContributingInterface::class
+            ]
+        )
+        interface ComponentInterface
+        """
+    ) {
+      assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
+      // Position to the class.
+      assertThat(messages).contains("Source.kt: (17, 11)")
+      assertThat(messages).contains(
+          "com.squareup.test.ComponentInterface with scope kotlin.Any wants to exclude " +
+              "com.squareup.test.ContributingInterface with scope kotlin.Unit. The exclusion " +
+              "must use the same scope."
+      )
+    }
+  }
+
   @Test fun `modules are added to components with corresponding scope`() {
     compile(
         """
@@ -490,19 +666,19 @@ class ModuleMergerTest(
 
     visibilities.forEach { visibility ->
       compile(
-        """
-        package com.squareup.test
-
-        import com.squareup.anvil.annotations.ContributesTo
-        $import
-        
-        @ContributesTo(Any::class)
-        @dagger.Module
-        $visibility abstract class DaggerModule1
-
-        $annotation(Any::class)
-        interface ComponentInterface
-        """
+          """
+          package com.squareup.test
+  
+          import com.squareup.anvil.annotations.ContributesTo
+          $import
+          
+          @ContributesTo(Any::class)
+          @dagger.Module
+          $visibility abstract class DaggerModule1
+  
+          $annotation(Any::class)
+          interface ComponentInterface
+          """
       ) {
         assertThat(exitCode).isEqualTo(COMPILATION_ERROR)
         // Position to the class.