diff --git a/app/src/main/kotlin/com/skydoves/myapplication/MainActivity.kt b/app/src/main/kotlin/com/skydoves/myapplication/MainActivity.kt index 367ed6c..aec576c 100644 --- a/app/src/main/kotlin/com/skydoves/myapplication/MainActivity.kt +++ b/app/src/main/kotlin/com/skydoves/myapplication/MainActivity.kt @@ -49,6 +49,8 @@ import androidx.lifecycle.ViewModel import com.skydoves.myapplication.models.ImmutableData import com.skydoves.myapplication.models.MyClass2 import com.skydoves.myapplication.models.NormalClass +import com.skydoves.myapplication.models.NormalSealedClass +import com.skydoves.myapplication.models.StableSealedClass import com.skydoves.myapplication.models.StableUser import com.skydoves.myapplication.models.UnstableUser import kotlinx.collections.immutable.ImmutableList @@ -162,6 +164,8 @@ fun Icon( title: String, painter: Painter, users: List, + normalSealedClass: NormalSealedClass.Normal, + stableSealedClass: StableSealedClass.Stable, elevation: CardElevation = CardDefaults.cardElevation(), unstableUser: UnstableUser, ) { diff --git a/app/src/main/kotlin/com/skydoves/myapplication/models/Models.kt b/app/src/main/kotlin/com/skydoves/myapplication/models/Models.kt index 6313c5e..8006874 100644 --- a/app/src/main/kotlin/com/skydoves/myapplication/models/Models.kt +++ b/app/src/main/kotlin/com/skydoves/myapplication/models/Models.kt @@ -73,6 +73,15 @@ class StableClass( } } +sealed class NormalSealedClass { + data class Normal(val names: List) : NormalSealedClass() +} + +@Immutable +sealed class StableSealedClass { + data class Stable(val names: List) : StableSealedClass() +} + /** * Class with mixed stability properties. * Should be unstable due to mutableList. diff --git a/app/stability/app.stability b/app/stability/app.stability index a751c2b..8998def 100644 --- a/app/stability/app.stability +++ b/app/stability/app.stability @@ -14,7 +14,7 @@ public fun com.skydoves.myapplication.ActionButton(text: kotlin.String, onClick: @Composable public fun com.skydoves.myapplication.Card(modifier: androidx.compose.ui.Modifier?, shape: androidx.compose.ui.graphics.Shape?, colors: androidx.compose.material3.CardColors?, elevation: androidx.compose.material3.CardElevation?, func: kotlin.Function2, func2: kotlin.coroutines.SuspendFunction0, func3: kotlin.coroutines.SuspendFunction0, content: @[ExtensionFunctionType] kotlin.Function3): kotlin.Unit - skippable: false + skippable: true restartable: true params: - modifier: STABLE (marked @Stable or @Immutable) @@ -22,8 +22,8 @@ public fun com.skydoves.myapplication.Card(modifier: androidx.compose.ui.Modifie - colors: STABLE (marked @Stable or @Immutable) - elevation: STABLE (marked @Stable or @Immutable) - func: STABLE (function type) - - func2: RUNTIME (requires runtime check) - - func3: RUNTIME (requires runtime check) + - func2: STABLE (function type) + - func3: STABLE (function type) - content: STABLE (function type) @Composable @@ -40,10 +40,10 @@ public fun com.skydoves.myapplication.CorrectWithParameters(): kotlin.Unit @Composable public fun com.skydoves.myapplication.CounterDisplay(count: com.skydoves.myapplication.MainViewModel): kotlin.Unit - skippable: true + skippable: false restartable: true params: - - count: STABLE + - count: RUNTIME (requires runtime check) @Composable public fun com.skydoves.myapplication.GenericDisplay(item: T of com.skydoves.myapplication.GenericDisplay, fontWeight4: androidx.compose.ui.text.font.FontWeight, mySealed: com.skydoves.myapplication.MySealed, child2: com.skydoves.myapplication.MySealed.Child2, child3: com.skydoves.myapplication.MySealed.Child3, child4: com.skydoves.myapplication.MySealed.Child4, child: com.skydoves.myapplication.MySealed.Child, fontWeight: androidx.compose.ui.text.font.FontWeight, fontWeight2: androidx.compose.ui.text.font.FontWeight?, values: kotlin.String, value: kotlin.Int?, testValueClass: com.skydoves.myapplication.TestValueClass, textAlign: androidx.compose.ui.text.style.TextAlign, textAlign2: androidx.compose.ui.text.style.TextAlign?, displayText: kotlin.Function1): kotlin.Unit @@ -52,28 +52,30 @@ public fun com.skydoves.myapplication.GenericDisplay(item: T of com.skydoves.mya params: - item: RUNTIME (requires runtime check) - fontWeight4: STABLE (marked @Stable or @Immutable) - - mySealed: STABLE - - child2: STABLE - - child3: STABLE + - mySealed: STABLE (class with no mutable properties) + - child2: STABLE (class with no mutable properties) + - child3: STABLE (class with no mutable properties) - child4: UNSTABLE (has mutable properties or unstable members) - - child: STABLE + - child: STABLE (class with no mutable properties) - fontWeight: STABLE (marked @Stable or @Immutable) - fontWeight2: STABLE (marked @Stable or @Immutable) - values: STABLE (String is immutable) - - value: STABLE - - testValueClass: STABLE + - value: STABLE (class with no mutable properties) + - testValueClass: STABLE (class with no mutable properties) - textAlign: STABLE (known stable type) - textAlign2: STABLE (known stable type) - displayText: STABLE (function type) @Composable -public fun com.skydoves.myapplication.Icon(title: kotlin.String, painter: androidx.compose.ui.graphics.painter.Painter, users: kotlin.collections.List, elevation: androidx.compose.material3.CardElevation?, unstableUser: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit +public fun com.skydoves.myapplication.Icon(title: kotlin.String, painter: androidx.compose.ui.graphics.painter.Painter, users: kotlin.collections.List, normalSealedClass: com.skydoves.myapplication.models.NormalSealedClass.Normal, stableSealedClass: com.skydoves.myapplication.models.StableSealedClass.Stable, elevation: androidx.compose.material3.CardElevation?, unstableUser: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit skippable: false restartable: true params: - title: STABLE (String is immutable) - painter: RUNTIME (requires runtime check) - users: RUNTIME (requires runtime check) + - normalSealedClass: RUNTIME (requires runtime check) + - stableSealedClass: STABLE (class with no mutable properties) - elevation: STABLE (marked @Stable or @Immutable) - unstableUser: UNSTABLE (has mutable properties or unstable members) @@ -126,14 +128,14 @@ public fun com.skydoves.myapplication.StableUserCard(user: com.skydoves.myapplic skippable: true restartable: true params: - - user: STABLE + - user: STABLE (class with no mutable properties) @Composable public fun com.skydoves.myapplication.Test(stableUser2: com.skydoves.myapplication.models.StableUser): kotlin.Unit skippable: true restartable: true params: - - stableUser2: STABLE + - stableUser2: STABLE (class with no mutable properties) @Composable public fun com.skydoves.myapplication.Test(myClass2: com.skydoves.myapplication.models.MyClass2, normalClass: com.skydoves.myapplication.models.NormalClass, immutableList: kotlinx.collections.immutable.ImmutableList): kotlin.Unit @@ -141,7 +143,7 @@ public fun com.skydoves.myapplication.Test(myClass2: com.skydoves.myapplication. restartable: true params: - myClass2: UNSTABLE (has mutable properties or unstable members) - - normalClass: STABLE + - normalClass: STABLE (class with no mutable properties) - immutableList: STABLE (known stable type) @Composable @@ -161,10 +163,10 @@ public fun com.skydoves.myapplication.Test2(myClass2: kotlin.collections.List + val annotationFqName = annotation.classId?.asSingleFqName()?.asString() + annotationFqName == "androidx.compose.runtime.Stable" || + annotationFqName == "androidx.compose.runtime.Immutable" + } + + // Only return RUNTIME if it doesn't have stability annotations + if (!hasStabilityAnnotation) { + return KtStability.Runtime( + className = fqName ?: simpleName, + reason = "Abstract class - actual implementation could be mutable", + ) + } + // Abstract classes with @Stable/@Immutable continue to property analysis } - // 20. Regular classes - analyze properties first before checking @StabilityInferred + // 20. Regular classes (and sealed classes) - analyze properties first before checking @StabilityInferred val propertyStability = analyzeClassProperties(classSymbol, currentlyAnalyzing) return when { @@ -411,6 +425,31 @@ internal class KtStabilityInferencer { classSymbol: KaClassSymbol, currentlyAnalyzing: Set, ): KtStability { + // Issue #31: Check if parent sealed class has @Immutable/@Stable + val parentHasStabilityAnnotation = classSymbol.superTypes.any { superType -> + val superClassSymbol = superType.expandedSymbol as? KaClassSymbol + if (superClassSymbol != null) { + // Check if superclass is sealed (has sealed subclasses) + val isSealed = superClassSymbol.modality == KaSymbolModality.SEALED + // Check if it has @Stable or @Immutable annotation + val hasAnnotation = superClassSymbol.annotations.any { annotation -> + val annotationFqName = annotation.classId?.asSingleFqName()?.asString() + annotationFqName == "androidx.compose.runtime.Stable" || + annotationFqName == "androidx.compose.runtime.Immutable" + } + isSealed && hasAnnotation + } else { + false + } + } + + if (parentHasStabilityAnnotation) { + return KtStability.Certain( + stable = true, + reason = "Subclass of @Immutable/@Stable sealed class", + ) + } + // Check superclass stability first val superClassStability = analyzeSuperclassStability(classSymbol, currentlyAnalyzing) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb1998d..2e4175a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ androidGradlePlugin = "8.13.0" androidxActivity = "1.11.0" androidxComposeBom = "2025.10.01" jetbrains-compose = "1.9.2" -compose-stability-analyzer = "0.4.2" +compose-stability-analyzer = "0.5.0" spotless = "6.21.0" [libraries] diff --git a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt index d708098..487a6db 100644 --- a/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt +++ b/stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt @@ -395,20 +395,25 @@ public class StabilityAnalyzerTransformer( } // 16. Abstract classes - cannot determine (RUNTIME) - // EXCEPT sealed classes which are handled by property analysis + // EXCEPT: sealed classes with @Stable/@Immutable annotations + // Issue #31: @Immutable sealed classes should be trusted as stable if (clazz.modality == org.jetbrains.kotlin.descriptors.Modality.ABSTRACT) { - // Check if this is a sealed class (sealed classes are abstract but stable if no mutable props) + // Check if this is a sealed class (sealed classes are abstract but stable if annotated) val isSealed = try { clazz.sealedSubclasses.isNotEmpty() } catch (e: Exception) { false } - // Only mark as RUNTIME if it's NOT a sealed class - if (!isSealed) { + // Check if it has @Stable or @Immutable annotation + val hasStabilityAnnotation = clazz.hasAnnotation(stableFqName) || + clazz.hasAnnotation(immutableFqName) + + // Only mark as RUNTIME if it's NOT a sealed class AND doesn't have stability annotation + if (!isSealed && !hasStabilityAnnotation) { return ParameterStability.RUNTIME } - // Sealed classes continue to property analysis + // Sealed classes and annotated abstract classes continue to property analysis } // 18. Regular classes - analyze properties first before checking @StabilityInferred @@ -434,6 +439,29 @@ public class StabilityAnalyzerTransformer( * Matches K2 implementation logic. */ private fun analyzeClassProperties(clazz: IrClass, fqName: String?): ParameterStability { + // Issue #31: Check if parent sealed class has @Immutable/@Stable + val parentHasStabilityAnnotation = clazz.superTypes.any { superType -> + val superClassSymbol = superType.classOrNull + if (superClassSymbol != null) { + val superClass = superClassSymbol.owner + // Check if superclass is sealed AND has stability annotation + val isSealed = try { + superClass.sealedSubclasses.isNotEmpty() + } catch (e: Exception) { + false + } + val hasAnnotation = superClass.hasAnnotation(stableFqName) || + superClass.hasAnnotation(immutableFqName) + isSealed && hasAnnotation + } else { + false + } + } + + if (parentHasStabilityAnnotation) { + return ParameterStability.STABLE + } + // Check superclass stability first (matches IDE plugin logic) val superClassStability = analyzeSuperclassStability(clazz)