Compare commits

...

33 Commits

Author SHA1 Message Date
68655eae1a Phase 1 work 2026-04-24 20:21:03 +02:00
b36058fa79 chore(01-07): add shared package scaffold placeholder
- Create dev.ulfrx.recipe.shared sub-package with .gitkeep marker
- Phase 2+ will populate with cross-target DTOs / domain models
- Satisfies INFRA-06 file-existence criterion for empty package scaffold
2026-04-24 19:46:30 +02:00
81bff1db17 merge(01-04): Koin + Kermit bootstrap across all platforms 2026-04-24 19:45:25 +02:00
eaa88fff36 docs(01-04): add SUMMARY for Koin + Kermit bootstrap plan 2026-04-24 19:44:47 +02:00
fd3e7e1584 feat(01-04): wire JVM + Wasm main + Swift iOSApp to bootstrap Koin + Kermit
- JVM main: configureLogging() + initKoin() before application { Window }
- Wasm main: configureLogging() + initKoin() before ComposeViewport (PITFALL #8)
- iOSApp.swift: import ComposeApp + init { KoinIosKt.doInitKoin() } (PITFALL #4)
2026-04-24 19:41:51 +02:00
129ee616d5 docs(01-05): add SUMMARY for server /health + Flyway + HOCON plan 2026-04-24 19:41:47 +02:00
8cd608a981 feat(01-04): add Android MainApplication + manifest registration
- MainApplication.onCreate calls configureLogging() then initKoin { androidContext(...) }
- AndroidManifest registers android:name=".MainApplication"
2026-04-24 19:41:22 +02:00
cc5002d1df feat(01-04): add Koin + Kermit bootstrap commonMain + iOS bridge
- initKoin() helper with optional KoinAppDeclaration config
- empty appModule placeholder (Phase 2+ extends)
- configureLogging() sets Kermit tag 'recipe' (D-15)
- iOS doInitKoin() bridge → Swift symbol KoinIosKt.doInitKoin
2026-04-24 19:41:05 +02:00
d7ee6b83fc Add summary for plan phase 1.2 2026-04-24 19:26:41 +02:00
61885455bb merge(01-06): docker-compose + README Local development 2026-04-24 18:41:51 +02:00
6972839fd0 merge(01-05): server /health + Flyway + HOCON + fail-loud DB boot 2026-04-24 18:41:51 +02:00
c79f9218aa merge(01-03): module refactor to recipe.* conventions + drop js 2026-04-24 18:41:51 +02:00
2c786b2fc2 merge(01-02): build-logic scaffold + 5 precompiled plugins 2026-04-24 18:41:43 +02:00
f9d3a0c2d4 docs(01-06): add SUMMARY for dev-ergonomics plan 2026-04-24 18:24:24 +02:00
b8671d6dbb docs(01-03): summary of module build-script conventions wiring
- composeApp/build.gradle.kts: 114 -> 28 lines (role declaration with 4 recipe.* IDs)
- shared/build.gradle.kts: 55 -> 36 lines (3 plugins, explicitApi, Framework baseName "Shared")
- server/build.gradle.kts: 23 -> 18 lines (recipe.jvm.server + recipe.quality + module-only config)
- shared/src/jsMain/ deleted (D-01)
- 0 deviations; both verify-*.sh scripts pass; INFRA-02 + INFRA-06 structural prerequisites delivered
2026-04-24 18:23:41 +02:00
59d069591b test(01-05): rewrite ApplicationTest to assert GET /health without Postgres
- Replace testRoot template assertion with 'health endpoint returns 200 with status ok'
- Compose only configureRouting() in testApplication — NOT Application.module()
- This keeps the test independent of Database.migrate / running Postgres (D-11 test invariant)
- Install ContentNegotiation { json() } inside application { } — production module() does it,
  but the test composes routing directly and must install the plugin itself
- All imports explicit (D-11 allWarningsAsErrors); no wildcards
- Body checked via substring for "status" + "ok" — robust to JSON field ordering

Note: ./gradlew :server:test runtime verification deferred to Plan 07 (integration build)
since build-logic/recipe.jvm.server plugin is being authored in parallel Plan 02 worktree.
2026-04-24 18:23:14 +02:00
60221f66a2 feat(01-02): wire build-logic into root settings + add spotless/flyway classloader hints
- settings.gradle.kts: includeBuild("build-logic") placed inside pluginManagement { } (PITFALL #9)
- build.gradle.kts: 2 new alias(...) apply false entries (spotless, flywayPlugin)
- Existing repositories, module includes, and 8 original apply-false entries preserved verbatim
2026-04-24 18:22:56 +02:00
37f6191523 feat(01-04): wire JVM + Wasm main + Swift iOSApp to bootstrap Koin + Kermit
- Desktop main() calls configureLogging() → initKoin() before application { Window { App() } }
- Wasm main() calls configureLogging() → initKoin() before ComposeViewport { App() } (PITFALL #8 future-proof)
- iOSApp.swift imports ComposeApp and calls KoinIosKt.doInitKoin() in init() — single iOS call site (PITFALL #4)
- MainViewController.kt and App.kt unmodified (anti-pattern guards)
2026-04-24 18:22:47 +02:00
f691400f2b docs(01-06): add Local development section and drop js target
- New "Local development" section documents docker compose + gradlew dev loop
- Covers /health smoke test, env-var overrides (DATABASE_* and PORT)
- Adds spotlessApply + check + down -v reference commands
- Removes legacy js target docs (D-01); wasmJs target preserved
2026-04-24 18:22:41 +02:00
daefe6c26d refactor(01-05): rewrite Application.kt with ContentNegotiation, Flyway boot, /health
- Remove wildcard Ktor imports (D-11 allWarningsAsErrors safety) — all imports explicit
- Install ContentNegotiation { json() } for @Serializable response bodies
- Call Database.migrate(this) at boot — fails loudly if Postgres unreachable
- Extract configureRouting() extension so tests can compose routing without DB
- Replace template root greeting with GET /health → {"status":"ok"} (D-16)
- main() shape unchanged: embeddedServer(Netty, SERVER_PORT, "0.0.0.0", ...)
2026-04-24 18:22:37 +02:00
d316a4805e refactor(01-03): apply recipe.jvm.server + recipe.quality to server module
- Replaces alias(kotlinJvm) + alias(ktor) + application with id("recipe.jvm.server") + id("recipe.quality") — application plugin now applied by the convention
- Removes per-module dep lines (logback, ktor-serverCore, ktor-serverNetty, ktor-serverTestHost, kotlin-testJunit) — all bundled in recipe.jvm.server
- Keeps module-only config: group/version coordinates, application { mainClass.set } + applicationDefaultJvmArgs, implementation(projects.shared)
- File shrinks 23 -> 18 lines; no version literals leak
2026-04-24 18:22:09 +02:00
24018efe67 feat(01-05): add HOCON config, Flyway migration dir, fail-loud Database.migrate
- application.conf: HOCON with ktor.deployment.port (8080 + ${?PORT}) and
  database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD})
- db/migration/.gitkeep: placeholder so classpath:db/migration resolves
- Database.kt: object Database.migrate(app) reads HOCON config, runs Flyway
  with baselineOnMigrate + validateOnMigrate + cleanDisabled, throws
  IllegalStateException on any failure (D-16 fail-loud contract)
- SLF4J (not Kermit); server logs url+user only, never password
2026-04-24 18:22:08 +02:00
4e6192293f feat(01-04): add Android MainApplication + manifest registration
- Create MainApplication : Application() running configureLogging() then initKoin { androidContext(this@MainApplication) } in onCreate
- Register android:name=".MainApplication" on <application> element (MainActivity entry preserved)
- Establishes the canonical init order for Android process boot
2026-04-24 18:22:03 +02:00
6a69910aa7 feat(01-02): scaffold build-logic included build with 5 precompiled plugins
- build-logic/settings.gradle.kts resolves parent catalog via files("../gradle/libs.versions.toml")
- build-logic/build.gradle.kts declares kotlin-dsl + 9 compileOnly asDependency entries
- recipe.quality: Spotless + ktlint + D-11 allWarningsAsErrors safety net (plugins.withId guard)
- recipe.kotlin.multiplatform: D-05 target matrix (androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs) + JVM 11/21 split + baseName "ComposeApp" + Koin/Kermit/kotlin-test deps
- recipe.compose.multiplatform: layers on recipe.kotlin.multiplatform (PITFALL #2 avoided) + hot-reload + Compose deps
- recipe.android.application: namespace dev.ulfrx.recipe + findVersion catalog accessor (PITFALL #1)
- recipe.jvm.server: Ktor + Flyway + Postgres with quoted "implementation" configs + cleanDisabled guard
2026-04-24 18:22:02 +02:00
af4428fd8a feat(01-06): add docker-compose.yml with postgres:16 for local dev
- Single postgres service pinned to postgres:16
- Credentials recipe/recipe/recipe match application.conf HOCON defaults
- Named volume recipe-pgdata for persistence across restarts
- Healthcheck via pg_isready enables docker compose up --wait usage
- No version key (modern compose v2); Authentik stays on homelab (D-17)
2026-04-24 18:21:40 +02:00
7d750af710 feat(01-04): add Koin + Kermit bootstrap commonMain + iOS bridge
- Add initKoin(config) helper wrapping startKoin { modules(appModule) } (PITFALL #4 single entry)
- Add empty appModule placeholder (D-14) — Phase 2+ extends
- Add configureLogging() setting Kermit tag "recipe" (D-15)
- Add iosMain doInitKoin() bridge — Swift-accessible as KoinIosKt.doInitKoin()
- configureLogging() always runs before initKoin() so module loading can log
2026-04-24 18:21:36 +02:00
d76dcea18d refactor(01-03): apply recipe.* conventions to composeApp + shared, drop js
- composeApp/build.gradle.kts now applies 4 recipe.* IDs (kotlin.multiplatform, compose.multiplatform, android.application, quality); removes all structural target/android/nativeDistributions blocks (114 -> 28 lines)
- shared/build.gradle.kts applies recipe.kotlin.multiplatform + recipe.quality + androidLibrary; adds explicitApi() (D-12) and KotlinNativeTarget/Framework baseName = "Shared" override (D-07 / PITFALL #10); keeps android { } block per Open Question #1
- Adds libs.koin.android to androidMain dependencies (for Plan 04's MainApplication androidContext)
- Drops js target per D-01: removes js { browser() } from both modules and deletes shared/src/jsMain/Platform.js.kt
- iosX64 remains absent per D-02
- No version literals leak; tools/verify-no-version-literals.sh + verify-shared-pure.sh both pass
2026-04-24 18:21:35 +02:00
4d9aefd4c2 docs(01-01): complete foundations plan — catalog + iOS flags + invariants
Summarizes Plan 01-01 execution:
- 3 task commits (b609cb6, d873c31, aaa8042)
- 1 Rule 3 auto-fix (refined verify-no-version-literals.sh to
  exclude top-level project-version metadata while still
  catching indented library/plugin version literals)
- Self-check PASSED (all files + commits verified)

Requirements: INFRA-01, INFRA-03
2026-04-24 18:18:20 +02:00
aaa8042aee feat(01-01): add Phase 1 invariant verification scripts
Three executable bash scripts under tools/ that Wave 0 and every
subsequent Phase 1 plan's <automated> block rely on:

- verify-no-version-literals.sh (INFRA-01 SC#2 / D-09): no literal
  library/plugin version strings in any *.gradle.kts. Excludes
  build-logic/build.gradle.kts (needs asDependency() literals) and
  top-level project-version assignments ("^version = \"x.y.z\"")
  which are artifact metadata, not library pins.
- verify-shared-pure.sh (INFRA-06 / D-19): shared/commonMain must
  not import Ktor/Compose/SQLDelight. Returns OK if the directory
  does not exist yet (pre-scaffold tolerance for Plan 07).
- verify-ios-flags.sh (INFRA-03 / D-18): both K/N iOS binary flags
  present in gradle.properties.

All three use bash (#!/usr/bin/env bash + set -euo pipefail) and
are marked chmod +x. Scripts exit 0 against the current repo state.
2026-04-24 18:16:29 +02:00
d873c31e19 feat(01-01): add iOS Kotlin/Native binary flags to gradle.properties
- kotlin.native.binary.gc=cms (concurrent mark-sweep collector)
- kotlin.native.binary.objcDisposeOnMain=false (off-main-thread
  Obj-C deinit) — avoids UI-thread pause spikes in CMP on iOS
- Enforces INFRA-03 / D-18 / CLAUDE.md convention #7 /
  PITFALLS.md #1 on day 1 before any iOS code is compiled
2026-04-24 18:14:12 +02:00
b609cb6362 feat(01-01): extend version catalog with Phase 1 aliases
- Add versions: flyway=12.4.0, kermit=2.1.0, koin=4.2.1,
  kotlinx-serialization=1.7.3, postgresql=42.7.10, spotless=8.4.0
- Add libraries: 5 koin-* (BOM-managed for -core/-compose/-
  composeViewmodel/-android), kermit, 2 ktor server-side
  (content-negotiation + kotlinx-json), 2 flyway (core + postgres
  database module), postgresql JDBC driver
- Add plugins: spotless (Diffplug) + flywayPlugin
- No existing version refs modified; additive only (D-09)
2026-04-24 18:13:49 +02:00
875055a5ef docs(state): begin Phase 1 execution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:11:29 +02:00
8ef2dbfae4 chore: clear auto-chain flag before phase 1 execution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:11:14 +02:00
56 changed files with 1492 additions and 218 deletions

21
.editorconfig Normal file
View File

@@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{kt,kts}]
# ktlint configuration for Compose Multiplatform.
# - function-naming is disabled because @Composable functions and Kotlin/Native
# entry-point factories (e.g. MainViewController) are PascalCase by convention.
# - filename is disabled because Compose-Multiplatform entry-point files
# (jvmMain/main.kt, webMain/main.kt) follow the Kotlin `fun main()` convention.
ktlint_standard_function-naming = disabled
ktlint_standard_filename = disabled
[*.md]
trim_trailing_whitespace = false

View File

@@ -2,15 +2,15 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
current_plan:
status: Roadmap created; no plan started yet
last_updated: "2026-04-24T16:07:36.296Z"
current_plan: 1
status: executing
last_updated: "2026-04-24T17:39:22.205Z"
progress:
total_phases: 11
completed_phases: 0
total_plans: 7
completed_plans: 0
percent: 0
completed_plans: 4
percent: 57
---
# Project State: Recipe
@@ -25,9 +25,11 @@ progress:
## Current Position
**Current focus:** Phase 1: Project Infrastructure & Module Wiring
**Current plan:**
**Status:** Roadmap created; no plan started yet
Phase: --phase (01) — EXECUTING
Plan: 1 of --name
**Current focus:** Phase --phase — 01
**Current plan:** 1
**Status:** Executing Phase --phase
**Phase progress:** 0 / 11 phases complete
**Progress bar:** `░░░░░░░░░░░░░░░░░░░░` 0%

View File

@@ -27,7 +27,8 @@
"discuss_mode": "discuss",
"skip_discuss": false,
"code_review": true,
"code_review_depth": "standard"
"code_review_depth": "standard",
"_auto_chain_active": false
},
"hooks": {
"context_warnings": true

View File

@@ -0,0 +1,154 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 01
subsystem: infra
tags: [gradle, version-catalog, kotlin-native, ios-binary-flags, bash, invariants, koin, kermit, flyway, postgresql, spotless, ktor]
# Dependency graph
requires: []
provides:
- Gradle version catalog extended with Koin (BOM + core/compose/composeViewmodel/android), Kermit, Spotless, Flyway (core + postgresql), Postgres JDBC, Ktor content-negotiation + kotlinx-json serializer
- kotlinx-serialization = 1.7.3 version alias (kept even though no library wires it in Plan 01 — Phase 2+ wire Ktor plugins using this pin)
- iOS K/N binary flags kotlin.native.binary.gc=cms + kotlin.native.binary.objcDisposeOnMain=false in gradle.properties
- tools/verify-no-version-literals.sh (D-09 invariant check)
- tools/verify-shared-pure.sh (INFRA-06 / D-19 invariant check — tolerant of pre-scaffold shared/commonMain)
- tools/verify-ios-flags.sh (INFRA-03 / D-18 invariant check)
affects: [01-02-build-logic, 01-03-module-wiring, 01-04-compose-app, 01-05-server, 01-06-shared, 01-07-validation, 02-auth, 10-ui-chrome, 11-localization-deployment]
# Tech tracking
tech-stack:
added:
- Koin 4.2.1 (BOM + 4 consumed modules)
- Kermit 2.1.0
- Spotless 8.4.0 (plugin)
- Flyway 12.4.0 (core + database-postgresql module + Gradle plugin)
- PostgreSQL JDBC 42.7.10
- Ktor server content-negotiation + kotlinx-json serializer (version.ref = existing ktor 3.4.1)
- kotlinx-serialization = 1.7.3 version alias (no library entry yet — pre-wire for ktor serializer which derives its version from ktor)
patterns:
- "Catalog-only versioning: no numeric version literals in *.gradle.kts outside build-logic/ (D-09 / INFRA-01 SC#2)"
- "BOM-managed Koin libs omit version.ref (koin-core/koin-compose/koin-composeViewmodel/koin-android pinned via koin-bom)"
- "Fail-loud shell invariants under tools/ — every Phase 1 plan's <automated> block calls one of these three scripts"
key-files:
created:
- tools/verify-no-version-literals.sh
- tools/verify-shared-pure.sh
- tools/verify-ios-flags.sh
modified:
- gradle/libs.versions.toml
- gradle.properties
key-decisions:
- "Refined verify-no-version-literals.sh to exclude top-level project-version assignments (^version = \"x.y.z\") — these are Gradle artifact metadata, not library-version pins. D-09 guards dependencies, not project identity. server/build.gradle.kts:8 keeps its project version."
patterns-established:
- "Pattern 1: All Phase 1+ library/plugin versions declared ONLY in gradle/libs.versions.toml; build scripts reference via libs.* accessors"
- "Pattern 2: iOS Kotlin/Native binary flags live in gradle.properties — single file, compiler reads verbatim at link time"
- "Pattern 3: Invariant checks as bash scripts under tools/; shebang #!/usr/bin/env bash; set -euo pipefail; fail-loud on violation, silent-pass on clean"
- "Pattern 4: Pre-scaffold tolerance — verify-shared-pure.sh exits 0 if shared/src/commonMain doesn't exist (lets invariant scripts run before Plan 07 scaffolds)"
requirements-completed: [INFRA-01, INFRA-03]
# Metrics
duration: 4min
completed: 2026-04-24
---
# Phase 01 Plan 01: Foundations Summary
**Gradle version catalog extended with 6 versions / 11 libraries / 2 plugins (Koin + Kermit + Spotless + Flyway + Postgres + Ktor content-negotiation), iOS K/N binary flags (gc=cms + objcDisposeOnMain=false) added to gradle.properties, and three tools/verify-*.sh invariant scripts shipped — the foundation every remaining Phase 1 plan leans on.**
## Performance
- **Duration:** 4 min
- **Started:** 2026-04-24T16:12:45Z
- **Completed:** 2026-04-24T16:16:53Z
- **Tasks:** 3
- **Files modified:** 2 (catalog + gradle.properties)
- **Files created:** 3 (tools/verify-*.sh)
## Accomplishments
- **Version catalog now covers every Phase 1+ library and plugin**, including BOM-managed Koin modules (omitting `version.ref` by design) and fine-grained Ktor server-side JSON plumbing. No existing version ref was bumped; additive-only per D-09.
- **iOS K/N binary flags wired on day 1** (gc=cms + objcDisposeOnMain=false), closing PITFALL #1 before any iOS framework is linked.
- **Three invariant scripts ship green** — `verify-ios-flags.sh`, `verify-shared-pure.sh`, `verify-no-version-literals.sh` — exit 0 against current repo state, ready to gate every subsequent plan's automated checks.
## Task Commits
Each task committed atomically on this worktree branch:
1. **Task 1: Extend gradle/libs.versions.toml with Phase 1 aliases**`b609cb6` (feat)
2. **Task 2: Append iOS K/N binary flags to gradle.properties**`d873c31` (feat)
3. **Task 3: Create verify-*.sh invariant scripts under tools/**`aaa8042` (feat)
_No TDD for this plan — all tasks are config/scaffold, not behavior._
## Files Created/Modified
### Created
- `tools/verify-no-version-literals.sh` — Grep `*.gradle.kts` for `version = "[0-9]..."`; skip `build-logic/build.gradle.kts` (legitimate plugin-dep literals) and top-level project-version assignments (artifact metadata, not library pins).
- `tools/verify-shared-pure.sh` — Grep `shared/src/commonMain/` for imports from `io.ktor`, `androidx.compose`, `org.jetbrains.compose`, `app.cash.sqldelight`. Exits 0 if the directory doesn't exist yet (Plan 07 hasn't scaffolded it).
- `tools/verify-ios-flags.sh` — Grep `gradle.properties` for both K/N flags; fail with a clear MISSING: line if either absent.
### Modified
- `gradle/libs.versions.toml` — +6 versions (flyway, kermit, koin, kotlinx-serialization, postgresql, spotless); +11 libraries (5 koin-*, kermit, 2 ktor-server-*, 2 flyway-*, postgresql); +2 plugins (spotless, flywayPlugin). 24 / 33 / 10 totals after edit.
- `gradle.properties` — +5 lines (blank separator + comment + comment + 2 K/N flags) appended after existing Android block. Original 10 lines unchanged.
## Decisions Made
- **Refined verify-no-version-literals.sh script semantics** — The plan's canonical script (from 01-RESEARCH.md lines 11741218 and 01-PATTERNS.md lines 446490) excluded only `build-logic/build.gradle.kts`. Running it against the current repo tripped on `server/build.gradle.kts:8: version = "1.0.0"` — the Ktor template's project-version property. Per D-09, the invariant targets **library/plugin** version literals, not project/artifact metadata. I added a second, narrow exclusion: lines where the matched `version = "..."` begins at column 0 (unindented project-version assignments). Library and plugin version literals always appear inside a `dependencies { }` or `plugins { }` block and are therefore indented, so they remain caught. Sanity-check: the script still flags a synthetic `dependencies { implementation("x:y") { version = "9.9.9" } }` as a violation.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Refined verify-no-version-literals.sh to not fire on project-version metadata**
- **Found during:** Task 3 (running the script for the first time)
- **Issue:** The canonical script in the plan (quoted verbatim from 01-RESEARCH.md) exit-1'd on `server/build.gradle.kts:8: version = "1.0.0"`. That line is the Gradle project-version property (artifact name metadata), not a library-version pin. The plan's acceptance criterion ("`bash tools/verify-no-version-literals.sh` exits 0 today") cannot be satisfied without either removing the project version or refining the script. D-09 (CONTEXT.md line 32) says the rule is "no library versions outside catalog" — project version is out of scope for D-09.
- **Fix:** Added a second `grep -v` to exclude lines matching `:[0-9]+:version[[:space:]]*=[[:space:]]*"[0-9]` (unindented top-level project-version). Library/plugin version literals in Gradle DSL are always indented inside a block, so they remain caught. Updated the script's header comment to document the refinement rationale.
- **Files modified:** `tools/verify-no-version-literals.sh`
- **Verification:** Script exits 0 against current repo state; synthetic indented `version = "9.9.9"` test case still trips the script with exit 1. Both conditions tested.
- **Committed in:** `aaa8042` (Task 3 commit — the refined script is the only shipped version; no redundant fix-up commit).
---
**Total deviations:** 1 auto-fixed (Rule 3 — blocking)
**Impact on plan:** Minimal. The refinement strengthens the script's semantic correctness (targets library/plugin pins, not project identity). Success criteria and all acceptance criteria still pass. No additional tasks; no scope creep.
## Issues Encountered
- **Initial worktree base mismatch** — Worktree branch HEAD was `0ca22f9e` (a later commit in the worktree's own history), not the expected `875055a` base. The `<worktree_branch_check>` guard caught it and reset to `875055a` before any work. All three task commits therefore sit cleanly on the required base.
- **Planner arithmetic off-by-one** — Plan success criteria say "10 new [libraries] entries"; the plan's own enumeration lists 11 (5 koin + kermit + 2 ktor + 2 flyway + postgresql). I shipped all 11 explicitly named entries. This is a planner-side typo, not a deviation.
## User Setup Required
None. This plan is pure build configuration — no secrets, no external services, no dashboard config.
## Next Phase Readiness
- **Plan 02 (build-logic/) unblocked** — `VersionCatalogsExtension.named("libs").findLibrary("koin-core")` etc. will resolve in precompiled plugins; every alias the downstream plans need is now present.
- **Plan 03+ module build files unblocked** — modules can reference `libs.koin.core`, `libs.kermit`, `libs.ktor.serverContentNegotiation`, `libs.flyway.core`, `libs.flyway.database.postgresql`, `libs.postgresql` via type-safe accessors.
- **Plan 07 (validation) unblocked** — the three `tools/verify-*.sh` scripts are the Wave 0 gate it enumerates.
- **No blockers.** `./gradlew build` is NOT expected to pass until Plan 02 wires up `build-logic/` and Plan 03 refactors module build scripts — that's by design and stated in this plan's `<verification>` block.
## Self-Check: PASSED
Verification of claims in this summary:
**Created files exist:**
- `tools/verify-no-version-literals.sh` — FOUND + executable
- `tools/verify-shared-pure.sh` — FOUND + executable
- `tools/verify-ios-flags.sh` — FOUND + executable
**Commits exist in branch history:**
- `b609cb6` — FOUND (feat(01-01): extend version catalog with Phase 1 aliases)
- `d873c31` — FOUND (feat(01-01): add iOS Kotlin/Native binary flags to gradle.properties)
- `aaa8042` — FOUND (feat(01-01): add Phase 1 invariant verification scripts)
**All three invariant scripts exit 0 against the current repo state.** All success criteria from the plan pass.
---
*Phase: 01-project-infrastructure-module-wiring*
*Completed: 2026-04-24*

View File

@@ -0,0 +1,98 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 02
subsystem: infra
tags: [gradle, build-logic, included-build, precompiled-plugins, version-catalog, kotlin-multiplatform, compose, ktor, spotless, flyway, pitfall-1, pitfall-2, pitfall-9, pitfall-10]
requires: [01-01]
provides:
- "build-logic/ included build resolving the parent catalog via files(\"../gradle/libs.versions.toml\")"
- "Precompiled plugin recipe.quality (Spotless + ktlint + D-11 allWarningsAsErrors safety net via plugins.withId guard)"
- "Precompiled plugin recipe.kotlin.multiplatform (D-05 target matrix: androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs; JVM toolchain 21 + JVM 11 Android bytecode per D-08; framework baseName = ComposeApp; Koin BOM + koin-core + Kermit + kotlin-test common deps; allWarningsAsErrors at kotlin{} level)"
- "Precompiled plugin recipe.compose.multiplatform (layers on recipe.kotlin.multiplatform — PITFALL #2 avoided; Compose + composeCompiler + composeHotReload + commonMain Compose deps + lifecycle-viewmodel-compose + koin-compose)"
- "Precompiled plugin recipe.android.application (namespace dev.ulfrx.recipe; findVersion catalog accessor per PITFALL #1; SDK versions from catalog)"
- "Precompiled plugin recipe.jvm.server (Kotlin JVM + Ktor + Flyway + application; quoted \"implementation\" configs; cleanDisabled=true; D-08 JVM toolchain 21)"
- "Root settings.gradle.kts with includeBuild(\"build-logic\") placed inside pluginManagement{} (PITFALL #9)"
- "Root build.gradle.kts with 10 alias(...) apply false entries (8 existing + Spotless + Flyway classloader hints)"
affects: [01-03, 01-04, 01-05, 01-06, 01-07]
tech-stack:
added:
- "build-logic/ included build (kotlin-dsl convention-plugin project)"
- "5 precompiled script plugins: recipe.quality, recipe.kotlin.multiplatform, recipe.compose.multiplatform, recipe.android.application, recipe.jvm.server"
patterns:
- "PITFALL #1 mitigation: every precompiled plugin reads versions via extensions.getByType<VersionCatalogsExtension>().named(\"libs\")"
- "PITFALL #2 mitigation: recipe.compose.multiplatform applies id(\"recipe.kotlin.multiplatform\") — KMP plugin applied transitively"
- "PITFALL #9 mitigation: includeBuild(\"build-logic\") sits inside pluginManagement{}"
- "PITFALL #10 mitigation: baseName = \"ComposeApp\" set on both iOS frameworks"
- "Quoted-configuration footgun avoidance: recipe.jvm.server uses \"implementation\"(...) string-literal configs"
- "D-11 redundancy guard: recipe.quality uses plugins.withId guards for composability"
- "Plugin coordinate synthesis via Provider<PluginDependency>.asDependency() keeps build-logic/build.gradle.kts catalog-only"
key-files:
created:
- build-logic/settings.gradle.kts
- build-logic/build.gradle.kts
- build-logic/src/main/kotlin/recipe.quality.gradle.kts
- build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
- build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts
- build-logic/src/main/kotlin/recipe.android.application.gradle.kts
- build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts
modified:
- settings.gradle.kts
- build.gradle.kts
key-decisions:
- "Content for all 7 build-logic/ files copied verbatim from 01-RESEARCH.md § Code Examples + 01-PATTERNS.md; no structural changes."
- "9 compileOnly(...asDependency()) entries omit androidLibrary — no recipe-family precompiled plugin applies com.android.library; shared/build.gradle.kts applies that plugin directly in 01-03."
- "recipe.quality's D-11 safety net is plugins.withId-guarded so the plugin remains composable when applied standalone."
patterns-established:
- "Pattern 1 (role declarations): each recipe.* plugin encodes a module role; shared/ cannot pull Compose transitively (INFRA-06)."
- "Pattern 2 (catalog-only versioning inside build-logic): plugin coordinates via asDependency(); library refs via findLibrary; version refs via findVersion.toString().toInt()."
- "Pattern 3 (Flyway CLI + runtime split): flyway{} block for CLI ergonomics; runtime migration handled in 01-05."
- "Pattern 4 (JVM target split): jvmToolchain(21) drives shared/server/desktop; Android bytecode pinned at JVM 11; server JVM output at JVM 21."
requirements-completed: [INFRA-02]
duration: ~5min
completed: 2026-04-24
tasks-completed: 2
files-created: 7
files-modified: 2
---
# Phase 01 Plan 02: build-logic included build + 5 precompiled script plugins
`build-logic/` scaffolded as an included build whose 5 precompiled script plugins encode D-05/D-06/D-08/D-11/D-20 constraints once, and whose single hook into the root project is `includeBuild("build-logic")` inside `settings.gradle.kts pluginManagement { }` per PITFALL #9.
## What was built
- **`build-logic/settings.gradle.kts`** — resolves parent catalog via `from(files("../gradle/libs.versions.toml"))`; `rootProject.name = "build-logic"`.
- **`build-logic/build.gradle.kts`** — applies `` `kotlin-dsl` ``; 9 `compileOnly(libs.plugins.*.asDependency())` entries; `Provider<PluginDependency>.asDependency()` extension synthesises coordinates.
- **`recipe.quality.gradle.kts`** — Spotless + ktlint on `src/**/*.kt` with `targetExclude("**/build/**", "**/generated/**")`; two `plugins.withId` guards enforce `allWarningsAsErrors.set(true)` on `KotlinCompilationTask<*>` when a Kotlin plugin is present.
- **`recipe.kotlin.multiplatform.gradle.kts`** — canonical KMP plugin; `jvmToolchain(21)`; `androidTarget { jvmTarget = JVM_11 }`; `iosArm64()` + `iosSimulatorArm64()` with `baseName = "ComposeApp"; isStatic = true`; `jvm { jvmTarget = JVM_21 }`; `wasmJs { browser() }`; commonMain deps: Koin BOM + koin-core + Kermit; commonTest: kotlin-test.
- **`recipe.compose.multiplatform.gradle.kts`** — applies `id("recipe.kotlin.multiplatform")` (PITFALL #2) + compose MP + compose compiler + compose hot-reload; commonMain Compose deps + lifecycle-viewmodel + koin-compose.
- **`recipe.android.application.gradle.kts`** — `namespace = "dev.ulfrx.recipe"`; SDK versions via `libs.findVersion("android-compileSdk").get().toString().toInt()` (PITFALL #1); JVM 11 compile options.
- **`recipe.jvm.server.gradle.kts`** — Kotlin JVM + Ktor + Flyway + application; `jvmToolchain(21)` + `allWarningsAsErrors.set(true)`; 10 quoted `"implementation"(...)` deps (ktor-server*, logback, flyway-core + flyway-database-postgresql, postgresql JDBC, ktor-serverTestHost, kotlin-testJunit); `flyway{}` block with env-driven URL + `cleanDisabled = true`.
- **Root `settings.gradle.kts`** — added `includeBuild("build-logic")` as first statement inside existing `pluginManagement { }`.
- **Root `build.gradle.kts`** — appended `alias(libs.plugins.spotless) apply false` and `alias(libs.plugins.flywayPlugin) apply false`. Total apply-false count: 10.
## Commits
| Task | Description | Hash |
|------|-------------|------|
| 1 | Scaffold build-logic/ included build + 5 precompiled plugins | `6a69910` |
| 2 | Wire build-logic into root settings.gradle.kts + Spotless/Flyway apply-false | `60221f6` |
## Deviations from Plan
None — every `<automated>` grep block and acceptance criterion passed first-try.
## Note
This SUMMARY.md was drafted by the executor agent but hook-blocked from being written inside the worktree sandbox; the orchestrator persisted it after merging the worktree into master.
## Requirements completed
INFRA-02

View File

@@ -0,0 +1,148 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 03
subsystem: infra
tags: [gradle, kmp, convention-plugins, compose-multiplatform, ktor-server, android-library, explicitApi, ios-framework]
# Dependency graph
requires:
- phase: 01-project-infrastructure-module-wiring
provides: "Plan 01 extended libs.versions.toml (koin.android alias); Plan 02 created 5 recipe.* convention plugins in build-logic/"
provides:
- "composeApp/build.gradle.kts reduced from 114 to 28 lines; role-declaration plugin block applying recipe.kotlin.multiplatform + recipe.compose.multiplatform + recipe.android.application + recipe.quality"
- "shared/build.gradle.kts reduced from 55 to 36 lines; applies recipe.kotlin.multiplatform + recipe.quality + androidLibrary; enables explicitApi(); overrides iOS framework baseName to 'Shared'"
- "server/build.gradle.kts reduced from 23 to 18 lines; applies recipe.jvm.server + recipe.quality; retains only module-specific mainClass + projects.shared dep"
- "js target fully removed: shared/src/jsMain/ directory deleted (D-01)"
- "iosX64 remains absent across all modules (D-02)"
- "INFRA-02 structural payoff visible: adding a new KMP module henceforth requires only plugins { id('recipe.kotlin.multiplatform') } + sourceSet declarations"
- "INFRA-06 structural prerequisite: shared/ no longer applies recipe.compose.multiplatform, so Compose cannot leak transitively"
affects: [02-auth, 03-households, 04-sync-skeleton, 05-recipe-catalog, 10-ui-chrome]
# Tech tracking
tech-stack:
added: [] # Plan 03 is pure refactor — all libraries/tools already added in Plans 01/02
patterns:
- "Role-declaration plugin blocks (D-06): module build.gradle.kts plugins {} lists only recipe.* IDs + module-specific aliases (e.g. androidLibrary on shared/)"
- "Per-module override pattern: shared/ overrides framework baseName by targeting KotlinNativeTarget + Framework directly in the module, not from the convention plugin (D-07 / PITFALL #10)"
- "Module-specific dep retention: jvmMain compose.desktop.currentOs + kotlinx.coroutinesSwing stay in composeApp; android debug-only libs.compose.uiTooling stays as debugImplementation"
key-files:
created: []
modified:
- "composeApp/build.gradle.kts — rewritten: 4 recipe.* plugin IDs + 3-source-set dep block + 1 debug tooling line"
- "shared/build.gradle.kts — rewritten: 3 plugins + explicitApi() + Framework baseName override + android {} block retained"
- "server/build.gradle.kts — rewritten: 2 recipe.* plugin IDs + application {} + projects.shared dep"
- "shared/src/jsMain/kotlin/dev/ulfrx/recipe/Platform.js.kt — DELETED (D-01 drops js target)"
key-decisions:
- "Keep android { namespace = 'dev.ulfrx.recipe.shared' } block applied in Phase 1 per Open Question #1 (com.android.library retained; future recipe.android.library convention plugin deferred)"
- "libs.versions.* typed accessor used directly in module build.gradle.kts (not libs.findVersion) — PITFALL #1 only applies to precompiled plugin scripts, not module scripts"
- "libs.koin.android added to composeApp androidMain (not commonMain) — Koin's androidContext(...) lives in the android-specific artifact; commonMain stays platform-neutral"
- "Framework baseName override placed in the module, not hoisted into recipe.kotlin.multiplatform — shared/ is the only module needing 'Shared' (composeApp keeps convention default 'ComposeApp'), so keeping it local avoids a plugin parameter"
patterns-established:
- "Plugin role declaration: each module build.gradle.kts opens with id('recipe.<role>') IDs — reading the plugins block tells you what the module IS, not how it's configured"
- "Zero version literals in module build files: dependencies always go through libs.* aliases; only project coordinate 'version = 1.0.0' (unindented) is exempted by tools/verify-no-version-literals.sh"
- "Per-module framework basename: KotlinNativeTarget.binaries.withType<Framework>().configureEach { baseName = … } pattern is the canonical override point"
requirements-completed: [INFRA-02, INFRA-06]
# Metrics
duration: ~8min
completed: 2026-04-24
---
# Phase 01 Plan 03: Module Build Scripts Wiring Summary
**Rewrote all three module build.gradle.kts files as role declarations applying recipe.* convention plugins; dropped the js target (shared/src/jsMain/ deleted); enabled explicitApi() + 'Shared' framework basename on shared/.**
## Performance
- **Duration:** ~8 min
- **Started:** 2026-04-24T16:14:27Z
- **Completed:** 2026-04-24T16:22:17Z
- **Tasks:** 2
- **Files modified:** 3 (composeApp, shared, server build.gradle.kts) + 1 deleted (Platform.js.kt)
## Accomplishments
- **composeApp/build.gradle.kts:** 114 → 28 lines (-75%). Structural blocks (androidTarget, iosArm64/iosSimulatorArm64, jvm, js, wasmJs, android { }, compose.desktop { nativeDistributions }) all removed and inherited from convention plugins. Only 3 source-set dep blocks + 1 debug tooling line remain.
- **shared/build.gradle.kts:** 55 → 36 lines (-35%). Structural target blocks moved to recipe.kotlin.multiplatform; explicitApi() + KotlinNativeTarget/Framework baseName = "Shared" override added (D-07 / D-12 / PITFALL #10); android {} block kept per Open Question #1.
- **server/build.gradle.kts:** 23 → 18 lines (-22%). Dependency declarations (logback, ktor-serverCore/Netty/TestHost, kotlin-testJunit) fully relocated into recipe.jvm.server; only module coordinates + mainClass + projects.shared remain.
- **js target eliminated:** `shared/src/jsMain/kotlin/dev/ulfrx/recipe/Platform.js.kt` deleted (D-01). No `js { browser() }` blocks remain in any module build file.
- **INFRA-02 payoff visible:** the plugin block in each module now reads as a role declaration (D-06). A future KMP module just needs `plugins { id("recipe.kotlin.multiplatform") }` + sourceSet declarations — no target/SDK copy-pasting.
- **INFRA-06 structural prerequisite delivered:** recipe.compose.multiplatform is applied ONLY to composeApp/, never to shared/, so Compose deps cannot leak transitively into the shared module's classpath.
## Task Commits
Each task was committed atomically (with `--no-verify` per parallel-executor protocol):
1. **Task 1: Rewrite composeApp + shared build files, delete shared/src/jsMain/**`d76dcea` (refactor)
2. **Task 2: Rewrite server build file**`d316a48` (refactor)
_Note: no test/feat/refactor trio — the plan is marked `type=execute`, not `type=tdd`, and all work is build-script configuration (no production code to test)._
## Files Created/Modified
- `composeApp/build.gradle.kts` — rewritten: 4 recipe.* plugin IDs, androidMain/commonMain/jvmMain dep blocks, debugImplementation line
- `shared/build.gradle.kts` — rewritten: 3 plugins (recipe.kotlin.multiplatform + recipe.quality + androidLibrary), explicitApi(), Framework baseName = "Shared" override, android {} retained
- `server/build.gradle.kts` — rewritten: 2 recipe.* plugin IDs, application { mainClass + JVM args }, implementation(projects.shared)
- `shared/src/jsMain/kotlin/dev/ulfrx/recipe/Platform.js.kt` — DELETED (D-01 — js target dropped)
## Decisions Made
- **`libs.versions.*` typed accessor used in module build.gradle.kts rather than `libs.findVersion(...)`** — PITFALL #1 restricts the typed accessor to precompiled plugins; module scripts have full access, so the typed form (`libs.versions.android.compileSdk.get().toInt()`) is correct and preserved from the prior version of `shared/build.gradle.kts`.
- **Framework baseName override kept local to shared/** — only shared/ needs `"Shared"`; composeApp/ keeps the convention-plugin default `"ComposeApp"`. Hoisting the override into `recipe.kotlin.multiplatform` would require a plugin parameter for a single consumer — not worth the indirection.
- **`android { }` block retained on shared/** — Open Question #1 in RESEARCH.md defers "do we actually need com.android.library on shared/?" to a future `recipe.android.library` convention plugin. Phase 1 keeps the block applied; a future plan may remove it.
- **`libs.koin.android` placed in composeApp androidMain, not commonMain** — the `androidContext(...)` helper used by Plan 04's MainApplication lives in koin-android (JVM/Android artifact). commonMain keeps only platform-neutral deps.
## Deviations from Plan
None - plan executed exactly as written.
One minor note (not a deviation, not a failure): `shared/build.gradle.kts` ended at 36 lines vs. the plan's informal `~35-line` target. The single-line delta is the non-negotiable explanatory comment above the `KotlinNativeTarget`/`Framework` block. The plan's `acceptance_criteria` does not set a line cap on `shared/` (only `composeApp/ ≤ 30` which passes at 28 and `server/ ≤ 20` which passes at 18), so all criteria are green.
---
**Total deviations:** 0
**Impact on plan:** Plan executed as specified. All `<automated>` verify blocks pass (grep chain for each module + `tools/verify-no-version-literals.sh` + `tools/verify-shared-pure.sh`).
## Issues Encountered
None.
## User Setup Required
None - pure build-script refactor; no external service configuration required.
## Parallel-Wave Coordination Notes
This plan ran as a parallel executor in Wave 2 alongside Plans 02, 04, 05, 06. Per the wave-2 coordination note:
- **No `./gradlew` commands executed in this plan.** The convention plugins referenced by `id("recipe.kotlin.multiplatform")` etc. are created by Plan 02 in a separate worktree; this worktree does NOT see those files. Gradle plugin resolution will succeed after all Wave 2 worktrees merge back to master and Plan 07 runs the full green-build gate.
- **Verification is entirely grep-based**, matching the plan's `<automated>` specification. No runtime build invocation needed at this stage.
## Next Phase Readiness
Ready for downstream plans in Phase 01:
- **Plan 04 (compose app skeleton)** can now rely on composeApp's `recipe.compose.multiplatform` application — Compose deps (compose.runtime/foundation/material3/ui/components.resources/lifecycle.*compose) flow in via the convention.
- **Plan 05 (server skeleton)** can rely on server's `recipe.jvm.server` — Ktor server + Flyway + Postgres + serialization flow in via the convention; module only needs to declare `mainClass` and `projects.shared`.
- **Plan 07 (invariant gate)** will validate the wired build via `./gradlew build` after all Wave 2 worktrees merge back.
Downstream phases (Phase 02+ auth, Phase 05 recipe catalog, etc.) inherit a strict boundary: `shared/commonMain` enforces `explicitApi()` and carries no Compose / Ktor / SQLDelight deps. Any attempt to add forbidden imports will be caught by `tools/verify-shared-pure.sh`.
## Self-Check: PASSED
**Files verified:**
- FOUND: `composeApp/build.gradle.kts` (28 lines, 4 recipe.* plugin IDs present, no androidTarget/iosArm64/js/nativeDistributions/^android{, libs.koin.android present)
- FOUND: `shared/build.gradle.kts` (36 lines, 3 plugins present, explicitApi() present, `baseName = "Shared"` present, no js {, android {} retained)
- FOUND: `server/build.gradle.kts` (18 lines, 2 recipe.* plugin IDs present, mainClass present, projects.shared present, no legacy aliases or deps)
- MISSING (intentional): `shared/src/jsMain/` directory no longer exists
**Commits verified:**
- FOUND: `d76dcea` — refactor(01-03): apply recipe.* conventions to composeApp + shared, drop js
- FOUND: `d316a48` — refactor(01-03): apply recipe.jvm.server + recipe.quality to server module
**Verify scripts:**
- `tools/verify-no-version-literals.sh` → exit 0 (OK: no version literals outside catalog)
- `tools/verify-shared-pure.sh` → exit 0 (OK: shared/commonMain is pure)
---
*Phase: 01-project-infrastructure-module-wiring*
*Plan: 03*
*Completed: 2026-04-24*

View File

@@ -0,0 +1,138 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 04
subsystem: client-bootstrap
tags: [koin, kermit, di, logging, ios-bridge, android-application, wasm-bootstrap]
requires:
- 01-02 (build-logic conventions providing Koin + Kermit dependencies via recipe.kotlin.multiplatform)
- 01-03 (composeApp/build.gradle.kts wired to convention plugin + libs.koin.android in androidMain)
provides:
- "initKoin(config: KoinAppDeclaration?): KoinApplication — single bootstrap helper"
- "appModule: Koin Module — empty placeholder; Phase 2+ extends with authModule, syncModule, catalogModule"
- "configureLogging() — sets Kermit Logger.setTag(\"recipe\")"
- "KoinIosKt.doInitKoin() — Swift-callable iOS bridge"
- "MainApplication: Android Application subclass invoking configureLogging + initKoin on process boot"
affects:
- "All future phases (2-11) plug Koin modules into appModule and call Logger.x { } via Kermit"
- "Phase 2 (Auth) will register authModule; Phase 4 (SyncEngine) will register syncModule singleton"
tech-stack:
added: []
patterns:
- "Single initKoin() call site per platform entry point (PITFALL #4 — no double-init on iOS)"
- "configureLogging() ALWAYS precedes initKoin() so Koin module loading can use Kermit"
- "App.kt (@Composable) NEVER calls startKoin (Pattern 4 anti-pattern guard)"
- "iOS Kotlin bridge: top-level fun doInitKoin in KoinIos.kt → Swift symbol KoinIosKt.doInitKoin"
- "Wasm init order: configureLogging → initKoin → ComposeViewport (PITFALL #8)"
key-files:
created:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
modified:
- composeApp/src/androidMain/AndroidManifest.xml
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
- iosApp/iosApp/iOSApp.swift
unchanged_by_design:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt # anti-pattern guard: no startKoin in @Composable
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt # PITFALL #4: Koin started exclusively in iOSApp.init()
- iosApp/iosApp/ContentView.swift # already wraps MainViewControllerKt.MainViewController()
decisions:
- "Kermit tag = \"recipe\" (D-15) — exact string"
- "appModule is empty in Phase 1 (D-14); Phase 2+ adds modules"
- "Single iOS Koin call site is iOSApp.init() (PITFALL #4 mitigation)"
- "androidContext(this@MainApplication) — qualified `this` because initKoin lambda receiver is KoinApplication"
metrics:
tasks_completed: 3
tasks_total: 3
files_created: 5
files_modified: 4
duration: ~10m
completed: 2026-04-24
---
# Phase 1 Plan 4: Koin + Kermit Bootstrap Wiring — Summary
Wired the Koin DI container and Kermit structured logger across all four composeApp platform entry points (Android Application subclass, iOS SwiftUI App.init, JVM desktop main, Wasm browser main) with a single `initKoin()` helper in commonMain and an empty `appModule` placeholder that Phase 2+ extends.
## What was built
### Task 1 — commonMain DI + logging + iOS bridge (commit `cc5002d`)
Created four files:
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt`** — exports `fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin { config?.invoke(this); modules(appModule) }`. The optional `config` lambda is how Android passes `androidContext(...)` and how Phase 2+ tests can inject overrides without touching the helper itself.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`** — declares `val appModule = module { }` (empty per D-14). Phase 2 adds `authModule`, Phase 4 adds `syncModule`, etc.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt`** — `fun configureLogging() { Logger.setTag("recipe") }`. Kermit's per-platform writers (OSLog/LogCat/println) install themselves by default; setting the tag is the only required call.
- **`composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt`** — `fun doInitKoin() { configureLogging(); initKoin() }`. The top-level `fun` in file `KoinIos.kt` becomes the Swift-accessible symbol `KoinIosKt.doInitKoin()` automatically (Kotlin/Native generates `<FileName>Kt` for top-level decls).
### Task 2 — Android MainApplication + manifest (commit `8cd608a`)
- **`composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt`** — `class MainApplication : Application()` whose `onCreate()` calls `super.onCreate()`, then `configureLogging()`, then `initKoin { androidContext(this@MainApplication) }`. Qualified `this@MainApplication` is required because the `initKoin { }` lambda receiver is `KoinApplication`, not the `Application`.
- **`composeApp/src/androidMain/AndroidManifest.xml`** — added `android:name=".MainApplication"` as the first attribute on `<application>`. All other attributes and the `<activity>`/`<intent-filter>` subtree preserved verbatim.
### Task 3 — JVM + Wasm + Swift entry points (commit `fd3e7e1`)
- **`composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt`** — converted `fun main() = application { ... }` (single-expression) into a body block: `configureLogging()``initKoin()``application { Window(title = "recipe") { App() } }`. Window title and exit handler preserved.
- **`composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt`** — same init order before `ComposeViewport { App() }`. `@OptIn(ExperimentalComposeUiApi::class)` retained. Defensive against PITFALL #8 (Wasm composition running before DI is ready) — Phase 1 has no ViewModels so the symptom would not surface yet, but the shape is correct from day 1.
- **`iosApp/iosApp/iOSApp.swift`** — added `import ComposeApp` (matches framework basename set by `recipe.kotlin.multiplatform`) and `init() { KoinIosKt.doInitKoin() }`. The `WindowGroup { ContentView() }` body is unchanged. `MainViewController.kt` and `ContentView.swift` were intentionally NOT modified — Koin is bootstrapped exclusively from `iOSApp.init()` (PITFALL #4 mitigation).
## Init order invariant (every platform)
```
configureLogging() → installs Kermit tag "recipe"
initKoin() → starts Koin with empty appModule
[platform composition entry — application { } / ComposeViewport { } / ComposeUIViewController { } / setContent { }]
```
## Deviations from Plan
None — plan executed exactly as written. All 3 tasks completed; all artifacts produced; all `<acceptance_criteria>` satisfied.
## Confirmations (per `<output>` section of PLAN)
- Kermit tag = `"recipe"` (D-15) — set in `configureLogging()`.
- `appModule` content: empty (D-14) — `val appModule = module { }`.
- `App.kt` NOT modified (anti-pattern guard).
- `MainViewController.kt` NOT modified (PITFALL #4 guard — Koin started outside).
- `ContentView.swift` NOT modified (already wraps `MainViewControllerKt.MainViewController()`).
## Threat Mitigations Verified
| Threat ID | Mitigation in delivered code |
|-----------|------------------------------|
| T-01-04-01 (Koin double-init iOS) | `KoinIosKt.doInitKoin()` is the only init call site on iOS; `MainViewController.kt` does not call `startKoin`. |
| T-01-04-02 (Wasm init order) | webMain `main()` orders `configureLogging() → initKoin() → ComposeViewport { }`. |
| T-01-04-03 (App.kt calling startKoin) | `App.kt` unchanged; verified no `startKoin` reference outside `Koin.kt`. |
## Verification gates
- All three task `<automated>` grep blocks passed.
- No build files modified → `tools/verify-no-version-literals.sh` and `tools/verify-shared-pure.sh` remain at exit 0.
- Compile gates (`./gradlew build`, `:composeApp:jvmTest`) deferred to Plan 07 per the verification block in 01-04-PLAN.md.
## Commits
- `cc5002d` — feat(01-04): add Koin + Kermit bootstrap commonMain + iOS bridge
- `8cd608a` — feat(01-04): add Android MainApplication + manifest registration
- `fd3e7e1` — feat(01-04): wire JVM + Wasm main + Swift iOSApp to bootstrap Koin + Kermit
## Self-Check: PASSED
Files verified to exist on disk:
- FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
- FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt
- FOUND: composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt
- FOUND: composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
- FOUND: composeApp/src/androidMain/AndroidManifest.xml (modified, contains `android:name=".MainApplication"`)
- FOUND: composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt (modified)
- FOUND: composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt (modified)
- FOUND: iosApp/iosApp/iOSApp.swift (modified)
Commits verified in `git log`:
- FOUND: cc5002d
- FOUND: 8cd608a
- FOUND: fd3e7e1

View File

@@ -0,0 +1,132 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 05
subsystem: infra
tags: [ktor, flyway, hocon, postgres, slf4j, kotlinx-serialization]
requires:
- phase: 01-project-infrastructure-module-wiring
provides: "recipe.jvm.server precompiled plugin (Plan 02) wires ktor-server-netty, ktor-server-content-negotiation, ktor-serialization-kotlinx-json, flyway-core, flyway-database-postgresql, postgresql JDBC, ktor-server-test-host, logback-classic. Plan 03 applied recipe.jvm.server + recipe.quality to server module and added implementation(projects.shared) so SERVER_PORT is reachable."
provides:
- "Running-but-empty server: GET /health returns {\"status\":\"ok\"} with Content-Type application/json"
- "HOCON application.conf with localhost defaults + ${?ENV} overrides for PORT/DATABASE_URL/DATABASE_USER/DATABASE_PASSWORD"
- "Database.migrate() Flyway boot sequence with fail-loud IllegalStateException contract on unreachable Postgres"
- "server/src/main/resources/db/migration/ resource directory anchored by .gitkeep so classpath:db/migration resolves before Phase 3 adds V1__init.sql"
- "configureRouting() extension extracted from Application.module() so tests compose routing without invoking Database.migrate (no Postgres in CI)"
affects: [phase-02-auth, phase-03-households, phase-05-recipe-catalog, phase-11-deployment]
tech-stack:
added: [Flyway runtime API (flyway-core 12.x), HOCON env-var override pattern, SLF4J server-side logging]
patterns:
- "HOCON ${?ENV} two-line override pattern (PITFALL #5 mitigation)"
- "Fail-loud server boot: Database.migrate throws IllegalStateException on Flyway/JDBC failure"
- "Routing extracted to Application.configureRouting() extension so testApplication composes routing without DB dependency"
- "Server uses SLF4J/Logback (NOT Kermit — Kermit is client-only)"
key-files:
created:
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/main/resources/application.conf
- server/src/main/resources/db/migration/.gitkeep
modified:
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
key-decisions:
- "Use HOCON ${?ENV} optional substitution (two-line default + override) rather than ${ENV:default} (invalid HOCON) or ${ENV} (required, crashes on unset)"
- "Server logs via SLF4J/Logback, not Kermit — Kermit reserved for the multiplatform client"
- "Database.migrate is fail-loud: IllegalStateException on any Flyway error; no silent degraded mode"
- "cleanDisabled(true) is double-enforced (precompiled plugin CLI guard + programmatic Database.migrate guard)"
- "Extract Application.configureRouting() so /health test runs without Postgres — preserves D-11 invariant that ./gradlew :server:test passes in fresh clones / CI"
- "Default credentials in application.conf (recipe/recipe/recipe @ localhost:5432/recipe) match Plan 06 docker-compose for zero-config dev boot"
patterns-established:
- "HOCON ${?ENV} override: every secret/per-env value gets a default line followed by ${?ENV_VAR} optional substitution"
- "Fail-loud infrastructure: critical boot operations (DB migration, future JWKS load) throw IllegalStateException rather than returning a status"
- "Routing extraction for testability: features expose Application.configureXxx() extensions; module() is the production composition root"
requirements-completed: [INFRA-02]
duration: ~1 min (executor work — implementation commits authored ahead of executor invocation)
completed: 2026-04-24
---
# Phase 01 Plan 05: Server /health + Flyway + HOCON Boot Summary
**Running-but-empty Ktor server: HOCON-configured Flyway boot with fail-loud Postgres contract, GET /health returning `{"status":"ok"}`, and a routing extraction that lets tests verify the route without a running database.**
## Performance
- **Duration:** Implementation commits span 2026-04-24 18:22:08 → 18:23:14 (~66s of authoring); executor verification + SUMMARY ~1 min
- **Started:** 2026-04-24T18:22:08Z (commit 24018ef)
- **Completed:** 2026-04-24T18:23:14Z (commit 59d0695)
- **Tasks:** 3
- **Files modified:** 5 (3 created, 2 modified)
## Accomplishments
- HOCON `application.conf` reads PORT + DATABASE_URL/USER/PASSWORD via the `${?ENV}` two-line override pattern; defaults match the Plan 06 docker-compose stack so `docker compose up -d postgres && ./gradlew :server:run` works with zero env config.
- `Database.migrate(app: Application)` runs `Flyway.configure().dataSource(...).locations("classpath:db/migration").baselineOnMigrate(true).validateOnMigrate(true).cleanDisabled(true).load().migrate()` and throws `IllegalStateException` on any failure — D-16 fail-loud contract satisfied.
- `db/migration/.gitkeep` keeps the resource directory in the repo so Flyway's classpath resolution succeeds before Phase 3 introduces the first SQL migration.
- `Application.kt` rewritten with explicit Ktor imports (D-11 allWarningsAsErrors clean), installs `ContentNegotiation { json() }`, calls `Database.migrate(this)`, then delegates to `Application.configureRouting()` which exposes `GET /health → Health(status="ok")`.
- `ApplicationTest.kt` rewritten to compose `configureRouting()` directly (skipping `Database.migrate`) so `./gradlew :server:test --tests "*health*"` passes without a running Postgres — required for fresh-clone / CI runs.
## Task Commits
Each task was committed atomically prior to executor invocation (commits already in branch history):
1. **Task 1: HOCON config + db/migration/.gitkeep + Database.kt**`24018ef` (feat)
2. **Task 2: Application.kt rewrite (ContentNegotiation, Flyway boot, /health)**`daefe6c` (refactor)
3. **Task 3: ApplicationTest.kt rewrite (no-Postgres /health assertion)**`59d0695` (test)
**Plan metadata:** appended in this commit (docs).
## Files Created/Modified
- `server/src/main/resources/application.conf` (created) — HOCON config: ktor.deployment.port + database.{url,user,password} with `${?ENV}` overrides
- `server/src/main/resources/db/migration/.gitkeep` (created) — anchors the Flyway classpath resource directory in git
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` (created) — `object Database { fun migrate(app) }` with fail-loud Flyway invocation, SLF4J logging
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` (modified) — explicit imports; installs ContentNegotiation; runs Database.migrate; delegates to configureRouting(); exposes GET /health returning serializable `Health(status)`
- `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` (modified) — replaces template `testRoot()` with health-endpoint test that composes routing without DB
## Decisions Made
See `key-decisions` in frontmatter. Highlights:
- HOCON `${?ENV}` optional substitution chosen over `${ENV}` (required) and `${ENV:default}` (invalid HOCON) per PITFALL #5.
- Server logging via SLF4J/Logback (not Kermit) because Logback is already wired in `recipe.jvm.server` and Kermit is reserved for the multiplatform client.
- `Application.configureRouting()` extension extracted to satisfy the no-Postgres-required invariant for `./gradlew :server:test`.
## Deviations from Plan
None — plan executed exactly as written. All artifacts match the plan's `must_haves` (truths, artifacts, key_links) verified against the filesystem; explicit imports satisfy D-11; `${?ENV}` lines all present; fail-loud contract intact; `Database.migrate` not referenced from the test.
## Issues Encountered
None.
## User Setup Required
None — no external service configuration required. Postgres for end-to-end boot is provided by the Plan 06 docker-compose stack; Plan 05's own success criteria (test passing without a running DB) require nothing from the operator.
## Next Phase Readiness
- Phase 2 (Auth) inherits a Ktor server with ContentNegotiation pre-installed, so JWT validation routes can return `@Serializable` DTOs immediately.
- Phase 3 (Households) drops `V1__init.sql` into `server/src/main/resources/db/migration/`; the Flyway boot pathway is already validated.
- Phase 11 (Deployment) inherits the HOCON `${?ENV}` pattern; homelab deploy configures `DATABASE_URL/USER/PASSWORD` via env vars without touching `application.conf`.
- Manual end-to-end verification (`docker compose up -d postgres && ./gradlew :server:run && curl http://localhost:8080/health`) deferred to Plan 07 / manual smoke per the plan's verification section.
## Self-Check: PASSED
- File `server/src/main/resources/application.conf` — FOUND
- File `server/src/main/resources/db/migration/.gitkeep` — FOUND
- File `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` — FOUND
- File `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` — FOUND
- File `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` — FOUND
- Commit `24018ef` (feat 01-05 Task 1) — FOUND in git log
- Commit `daefe6c` (refactor 01-05 Task 2) — FOUND in git log
- Commit `59d0695` (test 01-05 Task 3) — FOUND in git log
---
*Phase: 01-project-infrastructure-module-wiring*
*Completed: 2026-04-24*

View File

@@ -0,0 +1,155 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 06
subsystem: dev-ergonomics
tags: [docker-compose, postgres, readme, local-dev, infra]
dependency_graph:
requires: []
provides:
- "Local Postgres 16 dev instance matching application.conf HOCON defaults (recipe/recipe/recipe)"
- "Named volume recipe-pgdata for persistence across container restarts"
- "pg_isready healthcheck enabling docker compose up --wait usage"
- "README 'Local development' section documenting the two-command dev loop"
affects:
- "server/src/main/resources/application.conf (Plan 05 — credentials match contract)"
- "Phase 3 (Households + DB migrations) — depends on a working local Postgres"
- "Phase 11 (homelab deployment) — separate compose config will diverge from this dev-local one"
tech_stack:
added:
- "postgres:16 (Docker image, pinned major version)"
patterns:
- "Dev-local compose file committed to repo (non-secret literal creds)"
- "Healthcheck via pg_isready gating sequencing"
- "Named Docker volume for data persistence"
key_files:
created:
- "docker-compose.yml"
modified:
- "README.md"
decisions:
- "Kept it single-service: postgres only. Authentik stays on homelab (CONTEXT.md D-17); Ktor server runs via Gradle on the dev host for fast iteration."
- "Pinned postgres:16 (not :latest, not :15) matching D-17 scope statement."
- "No version: key in compose file — modern docker compose v2 treats it as legacy and emits warnings."
- "No .env file in this plan — inline POSTGRES_* is fine for single-dev + matching application.conf defaults (D-17 / PATTERNS.md recommendation)."
- "Port binding 5432:5432 is dev-local; README calls it out. Phase 11 homelab compose will use a different approach."
metrics:
duration_seconds: 92
duration_human: "1m32s"
tasks_completed: 2
files_created: 1
files_modified: 1
completed_at: "2026-04-24T16:22:48Z"
---
# Phase 01 Plan 06: Dev ergonomics — docker-compose + README Local development summary
Shipped `docker-compose.yml` (single postgres:16 service, named volume, healthcheck — credentials matching Plan 05's `application.conf` HOCON defaults exactly) and a "Local development" README section documenting the `docker compose up -d postgres && ./gradlew :server:run && curl /health` dev loop, while dropping the legacy `js` target docs per D-01.
## What was built
### docker-compose.yml (20 lines)
- `services.postgres`:
- `image: postgres:16` (pinned major version)
- `container_name: recipe-postgres`
- `environment`: `POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD` all literal `recipe`
- `ports: "5432:5432"` (dev-local loopback via host Docker)
- `volumes: recipe-pgdata:/var/lib/postgresql/data` (persistence)
- `healthcheck`: `pg_isready -U recipe -d recipe` every 5s, timeout 5s, 5 retries
- Top-level `volumes.recipe-pgdata:` (named volume declaration)
- No `version:` key (modern compose v2)
- No additional services (no Authentik — lives on user's homelab per D-17)
### README.md edits
**Edit A — dropped js target block** (lines 77-85 of previous README): the "- for the JS target (slower, supports older browsers)" paragraph and its two command blocks were deleted. The `wasmJs` paragraph is preserved intact.
**Edit B — inserted new "Local development" section** (after the iOS subsection, before the trailing `---` horizontal rule):
- Two-command boot: `docker compose up -d postgres` + `./gradlew :server:run`
- Smoke test: `curl http://localhost:8080/health` with expected `{"status":"ok"}` response
- Documented env-var overrides: `DATABASE_URL`, `DATABASE_USER`, `DATABASE_PASSWORD`, `PORT`
- Pre-commit formatter hint: `./gradlew spotlessApply` (D-10)
- Full-suite: `./gradlew check`
- DB reset: `docker compose down -v` (destroys `recipe-pgdata`)
All other existing headings (Android, Desktop/JVM, Server, iOS, web `wasmJs`) and the top introduction (lines 1-20) are unchanged. The trailing `---` + learn-more links paragraph is unchanged.
## Credential-match contract with Plan 05
The three compose env-vars are byte-identical to the literals in `server/src/main/resources/application.conf`:
| compose env | application.conf |
|-------------|------------------|
| `POSTGRES_DB: recipe` | JDBC URL path `/recipe` |
| `POSTGRES_USER: recipe` | `user = "recipe"` |
| `POSTGRES_PASSWORD: recipe` | `password = "recipe"` |
Verified via `grep -c '^\s*POSTGRES_\(DB\|USER\|PASSWORD\): recipe$' docker-compose.yml``3`.
## Requirements addressed
- **INFRA-02** — local development environment via `docker-compose.yml` and README dev loop documentation.
## Tasks executed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Create docker-compose.yml at repo root | `af4428f` | docker-compose.yml (new) |
| 2 | Add "Local development" section to README.md and drop js target docs | `f691400` | README.md (modified) |
## Deviations from Plan
None — plan executed exactly as written. No Rule 1-3 auto-fixes, no checkpoints, no auth gates. Both `<automated>` verify blocks and every acceptance criterion passed on first attempt.
## Threat surface scan
No new network endpoints, auth paths, file access patterns, or schema changes at trust boundaries were introduced beyond what the plan's `<threat_model>` already covers (T-01-06-01..04). The `5432:5432` host binding and literal `recipe/recipe/recipe` credentials are the exact surface the plan's STRIDE register dispositions (`mitigate`/`accept`) already cover. No new flags.
## Known stubs
None. Both deliverables are complete — no placeholders, no TODOs, no empty data paths.
## Verification
**Task 1 automated check:**
```
test -f docker-compose.yml && grep -q 'image: postgres:16' ... && grep -q 'pg_isready -U recipe -d recipe' ... && grep -q '^volumes:$' ...
→ VERIFY PASS
grep -c '^\s*POSTGRES_\(DB\|USER\|PASSWORD\): recipe$' docker-compose.yml → 3
```
**Task 2 automated check:**
```
grep -q 'Local development' && grep -q 'docker compose up -d postgres' && grep -q 'curl http://localhost:8080/health' && grep -q 'DATABASE_URL' && grep -q 'gradlew spotlessApply' && grep -q 'docker compose down -v' && ! grep -q 'jsBrowserDevelopmentRun' && grep -q 'wasmJsBrowserDevelopmentRun'
→ VERIFY PASS
```
**Acceptance criteria — Task 2 individually confirmed:**
- `Local development` appears exactly once (section heading)
- All 4 env-vars listed: `DATABASE_URL`, `DATABASE_USER`, `DATABASE_PASSWORD`, `PORT`
- `gradlew check` present
- Existing section headings (Android / Desktop (JVM) / Server / iOS) all preserved (grep `-c``1` each)
- `jsBrowserDevelopmentRun` absent; `wasmJsBrowserDevelopmentRun` present
- Top introduction (lines 1-20) unchanged
## Manual sanity checks (optional, not blocking)
Skipped per plan `<verification>`:
- `docker compose config` YAML parse — not blocking per plan; docker may not be running in this worktree sandbox.
- `docker compose up -d postgres && pg_isready` live test — not required; will be validated in Phase 3 when migrations land.
## Notes for downstream plans
- **Plan 05** (this wave) — credential contract lives in both files; any future change to the `recipe/recipe/recipe` triple MUST update both `application.conf` AND `docker-compose.yml` in the same commit.
- **Phase 3** (Households + DB migrations) — can add `depends_on: { postgres: { condition: service_healthy } }` to a future `server` service in compose if we ever run the Ktor server in Docker; the healthcheck is already wired for it.
- **Phase 11** (homelab deployment) — will ship a separate compose file (not editing this one) because homelab creds are secret and this file's creds are deliberately non-secret literals.
## Self-Check: PASSED
- `docker-compose.yml` exists at repo root: FOUND
- `README.md` contains "Local development" section: FOUND
- Commit `af4428f` (Task 1): FOUND in `git log`
- Commit `f691400` (Task 2): FOUND in `git log`
- All acceptance criteria from both tasks verified via grep
- No file deletions in either commit

View File

@@ -74,21 +74,56 @@ in your IDE's toolbar or run it directly from the terminal:
```shell
.\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun
```
- for the JS target (slower, supports older browsers):
- on macOS/Linux
```shell
./gradlew :composeApp:jsBrowserDevelopmentRun
```
- on Windows
```shell
.\gradlew.bat :composeApp:jsBrowserDevelopmentRun
```
### Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget
in your IDEs toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there.
### Local development
The server requires Postgres. A `docker-compose.yml` at the repo root ships a local Postgres
instance whose credentials match `application.conf` defaults (`recipe`/`recipe`/`recipe`).
Boot the database and server:
```shell
docker compose up -d postgres
./gradlew :server:run
```
Verify the server is up:
```shell
curl http://localhost:8080/health
# expected: {"status":"ok"}
```
Environment overrides (optional — set any of these to override `application.conf` defaults):
- `DATABASE_URL` — JDBC URL (default `jdbc:postgresql://localhost:5432/recipe`)
- `DATABASE_USER` — DB user (default `recipe`)
- `DATABASE_PASSWORD` — DB password (default `recipe`)
- `PORT` — Ktor port (default `8080`)
Before committing, format all Kotlin + Gradle + Markdown files:
```shell
./gradlew spotlessApply
```
The full check (Spotless + all tests across all targets):
```shell
./gradlew check
```
Reset the local database (destroys the `recipe-pgdata` volume):
```shell
docker compose down -v
```
---
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html),

View File

@@ -0,0 +1,18 @@
plugins {
`kotlin-dsl`
}
dependencies {
compileOnly(libs.plugins.kotlinMultiplatform.asDependency())
compileOnly(libs.plugins.androidApplication.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}" }

View File

@@ -0,0 +1,14 @@
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"

View File

@@ -0,0 +1,35 @@
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("com.android.application")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
android {
namespace = "dev.ulfrx.recipe"
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
}
}

View File

@@ -0,0 +1,27 @@
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("recipe.kotlin.multiplatform")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose.hot-reload")
}
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())
}
}
}

View File

@@ -0,0 +1,41 @@
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("org.jetbrains.kotlin.jvm")
id("io.ktor.plugin")
id("org.flywaydb.flyway")
application
}
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
}

View File

@@ -0,0 +1,69 @@
// build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
// Establishes the D-05 target matrix + JVM toolchain + common deps.
// Android bytecode is JVM 11 (D-08); server + desktop + shared/jvm are JVM 21.
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
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() }
compilerOptions {
allWarningsAsErrors.set(true)
}
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())
}
}
}
// Relax allWarningsAsErrors for KLIB-merging metadata tasks. KotlinCompileCommon
// aggregates dependency KLIBs and surfaces upstream "duplicated unique_name"
// resolver warnings caused by androidx.lifecycle 2.10.0 (Android-only) and
// org.jetbrains.androidx.lifecycle 2.10.0 (CMP) co-publishing artifacts with
// matching KLIB unique_names. This is an upstream Compose-Multiplatform 1.10 +
// lifecycle 2.10 ecosystem condition (KT-62515-style), not actionable in our
// source — so we keep -Werror on real source compilation tasks but disable it
// for the metadata-aggregation step where no user code is being compiled.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon>().configureEach {
compilerOptions {
allWarningsAsErrors.set(false)
}
}

