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
1 change: 1 addition & 0 deletions featured-gradle-plugin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ featured {
localFlags {
boolean("dark_mode", default = false) { category = "UI" }
int("max_retries", default = 3)
enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY")
}
remoteFlags {
boolean("promo_banner", default = false) { description = "Show promo banner" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,11 @@ public object ConfigParamGenerator {
}

private fun LocalFlagEntry.formatDefault(): String =
when (type) {
"String" -> defaultValue
"Long" -> "${defaultValue}L"
"Float" -> "${defaultValue}f"
when {
isEnum -> "$type.$defaultValue"
type == "String" -> defaultValue
type == "Long" -> "${defaultValue}L"
type == "Float" -> "${defaultValue}f"
else -> defaultValue
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package dev.androidbroadcast.featured.gradle
* boolean("dark_mode", default = false) { category = "UI" }
* int("retry_count", default = 3)
* string("api_base_url", default = "https://example.com")
* enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY")
* }
* remoteFlags {
* boolean("promo_banner_enabled", default = false) {
Expand Down Expand Up @@ -80,6 +81,29 @@ public class FlagContainer {
_flags += FlagSpec(key, "\"$default\"", "String").apply(configure)
}

/**
* Declares an enum-typed feature flag.
*
* Enum flags are intentionally excluded from R8 `-assumevalues` DCE rules — the value
* cannot be assumed at build time (it is resolved at runtime from providers).
*
* @param key The configuration key string (e.g. `"checkout_variant"`).
* @param typeFqn The fully-qualified Kotlin class name of the enum (e.g. `"com.example.CheckoutVariant"`).
* @param default The name of the default enum constant (e.g. `"LEGACY"`).
* @param configure Optional block to set [FlagSpec.description], [FlagSpec.category], or [FlagSpec.expiresAt].
*/
public fun enum(
key: String,
typeFqn: String,
default: String,
configure: FlagSpec.() -> Unit = {},
) {
require('.' in typeFqn) {
"typeFqn must be a fully-qualified class name (e.g. \"com.example.MyEnum\"), got \"$typeFqn\""
}
_flags += FlagSpec(key = key, defaultValue = default, type = typeFqn).apply(configure)
}

Comment on lines +100 to +106
/** Serialises all flags to pipe-delimited descriptors for [ResolveFlagsTask] inputs. */
internal fun toDescriptors(): List<String> = _flags.map { it.toDescriptor() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ internal fun String.capitalized(): String = replaceFirstChar { it.uppercase() }
* Returns the name of the generated `ConfigValues` extension function for this flag.
*
* - Boolean flags: `is<Name>Enabled` (e.g. `isDarkModeEnabled`)
* - All other types: `get<Name>` (e.g. `getMaxRetries`)
* - All other types (including enum): `get<Name>` (e.g. `getMaxRetries`, `getCheckoutVariant`)
*/
internal fun LocalFlagEntry.extensionFunctionName(): String {
val capitalized = propertyName.capitalized()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ package dev.androidbroadcast.featured.gradle
*
* @property key The configuration key string (e.g. `"dark_mode"`). Acts as the unique identifier.
* @property defaultValue The default value serialised to a string (e.g. `"false"`, `"42"`).
* @property type The Kotlin type name: `"Boolean"`, `"Int"`, `"Long"`, `"Float"`, `"Double"`, or `"String"`.
* @property type The Kotlin type name: `"Boolean"`, `"Int"`, `"Long"`, `"Float"`, `"Double"`, `"String"`,
* or a fully-qualified enum class name (e.g. `"com.example.CheckoutVariant"`).
* @property description Optional human-readable description passed to the generated [ConfigParam].
* @property category Optional grouping label shown in the debug UI.
* @property expiresAt Optional ISO-8601 date (`"YYYY-MM-DD"`) after which the flag is considered stale.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,28 @@ private const val HEADER = "// Auto-generated by featured-gradle-plugin — do n
*
* String values are wrapped in double-quotes; all other primitive types are emitted
* verbatim (matching Kotlin literal syntax for Boolean, Int, Long, Double, Float).
*
* **Enum flags are not supported** — Kotlin `const val` only accepts primitive types
* and `String`. Enum entries are silently skipped in both [generate] and [generateExpect].
*/
public object IosConstValGenerator {
/**
* Generates the `iosMain` Kotlin source file containing `actual const val`
* declarations for every entry in [entries].
*
* Returns a blank string when [entries] is empty.
* Enum-typed entries (detected via [LocalFlagEntry.isEnum]) are excluded because
* `const val` does not support enum types.
*
* Returns a blank string when [entries] is empty or all entries are enums.
*/
public fun generate(entries: List<LocalFlagEntry>): String {
if (entries.isEmpty()) return ""
val eligible = entries.filterNot { it.isEnum }
if (eligible.isEmpty()) return ""
Comment on lines +33 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Ios includes remote flags 🐞 Bug ≡ Correctness

IosConstValGenerator filters out enum-typed entries but still generates expect/actual declarations
for remote flags, freezing remote defaults into compile-time constants. Because the iOS generation
task reads ResolveFlagsTask output containing both local and remote flags, this can incorrectly
eliminate runtime-configurable behavior on iOS.
Agent Prompt
### Issue description
`GenerateIosConstValTask` reads the unified flags file produced by `ResolveFlagsTask` (which includes both local and remote flags). `IosConstValGenerator` currently only filters out enums, so remote flags still end up as `expect val` / `actual const val`, incorrectly turning runtime-resolved remote values into compile-time constants.

### Issue Context
Other generators explicitly treat local and remote differently (e.g., ProGuard rules are local-only). iOS const-val generation should similarly operate on local flags only.

### Fix Focus Areas
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt[45-56]
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt[117-132]
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt[53-60]
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt[64-69]
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt[60-71]
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt[32-64]

### Suggested change
- In both `generate` and `generateExpect`, change eligibility to:
  - `val eligible = entries.filter { it.isLocal && !it.isEnum }`
- Add/update tests to include a remote flag and assert it is absent from iOS output.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return buildString {
appendLine(HEADER)
appendLine("package $GENERATED_PACKAGE")
appendLine()
entries.forEach { entry ->
eligible.forEach { entry ->
appendLine(actualDeclaration(entry))
Comment on lines 32 to 40
}
}
Expand All @@ -39,15 +46,19 @@ public object IosConstValGenerator {
* Generates the `commonMain` Kotlin source file containing `expect val`
* declarations for every entry in [entries].
*
* Returns a blank string when [entries] is empty.
* Enum-typed entries (detected via [LocalFlagEntry.isEnum]) are excluded because
* `const val` does not support enum types.
*
* Returns a blank string when [entries] is empty or all entries are enums.
*/
public fun generateExpect(entries: List<LocalFlagEntry>): String {
if (entries.isEmpty()) return ""
val eligible = entries.filterNot { it.isEnum }
if (eligible.isEmpty()) return ""
return buildString {
appendLine(HEADER)
appendLine("package $GENERATED_PACKAGE")
appendLine()
entries.forEach { entry ->
eligible.forEach { entry ->
appendLine("public expect val ${entry.key}: ${entry.type}")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ package dev.androidbroadcast.featured.gradle
*
* @property key The configuration key string (e.g. `"dark_mode"`).
* @property defaultValue The default value as a raw string (e.g. `"false"`, `"42"`).
* @property type The Kotlin type name: `"Boolean"`, `"Int"`, `"Long"`, `"Float"`, `"Double"`, or `"String"`.
* @property type The Kotlin type name: `"Boolean"`, `"Int"`, `"Long"`, `"Float"`, `"Double"`, `"String"`,
* or a fully-qualified enum class name (e.g. `"com.example.CheckoutVariant"`).
* @property moduleName The Gradle module path that declares this flag (e.g. `":feature:checkout"`).
* @property propertyName The camelCase property name derived from [key] (e.g. `"darkMode"`).
* @property flagType Either `"local"` or `"remote"`.
Expand All @@ -26,6 +27,17 @@ public data class LocalFlagEntry(
) {
public val isLocal: Boolean get() = flagType == FLAG_TYPE_LOCAL

/**
* Returns `true` when this flag's type is an enum (i.e. a fully-qualified class name
* containing a `.`) rather than a built-in Kotlin primitive or `String`.
*
* Enum flags require special handling in code generators:
* - ProGuard `-assumevalues` rules are skipped (enum values are not assumable at build time).
* - iOS `const val` generation is skipped (`const` only supports primitive types and `String`).
* - The generated `ConfigParam` default expression uses `TypeFqn.CONSTANT_NAME` syntax.
*/
public val isEnum: Boolean get() = '.' in type

/**
* Returns the Kotlin reference used in the generated `FlagRegistry.register(...)` call.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ package dev.androidbroadcast.featured.gradle
* cannot be assumed at build time.
*
* Supported return types: `boolean`, `int`, `long`, `float`, `double`, `java.lang.String`.
*
* **Enum flags are intentionally excluded** — their runtime values are resolved from providers
* and cannot be assumed at build time (see issue #162). [jvmType] returns `null` for any
* unrecognised type (including enum FQNs), which causes those entries to be filtered out.
*/
public object ProguardRulesGenerator {
private const val PACKAGE = "dev.androidbroadcast.featured.generated"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ android {
featured {
localFlags {
boolean("dark_mode", default = false)
enum("checkout_variant", typeFqn = "dev.androidbroadcast.featured.testapp.CheckoutVariant", default = "LEGACY")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dev.androidbroadcast.featured.testapp

enum class CheckoutVariant {
LEGACY,
NEW,
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ class ConfigParamGeneratorTest {
assertContains(local, "Auto-generated by Featured Gradle Plugin")
}

// ── enum flags ────────────────────────────────────────────────────────────

@Test
fun `generates enum ConfigParam with fqn type argument`() {
val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant"))
val (local, _) = ConfigParamGenerator.generate(entries)
assertContains(local, "ConfigParam<com.example.CheckoutVariant>")
}

@Test
fun `enum default value uses fqn dot constant syntax`() {
val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant"))
val (local, _) = ConfigParamGenerator.generate(entries)
assertContains(local, "defaultValue = com.example.CheckoutVariant.LEGACY")
}

@Test
fun `enum flag is included in local object`() {
val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant"))
val (local, _) = ConfigParamGenerator.generate(entries)
assertContains(local, "val checkoutVariant = ConfigParam<com.example.CheckoutVariant>")
}

// ── helpers ───────────────────────────────────────────────────────────────

private fun localEntry(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ class ExtensionFunctionGeneratorTest {
assertContains(source, "fun ConfigValues.getApiUrl(): String")
}

// ── local enum flag ───────────────────────────────────────────────────────

@Test
fun `generates get… extension for local enum flag`() {
val entries = listOf(localEntry("checkout_variant", "com.example.CheckoutVariant"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "fun ConfigValues.getCheckoutVariant(): com.example.CheckoutVariant")
assertContains(source, "getValue(GeneratedLocalFlags.checkoutVariant).value")
}

@Test
fun `enum extension uses get… prefix, not is…Enabled`() {
val entries = listOf(localEntry("checkout_variant", "com.example.CheckoutVariant"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertFalse(source.contains("isCheckoutVariantEnabled"), "Enum flag must not use is…Enabled naming")
}

// ── remote flag ───────────────────────────────────────────────────────────

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ class FeaturedPluginIntegrationTest {

/**
* Asserts that [content] contains a well-formed `-assumevalues` block targeting the
* extensions class for the root module (`:`) and the `dark_mode` boolean flag.
* extensions class for the root module (`:`) and the `dark_mode` boolean flag,
* and that the enum flag `checkout_variant` is NOT present in the rules.
*
* Expected output (from [ProguardRulesGenerator]):
* ```proguard
Expand All @@ -111,6 +112,9 @@ class FeaturedPluginIntegrationTest {
*
* The root module path `:` produces the identifier `Root` via [String.modulePathToIdentifier],
* so the JVM class name is `FeaturedRoot_FlagExtensionsKt`.
*
* Enum flags (`checkout_variant`) must not appear in `-assumevalues` rules — their values
* are resolved at runtime from providers and cannot be assumed at build time (issue #162).
*/
private fun assertContainsAssumevaluesBlock(content: String) {
assertTrue(
Expand All @@ -121,6 +125,10 @@ class FeaturedPluginIntegrationTest {
content.contains("boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false;"),
"Expected 'boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false;' in rules\nActual content:\n$content",
)
assertTrue(
!content.contains("checkoutVariant"),
"Enum flag 'checkout_variant' must not appear in -assumevalues rules\nActual content:\n$content",
)
}

// ── Helpers ───────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,42 @@ class FlagContainerTest {
fun `empty container has no flags`() {
assertTrue(FlagContainer().flags.isEmpty())
}

@Test
fun `enum flag stores fully-qualified type name`() {
val container =
FlagContainer().apply {
enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY")
}
val flag = container.flags.single()
assertEquals("checkout_variant", flag.key)
assertEquals("com.example.CheckoutVariant", flag.type)
assertEquals("LEGACY", flag.defaultValue)
}

@Test
fun `enum flag configure block sets description and category`() {
val container =
FlagContainer().apply {
enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY") {
description = "Checkout flow variant"
category = "Checkout"
}
}
val flag = container.flags.single()
assertEquals("Checkout flow variant", flag.description)
assertEquals("Checkout", flag.category)
}

@Test
fun `enum flag descriptor contains type fqn and default constant`() {
val container =
FlagContainer().apply {
enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY")
}
val descriptor = container.toDescriptors().single()
assertContains(descriptor, "checkout_variant")
assertContains(descriptor, "com.example.CheckoutVariant")
assertContains(descriptor, "LEGACY")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev.androidbroadcast.featured.gradle

import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class IosConstValGeneratorTest {
Expand Down Expand Up @@ -163,4 +164,70 @@ class IosConstValGeneratorTest {
val result = IosConstValGenerator.generateExpect(emptyList())
assertTrue(result.isBlank(), "Expected blank output for empty entries, got: '$result'")
}

// ── enum flag exclusion ───────────────────────────────────────────────────

@Test
fun `generate skips enum-typed entries`() {
val entries =
listOf(
LocalFlagEntry(key = "dark_mode", defaultValue = "false", type = "Boolean", moduleName = ":app"),
LocalFlagEntry(
key = "checkout_variant",
defaultValue = "LEGACY",
type = "com.example.CheckoutVariant",
moduleName = ":app",
),
)
val result = IosConstValGenerator.generate(entries)
assertContains(result, "public actual const val dark_mode: Boolean = false")
assertFalse(result.contains("checkout_variant"), "Enum flags must not appear in iOS const val output")
}

@Test
fun `generate returns blank when only enum entries are present`() {
val entries =
listOf(
LocalFlagEntry(
key = "checkout_variant",
defaultValue = "LEGACY",
type = "com.example.CheckoutVariant",
moduleName = ":app",
),
)
val result = IosConstValGenerator.generate(entries)
assertTrue(result.isBlank(), "Expected blank output when all entries are enums, got: '$result'")
}

@Test
fun `generateExpect skips enum-typed entries`() {
val entries =
listOf(
LocalFlagEntry(key = "dark_mode", defaultValue = "false", type = "Boolean", moduleName = ":app"),
LocalFlagEntry(
key = "checkout_variant",
defaultValue = "LEGACY",
type = "com.example.CheckoutVariant",
moduleName = ":app",
),
)
val result = IosConstValGenerator.generateExpect(entries)
assertContains(result, "public expect val dark_mode: Boolean")
assertFalse(result.contains("checkout_variant"), "Enum flags must not appear in iOS expect declarations")
}

@Test
fun `generateExpect returns blank when only enum entries are present`() {
val entries =
listOf(
LocalFlagEntry(
key = "checkout_variant",
defaultValue = "LEGACY",
type = "com.example.CheckoutVariant",
moduleName = ":app",
),
)
val result = IosConstValGenerator.generateExpect(entries)
assertTrue(result.isBlank(), "Expected blank output when all entries are enums, got: '$result'")
}
}
Loading
Loading