create-cmp-cli 0.8.0 → 0.9.0

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 (74) hide show
  1. package/README.md +67 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +91 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +196 -48
  12. package/template/README.md +23 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  18. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  19. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  43. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  44. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  45. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  46. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  47. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  48. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  49. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  50. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  51. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  52. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  53. package/template/docs/ARCHITECTURE.md +317 -34
  54. package/template/docs/TESTING.md +6 -5
  55. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  56. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  57. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  58. package/template/qa/approvals.json +42 -0
  59. package/template/qa/approve.mjs +139 -0
  60. package/template/qa/arch-doc.mjs +69 -0
  61. package/template/qa/comment.mjs +76 -0
  62. package/template/qa/comments.json +4 -0
  63. package/template/qa/golden/home.json +3 -3
  64. package/template/qa/lib/approvals.mjs +806 -0
  65. package/template/qa/lib/arch-doc.mjs +451 -0
  66. package/template/qa/lib/comments.mjs +252 -0
  67. package/template/qa/lib/component-stories.mjs +183 -0
  68. package/template/qa/lib/inputs-hash.mjs +5 -1
  69. package/template/qa/scaffold-feature.mjs +184 -67
  70. package/template/qa/setup-hooks.mjs +33 -0
  71. package/template/qa/verify.mjs +118 -9
  72. package/template/specs/app-base.spec.md +44 -7
  73. package/template/specs/home.spec.md +7 -4
  74. package/template/specs/intent.md +50 -0
