Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----|--------|-------|-------|
Expand Down
13 changes: 12 additions & 1 deletion docs/CALL-SITE-SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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)

Expand Down
14 changes: 10 additions & 4 deletions docs/DEPS-CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 =`.
Expand Down
200 changes: 200 additions & 0 deletions docs/HYBRID-CONFIGURATION.md
Original file line number Diff line number Diff line change
@@ -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/<name>/{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)
22 changes: 22 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/PROGRESSIVE-EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/{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 |
Expand Down
Loading
Loading