-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
license-gather: add license compatibility validation task
- Loading branch information
Showing
10 changed files
with
777 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
...ather-plugin/src/main/kotlin/com/github/vlsi/gradle/license/LicenseCompatibilityConfig.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
/* | ||
* Copyright 2019 Vladimir Sitnikov <sitnikov.vladimir@gmail.com> | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
package com.github.vlsi.gradle.license | ||
|
||
interface LicenseCompatibilityConfig { | ||
/** | ||
* Clarifies the reason for [LicenseCompatibility] so the output of | ||
* [VerifyLicenseCompatibilityTask] is easier to understand. | ||
*/ | ||
fun because(reason: String) | ||
} |
127 changes: 127 additions & 0 deletions
127
...-plugin/src/main/kotlin/com/github/vlsi/gradle/license/LicenseCompatibilityInterpreter.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
/* | ||
* Copyright 2019 Vladimir Sitnikov <sitnikov.vladimir@gmail.com> | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
package com.github.vlsi.gradle.license | ||
|
||
import com.github.vlsi.gradle.license.CompatibilityResult.ALLOW | ||
import com.github.vlsi.gradle.license.CompatibilityResult.REJECT | ||
import com.github.vlsi.gradle.license.CompatibilityResult.UNKNOWN | ||
import com.github.vlsi.gradle.license.api.ConjunctionLicenseExpression | ||
import com.github.vlsi.gradle.license.api.DisjunctionLicenseExpression | ||
import com.github.vlsi.gradle.license.api.LicenseEquivalence | ||
import com.github.vlsi.gradle.license.api.LicenseExpression | ||
import com.github.vlsi.gradle.license.api.disjunctions | ||
|
||
enum class CompatibilityResult { | ||
ALLOW, UNKNOWN, REJECT; | ||
} | ||
|
||
data class LicenseCompatibility( | ||
val type: CompatibilityResult, val reason: String | ||
) : java.io.Serializable | ||
|
||
internal data class ResolvedLicenseCompatibility( | ||
val type: CompatibilityResult, val reasons: List<String> | ||
) : Comparable<ResolvedLicenseCompatibility> { | ||
constructor(type: CompatibilityResult, vararg reasons: String) : this(type, reasons.toList()) | ||
|
||
override fun compareTo(other: ResolvedLicenseCompatibility) = | ||
compareValuesBy(this, other, { it.type }, { it.reasons.toString() }) | ||
} | ||
|
||
internal fun LicenseCompatibility.asResolved(license: LicenseExpression) = | ||
ResolvedLicenseCompatibility( | ||
type, | ||
if (reason.isEmpty()) "$license: $type" else "$license: $reason" | ||
) | ||
|
||
internal class LicenseCompatibilityInterpreter( | ||
private val licenseEquivalence: LicenseEquivalence, | ||
private val resolvedCases: Map<LicenseExpression, LicenseCompatibility> | ||
) { | ||
val resolvedParts = resolvedCases.asSequence().flatMap { (license, _) -> | ||
licenseEquivalence.expand(license).disjunctions().asSequence().map { it to license } | ||
}.groupingBy { it.first }.aggregate { key, acc: LicenseExpression?, element, first -> | ||
if (first) { | ||
element.second | ||
} else { | ||
throw IllegalArgumentException( | ||
"License $key participates in multiple resolved cases: $acc and ${element.second}. " + "Please make sure resolvedCases do not intersect" | ||
) | ||
} | ||
} | ||
|
||
override fun toString() = "LicenseCompatibilityInterpreter(resolved=$resolvedCases)" | ||
|
||
fun eval(licenseExpression: LicenseExpression?): ResolvedLicenseCompatibility { | ||
if (licenseExpression == null) { | ||
return ResolvedLicenseCompatibility(REJECT, listOf("License is null")) | ||
} | ||
// If the case is already resolved, just return the resolution | ||
resolvedCases[licenseExpression]?.let { return it.asResolved(licenseExpression) } | ||
|
||
// Expand the license (e.g. expand OR_LATER into OR ... OR) | ||
val e = licenseEquivalence.expand(licenseExpression) | ||
|
||
return when (e) { | ||
is DisjunctionLicenseExpression -> | ||
// A or X => A | ||
e.unordered.takeIf { it.isNotEmpty() }?.map { eval(it) }?.reduce { a, b -> | ||
when { | ||
a.type == b.type -> ResolvedLicenseCompatibility( | ||
a.type, | ||
a.reasons + b.reasons | ||
) | ||
// allow OR (unknown | reject) -> allow | ||
a.type == ALLOW -> a | ||
b.type == ALLOW -> b | ||
// reject OR unknown -> unknown | ||
else -> ResolvedLicenseCompatibility( | ||
UNKNOWN, | ||
a.reasons.map { "${a.type}: $it" } + b.reasons.map { "${b.type}: $it" } | ||
) | ||
} | ||
} | ||
is ConjunctionLicenseExpression -> e.unordered.takeIf { it.isNotEmpty() } | ||
?.map { eval(it) }?.reduce { a, b -> | ||
when { | ||
a.type == b.type -> ResolvedLicenseCompatibility( | ||
a.type, | ||
a.reasons + b.reasons | ||
) | ||
// allow OR next=(unknown | reject) -> next | ||
a.type == ALLOW -> b | ||
b.type == ALLOW -> a | ||
// reject OR unknown -> reject | ||
else -> ResolvedLicenseCompatibility( | ||
REJECT, | ||
a.reasons.map { "${a.type}: $it" } + b.reasons.map { "${b.type}: $it" } | ||
) | ||
} | ||
} | ||
else -> resolvedParts[e]?.let { resolved -> | ||
resolvedCases.getValue(resolved).let { | ||
if (e == resolved) { | ||
it.asResolved(resolved) | ||
} else { | ||
it.asResolved(e) | ||
} | ||
} | ||
} | ||
} ?: ResolvedLicenseCompatibility(UNKNOWN, listOf("No rules found for $licenseExpression")) | ||
} | ||
} |
216 changes: 216 additions & 0 deletions
216
...r-plugin/src/main/kotlin/com/github/vlsi/gradle/license/VerifyLicenseCompatibilityTask.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
/* | ||
* Copyright 2019 Vladimir Sitnikov <sitnikov.vladimir@gmail.com> | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
package com.github.vlsi.gradle.license | ||
|
||
import com.github.vlsi.gradle.license.api.License | ||
import com.github.vlsi.gradle.license.api.LicenseEquivalence | ||
import com.github.vlsi.gradle.license.api.LicenseExpression | ||
import com.github.vlsi.gradle.license.api.asExpression | ||
import org.gradle.api.Action | ||
import org.gradle.api.DefaultTask | ||
import org.gradle.api.GradleException | ||
import org.gradle.api.artifacts.component.ModuleComponentIdentifier | ||
import org.gradle.api.file.ProjectLayout | ||
import org.gradle.api.logging.LogLevel | ||
import org.gradle.api.model.ObjectFactory | ||
import org.gradle.api.tasks.Console | ||
import org.gradle.api.tasks.Input | ||
import org.gradle.api.tasks.InputFiles | ||
import org.gradle.api.tasks.OutputFile | ||
import org.gradle.api.tasks.TaskAction | ||
import org.gradle.api.tasks.options.Option | ||
import org.gradle.kotlin.dsl.invoke | ||
import org.gradle.kotlin.dsl.mapProperty | ||
import org.gradle.kotlin.dsl.property | ||
import java.util.* | ||
import javax.inject.Inject | ||
|
||
open class VerifyLicenseCompatibilityTask @Inject constructor( | ||
objectFactory: ObjectFactory, | ||
layout: ProjectLayout | ||
) : DefaultTask() { | ||
@InputFiles | ||
val metadata = objectFactory.fileCollection() | ||
|
||
@Input | ||
val failOnIncompatibleLicense = objectFactory.property<Boolean>().convention(true) | ||
|
||
@Input | ||
val resolvedCases = objectFactory.mapProperty<LicenseExpression, LicenseCompatibility>() | ||
|
||
@Input | ||
val licenseSimilarityNormalizationThreshold = | ||
objectFactory.property<Int>().convention(42) | ||
|
||
@Option(option = "print", description = "prints the verification results to console") | ||
@Console | ||
val printResults = objectFactory.property<Boolean>().convention(false) | ||
|
||
/** | ||
* Outputs the license verification results (incompatible and unknown licenses are listed first). | ||
*/ | ||
@OutputFile | ||
val outputFile = objectFactory.fileProperty() | ||
.convention(layout.buildDirectory.file("verifyLicense/$name/verification_result.txt")) | ||
|
||
private fun registerResolution( | ||
licenseExpression: LicenseExpression, | ||
type: CompatibilityResult, | ||
action: Action<LicenseCompatibilityConfig>? = null | ||
) { | ||
val reason = action?.let { | ||
object : LicenseCompatibilityConfig { | ||
var reason = "" | ||
override fun because(reason: String) { | ||
this.reason = reason | ||
} | ||
}.let { | ||
action.invoke(it) | ||
it.reason | ||
} | ||
} ?: "" | ||
resolvedCases.put(licenseExpression, LicenseCompatibility(type, reason)) | ||
} | ||
|
||
@JvmOverloads | ||
fun allow(license: License, action: Action<LicenseCompatibilityConfig>? = null) { | ||
allow(license.asExpression(), action) | ||
} | ||
|
||
@JvmOverloads | ||
fun allow( | ||
licenseExpression: LicenseExpression, | ||
action: Action<LicenseCompatibilityConfig>? = null | ||
) { | ||
registerResolution(licenseExpression, CompatibilityResult.ALLOW, action) | ||
} | ||
|
||
@JvmOverloads | ||
fun reject(license: License, action: Action<LicenseCompatibilityConfig>? = null) { | ||
reject(license.asExpression(), action) | ||
} | ||
|
||
@JvmOverloads | ||
fun reject( | ||
licenseExpression: LicenseExpression, | ||
action: Action<LicenseCompatibilityConfig>? = null | ||
) { | ||
registerResolution(licenseExpression, CompatibilityResult.REJECT, action) | ||
} | ||
|
||
@JvmOverloads | ||
fun unknown(license: License, action: Action<LicenseCompatibilityConfig>? = null) { | ||
unknown(license.asExpression(), action) | ||
} | ||
|
||
@JvmOverloads | ||
fun unknown( | ||
licenseExpression: LicenseExpression, | ||
action: Action<LicenseCompatibilityConfig>? = null | ||
) { | ||
registerResolution(licenseExpression, CompatibilityResult.UNKNOWN, action) | ||
} | ||
|
||
@TaskAction | ||
fun run() { | ||
val dependencies = MetadataStore.load(metadata).dependencies | ||
|
||
val licenseNormalizer = GuessBasedNormalizer( | ||
logger, licenseSimilarityNormalizationThreshold.get().toDouble() | ||
) | ||
val licenseCompatibilityInterpreter = LicenseCompatibilityInterpreter( | ||
// TODO: make it configurable | ||
LicenseEquivalence(), | ||
resolvedCases.get().mapKeys { | ||
licenseNormalizer.normalize(it.key) | ||
} | ||
) | ||
|
||
val ok = StringBuilder() | ||
val ko = StringBuilder() | ||
|
||
dependencies | ||
.asSequence() | ||
.map { (component, licenseInfo) -> component to licenseInfo.license } | ||
.groupByTo(TreeMap()) { (component, license) -> | ||
licenseCompatibilityInterpreter.eval(license).also { | ||
logger.log( | ||
when (it.type) { | ||
CompatibilityResult.ALLOW -> LogLevel.DEBUG | ||
CompatibilityResult.UNKNOWN -> LogLevel.LIFECYCLE | ||
CompatibilityResult.REJECT -> LogLevel.LIFECYCLE | ||
}, | ||
"License compatibility for {}: {} -> {}", component, license, it | ||
) | ||
} | ||
} | ||
.forEach { (licenseCompatibility, components) -> | ||
val header = | ||
"${licenseCompatibility.type}: ${licenseCompatibility.reasons.joinToString(", ")}" | ||
val sb = if (licenseCompatibility.type == CompatibilityResult.ALLOW) ok else ko | ||
if (sb.isNotEmpty()) { | ||
sb.append('\n') | ||
} | ||
sb.append(header).append('\n') | ||
sb.append("=".repeat(header.length)).append('\n') | ||
sb.appendComponents(components) | ||
} | ||
|
||
val errorMessage = ko.toString() | ||
val result = ko.apply { | ||
if (isNotEmpty() && ok.isNotEmpty()) { | ||
append('\n') | ||
} | ||
append(ok) | ||
while (endsWith('\n')) { | ||
setLength(length - 1) | ||
} | ||
}.toString() | ||
|
||
if (printResults.get()) { | ||
println(result) | ||
} | ||
|
||
outputFile.get().asFile.apply { | ||
parentFile.mkdirs() | ||
writeText(result) | ||
} | ||
|
||
if (errorMessage.isNotEmpty()) { | ||
if (failOnIncompatibleLicense.get()) { | ||
throw GradleException(errorMessage) | ||
} else { | ||
logger.warn(errorMessage) | ||
} | ||
} | ||
} | ||
|
||
private fun Appendable.appendComponents( | ||
components: List<Pair<ModuleComponentIdentifier, LicenseExpression?>> | ||
) = | ||
components | ||
.groupByTo(TreeMap(nullsFirst(LicenseExpression.NATURAL_ORDER)), | ||
{ it.second }, { it.first }) | ||
.forEach { (license, components) -> | ||
append('\n') | ||
append(license?.toString() ?: "Unknown license").append('\n') | ||
components.forEach { | ||
append("* ${it.displayName}\n") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.