diff --git a/README.md b/README.md
index f4f7e8d..5f3ba41 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ systems like Buck and Bazel.
5. [Plugin publish path](docs/PLUGIN-PUBLISH.md) — Portal metadata DSL + release notes (F-016)
6. [Configuration performance](docs/CONFIGURATION-PERFORMANCE.md) — measure + hot-path guidance (F-017 / GH #106)
7. [forma-core public API design](docs/forma-core-api.md) — extraction contract for types / restrictions / registry (F-020)
+8. [JVM targets](docs/JVM-TARGETS.md) — pure JVM platform plugin `tools.forma.jvm` (F-030)
Configuration made easy:
@@ -200,6 +201,7 @@ forma-core extraction (restriction engine + TargetType + Android matrix kit): **
forma-core validation SPI + content rules (Gradle facade in `:validation`): **F-022** done.
forma-core TargetRegistry + Android DSL consumers: **F-023** done.
Publish coordinates for `tools.forma:core` (library) + facade deprecation policy: **F-024** done (see [`docs/PLUGIN-PUBLISH.md`](docs/PLUGIN-PUBLISH.md)).
+Pure JVM target set (`tools.forma.jvm`, `JvmTargetRegistry`): **F-030** done (see [`docs/JVM-TARGETS.md`](docs/JVM-TARGETS.md)).
Icons made by Freepik
from www.flaticon.com
diff --git a/TICKETS.md b/TICKETS.md
index 8cad5bb..6702198 100644
--- a/TICKETS.md
+++ b/TICKETS.md
@@ -40,7 +40,7 @@ Update this file when picking or finishing work. Cron workers must pick the **hi
| ID | Status | Title | Notes |
|----|--------|-------|-------|
-| F-030 | todo | JVM target set on forma-core (`library`, `api`, `impl`, `utils`, tests) | |
+| F-030 | done | JVM target set on forma-core (`library`, `api`, `impl`, `utils`, tests) | `plugins/jvm` + `tools.forma.jvm`; `docs/JVM-TARGETS.md` |
| F-031 | todo | JVM sample application | |
| F-032 | todo | Docs: JVM getting started | |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index da3a850..210aa59 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -60,7 +60,7 @@ that compose via `includeBuild`:
- `pluginManagement { includeBuild("../build-settings") }`
- `tools.forma.includer` **0.2.0** from Portal (not local includer composite)
-- Includer discovers `:android`, `:config`, `:core`, `:deps`, `:owners`, `:target`, `:validation` (F-021 added `:core`)
+- Includer discovers `:android`, `:config`, `:core`, `:deps`, `:jvm`, `:owners`, `:target`, `:validation` (F-021 `:core`; F-030 `:jvm`)
---
@@ -90,9 +90,12 @@ Group/version (root `plugins/build.gradle.kts`): **`tools.forma` / `0.1.3`**.
│ │ │
└─────┬─────┴────────────┘
│
- ┌─────┴─────┐
- │ android │ DSL + AGP + AndroidTargetTypes + matrix + content helpers
- └───────────┘
+ ┌───────────┴───────────┐
+ │ │
+ ┌─────┴─────┐ ┌─────┴─────┐
+ │ android │ │ jvm │ F-030 pure JVM DSL + JvmTargetRegistry
+ │ DSL+AGP │ │ no AGP │ (parallel platform consumer of core)
+ └───────────┘ └───────────┘
```
| Module | Plugin id | Depends on | Responsibility |
@@ -104,6 +107,7 @@ Group/version (root `plugins/build.gradle.kts`): **`tools.forma` / `0.1.3`**.
| `:config` | `tools.forma.config` | `gradleApi` | `AndroidProjectSettings`, `FormaSettingsStore`, plugin/dep registration maps |
| `:deps` | `tools.forma.deps` | `:core`, `:validation`, `:target`, `:config`, kotlin-dsl | `FormaDependency` model, `applyDependencies`, version-catalog generators |
| `:android` | `tools.forma.android` | `:core` + all of the above + **AGP** + Kotlin GP | Target DSL; `AndroidTargetTypes` + `AndroidRestrictionKit` (F-021); content helpers call core `ContentRule` (F-022) |
+| `:jvm` | `tools.forma.jvm` | `:core`, `:deps`, `:validation`, `:target`, `:owners` + Kotlin GP (**no AGP**) | Pure JVM DSL (`api`/`impl`/`library`/`util`/`testUtil` in package `tools.forma.jvm`); `JvmTargetTypes` + `JvmTargetRegistry` (F-030) |
`:android` compiles against **AGP 8.1.2** (aligned with sample runtime force in
`application/settings.gradle.kts` as of F-003). Keep `plugins/android` AGP
@@ -315,7 +319,7 @@ Aligned with `docs/VISION.md`: core must not assume Android/AGP/Dagger.
| Settings store | `:config` | **Split** | Generic `SettingsStore` / plugin registry → core; `AndroidProjectSettings` → android |
| Owners | `:owners` | **Optional / yes** | Platform-agnostic metadata |
| Feature definitions (AGP library/binary/native) | `:android` | **No** | Stay platform |
-| DSL entrypoints (`api`/`impl`/…) | `:android` | **No** (register *onto* core) | First consumer of core — **F-023 done** (`AndroidTargetRegistry`) |
+| DSL entrypoints (`api`/`impl`/…) | `:android` + `:jvm` | **No** (register *onto* core) | Android first consumer — **F-023**; pure JVM plugin — **F-030** (`JvmTargetRegistry`) |
| Includer / depgen | separate builds | **No** | Adjacent tooling |
| build-dependencies catalogs | sample support | **No** | Demo-only; pattern informs F-012 |
@@ -329,6 +333,9 @@ Suggested extraction order (tickets F-020…F-024):
4. Wire Android DSL / registry as first consumer of core — **done (F-023)**
(`TargetRegistry` + `AndroidTargetRegistry`; sample green).
5. Coordinates + publish finalization: `tools.forma:core` (library) vs `tools.forma.android` + facades (F-024 done — see `docs/PLUGIN-PUBLISH.md`).
+6. JVM platform consumer of core — **done (F-030):** `plugins/jvm`, plugin id
+ `tools.forma.jvm`, `JvmTargetRegistry` + matrix ([`JVM-TARGETS.md`](JVM-TARGETS.md)).
+ Sample JVM app is **F-031**.
---
@@ -354,8 +361,9 @@ Suggested extraction order (tickets F-020…F-024):
|------|------------|
| New user / first project | [`docs/GETTING-STARTED.md`](GETTING-STARTED.md) |
| Configuration-time performance | [`docs/CONFIGURATION-PERFORMANCE.md`](CONFIGURATION-PERFORMANCE.md) |
-| New target type | `AndroidTargets.kt` + new DSL file under `plugins/android/src/main/java/` + validator list |
-| Tighten dep rules | `validator(...)` in that DSL file; update this doc §2.2 |
+| New Android target type | `AndroidTargetTypes` + DSL under `plugins/android/` + `registerAndroidDefaults` |
+| New JVM target type | `JvmTargetTypes` + DSL under `plugins/jvm/` + `registerJvmDefaults` ([`JVM-TARGETS.md`](JVM-TARGETS.md)) |
+| Tighten dep rules | platform registry allow-lists; update DEPENDENCY-MATRIX / JVM-TARGETS |
| Global SDK/AGP defaults | `androidProjectConfiguration` + sample `application/build.gradle.kts` |
| External deps UX | `plugins/deps` catalog + `build-dependencies` |
| Auto module discovery | `includer/` |
diff --git a/docs/JVM-TARGETS.md b/docs/JVM-TARGETS.md
new file mode 100644
index 0000000..1467daa
--- /dev/null
+++ b/docs/JVM-TARGETS.md
@@ -0,0 +1,115 @@
+# JVM targets (F-030)
+
+Pure **JVM** platform for Forma, built on **forma-core** (`tools.forma:core`)
+without AGP. Parallel to the Android product (`tools.forma.android`), not a
+fork of the restriction engine.
+
+Plugin id: **`tools.forma.jvm`** (module `plugins/:jvm`).
+
+## Types
+
+| DSL (package `tools.forma.jvm`) | Type id | Name suffix | Role |
+|---------------------------------|---------|-------------|------|
+| `api` | `jvm.api` | `api` | Public contracts (Dagger-friendly) |
+| `impl` | `jvm.impl` | `impl` | Feature implementation |
+| `library` | `jvm.library` | `library` | Shared pure-JVM library |
+| `util` | `jvm.util` | `util` | Utilities / extensions |
+| `testUtil` | `jvm.test-util` | `test-util` | Shared test helpers |
+
+Name matching is the same as Android / core: project name equals the suffix or
+ends with `-$suffix` (see `SuffixNameMatcher`).
+
+Composition roots (`binary` / app) are **out of scope for F-030** (sample app
+is F-031). Until then, feature graphs should avoid needing multi-`impl`
+composition at a root.
+
+## Dependency matrix (project edges only)
+
+External / catalog GAV deps are not filtered by type (same as Android).
+
+| Consumer ↓ \ Dep → | api | impl | library | util | test-util |
+|--------------------|-----|------|---------|------|-----------|
+| **api** | Y | — | Y | — | — |
+| **impl** | Y | — | Y | Y | Y |
+| **library** | — | — | — | Y | Y |
+| **util** | — | — | Y | Y | — |
+| **test-util** | — | — | Y | Y | Y |
+
+### Rules of thumb
+
+- **`impl` ↛ `impl`** — implementations stay independent; compose later at a
+ binary/app root (F-031).
+- **`api` only sees contracts** — `api` + `library` (no util/impl leakage into
+ the public surface).
+- **`library` stays leaf-ish** — only `util` / `test-util` (parity with the
+ historical JVM `library()` row inside the Android matrix).
+- **`util` cannot depend on `api` / `impl`**.
+- **Content rules** — pure JVM has no Android `res/`; no content rules are
+ attached for F-030.
+
+Source of truth in code: `plugins/jvm/.../JvmTargetRegistry.kt`
+(`registerJvmDefaults`).
+
+## Relation to Android
+
+- Android already exposes pure-Kotlin entrypoints (`api`, `library`, `util`,
+ `testUtil`) **inside** `tools.forma.android`, registered on
+ `AndroidTargetRegistry` with some shared **id strings** (`jvm.library`,
+ `jvm.util`).
+- F-030 introduces an **authoritative JVM platform registry**
+ (`JvmTargetRegistry`) and plugin so pure-JVM products do not need AGP.
+- Registries are **separate singletons**; shared id strings are intentional.
+- Full Android matrix remains [`DEPENDENCY-MATRIX.md`](DEPENDENCY-MATRIX.md).
+
+## Consumer sketch
+
+```kotlin
+// settings.gradle.kts
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ }
+}
+plugins {
+ id("tools.forma.jvm") version "0.1.3"
+}
+
+// some-module/build.gradle.kts
+import tools.forma.jvm.api
+import tools.forma.jvm.library
+// …
+
+api(
+ packageName = "com.example.feature.api",
+ dependencies = deps(
+ project(":shared-library")
+ )
+)
+```
+
+`registerJvmDefaults()` runs on first DSL entry so the matrix is available
+without a separate configuration call (a dedicated `jvmProjectConfiguration`
+can land later if settings need grow).
+
+Kotlin JVM is applied by the DSL (`org.jetbrains.kotlin.jvm`); default
+compatibility is Java **11** (`JvmDefaults`).
+
+## Tests
+
+- `plugins/jvm` unit tests: `JvmTargetRegistryTest` (matrix allow/deny,
+ self-suffix, validator identity cache).
+- Engine coverage remains in `plugins/core` tests.
+
+## Next
+
+- **F-031** — multi-module JVM sample application (gold standard like
+ `application/`).
+- **F-032** — JVM getting-started tutorial.
+
+## See also
+
+- [`forma-core-api.md`](forma-core-api.md) — core types / registry SPI
+- [`ARCHITECTURE.md`](ARCHITECTURE.md) — module graph
+- [`PLUGIN-PUBLISH.md`](PLUGIN-PUBLISH.md) — coordinates
+- [`VISION.md`](VISION.md) — product sequencing
diff --git a/docs/PLUGIN-PUBLISH.md b/docs/PLUGIN-PUBLISH.md
index c89bfa6..5c8f7a1 100644
--- a/docs/PLUGIN-PUBLISH.md
+++ b/docs/PLUGIN-PUBLISH.md
@@ -9,7 +9,8 @@ expected ownership model and credential flow so workers do not invent secrets.
| Project | Plugin id | Notes |
|---------|-----------|--------|
-| `plugins/:android` | `tools.forma.android` | Main product entrypoint |
+| `plugins/:android` | `tools.forma.android` | Main Android product entrypoint |
+| `plugins/:jvm` | `tools.forma.jvm` | Pure JVM platform (F-030); no AGP |
| `plugins/:target` | `tools.forma.target` | Target identity |
| `plugins/:validation` | `tools.forma.validation` | Validators |
| `plugins/:owners` | `tools.forma.owners` | Ownership metadata |
@@ -37,7 +38,8 @@ Android consumers continue to use the unchanged plugin id `tools.forma.android`.
| Artifact | Coordinates | Type | Notes |
|----------|-------------|------|-------|
| forma-core | `tools.forma:core:0.1.3` (+ `-sources`) | Maven library | Single jar. Contains `tools.forma.core.target`, `.restriction`, `.validation`. |
-| Android | `tools.forma.android` (Plugin Portal) | Gradle plugin | Primary entrypoint. Depends on core (transitive). |
+| Android | `tools.forma.android` (Plugin Portal) | Gradle plugin | Primary Android entrypoint. Depends on core (transitive). |
+| JVM | `tools.forma.jvm` (Plugin Portal) | Gradle plugin | Pure JVM targets (F-030). Depends on core (transitive); **no AGP**. See [`JVM-TARGETS.md`](JVM-TARGETS.md). |
| Facades (compat) | `tools.forma.target`, `.validation`, `.deps`, `.config`, `.owners` | Gradle plugins (thin) | Remain published; delegate/re-export core. |
Group `tools.forma`, version `0.1.3` (shared source of truth in root `formaPluginConfiguration`).
@@ -50,10 +52,16 @@ Group `tools.forma`, version `0.1.3` (shared source of truth in root `formaPlugi
id("tools.forma.android") version "0.1.3"
}
```
-- **Pure engine / future JVM or Bazel** (F-030+): depend directly on the library
- when writing adapters that do not need the Android DSL:
+- **Pure JVM apps** (F-030): use the JVM plugin (no AGP):
+ ```kotlin
+ plugins {
+ id("tools.forma.jvm") version "0.1.3"
+ }
+ ```
+ DSL lives in package `tools.forma.jvm` — see [`JVM-TARGETS.md`](JVM-TARGETS.md).
+- **Pure engine / Bazel adapters**: depend directly on the library when writing
+ adapters that do not need a platform DSL:
`implementation("tools.forma:core:0.1.3")`.
- (A future `tools.forma.jvm` plugin would also pull core.)
### How plugin POMs declare the core dependency
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 6b8272d..9989344 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -2,6 +2,29 @@
Newest entries first.
+## 2026-07-13 — F-030 JVM target set on forma-core
+
+- **Ticket:** F-030 → `done`
+- **Branch:** `forma/F-030-jvm-targets` (from `origin/v2`)
+- **Skills/modes:** Grok Build `--mode full` (design plan completed; implement hit max-turns with partial tree); Hermes finished tests, docs, verify, commit/PR
+- **Platform (`plugins/jvm`):**
+ - Plugin id **`tools.forma.jvm`** via `formaPublishedPlugin(name = "jvm")` + empty Settings `FormaPlugin`
+ - **No AGP**; depends on `:core`, `:deps`, `:validation`, `:target`, `:owners` + embedded Kotlin GP
+ - `JvmTargetTypes`: `jvm.api`, `jvm.impl`, `jvm.library`, `jvm.util`, `jvm.test-util`
+ - `JvmTargetRegistry` + `registerJvmDefaults()` matrix (impl↛impl; api→api/library; library→util/test-util; util→util/library; test-util→test-util/util/library)
+ - DSL under package `tools.forma.jvm`: `api` / `impl` / `library` / `util` / `testUtil`
+ - Minimal `applyKotlinJvm()` (Java 11) without `AndroidProjectSettings`
+- **Tests:** `JvmTargetRegistryTest` (matrix, self-suffix, identity cache)
+- **Docs:** `docs/JVM-TARGETS.md`; ARCHITECTURE module graph; PLUGIN-PUBLISH table; README Progress; forma-core-api status
+- **Verify (OpenJDK 17 + env-mac.sh):**
+ - `plugins/`: `./gradlew :core:test :jvm:test build` → **BUILD SUCCESSFUL** (78 tasks; `JvmTargetRegistryTest` 5/5)
+ - Plugin descriptor `tools.forma.jvm` → `tools.forma.jvm.plugin.FormaPlugin`
+ - `application/`: `./gradlew :binary:assembleDebug` → **BUILD SUCCESSFUL** (578 tasks)
+- **Grounded:** all commands executed; no invented green builds.
+- **Commits/PRs:** this run — push + PR base `v2`
+- **Blockers:** none (Grok implement max-turns; completed under Hermes)
+- **Next step:** F-031 JVM sample application
+
## 2026-07-13 — F-024 Publish/coordinate coordinates: `tools.forma:core` vs android plugins
- **Ticket:** F-024 → `done`
diff --git a/docs/forma-core-api.md b/docs/forma-core-api.md
index b4769e4..6242acb 100644
--- a/docs/forma-core-api.md
+++ b/docs/forma-core-api.md
@@ -7,8 +7,10 @@ plugins under `plugins/` and in [`ARCHITECTURE.md`](ARCHITECTURE.md) §6 +
**Status:** accepted design. **Implemented so far:** F-021 (types +
restriction graph + Android kit), **F-022** (validation SPI + content
predicates in `plugins/core`; `:validation` is a Gradle facade), **F-023**
-(`TargetRegistry` + Android DSL consumers via `AndroidTargetRegistry`).
-Publishing coordinates remain **F-024**.
+(`TargetRegistry` + Android DSL consumers via `AndroidTargetRegistry`),
+**F-024** (publish coordinates for `tools.forma:core`), **F-030** (pure JVM
+platform plugin `tools.forma.jvm` + `JvmTargetRegistry` — see
+[`JVM-TARGETS.md`](JVM-TARGETS.md)).
---
diff --git a/plugins/jvm/build.gradle.kts b/plugins/jvm/build.gradle.kts
new file mode 100644
index 0000000..50acbb0
--- /dev/null
+++ b/plugins/jvm/build.gradle.kts
@@ -0,0 +1,39 @@
+@file:Suppress("UnstableApiUsage")
+
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
+
+plugins {
+ id("org.gradle.kotlin.kotlin-dsl")
+ id("com.gradle.plugin-publish")
+}
+
+formaPublishedPlugin(name = "jvm")
+
+tasks.named("compileKotlin", KotlinCompilationTask::class.java) {
+ compilerOptions {
+ // Keep parity options with sibling plugins if useful; none strictly required for pure JVM.
+ }
+}
+
+dependencies {
+ // Pure JVM platform plugin — NO AGP. Kotlin JVM only.
+ implementation(embeddedKotlin("gradle-plugin"))
+
+ // forma-core + supporting facades (deps/validation own the apply + validator facades)
+ implementation(project(":core"))
+ implementation(project(":deps"))
+ implementation(project(":validation"))
+ implementation(project(":target"))
+ implementation(project(":owners"))
+
+ testImplementation(kotlin("test"))
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+
+tasks.named("publishPlugins") {
+ // If/when wiring a root publish that chains jvm too, list deps here.
+ // For F-030 slice, standalone :jvm:publishPlugins works for validation.
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/api.kt b/plugins/jvm/src/main/java/tools/forma/jvm/api.kt
new file mode 100644
index 0000000..dc55f2e
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/api.kt
@@ -0,0 +1,39 @@
+package tools.forma.jvm
+
+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.jvm.feature.applyKotlinJvm
+import tools.forma.jvm.target.JvmTargetRegistry
+import tools.forma.jvm.target.JvmTargetTypes
+import tools.forma.jvm.target.registerJvmDefaults
+import tools.forma.owners.NoOwner
+import tools.forma.owners.Owner
+import tools.forma.target.FormaTarget
+import tools.forma.validation.asValidator
+
+/**
+ * Feature **API** surface (pure JVM).
+ *
+ * Dagger2-friendly public contracts only.
+ * May depend on other `api` and pure `jvm.library` modules.
+ * Must not depend on `impl`, `util` (except through library contracts if modeled that way).
+ */
+fun Project.api(
+ packageName: String,
+ owner: Owner = NoOwner,
+ dependencies: FormaDependency = EmptyDependency
+) {
+ registerJvmDefaults()
+
+ JvmTargetRegistry.selfValidator(JvmTargetTypes.api).asValidator()
+ .validate(FormaTarget(this))
+
+ applyKotlinJvm()
+
+ applyDependencies(
+ validator = JvmTargetRegistry.validatorFor(JvmTargetTypes.api).asValidator(),
+ dependencies = dependencies
+ )
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/feature/JvmFeature.kt b/plugins/jvm/src/main/java/tools/forma/jvm/feature/JvmFeature.kt
new file mode 100644
index 0000000..3e9216e
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/feature/JvmFeature.kt
@@ -0,0 +1,37 @@
+package tools.forma.jvm.feature
+
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.tasks.compile.JavaCompile
+import org.gradle.kotlin.dsl.apply
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+/**
+ * Minimal Kotlin JVM feature applicator for the pure JVM plugin (F-030).
+ *
+ * Applies `org.jetbrains.kotlin.jvm` and configures source/target compatibility + jvmTarget.
+ * Does not depend on AndroidProjectSettings / Forma.settings or any Android bits.
+ *
+ * Default JVM target: 11 (modern default for pure JVM targets; override in future
+ * jvmProjectConfiguration if added).
+ */
+object JvmDefaults {
+ val javaVersionCompatibility: JavaVersion = JavaVersion.VERSION_11
+ const val jvmTarget: String = "11"
+}
+
+fun Project.applyKotlinJvm() {
+ apply(plugin = "org.jetbrains.kotlin.jvm")
+
+ val jvmTarget = JvmDefaults.jvmTarget
+ val javaVersion = JvmDefaults.javaVersionCompatibility.toString()
+
+ tasks.withType(JavaCompile::class.java).configureEach {
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
+ }
+
+ tasks.withType(KotlinCompile::class.java).configureEach {
+ kotlinOptions.jvmTarget = jvmTarget
+ }
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/impl.kt b/plugins/jvm/src/main/java/tools/forma/jvm/impl.kt
new file mode 100644
index 0000000..4410962
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/impl.kt
@@ -0,0 +1,40 @@
+package tools.forma.jvm
+
+import org.gradle.api.Project
+import tools.forma.deps.core.EmptyDependency
+import tools.forma.deps.core.FormaDependency
+import tools.forma.deps.core.NamedDependency
+import tools.forma.deps.core.applyDependencies
+import tools.forma.jvm.feature.applyKotlinJvm
+import tools.forma.jvm.target.JvmTargetRegistry
+import tools.forma.jvm.target.JvmTargetTypes
+import tools.forma.jvm.target.registerJvmDefaults
+import tools.forma.target.FormaTarget
+import tools.forma.validation.asValidator
+
+/**
+ * Feature **implementation** (pure JVM).
+ *
+ * Dagger2-friendly boundaries:
+ * - May depend on `api`, `library`, `util`, `test-util`.
+ * - **Must not** depend on other `impl` modules.
+ * Composition of impls happens at a binary / app root (later F-031).
+ */
+fun Project.impl(
+ packageName: String,
+ dependencies: FormaDependency = EmptyDependency,
+ testDependencies: NamedDependency = NamedDependency()
+) {
+ registerJvmDefaults()
+
+ JvmTargetRegistry.selfValidator(JvmTargetTypes.impl).asValidator()
+ .validate(FormaTarget(this))
+
+ applyKotlinJvm()
+
+ applyDependencies(
+ validator = JvmTargetRegistry.validatorFor(JvmTargetTypes.impl).asValidator(),
+ dependencies = dependencies,
+ testDependencies = testDependencies
+ )
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/library.kt b/plugins/jvm/src/main/java/tools/forma/jvm/library.kt
new file mode 100644
index 0000000..0bb9ac7
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/library.kt
@@ -0,0 +1,40 @@
+package tools.forma.jvm
+
+import org.gradle.api.Project
+import tools.forma.deps.core.EmptyDependency
+import tools.forma.deps.core.FormaDependency
+import tools.forma.deps.core.NamedDependency
+import tools.forma.deps.core.applyDependencies
+import tools.forma.jvm.feature.applyKotlinJvm
+import tools.forma.jvm.target.JvmTargetRegistry
+import tools.forma.jvm.target.JvmTargetTypes
+import tools.forma.jvm.target.registerJvmDefaults
+import tools.forma.owners.NoOwner
+import tools.forma.owners.Owner
+import tools.forma.target.FormaTarget
+import tools.forma.validation.asValidator
+
+/**
+ * Pure JVM **library** (shared code, no Android).
+ *
+ * May only depend on `util` + `test-util` (parity with prior JVM library row).
+ */
+fun Project.library(
+ packageName: String,
+ dependencies: FormaDependency = EmptyDependency,
+ owner: Owner = NoOwner,
+ testDependencies: NamedDependency = NamedDependency()
+) {
+ registerJvmDefaults()
+
+ JvmTargetRegistry.selfValidator(JvmTargetTypes.library).asValidator()
+ .validate(FormaTarget(this))
+
+ applyKotlinJvm()
+
+ applyDependencies(
+ validator = JvmTargetRegistry.validatorFor(JvmTargetTypes.library).asValidator(),
+ dependencies = dependencies,
+ testDependencies = testDependencies
+ )
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/plugin/FormaPlugin.kt b/plugins/jvm/src/main/java/tools/forma/jvm/plugin/FormaPlugin.kt
new file mode 100644
index 0000000..8ab6f17
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/plugin/FormaPlugin.kt
@@ -0,0 +1,17 @@
+@file:Suppress("unused")
+
+package tools.forma.jvm.plugin
+
+import org.gradle.api.Plugin
+import org.gradle.api.initialization.Settings
+
+/**
+ * Empty Settings plugin implementation to enable publishing `tools.forma.jvm`
+ * to the Gradle Plugin Portal (same pattern as :android and sibling facades).
+ */
+class FormaPlugin : Plugin {
+
+ override fun apply(settings: Settings) {
+ return
+ }
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/target/JvmTargetRegistry.kt b/plugins/jvm/src/main/java/tools/forma/jvm/target/JvmTargetRegistry.kt
new file mode 100644
index 0000000..a46fec8
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/target/JvmTargetRegistry.kt
@@ -0,0 +1,69 @@
+package tools.forma.jvm.target
+
+import tools.forma.core.target.DefaultTargetRegistry
+import tools.forma.core.target.TargetRegistration
+import tools.forma.core.target.TargetRegistry
+
+/**
+ * Singleton registry for pure JVM target types (F-030).
+ * Separate from AndroidTargetRegistry so platforms can evolve matrices independently.
+ * Use [registerJvmDefaults] to populate the restriction matrix.
+ */
+object JvmTargetRegistry : TargetRegistry by DefaultTargetRegistry()
+
+/**
+ * Register JVM target types + restriction matrix.
+ *
+ * Matrix (Dagger-friendly, pure JVM, no Android types):
+ * - api → api, library
+ * - impl → api, library, util, test-util (NO impl — composition only at binary/app roots later)
+ * - library → util, test-util
+ * - util → util, library
+ * - testUtil → test-util, util (library allowed for test helpers that need prod contracts)
+ *
+ * No ContentRules attached for pure JVM (no Android resources concept).
+ * Safe to call multiple times (re-register replaces).
+ */
+fun registerJvmDefaults(registry: TargetRegistry = JvmTargetRegistry) {
+ val t = JvmTargetTypes
+
+ // api: contracts only — api + (pure jvm) library
+ registry.register(
+ TargetRegistration(
+ type = t.api,
+ allowedDependencies = setOf(t.api, t.library)
+ )
+ )
+
+ // impl: impl may use contracts + shared + utils; **no other impl**
+ registry.register(
+ TargetRegistration(
+ type = t.impl,
+ allowedDependencies = setOf(t.api, t.library, t.util, t.testUtil)
+ )
+ )
+
+ // library (pure JVM): only util + test-util (parity with existing android jvm.library row)
+ registry.register(
+ TargetRegistration(
+ type = t.library,
+ allowedDependencies = setOf(t.util, t.testUtil)
+ )
+ )
+
+ // util: utilities may depend on other utils + libraries
+ registry.register(
+ TargetRegistration(
+ type = t.util,
+ allowedDependencies = setOf(t.util, t.library)
+ )
+ )
+
+ // test-util: test helpers use other test utils + util (+ library for test code needing contracts)
+ registry.register(
+ TargetRegistration(
+ type = t.testUtil,
+ allowedDependencies = setOf(t.testUtil, t.util, t.library)
+ )
+ )
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/target/JvmTargetTypes.kt b/plugins/jvm/src/main/java/tools/forma/jvm/target/JvmTargetTypes.kt
new file mode 100644
index 0000000..27133fc
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/target/JvmTargetTypes.kt
@@ -0,0 +1,29 @@
+package tools.forma.jvm.target
+
+import tools.forma.core.target.TargetType
+import tools.forma.core.target.targetType
+
+/**
+ * Stable TargetType instances for the pure JVM platform (F-030).
+ *
+ * These are the authoritative definitions for standalone JVM usage.
+ * Ids are distinct where needed; "jvm.library" / "jvm.util" intentionally reuse the
+ * ids that AndroidTargetTypes also declares for its pure-JVM rows (shared id strings ok;
+ * each platform uses its own *TargetRegistry singleton).
+ */
+object JvmTargetTypes {
+ /** Public API contracts (Dagger friendly). */
+ val api: TargetType = targetType("jvm.api", "api")
+
+ /** Feature implementation (pure JVM). Must not depend on other impl. */
+ val impl: TargetType = targetType("jvm.impl", "impl")
+
+ /** Shared pure JVM library code. */
+ val library: TargetType = targetType("jvm.library", "library")
+
+ /** Utility / extension modules (JVM). */
+ val util: TargetType = targetType("jvm.util", "util")
+
+ /** Test helpers (JVM). */
+ val testUtil: TargetType = targetType("jvm.test-util", "test-util")
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/testUtil.kt b/plugins/jvm/src/main/java/tools/forma/jvm/testUtil.kt
new file mode 100644
index 0000000..6788617
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/testUtil.kt
@@ -0,0 +1,37 @@
+package tools.forma.jvm
+
+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.jvm.feature.applyKotlinJvm
+import tools.forma.jvm.target.JvmTargetRegistry
+import tools.forma.jvm.target.JvmTargetTypes
+import tools.forma.jvm.target.registerJvmDefaults
+import tools.forma.owners.NoOwner
+import tools.forma.owners.Owner
+import tools.forma.target.FormaTarget
+import tools.forma.validation.asValidator
+
+/**
+ * JVM **test-util** helpers.
+ *
+ * Intended for test code shared across modules. Depends on `test-util` + `util` (+ library).
+ */
+fun Project.testUtil(
+ packageName: String,
+ owner: Owner = NoOwner,
+ dependencies: FormaDependency = EmptyDependency
+) {
+ registerJvmDefaults()
+
+ JvmTargetRegistry.selfValidator(JvmTargetTypes.testUtil).asValidator()
+ .validate(FormaTarget(this))
+
+ applyKotlinJvm()
+
+ applyDependencies(
+ validator = JvmTargetRegistry.validatorFor(JvmTargetTypes.testUtil).asValidator(),
+ dependencies = dependencies
+ )
+}
diff --git a/plugins/jvm/src/main/java/tools/forma/jvm/util.kt b/plugins/jvm/src/main/java/tools/forma/jvm/util.kt
new file mode 100644
index 0000000..365be39
--- /dev/null
+++ b/plugins/jvm/src/main/java/tools/forma/jvm/util.kt
@@ -0,0 +1,39 @@
+package tools.forma.jvm
+
+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.jvm.feature.applyKotlinJvm
+import tools.forma.jvm.target.JvmTargetRegistry
+import tools.forma.jvm.target.JvmTargetTypes
+import tools.forma.jvm.target.registerJvmDefaults
+import tools.forma.owners.NoOwner
+import tools.forma.owners.Owner
+import tools.forma.target.FormaTarget
+import tools.forma.validation.asValidator
+
+/**
+ * JVM **util** / extensions module.
+ *
+ * May depend on other `util` + `library`.
+ */
+fun Project.util(
+ packageName: String,
+ owner: Owner = NoOwner,
+ dependencies: FormaDependency = EmptyDependency,
+ testDependencies: FormaDependency = EmptyDependency
+) {
+ registerJvmDefaults()
+
+ JvmTargetRegistry.selfValidator(JvmTargetTypes.util).asValidator()
+ .validate(FormaTarget(this))
+
+ applyKotlinJvm()
+
+ applyDependencies(
+ validator = JvmTargetRegistry.validatorFor(JvmTargetTypes.util).asValidator(),
+ dependencies = dependencies,
+ testDependencies = testDependencies
+ )
+}
diff --git a/plugins/jvm/src/test/java/tools/forma/jvm/target/JvmTargetRegistryTest.kt b/plugins/jvm/src/test/java/tools/forma/jvm/target/JvmTargetRegistryTest.kt
new file mode 100644
index 0000000..2533a0c
--- /dev/null
+++ b/plugins/jvm/src/test/java/tools/forma/jvm/target/JvmTargetRegistryTest.kt
@@ -0,0 +1,118 @@
+package tools.forma.jvm.target
+
+import tools.forma.core.target.TargetRef
+import tools.forma.core.validation.FormaValidationException
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+/**
+ * F-030: pure JVM matrix + registry behavior (no AGP).
+ */
+class JvmTargetRegistryTest {
+
+ private data class TestRef(override val name: String) : TargetRef
+
+ @BeforeTest
+ fun setup() {
+ // Fresh defaults each test (replace semantics)
+ registerJvmDefaults()
+ }
+
+ @Test
+ fun `all five jvm types are registered by id`() {
+ val t = JvmTargetTypes
+ assertEquals(t.api, JvmTargetRegistry.get("jvm.api"))
+ assertEquals(t.impl, JvmTargetRegistry.get("jvm.impl"))
+ assertEquals(t.library, JvmTargetRegistry.get("jvm.library"))
+ assertEquals(t.util, JvmTargetRegistry.get("jvm.util"))
+ assertEquals(t.testUtil, JvmTargetRegistry.get("jvm.test-util"))
+ assertEquals(5, JvmTargetRegistry.all().size)
+ }
+
+ @Test
+ fun `restriction graph encodes dagger friendly matrix`() {
+ val t = JvmTargetTypes
+ val g = JvmTargetRegistry.restrictionGraph()
+
+ // api
+ assertTrue(g.isAllowed(t.api, t.api))
+ assertTrue(g.isAllowed(t.api, t.library))
+ assertFalse(g.isAllowed(t.api, t.impl))
+ assertFalse(g.isAllowed(t.api, t.util))
+
+ // impl: no impl→impl
+ assertTrue(g.isAllowed(t.impl, t.api))
+ assertTrue(g.isAllowed(t.impl, t.library))
+ assertTrue(g.isAllowed(t.impl, t.util))
+ assertTrue(g.isAllowed(t.impl, t.testUtil))
+ assertFalse(g.isAllowed(t.impl, t.impl))
+
+ // library
+ assertTrue(g.isAllowed(t.library, t.util))
+ assertTrue(g.isAllowed(t.library, t.testUtil))
+ assertFalse(g.isAllowed(t.library, t.api))
+ assertFalse(g.isAllowed(t.library, t.impl))
+ assertFalse(g.isAllowed(t.library, t.library))
+
+ // util
+ assertTrue(g.isAllowed(t.util, t.util))
+ assertTrue(g.isAllowed(t.util, t.library))
+ assertFalse(g.isAllowed(t.util, t.api))
+ assertFalse(g.isAllowed(t.util, t.impl))
+
+ // test-util
+ assertTrue(g.isAllowed(t.testUtil, t.testUtil))
+ assertTrue(g.isAllowed(t.testUtil, t.util))
+ assertTrue(g.isAllowed(t.testUtil, t.library))
+ assertFalse(g.isAllowed(t.testUtil, t.api))
+ assertFalse(g.isAllowed(t.testUtil, t.impl))
+ }
+
+ @Test
+ fun `validatorFor rejects disallowed project suffixes`() {
+ val t = JvmTargetTypes
+ val apiV = JvmTargetRegistry.validatorFor(t.api)
+ apiV.validate(TestRef("feature-api"))
+ apiV.validate(TestRef("core-library"))
+ assertFailsWith { apiV.validate(TestRef("feature-impl")) }
+ assertFailsWith { apiV.validate(TestRef("shared-util")) }
+
+ val implV = JvmTargetRegistry.validatorFor(t.impl)
+ implV.validate(TestRef("feature-api"))
+ assertFailsWith { implV.validate(TestRef("other-impl")) }
+
+ val libV = JvmTargetRegistry.validatorFor(t.library)
+ libV.validate(TestRef("shared-util"))
+ assertFailsWith { libV.validate(TestRef("feature-api")) }
+ }
+
+ @Test
+ fun `selfValidator accepts matching suffix`() {
+ val t = JvmTargetTypes
+ JvmTargetRegistry.selfValidator(t.api).validate(TestRef("api"))
+ JvmTargetRegistry.selfValidator(t.api).validate(TestRef("feature-api"))
+ assertFailsWith {
+ JvmTargetRegistry.selfValidator(t.api).validate(TestRef("feature-impl"))
+ }
+ JvmTargetRegistry.selfValidator(t.impl).validate(TestRef("home-impl"))
+ JvmTargetRegistry.selfValidator(t.library).validate(TestRef("common-library"))
+ JvmTargetRegistry.selfValidator(t.util).validate(TestRef("network-util"))
+ JvmTargetRegistry.selfValidator(t.testUtil).validate(TestRef("shared-test-util"))
+ }
+
+ @Test
+ fun `validatorFor is identity cached for same type`() {
+ val t = JvmTargetTypes
+ val v1 = JvmTargetRegistry.validatorFor(t.api)
+ val v2 = JvmTargetRegistry.validatorFor(t.api)
+ assertSame(v1, v2)
+ val s1 = JvmTargetRegistry.selfValidator(t.impl)
+ val s2 = JvmTargetRegistry.selfValidator(t.impl)
+ assertSame(s1, s2)
+ }
+}