1344 lines
55 KiB
Markdown
1344 lines
55 KiB
Markdown
# Phase 1: Project Infrastructure & Module Wiring — Pattern Map
|
||
|
||
**Mapped:** 2026-04-24
|
||
**Files analyzed:** 35 (new + modified across build, client, server, iOS bootstrap, dev ergonomics)
|
||
**Analogs found:** 28 / 35 (in-repo template files or RESEARCH.md canonical excerpts)
|
||
**Analog provenance:** All analogs are either (a) an existing JetBrains KMP template file in this repo, or (b) a canonical excerpt in `01-RESEARCH.md § Code Examples / § Architecture Patterns`. No external code was consulted — the upstream-template idioms already shipped in-repo are the highest-fidelity reference.
|
||
|
||
---
|
||
|
||
## Orientation for the executor
|
||
|
||
Phase 1 is **greenfield-infrastructure with refactor of the JetBrains template**. Three shapes of file exist:
|
||
|
||
| Shape | Count | Where the pattern lives |
|
||
|-------|-------|-------------------------|
|
||
| **MODIFIED-IN-PLACE** — existing template file, narrow edits (add flags, drop `js`, etc.) | 11 | The file itself is the analog; PATTERNS.md shows the exact delta |
|
||
| **NEW-FROM-TEMPLATE-ANALOG** — new file whose shape mirrors a sibling template file | 9 | The sibling template file is the analog (e.g. `MainActivity.kt` → `MainApplication.kt`) |
|
||
| **NEW-FROM-RESEARCH** — net-new files with no in-repo analog; canonical example in RESEARCH.md § Code Examples | 15 | Copy directly from `01-RESEARCH.md § Code Examples` excerpts (lines 777–1107) |
|
||
|
||
**Executor rule of thumb:** if a file exists today, use it as the analog and apply minimal edits. If it does not, RESEARCH.md lines 777–1107 contain a near-final canonical excerpt — treat those excerpts as the implementation starting point, adjust only for the D-# decisions explicitly referenced here.
|
||
|
||
---
|
||
|
||
## File Classification
|
||
|
||
### A. Build infrastructure (new `build-logic/` + catalog + properties)
|
||
|
||
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|
||
|------|--------------|------|-----------|----------------|---------------|
|
||
| `build-logic/settings.gradle.kts` | NEW | included-build settings | config | RESEARCH.md § Pattern 1 (lines 314–331) | canonical |
|
||
| `build-logic/build.gradle.kts` | NEW | plugin buildscript | config | RESEARCH.md § Pattern 1 (lines 333–358) | canonical |
|
||
| `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Code Examples (lines 777–835); current `composeApp/build.gradle.kts` `kotlin { }` block (lines 13–71) | role+flow match |
|
||
| `build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 4 (lines 447–477) | canonical |
|
||
| `build-logic/src/main/kotlin/recipe.android.application.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 6 (lines 516–552); current `composeApp/build.gradle.kts` `android { }` block (lines 73–98) | canonical + repo mirror |
|
||
| `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 7 (lines 558–601); current `server/build.gradle.kts` | canonical + repo mirror |
|
||
| `build-logic/src/main/kotlin/recipe.quality.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 5 (lines 483–512) | canonical |
|
||
| `gradle/libs.versions.toml` | MODIFIED | version catalog | config | itself (lines 1–53); add new `[versions]` + `[libraries]` + `[plugins]` entries for koin, kermit, spotless, flyway, postgres | self |
|
||
| `gradle.properties` | MODIFIED | gradle daemon + K/N flags | config | itself (lines 1–10) + RESEARCH.md § `gradle.properties` (lines 1083–1102) | self |
|
||
| `settings.gradle.kts` | MODIFIED | root settings | config | itself (lines 1–37); add `includeBuild("build-logic")` | self |
|
||
| `build.gradle.kts` | MODIFIED | root build | config | itself (lines 1–12); keep `apply false` list, extend with new plugins | self |
|
||
|
||
### B. Module refactors (apply convention plugins, drop `js`)
|
||
|
||
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|
||
|------|--------------|------|-----------|----------------|---------------|
|
||
| `composeApp/build.gradle.kts` | MODIFIED | module build | config | itself (lines 1–114); rewrite plugins block to convention-plugin IDs, drop `js { }` (lines 36–39), drop `compose.desktop { nativeDistributions { ... } }` packaging (lines 104–114, per D-03) | self |
|
||
| `shared/build.gradle.kts` | MODIFIED | module build | config | itself (lines 1–55); rewrite plugins block, drop `js { }` (lines 25–27), add `explicitApi()` (D-12), possibly drop `androidLibrary` plugin (see D-07 + Pattern 6 note) | self |
|
||
| `server/build.gradle.kts` | MODIFIED | module build | config | itself (lines 1–23); replace `alias(libs.plugins.*)` with convention-plugin IDs, add Flyway + Postgres deps via `recipe.jvm.server` | self |
|
||
|
||
### C. Client DI + logging bootstrap (new files in composeApp)
|
||
|
||
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|
||
|------|--------------|------|-----------|----------------|---------------|
|
||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` | NEW | DI bootstrap | init-once | RESEARCH.md § Koin bootstrap (lines 840–861) | canonical |
|
||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | NEW | DI module declaration | config | RESEARCH.md § Koin bootstrap (lines 852–861) | canonical |
|
||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` | NEW | logger bootstrap | init-once | RESEARCH.md § Kermit bootstrap (lines 933–946) | canonical |
|
||
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` | NEW | platform bridge (Kotlin→Swift symbol) | init-once | RESEARCH.md § Koin bootstrap (lines 865–870); sibling: `MainViewController.kt` is the only existing iosMain file | canonical + structural |
|
||
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` | NEW | Android app entry | init-once | RESEARCH.md § Koin bootstrap (lines 896–911); sibling: `MainActivity.kt` (lines 1–19) | canonical + sibling |
|
||
| `composeApp/src/androidMain/AndroidManifest.xml` | MODIFIED | Android manifest | config | itself (lines 1–22); add `android:name=".MainApplication"` to `<application>` tag | self |
|
||
| `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` | MODIFIED | Desktop entry | init-once | itself (lines 1–13); add `initKoin()` + `configureLogging()` at top of `main()` | self |
|
||
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` | MODIFIED | Wasm entry | init-once | itself (lines 1–10); add `initKoin()` + `configureLogging()` before `ComposeViewport { }` (PITFALL #8) | self |
|
||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | UNMODIFIED in Phase 1 | Compose root | render | keep current template body (lines 1–49); do NOT call `startKoin` from inside `@Composable` (anti-pattern in Pattern 4 notes) | n/a |
|
||
|
||
### D. iOS Swift bootstrap (wire `doInitKoin`)
|
||
|
||
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|
||
|------|--------------|------|-----------|----------------|---------------|
|
||
| `iosApp/iosApp/iOSApp.swift` | MODIFIED | iOS app entry | init-once | itself (lines 1–11); add `init() { KoinIosKt.doInitKoin() }` (RESEARCH.md lines 874–891) | self |
|
||
| `iosApp/iosApp/ContentView.swift` | UNMODIFIED | SwiftUI shell | render | n/a | n/a |
|
||
|
||
### E. Shared module scaffold
|
||
|
||
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|
||
|------|--------------|------|-----------|----------------|---------------|
|
||
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` (or placeholder `package-info.kt`-style file) | NEW | empty scaffold | n/a | D-19: `shared/commonMain` is empty in Phase 1 beyond placeholder; existing `Greeting.kt` / `Platform.kt` / `Constants.kt` stay where they are (untouched this phase) | structural |
|
||
| `shared/src/jsMain/**` | DELETED | n/a | n/a | D-01 drops `js` target; remove the entire directory (`Platform.js.kt`) | delete |
|
||
| `composeApp/src/jsMain/**` (if any) | DELETED | n/a | n/a | D-01 drops `js` target; `composeApp` does not currently have a `jsMain/` dir but has `js { browser() }` in its build — the build edit alone suffices | delete |
|
||
|
||
### F. Server infrastructure (new `/health` + Flyway + config)
|
||
|
||
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|
||
|------|--------------|------|-----------|----------------|---------------|
|
||
| `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | MODIFIED | Ktor module + entry | request-response | itself (lines 1–20); rewrite per RESEARCH.md § Ktor `/health` (lines 952–985) — swap `get("/")` + `respondText(...)` for `install(ContentNegotiation) { json() }` + `Database.migrate(this)` + `get("/health")` | self + canonical |
|
||
| `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | NEW | Flyway + DataSource bootstrap | init-once | RESEARCH.md § `Database.kt` (lines 988–1023) | canonical |
|
||
| `server/src/main/resources/application.conf` | NEW | Ktor HOCON config | config | RESEARCH.md § `application.conf` (lines 1031–1051) | canonical |
|
||
| `server/src/main/resources/db/migration/.gitkeep` | NEW | empty Flyway dir | n/a | convention (Flyway convention path) | structural |
|
||
| `server/src/main/resources/logback.xml` | UNMODIFIED | log config | config | keep as-is (lines 1–12); D-16 decides server uses SLF4J/Logback, not Kermit | self |
|
||
| `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | MODIFIED | Ktor test | test | itself (lines 1–20); replace `testRoot()` body to assert `/health` returns 200 with `{"status":"ok"}` | self |
|
||
|
||
### G. Dev ergonomics (repo root)
|
||
|
||
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|
||
|------|--------------|------|-----------|----------------|---------------|
|
||
| `docker-compose.yml` | NEW | local postgres | config | RESEARCH.md § `docker-compose.yml` (lines 1053–1077) | canonical |
|
||
| `README.md` | MODIFIED | dev docs | n/a | itself (lines 1–100); add "Local development" section with `docker compose up -d postgres` + env defaults, drop "Build and Run Web Application" JS section (D-01) | self |
|
||
| `.gitignore` | MODIFIED (optional) | vcs config | n/a | itself (lines 1–20); add `build-logic/build/`, `**/.gradle/` patterns if not already covered | self |
|
||
| `tools/verify-no-version-literals.sh` | NEW (optional, Wave 0 gap) | shell validator | test | no analog — small shell script, RESEARCH.md § Wave 0 Gaps (line 1244) describes behavior | no analog |
|
||
| `tools/verify-shared-pure.sh` | NEW (optional, Wave 0 gap) | shell validator | test | no analog — same pattern as above | no analog |
|
||
| `tools/verify-ios-flags.sh` | NEW (optional, Wave 0 gap) | shell validator | test | no analog — same pattern as above | no analog |
|
||
|
||
---
|
||
|
||
## Pattern Assignments
|
||
|
||
### `build-logic/settings.gradle.kts` (included-build settings)
|
||
|
||
**Analog:** RESEARCH.md § Pattern 1 (lines 314–331). No in-repo analog; this is a greenfield Gradle idiom.
|
||
|
||
**Complete excerpt to copy:**
|
||
|
||
```kotlin
|
||
dependencyResolutionManagement {
|
||
repositories {
|
||
google()
|
||
mavenCentral()
|
||
gradlePluginPortal()
|
||
}
|
||
versionCatalogs {
|
||
create("libs") {
|
||
from(files("../gradle/libs.versions.toml"))
|
||
}
|
||
}
|
||
}
|
||
|
||
rootProject.name = "build-logic"
|
||
```
|
||
|
||
**Why this shape:** `build-logic/` is its own Gradle build. The `from(files("../gradle/libs.versions.toml"))` line is load-bearing — without it, the root catalog is invisible inside precompiled plugins and the `findLibrary("...")` lookups fail. `[CITED: gradle best practices, VersionCatalogSample]`
|
||
|
||
---
|
||
|
||
### `build-logic/build.gradle.kts` (plugin buildscript)
|
||
|
||
**Analog:** RESEARCH.md § Pattern 1 (lines 333–358).
|
||
|
||
**Complete excerpt to copy:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
`kotlin-dsl`
|
||
}
|
||
|
||
dependencies {
|
||
compileOnly(libs.plugins.kotlinMultiplatform.asDependency())
|
||
compileOnly(libs.plugins.androidApplication.asDependency())
|
||
compileOnly(libs.plugins.androidLibrary.asDependency())
|
||
compileOnly(libs.plugins.composeMultiplatform.asDependency())
|
||
compileOnly(libs.plugins.composeCompiler.asDependency())
|
||
compileOnly(libs.plugins.composeHotReload.asDependency())
|
||
compileOnly(libs.plugins.kotlinJvm.asDependency())
|
||
compileOnly(libs.plugins.ktor.asDependency())
|
||
compileOnly(libs.plugins.spotless.asDependency())
|
||
compileOnly(libs.plugins.flywayPlugin.asDependency())
|
||
}
|
||
|
||
fun Provider<PluginDependency>.asDependency(): Provider<String> =
|
||
map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" }
|
||
```
|
||
|
||
**Critical detail:** The `.asDependency()` extension is the bridge between catalog plugin entries and the buildscript classpath. Without it, precompiled plugins cannot write `plugins { id("...") }` without inlining a version (which D-09 forbids). The `compileOnly(...)` scope is correct — the plugin markers are needed only at plugin-compile time, not at runtime.
|
||
|
||
---
|
||
|
||
### `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts`
|
||
|
||
**Analogs (two sources):**
|
||
1. **RESEARCH.md § Code Examples (lines 777–835)** — the canonical form for this plugin.
|
||
2. **`composeApp/build.gradle.kts` lines 13–71** — the current template's `kotlin { }` block that needs to be generalized and moved into this plugin.
|
||
|
||
**Imports pattern:**
|
||
|
||
```kotlin
|
||
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||
import org.gradle.kotlin.dsl.getByType
|
||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||
```
|
||
|
||
**Core pattern (from RESEARCH.md lines 789–834 — copy verbatim, adjusted for D-# decisions):**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("org.jetbrains.kotlin.multiplatform")
|
||
}
|
||
|
||
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||
|
||
kotlin {
|
||
jvmToolchain(21) // D-08: JVM 21 toolchain
|
||
|
||
androidTarget {
|
||
compilerOptions {
|
||
jvmTarget.set(JvmTarget.JVM_11) // D-08: Android bytecode stays JVM 11
|
||
}
|
||
}
|
||
|
||
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
|
||
iosTarget.binaries.framework {
|
||
baseName = "ComposeApp" // D-20; shared/build.gradle.kts overrides to "Shared"
|
||
isStatic = true
|
||
}
|
||
}
|
||
|
||
jvm {
|
||
compilerOptions {
|
||
jvmTarget.set(JvmTarget.JVM_21) // D-08: server + desktop on JVM 21
|
||
}
|
||
}
|
||
|
||
@OptIn(ExperimentalWasmDsl::class)
|
||
wasmJs { browser() }
|
||
|
||
compilerOptions {
|
||
allWarningsAsErrors.set(true) // D-11
|
||
}
|
||
|
||
sourceSets {
|
||
commonMain.dependencies {
|
||
implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get()))
|
||
implementation(libs.findLibrary("koin-core").get())
|
||
implementation(libs.findLibrary("kermit").get())
|
||
}
|
||
commonTest.dependencies {
|
||
implementation(libs.findLibrary("kotlin-test").get())
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current `composeApp/build.gradle.kts` (lines 13–71):**
|
||
- DROP the `js { browser(); binaries.executable() }` block (lines 36–39) — D-01.
|
||
- DROP `iosX64` — already absent; keep absent (D-02).
|
||
- Keep `iosArm64`, `iosSimulatorArm64`, `jvm`, `wasmJs`, `androidTarget` (D-05).
|
||
- PROMOTE the `kotlin { ... }` block verbatim into this plugin.
|
||
- ADD Koin + Kermit + kotlin-test catalog references (deps did not exist on current `composeApp`).
|
||
- ADD `compilerOptions { allWarningsAsErrors.set(true) }` at the `kotlin { }` extension level (D-11).
|
||
- Note the **exact framework basename** `"ComposeApp"` — do not typo as `"composeApp"`. PITFALL #10.
|
||
|
||
**Anti-patterns to avoid** (from RESEARCH.md § Anti-Patterns, lines 607–618):
|
||
- Do NOT re-declare `org.jetbrains.kotlin.multiplatform` in `recipe.compose.multiplatform.gradle.kts` — applying THIS plugin already applies it (PITFALL #2).
|
||
- Do NOT open per-target `compilerOptions { }` to set `allWarningsAsErrors` — set it once at the `kotlin { compilerOptions { } }` extension level (PITFALL #3).
|
||
- Do NOT use deprecated `kotlinOptions { }` DSL — Kotlin 2.3 removes it; `compilerOptions { property.set(...) }` is the only correct form (PITFALL #7).
|
||
|
||
---
|
||
|
||
### `build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts`
|
||
|
||
**Analog:** RESEARCH.md § Pattern 4 (lines 447–477). No in-repo analog — the plugin did not exist.
|
||
|
||
**Complete excerpt to copy:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("recipe.kotlin.multiplatform") // layers on top — do not re-declare KMP plugin
|
||
id("org.jetbrains.compose")
|
||
id("org.jetbrains.kotlin.plugin.compose")
|
||
id("org.jetbrains.compose.hot-reload") // preserve commit c50d747 (hot-reload wiring)
|
||
}
|
||
|
||
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||
import org.gradle.kotlin.dsl.getByType
|
||
|
||
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||
|
||
kotlin {
|
||
sourceSets {
|
||
commonMain.dependencies {
|
||
implementation(libs.findLibrary("compose-runtime").get())
|
||
implementation(libs.findLibrary("compose-foundation").get())
|
||
implementation(libs.findLibrary("compose-material3").get())
|
||
implementation(libs.findLibrary("compose-ui").get())
|
||
implementation(libs.findLibrary("compose-components-resources").get())
|
||
implementation(libs.findLibrary("androidx-lifecycle-viewmodelCompose").get())
|
||
implementation(libs.findLibrary("androidx-lifecycle-runtimeCompose").get())
|
||
implementation(libs.findLibrary("koin-compose").get())
|
||
implementation(libs.findLibrary("koin-composeViewmodel").get())
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current `composeApp/build.gradle.kts` lines 52–62:**
|
||
- The current file declares Compose deps in `commonMain.dependencies` — MOVE these into THIS plugin so `shared/` does not inherit Compose (D-19 / INFRA-06).
|
||
- ADD koin-compose + koin-composeViewmodel (not in catalog yet).
|
||
|
||
**Why separate from `recipe.kotlin.multiplatform`:** if Compose deps were in the KMP plugin, `shared/` would pull Compose, violating D-19 / INFRA-06. This plugin layers Compose **on top** — `shared/` applies only `recipe.kotlin.multiplatform`, so it stays Compose-free.
|
||
|
||
---
|
||
|
||
### `build-logic/src/main/kotlin/recipe.android.application.gradle.kts`
|
||
|
||
**Analogs:**
|
||
1. **RESEARCH.md § Pattern 6 (lines 516–552)** — canonical form.
|
||
2. **`composeApp/build.gradle.kts` lines 73–98** — the current template's `android { }` block, moved verbatim.
|
||
|
||
**Complete excerpt to copy:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("com.android.application")
|
||
}
|
||
|
||
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||
import org.gradle.kotlin.dsl.getByType
|
||
|
||
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||
|
||
android {
|
||
namespace = "dev.ulfrx.recipe" // D-20
|
||
compileSdk = libs.findVersion("android-compileSdk").get().toString().toInt()
|
||
|
||
defaultConfig {
|
||
applicationId = "dev.ulfrx.recipe"
|
||
minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
|
||
targetSdk = libs.findVersion("android-targetSdk").get().toString().toInt()
|
||
versionCode = 1
|
||
versionName = "1.0"
|
||
}
|
||
packaging {
|
||
resources {
|
||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||
}
|
||
}
|
||
buildTypes {
|
||
getByName("release") {
|
||
isMinifyEnabled = false
|
||
}
|
||
}
|
||
compileOptions {
|
||
sourceCompatibility = JavaVersion.VERSION_11
|
||
targetCompatibility = JavaVersion.VERSION_11
|
||
}
|
||
}
|
||
```
|
||
|
||
**Delta vs. current `composeApp/build.gradle.kts` (lines 73–98):**
|
||
- Replace `libs.versions.android.compileSdk.get().toInt()` with `libs.findVersion("android-compileSdk").get().toString().toInt()` — catalog accessor syntax changes inside precompiled plugins (PITFALL #1).
|
||
|
||
**Anti-pattern** (RESEARCH.md line 554): do NOT apply this plugin to `shared/`. `shared/` is a KMP library. If `shared/` needs Android, it applies `com.android.library` separately — but verify in Phase 1 whether `shared/` actually needs that plugin; the current `shared/build.gradle.kts` line 7 applies it but it may be redundant with `androidTarget` in KMP.
|
||
|
||
---
|
||
|
||
### `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts`
|
||
|
||
**Analogs:**
|
||
1. **RESEARCH.md § Pattern 7 (lines 558–601)** — canonical form.
|
||
2. **`server/build.gradle.kts` lines 1–23** — current plugins block + dependencies, extended.
|
||
|
||
**Complete excerpt to copy:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("org.jetbrains.kotlin.jvm")
|
||
id("io.ktor.plugin")
|
||
id("org.flywaydb.flyway")
|
||
application
|
||
}
|
||
|
||
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||
import org.gradle.kotlin.dsl.getByType
|
||
|
||
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||
|
||
kotlin {
|
||
jvmToolchain(21)
|
||
compilerOptions {
|
||
allWarningsAsErrors.set(true)
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
"implementation"(libs.findLibrary("ktor-serverCore").get())
|
||
"implementation"(libs.findLibrary("ktor-serverNetty").get())
|
||
"implementation"(libs.findLibrary("ktor-serverContentNegotiation").get())
|
||
"implementation"(libs.findLibrary("ktor-serializationKotlinxJson").get())
|
||
"implementation"(libs.findLibrary("logback").get())
|
||
"implementation"(libs.findLibrary("flyway-core").get())
|
||
"implementation"(libs.findLibrary("flyway-database-postgresql").get())
|
||
"implementation"(libs.findLibrary("postgresql").get())
|
||
"testImplementation"(libs.findLibrary("ktor-serverTestHost").get())
|
||
"testImplementation"(libs.findLibrary("kotlin-testJunit").get())
|
||
}
|
||
|
||
flyway {
|
||
url = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/recipe"
|
||
user = System.getenv("DATABASE_USER") ?: "recipe"
|
||
password = System.getenv("DATABASE_PASSWORD") ?: "recipe"
|
||
locations = arrayOf("classpath:db/migration")
|
||
cleanDisabled = true
|
||
baselineOnMigrate = true
|
||
validateOnMigrate = true
|
||
}
|
||
```
|
||
|
||
**Quoted-config footgun** (RESEARCH.md line 603): inside a precompiled plugin, `implementation(...)` is not a typed method. You MUST write `"implementation"(libs.findLibrary(...).get())` with quoted config name, or the build will fail with "unresolved reference: implementation".
|
||
|
||
**Delta vs. current `server/build.gradle.kts`:**
|
||
- `application { }` block (lines 7–14 of current file) stays in the MODULE `server/build.gradle.kts`, not this plugin — per-module concern.
|
||
- ADD Flyway + Postgres + ContentNegotiation + kotlinx-serialization deps (catalog entries to add).
|
||
- Current `server/build.gradle.kts` uses `libs.plugins.kotlinJvm` / `libs.plugins.ktor` aliases — REPLACE with convention-plugin ID `id("recipe.jvm.server")`.
|
||
|
||
**Flyway caveat** (PITFALL #6, RESEARCH.md lines 719–724): the Flyway Gradle plugin is for CLI ergonomics (`./gradlew flywayMigrate`) only. Runtime migration happens through the Flyway Java API in `Database.kt` — do NOT wire Flyway tasks as a dependency of `classes` or `build`, or `./gradlew build` will fail when Postgres is not running.
|
||
|
||
---
|
||
|
||
### `build-logic/src/main/kotlin/recipe.quality.gradle.kts`
|
||
|
||
**Analog:** RESEARCH.md § Pattern 5 (lines 483–512). No in-repo analog.
|
||
|
||
**Complete excerpt to copy:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("com.diffplug.spotless")
|
||
}
|
||
|
||
spotless {
|
||
kotlin {
|
||
target("src/**/*.kt")
|
||
targetExclude("**/build/**", "**/generated/**")
|
||
ktlint() // latest stable (Spotless default)
|
||
}
|
||
kotlinGradle {
|
||
target("*.gradle.kts")
|
||
ktlint()
|
||
}
|
||
format("markdown") {
|
||
target("*.md", "docs/**/*.md")
|
||
endWithNewline()
|
||
trimTrailingWhitespace()
|
||
}
|
||
}
|
||
|
||
// Redundancy guard for modules that apply recipe.quality without recipe.kotlin.multiplatform
|
||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
|
||
compilerOptions {
|
||
allWarningsAsErrors.set(true)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note on ktlint ruleset version:** D-10 says pick the latest stable; Spotless's bare `ktlint()` call uses its default, which is fine. Pin only if drift becomes a problem.
|
||
|
||
---
|
||
|
||
### `gradle/libs.versions.toml` (MODIFIED — catalog extensions)
|
||
|
||
**Analog:** itself (lines 1–53).
|
||
|
||
**Imports pattern:** n/a (TOML).
|
||
|
||
**Delta — add under `[versions]`:**
|
||
|
||
```toml
|
||
koin = "4.1.0" # bump to current at plan time; verify compose-multiplatform compat
|
||
kermit = "2.0.6" # bump to current at plan time
|
||
spotless = "7.2.1" # bump to current at plan time
|
||
flyway = "11.10.0" # bump to current at plan time
|
||
postgres-jdbc = "42.7.7" # bump to current at plan time
|
||
```
|
||
|
||
**Delta — add under `[libraries]`:**
|
||
|
||
```toml
|
||
# Koin (client DI)
|
||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
|
||
koin-core = { module = "io.insert-koin:koin-core" }
|
||
koin-compose = { module = "io.insert-koin:koin-compose" }
|
||
koin-composeViewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
|
||
# Note: BOM-managed deps omit version.ref
|
||
|
||
# Kermit (client logger)
|
||
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
|
||
|
||
# Server: ContentNegotiation + Flyway + Postgres
|
||
ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" }
|
||
ktor-serializationKotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" }
|
||
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
|
||
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
|
||
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgres-jdbc" }
|
||
```
|
||
|
||
**Delta — add under `[plugins]`:**
|
||
|
||
```toml
|
||
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
||
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }
|
||
```
|
||
|
||
**Hard invariant (D-09 / INFRA-01 SC#2):** after this edit, `grep -rn 'version = \"[0-9]' --include='*.gradle.kts' .` should return zero hits outside `build-logic/build.gradle.kts` auto-generated accessors. Wave 0 gap `tools/verify-no-version-literals.sh` enforces this.
|
||
|
||
---
|
||
|
||
### `gradle.properties` (MODIFIED — add K/N flags)
|
||
|
||
**Analog:** itself (lines 1–10) + RESEARCH.md § `gradle.properties` (lines 1083–1102).
|
||
|
||
**Delta — append to file (D-18, INFRA-03, PITFALL #1):**
|
||
|
||
```properties
|
||
# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1
|
||
kotlin.native.binary.gc=cms
|
||
kotlin.native.binary.objcDisposeOnMain=false
|
||
```
|
||
|
||
**Verification** (RESEARCH.md lines 1104–1107): `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info | grep -E 'objcDisposeOnMain|gc=cms'` should echo both flags. Wave 0 gap `tools/verify-ios-flags.sh` automates the grep.
|
||
|
||
---
|
||
|
||
### `settings.gradle.kts` (MODIFIED — include build-logic)
|
||
|
||
**Analog:** itself (lines 1–37).
|
||
|
||
**Delta:** add `includeBuild("build-logic")` in the right position.
|
||
|
||
**PITFALL #9** (RESEARCH.md lines 749–767): `includeBuild("build-logic")` must appear **before** `pluginManagement { }` consumes plugin IDs that come from `build-logic`, OR more simply: put `pluginManagement { includeBuild("build-logic") }` at the top. The proven layout:
|
||
|
||
```kotlin
|
||
rootProject.name = "recipe"
|
||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||
|
||
pluginManagement {
|
||
includeBuild("build-logic")
|
||
repositories {
|
||
google { mavenContent { includeGroupAndSubgroups("androidx"); includeGroupAndSubgroups("com.android"); includeGroupAndSubgroups("com.google") } }
|
||
mavenCentral()
|
||
gradlePluginPortal()
|
||
}
|
||
}
|
||
|
||
dependencyResolutionManagement {
|
||
repositories {
|
||
google { mavenContent { includeGroupAndSubgroups("androidx"); includeGroupAndSubgroups("com.android"); includeGroupAndSubgroups("com.google") } }
|
||
mavenCentral()
|
||
}
|
||
}
|
||
|
||
plugins {
|
||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||
}
|
||
|
||
include(":composeApp")
|
||
include(":server")
|
||
include(":shared")
|
||
```
|
||
|
||
Note that `includeBuild` lives inside `pluginManagement { }` — not at top level — so the `build-logic` plugins are resolvable by ID in child module `plugins { }` blocks.
|
||
|
||
---
|
||
|
||
### `build.gradle.kts` (root — MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–12).
|
||
|
||
**Delta:** add `apply false` entries for `spotless` and `flywayPlugin` (so Gradle's classloader hint covers them):
|
||
|
||
```kotlin
|
||
plugins {
|
||
alias(libs.plugins.androidApplication) apply false
|
||
alias(libs.plugins.androidLibrary) apply false
|
||
alias(libs.plugins.composeHotReload) apply false
|
||
alias(libs.plugins.composeMultiplatform) apply false
|
||
alias(libs.plugins.composeCompiler) apply false
|
||
alias(libs.plugins.kotlinJvm) apply false
|
||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||
alias(libs.plugins.ktor) apply false
|
||
alias(libs.plugins.spotless) apply false // NEW
|
||
alias(libs.plugins.flywayPlugin) apply false // NEW
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `composeApp/build.gradle.kts` (MODIFIED — apply conventions)
|
||
|
||
**Analog:** itself (lines 1–114).
|
||
|
||
**Core pattern — new plugins block:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("recipe.kotlin.multiplatform")
|
||
id("recipe.compose.multiplatform")
|
||
id("recipe.android.application")
|
||
id("recipe.quality")
|
||
}
|
||
|
||
kotlin {
|
||
sourceSets {
|
||
androidMain.dependencies {
|
||
implementation(libs.compose.uiToolingPreview)
|
||
implementation(libs.androidx.activity.compose)
|
||
}
|
||
commonMain.dependencies {
|
||
implementation(libs.compose.uiToolingPreview)
|
||
implementation(projects.shared)
|
||
// Compose + Koin + Kermit + kotlin-test come via convention plugins
|
||
}
|
||
jvmMain.dependencies {
|
||
implementation(compose.desktop.currentOs)
|
||
implementation(libs.kotlinx.coroutinesSwing)
|
||
}
|
||
}
|
||
}
|
||
|
||
dependencies {
|
||
debugImplementation(libs.compose.uiTooling)
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current file:**
|
||
- REPLACE plugins block (lines 5–11) with convention-plugin IDs.
|
||
- DROP the entire `kotlin { androidTarget { ... } ... }` block (lines 13–71) — moved to `recipe.kotlin.multiplatform`. Keep only the per-module `sourceSets { ... }` overrides for `androidMain` / `commonMain` / `jvmMain` deps that are NOT shared.
|
||
- DROP the `android { }` block (lines 73–98) — moved to `recipe.android.application`.
|
||
- DROP `js { browser(); binaries.executable() }` (lines 36–39) — D-01.
|
||
- DROP `compose.desktop { application { ... nativeDistributions { ... } } }` (lines 104–114) — D-03 says no desktop packaging.
|
||
|
||
---
|
||
|
||
### `shared/build.gradle.kts` (MODIFIED — apply conventions + explicitApi)
|
||
|
||
**Analog:** itself (lines 1–55).
|
||
|
||
**Core pattern:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("recipe.kotlin.multiplatform")
|
||
id("recipe.quality")
|
||
// NOTE: recipe.android.application is NOT applied — shared is a library, not an app
|
||
// NOTE: if com.android.library is still needed for androidTarget resources, apply directly:
|
||
// alias(libs.plugins.androidLibrary)
|
||
}
|
||
|
||
kotlin {
|
||
explicitApi() // D-12: strict only on shared/
|
||
sourceSets {
|
||
commonMain.dependencies {
|
||
// Phase 1: empty — domain models + DTOs land Phase 2+
|
||
}
|
||
}
|
||
// Override framework baseName for this module
|
||
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
|
||
binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.Framework>().configureEach {
|
||
baseName = "Shared"
|
||
}
|
||
}
|
||
}
|
||
|
||
// Optional (see Open Questions in RESEARCH.md)
|
||
android {
|
||
namespace = "dev.ulfrx.recipe.shared"
|
||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||
compileOptions {
|
||
sourceCompatibility = JavaVersion.VERSION_11
|
||
targetCompatibility = JavaVersion.VERSION_11
|
||
}
|
||
defaultConfig {
|
||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||
}
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current file (lines 1–55):**
|
||
- REPLACE plugins block (lines 4–7) with convention-plugin IDs; retain `androidLibrary` alias if Android namespace / resources are required (decision pending per RESEARCH.md Anti-Patterns §).
|
||
- DROP entire `kotlin { androidTarget { ... } ... }` target block (lines 9–33) — moved to `recipe.kotlin.multiplatform`.
|
||
- DROP `js { browser() }` (lines 25–27) — D-01.
|
||
- ADD `explicitApi()` (D-12) — lives in the MODULE file so app modules don't inherit it.
|
||
- OVERRIDE framework baseName to `"Shared"` (the KMP plugin defaults to `"ComposeApp"`; shared needs its own symbol — PITFALL #10).
|
||
|
||
**Anti-pattern check (D-19):** `commonMain.dependencies { }` must stay empty in Phase 1. Do NOT add Ktor, Compose, or SQLDelight here — EVER. Only `kotlinx-serialization` + `kotlinx-datetime` are whitelisted for future phases.
|
||
|
||
---
|
||
|
||
### `server/build.gradle.kts` (MODIFIED — apply convention)
|
||
|
||
**Analog:** itself (lines 1–23).
|
||
|
||
**Core pattern:**
|
||
|
||
```kotlin
|
||
plugins {
|
||
id("recipe.jvm.server")
|
||
id("recipe.quality")
|
||
}
|
||
|
||
group = "dev.ulfrx.recipe"
|
||
version = "1.0.0"
|
||
|
||
application {
|
||
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
|
||
|
||
val isDevelopment: Boolean = project.ext.has("development")
|
||
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
||
}
|
||
|
||
dependencies {
|
||
implementation(projects.shared)
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current file (lines 1–23):**
|
||
- REPLACE plugins block (lines 1–5) with convention-plugin IDs.
|
||
- DROP individual library implementations (lines 16–22) — moved to `recipe.jvm.server`.
|
||
- KEEP `application { mainClass.set(...) }` (lines 9–14) — per-module concern.
|
||
- KEEP `implementation(projects.shared)` (line 17) — module-specific project dependency.
|
||
|
||
---
|
||
|
||
### `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` (NEW)
|
||
|
||
**Analog:** RESEARCH.md § Koin bootstrap (lines 840–850). No in-repo analog.
|
||
|
||
**Complete file:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe.di
|
||
|
||
import org.koin.core.KoinApplication
|
||
import org.koin.core.context.startKoin
|
||
import org.koin.dsl.KoinAppDeclaration
|
||
|
||
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin {
|
||
config?.invoke(this)
|
||
modules(appModule)
|
||
}
|
||
```
|
||
|
||
**Usage contract** (RESEARCH.md § Kermit bootstrap line 948): call `configureLogging()` BEFORE `initKoin()`, so Koin module loading can use Kermit. Order per platform:
|
||
- Android: `MainApplication.onCreate()` → `configureLogging(); initKoin { androidContext(this) }`
|
||
- iOS: `iOSApp.init()` → Swift side calls `KoinIosKt.doInitKoin()` which invokes `configureLogging(); initKoin()`
|
||
- Desktop: `main()` top → `configureLogging(); initKoin(); application { Window { App() } }`
|
||
- Wasm: `main()` top → `configureLogging(); initKoin(); ComposeViewport { App() }`
|
||
|
||
**Anti-pattern (PITFALL #4):** do NOT call `startKoin { }` from inside `MainViewController()` AND from `iOSApp.init()` — you'll hit `KoinApplicationAlreadyStartedException` on second cold launch. Pick one: the canonical choice is `iOSApp.init() → doInitKoin()`. `MainViewController()` stays as-is (`fun MainViewController() = ComposeUIViewController { App() }`).
|
||
|
||
---
|
||
|
||
### `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` (NEW)
|
||
|
||
**Analog:** RESEARCH.md § Koin bootstrap (lines 852–861).
|
||
|
||
**Complete file:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe.di
|
||
|
||
import org.koin.dsl.module
|
||
|
||
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
|
||
val appModule = module {
|
||
// intentionally empty in Phase 1
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` (NEW)
|
||
|
||
**Analog:** RESEARCH.md § Kermit bootstrap (lines 935–946).
|
||
|
||
**Complete file:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe.logging
|
||
|
||
import co.touchlab.kermit.Logger
|
||
|
||
fun configureLogging() {
|
||
Logger.setTag("recipe")
|
||
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` (NEW)
|
||
|
||
**Analogs:**
|
||
1. **RESEARCH.md § Koin bootstrap (lines 865–870)** — the canonical symbol.
|
||
2. **`composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt` (lines 1–5)** — sibling showing the `expect`/`actual`-free simple iosMain style.
|
||
|
||
**Complete file:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe.di
|
||
|
||
import dev.ulfrx.recipe.logging.configureLogging
|
||
|
||
fun doInitKoin() {
|
||
configureLogging()
|
||
initKoin()
|
||
}
|
||
```
|
||
|
||
**Why the naming:** Kotlin's top-level `fun doInitKoin()` in package `dev.ulfrx.recipe.di` becomes the Swift symbol `KoinIosKt.doInitKoin()` (framework baseName is `ComposeApp` per D-20, but the generated Swift class is `<KotlinFileName>Kt` — so `KoinIos.kt` → `KoinIosKt`). PITFALL #10 warns about basename mismatches; here the class suffix `Kt` is automatic and tied to the file name.
|
||
|
||
---
|
||
|
||
### `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` (NEW)
|
||
|
||
**Analogs:**
|
||
1. **RESEARCH.md § Koin bootstrap (lines 896–911)** — canonical.
|
||
2. **`composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` (lines 1–19)** — sibling showing package + import conventions for this target.
|
||
|
||
**Complete file:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe
|
||
|
||
import android.app.Application
|
||
import dev.ulfrx.recipe.di.initKoin
|
||
import dev.ulfrx.recipe.logging.configureLogging
|
||
import org.koin.android.ext.koin.androidContext
|
||
|
||
class MainApplication : Application() {
|
||
override fun onCreate() {
|
||
super.onCreate()
|
||
configureLogging()
|
||
initKoin {
|
||
androidContext(this@MainApplication)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Additional catalog entry needed:** `koin-android = { module = "io.insert-koin:koin-android" }` under `[libraries]` (BOM-managed, no version.ref). Wire via `androidMain.dependencies` in `composeApp/build.gradle.kts` OR add to `recipe.compose.multiplatform` if every Android consumer needs it.
|
||
|
||
---
|
||
|
||
### `composeApp/src/androidMain/AndroidManifest.xml` (MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–22).
|
||
|
||
**Delta:** line 4 `<application` tag — add `android:name=".MainApplication"`.
|
||
|
||
**After edit:**
|
||
|
||
```xml
|
||
<application
|
||
android:name=".MainApplication"
|
||
android:allowBackup="true"
|
||
android:icon="@mipmap/ic_launcher"
|
||
android:label="@string/app_name"
|
||
android:roundIcon="@mipmap/ic_launcher_round"
|
||
android:supportsRtl="true"
|
||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||
```
|
||
|
||
---
|
||
|
||
### `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` (MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–13) + RESEARCH.md § Koin bootstrap (lines 918–924).
|
||
|
||
**Full replacement:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe
|
||
|
||
import androidx.compose.ui.window.Window
|
||
import androidx.compose.ui.window.application
|
||
import dev.ulfrx.recipe.di.initKoin
|
||
import dev.ulfrx.recipe.logging.configureLogging
|
||
|
||
fun main() {
|
||
configureLogging()
|
||
initKoin()
|
||
application {
|
||
Window(
|
||
onCloseRequest = ::exitApplication,
|
||
title = "recipe",
|
||
) {
|
||
App()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` (MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–10) + RESEARCH.md § Koin bootstrap (lines 927–931); PITFALL #8 (lines 733–747).
|
||
|
||
**Full replacement:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe
|
||
|
||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||
import androidx.compose.ui.window.ComposeViewport
|
||
import dev.ulfrx.recipe.di.initKoin
|
||
import dev.ulfrx.recipe.logging.configureLogging
|
||
|
||
@OptIn(ExperimentalComposeUiApi::class)
|
||
fun main() {
|
||
configureLogging()
|
||
initKoin()
|
||
ComposeViewport {
|
||
App()
|
||
}
|
||
}
|
||
```
|
||
|
||
**Critical:** both `configureLogging()` and `initKoin()` must run **before** `ComposeViewport { }` — otherwise first `koinViewModel<X>()` inside composition throws (PITFALL #8). Phase 1 has no ViewModels so this is defensive, but the template's shape must be right from day 1.
|
||
|
||
---
|
||
|
||
### `iosApp/iosApp/iOSApp.swift` (MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–11) + RESEARCH.md § Koin bootstrap (lines 874–891).
|
||
|
||
**Full replacement:**
|
||
|
||
```swift
|
||
import SwiftUI
|
||
import ComposeApp
|
||
|
||
@main
|
||
struct iOSApp: App {
|
||
init() {
|
||
KoinIosKt.doInitKoin() // calls Kotlin's fun doInitKoin() in dev.ulfrx.recipe.di
|
||
}
|
||
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
ContentView()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current file (lines 1–11):**
|
||
- ADD `import ComposeApp` (the framework baseName — D-20 / PITFALL #10).
|
||
- ADD `init() { KoinIosKt.doInitKoin() }`.
|
||
|
||
---
|
||
|
||
### `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` (MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–20) + RESEARCH.md § Ktor `/health` (lines 952–985).
|
||
|
||
**Full replacement:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe
|
||
|
||
import io.ktor.serialization.kotlinx.json.json
|
||
import io.ktor.server.application.Application
|
||
import io.ktor.server.engine.embeddedServer
|
||
import io.ktor.server.netty.Netty
|
||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||
import io.ktor.server.response.respond
|
||
import io.ktor.server.routing.get
|
||
import io.ktor.server.routing.routing
|
||
import kotlinx.serialization.Serializable
|
||
|
||
fun main() {
|
||
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
|
||
.start(wait = true)
|
||
}
|
||
|
||
@Serializable
|
||
private data class Health(val status: String)
|
||
|
||
fun Application.module() {
|
||
install(ContentNegotiation) {
|
||
json()
|
||
}
|
||
Database.migrate(this) // fails loudly if Postgres unreachable (D-16)
|
||
|
||
routing {
|
||
get("/health") {
|
||
call.respond(Health(status = "ok"))
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current file (lines 1–20):**
|
||
- DROP `get("/") { call.respondText("Ktor: ${Greeting().greet()}") }` — replaced by `/health`.
|
||
- ADD `install(ContentNegotiation) { json() }` — required for `@Serializable` response.
|
||
- ADD `Database.migrate(this)` — Flyway bootstrap (fails server boot if DB unreachable).
|
||
- CHANGE imports from wildcard (`io.ktor.server.application.*`) to specific — D-11 may warn on unused wildcards; be explicit.
|
||
- `SERVER_PORT` constant continues to live in `shared/commonMain/.../Constants.kt`.
|
||
|
||
---
|
||
|
||
### `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` (NEW)
|
||
|
||
**Analog:** RESEARCH.md § `Database.kt` (lines 990–1023). No in-repo analog.
|
||
|
||
**Important substitution** (RESEARCH.md lines 1025–1027): Kermit is the **client** logger. The server uses SLF4J + Logback (already wired via `logback.xml`). So this file must use SLF4J, not Kermit:
|
||
|
||
**Complete file (server-adjusted SLF4J variant):**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe
|
||
|
||
import io.ktor.server.application.Application
|
||
import org.flywaydb.core.Flyway
|
||
import org.slf4j.LoggerFactory
|
||
|
||
object Database {
|
||
private val log = LoggerFactory.getLogger(Database::class.java)
|
||
|
||
fun migrate(app: Application) {
|
||
val url = app.environment.config.property("database.url").getString()
|
||
val user = app.environment.config.property("database.user").getString()
|
||
val password = app.environment.config.property("database.password").getString()
|
||
|
||
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
|
||
|
||
runCatching {
|
||
Flyway.configure()
|
||
.dataSource(url, user, password)
|
||
.locations("classpath:db/migration")
|
||
.baselineOnMigrate(true)
|
||
.validateOnMigrate(true)
|
||
.cleanDisabled(true)
|
||
.load()
|
||
.migrate()
|
||
}.onFailure { ex ->
|
||
log.error("Flyway migration failed — cannot start server", ex)
|
||
throw IllegalStateException("Database unreachable or migration failed", ex)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Fail-loud contract (D-16):** the `throw IllegalStateException(...)` is load-bearing — the server MUST refuse to start if Postgres is unreachable. This surfaces config errors immediately instead of letting the server run with a broken DB.
|
||
|
||
---
|
||
|
||
### `server/src/main/resources/application.conf` (NEW)
|
||
|
||
**Analog:** RESEARCH.md § `application.conf` (lines 1031–1051). No in-repo analog (the current server has no `application.conf`; it's purely programmatic in `Application.kt`).
|
||
|
||
**Complete file:**
|
||
|
||
```hocon
|
||
ktor {
|
||
deployment {
|
||
port = 8080
|
||
port = ${?PORT}
|
||
}
|
||
application {
|
||
modules = [ dev.ulfrx.recipe.ApplicationKt.module ]
|
||
}
|
||
}
|
||
|
||
database {
|
||
url = "jdbc:postgresql://localhost:5432/recipe"
|
||
url = ${?DATABASE_URL}
|
||
user = "recipe"
|
||
user = ${?DATABASE_USER}
|
||
password = "recipe"
|
||
password = ${?DATABASE_PASSWORD}
|
||
}
|
||
```
|
||
|
||
**HOCON substitution contract (PITFALL #5, RESEARCH.md lines 692–717):** the two-line pattern `url = "default"; url = ${?DATABASE_URL}` is load-bearing. Use `${?X}` (optional substitution), NOT `${X}` (required, parse-time failure) or `${X:default}` (wrong syntax for HOCON defaults).
|
||
|
||
**Interaction with programmatic `main()`:** the current `Application.kt` uses `embeddedServer(Netty, port = SERVER_PORT, ...)` programmatically. When `application.conf` is introduced, Ktor will read `application.modules = [...]` at boot time. The programmatic `embeddedServer` form in `main()` is still valid — HOCON overrides happen at `application.environment.config.property(...)` lookups (as `Database.kt` does). Verify at plan time whether to keep programmatic boot or switch to `EngineMain` (HOCON-driven). Either works; RESEARCH.md excerpt keeps the programmatic form for simplicity.
|
||
|
||
---
|
||
|
||
### `server/src/main/resources/db/migration/.gitkeep` (NEW)
|
||
|
||
**Analog:** Flyway convention — empty directory placeholder.
|
||
|
||
**Complete file:** empty `.gitkeep` file. Phase 3 drops `V1__init.sql` here.
|
||
|
||
**Why `.gitkeep`:** git does not track empty directories; the `.gitkeep` convention (a zero-byte file) ensures the directory ships in-repo so `Flyway.locations("classpath:db/migration")` finds it.
|
||
|
||
---
|
||
|
||
### `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` (MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–20).
|
||
|
||
**Full replacement:**
|
||
|
||
```kotlin
|
||
package dev.ulfrx.recipe
|
||
|
||
import io.ktor.client.request.get
|
||
import io.ktor.client.statement.bodyAsText
|
||
import io.ktor.http.HttpStatusCode
|
||
import io.ktor.server.testing.testApplication
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.test.assertTrue
|
||
|
||
class ApplicationTest {
|
||
|
||
@Test
|
||
fun `health endpoint returns 200 with status ok`() = testApplication {
|
||
// Note: testApplication uses in-memory config; Database.migrate() must be skipped or mocked
|
||
// for this test to run without Postgres. Recommend: extract a `Application.configureRouting()`
|
||
// and test only routing in isolation. See plan note.
|
||
application {
|
||
install(io.ktor.server.plugins.contentnegotiation.ContentNegotiation) {
|
||
io.ktor.serialization.kotlinx.json.json()
|
||
}
|
||
io.ktor.server.routing.routing {
|
||
io.ktor.server.routing.get("/health") {
|
||
io.ktor.server.response.respond(mapOf("status" to "ok"))
|
||
}
|
||
}
|
||
}
|
||
val response = client.get("/health")
|
||
assertEquals(HttpStatusCode.OK, response.status)
|
||
assertTrue(response.bodyAsText().contains("\"status\""))
|
||
assertTrue(response.bodyAsText().contains("\"ok\""))
|
||
}
|
||
}
|
||
```
|
||
|
||
**Deltas vs. current file (lines 1–20):**
|
||
- DROP `testRoot()` (tests `Greeting().greet()` output).
|
||
- ADD `health endpoint returns 200` test.
|
||
- CAVEAT: the current `module()` calls `Database.migrate(this)` which needs a real Postgres. Split the Ktor config: extract `Application.configureRouting()` + `Application.configureSerialization()` helpers so tests can compose routing without the DB. Plan should capture this refactor.
|
||
|
||
---
|
||
|
||
### `docker-compose.yml` (NEW)
|
||
|
||
**Analog:** RESEARCH.md § `docker-compose.yml` (lines 1055–1077). No in-repo analog.
|
||
|
||
**Complete file:**
|
||
|
||
```yaml
|
||
services:
|
||
postgres:
|
||
image: postgres:16
|
||
container_name: recipe-postgres
|
||
environment:
|
||
POSTGRES_DB: recipe
|
||
POSTGRES_USER: recipe
|
||
POSTGRES_PASSWORD: recipe
|
||
ports:
|
||
- "5432:5432"
|
||
volumes:
|
||
- recipe-pgdata:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U recipe -d recipe"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
volumes:
|
||
recipe-pgdata:
|
||
```
|
||
|
||
**Matched defaults:** `POSTGRES_*` env values here match `application.conf` localhost defaults exactly — running `docker compose up -d postgres` + `./gradlew :server:run` works with zero additional env setup. Homelab deploy (Phase 11) uses a different compose file with real creds + Authentik alongside.
|
||
|
||
---
|
||
|
||
### `README.md` (MODIFIED)
|
||
|
||
**Analog:** itself (lines 1–100).
|
||
|
||
**Delta:**
|
||
1. DROP "Build and Run Web Application" JS sections (lines 77–85) — D-01 drops `js` target; keep only the `wasmJs` section.
|
||
2. ADD a new "Local development" section documenting `docker compose up -d postgres` + `./gradlew :server:run` + `curl localhost:8080/health`.
|
||
3. ADD mention of `./gradlew spotlessApply` before commits (D-10 / D-13).
|
||
|
||
---
|
||
|
||
### `tools/verify-*.sh` (NEW — Wave 0 gap shell scripts)
|
||
|
||
**Analog:** none — small bespoke shell scripts, RESEARCH.md § Wave 0 Gaps lines 1242–1256 describes behavior.
|
||
|
||
**`tools/verify-no-version-literals.sh` — sketch:**
|
||
|
||
```sh
|
||
#!/usr/bin/env bash
|
||
# Enforces INFRA-01 SC#2 / D-09: no literal version strings outside catalog.
|
||
set -e
|
||
VIOLATIONS=$(grep -rn 'version\s*=\s*"[0-9]' --include='*.gradle.kts' . | grep -v 'build-logic/build.gradle.kts' || true)
|
||
if [ -n "$VIOLATIONS" ]; then
|
||
echo "ERROR: version literals found outside catalog:"
|
||
echo "$VIOLATIONS"
|
||
exit 1
|
||
fi
|
||
echo "OK: no version literals outside catalog."
|
||
```
|
||
|
||
**`tools/verify-shared-pure.sh` — sketch:**
|
||
|
||
```sh
|
||
#!/usr/bin/env bash
|
||
# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight.
|
||
set -e
|
||
VIOLATIONS=$(grep -rn -E 'import\s+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ || true)
|
||
if [ -n "$VIOLATIONS" ]; then
|
||
echo "ERROR: shared/commonMain has forbidden imports:"
|
||
echo "$VIOLATIONS"
|
||
exit 1
|
||
fi
|
||
echo "OK: shared/commonMain is pure."
|
||
```
|
||
|
||
**`tools/verify-ios-flags.sh` — sketch:**
|
||
|
||
```sh
|
||
#!/usr/bin/env bash
|
||
# Enforces INFRA-03 / D-18: iOS K/N flags present.
|
||
set -e
|
||
grep -q '^kotlin\.native\.binary\.gc=cms' gradle.properties || { echo "MISSING: kotlin.native.binary.gc=cms"; exit 1; }
|
||
grep -q '^kotlin\.native\.binary\.objcDisposeOnMain=false' gradle.properties || { echo "MISSING: kotlin.native.binary.objcDisposeOnMain=false"; exit 1; }
|
||
echo "OK: iOS binary flags present."
|
||
```
|
||
|
||
All three scripts are simple greps; no complex analog needed. Make executable (`chmod +x`) and optionally wire into `./gradlew check` via a `Exec` task in `recipe.quality`.
|
||
|
||
---
|
||
|
||
## Shared Patterns
|
||
|
||
### Version catalog accessor inside precompiled plugins
|
||
|
||
**Source:** RESEARCH.md § Pattern 2 (lines 362–380), PITFALL #1 (lines 654–660).
|
||
**Apply to:** every `.gradle.kts` file under `build-logic/src/main/kotlin/`.
|
||
|
||
```kotlin
|
||
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||
import org.gradle.kotlin.dsl.getByType
|
||
|
||
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||
|
||
// Then:
|
||
implementation(libs.findLibrary("koin-core").get())
|
||
val kotlinVersion = libs.findVersion("kotlin").get().toString()
|
||
val minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
|
||
```
|
||
|
||
**Anti-pattern:** `implementation(libs.koin.core)` inside a precompiled plugin → unresolved reference compile error.
|
||
|
||
---
|
||
|
||
### Quoted configuration names in precompiled-plugin `dependencies { }` blocks
|
||
|
||
**Source:** RESEARCH.md § Pattern 7 footnote (lines 603–605).
|
||
**Apply to:** `recipe.jvm.server.gradle.kts` and any future precompiled plugin that adds module dependencies.
|
||
|
||
```kotlin
|
||
dependencies {
|
||
"implementation"(libs.findLibrary("ktor-serverCore").get())
|
||
"testImplementation"(libs.findLibrary("ktor-serverTestHost").get())
|
||
}
|
||
```
|
||
|
||
**Not this:** `implementation(libs.findLibrary(...).get())` — the unquoted form is a typed DSL method that only exists on module build scripts, not on precompiled plugins.
|
||
|
||
---
|
||
|
||
### `allWarningsAsErrors` at extension level only
|
||
|
||
**Source:** D-11, PITFALL #3, PITFALL #7.
|
||
**Apply to:** `recipe.kotlin.multiplatform`, `recipe.jvm.server`, `recipe.quality` (as a safety net).
|
||
|
||
```kotlin
|
||
kotlin {
|
||
compilerOptions {
|
||
allWarningsAsErrors.set(true) // at kotlin { } extension level
|
||
}
|
||
// NOT inside androidTarget { compilerOptions { ... } } or jvm { compilerOptions { ... } }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Init order on every platform entry: configureLogging → initKoin → compose
|
||
|
||
**Source:** RESEARCH.md § Kermit bootstrap notes (line 948), PITFALLS #4 + #8.
|
||
**Apply to:** `MainApplication.onCreate()` (Android), `KoinIos.doInitKoin()` (iOS), `main()` (jvm + wasmJs).
|
||
|
||
```kotlin
|
||
configureLogging() // set Kermit tag first
|
||
initKoin() // Koin modules may log during load
|
||
// THEN composition entry: application { Window { App() } }
|
||
// OR ComposeViewport { App() }
|
||
// OR setContent { App() }
|
||
```
|
||
|
||
**Anti-pattern:** calling `startKoin` from inside a `@Composable` function — races with recomposition, panics.
|
||
|
||
---
|
||
|
||
### iOS framework baseName consistency (`ComposeApp` / `Shared`)
|
||
|
||
**Source:** D-20, PITFALL #10.
|
||
**Apply to:** `recipe.kotlin.multiplatform` (default: `"ComposeApp"`), `shared/build.gradle.kts` override (`"Shared"`), `iosApp/iosApp/iOSApp.swift` `import ComposeApp`, `iosApp/iosApp/ContentView.swift` `MainViewControllerKt.MainViewController()`.
|
||
|
||
**Invariant:** whatever string is set as `baseName` MUST match the `import X` in Swift, and the generated Swift class name is `<KotlinFileName>Kt` (e.g. `KoinIos.kt` → `KoinIosKt`, `MainViewController.kt` → `MainViewControllerKt`).
|
||
|
||
---
|
||
|
||
### Catalog-only version hard rule (D-09 / INFRA-01 SC#2)
|
||
|
||
**Source:** D-09.
|
||
**Apply to:** every `*.gradle.kts` except `build-logic/build.gradle.kts` (which needs literal `asDependency()` version in coordinates).
|
||
|
||
**Verification:** `grep -rn 'version\s*=\s*"[0-9]' --include='*.gradle.kts' .` must return zero hits outside `build-logic/build.gradle.kts`.
|
||
|
||
---
|
||
|
||
## Files with No Analog
|
||
|
||
These files are small bespoke shell scripts or structural placeholders; RESEARCH.md documents their intent but no other file in the repo has the same shape.
|
||
|
||
| File | Role | Data Flow | Reason |
|
||
|------|------|-----------|--------|
|
||
| `tools/verify-no-version-literals.sh` | validator | test/grep | Bespoke grep pipeline; canonical form shown above |
|
||
| `tools/verify-shared-pure.sh` | validator | test/grep | Bespoke grep pipeline; canonical form shown above |
|
||
| `tools/verify-ios-flags.sh` | validator | test/grep | Bespoke grep pipeline; canonical form shown above |
|
||
| `server/src/main/resources/db/migration/.gitkeep` | empty dir marker | n/a | Convention; zero-byte file |
|
||
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` | empty dir marker | n/a | Convention; zero-byte file |
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Analog search scope:**
|
||
- `/Users/rwilk/dev/repo/recipe/composeApp/` (template Kotlin + resources)
|
||
- `/Users/rwilk/dev/repo/recipe/shared/` (template Kotlin)
|
||
- `/Users/rwilk/dev/repo/recipe/server/` (template Ktor + resources)
|
||
- `/Users/rwilk/dev/repo/recipe/iosApp/` (SwiftUI shell)
|
||
- `/Users/rwilk/dev/repo/recipe/gradle/`, root-level `*.gradle.kts`, `gradle.properties`, `settings.gradle.kts`, `.gitignore`
|
||
- `/Users/rwilk/dev/repo/recipe/.planning/phases/01-.../01-RESEARCH.md` § Code Examples + § Architecture Patterns
|
||
|
||
**Files scanned:** ~30 (all existing sources in the refactor surface).
|
||
|
||
**Pattern extraction date:** 2026-04-24.
|
||
|
||
**Confidence:** HIGH for all in-repo-analog files (they are the exact files the executor will edit). HIGH for RESEARCH.md-canonical files — those excerpts were written with Phase 1 D-# decisions already applied. Residual risk is in two areas:
|
||
1. Exact plugin/library versions to pin in catalog (bump to latest stable at plan time; RESEARCH.md notes this).
|
||
2. Whether `shared/build.gradle.kts` retains `com.android.library` or drops it (see RESEARCH.md § Open Questions; plan must decide — default: keep, since `shared/` currently uses `namespace = "dev.ulfrx.recipe.shared"` for Android resources).
|
||
|
||
**Not covered by this document (intentionally deferred):** detekt, konsist, CI config, git hooks, compose-desktop packaging, `js` target, `iosX64` target — all are in CONTEXT.md § Deferred Ideas.
|