@@ -0,0 +1,99 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import java.io.File
6
+ import java.text.SimpleDateFormat
7
+ import java.util.Date
8
+ import java.util.Locale
9
+ import java.util.TimeZone
10
+ import kotlinx.serialization.json.Json
11
+ import kotlinx.serialization.json.JsonElement
12
+ import kotlinx.serialization.json.JsonNull
13
+ import kotlinx.serialization.json.JsonPrimitive
14
+ import kotlinx.serialization.json.buildJsonArray
15
+ import kotlinx.serialization.json.buildJsonObject
16
+
17
+ /**
18
+ * Debug-only crash capture: [install] sets a process-wide
19
+ * [Thread.UncaughtExceptionHandler] that persists crash JSON to
20
+ * `filesDir/inspector/crashes/` (bounded to the last [MAX_CRASHES]) so a crash survives the
21
+ * process death that follows it — an in-memory ring buffer would not.
22
+ *
23
+ * MUST NEVER SWALLOW THE CRASH: after persisting, it always hands off to whatever handler was
24
+ * installed before it (chained, not replaced) so system crash dialogs, `System.exit`, and any
25
+ * other crash-reporting tool still behave exactly as if this class did not exist.
26
+ */
27
+ object CrashRecorder {
28
+
29
+ private const val TAG = "CmpInspector"
30
+ private const val MAX_CRASHES = 20
31
+
32
+ fun install(context: Context) {
33
+ val crashDir = File(context.filesDir, "inspector/crashes").apply { mkdirs() }
34
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
35
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
36
+ try {
37
+ persist(crashDir, throwable)
38
+ } catch (t: Throwable) {
39
+ // Persisting the crash record must never itself crash the crash handler.
40
+ Log.w(TAG, "failed to persist crash record", t)
41
+ } finally {
42
+ if (previous != null) {
43
+ previous.uncaughtException(thread, throwable)
44
+ } else {
45
+ // No previous handler installed: fall back to the JVM's own default so the
46
+ // process still dies the normal way instead of hanging.
47
+ Runtime.getRuntime().exit(10)
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ private fun persist(crashDir: File, throwable: Throwable) {
54
+ val doc = buildJsonObject {
55
+ put("timestamp", JsonPrimitive(isoNow()))
56
+ put("exception", JsonPrimitive(throwable::class.qualifiedName ?: throwable.javaClass.name))
57
+ put("message", throwable.message?.let { JsonPrimitive(it) } ?: JsonNull)
58
+ put("frames", buildJsonArray {
59
+ throwable.stackTrace.forEach { el ->
60
+ add(buildJsonObject {
61
+ put("className", JsonPrimitive(el.className))
62
+ put("methodName", JsonPrimitive(el.methodName))
63
+ put("fileName", el.fileName?.let { JsonPrimitive(it) } ?: JsonNull)
64
+ put("lineNumber", JsonPrimitive(el.lineNumber))
65
+ })
66
+ }
67
+ })
68
+ }
69
+ File(crashDir, "crash-${System.currentTimeMillis()}.json")
70
+ .writeText(Json.encodeToString(JsonElement.serializer(), doc))
71
+ prune(crashDir)
72
+ }
73
+
74
+ /** Keep only the most recent [MAX_CRASHES] crash files (current boot + previous ones). */
75
+ private fun prune(crashDir: File) {
76
+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return
77
+ if (files.size <= MAX_CRASHES) return
78
+ files.sortedBy { it.lastModified() }
79
+ .take(files.size - MAX_CRASHES)
80
+ .forEach { it.delete() }
81
+ }
82
+
83
+ /**
84
+ * Every persisted crash's raw JSON text, newest first — spans the current boot AND any
85
+ * previous ones ([install] never clears the directory, only [prune] bounds it).
86
+ */
87
+ fun readAll(context: Context): List<String> {
88
+ val crashDir = File(context.filesDir, "inspector/crashes")
89
+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
90
+ return files.sortedByDescending { it.lastModified() }
91
+ .mapNotNull { runCatching { it.readText() }.getOrNull() }
92
+ }
93
+
94
+ private fun isoNow(): String {
95
+ val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
96
+ fmt.timeZone = TimeZone.getTimeZone("UTC")
97
+ return fmt.format(Date())
98
+ }
99
+ }
@@ -0,0 +1,144 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ // >>> cmp:feature room
4
+ import __PACKAGE__.data.local.AppDatabase
5
+ import androidx.room.useReaderConnection
6
+ import kotlinx.coroutines.runBlocking
7
+ import kotlinx.serialization.json.Json
8
+ import kotlinx.serialization.json.JsonElement
9
+ import kotlinx.serialization.json.JsonNull
10
+ import kotlinx.serialization.json.JsonPrimitive
11
+ import kotlinx.serialization.json.buildJsonArray
12
+ import kotlinx.serialization.json.buildJsonObject
13
+ import org.koin.core.context.GlobalContext
14
+ // <<< cmp:feature room
15
+
16
+ /**
17
+ * `GET /inspect/db` (schema: tables via `sqlite_master`) and `GET /inspect/db?table=<name>&limit=<n>`
18
+ * (rows). Read-only, off the main thread. 404 when this project's local-database feature is off
19
+ * (there is nothing to query) — see the `room`-gated implementation below for the real path.
20
+ */
21
+ object DbInspector {
22
+
23
+ // >>> cmp:feature room
24
+ // Reads go through the project's Room database (a Koin single). Injection-safe by
25
+ // construction: a requested `table` is only ever used in a query after it is proven to be
26
+ // a real name returned by `sqlite_master` in THIS call — the raw wire value never reaches
27
+ // SQL beyond that validated identifier.
28
+ //
29
+ // This project's Room config uses the KMP driver architecture (BundledSQLiteDriver, see
30
+ // data/local/DatabaseBuilder.kt), not the legacy Android-only SupportSQLiteDatabase — so
31
+ // reads go through Room 2.8's public `useReaderConnection { transactor -> ... }` whose
32
+ // receiver is a pooled connection exposing `usePrepared(sql) { stmt -> ... }` (the
33
+ // statement is created and closed by Room; binds are 1-based, column reads 0-based).
34
+ private const val DEFAULT_ROW_LIMIT = 50
35
+ private const val MAX_ROW_LIMIT = 500
36
+ private val VALID_IDENTIFIER = Regex("^[A-Za-z_][A-Za-z0-9_]*$")
37
+ private val prettyJson = Json { prettyPrint = true }
38
+
39
+ private fun appDatabaseOrNull(): AppDatabase? =
40
+ try {
41
+ // GlobalContext.getOrNull() IS the Koin instance (or null before startKoin).
42
+ GlobalContext.getOrNull()?.getOrNull<AppDatabase>()
43
+ } catch (t: Throwable) {
44
+ null
45
+ }
46
+
47
+ fun schema(): Pair<Int, String> {
48
+ val db = appDatabaseOrNull()
49
+ ?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
50
+ return try {
51
+ val tables = runBlocking {
52
+ db.useReaderConnection { connection ->
53
+ connection.usePrepared(
54
+ "SELECT name, sql FROM sqlite_master WHERE type = 'table' " +
55
+ "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'room_%' ORDER BY name"
56
+ ) { stmt ->
57
+ val out = mutableListOf<Pair<String, String?>>()
58
+ while (stmt.step()) {
59
+ out += stmt.getText(0) to (if (stmt.isNull(1)) null else stmt.getText(1))
60
+ }
61
+ out
62
+ }
63
+ }
64
+ }
65
+ 200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
66
+ put("tables", buildJsonArray {
67
+ tables.forEach { (name, sql) ->
68
+ add(buildJsonObject {
69
+ put("name", JsonPrimitive(name))
70
+ put("sql", sql?.let { JsonPrimitive(it) } ?: JsonNull)
71
+ })
72
+ }
73
+ })
74
+ })
75
+ } catch (t: Throwable) {
76
+ 500 to errorJson("failed to read schema: ${t.message}")
77
+ }
78
+ }
79
+
80
+ /** Row page for one table: columns + stringified values, capped. */
81
+ private class TableRows(val columns: List<String>, val rows: List<JsonElement>)
82
+
83
+ fun rows(table: String, limitParam: String?): Pair<Int, String> {
84
+ if (!VALID_IDENTIFIER.matches(table)) {
85
+ return 400 to errorJson("invalid table name '$table' — expected a plain SQL identifier.")
86
+ }
87
+ val db = appDatabaseOrNull()
88
+ ?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
89
+ val limit = (limitParam?.toIntOrNull() ?: DEFAULT_ROW_LIMIT).coerceIn(1, MAX_ROW_LIMIT)
90
+ return try {
91
+ val result: TableRows? = runBlocking {
92
+ db.useReaderConnection { connection ->
93
+ // STRICT validation: `table` is only used in the row query below once THIS
94
+ // check proves it is a real sqlite_master identifier — never the raw wire value.
95
+ val exists = connection.usePrepared(
96
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
97
+ ) { stmt ->
98
+ stmt.bindText(1, table)
99
+ stmt.step()
100
+ }
101
+ if (!exists) return@useReaderConnection null
102
+
103
+ connection.usePrepared("SELECT * FROM \"$table\" LIMIT ?") { stmt ->
104
+ stmt.bindLong(1, limit.toLong())
105
+ val colCount = stmt.getColumnCount()
106
+ val columns = (0 until colCount).map { stmt.getColumnName(it) }
107
+ val rows = mutableListOf<JsonElement>()
108
+ while (stmt.step()) {
109
+ rows += buildJsonObject {
110
+ for (i in 0 until colCount) {
111
+ put(columns[i], if (stmt.isNull(i)) JsonNull else JsonPrimitive(stmt.getText(i)))
112
+ }
113
+ }
114
+ }
115
+ TableRows(columns, rows)
116
+ }
117
+ }
118
+ }
119
+ if (result == null) {
120
+ return 404 to errorJson("unknown table '$table' — not present in sqlite_master.")
121
+ }
122
+ 200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
123
+ put("table", JsonPrimitive(table))
124
+ put("columns", buildJsonArray { result.columns.forEach { add(JsonPrimitive(it)) } })
125
+ put("rows", buildJsonArray { result.rows.forEach { add(it) } })
126
+ put("rowCount", JsonPrimitive(result.rows.size))
127
+ })
128
+ } catch (t: Throwable) {
129
+ 500 to errorJson("failed to read table '$table': ${t.message}")
130
+ }
131
+ }
132
+
133
+ private fun errorJson(message: String): String =
134
+ """{"error":${JsonPrimitive(message)}}"""
135
+ // <<< cmp:feature room
136
+ // >>> cmp:feature !room
137
+ private const val DISABLED_MESSAGE =
138
+ "the 'room' feature is disabled in this project — /inspect/db is unavailable."
139
+
140
+ fun schema(): Pair<Int, String> = 404 to """{"error":"$DISABLED_MESSAGE"}"""
141
+
142
+ fun rows(table: String, limitParam: String?): Pair<Int, String> = schema()
143
+ // <<< cmp:feature !room
144
+ }
@@ -1,5 +1,6 @@
1
1
  package __PACKAGE__.inspector
