Skip to content

Ignore computed getter-only properties in stability inference#180

Open
skydoves wants to merge 1 commit into
mainfrom
fix/computed-property-false-positive
Open

Ignore computed getter-only properties in stability inference#180
skydoves wants to merge 1 commit into
mainfrom
fix/computed-property-false-positive

Conversation

@skydoves

@skydoves skydoves commented Jul 3, 2026

Copy link
Copy Markdown
Owner

🎯 Goal

Fixes #178. A data class was reported as RUNTIME / non-skippable by stabilityDump / stabilityCheck when it (or an abstract supertype) exposed a computed getter-only property, e.g.:

data class TestClass(val input: String) : AbstractClass()
abstract class AbstractClass { open val testInterface: TestInterface? get() = null }
interface TestInterface

Compose infers TestClass as stable; the analyzer reported testClass: RUNTIME. Regression range matches the report (0.8.0 clean, 0.9.0+ affected — introduced with the 2.4.0 interface/abstract → Unknown handling).

🛠 Implementation details

The Compose compiler's class-stability inference considers only members that store state — it guards on member.backingField?.let { ... } and never inspects a property that has no backing field (Stability.kt @ v2.4.0). This analyzer instead counted every IrProperty in clazz.declarations, including computed getter-only properties (no backing field) and IR fake-overrides of inherited members. So the computed testInterface: TestInterface? was analyzed and its interface type demoted the class to RUNTIME.

Changes in StabilityAnalyzerTransformer.kt:

  • Filter properties to those representing stored state at all three collection sites (regular class, @Parcelize, value class), via a new representsStoredState() helper. A computed property with no backing field anywhere in its override chain is dropped.
  • Inherited stored state is preserved: an inherited var/unstable field appears as a fake-override with no backing field on the subclass, so the helper resolves through overriddenSymbols to the real declaration and keeps it — e.g. data class Child(val x: String) : Base { var count = 0 } stays UNSTABLE.
  • Fixed the empty-list branch to drop a bare-Unknown (abstract/open) superclass instead of propagating it, matching Compose — otherwise a class whose only properties are computed would inherit the abstract base's Unknown.

Verified with the sample app's debugStabilityDump across a matrix (inherited computed → STABLE, own computed → STABLE, inherited var → UNSTABLE, inherited MutableList → UNSTABLE, inherited stable val → STABLE), and cross-checked against the Compose 2.4.0 StabilityInferencer source.

✍️ Explain examples

New IR-dump regression test InheritedComputedPropertyStability.kt pins the injected trackParameter(..., isStable = ...) verdicts:

  • holder: DataHolder (inherited computed prop) → isStable = true
  • card: CardData (own computed prop) → isStable = true
  • counter: CounterData (inherited var) → isStable = false
  • profile: ProfileCard (computed-only class extending an abstract base) → isStable = true

Note — IDE plugin follow-up (not in this PR)

The IDE plugin's KtStabilityInferencer has a separate, pre-existing divergence in this area: it short-circuits abstract/open classes to Unknown without analyzing their fields and folds that into concrete subclasses, so any concrete class extending an unannotated abstract/open base is shown non-skippable in the IDE (vs stable in the compiler / Compose). Fixing that properly needs a broader inferencer change with its own tests, so it is intentionally left out to keep this PR focused on the reported stabilityCheck regression. Happy to follow up with the IDE-side change.

Summary by CodeRabbit

  • Bug Fixes
    • Improved stability detection so computed, getter-only properties are treated as stable when they have no backing state.
    • Prevented inherited abstract or base types without stored state from incorrectly making subclasses unstable.
    • Refined handling of mutable stored properties so real mutable state still correctly marks types as unstable.
    • Added coverage for several inheritance scenarios to keep stability behavior consistent.

A class was wrongly inferred RUNTIME/non-skippable when it (or an abstract supertype) exposed a computed getter-only property such as `open val foo: Iface? get() = null`. A computed property has no backing field and stores no state, so the Compose compiler ignores it (its class inference guards on `member.backingField?.let { ... }`). The analyzer was counting it and letting its interface type demote the class.

