Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,6 +164,8 @@ fun Icon(
title: String,
painter: Painter,
users: List<StableUser>,
normalSealedClass: NormalSealedClass.Normal,
stableSealedClass: StableSealedClass.Stable,
elevation: CardElevation = CardDefaults.cardElevation(),
unstableUser: UnstableUser,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ class StableClass(
}
}

sealed class NormalSealedClass {
data class Normal(val names: List<String>) : NormalSealedClass()
}

@Immutable
sealed class StableSealedClass {
data class Stable(val names: List<String>) : StableSealedClass()
}

/**
* Class with mixed stability properties.
* Should be unstable due to mutableList.
Expand Down
40 changes: 21 additions & 19 deletions app/stability/app.stability
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ 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<androidx.compose.runtime.Composer, kotlin.Int, com.skydoves.myapplication.models.UnstableUser>, func2: kotlin.coroutines.SuspendFunction0<com.skydoves.myapplication.models.UnstableUser>, func3: kotlin.coroutines.SuspendFunction0<com.skydoves.myapplication.models.StableUser>, content: @[ExtensionFunctionType] kotlin.Function3<androidx.compose.foundation.layout.ColumnScope, androidx.compose.runtime.Composer, kotlin.Int, kotlin.Unit>): kotlin.Unit
skippable: false
skippable: true
restartable: true
params:
- modifier: STABLE (marked @Stable or @Immutable)
- shape: STABLE (marked @Stable or @Immutable)
- 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
Expand All @@ -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<T of com.skydoves.myapplication.GenericDisplay, kotlin.String>): kotlin.Unit
Expand All @@ -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<com.skydoves.myapplication.models.StableUser>, 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<com.skydoves.myapplication.models.StableUser>, 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)

Expand Down Expand Up @@ -126,22 +128,22 @@ 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.String>): kotlin.Unit
skippable: false
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
Expand All @@ -161,10 +163,10 @@ public fun com.skydoves.myapplication.Test2(myClass2: kotlin.collections.List<ko

@Composable
public fun com.skydoves.myapplication.Test3(myClass2: java.lang.StringBuilder): kotlin.Unit
skippable: true
skippable: false
restartable: true
params:
- myClass2: STABLE
- myClass2: UNSTABLE (mutable Java class)

@Composable
public fun com.skydoves.myapplication.Test4(count: androidx.lifecycle.ViewModel): kotlin.Unit
Expand Down Expand Up @@ -217,7 +219,7 @@ public fun com.skydoves.myapplication.TrackedMixedParameters(title: kotlin.Strin
params:
- title: STABLE (String is immutable)
- count: STABLE (primitive type)
- user: STABLE
- user: STABLE (class with no mutable properties)

@Composable
public fun com.skydoves.myapplication.TrackedUnstableUserCard(user: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit
Expand All @@ -231,7 +233,7 @@ public fun com.skydoves.myapplication.TrackedUserProfile(user: com.skydoves.myap
skippable: true
restartable: true
params:
- user: STABLE
- user: STABLE (class with no mutable properties)

@Composable
public fun com.skydoves.myapplication.UnstableUserCard(user: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit
Expand Down
7 changes: 7 additions & 0 deletions compose-stability-analyzer-idea/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ All notable changes to the IntelliJ IDEA plugin will be documented in this file.
- Implemented `DefaultRecompositionLogger` for wasmJs using `println()` for browser console output
- wasmJs target placed directly under common hierarchy (separate from skia group) for proper source set resolution

### Fixed
- **Fixed sealed class stability inheritance** (Issue #31)
- Abstract classes with `@Immutable` or `@Stable` annotations now correctly analyzed for stability
- Sealed classes with `@Immutable`/`@Stable` now properly propagate stability to subclasses
- Example: `@Immutable sealed class StableSealedClass` with `data class Stable(...)` subclass now correctly shows as STABLE instead of RUNTIME
- IDE plugin now matches compiler plugin behavior for sealed class hierarchies

---

## [0.5.0] - 2025-11-08
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,14 +368,28 @@ internal class KtStabilityInferencer {
}

// 19. Abstract classes - cannot determine (RUNTIME)
// EXCEPT: Sealed classes with @Stable/@Immutable should be analyzed like regular classes
// Issue #31: Sealed classes with @Immutable/@Stable should propagate stability to subclasses
if (classSymbol.modality == KaSymbolModality.ABSTRACT) {
return KtStability.Runtime(
className = fqName ?: simpleName,
reason = "Abstract class - actual implementation could be mutable",
)
// Check if this abstract class has @Stable or @Immutable annotation
// If it does, it should be analyzed (not immediately returned as RUNTIME)
val hasStabilityAnnotation = classSymbol.annotations.any { annotation ->
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 {
Expand Down Expand Up @@ -411,6 +425,31 @@ internal class KtStabilityInferencer {
classSymbol: KaClassSymbol,
currentlyAnalyzing: Set<KaClassLikeSymbol>,
): 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",
)
}
Comment on lines +429 to +451
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Propagate all stable markers, not just @Stable/@Immutable.

Line 435 only matches Compose’s two annotations. Because hasStableAnnotation() already recognizes @StableForAnalysis (and other stable markers), sealed subclasses of those annotations still get downgraded to runtime stability. That breaks the promise we make for custom stable markers. Please reuse superClassSymbol.hasStableAnnotation() (or the same constant set) so every annotation we treat as stable elsewhere also propagates through sealed parents. This keeps IDE inference consistent with the rest of the analyzer.

🤖 Prompt for AI Agents
In
compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/k2/KtStabilityInferencer.kt
around lines 429 to 451, the code only checks for the two Compose annotations
("androidx.compose.runtime.Stable" and "androidx.compose.runtime.Immutable") on
sealed superclasses, but should propagate all stable markers; replace the manual
annotation check with the existing helper (e.g., call
superClassSymbol.hasStableAnnotation() or reuse the same stable-annotation set)
so the condition becomes: detect sealed superclasses AND use
hasStableAnnotation() to determine stability, then return the same
KtStability.Certain when true.


// Check superclass stability first
val superClassStability = analyzeSuperclassStability(classSymbol, currentlyAnalyzing)

Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Comment on lines +443 to +463
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mirror the IDE fix for all stable annotations.

Here we only check stableFqName/immutableFqName. That leaves sealed parents annotated with @StableForAnalysis (or other supported markers) still reporting runtime in the compiler plugin even though the IDE parks them as stable. Please expand the guard to include every annotation we recognise as stable (e.g. reuse the same helper/constant set) so IDE and compiler stay in lockstep.

🧰 Tools
🪛 detekt (1.23.8)

[warning] 450-450: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


// Check superclass stability first (matches IDE plugin logic)
val superClassStability = analyzeSuperclassStability(clazz)

Expand Down
Loading