From 6c32adaf9813a5adb31435b72736779d0e7d6c5a Mon Sep 17 00:00:00 2001 From: Claw Date: Mon, 13 Jul 2026 05:23:23 -0700 Subject: [PATCH] F-022: extract validation framework into plugins/core Move TargetValidator SPI, identity-cached factories, ContentRule predicates, and FormaValidationException into tools.forma.core.validation. Keep tools.forma.validation as a thin facade (ProjectValidationError parity) and wire Android content helpers to pure core rules. Sample + plugins builds green. --- README.md | 1 + TICKETS.md | 2 +- docs/ARCHITECTURE.md | 31 ++--- docs/PROGRESS.md | 24 ++++ docs/forma-core-api.md | 29 ++--- .../android/validation/commonValidators.kt | 74 ++++++++---- .../forma/core/validation/ContentRule.kt | 47 ++++++++ .../forma/core/validation/TargetValidator.kt | 93 +++++++++++++++ .../forma/core/validation/ContentRuleTest.kt | 56 +++++++++ .../core/validation/TargetValidatorTest.kt | 111 ++++++++++++++++++ plugins/deps/build.gradle.kts | 2 + plugins/target/build.gradle.kts | 2 + .../java/tools/forma/target/FormaTarget.kt | 9 +- plugins/validation/build.gradle.kts | 2 + .../java/tools/forma/validation/Validator.kt | 55 +++++---- 15 files changed, 465 insertions(+), 73 deletions(-) create mode 100644 plugins/core/src/main/java/tools/forma/core/validation/ContentRule.kt create mode 100644 plugins/core/src/main/java/tools/forma/core/validation/TargetValidator.kt create mode 100644 plugins/core/src/test/java/tools/forma/core/validation/ContentRuleTest.kt create mode 100644 plugins/core/src/test/java/tools/forma/core/validation/TargetValidatorTest.kt 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()) } }