@@ -5,7 +5,9 @@ import japicmp.model.JApiAnnotation
55import japicmp.model.JApiClass
66import japicmp.model.JApiCompatibility
77import japicmp.model.JApiCompatibilityChangeType
8+ import japicmp.model.JApiField
89import japicmp.model.JApiMethod
10+ import javassist.CtMethod
911import me.champeau.gradle.japicmp.JapicmpTask
1012import me.champeau.gradle.japicmp.report.Violation
1113import me.champeau.gradle.japicmp.report.stdrules.AbstractRecordingSeenMembers
@@ -224,6 +226,10 @@ public class InternalFilterRule : AbstractRecordingSeenMembers() {
224226 Violation .accept(member, " Kotlin internal visibility" )
225227 } else if (member.hasOnlyKotlinMetadataModification()) {
226228 Violation .accept(member, " Kotlin metadata change" )
229+ } else if (member.classContainsOnlyMethodChangesWithExcludedAnnotations()) {
230+ Violation .accept(member, " Contains changes to excluded annotations" )
231+ } else if (member.containsChangesToCompanionObjectVariableWithExcludedAnnotations()) {
232+ Violation .accept(member, " Companion object variable with excluded annotations" )
227233 } else {
228234 binaryIncompatibleRule.maybeAddViolation(member)
229235 }
@@ -267,6 +273,69 @@ public class InternalFilterRule : AbstractRecordingSeenMembers() {
267273 private fun List<JApiAnnotation>?.containsKotlinMetadata (): Boolean {
268274 return this ?.any { it.fullyQualifiedName == " kotlin.Metadata" } == true
269275 }
276+
277+ /* *
278+ * When we have a constant declared in a companion object, the annotation is declared in separate class ending with '$Companion'.
279+ * This function checks if field is declared in companion object and if it has any of the excluded annotations.
280+ */
281+ private fun JApiCompatibility.containsChangesToCompanionObjectVariableWithExcludedAnnotations (): Boolean {
282+ if (this .isBinaryCompatible) return false
283+ val member = (this as ? JApiField ) ? : return false
284+ // Get the declaring class first
285+ val declaringClass = member.getjApiClass()
286+ val oldClass = declaringClass.oldClass.orElse(null ) ? : return false
287+ val companionClass =
288+ oldClass.declaredClasses.firstOrNull { it.name.contains(" ${oldClass.name} \$ Companion" ) }
289+ ? : return false
290+ val methodMatchingFieldName =
291+ companionClass.methods.firstOrNull { it.name.contains(member.name) } ? : return false
292+ return methodMatchingFieldName.containsExcludedAnnotation()
293+ }
294+
295+ private fun CtMethod.containsExcludedAnnotation (): Boolean {
296+ return arrayOf(
297+ RESTRICT_TO_ANNOTATION ,
298+ MAPS_EXPERIMENTAL_ANNOTATION ,
299+ VISIBLE_FOR_TESTING_ANNOTATION ,
300+ EXPERIMENTAL_ANNOTATION
301+ )
302+ .map { it.substring(1 ) }
303+ .any {
304+ hasAnnotation(it)
305+ }
306+ }
307+
308+ /* *
309+ * Checks if all method updates in a class and its declared classes only affect methods with excluded annotations.
310+ *
311+ * This function identifies methods that existed in the old version of a class and its nested classes
312+ * but were either removed or changed in the new version. It goes through updated methods
313+ * and checks whether they contain excluded annotations (like @MapboxExperimental, @RestrictTo, etc.).
314+ *
315+ * @return true if all method changes only affect methods with excluded annotations.
316+ */
317+ private fun JApiCompatibility.classContainsOnlyMethodChangesWithExcludedAnnotations (): Boolean {
318+ if (this .isBinaryCompatible) return false
319+ val member = (this as ? JApiClass ) ? : return false
320+ // Get the declaring class first
321+ val declaringClass = member
322+ val oldClass = declaringClass.oldClass.orElse(null ) ? : return false
323+ val newClass = declaringClass.newClass.orElse(null ) ? : return false
324+ val oldMethods = (oldClass.methods + oldClass.declaredClasses.flatMap { it.methods.toList() }).toMutableList()
325+ val newMethods = newClass.methods + newClass.declaredClasses.flatMap { it.methods.toList() }
326+ newMethods.forEach { newMethod ->
327+ // remove methods that have the same name and signature
328+ oldMethods.removeIf {
329+ it.name == newMethod.name && it.signature == newMethod.signature
330+ }
331+ }
332+ // Remaining methods might be deleted or have been changed.
333+ // If they contain excluded annotations, we will not consider them as breaking changes.
334+ // That's why we check if all of them contain excluded annotations.
335+ // If there is at least one method that doesn't contain excluded annotation,
336+ // we will consider it as breaking change and JApiCmp should provide details in the report.
337+ return oldMethods.all { it.containsExcludedAnnotation() }
338+ }
270339}
271340
272341private const val RESTRICT_TO_ANNOTATION = " @androidx.annotation.RestrictTo"
0 commit comments