From 426323eabed0fa2e9a9ee380221c7c41323e55b5 Mon Sep 17 00:00:00 2001 From: Ilya Gorbunov Date: Tue, 27 Sep 2022 22:22:10 +0200 Subject: [PATCH 1/5] Support reading classes from resulting jar and allow configuring tasks without registering extension --- .../BinaryCompatibilityValidatorPlugin.kt | 21 ++++----- src/main/kotlin/KotlinApiBuildTask.kt | 45 ++++++++++++++----- src/main/kotlin/KotlinApiCompareTask.kt | 10 +++++ 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt index 7b48672c..f9a98a47 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(targetConfig.apiTaskName("Build"), extension) { + val apiBuild = task(targetConfig.apiTaskName("Build")) { // Do not enable task for empty umbrella modules isEnabled = apiCheckEnabled(projectName, extension) && compilation.allKotlinSourceSets.any { it.kotlin.srcDirs.any { it.exists() } } @@ -199,6 +199,9 @@ private fun Project.configureKotlinCompilation( files(provider { if (isEnabled) compilation.compileDependencyFiles else emptyList() }) } outputApiDir = apiBuildDir.get() + ignoredPackages = extension.ignoredPackages + ignoredClasses = extension.ignoredClasses + nonPublicMarkers = extension.nonPublicMarkers } configureCheckTasks(apiBuildDir, apiBuild, extension, targetConfig, commonApiDump, commonApiCheck) } @@ -216,7 +219,7 @@ private fun Project.configureApiTasks( ) { val projectName = project.name val apiBuildDir = targetConfig.apiDir.map { buildDir.resolve(it) } - val apiBuild = task(targetConfig.apiTaskName("Build"), extension) { + val apiBuild = task(targetConfig.apiTaskName("Build")) { isEnabled = apiCheckEnabled(projectName, extension) // 'group' is not specified deliberately so it will be hidden from ./gradlew tasks description = @@ -224,6 +227,9 @@ private fun Project.configureApiTasks( inputClassesDirs = files(provider { if (isEnabled) sourceSet.output.classesDirs else emptyList() }) inputDependencies = files(provider { if (isEnabled) sourceSet.output.classesDirs else emptyList() }) outputApiDir = apiBuildDir.get() + ignoredPackages = extension.ignoredPackages + ignoredClasses = extension.ignoredClasses + nonPublicMarkers = extension.nonPublicMarkers } configureCheckTasks(apiBuildDir, apiBuild, extension, targetConfig) @@ -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 af13e387..1ac2e3be 100644 --- a/src/main/kotlin/KotlinApiBuildTask.kt +++ b/src/main/kotlin/KotlinApiBuildTask.kt @@ -10,15 +10,21 @@ 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() { @InputFiles + @Optional @PathSensitive(PathSensitivity.RELATIVE) - lateinit var inputClassesDirs: FileCollection + var inputClassesDirs: FileCollection? = null + + @InputFile + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + val inputJar: RegularFileProperty = this.project.objects.fileProperty() @InputFiles @PathSensitive(PathSensitivity.RELATIVE) @@ -28,13 +34,13 @@ open class KotlinApiBuildTask @Inject constructor( lateinit var outputApiDir: File @get:Input - val ignoredPackages : Set get() = extension.ignoredPackages + var ignoredPackages : Set = emptySet() @get:Input - val nonPublicMarkers : Set get() = extension.nonPublicMarkers + var nonPublicMarkers : Set = emptySet() @get:Input - val ignoredClasses : Set get() = extension.ignoredClasses + var ignoredClasses : Set = emptySet() @get:Internal internal val projectName = project.name @@ -44,17 +50,32 @@ 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 + if (listOfNotNull(inputClassesDirs, inputJar.orNull).size != 1) { + throw GradleException("KotlinApiBuildTask should have either inputClassesDirs, or inputJar properties set") + } + val signatures = when { + inputClassesDirs != null -> + inputClassesDirs.asFileTree.asSequence() + .filter { + !it.isDirectory && it.name.endsWith(".class") && !it.name.startsWith("META-INF/") + } + .map { it.inputStream() } + .loadApiFromJvmClasses() + inputJar.isPresent -> + JarFile(inputJar.get().asFile) + .loadApiFromJvmClasses() + else -> + error("Unreachable") + } + + + 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 25b35118..6ca711e6 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 From 658faf4634d0f697ef307f27db611dc11f1ae922 Mon Sep 17 00:00:00 2001 From: Ilya Gorbunov Date: Wed, 5 Oct 2022 17:45:07 +0200 Subject: [PATCH 2/5] KotlinApiBuildTask finds extension by itself looking up in the project hierarchy If there's an ApiValidationExtension somewhere in the project or in its parent projects, it uses its properties as defaults for the corresponding task properties. It is still possible to instantiate a task without extension and to set or override properties individually. --- .../BinaryCompatibilityValidatorPlugin.kt | 12 ++++++------ src/main/kotlin/KotlinApiBuildTask.kt | 17 ++++++++++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt index f9a98a47..71803002 100644 --- a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt +++ b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt @@ -199,9 +199,6 @@ private fun Project.configureKotlinCompilation( files(provider { if (isEnabled) compilation.compileDependencyFiles else emptyList() }) } outputApiDir = apiBuildDir.get() - ignoredPackages = extension.ignoredPackages - ignoredClasses = extension.ignoredClasses - nonPublicMarkers = extension.nonPublicMarkers } configureCheckTasks(apiBuildDir, apiBuild, extension, targetConfig, commonApiDump, commonApiCheck) } @@ -209,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 @@ -227,9 +230,6 @@ private fun Project.configureApiTasks( inputClassesDirs = files(provider { if (isEnabled) sourceSet.output.classesDirs else emptyList() }) inputDependencies = files(provider { if (isEnabled) sourceSet.output.classesDirs else emptyList() }) outputApiDir = apiBuildDir.get() - ignoredPackages = extension.ignoredPackages - ignoredClasses = extension.ignoredClasses - nonPublicMarkers = extension.nonPublicMarkers } configureCheckTasks(apiBuildDir, apiBuild, extension, targetConfig) diff --git a/src/main/kotlin/KotlinApiBuildTask.kt b/src/main/kotlin/KotlinApiBuildTask.kt index 1ac2e3be..7aafe05a 100644 --- a/src/main/kotlin/KotlinApiBuildTask.kt +++ b/src/main/kotlin/KotlinApiBuildTask.kt @@ -16,6 +16,8 @@ import javax.inject.Inject open class KotlinApiBuildTask @Inject constructor( ) : DefaultTask() { + private val extension = project.apiValidationExtensionOrNull + @InputFiles @Optional @PathSensitive(PathSensitivity.RELATIVE) @@ -33,14 +35,23 @@ open class KotlinApiBuildTask @Inject constructor( @OutputDirectory lateinit var outputApiDir: File + private var _ignoredPackages: Set? = null @get:Input - var ignoredPackages : Set = emptySet() + var ignoredPackages : Set + get() = _ignoredPackages ?: extension?.ignoredPackages ?: emptySet() + set(value) { _ignoredPackages = value } + private var _nonPublicMarkes: Set? = null @get:Input - var nonPublicMarkers : Set = emptySet() + var nonPublicMarkers : Set + get() = _nonPublicMarkes ?: extension?.nonPublicMarkers ?: emptySet() + set(value) { _nonPublicMarkes = value } + private var _ignoredClasses: Set? = null @get:Input - var ignoredClasses : Set = emptySet() + var ignoredClasses : Set + get() = _ignoredClasses ?: extension?.ignoredClasses ?: emptySet() + set(value) { _ignoredClasses = value } @get:Internal internal val projectName = project.name From 5830331cba5671c9b7bf7a6568f118175c57728f Mon Sep 17 00:00:00 2001 From: Ilya Gorbunov Date: Thu, 6 Oct 2022 19:46:58 +0200 Subject: [PATCH 3/5] Make inputJar source override inputClassesDirs It allows to use the default task setup created by BCV plugin and only configure input jar to switch to another source of classes. --- src/main/kotlin/KotlinApiBuildTask.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/KotlinApiBuildTask.kt b/src/main/kotlin/KotlinApiBuildTask.kt index 7aafe05a..1621c65b 100644 --- a/src/main/kotlin/KotlinApiBuildTask.kt +++ b/src/main/kotlin/KotlinApiBuildTask.kt @@ -62,10 +62,10 @@ open class KotlinApiBuildTask @Inject constructor( outputApiDir.mkdirs() val inputClassesDirs = inputClassesDirs - if (listOfNotNull(inputClassesDirs, inputJar.orNull).size != 1) { - throw GradleException("KotlinApiBuildTask should have either inputClassesDirs, or inputJar properties set") - } val signatures = when { + // inputJar takes precedence if specified + inputJar.isPresent -> + JarFile(inputJar.get().asFile).use { it.loadApiFromJvmClasses() } inputClassesDirs != null -> inputClassesDirs.asFileTree.asSequence() .filter { @@ -73,11 +73,8 @@ open class KotlinApiBuildTask @Inject constructor( } .map { it.inputStream() } .loadApiFromJvmClasses() - inputJar.isPresent -> - JarFile(inputJar.get().asFile) - .loadApiFromJvmClasses() else -> - error("Unreachable") + throw GradleException("KotlinApiBuildTask should have either inputClassesDirs, or inputJar property set") } From f6d835c052beeff9f6193955a3493817c83da12e Mon Sep 17 00:00:00 2001 From: Ilya Gorbunov Date: Tue, 18 Oct 2022 22:26:05 +0200 Subject: [PATCH 4/5] Test inputJar property overriding inputClassesDirs --- .../kotlinx/validation/test/InputJarTest.kt | 39 +++++++++++++++++++ .../classes/PropertiesJarTransformed.dump | 8 ++++ .../jarAsInput/inputJar.gradle.kts | 9 +++++ 3 files changed, 56 insertions(+) create mode 100644 src/functionalTest/kotlin/kotlinx/validation/test/InputJarTest.kt create mode 100644 src/functionalTest/resources/examples/classes/PropertiesJarTransformed.dump create mode 100644 src/functionalTest/resources/examples/gradle/configuration/jarAsInput/inputJar.gradle.kts 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 00000000..61c03c42 --- /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 00000000..dc02831c --- /dev/null +++ b/src/functionalTest/resources/examples/classes/PropertiesJarTransformed.dump @@ -0,0 +1,8 @@ +public final class foo/ClassWithProperties { + public fun ()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 00000000..c96da59e --- /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 From a548393406973a302a40f6054ab8bbab100ef416 Mon Sep 17 00:00:00 2001 From: Ilya Gorbunov Date: Tue, 18 Oct 2022 22:40:17 +0200 Subject: [PATCH 5/5] Document inputJar feature in README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 93ea405d..aa41c338 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: