2026-03-12 21:23:25 -05:00
# Build-Logic Convention Patterns & Guidelines
Quick reference for maintaining and extending the build-logic convention system.
## Core Principles
1. **DRY (Don't Repeat Yourself)** : Extract common configuration into functions
2. **Clarity Over Cleverness** : Explicit intent in `build.gradle.kts` files matters
3. **Single Responsibility** : Each convention plugin has one clear purpose
4. **Test-Driven** : Configuration changes must pass `spotlessCheck` , `detekt` , and tests
## Convention Plugin Architecture
```
build-logic/
├── convention/
│ ├── src/main/kotlin/
2026-03-17 15:35:39 -05:00
│ │ ├── KmpFeatureConventionPlugin.kt # KMP feature modules (composes library + compose + koin + common deps)
│ │ ├── KmpLibraryConventionPlugin.kt # KMP modules: core libraries
│ │ ├── KmpLibraryComposeConventionPlugin.kt # KMP Compose Multiplatform setup
│ │ ├── KmpJvmAndroidConventionPlugin.kt # Opt-in jvmAndroidMain hierarchy for Android + desktop JVM
│ │ ├── AndroidApplicationConventionPlugin.kt # Main app
│ │ ├── AndroidLibraryConventionPlugin.kt # Android-only libraries
2026-03-12 21:23:25 -05:00
│ │ ├── AndroidApplicationComposeConventionPlugin.kt
│ │ ├── AndroidLibraryComposeConventionPlugin.kt
│ │ ├── org/meshtastic/buildlogic/
│ │ │ ├── KotlinAndroid.kt # Base Kotlin/Android config
│ │ │ ├── AndroidCompose.kt # Compose setup
│ │ │ ├── FlavorResolution.kt # Flavor configuration
│ │ │ ├── MeshtasticFlavor.kt # Flavor definitions
│ │ │ ├── Detekt.kt # Static analysis
│ │ │ ├── Spotless.kt # Code formatting
│ │ │ └── ... (other config modules)
```
## How to Add a New Convention
### Example: Adding a new test framework dependency
**Current Pattern (GOOD ✅):**
If all KMP modules need a dependency, add it to `KotlinAndroid.kt::configureKmpTestDependencies()` :
```kotlin
internal fun Project.configureKmpTestDependencies() {
extensions.configure< KotlinMultiplatformExtension > {
sourceSets.apply {
val commonTest = findByName("commonTest") ?: return@apply
commonTest.dependencies {
implementation(kotlin("test"))
// NEW: Add here once, applies to all ~15 KMP modules
implementation(libs.library("new-test-framework"))
}
// ... androidHostTest setup
}
}
}
```
**Result:** All 15 feature and core modules automatically get the dependency ✅
### Example: Adding shared `jvmAndroidMain` code to a KMP module
**Current Pattern (GOOD ✅):**
If a KMP module needs Java/JVM APIs shared between Android and desktop JVM, apply the opt-in convention plugin instead of manually creating source sets and `dependsOn(...)` edges:
```kotlin
plugins {
alias(libs.plugins.meshtastic.kmp.library)
id("meshtastic.kmp.jvm.android")
}
kotlin {
jvm()
android { /* ... */ }
sourceSets {
commonMain.dependencies { /* ... */ }
jvmMain.dependencies { /* jvm-only additions */ }
androidMain.dependencies { /* android-only additions */ }
}
}
```
**Why:** The convention uses Kotlin's hierarchy template API to create `jvmAndroidMain` without the `Default Kotlin Hierarchy Template Not Applied Correctly` warning triggered by hand-written `dependsOn(...)` graphs.
2026-03-17 15:35:39 -05:00
### Example: Creating a new KMP feature module
**Current Pattern (GOOD ✅):**
Use `meshtastic.kmp.feature` for any `feature:*` module. It composes `kmp.library` + `kmp.library.compose` + `koin` and provides all the common Compose/Lifecycle/Koin/Android dependencies that every feature needs:
```kotlin
plugins {
alias(libs.plugins.meshtastic.kmp.feature)
// Optional: add only if this feature needs serialization
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
kotlin {
jvm()
android {
namespace = "org.meshtastic.feature.yourfeature"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
commonMain.dependencies {
// Only module-SPECIFIC deps here
implementation(projects.core.common)
implementation(projects.core.model)
implementation(projects.core.ui)
}
androidMain.dependencies {
// Only Android-specific extras here
}
}
}
```
**What the plugin provides automatically:**
- `commonMain` : `compose-multiplatform-material3` , `compose-multiplatform-materialIconsExtended` , `jetbrains-lifecycle-viewmodel-compose` , `koin-compose-viewmodel` , `kermit`
- `androidMain` : `androidx-compose-bom` (platform), `accompanist-permissions` , `androidx-activity-compose` , `androidx-compose-material3` , `androidx-compose-material-iconsExtended` , `androidx-compose-ui-text` , `androidx-compose-ui-tooling-preview`
- `commonTest` : `core:testing`
**Why:** Eliminates ~15 duplicate dependency declarations per feature module (modelled after Now in Android's `AndroidFeatureImplConventionPlugin` ).
2026-03-12 21:23:25 -05:00
### Example: Adding Android-specific test config
**Pattern:** Add to `AndroidLibraryConventionPlugin.kt` :
```kotlin
extensions.configure< LibraryExtension > {
configureKotlinAndroid(this)
testOptions.apply {
animationsDisabled = true
// NEW: Android-specific test config
unitTests.isIncludeAndroidResources = true
}
}
```
**Alternative:** If it applies to both app and library, consider extracting a function:
```kotlin
internal fun Project.configureAndroidTestOptions() {
extensions.configure< CommonExtension > {
testOptions.apply {
animationsDisabled = true
// Shared test options
}
}
}
```
## Duplication Heuristics
**When to consolidate (DRY):**
- ✅ Configuration appears in 3+ convention plugins
- ✅ The duplication changes together (same reasons to update)
- ✅ Extraction doesn't require complex type gymnastics
- ✅ Underlying Gradle extension is the same (`CommonExtension` )
**When to keep separate (Clarity):**
- ✅ Different Gradle extension types (`ApplicationExtension` vs `LibraryExtension` )
- ✅ Plugin intent is explicit in `build.gradle.kts` usage
- ✅ Duplication is small (< 50 lines ) and stable
- ✅ Future divergence between app/library handling is plausible
**Examples in codebase:**
| Duplication | Status | Reasoning |
|-------------|--------|-----------|
| `AndroidApplicationComposeConventionPlugin` ≈ `AndroidLibraryComposeConventionPlugin` | **Kept Separate** | Different extension types; small duplication; explicit intent |
| `AndroidApplicationFlavorsConventionPlugin` ≈ `AndroidLibraryFlavorsConventionPlugin` | **Kept Separate** | Different extension types; small duplication; explicit intent |
| `configureKmpTestDependencies()` (7 modules) | **Consolidated** | Large duplication; single source of truth; all KMP modules benefit |
| `jvmAndroidMain` hierarchy setup (4 modules) | **Consolidated** | Shared KMP hierarchy pattern; avoids manual `dependsOn(...)` edges and hierarchy warnings |
## Testing Convention Changes
After modifying a convention plugin, verify:
```bash
# 1. Code quality
./gradlew spotlessCheck detekt
# 2. Compilation
./gradlew assembleDebug assembleRelease
# 3. Tests
./gradlew test # All unit tests
./gradlew :feature:messaging:jvmTest # Feature module tests
./gradlew :feature:node:testAndroidHostTest # Android host tests
```
## Documentation Requirements
When you add/modify a convention:
1. **Add Kotlin docs** to the function:
```kotlin
/**
* Configure test dependencies for KMP modules.
*
* Automatically applies kotlin("test") to:
* - commonTest source set (all targets)
* - androidHostTest source set (Android-only)
*
* Usage: Called automatically by KmpLibraryConventionPlugin
*/
internal fun Project.configureKmpTestDependencies() { ... }
```
2. **Update AGENTS.md** if convention affects developers
3. **Update this guide** if pattern changes
## Performance Tips
- **Configuration-time:** Convention logic runs during Gradle configuration (0.5-2s)
- **Build-time:** No impact (conventions don't execute tasks)
- **Optimization focus:** Minimize `extensions.configure()` blocks (lazy evaluation is preferred)
### Good ✅
```kotlin
extensions.configure< KotlinMultiplatformExtension > {
// Single block for all source set configuration
sourceSets.apply {
commonTest.dependencies { /* ... */ }
androidHostTest?.dependencies { /* ... */ }
}
}
```
### Avoid ❌
```kotlin
// Multiple blocks - slower configuration
extensions.configure< KotlinMultiplatformExtension > {
sourceSets.getByName("commonTest").dependencies { /* ... */ }
}
extensions.configure< KotlinMultiplatformExtension > {
sourceSets.getByName("androidHostTest").dependencies { /* ... */ }
}
```
## Common Pitfalls
### ❌ **Mistake: Adding dependencies in the wrong place**
```kotlin
// WRONG: Adds to ALL modules, not just KMP
extensions.configure< Project > {
dependencies { add("implementation", ...) } // Global!
}
// RIGHT: Scoped to specific source set/module type
commonTest.dependencies { implementation(...) }
```
### ❌ **Mistake: Extension type mismatch**
```kotlin
// WRONG: LibraryExtension isn't a subtype of ApplicationExtension
extensions.configure< ApplicationExtension > {
// Won't apply to library modules
}
// RIGHT: Use CommonExtension or specific types
extensions.configure< CommonExtension > {
// Applies to both
}
```
### ❌ **Mistake: Side effects during configuration**
```kotlin
2026-03-17 15:35:39 -05:00
// WRONG: Eager task configuration at plugin-apply time
2026-03-12 21:23:25 -05:00
tasks.withType< Test > {
2026-03-17 15:35:39 -05:00
// Can realize tasks too early
2026-03-12 21:23:25 -05:00
}
2026-03-17 15:35:39 -05:00
// RIGHT: Lazy, configuration-cache-friendly wiring
tasks.withType< Test > ().configureEach {
// Applies to existing and future tasks lazily
2026-03-12 21:23:25 -05:00
}
```
## Related Files
- `AGENTS.md` - Development guidelines (Section 3.B testing, Section 4.A build protocol)
- `build-logic/convention/build.gradle.kts` - Convention plugin build config