diff --git a/README.md b/README.md
index af531ebc..a729aa89 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,7 @@ Configuration-time performance: **F-017**
([`docs/CONFIGURATION-PERFORMANCE.md`](docs/CONFIGURATION-PERFORMANCE.md)).
forma-core API design: **F-020** ([`docs/forma-core-api.md`](docs/forma-core-api.md)).
forma-core extraction (restriction engine + TargetType + Android matrix kit): **F-021** done (`plugins/core`).
+forma-core validation SPI + content rules (Gradle facade in `:validation`): **F-022** done.
Icons made by Freepik
from www.flaticon.com
diff --git a/TICKETS.md b/TICKETS.md
index 0dd074bc..b3105119 100644
--- a/TICKETS.md
+++ b/TICKETS.md
@@ -32,7 +32,7 @@ Update this file when picking or finishing work. Cron workers must pick the **hi
|----|--------|-------|-------|
| F-020 | done | Design forma-core public API (types, restrictions, validation, target registry) | `docs/forma-core-api.md` — types, restriction graph, validator SPI, registry, coords, library-suffix decision |
| F-021 | done | Extract dependency-type / restriction engine into `forma-core` | `plugins/core` + TargetType/NameMatcher/RestrictionGraph + AndroidTargetTypes + AndroidRestrictionKit (distinct jvm.library vs android.library); facades preserved. Related GH #39 |
-| F-022 | todo | Extract validation framework into `forma-core` | Keep Android validators as plugins |
+| F-022 | done | Extract validation framework into `forma-core` | `plugins/core` TargetValidator + ContentRule; `:validation` facade; Android helpers call pure rules |
| F-023 | todo | Wire Android implementation as first consumer of forma-core | Sample still builds |
| F-024 | todo | Publish/coordinate coordinates: `tools.forma:core` vs android plugins | |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4dd8d8a3..704a3595 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -70,17 +70,18 @@ Group/version (root `plugins/build.gradle.kts`): **`tools.forma` / `0.1.3`**.
```
┌────────────┐
- │ core │ TargetType, NameMatcher, RestrictionGraph (F-021)
+ │ core │ TargetType, RestrictionGraph (F-021),
+ │ │ TargetValidator + ContentRule (F-022)
└─────▲──────┘
│
┌─────┴──────┐
- │ target │ TargetTemplate, FormaTarget (parallel for compat)
+ │ target │ TargetTemplate; FormaTarget : TargetRef
└─────▲──────┘
│
┌───────────┼────────────┐
│ │ │
┌─────┴─────┐ ┌───┴────┐ │
- │validation │ │ owners │ │
+ │validation │ │ owners │ │ validation = Gradle facade over core
└─────▲─────┘ └───▲────┘ │
│ │ │
┌─────┴─────┐ │ ┌─────┴─────┐
@@ -90,19 +91,19 @@ Group/version (root `plugins/build.gradle.kts`): **`tools.forma` / `0.1.3`**.
└─────┬─────┴────────────┘
│
┌─────┴─────┐
- │ android │ user-facing DSL + AGP features (+ AndroidTargetTypes + matrix)
+ │ android │ DSL + AGP + AndroidTargetTypes + matrix + content helpers
└───────────┘
```
| Module | Plugin id | Depends on | Responsibility |
|--------|-----------|------------|----------------|
-| `:core` | (library, not plugin) | (none / gradleApi compileOnly if needed) | `TargetType` / `TargetRef`, `NameMatcher`/`SuffixNameMatcher`, `RestrictionGraph`/`MutableRestrictionGraph`, `EdgeKind`, `RestrictionRule`. Pure engine. |
-| `:target` | `tools.forma.target` | `gradleApi` | `TargetTemplate(suffix)`, `FormaTarget(project)` (compat facade; F-021 adds parallel core types) |
-| `:validation` | `tools.forma.validation` | `:target`, `gradleApi` | Name validators, content validators, `ProjectValidationError` (F-022 will migrate to core) |
+| `:core` | (library, not plugin) | (none) | Pure engine: `TargetType`/`TargetRef`, `NameMatcher`, `RestrictionGraph`, **`TargetValidator` / `dependencyTypeValidator` / `selfTypeValidator` / `AcceptAny`**, **`ContentRule` + No/OnlyResources/OnlyLayout**, `FormaValidationException`. Identity-cached factories (F-017). |
+| `:target` | `tools.forma.target` | `:core`, `gradleApi` | `TargetTemplate(suffix)`; `FormaTarget(project)` implements `TargetRef` |
+| `:validation` | `tools.forma.validation` | `:core`, `:target`, `gradleApi` | Thin Gradle facade: legacy `Validator`/`validator(TargetTemplate…)` → core; `ProjectValidationError`; `validateDirectoryContent` (FS listing) |
| `:owners` | `tools.forma.owners` | `gradleApi` | `Owner` / `Person` / `Team` / `NoOwner` |
| `:config` | `tools.forma.config` | `gradleApi` | `AndroidProjectSettings`, `FormaSettingsStore`, plugin/dep registration maps |
-| `:deps` | `tools.forma.deps` | `: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 (`api`, `impl`, `androidLibrary`, …), feature appliers; owns `AndroidTargetTypes` + `AndroidRestrictionKit` (F-021) |
+| `: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) |
`: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
@@ -307,9 +308,9 @@ Aligned with `docs/VISION.md`: core must not assume Android/AGP/Dagger.
| Concern | Current home | forma-core? | Notes |
|---------|--------------|-------------|-------|
-| Target type identity (`TargetTemplate` / suffix) | `:target` + `:core` (F-021) | **Yes** | Parallel `TargetType` in core; templates kept for compat in F-021. Registry in F-022/F-023. |
-| Name + dep-type `Validator` | `:validation` | **Yes** | Keep framework; Android content rules as plugins |
-| Content validators (`onlyAllowResources`, …) | `:android` + `:validation` helpers | **Split** | Generic dir checks → core; Android paths → android plugin |
+| Target type identity (`TargetTemplate` / suffix) | `:target` + `:core` (F-021) | **Yes** | Parallel `TargetType` in core; templates kept for compat. Registry in F-023. |
+| Name + dep-type `Validator` | `:core` + `:validation` facade (F-022) | **Yes** | Core `TargetValidator` + factories; legacy API re-exports |
+| Content validators (`onlyAllowResources`, …) | core predicates + `:android` helpers (F-022) | **Split** | Pure `ContentRule` in core; Gradle listing + Android helpers in platform |
| Dependency model + apply | `:deps` | **Mostly yes** | Strip AGP-ish config features; catalog generators may stay tooling |
| Settings store | `:config` | **Split** | Generic `SettingsStore` / plugin registry → core; `AndroidProjectSettings` → android |
| Owners | `:owners` | **Optional / yes** | Platform-agnostic metadata |
@@ -323,9 +324,9 @@ Suggested extraction order (tickets F-020…F-024):
1. Document public API — **done (F-020):** [`forma-core-api.md`](forma-core-api.md)
(types, restriction graph, validator SPI, target registry, coords preview,
`library` suffix decision).
-2. Create `plugins/core` + TargetType + RestrictionGraph + wire Android matrix from DEPENDENCY-MATRIX (F-021). Parallel types + facades; full move later.
-3. Re-home `applyDependencies` project-validation path on core validators (F-022).
-4. Leave `:android` as the first platform package implementing templates + AGP features (F-023).
+2. Create `plugins/core` + TargetType + RestrictionGraph + wire Android matrix from DEPENDENCY-MATRIX — **done (F-021)**.
+3. Validation SPI + content predicates in core; `:validation` thin facade — **done (F-022)**.
+4. Wire Android DSL / registry as first consumer of core (F-023); sample stays green.
5. Coordinates: e.g. `tools.forma:core` vs `tools.forma.android` (F-024).
---
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 4b5449b8..ea7302e2 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -2,6 +2,30 @@
Newest entries first.
+## 2026-07-13 — F-022 Extract validation framework into forma-core
+
+- **Ticket:** F-022 → `done`
+- **Branch:** `forma/F-022-validation-framework` (from `origin/v2`)
+- **Skills/modes:** Grok Build `--mode full` (plan design → implement; implement hit max-turns); Hermes finished docs, OnlyLayout empty-list parity, builds, commit/PR
+- **Core (`tools.forma.core.validation`):**
+ - `TargetValidator` SPI + `AcceptAny`
+ - `dependencyTypeValidator` / `selfTypeValidator` with **identity cache** (F-017)
+ - `FormaValidationException` (recognizable suffix messages)
+ - Pure `ContentRule` + `NoResourcesUnderMain` / `OnlyResourcesUnderMain` / `OnlyLayoutResources`
+ - Unit tests: `TargetValidatorTest`, `ContentRuleTest`
+- **Facades:**
+ - `:validation` `Validator`/`validator(TargetTemplate…)` → core; still throws `ProjectValidationError`
+ - `FormaTarget` implements `TargetRef`; `:target`/`:deps` depend on `:core`
+ - Android `commonValidators.kt` lists dirs via Gradle, checks via core rules
+- **Docs:** ARCHITECTURE graph/table/extraction; forma-core-api status + checklist; README Progress; TICKETS
+- **Verify (OpenJDK 17 + env-mac.sh):**
+ - `plugins/`: `./gradlew :core:test` → **BUILD SUCCESSFUL**
+ - `plugins/`: `./gradlew build` → **BUILD SUCCESSFUL** (68 tasks)
+ - `application/`: `./gradlew :binary:assembleDebug` → **BUILD SUCCESSFUL** (578 tasks)
+- **Commits/PRs:** this run — push + PR base `v2`
+- **Blockers:** none (Grok implement max-turns; tree completed by Hermes verify/docs)
+- **Next step:** F-023 Wire Android as first consumer of forma-core (registry / DSL)
+
## 2026-07-13 — Local maven publishing for plugin testing
- **Scope:** tooling for F-018 AGP/Gradle smoke tests + external consumers
diff --git a/docs/forma-core-api.md b/docs/forma-core-api.md
index c000faa3..61ca4169 100644
--- a/docs/forma-core-api.md
+++ b/docs/forma-core-api.md
@@ -1,12 +1,13 @@
# forma-core public API design (F-020)
-Design-only ticket. This document is the contract for extraction tickets
-**F-021…F-024**. It is grounded in the live plugins under `plugins/`
-(`:target`, `:validation`, `:deps`, `:config`, `:owners`, `:android`) and in
-[`ARCHITECTURE.md`](ARCHITECTURE.md) §6 + [`VISION.md`](VISION.md).
+Design ticket contract for extraction **F-021…F-024**. Grounded in the live
+plugins under `plugins/` and in [`ARCHITECTURE.md`](ARCHITECTURE.md) §6 +
+[`VISION.md`](VISION.md).
-**Status:** accepted design for workers. Code moves land in later tickets;
-do **not** treat this file as already-implemented packages.
+**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). Registry /
+DSL consumption remains **F-023**.
---
@@ -401,7 +402,7 @@ Bazel adapter (later): same registry + restriction graph; replace
|--------|--------|
| **F-020** (this doc) | Public API design only |
| **F-021** | Create `plugins/core` (or move packages); target types + **restriction graph** + wire Android matrix data; keep binary/API facades so sample builds |
-| **F-022** | Move validation SPI, default suffix validators, content predicates; Android content helpers call core |
+| **F-022** | **Done:** validation SPI, identity-cached factories, content predicates in `plugins/core`; Android helpers call core; `:validation` facade |
| **F-023** | Android DSL uses registry; delete duplicated allow-lists from individual `*.kt` entrypoints where safe; sample green |
| **F-024** | Publishing coordinates, README/Portal metadata, deprecate old plugin jars if merged |
@@ -413,15 +414,15 @@ Do not skip to JVM targets (F-030) until F-023 is done.
**Must ship in forma-core v1**
-- [ ] `TargetType`, `TargetRef`, `NameMatcher` / `SuffixNameMatcher`
-- [ ] `TargetRegistry` + `TargetRegistration` + `DefaultTargetRegistry`
-- [ ] `RestrictionGraph` / `RestrictionRule` / `EdgeKind`
-- [ ] `TargetValidator`, `AcceptAny`, `dependencyTypeValidator`, `selfTypeValidator`
-- [ ] Content rule interfaces + `NoResourcesUnderMain` / `OnlyResourcesUnderMain`
-- [ ] Validation error type + message helpers (suffix lists)
+- [x] `TargetType`, `TargetRef`, `NameMatcher` / `SuffixNameMatcher` (F-021)
+- [ ] `TargetRegistry` + `TargetRegistration` + `DefaultTargetRegistry` (F-023)
+- [x] `RestrictionGraph` / `RestrictionRule` / `EdgeKind` (F-021)
+- [x] `TargetValidator`, `AcceptAny`, `dependencyTypeValidator`, `selfTypeValidator` (F-022)
+- [x] Content rule interfaces + `NoResourcesUnderMain` / `OnlyResourcesUnderMain` / `OnlyLayoutResources` (F-022)
+- [x] Validation error type + message helpers (suffix lists) — core `FormaValidationException`; facade keeps `ProjectValidationError` (F-022)
- [ ] `SettingsStore`, `PluginBindingStore` (from today’s config interfaces)
- [ ] Dependency model types needed for project-edge validation
-- [ ] Identity-cached validator factory (F-017 behavior)
+- [x] Identity-cached validator factory (F-017 behavior) (F-022)
**Explicitly deferred**
diff --git a/plugins/android/src/main/java/tools/forma/android/validation/commonValidators.kt b/plugins/android/src/main/java/tools/forma/android/validation/commonValidators.kt
index f4af28a4..7417bfa9 100644
--- a/plugins/android/src/main/java/tools/forma/android/validation/commonValidators.kt
+++ b/plugins/android/src/main/java/tools/forma/android/validation/commonValidators.kt
@@ -2,29 +2,63 @@ package tools.forma.android.validation
import org.gradle.api.Project
import java.io.File
-import tools.forma.validation.validateDirectoryContent
+import tools.forma.core.validation.NoResourcesUnderMain
+import tools.forma.core.validation.OnlyLayoutResources
+import tools.forma.core.validation.OnlyResourcesUnderMain
+import tools.forma.validation.buildException
+// validateDirectoryContent kept in :validation as Gradle-coupled facade (core ContentRule is pure)
-fun Project.disallowResources() = validateDirectoryContent(
- dir = "./src/main",
- errorMsg = "Please make sure this does not contain `res` directory"
-) { files ->
- files.filter(File::isDirectory)
- .map { it.name }
- .run { !contains("res") }
+/**
+ * Gradle-coupled content helpers. Pure predicates live in core (F-022); listing + error wrapping
+ * uses the validation facade so behavior and messages for existing DSLs are unchanged.
+ */
+
+fun Project.disallowResources() {
+ val dir = "./src/main"
+ val files = file(dir).listFiles() ?: throw buildException(project.name, "'$dir' does not exists")
+ val names = files.filter(File::isDirectory).map { it.name }
+ val failure = NoResourcesUnderMain.check(names)
+ if (failure != null) {
+ throw buildException(
+ project.name,
+ """$failure
+ |Current list of files in $dir:
+ |${files.joinToString("\n") { it.name }}
+ """.trimMargin()
+ )
+ }
}
-fun Project.onlyAllowResources() = validateDirectoryContent(
- dir = "./src/main",
- errorMsg = "Please make sure this target only contains `res` folder in `src/main`"
-) {
- it.filter(File::isDirectory)
- .run { size == 1 && first().name == "res" }
+fun Project.onlyAllowResources() {
+ val dir = "./src/main"
+ val files = file(dir).listFiles() ?: throw buildException(project.name, "'$dir' does not exists")
+ val names = files.filter(File::isDirectory).map { it.name }
+ val failure = OnlyResourcesUnderMain.check(names)
+ if (failure != null) {
+ throw buildException(
+ project.name,
+ """$failure
+ |Current list of files in $dir:
+ |${files.joinToString("\n") { it.name }}
+ """.trimMargin()
+ )
+ }
}
-fun Project.onlyAllowLayouts() = validateDirectoryContent(
- dir = "./src/main/res",
- errorMsg = "Please make sure this target only contains `layout.*` folders in `src/main/res`"
-) {
- it.filter(File::isDirectory)
- .all { it.name.startsWith("layout") }
+fun Project.onlyAllowLayouts() {
+ val dir = "./src/main/res"
+ val files = file(dir).listFiles() ?: throw buildException(project.name, "'$dir' does not exists")
+ val names = files.filter(File::isDirectory).map { it.name }
+ val failure = OnlyLayoutResources.check(names)
+ if (failure != null) {
+ throw buildException(
+ project.name,
+ """$failure
+ |Current list of files in $dir:
+ |${files.joinToString("\n") { it.name }}
+ """.trimMargin()
+ )
+ }
}
+
+// Note: validateDirectoryContent remains in tools.forma.validation for any external Gradle-coupled usage.
diff --git a/plugins/core/src/main/java/tools/forma/core/validation/ContentRule.kt b/plugins/core/src/main/java/tools/forma/core/validation/ContentRule.kt
new file mode 100644
index 00000000..7ee09def
--- /dev/null
+++ b/plugins/core/src/main/java/tools/forma/core/validation/ContentRule.kt
@@ -0,0 +1,47 @@
+package tools.forma.core.validation
+
+/**
+ * Pure predicate for directory content validation (no Gradle APIs).
+ * Implementations receive relative file/dir names under the inspected root (e.g. "src/main" or "src/main/res").
+ * @return null if content is acceptable, otherwise a human-readable failure reason.
+ */
+fun interface ContentRule {
+ fun check(filesUnderRoot: List): String?
+}
+
+/** Rejects any "res" directory under the inspected root (e.g. src/main). */
+object NoResourcesUnderMain : ContentRule {
+ override fun check(filesUnderRoot: List): String? =
+ if (filesUnderRoot.any { it == "res" }) {
+ "Please make sure this does not contain `res` directory"
+ } else {
+ null
+ }
+}
+
+/** Requires the inspected root to contain exactly one "res" directory and nothing else. */
+object OnlyResourcesUnderMain : ContentRule {
+ override fun check(filesUnderRoot: List): String? {
+ val dirs = filesUnderRoot
+ return if (dirs.size == 1 && dirs.first() == "res") {
+ null
+ } else {
+ "Please make sure this target only contains `res` folder in `src/main`"
+ }
+ }
+}
+
+/**
+ * For res/ inspection: every top-level entry under res must start with "layout".
+ * Empty list is OK (matches historical `list.all { startsWith("layout") }` on empty).
+ */
+object OnlyLayoutResources : ContentRule {
+ override fun check(filesUnderRoot: List): String? {
+ val allLayouts = filesUnderRoot.all { it.startsWith("layout") }
+ return if (allLayouts) {
+ null
+ } else {
+ "Please make sure this target only contains `layout.*` folders in `src/main/res`"
+ }
+ }
+}
diff --git a/plugins/core/src/main/java/tools/forma/core/validation/TargetValidator.kt b/plugins/core/src/main/java/tools/forma/core/validation/TargetValidator.kt
new file mode 100644
index 00000000..59e47c07
--- /dev/null
+++ b/plugins/core/src/main/java/tools/forma/core/validation/TargetValidator.kt
@@ -0,0 +1,93 @@
+package tools.forma.core.validation
+
+import tools.forma.core.target.NameMatcher
+import tools.forma.core.target.SuffixNameMatcher
+import tools.forma.core.target.TargetRef
+import tools.forma.core.target.TargetType
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * SPI for validating a target (by name against allowed types or other rules).
+ * Core is pure; Gradle/AGP concerns stay in platform plugins.
+ */
+fun interface TargetValidator {
+ fun validate(target: TargetRef)
+}
+
+/** No-op validator that accepts any target. Useful for composition roots during transition. */
+object AcceptAny : TargetValidator {
+ override fun validate(target: TargetRef) = Unit
+}
+
+/**
+ * Factory for a validator that accepts dependency (or self) names matching ANY of the allowed TargetTypes.
+ *
+ * Identity-cached (F-017): calling with the same TargetType instance(s) returns the exact same
+ * validator instance (===). Keys are by TargetType reference for singles and by content-equal
+ * list for multis (preserves cache behavior of legacy ConcurrentHashMap on templates).
+ */
+fun dependencyTypeValidator(
+ allowed: Collection,
+ nameMatcher: NameMatcher = SuffixNameMatcher,
+): TargetValidator {
+ if (allowed.isEmpty()) return AcceptAny
+ if (allowed.size == 1) {
+ val only = allowed.first()
+ return singleValidators.getOrPut(only) { SingleTypeValidator(only, nameMatcher) }
+ }
+ val key = allowed.toList()
+ return multiValidators.getOrPut(key) { MultiTypeValidator(key.toTypedArray(), nameMatcher) }
+}
+
+/**
+ * Factory for self-type validation: the target's own name must match the expected type's suffix rule.
+ * Reuses the single-type cache for efficiency.
+ */
+fun selfTypeValidator(
+ expected: TargetType,
+ nameMatcher: NameMatcher = SuffixNameMatcher,
+): TargetValidator = dependencyTypeValidator(listOf(expected), nameMatcher)
+
+/** Exception thrown by core validators on mismatch. Message shape kept close to legacy for recognizability. */
+class FormaValidationException(message: String) : RuntimeException(message)
+
+// ---- internal cached impls ----
+
+private val singleValidators = ConcurrentHashMap()
+private val multiValidators = ConcurrentHashMap, TargetValidator>()
+
+private class SingleTypeValidator(
+ private val type: TargetType,
+ private val matcher: NameMatcher
+) : TargetValidator {
+ override fun validate(target: TargetRef) {
+ if (matcher.matches(target.name, type)) return
+ throw FormaValidationException(
+ """
+ Project ${target.name}: name does not match allowed target type(s)
+ Allowed name suffix(es): ${type.nameSuffix}
+ (Used for self-type checks and project-dependency type checks.)
+ """.trimIndent()
+ )
+ }
+}
+
+private class MultiTypeValidator(
+ private val types: Array,
+ private val matcher: NameMatcher
+) : TargetValidator {
+ override fun validate(target: TargetRef) {
+ val name = target.name
+ for (t in types) {
+ if (matcher.matches(name, t)) return
+ }
+ val allowed = types.joinToString { it.nameSuffix }
+ throw FormaValidationException(
+ """
+ Project $name: name does not match allowed target type(s)
+ Allowed name suffix(es): $allowed
+ (Used for self-type checks and project-dependency type checks.)
+ """.trimIndent()
+ )
+ }
+}
diff --git a/plugins/core/src/test/java/tools/forma/core/validation/ContentRuleTest.kt b/plugins/core/src/test/java/tools/forma/core/validation/ContentRuleTest.kt
new file mode 100644
index 00000000..20945674
--- /dev/null
+++ b/plugins/core/src/test/java/tools/forma/core/validation/ContentRuleTest.kt
@@ -0,0 +1,56 @@
+package tools.forma.core.validation
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class ContentRuleTest {
+
+ @Test
+ fun `NoResourcesUnderMain accepts clean tree`() {
+ assertNull(NoResourcesUnderMain.check(listOf("java", "kotlin", "AndroidManifest.xml")))
+ }
+
+ @Test
+ fun `NoResourcesUnderMain rejects when res present`() {
+ val msg = NoResourcesUnderMain.check(listOf("java", "res", "kotlin"))
+ assertEquals("Please make sure this does not contain `res` directory", msg)
+ }
+
+ @Test
+ fun `OnlyResourcesUnderMain accepts exactly res`() {
+ assertNull(OnlyResourcesUnderMain.check(listOf("res")))
+ }
+
+ @Test
+ fun `OnlyResourcesUnderMain rejects other or multiple`() {
+ assertEquals(
+ "Please make sure this target only contains `res` folder in `src/main`",
+ OnlyResourcesUnderMain.check(listOf("java"))
+ )
+ assertEquals(
+ "Please make sure this target only contains `res` folder in `src/main`",
+ OnlyResourcesUnderMain.check(listOf("res", "java"))
+ )
+ assertEquals(
+ "Please make sure this target only contains `res` folder in `src/main`",
+ OnlyResourcesUnderMain.check(emptyList())
+ )
+ }
+
+ @Test
+ fun `OnlyLayoutResources accepts only layout dirs`() {
+ assertNull(OnlyLayoutResources.check(listOf("layout", "layout-land", "layout-sw600dp")))
+ }
+
+ @Test
+ fun `OnlyLayoutResources rejects non-layout under res`() {
+ val msg = OnlyLayoutResources.check(listOf("layout", "drawable", "values"))
+ assertEquals("Please make sure this target only contains `layout.*` folders in `src/main/res`", msg)
+ }
+
+ @Test
+ fun `OnlyLayoutResources accepts empty (historical all-on-empty)`() {
+ assertNull(OnlyLayoutResources.check(emptyList()))
+ }
+}
diff --git a/plugins/core/src/test/java/tools/forma/core/validation/TargetValidatorTest.kt b/plugins/core/src/test/java/tools/forma/core/validation/TargetValidatorTest.kt
new file mode 100644
index 00000000..2a5f80b0
--- /dev/null
+++ b/plugins/core/src/test/java/tools/forma/core/validation/TargetValidatorTest.kt
@@ -0,0 +1,111 @@
+package tools.forma.core.validation
+
+import tools.forma.core.target.SuffixNameMatcher
+import tools.forma.core.target.TargetRef
+import tools.forma.core.target.targetType
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class TargetValidatorTest {
+
+ private data class TestRef(override val name: String) : TargetRef
+
+ private val api = targetType("android.api", "api")
+ private val impl = targetType("android.impl", "impl")
+ private val library = targetType("android.library", "library")
+ private val res = targetType("android.res", "res")
+
+ @Test
+ fun `AcceptAny never throws`() {
+ AcceptAny.validate(TestRef("anything"))
+ AcceptAny.validate(TestRef("impl"))
+ // no exception
+ }
+
+ @Test
+ fun `dependencyTypeValidator accepts exact suffix match`() {
+ val v = dependencyTypeValidator(listOf(api))
+ v.validate(TestRef("api"))
+ }
+
+ @Test
+ fun `dependencyTypeValidator accepts dashed suffix match`() {
+ val v = dependencyTypeValidator(listOf(impl))
+ v.validate(TestRef("feature-characters-impl"))
+ v.validate(TestRef("my-impl"))
+ }
+
+ @Test
+ fun `dependencyTypeValidator rejects wrong suffix with recognizable message`() {
+ val v = dependencyTypeValidator(listOf(api))
+ val ex = assertFailsWith {
+ v.validate(TestRef("impl"))
+ }
+ assertTrue("name does not match allowed target type(s)" in ex.message.orEmpty())
+ assertTrue("Allowed name suffix(es): api" in ex.message.orEmpty())
+ }
+
+ @Test
+ fun `dependencyTypeValidator multi-type allow list`() {
+ val v = dependencyTypeValidator(listOf(api, library))
+ v.validate(TestRef("api"))
+ v.validate(TestRef("library"))
+ v.validate(TestRef("core-library"))
+ val ex = assertFailsWith {
+ v.validate(TestRef("impl"))
+ }
+ assertTrue("Allowed name suffix(es): api, library" in ex.message.orEmpty())
+ }
+
+ @Test
+ fun `selfTypeValidator enforces single expected type`() {
+ val v = selfTypeValidator(impl)
+ v.validate(TestRef("impl"))
+ v.validate(TestRef("my-feature-impl"))
+ assertFailsWith {
+ v.validate(TestRef("api"))
+ }
+ }
+
+ @Test
+ fun `identity cache returns same validator instance for same TargetType ref (===)`() {
+ val t = targetType("test.foo", "foo")
+ val v1 = dependencyTypeValidator(listOf(t))
+ val v2 = dependencyTypeValidator(listOf(t))
+ assertSame(v1, v2, "same TargetType instance must yield === validator")
+
+ val vs1 = selfTypeValidator(t)
+ val vs2 = selfTypeValidator(t)
+ assertSame(vs1, vs2)
+ }
+
+ @Test
+ fun `identity cache for multi also returns cached instance`() {
+ val t1 = targetType("x.a", "a")
+ val t2 = targetType("x.b", "b")
+ val list = listOf(t1, t2)
+ val v1 = dependencyTypeValidator(list)
+ val v2 = dependencyTypeValidator(list) // same list ref
+ assertSame(v1, v2)
+
+ // different list obj but equal content should also hit (CHM + List.equals)
+ val v3 = dependencyTypeValidator(listOf(t1, t2))
+ assertSame(v1, v3)
+ }
+
+ @Test
+ fun `custom NameMatcher is honored`() {
+ val strict = object : tools.forma.core.target.NameMatcher {
+ override fun matches(projectName: String, type: tools.forma.core.target.TargetType): Boolean =
+ projectName == type.nameSuffix
+ }
+ val v = dependencyTypeValidator(listOf(api), strict)
+ v.validate(TestRef("api"))
+ assertFailsWith {
+ v.validate(TestRef("foo-api"))
+ }
+ }
+}
diff --git a/plugins/deps/build.gradle.kts b/plugins/deps/build.gradle.kts
index 6ffc7c0a..51c687f4 100644
--- a/plugins/deps/build.gradle.kts
+++ b/plugins/deps/build.gradle.kts
@@ -8,6 +8,8 @@ formaPublishedPlugin(name = "deps")
dependencies {
implementation(project(":validation"))
implementation(project(":target"))
+ // F-022: FormaTarget now implements TargetRef from core; expose for Kotlin hierarchy resolution in consumers of FormaTarget (e.g. applyDependencies)
+ implementation(project(":core"))
implementation(project(":config"))
implementation(gradleKotlinDsl())
diff --git a/plugins/target/build.gradle.kts b/plugins/target/build.gradle.kts
index 6b9912aa..d08f631b 100644
--- a/plugins/target/build.gradle.kts
+++ b/plugins/target/build.gradle.kts
@@ -6,5 +6,7 @@ plugins {
formaPublishedPlugin(name = "target")
dependencies {
+ // F-022: FormaTarget implements TargetRef from core; TargetTemplate remains compat layer
+ implementation(project(":core"))
implementation(gradleApi())
}
diff --git a/plugins/target/src/main/java/tools/forma/target/FormaTarget.kt b/plugins/target/src/main/java/tools/forma/target/FormaTarget.kt
index 59130dab..a7e0b31a 100644
--- a/plugins/target/src/main/java/tools/forma/target/FormaTarget.kt
+++ b/plugins/target/src/main/java/tools/forma/target/FormaTarget.kt
@@ -1,7 +1,12 @@
package tools.forma.target
import org.gradle.api.Project
+import tools.forma.core.target.TargetRef
-class FormaTarget(val project: Project) {
- val name: String = project.name
+/**
+ * Gradle adapter for a target. Implements core TargetRef (name sufficient for validation).
+ * TargetTemplate remains the legacy suffix-based template for DSL compat (F-022).
+ */
+class FormaTarget(val project: Project) : TargetRef {
+ override val name: String = project.name
}
diff --git a/plugins/validation/build.gradle.kts b/plugins/validation/build.gradle.kts
index eda85d77..ef3757c3 100644
--- a/plugins/validation/build.gradle.kts
+++ b/plugins/validation/build.gradle.kts
@@ -6,6 +6,8 @@ plugins {
formaPublishedPlugin(name = "validation")
dependencies {
+ // F-022: core owns validation SPI + pure rules; validation is Gradle compat facade
+ implementation(project(":core"))
implementation(project(":target"))
implementation(gradleApi())
}
diff --git a/plugins/validation/src/main/java/tools/forma/validation/Validator.kt b/plugins/validation/src/main/java/tools/forma/validation/Validator.kt
index eaa778d0..708e5e25 100644
--- a/plugins/validation/src/main/java/tools/forma/validation/Validator.kt
+++ b/plugins/validation/src/main/java/tools/forma/validation/Validator.kt
@@ -4,6 +4,11 @@ import tools.forma.validation.error.ProjectValidationError
import tools.forma.target.FormaTarget
import tools.forma.target.TargetTemplate
import org.gradle.api.Project
+import tools.forma.core.target.targetType
+import tools.forma.core.validation.FormaValidationException
+import tools.forma.core.validation.TargetValidator as CoreValidator
+import tools.forma.core.validation.dependencyTypeValidator as coreDependencyTypeValidator
+import java.util.concurrent.ConcurrentHashMap
interface Validator {
fun validate(target: FormaTarget)
@@ -24,49 +29,57 @@ fun FormaTarget.validate(target: TargetTemplate) {
/**
* Name-suffix validator for project dependency / self-type checks.
*
- * Validators are **identity-cached** for a given set of [TargetTemplate] instances so
- * multi-module configuration does not allocate a fresh anonymous [Validator] (and
- * intermediate lists) on every `impl` / `api` / … call (F-017 / GH #106).
+ * Validators are **identity-cached** (F-017) for a given set of [TargetTemplate] instances.
+ * Thin facade over core `dependencyTypeValidator` (which also uses identity caching on TargetType).
+ * Legacy call sites and exception types (ProjectValidationError) are preserved exactly.
*/
fun validator(vararg targets: TargetTemplate): Validator {
if (targets.isEmpty()) return EmptyValidator
if (targets.size == 1) {
val only = targets[0]
- return singleValidators.getOrPut(only) { SingleSuffixValidator(only) }
+ return singleValidators.getOrPut(only) { LegacySingleValidator(only) }
}
- // Identity-based key: TargetTemplate objects are singletons in Forma.
+ // Identity-based key using the original TargetTemplate objects (singletons).
val key = targets.toList()
- return multiValidators.getOrPut(key) { MultiSuffixValidator(targets.copyOf()) }
+ return multiValidators.getOrPut(key) { LegacyMultiValidator(targets.copyOf()) }
}
-private val singleValidators = java.util.concurrent.ConcurrentHashMap()
-private val multiValidators = java.util.concurrent.ConcurrentHashMap, Validator>()
+private val singleValidators = ConcurrentHashMap()
+private val multiValidators = ConcurrentHashMap, Validator>()
-private class SingleSuffixValidator(
+/** Stable mapping from legacy template -> synthetic TargetType for core delegation + cache hits. */
+private val legacyTypeCache = ConcurrentHashMap()
+private fun legacyTypeFor(t: TargetTemplate): tools.forma.core.target.TargetType =
+ legacyTypeCache.getOrPut(t) { targetType("legacy.${t.suffix}", t.suffix) }
+
+private class LegacySingleValidator(
private val template: TargetTemplate
) : Validator {
- private val suffix: String = template.suffix
- private val dashSuffix: String = "-$suffix"
+ private val coreType = legacyTypeFor(template)
+ private val coreV: CoreValidator = coreDependencyTypeValidator(listOf(coreType))
override fun validate(target: FormaTarget) {
- val name = target.name
- if (name == suffix || name.endsWith(dashSuffix)) return
- throwProjectValidationError(name, listOf(template))
+ try {
+ coreV.validate(target) // FormaTarget implements TargetRef
+ } catch (ex: FormaValidationException) {
+ // Preserve exact legacy exception type + message shape for all consumers
+ throwProjectValidationError(target.name, listOf(template))
+ }
}
}
-private class MultiSuffixValidator(
+private class LegacyMultiValidator(
private val templates: Array
) : Validator {
- private val suffixes: Array = Array(templates.size) { templates[it].suffix }
- private val dashSuffixes: Array = Array(suffixes.size) { "-${suffixes[it]}" }
+ private val coreTypes = templates.map { legacyTypeFor(it) }
+ private val coreV: CoreValidator = coreDependencyTypeValidator(coreTypes)
override fun validate(target: FormaTarget) {
- val name = target.name
- for (i in suffixes.indices) {
- if (name == suffixes[i] || name.endsWith(dashSuffixes[i])) return
+ try {
+ coreV.validate(target)
+ } catch (ex: FormaValidationException) {
+ throwProjectValidationError(target.name, templates.asList())
}
- throwProjectValidationError(name, templates.asList())
}
}