
Generating a typesafe DSL that assembles MVI stores from sealed-state declarations, enforcing declared transitions/events as compile-time errors; emits diagram IR and IDE visualization support.
koma(KMP の MVI 状態管理ライブラリ)向けの KSP プラグイン。
State 定義から Store を typesafe に組み上げる DSL を自動生成する実験プロジェクト。
[!CAUTION] このプロジェクトは実験的なプロジェクトであり、バージョンが 1 系に達するまでは破壊的変更を多く含みます。 また non-stable バージョンである 4 系に依存しています。
koma の素の DSL には強制が一切ない ── state の網羅は要求されず、遷移先も emit する event も無制限。 koma-strict はこの規約を「レビューで守る」のではなく コンパイルエラーで守る:
① 宣言していない遷移・イベントは 書けない。 ② 宣言した State / Action のハンドリングは 書き忘れられない 仕組みを提供する。
この 2 つを、実行時チェックではなく すべてコンパイルエラーとして実現する。
// module/build.gradle.kts
plugins {
id("com.google.devtools.ksp") version "<ksp-version>"
}
dependencies {
implementation("me.tbsten.koma.strict:koma-strict-runtime:<koma-strict-version>")
ksp("me.tbsten.koma.strict:koma-strict-ksp:<koma-strict-version>")
}(KMP プロジェクトの場合は KSP の制限を回避するためのワークアラウンド が必要です)
また IDE 拡張機能をインストールすることで、@StoreSpec を IDE 上で可視化したり、追加のコード生成機能などを利用することができます。最新リリース から koma-strict-idea-<version>.zip をダウンロードして、お使いの IDE の Settings → Plugins → ⚙ → Install Plugin from Disk… から選択してインストールしてください(IntelliJ IDEA / Android Studio 2026.1 以降が必要です)。
@StoreSpec(initial = [LceState.Loading::class]) // actions / events は宣言から推論
sealed interface LceState : State {
companion object // 生成拡張の生やし先
@OnEnter(nextState = [Content::class, Error::class], emit = [LceEvent.LoadFailed::class])
interface Loading : LceState { companion object }
@OnAction<LceAction.Reload>(nextState = [Loading::class])
interface Content : LceState { val data: String; companion object }
@OnAction<LceAction.Retry>(nextState = [Loading::class])
interface Error : LceState { val message: String?; companion object }
}
sealed interface LceAction : Action {
data object Reload : LceAction
data object Retry : LceAction
}
sealed interface LceEvent : Event {
data class LoadFailed(val message: String?) : LceEvent
}val store = createLceStore( // 生成 factory(型引数を書かない糖衣入口)
initialState = LceState.Loading(), // 宣言済み initial 候補に型で絞り込まれる
loading = LceState.Loading.actions(
enter = {
runCatching { fetchData() }.fold(
onSuccess = { nextState.toContent(data = it) }, // 宣言済み遷移だけが生える
onFailure = {
emitLoadFailed(it.message) // 宣言済み event だけが emit できる
nextState.toError(message = it.message)
},
)
},
),
content = LceState.Content.actions(reload = { nextState.toLoading() }),
error = LceState.Error.actions(retry = { nextState.toLoading() }),
)content を渡し忘れる → コンパイルエラー(必須引数)nextState.toXxx)や event(emitXxx)はそもそも生えないので書けないstore は本物の Store<LceState, LceAction, LceEvent> ── koma-compose / koma-test 等がそのまま使えるcreateLceStore の initialState は宣言済み @StoreSpec(initial = ...) 候補
(LceState.Loading)に型で絞り込まれる。永続化 state からの復元やテストでの途中 state 起動には、
任意の LceState を受け付ける restoreLceStore を使うこのフレームワークの入出力は snapshot test されています。 snapshot は ./koma-strict-ksp/snapshots に格納されており、 特に koma-strict-ksp/snapshots/StoreSpecUseCasesTest/samples.md のユースケースとオプションの全組み合わせ/option=Default は実際のユースケースを想定した入出力がまとまっているため、これを参照して以下のような具体的なユースケース別の生成例を確認できます。
Stable)/ 条件付き遷移(Stay)/ prop 持ち越しを伴う LCE(pull-to-refresh + additional load)E = Nothing(タブ切替)@OnRecover(root 共有)/ @OnExit / 例外→宣言済み遷移の強制(認証 + セッション切れ)package はすべて me.tbsten.koma.strict 直下。
| annotation | 付与先 | 役割 |
|---|---|---|
@StoreSpec(actions, events, initial) |
sealed root | store 仕様の起点。actions / events は宣言から推論(省略可) |
@OnEnter(nextState, emit) |
state | enter handler の宣言 |
@OnExit(emit) |
state / 中間 / root | exit handler の宣言(遷移不可 ── emit のみ) |
@OnAction<A>(nextState, emit) |
leaf / 中間 / root | (state, action) handler。中間・root に付けると scope 共有アクション |
@OnRecover<E : Exception>(nextState, emit) |
state / 中間 / root | 例外 E 捕捉時の handler(@OnAction と相似形。Scope に error: E) |
@DefaultName(name) |
root / 中間 | 共有ブロックの引数名を変更(デフォルト "default") |
Stay |
nextState の要素 |
「現状維持も可」の宣言(koma.core.State を実装する sentinel ── nextState の型境界を満たすためだけの存在で、実 state にはならない) |
アクション能力ルール: nextState リストがそのハンドラの全能力を決める ──
[](省略)= stayState() のみ / [X::class] = nextState.toX() のみ / [Stay::class, X::class] = 両方(条件分岐)。
遷移先は具象 leaf のみ(nextState: Array<KClass<out State>> のため State 非実装型は型エラー、中間 sealed 型は KSP エラー)。
annotation 付き sealed State + Action / Event
→ KSP 解析 (koma-strict-ksp)
→ 生成: StoreBuilder への states()/actions() などの typesafe builder の生成
→ 利用者は koma 標準の Store {} 内で states() を呼ぶ(全 handler = 必須 named param)
→ 出来上がるのは素の koma Store<S, A, E>(入口から本物・エコシステムがそのまま使える)
思想は 4 本:状態遷移とその実装を分離/ 遷移は State 定義の近くに置く(annotation のつけ外し =
扱える遷移の増減)/ 強い制約とシンプルな定義(複雑さは生成コードが背負い、利用者の定義は素の
sealed interface + 少数の annotation)/ エンジンを密閉しない(入口は koma 標準の Store {} そのもの)。
Kotlin でコンパイル時に「全部書かせる」道具は 必須引数 と abstract メンバー の 2 つだけ。そこで:
| 層 | 強制の仕掛け |
|---|---|
| state 網羅 | root states() の必須 named param(states( を書いた瞬間に全 state 分が要求される。ハンドラ宣言ゼロの state も引数として要求される ── 宣言した state は書き忘れられない) |
| アクション網羅 |
<State>.actions(...) の必須 named param |
| 遷移ホワイトリスト | handler scope に宣言済みの toXxx だけが生える |
| event ホワイトリスト | 宣言済み event ごとに emit{Event}(...) を生成(未宣言は関数自体が無い) |
| +1: 反応の強制 | handler の戻り値型 = per-handler Reaction。toXxx() / stayState() でしか作れない |
同じ store を、好みに合わせて複数の形で書ける(混在も可):
createLceStore(initialState, loading = ..., ...)(型引数不要の糖衣入口。
initialState は宣言済み initial 候補に絞り込まれる)/ 任意の state から始める restoreLceStore
Store<S, A, E>(initialState) { states(...) }(正。型引数は明示)loading = X.actions(...) でも loading = { actions(...) } でも(両対応)X.actions(...) + X.states(...)(片方忘れは型エラー)actions { reload { ... } } / states { idle { ... } }
(※ この形式のみ、アクション網羅は構築時 fail-fast に弱まる ── opt-in のトレードオフ)actions(...) { /* per-state 素の koma DSL */ } や Store {} 末尾の configuration
で、v1 スコープ外の koma 機能(launch {} / transaction {} 等)を差し込める詳細と全ユースケースは doc/internal/samples.md を参照。
| モジュール | 中身 | publish |
|---|---|---|
koma-strict-runtime |
annotation + Stay + @KomaStrictDsl + 生成コード共通機構(dsl/)。koma-core に api 依存 |
✓ |
koma-strict-ksp |
KSP processor(発見 → 検証 → 生成) | ✓ |
koma-strict-ksp:shared |
KSP 非依存の StoreSpec model / 命名 / codegen | ✓ |
koma-strict-diagram |
状態遷移図の IR(StoreDiagramModel 等)+ lowering / layout。KSP・Compose 非依存の pure KMP モジュール |
✓ |
koma-strict-diagram-compose |
koma-strict-diagram の IR を描画する StoreDiagramPanel 等の Compose Multiplatform UI |
✓ |
integrationTest |
実物 koma-core に KSP を適用した E2E 検証 | ✗ |
KSP 非依存の shared(model・命名・codegen)を核に、KSP は frontend に徹する層構成
(将来の Analysis API / compiler plugin 移行を見据えた設計。.claude/rules/ksp-architecture.md)。
@StoreSpec 一式は遷移グラフの全データを静的に持つので、図生成は KSP 解析の副産物として実現できる。
KSP オプション koma.strict.generateDiagramModel(既定 false)を有効にすると、store ごとに
<Root>.diagram.generated.kt が生成される。中身は:
<Root>DiagramModel: StoreDiagramModel ── @StoreSpec を静的に射影した IR(states / actions / initial /
到達可能性など)<Root>.diagramStateId(): StateId ── 生きた state 値を上の IR の StateId へ写す拡張関数(sealed 網羅・
else 無しなので新しい leaf を足すと生成が追いつくまでコンパイルが止まる)描画するにはこの 2 つを koma-strict-diagram-compose の StoreDiagramPanel composable に渡す。実例は
integrationTest/composeApp の tripletriad サンプル(GamePane のトグル可能な右パネル)。
[!NOTE] 必要な依存: このオプションを有効にすると、生成コードが
koma-strict-diagramの型を参照する。 図モジュールは Maven Central に publish 済みなので、リポジトリ外のプロジェクトでもそのまま使える。// 生成される <Root>DiagramModel / diagramStateId() を解決するのに必須 implementation("me.tbsten.koma.strict:koma-strict-diagram:<version>") // StoreDiagramPanel で実際に描画する場合に追加 (Compose Multiplatform) implementation("me.tbsten.koma.strict:koma-strict-diagram-compose:<version>")
koma-strict-diagramは android / jvm / js / wasmJs / iosArm64 / iosSimulatorArm64 を publish するが、koma-strict-diagram-composeは Compose UI が legacyjsを持たないため js のみ非対応。
Mermaid / PlantUML の「図 + 遷移表」ペアを docs として出力し、CI の drift check で宣言との乖離を防ぐ構想は
未着手。設計は doc/internal/generate-state-diagrams.md。
koma(KMP の MVI 状態管理ライブラリ)向けの KSP プラグイン。
State 定義から Store を typesafe に組み上げる DSL を自動生成する実験プロジェクト。
[!CAUTION] このプロジェクトは実験的なプロジェクトであり、バージョンが 1 系に達するまでは破壊的変更を多く含みます。 また non-stable バージョンである 4 系に依存しています。
koma の素の DSL には強制が一切ない ── state の網羅は要求されず、遷移先も emit する event も無制限。 koma-strict はこの規約を「レビューで守る」のではなく コンパイルエラーで守る:
① 宣言していない遷移・イベントは 書けない。 ② 宣言した State / Action のハンドリングは 書き忘れられない 仕組みを提供する。
この 2 つを、実行時チェックではなく すべてコンパイルエラーとして実現する。
// module/build.gradle.kts
plugins {
id("com.google.devtools.ksp") version "<ksp-version>"
}
dependencies {
implementation("me.tbsten.koma.strict:koma-strict-runtime:<koma-strict-version>")
ksp("me.tbsten.koma.strict:koma-strict-ksp:<koma-strict-version>")
}(KMP プロジェクトの場合は KSP の制限を回避するためのワークアラウンド が必要です)
また IDE 拡張機能をインストールすることで、@StoreSpec を IDE 上で可視化したり、追加のコード生成機能などを利用することができます。最新リリース から koma-strict-idea-<version>.zip をダウンロードして、お使いの IDE の Settings → Plugins → ⚙ → Install Plugin from Disk… から選択してインストールしてください(IntelliJ IDEA / Android Studio 2026.1 以降が必要です)。
@StoreSpec(initial = [LceState.Loading::class]) // actions / events は宣言から推論
sealed interface LceState : State {
companion object // 生成拡張の生やし先
@OnEnter(nextState = [Content::class, Error::class], emit = [LceEvent.LoadFailed::class])
interface Loading : LceState { companion object }
@OnAction<LceAction.Reload>(nextState = [Loading::class])
interface Content : LceState { val data: String; companion object }
@OnAction<LceAction.Retry>(nextState = [Loading::class])
interface Error : LceState { val message: String?; companion object }
}
sealed interface LceAction : Action {
data object Reload : LceAction
data object Retry : LceAction
}
sealed interface LceEvent : Event {
data class LoadFailed(val message: String?) : LceEvent
}val store = createLceStore( // 生成 factory(型引数を書かない糖衣入口)
initialState = LceState.Loading(), // 宣言済み initial 候補に型で絞り込まれる
loading = LceState.Loading.actions(
enter = {
runCatching { fetchData() }.fold(
onSuccess = { nextState.toContent(data = it) }, // 宣言済み遷移だけが生える
onFailure = {
emitLoadFailed(it.message) // 宣言済み event だけが emit できる
nextState.toError(message = it.message)
},
)
},
),
content = LceState.Content.actions(reload = { nextState.toLoading() }),
error = LceState.Error.actions(retry = { nextState.toLoading() }),
)content を渡し忘れる → コンパイルエラー(必須引数)nextState.toXxx)や event(emitXxx)はそもそも生えないので書けないstore は本物の Store<LceState, LceAction, LceEvent> ── koma-compose / koma-test 等がそのまま使えるcreateLceStore の initialState は宣言済み @StoreSpec(initial = ...) 候補
(LceState.Loading)に型で絞り込まれる。永続化 state からの復元やテストでの途中 state 起動には、
任意の LceState を受け付ける restoreLceStore を使うこのフレームワークの入出力は snapshot test されています。 snapshot は ./koma-strict-ksp/snapshots に格納されており、 特に koma-strict-ksp/snapshots/StoreSpecUseCasesTest/samples.md のユースケースとオプションの全組み合わせ/option=Default は実際のユースケースを想定した入出力がまとまっているため、これを参照して以下のような具体的なユースケース別の生成例を確認できます。
Stable)/ 条件付き遷移(Stay)/ prop 持ち越しを伴う LCE(pull-to-refresh + additional load)E = Nothing(タブ切替)@OnRecover(root 共有)/ @OnExit / 例外→宣言済み遷移の強制(認証 + セッション切れ)package はすべて me.tbsten.koma.strict 直下。
| annotation | 付与先 | 役割 |
|---|---|---|
@StoreSpec(actions, events, initial) |
sealed root | store 仕様の起点。actions / events は宣言から推論(省略可) |
@OnEnter(nextState, emit) |
state | enter handler の宣言 |
@OnExit(emit) |
state / 中間 / root | exit handler の宣言(遷移不可 ── emit のみ) |
@OnAction<A>(nextState, emit) |
leaf / 中間 / root | (state, action) handler。中間・root に付けると scope 共有アクション |
@OnRecover<E : Exception>(nextState, emit) |
state / 中間 / root | 例外 E 捕捉時の handler(@OnAction と相似形。Scope に error: E) |
@DefaultName(name) |
root / 中間 | 共有ブロックの引数名を変更(デフォルト "default") |
Stay |
nextState の要素 |
「現状維持も可」の宣言(koma.core.State を実装する sentinel ── nextState の型境界を満たすためだけの存在で、実 state にはならない) |
アクション能力ルール: nextState リストがそのハンドラの全能力を決める ──
[](省略)= stayState() のみ / [X::class] = nextState.toX() のみ / [Stay::class, X::class] = 両方(条件分岐)。
遷移先は具象 leaf のみ(nextState: Array<KClass<out State>> のため State 非実装型は型エラー、中間 sealed 型は KSP エラー)。
annotation 付き sealed State + Action / Event
→ KSP 解析 (koma-strict-ksp)
→ 生成: StoreBuilder への states()/actions() などの typesafe builder の生成
→ 利用者は koma 標準の Store {} 内で states() を呼ぶ(全 handler = 必須 named param)
→ 出来上がるのは素の koma Store<S, A, E>(入口から本物・エコシステムがそのまま使える)
思想は 4 本:状態遷移とその実装を分離/ 遷移は State 定義の近くに置く(annotation のつけ外し =
扱える遷移の増減)/ 強い制約とシンプルな定義(複雑さは生成コードが背負い、利用者の定義は素の
sealed interface + 少数の annotation)/ エンジンを密閉しない(入口は koma 標準の Store {} そのもの)。
Kotlin でコンパイル時に「全部書かせる」道具は 必須引数 と abstract メンバー の 2 つだけ。そこで:
| 層 | 強制の仕掛け |
|---|---|
| state 網羅 | root states() の必須 named param(states( を書いた瞬間に全 state 分が要求される。ハンドラ宣言ゼロの state も引数として要求される ── 宣言した state は書き忘れられない) |
| アクション網羅 |
<State>.actions(...) の必須 named param |
| 遷移ホワイトリスト | handler scope に宣言済みの toXxx だけが生える |
| event ホワイトリスト | 宣言済み event ごとに emit{Event}(...) を生成(未宣言は関数自体が無い) |
| +1: 反応の強制 | handler の戻り値型 = per-handler Reaction。toXxx() / stayState() でしか作れない |
同じ store を、好みに合わせて複数の形で書ける(混在も可):
createLceStore(initialState, loading = ..., ...)(型引数不要の糖衣入口。
initialState は宣言済み initial 候補に絞り込まれる)/ 任意の state から始める restoreLceStore
Store<S, A, E>(initialState) { states(...) }(正。型引数は明示)loading = X.actions(...) でも loading = { actions(...) } でも(両対応)X.actions(...) + X.states(...)(片方忘れは型エラー)actions { reload { ... } } / states { idle { ... } }
(※ この形式のみ、アクション網羅は構築時 fail-fast に弱まる ── opt-in のトレードオフ)actions(...) { /* per-state 素の koma DSL */ } や Store {} 末尾の configuration
で、v1 スコープ外の koma 機能(launch {} / transaction {} 等)を差し込める詳細と全ユースケースは doc/internal/samples.md を参照。
| モジュール | 中身 | publish |
|---|---|---|
koma-strict-runtime |
annotation + Stay + @KomaStrictDsl + 生成コード共通機構(dsl/)。koma-core に api 依存 |
✓ |
koma-strict-ksp |
KSP processor(発見 → 検証 → 生成) | ✓ |
koma-strict-ksp:shared |
KSP 非依存の StoreSpec model / 命名 / codegen | ✓ |
koma-strict-diagram |
状態遷移図の IR(StoreDiagramModel 等)+ lowering / layout。KSP・Compose 非依存の pure KMP モジュール |
✓ |
koma-strict-diagram-compose |
koma-strict-diagram の IR を描画する StoreDiagramPanel 等の Compose Multiplatform UI |
✓ |
integrationTest |
実物 koma-core に KSP を適用した E2E 検証 | ✗ |
KSP 非依存の shared(model・命名・codegen)を核に、KSP は frontend に徹する層構成
(将来の Analysis API / compiler plugin 移行を見据えた設計。.claude/rules/ksp-architecture.md)。
@StoreSpec 一式は遷移グラフの全データを静的に持つので、図生成は KSP 解析の副産物として実現できる。
KSP オプション koma.strict.generateDiagramModel(既定 false)を有効にすると、store ごとに
<Root>.diagram.generated.kt が生成される。中身は:
<Root>DiagramModel: StoreDiagramModel ── @StoreSpec を静的に射影した IR(states / actions / initial /
到達可能性など)<Root>.diagramStateId(): StateId ── 生きた state 値を上の IR の StateId へ写す拡張関数(sealed 網羅・
else 無しなので新しい leaf を足すと生成が追いつくまでコンパイルが止まる)描画するにはこの 2 つを koma-strict-diagram-compose の StoreDiagramPanel composable に渡す。実例は
integrationTest/composeApp の tripletriad サンプル(GamePane のトグル可能な右パネル)。
[!NOTE] 必要な依存: このオプションを有効にすると、生成コードが
koma-strict-diagramの型を参照する。 図モジュールは Maven Central に publish 済みなので、リポジトリ外のプロジェクトでもそのまま使える。// 生成される <Root>DiagramModel / diagramStateId() を解決するのに必須 implementation("me.tbsten.koma.strict:koma-strict-diagram:<version>") // StoreDiagramPanel で実際に描画する場合に追加 (Compose Multiplatform) implementation("me.tbsten.koma.strict:koma-strict-diagram-compose:<version>")
koma-strict-diagramは android / jvm / js / wasmJs / iosArm64 / iosSimulatorArm64 を publish するが、koma-strict-diagram-composeは Compose UI が legacyjsを持たないため js のみ非対応。
Mermaid / PlantUML の「図 + 遷移表」ペアを docs として出力し、CI の drift check で宣言との乖離を防ぐ構想は
未着手。設計は doc/internal/generate-state-diagrams.md。