create-cmp-cli 0.8.0 → 0.10.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 (85) hide show
  1. package/README.md +83 -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 +97 -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 +213 -47
  12. package/template/README.md +32 -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/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. package/template/specs/intent.md +50 -0
@@ -19,7 +19,8 @@ import kotlin.math.roundToInt
19
19
  * inspector contract (schemaVersion 1, source "headless-jvm"). Every node carries pixel,
20
20
  * root-relative `bounds` and a (possibly empty) `children` array; testTag / text /
21
21
  * contentDescription / designToken are nullable; `role` / `clickable` / `disabled` are the
22
- * additive interaction fields.
22
+ * additive interaction fields, and `size` is the additive full-composed-size field (see
23
+ * [sizeJson]).
23
24
  *
24
25
  * This dumper reads THIS project's [DesignTokenKey], so the resolved design tokens the
25
26
  * component kit self-reports (Modifier.designToken) appear in the dump — which is what
@@ -54,10 +55,22 @@ object PreviewSemanticsJson {
54
55
  put("clickable", JsonPrimitive(node.config.contains(SemanticsActions.OnClick)))
55
56
  put("disabled", JsonPrimitive(node.config.contains(SemanticsProperties.Disabled)))
56
57
  put("bounds", node.boundsJson())
58
+ put("size", node.sizeJson())
57
59
  put("designToken", node.designTokenJson())
58
60
  put("children", buildJsonArray { node.children.forEach { add(nodeToJson(it)) } })
59
61
  }
60
62
 
