Skip to content

Commit 9fd1a5c

Browse files
authored
Add runtime libraries compatibility check for skiko (#5541)
[CMP-9799](https://youtrack.jetbrains.com/issue/CMP-9799) Check skiko library compatibility Addition to #5485 ## Release Notes ### Features - Gradle Plugin - Add a compatibility check for skiko libraries to ensure consistency
1 parent 98e1f71 commit 9fd1a5c

2 files changed

Lines changed: 177 additions & 53 deletions

File tree

gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/RuntimeLibrariesCompatibilityCheck.kt

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ package org.jetbrains.compose
22

33
import org.gradle.api.DefaultTask
44
import org.gradle.api.Project
5+
import org.gradle.api.artifacts.ModuleVersionIdentifier
6+
import org.gradle.api.artifacts.component.ComponentIdentifier
7+
import org.gradle.api.artifacts.component.ComponentSelector
8+
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
9+
import org.gradle.api.artifacts.component.ModuleComponentSelector
10+
import org.gradle.api.artifacts.component.ProjectComponentIdentifier
11+
import org.gradle.api.artifacts.component.ProjectComponentSelector
12+
import org.gradle.api.artifacts.result.ResolvedDependencyResult
513
import org.gradle.api.artifacts.result.ResolvedComponentResult
614
import org.gradle.api.provider.Property
715
import org.gradle.api.provider.ProviderFactory
@@ -54,18 +62,32 @@ private fun KotlinTarget.configureRuntimeLibrariesCompatibilityCheck() {
5462
expectedVersion.set(composeVersion)
5563
projectPath.set(project.path)
5664
configurationName.set(runtimeDependencyConfigurationName)
57-
runtimeDependencies.set(provider { config.incoming.resolutionResult.allComponents })
65+
allDependencies.set(
66+
provider {
67+
config.incoming.resolutionResult.allDependencies
68+
.filterIsInstance<ResolvedDependencyResult>()
69+
}
70+
)
5871
}
5972
compilation.compileTaskProvider.dependsOn(task)
6073
}
6174
}
6275

