From a096fa53fa654fd640ecfd74650d77c257d3ed7a Mon Sep 17 00:00:00 2001 From: Claw Date: Mon, 20 Jul 2026 18:58:35 -0700 Subject: [PATCH] F-084: fleet check/generate/migrate toolkit v1 Pure tools.forma.core.fleet helpers (layout check/generate, path forms, migrate planner) + docs/FLEET-TOOLING.md and agent skill. GH #54 package layout covered by unit tests. --- README.md | 2 +- TICKETS.md | 6 +- docs/ARCHITECTURE.md | 2 +- docs/FLEET-TOOLING.md | 145 +++++++++++++++ docs/GETTING-STARTED.md | 3 +- docs/PROGRESS.md | 22 +++ docs/PROGRESSIVE-EXAMPLES.md | 1 + docs/VISION.md | 2 +- examples/agent-skills/README.md | 1 + examples/agent-skills/forma-fleet-tooling.md | 54 ++++++ plugins/core/build.gradle.kts | 3 +- .../tools/forma/core/fleet/LayoutChecker.kt | 49 +++++ .../tools/forma/core/fleet/LayoutGenerator.kt | 97 ++++++++++ .../tools/forma/core/fleet/MigratePlanner.kt | 69 +++++++ .../tools/forma/core/fleet/PackageLayout.kt | 43 +++++ .../forma/core/fleet/ProjectPathForms.kt | 59 ++++++ .../tools/forma/core/fleet/SourceLanguage.kt | 10 + .../forma/core/fleet/FleetToolkitTest.kt | 171 ++++++++++++++++++ 18 files changed, 731 insertions(+), 8 deletions(-) create mode 100644 docs/FLEET-TOOLING.md create mode 100644 examples/agent-skills/forma-fleet-tooling.md create mode 100644 plugins/core/src/main/java/tools/forma/core/fleet/LayoutChecker.kt create mode 100644 plugins/core/src/main/java/tools/forma/core/fleet/LayoutGenerator.kt create mode 100644 plugins/core/src/main/java/tools/forma/core/fleet/MigratePlanner.kt create mode 100644 plugins/core/src/main/java/tools/forma/core/fleet/PackageLayout.kt create mode 100644 plugins/core/src/main/java/tools/forma/core/fleet/ProjectPathForms.kt create mode 100644 plugins/core/src/main/java/tools/forma/core/fleet/SourceLanguage.kt create mode 100644 plugins/core/src/test/java/tools/forma/core/fleet/FleetToolkitTest.kt diff --git a/README.md b/README.md index 288a32f9..e8aacf13 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) · [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/migrate, F-084) · [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 f32ba203..cd8bddb1 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -100,8 +100,8 @@ Close the biggest call-site / multi-way gap: chain `withPlugin`. Design: ## P8 — Principle alignment (implementation matches goals) Close remaining gaps where code/docs still allow **multiple ways**, **fat call -sites**, or **missing fleet tooling**. P7 (F-070–F-073) and F-081–F-083 are **done**; **F-019 Phase 1 done**. -**Next coding:** **F-084** +sites**, or **missing fleet tooling**. P7 (F-070–F-073) and F-081–F-084 are **done**; **F-019 Phase 1 done**. +**Next coding:** **F-085** | ID | Status | Title | Notes | |----|--------|-------|-------| @@ -109,7 +109,7 @@ sites**, or **missing fleet tooling**. P7 (F-070–F-073) and F-081–F-083 are | F-081 | done | Call-site surface audit: `Unit` returns, no builder chains, minimal attrs | Hard-removed `TargetBuilder` / `PluginWrapper` / sample `Plugins` + chain-only `PluginConfiguration`; all Android/JVM DSLs already `Unit`; `docs/CALL-SITE-SURFACE.md` flag inventory (`compose` → project-global default, `viewBinding` on impl + dedicated type) | | F-082 | done | One global configuration path | Hard-removed `Project.androidProjectConfiguration`; `ScriptHandlerScope` form is the single API; `extraPlugins` documented classpath-only; new `PROJECT-CONFIGURATION.md` + cross-links; builds green | | F-083 | done | One project-global external-deps convention | **House style = `projectDependencies` → `libs.*`**. Typed `build-dependencies/` catalogs = advanced/sample-scale only (not dual happy path). `DEPS-CATALOG.md` + README/GETTING-STARTED/agent skill/08 README | -| F-084 | todo | Fleet tooling for explicit graphs (check / generate / migrate) | Principle 3 at scale: tighten includer/depgen/bazel-check; design+spike migrate helpers for renames/type changes; related GH **#54**. Reliable large refactors without hand-editing thousands of modules | +| F-084 | done | Fleet tooling for explicit graphs (check / generate / migrate) | **v1 in `tools.forma.core.fleet`:** `LayoutChecker` / `LayoutGenerator` / `MigratePlanner` / path forms; `docs/FLEET-TOOLING.md` + agent skill; GH **#54** package→dir covered. Follow-ups: Gradle tasks, AST migrate, depgen — listed in FLEET-TOOLING.md | | F-085 | todo | Full-tree principle audit: sample + examples + agent skills | After F-072/F-081: no chain APIs, no dual styles taught, call sites minimal; fix stragglers | ## Backlog (lower priority / historical GitHub) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58e90bd8..11564cfc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ Living map of the monorepo build graph, plugin modules, sample app, CI, and what should become **forma-core**. Source of truth for F-002; update when the graph changes. -Last audited: **2026-07-19** (F-018 8.x terminal toolchain on `v2`). +Last audited: **2026-07-20** (F-084 fleet toolkit in `tools.forma.core.fleet`; toolchain Gradle 9.6.1). --- diff --git a/docs/FLEET-TOOLING.md b/docs/FLEET-TOOLING.md new file mode 100644 index 00000000..51829656 --- /dev/null +++ b/docs/FLEET-TOOLING.md @@ -0,0 +1,145 @@ +# Fleet tooling — check / generate / migrate (F-084) + +**Status:** v1 toolkit shipped in `tools.forma:core` (`tools.forma.core.fleet`). +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 +**tooling** can verify and reshape the graph without hand-editing every path. + +## Problem + +- Renames, type migrations, and new targets touch filesystem layout, Gradle + project names (includer), Forma `target(":…")` refs, and `packageName` source + roots together. +- Runtime validators (`ContentRule`, dependency matrix) catch some mistakes + **after** configuration — too late for bulk refactors and greenfield scaffolding. +- GH #54: from a minimal declaration such as + + ```kotlin + api( + packageName = "com.stepango.blockme.character.list.api", + owner = Teams.core, + ) + ``` + + the package directory `src/main/kotlin/com/stepango/blockme/character/list/api` + should be **generatable**, not hand-created. + +## 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` | + +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 +path rules. + +## Tool map + +| Tool | Role today | Notes | +|------|------------|--------| +| **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 | +| **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 | + +## Path conventions (source of truth) + +| Form | Example | Producer | +|------|---------|----------| +| 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 | + +Helpers: `ProjectPathForms`, `PackageLayout`. + +**Caveat:** reversing `:feature-home-impl` → `feature/home/impl` is **ambiguous** +when a path segment itself contains `-`. Prefer the filesystem relative path as +the canonical key for migrate plans. + +## API (forma-core) + +Package: `tools.forma.core.fleet` + +```kotlin +// Package → directories +PackageLayout.segments("com.foo.bar") +PackageLayout.sourceDir("com.foo.bar") // src/main/kotlin/com/foo/bar +PackageLayout.sourceDir("com.foo.bar", SourceLanguage.JAVA) + +// Path forms (includer-aligned) +ProjectPathForms.gradleProjectPath("feature/home/impl") // :feature-home-impl +ProjectPathForms.formaTargetPath("feature/home/impl") // :feature:home:impl + +// Generate (GH #54) +LayoutGenerator.apply( + GenerateLayoutRequest( + moduleDir = modulePath, + packageName = "com.stepango.blockme.character.list.api", + language = SourceLanguage.KOTLIN, + createPlaceholder = true, // optional .gitkeep + ), +) + +// Check +LayoutChecker.checkPackageSourceDir(modulePath, "com.foo.bar") // empty = OK + +// Migrate plan (no IO) +MigratePlanner.planRename( + PathRenamePlan("feature/home/impl", "feature/dashboard/impl"), +) +// → filesystemMoves + referenceRewrites (:gradle, :forma, target(), project()) +``` + +### Using from a one-off script / future task + +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. + +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. + +## What v1 ships vs follow-ups + +**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)** + +- 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) + +- 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 + +## Agent skill + +See [`examples/agent-skills/forma-fleet-tooling.md`](../examples/agent-skills/forma-fleet-tooling.md). + +## Verify + +```bash +source scripts/env-mac.sh +cd plugins && ./gradlew :core:test +# or full +./gradlew build +``` diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 43a6e564..b0350766 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -8,7 +8,8 @@ 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. +explicit structure, with tooling for large-scale change +([`FLEET-TOOLING.md`](FLEET-TOOLING.md) — check / generate / migrate). 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). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 1cc3ac42..78c027d6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,28 @@ Newest entries first. +## 2026-07-20 — F-084: fleet tooling check/generate/migrate v1 + +- **Ticket:** F-084 → `done` (v1 toolkit; follow-ups listed in `docs/FLEET-TOOLING.md`) +- **Branch:** `forma/F-084-fleet-tooling` (from origin/v2) +- **Skills/modes:** Grok Build `--mode full` design 503 + implement hang (no tree changes); Hermes finish path for pure-core + docs slice +- **Code (`plugins/core` `tools.forma.core.fleet`):** + - `PackageLayout` / `SourceLanguage` — packageName → `src/main/{kotlin|java}/…` + - `ProjectPathForms` — includer `:feature-home-impl` ↔ Forma `:feature:home:impl` + - `LayoutGenerator` plan/apply (GH #54) + optional `.gitkeep` + - `LayoutChecker` — missing package dir violations + - `MigratePlanner` — path rename + suggested reference rewrites (not AST) + - Unit tests: `FleetToolkitTest` (GH #54 package, idempotent apply, path forms, migrate) +- **Docs / teaching:** + - New `docs/FLEET-TOOLING.md` (modes, tool map, API, follow-ups) + - VISION principle 3 link; README + GETTING-STARTED; ARCHITECTURE audit stamp + - Agent skill `forma-fleet-tooling` + skills README + PROGRESSIVE-EXAMPLES row +- **Out of scope (documented follow-ups):** Gradle tasks, AST migrate, depgen finish, config-time package hook +- **Verify (real host, `source scripts/env-mac.sh`):** + - `plugins/ ./gradlew :core:test build` → **BUILD SUCCESSFUL** (78 tasks; core fleet unit tests green) +- **Blockers:** none for v1 +- **Next:** F-085 full-tree principle audit + ## 2026-07-20 — F-083: one external-deps house style - **Ticket:** F-083 → `done` diff --git a/docs/PROGRESSIVE-EXAMPLES.md b/docs/PROGRESSIVE-EXAMPLES.md index 9ef2aba2..7754375f 100644 --- a/docs/PROGRESSIVE-EXAMPLES.md +++ b/docs/PROGRESSIVE-EXAMPLES.md @@ -31,6 +31,7 @@ narrative tutorials when you want a **minimal buildable project per concept**. | `testUtil` | 09 | 05 | platform skills | | `androidTestUtil` | 09 | — | forma-android-targets | | Type-owned target plugins (`navigationRes` Path B) | 10 | — | forma-target-plugins | +| Fleet check/generate/migrate (`tools.forma.core.fleet`) | docs | docs | forma-fleet-tooling | | `androidNative` | skill/docs | — | forma-android-targets | | Bazel adapter / sample | skill | skill | forma-bazel | diff --git a/docs/VISION.md b/docs/VISION.md index bbc70daf..64bc7b58 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -20,7 +20,7 @@ README meta-build pitch; stated here so they cannot drift. 3. **Everything is explicit** — Structure and boundaries are declared, not implied by hidden convention soup. Tradeoff: more configuration at the **type / project** layer. Remedy: **tooling** so large-scale changes stay easy and reliable - (check / generate / migrate — includer, depgen, adapters, future fleet tools). + (check / generate / migrate — includer, adapters, [`FLEET-TOOLING.md`](FLEET-TOOLING.md)). ### Consequences (do not treat as separate products) diff --git a/examples/agent-skills/README.md b/examples/agent-skills/README.md index 9799ab75..cb9249f1 100644 --- a/examples/agent-skills/README.md +++ b/examples/agent-skills/README.md @@ -14,6 +14,7 @@ matrix rules, and progressive example pointers. | [forma-compose](forma-compose.md) | Jetpack Compose | | [forma-includer-settings](forma-includer-settings.md) | settings.gradle / includer / composite | | [forma-project-layout](forma-project-layout.md) | Feature folders, packages, composition roots | +| [forma-fleet-tooling](forma-fleet-tooling.md) | check/generate/migrate layout + path forms (F-084) | | [forma-bazel](forma-bazel.md) | Bazel adapter / sample | **Hard rules agents must never violate** diff --git a/examples/agent-skills/forma-fleet-tooling.md b/examples/agent-skills/forma-fleet-tooling.md new file mode 100644 index 00000000..fc4f11ad --- /dev/null +++ b/examples/agent-skills/forma-fleet-tooling.md @@ -0,0 +1,54 @@ +--- +name: forma-fleet-tooling +description: Check/generate/migrate package layout and path forms (F-084 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). + +## 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) + +## Rules + +1. **One way:** use `PackageLayout` / `LayoutGenerator` / `LayoutChecker` / `MigratePlanner` — do not invent alternate path math. +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. +5. Still obey matrix / `impl` ↛ `impl` / no `androidLibrary` / type-owned plugins. + +## Snippets + +```kotlin +import tools.forma.core.fleet.* + +// GH #54 generate +LayoutGenerator.apply( + GenerateLayoutRequest( + moduleDir = Path.of("feature/character/list/api"), + packageName = "com.stepango.blockme.character.list.api", + createPlaceholder = true, + ), +) + +// check +val violations = LayoutChecker.checkPackageSourceDir(moduleDir, packageName) +check(violations.isEmpty()) { violations.joinToString { it.message } } + +// rename plan +val plan = MigratePlanner.planRename( + PathRenamePlan("feature/home/impl", "feature/dashboard/impl"), +) +// plan.filesystemMoves, plan.referenceRewrites +``` + +## Related skills + +- [forma-includer-settings](forma-includer-settings.md) — discovery / include +- [forma-project-layout](forma-project-layout.md) — feature folders +- [forma-android-targets](forma-android-targets.md) / [forma-jvm-targets](forma-jvm-targets.md) diff --git a/plugins/core/build.gradle.kts b/plugins/core/build.gradle.kts index aafa369a..d5e78a22 100644 --- a/plugins/core/build.gradle.kts +++ b/plugins/core/build.gradle.kts @@ -32,7 +32,8 @@ publishing { name.set("forma-core") description.set( "Forma core library (not a Gradle plugin): TargetType, RestrictionGraph, " + - "TargetValidator / ContentRule, TargetRegistry. Co-versioned with tools.forma.* plugins." + "TargetValidator / ContentRule, TargetRegistry, fleet check/generate/migrate helpers. " + + "Co-versioned with tools.forma.* plugins." ) url.set("https://forma.tools/") scm { diff --git a/plugins/core/src/main/java/tools/forma/core/fleet/LayoutChecker.kt b/plugins/core/src/main/java/tools/forma/core/fleet/LayoutChecker.kt new file mode 100644 index 00000000..f0fafe28 --- /dev/null +++ b/plugins/core/src/main/java/tools/forma/core/fleet/LayoutChecker.kt @@ -0,0 +1,49 @@ +package tools.forma.core.fleet + +import java.nio.file.Files +import java.nio.file.Path + +data class LayoutCheckViolation( + val path: String, + val message: String, +) + +/** + * Check mode for package source layout (F-084). Pure relative to a module directory. + * Complements runtime [tools.forma.core.validation.ContentRule] checks. + */ +object LayoutChecker { + + /** + * Verify that `src/main/{java|kotlin}/` exists under [moduleDir]. + */ + fun checkPackageSourceDir( + moduleDir: Path, + packageName: String, + language: SourceLanguage = SourceLanguage.KOTLIN, + ): List { + val violations = mutableListOf() + val rel = + try { + PackageLayout.sourceDir(packageName, language) + } catch (e: IllegalArgumentException) { + return listOf( + LayoutCheckViolation( + path = packageName, + message = e.message ?: "invalid packageName", + ), + ) + } + val dir = moduleDir.resolve(rel) + if (!Files.isDirectory(dir)) { + violations += + LayoutCheckViolation( + path = rel, + message = + "missing package source directory for '$packageName' " + + "(expected $rel under module)", + ) + } + return violations + } +} diff --git a/plugins/core/src/main/java/tools/forma/core/fleet/LayoutGenerator.kt b/plugins/core/src/main/java/tools/forma/core/fleet/LayoutGenerator.kt new file mode 100644 index 00000000..2b735b09 --- /dev/null +++ b/plugins/core/src/main/java/tools/forma/core/fleet/LayoutGenerator.kt @@ -0,0 +1,97 @@ +package tools.forma.core.fleet + +import java.nio.file.Files +import java.nio.file.Path + +data class GenerateLayoutRequest( + val moduleDir: Path, + val packageName: String, + val language: SourceLanguage = SourceLanguage.KOTLIN, + /** When true, write an empty `.gitkeep` under the package dir if no files exist there. */ + val createPlaceholder: Boolean = false, +) + +data class GenerateLayoutResult( + val createdDirs: List, + val createdFiles: List, + val alreadyExisted: List, +) + +/** + * Create (or plan) package source directories under a module root — GH #54 / F-084 generate mode. + */ +object LayoutGenerator { + + /** Pure plan: paths that would be created; does not touch the filesystem. */ + fun plan(request: GenerateLayoutRequest): GenerateLayoutResult { + val packageDir = request.moduleDir.resolve(PackageLayout.sourceDir(request.packageName, request.language)) + val dirs = mutableListOf() + var cursor = request.moduleDir + val relative = request.moduleDir.relativize(packageDir) + for (i in 0 until relative.nameCount) { + cursor = cursor.resolve(relative.getName(i)) + dirs.add(cursor) + } + val files = + if (request.createPlaceholder) { + listOf(packageDir.resolve(".gitkeep")) + } else { + emptyList() + } + // plan mode cannot know existence without IO — report all as "to create" + return GenerateLayoutResult( + createdDirs = dirs, + createdFiles = files, + alreadyExisted = emptyList(), + ) + } + + /** Create missing directories (and optional placeholder). Idempotent. */ + fun apply(request: GenerateLayoutRequest): GenerateLayoutResult { + val packageRel = PackageLayout.sourceDir(request.packageName, request.language) + val moduleRoot = request.moduleDir.toAbsolutePath().normalize() + val packageDir = moduleRoot.resolve(packageRel).normalize() + require(packageDir.startsWith(moduleRoot)) { + "resolved package dir escapes moduleDir" + } + + val createdDirs = mutableListOf() + val alreadyExisted = mutableListOf() + val createdFiles = mutableListOf() + + var cursor = moduleRoot + val relative = moduleRoot.relativize(packageDir) + for (i in 0 until relative.nameCount) { + cursor = cursor.resolve(relative.getName(i)) + if (Files.isDirectory(cursor)) { + alreadyExisted.add(cursor) + } else { + Files.createDirectories(cursor) + createdDirs.add(cursor) + } + } + + if (request.createPlaceholder) { + val gitkeep = packageDir.resolve(".gitkeep") + if (Files.exists(gitkeep)) { + alreadyExisted.add(gitkeep) + } else { + // only add placeholder if directory has no other entries + val hasContent = + Files.list(packageDir).use { stream -> + stream.anyMatch { it.fileName.toString() != ".gitkeep" } + } + if (!hasContent) { + Files.writeString(gitkeep, "") + createdFiles.add(gitkeep) + } + } + } + + return GenerateLayoutResult( + createdDirs = createdDirs, + createdFiles = createdFiles, + alreadyExisted = alreadyExisted, + ) + } +} diff --git a/plugins/core/src/main/java/tools/forma/core/fleet/MigratePlanner.kt b/plugins/core/src/main/java/tools/forma/core/fleet/MigratePlanner.kt new file mode 100644 index 00000000..66d7ce19 --- /dev/null +++ b/plugins/core/src/main/java/tools/forma/core/fleet/MigratePlanner.kt @@ -0,0 +1,69 @@ +package tools.forma.core.fleet + +/** + * Filesystem-relative module path rename (no IO) — F-084 migrate spike. + * + * Does **not** rewrite build script ASTs; emits suggested string replacements for + * Gradle project paths and Forma `target("…")` forms so fleet tools / humans can apply. + */ +data class PathRenamePlan( + /** e.g. `feature/home/impl` */ + val fromModuleRelative: String, + /** e.g. `feature/home/impl2` or `feature/dashboard/impl` */ + val toModuleRelative: String, +) + +data class MigrationPlan( + val filesystemMoves: List>, + /** Suggested textual replacements (old → new) for refs in scripts/docs. */ + val referenceRewrites: List>, + val notes: List, +) + +object MigratePlanner { + + fun planRename(plan: PathRenamePlan): MigrationPlan { + val from = normalize(plan.fromModuleRelative) + val to = normalize(plan.toModuleRelative) + require(from.isNotEmpty()) { "fromModuleRelative must not be empty" } + require(to.isNotEmpty()) { "toModuleRelative must not be empty" } + require(from != to) { "from and to paths must differ" } + + val fromGradle = ProjectPathForms.gradleProjectPath(from) + val toGradle = ProjectPathForms.gradleProjectPath(to) + val fromForma = ProjectPathForms.formaTargetPath(from) + val toForma = ProjectPathForms.formaTargetPath(to) + + val rewrites = + listOf( + fromGradle to toGradle, + fromForma to toForma, + // common target() call spellings + "target(\"$fromForma\")" to "target(\"$toForma\")", + "project(\"$fromGradle\")" to "project(\"$toGradle\")", + ) + + val notes = + listOf( + "Filesystem move is module-dir only; update packageName / sources separately if the package changes.", + "fromGradleProjectPath is ambiguous when segments contain '-'; prefer relative dirs as source of truth.", + "Apply referenceRewrites with review — string replace is not AST-safe.", + "Run LayoutChecker / Forma validators after the move.", + ) + + return MigrationPlan( + filesystemMoves = listOf(from to to), + referenceRewrites = rewrites, + notes = notes, + ) + } + + private fun normalize(relativeDir: String): String { + var s = relativeDir.trim().replace('\\', '/') + while (s.startsWith("./")) s = s.removePrefix("./") + while (s.startsWith("/")) s = s.removePrefix("/") + while (s.endsWith("/")) s = s.removeSuffix("/") + while (s.contains("//")) s = s.replace("//", "/") + return s + } +} diff --git a/plugins/core/src/main/java/tools/forma/core/fleet/PackageLayout.kt b/plugins/core/src/main/java/tools/forma/core/fleet/PackageLayout.kt new file mode 100644 index 00000000..fd069e9b --- /dev/null +++ b/plugins/core/src/main/java/tools/forma/core/fleet/PackageLayout.kt @@ -0,0 +1,43 @@ +package tools.forma.core.fleet + +/** + * Pure helpers for JVM/Android package → directory layout (F-084). + * No Gradle APIs — safe for forma-core consumers and offline tools. + */ +object PackageLayout { + + private val segmentPattern = Regex("^[A-Za-z_][A-Za-z0-9_]*$") + + /** + * Split a dotted package name into path segments. + * @throws IllegalArgumentException if empty or any segment is invalid + */ + fun segments(packageName: String): List { + val trimmed = packageName.trim() + require(trimmed.isNotEmpty()) { "packageName must not be empty" } + val parts = trimmed.split('.') + require(parts.none { it.isEmpty() }) { + "packageName must not contain empty segments: '$packageName'" + } + parts.forEach { part -> + require(segmentPattern.matches(part)) { + "invalid package segment '$part' in '$packageName'" + } + } + return parts + } + + /** + * Relative source directory for [packageName], e.g. + * `src/main/kotlin/com/foo/bar` or `src/main/java/com/foo/bar`. + */ + fun sourceDir( + packageName: String, + language: SourceLanguage = SourceLanguage.KOTLIN, + ): String { + val segs = segments(packageName) + return listOf("src", "main", language.sourceSetDir) + .plus(segs) + .joinToString("/") + } +} diff --git a/plugins/core/src/main/java/tools/forma/core/fleet/ProjectPathForms.kt b/plugins/core/src/main/java/tools/forma/core/fleet/ProjectPathForms.kt new file mode 100644 index 00000000..2b19d19a --- /dev/null +++ b/plugins/core/src/main/java/tools/forma/core/fleet/ProjectPathForms.kt @@ -0,0 +1,59 @@ +package tools.forma.core.fleet + +/** + * Path form conversions matching tools.forma.includer conventions (F-084). + * + * - Filesystem relative dir: `feature/home/impl` + * - Gradle project path (includer): `:feature-home-impl` + * - Forma target ref style: `:feature:home:impl` + */ +object ProjectPathForms { + + /** + * `feature/home/impl` → `:feature-home-impl` + * Accepts optional leading `./` and normalizes separators to `/` then `-`. + */ + fun gradleProjectPath(relativeDir: String): String { + val normalized = normalizeRelative(relativeDir) + require(normalized.isNotEmpty()) { "relativeDir must not be empty" } + return ":${normalized.replace('/', '-')}" + } + + /** + * `feature/home/impl` → `:feature:home:impl` + */ + fun formaTargetPath(relativeDir: String): String { + val normalized = normalizeRelative(relativeDir) + require(normalized.isNotEmpty()) { "relativeDir must not be empty" } + return ":${normalized.replace('/', ':')}" + } + + /** + * Best-effort reverse of [gradleProjectPath]: `:feature-home-impl` → `feature/home/impl`. + * + * **Ambiguous** when path segments themselves contain `-` (includer joins with `-`). + * Prefer keeping the filesystem relative path as source of truth when available. + */ + fun fromGradleProjectPath(gradlePath: String): String { + val trimmed = gradlePath.trim() + require(trimmed.startsWith(":")) { + "gradle project path must start with ':': '$gradlePath'" + } + val body = trimmed.removePrefix(":") + require(body.isNotEmpty()) { "gradle project path body must not be empty" } + require(!body.contains('/')) { + "expected includer-style path without '/': '$gradlePath'" + } + return body.replace('-', '/') + } + + private fun normalizeRelative(relativeDir: String): String { + var s = relativeDir.trim().replace('\\', '/') + while (s.startsWith("./")) s = s.removePrefix("./") + while (s.startsWith("/")) s = s.removePrefix("/") + while (s.endsWith("/")) s = s.removeSuffix("/") + // collapse duplicate slashes + while (s.contains("//")) s = s.replace("//", "/") + return s + } +} diff --git a/plugins/core/src/main/java/tools/forma/core/fleet/SourceLanguage.kt b/plugins/core/src/main/java/tools/forma/core/fleet/SourceLanguage.kt new file mode 100644 index 00000000..d293ac4d --- /dev/null +++ b/plugins/core/src/main/java/tools/forma/core/fleet/SourceLanguage.kt @@ -0,0 +1,10 @@ +package tools.forma.core.fleet + +/** + * Source root language under a module (GH #54 / F-084 layout tooling). + * Maps to the conventional Android/JVM source set directory name. + */ +enum class SourceLanguage(val sourceSetDir: String) { + JAVA("java"), + KOTLIN("kotlin"), +} diff --git a/plugins/core/src/test/java/tools/forma/core/fleet/FleetToolkitTest.kt b/plugins/core/src/test/java/tools/forma/core/fleet/FleetToolkitTest.kt new file mode 100644 index 00000000..552ddeb5 --- /dev/null +++ b/plugins/core/src/test/java/tools/forma/core/fleet/FleetToolkitTest.kt @@ -0,0 +1,171 @@ +package tools.forma.core.fleet + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PackageLayoutTest { + + @Test + fun `segments splits dotted package`() { + assertEquals( + listOf("com", "stepango", "blockme", "character", "list", "api"), + PackageLayout.segments("com.stepango.blockme.character.list.api"), + ) + } + + @Test + fun `sourceDir kotlin default matches GH-54 layout`() { + val expected = + "src/main/kotlin/com/stepango/blockme/character/list/api" + assertEquals( + expected, + PackageLayout.sourceDir("com.stepango.blockme.character.list.api"), + ) + } + + @Test + fun `sourceDir java language`() { + assertEquals( + "src/main/java/com/foo/bar", + PackageLayout.sourceDir("com.foo.bar", SourceLanguage.JAVA), + ) + } + + @Test + fun `empty package rejected`() { + assertFailsWith { PackageLayout.segments("") } + assertFailsWith { PackageLayout.segments(" ") } + } + + @Test + fun `empty segment rejected`() { + assertFailsWith { PackageLayout.segments("com..foo") } + } + + @Test + fun `invalid segment rejected`() { + assertFailsWith { PackageLayout.segments("com.foo-bar.baz") } + assertFailsWith { PackageLayout.segments("com.1foo") } + } +} + +class ProjectPathFormsTest { + + @Test + fun `gradle and forma path forms`() { + assertEquals(":feature-home-impl", ProjectPathForms.gradleProjectPath("feature/home/impl")) + assertEquals(":feature:home:impl", ProjectPathForms.formaTargetPath("feature/home/impl")) + } + + @Test + fun `normalizes slashes and dots`() { + assertEquals(":feature-home-api", ProjectPathForms.gradleProjectPath("./feature/home/api/")) + assertEquals(":a:b", ProjectPathForms.formaTargetPath("\\a\\b")) + } + + @Test + fun `fromGradleProjectPath best effort reverse`() { + assertEquals( + "feature/home/impl", + ProjectPathForms.fromGradleProjectPath(":feature-home-impl"), + ) + } + + @Test + fun `empty relative rejected`() { + assertFailsWith { ProjectPathForms.gradleProjectPath("") } + } +} + +class LayoutGeneratorCheckerTest { + + @Test + fun `apply creates package dirs idempotently`() { + val root = Files.createTempDirectory("forma-fleet-") + try { + val pkg = "com.stepango.blockme.character.list.api" + val req = + GenerateLayoutRequest( + moduleDir = root, + packageName = pkg, + language = SourceLanguage.KOTLIN, + createPlaceholder = true, + ) + val first = LayoutGenerator.apply(req) + assertTrue(first.createdDirs.isNotEmpty()) + assertTrue(first.createdFiles.any { it.fileName.toString() == ".gitkeep" }) + assertTrue(LayoutChecker.checkPackageSourceDir(root, pkg).isEmpty()) + + val second = LayoutGenerator.apply(req) + assertTrue(second.createdDirs.isEmpty()) + assertTrue(second.alreadyExisted.isNotEmpty()) + assertTrue(LayoutChecker.checkPackageSourceDir(root, pkg).isEmpty()) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `check reports missing package dir`() { + val root = Files.createTempDirectory("forma-fleet-miss-") + try { + val v = LayoutChecker.checkPackageSourceDir(root, "com.foo.bar") + assertEquals(1, v.size) + assertTrue(v.single().path.contains("src/main/kotlin/com/foo/bar")) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `plan lists package path without IO side effects`() { + val root = Files.createTempDirectory("forma-fleet-plan-") + try { + val plan = + LayoutGenerator.plan( + GenerateLayoutRequest(root, "com.foo.bar", createPlaceholder = true), + ) + assertTrue(plan.createdDirs.isNotEmpty()) + assertTrue(plan.createdFiles.single().endsWith(".gitkeep")) + assertTrue(Files.notExists(root.resolve("src"))) + } finally { + root.toFile().deleteRecursively() + } + } +} + +class MigratePlannerTest { + + @Test + fun `planRename emits path and reference rewrites`() { + val plan = + MigratePlanner.planRename( + PathRenamePlan( + fromModuleRelative = "feature/home/impl", + toModuleRelative = "feature/dashboard/impl", + ), + ) + assertEquals( + listOf("feature/home/impl" to "feature/dashboard/impl"), + plan.filesystemMoves, + ) + assertTrue(plan.referenceRewrites.contains(":feature-home-impl" to ":feature-dashboard-impl")) + assertTrue(plan.referenceRewrites.contains(":feature:home:impl" to ":feature:dashboard:impl")) + assertTrue( + plan.referenceRewrites.any { + it.first.contains("target(") && it.second.contains("dashboard") + }, + ) + assertTrue(plan.notes.isNotEmpty()) + } + + @Test + fun `same path rejected`() { + assertFailsWith { + MigratePlanner.planRename(PathRenamePlan("a/api", "a/api")) + } + } +}