Skip to content

Commit c09f0a9

Browse files
committed
license-gather: add license compatibility validation task
1 parent 8ed8f3e commit c09f0a9

File tree

10 files changed

+777
-15
lines changed

10 files changed

+777
-15
lines changed

plugins/license-gather-plugin/src/main/kotlin/com/github/vlsi/gradle/license/GatherLicenseTask.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ open class GatherLicenseTask @Inject constructor(
9696
objectFactory: ObjectFactory,
9797
private val workerExecutor: WorkerExecutor
9898
) : DefaultTask() {
99+
init {
100+
// TODO: capture [licenseOverrides] as input
101+
outputs.upToDateWhen { false }
102+
}
103+
99104
@InputFiles
100105
val configurations = objectFactory.setProperty<Configuration>()
101106

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2019 Vladimir Sitnikov <[email protected]>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
package com.github.vlsi.gradle.license
19+
20+
interface LicenseCompatibilityConfig {
21+
/**
22+
* Clarifies the reason for [LicenseCompatibility] so the output of
23+
* [VerifyLicenseCompatibilityTask] is easier to understand.
24+
*/
25+
fun because(reason: String)
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2019 Vladimir Sitnikov <[email protected]>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
package com.github.vlsi.gradle.license
19+
20+
import com.github.vlsi.gradle.license.CompatibilityResult.ALLOW
21+
import com.github.vlsi.gradle.license.CompatibilityResult.REJECT
22+
import com.github.vlsi.gradle.license.CompatibilityResult.UNKNOWN
23+
import com.github.vlsi.gradle.license.api.ConjunctionLicenseExpression
24+
import com.github.vlsi.gradle.license.api.DisjunctionLicenseExpression
25+
import com.github.vlsi.gradle.license.api.LicenseEquivalence
26+
import com.github.vlsi.gradle.license.api.LicenseExpression
27+
import com.github.vlsi.gradle.license.api.disjunctions
28+
29+
enum class CompatibilityResult {
30+
ALLOW, UNKNOWN, REJECT;
31+
}
32+
33+
data class LicenseCompatibility(
34+
val type: CompatibilityResult, val reason: String
35+
) : java.io.Serializable
36+
37+
internal data class ResolvedLicenseCompatibility(
38+
val type: CompatibilityResult, val reasons: List<String>
39+
) : Comparable<ResolvedLicenseCompatibility> {
40+
constructor(type: CompatibilityResult, vararg reasons: String) : this(type, reasons.toList())
41+
42+
override fun compareTo(other: ResolvedLicenseCompatibility) =
43+
compareValuesBy(this, other, { it.type }, { it.reasons.toString() })
44+
}
45+
46+
internal fun LicenseCompatibility.asResolved(license: LicenseExpression) =
47+
ResolvedLicenseCompatibility(
48+
type,
49+
if (reason.isEmpty()) "$license: $type" else "$license: $reason"
50+
)
51+
52+
internal class LicenseCompatibilityInterpreter(
53+
private val licenseEquivalence: LicenseEquivalence,
54+
private val resolvedCases: Map<LicenseExpression, LicenseCompatibility>
55+
) {
56+
val resolvedParts = resolvedCases.asSequence().flatMap { (license, _) ->
57+
licenseEquivalence.expand(license).disjunctions().asSequence().map { it to license }
58+
}.groupingBy { it.first }.aggregate { key, acc: LicenseExpression?, element, first ->
59+
if (first) {
60+
element.second
61+
} else {
62+
throw IllegalArgumentException(
63+
"License $key participates in multiple resolved cases: $acc and ${element.second}. " + "Please make sure resolvedCases do not intersect"
64+
)
65+
}
66+
}
67+
68+
override fun toString() = "LicenseCompatibilityInterpreter(resolved=$resolvedCases)"
69+
70+
fun eval(licenseExpression: LicenseExpression?): ResolvedLicenseCompatibility {
71+
if (licenseExpression == null) {
72+
return ResolvedLicenseCompatibility(REJECT, listOf("License is null"))
73+
}
74+
// If the case is already resolved, just return the resolution
75+
resolvedCases[licenseExpression]?.let { return it.asResolved(licenseExpression) }
76+
77+
// Expand the license (e.g. expand OR_LATER into OR ... OR)
78+
val e = licenseEquivalence.expand(licenseExpression)
79+
80+
return when (e) {
81+
is DisjunctionLicenseExpression ->
82+
// A or X => A
83+
e.unordered.takeIf { it.isNotEmpty() }?.map { eval(it) }?.reduce { a, b ->
84+
when {
85+
a.type == b.type -> ResolvedLicenseCompatibility(
86+
a.type,
87+
a.reasons + b.reasons
88+
)
89+
// allow OR (unknown | reject) -> allow
90+
a.type == ALLOW -> a
91+
b.type == ALLOW -> b
92+
// reject OR unknown -> unknown
93+
else -> ResolvedLicenseCompatibility(
94+
UNKNOWN,
95+
a.reasons.map { "${a.type}: $it" } + b.reasons.map { "${b.type}: $it" }
96+
)
97+
}
98+
}
99+
is ConjunctionLicenseExpression -> e.unordered.takeIf { it.isNotEmpty() }
100+
?.map { eval(it) }?.reduce { a, b ->
101+
when {
102+
a.type == b.type -> ResolvedLicenseCompatibility(
103+
a.type,
104+
a.reasons + b.reasons
105+
)
106+
// allow OR next=(unknown | reject) -> next
107+
a.type == ALLOW -> b
108+
b.type == ALLOW -> a
109+
// reject OR unknown -> reject
110+
else -> ResolvedLicenseCompatibility(
111+
REJECT,
112+
a.reasons.map { "${a.type}: $it" } + b.reasons.map { "${b.type}: $it" }
113+
)
114+
}
115+
}
116+
else -> resolvedParts[e]?.let { resolved ->
117+
resolvedCases.getValue(resolved).let {
118+
if (e == resolved) {
119+
it.asResolved(resolved)
120+
} else {
121+
it.asResolved(e)
122+
}
123+
}
124+
}
125+
} ?: ResolvedLicenseCompatibility(UNKNOWN, listOf("No rules found for $licenseExpression"))
126+
}
127+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
* Copyright 2019 Vladimir Sitnikov <[email protected]>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
package com.github.vlsi.gradle.license
19+
20+
import com.github.vlsi.gradle.license.api.License
21+
import com.github.vlsi.gradle.license.api.LicenseEquivalence
22+
import com.github.vlsi.gradle.license.api.LicenseExpression
23+
import com.github.vlsi.gradle.license.api.asExpression
24+
import org.gradle.api.Action
25+
import org.gradle.api.DefaultTask
26+
import org.gradle.api.GradleException
27+
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
28+
import org.gradle.api.file.ProjectLayout
29+
import org.gradle.api.logging.LogLevel
30+
import org.gradle.api.model.ObjectFactory
31+
import org.gradle.api.tasks.Console
32+
import org.gradle.api.tasks.Input
33+
import org.gradle.api.tasks.InputFiles
34+
import org.gradle.api.tasks.OutputFile
35+
import org.gradle.api.tasks.TaskAction
36+
import org.gradle.api.tasks.options.Option
37+
import org.gradle.kotlin.dsl.invoke
38+
import org.gradle.kotlin.dsl.mapProperty
39+
import org.gradle.kotlin.dsl.property
40+
import java.util.*
41+
import javax.inject.Inject
42+
43+
open class VerifyLicenseCompatibilityTask @Inject constructor(
44+
objectFactory: ObjectFactory,
45+
layout: ProjectLayout
46+
) : DefaultTask() {
47+
@InputFiles
48+
val metadata = objectFactory.fileCollection()
49+
50+
@Input
51+
val failOnIncompatibleLicense = objectFactory.property<Boolean>().convention(true)
52+
53+
@Input
54+
val resolvedCases = objectFactory.mapProperty<LicenseExpression, LicenseCompatibility>()
55+
56+
@Input
57+
val licenseSimilarityNormalizationThreshold =
58+
objectFactory.property<Int>().convention(42)
59+
60+
@Option(option = "print", description = "prints the verification results to console")
61+
@Console
62+
val printResults = objectFactory.property<Boolean>().convention(false)
63+
64+
/**
65+
* Outputs the license verification results (incompatible and unknown licenses are listed first).
66+
*/
67+
@OutputFile
68+
val outputFile = objectFactory.fileProperty()
69+
.convention(layout.buildDirectory.file("verifyLicense/$name/verification_result.txt"))
70+
71+
private fun registerResolution(
72+
licenseExpression: LicenseExpression,
73+
type: CompatibilityResult,
74+
action: Action<LicenseCompatibilityConfig>? = null
75+
) {
76+
val reason = action?.let {
77+
object : LicenseCompatibilityConfig {
78+
var reason = ""
79+
override fun because(reason: String) {
80+
this.reason = reason
81+
}
82+
}.let {
83+
action.invoke(it)
84+
it.reason
85+
}
86+
} ?: ""
87+
resolvedCases.put(licenseExpression, LicenseCompatibility(type, reason))
88+
}
89+
90+
@JvmOverloads
91+
fun allow(license: License, action: Action<LicenseCompatibilityConfig>? = null) {
92+
allow(license.asExpression(), action)
93+
}
94+
95+
@JvmOverloads
96+
fun allow(
97+
licenseExpression: LicenseExpression,
98+
action: Action<LicenseCompatibilityConfig>? = null
99+
) {
100+
registerResolution(licenseExpression, CompatibilityResult.ALLOW, action)
101+
}
102+
103+
@JvmOverloads
104+
fun reject(license: License, action: Action<LicenseCompatibilityConfig>? = null) {
105+
reject(license.asExpression(), action)
106+
}
107+
108+
@JvmOverloads
109+
fun reject(
110+
licenseExpression: LicenseExpression,
111+
action: Action<LicenseCompatibilityConfig>? = null
112+
) {
113+
registerResolution(licenseExpression, CompatibilityResult.REJECT, action)
114+
}
115+
116+
@JvmOverloads
117+
fun unknown(license: License, action: Action<LicenseCompatibilityConfig>? = null) {
118+
unknown(license.asExpression(), action)
119+
}
120+
121+
@JvmOverloads
122+
fun unknown(
123+
licenseExpression: LicenseExpression,
124+
action: Action<LicenseCompatibilityConfig>? = null
125+
) {
126+
registerResolution(licenseExpression, CompatibilityResult.UNKNOWN, action)
127+
}
128+
129+
@TaskAction
130+
fun run() {
131+
val dependencies = MetadataStore.load(metadata).dependencies
132+
133+
val licenseNormalizer = GuessBasedNormalizer(
134+
logger, licenseSimilarityNormalizationThreshold.get().toDouble()
135+
)
136+
val licenseCompatibilityInterpreter = LicenseCompatibilityInterpreter(
137+
// TODO: make it configurable
138+
LicenseEquivalence(),
139+
resolvedCases.get().mapKeys {
140+
licenseNormalizer.normalize(it.key)
141+
}
142+
)
143+
144+
val ok = StringBuilder()
145+
val ko = StringBuilder()
146+
147+
dependencies
148+
.asSequence()
149+
.map { (component, licenseInfo) -> component to licenseInfo.license }
150+
.groupByTo(TreeMap()) { (component, license) ->
151+
licenseCompatibilityInterpreter.eval(license).also {
152+
logger.log(
153+
when (it.type) {
154+
CompatibilityResult.ALLOW -> LogLevel.DEBUG
155+
CompatibilityResult.UNKNOWN -> LogLevel.LIFECYCLE
156+
CompatibilityResult.REJECT -> LogLevel.LIFECYCLE
157+
},
158+
"License compatibility for {}: {} -> {}", component, license, it
159+
)
160+
}
161+
}
162+
.forEach { (licenseCompatibility, components) ->
163+
val header =
164+
"${licenseCompatibility.type}: ${licenseCompatibility.reasons.joinToString(", ")}"
165+
val sb = if (licenseCompatibility.type == CompatibilityResult.ALLOW) ok else ko
166+
if (sb.isNotEmpty()) {
167+
sb.append('\n')
168+
}
169+
sb.append(header).append('\n')
170+
sb.append("=".repeat(header.length)).append('\n')
171+
sb.appendComponents(components)
172+
}
173+
174+
val errorMessage = ko.toString()
175+
val result = ko.apply {
176+
if (isNotEmpty() && ok.isNotEmpty()) {
177+
append('\n')
178+
}
179+
append(ok)
180+
while (endsWith('\n')) {
181+
setLength(length - 1)
182+
}
183+
}.toString()
184+
185+
if (printResults.get()) {
186+
println(result)
187+
}
188+
189+
outputFile.get().asFile.apply {
190+
parentFile.mkdirs()
191+
writeText(result)
192+
}
193+
194+
if (errorMessage.isNotEmpty()) {
195+
if (failOnIncompatibleLicense.get()) {
196+
throw GradleException(errorMessage)
197+
} else {
198+
logger.warn(errorMessage)
199+
}
200+
}
201+
}
202+
203+
private fun Appendable.appendComponents(
204+
components: List<Pair<ModuleComponentIdentifier, LicenseExpression?>>
205+
) =
206+
components
207+
.groupByTo(TreeMap(nullsFirst(LicenseExpression.NATURAL_ORDER)),
208+
{ it.second }, { it.first })
209+
.forEach { (license, components) ->
210+
append('\n')
211+
append(license?.toString() ?: "Unknown license").append('\n')
212+
components.forEach {
213+
append("* ${it.displayName}\n")
214+
}
215+
}
216+
}

plugins/license-gather-plugin/src/main/kotlin/com/github/vlsi/gradle/license/api/License.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ package com.github.vlsi.gradle.license.api
1919

2020
import java.net.URI
2121

22-
interface License {
22+
interface License : java.io.Serializable {
2323
val title: String
2424
val uri: List<URI>
2525
}
2626

27-
interface LicenseException {
27+
interface LicenseException : java.io.Serializable {
2828
val title: String
2929
val uri: List<URI>
3030
}

0 commit comments

Comments
 (0)