From ed2ed321fc6623505de87daa02883185a284c808 Mon Sep 17 00:00:00 2001 From: Claw Date: Tue, 21 Jul 2026 15:38:00 -0700 Subject: [PATCH] F-088: Gradle layout check/generate tasks over core fleet APIs Thin shells formaLayoutCheck/Generate (+ root *All) via tools.forma.deps.fleet, wired from all packageName target DSLs. Opt-in checkPackageLayoutAtConfiguration default-off. Accept java|kotlin source roots; skip res/viewBinding/binary source requirement. Docs and agent skill updated; GH #54 generate path user-complete. --- README.md | 2 +- TICKETS.md | 2 +- docs/FLEET-TOOLING.md | 117 +++++-- docs/GETTING-STARTED.md | 6 +- docs/PROGRESS.md | 23 ++ examples/agent-skills/forma-fleet-tooling.md | 42 ++- plugins/android/src/main/java/androidApp.kt | 2 + .../android/src/main/java/androidBinary.kt | 3 + .../android/src/main/java/androidNative.kt | 2 + .../main/java/androidProjectConfiguration.kt | 10 +- .../android/src/main/java/androidTestUtil.kt | 2 + plugins/android/src/main/java/androidUtil.kt | 2 + plugins/android/src/main/java/api.kt | 2 + .../android/src/main/java/composeWidget.kt | 2 + plugins/android/src/main/java/impl.kt | 2 + plugins/android/src/main/java/library.kt | 2 + .../android/src/main/java/resourcesTarget.kt | 3 + plugins/android/src/main/java/testUtil.kt | 2 + plugins/android/src/main/java/uiLibrary.kt | 2 + plugins/android/src/main/java/util.kt | 2 + plugins/android/src/main/java/viewBinding.kt | 3 + plugins/android/src/main/java/widget.kt | 2 + .../forma/config/AndroidProjectSettings.kt | 11 +- .../forma/deps/fleet/FormaLayoutExtension.kt | 24 ++ .../forma/deps/fleet/FormaLayoutTasks.kt | 316 ++++++++++++++++++ .../jvm/src/main/java/tools/forma/jvm/api.kt | 2 + .../src/main/java/tools/forma/jvm/binary.kt | 2 + .../jvm/src/main/java/tools/forma/jvm/impl.kt | 2 + .../src/main/java/tools/forma/jvm/library.kt | 2 + .../src/main/java/tools/forma/jvm/testUtil.kt | 2 + .../jvm/src/main/java/tools/forma/jvm/util.kt | 2 + 31 files changed, 560 insertions(+), 38 deletions(-) create mode 100644 plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutExtension.kt create mode 100644 plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutTasks.kt diff --git a/README.md b/README.md index 9bd43fff..a73b7648 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ systems like Buck and Bazel. 2. [**JVM getting started**](docs/JVM-GETTING-STARTED.md) — pure JVM tutorial, run the sample, greenfield skeleton with `binary`, `api`/`impl`, target cheat sheet (F-032) 3. [Sample app gold standard](docs/SAMPLE-APP.md) — multi-feature layout to copy 4. [Dependency matrix](docs/DEPENDENCY-MATRIX.md) — what may depend on what -5. [External deps catalogs](docs/DEPS-CATALOG.md) · [Target plugins](docs/TARGET-PLUGINS.md) (type-owned, Bazel-like; example `examples/android/10-target-plugins`) · [Call-site surface](docs/CALL-SITE-SURFACE.md) (Unit DSLs + flag inventory) · [Fleet tooling](docs/FLEET-TOOLING.md) (check/generate/migrate, F-084) · [Principle audit](docs/PRINCIPLE-AUDIT.md) (F-085) · [Compose](docs/COMPOSE.md) · [Environment](docs/ENV.md) +5. [External deps catalogs](docs/DEPS-CATALOG.md) · [Target plugins](docs/TARGET-PLUGINS.md) (type-owned, Bazel-like; example `examples/android/10-target-plugins`) · [Call-site surface](docs/CALL-SITE-SURFACE.md) (Unit DSLs + flag inventory) · [Fleet tooling](docs/FLEET-TOOLING.md) (check/generate Gradle tasks + core APIs, F-084/F-088) · [Principle audit](docs/PRINCIPLE-AUDIT.md) (F-085) · [Compose](docs/COMPOSE.md) · [Environment](docs/ENV.md) 6. [Plugin publish path](docs/PLUGIN-PUBLISH.md) — Portal metadata DSL + release notes (F-016) 7. [Configuration performance](docs/CONFIGURATION-PERFORMANCE.md) — measure + hot-path guidance (F-017 / GH #106) 8. [forma-core public API design](docs/forma-core-api.md) — extraction contract for types / restrictions / registry (F-020) diff --git a/TICKETS.md b/TICKETS.md index e2e4ae2b..87c93859 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -119,7 +119,7 @@ Promoted from daily next-actions / historical GH. Workers pick top `todo` in ord | ID | Status | Title | Notes | |----|--------|-------|-------| | F-087 | done | AndroidX / SDK ceiling after AGP 9 | compile/target **37**; core **1.19.0**, activity **1.13.0**, lifecycle **2.11.0**, compose **1.11.4**, room **2.8.4**, material **1.14.0**; JVM **11**; navigationevent on activity graph; paging **2.1.2** kept. Host+CI platform 37. | -| F-088 | todo | Fleet tooling phase 2 (F-084 follow-ups) | From `docs/FLEET-TOOLING.md`: Gradle `formaLayoutCheck` / `formaLayoutGenerate` tasks (thin shells over core); optional config-time packageName dir hook; bulk generate driver for includer graph. Defer AST migrate / depgen resurrection unless unblocked in-slice. Close GH **#54** if generate path is user-complete. | +| F-088 | done | Fleet tooling phase 2 (F-084 follow-ups) | Gradle `formaLayoutCheck`/`formaLayoutGenerate` + root `*All` via `tools.forma.deps.fleet.registerFormaLayout` on all packageName DSLs; opt-in `checkPackageLayoutAtConfiguration` (default false); res/viewBinding/binary skip source-dir requirement; docs/skill updated. GH **#54** generate path user-complete (Hermes may close). AST migrate / depgen still deferred. | | F-089 | todo | Navigation task cache broken | GH **#110** — fix navigation/safe-args related task cache so config/build cache is correct; reproduce from issue screenshots; add regression note or test if feasible; plugins+app green. | | F-090 | todo | Exclude modules from dependency validation | GH **#97** — allow-list / opt-out for forked-in libraries (ExoPlayer-class cases) without weakening default matrix. One global configuration path (not per-call-site escape hatch spam). Docs + sample or unit coverage. | | F-091 | todo | BuildFeatures under Forma | GH **#88** — map AGP `BuildFeatures` into Forma (default off where safe); single configuration path aligned with type=rule + project-global defaults; document in CALL-SITE / PROJECT-CONFIGURATION. Do not reintroduce fat call-site plugin shopping. | diff --git a/docs/FLEET-TOOLING.md b/docs/FLEET-TOOLING.md index 4f8c86f0..62683996 100644 --- a/docs/FLEET-TOOLING.md +++ b/docs/FLEET-TOOLING.md @@ -1,6 +1,6 @@ -# Fleet tooling — check / generate / migrate (F-084) +# Fleet tooling — check / generate / migrate (F-084 + F-088) -**Status:** v1 toolkit shipped in `tools.forma:core` (`tools.forma.core.fleet`). +**Status:** v1 pure APIs in `tools.forma:core` (`tools.forma.core.fleet`) **and** phase-2 Gradle shells (F-088). Related: principle **3** in [`VISION.md`](VISION.md), GH **#54**, includer, bazel-adapter generate/check. Forma keeps structure **explicit**. At thousands of modules that only works if @@ -27,11 +27,11 @@ Forma keeps structure **explicit**. At thousands of modules that only works if ## One global way: three modes -| Mode | Job | v1 entry | -|------|-----|----------| -| **check** | Fail when layout does not match declared `packageName` (CI-friendly pure API) | `LayoutChecker` | -| **generate** | Create missing `src/main/{java\|kotlin}/…` trees from `packageName` | `LayoutGenerator` | -| **migrate** | Plan module path renames + suggested reference rewrites (not AST rewrite) | `MigratePlanner` | +| Mode | Job | Entry | +|------|-----|-------| +| **check** | Fail when layout does not match declared `packageName` | Core `LayoutChecker` · Gradle `formaLayoutCheck` / `formaLayoutCheckAll` | +| **generate** | Create missing `src/main/{java\|kotlin}/…` trees from `packageName` | Core `LayoutGenerator` · Gradle `formaLayoutGenerate` / `formaLayoutGenerateAll` | +| **migrate** | Plan module path renames + suggested reference rewrites (not AST rewrite) | Core `MigratePlanner` (plan-only; AST rewrite deferred) | Do **not** invent a second parallel layout convention per team. Extend these helpers (or thin Gradle tasks that call them), not ad-hoc scripts with different @@ -43,9 +43,10 @@ path rules. |------|------------|--------| | **includer** (`includer/`) | **Discover** modules: any dir with `build.gradle(.kts)` is included; nested `settings` skipped | Naming: `feature/home/impl` → Gradle `:feature-home-impl` | | **forma-core validators** | **Runtime check** of content rules + restriction graph | Configuration-time; not generate | -| **`tools.forma.core.fleet`** (this doc) | **Offline** check / generate / migrate-plan | Pure Kotlin, unit-tested, no Gradle APIs | +| **`tools.forma.core.fleet`** | **Offline** check / generate / migrate-plan | Pure Kotlin, unit-tested, no Gradle APIs | +| **`tools.forma.deps.fleet`** (F-088) | **Gradle shells** + layout metadata on every `packageName` DSL | Thin tasks over core; shared Android + JVM | | **bazel-adapter** | Bazel **generate** + **check** from `FormaProjectModel` | F-041; JVM-first; see [`BAZEL-ADAPTER.md`](BAZEL-ADAPTER.md) | -| **depgen** (`depgen/`) | Intended transitive external-deps generation | **Stub** plugin + unfinished task; not on sample critical path; not part of F-084 v1 | +| **depgen** (`depgen/`) | Intended transitive external-deps generation | **Stub** plugin + unfinished task; not on sample critical path; **deferred** (not resurrected in F-088) | ## Path conventions (source of truth) @@ -54,7 +55,7 @@ path rules. | Filesystem relative dir | `feature/home/impl` | Repo layout + includer walk | | Gradle project path | `:feature-home-impl` | Includer (`/` → `-`) | | Forma target ref | `:feature:home:impl` | `target(":feature:home:impl")` | -| Package source dir | `src/main/kotlin/com/foo/bar` | `packageName` attribute | +| Package source dir | `src/main/kotlin/com.foo.bar` | `packageName` attribute | Helpers: `ProjectPathForms`, `PackageLayout`. @@ -96,50 +97,112 @@ MigratePlanner.planRename( // → filesystemMoves + referenceRewrites (:gradle, :forma, target(), project()) ``` -### Using from a one-off script / future task +Logic **stays in core**. Gradle tasks are thin shells only. -1. Depend on `tools.forma:core` (same version as Forma plugins). -2. Call pure APIs above (temp dirs in tests; real module dirs in tools). -3. Wire a Gradle task later if desired — **logic stays in core**, task is a thin shell. +## Gradle tasks (F-088 phase 2) -v1 does **not** ship a Gradle plugin task or CLI binary; the library + tests are -the product surface so agents and future tasks share one implementation. +Every Android and JVM target DSL that takes `packageName` calls +`Project.registerFormaLayout(packageName)` (`tools.forma.deps.fleet`): -## What v1 ships vs follow-ups +1. Stores typed metadata on the project: extension `formaLayout` / + `FormaLayoutExtension` (`packageName` + `SourceLanguage`, default KOTLIN). +2. Registers per-project tasks: + - **`formaLayoutCheck`** — for code targets, package dir must exist under **either** + `src/main/kotlin/…` or `src/main/java/…` (sample Android trees use the java root + for Kotlin sources). Fails if missing under both. **Skipped** (success) for + AGP-identity-only targets: `androidRes` / `resourcesTarget`, `viewBinding`, + `androidBinary` (`requirePackageSourceDir = false`). + - **`formaLayoutGenerate`** — `LayoutGenerator.apply(...)` for code targets; logs + created dirs. Prefers an existing conventional root (`java` vs `kotlin`); + otherwise default language KOTLIN. Optional placeholder via project property + **`forma.layout.createPlaceholder=true`** (default **false**). **Skipped** for + AGP-identity-only targets (same set as check skip) so bulk generate does not + drop empty kotlin trees into res/binary modules. +3. Idempotent if the helper is invoked twice on the same project. + +Root aggregates (registered from `androidProjectConfiguration` and/or first layout registration): + +| Task | Behavior | +|------|----------| +| **`formaLayoutCheckAll`** | `dependsOn` every subproject `formaLayoutCheck` that registered metadata | +| **`formaLayoutGenerateAll`** | `dependsOn` every subproject `formaLayoutGenerate` that registered metadata | + +### Optional configuration-time check + +On `androidProjectConfiguration` / `AndroidProjectSettings`: + +```kotlin +androidProjectConfiguration( + project = rootProject, + // ... + checkPackageLayoutAtConfiguration = false, // default — leave off for sample green +) +``` + +When **`true`**, after a target registers layout metadata, if the package source +dir is missing → `logger.error` + `GradleException` at **configuration** time. + +- **Opt-in** CI/dev strictness only. +- Happy path for scaffolding remains **`formaLayoutGenerate`** / **`formaLayoutGenerateAll`**. +- Pure JVM projects without Android settings skip the config-time hook (no second configuration path). + +### Verify commands + +```bash +source scripts/env-mac.sh +cd plugins && ./gradlew :core:test build + +cd application +./gradlew help +./gradlew :feature-home-api:formaLayoutCheck +./gradlew formaLayoutCheckAll +# scaffold missing trees (explicit; does not run on normal build): +./gradlew :some-module:formaLayoutGenerate +./gradlew formaLayoutGenerateAll +# optional placeholder files: +./gradlew formaLayoutGenerateAll -Pforma.layout.createPlaceholder=true +``` + +Do **not** enable `checkPackageLayoutAtConfiguration` on the sample by default. + +## What ships vs deferred **Shipped (F-084 v1)** - Design (this doc) + agent skill - Pure `check` / `generate` / `migrate-plan` + path forms in `plugins/core` - Unit tests including GH #54 package example -- Cross-links from README / VISION / GETTING-STARTED -**Follow-ups (not blocking F-084 done)** — board: **F-088** +**Shipped (F-088 phase 2)** + +- Gradle `formaLayoutCheck` / `formaLayoutGenerate` on every `packageName` target +- Root `formaLayoutCheckAll` / `formaLayoutGenerateAll` +- Opt-in `checkPackageLayoutAtConfiguration` (default false) +- Docs + agent skill task usage +- GH **#54** generate path is user-complete via Gradle tasks (close when merged) + +**Still deferred** -- Gradle `formaLayoutCheck` / `formaLayoutGenerate` tasks on Android/JVM platforms -- Optional configuration-time hook: fail if `packageName` dir missing (opt-in) - AST-safe build-script rewrite for migrate (IntelliJ/PSI or Kotlin compiler) - packageName rename paired with directory move - depgen resurrection or retire (separate decision) - Deeper bazel-adapter integration with the same `FormaProjectModel` export -- Bulk “generate missing trees for whole includer graph” driver -## Non-goals (v1) +## Non-goals - Full monorepo rewrite engine - Teaching a second layout style beside includer + `packageName` - Android `res/` scaffolding (content rules already constrain res targets) - Finishing depgen transitive generation +- Mass-creating missing dirs during normal `build` (tasks are explicit) ## Agent skill See [`examples/agent-skills/forma-fleet-tooling.md`](../examples/agent-skills/forma-fleet-tooling.md). -## Verify +## Verify (plugins unit + shells compile) ```bash source scripts/env-mac.sh -cd plugins && ./gradlew :core:test -# or full -./gradlew build +cd plugins && ./gradlew :core:test build ``` diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index e3f3cbf1..ddcc7f4a 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -9,7 +9,7 @@ visibility validation** for you. **Root principles** (see [`VISION.md`](VISION.md)): (1) Bazel-like rules — configure once on the type, minimal call-site attrs; (2) one global way project-wide; (3) explicit structure, with tooling for large-scale change -([`FLEET-TOOLING.md`](FLEET-TOOLING.md) — check / generate / migrate). +([`FLEET-TOOLING.md`](FLEET-TOOLING.md) — `formaLayoutCheck` / `formaLayoutGenerate` + core APIs). This tutorial gets you from zero to a working multi-target Android app. For the full multi-feature reference layout, see [SAMPLE-APP.md](SAMPLE-APP.md). @@ -297,6 +297,8 @@ impl( ``` Put Kotlin under `src/main/java//…` matching `packageName`. +Scaffold missing package trees with `./gradlew :module:formaLayoutGenerate` or root +`formaLayoutGenerateAll` (see [FLEET-TOOLING.md](FLEET-TOOLING.md)). ### 4.4 Build @@ -445,7 +447,7 @@ Details: [COMPOSE.md](COMPOSE.md). Sample: | SDK / `compileSdk` errors | Install platform **37** (`platforms;android-37.0` + `android-37` symlink); set `local.properties` `sdk.dir` | | Project not included | Missing `build.gradle.kts`, or nested `settings.gradle.kts` blocked Includer | | Illegal project dependency | Matrix violation — see [DEPENDENCY-MATRIX.md](DEPENDENCY-MATRIX.md) | -| Empty / wrong package | `packageName` ≠ directory under `src/main/java` | +| Empty / wrong package | `packageName` ≠ directory under `src/main/java` — run `formaLayoutCheck` / `formaLayoutGenerate` | | Compose compiler mismatch | Align `composeCompilerVersion` with Kotlin (sample: 2.3.21 ↔ 2.3.21) | | AGP resolution conflicts | Align consumer `agpVersion` with plugin AGP line (**9.3.0** today) | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index fb14261e..068676e3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,29 @@ Newest entries first. +## 2026-07-21 — F-088: Fleet tooling phase 2 + +- **Ticket:** F-088 → `done` +- **Branch:** `forma/F-088-fleet-phase2` (from origin/v2 @ 4fe663a) +- **Actions:** + - Shared Gradle helper `tools.forma.deps.fleet` (`FormaLayoutExtension`, `registerFormaLayout`, root `ensureFormaLayoutRootTasks`) + - Per-project tasks `formaLayoutCheck` / `formaLayoutGenerate` (thin shells over core `LayoutChecker` / `LayoutGenerator`) + - Root aggregates `formaLayoutCheckAll` / `formaLayoutGenerateAll` (`dependsOn` registered subprojects) + - Wired into **all** Android + JVM target DSLs that take `packageName` + - Opt-in `AndroidProjectSettings.checkPackageLayoutAtConfiguration` (default **false**); set via `androidProjectConfiguration(...)`; pure JVM skips when settings unset + - Check accepts either `src/main/java` or `src/main/kotlin` package trees (sample uses java root) + - `requirePackageSourceDir = false` for `resourcesTarget` / `viewBinding` / `androidBinary` (AGP identity only — avoids checkAll fail + bulk-generate pollution on res) + - Docs: `docs/FLEET-TOOLING.md` phase-2 shipped; agent skill task usage; README + GETTING-STARTED cross-links + - **Deferred (documented):** AST migrate rewrite, depgen resurrection + - **GH #54:** generate path is user-complete via Gradle tasks + docs — leave note for Hermes to close (not closed from this CLI) +- **Verify (real host, `source scripts/env-mac.sh`):** + - `plugins/ ./gradlew :core:test build` → **BUILD SUCCESSFUL** (78 tasks) + - `application/ ./gradlew help :feature-home-api:formaLayoutCheck formaLayoutCheckAll --no-configuration-cache` → **BUILD SUCCESSFUL** (82 tasks) + - Sample does **not** set `checkPackageLayoutAtConfiguration` + - Accidental res kotlin trees from an intermediate `generateAll` run were cleaned (`git clean`); generate now skips AGP-identity targets +- **Blockers:** none +- **Next:** F-089 Navigation task cache broken (GH #110) + ## 2026-07-21 — F-087: AndroidX / SDK ceiling - **Ticket:** F-087 → `done` diff --git a/examples/agent-skills/forma-fleet-tooling.md b/examples/agent-skills/forma-fleet-tooling.md index fc4f11ad..1e101824 100644 --- a/examples/agent-skills/forma-fleet-tooling.md +++ b/examples/agent-skills/forma-fleet-tooling.md @@ -1,28 +1,60 @@ --- name: forma-fleet-tooling -description: Check/generate/migrate package layout and path forms (F-084 fleet toolkit). +description: Check/generate/migrate package layout and path forms (F-084/F-088 fleet toolkit). --- # Fleet tooling (agent skill) Canonical design: [`docs/FLEET-TOOLING.md`](../../docs/FLEET-TOOLING.md). -Implementation: `tools.forma.core.fleet` in `plugins/core` (pure Kotlin). +- Pure APIs: `tools.forma.core.fleet` in `plugins/core` +- Gradle shells: `tools.forma.deps.fleet` (`registerFormaLayout` from every `packageName` DSL) ## When to use - Scaffolding a new target after writing `api` / `impl` / … with `packageName` - CI or pre-commit layout check for `packageName` ↔ source dir - Planning a module path rename (includer path + Gradle + `target()` refs) +- Bulk generate for the whole includer graph after adding many modules ## Rules -1. **One way:** use `PackageLayout` / `LayoutGenerator` / `LayoutChecker` / `MigratePlanner` — do not invent alternate path math. +1. **One way:** use `PackageLayout` / `LayoutGenerator` / `LayoutChecker` / `MigratePlanner` — do not invent alternate path math. Gradle tasks are thin shells over the same APIs. 2. `packageName` must match `src/main/kotlin|java/`. 3. Includer: `feature/home/impl` → `:feature-home-impl`; Forma refs use `:feature:home:impl`. -4. Migrate planner is **plan-only** (string suggestions). Review before bulk replace; not AST-safe. +4. Migrate planner is **plan-only** (string suggestions). Review before bulk replace; not AST-safe (deferred). 5. Still obey matrix / `impl` ↛ `impl` / no `androidLibrary` / type-owned plugins. +6. Do **not** turn on `checkPackageLayoutAtConfiguration` on the sample by default; use explicit generate/check tasks. +7. `androidRes` / `viewBinding` / `androidBinary` register layout metadata but **skip** check/generate (packageName is AGP identity only). -## Snippets +## Gradle tasks (preferred for day-to-day) + +Per project (registered when the target DSL runs): + +```bash +./gradlew :feature-home-api:formaLayoutCheck +./gradlew :feature-home-api:formaLayoutGenerate +# optional .gitkeep under empty package dir: +./gradlew :feature-home-api:formaLayoutGenerate -Pforma.layout.createPlaceholder=true +``` + +Whole graph (root aggregates): + +```bash +./gradlew formaLayoutCheckAll +./gradlew formaLayoutGenerateAll +``` + +Opt-in configuration-time fail (Android only, default **false**): + +```kotlin +androidProjectConfiguration( + project = rootProject, + // ... + checkPackageLayoutAtConfiguration = true, // CI/strict only +) +``` + +## Core API snippets (scripts / offline) ```kotlin import tools.forma.core.fleet.* diff --git a/plugins/android/src/main/java/androidApp.kt b/plugins/android/src/main/java/androidApp.kt index ca082b29..487c77ce 100644 --- a/plugins/android/src/main/java/androidApp.kt +++ b/plugins/android/src/main/java/androidApp.kt @@ -14,6 +14,7 @@ import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.NamedDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.validation.asValidator @@ -45,6 +46,7 @@ fun Project.androidApp( val selfV = AndroidTargetRegistry.selfValidator(AndroidTargetTypes.app).asValidator() selfV.validate(target) + registerFormaLayout(packageName) val libraryFeatureConfiguration = AndroidLibraryFeatureConfiguration( packageName, buildConfiguration, diff --git a/plugins/android/src/main/java/androidBinary.kt b/plugins/android/src/main/java/androidBinary.kt index 7ec43d8b..160a6959 100644 --- a/plugins/android/src/main/java/androidBinary.kt +++ b/plugins/android/src/main/java/androidBinary.kt @@ -9,6 +9,7 @@ import tools.forma.android.validation.disallowResources import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.validation.asValidator @@ -47,6 +48,8 @@ fun Project.androidBinary( val selfV = AndroidTargetRegistry.selfValidator(AndroidTargetTypes.binary).asValidator() selfV.validate(target) + // applicationId / namespace; composition root often has only a manifest. + registerFormaLayout(packageName, requirePackageSourceDir = false) val binaryFeatureConfiguration = AndroidBinaryFeatureConfiguration( packageName, versionCode, diff --git a/plugins/android/src/main/java/androidNative.kt b/plugins/android/src/main/java/androidNative.kt index ea1d6975..05372477 100644 --- a/plugins/android/src/main/java/androidNative.kt +++ b/plugins/android/src/main/java/androidNative.kt @@ -12,6 +12,7 @@ import tools.forma.android.visibility.Visibility import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -24,6 +25,7 @@ fun Project.androidNative( ) { disallowResources() AndroidTargetRegistry.selfValidator(AndroidTargetTypes.native).asValidator().validate(target) + registerFormaLayout(packageName) val configuration = AndroidNativeConfiguration( packageName = packageName, diff --git a/plugins/android/src/main/java/androidProjectConfiguration.kt b/plugins/android/src/main/java/androidProjectConfiguration.kt index 576a4efa..c12891b6 100644 --- a/plugins/android/src/main/java/androidProjectConfiguration.kt +++ b/plugins/android/src/main/java/androidProjectConfiguration.kt @@ -13,6 +13,7 @@ import tools.forma.config.AndroidProjectSettings import tools.forma.config.FormaSettingsStore import tools.forma.config.PluginInfoStore import tools.forma.config.SettingsStore +import tools.forma.deps.fleet.ensureFormaLayoutRootTasks /** * The **single supported entry point** for Android project-wide configuration in Forma. @@ -64,6 +65,9 @@ import tools.forma.config.SettingsStore * @param javaVersionCompatibility Java language level for source/target compatibility * @param mandatoryOwners when true, all targets require an owner declaration * @param vectorDrawablesUseSupportLibrary passed through to Android vector drawable config + * @param checkPackageLayoutAtConfiguration when true, fail configuration if a target's + * `packageName` source dir is missing (default false; see [AndroidProjectSettings] and + * docs/FLEET-TOOLING.md). Generate via `formaLayoutGenerate` / `formaLayoutGenerateAll`. * @param extraPlugins list of extra artifacts / plugin providers to add to the **buildscript classpath only**. * See "Classpath vs apply" in TARGET-PLUGINS.md. */ @@ -80,6 +84,7 @@ fun ScriptHandlerScope.androidProjectConfiguration( javaVersionCompatibility: JavaVersion = JavaVersion.VERSION_1_8, // Java/Kotlin configuration mandatoryOwners: Boolean = false, vectorDrawablesUseSupportLibrary: Boolean = false, + checkPackageLayoutAtConfiguration: Boolean = false, extraPlugins: List = emptyList() ) { buildScriptConfiguration( @@ -96,6 +101,8 @@ fun ScriptHandlerScope.androidProjectConfiguration( "clean", Delete::class ) { delete(layout.buildDirectory) } + // Root bulk layout tasks (F-088); per-target tasks wire dependsOn when DSLs register. + ensureFormaLayoutRootTasks() } val configuration = @@ -112,7 +119,8 @@ fun ScriptHandlerScope.androidProjectConfiguration( mandatoryOwners = mandatoryOwners, compose = compose, composeCompilerVersion = composeCompilerVersion, - vectorDrawablesUseSupportLibrary = vectorDrawablesUseSupportLibrary + vectorDrawablesUseSupportLibrary = vectorDrawablesUseSupportLibrary, + checkPackageLayoutAtConfiguration = checkPackageLayoutAtConfiguration, ) Forma.store(configuration) diff --git a/plugins/android/src/main/java/androidTestUtil.kt b/plugins/android/src/main/java/androidTestUtil.kt index 781d1ceb..b35f1b3c 100644 --- a/plugins/android/src/main/java/androidTestUtil.kt +++ b/plugins/android/src/main/java/androidTestUtil.kt @@ -12,6 +12,7 @@ import org.gradle.api.Project import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins import tools.forma.deps.core.FormaDependency +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -22,6 +23,7 @@ fun Project.androidTestUtil( dependencies: FormaDependency = emptyDependency() ) { AndroidTargetRegistry.selfValidator(AndroidTargetTypes.androidTestUtil).asValidator().validate(target) + registerFormaLayout(packageName) val androidFeatureConfig = AndroidLibraryFeatureConfiguration( packageName diff --git a/plugins/android/src/main/java/androidUtil.kt b/plugins/android/src/main/java/androidUtil.kt index 1763517d..96e459ca 100644 --- a/plugins/android/src/main/java/androidUtil.kt +++ b/plugins/android/src/main/java/androidUtil.kt @@ -14,6 +14,7 @@ import tools.forma.android.feature.processorConfigurationFeatures import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins import tools.forma.deps.core.FormaDependency +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -47,6 +48,7 @@ fun Project.androidUtil( //TODO unify with util, use androidJar dependency AndroidTargetRegistry.selfValidator(AndroidTargetTypes.androidUtil).asValidator().validate(target) + registerFormaLayout(packageName) val androidFeatureConfig = AndroidLibraryFeatureConfiguration( packageName = packageName, diff --git a/plugins/android/src/main/java/api.kt b/plugins/android/src/main/java/api.kt index f0005b76..3591f9c2 100644 --- a/plugins/android/src/main/java/api.kt +++ b/plugins/android/src/main/java/api.kt @@ -9,6 +9,7 @@ import org.gradle.api.Project import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins import tools.forma.deps.core.FormaDependency +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -28,6 +29,7 @@ fun Project.api( disallowResources() AndroidTargetRegistry.selfValidator(AndroidTargetTypes.api).asValidator().validate(target) + registerFormaLayout(packageName) applyFeatures( kotlinFeatureDefinition() ) diff --git a/plugins/android/src/main/java/composeWidget.kt b/plugins/android/src/main/java/composeWidget.kt index 4f75bdb1..220b94de 100644 --- a/plugins/android/src/main/java/composeWidget.kt +++ b/plugins/android/src/main/java/composeWidget.kt @@ -11,6 +11,7 @@ import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.NamedDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.validation.asValidator @@ -36,6 +37,7 @@ fun Project.composeWidget( ) { val selfV = AndroidTargetRegistry.selfValidator(AndroidTargetTypes.composeWidget).asValidator() selfV.validate(target) + registerFormaLayout(packageName) val featureConfiguration = AndroidLibraryFeatureConfiguration( packageName = packageName, diff --git a/plugins/android/src/main/java/impl.kt b/plugins/android/src/main/java/impl.kt index e7910e72..130647e8 100644 --- a/plugins/android/src/main/java/impl.kt +++ b/plugins/android/src/main/java/impl.kt @@ -11,6 +11,7 @@ import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.NamedDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -42,6 +43,7 @@ fun Project.impl( val selfV = AndroidTargetRegistry.selfValidator(AndroidTargetTypes.impl).asValidator() selfV.validate(target) + registerFormaLayout(packageName) val libraryFeatureConfiguration = AndroidLibraryFeatureConfiguration( packageName, buildConfiguration, diff --git a/plugins/android/src/main/java/library.kt b/plugins/android/src/main/java/library.kt index 7ed0348b..debb3c94 100644 --- a/plugins/android/src/main/java/library.kt +++ b/plugins/android/src/main/java/library.kt @@ -12,6 +12,7 @@ import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.NamedDependency +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -26,6 +27,7 @@ fun Project.library( testDependencies: NamedDependency = emptyDependency() ) { AndroidTargetRegistry.selfValidator(AndroidTargetTypes.jvmLibrary).asValidator().validate(target) + registerFormaLayout(packageName) applyFeatures( kotlinFeatureDefinition() diff --git a/plugins/android/src/main/java/resourcesTarget.kt b/plugins/android/src/main/java/resourcesTarget.kt index 6d1e4860..b6a92a6a 100644 --- a/plugins/android/src/main/java/resourcesTarget.kt +++ b/plugins/android/src/main/java/resourcesTarget.kt @@ -11,6 +11,7 @@ import tools.forma.core.target.TargetType import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.validation.asValidator @@ -34,6 +35,8 @@ fun Project.resourcesTarget( onlyAllowResources() AndroidTargetRegistry.selfValidator(type).asValidator().validate(target) + // packageName drives AGP namespace; res trees are not package source dirs. + registerFormaLayout(packageName, requirePackageSourceDir = false) val libraryFeatureConfiguration = AndroidLibraryFeatureConfiguration( packageName = packageName, manifestPlaceholders = manifestPlaceholders diff --git a/plugins/android/src/main/java/testUtil.kt b/plugins/android/src/main/java/testUtil.kt index d89ea9c4..9275e840 100644 --- a/plugins/android/src/main/java/testUtil.kt +++ b/plugins/android/src/main/java/testUtil.kt @@ -11,6 +11,7 @@ import org.gradle.api.Project import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins import tools.forma.deps.core.FormaDependency +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -24,6 +25,7 @@ fun Project.testUtil( disallowResources() AndroidTargetRegistry.selfValidator(AndroidTargetTypes.testUtil).asValidator().validate(target) + registerFormaLayout(packageName) applyFeatures( kotlinFeatureDefinition() diff --git a/plugins/android/src/main/java/uiLibrary.kt b/plugins/android/src/main/java/uiLibrary.kt index 477bea6b..40bfc98c 100644 --- a/plugins/android/src/main/java/uiLibrary.kt +++ b/plugins/android/src/main/java/uiLibrary.kt @@ -13,6 +13,7 @@ import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.NamedDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.validation.asValidator @@ -38,6 +39,7 @@ fun Project.uiLibrary( compose: Boolean = Forma.settings.compose, ) { AndroidTargetRegistry.selfValidator(AndroidTargetTypes.uiLibrary).asValidator().validate(target) + registerFormaLayout(packageName) val libraryFeatureConfiguration = AndroidLibraryFeatureConfiguration( packageName, buildConfiguration, diff --git a/plugins/android/src/main/java/util.kt b/plugins/android/src/main/java/util.kt index 89e6ac0d..ac7fcc30 100644 --- a/plugins/android/src/main/java/util.kt +++ b/plugins/android/src/main/java/util.kt @@ -11,6 +11,7 @@ import org.gradle.api.Project import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins import tools.forma.deps.core.FormaDependency +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.validation.asValidator import tools.forma.validation.validate @@ -41,6 +42,7 @@ fun Project.util( disallowResources() AndroidTargetRegistry.selfValidator(AndroidTargetTypes.util).asValidator().validate(target) + registerFormaLayout(packageName) applyFeatures( kotlinFeatureDefinition() diff --git a/plugins/android/src/main/java/viewBinding.kt b/plugins/android/src/main/java/viewBinding.kt index 5ecc4c04..9e3f32fa 100644 --- a/plugins/android/src/main/java/viewBinding.kt +++ b/plugins/android/src/main/java/viewBinding.kt @@ -11,6 +11,7 @@ import tools.forma.android.visibility.Visibility import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.validation.asValidator @@ -37,6 +38,8 @@ fun Project.viewBinding( onlyAllowLayouts() AndroidTargetRegistry.selfValidator(AndroidTargetTypes.viewBinding).asValidator().validate(target) + // Layouts-only; packageName is AGP namespace, not a Kotlin/Java package tree. + registerFormaLayout(packageName, requirePackageSourceDir = false) val libraryFeatureConfiguration = AndroidLibraryFeatureConfiguration( packageName = packageName, consumerMinificationFiles = consumerMinificationFiles, diff --git a/plugins/android/src/main/java/widget.kt b/plugins/android/src/main/java/widget.kt index 0117d7cd..a76d840c 100644 --- a/plugins/android/src/main/java/widget.kt +++ b/plugins/android/src/main/java/widget.kt @@ -11,6 +11,7 @@ import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.NamedDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.validation.asValidator @@ -35,6 +36,7 @@ fun Project.widget( manifestPlaceholders: Map = emptyMap() ) { AndroidTargetRegistry.selfValidator(AndroidTargetTypes.widget).asValidator().validate(target) + registerFormaLayout(packageName) val featureConfiguration = AndroidLibraryFeatureConfiguration( packageName = packageName, diff --git a/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt b/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt index 26773269..5df86b0b 100644 --- a/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt +++ b/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt @@ -46,7 +46,16 @@ data class AndroidProjectSettings( val composeCompilerVersion: String, val vectorDrawablesUseSupportLibrary: Boolean, val javaVersionCompatibility: JavaVersion, // Java/Kotlin configuration - val mandatoryOwners: Boolean + val mandatoryOwners: Boolean, + /** + * When true, each target that registers `packageName` layout metadata fails at + * **configuration time** if `src/main/{kotlin|java}/` is missing under the + * module (F-088). Default **false** so greenfield / sample trees are not forced to + * scaffold dirs before first generate. Prefer explicit tasks `formaLayoutCheck` / + * `formaLayoutGenerate` (and root `*All` variants) for the happy path; turn this on + * in CI or local strict mode only. + */ + val checkPackageLayoutAtConfiguration: Boolean = false, ) /** diff --git a/plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutExtension.kt b/plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutExtension.kt new file mode 100644 index 00000000..fcd82be0 --- /dev/null +++ b/plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutExtension.kt @@ -0,0 +1,24 @@ +package tools.forma.deps.fleet + +import tools.forma.core.fleet.SourceLanguage + +/** + * Per-project layout metadata recorded from target DSLs that take `packageName` (F-088). + * + * Stored as a typed project extension (`formaLayout`) so check/generate tasks and + * bulk root drivers share one source of truth. + */ +open class FormaLayoutExtension { + /** Dotted package from the target DSL (e.g. `com.example.feature.api`). */ + var packageName: String = "" + + /** Source root language; defaults to Kotlin. */ + var language: SourceLanguage = SourceLanguage.KOTLIN + + /** + * When false, [formaLayoutCheck] and [formaLayoutGenerate] are no-op success + * (res / viewBinding / binary use `packageName` for AGP namespace / applicationId only). + * Default true for code targets. + */ + var requirePackageSourceDir: Boolean = true +} diff --git a/plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutTasks.kt b/plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutTasks.kt new file mode 100644 index 00000000..5ef4cae4 --- /dev/null +++ b/plugins/deps/src/main/java/tools/forma/deps/fleet/FormaLayoutTasks.kt @@ -0,0 +1,316 @@ +package tools.forma.deps.fleet + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.tasks.TaskProvider +import tools.forma.config.FormaSettingsStore +import tools.forma.core.fleet.GenerateLayoutRequest +import tools.forma.core.fleet.LayoutChecker +import tools.forma.core.fleet.LayoutGenerator +import tools.forma.core.fleet.PackageLayout +import tools.forma.core.fleet.SourceLanguage +import java.io.File +import java.nio.file.Files + +const val FORMA_LAYOUT_CHECK_TASK = "formaLayoutCheck" +const val FORMA_LAYOUT_GENERATE_TASK = "formaLayoutGenerate" +const val FORMA_LAYOUT_CHECK_ALL_TASK = "formaLayoutCheckAll" +const val FORMA_LAYOUT_GENERATE_ALL_TASK = "formaLayoutGenerateAll" + +private const val LAYOUT_GROUP = "forma layout" +private const val ROOT_AGGREGATE_MARKER = "forma.layout.rootTasksRegistered" +private const val PLACEHOLDER_PROPERTY = "forma.layout.createPlaceholder" + +/** + * One global way to record `packageName` layout intent and expose thin Gradle shells + * over [tools.forma.core.fleet] APIs (F-088 / F-084 follow-up). + * + * Idempotent: safe if a target DSL calls this more than once on the same project. + * + * Registers: + * - extension [FormaLayoutExtension] (`formaLayout`) + * - [FORMA_LAYOUT_CHECK_TASK] / [FORMA_LAYOUT_GENERATE_TASK] on this project + * - root [FORMA_LAYOUT_CHECK_ALL_TASK] / [FORMA_LAYOUT_GENERATE_ALL_TASK] (once) + * + * Optional configuration-time failure when + * [tools.forma.config.AndroidProjectSettings.checkPackageLayoutAtConfiguration] is true + * and Forma Android settings are stored (pure JVM skips the hook). + * + * Check accepts either `src/main/kotlin` or `src/main/java` package trees (sample and + * many Android trees use the java root for Kotlin sources). Generate prefers an + * existing conventional root, else [language] (default KOTLIN). + */ +fun Project.registerFormaLayout( + packageName: String, + language: SourceLanguage = SourceLanguage.KOTLIN, + /** + * When false, check is a no-op success (AGP namespace-only targets: res, viewBinding, + * androidBinary). Generate still scaffolds a package tree if invoked explicitly. + */ + requirePackageSourceDir: Boolean = true, +) { + val existing = extensions.findByType(FormaLayoutExtension::class.java) + if (existing != null) { + // Already registered — keep first metadata; tasks already wired. + return + } + + val extension = + extensions.create("formaLayout", FormaLayoutExtension::class.java).apply { + this.packageName = packageName + this.language = language + this.requirePackageSourceDir = requirePackageSourceDir + } + + // Capture serializable values only (configuration-cache safe task actions). + val moduleDir: File = projectDir + val pkg: String = packageName + val preferredLanguage: SourceLanguage = language + val requireSources: Boolean = requirePackageSourceDir + val createPlaceholder: Boolean = + findProperty(PLACEHOLDER_PROPERTY)?.toString().let { raw -> + when { + raw == null -> false + raw.equals("true", ignoreCase = true) || raw == "1" -> true + else -> false + } + } + + val checkTask = registerLayoutCheckTask(moduleDir, pkg, requireSources) + val generateTask = + registerLayoutGenerateTask( + moduleDir = moduleDir, + packageName = pkg, + preferredLanguage = preferredLanguage, + createPlaceholder = createPlaceholder, + requirePackageSourceDir = requireSources, + ) + ensureRootLayoutAggregateTasks(checkTask, generateTask) + + if (requireSources) { + maybeFailAtConfigurationIfPackageDirMissing(moduleDir, pkg) + } +} + +/** + * Ensures root aggregate tasks exist (e.g. from androidProjectConfiguration before any + * target registers). No-op if already registered. + */ +fun Project.ensureFormaLayoutRootTasks() { + val root = rootProject + if (root.extensions.extraProperties.has(ROOT_AGGREGATE_MARKER)) return + root.extensions.extraProperties.set(ROOT_AGGREGATE_MARKER, true) + root.tasks.register(FORMA_LAYOUT_CHECK_ALL_TASK) { task -> + task.group = LAYOUT_GROUP + task.description = + "Runs formaLayoutCheck on every subproject that registered package layout metadata" + } + root.tasks.register(FORMA_LAYOUT_GENERATE_ALL_TASK) { task -> + task.group = LAYOUT_GROUP + task.description = + "Runs formaLayoutGenerate on every subproject that registered package layout metadata" + } +} + +private fun Project.registerLayoutCheckTask( + moduleDir: File, + packageName: String, + requirePackageSourceDir: Boolean, +): TaskProvider { + if (tasks.names.contains(FORMA_LAYOUT_CHECK_TASK)) { + return tasks.named(FORMA_LAYOUT_CHECK_TASK) + } + return tasks.register(FORMA_LAYOUT_CHECK_TASK) { task -> + task.group = LAYOUT_GROUP + task.description = + "Checks that src/main/{kotlin|java}/ exists (LayoutChecker)" + task.inputs.property("packageName", packageName) + task.inputs.property("moduleDir", moduleDir.path) + task.inputs.property("requirePackageSourceDir", requirePackageSourceDir) + task.doLast { + if (!requirePackageSourceDir) { + logger.lifecycle( + "formaLayoutCheck skipped (packageName is AGP identity only; " + + "no package source dir required) for '$packageName'", + ) + return@doLast + } + val violations = checkPackageDirEitherLanguage(moduleDir, packageName) + if (violations.isNotEmpty()) { + val detail = violations.joinToString("\n") { " $it" } + throw GradleException( + "formaLayoutCheck failed for packageName='$packageName' " + + "under ${moduleDir.path}:\n$detail\n" + + "Scaffold with $FORMA_LAYOUT_GENERATE_TASK or root " + + "$FORMA_LAYOUT_GENERATE_ALL_TASK.", + ) + } + val found = existingPackageRel(moduleDir, packageName) ?: "(present)" + logger.lifecycle("formaLayoutCheck OK: $found") + } + } +} + +private fun Project.registerLayoutGenerateTask( + moduleDir: File, + packageName: String, + preferredLanguage: SourceLanguage, + createPlaceholder: Boolean, + requirePackageSourceDir: Boolean, +): TaskProvider { + if (tasks.names.contains(FORMA_LAYOUT_GENERATE_TASK)) { + return tasks.named(FORMA_LAYOUT_GENERATE_TASK) + } + return tasks.register(FORMA_LAYOUT_GENERATE_TASK) { task -> + task.group = LAYOUT_GROUP + task.description = + "Creates missing package source dirs from packageName (LayoutGenerator)" + task.inputs.property("packageName", packageName) + task.inputs.property("preferredLanguage", preferredLanguage.name) + task.inputs.property("createPlaceholder", createPlaceholder) + task.inputs.property("moduleDir", moduleDir.path) + task.inputs.property("requirePackageSourceDir", requirePackageSourceDir) + task.doLast { + // AGP-identity-only targets: do not scaffold empty kotlin/java trees into res/binary. + if (!requirePackageSourceDir) { + logger.lifecycle( + "formaLayoutGenerate skipped (packageName is AGP identity only) " + + "for '$packageName'", + ) + return@doLast + } + // Already present under either conventional root — no-op. + if (existingPackageRel(moduleDir, packageName) != null) { + logger.lifecycle( + "formaLayoutGenerate: already present for '$packageName'", + ) + return@doLast + } + val language = resolveGenerateLanguage(moduleDir, preferredLanguage) + val result = + LayoutGenerator.apply( + GenerateLayoutRequest( + moduleDir = moduleDir.toPath(), + packageName = packageName, + language = language, + createPlaceholder = createPlaceholder, + ), + ) + if (result.createdDirs.isEmpty() && result.createdFiles.isEmpty()) { + logger.lifecycle( + "formaLayoutGenerate: already present for '$packageName'", + ) + } else { + result.createdDirs.forEach { dir -> + logger.lifecycle("formaLayoutGenerate: created dir $dir") + } + result.createdFiles.forEach { file -> + logger.lifecycle("formaLayoutGenerate: created file $file") + } + } + } + } +} + +private fun Project.ensureRootLayoutAggregateTasks( + checkTask: TaskProvider, + generateTask: TaskProvider, +) { + ensureFormaLayoutRootTasks() + rootProject.tasks.named(FORMA_LAYOUT_CHECK_ALL_TASK).configure { it.dependsOn(checkTask) } + rootProject.tasks.named(FORMA_LAYOUT_GENERATE_ALL_TASK).configure { it.dependsOn(generateTask) } +} + +/** + * Prefer an existing conventional source root so generate matches the module's style; + * otherwise use the DSL preferred language (default KOTLIN). + */ +internal fun resolveGenerateLanguage( + moduleDir: File, + preferred: SourceLanguage, +): SourceLanguage { + val javaRoot = moduleDir.resolve("src/main/java") + val kotlinRoot = moduleDir.resolve("src/main/kotlin") + return when { + javaRoot.isDirectory && !kotlinRoot.isDirectory -> SourceLanguage.JAVA + kotlinRoot.isDirectory && !javaRoot.isDirectory -> SourceLanguage.KOTLIN + else -> preferred + } +} + +/** Relative package path if present under kotlin or java root; null if missing. */ +internal fun existingPackageRel(moduleDir: File, packageName: String): String? { + for (language in listOf(SourceLanguage.KOTLIN, SourceLanguage.JAVA)) { + val rel = PackageLayout.sourceDir(packageName, language) + if (Files.isDirectory(moduleDir.toPath().resolve(rel))) { + return rel + } + } + return null +} + +/** + * Empty list = OK (dir exists under kotlin and/or java). Otherwise human-readable + * violation lines (not core LayoutCheckViolation — either-language policy is Gradle-shell). + */ +internal fun checkPackageDirEitherLanguage( + moduleDir: File, + packageName: String, +): List { + if (existingPackageRel(moduleDir, packageName) != null) return emptyList() + // Surface invalid package via core checker. + val probe = + LayoutChecker.checkPackageSourceDir( + moduleDir.toPath(), + packageName, + SourceLanguage.KOTLIN, + ) + if (probe.any { it.path == packageName }) { + return probe.map { it.message } + } + val kotlinRel = PackageLayout.sourceDir(packageName, SourceLanguage.KOTLIN) + val javaRel = PackageLayout.sourceDir(packageName, SourceLanguage.JAVA) + return listOf( + "missing package source directory for '$packageName' " + + "(expected $kotlinRel or $javaRel under module)", + ) +} + +/** + * Opt-in configuration-time strictness. Reads Android project settings when stored; + * pure JVM (or unset store) skips without inventing a second configuration path. + */ +private fun Project.maybeFailAtConfigurationIfPackageDirMissing( + moduleDir: File, + packageName: String, +) { + val enabled = + try { + FormaSettingsStore.settings.checkPackageLayoutAtConfiguration + } catch (_: UninitializedPropertyAccessException) { + false + } catch (_: Throwable) { + false + } + if (!enabled) return + + val violations = checkPackageDirEitherLanguage(moduleDir, packageName) + if (violations.isEmpty()) return + + val detail = violations.joinToString("; ") + logger.error( + "Forma layout: missing package source dir for project '{}' packageName='{}'. " + + "Enable is opt-in via androidProjectConfiguration(" + + "checkPackageLayoutAtConfiguration = true). Scaffold with {} or {}. Detail: {}", + path, + packageName, + FORMA_LAYOUT_GENERATE_TASK, + FORMA_LAYOUT_GENERATE_ALL_TASK, + detail, + ) + throw GradleException( + "checkPackageLayoutAtConfiguration: $detail " + + "(project ${path}, packageName='$packageName')", + ) +} diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/api.kt b/plugins/jvm/src/main/java/tools/forma/jvm/api.kt index dc55f2e0..cf14954f 100644 --- a/plugins/jvm/src/main/java/tools/forma/jvm/api.kt +++ b/plugins/jvm/src/main/java/tools/forma/jvm/api.kt @@ -4,6 +4,7 @@ import org.gradle.api.Project import tools.forma.deps.core.EmptyDependency import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.jvm.feature.applyKotlinJvm import tools.forma.jvm.target.JvmTargetRegistry import tools.forma.jvm.target.JvmTargetTypes @@ -29,6 +30,7 @@ fun Project.api( JvmTargetRegistry.selfValidator(JvmTargetTypes.api).asValidator() .validate(FormaTarget(this)) + registerFormaLayout(packageName) applyKotlinJvm() diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/binary.kt b/plugins/jvm/src/main/java/tools/forma/jvm/binary.kt index 2354c917..2c1bb97e 100644 --- a/plugins/jvm/src/main/java/tools/forma/jvm/binary.kt +++ b/plugins/jvm/src/main/java/tools/forma/jvm/binary.kt @@ -7,6 +7,7 @@ import org.gradle.kotlin.dsl.configure import tools.forma.deps.core.EmptyDependency import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.jvm.feature.applyKotlinJvm import tools.forma.jvm.target.JvmTargetRegistry import tools.forma.jvm.target.JvmTargetTypes @@ -35,6 +36,7 @@ fun Project.binary( JvmTargetRegistry.selfValidator(JvmTargetTypes.binary).asValidator() .validate(FormaTarget(this)) + registerFormaLayout(packageName) applyKotlinJvm() apply(plugin = "application") diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/impl.kt b/plugins/jvm/src/main/java/tools/forma/jvm/impl.kt index 34430bd3..4361195d 100644 --- a/plugins/jvm/src/main/java/tools/forma/jvm/impl.kt +++ b/plugins/jvm/src/main/java/tools/forma/jvm/impl.kt @@ -4,6 +4,7 @@ import org.gradle.api.Project import tools.forma.deps.core.EmptyDependency import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.jvm.feature.applyKotlinJvm import tools.forma.jvm.target.JvmTargetRegistry import tools.forma.jvm.target.JvmTargetTypes @@ -28,6 +29,7 @@ fun Project.impl( JvmTargetRegistry.selfValidator(JvmTargetTypes.impl).asValidator() .validate(FormaTarget(this)) + registerFormaLayout(packageName) applyKotlinJvm() diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/library.kt b/plugins/jvm/src/main/java/tools/forma/jvm/library.kt index 8f8ad6be..ff3a16a5 100644 --- a/plugins/jvm/src/main/java/tools/forma/jvm/library.kt +++ b/plugins/jvm/src/main/java/tools/forma/jvm/library.kt @@ -4,6 +4,7 @@ import org.gradle.api.Project import tools.forma.deps.core.EmptyDependency import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.jvm.feature.applyKotlinJvm import tools.forma.jvm.target.JvmTargetRegistry import tools.forma.jvm.target.JvmTargetTypes @@ -28,6 +29,7 @@ fun Project.library( JvmTargetRegistry.selfValidator(JvmTargetTypes.library).asValidator() .validate(FormaTarget(this)) + registerFormaLayout(packageName) applyKotlinJvm() diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/testUtil.kt b/plugins/jvm/src/main/java/tools/forma/jvm/testUtil.kt index 67886179..091b9da2 100644 --- a/plugins/jvm/src/main/java/tools/forma/jvm/testUtil.kt +++ b/plugins/jvm/src/main/java/tools/forma/jvm/testUtil.kt @@ -4,6 +4,7 @@ import org.gradle.api.Project import tools.forma.deps.core.EmptyDependency import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.jvm.feature.applyKotlinJvm import tools.forma.jvm.target.JvmTargetRegistry import tools.forma.jvm.target.JvmTargetTypes @@ -27,6 +28,7 @@ fun Project.testUtil( JvmTargetRegistry.selfValidator(JvmTargetTypes.testUtil).asValidator() .validate(FormaTarget(this)) + registerFormaLayout(packageName) applyKotlinJvm() diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/util.kt b/plugins/jvm/src/main/java/tools/forma/jvm/util.kt index 365be39c..29b046b8 100644 --- a/plugins/jvm/src/main/java/tools/forma/jvm/util.kt +++ b/plugins/jvm/src/main/java/tools/forma/jvm/util.kt @@ -4,6 +4,7 @@ import org.gradle.api.Project import tools.forma.deps.core.EmptyDependency import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies +import tools.forma.deps.fleet.registerFormaLayout import tools.forma.jvm.feature.applyKotlinJvm import tools.forma.jvm.target.JvmTargetRegistry import tools.forma.jvm.target.JvmTargetTypes @@ -28,6 +29,7 @@ fun Project.util( JvmTargetRegistry.selfValidator(JvmTargetTypes.util).asValidator() .validate(FormaTarget(this)) + registerFormaLayout(packageName) applyKotlinJvm()