# 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 `` 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.asDependency(): Provider = 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().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().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().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().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>().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().configureEach { binaries.withType().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 `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 ` ``` --- ### `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()` 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().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 `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.