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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Sample build / install:

- `generateConfigParam` — typed `ConfigParam` objects + `ConfigValues` extensions
- `generateFeaturedProguardRules` — R8 `-assumevalues` rules for local flags
- `generateFeaturedCheckDiscardRules` — R8 `-checkdiscard` rules that *verify* flag-guarded code was stripped (opt-in via `discard(...)` on a local boolean default-`false` flag; fails the build if a "disabled" feature survives)
- `generateIosConstVal` / `generateXcconfig` — Swift DCE inputs
- `generateFeaturedManifest` — emits `featured-manifest.json` consumed by the aggregator
- `generateFeaturedRegistry` (aggregator-only) — produces `GeneratedFeaturedRegistry.kt`
Expand Down Expand Up @@ -110,12 +111,12 @@ For non-reactive reads (logging, eager-conditional paths) use `configValues.getV
- **Branching:** `develop` is the integration branch; PRs go to `develop`, not `main`. `main` is updated only on releases. One logical change per PR — do not bundle.
- **Comment language:** English (per `.github/copilot-instructions.md`).
- **iOS:** SKIE is applied in `:core`; the XCFramework is named `FeaturedCore`. SKIE config is `skie.toml` at repo root.
- **R8:** the project relies on `android.enableR8.fullMode=true` and `android.r8.strictInputValidation=true`. The generated ProGuard rules + `-assumevalues` are what make DCE work.
- **R8:** the project relies on `android.enableR8.fullMode=true` and `android.r8.strictInputValidation=true`. The generated ProGuard rules + `-assumevalues` are what make DCE work; the opt-in `discard(...)` → `-checkdiscard` rules *prove* the disabled-flag code left the binary (the final DEX is otherwise checked by nothing — unit tests see the pre-R8 classpath, `androidTest` runs without minify).

## Where to Look First When…

- "Find how the DSL is parsed" → `featured-gradle-plugin/src/main/kotlin/.../FeaturedExtension.kt`, `FlagSpec.kt`, `FlagContainer.kt`.
- "Find codegen output shape" → `ConfigParamGenerator.kt`, `ExtensionFunctionGenerator.kt`, `ProguardRulesGenerator.kt`, `XcconfigGenerator.kt`, `IosConstValGenerator.kt` (all in `featured-gradle-plugin/src/main/kotlin/`).
- "Find codegen output shape" → `ConfigParamGenerator.kt`, `ExtensionFunctionGenerator.kt`, `ProguardRulesGenerator.kt`, `CheckDiscardRulesGenerator.kt`, `XcconfigGenerator.kt`, `IosConstValGenerator.kt` (all in `featured-gradle-plugin/src/main/kotlin/`).
- "Find aggregator wiring" → `FeaturedApplicationPlugin.kt` + `aggregation/` subpackage.
- "Find manifest format" → `manifest/` subpackage (`GenerateFeaturedManifestTask.kt`, `SCHEMA_VERSION`).
- "Verify R8 DCE behaviour" → `featured-shrinker-tests/` (integration tests over real `assembleRelease`).
Expand Down
17 changes: 17 additions & 0 deletions featured-gradle-plugin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ featured {
boolean("dark_mode", default = false) { category = "UI" }
int("max_retries", default = 3)
enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY")
boolean("new_checkout", default = false) {
// assert the guarded code is gone from release binaries (R8 -checkdiscard)
discard("com.example.checkout.newflow.**")
}
}
remoteFlags {
boolean("promo_banner", default = false) { description = "Show promo banner" }
Expand All @@ -39,13 +43,26 @@ featured {
- ProGuard rules and the iOS const-val files follow the **local** section's effective package.
- The iOS expect/actual `const val` declarations stay `public` regardless of `visibility`.

`discard(...)` notes (per local boolean flag, default `false` only):

- Emits an R8 `-checkdiscard class <spec> { *; }` rule that **verifies** the flag-guarded code
was actually dead-code-eliminated in release builds — the companion check to the `-assumevalues`
rule that causes the elimination. If anything keeps the code alive (a forgotten DI reference, a
manifest entry, reflection, an over-broad `-keep`), R8 fails the build with `Discard checks
failed`; diagnose with `-whyareyoukeeping`.
- Wired like `-assumevalues`: into the app's own R8 for application modules, and as a consumer
ProGuard file for library modules so the check runs in the consuming app's R8.
- Rejected on non-boolean flags, on `default = true` flags, and on remote flags (a runtime-resolved
value is never pinned at build time). Covers **code only** — resource shrinking is not verified.

## Tasks registered per module

| Task | Output |
|------|--------|
| `resolveFeatureFlags` | `build/featured/flags.txt` |
| `generateConfigParam` | `build/generated/featured/commonMain/Generated{Local,Remote}Flags.kt` + `Generated{Local,Remote}FlagExtensions.kt` (file names follow custom `className`s) |
| `generateFeaturedProguardRules` | `build/featured/proguard-featured.pro` |
| `generateFeaturedCheckDiscardRules` | `build/featured/proguard-featured-checkdiscard.pro` |
| `generateIosConstVal` | iOS constant value files |
| `generateXcconfig` | `build/featured/FeatureFlags.generated.xcconfig` |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@ import org.gradle.api.Project
import org.gradle.api.tasks.TaskProvider

/**
* Wires the generated ProGuard rules file into every application variant via the AGP Variant API.
* Wires the generated ProGuard rules files into every application variant via the AGP Variant API.
*
* Called lazily — only when `com.android.application` is present on the project.
*
* Both the `-assumevalues` rules ([proguardTask]) and the `-checkdiscard` rules
* ([checkDiscardTask]) are added: the first drives flag dead-code elimination, the second
* verifies it in the same R8 run. The check-discard file is empty unless a flag declares
* `discard(...)`, and an empty ProGuard configuration is a harmless no-op.
*
* AGP 9.x does not propagate implicit Gradle task dependencies through [Variant.proguardFiles],
* so [proguardTask] is also wired explicitly as a dependency of every `minify*WithR8` task.
* so both tasks are also wired explicitly as dependencies of every `minify*WithR8` task.
*/
internal fun wireProguardToApplicationVariants(
project: Project,
proguardTask: TaskProvider<GenerateProguardRulesTask>,
checkDiscardTask: TaskProvider<GenerateCheckDiscardRulesTask>,
) {
val androidComponents =
project.extensions
Expand All @@ -25,32 +31,39 @@ internal fun wireProguardToApplicationVariants(
variant.proguardFiles.add(
proguardTask.flatMap { it.outputFile },
)
variant.proguardFiles.add(
checkDiscardTask.flatMap { it.outputFile },
)
}
// AGP 9.x does not propagate implicit task dependencies through variant.proguardFiles,
// so we wire an explicit dependsOn on every R8 minify task.
project.tasks.configureEach { task ->
if (task.name.startsWith("minify") && task.name.endsWith("WithR8")) {
task.dependsOn(proguardTask)
task.dependsOn(checkDiscardTask)
}
}
}

/**
* Wires the generated ProGuard rules file as consumer ProGuard rules for every library variant.
* Wires the generated ProGuard rules files as consumer ProGuard rules for every library variant.
*
* Called lazily — only when `com.android.library` is present on the project.
*
* Library modules do not run R8 themselves, so [Variant.proguardFiles] would never be applied.
* Consumer ProGuard rules are bundled into the AAR and forwarded to every consuming app's R8,
* which is where the `-assumevalues` rules need to run for flag dead-code elimination to work.
* The `-checkdiscard` rules ([checkDiscardTask]) ride along the same path so the discard check
* runs in the consuming app's R8 — exactly where the matching `-assumevalues` rule applies.
*
* AGP 9.x does not propagate implicit Gradle task dependencies through
* [LibraryVariant.consumerProguardFiles], so [proguardTask] is also wired explicitly as a
* [LibraryVariant.consumerProguardFiles], so both tasks are also wired explicitly as a
* dependency of every `export*ConsumerProguardFiles` task.
*/
internal fun wireProguardToLibraryVariants(
project: Project,
proguardTask: TaskProvider<GenerateProguardRulesTask>,
checkDiscardTask: TaskProvider<GenerateCheckDiscardRulesTask>,
) {
val libraryComponents =
project.extensions
Expand All @@ -59,13 +72,17 @@ internal fun wireProguardToLibraryVariants(
variant.consumerProguardFiles.add(
proguardTask.flatMap { it.outputFile },
)
variant.consumerProguardFiles.add(
checkDiscardTask.flatMap { it.outputFile },
)
}
// AGP 9.x does not propagate implicit task dependencies through
// variant.consumerProguardFiles, so we wire an explicit dependsOn on every
// export*ConsumerProguardFiles task (AGP's task for packaging consumer rules into the AAR).
project.tasks.configureEach { task ->
if (task.name.startsWith("export") && task.name.endsWith("ConsumerProguardFiles")) {
task.dependsOn(proguardTask)
task.dependsOn(checkDiscardTask)
}
}
}
Expand All @@ -81,14 +98,17 @@ internal fun wireProguardToLibraryVariants(
*
* Consumer ProGuard rules are bundled into the AAR and forwarded to every consuming app's R8,
* which is where the `-assumevalues` rules need to run for flag dead-code elimination to work.
* The `-checkdiscard` rules ([checkDiscardTask]) ride along the same path so the discard check
* runs in the consuming app's R8.
*
* AGP 9.x does not propagate implicit Gradle task dependencies through
* [KotlinMultiplatformAndroidVariant.consumerProguardFiles], so [proguardTask] is also wired
* [KotlinMultiplatformAndroidVariant.consumerProguardFiles], so both tasks are also wired
* explicitly as a dependency of every `export*ConsumerProguardFiles` task.
*/
internal fun wireProguardToKmpLibraryVariants(
project: Project,
proguardTask: TaskProvider<GenerateProguardRulesTask>,
checkDiscardTask: TaskProvider<GenerateCheckDiscardRulesTask>,
) {
val kmpComponents =
project.extensions
Expand All @@ -97,10 +117,14 @@ internal fun wireProguardToKmpLibraryVariants(
variant.consumerProguardFiles.add(
proguardTask.flatMap { it.outputFile },
)
variant.consumerProguardFiles.add(
checkDiscardTask.flatMap { it.outputFile },
)
}
project.tasks.configureEach { task ->
if (task.name.startsWith("export") && task.name.endsWith("ConsumerProguardFiles")) {
task.dependsOn(proguardTask)
task.dependsOn(checkDiscardTask)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package dev.androidbroadcast.featured.gradle

/**
* A single `-checkdiscard` target: a class specification that must be absent from the
* optimized release binary, together with the flag that guards it.
*
* @property flagKey The local boolean flag (default `false`) that guards [classSpec]. Used only
* for the generated comment so a failing check points at the responsible flag.
* @property classSpec An R8/ProGuard class specification (e.g. `com.example.featurex.**`).
*/
public data class CheckDiscardEntry(
public val flagKey: String,
public val classSpec: String,
)

/**
* Generates R8 `-checkdiscard` rules that **verify** flag-guarded code was actually removed
* from the optimized release binary.
*
* This is the verification half of the dead-code-elimination story. [ProguardRulesGenerator]
* emits `-assumevalues` rules that *cause* R8 to fold a local flag to its default and drop the
* disabled branch; `-checkdiscard` then *asserts* the result — if any listed class survives,
* R8 fails the build with `Discard checks failed` and prints the retention path (diagnose with
* `-whyareyoukeeping`).
*
* Unit tests see the full classpath before R8 runs and `androidTest` is assembled without
* minification, so without `-checkdiscard` nothing checks the final DEX. A forgotten DI
* reference, a manifest entry, or reflection that keeps the feature alive is caught at build
* time instead of shipping dead code (or worse, a still-reachable disabled feature).
*
* Each target is declared via `discard(...)` on a local boolean flag whose default is `false`
* (see [FlagSpec.discard]); the matching `-assumevalues` rule is what makes the discard
* guaranteed to hold in release builds.
*
* Example output for `boolean("new_checkout", default = false) { discard("com.example.checkout.newflow.**") }`:
* ```proguard
* # guarded by flag: new_checkout
* -checkdiscard class com.example.checkout.newflow.** { *; }
* ```
*
* The directive covers **code only** — it does not verify that resources are stripped.
*/
public object CheckDiscardRulesGenerator {
/**
* Generates `-checkdiscard` rules for all [entries].
*
* Entries with a blank [CheckDiscardEntry.classSpec] are skipped. Returns a blank string
* when no entry contributes a rule, so the output file can be wired into R8 unconditionally
* (an empty ProGuard configuration is a valid no-op).
*/
public fun generate(entries: List<CheckDiscardEntry>): String {
val valid =
entries.mapNotNull { entry ->
val spec = entry.classSpec.trim()
if (spec.isEmpty()) null else entry.copy(classSpec = spec)
}
if (valid.isEmpty()) return ""

val header =
listOf(
"# Auto-generated by Featured Gradle Plugin — do not edit manually.",
"# -checkdiscard asserts these classes are absent from the optimized (R8) release binary.",
"# Each target is guarded by a local boolean flag pinned to false via the generated",
"# -assumevalues rule, so the flag-guarded code must be dead-code-eliminated.",
"# If a target survives, R8 fails the build with \"Discard checks failed\" and the",
"# retention path. Diagnose what keeps a class alive with -whyareyoukeeping.",
)
val rules =
valid.map { entry ->
"# guarded by flag: ${entry.flagKey}\n-checkdiscard class ${entry.classSpec} { *; }"
}
return (header + rules).joinToString("\n")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import org.gradle.api.tasks.TaskProvider
internal const val RESOLVE_FLAGS_TASK_NAME = "resolveFeatureFlags"
internal const val VERIFY_EXPIRED_FLAGS_TASK_NAME = "verifyExpiredFlags"
internal const val GENERATE_PROGUARD_TASK_NAME = "generateFeaturedProguardRules"
internal const val GENERATE_CHECK_DISCARD_TASK_NAME = "generateFeaturedCheckDiscardRules"
internal const val GENERATE_IOS_CONST_VAL_TASK_NAME = "generateIosConstVal"
internal const val GENERATE_XCCONFIG_TASK_NAME = "generateXcconfig"
internal const val GENERATE_CONFIG_PARAM_TASK_NAME = "generateConfigParam"
Expand Down Expand Up @@ -47,18 +48,19 @@ public class FeaturedPlugin : Plugin<Project> {
val verifyTask = registerVerifyExpiredFlagsTask(target, extension, resolveTask)
registerConfigParamTask(target, extension, resolveTask, verifyTask)
val proguardTask = registerProguardTask(target, extension, resolveTask, verifyTask)
val checkDiscardTask = registerCheckDiscardTask(target, extension, resolveTask, verifyTask)
registerIosConstValTask(target, extension, resolveTask, verifyTask)
registerXcconfigTask(target, resolveTask, verifyTask)
val manifestTask = registerManifestTask(target, resolveTask)
registerFeaturedManifestConfiguration(target, manifestTask)
target.plugins.withId("com.android.application") {
wireProguardToApplicationVariants(target, proguardTask)
wireProguardToApplicationVariants(target, proguardTask, checkDiscardTask)
}
target.plugins.withId("com.android.library") {
wireProguardToLibraryVariants(target, proguardTask)
wireProguardToLibraryVariants(target, proguardTask, checkDiscardTask)
}
target.plugins.withId("com.android.kotlin.multiplatform.library") {
wireProguardToKmpLibraryVariants(target, proguardTask)
wireProguardToKmpLibraryVariants(target, proguardTask, checkDiscardTask)
}
}

Expand Down Expand Up @@ -136,6 +138,30 @@ public class FeaturedPlugin : Plugin<Project> {
task.dependsOn(verifyTask)
}

private fun registerCheckDiscardTask(
target: Project,
extension: FeaturedExtension,
resolveTask: TaskProvider<ResolveFlagsTask>,
verifyTask: TaskProvider<VerifyExpiredFlagsTask>,
): TaskProvider<GenerateCheckDiscardRulesTask> =
target.tasks.register(GENERATE_CHECK_DISCARD_TASK_NAME, GenerateCheckDiscardRulesTask::class.java) { task ->
task.group = "featured"
task.description = "Generates R8 -checkdiscard rules for local flag discard(...) targets in '${target.path}'."
// Read straight from the DSL (not the resolved-flags report) so discard targets and
// the remote-flag guard participate in the @Input fingerprint and invalidate caching.
task.discardDescriptors.set(target.provider { extension.localFlags.discardDescriptors() })
task.remoteFlagsWithDiscards.set(
target.provider {
extension.remoteFlags.flags
.filter { it.discards.isNotEmpty() }
.map { it.key }
},
)
task.outputFile.set(target.layout.buildDirectory.file("featured/proguard-featured-checkdiscard.pro"))
task.dependsOn(resolveTask)
task.dependsOn(verifyTask)
}

private fun registerIosConstValTask(
target: Project,
extension: FeaturedExtension,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,10 @@ public class FlagContainer {

/** Serialises all flags to pipe-delimited descriptors for [ResolveFlagsTask] inputs. */
internal fun toDescriptors(): List<String> = _flags.map { it.toDescriptor() }

/**
* Serialises every `discard(...)` target to a `flagKey|classSpec` descriptor for
* [GenerateCheckDiscardRulesTask] inputs. Flags without discard targets contribute nothing.
*/
internal fun discardDescriptors(): List<String> = _flags.flatMap { flag -> flag.discards.map { spec -> "${flag.key}|$spec" } }
}
Loading
Loading