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: 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 diff --git a/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt b/src/main/kotlin/BinaryCompatibilityValidatorPlugin.kt index 7b48672c..71803002 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() } } @@ -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(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 = @@ -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..1621c65b 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? = null @get:Input - val ignoredPackages : Set get() = extension.ignoredPackages + var ignoredPackages : Set + get() = _ignoredPackages ?: extension?.ignoredPackages ?: emptySet() + set(value) { _ignoredPackages = value } + private var _nonPublicMarkes: Set? = null @get:Input - val nonPublicMarkers : Set get() = extension.nonPublicMarkers + var nonPublicMarkers : Set + get() = _nonPublicMarkes ?: extension?.nonPublicMarkers ?: emptySet() + set(value) { _nonPublicMarkes = value } + private var _ignoredClasses: Set? = null @get:Input - val ignoredClasses : Set get() = extension.ignoredClasses + var ignoredClasses : Set + 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 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