Repository: Kotlin/binary-compatibility-validator
Pull request #99 (2022-09-28 11:25:57)

Support reading classes from resulting jar
... and allow configuring tasks without link to `ApiValidationExtension`

An example of using manually configured tasks in a project without applying the plugin:
```
plugins {
    id("org.jetbrains.kotlinx.binary-compatibility-validator") version ... apply false
}

tasks {
    val apiBuildDir = file("build/api")
    val apiReferenceDir = file("api")
    val apiBuild by registering(KotlinApiBuildTask::class) {
        outputApiDir = apiBuildDir
        inputJar.value(jar.flatMap { it.archiveFile })
        inputDependencies = files()
    }
    val apiDump by registering(Sync::class) {
        dependsOn(apiBuild)
        from(apiBuildDir)
        into(apiReferenceDir)
    }
    val apiCompare by registering(KotlinApiCompareTask::class) {
        dependsOn(apiBuild)
        compareApiDumps(apiReferenceDir, apiBuildDir)
    }
    check { dependsOn(apiCompare) }
}
```

ilya-g: Two more questions: 
- what is the purpose of `inputDependencies` property? It seems not used in the task action. Can it be made optional?
- can we make `Project.sourceSets` extension internal? Due to being public, it leaks into client scripts and could cause some mess.
qwwdfsad: >what is the purpose of inputDependencies property? It seems not used in the task action. Can it be made optional?

Yes, it can be. It is required to manually track compilations output and react on its changes even if the corresponding dependent task hasn't been launched

>can we make Project.sourceSets extension val internal?

Sure
ilya-g: I've added a test and a readme section.

I'm going to make the other improvements (e.g. making Project.sourceSets internal) in a further PR.  

Patch:
diff --git a/README.md b/README.md
index 93ea405..aa41c33 100644
--- a/README.md
+++ b/README.md
@@ -125,6 +125,26 @@ apiValidation {
 }
 ```
 
+### Producing dump of a jar
+
+By default, binary compatibility validator analyzes project output class files from `build/classes` directory when building an API dump.
+If you pack these classes into an output jar not in a regular way, for example, by excluding certain classes, applying `shadow` plugin, and so on,
+the API dump built from the original class files may no longer reflect the resulting jar contents accurately.
+In that case, it makes sense to use the resulting jar as an input of the `apuBuild` task:
+
+Kotlin
+```kotlin
+tasks {
+    apiBuild {
+        // "jar" here is the name of the default Jar task producing the resulting jar file
+        // in a multiplatform project it can be named "jvmJar"
+        // if you applied the shadow plugin, it creates the "shadowJar" task that produces the transformed jar
+        inputJar.value(jar.flatMap { it.archiveFile })
+    }
+}
+```
+
+
 ### Workflow
 
 When starting to validate your library public API, we recommend the following workflow:
diff --git a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt
index 7b48672..7180300 100644
--- a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt
+++ b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt
@@ -179,7 +179,7 @@ private fun Project.configureKotlinCompilation(
     val apiDirProvider = targetConfig.apiDir
     val apiBuildDir = apiDirProvider.map { buildDir.resolve(it) }
 
-    val apiBuild = task<KotlinApiBuildTask>(targetConfig.apiTaskName("Build"), extension) {
+    val apiBuild = task<KotlinApiBuildTask>(targetConfig.apiTaskName("Build")) {
         // Do not enable task for empty umbrella modules
         isEnabled =
             apiCheckEnabled(projectName, extension) && compilation.allKotlinSourceSets.any { it.kotlin.srcDirs.any { it.exists() } }
@@ -206,6 +206,12 @@ private fun Project.configureKotlinCompilation(
 val Project.sourceSets: SourceSetContainer
     get() = convention.getPlugin(JavaPluginConvention::class.java).sourceSets
 
+internal val Project.apiValidationExtensionOrNull: ApiValidationExtension?
+    get() =
+        generateSequence(this) { it.parent }
+            .map { it.extensions.findByType(ApiValidationExtension::class.java) }
+            .firstOrNull { it != null }
+
 fun apiCheckEnabled(projectName: String, extension: ApiValidationExtension): Boolean =
     projectName !in extension.ignoredProjects && !extension.validationDisabled
 
@@ -216,7 +222,7 @@ private fun Project.configureApiTasks(
 ) {
     val projectName = project.name
     val apiBuildDir = targetConfig.apiDir.map { buildDir.resolve(it) }
-    val apiBuild = task<KotlinApiBuildTask>(targetConfig.apiTaskName("Build"), extension) {
+    val apiBuild = task<KotlinApiBuildTask>(targetConfig.apiTaskName("Build")) {
         isEnabled = apiCheckEnabled(projectName, extension)
         // 'group' is not specified deliberately so it will be hidden from ./gradlew tasks
         description =
@@ -247,16 +253,7 @@ private fun Project.configureCheckTasks(
         isEnabled = apiCheckEnabled(projectName, extension) && apiBuild.map { it.enabled }.getOrElse(true)
         group = "verification"
         description = "Checks signatures of public API against the golden value in API folder for $projectName"
-        run {
-            val d = apiCheckDir.get()
-            projectApiDir = if (d.exists()) {
-                d
-            } else {
-                nonExistingProjectApiDir = d.toString()
-                null
-            }
-            this.apiBuildDir = apiBuildDir.get()
-        }
+        compareApiDumps(apiReferenceDir = apiCheckDir.get(), apiBuildDir = apiBuildDir.get())
         dependsOn(apiBuild)
     }
 
diff --git a/src/main/kotlin/KotlinApiBuildTask.kt b/src/main/kotlin/KotlinApiBuildTask.kt
index af13e38..1621c65 100644
--- a/src/main/kotlin/KotlinApiBuildTask.kt
+++ b/src/main/kotlin/KotlinApiBuildTask.kt
@@ -10,15 +10,23 @@ import org.gradle.api.*
 import org.gradle.api.file.*
 import org.gradle.api.tasks.*
 import java.io.*
+import java.util.jar.JarFile
 import javax.inject.Inject
 
 open class KotlinApiBuildTask @Inject constructor(
-    private val extension: ApiValidationExtension
 ) : DefaultTask() {
 
+    private val extension = project.apiValidationExtensionOrNull
+
     @InputFiles
+    @Optional
+    @PathSensitive(PathSensitivity.RELATIVE)
+    var inputClassesDirs: FileCollection? = null
+
+    @InputFile
+    @Optional
     @PathSensitive(PathSensitivity.RELATIVE)
-    lateinit var inputClassesDirs: FileCollection
+    val inputJar: RegularFileProperty = this.project.objects.fileProperty()
 
     @InputFiles
     @PathSensitive(PathSensitivity.RELATIVE)
@@ -27,14 +35,23 @@ open class KotlinApiBuildTask @Inject constructor(
     @OutputDirectory
     lateinit var outputApiDir: File
 
+    private var _ignoredPackages: Set<String>? = null
     @get:Input
-    val ignoredPackages : Set<String> get() = extension.ignoredPackages
+    var ignoredPackages : Set<String>
+        get() = _ignoredPackages ?: extension?.ignoredPackages ?: emptySet()
+        set(value) { _ignoredPackages = value }
 
+    private var _nonPublicMarkes: Set<String>? = null
     @get:Input
-    val nonPublicMarkers : Set<String> get() = extension.nonPublicMarkers
+    var nonPublicMarkers : Set<String>
+        get() = _nonPublicMarkes ?: extension?.nonPublicMarkers ?: emptySet()
+        set(value) { _nonPublicMarkes = value }
 
+    private var _ignoredClasses: Set<String>? = null
     @get:Input
-    val ignoredClasses : Set<String> get() = extension.ignoredClasses
+    var ignoredClasses : Set<String>
+        get() = _ignoredClasses ?: extension?.ignoredClasses ?: emptySet()
+        set(value) { _ignoredClasses = value }
 
     @get:Internal
     internal val projectName = project.name
@@ -44,17 +61,29 @@ open class KotlinApiBuildTask @Inject constructor(
         cleanup(outputApiDir)
         outputApiDir.mkdirs()
 
-        val signatures = inputClassesDirs.asFileTree.asSequence()
-            .filter {
-                !it.isDirectory && it.name.endsWith(".class") && !it.name.startsWith("META-INF/")
-            }
-            .map { it.inputStream() }
-            .loadApiFromJvmClasses()
+        val inputClassesDirs = inputClassesDirs
+        val signatures = when {
+            // inputJar takes precedence if specified
+            inputJar.isPresent ->
+                JarFile(inputJar.get().asFile).use { it.loadApiFromJvmClasses() }
+            inputClassesDirs != null ->
+                inputClassesDirs.asFileTree.asSequence()
+                    .filter {
+                        !it.isDirectory && it.name.endsWith(".class") && !it.name.startsWith("META-INF/")
+                    }
+                    .map { it.inputStream() }
+                    .loadApiFromJvmClasses()
+            else ->
+                throw GradleException("KotlinApiBuildTask should have either inputClassesDirs, or inputJar property set")
+        }
+
+
+        val filteredSignatures = signatures
             .filterOutNonPublic(ignoredPackages, ignoredClasses)
             .filterOutAnnotated(nonPublicMarkers.map { it.replace(".", "/") }.toSet())
 
         outputApiDir.resolve("$projectName.api").bufferedWriter().use { writer ->
-            signatures
+            filteredSignatures
                 .sortedBy { it.name }
                 .forEach { api ->
                     writer.append(api.signature).appendLine(" {")
diff --git a/src/main/kotlin/KotlinApiCompareTask.kt b/src/main/kotlin/KotlinApiCompareTask.kt
index 25b3511..6ca711e 100644
--- a/src/main/kotlin/KotlinApiCompareTask.kt
+++ b/src/main/kotlin/KotlinApiCompareTask.kt
@@ -32,6 +32,16 @@ open class KotlinApiCompareTask @Inject constructor(private val objects: ObjectF
     @Optional
     var nonExistingProjectApiDir: String? = null
 
+    fun compareApiDumps(apiReferenceDir: File, apiBuildDir: File) {
+        if (apiReferenceDir.exists()) {
+            projectApiDir = apiReferenceDir
+        } else {
+            projectApiDir = null
+            nonExistingProjectApiDir = apiReferenceDir.toString()
+        }
+        this.apiBuildDir = apiBuildDir
+    }
+
     @InputDirectory
     @PathSensitive(PathSensitivity.RELATIVE)
     lateinit var apiBuildDir: File


Test patch:
diff --git a/src/functionalTest/kotlin/kotlinx/validation/test/InputJarTest.kt b/src/functionalTest/kotlin/kotlinx/validation/test/InputJarTest.kt
new file mode 100644
index 0000000..61c03c4
--- /dev/null
+++ b/src/functionalTest/kotlin/kotlinx/validation/test/InputJarTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2016-2022 JetBrains s.r.o.
+ * Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
+ */
+
+package kotlinx.validation.test
+
+import kotlinx.validation.api.*
+import org.junit.*
+
+class InputJarTest : BaseKotlinGradleTest() {
+
+    @Test
+    fun testOverrideInputJar() {
+        val runner = test {
+            buildGradleKts {
+                resolve("examples/gradle/base/withPlugin.gradle.kts")
+                resolve("examples/gradle/configuration/jarAsInput/inputJar.gradle.kts")
+            }
+
+            kotlin("Properties.kt") {
+                resolve("examples/classes/Properties.kt")
+            }
+
+            apiFile(projectName = rootProjectDir.name) {
+                resolve("examples/classes/PropertiesJarTransformed.dump")
+            }
+
+            runner {
+                arguments.add(":apiCheck")
+            }
+        }
+
+        runner.build().apply {
+            assertTaskSuccess(":jar")
+            assertTaskSuccess(":apiCheck")
+        }
+    }
+}
diff --git a/src/functionalTest/resources/examples/classes/PropertiesJarTransformed.dump b/src/functionalTest/resources/examples/classes/PropertiesJarTransformed.dump
new file mode 100644
index 0000000..dc02831
--- /dev/null
+++ b/src/functionalTest/resources/examples/classes/PropertiesJarTransformed.dump
@@ -0,0 +1,8 @@
+public final class foo/ClassWithProperties {
+	public fun <init> ()V
+	public final fun getBar1 ()I
+	public final fun getBar2 ()I
+	public final fun setBar1 (I)V
+	public final fun setBar2 (I)V
+}
+
diff --git a/src/functionalTest/resources/examples/gradle/configuration/jarAsInput/inputJar.gradle.kts b/src/functionalTest/resources/examples/gradle/configuration/jarAsInput/inputJar.gradle.kts
new file mode 100644
index 0000000..c96da59
--- /dev/null
+++ b/src/functionalTest/resources/examples/gradle/configuration/jarAsInput/inputJar.gradle.kts
@@ -0,0 +1,9 @@
+tasks {
+    jar {
+        exclude("foo/HiddenField.class")
+        exclude("foo/HiddenProperty.class")
+    }
+    apiBuild {
+        inputJar.value(jar.flatMap { it.archiveFile })
+    }
+}
\ No newline at end of file