2
2
 
3
+ import android.content.Context
3
4
  import android.graphics.Bitmap
4
5
  import android.graphics.Canvas
5
6
  import android.os.Handler
@@ -14,6 +15,7 @@ import java.io.InputStreamReader
14
15
  import java.net.InetAddress
15
16
  import java.net.ServerSocket
16
17
  import java.net.Socket
18
+ import java.net.URLDecoder
17
19
  import java.nio.charset.StandardCharsets
18
20
  import java.util.concurrent.CountDownLatch
19
21
  import java.util.concurrent.TimeUnit
@@ -43,6 +45,15 @@ import kotlin.math.roundToInt
43
45
  * pair to the root view → {"tapped":true,"x":…,"y":…}.
44
46
  * GET /inspect/remote → the self-contained remote-control HTML page (same-origin,
45
47
  * zero CORS): live screenshot + click-to-tap in a browser.
48
+ * GET /inspect/nav → { currentRoute, backStack } — best-effort, reported by the
49
+ * common `NavInspectionHook` seam; empty snapshot before the
50
+ * first navigation event (see [NavInspector]).
51
+ * GET /inspect/crashes → { crashes: [...] } — persisted crash JSON (current boot +
52
+ * previous ones), newest first (see [CrashRecorder]).
53
+ * GET /inspect/db → schema: { tables:[{name,sql}] } via `sqlite_master`.
54
+ * GET /inspect/db?table=<n>&limit=<n> → rows for one table (read-only, bounded; see
55
+ * [DbInspector]). 404 (or empty schema) when the project's
56
+ * `room` feature is off.
46
57
  *
