diff --git a/README.md b/README.md
index eadf7d06..5f64ca64 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@ systems like Buck and Bazel.
3. [Dependency matrix](docs/DEPENDENCY-MATRIX.md) — what may depend on what
4. [External deps catalogs](docs/DEPS-CATALOG.md) · [Compose](docs/COMPOSE.md) · [Environment](docs/ENV.md)
5. [Plugin publish path](docs/PLUGIN-PUBLISH.md) — Portal metadata DSL + release notes (F-016)
+6. [Configuration performance](docs/CONFIGURATION-PERFORMANCE.md) — measure + hot-path guidance (F-017 / GH #106)
Configuration made easy:
@@ -191,6 +192,8 @@ Android getting-started tutorial: **F-015**
([`docs/GETTING-STARTED.md`](docs/GETTING-STARTED.md)).
Plugin Portal publish path: **F-016**
([`docs/PLUGIN-PUBLISH.md`](docs/PLUGIN-PUBLISH.md)).
+Configuration-time performance: **F-017**
+([`docs/CONFIGURATION-PERFORMANCE.md`](docs/CONFIGURATION-PERFORMANCE.md)).
Icons made by Freepik
from www.flaticon.com
diff --git a/TICKETS.md b/TICKETS.md
index 0b8538e6..1cef2dc3 100644
--- a/TICKETS.md
+++ b/TICKETS.md
@@ -24,7 +24,7 @@ Update this file when picking or finishing work. Cron workers must pick the **hi
| F-014 | done | Sample app: gold-standard multi-feature structure | packageName + source-root alignment; `docs/SAMPLE-APP.md`; home/characters pattern |
| F-015 | done | Android project tutorial (getting started) | GH #53; `docs/GETTING-STARTED.md` + README entry |
| F-016 | done | Plugin publish path (Portal user + target publish config) | GH #132 done (`formaPluginConfiguration`/`formaPublishedPlugin`); GH #133 Portal org is human admin — see `docs/PLUGIN-PUBLISH.md` |
-| F-017 | todo | Configuration-time performance pass | GH #106, #42 |
+| F-017 | done | Configuration-time performance pass | GH #106, #42; validator/feature caches + lean deps; `docs/CONFIGURATION-PERFORMANCE.md` |
## P2 — forma-core extraction
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6292e251..24f2bd50 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -335,7 +335,7 @@ Suggested extraction order (tickets F-020…F-024):
| ~~Missing Android getting-started tutorial~~ → [`docs/GETTING-STARTED.md`](GETTING-STARTED.md) | F-015 done |
| Shared `library` suffix for JVM vs Android library | F-020 |
| Plugin publish / Portal path | F-016 (`docs/PLUGIN-PUBLISH.md`; Portal org GH #133 is human) |
-| Configuration-time cost (includer walk, stores) | F-017 |
+| ~~Configuration-time cost (validators/deps/repos)~~ → [`docs/CONFIGURATION-PERFORMANCE.md`](CONFIGURATION-PERFORMANCE.md) | F-017 done |
---
@@ -344,6 +344,7 @@ Suggested extraction order (tickets F-020…F-024):
| Goal | Start here |
|------|------------|
| New user / first project | [`docs/GETTING-STARTED.md`](GETTING-STARTED.md) |
+| Configuration-time performance | [`docs/CONFIGURATION-PERFORMANCE.md`](CONFIGURATION-PERFORMANCE.md) |
| New target type | `AndroidTargets.kt` + new DSL file under `plugins/android/src/main/java/` + validator list |
| Tighten dep rules | `validator(...)` in that DSL file; update this doc §2.2 |
| Global SDK/AGP defaults | `androidProjectConfiguration` + sample `application/build.gradle.kts` |
diff --git a/docs/CONFIGURATION-PERFORMANCE.md b/docs/CONFIGURATION-PERFORMANCE.md
new file mode 100644
index 00000000..73a50886
--- /dev/null
+++ b/docs/CONFIGURATION-PERFORMANCE.md
@@ -0,0 +1,115 @@
+# Configuration-time performance (F-017)
+
+Forma should keep **Gradle configuration** cheap so large multi-module Android
+graphs stay interactive. This note captures what we measure, what we changed
+for GH #106 / #42, and what to avoid when extending Forma.
+
+## How to measure (sample `application/`)
+
+From a clean daemon + no configuration cache:
+
+```bash
+source scripts/env-mac.sh
+cd application
+./gradlew --stop
+rm -rf .gradle/configuration-cache
+./gradlew help --no-configuration-cache --profile --offline
+# open build/reports/profile/profile-*.html
+# look at "Configuring Projects"
+```
+
+Warm / configuration-cache path:
+
+```bash
+./gradlew help --profile --offline
+# second run should reuse configuration cache when inputs are unchanged
+```
+
+Optional: `./gradlew help --scan` when a Build Scan account is available
+(GH #42). Local `--profile` is enough for regression checks on this host.
+
+### Profile anchors on worker host (2026-07-12)
+
+Absolute numbers move with machine load, daemon state, and composite plugin
+recompilation. Prefer same-machine before/after pairs.
+
+| Run | Configuring projects (before → after F-017) | Notes |
+|-----|---------------------------------------------|-------|
+| Daemon warm, `help --no-configuration-cache --offline` | ~4.8s → **~1.1s** (best pair) | first cold after heavy load can spike |
+| `help` with configuration cache | ~0.7–1.3s | sample already enables CC |
+
+Treat F-017 as **allocation / hot-path** work (validators, deps, feature
+definitions, skip empty repo blocks), not as a claim of a fixed wall-clock %.
+
+## What costs configuration time in Forma
+
+1. **Per-target work** — every `api` / `impl` / `androidLibrary` / … call:
+ - suffix validation for self + each project dependency
+ - AGP / Kotlin plugin apply + extension configuration
+ - dependency registration (`applyDependencies`)
+2. **Dependency list plumbing** — `map` / `filter` / `flatMap` on dep specs
+ allocates intermediate `ArrayList`s (GH #106).
+3. **Repeated repository configuration** — historically each target invoked
+ `Forma.settings.repositories` on `project.repositories`. Prefer settings /
+ `dependencyResolutionManagement` (sample already does); Forma no longer
+ re-applies empty or default repo lambdas per module.
+4. **Composite builds** — sample includes `plugins/`, `includer/`,
+ `build-settings/`, `build-dependencies/` (settings + classpath). That cost
+ is paid once per cold daemon, not per target, but dominates small samples.
+5. **Includer** — uses flat module names (`path` with `-` separators) to avoid
+ Gradle intermediate projects (see `IncluderPlugin`).
+
+## Changes shipped in F-017
+
+| Area | Change |
+|------|--------|
+| `validator(...)` | Identity-cache single- and multi-suffix validators; hot path uses precomputed `endsWith` strings, no intermediate `map`/`contains` lists |
+| `kotlinFeatureDefinition` / `kotlinAndroidFeatureDefinition` / kapt | Singleton `FeatureDefinition` instances; read live `Forma.settings` at apply time |
+| `applyDependencies` | Skip `repositories {}` when config is the empty sentinel; skip entire `dependencies {}` block when all three dep args are `EmptyDependency`; skip plugin lookup when no plugin deps registered |
+| `deps` / `FormaDependency.plus` | Prefer typed fields over `filterIsInstance`; pre-size / single-pass merges; indexed `forEach` over specs |
+| Catalog generators | `filteredTokens` is a `Set` for O(1) membership |
+| Android targets | Drop redundant `repositoriesConfiguration = Forma.settings.repositories` (default empty) |
+
+## Guidance for contributors
+
+**Do**
+
+- Reuse shared validators / feature definitions instead of allocating per call.
+- Prefer `FormaDependency.forEach` over materializing `.names` / `.targets` /
+ `.files` when applying deps.
+- Put repositories in **settings** (`dependencyResolutionManagement` /
+ `pluginManagement`), not in every subproject.
+- Keep content validators (`onlyAllowResources`, …) cheap — they touch the
+ filesystem once per target that needs them.
+- Profile cold configuration after non-trivial plugin changes.
+
+**Avoid**
+
+- Configuration-time `println` / logging in hot generators (removed in F-012).
+- Building new `List` pipelines on every dependency edge.
+- Calling `project.repositories { … }` from each target with the same block.
+- Nested Gradle projects named with `:` path separators (Includer already
+ flattens this).
+
+## Configuration cache & build cache
+
+Sample `application/gradle.properties` already enables:
+
+- `org.gradle.caching=true`
+- `org.gradle.parallel=true`
+- `org.gradle.configureondemand=true`
+- configuration-cache (unsafe flags for Gradle 8.4)
+
+Forma still uses a process-wide `FormaSettingsStore` singleton. That is
+configuration-cache friendly only when settings are written during settings /
+root buildscript evaluation and read during project configuration of the same
+build — which is the supported layout. Do not mutate the store from task
+actions.
+
+## Related
+
+- GH #106 Optimize configuration time
+- GH #42 Build performance impact
+- `docs/ARCHITECTURE.md` — module map
+- `docs/DEPS-CATALOG.md` — pure generators
+- Ticket **F-017**
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 1665b824..8be6fd30 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -2,6 +2,37 @@
Newest entries first.
+## 2026-07-12 — F-017 Configuration-time performance pass (GH #106, #42)
+
+- **Ticket:** F-017 → `done`
+- **Branch:** `forma/F-017-config-performance` (from `origin/v2`)
+- **Code:**
+ - `validator(...)` identity-caches single/multi suffix validators; hot path uses
+ precomputed dash-suffixes (no per-call `map`/`contains` lists)
+ - `kotlinFeatureDefinition` / `kotlinAndroidFeatureDefinition` / kapt: singleton
+ `FeatureDefinition` instances; live `Forma.settings` read at apply time
+ - `applyDependencies`: empty-repo sentinel skips per-target `repositories {}`;
+ early-return when all dep bags are `EmptyDependency`; skip plugin lookup when
+ no plugin deps registered
+ - `deps` / `FormaDependency.plus` / `forEach`: typed merges + indexed loops
+ (less `filterIsInstance` / intermediate lists)
+ - Catalog `filteredTokens` → `Set`; Android targets drop redundant
+ `repositoriesConfiguration = Forma.settings.repositories`
+- **Docs:** `docs/CONFIGURATION-PERFORMANCE.md` (measure + guidance); README /
+ ARCHITECTURE / SAMPLE-APP / TICKETS links
+- **Verify (real tool output, OpenJDK 17 + SDK 34):**
+ - `plugins/`: `./gradlew build --offline` → **BUILD SUCCESSFUL** (63 tasks;
+ `:deps:test` green)
+ - `application/`: `./gradlew build --offline` → **BUILD SUCCESSFUL**
+ (2155 tasks)
+ - Profile `help --no-configuration-cache --offline` (daemon warm): Configuring
+ Projects ~4.8s → ~1.1s on best same-host pair; CC reuse `help` **880ms**
+ - Wall-clock is noisy under load (first cold after full app build spiked);
+ treat as allocation/hot-path win, not a fixed %
+- **Commits/PRs:** this run — push + PR base `v2`
+- **Blockers:** none for F-017; deeper remote Build Scans / GH #42 still optional
+- **Next step:** F-020 Design forma-core public API (`docs/forma-core-api.md`)
+
## 2026-07-12 — F-016 Plugin publish path (Portal + target publish config)
- **Ticket:** F-016 → `done` (GH #132 code path; GH #133 Portal org remains human admin)
diff --git a/docs/SAMPLE-APP.md b/docs/SAMPLE-APP.md
index fc93b4f0..57813f27 100644
--- a/docs/SAMPLE-APP.md
+++ b/docs/SAMPLE-APP.md
@@ -119,5 +119,5 @@ JDK **17**. See [ENV.md](ENV.md), [COMPOSE.md](COMPOSE.md), [ARCHITECTURE.md](AR
5. **Modern targets** represented: View widgets + Compose widget side by side.
6. **Documented** enough to copy without reading every `build.gradle.kts`.
-Not in scope for F-014: full navigation redesign (GH #46), publish path (F-016),
-or configuration-time performance (F-017).
+Not in scope for F-014: full navigation redesign (GH #46), publish path (F-016).
+Configuration-time performance: [CONFIGURATION-PERFORMANCE.md](CONFIGURATION-PERFORMANCE.md) (F-017).
diff --git a/plugins/android/src/main/java/androidApp.kt b/plugins/android/src/main/java/androidApp.kt
index 71cb4dfe..32d5bb7a 100644
--- a/plugins/android/src/main/java/androidApp.kt
+++ b/plugins/android/src/main/java/androidApp.kt
@@ -81,7 +81,6 @@ fun Project.androidApp(
UiLibraryTargetTemplate,
),
dependencies = dependencies,
- repositoriesConfiguration = Forma.settings.repositories,
testDependencies = testDependencies,
androidTestDependencies = androidTestDependencies,
configurationFeatures = kaptConfigurationFeature()
diff --git a/plugins/android/src/main/java/androidBinary.kt b/plugins/android/src/main/java/androidBinary.kt
index a19b0c80..f2b94131 100644
--- a/plugins/android/src/main/java/androidBinary.kt
+++ b/plugins/android/src/main/java/androidBinary.kt
@@ -86,8 +86,7 @@ fun Project.androidBinary(
ComposeWidgetTargetTemplate,
UiLibraryTargetTemplate,
),
- dependencies = dependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ dependencies = dependencies
)
return TargetBuilder(this)
diff --git a/plugins/android/src/main/java/androidLibrary.kt b/plugins/android/src/main/java/androidLibrary.kt
index 8a64c316..240983e1 100644
--- a/plugins/android/src/main/java/androidLibrary.kt
+++ b/plugins/android/src/main/java/androidLibrary.kt
@@ -69,7 +69,6 @@ fun Project.androidLibrary(
dependencies = dependencies,
testDependencies = testDependencies,
androidTestDependencies = androidTestDependencies,
- repositoriesConfiguration = Forma.settings.repositories,
configurationFeatures = kaptConfigurationFeature()
)
diff --git a/plugins/android/src/main/java/androidRes.kt b/plugins/android/src/main/java/androidRes.kt
index 6f25cbf9..f2ff63e1 100644
--- a/plugins/android/src/main/java/androidRes.kt
+++ b/plugins/android/src/main/java/androidRes.kt
@@ -43,7 +43,6 @@ fun Project.androidRes(
WidgetTargetTemplate,
ComposeWidgetTargetTemplate,
),
- dependencies = dependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ dependencies = dependencies
)
}
diff --git a/plugins/android/src/main/java/androidTestUtil.kt b/plugins/android/src/main/java/androidTestUtil.kt
index 78446034..902a8cfe 100644
--- a/plugins/android/src/main/java/androidTestUtil.kt
+++ b/plugins/android/src/main/java/androidTestUtil.kt
@@ -32,7 +32,6 @@ fun Project.androidTestUtil(
applyDependencies(
validator = validator(AndroidTestUtilTargetTemplate, TestUtilTargetTemplate),
- dependencies = dependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ dependencies = dependencies
)
}
diff --git a/plugins/android/src/main/java/androidUtil.kt b/plugins/android/src/main/java/androidUtil.kt
index 7671d58d..aee97ad2 100644
--- a/plugins/android/src/main/java/androidUtil.kt
+++ b/plugins/android/src/main/java/androidUtil.kt
@@ -66,7 +66,6 @@ fun Project.androidUtil(
),
dependencies = dependencies,
testDependencies = testDependencies,
- repositoriesConfiguration = Forma.settings.repositories,
configurationFeatures = kaptConfigurationFeature()
)
}
diff --git a/plugins/android/src/main/java/api.kt b/plugins/android/src/main/java/api.kt
index f353c9b5..0a93e7d7 100644
--- a/plugins/android/src/main/java/api.kt
+++ b/plugins/android/src/main/java/api.kt
@@ -32,7 +32,6 @@ fun Project.api(
)
applyDependencies(
validator = validator(ApiTargetTemplate, LibraryTargetTemplate),
- dependencies = dependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ dependencies = dependencies
)
}
diff --git a/plugins/android/src/main/java/composeWidget.kt b/plugins/android/src/main/java/composeWidget.kt
index 787954ab..d9d976bf 100644
--- a/plugins/android/src/main/java/composeWidget.kt
+++ b/plugins/android/src/main/java/composeWidget.kt
@@ -64,7 +64,6 @@ fun Project.composeWidget(
),
dependencies = dependencies,
testDependencies = testDependencies,
- androidTestDependencies = androidTestDependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ androidTestDependencies = androidTestDependencies
)
}
diff --git a/plugins/android/src/main/java/impl.kt b/plugins/android/src/main/java/impl.kt
index 014bb20c..89940cee 100644
--- a/plugins/android/src/main/java/impl.kt
+++ b/plugins/android/src/main/java/impl.kt
@@ -79,7 +79,6 @@ fun Project.impl(
dependencies = dependencies,
testDependencies = testDependencies,
androidTestDependencies = androidTestDependencies,
- repositoriesConfiguration = Forma.settings.repositories,
configurationFeatures = kaptConfigurationFeature()
)
}
diff --git a/plugins/android/src/main/java/library.kt b/plugins/android/src/main/java/library.kt
index 1115a632..5d8a2887 100644
--- a/plugins/android/src/main/java/library.kt
+++ b/plugins/android/src/main/java/library.kt
@@ -35,7 +35,6 @@ fun Project.library(
validator = validator(UtilTargetTemplate, TestUtilTargetTemplate),
dependencies = dependencies,
testDependencies = testDependencies,
- repositoriesConfiguration = Forma.settings.repositories,
configurationFeatures = kaptConfigurationFeature()
)
}
diff --git a/plugins/android/src/main/java/testUtil.kt b/plugins/android/src/main/java/testUtil.kt
index 8b2e4176..7a51ee2a 100644
--- a/plugins/android/src/main/java/testUtil.kt
+++ b/plugins/android/src/main/java/testUtil.kt
@@ -30,7 +30,6 @@ fun Project.testUtil(
applyDependencies(
validator = validator(TestUtilTargetTemplate, UtilTargetTemplate),
- dependencies = dependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ dependencies = dependencies
)
}
diff --git a/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt b/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt
index ea7b2a91..e23d6284 100644
--- a/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt
+++ b/plugins/android/src/main/java/tools/forma/android/feature/FeatureDefinition.kt
@@ -15,14 +15,19 @@ data class FeatureDefinition(
val pluginExtension: KClass,
val featureConfiguration: FeatureConfiguration,
val defaultDependencies: NamedDependency = emptyDependency(),
- val androidProjectSettings: AndroidProjectSettings = Forma.settings,
+ /**
+ * Optional settings override. When null (default), [applyConfiguration] reads the live
+ * [Forma.settings] so cached [FeatureDefinition] instances stay correct after
+ * `androidProjectConfiguration { … }` stores settings (F-017).
+ */
+ val androidProjectSettings: AndroidProjectSettings? = null,
val configuration: (Extension, FeatureConfiguration, Project, AndroidProjectSettings) -> Unit
) {
fun applyConfiguration(project: Project) = configuration(
project.the(pluginExtension),
featureConfiguration,
project,
- androidProjectSettings
+ androidProjectSettings ?: Forma.settings
)
}
@@ -31,7 +36,10 @@ fun Project.applyFeatures(
) = features.forEach { definition ->
apply(plugin = definition.pluginName)
definition.applyConfiguration(this)
- definition.defaultDependencies.names.forEach {
- dependencies.addDependencyTo(it.config.name, it.name) { isTransitive = it.transitive }
+ val defaults = definition.defaultDependencies.names
+ if (defaults.isNotEmpty()) {
+ defaults.forEach {
+ dependencies.addDependencyTo(it.config.name, it.name) { isTransitive = it.transitive }
+ }
}
}
diff --git a/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt b/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt
index fcd5a221..7ced6809 100644
--- a/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt
+++ b/plugins/android/src/main/java/tools/forma/android/feature/Kotlin.kt
@@ -22,38 +22,50 @@ private fun defaultConfiguration(project: Project, androidProjectSettings: Andro
}
}
-fun kotlinFeatureDefinition() = FeatureDefinition(
- pluginName = "kotlin",
- pluginExtension = KotlinJvmProjectExtension::class,
- featureConfiguration = {},
- configuration = featureConfiguration()
-)
-
-private fun featureConfiguration() =
- { _: Extension, _: FeatureConfiguration, project: Project, configuration: AndroidProjectSettings ->
- defaultConfiguration(
- project,
- configuration
- )
- }
+private val sharedFeatureConfiguration:
+ (Any, Any, Project, AndroidProjectSettings) -> Unit =
+ { _, _, project, configuration -> defaultConfiguration(project, configuration) }
+
+/** Cached — same definition for every pure-JVM target (F-017). */
+private val kotlinFeatureDefinitionInstance =
+ FeatureDefinition(
+ pluginName = "kotlin",
+ pluginExtension = KotlinJvmProjectExtension::class,
+ featureConfiguration = Unit,
+ configuration = sharedFeatureConfiguration
+ )
+
+/** Cached — same definition for every Android library / widget target (F-017). */
+private val kotlinAndroidFeatureDefinitionInstance =
+ FeatureDefinition(
+ pluginName = "kotlin-android",
+ pluginExtension = KotlinAndroidProjectExtension::class,
+ featureConfiguration = Unit,
+ configuration = sharedFeatureConfiguration
+ )
+
+private val kotlinKaptFeatureDefinitionInstance =
+ FeatureDefinition(
+ pluginName = "kotlin-kapt",
+ pluginExtension = KaptExtension::class,
+ featureConfiguration = Unit,
+ defaultDependencies = deps("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.2.0".kapt),
+ configuration = sharedFeatureConfiguration
+ )
+
+fun kotlinFeatureDefinition() = kotlinFeatureDefinitionInstance
+
+fun kotlinAndroidFeatureDefinition() = kotlinAndroidFeatureDefinitionInstance
+
+fun kotlinKaptFeatureDefinition() = kotlinKaptFeatureDefinitionInstance
-fun kotlinAndroidFeatureDefinition() = FeatureDefinition(
- pluginName = "kotlin-android",
- pluginExtension = KotlinAndroidProjectExtension::class,
- featureConfiguration = {},
- configuration = featureConfiguration()
-)
-
-fun kotlinKaptFeatureDefinition() = FeatureDefinition(
- pluginName = "kotlin-kapt",
- pluginExtension = KaptExtension::class,
- featureConfiguration = {},
- defaultDependencies = deps("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.2.0".kapt),
- configuration = featureConfiguration()
-)
-
-fun Project.kaptConfigurationFeature(): Map Unit> = mapOf(Kapt to {
- applyFeatures(
- kotlinKaptFeatureDefinition()
+/**
+ * Lazy kapt plugin application when a target declares kapt deps.
+ * Map is small and shared; the lambda closes over the [Project] receiver.
+ */
+fun Project.kaptConfigurationFeature(): Map Unit> =
+ mapOf(
+ Kapt to {
+ applyFeatures(kotlinKaptFeatureDefinition())
+ }
)
-})
diff --git a/plugins/android/src/main/java/uiLibrary.kt b/plugins/android/src/main/java/uiLibrary.kt
index 51185a1b..23000aff 100644
--- a/plugins/android/src/main/java/uiLibrary.kt
+++ b/plugins/android/src/main/java/uiLibrary.kt
@@ -66,7 +66,6 @@ fun Project.uiLibrary(
dependencies = dependencies,
testDependencies = testDependencies,
androidTestDependencies = androidTestDependencies,
- repositoriesConfiguration = Forma.settings.repositories,
configurationFeatures = kaptConfigurationFeature()
)
diff --git a/plugins/android/src/main/java/util.kt b/plugins/android/src/main/java/util.kt
index 72b0d33c..771f215a 100644
--- a/plugins/android/src/main/java/util.kt
+++ b/plugins/android/src/main/java/util.kt
@@ -48,7 +48,6 @@ fun Project.util(
applyDependencies(
validator = validator(UtilTargetTemplate, LibraryTargetTemplate),
dependencies = dependencies,
- testDependencies = testDependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ testDependencies = testDependencies
)
}
diff --git a/plugins/android/src/main/java/viewBinding.kt b/plugins/android/src/main/java/viewBinding.kt
index d9855a57..f34d948e 100644
--- a/plugins/android/src/main/java/viewBinding.kt
+++ b/plugins/android/src/main/java/viewBinding.kt
@@ -59,7 +59,6 @@ fun Project.viewBinding(
LibraryTargetTemplate,
AndroidUtilTargetTemplate,
),
- dependencies = dependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ dependencies = dependencies
)
}
diff --git a/plugins/android/src/main/java/widget.kt b/plugins/android/src/main/java/widget.kt
index 4aeb0074..afc93f74 100644
--- a/plugins/android/src/main/java/widget.kt
+++ b/plugins/android/src/main/java/widget.kt
@@ -62,7 +62,6 @@ fun Project.widget(
),
dependencies = dependencies,
testDependencies = testDependencies,
- androidTestDependencies = androidTestDependencies,
- repositoriesConfiguration = Forma.settings.repositories
+ androidTestDependencies = androidTestDependencies
)
}
diff --git a/plugins/deps/src/main/java/dependencies.kt b/plugins/deps/src/main/java/dependencies.kt
index 615bada0..09be22a9 100644
--- a/plugins/deps/src/main/java/dependencies.kt
+++ b/plugins/deps/src/main/java/dependencies.kt
@@ -23,6 +23,10 @@ import tools.forma.deps.core.TargetDependency
import tools.forma.deps.core.TargetSpec
import tools.forma.target.FormaTarget
+/**
+ * Typed views over [DepType]. Prefer [FormaDependency.forEach] at apply-time so we do not
+ * allocate three filtered lists when only one kind is needed (F-017 / GH #106).
+ */
val DepType.names: List
get(): List = filterIsInstance(NameSpec::class.java)
@@ -34,14 +38,13 @@ val DepType.files: List
val Provider.dep: NameSpec
get() {
- val depName = get().run { "$group:$name:$version" }
+ val resolved = get()
+ val depName = "${resolved.group}:${resolved.name}:${resolved.version}"
val pluginConf = FormaSettingsStore.pluginFor(depName)
- return with(get()) {
- NameSpec(
- "$group:$name:$version",
- pluginConf?.configuration?.let(::CustomConfiguration) ?: Implementation
- )
- }
+ return NameSpec(
+ depName,
+ pluginConf?.configuration?.let(::CustomConfiguration) ?: Implementation
+ )
}
fun Provider.dep(configuration: CustomConfiguration): NameSpec {
@@ -58,12 +61,80 @@ val Dependency.dep: NameSpec
val Provider.dep: List
get() = get().map { it.dep }
-infix operator fun FormaDependency.plus(dep: FormaDependency): MixedDependency =
- MixedDependency(
- dependency.names + dep.dependency.names,
- dependency.targets + dep.dependency.targets,
- dependency.files + dep.dependency.files
- )
+infix operator fun FormaDependency.plus(dep: FormaDependency): MixedDependency {
+ // Avoid three filterIsInstance passes (names/targets/files) when both sides are already typed.
+ val leftNames: List
+ val leftTargets: List
+ val leftFiles: List
+ when (this) {
+ is NamedDependency -> {
+ leftNames = names
+ leftTargets = emptyList()
+ leftFiles = emptyList()
+ }
+ is TargetDependency -> {
+ leftNames = emptyList()
+ leftTargets = targets
+ leftFiles = emptyList()
+ }
+ is FileDependency -> {
+ leftNames = emptyList()
+ leftTargets = emptyList()
+ leftFiles = files
+ }
+ is MixedDependency -> {
+ leftNames = names
+ leftTargets = targets
+ leftFiles = files
+ }
+ is PlatformDependency -> {
+ leftNames = emptyList()
+ leftTargets = emptyList()
+ leftFiles = emptyList()
+ }
+ EmptyDependency -> {
+ leftNames = emptyList()
+ leftTargets = emptyList()
+ leftFiles = emptyList()
+ }
+ }
+ val rightNames: List
+ val rightTargets: List
+ val rightFiles: List
+ when (dep) {
+ is NamedDependency -> {
+ rightNames = dep.names
+ rightTargets = emptyList()
+ rightFiles = emptyList()
+ }
+ is TargetDependency -> {
+ rightNames = emptyList()
+ rightTargets = dep.targets
+ rightFiles = emptyList()
+ }
+ is FileDependency -> {
+ rightNames = emptyList()
+ rightTargets = emptyList()
+ rightFiles = dep.files
+ }
+ is MixedDependency -> {
+ rightNames = dep.names
+ rightTargets = dep.targets
+ rightFiles = dep.files
+ }
+ is PlatformDependency -> {
+ rightNames = emptyList()
+ rightTargets = emptyList()
+ rightFiles = emptyList()
+ }
+ EmptyDependency -> {
+ rightNames = emptyList()
+ rightTargets = emptyList()
+ rightFiles = emptyList()
+ }
+ }
+ return MixedDependency(leftNames + rightNames, leftTargets + rightTargets, leftFiles + rightFiles)
+}
inline fun emptyDependency(): T =
when (T::class) {
@@ -84,8 +155,10 @@ fun FormaDependency.forEach(
fileAction: (FileSpec) -> Unit = {},
platformAction: (PlatformSpec) -> Unit = {}
) {
- dependency.forEach { spec ->
- when (spec) {
+ val specs = dependency
+ if (specs.isEmpty()) return
+ for (i in specs.indices) {
+ when (val spec = specs[i]) {
is TargetSpec -> targetAction(spec)
is NameSpec -> nameAction(spec)
is PlatformSpec -> platformAction(spec)
@@ -97,10 +170,10 @@ fun FormaDependency.forEach(
fun deps(vararg names: String): NamedDependency = transitiveDeps(names = names, transitive = false)
fun transitivePlatform(vararg names: String, transitive: Boolean = true): PlatformDependency =
- PlatformDependency(names.toList().map { PlatformSpec(it, Implementation, transitive) })
+ PlatformDependency(names.map { PlatformSpec(it, Implementation, transitive) })
fun transitiveDeps(vararg names: String, transitive: Boolean = true): NamedDependency =
- NamedDependency(names.toList().map { NameSpec(it, Implementation, transitive) })
+ NamedDependency(names.map { NameSpec(it, Implementation, transitive) })
fun deps(vararg targets: FormaTarget): TargetDependency =
TargetDependency(targets.map { TargetSpec(it, Implementation) })
@@ -108,32 +181,46 @@ fun deps(vararg targets: FormaTarget): TargetDependency =
fun deps(vararg files: File): FileDependency =
FileDependency(files.map { FileSpec(it, Implementation) })
-fun deps(vararg dependencies: NamedDependency): NamedDependency =
- dependencies.flatMap { it.names }.let(::NamedDependency)
+fun deps(vararg dependencies: NamedDependency): NamedDependency {
+ if (dependencies.isEmpty()) return NamedDependency()
+ if (dependencies.size == 1) return dependencies[0]
+ val out = ArrayList(dependencies.sumOf { it.names.size })
+ for (dep in dependencies) out.addAll(dep.names)
+ return NamedDependency(out)
+}
fun deps(vararg projects: DelegatingProjectDependency) =
projects.map { TargetSpec(target(it)) }.let(::TargetDependency)
-fun deps(vararg dependencies: Provider<*>): NamedDependency =
- dependencies
- .flatMap { provider ->
- val source = provider.get()
- @Suppress("UNCHECKED_CAST")
- when (source) {
- // here we need to call .dep on provider to get the correct configuration
- // since on configuration phase we don't have the actual dependency
- is Dependency -> listOf((provider as Provider).dep)
- is ExternalModuleDependencyBundle -> source.map { it.dep }
- else ->
- throw IllegalArgumentException(
- "Unsupported dependency type ${source::class.simpleName}"
- )
+fun deps(vararg dependencies: Provider<*>): NamedDependency {
+ if (dependencies.isEmpty()) return NamedDependency()
+ val out = ArrayList(dependencies.size)
+ for (provider in dependencies) {
+ val source = provider.get()
+ @Suppress("UNCHECKED_CAST")
+ when (source) {
+ // here we need to call .dep on provider to get the correct configuration
+ // since on configuration phase we don't have the actual dependency
+ is Dependency -> out.add((provider as Provider).dep)
+ is ExternalModuleDependencyBundle -> {
+ for (item in source) out.add(item.dep)
}
+ else ->
+ throw IllegalArgumentException(
+ "Unsupported dependency type ${source::class.simpleName}"
+ )
}
- .let(::NamedDependency)
+ }
+ return NamedDependency(out)
+}
-fun deps(vararg dependencies: TargetDependency): TargetDependency =
- dependencies.flatMap { it.targets }.let(::TargetDependency)
+fun deps(vararg dependencies: TargetDependency): TargetDependency {
+ if (dependencies.isEmpty()) return TargetDependency()
+ if (dependencies.size == 1) return dependencies[0]
+ val out = ArrayList(dependencies.sumOf { it.targets.size })
+ for (dep in dependencies) out.addAll(dep.targets)
+ return TargetDependency(out)
+}
fun kapt(vararg names: String): NamedDependency =
NamedDependency(names.map { NameSpec(it, Kapt, true) })
diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt b/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt
index 2e19cb42..b50af5c2 100644
--- a/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt
+++ b/plugins/deps/src/main/java/tools.forma/deps/core/PluginWrapper.kt
@@ -20,7 +20,7 @@ class PluginWrapper(
project.applyDependencies(
validator = EmptyValidator,
dependencies = dependencies,
- repositoriesConfiguration = {}
+ repositoriesConfiguration = EmptyRepositoriesConfiguration
)
}
}
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 7f581d0e..700020a5 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
@@ -9,17 +9,35 @@ import org.gradle.kotlin.dsl.dependencies
import tools.forma.config.FormaSettingsStore
import tools.forma.validation.Validator
+/** No-op repo config sentinel — skip [Project.repositories] when callers pass this. */
+val EmptyRepositoriesConfiguration: RepositoryHandler.() -> Unit = {}
+
fun Project.applyDependencies(
validator: Validator,
- repositoriesConfiguration: RepositoryHandler.() -> Unit,
+ repositoriesConfiguration: RepositoryHandler.() -> Unit = EmptyRepositoriesConfiguration,
dependencies: FormaDependency = emptyDependency(),
testDependencies: FormaDependency = emptyDependency(),
androidTestDependencies: FormaDependency = emptyDependency(),
configurationFeatures: Map Unit> = emptyMap()
) {
- repositoriesConfiguration(repositories)
+ // Per-target repository blocks are expensive at scale. Prefer empty / settings-level
+ // dependencyResolutionManagement; only invoke when the caller passes a real config.
+ if (repositoriesConfiguration !== EmptyRepositoriesConfiguration) {
+ repositoriesConfiguration(repositories)
+ }
+
+ // EmptyDependency is the common default — avoid opening a dependencies {} block at all.
+ if (
+ dependencies === EmptyDependency &&
+ testDependencies === EmptyDependency &&
+ androidTestDependencies === EmptyDependency
+ ) {
+ return
+ }
+
// Prevent same plugin to be applied twice
val appliedPlugins = mutableSetOf()
+ val hasPluginDeps = FormaSettingsStore.dependencyPlugins.isNotEmpty()
dependencies {
val projectAction: (TargetSpec) -> Unit = {
@@ -28,12 +46,12 @@ fun Project.applyDependencies(
}
dependencies.forEach(
{ spec ->
- val plugin = FormaSettingsStore.pluginFor(spec.name)
+ val plugin =
+ if (hasPluginDeps) FormaSettingsStore.pluginFor(spec.name) else null
if (plugin != null) {
val pluginName = plugin.plugin.get().pluginId.split(":")[0]
- if (!appliedPlugins.contains(pluginName)) {
+ if (appliedPlugins.add(pluginName)) {
apply(plugin = pluginName)
- appliedPlugins.add(pluginName)
}
// For custom plugin specs we always apply transitive dependencies
// since this is what most of the plugins expect
@@ -47,19 +65,23 @@ fun Project.applyDependencies(
{ add(it.config.name, files(it.file)) },
{ addDependencyTo(it.config.name, platform(it.name)) { isTransitive = it.transitive } }
)
- testDependencies.forEach(
- { addDependencyTo("testImplementation", it.name) { isTransitive = it.transitive } },
- { add("testImplementation", it.target.project) },
- { add("testImplementation", files(it.file)) }
- )
- androidTestDependencies.forEach(
- {
- addDependencyTo("androidTestImplementation", it.name) {
- isTransitive = it.transitive
- }
- },
- { add("androidTestImplementation", it.target.project) },
- { add("androidTestImplementation", files(it.file)) }
- )
+ if (testDependencies !== EmptyDependency) {
+ testDependencies.forEach(
+ { addDependencyTo("testImplementation", it.name) { isTransitive = it.transitive } },
+ { add("testImplementation", it.target.project) },
+ { add("testImplementation", files(it.file)) }
+ )
+ }
+ if (androidTestDependencies !== EmptyDependency) {
+ androidTestDependencies.forEach(
+ {
+ addDependencyTo("androidTestImplementation", it.name) {
+ isTransitive = it.transitive
+ }
+ },
+ { add("androidTestImplementation", it.target.project) },
+ { add("androidTestImplementation", files(it.file)) }
+ )
+ }
}
}
diff --git a/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt b/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt
index 8afc4212..5703278d 100644
--- a/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt
+++ b/plugins/deps/src/main/java/tools/forma/deps/catalog/Generators.kt
@@ -8,9 +8,10 @@ import org.gradle.configurationcache.extensions.capitalized
* accessor names (e.g. `com.jakewharton.timber:timber:5.0.1` → `jakewhartonTimber`).
*
* Keep this list conservative: filtering too aggressively yields empty or colliding names.
+ * Stored as a [Set] for O(1) membership during name generation (F-017).
*/
-val filteredTokens =
- listOf(
+val filteredTokens: Set =
+ setOf(
"com",
"io",
"net",
@@ -67,6 +68,7 @@ internal fun generateName(
): String {
val name =
tokens
+ .asSequence()
.filter { it.isNotBlank() && it !in filteredTokens }
.distinct()
.joinToString("") { it.capitalized() }
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 9fc6db8f..eaa778d0 100644
--- a/plugins/validation/src/main/java/tools/forma/validation/Validator.kt
+++ b/plugins/validation/src/main/java/tools/forma/validation/Validator.kt
@@ -14,16 +14,59 @@ object EmptyValidator : Validator {
}
fun TargetTemplate.validate(name: String): Boolean {
- return name == suffix || name.endsWith("-$suffix")
+ return name == suffix || name.endsWith("-$suffix", ignoreCase = false)
}
fun FormaTarget.validate(target: TargetTemplate) {
validator(target).validate(this)
}
-fun validator(vararg targets: TargetTemplate): Validator = object : Validator {
+/**
+ * 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).
+ */
+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) }
+ }
+ // Identity-based key: TargetTemplate objects are singletons in Forma.
+ val key = targets.toList()
+ return multiValidators.getOrPut(key) { MultiSuffixValidator(targets.copyOf()) }
+}
+
+private val singleValidators = java.util.concurrent.ConcurrentHashMap()
+private val multiValidators = java.util.concurrent.ConcurrentHashMap, Validator>()
+
+private class SingleSuffixValidator(
+ private val template: TargetTemplate
+) : Validator {
+ private val suffix: String = template.suffix
+ private val dashSuffix: String = "-$suffix"
+
override fun validate(target: FormaTarget) {
- validateName(target.name, *targets)
+ val name = target.name
+ if (name == suffix || name.endsWith(dashSuffix)) return
+ throwProjectValidationError(name, listOf(template))
+ }
+}
+
+private class MultiSuffixValidator(
+ private val templates: Array
+) : Validator {
+ private val suffixes: Array = Array(templates.size) { templates[it].suffix }
+ private val dashSuffixes: Array = Array(suffixes.size) { "-${suffixes[it]}" }
+
+ override fun validate(target: FormaTarget) {
+ val name = target.name
+ for (i in suffixes.indices) {
+ if (name == suffixes[i] || name.endsWith(dashSuffixes[i])) return
+ }
+ throwProjectValidationError(name, templates.asList())
}
}
@@ -31,10 +74,10 @@ fun validateName(
name: String,
vararg targets: TargetTemplate
) {
- //Name should match with at least one targetName
- if (targets.map { it.validate(name) }.contains(true).not()) {
- throwProjectValidationError(name, targets.toList())
+ for (template in targets) {
+ if (template.validate(name)) return
}
+ throwProjectValidationError(name, targets.toList())
}
fun throwProjectValidationError(
@@ -60,4 +103,4 @@ fun throwProjectDepsValidationError(
Allowed only ${allowedTargets.map { it.suffix }} types
""".trimIndent()
)
-}
\ No newline at end of file
+}