6376
internal abstract class RuntimeLibrariesCompatibilityCheck : DefaultTask() {
6477
private companion object {
65-
val librariesForCheck = listOf(
78+
val composeLibrariesForCheck = setOf(
6679
"org.jetbrains.compose.foundation:foundation",
6780
"org.jetbrains.compose.ui:ui"
6881
)
82+
val skikoLibraryForCheck = "org.jetbrains.skiko:skiko"
83+
84+
private val majorMinorRegex = """^(\d+)\.(\d+)""".toRegex()
85+
fun majorMinorVersion(version: String): String {
86+
val match = majorMinorRegex.find(version) ?: return version
87+
val major = match.groupValues[1]
88+
val minor = match.groupValues[2]
89+
return "$major.$minor"
90+
}
6991
}
7092

7193
@get:Inject
@@ -81,7 +103,7 @@ internal abstract class RuntimeLibrariesCompatibilityCheck : DefaultTask() {
81103
abstract val configurationName: Property<String>
82104

83105
@get:Input
84-
abstract val runtimeDependencies: SetProperty<ResolvedComponentResult>
106+
abstract val allDependencies: SetProperty<ResolvedDependencyResult>
85107

86108
init {
87109
onlyIf {
@@ -92,47 +114,98 @@ internal abstract class RuntimeLibrariesCompatibilityCheck : DefaultTask() {
92114
@TaskAction
93115
fun run() {
94116
val expectedRuntimeVersion = expectedVersion.get()
95-
val foundLibs = runtimeDependencies.get().filter { component ->
96-
component.moduleVersion?.let { lib -> lib.group + ":" + lib.name } in librariesForCheck
117+
val composeLibraries = allDependencies.get()
118+
.mapNotNull { it.selected.moduleVersion }
119+
.filter { lib -> "${lib.group}:${lib.name}" in composeLibrariesForCheck }
120+
.distinctBy { lib -> "${lib.group}:${lib.name}:${lib.version}" }
121+
val composeInconsistentVersions = composeLibraries.filter { lib ->
122+
lib.version != expectedRuntimeVersion
97123
}
98-
val problems = foundLibs.mapNotNull { component ->
99-
val module = component.moduleVersion ?: return@mapNotNull null
100-
if (module.version == expectedRuntimeVersion) return@mapNotNull null
101-
ProblemLibrary(module.group + ":" + module.name, module.version)
124+
if (composeInconsistentVersions.isNotEmpty()) {
125+
logger.warn(
126+
getComposeMessage(
127+
projectPath.get(),
128+
configurationName.get(),
129+
composeInconsistentVersions,
130+
expectedRuntimeVersion
131+
)
132+
)
102133
}
103134

104-
if (problems.isNotEmpty()) {
135+
val skikoIncompatibleDependencyUsages = allDependencies.get().filter { dependency ->
136+
val requested = dependency.requested as? ModuleComponentSelector ?: return@filter false
137+
val selected = dependency.selected.moduleVersion ?: return@filter false
138+
if ("${requested.group}:${requested.module}" != skikoLibraryForCheck) return@filter false
139+
if ("${selected.group}:${selected.name}" != skikoLibraryForCheck) return@filter false
140+
majorMinorVersion(requested.version) != majorMinorVersion(selected.version)
141+
}
142+
if (skikoIncompatibleDependencyUsages.isNotEmpty()) {
105143
logger.warn(
106-
getMessage(
144+
getSkikoMessage(
107145
projectPath.get(),
108146
configurationName.get(),
109-
problems,
110-
expectedRuntimeVersion
147+
skikoIncompatibleDependencyUsages
111148
)
112149
)
113150
}
114151
}
115152

116-
private data class ProblemLibrary(val name: String, val version: String)
117-
118-
private fun getMessage(
153+
private fun getComposeMessage(
119154
projectName: String,
120155
configurationName: String,
121-
problemLibs: List<ProblemLibrary>,
122-
expectedVersion: String
156+
composeInconsistentVersions: List<ModuleVersionIdentifier>,
157+
expectedVersion: String,
123158
): String = buildString {
124159
appendLine("w: Compose Multiplatform runtime dependencies' versions don't match with plugin version.")
125-
problemLibs.forEach { lib ->
126-
appendLine(" expected: '${lib.name}:$expectedVersion'")
127-
appendLine(" actual: '${lib.name}:${lib.version}'")
160+
composeInconsistentVersions.forEach { library ->
161+
appendLine(" expected: '${library.group}:${library.name}:$expectedVersion'")
162+
appendLine(" actual: '${library.group}:${library.name}:${library.version}'")
128163
appendLine()
129164
}
165+
appendNoteAboutDependencyMismatch(projectName, configurationName)
166+
appendLine()
167+
appendLine("Please update Compose Multiplatform Gradle plugin's version or align dependencies' versions to match the current plugin version.")
168+
}
169+
170+
private fun getSkikoMessage(
171+
projectName: String,
172+
configurationName: String,
173+
dependencyUsages: List<ResolvedDependencyResult>,
174+
): String = buildString {
175+
appendLine("w: Skiko dependencies' versions are incompatible.")
176+
dependencyUsages.forEach { usage ->
177+
val from = usage.from.moduleVersion
178+
val requested = usage.requested
179+
val selected = usage.selected.moduleVersion
180+
appendLine(" ${from.toModuleString()}")
181+
appendLine(" \\--- ${requested.toModuleString()} -> ${selected?.version}")
182+
appendLine()
183+
}
184+
appendNoteAboutDependencyMismatch(projectName, configurationName)
185+
appendLine()
186+
appendLine("Note: Skiko is considered implementation detail in Compose Multiplatform and might be incompatible across versions.")
187+
appendLine("Please align Skiko dependencies to the same version. If possible, avoid direct Skiko references and use Compose APIs instead.")
188+
}
189+
190+
private fun StringBuilder.appendNoteAboutDependencyMismatch(
191+
projectName: String,
192+
configurationName: String,
193+
) {
130194
appendLine("This may lead to compilation errors or unexpected behavior at runtime.")
131195
appendLine("Such version mismatch might be caused by dependency constraints in one of the included libraries.")
132196
val taskName = if (projectName.isNotEmpty() && !projectName.endsWith(":")) "$projectName:dependencies" else "${projectName}dependencies"
133197
appendLine("You can inspect resulted dependencies tree via `./gradlew $taskName --configuration ${configurationName}`.")
134198
appendLine("See more details in Gradle documentation: https://docs.gradle.org/current/userguide/viewing_debugging_dependencies.html#sec:listing-dependencies")
135-
appendLine()
136-
appendLine("Please update Compose Multiplatform Gradle plugin's version or align dependencies' versions to match the current plugin version.")
137199
}
138-
}
200+
201+
private fun ModuleVersionIdentifier?.toModuleString() = when (this) {
202+
null -> "<unknown>"
203+
else -> "$group:$name:$version"
204+
}
205+
private fun ComponentSelector?.toModuleString(): String = when (this) {
206+
is ProjectComponentSelector -> "project $projectPath"
207+
is ModuleComponentSelector -> "$group:$module:$version"
208+
null -> "<unknown>"
209+
else -> displayName
210+
}
211+
}

gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/RuntimeLibrariesCompatibilityCheckTest.kt

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import org.jetbrains.compose.desktop.application.internal.ComposeProperties
44
import org.jetbrains.compose.internal.utils.OS
55
import org.jetbrains.compose.internal.utils.currentOS
66
import org.jetbrains.compose.test.utils.GradlePluginTestBase
7+
import org.jetbrains.compose.test.utils.TestProject
78
import org.jetbrains.compose.test.utils.checks
89
import org.jetbrains.compose.test.utils.modify
910
import kotlin.test.Test
@@ -23,26 +24,12 @@ class RuntimeLibrariesCompatibilityCheckTest : GradlePluginTestBase() {
2324
check.logDoesntContain("checkMetadataMainComposeLibrariesCompatibility")
2425
check.logDoesntContain(logMsg)
2526
}
26-
gradle("jvmMainClasses").checks {
27-
check.taskSuccessful(":checkJvmMainComposeLibrariesCompatibility")
28-
check.logDoesntContain(logMsg)
29-
}
3027
gradle("jvmTestClasses").checks {
3128
check.taskSuccessful(":checkJvmMainComposeLibrariesCompatibility")
3229
check.taskSuccessful(":checkJvmTestComposeLibrariesCompatibility")
3330
check.logDoesntContain(logMsg)
3431
}
35-
gradle("wasmJsMainClasses").checks {
36-
check.taskSuccessful(":checkWasmJsMainComposeLibrariesCompatibility")
37-
check.logDoesntContain(logMsg)
38-
}
39-
40-
if (currentOS == OS.MacOS) {
41-
gradle("compileKotlinIosSimulatorArm64").checks {
42-
check.taskSuccessful(":checkIosSimulatorArm64MainComposeLibrariesCompatibility")
43-
check.logDoesntContain(logMsg)
44-
}
45-
}
32+
checkMainTargetsCompatibility(logMsg, warningExpected = false)
4633

4734
file("build.gradle.kts").modify {
4835
it.replace(
@@ -61,25 +48,89 @@ class RuntimeLibrariesCompatibilityCheckTest : GradlePluginTestBase() {
6148
appendLine(" expected: 'org.jetbrains.compose.foundation:foundation:${defaultTestEnvironment.composeVersion}'")
6249
appendLine(" actual: 'org.jetbrains.compose.foundation:foundation:1.9.3'")
6350
}
64-
gradle("jvmMainClasses").checks {
51+
checkMainTargetsCompatibility(msg, warningExpected = true)
52+
checkMainTargetsCompatibility(msg, warningExpected = false, disabled = true)
53+
}
54+
55+
@Test
56+
fun skikoIncompatibleWarning(): Unit = with(
57+
testProject("misc/compatibilityLibCheck")
58+
) {
59+
val logMsg = "w: Skiko dependencies' versions are incompatible."
60+
gradle("metadataMainClasses").checks {
61+
check.logDoesntContain("checkMetadataMainComposeLibrariesCompatibility")
62+
check.logDoesntContain(logMsg)
63+
}
64+
checkMainTargetsCompatibility(logMsg, warningExpected = false)
65+
gradle("jvmTestClasses").checks {
6566
check.taskSuccessful(":checkJvmMainComposeLibrariesCompatibility")
66-
check.logContains(msg)
67+
check.taskSuccessful(":checkJvmTestComposeLibrariesCompatibility")
68+
check.logDoesntContain(logMsg)
69+
}
70+
71+
// In case of dependency to old compose:ui without skiko explicitly, no warning should be emitted.
72+
file("build.gradle.kts").modify {
73+
it.replace(
74+
"api(\"org.jetbrains.compose.foundation:foundation:${defaultTestEnvironment.composeVersion}\")",
75+
"api(\"org.jetbrains.compose.foundation:foundation:${defaultTestEnvironment.composeVersion}\")\n" +
76+
" implementation(\"$OLD_COMPOSE_DEPENDENCY\")",
77+
)
6778
}
68-
gradle("wasmJsMainClasses").checks {
69-
check.taskSuccessful(":checkWasmJsMainComposeLibrariesCompatibility")
70-
check.logContains(msg)
79+
checkMainTargetsCompatibility(logMsg, warningExpected = false)
80+
81+
// Direct explicit dependency on old skiko introduces requested-version mismatch versus selected version,
82+
// so warning should be emitted.
83+
file("build.gradle.kts").modify {
84+
it.replace(
85+
"implementation(\"$OLD_COMPOSE_DEPENDENCY\")",
86+
"implementation(\"$OLD_COMPOSE_DEPENDENCY\")\n" +
87+
" implementation(\"$OLD_SKIKO_DEPENDENCY\")",
88+
)
7189
}
90+
checkMainTargetsCompatibility(logMsg, warningExpected = true)
91+
checkMainTargetsCompatibility(logMsg, warningExpected = false, disabled = true)
92+
}
7293

73-
if (currentOS == OS.MacOS) {
74-
gradle("compileKotlinIosSimulatorArm64").checks {
75-
check.taskSuccessful(":checkIosSimulatorArm64MainComposeLibrariesCompatibility")
76-
check.logContains(msg)
94+
private fun TestProject.checkMainTargetsCompatibility(
95+
warningMessage: String,
96+
warningExpected: Boolean,
97+
disabled: Boolean = false
98+
) {
99+
val disableProperty = ComposeProperties.DISABLE_LIBRARY_COMPATIBILITY_CHECK
100+
val additionalArgs = if (disabled) arrayOf("-P${disableProperty}=true") else emptyArray()
101+
102+
gradle("jvmMainClasses", *additionalArgs).checks {
103+
if (disabled) {
104+
check.taskSkipped(":checkJvmMainComposeLibrariesCompatibility")
105+
} else {
106+
check.taskSuccessful(":checkJvmMainComposeLibrariesCompatibility")
77107
}
108+
if (warningExpected) check.logContains(warningMessage) else check.logDoesntContain(warningMessage)
78109
}
79-
val disableProperty = ComposeProperties.DISABLE_LIBRARY_COMPATIBILITY_CHECK
80-
gradle("jvmMainClasses", "-P${disableProperty}=true").checks {
81-
check.taskSkipped(":checkJvmMainComposeLibrariesCompatibility")
82-
check.logDoesntContain(msg)
110+
111+
gradle("wasmJsMainClasses", *additionalArgs).checks {
112+
if (disabled) {
113+
check.taskSkipped(":checkWasmJsMainComposeLibrariesCompatibility")
114+
} else {
115+
check.taskSuccessful(":checkWasmJsMainComposeLibrariesCompatibility")
116+
}
117+
if (warningExpected) check.logContains(warningMessage) else check.logDoesntContain(warningMessage)
118+
}
119+
120+
if (currentOS == OS.MacOS) {
121+
gradle("compileKotlinIosSimulatorArm64", *additionalArgs).checks {
122+
if (disabled) {
123+
check.taskSkipped(":checkIosSimulatorArm64MainComposeLibrariesCompatibility")
124+
} else {
125+
check.taskSuccessful(":checkIosSimulatorArm64MainComposeLibrariesCompatibility")
126+
}
127+
if (warningExpected) check.logContains(warningMessage) else check.logDoesntContain(warningMessage)
128+
}
83129
}
84130
}
85-
}
131+
}
132+
133+
// lifecycle-viewmodel-compose:2.8.3 transitively pulls old compose.ui:1.6.11,
134+
// which in turn depends on skiko:0.8.4.
135+
private const val OLD_COMPOSE_DEPENDENCY = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3"
136+
private const val OLD_SKIKO_DEPENDENCY = "org.jetbrains.skiko:skiko:0.8.4"

0 commit comments

Comments
 (0)