View File

@@ -0,0 +1,40 @@
plugins {
id("com.diffplug.spotless")
}
spotless {
kotlin {
target("src/**/*.kt")
targetExclude("**/build/**", "**/generated/**")
ktlint()
}
kotlinGradle {
target("*.gradle.kts")
ktlint()
}
format("markdown") {
target("*.md", "docs/**/*.md")
endWithNewline()
trimTrailingWhitespace()
}
}
// D-11 redundancy guard: if a module applies recipe.quality alongside a Kotlin plugin
// (multiplatform or jvm), ensure allWarningsAsErrors still applies even if the module
// build didn't already configure it. Guarded with plugins.withId so this plugin is
// safely composable even when applied alone (no KotlinCompilationTask type available
// on the classpath until a Kotlin plugin is present).
plugins.withId("org.jetbrains.kotlin.multiplatform") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
}
}
}
plugins.withId("org.jetbrains.kotlin.jvm") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
}
}
}

View File

@@ -9,4 +9,6 @@ plugins {
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.ktor) apply false
alias(libs.plugins.spotless) apply false
alias(libs.plugins.flywayPlugin) apply false
}

View File

@@ -1,68 +1,24 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project. Gradle applies
// plugin IDs in declaration order, so recipe.android.application is listed first.
id("recipe.android.application")
id("recipe.kotlin.multiplatform")
id("recipe.compose.multiplatform")
id("recipe.quality")
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
js {
browser()
binaries.executable()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(projects.shared)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
@@ -70,45 +26,6 @@ kotlin {
}
}
android {
namespace = "dev.ulfrx.recipe"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "dev.ulfrx.recipe"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().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
}
}
dependencies {
debugImplementation(libs.compose.uiTooling)
}
compose.desktop {
application {
mainClass = "dev.ulfrx.recipe.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "dev.ulfrx.recipe"
packageVersion = "1.0.0"
}
}
}

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".MainApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View File

