From db1329b786261ee9ebce5e4cf10642f6aabbedc4 Mon Sep 17 00:00:00 2001 From: Claw Date: Wed, 22 Jul 2026 08:38:16 -0700 Subject: [PATCH] F-091: map AGP BuildFeatures into Forma (defaults off) Project-global FormaBuildFeatures (all false) on androidProjectConfiguration; central apply on library+binary; type-owned viewBinding/compose; docs. --- TICKETS.md | 2 +- application/build.gradle.kts | 4 + docs/CALL-SITE-SURFACE.md | 34 ++++++- docs/COMPOSE.md | 6 ++ docs/PROGRESS.md | 24 +++++ docs/PROJECT-CONFIGURATION.md | 39 +++++++- .../main/java/androidProjectConfiguration.kt | 9 +- plugins/android/src/main/java/impl.kt | 5 +- .../forma/android/feature/AndroidBinary.kt | 11 ++- .../forma/android/feature/AndroidLibrary.kt | 11 ++- .../android/feature/BuildFeaturesSupport.kt | 66 +++++++++++++ .../forma/config/AndroidProjectSettings.kt | 14 ++- .../tools/forma/config/FormaBuildFeatures.kt | 77 +++++++++++++++ .../forma/config/FormaBuildFeaturesTest.kt | 99 +++++++++++++++++++ 14 files changed, 384 insertions(+), 17 deletions(-) create mode 100644 plugins/android/src/main/java/tools/forma/android/feature/BuildFeaturesSupport.kt create mode 100644 plugins/config/src/main/java/tools/forma/config/FormaBuildFeatures.kt create mode 100644 plugins/config/src/test/java/tools/forma/config/FormaBuildFeaturesTest.kt diff --git a/TICKETS.md b/TICKETS.md index adb9b3c8..5f796d60 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -122,7 +122,7 @@ Promoted from daily next-actions / historical GH. Workers pick top `todo` in ord | F-088 | done | Fleet tooling phase 2 (F-084 follow-ups) | Gradle `formaLayoutCheck`/`formaLayoutGenerate` + root `*All` via `tools.forma.deps.fleet.registerFormaLayout` on all packageName DSLs; opt-in `checkPackageLayoutAtConfiguration` (default false); res/viewBinding/binary skip source-dir requirement; docs/skill updated. GH **#54** generate path user-complete (Hermes may close). AST migrate / depgen still deferred. | | F-089 | done | Navigation task cache broken | GH **#110** — upstream Safe Args **2.9.8** already `@PathSensitive(RELATIVE)` on `navigationFiles`; two-location build-cache hit verified on sample `navigationRes`; regression note in `docs/CONFIGURATION-PERFORMANCE.md`. | | F-090 | done | Exclude modules from dependency validation | GH **#97** — `dependencyValidationExclusions` on root `androidProjectConfiguration` / `AndroidProjectSettings`; `applyDependencies` skips project-dep suffix validation for exact path/name matches only. Self-type validation unchanged. Unit tests in `:config` + `:deps`; matrix + PROJECT-CONFIGURATION docs. | -| F-091 | todo | BuildFeatures under Forma | GH **#88** — map AGP `BuildFeatures` into Forma (default off where safe); single configuration path aligned with type=rule + project-global defaults; document in CALL-SITE / PROJECT-CONFIGURATION. Do not reintroduce fat call-site plugin shopping. | +| F-091 | done | BuildFeatures under Forma | GH **#88** — `FormaBuildFeatures` project-global (defaults **off**); central apply on library+binary; type-owned viewBinding/composeWidget + minimal `impl`/`compose` attrs; docs CALL-SITE + PROJECT-CONFIGURATION. | | F-092 | todo | versionCode / versionName on binary (and app) | GH **#82** — move version identity off global-only `FormaConfiguration`; set on `androidBinary` / `androidApp` as call-site attrs (Gradle limits acknowledged). Sample uses new API; docs. | | F-093 | todo | Remove legacy `.kapt` once unused | After F-086 sample is KSP-only: hard-remove or hard-deprecate `String.kapt` / `kapt()` / `Kapt` configuration path if no in-repo consumers; keep only if a documented external-compat shim is still required. Grep tree + tests + DEPS docs. | | F-094 | blocked | Gradle Plugin Portal Forma org/user | GH **#133** — human/admin: create shared Portal user/org and credentials for team publish. Worker cannot finish without Stepan Portal access. Track only; see `docs/PLUGIN-PUBLISH.md`. | diff --git a/application/build.gradle.kts b/application/build.gradle.kts index 1766b327..8ad52cea 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -15,6 +15,10 @@ buildscript { // dependencyValidationExclusions = setOf( // ":third-party:exoplayer:library-core", // ), + // F-091 / GH #88: AGP BuildFeatures — all default off. Opt in intentionally, e.g.: + // buildFeatures = tools.forma.config.FormaBuildFeatures( + // buildConfig = true, + // ), extraPlugins = listOf( libs.plugins.toolsFormaDemoDependencies, diff --git a/docs/CALL-SITE-SURFACE.md b/docs/CALL-SITE-SURFACE.md index 33a32766..2cad9ee0 100644 --- a/docs/CALL-SITE-SURFACE.md +++ b/docs/CALL-SITE-SURFACE.md @@ -12,7 +12,7 @@ settings), not on a builder chain after the call. | Layer | Owns | |-------|------| | Target **type** / rule | Plugins, content rules, matrix row, always-on features for that role | -| Project configuration | Global defaults (`Forma.settings.compose`, SDK, etc.) | +| Project configuration | Global defaults (`Forma.settings.compose`, `Forma.settings.buildFeatures`, SDK, etc.) | | Call site (`build.gradle.kts`) | Instance attrs only: `packageName`, deps, version, optional rule flags | **Rejected at call sites** @@ -67,7 +67,7 @@ a second way to apply plugins. | Flag | Where | Default | Policy | |------|--------|---------|--------| | `compose` | `impl`, `androidApp`, `androidBinary`, `uiLibrary`, `androidUtil` | `Forma.settings.compose` (project-global) | Prefer global on; override only when a module must differ. Dedicated Compose UI → `composeWidget` type. | -| `viewBinding` | `impl` only (Boolean) | `false` | Layouts-only modules use the **`viewBinding` target type** (always on). Do not invent dual structure via flags. | +| `viewBinding` | `impl` only (Boolean) | `Forma.settings.buildFeatures.viewBinding` (default **false**) | Layouts-only modules use the **`viewBinding` target type** (always on). Do not invent dual structure via flags. | | *(type-owned)* | `composeWidget` | Compose always on | Prefer dedicated type over `widget(compose=true)`. | | *(type-owned)* | `viewBinding` target | View binding always on | Prefer type over `impl(viewBinding=true)` when the module is layouts-only. | | External plugins | never a call-site flag | n/a | `targetPlugin` + `deriveTargetType` / `registerTargetPlugin` | @@ -77,6 +77,36 @@ Other common attrs (`testInstrumentationRunner`, `buildConfiguration`, instance knobs with empty/public defaults — keep them; do not grow plugin-id parameters beside them. +## AGP BuildFeatures (F-091 / GH #88) + +AGP `BuildFeatures` flags are **project-global by default** (all **off**), with +type/call-site ownership only where already established. There is **no** per-flag +Boolean shopping list on every DSL. + +| Concern | Owner | How to enable | +|---------|--------|----------------| +| `aidl`, `buildConfig`, `dataBinding`, `prefab`, `resValues`, `shaders` | Project only | `androidProjectConfiguration(buildFeatures = FormaBuildFeatures(...))` | +| `viewBinding` (project default for `impl` + binary) | Project + `impl` attr | `buildFeatures.viewBinding`; `impl` may override; binary has **no** call-site attr (fleet default only) | +| `viewBinding` always on | **`viewBinding` target type** | Use `viewBinding { … }` (type forces on) | +| `compose` project default | Top-level `compose =` on `androidProjectConfiguration` | **Not** nested under `buildFeatures` (one happy path) | +| `compose` per target | Call-site `compose` on listed DSLs | Defaults to `Forma.settings.compose` | +| `compose` always on | **`composeWidget` target type** | Use `composeWidget { … }` | + +**Apply path:** library + binary feature definitions call a central helper that +starts from `Forma.settings.buildFeatures`, applies type/call-site overrides for +viewBinding/compose, and **explicitly sets every supported flag** so AGP cannot +silently enable features. Compose still runs `enableCompose` (compiler plugin), +not only the boolean. + +**Do not** add call-site Booleans for `aidl` / `buildConfig` / `dataBinding` / etc. +Fleet-wide BuildConfig → `buildFeatures.buildConfig = true`. Rare per-module +BuildConfig (if ever needed) should be a dedicated derived type or a single +documented attr later — not free-form plugin/feature shopping. + +**dataBinding** often pairs with view binding in app code; Forma does **not** +auto-enable one when the other is on — set both explicitly if both are required. +`renderScript` is not mapped (removed/deprecated in modern AGP). + ## Removed (F-081) | Removed | Replacement | diff --git a/docs/COMPOSE.md b/docs/COMPOSE.md index fc3b7df0..f4a10474 100644 --- a/docs/COMPOSE.md +++ b/docs/COMPOSE.md @@ -28,6 +28,12 @@ androidProjectConfiguration( `composeWidget` modules **always** enable Compose; they ignore the project default (they do not need `compose = true`). +**F-091 note:** Compose stays a **top-level** `androidProjectConfiguration` parameter. +Other AGP `BuildFeatures` flags (`buildConfig`, `aidl`, …) live under nested +`buildFeatures = FormaBuildFeatures(...)`. Do not set Compose via that nested +object — one happy path only. See [`PROJECT-CONFIGURATION.md`](PROJECT-CONFIGURATION.md) +§ `buildFeatures`. + ## Per-target `compose` flag ```kotlin diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7ffe28cf..21d87a51 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,30 @@ Newest entries first. +## 2026-07-22 — F-091: BuildFeatures under Forma (GH #88) + +- **Ticket:** F-091 → `done` +- **Branch:** `forma/F-091-build-features` (from origin/v2 @ cb65049) +- **Skills/modes:** Grok Build `--mode full` (design/plan + implement); Hermes finish path after implement timeout on app verify +- **Design (shipped):** + - Layer ownership: type owns always-on (`viewBinding` / `composeWidget`); project owns fleet AGP flags; minimal call-site attrs only (`compose`, `impl.viewBinding`) + - Nested `FormaBuildFeatures` for non-Compose flags (all default **false**); Compose stays top-level `compose` / `composeCompilerVersion` (one happy path) + - No per-flag call-site shopping; no `renderScript`; dataBinding not auto-coupled to viewBinding +- **Code:** + - `plugins/config` `FormaBuildFeatures` + `resolveWith` → `ResolvedFormaBuildFeatures`; unit tests + - `AndroidProjectSettings.buildFeatures`; removed “No BuildConfig Support” limitation + - `androidProjectConfiguration(buildFeatures = …)` store wiring + - `BuildFeaturesSupport.applyFormaBuildFeatures` — explicit write of every supported flag; `dataBinding` via `LibraryBuildFeatures` / `ApplicationBuildFeatures` (AGP 9); compose via `enableCompose` or forced false + - Wired into library + binary feature definitions; binary viewBinding = project default only + - `impl(viewBinding=)` default now `Forma.settings.buildFeatures.viewBinding` +- **Docs:** `CALL-SITE-SURFACE.md` inventory; `PROJECT-CONFIGURATION.md` § buildFeatures; `COMPOSE.md` cross-link; sample commented example +- **Verify (real host, `source scripts/env-mac.sh`):** + - `plugins/`: `./gradlew :config:test :android:compileKotlin build --no-daemon` → **BUILD SUCCESSFUL** (80 tasks) + - `application/`: `./gradlew help` + `assembleDebug --no-daemon` → **BUILD SUCCESSFUL** in 2m4s (896 tasks) +- **Blockers:** none +- **Next:** F-092 versionCode / versionName on binary (and app) (GH #82) +- **GH #88:** close when this PR merges + ## 2026-07-22 — F-090: Exclude modules from dependency validation (GH #97) - **Ticket:** F-090 → `done` diff --git a/docs/PROJECT-CONFIGURATION.md b/docs/PROJECT-CONFIGURATION.md index bb68442b..1fe1998d 100644 --- a/docs/PROJECT-CONFIGURATION.md +++ b/docs/PROJECT-CONFIGURATION.md @@ -15,6 +15,11 @@ buildscript { agpVersion = "9.3.0", // compose = false, // composeCompilerVersion = "2.3.21", + // F-091 / GH #88: AGP BuildFeatures — all default off; opt in intentionally + // buildFeatures = FormaBuildFeatures( + // buildConfig = true, + // // viewBinding = true, // default for impl(viewBinding=…) only + // ), // F-090: skip project-dep suffix checks only for listed forked-in modules // dependencyValidationExclusions = setOf( // ":third-party:exoplayer:library-core", @@ -63,6 +68,37 @@ Does **not** disable self-type validation on Forma DSL entry points. See [`DEPENDENCY-MATRIX.md`](DEPENDENCY-MATRIX.md) § Dependency validation exclusions. +### `buildFeatures` (F-091 / GH #88) + +| | | +|--|--| +| **Type** | `FormaBuildFeatures` (default `FormaBuildFeatures()` — **all flags false**) | +| **Set on** | Root `androidProjectConfiguration(...)` only | +| **Stored as** | `AndroidProjectSettings.buildFeatures` | +| **Read by** | Android library + binary feature definitions (central apply helper) | + +Maps AGP public `BuildFeatures` flags into Forma with **defaults off** where safe: + +| Flag | In `FormaBuildFeatures` | Notes | +|------|-------------------------|--------| +| `aidl` | yes | Project-global only | +| `buildConfig` | yes | Project-global; enables `BuildConfig` generation fleet-wide when true | +| `dataBinding` | yes | Project-global; AGP 9 exposes this on library/app feature subtypes; does not auto-enable `viewBinding` | +| `prefab` | yes | Project-global | +| `resValues` | yes | Project-global | +| `shaders` | yes | Project-global | +| `viewBinding` | yes | Default for `impl(viewBinding=…)`; **`viewBinding` target type always on** | +| `compose` | **no** — use top-level `compose` | One happy path for Compose project default | +| `renderScript` | not mapped | Deprecated/removed in modern AGP | + +Every Android library/app target **explicitly sets** each supported flag from the +resolved values so AGP platform defaults cannot silently turn features on. + +**Compose relationship:** keep using top-level `compose = true/false` and +`composeCompilerVersion`. Do not put a second Compose default inside +`FormaBuildFeatures`. See [`COMPOSE.md`](COMPOSE.md) and +[`CALL-SITE-SURFACE.md`](CALL-SITE-SURFACE.md) § BuildFeatures. + ## What it does **not** do - `extraPlugins` (and catalog `plugin(...)` entries) are **classpath only**. They put Gradle plugin jars on the buildscript classpath. They do **not** apply any plugin to your modules. @@ -86,7 +122,8 @@ The deprecated `Project.androidProjectConfiguration(...)` receiver overload has | Piece | Location | Role | |-------|----------|------| | `androidProjectConfiguration(...)` (ScriptHandlerScope) | `plugins/android/.../androidProjectConfiguration.kt` | The one public API; called from root `buildscript` | -| `AndroidProjectSettings` | `plugins/config/.../AndroidProjectSettings.kt` | Immutable data class holding SDKs, versions, compose default, repos, etc. | +| `AndroidProjectSettings` | `plugins/config/.../AndroidProjectSettings.kt` | Immutable data class holding SDKs, versions, compose default, `buildFeatures`, repos, etc. | +| `FormaBuildFeatures` | `plugins/config/.../FormaBuildFeatures.kt` | Nested AGP BuildFeatures defaults (all off); pure data + resolve helper | | `FormaSettingsStore` | `plugins/config/.../AndroidProjectSettings.kt` (as `object`) | The backing singleton store (`SettingsStore` + `PluginInfoStore`) | | `Forma` (singleton accessor) | `plugins/android/.../androidProjectConfiguration.kt` | `object Forma : SettingsStore<...> by FormaSettingsStore, ...` — primary reader surface (`Forma.settings`) | | `registerAndroidDefaults()` | `plugins/android/.../AndroidTargetRegistry.kt` | Populates `AndroidTargetRegistry` (target types + matrix) | diff --git a/plugins/android/src/main/java/androidProjectConfiguration.kt b/plugins/android/src/main/java/androidProjectConfiguration.kt index 06b73ced..c5bf009c 100644 --- a/plugins/android/src/main/java/androidProjectConfiguration.kt +++ b/plugins/android/src/main/java/androidProjectConfiguration.kt @@ -10,6 +10,7 @@ import org.gradle.plugin.use.PluginDependency import tools.forma.android.utils.register import tools.forma.android.target.registerAndroidDefaults import tools.forma.config.AndroidProjectSettings +import tools.forma.config.FormaBuildFeatures import tools.forma.config.FormaSettingsStore import tools.forma.config.PluginInfoStore import tools.forma.config.SettingsStore @@ -59,7 +60,8 @@ import tools.forma.deps.fleet.ensureFormaLayoutRootTasks * @param kotlinVersion Kotlin version (defaults to Gradle's embeddedKotlinVersion) * @param agpVersion Android Gradle Plugin version to place on the buildscript classpath * @param repositories optional block to configure repositories (applied in buildscript context) - * @param compose project-wide default for per-target Compose flags (see [AndroidProjectSettings.compose]) + * @param compose project-wide default for per-target Compose flags (see [AndroidProjectSettings.compose]). + * Kept top-level — do **not** nest Compose under [buildFeatures] (one happy path for Compose default). * @param composeCompilerVersion Compose compiler extension version for AGP `composeOptions` * (must match the Kotlin version used) * @param javaVersionCompatibility Java language level for source/target compatibility @@ -73,6 +75,9 @@ import tools.forma.deps.fleet.ensureFormaLayoutRootTasks * For forked-in third-party trees that do not use Forma suffixes. Does not weaken the * default matrix for non-listed modules and does not skip self-type validation on DSL * targets. Default empty. See [AndroidProjectSettings.dependencyValidationExclusions]. + * @param buildFeatures project-global AGP BuildFeatures defaults (F-091 / GH #88). All flags + * default **false**. Nested object for non-Compose flags (+ optional `viewBinding` default + * for `impl`). Compose stays on [compose] above. See [FormaBuildFeatures]. * @param extraPlugins list of extra artifacts / plugin providers to add to the **buildscript classpath only**. * See "Classpath vs apply" in TARGET-PLUGINS.md. */ @@ -91,6 +96,7 @@ fun ScriptHandlerScope.androidProjectConfiguration( vectorDrawablesUseSupportLibrary: Boolean = false, checkPackageLayoutAtConfiguration: Boolean = false, dependencyValidationExclusions: Set = emptySet(), + buildFeatures: FormaBuildFeatures = FormaBuildFeatures(), extraPlugins: List = emptyList() ) { buildScriptConfiguration( @@ -128,6 +134,7 @@ fun ScriptHandlerScope.androidProjectConfiguration( vectorDrawablesUseSupportLibrary = vectorDrawablesUseSupportLibrary, checkPackageLayoutAtConfiguration = checkPackageLayoutAtConfiguration, dependencyValidationExclusions = dependencyValidationExclusions, + buildFeatures = buildFeatures, ) Forma.store(configuration) diff --git a/plugins/android/src/main/java/impl.kt b/plugins/android/src/main/java/impl.kt index 130647e8..835ba383 100644 --- a/plugins/android/src/main/java/impl.kt +++ b/plugins/android/src/main/java/impl.kt @@ -25,6 +25,9 @@ import tools.forma.validation.validate * - **Must not** depend on other `impl` modules — feature graphs compose only * at [androidApp] / [androidBinary] so implementations stay independent. * + * @param viewBinding enable View Binding for this feature impl; defaults to project-wide + * [androidProjectConfiguration] `buildFeatures.viewBinding`. Layouts-only modules should + * use the dedicated `viewBinding` target type instead (always on). * @param compose enable Jetpack Compose for this target; defaults to project-wide * [androidProjectConfiguration] `compose` flag. */ @@ -33,7 +36,7 @@ fun Project.impl( dependencies: FormaDependency = emptyDependency(), testDependencies: NamedDependency = emptyDependency(), androidTestDependencies: NamedDependency = emptyDependency(), - viewBinding: Boolean = false, + viewBinding: Boolean = Forma.settings.buildFeatures.viewBinding, compose: Boolean = Forma.settings.compose, testInstrumentationRunner: String = androidJunitRunner, buildConfiguration: BuildConfiguration = BuildConfiguration(), diff --git a/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt b/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt index a88e0487..f259c570 100644 --- a/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt +++ b/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt @@ -47,9 +47,14 @@ fun androidBinaryFeatureDefinition( buildTypes.applyFrom(configuration.buildConfiguration) compileOptions.applyFrom(formaConfiguration) - if (configuration.compose) { - enableCompose(project, formaConfiguration.composeCompilerVersion) - } + applyFormaBuildFeatures( + project = project, + defaults = formaConfiguration.buildFeatures, + composeCompilerVersion = formaConfiguration.composeCompilerVersion, + // No binary call-site attr — fleet-wide project default only (F-091). + viewBinding = formaConfiguration.buildFeatures.viewBinding, + compose = configuration.compose, + ) } } ) diff --git a/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt b/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt index 7f12600c..b58fc5e9 100644 --- a/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt +++ b/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt @@ -50,10 +50,13 @@ fun androidLibraryFeatureDefinition( buildTypes.applyFrom(feature.buildConfiguration) compileOptions.applyFrom(formaConfiguration) - buildFeatures.viewBinding = feature.viewBinding - if (feature.compose) { - enableCompose(project, formaConfiguration.composeCompilerVersion) - } + applyFormaBuildFeatures( + project = project, + defaults = formaConfiguration.buildFeatures, + composeCompilerVersion = formaConfiguration.composeCompilerVersion, + viewBinding = feature.viewBinding, + compose = feature.compose, + ) } // AGP library `verify*Resources` is overly strict with Navigation safe-args // graphs in pure `androidRes` modules (debug APK still packages graphs correctly). diff --git a/plugins/android/src/main/java/tools/forma/android/feature/BuildFeaturesSupport.kt b/plugins/android/src/main/java/tools/forma/android/feature/BuildFeaturesSupport.kt new file mode 100644 index 00000000..512dec5e --- /dev/null +++ b/plugins/android/src/main/java/tools/forma/android/feature/BuildFeaturesSupport.kt @@ -0,0 +1,66 @@ +package tools.forma.android.feature + +import com.android.build.api.dsl.ApplicationBuildFeatures +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.LibraryBuildFeatures +import org.gradle.api.Project +import tools.forma.config.FormaBuildFeatures +import tools.forma.config.ResolvedFormaBuildFeatures +import tools.forma.config.resolveWith + +/** + * Apply Forma-resolved AGP [com.android.build.api.dsl.BuildFeatures] to a library or app extension. + * + * Starts from project-global [FormaBuildFeatures] defaults (all off unless configured), + * then applies type/call-site overrides for [viewBinding] and [compose]. Every supported + * flag is set explicitly so AGP platform defaults cannot silently enable features (GH #88). + * + * When [compose] is true, also runs [enableCompose] (compiler plugin + composeOptions). + * Setting `buildFeatures.compose = true` alone is not enough on modern Kotlin/AGP. + * + * Public AGP DSL only — never `internal.dsl`. + * + * Note: `dataBinding` lives on [LibraryBuildFeatures] / [ApplicationBuildFeatures], not the + * base [com.android.build.api.dsl.BuildFeatures] interface (AGP 9). + */ +@Suppress("UnstableApiUsage") +internal fun CommonExtension.applyFormaBuildFeatures( + project: Project, + defaults: FormaBuildFeatures, + composeCompilerVersion: String, + viewBinding: Boolean, + compose: Boolean, +) { + val resolved: ResolvedFormaBuildFeatures = + defaults.resolveWith(viewBinding = viewBinding, compose = compose) + applyResolvedBuildFeatures(project, composeCompilerVersion, resolved) +} + +/** + * Write a fully resolved feature set onto the AGP extension (testable via [resolveWith]). + */ +@Suppress("UnstableApiUsage") +internal fun CommonExtension.applyResolvedBuildFeatures( + project: Project, + composeCompilerVersion: String, + resolved: ResolvedFormaBuildFeatures, +) { + with(buildFeatures) { + aidl = resolved.aidl + buildConfig = resolved.buildConfig + prefab = resolved.prefab + resValues = resolved.resValues + shaders = resolved.shaders + viewBinding = resolved.viewBinding + // dataBinding is library/app-specific in AGP 9 public API + when (this) { + is LibraryBuildFeatures -> dataBinding = resolved.dataBinding + is ApplicationBuildFeatures -> dataBinding = resolved.dataBinding + } + } + if (resolved.compose) { + enableCompose(project, composeCompilerVersion) + } else { + buildFeatures.compose = false + } +} diff --git a/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt b/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt index 91c19083..4bd32da9 100644 --- a/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt +++ b/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt @@ -5,10 +5,6 @@ import org.gradle.api.artifacts.dsl.RepositoryHandler import org.gradle.api.provider.Provider import org.gradle.plugin.use.PluginDependency -/** - * Limitations: - * No BuildConfig Support - */ // TODO use jacoco by default // TODO publishing // TODO owners @@ -34,6 +30,9 @@ data class AndroidProjectSettings( * Individual targets may still pass `compose = true/false` * to override. Does **not** auto-enable `composeWidget` modules (those always * enable Compose themselves). + * + * Kept top-level (not nested under [buildFeatures]) so there is one documented + * happy path for the Compose project default — see F-091 / [FormaBuildFeatures]. */ val compose: Boolean, /** @@ -69,6 +68,13 @@ data class AndroidProjectSettings( * Default empty — strict matrix unchanged. */ val dependencyValidationExclusions: Set = emptySet(), + /** + * Project-global AGP [FormaBuildFeatures] defaults (F-091 / GH #88). + * All flags default **false**. Applied explicitly on every Android library/app + * target so AGP cannot silently enable unwanted features. Compose project default + * stays on [compose] (not nested here). + */ + val buildFeatures: FormaBuildFeatures = FormaBuildFeatures(), ) /** diff --git a/plugins/config/src/main/java/tools/forma/config/FormaBuildFeatures.kt b/plugins/config/src/main/java/tools/forma/config/FormaBuildFeatures.kt new file mode 100644 index 00000000..3de0b08b --- /dev/null +++ b/plugins/config/src/main/java/tools/forma/config/FormaBuildFeatures.kt @@ -0,0 +1,77 @@ +package tools.forma.config + +/** + * Project-global defaults for AGP [com.android.build.api.dsl.BuildFeatures] flags (F-091 / GH #88). + * + * All flags default to **false** so AGP features are opt-in at the Forma project level. + * Configure once via root `androidProjectConfiguration(buildFeatures = FormaBuildFeatures(...))`. + * + * **Compose is not here.** The project-wide Compose default remains the top-level + * `compose` / `composeCompilerVersion` parameters on `androidProjectConfiguration` + * (one happy path — see [AndroidProjectSettings.compose]). + * + * **Layer ownership** + * - Always-on for a role → target type (`viewBinding` target ⇒ viewBinding on; `composeWidget` ⇒ compose on) + * - Project-wide default → this class + top-level `compose` + * - Rare per-module override → minimal Boolean attrs only (`compose` on several DSLs; `viewBinding` on `impl`) + * + * Do **not** add a call-site Boolean for every flag — that would reintroduce fat call sites. + * Rare fleet-wide needs (e.g. `buildConfig`) belong here; rare per-module needs should become + * a dedicated derived type or a single documented attr later, not free-form shopping. + * + * **dataBinding** often pairs with view binding in app code; Forma does not auto-enable + * one when the other is on — set both explicitly if both are required. + */ +data class FormaBuildFeatures( + val aidl: Boolean = false, + val buildConfig: Boolean = false, + val dataBinding: Boolean = false, + val prefab: Boolean = false, + val resValues: Boolean = false, + val shaders: Boolean = false, + /** + * Project-wide default for the `viewBinding` Boolean on `impl`. + * The dedicated `viewBinding` target type always enables view binding regardless of this flag. + */ + val viewBinding: Boolean = false, +) + +/** + * Final AGP BuildFeatures values after merging project defaults with type/call-site overrides. + * + * Pure data — safe to unit-test without AGP. Applied to the public AGP DSL by the Android plugin. + */ +data class ResolvedFormaBuildFeatures( + val aidl: Boolean, + val buildConfig: Boolean, + val compose: Boolean, + val dataBinding: Boolean, + val prefab: Boolean, + val resValues: Boolean, + val shaders: Boolean, + val viewBinding: Boolean, +) + +/** + * Merge project-global [FormaBuildFeatures] with per-target overrides. + * + * - Project defaults supply fleet-wide flags (`aidl`, `buildConfig`, …). + * - [viewBinding] and [compose] are the **final** values already resolved by the target type + * or call-site attr (type-owned targets pass `true`; `impl` may default from + * [FormaBuildFeatures.viewBinding]; compose DSLs default from [AndroidProjectSettings.compose]). + * - Type/call-site overrides win for viewBinding and compose; other flags stay project-global only. + */ +fun FormaBuildFeatures.resolveWith( + viewBinding: Boolean, + compose: Boolean, +): ResolvedFormaBuildFeatures = + ResolvedFormaBuildFeatures( + aidl = aidl, + buildConfig = buildConfig, + compose = compose, + dataBinding = dataBinding, + prefab = prefab, + resValues = resValues, + shaders = shaders, + viewBinding = viewBinding, + ) diff --git a/plugins/config/src/test/java/tools/forma/config/FormaBuildFeaturesTest.kt b/plugins/config/src/test/java/tools/forma/config/FormaBuildFeaturesTest.kt new file mode 100644 index 00000000..803a4dd7 --- /dev/null +++ b/plugins/config/src/test/java/tools/forma/config/FormaBuildFeaturesTest.kt @@ -0,0 +1,99 @@ +package tools.forma.config + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * F-091 / GH #88: BuildFeatures defaults off + type/call-site override merge. + */ +class FormaBuildFeaturesTest { + + @Test + fun `defaults are all false`() { + val defaults = FormaBuildFeatures() + assertFalse(defaults.aidl) + assertFalse(defaults.buildConfig) + assertFalse(defaults.dataBinding) + assertFalse(defaults.prefab) + assertFalse(defaults.resValues) + assertFalse(defaults.shaders) + assertFalse(defaults.viewBinding) + } + + @Test + fun `resolve keeps project defaults for fleet flags`() { + val defaults = + FormaBuildFeatures( + aidl = true, + buildConfig = true, + dataBinding = false, + prefab = true, + resValues = true, + shaders = false, + viewBinding = false, + ) + val resolved = defaults.resolveWith(viewBinding = false, compose = false) + assertTrue(resolved.aidl) + assertTrue(resolved.buildConfig) + assertFalse(resolved.dataBinding) + assertTrue(resolved.prefab) + assertTrue(resolved.resValues) + assertFalse(resolved.shaders) + assertFalse(resolved.viewBinding) + assertFalse(resolved.compose) + } + + @Test + fun `type owned viewBinding override wins over project default false`() { + val defaults = FormaBuildFeatures(viewBinding = false) + val resolved = defaults.resolveWith(viewBinding = true, compose = false) + assertTrue(resolved.viewBinding) + assertFalse(resolved.compose) + } + + @Test + fun `call site compose override wins independently of other flags`() { + val defaults = FormaBuildFeatures(buildConfig = true) + val resolved = defaults.resolveWith(viewBinding = false, compose = true) + assertTrue(resolved.compose) + assertTrue(resolved.buildConfig) + assertFalse(resolved.viewBinding) + } + + @Test + fun `project viewBinding default is not auto applied without override arg`() { + // resolveWith takes the *final* viewBinding value from the DSL layer. + // Project default is applied at the impl() call-site default parameter, not here. + val defaults = FormaBuildFeatures(viewBinding = true) + val withoutOptIn = defaults.resolveWith(viewBinding = false, compose = false) + assertFalse(withoutOptIn.viewBinding) + val withOptIn = defaults.resolveWith(viewBinding = true, compose = false) + assertTrue(withOptIn.viewBinding) + } + + @Test + fun `AndroidProjectSettings stores buildFeatures`() { + val features = FormaBuildFeatures(buildConfig = true, shaders = true) + val settings = + AndroidProjectSettings( + minSdk = 23, + targetSdk = 35, + compileSdk = 35, + kotlinVersion = "2.0.0", + agpVersion = "9.3.0", + repositories = {}, + compose = false, + composeCompilerVersion = "2.0.0", + vectorDrawablesUseSupportLibrary = false, + javaVersionCompatibility = org.gradle.api.JavaVersion.VERSION_11, + mandatoryOwners = false, + buildFeatures = features, + ) + assertEquals(features, settings.buildFeatures) + assertTrue(settings.buildFeatures.buildConfig) + assertTrue(settings.buildFeatures.shaders) + assertFalse(settings.buildFeatures.aidl) + } +}