Skip to content
Open
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 @@ -58,68 +58,73 @@ public class ComposableStabilityCollector(private val project: Project) {
val contentRoots = moduleRootManager.contentRoots

for (contentRoot in contentRoots) {
// Look for build/stability/stability-info.json
val jsonFile = File(contentRoot.path, "build/stability/stability-info.json")
if (!jsonFile.exists()) {
continue
}

try {
val jsonContent = jsonFile.readText()
val jsonObject = JsonParser.parseString(jsonContent).asJsonObject
val composablesArray = jsonObject.getAsJsonArray("composables")
val rootStabilityFolder = File(contentRoot.path, "build/stability")
val variantFolders = rootStabilityFolder.listFiles().orEmpty().filter { it.isDirectory }
val allStabilityFolders = variantFolders + rootStabilityFolder
for (folder in allStabilityFolders) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +63 to +64
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 14, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve variant identity or dedupe before building stats.

The collector now merges debug, release, *UnitTest, etc. into one flat list but throws away the folder/variant name. Shared composables will show up as indistinguishable duplicates, and StabilityStats will overcount them. Please either carry the variant through the model/UI or collapse exact duplicates before computing the summary.

Also applies to: 110-123, 133-136

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/toolwindow/ComposableStabilityCollector.kt`
around lines 63 - 64, The collector currently flattens variantFolders +
rootStabilityFolder into allStabilityFolders and loses variant identity, causing
duplicate composables to be indistinguishable and StabilityStats to overcount;
update ComposableStabilityCollector to either (A) carry variant metadata with
each folder entry (e.g., change the collection element to a data class or pair
like (variantName, folder) and propagate that through the map/reduce that builds
StabilityStats and the UI model) or (B) deduplicate exact composable entries
before computing the summary (compare by canonical path/signature and collapse
identical entries) and apply the same change to the other aggregation sites
around the blocks that build the stats and UI model so counts reflect unique
composables per variant or globally as intended.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That is a good point, but I feel extra UI is out of scope for this PR. What do you think @skydoves?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

// Look for build/stability/stability-info.json
val jsonFile = File(folder, "stability-info.json")
if (!jsonFile.exists()) {
continue
}

for (composableElement in composablesArray) {
val composableJson = composableElement.asJsonObject
try {
val jsonContent = jsonFile.readText()
val jsonObject = JsonParser.parseString(jsonContent).asJsonObject
val composablesArray = jsonObject.getAsJsonArray("composables")

// Skip anonymous composables
val simpleName = composableJson.get("simpleName").asString
if (simpleName == "<anonymous>") {
continue
}
for (composableElement in composablesArray) {
val composableJson = composableElement.asJsonObject

val qualifiedName = composableJson.get("qualifiedName").asString
val skippable = composableJson.get("skippable").asBoolean
val restartable = composableJson.get("restartable").asBoolean

// Parse parameters
val parametersArray = composableJson.getAsJsonArray("parameters")
val parameters = parametersArray.map { paramElement ->
val paramJson = paramElement.asJsonObject
val stability = paramJson.get("stability").asString
ParameterInfo(
name = paramJson.get("name").asString,
type = paramJson.get("type").asString,
isStable = stability == "STABLE",
isRuntime = stability == "RUNTIME",
)
}
// Skip anonymous composables
val simpleName = composableJson.get("simpleName").asString
if (simpleName == "<anonymous>") {
continue
}

// Try to find the source file and line number
val (filePath, fileName, line) =
ReadAction.compute<Triple<String, String, Int>, Exception> {
findSourceLocation(qualifiedName, simpleName)
val qualifiedName = composableJson.get("qualifiedName").asString
val skippable = composableJson.get("skippable").asBoolean
val restartable = composableJson.get("restartable").asBoolean

// Parse parameters
val parametersArray = composableJson.getAsJsonArray("parameters")
val parameters = parametersArray.map { paramElement ->
val paramJson = paramElement.asJsonObject
val stability = paramJson.get("stability").asString
ParameterInfo(
name = paramJson.get("name").asString,
type = paramJson.get("type").asString,
isStable = stability == "STABLE",
isRuntime = stability == "RUNTIME",
)
}

val packageName = qualifiedName.substringBeforeLast(".$simpleName", "")

composables.add(
ComposableInfo(
functionName = simpleName,
moduleName = module.name,
packageName = packageName.ifEmpty { "<default>" },
fileName = fileName,
filePath = filePath,
line = line,
isSkippable = skippable,
isRestartable = restartable,
isRuntime = !skippable && restartable,
parameters = parameters,
),
)
// Try to find the source file and line number
val (filePath, fileName, line) =
ReadAction.compute<Triple<String, String, Int>, Exception> {
findSourceLocation(qualifiedName, simpleName)
}

val packageName = qualifiedName.substringBeforeLast(".$simpleName", "")

composables.add(
ComposableInfo(
functionName = simpleName,
moduleName = module.name,
packageName = packageName.ifEmpty { "<default>" },
fileName = fileName,
filePath = filePath,
line = line,
isSkippable = skippable,
isRestartable = restartable,
isRuntime = !skippable && restartable,
parameters = parameters,
),
)
}
} catch (e: Exception) {
// Skip modules that fail to parse
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch (e: Exception) {
// Skip modules that fail to parse
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,6 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
} else {
registerTasksAndroid(target, extension, androidComponents)
}

// Add output parameter to the Kotlin tasks to ensure it is compatible with the Build Cache
target.tasks.withType(KotlinCompile::class.java).configureEach {
val stabilityDir = target.layout.buildDirectory.dir("stability").get()
outputs.dir(stabilityDir).optional(true)
}
}

private fun registerTasksNonAndroid(
Expand All @@ -93,6 +87,9 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
stabilityInputFiles.setFrom(
target.layout.buildDirectory.file("stability/stability-info.json"),
)
stabilityInputFiles.setFrom(
target.layout.buildDirectory.file("stability/test/stability-info.json"),
)
Comment on lines 87 to +92
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: Second setFrom() overwrites the first input file.

setFrom() replaces the entire file collection. The call at line 90 discards the file added at line 87, so the dump task will only read from stability/test/stability-info.json. Use from() for the second call to append instead.

🐛 Proposed fix
       stabilityInputFiles.setFrom(
         target.layout.buildDirectory.file("stability/stability-info.json"),
       )
-      stabilityInputFiles.setFrom(
+      stabilityInputFiles.from(
         target.layout.buildDirectory.file("stability/test/stability-info.json"),
       )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerGradlePlugin.kt`
around lines 87 - 92, The code uses stabilityInputFiles.setFrom(...) twice which
causes the second call to overwrite the first; change the second invocation to
stabilityInputFiles.from(target.layout.buildDirectory.file("stability/test/stability-info.json"))
so the test file is appended instead of replacing the initial
stability/stability-info.json entry, ensuring stabilityInputFiles contains both
files.

outputDir.set(extension.stabilityValidation.outputDir)
ignoredPackages.set(extension.stabilityValidation.ignoredPackages)
ignoredClasses.set(extension.stabilityValidation.ignoredClasses)
Expand All @@ -109,6 +106,9 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
stabilityInputFiles.from(
target.layout.buildDirectory.file("stability/stability-info.json"),
)
stabilityInputFiles.from(
target.layout.buildDirectory.file("stability/test/stability-info.json"),
)
stabilityReferenceFiles.from(extension.stabilityValidation.outputDir)
ignoredPackages.set(extension.stabilityValidation.ignoredPackages)
ignoredClasses.set(extension.stabilityValidation.ignoredClasses)
Expand All @@ -131,6 +131,21 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
configureTaskDependencies(target, extension, null, stabilityDumpTask, stabilityCheckTask)
addRuntimeDependency(target)
}

// Add output parameter to the Kotlin tasks to ensure it is compatible with the Build Cache
target.tasks.withType(KotlinCompile::class.java)
.named {
isKotlinTaskApplicable(
it,
extension.stabilityValidation.includeTests.get(),
)
}
.configureEach {
val stabilityDir = target.layout.buildDirectory
.dir(getKotlinTaskStabilityFolderName(project, name))
.get()
outputs.dir(stabilityDir).optional(true)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private fun registerTasksAndroid(
Expand Down Expand Up @@ -158,7 +173,17 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
) {
projectName.set(target.name)
stabilityInputFiles.setFrom(
target.layout.buildDirectory.file("stability/stability-info.json"),
target.layout.buildDirectory.file("stability/${variant.name}/stability-info.json"),
)
stabilityInputFiles.setFrom(
target.layout.buildDirectory.file(
"stability/${variant.name}UnitTest/stability-info.json",
),
)
stabilityInputFiles.setFrom(
target.layout.buildDirectory.file(
"stability/${variant.name}AndroidTest/stability-info.json",
),
)
Comment on lines 175 to 187
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: Repeated setFrom() calls overwrite previous input files.

Same issue as the non-Android path—each setFrom() replaces the collection, so only the AndroidTest file will be read. The main variant and UnitTest files are discarded.

🐛 Proposed fix
       stabilityInputFiles.setFrom(
         target.layout.buildDirectory.file("stability/${variant.name}/stability-info.json"),
       )
-      stabilityInputFiles.setFrom(
+      stabilityInputFiles.from(
         target.layout.buildDirectory.file(
           "stability/${variant.name}UnitTest/stability-info.json",
         ),
       )
-      stabilityInputFiles.setFrom(
+      stabilityInputFiles.from(
         target.layout.buildDirectory.file(
           "stability/${variant.name}AndroidTest/stability-info.json",
         ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stability-gradle/src/main/kotlin/com/skydoves/compose/stability/gradle/StabilityAnalyzerGradlePlugin.kt`
around lines 175 - 187, The stabilityInputFiles property is being overwritten by
repeated setFrom() calls so only the last file (AndroidTest) is kept; update the
code that sets stabilityInputFiles (references: stabilityInputFiles and
variant.name in StabilityAnalyzerGradlePlugin.kt) to aggregate all three files
instead of replacing them—use a single setFrom(...) with a collection of files
or call from(...) / addAll(...) for each file (e.g., gather the three
target.layout.buildDirectory.file(...) results into a list and pass that to
stabilityInputFiles.setFrom or call stabilityInputFiles.from(...) for each) so
main variant, UnitTest and AndroidTest files are all included.

outputDir.set(extension.stabilityValidation.outputDir)
ignoredPackages.set(extension.stabilityValidation.ignoredPackages)
Expand All @@ -175,7 +200,17 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
) {
projectName.set(target.name)
stabilityInputFiles.from(
target.layout.buildDirectory.file("stability/stability-info.json"),
target.layout.buildDirectory.file("stability/${variant.name}/stability-info.json"),
)
stabilityInputFiles.from(
target.layout.buildDirectory.file(
"stability/${variant.name}UnitTest/stability-info.json",
),
)
stabilityInputFiles.from(
target.layout.buildDirectory.file(
"stability/${variant.name}AndroidTest/stability-info.json",
),
)
stabilityReferenceFiles.from(extension.stabilityValidation.outputDir)
ignoredPackages.set(extension.stabilityValidation.ignoredPackages)
Expand Down Expand Up @@ -212,6 +247,22 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
stabilityCheckTask,
)
}

// Add output parameter to the Kotlin tasks to ensure it is compatible with the Build Cache
target.tasks.withType(KotlinCompile::class.java)
.named {
it.contains(variantNameUpperCase) && isKotlinTaskApplicable(
it,
extension.stabilityValidation.includeTests.get(),
)
}
.configureEach {
val stabilityDir =
target.layout.buildDirectory
.dir(getKotlinTaskStabilityFolderName(project, name))
.get()
outputs.dir(stabilityDir).optional(true)
}
}

target.afterEvaluate {
Expand Down Expand Up @@ -266,10 +317,19 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
return project.provider {
val projectDependencies = collectProjectDependencies(project)

val stabilityFolderName = getKotlinTaskStabilityFolderName(
project,
kotlinCompilation.compileKotlinTaskName,
)

// Write project dependencies to a file to avoid empty string issues with SubpluginOption
val stabilityDir = project.layout.buildDirectory.dir("stability").get().asFile
val stabilityDir = project.layout.buildDirectory.dir(stabilityFolderName).get().asFile
stabilityDir.mkdirs()
val dependenciesFile = java.io.File(stabilityDir, "project-dependencies.txt")

val variantlessStabilityDir = project.layout.buildDirectory.dir("stability").get().asFile
variantlessStabilityDir.mkdirs()

val dependenciesFile = java.io.File(variantlessStabilityDir, "project-dependencies.txt")
dependenciesFile.writeText(projectDependencies.joinToString("\n"))

listOf(
Expand All @@ -289,6 +349,32 @@ public class StabilityAnalyzerGradlePlugin : KotlinCompilerPluginSupportPlugin {
}
}

private fun getKotlinTaskStabilityFolderName(
project: Project,
taskName: String,
): String {
val variant =
if (project.extensions.findByType(AndroidComponentsExtension::class.java) != null) {
taskName
.removePrefix("compile")
.removeSuffix("Kotlin")
.replaceFirstChar { it.lowercase() }
} else {
if (taskName.contains("Test")) {
"test"
} else {
""
}
}

val stabilityFolderName = if (variant.isBlank()) {
"stability"
} else {
"stability/$variant"
}
return stabilityFolderName
}

/**
* Add runtime to compiler plugin classpath.
* This ensures the compiler plugin can access runtime classes during compilation.
Expand Down
Loading