47
58
  * Single-threaded accept loop on a daemon thread = one client at a time = bounded by design.
48
59
  * Failure to bind logs a warning and gives up — the inspector must never crash or block
@@ -67,9 +78,14 @@ object InspectorHttpServer {
67
78
 
68
79
  @Volatile private var started = false
69
80
 
70
- fun start(appId: String) {
81
+ // Set once in [start]; read from the HTTP thread only (crashes/db routes). applicationContext
82
+ // is safe to hold — it never leaks an Activity.
83
+ @Volatile private var appContext: Context? = null
84
+
85
+ fun start(appId: String, context: Context) {
71
86
  if (started) return
72
87
  started = true
88
+ appContext = context.applicationContext
73
89
  val thread = Thread({ serve(appId) }, "cmp-inspector-http")
74
90
  thread.isDaemon = true
75
91
  thread.start()
@@ -114,7 +130,9 @@ object InspectorHttpServer {
114
130
  }
115
131
  val parts = requestLine.split(" ")
116
132
  val method = parts.getOrNull(0) ?: ""
117
- val path = (parts.getOrNull(1) ?: "").substringBefore('?')
133
+ val rawTarget = parts.getOrNull(1) ?: ""
134
+ val path = rawTarget.substringBefore('?')
135
+ val query = rawTarget.substringAfter('?', "")
118
136
 
119
137
  when {
120
138
  method == "GET" && path == "/inspect/health" ->
@@ -129,6 +147,12 @@ object InspectorHttpServer {
129
147
  writeResponse(client, 200, RemoteControlPage.html(appId).toByteArray(StandardCharsets.UTF_8), HTML_TYPE)
130
148
  method == "POST" && path == "/inspect/tap" ->
131
149
  tapResponse(readBody(reader, contentLength)).let { (s, b) -> writeJson(client, s, b) }
150
+ method == "GET" && path == "/inspect/nav" ->
151
+ writeJson(client, 200, navJson())
152
+ method == "GET" && path == "/inspect/crashes" ->
153
+ writeJson(client, 200, crashesJson())
154
+ method == "GET" && path == "/inspect/db" ->
155
+ dbResponse(query).let { (s, b) -> writeJson(client, s, b) }
132
156
  method != "GET" && method != "POST" ->
133
157
  writeJson(client, 405, errorJson("method not allowed"))
134
158
  else ->
@@ -183,6 +207,49 @@ object InspectorHttpServer {
183
207
  }
184
208
  }
185
209
 
210
+ /** { currentRoute, backStack } from [NavInspector] — best-effort, never blocks. */
211
+ private fun navJson(): String {
212
+ val snapshot = NavInspector.current()
213
+ val currentRouteJson = snapshot.currentRoute?.let { JsonPrimitive(it).toString() } ?: "null"
214
+ val backStackJson = snapshot.backStack.joinToString(",") { JsonPrimitive(it).toString() }
215
+ return """{"currentRoute":$currentRouteJson,"backStack":[$backStackJson]}"""
216
+ }
217
+
218
+ /** { crashes:[...] } — each element is a persisted crash JSON document, verbatim. */
219
+ private fun crashesJson(): String {
220
+ val ctx = appContext ?: return """{"crashes":[]}"""
221
+ val crashes = CrashRecorder.readAll(ctx)
222
+ return """{"crashes":[${crashes.joinToString(",")}]}"""
223
+ }
224
+
225
+ /** GET /inspect/db dispatch: no `table` → schema, else → rows for that table. */
226
+ private fun dbResponse(query: String): Pair<Int, String> {
227
+ val params = parseQuery(query)
228
+ val table = params["table"]
229
+ return if (table == null) DbInspector.schema() else DbInspector.rows(table, params["limit"])
230
+ }
231
+
232
+ /** Minimal `a=b&c=d` query-string parser (URL-decoded values). Last value wins on repeats. */
233
+ private fun parseQuery(query: String): Map<String, String> {
234
+ if (query.isEmpty()) return emptyMap()
235
+ val out = mutableMapOf<String, String>()
236
+ for (pair in query.split("&")) {
237
+ if (pair.isEmpty()) continue
238
+ val eq = pair.indexOf('=')
239
+ val key = if (eq >= 0) pair.substring(0, eq) else pair
240
+ val value = if (eq >= 0) pair.substring(eq + 1) else ""
241
+ out[urlDecode(key)] = urlDecode(value)
242
+ }
243
+ return out
244
+ }
245
+
246
+ private fun urlDecode(s: String): String =
247
+ try {
248
+ URLDecoder.decode(s, "UTF-8")
249
+ } catch (t: Throwable) {
250
+ s
251
+ }
252
+
186
253
  /**
187
254
  * PNG of the current Compose root. The Bitmap is rendered on the MAIN thread (views are
188
255
  * not thread-safe); PNG compression — tens of ms for a full screen — happens back on the
@@ -3,14 +3,18 @@ package __PACKAGE__.inspector
3
3
  import android.app.Application
4
4
 
5
5
  /**
6
- * DEBUG variant: install the Compose root registry (must happen BEFORE any Activity so the
7
- * `onViewCreatedCallback` hook catches every root), then start the loopback-only inspection
8
- * server on 127.0.0.1:9500. Reach it from the host via `adb forward tcp:9500 tcp:9500`.
6
+ * DEBUG variant: install the Compose root registry and the nav-inspection listener (both must
7
+ * happen BEFORE any Activity — the registry so `onViewCreatedCallback` catches every root, the
8
+ * nav listener so the first `AppNavHost` composition is observed), chain in the crash recorder,
9
+ * then start the loopback-only inspection server on 127.0.0.1:9500. Reach it from the host via
10
+ * `adb forward tcp:9500 tcp:9500`.
9
11
  *
10
12
  * The release source set carries a same-signature no-op twin — the compiler picks the variant
11
13
  * body, so release builds contain no inspector code at all (structural absence, not a flag).
12
14
  */
13
15
  fun Application.startInspector() {
14
16
  ComposeRootRegistry.install()
15
- InspectorHttpServer.start(appId = packageName)
17
+ NavInspector.install()
18
+ CrashRecorder.install(this)
19
+ InspectorHttpServer.start(appId = packageName, context = this)
16
20
  }
@@ -0,0 +1,31 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import __PACKAGE__.presentation.navigation.NavInspectionHook
4
+ import java.util.concurrent.atomic.AtomicReference
5
+
6
+ /**
7
+ * Debug-only sink for [NavInspectionHook]: [install] registers a listener that stores the
8
+ * latest route/back-stack snapshot the common `AppNavHost` reports, so `GET /inspect/nav` can
9
+ * read it synchronously from the HTTP thread without touching Compose state directly.
10
+ *
11
+ * Best-effort by design: until the first navigation event fires (cold start, before
12
+ * `AppNavHost` has composed at least once), [current] reports an empty snapshot rather than
13
+ * blocking or erroring — mirrors the tree/screenshot routes' "not ready yet, retry" posture,
14
+ * just without the 503 (an empty nav snapshot is a valid, if uninteresting, answer).
15
+ */
16
+ object NavInspector {
17
+
18
+ data class Snapshot(val currentRoute: String?, val backStack: List<String>)
19
+
20
+ private val EMPTY = Snapshot(currentRoute = null, backStack = emptyList())
21
+ private val state = AtomicReference(EMPTY)
22
+
23
+ /** Must run before the first Activity's setContent — same timing rule as [ComposeRootRegistry]. */
24
+ fun install() {
25
+ NavInspectionHook.listener = { current, backStack ->
26
+ state.set(Snapshot(current, backStack))
27
+ }
28
+ }
29
+
30
+ fun current(): Snapshot = state.get()
31
+ }
@@ -0,0 +1,32 @@
1
+ package __PACKAGE__.data
2
+
3
+ import __PACKAGE__.domain.model.DomainError
4
+ import __PACKAGE__.domain.result.AppResult
5
+ import kotlin.coroutines.cancellation.CancellationException
6
+
7
+ /**
8
+ * The data layer's ONLY exception-catching mechanism (`specs/app-base.spec.md` ARCH-08).
9
+ * Repository implementations wrap their I/O in this instead of writing `try`/`catch` —
10
+ * it is the single translation point where infrastructure exceptions become typed
11
+ * [DomainError] values, and it enforces the one non-negotiable rule of coroutine error
12
+ * handling:
13
+ *
14
+ * **`CancellationException` is ALWAYS rethrown, never mapped.** Swallowing it breaks
15
+ * structured concurrency — a cancelled screen would render an error state instead of
16
+ * simply stopping. The conformance gate scans for exactly this guard.
17
+ *
18
+ * [mapError] classifies everything else into your [DomainError] vocabulary; the default
19
+ * files anything unclassified under [DomainError.Unexpected] with the cause preserved
20
+ * for logging (never for display).
21
+ */
22
+ suspend fun <T> suspendRunCatching(
23
+ mapError: (Throwable) -> DomainError = { DomainError.Unexpected(it) },
24
+ block: suspend () -> T,
25
+ ): AppResult<T> =
26
+ try {
27
+ AppResult.Success(block())
28
+ } catch (e: CancellationException) {
29
+ throw e // never mapped: cancellation is not a failure state
30
+ } catch (e: Throwable) {
31
+ AppResult.Failure(mapError(e))
32
+ }
@@ -1,7 +1,9 @@
1
1
  package __PACKAGE__.data.remote
2
2
 
3
+ import __PACKAGE__.data.suspendRunCatching
3
4
  import __PACKAGE__.domain.model.Item
4
5
  import __PACKAGE__.domain.repository.ItemRepository
6
+ import __PACKAGE__.domain.result.AppResult
5
7
  import kotlinx.coroutines.delay
6
8
 
7
9
  // Example data source for the `home` feature. This is intentionally dependency-light
@@ -9,10 +11,15 @@ import kotlinx.coroutines.delay
9
11
  //
10
12
  // Real apps swap this for a Firestore/Ktor source and add a Room cache (see data/local).
11
13
  // The Clean Architecture seam is the ItemRepository interface in the domain layer.
14
+ //
15
+ // The repository is the ONLY exception-translation point: I/O runs inside
16
+ // suspendRunCatching (data/AppResultCatching.kt), which maps infrastructure exceptions
17
+ // to typed DomainError values and ALWAYS rethrows CancellationException. Pass a mapError
18
+ // lambda to classify your real source's exceptions (IOException -> Network, etc).
12
19
  class ItemRepositoryImpl : ItemRepository {
13
- override suspend fun getItems(): List<Item> {
20
+ override suspend fun getItems(): AppResult<List<Item>> = suspendRunCatching {
14
21
  delay(300) // simulate I/O
15
- return listOf(
22
+ listOf(
16
23
  Item("1", "Welcome to __APP_NAME__", "Your Compose Multiplatform app is wired end-to-end."),
17
24
  Item("2", "Clean Architecture", "presentation → domain → data, with Koin DI."),
18
25
  Item("3", "Edge-to-edge, pre-solved", "BaseScreen owns the window insets for you."),
@@ -0,0 +1,21 @@
1
+ package __PACKAGE__.domain.model
2
+
3
+ /**
4
+ * The typed failure vocabulary of the domain layer — every failure a repository can report
5
+ * is one of these KINDS. No user-facing message strings live here: mapping a kind to copy
6
+ * is the presentation layer's job (see the exemplar ViewModel's `toUserMessage()`), so the
7
+ * domain stays translatable and UI-copy changes never touch this layer.
8
+ *
9
+ * Extend with the kinds YOUR sources can actually produce (e.g. `Unauthorized`, `Conflict`)
10
+ * — the data layer's `suspendRunCatching` mapper is the single place they are assigned.
11
+ */
12
+ sealed interface DomainError {
13
+ /** The source was unreachable — connectivity, DNS, timeouts. */
14
+ data object Network : DomainError
15
+
16
+ /** The requested entity does not exist at the source. */
17
+ data object NotFound : DomainError
18
+
19
+ /** Anything not yet classified. Carries the cause for logging — never for display. */
20
+ data class Unexpected(val cause: Throwable? = null) : DomainError
21
+ }
@@ -1,8 +1,11 @@
1
1
  package __PACKAGE__.domain.repository
2
2
 
3
3
  import __PACKAGE__.domain.model.Item
4
+ import __PACKAGE__.domain.result.AppResult
4
5
 
5
6
  // Domain-facing contract. Presentation depends on THIS, never on a concrete data source.
7
+ // One-shot operations return AppResult — they never throw (ARCH-06): failures cross the
8
+ // boundary as typed DomainError values, translated inside the data implementation.
6
9
  interface ItemRepository {
7
- suspend fun getItems(): List<Item>
10
+ suspend fun getItems(): AppResult<List<Item>>
8
11
  }
@@ -0,0 +1,23 @@
1
+ package __PACKAGE__.domain.result
2
+
3
+ import __PACKAGE__.domain.model.DomainError
4
+
5
+ /**
6
+ * The typed result that crosses the data → domain → presentation boundary. One-shot
7
+ * repository operations return `AppResult<T>`, never throw (`specs/app-base.spec.md`
8
+ * ARCH-06) — so a ViewModel exhaustively `when`s over Success/Failure instead of
9
+ * catching exceptions (ARCH-07).
10
+ *
11
+ * Deliberately our own type rather than `kotlin.Result`: the stdlib Result carries an
12
+ * untyped Throwable, which would put raw exceptions right back on the boundary this
13
+ * type exists to keep them off. A [Failure] carries a typed [DomainError] kind instead
14
+ * (the same call Now in Android makes with its own Result).
15
+ *
16
+ * Cancellation is NOT a result: `CancellationException` propagates (structured
17
+ * concurrency), enforced at the single translation point — `suspendRunCatching` in
18
+ * `data/AppResultCatching.kt` (ARCH-08).
19
+ */
20
+ sealed interface AppResult<out T> {
21
+ data class Success<out T>(val value: T) : AppResult<T>
22
+ data class Failure(val error: DomainError) : AppResult<Nothing>
23
+ }
@@ -2,11 +2,14 @@ package __PACKAGE__.domain.usecase
2
2
 
3
3
  import __PACKAGE__.domain.model.Item
4
4
  import __PACKAGE__.domain.repository.ItemRepository
5
+ import __PACKAGE__.domain.result.AppResult
5
6
 
6
7
  // A use case is a single business action. ViewModels depend on use cases, not repositories
7
8
  // directly, so business rules stay testable and out of the presentation layer.
9
+ // The typed result passes through untouched — a use case may combine or transform results,
10
+ // but it never unwraps them into exceptions.
8
11
  class GetItemsUseCase(
9
12
  private val repository: ItemRepository,
10
13
  ) {
11
- suspend operator fun invoke(): List<Item> = repository.getItems()
14
+ suspend operator fun invoke(): AppResult<List<Item>> = repository.getItems()
12
15
  }