[Gradle] Improve KotlinSourceSetTreeDependsOnMismatch heuristic

Introduce "White Crow" heuristic that will report incorrect dependency
edge when there is only one source set with different Source Set Tree.

Otherwise, report all source sets from all Source Set Tree groups
to user to choose which of them are incorrect.

And cover with more tests.

^KT-47144 Verification Pending
This commit is contained in:
Anton Lakotka
2023-06-14 23:36:22 +02:00
committed by Space Team
parent 681305c8e9
commit a4705050b1
4 changed files with 224 additions and 73 deletions
@@ -331,19 +331,38 @@ object KotlinToolingDiagnostics {
) )
} }
object KotlinSourceSetTreeDependsOnMismatch : ToolingDiagnosticFactory(ERROR) { object KotlinSourceSetTreeDependsOnMismatch : ToolingDiagnosticFactory(WARNING) {
operator fun invoke(dependeeName: String, dependencyName: String) = build( operator fun invoke(dependeeName: String, dependencyName: String) = build(
""" """
KotlinSourceSets '${dependeeName}' can't depend on '${dependencyName}' Kotlin Source Set '$dependeeName' can't depend on '$dependencyName' as they are from different Source Set Trees.
as they belong to different KotlinSourceSet trees. Please remove this dependency. Please remove this dependency edge.
""".trimIndent() """.trimIndent()
) )
operator fun invoke(dependeeAName: String, dependeeBName: String, dependencyName: String) = build( operator fun invoke(dependents: Map<String, List<String>>, dependencyName: String) = build(
""" """
KotlinSourceSets '${dependeeAName}' and '${dependeeBName}' can't both depend on '${dependencyName}' Following Kotlin Source Set groups can't depend on '$dependencyName' together as they belong to different Kotlin Source Set Trees.
as they belong to different KotlinSourceSet trees. Please remove one of the dependencies. ${renderSourceSetGroups(dependents).indentLines(16)}
Please keep dependsOn edges only from one group and remove the others.
""".trimIndent() """.trimIndent()
) )
private fun renderSourceSetGroups(sourceSetGroups: Map<String, List<String>>) = buildString {
for ((sourceSetTreeName, sourceSets) in sourceSetGroups) {
appendLine("Source Sets from '$sourceSetTreeName' Tree")
appendLine(sourceSets.joinToString("\n") { " * '$it'" })
}
}
} }
} }
private fun String.indentLines(nSpaces: Int = 4, skipFirstLine: Boolean = true): String {
val spaces = String(CharArray(nSpaces) { ' ' })
return lines()
.withIndex()
.joinToString(separator = "\n") { (index, line) ->
if (skipFirstLine && index == 0) return@joinToString line
if (line.isNotBlank()) "$spaces$line" else line
}
}
@@ -11,6 +11,7 @@ import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinGradleProjectChecker
import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinGradleProjectCheckerContext import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinGradleProjectCheckerContext
import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinToolingDiagnostics.KotlinSourceSetTreeDependsOnMismatch import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinToolingDiagnostics.KotlinSourceSetTreeDependsOnMismatch
import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinToolingDiagnosticsCollector import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinToolingDiagnosticsCollector
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinMetadataTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.targetHierarchy.orNull import org.jetbrains.kotlin.gradle.plugin.mpp.targetHierarchy.orNull
import org.jetbrains.kotlin.gradle.plugin.sources.android.androidSourceSetInfoOrNull import org.jetbrains.kotlin.gradle.plugin.sources.android.androidSourceSetInfoOrNull
import org.jetbrains.kotlin.gradle.plugin.sources.awaitPlatformCompilations import org.jetbrains.kotlin.gradle.plugin.sources.awaitPlatformCompilations
@@ -20,14 +21,21 @@ internal object KotlinSourceSetTreeDependsOnMismatchChecker : KotlinGradleProjec
override suspend fun KotlinGradleProjectCheckerContext.runChecks(collector: KotlinToolingDiagnosticsCollector) { override suspend fun KotlinGradleProjectCheckerContext.runChecks(collector: KotlinToolingDiagnosticsCollector) {
val sourceSets = this.multiplatformExtension val sourceSets = this.multiplatformExtension
?.awaitSourceSets() ?.awaitSourceSets()
// Ignoring Android source sets // Android source sets are excluded from verification as they can cause unexpected results
// As well as in [UnusedSourceSetsChecker]
?.filter { it.androidSourceSetInfoOrNull == null } ?.filter { it.androidSourceSetInfoOrNull == null }
?: return ?: return
// A "good" source set is part of only single Source Set Tree // A "good" source set is part of only single Source Set Tree
val goodSourceSets = mutableMapOf<KotlinSourceSet, SourceSetTree?>() val goodSourceSets = mutableMapOf<KotlinSourceSet, SourceSetTree?>()
// A "bad" source set is part of >=2 Source Set Trees // A "bad" source set is part of >=2 Source Set Trees
val badSourceSets = mutableMapOf<KotlinSourceSet, List<SourceSetTree?>>() val badSourceSets = mutableMapOf<KotlinSourceSet, Set<SourceSetTree?>>()
// A "leaf" source set is a source set with known Source Set Tree by default
val leafSourceSets = multiplatformExtension
.awaitTargets()
.filter { it !is KotlinMetadataTarget }
.flatMap { target -> target.compilations.map { it.defaultSourceSet to SourceSetTree.orNull(it) } }
.toMap()
val reverseSourceSetDependencies = mutableMapOf<KotlinSourceSet, MutableSet<KotlinSourceSet>>() val reverseSourceSetDependencies = mutableMapOf<KotlinSourceSet, MutableSet<KotlinSourceSet>>()
fun KotlinSourceSet.addReverseDependencyTo(that: KotlinSourceSet) = fun KotlinSourceSet.addReverseDependencyTo(that: KotlinSourceSet) =
@@ -37,7 +45,7 @@ internal object KotlinSourceSetTreeDependsOnMismatchChecker : KotlinGradleProjec
sourceSet.dependsOn.forEach { it.addReverseDependencyTo(sourceSet) } sourceSet.dependsOn.forEach { it.addReverseDependencyTo(sourceSet) }
val platformCompilations = sourceSet.internal.awaitPlatformCompilations() val platformCompilations = sourceSet.internal.awaitPlatformCompilations()
val distinctSourceSetTrees = platformCompilations.map { SourceSetTree.orNull(it) }.distinct() val distinctSourceSetTrees = platformCompilations.map { SourceSetTree.orNull(it) }.toSet()
val totalDistinctSourceSetTrees = distinctSourceSetTrees.size val totalDistinctSourceSetTrees = distinctSourceSetTrees.size
@@ -51,40 +59,82 @@ internal object KotlinSourceSetTreeDependsOnMismatchChecker : KotlinGradleProjec
} }
} }
for ((badSourceSet, sourceSetTrees) in badSourceSets) { for ((badSourceSet, _) in badSourceSets) {
val dependents = reverseSourceSetDependencies[badSourceSet].orEmpty() val dependents = reverseSourceSetDependencies[badSourceSet].orEmpty()
// check if any of its dependents is also a bad source set then skip it // check if any of [badSourceSet] dependents is also a bad source set then skip it
// For example if nativeTest depends on nativeMain, // For example if nativeTest depends on nativeMain,
// then transitively commonMain would have incorrect source set tree as well, but it is not a root cause. // then transitively commonMain would have incorrect source set tree as well, but it is not a root cause.
// NB: user can still add commonTest to commonMain dependency directly but this case will not be reported // NB: user can still add commonTest to commonMain dependency directly but this case will not be reported
// until "dependent source sets relations is fixed // until underlying dependent source sets relations are fixed
if (dependents.any { it in badSourceSets }) continue if (dependents.any { it in badSourceSets }) continue
// Heuristic: pick two source sets with different SourceSetTree // If [badSourceSet] is also a leaf source set then all its dependents edges are incorrect
// Theoretically there could be a lot of invalid pairs, // Therefore report everything that depend on the leaf source set (i.e. iosX64Test -> iosX64Main)
// but it should be enough to report only 1 of them as it should cover the majority of cases. // NB: Cyclic diagnostics such as iosX64Main -> commonMain -> iosX64Main is handled in [AbstractKotlinSourceSet::dependsOn]
// NB: it is safe to do destruction on two elements as per definition of "bad" source set. if (badSourceSet in leafSourceSets) {
val (sourceSetTreeA, sourceSetTreeB) = sourceSetTrees dependents.forEach { collector.report(project, KotlinSourceSetTreeDependsOnMismatch(it.name, badSourceSet.name)) }
val sourceSetA = dependents.firstOrNull { it in goodSourceSets && goodSourceSets[it] == sourceSetTreeA } continue
val sourceSetB = dependents.firstOrNull { it in goodSourceSets && goodSourceSets[it] == sourceSetTreeB }
if (sourceSetA == null || sourceSetB == null) {
val goodSourceSet = sourceSetA ?: sourceSetB
if (goodSourceSet == null) {
project.logger.warn("Can't identify why kotlin source set '${badSourceSet.name}' has incorrect dependsOn relation")
} else {
collector.report(
project,
KotlinSourceSetTreeDependsOnMismatch(goodSourceSet.name, badSourceSet.name)
)
}
} else {
collector.report(
project,
KotlinSourceSetTreeDependsOnMismatch(sourceSetA.name, sourceSetB.name, badSourceSet.name)
)
} }
// Heuristic "White Crow": If among dependents there is only one source set with different Source Set Tree then report it
// i.e.
// commonMain
// |
// +-----------+------------+
// | | |
// (jvmMain) (nativeMain) (nativeTest!?)
// jvmMain and nativeMain make a group of 'main' Source Set Tree,
// but nativeTest is the only one from group of 'test' Source Set Tree
// therefore dependency from nativeTest to commonMain is incorrect
// A bad scenario for this heuristic is when in Bamboo Source Set Structure (source set with single dependent)
// user adds two or more depends on edges from other Source Set Tree.
// i.e.
// commonMain
// |
// +-----------+---------------+
// | | |
// (nativeMain) (appleTest!?) (nativeTest!?)
// In this case dependency edge from nativeMain will be considered incorrect
val dependentsBySourceSetTree = dependents.groupBy {
when (it) {
in goodSourceSets -> goodSourceSets[it]
in leafSourceSets -> leafSourceSets[it]
else -> null
}
}
if (reportSingleSourceSetWithDifferentSourceSetTree(collector, badSourceSet, dependentsBySourceSetTree)) continue
// If there are more than one Source Sets with different Source Set Trees then we can't
// identify which group is incorrect, therefore we should report all of them
reportAllIncorrectSourceSetEdges(collector, badSourceSet, dependentsBySourceSetTree)
} }
} }
private fun KotlinGradleProjectCheckerContext.reportSingleSourceSetWithDifferentSourceSetTree(
collector: KotlinToolingDiagnosticsCollector,
badSourceSet: KotlinSourceSet,
dependentsBySourceSetTree: Map<SourceSetTree?, List<KotlinSourceSet>>
): Boolean {
val singleDependee = dependentsBySourceSetTree
.values
.singleOrNull { it.size == 1 }
?.single()
?: return false
collector.report(project, KotlinSourceSetTreeDependsOnMismatch(singleDependee.name, badSourceSet.name))
return true
}
private fun KotlinGradleProjectCheckerContext.reportAllIncorrectSourceSetEdges(
collector: KotlinToolingDiagnosticsCollector,
badSourceSet: KotlinSourceSet,
dependentsBySourceSetTree: Map<SourceSetTree?, List<KotlinSourceSet>>,
) {
val dependentsGroup = dependentsBySourceSetTree
.mapKeys { it.key?.name ?: "null" }
.mapValues { it.value.map(KotlinSourceSet::getName) }
collector.report(project, KotlinSourceSetTreeDependsOnMismatch(dependentsGroup, badSourceSet.name))
}
} }
@@ -18,11 +18,10 @@ class KotlinSourceSetTreeDependsOnMismatchTest {
val project = buildProjectWithMPP { val project = buildProjectWithMPP {
kotlin { kotlin {
targetHierarchy.default() targetHierarchy.default()
androidTarget()
project.androidApplication { compileSdk = 32 } project.androidApplication { compileSdk = 32 }
iosX64(); iosArm64(); iosSimulatorArm64() iosX64(); iosArm64(); iosSimulatorArm64()
watchosArm64(); watchosX64(); watchosSimulatorArm64()
macosX64(); macosArm64() macosX64(); macosArm64()
linuxX64(); linuxArm64()
configure() configure()
} }
@@ -31,24 +30,13 @@ class KotlinSourceSetTreeDependsOnMismatchTest {
return project.kotlinToolingDiagnosticsCollector.getDiagnosticsForProject(project).toList() return project.kotlinToolingDiagnosticsCollector.getDiagnosticsForProject(project).toList()
} }
private fun checkBadSourceSetDependency( private fun checkSingleBadSourceSetDependency(
correctDependent: String, dependent: String,
incorrectDependent: String, dependency: String,
dependency: String expectedDiagnostic: ToolingDiagnostic = KotlinSourceSetTreeDependsOnMismatch(dependent, dependency)
) = checkDiagnostics { ) = checkDiagnostics {
sourceSets.getByName(incorrectDependent).dependsOn(sourceSets.getByName(dependency)) sourceSets.getByName(dependent).dependsOn(sourceSets.getByName(dependency))
}.assertContainsDiagnostic( }.assertContainsDiagnostic(expectedDiagnostic)
KotlinSourceSetTreeDependsOnMismatch(correctDependent, incorrectDependent, dependency)
)
private fun checkBadSourceSetDependency(
incorrectDependent: String,
dependency: String
) = checkDiagnostics {
sourceSets.getByName(incorrectDependent).dependsOn(sourceSets.getByName(dependency))
}.assertContainsDiagnostic(
KotlinSourceSetTreeDependsOnMismatch(incorrectDependent, dependency)
)
@Test @Test
fun `no diagnostics should be reported for correctly configured project`() { fun `no diagnostics should be reported for correctly configured project`() {
@@ -58,36 +46,108 @@ class KotlinSourceSetTreeDependsOnMismatchTest {
} }
@Test @Test
fun `commonTest cant depend on commonMain`() = checkBadSourceSetDependency( fun `commonTest cant depend on commonMain`() = checkSingleBadSourceSetDependency(
correctDependent = "nativeMain", dependent = "commonTest",
incorrectDependent = "commonTest", dependency = "commonMain",
dependency = "commonMain" // Since for given test project commonMain have only one dependent (nativeMain) it is
// impossible to figure out wrong dependency via "white crow" heuristic
expectedDiagnostic = KotlinSourceSetTreeDependsOnMismatch(
dependents = mapOf(
"test" to listOf("commonTest"),
"main" to listOf("nativeMain"),
),
dependencyName = "commonMain"
)
) )
@Test @Test
fun `commonMain cant depend on commonTest`() = checkBadSourceSetDependency( fun `commonMain cant depend on commonTest`() = checkSingleBadSourceSetDependency(
correctDependent = "nativeTest", dependent = "commonMain",
incorrectDependent = "commonMain", dependency = "commonTest",
dependency = "commonTest" expectedDiagnostic = KotlinSourceSetTreeDependsOnMismatch(
dependents = mapOf(
"main" to listOf("commonMain"),
"test" to listOf("nativeTest"),
),
dependencyName = "commonTest"
)
) )
@Test @Test
fun `appleTest cant depend on appleMain`() = checkBadSourceSetDependency( fun `appleTest cant depend on appleMain`() = checkSingleBadSourceSetDependency(
correctDependent = "iosMain", dependent = "appleTest",
incorrectDependent = "appleTest",
dependency = "appleMain" dependency = "appleMain"
) )
@Test @Test
fun `iosX64Test cant depend on iosX64Main`() = checkBadSourceSetDependency( fun `appleMain cant depend on appleTest`() = checkSingleBadSourceSetDependency(
incorrectDependent = "iosX64Test", dependent = "appleMain",
dependency = "iosX64Main", dependency = "appleTest"
) )
@Test @Test
fun `iosX64Test cant depend on commonMain`() = checkBadSourceSetDependency( fun `iosTest cant depend on iosMain`() = checkSingleBadSourceSetDependency(
correctDependent = "nativeMain", dependent = "iosTest",
incorrectDependent = "iosX64Test", dependency = "iosMain"
dependency = "commonMain", )
@Test
fun `iosMain cant depend on iosTest`() = checkSingleBadSourceSetDependency(
dependent = "iosMain",
dependency = "iosTest"
)
@Test
fun `iosX64Test cant depend on iosX64Main`() = checkSingleBadSourceSetDependency(
dependent = "iosX64Test",
dependency = "iosX64Main"
)
@Test
fun `iosX64Main cant depend on iosX64Test`() = checkSingleBadSourceSetDependency(
dependent = "iosX64Main",
dependency = "iosX64Test"
)
@Test
fun `iosX64Test and iosArm64Test cant depend on iosMain`() = checkDiagnostics {
sourceSets.getByName("iosX64Test").dependsOn(sourceSets.getByName("iosMain"))
sourceSets.getByName("iosArm64Test").dependsOn(sourceSets.getByName("iosMain"))
}.assertContainsDiagnostic(
KotlinSourceSetTreeDependsOnMismatch(
dependents = mapOf(
"main" to listOf("iosArm64Main", "iosSimulatorArm64Main", "iosX64Main"),
"test" to listOf("iosArm64Test", "iosX64Test")
),
dependencyName = "iosMain"
)
)
@Test
fun `test that only lowest source set edges are reported`() = checkDiagnostics {
sourceSets.getByName("iosX64Test").dependsOn(sourceSets.getByName("iosMain"))
sourceSets.getByName("iosArm64Test").dependsOn(sourceSets.getByName("nativeMain"))
sourceSets.getByName("iosSimulatorArm64Main").dependsOn(sourceSets.getByName("commonMain"))
}.assertDiagnostics(
// Expected only one diagnostic since "nativeMain" and "commonMain" is a "bad source sets" because
// iosMain depends on all of them transitively and thus coloring them as "bad source sets" as well.
// Thus, only the lowest "bad source set" should be reported
KotlinSourceSetTreeDependsOnMismatch(dependeeName = "iosX64Test", dependencyName = "iosMain")
)
@Test
fun `test that few incorrect source set dependencies can be reported`() = checkDiagnostics {
sourceSets.getByName("iosX64Test").dependsOn(sourceSets.getByName("iosMain"))
sourceSets.getByName("macosX64Test").dependsOn(sourceSets.getByName("macosMain"))
sourceSets.getByName("macosArm64Test").dependsOn(sourceSets.getByName("macosMain"))
}.assertDiagnostics(
KotlinSourceSetTreeDependsOnMismatch(dependeeName = "iosX64Test", dependencyName = "iosMain"),
KotlinSourceSetTreeDependsOnMismatch(
dependents = mapOf(
"main" to listOf("macosArm64Main", "macosX64Main"),
"test" to listOf("macosArm64Test", "macosX64Test")
),
dependencyName = "macosMain"
),
) )
} }
@@ -80,6 +80,8 @@ internal fun Project.assertContainsDiagnostic(diagnostic: ToolingDiagnostic) {
kotlinToolingDiagnosticsCollector.getDiagnosticsForProject(this).assertContainsDiagnostic(diagnostic) kotlinToolingDiagnosticsCollector.getDiagnosticsForProject(this).assertContainsDiagnostic(diagnostic)
} }
private fun Any.withIndent() = this.toString().prependIndent(" ")
internal fun Collection<ToolingDiagnostic>.assertContainsDiagnostic(factory: ToolingDiagnosticFactory) { internal fun Collection<ToolingDiagnostic>.assertContainsDiagnostic(factory: ToolingDiagnosticFactory) {
if (!any { it.id == factory.id }) failDiagnosticNotFound("diagnostic with id ${factory.id} ", this) if (!any { it.id == factory.id }) failDiagnosticNotFound("diagnostic with id ${factory.id} ", this)
} }
@@ -89,10 +91,30 @@ internal fun Collection<ToolingDiagnostic>.assertContainsDiagnostic(diagnostic:
} }
private fun failDiagnosticNotFound(diagnosticDescription: String, notFoundInCollection: Collection<ToolingDiagnostic>) { private fun failDiagnosticNotFound(diagnosticDescription: String, notFoundInCollection: Collection<ToolingDiagnostic>) {
fun Any.withIndent() = this.toString().prependIndent(" ")
fail("Missing ${diagnosticDescription}in:\n${notFoundInCollection.render().withIndent()}") fail("Missing ${diagnosticDescription}in:\n${notFoundInCollection.render().withIndent()}")
} }
internal fun Collection<ToolingDiagnostic>.assertDiagnostics(vararg diagnostics: ToolingDiagnostic) {
val expectedDiagnostics = diagnostics.toSet()
val actualDiagnostic = this.toSet()
if (expectedDiagnostics == actualDiagnostic) return
val missingDiagnostics = this - expectedDiagnostics
val unexpectedDiagnostics = expectedDiagnostics - this
val errorMessage = buildString {
if (missingDiagnostics.isNotEmpty()) {
appendLine(missingDiagnostics.joinToString(prefix = "Missing diagnostic\n", separator = "\n") { it.withIndent() })
}
if (unexpectedDiagnostics.isNotEmpty()) {
appendLine(unexpectedDiagnostics.joinToString(prefix = "Unexpected diagnostic\n", separator = "\n") { it.withIndent() })
}
appendLine("in: \n${expectedDiagnostics.render().withIndent()}")
}
fail(errorMessage)
}
internal fun Project.assertNoDiagnostics(id: String) { internal fun Project.assertNoDiagnostics(id: String) {
kotlinToolingDiagnosticsCollector.getDiagnosticsForProject(this).assertNoDiagnostics(id) kotlinToolingDiagnosticsCollector.getDiagnosticsForProject(this).assertNoDiagnostics(id)
} }