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
4 changes: 2 additions & 2 deletions TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ worker (ops); `v2`→`master` (explicit git promote only). **F-094** stays `bloc
| F-102 | done | Navigation abstraction design | GH **#46** — **done (design):** [`docs/NAVIGATION-ABSTRACTION.md`](docs/NAVIGATION-ABSTRACTION.md) — Layer A presentation ports (no androidx.navigation in feature impl/VM) vs Layer B existing Path B `navigationRes` only; v1 = Jetpack behind root adapter; reject engine router DSL / plugin shopping / impl→impl / dual happy paths. Implement split: **F-111** sample, **F-112** progressive example. |
| 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 | todo | Hybrid targets example (flat dir / api+impl co-location) | GH **#44** — teaching example: simplified flat layout (api/impl/stub mental model, easy multi-module nav). May use includer layout + progressive example; align with F-084/F-088 fleet layout. No restore of `androidLibrary`. |
| 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-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). Next board priority is **P10** top `todo` (**F-103** hybrid targets, then **F-104**…) unless
**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.

Expand Down
19 changes: 19 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

Newest entries first.

## 2026-07-27 — F-103: Hybrid targets progressive example

- **Ticket:** F-103 → `done`
- **Branch:** `forma/F-103-hybrid-targets` (from `origin/v2`)
- **Actions:**
- Added `examples/android/15-hybrid-targets` — co-located `feature/{hello,world}/{api,impl,stub-impl}`
- Stubs are real `impl` modules in `stub-impl/` dirs (`packageName` `…stub`) — bare `stub/` fails self-type suffix (`Allowed name suffix(es): impl`); documented in example README
- Composition roots default to **api + impl**; commented manual swap to `stub-impl` → F-104 teaser
- `configureondemand=false` so unused stub-impl siblings still configure/validate
- Curriculum: example README, `docs/PROGRESSIVE-EXAMPLES.md` row 15, `examples/README.md` ladder + fix stale 01→11 wording, `examples/agent-skills/forma-project-layout.md` hybrid section
- No `plugins/` engine changes; no `androidLibrary`; matrix rules unchanged (`impl` ↛ `impl`)
- **Build verification:**
- `./gradlew :binary:assembleDebug` → **BUILD SUCCESSFUL**
- `./gradlew :feature-hello-stub-impl:compileDebugKotlin :feature-world-stub-impl:compileDebugKotlin` → green
- **Commits/PRs:** this branch
- **Blockers:** none
- **Next step:** F-104 (IDE sync swap impl→stub via project-global flag)


## 2026-07-26 — F-111: Sample navigation ports + root adapter

- **Ticket:** F-111 → `done` (GH #46 sample implement; design F-102 + teach F-112 already done)
Expand Down
1 change: 1 addition & 0 deletions docs/PROGRESSIVE-EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +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 |
| `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
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Gold-standard product apps remain [`application/`](../application/) (Android) an
| Track | Path | Steps |
|-------|------|-------|
| JVM | [`jvm/`](jvm/) | 01 hello-binary → 05 test-util |
| Android | [`android/`](android/) | 01 hello-apk → 11 metro-di |
| Android | [`android/`](android/) | 01 hello-apk → 15 hybrid-targets |
| KMP | [`kmp/`](kmp/) | 01 shared-library (kmpLibrary + JVM binary) |
| Agent skills | [`agent-skills/`](agent-skills/) | SKILL docs for coding agents |
| User index | [`docs/PROGRESSIVE-EXAMPLES.md`](../docs/PROGRESSIVE-EXAMPLES.md) | narrative + feature coverage matrix |
Expand Down Expand Up @@ -42,6 +42,7 @@ Gold-standard product apps remain [`application/`](../application/) (Android) an
| 12 | `android/12-navigation-ports` | Presentation `Navigator` ports + root adapter; Jetpack/Safe Args only at root; Path B `navigationRes` |
| 13 | `android/13-android-native` | `androidNative` (CMake) + JNI `androidUtil` consumer |
| 14 | `android/14-google-firebase` | Firebase Crashlytics + Analytics via Path A `firebaseBinary` |
| 15 | `android/15-hybrid-targets` | Co-located `feature/<name>/{api,impl,stub-impl}`; multi-feature; manual stub swap teaser (F-104) |

### KMP (`tools.forma.kmp`)

Expand Down
19 changes: 19 additions & 0 deletions examples/agent-skills/forma-project-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,22 @@ Roots **must** depend on each feature’s `api` and `impl` explicitly.

Examples gold standards: `docs/SAMPLE-APP.md`, `docs/JVM-SAMPLE.md`.
Teaching ladders: `examples/**`.


## Hybrid co-located roles (api + impl + stub)

