diff --git a/README.md b/README.md index a73b7648..2061424c 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,11 @@ project-dep type checks + content layout rules from live validators **Canonical reference (code truth):** [`docs/DEPENDENCY-MATRIX.md`](docs/DEPENDENCY-MATRIX.md). That document is generated from each target’s -`applyDependencies(validator = …)` call and content helpers. Summary: +`applyDependencies(validator = …)` call and content helpers. Forked-in vendor +modules (no Forma suffixes): optional project-global +`dependencyValidationExclusions` on `androidProjectConfiguration` only +([F-090](docs/DEPENDENCY-MATRIX.md#dependency-validation-exclusions-f-090--gh-97)) — +never a per-call-site escape hatch. Summary: | Consumer | May depend on project suffixes… | |----------|----------------------------------| diff --git a/TICKETS.md b/TICKETS.md index c523dcfe..adb9b3c8 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -121,7 +121,7 @@ Promoted from daily next-actions / historical GH. Workers pick top `todo` in ord | F-087 | done | AndroidX / SDK ceiling after AGP 9 | compile/target **37**; core **1.19.0**, activity **1.13.0**, lifecycle **2.11.0**, compose **1.11.4**, room **2.8.4**, material **1.14.0**; JVM **11**; navigationevent on activity graph; paging **2.1.2** kept. Host+CI platform 37. | | F-088 | done | Fleet tooling phase 2 (F-084 follow-ups) | Gradle `formaLayoutCheck`/`formaLayoutGenerate` + root `*All` via `tools.forma.deps.fleet.registerFormaLayout` on all packageName DSLs; opt-in `checkPackageLayoutAtConfiguration` (default false); res/viewBinding/binary skip source-dir requirement; docs/skill updated. GH **#54** generate path user-complete (Hermes may close). AST migrate / depgen still deferred. | | F-089 | done | Navigation task cache broken | GH **#110** — upstream Safe Args **2.9.8** already `@PathSensitive(RELATIVE)` on `navigationFiles`; two-location build-cache hit verified on sample `navigationRes`; regression note in `docs/CONFIGURATION-PERFORMANCE.md`. | -| F-090 | todo | Exclude modules from dependency validation | GH **#97** — allow-list / opt-out for forked-in libraries (ExoPlayer-class cases) without weakening default matrix. One global configuration path (not per-call-site escape hatch spam). Docs + sample or unit coverage. | +| F-090 | done | Exclude modules from dependency validation | GH **#97** — `dependencyValidationExclusions` on root `androidProjectConfiguration` / `AndroidProjectSettings`; `applyDependencies` skips project-dep suffix validation for exact path/name matches only. Self-type validation unchanged. Unit tests in `:config` + `:deps`; matrix + PROJECT-CONFIGURATION docs. | | F-091 | todo | BuildFeatures under Forma | GH **#88** — map AGP `BuildFeatures` into Forma (default off where safe); single configuration path aligned with type=rule + project-global defaults; document in CALL-SITE / PROJECT-CONFIGURATION. Do not reintroduce fat call-site plugin shopping. | | F-092 | todo | versionCode / versionName on binary (and app) | GH **#82** — move version identity off global-only `FormaConfiguration`; set on `androidBinary` / `androidApp` as call-site attrs (Gradle limits acknowledged). Sample uses new API; docs. | | F-093 | todo | Remove legacy `.kapt` once unused | After F-086 sample is KSP-only: hard-remove or hard-deprecate `String.kapt` / `kapt()` / `Kapt` configuration path if no in-repo consumers; keep only if a documented external-compat shim is still required. Grep tree + tests + DEPS docs. | diff --git a/application/build.gradle.kts b/application/build.gradle.kts index ded0a364..1766b327 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -10,6 +10,11 @@ buildscript { agpVersion = "9.3.0", // Navigation 2.9 / modern AndroidX ship JVM 11 bytecode — cannot inline into 1.8 javaVersionCompatibility = JavaVersion.VERSION_11, + // F-090 / GH #97: optional allow-list for forked-in third-party modules that do + // not use Forma suffixes. Exact Gradle path and/or project name. Default empty. + // dependencyValidationExclusions = setOf( + // ":third-party:exoplayer:library-core", + // ), extraPlugins = listOf( libs.plugins.toolsFormaDemoDependencies, diff --git a/docs/DEPENDENCY-MATRIX.md b/docs/DEPENDENCY-MATRIX.md index 86b87234..07a87ef6 100644 --- a/docs/DEPENDENCY-MATRIX.md +++ b/docs/DEPENDENCY-MATRIX.md @@ -33,6 +33,51 @@ suffix (`ApiTargetTemplate("api")`). --- +## Dependency validation exclusions (F-090 / GH #97) + +Sometimes a monorepo vendors a **forked third-party tree** (ExoPlayer-class +cases) whose module names do **not** follow Forma suffixes. Those modules +must be dependable from Forma targets without weakening the default matrix +for first-party code. + +**One global configuration path only** — set once on root +`androidProjectConfiguration`: + +```kotlin +buildscript { + androidProjectConfiguration( + project = rootProject, + // ... + dependencyValidationExclusions = setOf( + ":third-party:exoplayer:library-core", // Gradle project path + "forked-media-engine", // or exact project name + ), + ) +} +``` + +| Skips | Does **not** skip | +|-------|-------------------| +| Project-dependency **type/suffix** validation when the **dependency** project is listed | Self-type validation on Forma DSL modules (`target.validate(...)`) | +| | The default allow-list matrix for any **non-listed** dependency | +| | Content layout rules (`disallowResources`, etc.) | + +**Matching:** exact Gradle project **path** (e.g. `:third-party:exoplayer:library-core`) +and/or exact project **name**. No prefix/glob matching. + +**When to use:** forked-in libraries that live in the composite build but are +not Forma targets. + +**When not to use:** never to bypass `impl`↛`impl` (or any other matrix edge) +between first-party Forma modules. Fix the graph or re-slice targets instead. +There is **no** per-call-site `skipValidation` flag. + +Implementation: `FormaSettingsStore.isExcludedFromDependencyValidation` gated +inside `applyDependencies` `projectAction`. Default exclusions = empty (strict +matrix unchanged). See [`PROJECT-CONFIGURATION.md`](PROJECT-CONFIGURATION.md). + +--- + ## Target templates (suffixes) From `plugins/android/.../AndroidTargets.kt`: diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index ddcc7f4a..35f301ad 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -435,7 +435,11 @@ Details: [COMPOSE.md](COMPOSE.md). Sample: - Prefer small targets; keep feature folders consistent. - Put shared non-feature code under `core/` or `common/` (see sample). - When a validator fails, fix the dependency edge or re-slice the target — - don’t disable validation. + don’t disable validation. For **forked-in** third-party modules only, use + project-global `dependencyValidationExclusions` on `androidProjectConfiguration` + (F-090) — see [DEPENDENCY-MATRIX.md](DEPENDENCY-MATRIX.md) and + [PROJECT-CONFIGURATION.md](PROJECT-CONFIGURATION.md). Never use that list to + bypass first-party matrix rules (e.g. `impl`↛`impl`). --- @@ -446,7 +450,7 @@ Details: [COMPOSE.md](COMPOSE.md). Sample: | “Java not found” / old JDK | Use JDK 21 (or 17+ min); export `JAVA_HOME` ([ENV.md](ENV.md)) | | SDK / `compileSdk` errors | Install platform **37** (`platforms;android-37.0` + `android-37` symlink); set `local.properties` `sdk.dir` | | Project not included | Missing `build.gradle.kts`, or nested `settings.gradle.kts` blocked Includer | -| Illegal project dependency | Matrix violation — see [DEPENDENCY-MATRIX.md](DEPENDENCY-MATRIX.md) | +| Illegal project dependency | Matrix violation — see [DEPENDENCY-MATRIX.md](DEPENDENCY-MATRIX.md). Forked vendor modules: global `dependencyValidationExclusions` only (F-090) | | Empty / wrong package | `packageName` ≠ directory under `src/main/java` — run `formaLayoutCheck` / `formaLayoutGenerate` | | Compose compiler mismatch | Align `composeCompilerVersion` with Kotlin (sample: 2.3.21 ↔ 2.3.21) | | AGP resolution conflicts | Align consumer `agpVersion` with plugin AGP line (**9.3.0** today) | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 46b90929..7ffe28cf 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,30 @@ Newest entries first. +## 2026-07-22 — F-090: Exclude modules from dependency validation (GH #97) + +- **Ticket:** F-090 → `done` +- **Branch:** `forma/F-090-validation-exclusions` (from origin/v2) +- **Actions:** + - API: `dependencyValidationExclusions: Set = emptySet()` on + `AndroidProjectSettings` + root `androidProjectConfiguration(...)` + - Matcher: exact Gradle project path and/or name + (`matchesDependencyValidationExclusion`); store helpers null-safe when + settings never written (`FormaSettingsStore.isExcludedFromDependencyValidation`) + - `applyDependencies` `projectAction` skips suffix `validator.validate` only for + excluded dependency projects; still adds the edge. Self-type DSL validation + unchanged. Test/androidTest project deps still unvalidated (pre-existing). + - Unit tests: `:config` matcher/store; `:deps` apply-gate exclude vs non-exclude + - Docs: `DEPENDENCY-MATRIX.md` exclusions section; `PROJECT-CONFIGURATION.md` + parameter; GETTING-STARTED cross-links; sample `application/build.gradle.kts` + commented example +- **Verify (real tool output, `source scripts/env-mac.sh`):** + - `plugins/`: `./gradlew :core:test :validation:test :deps:test :config:test build --no-daemon` → **BUILD SUCCESSFUL** in 26s (80 tasks) + - `application/`: `./gradlew help --no-daemon` + `./gradlew assembleDebug --no-daemon` → **BUILD SUCCESSFUL** in 1m46s (896 tasks) +- **Blockers:** none +- **Next:** F-091 BuildFeatures configuration (GH #88) +- **Commits:** local on `forma/F-090-validation-exclusions` (orchestrator opens PR) + ## 2026-07-22 — F-089: Navigation Safe Args task cache (GH #110) - **Ticket:** F-089 → `done` diff --git a/docs/PROJECT-CONFIGURATION.md b/docs/PROJECT-CONFIGURATION.md index f09929a9..bb68442b 100644 --- a/docs/PROJECT-CONFIGURATION.md +++ b/docs/PROJECT-CONFIGURATION.md @@ -15,6 +15,11 @@ buildscript { agpVersion = "9.3.0", // compose = false, // composeCompilerVersion = "2.3.21", + // F-090: skip project-dep suffix checks only for listed forked-in modules + // dependencyValidationExclusions = setOf( + // ":third-party:exoplayer:library-core", + // "forked-media-engine", + // ), extraPlugins = listOf( // jars / Provider for the *buildscript classpath only* libs.plugins.navigationSafeArgs, @@ -35,6 +40,29 @@ This is the **only** supported way to configure an Android Forma project. Downstream targets and features read global values (SDK versions, `compose` default, `kotlinVersion`, `agpVersion`, etc.) from `Forma.settings` (or the delegated store surfaces). The store is initialized exactly once from the root `buildscript` block. +### `dependencyValidationExclusions` (F-090 / GH #97) + +| | | +|--|--| +| **Type** | `Set` (default `emptySet()`) | +| **Set on** | Root `androidProjectConfiguration(...)` only | +| **Stored as** | `AndroidProjectSettings.dependencyValidationExclusions` | +| **Read by** | `applyDependencies` via `FormaSettingsStore.isExcludedFromDependencyValidation` | + +Lists Gradle **project paths** and/or **project names** that skip **project-dependency +type/suffix validation** when they appear as a dependency of a Forma target. + +**Intended use:** monorepo-vendored forks (ExoPlayer-class) that do not use Forma +name suffixes. + +**Not for:** bypassing the matrix between first-party Forma modules (e.g. `impl`→`impl`). +There is no per-module / per-call-site `skipValidation` flag — one global allow-list only. + +**Matching:** exact path (e.g. `:third-party:exoplayer:library-core`) or exact name. +Does **not** disable self-type validation on Forma DSL entry points. + +See [`DEPENDENCY-MATRIX.md`](DEPENDENCY-MATRIX.md) § Dependency validation exclusions. + ## What it does **not** do - `extraPlugins` (and catalog `plugin(...)` entries) are **classpath only**. They put Gradle plugin jars on the buildscript classpath. They do **not** apply any plugin to your modules. @@ -84,6 +112,7 @@ Readers (feature wiring, dependency helpers, target DSLs) obtain values through - [ ] No calls to any `Project.androidProjectConfiguration` form (removed) - [ ] Child targets rely on `Forma.settings` / per-target overrides only - [ ] Type-owned plugins used for external plugin application (no ad-hoc apply) +- [ ] Forked third-party modules (if any) listed once in `dependencyValidationExclusions` — never per-call-site skips --- diff --git a/plugins/android/src/main/java/androidProjectConfiguration.kt b/plugins/android/src/main/java/androidProjectConfiguration.kt index c12891b6..06b73ced 100644 --- a/plugins/android/src/main/java/androidProjectConfiguration.kt +++ b/plugins/android/src/main/java/androidProjectConfiguration.kt @@ -68,6 +68,11 @@ import tools.forma.deps.fleet.ensureFormaLayoutRootTasks * @param checkPackageLayoutAtConfiguration when true, fail configuration if a target's * `packageName` source dir is missing (default false; see [AndroidProjectSettings] and * docs/FLEET-TOOLING.md). Generate via `formaLayoutGenerate` / `formaLayoutGenerateAll`. + * @param dependencyValidationExclusions project paths and/or names that skip **project- + * dependency** suffix validation when depended on by a Forma target (F-090 / GH #97). + * For forked-in third-party trees that do not use Forma suffixes. Does not weaken the + * default matrix for non-listed modules and does not skip self-type validation on DSL + * targets. Default empty. See [AndroidProjectSettings.dependencyValidationExclusions]. * @param extraPlugins list of extra artifacts / plugin providers to add to the **buildscript classpath only**. * See "Classpath vs apply" in TARGET-PLUGINS.md. */ @@ -85,6 +90,7 @@ fun ScriptHandlerScope.androidProjectConfiguration( mandatoryOwners: Boolean = false, vectorDrawablesUseSupportLibrary: Boolean = false, checkPackageLayoutAtConfiguration: Boolean = false, + dependencyValidationExclusions: Set = emptySet(), extraPlugins: List = emptyList() ) { buildScriptConfiguration( @@ -121,6 +127,7 @@ fun ScriptHandlerScope.androidProjectConfiguration( composeCompilerVersion = composeCompilerVersion, vectorDrawablesUseSupportLibrary = vectorDrawablesUseSupportLibrary, checkPackageLayoutAtConfiguration = checkPackageLayoutAtConfiguration, + dependencyValidationExclusions = dependencyValidationExclusions, ) Forma.store(configuration) diff --git a/plugins/config/build.gradle.kts b/plugins/config/build.gradle.kts index 2202662f..1db87636 100644 --- a/plugins/config/build.gradle.kts +++ b/plugins/config/build.gradle.kts @@ -7,4 +7,11 @@ formaPublishedPlugin(name = "config") dependencies { implementation(gradleApi()) + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() } + diff --git a/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt b/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt index 5df86b0b..91c19083 100644 --- a/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt +++ b/plugins/config/src/main/java/tools/forma/config/AndroidProjectSettings.kt @@ -56,8 +56,37 @@ data class AndroidProjectSettings( * in CI or local strict mode only. */ val checkPackageLayoutAtConfiguration: Boolean = false, + /** + * Project paths or names that skip **project-dependency type/suffix validation** + * when they appear as a dependency of a Forma target (F-090 / GH #97). + * + * Use for forked-in third-party trees (e.g. ExoPlayer) that live in the monorepo + * but do not follow Forma target suffixes. Does **not** disable self-type validation + * on Forma DSL modules and does **not** change the default dependency matrix. + * + * Match by Gradle project path (e.g. `:third-party:exoplayer:library-core`) and/or + * project name. Exact match only (see [matchesDependencyValidationExclusion]). + * Default empty — strict matrix unchanged. + */ + val dependencyValidationExclusions: Set = emptySet(), ) +/** + * Exact-match check for [AndroidProjectSettings.dependencyValidationExclusions]. + * + * @param projectName Gradle [org.gradle.api.Project.getName] + * @param projectPath Gradle [org.gradle.api.Project.getPath] (e.g. `:foo:bar`) + * @param exclusions allow-list from project settings; empty ⇒ never excluded + */ +fun matchesDependencyValidationExclusion( + projectName: String, + projectPath: String, + exclusions: Set, +): Boolean { + if (exclusions.isEmpty()) return false + return projectName in exclusions || projectPath in exclusions +} + /** * Singleton project configuration store, used by internal plugins */ @@ -69,6 +98,32 @@ object FormaSettingsStore : SettingsStore, PluginInfoSto _settings = configuration } + /** + * Whether settings have been written by [store] (false on JVM-only paths that never + * call `androidProjectConfiguration`). + */ + val isSettingsStored: Boolean + get() = this::_settings.isInitialized + + /** + * [AndroidProjectSettings.dependencyValidationExclusions] when settings exist; + * empty set otherwise (null-safe — does not throw). + */ + fun dependencyValidationExclusionsOrEmpty(): Set = + if (isSettingsStored) _settings.dependencyValidationExclusions else emptySet() + + /** + * True when [projectName] or [projectPath] is listed in + * [AndroidProjectSettings.dependencyValidationExclusions]. + * Safe when settings were never stored (treats as no exclusions). + */ + fun isExcludedFromDependencyValidation(projectName: String, projectPath: String): Boolean = + matchesDependencyValidationExclusion( + projectName = projectName, + projectPath = projectPath, + exclusions = dependencyValidationExclusionsOrEmpty(), + ) + override val plugins: MutableMap, PluginConfiguration> = mutableMapOf() override val dependencyPlugins: MutableMap = mutableMapOf() override val dependencies: MutableMap = mutableMapOf() diff --git a/plugins/config/src/test/java/tools/forma/config/DependencyValidationExclusionsTest.kt b/plugins/config/src/test/java/tools/forma/config/DependencyValidationExclusionsTest.kt new file mode 100644 index 00000000..bc10300a --- /dev/null +++ b/plugins/config/src/test/java/tools/forma/config/DependencyValidationExclusionsTest.kt @@ -0,0 +1,158 @@ +package tools.forma.config + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.gradle.api.JavaVersion + +/** + * F-090 / GH #97: global dependency-validation exclusions matcher + store null-safety. + */ +class DependencyValidationExclusionsTest { + + @AfterTest + fun resetStore() { + // Re-store empty settings so later tests do not see leftovers. + // FormaSettingsStore is a process-wide singleton. + FormaSettingsStore.store(minimalSettings(emptySet())) + } + + @Test + fun `empty exclusions never match`() { + assertFalse( + matchesDependencyValidationExclusion( + projectName = "library-core", + projectPath = ":third-party:exoplayer:library-core", + exclusions = emptySet(), + ) + ) + } + + @Test + fun `exact project path is excluded`() { + val exclusions = setOf(":third-party:exoplayer:library-core") + assertTrue( + matchesDependencyValidationExclusion( + projectName = "library-core", + projectPath = ":third-party:exoplayer:library-core", + exclusions = exclusions, + ) + ) + assertFalse( + matchesDependencyValidationExclusion( + projectName = "library-core", + projectPath = ":third-party:exoplayer:library-ui", + exclusions = exclusions, + ) + ) + } + + @Test + fun `exact project name is excluded`() { + val exclusions = setOf("exoplayer-library-core") + assertTrue( + matchesDependencyValidationExclusion( + projectName = "exoplayer-library-core", + projectPath = ":vendor:exoplayer-library-core", + exclusions = exclusions, + ) + ) + assertFalse( + matchesDependencyValidationExclusion( + projectName = "other-module", + projectPath = ":vendor:other-module", + exclusions = exclusions, + ) + ) + } + + @Test + fun `partial path or name is not excluded`() { + val exclusions = setOf(":third-party:exoplayer") + assertFalse( + matchesDependencyValidationExclusion( + projectName = "library-core", + projectPath = ":third-party:exoplayer:library-core", + exclusions = exclusions, + ) + ) + assertFalse( + matchesDependencyValidationExclusion( + projectName = "exoplayer-library-core", + projectPath = ":vendor:exoplayer-library-core", + exclusions = setOf("exoplayer"), + ) + ) + } + + @Test + fun `non-excluded illegal suffix still not matched by unrelated list`() { + // Simulates "feature-home-impl depending on another impl" — exclusion list does not + // cover the dep, so matcher returns false and applyDependencies will still validate. + assertFalse( + matchesDependencyValidationExclusion( + projectName = "feature-other-impl", + projectPath = ":feature:other:impl", + exclusions = setOf(":third-party:exoplayer:library-core"), + ) + ) + } + + @Test + fun `store with empty exclusions never excludes`() { + FormaSettingsStore.store(minimalSettings(emptySet())) + assertFalse( + FormaSettingsStore.isExcludedFromDependencyValidation( + "library-core", + ":tp:library-core", + ) + ) + } + + @Test + fun `store reads exclusions after configuration-equivalent store`() { + FormaSettingsStore.store( + minimalSettings( + setOf( + ":third-party:exoplayer:library-core", + "forked-media-engine", + ) + ) + ) + assertTrue( + FormaSettingsStore.isExcludedFromDependencyValidation( + "library-core", + ":third-party:exoplayer:library-core", + ) + ) + assertTrue( + FormaSettingsStore.isExcludedFromDependencyValidation( + "forked-media-engine", + ":vendor:forked-media-engine", + ) + ) + assertFalse( + FormaSettingsStore.isExcludedFromDependencyValidation( + "feature-home-impl", + ":feature:home:impl", + ) + ) + } + + private fun minimalSettings(exclusions: Set) = + AndroidProjectSettings( + minSdk = 23, + targetSdk = 35, + compileSdk = 35, + kotlinVersion = "2.0.0", + agpVersion = "8.0.0", + repositories = {}, + compose = false, + composeCompilerVersion = "2.0.0", + vectorDrawablesUseSupportLibrary = false, + javaVersionCompatibility = JavaVersion.VERSION_11, + mandatoryOwners = false, + dependencyValidationExclusions = exclusions, + ) +} diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt b/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt index 700020a5..7118f53d 100644 --- a/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt +++ b/plugins/deps/src/main/java/tools.forma/deps/core/applyDependencies.kt @@ -40,9 +40,18 @@ fun Project.applyDependencies( val hasPluginDeps = FormaSettingsStore.dependencyPlugins.isNotEmpty() dependencies { + // F-090: skip project-dep suffix validation only for globally excluded modules + // (forked third-party trees). Self-type validation on DSL entry stays enforced. val projectAction: (TargetSpec) -> Unit = { - validator.validate(it.target) - add(it.config.name, it.target.project) + val depProject = it.target.project + if (!FormaSettingsStore.isExcludedFromDependencyValidation( + projectName = depProject.name, + projectPath = depProject.path, + ) + ) { + validator.validate(it.target) + } + add(it.config.name, depProject) } dependencies.forEach( { spec -> diff --git a/plugins/deps/src/test/kotlin/tools/forma/deps/core/DependencyValidationExclusionApplyTest.kt b/plugins/deps/src/test/kotlin/tools/forma/deps/core/DependencyValidationExclusionApplyTest.kt new file mode 100644 index 00000000..255724ca --- /dev/null +++ b/plugins/deps/src/test/kotlin/tools/forma/deps/core/DependencyValidationExclusionApplyTest.kt @@ -0,0 +1,150 @@ +package tools.forma.deps.core + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.gradle.api.JavaVersion +import tools.forma.config.AndroidProjectSettings +import tools.forma.config.FormaSettingsStore +import tools.forma.config.matchesDependencyValidationExclusion + +/** + * F-090: documents the [applyDependencies] `projectAction` gate — run suffix validation + * only when the dependency project is **not** in the global exclusion set. + * + * Full Gradle Project wiring is heavy; this mirrors the production branch and proves + * excluded deps skip validation while non-excluded illegal names still fail. + */ +class DependencyValidationExclusionApplyTest { + + private var validateInvocations = 0 + + @AfterTest + fun reset() { + FormaSettingsStore.store(minimalSettings(emptySet())) + validateInvocations = 0 + } + + /** + * Same control flow as [applyDependencies] `projectAction`: + * skip validate when excluded; always record the edge (add). + */ + private fun projectActionGate( + projectName: String, + projectPath: String, + validate: (String) -> Unit, + added: MutableList, + ) { + if ( + !FormaSettingsStore.isExcludedFromDependencyValidation( + projectName = projectName, + projectPath = projectPath, + ) + ) { + validateInvocations++ + validate(projectName) + } + added += projectPath + } + + private val matrixValidate: (String) -> Unit = { name -> + // Simulate matrix failure for non-Forma suffixes (ExoPlayer-class names). + if (!name.endsWith("-api") && name != "api") { + error("Illegal project dependency name '$name' (simulated matrix failure)") + } + } + + @Test + fun `excluded dependency skips validator and still records edge`() { + FormaSettingsStore.store( + minimalSettings(setOf(":third-party:exoplayer:library-core")) + ) + val added = mutableListOf() + + projectActionGate( + projectName = "library-core", + projectPath = ":third-party:exoplayer:library-core", + validate = matrixValidate, + added = added, + ) + + assertEquals(0, validateInvocations, "validator must not run for exclusions") + assertEquals(listOf(":third-party:exoplayer:library-core"), added) + } + + @Test + fun `non-excluded illegal suffix still fails validation`() { + FormaSettingsStore.store( + minimalSettings(setOf(":third-party:exoplayer:library-core")) + ) + val added = mutableListOf() + + val ex = + assertFailsWith { + projectActionGate( + projectName = "feature-other-impl", + projectPath = ":feature:other:impl", + validate = matrixValidate, + added = added, + ) + } + assertTrue(ex.message.orEmpty().contains("feature-other-impl")) + assertEquals(1, validateInvocations) + assertTrue(added.isEmpty(), "failed validation should not add (gate throws before add)") + } + + @Test + fun `empty exclusions behave like strict matrix (no skip)`() { + FormaSettingsStore.store(minimalSettings(emptySet())) + assertFalse( + matchesDependencyValidationExclusion( + "library-core", + ":third-party:exoplayer:library-core", + emptySet(), + ) + ) + val added = mutableListOf() + assertFailsWith { + projectActionGate( + projectName = "library-core", + projectPath = ":third-party:exoplayer:library-core", + validate = matrixValidate, + added = added, + ) + } + assertEquals(1, validateInvocations) + } + + @Test + fun `allowed forma suffix still validates when not excluded`() { + FormaSettingsStore.store(minimalSettings(emptySet())) + val added = mutableListOf() + projectActionGate( + projectName = "feature-home-api", + projectPath = ":feature:home:api", + validate = matrixValidate, + added = added, + ) + assertEquals(1, validateInvocations) + assertEquals(listOf(":feature:home:api"), added) + } + + private fun minimalSettings(exclusions: Set) = + AndroidProjectSettings( + minSdk = 23, + targetSdk = 35, + compileSdk = 35, + kotlinVersion = "2.0.0", + agpVersion = "8.0.0", + repositories = {}, + compose = false, + composeCompilerVersion = "2.0.0", + vectorDrawablesUseSupportLibrary = false, + javaVersionCompatibility = JavaVersion.VERSION_11, + mandatoryOwners = false, + dependencyValidationExclusions = exclusions, + ) +}