From 30c53053fd416432c66df48fafad67561e69e747 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 13 Jun 2026 08:31:48 +0300 Subject: [PATCH] Make Featured plugin Project-Isolation-safe; remove scanAllLocalFlags root task Remove wireToRootAggregator and the scanAllLocalFlags root convenience task from FeaturedPlugin: the only rootProject access in the plugin and a Project Isolation violation. Cross-module flag resolution is covered by the built-in name-matched ./gradlew resolveFeatureFlags invocation. Add ProjectIsolationIntegrationTest asserting the configuration phase succeeds with org.gradle.unsafe.isolated-projects enabled. Closes #186 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 + featured-gradle-plugin/CLAUDE.md | 4 +- .../featured/gradle/FeaturedPlugin.kt | 21 --- .../gradle/ProjectIsolationIntegrationTest.kt | 131 ++++++++++++++++++ 4 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProjectIsolationIntegrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8900d7f..4d4f80b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dependency updates raised `androidx.core` to 1.19.0, which requires Android consumers to build with `compileSdk 37` or higher; the library itself now compiles with `compileSdk 37`. (#282) +### Removed + +- Gradle plugin: the `scanAllLocalFlags` root-project aggregation task has been removed. The + plugin no longer accesses `rootProject` and is now compatible with Gradle Project Isolation. + Use `./gradlew resolveFeatureFlags` (Gradle name-matched task invocation) to resolve flags + across all modules that apply the plugin. (#186) + ## [1.1.1] - 2026-06-04 ### Fixed diff --git a/featured-gradle-plugin/CLAUDE.md b/featured-gradle-plugin/CLAUDE.md index 0b71a10..62ccb67 100644 --- a/featured-gradle-plugin/CLAUDE.md +++ b/featured-gradle-plugin/CLAUDE.md @@ -49,7 +49,9 @@ featured { | `generateIosConstVal` | iOS constant value files | | `generateXcconfig` | `build/featured/FeatureFlags.generated.xcconfig` | -`scanAllLocalFlags` aggregates `resolveFeatureFlags` across all modules. +To resolve flags across all modules at once, use Gradle's name-matched task invocation: +`./gradlew resolveFeatureFlags` — Gradle runs the task in every module that applies the plugin. +The plugin holds no `rootProject` access and is compatible with Gradle Project Isolation. ## Tests diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt index db19024..b695b0a 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt @@ -13,7 +13,6 @@ 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 SCAN_ALL_TASK_NAME = "scanAllLocalFlags" internal const val GENERATE_PROGUARD_TASK_NAME = "generateFeaturedProguardRules" internal const val GENERATE_IOS_CONST_VAL_TASK_NAME = "generateIosConstVal" internal const val GENERATE_XCCONFIG_TASK_NAME = "generateXcconfig" @@ -52,7 +51,6 @@ public class FeaturedPlugin : Plugin { registerXcconfigTask(target, resolveTask, verifyTask) val manifestTask = registerManifestTask(target, resolveTask) registerFeaturedManifestConfiguration(target, manifestTask) - wireToRootAggregator(target, resolveTask) target.plugins.withId("com.android.application") { wireProguardToApplicationVariants(target, proguardTask) } @@ -262,23 +260,4 @@ public class FeaturedPlugin : Plugin { // this invariant: a KMP module that applies both `dev.androidbroadcast.featured` and // `maven-publish` produces module metadata with no `featured-manifest` Usage variant. } - - /** - * Ensures the root project has a `scanAllLocalFlags` aggregation task and wires - * [resolveTask] into it. `./gradlew scanAllLocalFlags` triggers flag resolution - * across every module that applies the plugin. - */ - private fun wireToRootAggregator( - target: Project, - resolveTask: TaskProvider, - ) { - val root = target.rootProject - if (root.tasks.findByName(SCAN_ALL_TASK_NAME) == null) { - root.tasks.register(SCAN_ALL_TASK_NAME) { task -> - task.group = "featured" - task.description = "Resolves feature flags across all modules applying the Featured plugin." - } - } - root.tasks.named(SCAN_ALL_TASK_NAME) { it.dependsOn(resolveTask) } - } } diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProjectIsolationIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProjectIsolationIntegrationTest.kt new file mode 100644 index 0000000..4f08264 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProjectIsolationIntegrationTest.kt @@ -0,0 +1,131 @@ +package dev.androidbroadcast.featured.gradle + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Verifies that the Featured Gradle plugin is compatible with Gradle Project Isolation. + * + * The plugin formerly registered a `scanAllLocalFlags` task on the root project from + * within a sub-project's configuration — a canonical Project Isolation violation. That + * access has been removed (#186). This test is the empirical proof: a multi-module build + * with isolated-projects enabled must configure and run `resolveFeatureFlags` without any + * cross-project access error. + * + * Project Isolation is enabled via `org.gradle.unsafe.isolated-projects=true`. The `unsafe.` + * prefix is still required as of Gradle 9.4.1 (verified against the official Gradle 9.4.1 + * user guide, "How do I use it?" section). + * + * No Android SDK is required; the fixture uses plain `java-library` modules. + */ +class ProjectIsolationIntegrationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setUp() { + projectDir = tempFolder.newFolder("pi-test-project") + + // Root settings: include the feature module. + projectDir.resolve("settings.gradle.kts").writeText( + """ + pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + } + rootProject.name = "pi-test-project" + include(":feature-module") + """.trimIndent(), + ) + + // Root gradle.properties: enable Project Isolation. + // The unsafe. prefix is still required as of Gradle 9.4.1. + projectDir.resolve("gradle.properties").writeText( + "org.gradle.unsafe.isolated-projects=true\n", + ) + + // Minimal root build script (no plugin applied at root). + projectDir.resolve("build.gradle.kts").writeText( + "// root build — no configuration here\n", + ) + + // Feature sub-module: applies the Featured plugin with two local flags. + val featureDir = projectDir.resolve("feature-module").also { it.mkdirs() } + featureDir.resolve("build.gradle.kts").writeText( + """ + plugins { + id("java-library") + id("dev.androidbroadcast.featured") + } + featured { + localFlags { + boolean("dark_mode", default = false) + boolean("promo_banner", default = true) + } + } + """.trimIndent(), + ) + } + + @Test + fun `resolveFeatureFlags succeeds with isolated-projects enabled`() { + val result = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() + // Run a lightweight task that exercises plugin configuration without a long build. + // --configuration-cache is passed explicitly so the CC engagement marker + // ("Configuration cache entry stored") is deterministically present on a cold run, + // letting us assert that Project Isolation (which implies CC) actually engaged. + .withArguments( + ":feature-module:$RESOLVE_FLAGS_TASK_NAME", + "--configuration-cache", + "--stacktrace", + ).build() + + // Structured outcome check: subsumes both "task ran" and "build succeeded" in one assertion. + assertEquals( + TaskOutcome.SUCCESS, + result.task(":feature-module:$RESOLVE_FLAGS_TASK_NAME")?.outcome, + "Expected :feature-module:$RESOLVE_FLAGS_TASK_NAME to complete with SUCCESS.\n${result.output}", + ) + + // Assert no Project Isolation violation in the build output. + // A violation from rootProject access would contain this substring. + // This is the authoritative guard: Gradle always uses this exact phrase in PI violation + // messages, and it never appears in the benign incubating-feature banner. + assertFalse( + result.output.contains("cannot access project"), + "Build output contains a Project Isolation violation — rootProject access detected.\n${result.output}", + ) + // Note: a generic "isolated projects" substring check (even case-insensitively) is NOT + // usable here — Gradle prints "Isolated Projects is an incubating feature." on EVERY run + // when the feature is enabled, which would cause a false failure. The "cannot access + // project" assertion above is sufficient as the authoritative PI-violation guard. + + // Assert that Project Isolation / Configuration Cache actually engaged. + // With --configuration-cache on a cold run, Gradle 9.x prints + // "Configuration cache entry stored." confirming the CC (and therefore PI) was active. + // Without this check the test would pass vacuously if the property name were silently + // ignored (Gradle ignores unknown properties). + assertTrue( + result.output.contains("Configuration cache entry stored"), + "Expected Configuration Cache engagement marker in output — " + + "Project Isolation may not have been active (check property name).\n${result.output}", + ) + } +}