From 52b833bcd6a4dd35609d8a02ab7fab39005782ac Mon Sep 17 00:00:00 2001 From: Claw Date: Mon, 27 Jul 2026 05:32:20 -0700 Subject: [PATCH] F-104: hybrid stub swap via project-global feature flag Gate TargetSpec on FormaFeatureFlags; add featureImplementation pair helper and target depsIf/whenFlag. Document in HYBRID-CONFIGURATION.md and migrate example 15 to -Pforma.useFeatureStubs. --- TICKETS.md | 9 +- docs/CALL-SITE-SURFACE.md | 13 +- docs/DEPS-CATALOG.md | 14 +- docs/HYBRID-CONFIGURATION.md | 200 ++++++++++++++++++ docs/PROGRESS.md | 22 ++ docs/PROGRESSIVE-EXAMPLES.md | 2 +- docs/PROJECT-CONFIGURATION.md | 17 +- docs/TARGET-FEATURE-OPTIONS.md | 14 +- examples/agent-skills/forma-project-layout.md | 17 +- examples/android/15-hybrid-targets/README.md | 102 +++++---- .../15-hybrid-targets/binary/build.gradle.kts | 24 ++- .../15-hybrid-targets/build.gradle.kts | 8 + .../feature/hello/stub-impl/build.gradle.kts | 5 +- .../feature/hello/impl/DefaultHelloMessage.kt | 11 + .../feature/hello/stub/StubHelloMessage.kt | 8 - .../feature/world/stub-impl/build.gradle.kts | 3 +- .../feature/world/impl/DefaultWorldMessage.kt | 11 + .../feature/world/stub/StubWorldMessage.kt | 8 - .../root-app/build.gradle.kts | 24 ++- .../android/hybrid/root/HelloActivity.kt | 9 +- plugins/deps/src/main/java/dependencies.kt | 105 +++++++++ .../deps/core/ConditionalDependency.kt | 119 +++++++++-- .../deps/core/ConfigurationType.kt | 20 +- .../deps/core/applyDependencies.kt | 5 +- .../deps/core/ConditionalDependencyTest.kt | 188 +++++++++++++++- 25 files changed, 825 insertions(+), 133 deletions(-) create mode 100644 docs/HYBRID-CONFIGURATION.md create mode 100644 examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/impl/DefaultHelloMessage.kt delete mode 100644 examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/stub/StubHelloMessage.kt create mode 100644 examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/impl/DefaultWorldMessage.kt delete mode 100644 examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/stub/StubWorldMessage.kt diff --git a/TICKETS.md b/TICKETS.md index af32f1de..0d478a63 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -146,16 +146,17 @@ worker (ops); `v2`→`master` (explicit git promote only). **F-094** stays `bloc | F-111 | done | Sample navigation ports + root adapter | GH **#46** implement — `core/navigation/api` ports + `root-app` Jetpack adapter + `core/navigation/android-util` home-shell binder; feature `impl`/VM Nav-free; `navigationRes` kept; feature→`core/navigation/res` stripped. | | F-112 | done | Progressive example: navigation ports | GH **#46** teach — `examples/android/12-navigation-ports` per F-102 §4.4; ladder + README; minimal graph; ports vs adapter vs `navigationRes`. Prefer before or with F-111. | | F-103 | done | Hybrid targets example (flat dir / api+impl co-location) | GH **#44** — `examples/android/15-hybrid-targets`: co-located `feature/{hello,world}/{api,impl,stub-impl}` (stub role = `impl` type; `-impl` suffix required). Roots wire api+impl; manual stub swap documented (F-104 separate). No `androidLibrary`, no engine churn. | -| F-104 | todo | Hybrid configuration / stub targets for IDE sync | GH **#43** — stub targets + deps API so IDE sync can swap `impl`→`stub` (compileOnly/runtimeOnly pattern) via **one project-global flag**, not per-module hacks. Design+spike; depend on F-101/`target` APIs. Keep matrix truth. | +| F-104 | done | Hybrid configuration / stub targets for IDE sync | GH **#43** — **done:** `docs/HYBRID-CONFIGURATION.md`; `TargetSpec` flag gating + `resolveFeatureFlags` on targets; `featureImplementation` / target `depsIf`/`whenFlag`; flag `useFeatureStubs` + `-Pforma.useFeatureStubs`; example 15 migrated; `:deps` unit tests. Simple swap (not dual-config default). Matrix unchanged. | | F-113 | done | Android first-party library examples complete | Close ladder gaps: real `androidTestUtil` usage in 09; `androidNative` step **13** + matrix edges (`androidUtil`/`app`/`binary`→`native`); `test*`/`androidTest*` deps = `FormaDependency` so project targets work; docs/skills/matrix aligned. | | F-114 | done | Google 1P libraries coverage + Firebase usage | Audit Architecture Components/Room/Nav/Compose/Material/Dagger/Play/Gson vs real sample usage; add `docs/GOOGLE-LIBRARIES.md`; progressive **14-google-firebase** (Path A `firebaseBinary` + dummy GMS JSON + Crashlytics/Analytics API use); fix `PlatformDependency + NamedDependency` dropping BOM platforms. | ## P11 — Kotlin Multiplatform (Stepan 2026-07-25) Third Gradle platform on forma-core (`tools.forma.kmp`). **Design:** [`docs/KMP-TARGETS.md`](docs/KMP-TARGETS.md). -**P11 v1 complete (F-105…F-110).** **F-100…F-102 done** (F-102 = navigation design only). **F-111 + F-112 done** (sample + progressive nav ports). **F-103 done** (hybrid targets example 15). Next board priority is **P10** top `todo` (**F-104** IDE stub swap…) unless -Stepan reprioritizes. v1 = **jvm + android** shared libraries only; type-owned MPP; **no** per-module -target shopping; composition stays at Android/JVM roots. iOS/JS/Wasm = later phase. +**P11 v1 complete (F-105…F-110).** **F-100…F-104 done** (nav design/sample/example + hybrid layout/stub swap). +Open board: **F-094** Plugin Portal only (`blocked` human/admin). Workers with no `todo`/`in_progress` → **`[SILENT]`**. +v1 KMP = **jvm + android** shared libraries only; type-owned MPP; **no** per-module target shopping; +composition stays at Android/JVM roots. iOS/JS/Wasm = later phase. | ID | Status | Title | Notes | |----|--------|-------|-------| diff --git a/docs/CALL-SITE-SURFACE.md b/docs/CALL-SITE-SURFACE.md index 42a68f51..6872fc4b 100644 --- a/docs/CALL-SITE-SURFACE.md +++ b/docs/CALL-SITE-SURFACE.md @@ -201,6 +201,7 @@ They are **not** AGP `BuildFeatures` and **not** a second path to apply plugins. | 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` | +| Conditional **target** deps + stub pair | Call-site helpers | `TargetDependency.whenFlag` / target `depsIf` / **`featureImplementation`** (F-104) | | 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 | @@ -214,11 +215,21 @@ dependencies = deps( depsIf("daggerReflect", "com.jakewharton.dagger:dagger-reflect:…".dep), depsUnless("daggerReflect", "com.google.dagger:dagger-compiler:…".ksp), ) + +// composition root — impl ↔ stub-impl (F-104); flag useFeatureStubs +dependencies = deps( + deps(target(":feature:hello:api")), + featureImplementation( + impl = target(":feature:hello:impl"), + stub = target(":feature:hello:stub-impl"), + ), +) ``` 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). +alternatives: [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md), +stub swap: [`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md). ## Project deps via `target(...)` only (F-101 / GH #56) diff --git a/docs/DEPS-CATALOG.md b/docs/DEPS-CATALOG.md index 1866f0e3..b425f2d7 100644 --- a/docs/DEPS-CATALOG.md +++ b/docs/DEPS-CATALOG.md @@ -258,7 +258,10 @@ Live **who may depend on whom**: [`DEPENDENCY-MATRIX.md`](DEPENDENCY-MATRIX.md). | `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`) | | `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) | +| `depsIf` / `depsUnless` / `TargetDependency.whenFlag` (targets) | root (`dependencies.kt`) | **F-104** — same flag gating for first-party `target(...)` edges | +| `featureImplementation(impl, stub)` | root (`dependencies.kt`) | **F-104** — composition-root impl ↔ stub-impl pair; default flag `useFeatureStubs` | +| `USE_FEATURE_STUBS_FLAG` / `USE_FEATURE_STUBS_PROPERTY` | `tools.forma.deps.core` | Canonical flag name + Gradle property for IDE stub swap | +| `resolveFeatureFlags` | `tools.forma.deps.core` | Pure filter of flag-gated `NameSpec`s **and** `TargetSpec`s (unit-tested) | | `applyDependencies` | `tools.forma.deps.core` | Wire deps + plugin side effects (+ F-099 flag resolution) | | `target` / `Project.target` / `deps(FormaTarget…)` / `Project.deps(ProjectDependency…)` | root (`dependencies.kt`) | **F-101** — internal project deps; see [§3](#3-project--target-deps-internal-modules) | | `ProjectPathForms.gradleProjectPathFromFormaTarget` | `tools.forma.core.fleet` | Pure Forma path → Gradle path | @@ -283,10 +286,13 @@ Live **who may depend on whom**: [`DEPENDENCY-MATRIX.md`](DEPENDENCY-MATRIX.md). ([TARGET-PLUGINS.md](TARGET-PLUGINS.md)). Local convention plugins: includeBuild + catalog plugin GAV — not `project(":…")` ([BUILDSCRIPT-PROJECT-CLASSPATH.md](BUILDSCRIPT-PROJECT-CLASSPATH.md)). -7. **Conditional deps (F-099)** — declare flags once on +7. **Conditional deps (F-099 / F-104)** — 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). + call sites for named **and** target deps. For impl ↔ stub-impl at roots prefer + `featureImplementation(impl, stub)` (flag `useFeatureStubs`). Do **not** shop + plugins with flags or add per-module Booleans for every product toggle. See + [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md) and + [`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md). 8. **Internal modules use `target(...)`** — colon Forma paths (`target(":feature:home:api")`) or typesafe `target(projects…)` / `deps(projects…)`. Never teach raw `project(":…")` in `dependencies =`. diff --git a/docs/HYBRID-CONFIGURATION.md b/docs/HYBRID-CONFIGURATION.md new file mode 100644 index 00000000..8ef8c85c --- /dev/null +++ b/docs/HYBRID-CONFIGURATION.md @@ -0,0 +1,200 @@ +# Hybrid configuration — impl ↔ stub-impl swap (F-104 / GH #43) + +**Status:** shipped (design + working spike) +**Depends on:** F-099 `FormaFeatureFlags`, F-101 `target(":…")`, F-103 `stub-impl/` layout +**Layout teaching:** [`examples/android/15-hybrid-targets`](../examples/android/15-hybrid-targets) + +## Problem + +Composition roots must depend on **api + one of impl / stub-impl** (never both). +Before F-104, example 15 documented a **manual comment swap** of `target(":…:impl")` +↔ `target(":…:stub-impl")`. That does not scale: every root repeats the dual list, +and IDE/local perf toggles become per-module Gradle `if` hacks. + +GH [#43](https://github.com/formatools/forma/issues/43) asks for stub targets and a +deps API so **IDE sync** can substitute stubs via **one project-global flag**. + +## Product axioms (non-negotiable) + +1. **Configure once** — flag on `androidProjectConfiguration`; call sites stay minimal. +2. **One global way** — single flag + pair helper; no dual happy path of free-form + `if (project.hasProperty)` as the product API. +3. **Explicit structure** — physical `stub-impl/` layout from F-103; no hidden magic folders. +4. **Closed matrix** — stubs remain type `impl`; no `androidLibrary`, no `impl`→`impl`, + composition only at roots. **No matrix change** for this ticket. +5. **Reuse F-099** — same `FormaFeatureFlags` / `resolveFeatureFlags` path; do not invent + a second flag system. + +## Chosen model + +### A. Canonical flag + property + +| | | +|--|--| +| **Flag name** | `useFeatureStubs` ([`USE_FEATURE_STUBS_FLAG`](../plugins/deps/src/main/java/tools.forma/deps/core/ConditionalDependency.kt)) | +| **Gradle property** | `forma.useFeatureStubs` ([`USE_FEATURE_STUBS_PROPERTY`](../plugins/deps/src/main/java/tools.forma/deps/core/ConditionalDependency.kt)) | +| **Default** | **false** / unset → production **impl** | +| **IDE / local** | `-Pforma.useFeatureStubs=true` or `forma.useFeatureStubs=true` in `gradle.properties` | + +```kotlin +// root build.gradle.kts +androidProjectConfiguration( + project = rootProject, + // ... + featureFlags = tools.forma.config.FormaFeatureFlags( + tools.forma.deps.core.USE_FEATURE_STUBS_FLAG to + providers.gradleProperty(tools.forma.deps.core.USE_FEATURE_STUBS_PROPERTY) + .map { it.toBoolean() } + .orElse(false) + .get(), + ), +) +``` + +Declare the flag **once** at the root. Call sites never read the property themselves. + +### B. Target-level flag metadata + +`TargetSpec` carries the same optional gate as `NameSpec`: + +- `featureFlag: String?` +- `featureFlagExpected: Boolean` (default `true`) +- `config: ConfigurationType` (default `Implementation`; `CompileOnly` / `RuntimeOnly` preserved) + +Pure `resolveFeatureFlags(flags)` filters: + +| Kind | Filtered? | +|------|-----------| +| `NameSpec` | yes (F-099) | +| `TargetSpec` | yes (F-104) | +| files / platforms | no (unchanged) | + +Applied in `applyDependencies` (and KMP apply path) at **apply** time — not when the +DSL helper is constructed. + +### C. Public DSL + +| Helper | Role | +|--------|------| +| `TargetDependency.whenFlag(flag, enabled)` | Tag all target specs | +| `depsIf(flag, …TargetDependency\|FormaTarget)` | Include targets when flag matches | +| `depsUnless(flag, …)` | Include targets when flag is off / unknown | +| **`featureImplementation(impl, stub, flag = useFeatureStubs)`** | **Product API** — one pair, no dual manual lists | + +```kotlin +dependencies = deps( + deps( + target(":root-res"), + target(":feature:hello:api"), + target(":feature:world:api"), + ), + featureImplementation( + impl = target(":feature:hello:impl"), + stub = target(":feature:hello:stub-impl"), + ), + featureImplementation( + impl = target(":feature:world:impl"), + stub = target(":feature:world:stub-impl"), + ), +) +``` + +### D. Resolution semantics (simple swap — shipped) + +| `useFeatureStubs` | Resolved edges | +|-------------------|----------------| +| `false` / unset | `implementation(impl)` only | +| `true` | `implementation(stub)` only | + +Gated-out targets are **not** on the resolved dependency graph. They must still be +**included** projects in `settings` (includer discovers `stub-impl/` siblings). With +configuration-on-demand, unused siblings may not configure; example 15 sets +`org.gradle.configureondemand=false` so stubs still validate when unused. + +### E. Optional dual-config pattern (not default) + +GH #43 mentioned `compileOnly` + `runtimeOnly` / `implementation`. That pattern is +**supported by the model** (`TargetSpec.config` + flag gates) but **not** the default +product recipe: + +- Simple swap avoids duplicate class bindings and is enough for IDE/local classpath shrink. +- Dual-config (e.g. `compileOnly(impl)` + `implementation(stub)`) can be composed manually + with `whenFlag` + non-default configs if a fleet needs it — unit-tested that configs + are preserved. Do not teach two equal happy paths. + +### F. Composition-root source / FQNs + +A classpath swap only changes runtime behavior when the root does not hardcode types +that exist on **only one** side. Teaching options: + +1. **Same FQN replacement (example 15)** — stub-impl provides the same package + class + names as production impl with lighter bodies (`DefaultHelloMessage` → `"HelloStub"`). + Root always imports production FQNs; flag selects which module supplies the bytecode. +2. **DI / SPI** — bind `api` types from the selected impl module (Dagger modules, ServiceLoader). + +Example 15 uses (1). Distinct `…stub` packages with different class names require a +source or binding change when swapping — fine for demos, worse for a one-flag toggle. + +## Physical layout (F-103, unchanged) + +``` +feature//{api,impl,stub-impl}/ +``` + +| Role | Directory | Type | `target()` path | +|------|-----------|------|-----------------| +| api | `api/` | `api` | `:feature:name:api` | +| production | `impl/` | `impl` | `:feature:name:impl` | +| stub | `stub-impl/` | `impl` | `:feature:name:stub-impl` | + +Bare `stub/` fails the `impl` suffix rule — keep `stub-impl/`. + +## IDE recipe + +1. Root declares `useFeatureStubs` from `-Pforma.useFeatureStubs` (default false). +2. Roots use `featureImplementation(impl, stub)` only — no comment blocks. +3. Local / IDE sync: add to `gradle.properties` or Run configuration: + ``` + forma.useFeatureStubs=true + ``` +4. CI / release builds: leave unset or `false`. +5. Verify: + ```bash + ./gradlew :binary:assembleDebug # Hello World + ./gradlew :binary:assembleDebug -Pforma.useFeatureStubs=true # HelloStub WorldStub + ``` + +## Rejected alternatives + +| Idea | Why rejected | +|------|----------------| +| Per-module `if (project.hasProperty("…"))` as happy path | Dual path; not fleet-scalable; violates one global way | +| New forever target type / bare `-stub` suffix | F-103 chose `stub-impl` + type `impl`; matrix churn without need | +| Restoring `androidLibrary` | Flat role-typed graph axiom | +| Second flag system beside `FormaFeatureFlags` | F-099 is the single map | +| Equal dual APIs (comment swap **and** flag API) | Migrate example 15 fully to the flag API | +| Changing gold `application/` layout in F-104 | Optional later; spike stays in example 15 | +| Default dual-config compileOnly+implementation | Risk of duplicate bindings; simple swap first | +| Free-form `.withPlugin` / plugin id lists gated by stubs | Type-owned plugins only | + +## API checklist + +- [x] Canonical flag `useFeatureStubs` + property `forma.useFeatureStubs` +- [x] `TargetSpec` feature-flag metadata + `ConfigurationType` preserved +- [x] `resolveFeatureFlags` filters targets (`TargetDependency` + `MixedDependency`) +- [x] `whenFlag` / `depsIf` / `depsUnless` for targets +- [x] `featureImplementation(impl, stub)` +- [x] Pure unit tests in `:deps` +- [x] Example 15 on the flag API +- [x] This design doc + cross-links + +## Cross references + +- [`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md) — `FormaFeatureFlags` (F-099) +- [`PROJECT-CONFIGURATION.md`](PROJECT-CONFIGURATION.md) — `featureFlags` on root config +- [`DEPS-CATALOG.md`](DEPS-CATALOG.md) — deps DSL table +- [`CALL-SITE-SURFACE.md`](CALL-SITE-SURFACE.md) — call-site vs project ownership +- [`DEPENDENCY-MATRIX.md`](DEPENDENCY-MATRIX.md) — unchanged edges +- [`PROGRESSIVE-EXAMPLES.md`](PROGRESSIVE-EXAMPLES.md) — ladder step 15 +- Example: [`examples/android/15-hybrid-targets`](../examples/android/15-hybrid-targets) +- Skill: [`examples/agent-skills/forma-project-layout.md`](../examples/agent-skills/forma-project-layout.md) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 6db82973..fb305be6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,28 @@ Newest entries first. +## 2026-07-27 — F-104: Hybrid stub swap via project-global feature flag + +- **Ticket:** F-104 → `done` (GH #43) +- **Branch:** `forma/F-104-hybrid-stub-config` (from `origin/v2`) +- **Actions:** + - Design: `docs/HYBRID-CONFIGURATION.md` (flag, API, IDE property, simple-swap semantics, rejected paths) + - `TargetSpec` optional `featureFlag` / `featureFlagExpected`; `resolveFeatureFlags` filters targets in `TargetDependency` + `MixedDependency` + - DSL: `TargetDependency.whenFlag`, target overloads of `depsIf`/`depsUnless`, `featureImplementation(impl, stub)` (default flag `useFeatureStubs`) + - Constants: `USE_FEATURE_STUBS_FLAG` / `USE_FEATURE_STUBS_PROPERTY` (`forma.useFeatureStubs`) + - Example 15: root `featureFlags` property-backed; roots use pair helper; stubs same FQN as impl for classpath replacement; README documents both assemble modes + - Cross-links: TARGET-FEATURE-OPTIONS, DEPS-CATALOG, CALL-SITE-SURFACE, PROJECT-CONFIGURATION, PROGRESSIVE-EXAMPLES, agent skill `forma-project-layout` +- **Skills/modes:** Grok Build `--mode full` (design/plan + implement); Hermes finish path after CLI timeout (verify + commit/PR) +- **Verify (real host):** + - `plugins/` `./gradlew :deps:test test jacocoHappyPathCoverageVerification` → **BUILD SUCCESSFUL** (75 tasks) + - `examples/android/15-hybrid-targets` `./gradlew :binary:assembleDebug` → **BUILD SUCCESSFUL**; APK strings include `Hello` + `World` (not Stub) + - same `./gradlew :binary:assembleDebug -Pforma.useFeatureStubs=true` → **BUILD SUCCESSFUL**; APK strings include `HelloStub` + `WorldStub` + - `:feature-hello-stub-impl:compileDebugKotlin` + world stub → green +- **Not in this slice:** dual-config compileOnly+impl as default; gold `application/` layout change; new target type/suffix; matrix edits +- **Commits/PRs:** this branch → PR base `v2` +- **Blockers:** none (only F-094 Portal remains blocked human/admin) +- **Next step:** empty coding queue except F-094 blocked — idle workers `[SILENT]` unless new tickets + ## 2026-07-27 — F-103: Hybrid targets progressive example - **Ticket:** F-103 → `done` diff --git a/docs/PROGRESSIVE-EXAMPLES.md b/docs/PROGRESSIVE-EXAMPLES.md index e13310f8..75634f20 100644 --- a/docs/PROGRESSIVE-EXAMPLES.md +++ b/docs/PROGRESSIVE-EXAMPLES.md @@ -36,7 +36,7 @@ narrative tutorials when you want a **minimal buildable project per concept**. | Navigation ports (presentation ports + adapter; Jetpack behind root) | **12** | — | — | forma-android-targets + [NAVIGATION-ABSTRACTION.md](NAVIGATION-ABSTRACTION.md) | | `androidNative` + JNI via `androidUtil` | **13** | — | — | forma-android-targets | | Google Firebase (Crashlytics + Analytics, Path A `firebaseBinary`) | **14** | — | — | forma-target-plugins + [GOOGLE-LIBRARIES.md](GOOGLE-LIBRARIES.md) | -| Hybrid flat layout (`feature//{api,impl,stub-impl}`) | **15** | — | — | forma-project-layout | +| Hybrid flat layout + `featureImplementation` stub swap (`useFeatureStubs`) | **15** | — | — | forma-project-layout + [HYBRID-CONFIGURATION.md](HYBRID-CONFIGURATION.md) | | `kmpLibrary` + `kmpProjectConfiguration` | — | — | 01 | forma-kmp-targets | | JVM/Android → `kmp.*` consumer edge | — | — | 01 (JVM binary) | forma-kmp-targets | | Fleet check/generate/migrate (`tools.forma.core.fleet`) | docs | docs | — | forma-fleet-tooling | diff --git a/docs/PROJECT-CONFIGURATION.md b/docs/PROJECT-CONFIGURATION.md index ed31005e..dcbdfa3b 100644 --- a/docs/PROJECT-CONFIGURATION.md +++ b/docs/PROJECT-CONFIGURATION.md @@ -189,12 +189,27 @@ dependencies = deps( ) ``` +**Hybrid stub swap (F-104)** — declare `useFeatureStubs` (often from +`-Pforma.useFeatureStubs`) and use `featureImplementation(impl, stub)` at +composition roots. See [`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md). + +```kotlin +featureFlags = FormaFeatureFlags( + tools.forma.deps.core.USE_FEATURE_STUBS_FLAG to + providers.gradleProperty(tools.forma.deps.core.USE_FEATURE_STUBS_PROPERTY) + .map { it.toBoolean() } + .orElse(false) + .get(), +) +``` + 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). +[`TARGET-FEATURE-OPTIONS.md`](TARGET-FEATURE-OPTIONS.md). Stub swap: +[`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md). ## What it does **not** do diff --git a/docs/TARGET-FEATURE-OPTIONS.md b/docs/TARGET-FEATURE-OPTIONS.md index d70e7a83..08c2b4ed 100644 --- a/docs/TARGET-FEATURE-OPTIONS.md +++ b/docs/TARGET-FEATURE-OPTIONS.md @@ -1,8 +1,9 @@ # 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**. +conditional dependency helpers (named deps F-099; **target** deps + stub swap +**F-104** — see [`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md)). +Binary-linked configuration remains **deferred**. ## Problem @@ -160,10 +161,15 @@ binary↔configuration product. The recipe above is the supported pattern. | 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 | +## Shipped follow-on: hybrid stub swap (F-104) + +Target-level flag gating + `featureImplementation(impl, stub)` for IDE/local +`impl` → `stub-impl` substitution. Canonical flag `useFeatureStubs` / +property `forma.useFeatureStubs`. **Design + API:** [`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md). + ## API checklist - [x] `FormaFeatureFlags` on `AndroidProjectSettings` @@ -172,11 +178,13 @@ binary↔configuration product. The recipe above is the supported pattern. - [x] Apply-time `resolveFeatureFlags` in `applyDependencies` - [x] Pure unit tests (`:config`, `:deps`) - [x] Docs: this file + PROJECT-CONFIGURATION + CALL-SITE-SURFACE + DEPS-CATALOG +- [x] F-104: target flag gating + `featureImplementation` — [`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md) ## 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 +- [`HYBRID-CONFIGURATION.md`](HYBRID-CONFIGURATION.md) — F-104 stub swap - [`TARGET-PLUGINS.md`](TARGET-PLUGINS.md) — type-owned plugins - [`VISION.md`](VISION.md) — root principles diff --git a/examples/agent-skills/forma-project-layout.md b/examples/agent-skills/forma-project-layout.md index b9d02d91..ea2ee6ed 100644 --- a/examples/agent-skills/forma-project-layout.md +++ b/examples/agent-skills/forma-project-layout.md @@ -28,18 +28,23 @@ Teaching ladders: `examples/**`. ## Hybrid co-located roles (api + impl + stub) -Optional fleet layout for easier multi-module navigation (GH #44 / F-103): +Optional fleet layout for easier multi-module navigation (GH #44 / F-103) plus +project-global stub swap (GH #43 / F-104): ``` feature//{api,impl,stub-impl}/ ``` - Same Forma types as the ladder (`api`, `impl`). A **stub** is a real `impl` module - in directory `stub-impl/` (name must end with `-impl` for the type suffix rule) - with `packageName` `…stub` and a lightweight stand-in of the same `api`. + in directory `stub-impl/` (name must end with `-impl` for the type suffix rule). - Gradle projects: `:feature--api`, `:feature--impl`, `:feature--stub-impl`. Forma paths: `target(":feature::api|impl|stub-impl")`. -- Composition roots depend on **api + one of impl/stub-impl** (default: **impl**). - Do not depend on both. Manual swap is documented; automated IDE swap is **F-104**. -- No `androidLibrary`. No `impl` → `impl`. No engine dual path — layout convention only. +- Composition roots: `featureImplementation(impl = target("…:impl"), stub = target("…:stub-impl"))`. + Flag **`useFeatureStubs`** (property `forma.useFeatureStubs`, default false) selects + impl vs stub-impl — never both. Prefer same FQNs on stub and impl so roots need no + source swap (simple classpath replacement). +- Declare the flag once on `androidProjectConfiguration(featureFlags = …)`. + Do **not** use per-module `if (project.hasProperty)` as the happy path. +- No `androidLibrary`. No `impl` → `impl`. Matrix unchanged. - Teaching example: [`examples/android/15-hybrid-targets`](../android/15-hybrid-targets). +- Design: [`docs/HYBRID-CONFIGURATION.md`](../../docs/HYBRID-CONFIGURATION.md). diff --git a/examples/android/15-hybrid-targets/README.md b/examples/android/15-hybrid-targets/README.md index 7e5b4014..bd7b9dc4 100644 --- a/examples/android/15-hybrid-targets/README.md +++ b/examples/android/15-hybrid-targets/README.md @@ -1,16 +1,19 @@ -# 15 — hybrid targets (flat api / impl / stub co-location) +# 15 — hybrid targets + stub swap (flat api / impl / stub-impl) **Goal:** Teach a **hybrid / flat co-located feature layout** where `api`, `impl`, and -a **stub** sibling live under one feature folder. Easy multi-module navigation -(GH [#44](https://github.com/formatools/forma/issues/44)) without restoring -`androidLibrary`, without engine changes, and without F-104 IDE swap wiring. +a **stub** sibling live under one feature folder, plus the **F-104** project-global +flag that swaps `impl` → `stub-impl` at composition roots (GH +[#44](https://github.com/formatools/forma/issues/44), +[#43](https://github.com/formatools/forma/issues/43)). + +Design: [`docs/HYBRID-CONFIGURATION.md`](../../../docs/HYBRID-CONFIGURATION.md). ## Features introduced - Co-located `feature//{api,impl,stub-impl}/` (same mental model as 02/07, flatter nav) -- Real **stub** modules that implement the same `api` with lightweight stand-ins +- Real **stub** modules that replace production impl FQNs with lightweight stand-ins - Multi-feature composition (hello + world) at the root — same rules as 07 -- Manual `impl` → `stub-impl` dependency swap as a **preview of F-104** (not automated) +- **`featureImplementation(impl, stub)`** + `useFeatureStubs` flag (no comment hacks) ## Layout @@ -23,7 +26,7 @@ a **stub** sibling live under one feature folder. Easy multi-module navigation hello/ api/ # api(...) contracts impl/ # impl(...) production - stub-impl/ # impl(...) lightweight stand-in (same api) + stub-impl/ # impl(...) lightweight stand-in (same api + same FQNs) world/ api/ impl/ @@ -42,21 +45,13 @@ the directory path, so the role folder is `stub-impl/`: | impl (production) | `feature/hello/impl` | `:feature-hello-impl` | `:feature:hello:impl` | | stub (stand-in) | `feature/hello/stub-impl` | `:feature-hello-stub-impl` | `:feature:hello:stub-impl` | -The **role** is still “stub”; the **type** remains `impl` with `packageName` `…stub`. -No new target type and no engine change — layout convention only. F-104 may later -add project-global swap tooling; it does not need a bare `-stub` suffix for this -teaching example. - -Includer discovers each role directory (`arbitraryBuildScriptNames = true`). +The **role** is still “stub”; the **type** remains `impl`. No new target type. -### Why this eases IDE navigation +### Same FQNs (simple swap) -Deep ladders scatter related roles across distant trees. Co-location keeps **one -folder per feature** with role-named children — open `feature/hello/` and see -api + impl + stub together. Optional browsing symlinks (e.g. top-level -`hello-api` → `feature/hello/api`) are fine if they help humans, but **includer -must still discover real `projectDir`s** and `target(":feature:…")` remains the -supported path. This example does **not** require symlinks. +Stubs ship the **same package + class names** as production (`DefaultHelloMessage`, +etc.) with different `text()` bodies. Composition roots always import production +FQNs; the flag selects which module is on the classpath. ### api / impl / stub mental model @@ -64,7 +59,7 @@ supported path. This example does **not** require symlinks. |------|-------------|------------|---------| | `api` | `api` | (none / shared libs) | Public contracts | | `impl` | `impl` | `api` of same feature | Production implementation | -| `stub` | `impl` (dir `stub-impl`, package `…stub`) | `api` of same feature | Lightweight stand-in for IDE/sync/demo | +| `stub` | `impl` (dir `stub-impl`) | `api` of same feature | Lightweight stand-in for IDE/sync/demo | Rules that still hold: @@ -73,54 +68,73 @@ Rules that still hold: - Composition **only at roots** (`androidBinary` / `androidApp`) - Roots depend on **api + one of impl/stub-impl** — never both (duplicate bindings) -Default wiring uses **impl**. Stubs compile as real modules so you can prove the -layout; they are not on the production classpath unless you swap deps. -This example sets `org.gradle.configureondemand=false` so stub-impl siblings are -always configured and validated even when unused by the default root graph. +## Automated stub swap (F-104) + +Root `androidProjectConfiguration` declares one flag from a Gradle property: + +```kotlin +featureFlags = FormaFeatureFlags( + USE_FEATURE_STUBS_FLAG to + providers.gradleProperty(USE_FEATURE_STUBS_PROPERTY) + .map { it.toBoolean() } + .orElse(false) + .get(), +) +``` -## Manual stub swap (teaser → F-104) +Roots use the pair helper (no comment blocks): -1. In `root-app/build.gradle.kts` and `binary/build.gradle.kts`, replace - `target(":feature:hello:impl")` with `target(":feature:hello:stub-impl")` - (same for `world`). -2. In `HelloActivity`, import `StubHelloMessage` / `StubWorldMessage` instead of - the `Default*` classes. -3. `./gradlew :binary:assembleDebug` — UI text becomes `HelloStub WorldStub`. +```kotlin +featureImplementation( + impl = target(":feature:hello:impl"), + stub = target(":feature:hello:stub-impl"), +) +``` + +| Command | Flag | UI text | +|---------|------|---------| +| `./gradlew :binary:assembleDebug` | off (default) | `Hello World` | +| `./gradlew :binary:assembleDebug -Pforma.useFeatureStubs=true` | on | `HelloStub WorldStub` | -**F-104** will automate impl→stub via a **project-global flag** (IDE sync / -compileOnly+runtimeOnly style). This step only teaches the **physical layout** -and keeps stubs wireable by hand. +This example sets `org.gradle.configureondemand=false` so stub-impl siblings are +always configured and validated even when unused by the default root graph. +Stubs remain **included** projects either way; gated-out targets are simply not +on the resolved dependency graph. ## Build ```bash source ../../../scripts/env-mac.sh printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties + +# Production impl (default) ./gradlew :binary:assembleDebug -# Optional: prove stubs compile without swapping roots + +# Stub mode (IDE / local perf demo) +./gradlew :binary:assembleDebug -Pforma.useFeatureStubs=true + +# Optional: prove stubs compile on their own ./gradlew :feature-hello-stub-impl:compileDebugKotlin :feature-world-stub-impl:compileDebugKotlin ``` -Expect **BUILD SUCCESSFUL**. Default APK shows combined hello + world messages -from **impl** (`Hello World`). +Expect **BUILD SUCCESSFUL** for both assemble variants. ## Principles 1. Bazel-like types — call sites are attributes only (`api` / `impl` / roots) -2. One global way — co-located `{api,impl,stub-impl}` is a **layout convention**, not a - second target DSL +2. One global way — single `useFeatureStubs` flag + `featureImplementation`; no per-module `if` 3. Explicit structure — role folders declare boundaries; matrix unchanged ## Non-goals -- F-104 IDE configuration substitution APIs -- Forma engine / dependency-matrix changes (including a dedicated `stub` type/suffix) -- Restoring `androidLibrary` +- Restoring `androidLibrary` or a dedicated `stub` target type/suffix - Changing gold `application/` sample layout +- Dual-config `compileOnly(impl)+implementation(stub)` as the default recipe + (model supports it; simple swap is the shipped happy path — see design doc) ## Related - Step 02 — minimal api+impl - Step 07 — multi-feature composition -- F-104 — automated stub swap for IDE sync +- [`docs/HYBRID-CONFIGURATION.md`](../../../docs/HYBRID-CONFIGURATION.md) — F-104 design - Agent skill: `examples/agent-skills/forma-project-layout.md` diff --git a/examples/android/15-hybrid-targets/binary/build.gradle.kts b/examples/android/15-hybrid-targets/binary/build.gradle.kts index b79f0488..adc732c3 100644 --- a/examples/android/15-hybrid-targets/binary/build.gradle.kts +++ b/examples/android/15-hybrid-targets/binary/build.gradle.kts @@ -3,16 +3,18 @@ androidBinary( versionCode = 1, versionName = "0.1.0", dependencies = deps( - target(":root-app"), - // Production path: api + impl (not stub). Do not wire both impl and stub-impl. - target(":feature:hello:api"), - target(":feature:hello:impl"), - target(":feature:world:api"), - target(":feature:world:impl"), - // Manual stub swap preview (F-104 will automate via project-global flag): - // target(":feature:hello:api"), - // target(":feature:hello:stub-impl"), - // target(":feature:world:api"), - // target(":feature:world:stub-impl"), + deps( + target(":root-app"), + target(":feature:hello:api"), + target(":feature:world:api"), + ), + featureImplementation( + impl = target(":feature:hello:impl"), + stub = target(":feature:hello:stub-impl"), + ), + featureImplementation( + impl = target(":feature:world:impl"), + stub = target(":feature:world:stub-impl"), + ), ) ) diff --git a/examples/android/15-hybrid-targets/build.gradle.kts b/examples/android/15-hybrid-targets/build.gradle.kts index 18f086e0..4b786cdf 100644 --- a/examples/android/15-hybrid-targets/build.gradle.kts +++ b/examples/android/15-hybrid-targets/build.gradle.kts @@ -5,5 +5,13 @@ buildscript { targetSdk = 37, compileSdk = 37, agpVersion = "9.3.0", + // F-104: one project-global flag — IDE/local stub swap (default production impl). + featureFlags = tools.forma.config.FormaFeatureFlags( + tools.forma.deps.core.USE_FEATURE_STUBS_FLAG to + providers.gradleProperty(tools.forma.deps.core.USE_FEATURE_STUBS_PROPERTY) + .map { it.toBoolean() } + .orElse(false) + .get(), + ), ) } diff --git a/examples/android/15-hybrid-targets/feature/hello/stub-impl/build.gradle.kts b/examples/android/15-hybrid-targets/feature/hello/stub-impl/build.gradle.kts index e56bcfa6..ab199cb4 100644 --- a/examples/android/15-hybrid-targets/feature/hello/stub-impl/build.gradle.kts +++ b/examples/android/15-hybrid-targets/feature/hello/stub-impl/build.gradle.kts @@ -1,9 +1,8 @@ // Real module implementing the same api with a lightweight stand-in. // Target type is impl — Gradle/Forma name must end with -impl (hence stub-impl/). -// packageName uses …stub so sources stay distinct from production impl. -// Not wired by default — see root deps comments. F-104 will swap impl→stub via flag. +// Same FQNs as production impl so composition roots need no source swap (F-104). impl( - packageName = "tools.forma.examples.android.hybrid.feature.hello.stub", + packageName = "tools.forma.examples.android.hybrid.feature.hello.impl", dependencies = deps( target(":feature:hello:api") ) diff --git a/examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/impl/DefaultHelloMessage.kt b/examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/impl/DefaultHelloMessage.kt new file mode 100644 index 00000000..7d05cd95 --- /dev/null +++ b/examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/impl/DefaultHelloMessage.kt @@ -0,0 +1,11 @@ +package tools.forma.examples.android.hybrid.feature.hello.impl + +import tools.forma.examples.android.hybrid.feature.hello.api.HelloMessage + +/** + * Classpath replacement for production [DefaultHelloMessage] when + * `useFeatureStubs` is on (same FQN, lighter body — F-104 simple swap). + */ +class DefaultHelloMessage : HelloMessage { + override fun text(): String = "HelloStub" +} diff --git a/examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/stub/StubHelloMessage.kt b/examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/stub/StubHelloMessage.kt deleted file mode 100644 index 9c39c59c..00000000 --- a/examples/android/15-hybrid-targets/feature/hello/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/hello/stub/StubHelloMessage.kt +++ /dev/null @@ -1,8 +0,0 @@ -package tools.forma.examples.android.hybrid.feature.hello.stub - -import tools.forma.examples.android.hybrid.feature.hello.api.HelloMessage - -/** Lightweight stand-in for [tools.forma.examples.android.hybrid.feature.hello.impl.DefaultHelloMessage]. */ -class StubHelloMessage : HelloMessage { - override fun text(): String = "HelloStub" -} diff --git a/examples/android/15-hybrid-targets/feature/world/stub-impl/build.gradle.kts b/examples/android/15-hybrid-targets/feature/world/stub-impl/build.gradle.kts index a1b06cd8..32cb428f 100644 --- a/examples/android/15-hybrid-targets/feature/world/stub-impl/build.gradle.kts +++ b/examples/android/15-hybrid-targets/feature/world/stub-impl/build.gradle.kts @@ -1,7 +1,8 @@ // Sibling stub for the world feature — same matrix rules as impl (depends on api only). // Directory name stub-impl satisfies the impl type suffix rule. +// Same FQNs as production impl so composition roots need no source swap (F-104). impl( - packageName = "tools.forma.examples.android.hybrid.feature.world.stub", + packageName = "tools.forma.examples.android.hybrid.feature.world.impl", dependencies = deps( target(":feature:world:api") ) diff --git a/examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/impl/DefaultWorldMessage.kt b/examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/impl/DefaultWorldMessage.kt new file mode 100644 index 00000000..520a0a1b --- /dev/null +++ b/examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/impl/DefaultWorldMessage.kt @@ -0,0 +1,11 @@ +package tools.forma.examples.android.hybrid.feature.world.impl + +import tools.forma.examples.android.hybrid.feature.world.api.WorldMessage + +/** + * Classpath replacement for production [DefaultWorldMessage] when + * `useFeatureStubs` is on (same FQN, lighter body — F-104 simple swap). + */ +class DefaultWorldMessage : WorldMessage { + override fun text(): String = "WorldStub" +} diff --git a/examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/stub/StubWorldMessage.kt b/examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/stub/StubWorldMessage.kt deleted file mode 100644 index d29c19d6..00000000 --- a/examples/android/15-hybrid-targets/feature/world/stub-impl/src/main/kotlin/tools/forma/examples/android/hybrid/feature/world/stub/StubWorldMessage.kt +++ /dev/null @@ -1,8 +0,0 @@ -package tools.forma.examples.android.hybrid.feature.world.stub - -import tools.forma.examples.android.hybrid.feature.world.api.WorldMessage - -/** Lightweight stand-in for [tools.forma.examples.android.hybrid.feature.world.impl.DefaultWorldMessage]. */ -class StubWorldMessage : WorldMessage { - override fun text(): String = "WorldStub" -} diff --git a/examples/android/15-hybrid-targets/root-app/build.gradle.kts b/examples/android/15-hybrid-targets/root-app/build.gradle.kts index 0643713e..fe1a8017 100644 --- a/examples/android/15-hybrid-targets/root-app/build.gradle.kts +++ b/examples/android/15-hybrid-targets/root-app/build.gradle.kts @@ -1,16 +1,18 @@ androidApp( packageName = "tools.forma.examples.android.hybrid.root", dependencies = deps( - target(":root-res"), - // Production path: api + impl. Swap impl → stub-impl manually to exercise stubs. - target(":feature:hello:api"), - target(":feature:hello:impl"), - target(":feature:world:api"), - target(":feature:world:impl"), - // Stub alternative (comment impl lines above; uncomment these): - // target(":feature:hello:api"), - // target(":feature:hello:stub-impl"), - // target(":feature:world:api"), - // target(":feature:world:stub-impl"), + deps( + target(":root-res"), + target(":feature:hello:api"), + target(":feature:world:api"), + ), + featureImplementation( + impl = target(":feature:hello:impl"), + stub = target(":feature:hello:stub-impl"), + ), + featureImplementation( + impl = target(":feature:world:impl"), + stub = target(":feature:world:stub-impl"), + ), ) ) diff --git a/examples/android/15-hybrid-targets/root-app/src/main/kotlin/tools/forma/examples/android/hybrid/root/HelloActivity.kt b/examples/android/15-hybrid-targets/root-app/src/main/kotlin/tools/forma/examples/android/hybrid/root/HelloActivity.kt index 1ab0bf72..20d8ebdc 100644 --- a/examples/android/15-hybrid-targets/root-app/src/main/kotlin/tools/forma/examples/android/hybrid/root/HelloActivity.kt +++ b/examples/android/15-hybrid-targets/root-app/src/main/kotlin/tools/forma/examples/android/hybrid/root/HelloActivity.kt @@ -9,10 +9,11 @@ import tools.forma.examples.android.hybrid.feature.world.api.WorldMessage import tools.forma.examples.android.hybrid.feature.world.impl.DefaultWorldMessage /** - * Composition root wires hello + world from **impl** defaults. - * To try stubs: swap root-app/binary deps to `:…:stub-impl` and import - * [tools.forma.examples.android.hybrid.feature.hello.stub.StubHelloMessage] / - * [tools.forma.examples.android.hybrid.feature.world.stub.StubWorldMessage]. + * Composition root always imports production FQNs. [featureImplementation] + + * `useFeatureStubs` swaps which module supplies those classes (impl vs stub-impl). + * + * - default → "Hello World" + * - `-Pforma.useFeatureStubs=true` → "HelloStub WorldStub" */ class HelloActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/plugins/deps/src/main/java/dependencies.kt b/plugins/deps/src/main/java/dependencies.kt index 3966225e..fdec8620 100644 --- a/plugins/deps/src/main/java/dependencies.kt +++ b/plugins/deps/src/main/java/dependencies.kt @@ -22,6 +22,8 @@ import tools.forma.deps.core.PlatformDependency import tools.forma.deps.core.PlatformSpec import tools.forma.deps.core.TargetDependency import tools.forma.deps.core.TargetSpec +import tools.forma.deps.core.USE_FEATURE_STUBS_FLAG +import tools.forma.deps.core.featureImplementationPair import tools.forma.target.FormaTarget /** @@ -231,6 +233,23 @@ fun NamedDependency.whenFlag(flag: String, enabled: Boolean = true): NamedDepend } ) +/** + * Gate every target spec in this [TargetDependency] on project-global feature flag [flag] + * (F-104 / GH #43). Same apply-time resolution as [NamedDependency.whenFlag]. + * Preserves each spec's [TargetSpec.config]. + */ +fun TargetDependency.whenFlag(flag: String, enabled: Boolean = true): TargetDependency = + TargetDependency( + targets.map { spec -> + TargetSpec( + target = spec.target, + config = spec.config, + featureFlag = flag, + featureFlagExpected = enabled, + ) + } + ) + /** * Include [dependencies] only when project-global flag [flag] equals [enabled] * (default: flag on). Compose with [deps]: @@ -253,6 +272,26 @@ fun depsIf( enabled: Boolean = true, ): NamedDependency = deps(*dependencies).whenFlag(flag, enabled) +/** + * Include project [dependencies] only when project-global flag [flag] equals [enabled] + * (F-104 target parity with named [depsIf]). + */ +fun depsIf( + flag: String, + vararg dependencies: TargetDependency, + enabled: Boolean = true, +): TargetDependency = deps(*dependencies).whenFlag(flag, enabled) + +/** + * Include [targets] only when project-global flag [flag] equals [enabled] + * (F-104 — `depsIf("useFeatureStubs", target(":f:stub-impl"))`). + */ +fun depsIf( + flag: String, + vararg targets: FormaTarget, + enabled: Boolean = true, +): TargetDependency = deps(*targets).whenFlag(flag, enabled) + /** * Include [dependencies] only when project-global flag [flag] is **off** * (unknown flags count as off — see [tools.forma.config.FormaFeatureFlags]). @@ -260,6 +299,72 @@ fun depsIf( fun depsUnless(flag: String, vararg dependencies: NamedDependency): NamedDependency = deps(*dependencies).whenFlag(flag, enabled = false) +/** + * Include project [dependencies] only when project-global flag [flag] is **off** (F-104). + */ +fun depsUnless(flag: String, vararg dependencies: TargetDependency): TargetDependency = + deps(*dependencies).whenFlag(flag, enabled = false) + +/** + * Include [targets] only when project-global flag [flag] is **off** (F-104). + */ +fun depsUnless(flag: String, vararg targets: FormaTarget): TargetDependency = + deps(*targets).whenFlag(flag, enabled = false) + +/** + * Composition-root helper: depend on production [impl] **or** [stub] based on one + * project-global flag (F-104 / GH #43). Default flag name is + * [USE_FEATURE_STUBS_FLAG] (`"useFeatureStubs"`). + * + * ```kotlin + * dependencies = deps( + * target(":feature:hello:api"), + * featureImplementation( + * impl = target(":feature:hello:impl"), + * stub = target(":feature:hello:stub-impl"), + * ), + * ) + * ``` + * + * | Flag | Classpath | + * |------|-----------| + * | off / unset | `implementation(impl)` only | + * | on | `implementation(stub)` only | + * + * Do **not** list both paths with hand-written `if (project.hasProperty)` — declare + * the flag once on `androidProjectConfiguration(featureFlags = …)`. + * See `docs/HYBRID-CONFIGURATION.md`. + */ +fun featureImplementation( + impl: FormaTarget, + stub: FormaTarget, + flag: String = USE_FEATURE_STUBS_FLAG, +): TargetDependency = + TargetDependency( + featureImplementationPair( + impl = listOf(TargetSpec(impl, Implementation)), + stub = listOf(TargetSpec(stub, Implementation)), + flag = flag, + ) + ) + +/** + * Same as [featureImplementation] taking [FormaTarget], for already-wrapped + * [TargetDependency] values (preserves non-default [TargetSpec.config]). + */ +fun featureImplementation( + impl: TargetDependency, + stub: TargetDependency, + flag: String = USE_FEATURE_STUBS_FLAG, +): TargetDependency = + TargetDependency( + featureImplementationPair( + impl = impl.targets, + stub = stub.targets, + flag = flag, + ) + ) + /** 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 index 29552a26..5aacd7ec 100644 --- a/plugins/deps/src/main/java/tools.forma/deps/core/ConditionalDependency.kt +++ b/plugins/deps/src/main/java/tools.forma/deps/core/ConditionalDependency.kt @@ -3,46 +3,129 @@ package tools.forma.deps.core import tools.forma.config.FormaFeatureFlags /** - * Pure resolution of feature-flag-gated named dependencies (F-099 / GH #126). + * Pure resolution of feature-flag-gated dependencies (F-099 / F-104 / GH #126 / #43). * - * 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. + * Specs carry optional flag metadata from `depsIf` / `depsUnless` / + * [NamedDependency.whenFlag] / [TargetDependency.whenFlag] / + * [featureImplementation]. 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. + * + * ## What is gated + * + * - [NameSpec] (named / external coords) — F-099 + * - [TargetSpec] (first-party project targets) — F-104 + * - Files and platforms are unchanged (no flag metadata on those kinds yet) */ +/** Canonical [FormaFeatureFlags] name for impl ↔ stub-impl swap (F-104 / GH #43). */ +const val USE_FEATURE_STUBS_FLAG: String = "useFeatureStubs" + +/** Gradle property that backs [USE_FEATURE_STUBS_FLAG] in examples (`-Pforma.useFeatureStubs=true`). */ +const val USE_FEATURE_STUBS_PROPERTY: String = "forma.useFeatureStubs" + /** Whether this named dep should be applied given [flags]. Unconditional specs always include. */ -fun NameSpec.isIncludedBy(flags: FormaFeatureFlags): Boolean { +fun NameSpec.isIncludedBy(flags: FormaFeatureFlags): Boolean = + isFlagConditionMet(featureFlag, featureFlagExpected, flags) + +/** Whether this target dep should be applied given [flags]. Unconditional specs always include. */ +fun TargetSpec.isIncludedBy(flags: FormaFeatureFlags): Boolean = + isFlagConditionMet(featureFlag, featureFlagExpected, flags) + +private fun isFlagConditionMet( + featureFlag: String?, + featureFlagExpected: Boolean, + 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 = +fun List.resolveNameFeatureFlags(flags: FormaFeatureFlags): List = + filter { it.isIncludedBy(flags) } + +/** Keep only target specs whose flag condition matches [flags]. */ +fun List.resolveTargetFeatureFlags(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). + * Drop specs that fail their feature-flag condition. + * Named deps and project targets are filtered; files and platforms pass through. */ fun FormaDependency.resolveFeatureFlags(flags: FormaFeatureFlags): FormaDependency = when (this) { EmptyDependency -> this is NamedDependency -> { - val resolved = names.resolveFeatureFlags(flags) + val resolved = names.resolveNameFeatureFlags(flags) if (resolved.isEmpty()) EmptyDependency else NamedDependency(resolved) } - is MixedDependency -> - MixedDependency( - names = names.resolveFeatureFlags(flags), - targets = targets, - files = files, - platforms = platforms, - ) - is TargetDependency -> this + is TargetDependency -> { + val resolved = targets.resolveTargetFeatureFlags(flags) + if (resolved.isEmpty()) EmptyDependency else TargetDependency(resolved) + } + is MixedDependency -> { + val resolvedNames = names.resolveNameFeatureFlags(flags) + val resolvedTargets = targets.resolveTargetFeatureFlags(flags) + if ( + resolvedNames.isEmpty() && + resolvedTargets.isEmpty() && + files.isEmpty() && + platforms.isEmpty() + ) { + EmptyDependency + } else { + MixedDependency( + names = resolvedNames, + targets = resolvedTargets, + files = files, + platforms = platforms, + ) + } + } is FileDependency -> this is PlatformDependency -> this } + +/** + * Pure impl ↔ stub-impl pair for composition roots (F-104). + * + * Builds two gated [TargetSpec] lists: + * - [impl] included when [flag] is **off** / unknown (production default) + * - [stub] included when [flag] is **on** (IDE / local stub mode) + * + * Preferred pattern is a **simple swap** (implementation of one side only). Both + * sides keep their [TargetSpec.config] (default [Implementation]). + * + * Call sites should prefer the DSL [featureImplementation] in `dependencies.kt`. + */ +fun featureImplementationPair( + impl: List, + stub: List, + flag: String = USE_FEATURE_STUBS_FLAG, +): List { + require(impl.isNotEmpty()) { "featureImplementation requires at least one impl TargetSpec" } + require(stub.isNotEmpty()) { "featureImplementation requires at least one stub TargetSpec" } + val implGated = + impl.map { spec -> + TargetSpec( + target = spec.target, + config = spec.config, + featureFlag = flag, + featureFlagExpected = false, + ) + } + val stubGated = + stub.map { spec -> + TargetSpec( + target = spec.target, + config = spec.config, + featureFlag = flag, + featureFlagExpected = true, + ) + } + return implGated + stubGated +} 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 1818dac1..e39063bb 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 @@ -32,7 +32,25 @@ object Ksp : ConfigurationType { sealed class DepSpec(val config: ConfigurationType) -class TargetSpec(val target: FormaTarget, config: ConfigurationType = Implementation) : DepSpec(config) +/** + * First-party project dependency. + * + * Optional [featureFlag] gates inclusion at apply time against project-global + * [tools.forma.config.FormaFeatureFlags] (F-099 / F-104). When null, always applied. + * See `depsIf` / `depsUnless` / [TargetDependency] `whenFlag` / + * `featureImplementation` and [resolveFeatureFlags]. + * + * [config] defaults to [Implementation]; use [CompileOnly] / [RuntimeOnly] when a + * recipe needs dual-config edges (not required for the simple impl↔stub swap). + */ +class TargetSpec( + val target: FormaTarget, + config: ConfigurationType = Implementation, + /** 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 FileSpec(val file: File, config: ConfigurationType) : 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 c9c5529d..866858ec 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,8 +35,9 @@ 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. + // F-099 / F-104: resolve feature-flag-gated named + target deps against project-global + // flags at apply time (not when depsIf/depsUnless/featureImplementation was called). + // Unknown flags = false. val featureFlags = FormaSettingsStore.featureFlagsOrEmpty() val resolvedDependencies = dependencies.resolveFeatureFlags(featureFlags) val resolvedTestDependencies = testDependencies.resolveFeatureFlags(featureFlags) 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 index b99dd4ab..5123fc41 100644 --- a/plugins/deps/src/test/kotlin/tools/forma/deps/core/ConditionalDependencyTest.kt +++ b/plugins/deps/src/test/kotlin/tools/forma/deps/core/ConditionalDependencyTest.kt @@ -4,20 +4,28 @@ import dep import deps import depsIf import depsUnless +import featureImplementation import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertSame import kotlin.test.assertTrue import ksp +import org.gradle.testfixtures.ProjectBuilder import tools.forma.config.FormaFeatureFlags +import tools.forma.target.FormaTarget import whenFlag /** - * F-099 / GH #126: pure conditional dependency resolution (no Gradle Project). + * F-099 / F-104: pure conditional dependency resolution (no Gradle apply path). */ class ConditionalDependencyTest { + private fun formaTarget(name: String): FormaTarget { + val project = ProjectBuilder.builder().withName(name).build() + return FormaTarget(project) + } + @Test fun `unconditional NameSpec always included`() { val spec = NameSpec("g:a:1", Implementation) @@ -111,7 +119,7 @@ class ConditionalDependencyTest { } @Test - fun `MixedDependency resolves names only`() { + fun `MixedDependency resolves names only historically`() { val mixed = MixedDependency( names = @@ -149,4 +157,180 @@ class ConditionalDependencyTest { fun `EmptyDependency resolve is identity`() { assertSame(EmptyDependency, EmptyDependency.resolveFeatureFlags(FormaFeatureFlags.EMPTY)) } + + // --- F-104: target flag gating + featureImplementation --- + + @Test + fun `unconditional TargetSpec always included`() { + val spec = TargetSpec(formaTarget("always-impl"), Implementation) + assertTrue(spec.isIncludedBy(FormaFeatureFlags.EMPTY)) + assertTrue(spec.isIncludedBy(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to true))) + } + + @Test + fun `TargetSpec whenFlag include and exclude`() { + val target = formaTarget("hello-impl") + val gated = deps(target).whenFlag(USE_FEATURE_STUBS_FLAG, enabled = true) + val spec = gated.targets.single() + assertEquals(USE_FEATURE_STUBS_FLAG, spec.featureFlag) + assertTrue(spec.featureFlagExpected) + assertTrue(spec.isIncludedBy(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to true))) + assertFalse(spec.isIncludedBy(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to false))) + assertFalse(spec.isIncludedBy(FormaFeatureFlags.EMPTY)) + } + + @Test + fun `TargetDependency whenFlag preserves ConfigurationType`() { + val target = formaTarget("lib") + val base = + TargetDependency( + listOf(TargetSpec(target, CompileOnly)), + ) + val gated = base.whenFlag("x", enabled = false) + val spec = gated.targets.single() + assertEquals(CompileOnly, spec.config) + assertEquals("x", spec.featureFlag) + assertFalse(spec.featureFlagExpected) + + val runtime = + TargetDependency(listOf(TargetSpec(target, RuntimeOnly))).whenFlag("y") + assertEquals(RuntimeOnly, runtime.targets.single().config) + assertEquals(Implementation, TargetSpec(target).config) + } + + @Test + fun `depsIf and depsUnless on FormaTarget`() { + val impl = formaTarget("f-impl") + val stub = formaTarget("f-stub-impl") + val ifOn = depsIf(USE_FEATURE_STUBS_FLAG, stub) + val unless = depsUnless(USE_FEATURE_STUBS_FLAG, impl) + + assertEquals(1, ifOn.targets.size) + assertTrue(ifOn.targets.single().featureFlagExpected) + assertFalse(unless.targets.single().featureFlagExpected) + + val composed = deps(ifOn, unless) + val stubsOn = + composed.resolveFeatureFlags(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to true)) + as TargetDependency + assertEquals(listOf("f-stub-impl"), stubsOn.targets.map { it.target.name }) + + val stubsOff = + composed.resolveFeatureFlags(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to false)) + as TargetDependency + assertEquals(listOf("f-impl"), stubsOff.targets.map { it.target.name }) + + val unknown = composed.resolveFeatureFlags(FormaFeatureFlags.EMPTY) as TargetDependency + assertEquals(listOf("f-impl"), unknown.targets.map { it.target.name }) + } + + @Test + fun `resolveFeatureFlags filters TargetDependency to EmptyDependency`() { + val onlyStub = depsIf(USE_FEATURE_STUBS_FLAG, formaTarget("stub-impl")) + val resolved = + onlyStub.resolveFeatureFlags(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to false)) + assertSame(EmptyDependency, resolved) + } + + @Test + fun `MixedDependency filters targets and keeps platforms`() { + val impl = formaTarget("hello-impl") + val stub = formaTarget("hello-stub-impl") + val mixed = + MixedDependency( + names = + listOf( + NameSpec( + "g:optional:1", + Implementation, + featureFlag = "extra", + featureFlagExpected = true, + ), + ), + targets = + featureImplementationPair( + impl = listOf(TargetSpec(impl)), + stub = listOf(TargetSpec(stub)), + ), + files = emptyList(), + platforms = listOf(PlatformSpec("g:bom:1", Implementation)), + ) + + val off = mixed.resolveFeatureFlags(FormaFeatureFlags.EMPTY) as MixedDependency + assertTrue(off.names.isEmpty()) + assertEquals(listOf("hello-impl"), off.targets.map { it.target.name }) + assertEquals(listOf("g:bom:1"), off.platforms.map { it.name }) + + val on = + mixed.resolveFeatureFlags( + FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to true, "extra" to true), + ) as MixedDependency + assertEquals(listOf("g:optional:1"), on.names.map { it.name }) + assertEquals(listOf("hello-stub-impl"), on.targets.map { it.target.name }) + assertEquals(1, on.platforms.size) + } + + @Test + fun `featureImplementation resolves impl-only vs stub-only`() { + val impl = formaTarget("feature-hello-impl") + val stub = formaTarget("feature-hello-stub-impl") + val pair = featureImplementation(impl = impl, stub = stub) + + assertEquals(2, pair.targets.size) + assertEquals(USE_FEATURE_STUBS_FLAG, pair.targets[0].featureFlag) + assertFalse(pair.targets[0].featureFlagExpected) + assertTrue(pair.targets[1].featureFlagExpected) + assertEquals(Implementation, pair.targets[0].config) + assertEquals(Implementation, pair.targets[1].config) + + val production = + pair.resolveFeatureFlags(FormaFeatureFlags.EMPTY) as TargetDependency + assertEquals(listOf("feature-hello-impl"), production.targets.map { it.target.name }) + + val stubMode = + pair.resolveFeatureFlags(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to true)) + as TargetDependency + assertEquals(listOf("feature-hello-stub-impl"), stubMode.targets.map { it.target.name }) + + val explicitOff = + pair.resolveFeatureFlags(FormaFeatureFlags(USE_FEATURE_STUBS_FLAG to false)) + as TargetDependency + assertEquals(listOf("feature-hello-impl"), explicitOff.targets.map { it.target.name }) + } + + @Test + fun `featureImplementation preserves custom ConfigurationType on both sides`() { + val impl = formaTarget("i") + val stub = formaTarget("s") + val pair = + featureImplementation( + impl = TargetDependency(listOf(TargetSpec(impl, CompileOnly))), + stub = TargetDependency(listOf(TargetSpec(stub, RuntimeOnly))), + ) + assertEquals(CompileOnly, pair.targets[0].config) + assertEquals(RuntimeOnly, pair.targets[1].config) + } + + @Test + fun `featureImplementationPair rejects empty sides`() { + val t = TargetSpec(formaTarget("x")) + try { + featureImplementationPair(impl = emptyList(), stub = listOf(t)) + kotlin.test.fail("expected require failure for empty impl") + } catch (_: IllegalArgumentException) { + // expected + } + try { + featureImplementationPair(impl = listOf(t), stub = emptyList()) + kotlin.test.fail("expected require failure for empty stub") + } catch (_: IllegalArgumentException) { + // expected + } + } + + @Test + fun `canonical flag constant matches documented name`() { + assertEquals("useFeatureStubs", USE_FEATURE_STUBS_FLAG) + assertEquals("forma.useFeatureStubs", USE_FEATURE_STUBS_PROPERTY) + } }