Optional fleet layout for easier multi-module navigation (GH #44 / F-103):

```
feature/<name>/{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`.
- Gradle projects: `:feature-<name>-api`, `:feature-<name>-impl`, `:feature-<name>-stub-impl`.
Forma paths: `target(":feature:<name>: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.
- Teaching example: [`examples/android/15-hybrid-targets`](../android/15-hybrid-targets).
126 changes: 126 additions & 0 deletions examples/android/15-hybrid-targets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# 15 — hybrid targets (flat api / impl / stub co-location)

**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.

## Features introduced

- Co-located `feature/<name>/{api,impl,stub-impl}/` (same mental model as 02/07, flatter nav)
- Real **stub** modules that implement the same `api` 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)

## Layout

```
15-hybrid-targets/
binary/ # androidBinary composition root
root-app/ # androidApp composition root
root-res/
feature/
hello/
api/ # api(...) contracts
impl/ # impl(...) production
stub-impl/ # impl(...) lightweight stand-in (same api)
world/
api/
impl/
stub-impl/
```

### Why `stub-impl` (not bare `stub/`)?

Forma target types are **suffix-typed**: an `impl { }` module’s Gradle project name
must end with `-impl` (self-type / matrix checks). Includer derives the name from
the directory path, so the role folder is `stub-impl/`:

| Role (mental model) | Directory | Gradle project | `target()` path |
|---------------------|-----------|----------------|-----------------|
| api | `feature/hello/api` | `:feature-hello-api` | `:feature:hello:api` |
| 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`).

### Why this eases IDE navigation

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.

### api / impl / stub mental model

| Role | Target type | Depends on | Purpose |
|------|-------------|------------|---------|
| `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 |

Rules that still hold:

- **No `androidLibrary`** — roles stay typed
- **`impl` ↛ `impl`** — stubs do not depend on production impls (or each other)
- 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.

## Manual stub swap (teaser → F-104)

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`.

**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.

## Build

```bash
source ../../../scripts/env-mac.sh
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
./gradlew :binary:assembleDebug
# Optional: prove stubs compile without swapping roots
./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`).

## 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
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`
- Changing gold `application/` sample layout

## Related

- Step 02 — minimal api+impl
- Step 07 — multi-feature composition
- F-104 — automated stub swap for IDE sync
- Agent skill: `examples/agent-skills/forma-project-layout.md`
18 changes: 18 additions & 0 deletions examples/android/15-hybrid-targets/binary/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
androidBinary(
packageName = "tools.forma.examples.android.hybrid",
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"),
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
<!-- Empty manifest required for AGP binary target -->
</manifest>
9 changes: 9 additions & 0 deletions examples/android/15-hybrid-targets/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
buildscript {
androidProjectConfiguration(
project = rootProject,
minSdk = 23,
targetSdk = 37,
compileSdk = 37,
agpVersion = "9.3.0",
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
api(
packageName = "tools.forma.examples.android.hybrid.feature.hello.api"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package tools.forma.examples.android.hybrid.feature.hello.api

interface HelloMessage {
fun text(): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
impl(
packageName = "tools.forma.examples.android.hybrid.feature.hello.impl",
dependencies = deps(
target(":feature:hello:api")
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package tools.forma.examples.android.hybrid.feature.hello.impl

import tools.forma.examples.android.hybrid.feature.hello.api.HelloMessage

class DefaultHelloMessage : HelloMessage {
override fun text(): String = "Hello"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// 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.
impl(
packageName = "tools.forma.examples.android.hybrid.feature.hello.stub",
dependencies = deps(
target(":feature:hello:api")
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
api(
packageName = "tools.forma.examples.android.hybrid.feature.world.api"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package tools.forma.examples.android.hybrid.feature.world.api

interface WorldMessage {
fun text(): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
impl(
packageName = "tools.forma.examples.android.hybrid.feature.world.impl",
dependencies = deps(
target(":feature:world:api")
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package tools.forma.examples.android.hybrid.feature.world.impl

import tools.forma.examples.android.hybrid.feature.world.api.WorldMessage

class DefaultWorldMessage : WorldMessage {
override fun text(): String = "World"
}
Original file line number Diff line number Diff line change
@@ -0,0 +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.
impl(
packageName = "tools.forma.examples.android.hybrid.feature.world.stub",
dependencies = deps(
target(":feature:world:api")
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
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"
}
11 changes: 11 additions & 0 deletions examples/android/15-hybrid-targets/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Progressive Android example
org.gradle.jvmargs=-Xmx2048m -XX:+UseParallelGC
org.gradle.parallel=true
org.gradle.caching=true
# Configure all modules so co-located stub-impl siblings are validated even when
# not on the default production classpath (api+impl path).
org.gradle.configureondemand=false
android.useAndroidX=true
android.enableJetifier=false
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading