@code-migration/wow-migrator 0.2.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/skills/android-to-kmp-migrator/SKILL.md +32 -6
  3. package/skills/android-to-kmp-migrator/bind.md +15 -0
  4. package/skills/android-to-kmp-migrator/output-contract.md +61 -12
  5. package/skills/android-to-kmp-migrator/references/kmp-expert.md +260 -0
  6. package/skills/android-to-kmp-migrator/references/kmp-mvi-flowredux.md +344 -0
  7. package/skills/android-to-kmp-migrator/references/kmp-mvvm.md +328 -0
  8. package/skills/android-to-kmp-migrator/roles/completion-report.md +2 -1
  9. package/skills/android-to-kmp-migrator/roles/global-migration-phase.md +7 -2
  10. package/skills/android-to-kmp-migrator/roles/migration-planning-gate.md +7 -2
  11. package/skills/android-to-kmp-migrator/roles/migration-prep.md +12 -2
  12. package/skills/android-to-kmp-migrator/roles/migration-verification.md +5 -3
  13. package/skills/android-to-kmp-migrator/roles/module-implementation.md +20 -2
  14. package/skills/android-to-kmp-migrator/roles/module-node-review-fix.md +19 -3
  15. package/skills/android-to-kmp-migrator/roles/target-project-assistant.md +10 -1
  16. package/skills/android-to-kmp-migrator/workflow.md +23 -2
  17. package/skills/kmp-test-validator/SKILL.md +3 -3
  18. package/skills/kmp-test-validator/bind.md +3 -2
  19. package/skills/kmp-test-validator/dependencies.yaml +15 -2
  20. package/skills/kmp-test-validator/output-contract.md +92 -8
  21. package/skills/kmp-test-validator/roles/validation-code-gate.md +53 -7
  22. package/skills/kmp-test-validator/roles/validation-workspace-state.md +7 -2
  23. package/skills/kmp-test-validator/workflow.md +3 -1
