diff --git a/reports/src/main/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiff.kt b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiff.kt new file mode 100644 index 00000000..922e96fb --- /dev/null +++ b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiff.kt @@ -0,0 +1,112 @@ +package com.jakewharton.diffuse.diff + +import com.jakewharton.diffuse.diffuseTable +import com.jakewharton.diffuse.format.Jar +import com.jakewharton.diffuse.format.TypeDescriptor +import com.jakewharton.diffuse.report.toDiffString +import com.jakewharton.picnic.TextAlignment.MiddleRight +import com.jakewharton.picnic.renderText +import kotlin.collections.iterator + +/** + * Diff for bytecode versions across two sets of JARs. + * + * @param versionCounts per-version summary: maps each bytecode version integer to a pair of + * (`oldCount`, `newCount`) where the counts differ, sorted by `version`. + * @param changedClasses classes that exist in both old and new but changed bytecode version, as + * triples of (`descriptor`, `oldVersion`, `newVersion`), sorted by `descriptor`. + */ +internal class BytecodeVersionDiff( + val versionCounts: Map>, + val changedClasses: List>, +) { + val changed = versionCounts.isNotEmpty() || changedClasses.isNotEmpty() + + @JvmInline value class Count(val count: Int) + + @JvmInline + value class Version(val version: Int) : Comparable { + override fun compareTo(other: Version): Int = version.compareTo(other.version) + } +} + +internal fun bytecodeVersionDiff(oldJars: List, newJars: List): BytecodeVersionDiff { + val oldVersionMap: Map = + oldJars + .flatMap { it.classes } + .associate { it.descriptor to BytecodeVersionDiff.Version(it.bytecodeVersion) } + val newVersionMap: Map = + newJars + .flatMap { it.classes } + .associate { it.descriptor to BytecodeVersionDiff.Version(it.bytecodeVersion) } + + // Classes present in both with different versions. + val changedClasses = + oldVersionMap + .mapNotNull { (descriptor, oldVersion) -> + val newVersion = newVersionMap[descriptor] + if (newVersion != null && newVersion != oldVersion) { + Triple(descriptor, oldVersion, newVersion) + } else { + null + } + } + .sortedBy { it.first } + + // Tally per-version counts across all classes in old and new. + val allVersions = (oldVersionMap.values + newVersionMap.values).toSortedSet() + val versionCounts = allVersions.associateWith { version -> + val oldCount = oldVersionMap.values.count { it == version } + val newCount = newVersionMap.values.count { it == version } + BytecodeVersionDiff.Count(oldCount) to BytecodeVersionDiff.Count(newCount) + } + + return BytecodeVersionDiff(versionCounts, changedClasses) +} + +internal fun StringBuilder.appendBytecodeVersionDiff(name: String, diff: BytecodeVersionDiff) { + if (!diff.changed) return + appendLine() + appendLine("$name:") + appendLine() + + if (diff.versionCounts.isNotEmpty()) { + diffuseTable { + header { + row { + cell("version") + cell("old") + cell("new") + cell("diff") + } + } + + body { + cellStyle { alignment = MiddleRight } + + for ((version, counts) in diff.versionCounts) { + val (oldCount, newCount) = counts + val net = (newCount.count - oldCount.count).toDiffString() + val added = + (newCount.count - oldCount.count).coerceAtLeast(0).toDiffString(zeroSign = '+') + val removed = + (-(oldCount.count - newCount.count).coerceAtLeast(0)).toDiffString(zeroSign = '-') + row(version.version, oldCount.count, newCount.count, "$net ($added $removed)") + } + } + } + .renderText() + .prependIndent(" ") + .let(::appendLine) + } + + if (diff.changedClasses.isNotEmpty()) { + if (diff.versionCounts.isNotEmpty()) { + appendLine() + } + diff.changedClasses.forEach { (descriptor, oldVersion, newVersion) -> + appendLine(" $descriptor: ${oldVersion.version} → ${newVersion.version}") + } + } + appendLine() +} diff --git a/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt index b402e235..96e1e3f7 100644 --- a/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt +++ b/reports/src/main/kotlin/com/jakewharton/diffuse/diff/JarsDiff.kt @@ -18,10 +18,7 @@ internal class JarsDiff( val newMapping: ApiMapping, ) { val classes = componentDiff(oldJars, newJars) { it.classes.map(Class::descriptor) } - val bytecodeVersions = - componentDiff(oldJars, newJars) { jar -> - jar.classes.map { "${it.descriptor}: ${it.bytecodeVersion}" } - } + val bytecodeVersions = bytecodeVersionDiff(oldJars, newJars) val methods = componentDiff(oldJars, newJars) { it.members.filterIsInstance() } val declaredMethods = componentDiff(oldJars, newJars) { it.declaredMembers.filterIsInstance() } @@ -76,7 +73,7 @@ internal fun JarsDiff.toSummaryTable(name: String) = internal fun JarsDiff.toDetailReport() = buildString { // TODO appendComponentDiff("STRINGS", strings)? appendComponentDiff("CLASSES", classes) - appendComponentDiff("BYTECODE VERSIONS", bytecodeVersions) + appendBytecodeVersionDiff("BYTECODE VERSIONS", bytecodeVersions) appendComponentDiff("METHODS", methods) appendComponentDiff("FIELDS", fields) } diff --git a/reports/src/test/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiffTest.kt b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiffTest.kt new file mode 100644 index 00000000..7efcef97 --- /dev/null +++ b/reports/src/test/kotlin/com/jakewharton/diffuse/diff/BytecodeVersionDiffTest.kt @@ -0,0 +1,282 @@ +package com.jakewharton.diffuse.diff + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.jakewharton.diffuse.diff.BytecodeVersionDiff.Count +import com.jakewharton.diffuse.diff.BytecodeVersionDiff.Version +import com.jakewharton.diffuse.format.TypeDescriptor +import org.junit.Test + +class BytecodeVersionDiffTest { + @Test + fun nothingChangedProducesNoOutput() { + val diff = BytecodeVersionDiff(versionCounts = emptyMap(), changedClasses = emptyList()) + assertThat(diff.changed).isFalse() + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }).isEqualTo("") + } + + @Test + fun singleVersionUpgrade() { + // One class moved from version 65 to 69. + val diff = + BytecodeVersionDiff( + versionCounts = + mapOf( + Version(65) to (Count(1) to Count(0)), // -1 net + Version(69) to (Count(0) to Count(1)), // +1 net + ), + changedClasses = + listOf( + Triple(TypeDescriptor("Lorg/example/MainKt;"), Version(65), Version(69)) // Up + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 1 │ 0 │ -1 (+0 -1) + | 69 │ 0 │ 1 │ +1 (+1 -0) + | + | org.example.MainKt: 65 → 69 + | + |""" + .trimMargin() + ) + } + + @Test + fun multipleClassesUpgrade() { + // Two classes moved from 61 to 65; the unchanged-count version is filtered out. + val diff = + BytecodeVersionDiff( + versionCounts = + mapOf( + Version(61) to (Count(3) to Count(1)), // -2 net + Version(65) to (Count(0) to Count(2)), // +2 net + ), + changedClasses = + listOf( + Triple(TypeDescriptor("Lcom/example/ClassA;"), Version(61), Version(65)), // Up + Triple(TypeDescriptor("Lcom/example/ClassB;"), Version(61), Version(65)), // Up + ), + ) + + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 61 │ 3 │ 1 │ -2 (+0 -2) + | 65 │ 0 │ 2 │ +2 (+2 -0) + | + | com.example.ClassA: 61 → 65 + | com.example.ClassB: 61 → 65 + | + |""" + .trimMargin() + ) + } + + @Test + fun singleVersionDowngrade() { + // One class moved from version 69 back down to 65. + val diff = + BytecodeVersionDiff( + versionCounts = + mapOf( + Version(65) to (Count(0) to Count(1)), // +1 net + Version(69) to (Count(1) to Count(0)), // -1 net + ), + changedClasses = + listOf( + Triple(TypeDescriptor("Lorg/example/MainKt;"), Version(69), Version(65)) // Down + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 0 │ 1 │ +1 (+1 -0) + | 69 │ 1 │ 0 │ -1 (+0 -1) + | + | org.example.MainKt: 69 → 65 + | + |""" + .trimMargin() + ) + } + + @Test + fun multipleClassesDowngrade() { + // Two classes moved from 65 down to 61. + val diff = + BytecodeVersionDiff( + versionCounts = + mapOf( + Version(61) to (Count(0) to Count(2)), // +2 net + Version(65) to (Count(2) to Count(0)), // -2 net + ), + changedClasses = + listOf( + Triple(TypeDescriptor("Lcom/example/ClassA;"), Version(65), Version(61)), // Down + Triple(TypeDescriptor("Lcom/example/ClassB;"), Version(65), Version(61)), // Down + ), + ) + + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 61 │ 0 │ 2 │ +2 (+2 -0) + | 65 │ 2 │ 0 │ -2 (+0 -2) + | + | com.example.ClassA: 65 → 61 + | com.example.ClassB: 65 → 61 + | + |""" + .trimMargin() + ) + } + + @Test + fun mixedUpgradeAndDowngrade() { + val diff = + BytecodeVersionDiff( + versionCounts = + mapOf( + Version(65) to (Count(2) to Count(1)), // -1 net + Version(69) to (Count(0) to Count(1)), // +1 net + // Version 61 is old 1, new 1 (Filtered) + ), + changedClasses = + listOf( + Triple(TypeDescriptor("Lcom/example/ClassA;"), Version(61), Version(65)), // Up + Triple(TypeDescriptor("Lcom/example/ClassB;"), Version(65), Version(61)), // Down + Triple(TypeDescriptor("Lcom/example/ClassC;"), Version(65), Version(69)), // Up + ), + ) + + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 2 │ 1 │ -1 (+0 -1) + | 69 │ 0 │ 1 │ +1 (+1 -0) + | + | com.example.ClassA: 61 → 65 + | com.example.ClassB: 65 → 61 + | com.example.ClassC: 65 → 69 + | + |""" + .trimMargin() + ) + } + + @Test + fun noChangedClassesButVersionCountsDiffer() { + // New classes added at version 65, no pre-existing classes changed version. + val diff = + BytecodeVersionDiff( + versionCounts = + mapOf( + Version(65) to (Count(0) to Count(2)) // +2 net + ), + changedClasses = emptyList(), + ) + + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 65 │ 0 │ 2 │ +2 (+2 -0) + | + |""" + .trimMargin() + ) + } + + @Test + fun onlyRemovals() { + // Classes removed at version 61. + val diff = + BytecodeVersionDiff( + versionCounts = + mapOf( + Version(61) to (Count(2) to Count(0)) // -2 net + ), + changedClasses = emptyList(), + ) + + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | version │ old │ new │ diff + | ─────────┼─────┼─────┼──────────── + | 61 │ 2 │ 0 │ -2 (+0 -2) + | + |""" + .trimMargin() + ) + } + + @Test + fun netZeroVersionChangesStillProducesOutput() { + // Classes shifted versions but net counts per version stayed same. + // Table is hidden, but class list is shown. + val diff = + BytecodeVersionDiff( + versionCounts = emptyMap(), + changedClasses = + listOf( + Triple(TypeDescriptor("Lcom/example/ClassA;"), Version(61), Version(65)), // Up + Triple(TypeDescriptor("Lcom/example/ClassB;"), Version(65), Version(61)), // Down + ), + ) + + assertThat(diff.changed).isTrue() + assertThat(buildString { appendBytecodeVersionDiff("BYTECODE VERSIONS", diff) }) + .isEqualTo( + """ + | + |BYTECODE VERSIONS: + | + | com.example.ClassA: 61 → 65 + | com.example.ClassB: 65 → 61 + | + |""" + .trimMargin() + ) + } +}