diff --git a/TICKETS.md b/TICKETS.md index 1d60dd41..294ccca4 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -139,7 +139,7 @@ worker (ops); `v2`→`master` (explicit git promote only). **F-094** stays `bloc | F-096 | done | `String.transitiveDep` catalog parity | GH **#77** — `val String.transitiveDep` → `transitiveDeps(this)` next to `String.dep` / `String.ksp`; unit tests + `docs/DEPS-CATALOG.md` API/tip. | | F-097 | done | Finish build types: signing configs | GH **#51** — `FormaSigningConfig` + `androidBinary(signingConfigs, buildTypeSigning)` wired to AGP application signing only; dummy `application/binary/demo-release.keystore`; CALL-SITE-SURFACE § APK signing; unit tests on `:android`. | | F-098 | done | Core library desugaring (Java 8+ APIs) | GH **#103** — project-global `coreLibraryDesugaring` + optional dep pin on `androidProjectConfiguration` / `AndroidProjectSettings`; `CompileOptions.applyFrom` + `applyCoreLibraryDesugaring` on library/binary/native; default off; docs PROJECT-CONFIGURATION + CALL-SITE-SURFACE; unit tests `:config` + `:android`. | -| F-099 | todo | Target-feature configuration options | GH **#126** — design+thin slice: conditional / flagged deps and feature toggles (e.g. AP vs reflect DI) without dual happy paths. Prefer **type/rule or single global configuration** over call-site plugin shopping. Outcome may be design doc + smallest useful API; reject free-form per-module plugin lists. | +| F-099 | done | Target-feature configuration options | GH **#126** — **done:** design `docs/TARGET-FEATURE-OPTIONS.md` + project-global `FormaFeatureFlags` on `androidProjectConfiguration` / `AndroidProjectSettings`; `depsIf`/`depsUnless`/`whenFlag` resolve at `applyDependencies` via pure `resolveFeatureFlags`; unit tests `:config`+`:deps`; docs PROJECT-CONFIGURATION / CALL-SITE / DEPS-CATALOG. Rejects call-site plugin shopping + binary-only flags. | | F-100 | todo | Gradle project on buildscript classpath | GH **#111** — investigate allowing a Gradle **project** (composite/included build) as `extraPlugins` / buildscript classpath source alongside Maven coords. Design note first if API shape unclear; keep `extraPlugins` = classpath only (F-082). Implement only if clean with AGP 9 / Gradle 9. | | F-101 | todo | Close `target(...)` deps API (audit) | GH **#56** — **likely largely done:** `Project.target` / `target(name)` / `target(ProjectDependency)` + `deps(vararg FormaTarget)` used across sample. Audit docs/DEPS-CATALOG/CALL-SITE; close issue or ship any missing notation/docs; do not reintroduce raw `project()` as happy path. | | F-102 | todo | Navigation abstraction (sample + optional targets) | GH **#46** — reduce Jetpack Navigation codegen bleed across features. **Design first** (doc): presentation-layer nav ports vs Navigation Component; optional Forma nav targets only if type=rule fits. Prefer progressive example / sample refactor slice over new forever DSL. Large — split follow-ups if needed. | diff --git a/application/build.gradle.kts b/application/build.gradle.kts index c5359f41..23168cfb 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -23,6 +23,12 @@ buildscript { // Project-global only — not a per-module call-site flag. Default off (sample minSdk 23). // coreLibraryDesugaring = true, // coreLibraryDesugaringDependency = "com.android.tools:desugar_jdk_libs:2.1.5", // optional override + // F-099 / GH #126: project-global product feature flags (not AGP BuildFeatures). + // Conditional deps: depsIf("daggerReflect", …) / depsUnless("daggerReflect", …). + // See docs/TARGET-FEATURE-OPTIONS.md. Sample keeps default empty (all unknown = false). + // featureFlags = tools.forma.config.FormaFeatureFlags( + // "daggerReflect" to true, + // ), extraPlugins = listOf( libs.plugins.toolsFormaDemoDependencies, diff --git a/docs/CALL-SITE-SURFACE.md b/docs/CALL-SITE-SURFACE.md index 32b2a7e7..fd10333c 100644 --- a/docs/CALL-SITE-SURFACE.md +++ b/docs/CALL-SITE-SURFACE.md @@ -12,8 +12,8 @@ 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`, `Forma.settings.buildFeatures`, `coreLibraryDesugaring`, SDK, etc.) | -| Call site (`build.gradle.kts`) | Instance attrs only: `packageName`, deps, version, optional rule flags | +| Project configuration | Global defaults (`Forma.settings.compose`, `Forma.settings.buildFeatures`, `Forma.settings.featureFlags`, `coreLibraryDesugaring`, SDK, etc.) | +| Call site (`build.gradle.kts`) | Instance attrs only: `packageName`, deps (incl. conditional), version, optional rule flags | **Rejected at call sites** @@ -21,6 +21,7 @@ settings), not on a builder chain after the call. - Free-form `plugins = plugins(plugin("id"))` lists - Re-selecting plugin bindings per module - Per-module `coreLibraryDesugaring` / desugar dependency shopping (F-098 — project-global only) +- Per-module product-flag Booleans for every toggle (F-099 — declare once in `featureFlags`) ## Android target DSLs (`tools.forma.android`) @@ -190,6 +191,35 @@ configuration. Default **off** (sample stays green without the extra artifact). See [`PROJECT-CONFIGURATION.md`](PROJECT-CONFIGURATION.md) § `coreLibraryDesugaring`. +## Product feature flags + conditional deps (F-099 / GH #126) + +Named **product** toggles (DI mode, optional stacks) are **project-global only**. +They are **not** AGP `BuildFeatures` and **not** a second path to apply plugins. + +| Concern | Owner | Notes | +|---------|--------|--------| +| Flag declarations | **Project only** | `androidProjectConfiguration(featureFlags = FormaFeatureFlags(...))` | +| Read | `Forma.settings.featureFlags["name"]` | Unknown names → **false** | +| Conditional named deps | Call-site helpers | `depsIf` / `depsUnless` / `NamedDependency.whenFlag` | +| Plugin identity shopping gated by flags | **Rejected** | Type-owned plugins stay type-owned ([TARGET-PLUGINS.md](TARGET-PLUGINS.md)) | +| Per-`impl` Boolean for each product flag | **Rejected** | Fat call sites; dual path | + +```kotlin +// root — declare once +featureFlags = FormaFeatureFlags("daggerReflect" to true) + +// target — same graph shape everywhere; resolution at apply time +dependencies = deps( + "com.google.dagger:dagger:…".dep, + depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:…".dep), + depsUnless("daggerReflect", "com.google.dagger:dagger-compiler:…".ksp), +) +``` + +Helpers **tag** specs; `applyDependencies` resolves against the store. Do not teach +raw `if (project.hasProperty)` Gradle as the happy path. Full design + rejected +alternatives: [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md). + ## Removed (F-081) | Removed | Replacement | diff --git a/docs/DEPS-CATALOG.md b/docs/DEPS-CATALOG.md index 9c7fcb6d..1d8e24e4 100644 --- a/docs/DEPS-CATALOG.md +++ b/docs/DEPS-CATALOG.md @@ -174,7 +174,9 @@ step 08 introduces catalogs. | `parseGroupArtifactVersion` | same | GAV validation | | `deps` / `String.dep` / `Provider.dep` | root (`dependencies.kt`) | Bridge into `FormaDependency` (named deps default **non-transitive**) | | `transitiveDeps` / `String.transitiveDep` | root (`dependencies.kt`) | Same bridge with **transitive** named deps (`String.transitiveDep` = single-string parity with `String.dep`) | -| `applyDependencies` | `tools.forma.deps.core` | Wire deps + plugin side effects | +| `depsIf` / `depsUnless` / `NamedDependency.whenFlag` | root (`dependencies.kt`) | **F-099** — gate named deps on project-global `FormaFeatureFlags`; resolved at `applyDependencies` time (not construction). Unknown flag = false | +| `resolveFeatureFlags` | `tools.forma.deps.core` | Pure filter of flag-gated `NameSpec`s (unit-tested) | +| `applyDependencies` | `tools.forma.deps.core` | Wire deps + plugin side effects (+ F-099 flag resolution) | --- @@ -194,6 +196,10 @@ step 08 introduces catalogs. 6. **Plugins vs apply** — catalog `plugin(...)` + `extraPlugins` put jars on the **buildscript classpath only**. Type-owned apply is separate ([TARGET-PLUGINS.md](TARGET-PLUGINS.md)). +7. **Conditional deps (F-099)** — declare flags once on + `androidProjectConfiguration(featureFlags = …)`; use `depsIf` / `depsUnless` at + call sites. Do **not** shop plugins with flags or add per-module Booleans for + every product toggle. See [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md). --- @@ -207,3 +213,4 @@ step 08 introduces catalogs. - **Target external plugins** — type owns plugin, call sites auto-apply: [`TARGET-PLUGINS.md`](TARGET-PLUGINS.md) - Call-site surface: [`CALL-SITE-SURFACE.md`](CALL-SITE-SURFACE.md) +- Product feature flags + conditional deps: [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md) diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 27bfa181..d787487c 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -170,6 +170,7 @@ buildscript { // compose = false, // project default for per-target compose flags // composeCompilerVersion = "2.3.21", // match your Kotlin (2.3.21 → 2.3.21) // coreLibraryDesugaring = true, // F-098: Java 8+ library APIs on lower minSdk (project-global) + // featureFlags = FormaFeatureFlags("daggerReflect" to true), // F-099: product flags + depsIf/depsUnless // Classpath only — does NOT apply plugins to modules. See TARGET-PLUGINS.md. extraPlugins = listOf( // e.g. libs.plugins.navigationSafeArgs (jar on buildscript classpath) @@ -183,7 +184,8 @@ puts AGP + optional plugin jars on the **buildscript classpath only**, and regis `clean` task. Child targets read values from `Forma.settings`. See [`PROJECT-CONFIGURATION.md`](PROJECT-CONFIGURATION.md) for the single-path + store story -(including optional **core library desugaring** for Java 8+ APIs on lower `minSdk` — F-098). +(including optional **core library desugaring** for Java 8+ APIs on lower `minSdk` — F-098, +and **feature flags** + conditional deps — F-099 / [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md)). ### External Gradle plugins (safe-args, Firebase, …) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5acff66e..88b43a50 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,28 @@ Newest entries first. +## 2026-07-25 — F-099: project-global feature flags + conditional deps (GH #126) + +- **Ticket:** F-099 → `done` (GH #126) +- **Branch:** `forma/F-099-target-feature-options` (from `origin/v2`) +- **Design:** `docs/TARGET-FEATURE-OPTIONS.md` — project-global named boolean flags only; flags select deps/behavior not plugin identity; binary-linked config deferred; rejects `.withPlugin` / per-module flag shopping / free-form Gradle as happy path. +- **Code:** + - `FormaFeatureFlags` (pure) + `AndroidProjectSettings.featureFlags` + `FormaSettingsStore.featureFlagsOrEmpty()` + - `androidProjectConfiguration(featureFlags = …)` + - `NameSpec.featureFlag` / `featureFlagExpected`; `depsIf` / `depsUnless` / `NamedDependency.whenFlag` + - Pure `resolveFeatureFlags` in `ConditionalDependency.kt`; wired in `applyDependencies` at apply time + - Sample: commented `featureFlags` example in `application/build.gradle.kts` (default empty) + - Jacoco happy-path includes `FormaFeatureFlags*` + `ConditionalDependency*` +- **Tests:** `:config` `FormaFeatureFlagsTest`; `:deps` `ConditionalDependencyTest` (DI swap recipe on/off/unknown) +- **Docs:** TARGET-FEATURE-OPTIONS; PROJECT-CONFIGURATION § featureFlags; CALL-SITE-SURFACE § product flags; DEPS-CATALOG API rows; GETTING-STARTED pointer +- **Skills/modes:** Grok Build `--mode full` (design/plan + implement); Hermes finish path after CLI timeout on app assemble +- **Verify (real host, `source scripts/env-mac.sh`):** + - `plugins/`: `./gradlew :config:test :deps:test test jacocoHappyPathCoverageVerification` → **BUILD SUCCESSFUL** in 9s + - `application/`: `./gradlew :binary:assembleDebug` → **BUILD SUCCESSFUL** in 10s (599 tasks, up-to-date) +- **Commits/PRs:** this branch; Hermes PR/merge; close GH #126 when merged +- **Blockers:** none (F-094 Portal still human-blocked) +- **Next step:** F-100 (Gradle project on buildscript classpath) + ## 2026-07-25 — F-098: project-global core library desugaring (GH #103) - **Ticket:** F-098 → `done` (GH #103) diff --git a/docs/PROJECT-CONFIGURATION.md b/docs/PROJECT-CONFIGURATION.md index b9a1096d..cd8ed219 100644 --- a/docs/PROJECT-CONFIGURATION.md +++ b/docs/PROJECT-CONFIGURATION.md @@ -23,6 +23,10 @@ buildscript { // F-098 / GH #103: core library desugaring (Java 8+ APIs on lower minSdk) // coreLibraryDesugaring = true, // coreLibraryDesugaringDependency = "com.android.tools:desugar_jdk_libs:2.1.5", // optional + // F-099 / GH #126: project-global product feature flags (not AGP BuildFeatures) + // featureFlags = tools.forma.config.FormaFeatureFlags( + // "daggerReflect" to true, + // ), // F-090: skip project-dep suffix checks only for listed forked-in modules // dependencyValidationExclusions = setOf( // ":third-party:exoplayer:library-core", @@ -139,6 +143,46 @@ androidProjectConfiguration( ) ``` +### `featureFlags` (F-099 / GH #126) + +| | | +|--|--| +| **Type** | `FormaFeatureFlags` (default empty — unknown names read as **false**) | +| **Set on** | Root `androidProjectConfiguration(...)` only | +| **Stored as** | `AndroidProjectSettings.featureFlags` | +| **Read by** | `applyDependencies` (conditional deps); optional `Forma.settings.featureFlags[…]` | + +Project-global **named boolean product flags** for conditional dependencies and +shared behavior toggles (e.g. AP/KSP Dagger vs dagger-reflect). **Not** AGP +`BuildFeatures` ([FormaBuildFeatures](#buildfeatures-f-091--gh-88) is a different +concern). **Not** per-module plugin shopping. + +```kotlin +androidProjectConfiguration( + // ... + featureFlags = FormaFeatureFlags( + "daggerReflect" to true, + ), +) +``` + +Conditional deps at target call sites: + +```kotlin +dependencies = deps( + "com.google.dagger:dagger:2.48".dep, + depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:…".dep), + depsUnless("daggerReflect", "com.google.dagger:dagger-compiler:…".ksp), +) +``` + +Resolution runs at **apply** time against the store (not when the helper is built). +Unknown flag names are **false** (`get` / `isEnabled`); use `require(name)` only when +a missing declaration must fail configuration. + +Full design, rejected alternatives, and DI recipe: +[`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md). + ## 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. @@ -169,8 +213,9 @@ 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, `buildFeatures`, core library desugaring, repos, etc. | +| `AndroidProjectSettings` | `plugins/config/.../AndroidProjectSettings.kt` | Immutable data class holding SDKs, versions, compose default, `buildFeatures`, `featureFlags`, core library desugaring, repos, etc. | | `FormaBuildFeatures` | `plugins/config/.../FormaBuildFeatures.kt` | Nested AGP BuildFeatures defaults (all off); pure data + resolve helper | +| `FormaFeatureFlags` | `plugins/config/.../FormaFeatureFlags.kt` | Project-global named product flags (F-099); empty ⇒ unknown = false | | `DEFAULT_CORE_LIBRARY_DESUGARING_DEPENDENCY` | `plugins/config/.../AndroidProjectSettings.kt` | Default `desugar_jdk_libs` GAV pin (F-098) | | `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`) | @@ -186,6 +231,7 @@ Readers (feature wiring, dependency helpers, target DSLs) obtain values through - [`COMPOSE.md`](COMPOSE.md) — `compose` + `composeCompilerVersion` details - [`ARCHITECTURE.md`](ARCHITECTURE.md) — plugin module layout and store - [`VISION.md`](VISION.md) — root principles (one global way) +- [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md) — product feature flags + conditional deps (F-099) - Sample: `application/build.gradle.kts` - Progressive example 10: `examples/android/10-target-plugins` diff --git a/docs/TARGET-FEATURE-OPTIONS.md b/docs/TARGET-FEATURE-OPTIONS.md new file mode 100644 index 00000000..d70e7a83 --- /dev/null +++ b/docs/TARGET-FEATURE-OPTIONS.md @@ -0,0 +1,182 @@ +# Target-feature configuration options (F-099 / GH #126) + +**Status:** design + thin API shipped. Project-global named boolean flags + +conditional dependency helpers. Binary-linked configuration and `configuration` +targets remain **deferred**. + +## Problem + +Product teams need **one** way to toggle behavior across many modules without +reintroducing fat call sites or plugin shopping. Classic examples: + +| Need | Example | +|------|---------| +| DI strategy | Annotation-processing / KSP Dagger in release vs [dagger-reflect](https://github.com/JakeWharton/dagger-reflect) in debug/dev | +| Optional stacks | Crash reporting, analytics, offline mode — different dep sets | +| Build-variant product features | Feature modules must know flags **while they configure**, not only at the final APK | + +Flags must be readable when **feature modules** apply dependencies. Waiting until +`androidBinary` configuration is often **too late**. + +## Why not (rejected) + +| Approach | Why rejected | +|----------|----------------| +| Per-module `.withPlugin` / free-form plugin id lists | Violates type-owned plugins ([TARGET-PLUGINS.md](TARGET-PLUGINS.md)); dual happy path; does not scale | +| Binary-only flag maps on every `androidBinary` | Too late for `impl` / feature graph configuration; encourages per-binary dual paths | +| Per-`impl` Boolean for every product flag | Fat call sites; drifts from Bazel-like “configure once on the type/project” | +| Free-form Gradle `if (project.hasProperty)` as happy path | Second path beside Forma; untyped; unreviewable at fleet scale | +| Flags select **plugin identity** ad hoc | Plugin identity stays type-owned (F-070); flags select **deps/behavior** only | +| Restoring generic `androidLibrary` buckets | Orthogonal and already removed (F-063) | + +## Chosen model: project-global `FormaFeatureFlags` + +**Named boolean dimensions declared once** at root configuration: + +```kotlin +// root build.gradle.kts +buildscript { + androidProjectConfiguration( + project = rootProject, + // ... + featureFlags = tools.forma.config.FormaFeatureFlags( + "daggerReflect" to true, // dev machine / CI job toggle + ), + ) +} +``` + +| Piece | Location | +|-------|----------| +| `FormaFeatureFlags` | `plugins/config` — pure immutable map | +| Storage | `AndroidProjectSettings.featureFlags` | +| Entry API | `androidProjectConfiguration(featureFlags = …)` | +| Read | `Forma.settings.featureFlags["name"]` / `FormaSettingsStore.featureFlagsOrEmpty()` | + +### Semantics + +| API | Unknown name | Declared `false` | Declared `true` | +|-----|--------------|------------------|-----------------| +| `flags["name"]` / `isEnabled` | **false** | false | true | +| `require("name")` | **throws** | false | true | +| `contains("name")` | false | true | true | + +**Default empty flags:** every name is off. Missing declaration is not an error for +`get` / conditional deps — it is a deliberate safe default so gated “on” deps stay out +until someone opts in at the root. + +## Conditional dependencies + +Helpers live next to `deps(...)` (root `dependencies.kt`). They **tag** named specs; +they do **not** read flags at construction time. + +```kotlin +dependencies = deps( + "com.google.dagger:dagger:2.48".dep, + depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:0.3.0".dep), + depsUnless("daggerReflect", "com.google.dagger:dagger:2.48".dep), // if not already always-on + depsUnless("daggerReflect", "com.google.dagger:dagger-compiler:2.48".ksp), +) +``` + +| Helper | Include when | +|--------|----------------| +| `depsIf(flag, …deps)` | flag **on** (expected `true`) | +| `depsIf(flag, …deps, enabled = false)` | flag equals `enabled` (named param after vararg) | +| `depsUnless(flag, …deps)` | flag **off** (or unknown) | +| `named.whenFlag(flag, enabled)` | same, extension on `NamedDependency` | + +### Resolution timing + +1. Call site builds `NamedDependency` graphs; gated specs carry `featureFlag` + + `featureFlagExpected` on `NameSpec`. +2. `applyDependencies` reads `FormaSettingsStore.featureFlagsOrEmpty()`. +3. Pure `resolveFeatureFlags(flags)` drops non-matching name specs (unit-tested). +4. Remaining specs apply as today (including KSP / catalog plugin side effects). + +This keeps the model **pure + JaCoCo-friendly** and correct under Gradle configuration +order: helpers may run before or after settings are stored; **apply** always sees the +store. + +### Composition + +Existing `deps(vararg NamedDependency)` still works — it flattens `NameSpec` lists and +preserves flag metadata. Unconditional deps stay unconditional (`featureFlag == null`). + +## Relationship to `FormaBuildFeatures` (F-091) + +| | `FormaBuildFeatures` | `FormaFeatureFlags` | +|--|----------------------|---------------------| +| Concern | AGP `BuildFeatures` (aidl, buildConfig, …) | **Product** toggles (DI mode, optional stacks) | +| Shape | Fixed typed Booleans | Open named map | +| Applied by | Android library/binary feature wiring | Conditional deps (+ future feature reads) | +| Compose | **Not** here — top-level `compose` | N/A | + +Do not nest product flags inside `FormaBuildFeatures`. Do not put AGP flags into +`FormaFeatureFlags`. + +## Relationship to type-owned plugins (F-070) + +- **Type owns plugin identity** — `targetPlugin` / `deriveTargetType` / registry auto-apply. +- **Flags select deps and behavior**, not “which plugin id string to shop at the call site.” +- If a product toggle must change plugins, prefer a **derived target type** or a single + documented type-level binding — not `if (flag) withPlugin(...)`. + +## Worked example: Dagger KSP vs dagger-reflect + +**Intent:** one DI graph API; swap runtime/processor wiring with a single root flag. + +```kotlin +// root +featureFlags = FormaFeatureFlags("daggerReflect" to providers.gradleProperty("forma.daggerReflect") + .map { it.toBoolean() } + .orElse(false) + .get()) // or a hard-coded true on a dev branch +``` + +```kotlin +// feature impl (same call site in all modules) +impl( + packageName = "com.example.feature.home.impl", + dependencies = deps( + deps(target(":feature:home:api")), + "com.google.dagger:dagger:2.48".dep, + depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:0.3.0".dep), + depsUnless("daggerReflect", "com.google.dagger:dagger-compiler:2.48".ksp), + ), +) +``` + +| `daggerReflect` | Runtime | Processor | +|-----------------|---------|-----------| +| `true` | dagger + dagger-reflect | *(no KSP compiler)* | +| `false` / unset | dagger | dagger-compiler via KSP | + +**Not in this slice:** actually wiring dagger-reflect into the gold sample, or a full +binary↔configuration product. The recipe above is the supported pattern. + +## Future (explicitly deferred) + +| Item | Notes | +|------|--------| +| Binary-linked configuration (1:1 with binaries) | May return as a later ticket; must still feed flags **early** enough for feature modules (e.g. root still declares, binary selects profile) | +| `configuration` / stub targets (F-104) | Separate structural idea | +| Non-boolean dimensions | Start with booleans; enums/multi only if a ticket demands | +| Flag-gated **target plugins** | Prefer derived types; do not reopen free-form plugin lists | + +## API checklist + +- [x] `FormaFeatureFlags` on `AndroidProjectSettings` +- [x] `androidProjectConfiguration(featureFlags = …)` +- [x] `depsIf` / `depsUnless` / `NamedDependency.whenFlag` +- [x] Apply-time `resolveFeatureFlags` in `applyDependencies` +- [x] Pure unit tests (`:config`, `:deps`) +- [x] Docs: this file + PROJECT-CONFIGURATION + CALL-SITE-SURFACE + DEPS-CATALOG + +## Cross references + +- [`PROJECT-CONFIGURATION.md`](PROJECT-CONFIGURATION.md) — `featureFlags` section +- [`CALL-SITE-SURFACE.md`](CALL-SITE-SURFACE.md) — conditional deps ≠ plugin shopping +- [`DEPS-CATALOG.md`](DEPS-CATALOG.md) — API table +- [`TARGET-PLUGINS.md`](TARGET-PLUGINS.md) — type-owned plugins +- [`VISION.md`](VISION.md) — root principles diff --git a/docs/TEST-COVERAGE.md b/docs/TEST-COVERAGE.md index 9515a750..722e120f 100644 --- a/docs/TEST-COVERAGE.md +++ b/docs/TEST-COVERAGE.md @@ -32,7 +32,7 @@ Pure, unit-tested engine surface (no AGP TestKit / `Project` apply paths): | Area | Classes | |------|---------| | **forma-core** | all `tools.forma.core.**` | -| **config model** | `AndroidProjectSettings`, `FormaBuildFeatures`, `FormaSettingsStore`, dependency-validation helpers | +| **config model** | `AndroidProjectSettings`, `FormaBuildFeatures`, `FormaFeatureFlags`, `FormaSettingsStore`, dependency-validation helpers | | **deps pure** | catalog `Generators*`, `TargetPlugin*` registry, configuration/dep model types | | **jvm targets** | `tools.forma.jvm.target.**` | diff --git a/plugins/android/src/main/java/androidProjectConfiguration.kt b/plugins/android/src/main/java/androidProjectConfiguration.kt index df9078bd..27dca70b 100644 --- a/plugins/android/src/main/java/androidProjectConfiguration.kt +++ b/plugins/android/src/main/java/androidProjectConfiguration.kt @@ -12,6 +12,7 @@ import tools.forma.android.target.registerAndroidDefaults import tools.forma.config.AndroidProjectSettings import tools.forma.config.DEFAULT_CORE_LIBRARY_DESUGARING_DEPENDENCY import tools.forma.config.FormaBuildFeatures +import tools.forma.config.FormaFeatureFlags import tools.forma.config.FormaSettingsStore import tools.forma.config.PluginInfoStore import tools.forma.config.SettingsStore @@ -87,6 +88,10 @@ import tools.forma.deps.fleet.ensureFormaLayoutRootTasks * @param coreLibraryDesugaringDependency Maven coordinate for desugar JDK libs when * [coreLibraryDesugaring] is true. Default [DEFAULT_CORE_LIBRARY_DESUGARING_DEPENDENCY]. * Stored when off but not applied until the flag is true. + * @param featureFlags project-global named product flags (F-099 / GH #126). Default empty + * (unknown names read as **false**). Conditional deps (`depsIf` / `depsUnless`) resolve + * against these at apply time. **Not** [FormaBuildFeatures] and **not** plugin shopping — + * see [FormaFeatureFlags] and docs/TARGET-FEATURE-OPTIONS.md. * @param extraPlugins list of extra artifacts / plugin providers to add to the **buildscript classpath only**. * See "Classpath vs apply" in TARGET-PLUGINS.md. */ @@ -108,6 +113,7 @@ fun ScriptHandlerScope.androidProjectConfiguration( buildFeatures: FormaBuildFeatures = FormaBuildFeatures(), coreLibraryDesugaring: Boolean = false, coreLibraryDesugaringDependency: String = DEFAULT_CORE_LIBRARY_DESUGARING_DEPENDENCY, + featureFlags: FormaFeatureFlags = FormaFeatureFlags.EMPTY, extraPlugins: List = emptyList() ) { buildScriptConfiguration( @@ -148,6 +154,7 @@ fun ScriptHandlerScope.androidProjectConfiguration( buildFeatures = buildFeatures, coreLibraryDesugaring = coreLibraryDesugaring, coreLibraryDesugaringDependency = coreLibraryDesugaringDependency, + featureFlags = featureFlags, ) Forma.store(configuration) diff --git a/plugins/buildSrc/src/main/kotlin/formaCoverage.kt b/plugins/buildSrc/src/main/kotlin/formaCoverage.kt index 10eb1b4b..25af32a4 100644 --- a/plugins/buildSrc/src/main/kotlin/formaCoverage.kt +++ b/plugins/buildSrc/src/main/kotlin/formaCoverage.kt @@ -42,6 +42,7 @@ internal val happyPathClassIncludes = "**/tools/forma/core/**", "**/tools/forma/config/AndroidProjectSettings*", "**/tools/forma/config/FormaBuildFeatures*", + "**/tools/forma/config/FormaFeatureFlags*", "**/tools/forma/config/FormaSettingsStore*", "**/tools/forma/config/DependencyValidation*", "**/tools/forma/deps/catalog/Generators*", @@ -66,6 +67,8 @@ internal val happyPathClassIncludes = "**/tools/forma/deps/core/TargetDependency*", "**/tools/forma/deps/core/FileDependency*", "**/tools/forma/deps/core/MixedDependency*", + // F-099 pure conditional-deps resolver (applyDependencies stays out of gate) + "**/tools/forma/deps/core/ConditionalDependency*", "**/tools/forma/jvm/target/**", ) 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 70b0e8e3..41355f1f 100644 --- a/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt +++ b/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt @@ -91,6 +91,16 @@ data class AndroidProjectSettings( * Default [DEFAULT_CORE_LIBRARY_DESUGARING_DEPENDENCY]. */ val coreLibraryDesugaringDependency: String = DEFAULT_CORE_LIBRARY_DESUGARING_DEPENDENCY, + /** + * Project-global named product feature flags (F-099 / GH #126). + * Default empty — unknown flag names read as **false**. + * Set from root `androidProjectConfiguration(featureFlags = …)`. + * Conditional deps (`depsIf` / `depsUnless`) resolve against this map at apply time. + * + * **Not** [FormaBuildFeatures] (AGP BuildFeatures). **Not** per-module plugin shopping. + * See `docs/TARGET-FEATURE-OPTIONS.md`. + */ + val featureFlags: FormaFeatureFlags = FormaFeatureFlags.EMPTY, ) /** @@ -162,6 +172,13 @@ object FormaSettingsStore : SettingsStore, PluginInfoSto exclusions = dependencyValidationExclusionsOrEmpty(), ) + /** + * [AndroidProjectSettings.featureFlags] when settings exist; empty flags otherwise + * (null-safe — does not throw; unknown names remain false). + */ + fun featureFlagsOrEmpty(): FormaFeatureFlags = + if (isSettingsStored) _settings.featureFlags else FormaFeatureFlags.EMPTY + override val plugins: MutableMap, PluginConfiguration> = mutableMapOf() override val dependencyPlugins: MutableMap = mutableMapOf() override val dependencies: MutableMap = mutableMapOf() diff --git a/plugins/config/src/main/java/tools/forma/config/FormaFeatureFlags.kt b/plugins/config/src/main/java/tools/forma/config/FormaFeatureFlags.kt new file mode 100644 index 00000000..4b82af06 --- /dev/null +++ b/plugins/config/src/main/java/tools/forma/config/FormaFeatureFlags.kt @@ -0,0 +1,76 @@ +package tools.forma.config + +/** + * Project-global named boolean feature flags (F-099 / GH #126). + * + * Declare once on root `androidProjectConfiguration(featureFlags = …)` / + * [AndroidProjectSettings.featureFlags]. Read when applying dependencies and + * (optionally) target features. **Not** per-module plugin shopping and **not** + * AGP [FormaBuildFeatures] (different concern — BuildFeatures vs product flags). + * + * ## Defaults + * + * - Empty map is the default: every unknown name is **off** (`false`). + * - [get] / [isEnabled] never throw for unknown names — missing = false. + * - [require] throws if the name was never declared (strict call sites). + * + * ## One global way + * + * Flags live only on project configuration. Do not invent per-`impl` Boolean + * parameters for each product toggle, free-form plugin id lists gated by flags, + * or binary-only flag maps (too late for feature modules). See + * `docs/TARGET-FEATURE-OPTIONS.md`. + * + * ```kotlin + * androidProjectConfiguration( + * // ... + * featureFlags = FormaFeatureFlags( + * "daggerReflect" to true, // dev: reflection DI + * ), + * ) + * ``` + */ +data class FormaFeatureFlags( + private val flags: Map = emptyMap(), +) { + /** + * Convenience constructor for call sites: + * `FormaFeatureFlags("daggerReflect" to true, "offlineMode" to false)`. + */ + constructor(vararg pairs: Pair) : this(mapOf(*pairs)) + + /** + * Whether [name] is enabled. Unknown names return **false** + * (safe default — undeclared flag = off). + */ + operator fun get(name: String): Boolean = flags[name] ?: false + + /** Same as [get] — unknown names are false. */ + fun isEnabled(name: String): Boolean = this[name] + + /** + * Declared value for [name], or throws if the flag was never declared. + * + * Prefer [get] / [isEnabled] when unknown-as-false is the correct product + * default. Use [require] only when a missing declaration is a configuration + * error (e.g. a shared recipe that must be toggled explicitly). + */ + fun require(name: String): Boolean = + flags[name] + ?: throw IllegalArgumentException( + "Unknown feature flag '$name'. Declare it once via " + + "androidProjectConfiguration(featureFlags = FormaFeatureFlags(...)). " + + "See docs/TARGET-FEATURE-OPTIONS.md." + ) + + /** True when [name] appears in the declared map (even if the value is false). */ + fun contains(name: String): Boolean = name in flags + + /** Immutable snapshot of declared flags. */ + fun toMap(): Map = flags.toMap() + + companion object { + /** Empty flags — every [get] / [isEnabled] returns false. */ + val EMPTY: FormaFeatureFlags = FormaFeatureFlags() + } +} diff --git a/plugins/config/src/test/java/tools/forma/config/FormaFeatureFlagsTest.kt b/plugins/config/src/test/java/tools/forma/config/FormaFeatureFlagsTest.kt new file mode 100644 index 00000000..326bbd72 --- /dev/null +++ b/plugins/config/src/test/java/tools/forma/config/FormaFeatureFlagsTest.kt @@ -0,0 +1,107 @@ +package tools.forma.config + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.gradle.api.JavaVersion + +/** + * F-099 / GH #126: project-global named feature flags (pure model). + */ +class FormaFeatureFlagsTest { + + @Test + fun `empty defaults unknown names to false`() { + val flags = FormaFeatureFlags() + assertFalse(flags["daggerReflect"]) + assertFalse(flags.isEnabled("anything")) + assertFalse(flags.contains("daggerReflect")) + assertTrue(flags.toMap().isEmpty()) + assertEquals(FormaFeatureFlags.EMPTY, FormaFeatureFlags()) + } + + @Test + fun `declared true and false are readable`() { + val flags = + FormaFeatureFlags( + "daggerReflect" to true, + "offlineMode" to false, + ) + assertTrue(flags["daggerReflect"]) + assertTrue(flags.isEnabled("daggerReflect")) + assertFalse(flags["offlineMode"]) + assertFalse(flags.isEnabled("offlineMode")) + assertTrue(flags.contains("offlineMode")) + assertFalse(flags.contains("missing")) + assertEquals( + mapOf("daggerReflect" to true, "offlineMode" to false), + flags.toMap(), + ) + } + + @Test + fun `map constructor matches vararg pairs`() { + val fromMap = FormaFeatureFlags(mapOf("a" to true, "b" to false)) + val fromPairs = FormaFeatureFlags("a" to true, "b" to false) + assertEquals(fromMap.toMap(), fromPairs.toMap()) + assertTrue(fromMap.isEnabled("a")) + assertFalse(fromMap.isEnabled("b")) + } + + @Test + fun `require returns declared value and throws for unknown`() { + val flags = FormaFeatureFlags("strict" to true) + assertTrue(flags.require("strict")) + val ex = + assertFailsWith { + flags.require("neverDeclared") + } + assertTrue(ex.message!!.contains("neverDeclared")) + assertTrue(ex.message!!.contains("androidProjectConfiguration")) + } + + @Test + fun `AndroidProjectSettings stores featureFlags`() { + val featureFlags = FormaFeatureFlags("daggerReflect" to 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 = JavaVersion.VERSION_11, + mandatoryOwners = false, + featureFlags = featureFlags, + ) + assertEquals(featureFlags, settings.featureFlags) + assertTrue(settings.featureFlags.isEnabled("daggerReflect")) + assertFalse(settings.featureFlags.isEnabled("missing")) + } + + @Test + fun `default AndroidProjectSettings featureFlags are empty`() { + 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 = JavaVersion.VERSION_11, + mandatoryOwners = false, + ) + assertEquals(FormaFeatureFlags.EMPTY, settings.featureFlags) + assertFalse(settings.featureFlags["x"]) + } +} diff --git a/plugins/deps/src/main/java/dependencies.kt b/plugins/deps/src/main/java/dependencies.kt index 7af5c5bd..7208fe3d 100644 --- a/plugins/deps/src/main/java/dependencies.kt +++ b/plugins/deps/src/main/java/dependencies.kt @@ -189,6 +189,57 @@ fun deps(vararg dependencies: NamedDependency): NamedDependency { return NamedDependency(out) } +/** + * Gate every name spec in this [NamedDependency] on project-global feature flag [flag] + * (F-099 / GH #126). Resolution happens at [tools.forma.deps.core.applyDependencies] + * time via [tools.forma.deps.core.resolveFeatureFlags] — the flag value is **not** + * frozen when this helper runs. + * + * @param flag name declared in `androidProjectConfiguration(featureFlags = …)` + * @param enabled expected flag value (default `true` = include when flag is on) + */ +fun NamedDependency.whenFlag(flag: String, enabled: Boolean = true): NamedDependency = + NamedDependency( + names.map { spec -> + NameSpec( + name = spec.name, + config = spec.config, + transitive = spec.transitive, + featureFlag = flag, + featureFlagExpected = enabled, + ) + } + ) + +/** + * Include [dependencies] only when project-global flag [flag] equals [enabled] + * (default: flag on). Compose with [deps]: + * + * ```kotlin + * dependencies = deps( + * "g:always:1".dep, + * depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:…".dep), + * depsUnless("daggerReflect", "com.google.dagger:dagger:…".dep), + * depsUnless("daggerReflect", "com.google.dagger:dagger-compiler:…".ksp), + * ) + * ``` + * + * Flag values are read at [tools.forma.deps.core.applyDependencies] time from + * `Forma.settings.featureFlags` / [tools.forma.config.FormaSettingsStore.featureFlagsOrEmpty]. + */ +fun depsIf( + flag: String, + vararg dependencies: NamedDependency, + enabled: Boolean = true, +): NamedDependency = deps(*dependencies).whenFlag(flag, enabled) + +/** + * Include [dependencies] only when project-global flag [flag] is **off** + * (unknown flags count as off — see [tools.forma.config.FormaFeatureFlags]). + */ +fun depsUnless(flag: String, vararg dependencies: NamedDependency): NamedDependency = + deps(*dependencies).whenFlag(flag, enabled = false) + /** Typesafe project accessors / [ProjectDependency] → target deps (Gradle 9: path only, no dependencyProject). */ fun Project.deps(vararg projects: ProjectDependency): TargetDependency = TargetDependency(projects.map { TargetSpec(target(it)) }) diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/ConditionalDependency.kt b/plugins/deps/src/main/java/tools.forma/deps/core/ConditionalDependency.kt new file mode 100644 index 00000000..86227fa8 --- /dev/null +++ b/plugins/deps/src/main/java/tools.forma/deps/core/ConditionalDependency.kt @@ -0,0 +1,47 @@ +package tools.forma.deps.core + +import tools.forma.config.FormaFeatureFlags + +/** + * Pure resolution of feature-flag-gated named dependencies (F-099 / GH #126). + * + * Specs carry optional [NameSpec.featureFlag] metadata from `depsIf` / + * `depsUnless` / [NamedDependency.whenFlag]. Resolution runs against + * project-global [FormaFeatureFlags] at **apply** time (see + * [applyDependencies]), not when the helper is constructed. + * + * Unknown flag names are **false** ([FormaFeatureFlags.get]) — matching + * `depsIf("missing", …)` drops the dep and `depsUnless("missing", …)` keeps it. + */ + +/** Whether this named dep should be applied given [flags]. Unconditional specs always include. */ +fun NameSpec.isIncludedBy(flags: FormaFeatureFlags): Boolean { + val flag = featureFlag ?: return true + return flags.isEnabled(flag) == featureFlagExpected +} + +/** Keep only name specs whose flag condition matches [flags]. */ +fun List.resolveFeatureFlags(flags: FormaFeatureFlags): List = + filter { it.isIncludedBy(flags) } + +/** + * Drop named deps that fail their feature-flag condition. + * Targets, files, and platforms are unchanged (no flag metadata on those kinds yet). + */ +fun FormaDependency.resolveFeatureFlags(flags: FormaFeatureFlags): FormaDependency = + when (this) { + EmptyDependency -> this + is NamedDependency -> { + val resolved = names.resolveFeatureFlags(flags) + if (resolved.isEmpty()) EmptyDependency else NamedDependency(resolved) + } + is MixedDependency -> + MixedDependency( + names = names.resolveFeatureFlags(flags), + targets = targets, + files = files, + ) + is TargetDependency -> this + is FileDependency -> this + is PlatformDependency -> this + } diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt b/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt index ac0fa991..1818dac1 100644 --- a/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt +++ b/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt @@ -36,8 +36,23 @@ class TargetSpec(val target: FormaTarget, config: ConfigurationType = Implementa class FileSpec(val file: File, config: ConfigurationType) : DepSpec(config) -class NameSpec(val name: String, config: ConfigurationType, val transitive: Boolean = false) : - DepSpec(config) +/** + * External module coordinate (GAV or catalog-resolved name). + * + * Optional [featureFlag] gates inclusion at apply time against project-global + * [tools.forma.config.FormaFeatureFlags] (F-099). When null, the dep is always applied. + * See `depsIf` / `depsUnless` / [NamedDependency.whenFlag] and + * [resolveFeatureFlags]. + */ +class NameSpec( + val name: String, + config: ConfigurationType, + val transitive: Boolean = false, + /** When non-null, include only if project flags match [featureFlagExpected]. */ + val featureFlag: String? = null, + /** Expected [tools.forma.config.FormaFeatureFlags] value for [featureFlag] (default true). */ + val featureFlagExpected: Boolean = true, +) : DepSpec(config) class PlatformSpec(val name: String, config: ConfigurationType, val transitive: Boolean = false) : DepSpec(config) diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt b/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt index 7118f53d..c9c5529d 100644 --- a/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt +++ b/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt @@ -35,6 +35,22 @@ fun Project.applyDependencies( return } + // F-099: resolve feature-flag-gated named deps against project-global flags at apply + // time (not when depsIf/depsUnless was called). Unknown flags = false. + val featureFlags = FormaSettingsStore.featureFlagsOrEmpty() + val resolvedDependencies = dependencies.resolveFeatureFlags(featureFlags) + val resolvedTestDependencies = testDependencies.resolveFeatureFlags(featureFlags) + val resolvedAndroidTestDependencies = + androidTestDependencies.resolveFeatureFlags(featureFlags) + + if ( + resolvedDependencies === EmptyDependency && + resolvedTestDependencies === EmptyDependency && + resolvedAndroidTestDependencies === EmptyDependency + ) { + return + } + // Prevent same plugin to be applied twice val appliedPlugins = mutableSetOf() val hasPluginDeps = FormaSettingsStore.dependencyPlugins.isNotEmpty() @@ -53,7 +69,7 @@ fun Project.applyDependencies( } add(it.config.name, depProject) } - dependencies.forEach( + resolvedDependencies.forEach( { spec -> val plugin = if (hasPluginDeps) FormaSettingsStore.pluginFor(spec.name) else null @@ -74,15 +90,15 @@ fun Project.applyDependencies( { add(it.config.name, files(it.file)) }, { addDependencyTo(it.config.name, platform(it.name)) { isTransitive = it.transitive } } ) - if (testDependencies !== EmptyDependency) { - testDependencies.forEach( + if (resolvedTestDependencies !== EmptyDependency) { + resolvedTestDependencies.forEach( { addDependencyTo("testImplementation", it.name) { isTransitive = it.transitive } }, { add("testImplementation", it.target.project) }, { add("testImplementation", files(it.file)) } ) } - if (androidTestDependencies !== EmptyDependency) { - androidTestDependencies.forEach( + if (resolvedAndroidTestDependencies !== EmptyDependency) { + resolvedAndroidTestDependencies.forEach( { addDependencyTo("androidTestImplementation", it.name) { isTransitive = it.transitive diff --git a/plugins/deps/src/test/kotlin/tools/forma/deps/core/ConditionalDependencyTest.kt b/plugins/deps/src/test/kotlin/tools/forma/deps/core/ConditionalDependencyTest.kt new file mode 100644 index 00000000..b99dd4ab --- /dev/null +++ b/plugins/deps/src/test/kotlin/tools/forma/deps/core/ConditionalDependencyTest.kt @@ -0,0 +1,152 @@ +package tools.forma.deps.core + +import dep +import deps +import depsIf +import depsUnless +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import ksp +import tools.forma.config.FormaFeatureFlags +import whenFlag + +/** + * F-099 / GH #126: pure conditional dependency resolution (no Gradle Project). + */ +class ConditionalDependencyTest { + + @Test + fun `unconditional NameSpec always included`() { + val spec = NameSpec("g:a:1", Implementation) + assertTrue(spec.isIncludedBy(FormaFeatureFlags.EMPTY)) + assertTrue(spec.isIncludedBy(FormaFeatureFlags("x" to true))) + } + + @Test + fun `depsIf includes only when flag matches expected`() { + val gated = depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:1".dep) + val spec = gated.names.single() + assertEquals("daggerReflect", spec.featureFlag) + assertTrue(spec.featureFlagExpected) + + assertTrue(spec.isIncludedBy(FormaFeatureFlags("daggerReflect" to true))) + assertFalse(spec.isIncludedBy(FormaFeatureFlags("daggerReflect" to false))) + assertFalse(spec.isIncludedBy(FormaFeatureFlags.EMPTY)) + } + + @Test + fun `depsUnless includes only when flag is off or unknown`() { + val gated = depsUnless("daggerReflect", "com.google.dagger:dagger:2".dep) + val spec = gated.names.single() + assertEquals("daggerReflect", spec.featureFlag) + assertFalse(spec.featureFlagExpected) + + assertFalse(spec.isIncludedBy(FormaFeatureFlags("daggerReflect" to true))) + assertTrue(spec.isIncludedBy(FormaFeatureFlags("daggerReflect" to false))) + assertTrue(spec.isIncludedBy(FormaFeatureFlags.EMPTY)) + } + + @Test + fun `whenFlag preserves config transitive and GAV`() { + val base = "g:processor:1".ksp + val gated = base.whenFlag("useKsp", enabled = true) + val spec = gated.names.single() + assertEquals("g:processor:1", spec.name) + assertEquals(Ksp, spec.config) + assertTrue(spec.transitive) + assertEquals("useKsp", spec.featureFlag) + assertTrue(spec.featureFlagExpected) + } + + @Test + fun `resolveFeatureFlags dagger reflect swap recipe`() { + val always = "com.google.dagger:dagger:2.48".dep + val reflect = depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:0.3.0".dep) + val compiler = + depsUnless("daggerReflect", "com.google.dagger:dagger-compiler:2.48".ksp) + val composed = deps(always, reflect, compiler) + + val reflectOn = + composed.resolveFeatureFlags(FormaFeatureFlags("daggerReflect" to true)) + as NamedDependency + assertEquals( + listOf( + "com.google.dagger:dagger:2.48", + "com.jakewharton.dagger:dagger-reflect:0.3.0", + ), + reflectOn.names.map { it.name }, + ) + + val reflectOff = + composed.resolveFeatureFlags(FormaFeatureFlags("daggerReflect" to false)) + as NamedDependency + assertEquals( + listOf( + "com.google.dagger:dagger:2.48", + "com.google.dagger:dagger-compiler:2.48", + ), + reflectOff.names.map { it.name }, + ) + + val unknown = + composed.resolveFeatureFlags(FormaFeatureFlags.EMPTY) as NamedDependency + // unknown = false → unless keeps compiler, if drops reflect + assertEquals( + listOf( + "com.google.dagger:dagger:2.48", + "com.google.dagger:dagger-compiler:2.48", + ), + unknown.names.map { it.name }, + ) + } + + @Test + fun `resolve all gated off becomes EmptyDependency`() { + val onlyIf = depsIf("x", "g:a:1".dep) + val resolved = onlyIf.resolveFeatureFlags(FormaFeatureFlags("x" to false)) + assertSame(EmptyDependency, resolved) + } + + @Test + fun `MixedDependency resolves names only`() { + val mixed = + MixedDependency( + names = + listOf( + NameSpec("g:a:1", Implementation), + NameSpec( + "g:b:1", + Implementation, + featureFlag = "flag", + featureFlagExpected = true, + ), + ), + targets = emptyList(), + files = emptyList(), + ) + val resolved = mixed.resolveFeatureFlags(FormaFeatureFlags.EMPTY) as MixedDependency + assertEquals(listOf("g:a:1"), resolved.names.map { it.name }) + } + + @Test + fun `depsIf vararg flattens multiple named deps under one flag`() { + val gated = + depsIf( + "bundle", + "g:a:1".dep, + "g:b:1".dep, + ) + assertEquals(2, gated.names.size) + assertTrue(gated.names.all { it.featureFlag == "bundle" && it.featureFlagExpected }) + val on = gated.resolveFeatureFlags(FormaFeatureFlags("bundle" to true)) as NamedDependency + assertEquals(listOf("g:a:1", "g:b:1"), on.names.map { it.name }) + } + + @Test + fun `EmptyDependency resolve is identity`() { + assertSame(EmptyDependency, EmptyDependency.resolveFeatureFlags(FormaFeatureFlags.EMPTY)) + } +}