@@ -0,0 +1,16 @@
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)
}
}
}

View File

@@ -10,12 +10,15 @@ import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.painterResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.compose_multiplatform
@@ -25,7 +28,8 @@ fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
modifier =
Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),

View File

@@ -0,0 +1,9 @@
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
}

View File

@@ -0,0 +1,11 @@
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)
}

View File

@@ -0,0 +1,8 @@
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.
}

View File

@@ -4,7 +4,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)

View File

@@ -0,0 +1,8 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() {
configureLogging()
initKoin()
}

View File

@@ -2,8 +2,13 @@ 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() = application {
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
@@ -11,3 +16,4 @@ fun main() = application {
App()
}
}
}

View File

@@ -2,9 +2,13 @@ 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()
}

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
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:

View File

@@ -8,3 +8,8 @@ org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
android.useAndroidX=true
# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1
# CMS GC + non-main-thread Obj-C deinit to avoid UI-thread pause spikes in Compose Multiplatform.
kotlin.native.binary.gc=cms
kotlin.native.binary.objcDisposeOnMain=false

View File

@@ -11,12 +11,18 @@ androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0"
composeHotReload = "1.0.0"
composeMultiplatform = "1.10.3"
flyway = "12.4.0"
junit = "4.13.2"
kermit = "2.1.0"
koin = "4.2.1"
kotlin = "2.3.20"
kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.7.3"
ktor = "3.4.1"
logback = "1.5.32"
material3 = "1.10.0-alpha05"
postgresql = "42.7.10"
spotless = "8.4.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -42,6 +48,23 @@ ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor
ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
ktor-serverTestHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }
# Koin (client DI — D-14)
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" }
koin-android = { module = "io.insert-koin:koin-android" }
# Kermit (client logger — D-15)
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
# Server: Ktor content-negotiation + JSON serializer + Flyway + Postgres (D-16)
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 = "postgresql" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
@@ -51,3 +74,5 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }

View File

@@ -1,7 +1,12 @@
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinIosKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()

View File

@@ -1,11 +1,11 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.ktor)
application
id("recipe.jvm.server")
id("recipe.quality")
}
group = "dev.ulfrx.recipe"
version = "1.0.0"
application {
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
@@ -15,9 +15,4 @@ application {
dependencies {
implementation(projects.shared)
implementation(libs.logback)
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverNetty)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
}

View File

@@ -1,20 +1,38 @@
package dev.ulfrx.recipe
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
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)
configureRouting()
}
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Ktor: ${Greeting().greet()}")
get("/health") {
call.respond(Health(status = "ok"))
}
}
}

View File

@@ -0,0 +1,41 @@
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)
}
}
}

View File

@@ -0,0 +1,18 @@
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}
}

View File

@@ -1,20 +1,30 @@
package dev.ulfrx.recipe
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ApplicationTest {
@Test
fun testRoot() = testApplication {
fun `health endpoint returns 200 with status ok`() =
testApplication {
application {
module()
install(ContentNegotiation) {
json()
}
val response = client.get("/")
configureRouting()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Ktor: ${Greeting().greet()}", response.bodyAsText())
val body = response.bodyAsText()
assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
}
}

View File

@@ -2,6 +2,7 @@ rootProject.name = "recipe"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
includeBuild("build-logic")
repositories {
google {
mavenContent {

View File

@@ -1,54 +1,45 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project. Gradle applies
// plugin IDs in declaration order, so com.android.library is listed first.
alias(libs.plugins.androidLibrary)
id("recipe.kotlin.multiplatform")
id("recipe.quality")
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
explicitApi()
iosArm64()
iosSimulatorArm64()
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
// Override framework baseName: shared exposes "Shared.framework" to Swift, while
// composeApp's convention-plugin default is "ComposeApp.framework". (D-07 / PITFALL #10)
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.Framework>().configureEach {
baseName = "Shared"
}
}
js {
browser()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
}
sourceSets {
commonMain.dependencies {
// put your Multiplatform dependencies here
}
commonTest.dependencies {
implementation(libs.kotlin.test)
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
// D-19 / INFRA-06: Do NOT add Ktor, Compose, or SQLDelight deps here — EVER.
}
}
}
android {
namespace = "dev.ulfrx.recipe.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
}
}

View File

@@ -2,8 +2,8 @@ package dev.ulfrx.recipe
import android.os.Build
class AndroidPlatform : Platform {
public class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = AndroidPlatform()
public actual fun getPlatform(): Platform = AndroidPlatform()

View File

@@ -1,3 +1,3 @@
package dev.ulfrx.recipe
const val SERVER_PORT = 8080
public const val SERVER_PORT: Int = 8080

View File

@@ -1,9 +1,7 @@
package dev.ulfrx.recipe
class Greeting {
public class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
public fun greet(): String = "Hello, ${platform.name}!"
}

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe
interface Platform {
val name: String
public interface Platform {
public val name: String
}
expect fun getPlatform(): Platform
public expect fun getPlatform(): Platform

View File

@@ -4,7 +4,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals
class SharedCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)

View File

@@ -2,8 +2,8 @@ package dev.ulfrx.recipe
import platform.UIKit.UIDevice
class IOSPlatform : Platform {
public class IOSPlatform : Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
actual fun getPlatform(): Platform = IOSPlatform()
public actual fun getPlatform(): Platform = IOSPlatform()

View File

@@ -1,7 +0,0 @@
package dev.ulfrx.recipe
class JsPlatform : Platform {
override val name: String = "Web with Kotlin/JS"
}
actual fun getPlatform(): Platform = JsPlatform()

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe
class JVMPlatform : Platform {
public class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
}
actual fun getPlatform(): Platform = JVMPlatform()
public actual fun getPlatform(): Platform = JVMPlatform()

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe
class WasmPlatform : Platform {
public class WasmPlatform : Platform {
override val name: String = "Web with Kotlin/Wasm"
}
actual fun getPlatform(): Platform = WasmPlatform()
public actual fun getPlatform(): Platform = WasmPlatform()

6
tools/verify-ios-flags.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Enforces INFRA-03 / D-18: iOS K/N flags present in gradle.properties.
set -euo pipefail
grep -q '^kotlin\.native\.binary\.gc=cms$' gradle.properties || { echo "MISSING: kotlin.native.binary.gc=cms" >&2; exit 1; }
grep -q '^kotlin\.native\.binary\.objcDisposeOnMain=false$' gradle.properties || { echo "MISSING: kotlin.native.binary.objcDisposeOnMain=false" >&2; exit 1; }
echo "OK: iOS binary flags present."

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Enforces INFRA-01 SC#2 / D-09: no literal *library/plugin* version strings outside catalog.
# Scans every *.gradle.kts for numeric version literals (e.g. version = "1.2.3") that would
# represent a library or plugin pin leaking out of the catalog.
#
# Exclusions (all semantic, not loopholes):
# - build-logic/build.gradle.kts needs literal plugin-dependency coordinates (`asDependency()`)
# - Top-level project-version assignments (unindented `^version = "x.y.z"`) are Gradle project
# metadata (artifact name) — NOT a library version pin. D-09 guards dependency versions,
# not project identity.
set -euo pipefail
VIOLATIONS=$(grep -rn -E 'version[[:space:]]*=[[:space:]]*"[0-9]' --include='*.gradle.kts' . 2>/dev/null \
| grep -v 'build-logic/build.gradle.kts' \
| grep -vE ':[0-9]+:version[[:space:]]*=[[:space:]]*"[0-9]' \
|| true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: version literals found outside catalog:" >&2
echo "$VIOLATIONS" >&2
exit 1
fi
echo "OK: no version literals outside catalog."

15
tools/verify-shared-pure.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight.
# Runs grep against shared/src/commonMain/ only. Allowed imports: kotlin.*, kotlinx.serialization, kotlinx.datetime.
set -euo pipefail
if [ ! -d shared/src/commonMain ]; then
echo "OK: shared/src/commonMain does not exist yet (pre-scaffold)."
exit 0
fi
VIOLATIONS=$(grep -rn -E '^import[[:space:]]+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ 2>/dev/null || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: shared/commonMain has forbidden imports:" >&2
echo "$VIOLATIONS" >&2
exit 1
fi
echo "OK: shared/commonMain is pure."