Filter class properties to those backed by real stored state before inference. Inherited stored `var`/unstable fields are preserved: they appear as IR fake-overrides and resolve through the override chain to a backed declaration. When the filtered list is empty, a bare-Unknown (abstract/open) superclass is dropped rather than propagated, matching Compose, so a concrete subclass with no state of its own stays stable.

Adds an IR-dump regression test covering an inherited computed property, an own computed property, an inherited var (stays unstable), and a computed-only class extending an abstract base.

Fixes #178
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Stability inference in StabilityAnalyzerTransformer now filters class and value-class property analysis (including @Parcelize handling) to only stored-state properties, via new helpers detecting backing fields or resolvable backed overrides. A corresponding compiler test fixture validates inherited/own computed property stability behavior.

Changes

Stored-state stability filtering

Layer / File(s) Summary
Stored-state detection and analysis
stability-compiler/.../lower/StabilityAnalyzerTransformer.kt
Adds representsStoredState()/resolvesToBackedProperty() helpers; filters analyzeClassProperties, analyzeValueClass, and @Parcelize property collection to stored-state properties only; changes empty-property fallback to return UNSTABLE/RUNTIME only for those superclass states, else STABLE.
Regression test fixture
compiler-tests/.../dump/ir/InheritedComputedPropertyStability.kt
New IR dump test defining classes with computed getter-only properties, stored var fields, and abstract bases, plus @Composable entrypoints exercising Issue #178 stability scenarios.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: excluding computed getter-only properties from stability inference.
Description check ✅ Passed The description covers the goal, implementation, and examples; only the review-prep template section is omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/computed-property-false-positive

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt`:
- Around line 795-834: The IDEA analyzer still infers stability from every
declared property, so it can disagree with the compiler on getter-only `val`s
and inherited stored properties. Update `StabilityAnalyzer.kt` and
`k2/KtStabilityInferencer.kt` to mirror `IrProperty.representsStoredState()` /
`resolvesToBackedProperty()` by filtering out properties without a backing field
and following override chains for inherited backed properties. Keep the same
stored-state semantics used by `analyzeValueClass()` so class, `@Parcelize`, and
value-class inference match compiler behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f79f7114-c8fc-4f10-b7e5-03516adbcd88

📥 Commits

Reviewing files that changed from the base of the PR and between 7f68d4c and 7dea19a.

⛔ Files ignored due to path filters (2)
  • compiler-tests/src/test/data/dump/ir/InheritedComputedPropertyStability.fir.kt.txt is excluded by !compiler-tests/src/test/data/**/*.txt
  • compiler-tests/src/test/java/com/skydoves/compose/stability/compiler/tests/IrDumpTestGenerated.java is excluded by !compiler-tests/src/test/java/**
📒 Files selected for processing (2)
  • compiler-tests/src/test/data/dump/ir/InheritedComputedPropertyStability.kt
  • stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt

Comment on lines +795 to +834
/**
* True when this property stores state — it has a backing field, or (for an inherited fake
* override) resolves through its override chain to a declaration that does.
*
* Computed getter-only properties (e.g. `open val foo: Bar? get() = null`) hold no state and
* must be excluded from stability inference. This mirrors the Compose compiler, whose class
* inference guards on `member.backingField?.let { ... }` and never inspects the type of a
* property without a backing field — so a stateless computed property (even of an interface
* type) never makes a class runtime/unstable. Inherited stored `var`/unstable fields still
* count, because they resolve through the override chain to a backed declaration (issue #178).
*/
private fun org.jetbrains.kotlin.ir.declarations.IrProperty.representsStoredState(): Boolean {
if (backingField != null) return true
return resolvesToBackedProperty(this, HashSet())
}

private fun resolvesToBackedProperty(
property: org.jetbrains.kotlin.ir.declarations.IrProperty,
seen: MutableSet<org.jetbrains.kotlin.ir.symbols.IrPropertySymbol>,
): Boolean {
if (property.backingField != null) return true
if (!seen.add(property.symbol)) return false
return property.overriddenSymbols.any { symbol ->
val owner = try {
symbol.owner
} catch (e: Exception) {
null
}
owner != null && resolvesToBackedProperty(owner, seen)
}
}

/**
* Analyzes value class (inline class) stability.
* Value classes inherit the stability of their underlying type.
*/
private fun analyzeValueClass(clazz: IrClass): ParameterStability {
val properties = clazz.declarations
.filterIsInstance<org.jetbrains.kotlin.ir.declarations.IrProperty>()
.filter { it.representsStoredState() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) Does the IDEA analyzer have an equivalent backing-field / stored-state filter?
fd -i 'StabilityAnalyzer|KtStabilityInferencer|StabilityAnalysisConstants' \
  | xargs -r rg -nP -C3 '(backingField|representsStoredState|getter\s*only|computed|overriddenSymbols)'

# 2) Confirm how the compiler side previously handled getter-only props (for parity delta)
fd 'StabilityAnalyzerTransformer.kt' \
  | xargs -r rg -nP -C3 'filterIsInstance.*IrProperty|isVar|overriddenSymbols|backingField'

Repository: skydoves/compose-stability-analyzer

Length of output: 10012


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate IDEA analyzer sources and inspect for any backing-field / stored-state logic.
git ls-files | rg 'compose-stability-analyzer-idea|stability-analyzer-idea|StabilityAnalyzer|KtStabilityInferencer|StabilityAnalysisConstants|analyzer-idea'

# Show concise outlines for candidate files if present.
for f in $(git ls-files | rg 'compose-stability-analyzer-idea|stability-analyzer-idea|StabilityAnalyzer|KtStabilityInferencer|StabilityAnalysisConstants'); do
  echo "### $f"
  wc -l "$f"
  ast-grep outline "$f" --view expanded || true
done

Repository: skydoves/compose-stability-analyzer

Length of output: 50391


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the IDEA K2 inference paths for property/state handling.
sed -n '520,760p' compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/k2/KtStabilityInferencer.kt

echo '---'
sed -n '250,360p' compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/StabilityAnalyzer.kt

Repository: skydoves/compose-stability-analyzer

Length of output: 12630


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "### IDEA-side backingField / stored-state references"
rg -n --hidden -S 'backingField|stored state|getter-only|computed getter|representsStoredState|overriddenSymbols' compose-stability-analyzer-idea/src/main/kotlin

echo
echo "### KtStabilityInferencer property-analysis slice"
sed -n '520,760p' compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/k2/KtStabilityInferencer.kt

echo
echo "### StabilityAnalyzer class-analysis slice"
sed -n '260,330p' compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/StabilityAnalyzer.kt

Repository: skydoves/compose-stability-analyzer

Length of output: 226


Mirror the stored-state filter in the IDEA analyzer.

representsStoredState()/resolvesToBackedProperty() changes class, @Parcelize, and value-class inference on the compiler side, but compose-stability-analyzer-idea still analyzes all declared properties in StabilityAnalyzer.kt and k2/KtStabilityInferencer.kt with no backing-field guard. That leaves IDE results diverging on getter-only vals and inherited stored properties.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt`
around lines 795 - 834, The IDEA analyzer still infers stability from every
declared property, so it can disagree with the compiler on getter-only `val`s
and inherited stored properties. Update `StabilityAnalyzer.kt` and
`k2/KtStabilityInferencer.kt` to mirror `IrProperty.representsStoredState()` /
`resolvesToBackedProperty()` by filtering out properties without a backing field
and following override chains for inherited backed properties. Keep the same
stored-state semantics used by `analyzeValueClass()` so class, `@Parcelize`, and
value-class inference match compiler behavior.

Source: Path instructions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use of abstract class with open val interface will result in a false positive

1 participant