Files
recipe/.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
2026-04-24 16:21:25 +02:00

1344 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 7771107) |
**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 7771107 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 314331) | canonical |
| `build-logic/build.gradle.kts` | NEW | plugin buildscript | config | RESEARCH.md § Pattern 1 (lines 333358) | canonical |
| `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Code Examples (lines 777835); current `composeApp/build.gradle.kts` `kotlin { }` block (lines 1371) | role+flow match |
| `build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 4 (lines 447477) | canonical |
| `build-logic/src/main/kotlin/recipe.android.application.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 6 (lines 516552); current `composeApp/build.gradle.kts` `android { }` block (lines 7398) | canonical + repo mirror |
| `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 7 (lines 558601); 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 483512) | canonical |
| `gradle/libs.versions.toml` | MODIFIED | version catalog | config | itself (lines 153); add new `[versions]` + `[libraries]` + `[plugins]` entries for koin, kermit, spotless, flyway, postgres | self |
| `gradle.properties` | MODIFIED | gradle daemon + K/N flags | config | itself (lines 110) + RESEARCH.md § `gradle.properties` (lines 10831102) | self |
| `settings.gradle.kts` | MODIFIED | root settings | config | itself (lines 137); add `includeBuild("build-logic")` | self |
| `build.gradle.kts` | MODIFIED | root build | config | itself (lines 112); 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 1114); rewrite plugins block to convention-plugin IDs, drop `js { }` (lines 3639), drop `compose.desktop { nativeDistributions { ... } }` packaging (lines 104114, per D-03) | self |
| `shared/build.gradle.kts` | MODIFIED | module build | config | itself (lines 155); rewrite plugins block, drop `js { }` (lines 2527), 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 123); 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 840861) | canonical |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | NEW | DI module declaration | config | RESEARCH.md § Koin bootstrap (lines 852861) | canonical |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` | NEW | logger bootstrap | init-once | RESEARCH.md § Kermit bootstrap (lines 933946) | canonical |
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` | NEW | platform bridge (Kotlin→Swift symbol) | init-once | RESEARCH.md § Koin bootstrap (lines 865870); 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 896911); sibling: `MainActivity.kt` (lines 119) | canonical + sibling |
| `composeApp/src/androidMain/AndroidManifest.xml` | MODIFIED | Android manifest | config | itself (lines 122); add `android:name=".MainApplication"` to `<application>` tag | self |
| `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` | MODIFIED | Desktop entry | init-once | itself (lines 113); add `initKoin()` + `configureLogging()` at top of `main()` | self |
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` | MODIFIED | Wasm entry | init-once | itself (lines 110); 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 149); 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 111); add `init() { KoinIosKt.doInitKoin() }` (RESEARCH.md lines 874891) | 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 120); rewrite per RESEARCH.md § Ktor `/health` (lines 952985) — 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 9881023) | canonical |
| `server/src/main/resources/application.conf` | NEW | Ktor HOCON config | config | RESEARCH.md § `application.conf` (lines 10311051) | 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 112); 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 120); 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 10531077) | canonical |
| `README.md` | MODIFIED | dev docs | n/a | itself (lines 1100); 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 120); 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 314331). 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 333358).
**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 777835)** — the canonical form for this plugin.
2. **`composeApp/build.gradle.kts` lines 1371** — 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 789834 — 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 1371):**
- DROP the `js { browser(); binaries.executable() }` block (lines 3639) — 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 607618):
- 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 447477). 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 5262:**
- 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 516552)** — canonical form.
2. **`composeApp/build.gradle.kts` lines 7398** — 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 7398):**
- 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 558601)** — canonical form.
2. **`server/build.gradle.kts` lines 123** — 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 714 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 719724): 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 483512). 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 153).
**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 110) + RESEARCH.md § `gradle.properties` (lines 10831102).
**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 11041107): `./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 137).
**Delta:** add `includeBuild("build-logic")` in the right position.
**PITFALL #9** (RESEARCH.md lines 749767): `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 112).
**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 1114).
**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 511) with convention-plugin IDs.
- DROP the entire `kotlin { androidTarget { ... } ... }` block (lines 1371) — 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 7398) — moved to `recipe.android.application`.
- DROP `js { browser(); binaries.executable() }` (lines 3639) — D-01.
- DROP `compose.desktop { application { ... nativeDistributions { ... } } }` (lines 104114) — D-03 says no desktop packaging.
---
### `shared/build.gradle.kts` (MODIFIED — apply conventions + explicitApi)
**Analog:** itself (lines 155).
**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 155):**
- REPLACE plugins block (lines 47) 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 933) — moved to `recipe.kotlin.multiplatform`.
- DROP `js { browser() }` (lines 2527) — 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 123).
**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 123):**
- REPLACE plugins block (lines 15) with convention-plugin IDs.
- DROP individual library implementations (lines 1622) — moved to `recipe.jvm.server`.
- KEEP `application { mainClass.set(...) }` (lines 914) — 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 840850). 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 852861).
**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 935946).
**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 865870)** — the canonical symbol.
2. **`composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt` (lines 15)** — 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 896911)** — canonical.
2. **`composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` (lines 119)** — 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 122).
**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 113) + RESEARCH.md § Koin bootstrap (lines 918924).
**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 110) + RESEARCH.md § Koin bootstrap (lines 927931); PITFALL #8 (lines 733747).
**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 111) + RESEARCH.md § Koin bootstrap (lines 874891).
**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 111):**
- 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 120) + RESEARCH.md § Ktor `/health` (lines 952985).
**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 120):**
- 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 9901023). No in-repo analog.
**Important substitution** (RESEARCH.md lines 10251027): 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 10311051). 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 692717):** 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 120).
**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 120):**
- 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 10551077). 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 1100).
**Delta:**
1. DROP "Build and Run Web Application" JS sections (lines 7785) — 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 12421256 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 362380), PITFALL #1 (lines 654660).
**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 603605).
**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.