@@ -0,0 +1,344 @@
1
+ ---
2
+ name: kmp-mvi-flowredux
3
+ description: >
4
+ KMP/CMP MVI architecture using FlowRedux state machines. Use when building or
5
+ reviewing Kotlin Multiplatform features that need MVI pattern: state machines,
6
+ sealed State/Action classes, unidirectional data flow, Compose Multiplatform UI
7
+ wiring, or when the user mentions FlowRedux, MVI, state machine, inState, onEnter,
8
+ dispatch, or sealed interface State.
9
+ ---
10
+
11
+ # KMP MVI with FlowRedux — architecture skill
12
+
13
+ Reference: https://freeletics.github.io/FlowRedux/
14
+
15
+ ## Core concepts
16
+
17
+ MVI (Model-View-Intent) enforces **unidirectional data flow**:
18
+
19
+ ```
20
+ Action (Intent) ──► StateMachine ──► State ──► UI
21
+ ▲ │
22
+ └────────────────────────────────────────┘
23
+ dispatch()
24
+ ```
25
+
26
+ FlowRedux models this as an explicit **state machine** with a Coroutines DSL:
27
+ - `State` — sealed interface; each subtype is a discrete screen state
28
+ - `Action` — sealed interface; every user gesture or external event
29
+ - `FlowReduxStateMachineFactory` — holds the `spec { }` DSL and produces instances
30
+ - `FlowReduxStateMachine` — public API: `state: Flow<State>` + `suspend dispatch(action)`
31
+
32
+ ---
33
+
34
+ ## Architecture layers
35
+
36
+ ```
37
+ :shared/commonMain
38
+ ├── feature/
39
+ │ ├── model/
40
+ │ │ ├── FeatureState.kt ← sealed interface State + subtypes
41
+ │ │ └── FeatureAction.kt ← sealed interface Action + subtypes
42
+ │ ├── statemachine/
43
+ │ │ └── FeatureStateMachineFactory.kt ← FlowReduxStateMachineFactory
44
+ │ └── domain/
45
+ │ └── FeatureRepository.kt ← interface; expect/actual for platform impl
46
+ └── di/
47
+ └── FeatureModule.kt ← Koin module wiring factory + repo
48
+
49
+ :composeApp/commonMain
50
+ └── feature/
51
+ ├── FeatureScreen.kt ← @Composable, uses produceStateMachine()
52
+ └── FeatureViewModel.kt ← optional: ViewModel wrapper for lifecycle
53
+ ```
54
+
55
+ **Rule:** All state machine logic lives in `:shared/commonMain`. The UI layer
56
+ (`:composeApp`) only renders state and dispatches actions — zero business logic.
57
+
58
+ ---
59
+
60
+ ## Coding guidance
61
+
62
+ ### 1. Define State and Action as sealed interfaces
63
+
64
+ ```kotlin
65
+ // shared/commonMain/.../model/ItemListState.kt
66
+ sealed interface ItemListState {
67
+ data object Loading : ItemListState
68
+ data class ShowContent(val items: List<Item>) : ItemListState
69
+ data class Error(val message: String, val countdown: Int) : ItemListState
70
+ }
71
+
72
+ // shared/commonMain/.../model/ItemListAction.kt
73
+ sealed interface ItemListAction {
74
+ data object RetryLoading : ItemListAction
75
+ data class ToggleFavorite(val itemId: Int) : ItemListAction
76
+ }
77
+ ```
78
+
79
+ Rules:
80
+ - `State` subtypes are `data object` (no fields) or `data class` (immutable fields)
81
+ - Never put UI logic inside a `State` — it is plain data
82
+ - `Action` subtypes carry only the payload the handler needs — nothing else
83
+
84
+ ### 2. Write the StateMachineFactory in commonMain
85
+
86
+ ```kotlin
87
+ // shared/commonMain/.../statemachine/ItemListStateMachineFactory.kt
88
+ class ItemListStateMachineFactory(
89
+ private val repository: ItemRepository
90
+ ) : FlowReduxStateMachineFactory<ItemListState, ItemListAction>() {
91
+
92
+ init {
93
+ initializeWith { Loading }
94
+
95
+ spec {
96
+ inState<Loading> {
97
+ onEnter { state ->
98
+ try {
99
+ val items = repository.loadItems()
100
+ state.override { ShowContent(items) }
101
+ } catch (t: Throwable) {
102
+ state.override { Error(t.message ?: "Unknown error", countdown = 3) }
103
+ }
104
+ }
105
+ }
106
+
107
+ inState<Error> {
108
+ on<RetryLoading> { _, state ->
109
+ state.override { Loading }
110
+ }
111
+
112
+ collectWhileInState(timerEverySecond()) { _, state ->
113
+ val next = state.snapshot.countdown - 1
114
+ if (next <= 0) state.override { Loading }
115
+ else state.mutate { copy(countdown = next) }
116
+ }
117
+ }
118
+
119
+ inState<ShowContent> {
120
+ on<ToggleFavorite>(executionPolicy = ExecutionPolicy.Unordered) { action, state ->
121
+ // delegate to child state machine — see composing section
122
+ state.mutate { copy(items = items.map { item ->
123
+ if (item.id == action.itemId) item.copy(toggling = true) else item
124
+ }) }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ Rules:
133
+ - One factory per feature/screen — not one global machine
134
+ - `initializeWith { }` always first in `init`
135
+ - Extract large handlers into `private suspend fun` for readability and unit-testability
136
+ - Never call `state.override` or `state.mutate` after the function returns — these are the only mutation points
137
+
138
+ ### 3. DSL blocks reference
139
+
140
+ | Block | Triggers when | Typical use |
141
+ |---|---|---|
142
+ | `onEnter { }` | State is entered | Load data, start timers |
143
+ | `on<Action> { }` | Action arrives while in this state | User interactions |
144
+ | `collectWhileInState(flow) { }` | Flow emits while in this state | Countdown, realtime updates |
145
+ | `onActionEffect<Action> { }` | Like `on<>` but does NOT transition state | Logging, analytics side-effects |
146
+ | `condition { }` | Wraps sub-blocks with a predicate | Feature flags, conditional handlers |
147
+ | `untilIdentityChanged { }` | Re-enters block when identity field changes | Pagination, refresh on id change |
148
+
149
+ ### 4. ExecutionPolicy — choose deliberately
150
+
151
+ ```kotlin
152
+ // Default: CancelPrevious — cancel in-flight handler when same action fires again
153
+ on<SearchQueryChanged> { action, state -> ... }
154
+
155
+ // Unordered — run in parallel, no order guarantee; use for independent async ops
156
+ on<ToggleFavorite>(executionPolicy = ExecutionPolicy.Unordered) { ... }
157
+
158
+ // Ordered — sequential; use when order matters and you must not cancel
159
+ on<PageLoad>(executionPolicy = ExecutionPolicy.Ordered) { ... }
160
+
161
+ // Throttled — ignore re-triggers within duration; use for debounce-like behaviour
162
+ on<ButtonClick>(executionPolicy = ExecutionPolicy.Throttled(500.milliseconds)) { ... }
163
+ ```
164
+
165
+ ### 5. Compose Multiplatform UI wiring
166
+
167
+ ```kotlin
168
+ // composeApp/commonMain/.../FeatureScreen.kt
169
+ @Composable
170
+ fun ItemListScreen(factory: ItemListStateMachineFactory) {
171
+ val stateMachine = factory.produceStateMachine() // FlowRedux compose extension
172
+ val state by stateMachine.state.collectAsState() // or use stateMachine.state.value
173
+
174
+ when (val s = state) {
175
+ is Loading -> LoadingIndicator()
176
+ is ShowContent -> ContentList(s.items, onToggle = {
177
+ stateMachine.dispatch(ToggleFavorite(it)) // dispatch is non-suspending in compose ext
178
+ })
179
+ is Error -> ErrorView(s.message, s.countdown, onRetry = {
180
+ stateMachine.dispatch(RetryLoading)
181
+ })
182
+ }
183
+ }
184
+ ```
185
+
186
+ For Android ViewModel lifecycle:
187
+
188
+ ```kotlin
189
+ class ItemListViewModel @Inject constructor(
190
+ private val factory: ItemListStateMachineFactory
191
+ ) : ViewModel() {
192
+ private val stateMachine = factory.shareIn(viewModelScope)
193
+ val state: StateFlow<ItemListState> = stateMachine.state
194
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)
195
+
196
+ fun dispatch(action: ItemListAction) {
197
+ viewModelScope.launch { stateMachine.dispatch(action) }
198
+ }
199
+ }
200
+ ```
201
+
202
+ ### 6. Composing state machines (hierarchical)
203
+
204
+ Delegate a sub-problem to a child state machine rather than bloating the parent spec:
205
+
206
+ ```kotlin
207
+ inState<ShowContent> {
208
+ onActionStartStateMachine(
209
+ stateMachineFactoryBuilder = { action: ToggleFavorite ->
210
+ FavoriteStatusStateMachineFactory(
211
+ itemId = action.itemId,
212
+ httpClient = httpClient
213
+ )
214
+ }
215
+ ) { favoriteStatus: FavoriteStatus ->
216
+ // merge child state into parent state
217
+ mutate { copy(items = items.updateFavoriteStatus(favoriteStatus)) }
218
+ }
219
+ }
220
+ ```
221
+
222
+ Use `onEnterStartStateMachine()` when you want a child machine to start on state entry
223
+ rather than on an explicit action.
224
+
225
+ ### 7. Effects (side-effects without state transition)
226
+
227
+ ```kotlin
228
+ inState<ShowContent> {
229
+ onActionEffect<ToggleFavorite> { action, stateSnapshot ->
230
+ analytics.track("toggle_favorite", mapOf("id" to action.itemId))
231
+ // cannot mutate state from an effect block
232
+ }
233
+ }
234
+ ```
235
+
236
+ Use `*Effect` variants whenever you need to react to an action or event but must not
237
+ change state (navigation events, analytics, logging).
238
+
239
+ ---
240
+
241
+ ## Project structure checklist
242
+
243
+ ```
244
+ :shared
245
+ commonMain
246
+ └── feature/itemlist/
247
+ ├── model/
248
+ │ ├── ItemListState.kt ✓ sealed interface, data classes, immutable
249
+ │ └── ItemListAction.kt ✓ sealed interface
250
+ ├── statemachine/
251
+ │ ├── ItemListStateMachineFactory.kt ✓ extends FlowReduxStateMachineFactory
252
+ │ └── FavoriteStatusStateMachineFactory.kt ✓ child machine
253
+ ├── domain/
254
+ │ └── ItemRepository.kt ✓ interface only
255
+ └── data/
256
+ └── ItemRepositoryImpl.kt ✓ actual implementation (or expect/actual)
257
+
258
+ :composeApp
259
+ commonMain
260
+ └── feature/itemlist/
261
+ ├── ItemListScreen.kt ✓ @Composable, pure render + dispatch
262
+ └── ItemListViewModel.kt ✓ only if AndroidX lifecycle needed
263
+ ```
264
+
265
+ ---
266
+
267
+ ## Testing guidance
268
+
269
+ Prefer **functional integration tests** with Turbine over unit tests per handler.
270
+
271
+ ```kotlin
272
+ // Use runTest + Turbine for the full state machine
273
+ @Test
274
+ fun `loading transitions to ShowContent on success`() = runTest {
275
+ val factory = ItemListStateMachineFactory(FakeItemRepository(items = sampleItems))
276
+ val sm = factory.shareIn(backgroundScope)
277
+
278
+ sm.state.test {
279
+ assertEquals(Loading, awaitItem())
280
+ assertEquals(ShowContent(sampleItems), awaitItem())
281
+ }
282
+ }
283
+
284
+ // Override initial state to test mid-flow without replaying all transitions
285
+ @Test
286
+ fun `retry from Error transitions to Loading`() = runTest {
287
+ val factory = ItemListStateMachineFactory(FakeItemRepository())
288
+ factory.initializeWith { Error("oops", countdown = 3) }
289
+ val sm = factory.shareIn(backgroundScope)
290
+
291
+ sm.state.test {
292
+ assertEquals(Error("oops", 3), awaitItem())
293
+ sm.dispatch(RetryLoading)
294
+ assertEquals(Loading, awaitItem())
295
+ }
296
+ }
297
+ ```
298
+
299
+ For **unit testing handlers**, extract them to `private suspend fun` and test with
300
+ `ChangeableState` + `changedState.reduce(snapshot)` directly.
301
+
302
+ ---
303
+
304
+ ## Gradle dependencies
305
+
306
+ ```kotlin
307
+ // shared/build.gradle.kts
308
+ kotlin {
309
+ sourceSets {
310
+ commonMain.dependencies {
311
+ // FlowRedux core (KMP)
312
+ implementation("com.freeletics.flowredux:flowredux:2.0.1")
313
+ // Coroutines
314
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
315
+ }
316
+ }
317
+ }
318
+
319
+ // composeApp/build.gradle.kts
320
+ commonMain.dependencies {
321
+ // FlowRedux Compose extensions (produceStateMachine, dispatch)
322
+ implementation("com.freeletics.flowredux:compose:2.0.1")
323
+ }
324
+
325
+ // Testing
326
+ commonTest.dependencies {
327
+ implementation("app.cash.turbine:turbine:1.1.0")
328
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
329
+ }
330
+ ```
331
+
332
+ ---
333
+
334
+ ## Anti-patterns to avoid
335
+
336
+ | Anti-pattern | Fix |
337
+ |---|---|
338
+ | Business logic inside `@Composable` | Move to `inState { }` handlers |
339
+ | Mutable state inside `State` data class | All fields must be `val`; use `state.mutate { copy(...) }` |
340
+ | One giant state machine for the whole app | One factory per screen/feature |
341
+ | `state.override` called after a `return` | Override is the last expression in the lambda |
342
+ | Catching all `Throwable` silently | Log or encode the error into an `Error` state subtype |
343
+ | Skipping `ExecutionPolicy` on concurrent actions | Explicitly choose `Unordered` or `Ordered` where needed |
344
+ | Navigation logic inside state | Use `onActionEffect` + a navigation callback; state stays pure data |
@@ -0,0 +1,328 @@
1
+ ---
2
+ name: kmp-mvvm
3
+ description: >
4
+ KMP/CMP MVVM architecture with shared ViewModels. Use when building or reviewing
5
+ Kotlin Multiplatform features that follow the MVVM pattern: ViewModel + StateFlow,
6
+ UI state classes, state hoisting, collectAsStateWithLifecycle, Koin viewModel
7
+ injection, or bridging shared ViewModels to SwiftUI on iOS. Triggers on MVVM,
8
+ ViewModel, StateFlow, uiState, viewModelScope, KMP-ObservableViewModel, SKIE.
9
+ ---
10
+
11
+ # KMP MVVM with shared ViewModels — architecture skill
12
+
13
+ References:
14
+ - https://kotlinlang.org/docs/multiplatform/compose-viewmodel.html
15
+ - https://touchlab.co/kmp-view-models
16
+
17
+ ## Core concepts
18
+
19
+ MVVM separates concerns into three groups:
20
+
21
+ ```
22
+ observe (StateFlow) user events (fun calls)
23
+ View ◄───────────────────────── ViewModel ◄──────────────────── View
24
+ │ │
25
+ │ ▼
26
+ │ Model (UseCase / Repository)
27
+ └─ renders UiState │
28
+
29
+ data / network / cache
30
+ ```
31
+
32
+ - **Model** — domain + data layer (UseCases, Repositories) in `commonMain`
33
+ - **ViewModel** — holds and exposes immutable `UiState` as `StateFlow`, handles events
34
+ - **View** — Compose Multiplatform (shared) or native (SwiftUI/Compose), renders state only
35
+
36
+ MVVM vs MVI: MVVM exposes a single immutable `UiState` and *public methods* for events,
37
+ rather than dispatching `Action` objects through a reducer. Use MVVM when the team is
38
+ Android/Compose-native and the per-screen state is simple; reach for MVI/FlowRedux when
39
+ state transitions are complex enough to warrant an explicit state machine.
40
+
41
+ ---
42
+
43
+ ## Architecture layers
44
+
45
+ ```
46
+ :shared/commonMain
47
+ ├── feature/order/
48
+ │ ├── presentation/
49
+ │ │ ├── OrderUiState.kt ← single immutable data class (the "Model" the View sees)
50
+ │ │ └── OrderViewModel.kt ← extends ViewModel, exposes StateFlow<OrderUiState>
51
+ │ ├── domain/
52
+ │ │ ├── OrderRepository.kt ← interface
53
+ │ │ └── PlaceOrderUseCase.kt ← business logic, no Android deps
54
+ │ └── data/
55
+ │ └── OrderRepositoryImpl.kt ← actual impl (Ktor/SQLDelight)
56
+ └── di/
57
+ └── OrderModule.kt ← Koin module: viewModelOf(::OrderViewModel)
58
+
59
+ :composeApp/commonMain
60
+ └── feature/order/
61
+ └── OrderScreen.kt ← @Composable, koinViewModel() + collectAsStateWithLifecycle()
62
+
63
+ iosApp (Swift) ← observes ViewModel via KMP-ObservableViewModel
64
+ ```
65
+
66
+ **Rule:** ViewModel, UiState, and all logic live in `:shared/commonMain`. The View only
67
+ collects state and calls ViewModel methods.
68
+
69
+ ---
70
+
71
+ ## Choosing the ViewModel base class
72
+
73
+ There are three viable approaches. Pick one per project and stay consistent.
74
+
75
+ | Approach | commonMain dependency | iOS story | When to use |
76
+ |---|---|---|---|
77
+ | **AndroidX `lifecycle-viewmodel` (multiplatform)** | `androidx.lifecycle:lifecycle-viewmodel:2.8+` | Manual lifecycle on iOS; brings `viewModelScope` baggage | CMP-first apps where iOS UI is also Compose |
78
+ | **KMP-ObservableViewModel** (rickclephas) | `com.rickclephas.kmp:kmp-observableviewmodel-core` | First-class: SwiftUI observes directly, handles store-owner boilerplate | **Recommended** when iOS uses SwiftUI; this is the official Kotlin-docs recommendation |
79
+ | **Pure Kotlin ViewModel** (your own `interface`/base) | none | Explicit Swift observer wrapper you write | Maximum control, zero androidx in commonMain |
80
+
81
+ The official Kotlin documentation recommends **KMP-ObservableViewModel** for SwiftUI
82
+ because there is no built-in `ViewModelStoreOwner` on iOS — the library ties the
83
+ ViewModel lifecycle to the SwiftUI view automatically.
84
+
85
+ ---
86
+
87
+ ## Coding guidance
88
+
89
+ ### 1. Model UiState as a single immutable data class
90
+
91
+ ```kotlin
92
+ // shared/commonMain/.../presentation/OrderUiState.kt
93
+ data class OrderUiState(
94
+ val quantity: Int = 1,
95
+ val items: List<OrderItem> = emptyList(),
96
+ val isLoading: Boolean = false,
97
+ val errorMessage: String? = null,
98
+ )
99
+ ```
100
+
101
+ Rules:
102
+ - One `UiState` per screen — never expose multiple loose `StateFlow`s for one screen
103
+ - All fields `val`, with sensible defaults
104
+ - Represent loading/error as fields (`isLoading`, `errorMessage`), or use a sealed
105
+ `UiState` if states are mutually exclusive enough to warrant it
106
+ - Keep it free of framework types (no `Composable`, no `Color`, no `Painter`)
107
+
108
+ ### 2. ViewModel exposes StateFlow, never MutableStateFlow
109
+
110
+ ```kotlin
111
+ // shared/commonMain/.../presentation/OrderViewModel.kt
112
+ import com.rickclephas.kmp.observableviewmodel.ViewModel
113
+ import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
114
+ import com.rickclephas.kmp.observableviewmodel.stateIn
115
+ import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
116
+
117
+ class OrderViewModel(
118
+ private val placeOrder: PlaceOrderUseCase,
119
+ private val repository: OrderRepository,
120
+ ) : ViewModel() {
121
+
122
+ private val _uiState = MutableStateFlow(viewModelScope, OrderUiState())
123
+
124
+ @NativeCoroutinesState // exposes the flow cleanly to Swift
125
+ val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
126
+
127
+ init { loadItems() }
128
+
129
+ fun setQuantity(n: Int) {
130
+ _uiState.update { it.copy(quantity = n) }
131
+ }
132
+
133
+ fun submit() {
134
+ viewModelScope.coroutineScope.launch {
135
+ _uiState.update { it.copy(isLoading = true, errorMessage = null) }
136
+ runCatching { placeOrder(_uiState.value.quantity) }
137
+ .onSuccess { _uiState.update { s -> s.copy(isLoading = false) } }
138
+ .onFailure { t -> _uiState.update { s -> s.copy(isLoading = false, errorMessage = t.message) } }
139
+ }
140
+ }
141
+
142
+ private fun loadItems() { /* launch in viewModelScope, update _uiState */ }
143
+ }
144
+ ```
145
+
146
+ Rules:
147
+ - `_uiState` is private `MutableStateFlow`; expose read-only `StateFlow` via `asStateFlow()`
148
+ - Always update via `update { copy(...) }` — atomic, avoids race conditions
149
+ - Launch coroutines in `viewModelScope` so they cancel when the ViewModel clears
150
+ - Annotate the public flow with `@NativeCoroutinesState` for clean Swift interop
151
+ - Never expose `suspend` functions to the View; the View calls plain `fun`, the VM launches
152
+
153
+ ### 3. Compose Multiplatform View — state hoisting + lifecycle-aware collection
154
+
155
+ ```kotlin
156
+ // composeApp/commonMain/.../OrderScreen.kt
157
+ @Composable
158
+ fun OrderScreen(viewModel: OrderViewModel = koinViewModel()) {
159
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
160
+
161
+ OrderContent(
162
+ state = uiState,
163
+ onQuantityChange = viewModel::setQuantity,
164
+ onSubmit = viewModel::submit,
165
+ )
166
+ }
167
+
168
+ // Stateless, hoisted — fully previewable and testable
169
+ @Composable
170
+ private fun OrderContent(
171
+ state: OrderUiState,
172
+ onQuantityChange: (Int) -> Unit,
173
+ onSubmit: () -> Unit,
174
+ ) {
175
+ Column {
176
+ if (state.isLoading) LinearProgressIndicator()
177
+ QuantityStepper(state.quantity, onQuantityChange)
178
+ state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
179
+ Button(onClick = onSubmit, enabled = !state.isLoading) { Text("Place order") }
180
+ }
181
+ }
182
+ ```
183
+
184
+ Rules:
185
+ - Split into a stateful screen (`koinViewModel()` + collect) and a stateless content
186
+ composable that takes `state` + lambdas — this is **state hoisting**
187
+ - Use `collectAsStateWithLifecycle()` (not bare `collectAsState()`) so collection pauses
188
+ when the UI is not visible
189
+ - Pass method references (`viewModel::setQuantity`), not the whole ViewModel, into content
190
+ - The stateless content composable has zero ViewModel dependency → trivially previewable
191
+
192
+ ### 4. Dependency injection with Koin
193
+
194
+ ```kotlin
195
+ // shared/commonMain/.../di/OrderModule.kt
196
+ val orderModule = module {
197
+ singleOf(::OrderRepositoryImpl) bind OrderRepository::class
198
+ factoryOf(::PlaceOrderUseCase)
199
+ viewModelOf(::OrderViewModel) // koin-compose-viewmodel
200
+ }
201
+ ```
202
+
203
+ Compose retrieves it with `koinViewModel()` (from `koin-compose-viewmodel`), which
204
+ scopes the ViewModel to the navigation entry / composition correctly on all platforms.
205
+
206
+ ### 5. iOS bridge (SwiftUI)
207
+
208
+ With KMP-ObservableViewModel + `@NativeCoroutinesState`, SwiftUI observes directly:
209
+
210
+ ```swift
211
+ struct OrderView: View {
212
+ @StateViewModel var viewModel = OrderViewModel(placeOrder: ..., repository: ...)
213
+
214
+ var body: some View {
215
+ VStack {
216
+ if viewModel.uiState.isLoading { ProgressView() }
217
+ Stepper("Qty: \(viewModel.uiState.quantity)",
218
+ value: Binding(get: { Int(viewModel.uiState.quantity) },
219
+ set: { viewModel.setQuantity(n: Int32($0)) }))
220
+ Button("Place order") { viewModel.submit() }
221
+ }
222
+ }
223
+ }
224
+ ```
225
+
226
+ If you instead use AndroidX ViewModel + SKIE, SKIE maps `StateFlow` to a Swift
227
+ `AsyncSequence`/`@Observable` and you bridge it with an observer wrapper.
228
+
229
+ ---
230
+
231
+ ## Project structure checklist
232
+
233
+ ```
234
+ :shared
235
+ commonMain
236
+ └── feature/order/
237
+ ├── presentation/
238
+ │ ├── OrderUiState.kt ✓ single immutable data class
239
+ │ └── OrderViewModel.kt ✓ private Mutable, public StateFlow
240
+ ├── domain/
241
+ │ ├── OrderRepository.kt ✓ interface
242
+ │ └── PlaceOrderUseCase.kt ✓ pure logic, no androidx
243
+ ├── data/
244
+ │ └── OrderRepositoryImpl.kt ✓ Ktor / SQLDelight
245
+ └── di/OrderModule.kt ✓ viewModelOf(::OrderViewModel)
246
+
247
+ :composeApp
248
+ commonMain
249
+ └── feature/order/OrderScreen.kt ✓ stateful screen + stateless content
250
+
251
+ iosApp ✓ @StateViewModel, observes uiState
252
+ ```
253
+
254
+ ---
255
+
256
+ ## Testing guidance
257
+
258
+ The ViewModel is a plain class — test it with `runTest` + Turbine, injecting fakes.
259
+
260
+ ```kotlin
261
+ @Test
262
+ fun `submit sets loading then clears on success`() = runTest {
263
+ val vm = OrderViewModel(
264
+ placeOrder = FakePlaceOrderUseCase(succeeds = true),
265
+ repository = FakeOrderRepository(),
266
+ )
267
+ vm.uiState.test {
268
+ assertEquals(OrderUiState(), awaitItem()) // initial
269
+ vm.setQuantity(3)
270
+ assertEquals(3, awaitItem().quantity)
271
+ vm.submit()
272
+ assertTrue(awaitItem().isLoading) // loading on
273
+ assertFalse(awaitItem().isLoading) // cleared after success
274
+ }
275
+ }
276
+ ```
277
+
278
+ The stateless content composable is tested separately with Compose UI tests / screenshot
279
+ tests — it takes a `UiState` directly, so no ViewModel is needed.
280
+
281
+ ---
282
+
283
+ ## Gradle dependencies
284
+
285
+ ```kotlin
286
+ // shared/build.gradle.kts
287
+ kotlin {
288
+ sourceSets {
289
+ commonMain.dependencies {
290
+ // Recommended iOS-friendly ViewModel (official Kotlin docs)
291
+ implementation("com.rickclephas.kmp:kmp-observableviewmodel-core:1.0.0-BETA-13")
292
+ // OR the AndroidX multiplatform ViewModel
293
+ // implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4")
294
+
295
+ implementation("io.insert-koin:koin-core:4.0.0")
296
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
297
+ }
298
+ commonTest.dependencies {
299
+ implementation("app.cash.turbine:turbine:1.1.0")
300
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
301
+ }
302
+ }
303
+ }
304
+
305
+ // composeApp/build.gradle.kts
306
+ commonMain.dependencies {
307
+ implementation("io.insert-koin:koin-compose-viewmodel:4.0.0") // koinViewModel()
308
+ implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
309
+ }
310
+ ```
311
+
312
+ For SwiftUI interop, add the KMP-ObservableViewModel Swift package, or KMP-NativeCoroutines / SKIE.
313
+
314
+ ---
315
+
316
+ ## Anti-patterns to avoid
317
+
318
+ | Anti-pattern | Fix |
319
+ |---|---|
320
+ | Exposing `MutableStateFlow` publicly | Expose read-only `StateFlow` via `asStateFlow()` |
321
+ | Multiple loose `StateFlow`s for one screen | Combine into one `UiState` data class |
322
+ | Business logic inside the `@Composable` | Move to the ViewModel; the View only renders + calls methods |
323
+ | Passing the whole ViewModel into child composables | Hoist state: pass `UiState` + lambdas |
324
+ | `collectAsState()` instead of `collectAsStateWithLifecycle()` | Use the lifecycle-aware variant to pause off-screen collection |
325
+ | `suspend` functions exposed to the View | Keep them internal; the VM launches in `viewModelScope` |
326
+ | Framework types (`Color`, `Painter`) inside `UiState` | Keep `UiState` platform-agnostic data only |
327
+ | AndroidX ViewModel in commonMain when targeting SwiftUI | Prefer KMP-ObservableViewModel or a pure-Kotlin base |
328
+ | Mutating state with `.value = .value.copy()` under concurrency | Use `update { copy(...) }` for atomic updates |
@@ -29,6 +29,7 @@ Forbidden:
29
29
  - Do not treat report mode success as final migration completion — Leader must still dispatch `kmp-test-validator` at MG17.
30
30
 
31
31
  Mandatory:
32
+ - Report mode MUST fail when scheduled modules required implementation but `target_changed_files[]` would be empty.
32
33
  - Validate `mode`, `migration_module_id`, module/global representation paths, workspace state, and exact `output_dir`.
33
34
  - Readiness output path is `<module_root>/node-results/completion-report/readiness` or `<global_dir>/node-results/completion-report/readiness`.
34
35
  - Report output path is `<output_root>/report`.
@@ -60,7 +61,7 @@ Shared return shape applies.
60
61
 
61
62
  - `completion_readiness.json`: machine-routable readiness artifact containing requirement coverage, migration invariants, module/global representation references when applicable, verification/review status, validation inputs readiness, rerun requests, and blockers.
62
63
  - `completion_readiness.md`: agent-readable readiness handoff containing coverage tables, invariant checks, incomplete markers, rerun routing, blockers, and whether representation/report gates may proceed.
63
- - `migration_report.json`: machine-routable final migration handoff containing migration scope, source/target paths, analyst_output_root, upstream_analyst_index, `handoff_gates` (`M0`–`M6`, `V0`), `handoff_package: V0`, module representations, global representation, `alignment_report` path, `global_system_integration` path, changed files by role, coverage summary, validation inputs for kmp-test-validator, `validation_deferred_to: kmp-test-validator`, limitations, blockers.
64
+ - `migration_report.json`: machine-routable final migration handoff containing migration scope, source/target paths, analyst_output_root, upstream_analyst_index, `handoff_gates` (`M0`–`M6`, `V0`), `handoff_package: V0`, module representations, global representation, `alignment_report` path, `global_system_integration` path, `target_changed_files[]` (deduplicated union of all module and global integrate target paths with `owning_role`), changed files by role, coverage summary, validation inputs for kmp-test-validator, `validation_deferred_to: kmp-test-validator`, limitations, blockers.
64
65
  - `migration_report.md`: agent-readable final migration report for `kmp-test-validator` and follow-up agents, preserving exact artifact paths, changed-file ownership, validation handoff context, limitations, and blockers.
65
66
  - Report mode success signals Leader to **mandatorily invoke** `kmp-test-validator` — migration is incomplete without validator dispatch.
66
67