63
+ /**
64
+ * Full composed (UNCLIPPED) size. `bounds` is the visible slice after ancestor clipping —
65
+ * a scroll container's fold truncates it — while `size` is what the node actually
66
+ * measures. Additive contract field (consumers treat it as optional); the a11y audit
67
+ * judges touch targets on it so a fold-clipped list row is never a false violation.
68
+ */
69
+ private fun SemanticsNode.sizeJson(): JsonObject = buildJsonObject {
70
+ put("width", JsonPrimitive(size.width))
71
+ put("height", JsonPrimitive(size.height))
72
+ }
73
+
61
74
  private fun SemanticsNode.boundsJson(): JsonObject {
62
75
  val rect = boundsInRoot
63
76
  return buildJsonObject {
@@ -28,16 +28,23 @@ class ArchitectureConformanceTest {
28
28
  file.readLines().filter { it.trimStart().startsWith("import ") }.map { it.trim() }
29
29
 
30
30
  /**
31
- * Source lines with comment lines stripped. Layer-boundary rules scan these for BOTH
32
- * `import x.y.` statements AND fully-qualified inline references (`x.y.Type(...)`)
33
- * import-only matching leaves a one-edit evasion open: delete the import, qualify the
34
- * name inline, and the gate goes green while the violation remains.
31
+ * Source lines with comments stripped — whole comment lines AND trailing `// …` tails
32
+ * (a `(?<!:)` guard keeps `https://` URLs inside strings intact). Layer-boundary rules
33
+ * scan these for BOTH `import x.y.` statements AND fully-qualified inline references
34
+ * (`x.y.Type(...)`) import-only matching leaves a one-edit evasion open: delete the
35
+ * import, qualify the name inline, and the gate goes green while the violation remains.
36
+ * Stripping trailing comments closes the inverse hole: prose like
37
+ * `get() // wires com.app.data.ItemRepositoryImpl` must not fail a boundary gate.
35
38
  */
39
+ private val trailingLineComment = Regex("""(?<!:)//.*""")
40
+
36
41
  private fun nonCommentLines(file: File): List<String> =
37
- file.readLines().filterNot {
38
- val t = it.trimStart()
39
- t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
40
- }
42
+ file.readLines()
43
+ .filterNot {
44
+ val t = it.trimStart()
45
+ t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
46
+ }
47
+ .map { it.replace(trailingLineComment, "") }
41
48
 
42
49
  private fun bannedReference(file: File, banned: List<String>): Boolean =
43
50
  nonCommentLines(file).any { line -> banned.any { line.contains(it) } }
@@ -113,8 +120,26 @@ class ArchitectureConformanceTest {
113
120
  }
114
121
 
115
122
  // SPEC: ARCH-04
123
+ // Component-derived tags (component-system-deep-dive.md §6.4) count as tag provenance:
124
+ // a screen built entirely from registry components (ScreenColumn/AppHeader/
125
+ // ContentStateContainer/…) is automation-reachable through the tags THOSE components
126
+ // emit from their required `screenTag` parameter, even with no literal `testTag` of its
127
+ // own. The acceptance is narrow by design (mitigates the risk table's sloppy-scan
128
+ // concern): `screenTag\s*=` in a call-argument position AND the file actually imports
129
+ // from `presentation.components` — a file that merely contains the substring
130
+ // "screenTag" in a comment, or that never touches the registry, still fails.
131
+ private val screenTagArgument = Regex("""screenTag\s*=""")
132
+
133
+ private fun hasTagProvenance(file: File): Boolean {
134
+ val text = file.readText()
135
+ val importsComponents = text.lines().any {
136
+ it.trimStart().startsWith("import ") && it.contains(".presentation.components.")
137
+ }
138
+ return text.contains("testTag") || (importsComponents && screenTagArgument.containsMatchIn(text))
139
+ }
140
+
116
141
  @Test
117
- fun `ARCH-04 every feature composable file declares a testTag`() {
142
+ fun `ARCH-04 every feature composable file is automation-reachable - literal testTag or screenTag provenance`() {
118
143
  // Scoped by CONTENT (contains @Composable), not by *Screen.kt filename: real apps
119
144
  // split features into Screen.kt (often ViewModel-only) and Content.kt (the UI).
120
145
  // Filename scoping produced both false negatives (untagged FooContent.kt slid
@@ -123,14 +148,36 @@ class ArchitectureConformanceTest {
123
148
  .filter { inPresentationFeatureDir(it) }
124
149
  .filterNot { under(it, "components") || under(it, "navigation") || under(it, "theme") }
125
150
  .filter { it.readText().contains("@Composable") }
126
- .filterNot { it.readText().contains("testTag") }
151
+ .filterNot { hasTagProvenance(it) }
127
152
  .map { it.path }
128
153
  if (offenders.isNotEmpty()) fail(
129
154
  violation(
130
155
  "ARCH-04", "every feature UI file is automation-reachable: files containing a " +
131
- "@Composable declare at least one testTag.",
156
+ "@Composable declare at least one literal testTag OR pass screenTag = to a " +
157
+ "registry component imported from presentation.components.",
158
+ offenders,
159
+ "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the file's key nodes, " +
160
+ "or compose it from registry components (e.g. ScreenColumn(screenTag = \"<feature>\") { … }).",
161
+ )
162
+ )
163
+ }
164
+
165
+ // SPEC: ARCH-11
166
+ @Test
167
+ fun `ARCH-11 screens present loading through the components registry, never a hand-rolled indicator`() {
168
+ val banned = listOf("CircularProgressIndicator", "LinearProgressIndicator")
169
+ val offenders = sources(commonMain)
170
+ .filter { inPresentationFeatureDir(it) }
171
+ .filterNot { under(it, "components") || under(it, "navigation") || under(it, "theme") }
172
+ .filter { bannedReference(it, banned) }
173
+ .map { it.path }
174
+ if (offenders.isNotEmpty()) fail(
175
+ violation(
176
+ "ARCH-11", "screens never reference CircularProgressIndicator/LinearProgressIndicator " +
177
+ "directly — loading is presented through ContentStateContainer/ContentStateDefaults.",
132
178
  offenders,
133
- "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the file's key nodes.",
179
+ "bind the screen's loading arm to ContentStateContainer(state = …, screenTag = …) instead " +
180
+ "of drawing a progress indicator by hand (see the exemplar HomeScreen).",
134
181
  )
135
182
  )
136
183
  }
@@ -141,7 +188,7 @@ class ArchitectureConformanceTest {
141
188
  val colorLiteral = Regex("""Color\(0x""")
142
189
  val offenders = sources(commonMain)
143
190
  .filterNot { under(it, "theme") }
144
- .filter { colorLiteral.containsMatchIn(it.readText()) }
191
+ .filter { file -> nonCommentLines(file).any { colorLiteral.containsMatchIn(it) } }
145
192
  .map { it.path }
146
193
  if (offenders.isNotEmpty()) fail(
147
194
  violation(
@@ -152,6 +199,185 @@ class ArchitectureConformanceTest {
152
199
  )
153
200
  }
154
201
 
202
+ /**
203
+ * Each `suspend fun` declaration in [file] as one whitespace-normalized signature —
204
+ * from the keyword through its balanced parameter list to the end of that line, so a
205
+ * multiline (trailing-comma style) signature is judged by its return type, not by
206
+ * whichever fragment happens to share a line with `suspend fun`.
207
+ */
208
+ private fun suspendFunSignatures(file: File): List<String> {
209
+ val text = nonCommentLines(file).joinToString("\n")
210
+ val signatures = mutableListOf<String>()
211
+ var start = text.indexOf("suspend fun")
212
+ while (start >= 0) {
213
+ val open = text.indexOf('(', start)
214
+ if (open == -1) break
215
+ var depth = 0
216
+ var i = open
217
+ while (i < text.length) {
218
+ val c = text[i]
219
+ if (c == '(') depth += 1
220
+ if (c == ')') {
221
+ depth -= 1
222
+ if (depth == 0) break
223
+ }
224
+ i += 1
225
+ }
226
+ val end = text.indexOf('\n', i).let { if (it == -1) text.length else it }
227
+ signatures.add(text.substring(start, end).replace(Regex("\\s+"), " ").trim())
228
+ start = text.indexOf("suspend fun", end)
229
+ }
230
+ return signatures
231
+ }
232
+
233
+ // SPEC: ARCH-06
234
+ @Test
235
+ fun `ARCH-06 repository interfaces return AppResult - exceptions never cross the boundary`() {
236
+ val offenders = sources(commonMain)
237
+ .filter { under(it, "domain") && under(it, "repository") }
238
+ .flatMap { file ->
239
+ suspendFunSignatures(file)
240
+ .filterNot { it.contains(": AppResult<") }
241
+ .map { "${file.path} — $it" }
242
+ }
243
+ if (offenders.isNotEmpty()) fail(
244
+ violation(
245
+ "ARCH-06", "every one-shot repository operation (suspend fun) declares an AppResult<…> " +
246
+ "return type — failures cross the data boundary as typed DomainError values, never as exceptions.",
247
+ offenders,
248
+ "return AppResult<T> and translate exceptions inside the data implementation via suspendRunCatching.",
249
+ )
250
+ )
251
+ }
252
+
253
+ // SPEC: ARCH-07
254
+ @Test
255
+ fun `ARCH-07 ViewModels contain no exception handling`() {
256
+ // Syntactic forms only (`try {`, `catch (`, `runCatching`) — a bare-word scan would
257
+ // false-positive on user-facing copy like "…try again." inside the ViewModel's strings.
258
+ val exceptionHandling = Regex("""\btry\s*\{|\bcatch\s*[({]|\brunCatching\b""")
259
+ val offenders = sources(commonMain)
260
+ .filter { under(it, "presentation") && it.name.endsWith("ViewModel.kt") }
261
+ .filter { file -> nonCommentLines(file).any { exceptionHandling.containsMatchIn(it) } }
262
+ .map { it.path }
263
+ if (offenders.isNotEmpty()) fail(
264
+ violation(
265
+ "ARCH-07", "ViewModels contain no try/catch/runCatching — they fold over AppResult " +
266
+ "and map DomainError kinds to user copy; exception translation is the data layer's job.",
267
+ offenders,
268
+ "remove the exception handling; `when` over the use case's AppResult instead (see the exemplar ViewModel).",
269
+ )
270
+ )
271
+ }
272
+
273
+ // SPEC: ARCH-08
274
+ @Test
275
+ fun `ARCH-08 the data layer's only catch mechanism is suspendRunCatching with its cancellation guard`() {
276
+ val helperName = "AppResultCatching.kt"
277
+ val catching = Regex("""\bcatch\s*\(|\brunCatching\b""")
278
+ val dataFiles = sources(commonMain).filter { under(it, "data") }
279
+
280
+ val offenders = dataFiles
281
+ .filterNot { it.name == helperName }
282
+ .filter { file -> nonCommentLines(file).any { catching.containsMatchIn(it) } }
283
+ .map { it.path }
284
+ .toMutableList()
285
+
286
+ // The helper itself must keep the guard that makes the convention safe:
287
+ // CancellationException is rethrown, never mapped to a Failure.
288
+ val helper = dataFiles.firstOrNull { it.name == helperName }
289
+ if (helper != null) {
290
+ val text = helper.readText()
291
+ if (!text.contains("catch (e: CancellationException)") || !text.contains("throw e")) {
292
+ offenders.add("${helper.path} (the CancellationException rethrow guard is missing)")
293
+ }
294
+ }
295
+
296
+ if (offenders.isNotEmpty()) fail(
297
+ violation(
298
+ "ARCH-08", "the ONLY exception-catching mechanism in the data layer is the shared " +
299
+ "suspendRunCatching helper ($helperName), which always rethrows CancellationException — " +
300
+ "ad-hoc catch blocks can silently swallow cancellation.",
301
+ offenders,
302
+ "wrap the I/O in suspendRunCatching { … } (with a mapError classifier) instead of catching directly.",
303
+ )
304
+ )
305
+ }
306
+
307
+ // SPEC: ARCH-09
308
+ @Test
309
+ fun `ARCH-09 data never references presentation or di`() {
310
+ val offenders = sources(commonMain)
311
+ .filter { under(it, "data") }
312
+ .filter { bannedReference(it, listOf("__PACKAGE__.presentation.", "__PACKAGE__.di.")) }
313
+ .map { it.path }
314
+ if (offenders.isNotEmpty()) fail(
315
+ violation(
316
+ "ARCH-09", "data serves domain contracts — it never references presentation or di " +
317
+ "(neither imports nor fully-qualified inline names).",
318
+ offenders,
319
+ "move the presentation/di-touching code out of data; data implements domain's repository " +
320
+ "interfaces and stops there.",
321
+ )
322
+ )
323
+ }
324
+
325
+ // SPEC: ARCH-10
326
+ @Test
327
+ fun `ARCH-10 core is leaf utility code - imports domain at most`() {
328
+ val banned = listOf("__PACKAGE__.presentation.", "__PACKAGE__.data.", "__PACKAGE__.di.")
329
+ val offenders = sources(commonMain)
330
+ .filter { under(it, "core") }
331
+ .filter { bannedReference(it, banned) }
332
+ .map { it.path }
333
+ if (offenders.isNotEmpty()) fail(
334
+ violation(
335
+ "ARCH-10", "core is leaf utility code, importable by every other layer — it references " +
336
+ "nothing in presentation, data, or di (neither imports nor fully-qualified inline names).",
337
+ offenders,
338
+ "move the app-layer-touching code out of core; core stays a leaf (domain types at most).",
339
+ )
340
+ )
341
+ }
342
+
343
+ // SPEC: ARCH-12
344
+ @Test
345
+ fun `ARCH-12 sample fixtures never cross into production wiring`() {
346
+ // The UI-first pattern's hazard gate. A stateless screen may default its own state
347
+ // parameter to a `sample*` fixture — that same-file default IS the preview seam —
348
+ // and the preview registry / stories / tests (desktopMain, commonTest) render it
349
+ // freely. But the moment ANOTHER commonMain file references the fixture (a nav
350
+ // host resolving entities from sample data, a repository seeding from it), fake
351
+ // data is driving production behavior. Proven drift: the Fuelled dogfood run's
352
+ // AppNavHost read `sampleFoods.firstOrNull { it.id == foodId }` for a real route.
353
+ val sampleDecl = Regex("""\bval\s+(sample[A-Z][A-Za-z0-9]*)""")
354
+ val declarations = mutableMapOf<String, File>() // symbol -> declaring file
355
+ for (file in sources(commonMain)) {
356
+ for (m in sampleDecl.findAll(file.readText())) declarations[m.groupValues[1]] = file
357
+ }
358
+ if (declarations.isEmpty()) return
359
+ val offenders = mutableListOf<String>()
360
+ for (file in sources(commonMain)) {
361
+ val lines = nonCommentLines(file)
362
+ for ((symbol, declaring) in declarations) {
363
+ if (file == declaring) continue
364
+ if (lines.any { Regex("""\b$symbol\b""").containsMatchIn(it) }) {
365
+ offenders.add("${file.path} (references $symbol from ${declaring.name})")
366
+ }
367
+ }
368
+ }
369
+ if (offenders.isNotEmpty()) fail(
370
+ violation(
371
+ "ARCH-12", "a sample*/fixture symbol is never referenced outside its declaring file, " +
372
+ "the preview registry, or test sources — sample data is a preview seam, not " +
373
+ "production wiring.",
374
+ offenders.distinct(),
375
+ "wire the real path (repository → use case → ViewModel) and keep the sample only as " +
376
+ "the stateless screen's default parameter.",
377
+ )
378
+ )
379
+ }
380
+
155
381
  // SPEC: SHELL-05
156
382
  @Test
157
383
  fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
@@ -159,9 +385,17 @@ class ArchitectureConformanceTest {
159
385
  // insets at all (bare Column at the nav layer) passes that rule while rendering
160
386
  // under the status bar. Tab screens are exempt — AppShell wraps them — so the rule
161
387
  // targets exactly the destinations registered directly on the NavHost.
162
- val navHost = sources(commonMain).firstOrNull { it.name == "AppNavHost.kt" } ?: return
388
+ // Filename first; a renamed nav host is still found by content (the `composable(`
389
+ // registrations this rule parses) so the gate cannot go silently vacuous.
390
+ val navHost = sources(commonMain).firstOrNull { it.name == "AppNavHost.kt" }
391
+ ?: sources(commonMain).firstOrNull { under(it, "navigation") && it.readText().contains("composable(") }
392
+ ?: return
163
393
  val text = navHost.readText()
164
- val screenCall = Regex("""([A-Z][A-Za-z0-9]*Screen)\s*\(""")
394
+ // Both destination shapes: the plain `XScreen(...)` AND the UI-first seam's
395
+ // VM-backed `XRoute(...)` wrapper — keying on `Screen(` alone left every *Route
396
+ // destination uninspected, passing the gate vacuously (dogfood finding: the
397
+ // invariant held in Fuelled, but only by convention, not because this asserted it).
398
+ val screenCall = Regex("""([A-Z][A-Za-z0-9]*(?:Screen|Route))\s*\(""")
165
399
  // A call with only a trailing lambda has no paren — `BaseScreen { … }` — so match both.
166
400
  val baseScreenCall = Regex("""BaseScreen\s*[({]""")
167
401
  val allSources = sources(commonMain)
@@ -201,7 +435,7 @@ class ArchitectureConformanceTest {
201
435
  val offenders = sources(commonMain)
202
436
  .filterNot { under(it, "components") }
203
437
  .filterNot { under(it, "navigation") }
204
- .filter { insetApi.containsMatchIn(it.readText()) }
438
+ .filter { file -> nonCommentLines(file).any { insetApi.containsMatchIn(it) } }
205
439
  .map { it.path }
206
440
  if (offenders.isNotEmpty()) fail(
207
441
  violation(
@@ -0,0 +1,84 @@
1
+ package __PACKAGE__.conformance
2
+
3
+ import androidx.compose.material3.MaterialTheme
4
+ import androidx.compose.ui.test.ExperimentalTestApi
5
+ import androidx.compose.ui.test.assertHeightIsAtLeast
6
+ import androidx.compose.ui.test.assertWidthIsAtLeast
7
+ import androidx.compose.ui.test.hasTestTag
8
+ import androidx.compose.ui.test.hasText
9
+ import androidx.compose.ui.test.onNodeWithTag
10
+ import androidx.compose.ui.test.runComposeUiTest
11
+ import androidx.compose.ui.unit.dp
12
+ import __PACKAGE__.domain.model.DomainError
13
+ import __PACKAGE__.domain.model.Item
14
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
15
+ import __PACKAGE__.presentation.home.HomeScreen
16
+ import __PACKAGE__.presentation.home.HomeViewModel
17
+ import __PACKAGE__.testing.awaitNode
18
+ import __PACKAGE__.testing.fakes.FakeItemRepository
19
+ import kotlin.test.Test
20
+
21
+ /**
22
+ * The COMP clauses (`specs/app-base.spec.md`) as executable checks — the component
23
+ * vocabulary's runtime contract, proven against the exemplar screen the same way the
24
+ * ARCH/SHELL conformance gates prove the architecture. Component substructure/tag
25
+ * stability is additionally pinned by the golden-tree baseline (`qa/golden/`); this class
26
+ * proves the STATE/A11Y contract every registry consumer inherits for free.
27
+ */
28
+ @OptIn(ExperimentalTestApi::class)
29
+ class ComponentConformanceTest {
30
+
31
+ // SPEC: COMP-01
32
+ @Test
33
+ fun `a failed load is presented by ContentStateContainer with the screen-derived error tag`() = runComposeUiTest {
34
+ val repository = FakeItemRepository().apply { failure = DomainError.Network }
35
+ setContent {
36
+ MaterialTheme {
37
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
38
+ }
39
+ }
40
+ awaitNode(hasTestTag("home_error"))
41
+ }
42
+
43
+ // SPEC: COMP-01
44
+ @Test
45
+ fun `a zero-item load is presented by ContentStateContainer with the screen-derived empty tag`() = runComposeUiTest {
46
+ val repository = FakeItemRepository().apply { items = emptyList() }
47
+ setContent {
48
+ MaterialTheme {
49
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
50
+ }
51
+ }
52
+ awaitNode(hasTestTag("home_empty"))
53
+ }
54
+
55
+ // SPEC: COMP-02
56
+ @Test
57
+ fun `a recoverable error renders a retry control of at least 48dp`() = runComposeUiTest {
58
+ val repository = FakeItemRepository().apply { failure = DomainError.Network }
59
+ setContent {
60
+ MaterialTheme {
61
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
62
+ }
63
+ }
64
+ awaitNode(hasTestTag("home_retry"))
65
+ onNodeWithTag("home_retry")
66
+ .assertWidthIsAtLeast(48.dp)
67
+ .assertHeightIsAtLeast(48.dp)
68
+ }
69
+
70
+ // SPEC: COMP-03
71
+ @Test
72
+ fun `every ListItemCard row clears the 48dp minimum pointer target`() = runComposeUiTest {
73
+ val repository = FakeItemRepository().apply {
74
+ items = listOf(Item(id = "1", title = "Row", subtitle = "sub"))
75
+ }
76
+ setContent {
77
+ MaterialTheme {
78
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
79
+ }
80
+ }
81
+ awaitNode(hasText("Row"))
82
+ onNodeWithTag("home_item_1").assertHeightIsAtLeast(48.dp)
83
+ }
84
+ }
@@ -10,6 +10,7 @@ import androidx.compose.ui.test.onFirst
10
10
  import androidx.compose.ui.test.onNodeWithTag
11
11
  import androidx.compose.ui.test.performClick
12
12
  import androidx.compose.ui.test.runComposeUiTest
13
+ import __PACKAGE__.domain.model.DomainError
13
14
  import __PACKAGE__.domain.model.Item
14
15
  import __PACKAGE__.domain.usecase.GetItemsUseCase
15
16
  import __PACKAGE__.testing.awaitNode
@@ -47,16 +48,27 @@ class HomeScreenTest {
47
48
 
48
49
  // SPEC: HOME-03
49
50
  @Test
50
- fun `shows the error message when loading fails`() = runComposeUiTest {
51
- repository.shouldFail = true
52
- repository.failureMessage = "network down"
51
+ fun `shows presentation-mapped error copy when loading fails`() = runComposeUiTest {
52
+ repository.failure = DomainError.Network
53
53
 
54
54
  setContent {
55
55
  MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
56
56
  }
57
57
 
58
58
  awaitNode(hasTestTag("home_error"))
59
- onAllNodesWithText("network down").assertCountEquals(1)
59
+ onAllNodesWithText(DomainError.Network.toUserMessage()).assertCountEquals(1)
60
+ }
61
+
62
+ // SPEC: HOME-07
63
+ @Test
64
+ fun `shows the empty state when the repository returns no items`() = runComposeUiTest {
65
+ repository.items = emptyList()
66
+
67
+ setContent {
68
+ MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
69
+ }
70
+
71
+ awaitNode(hasTestTag("home_empty"))
60
72
  }
61
73
 
62
74
  // SPEC: HOME-05
@@ -74,4 +86,24 @@ class HomeScreenTest {
74
86
  waitUntil(timeoutMillis = 5_000) { clickedId != null }
75
87
  kotlin.test.assertEquals("item-42", clickedId)
76
88
  }
89
+
90
+ // SPEC: HOME-04
91
+ @Test
92
+ fun `tapping retry after a failure reloads and shows recovered items`() = runComposeUiTest {
93
+ repository.failure = DomainError.Network
94
+ val vm = viewModel()
95
+
96
+ setContent {
97
+ MaterialTheme { HomeScreen(onItemClick = {}, viewModel = vm) }
98
+ }
99
+
100
+ awaitNode(hasTestTag("home_error"))
101
+ onNodeWithTag("home_retry", useUnmergedTree = true).assertExists()
102
+
103
+ repository.failure = null
104
+ repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
105
+ onNodeWithTag("home_retry").performClick()
106
+
107
+ awaitNode(hasText("Recovered"))
108
+ }
77
109
  }