create-cmp-cli 0.7.1 → 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 (79) hide show
  1. package/README.md +72 -11
  2. package/llms.txt +6 -2
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +8 -1
  5. package/src/lib/adr-seed.mjs +178 -0
  6. package/src/lib/registry.mjs +15 -2
  7. package/src/lib/tabs.mjs +91 -4
  8. package/src/lib/upgrade.mjs +49 -5
  9. package/src/scaffold.mjs +52 -1
  10. package/src/versions/candidates.json +4 -0
  11. package/src/versions/registry.json +88 -0
  12. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  13. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  14. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  15. package/template/.githooks/pre-push +24 -0
  16. package/template/CLAUDE.md +196 -48
  17. package/template/README.md +23 -27
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  20. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  21. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  22. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  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 +269 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  55. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  56. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  57. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  58. package/template/docs/ARCHITECTURE.md +317 -34
  59. package/template/docs/TESTING.md +6 -5
  60. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  61. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  62. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  63. package/template/qa/approvals.json +42 -0
  64. package/template/qa/approve.mjs +139 -0
  65. package/template/qa/arch-doc.mjs +69 -0
  66. package/template/qa/comment.mjs +76 -0
  67. package/template/qa/comments.json +4 -0
  68. package/template/qa/golden/home.json +3 -3
  69. package/template/qa/lib/approvals.mjs +806 -0
  70. package/template/qa/lib/arch-doc.mjs +451 -0
  71. package/template/qa/lib/comments.mjs +252 -0
  72. package/template/qa/lib/component-stories.mjs +183 -0
  73. package/template/qa/lib/inputs-hash.mjs +5 -1
  74. package/template/qa/scaffold-feature.mjs +184 -67
  75. package/template/qa/setup-hooks.mjs +33 -0
  76. package/template/qa/verify.mjs +118 -9
  77. package/template/specs/app-base.spec.md +44 -7
  78. package/template/specs/home.spec.md +7 -4
  79. package/template/specs/intent.md +50 -0
@@ -0,0 +1,269 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.layout.Arrangement
5
+ import androidx.compose.foundation.layout.Box
6
+ import androidx.compose.foundation.layout.BoxScope
7
+ import androidx.compose.foundation.layout.Column
8
+ import androidx.compose.foundation.layout.ColumnScope
9
+ import androidx.compose.foundation.layout.fillMaxSize
10
+ import androidx.compose.foundation.layout.height
11
+ import androidx.compose.foundation.layout.padding
12
+ import androidx.compose.material.icons.Icons
13
+ import androidx.compose.material.icons.filled.Home
14
+ import androidx.compose.material.icons.filled.Person
15
+ import androidx.compose.material3.Icon
16
+ import androidx.compose.material3.Text
17
+ import androidx.compose.runtime.Composable
18
+ import androidx.compose.ui.Alignment
19
+ import androidx.compose.ui.Modifier
20
+ import androidx.compose.ui.semantics.semantics
21
+ import androidx.compose.ui.semantics.testTag
22
+ import androidx.compose.ui.unit.dp
23
+ import __PACKAGE__.presentation.components.AppBottomBar
24
+ import __PACKAGE__.presentation.components.AppHeader
25
+ import __PACKAGE__.presentation.components.AppPrimaryButton
26
+ import __PACKAGE__.presentation.components.AppTextButton
27
+ import __PACKAGE__.presentation.components.BaseScreen
28
+ import __PACKAGE__.presentation.components.ContentStateContainer
29
+ import __PACKAGE__.presentation.components.ContentStateDefaults
30
+ import __PACKAGE__.presentation.components.ContentUiState
31
+ import __PACKAGE__.presentation.components.EmptyState
32
+ import __PACKAGE__.presentation.components.ErrorState
33
+ import __PACKAGE__.presentation.components.ListItemCard
34
+ import __PACKAGE__.presentation.components.ListItemSkeleton
35
+ import __PACKAGE__.presentation.components.NavItem
36
+ import __PACKAGE__.presentation.components.ScreenColumn
37
+ import __PACKAGE__.presentation.navigation.AppTab
38
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
39
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
40
+
41
+ /**
42
+ * Component stories — one preview-registry entry per `@Composable` in
43
+ * `presentation/components` (the Storybook analog at component granularity).
44
+ * Each story renders the component in isolation on a plain tokened surface;
45
+ * a multi-variant component stacks its variants in ONE render. Ids follow
46
+ * `component.<kebab-case-of-composable-name>` (`AppHeader` →
47
+ * `component.app-header`), derivable mechanically from the name — the
48
+ * verify lane's `componentStories` step (qa/lib/component-stories.mjs)
49
+ * enforces exactly one story per component. The console excludes
50
+ * `component.*` entries from the Screens grid and shows each render at the
51
+ * top of that component's Components-page entry instead.
52
+ *
53
+ * These are preview-surface code (desktopMain), not production API: sample
54
+ * args only, tokens for every design value, testTags on every interactive
55
+ * node — a story meets the same bar the screens do.
56
+ */
57
+ fun componentStories(): List<ScreenPreview> = listOf(
58
+ // Structure: the containers a screen roots itself in.
59
+ story("component.screen-column", "ScreenColumn") {
60
+ ScreenColumn(screenTag = "story") {
61
+ Text("ScreenColumn owns the tagged root and the PaddingPage inset.")
62
+ Text("Children stack vertically; scrollable = true adds scrolling.")
63
+ }
64
+ },
65
+ story("component.base-screen", "BaseScreen") {
66
+ BaseScreen { _ ->
67
+ Text(
68
+ "BaseScreen owns the status/navigation-bar insets; body content is safe with zero ceremony.",
69
+ modifier = Modifier.padding(__THEME_PREFIX__Tokens.PaddingPage),
70
+ )
71
+ }
72
+ },
73
+ // Header and navigation.
74
+ variantsStory("component.app-header", "AppHeader") {
75
+ AppHeader(title = "Screen title", screenTag = "story")
76
+ AppHeader(
77
+ title = "With back and action",
78
+ screenTag = "story_nav",
79
+ onBack = {},
80
+ actions = {
81
+ AppTextButton(
82
+ text = "Action",
83
+ onClick = {},
84
+ modifier = Modifier.semantics { testTag = "story_header_action" },
85
+ )
86
+ },
87
+ )
88
+ },
89
+ story("component.app-bottom-bar", "AppBottomBar") {
90
+ AppBottomBar(
91
+ tabs = listOf(
92
+ AppTab("Home", Icons.Filled.Home) {},
93
+ AppTab("Profile", Icons.Filled.Person) {},
94
+ ),
95
+ selectedIndex = 0,
96
+ onSelect = {},
97
+ modifier = Modifier.align(Alignment.BottomCenter),
98
+ )
99
+ },
100
+ variantsStory("component.nav-item", "NavItem") {
101
+ NavItem(label = "Selected", selected = true, onClick = {}) {
102
+ Icon(
103
+ Icons.Filled.Home,
104
+ contentDescription = "Selected",
105
+ tint = __THEME_PREFIX__Colors.Primary,
106
+ )
107
+ }
108
+ NavItem(label = "Unselected", selected = false, onClick = {}) {
109
+ Icon(
110
+ Icons.Filled.Person,
111
+ contentDescription = "Unselected",
112
+ tint = __THEME_PREFIX__Colors.OnSurfaceVariant,
113
+ )
114
+ }
115
+ },
116
+ // Buttons.
117
+ variantsStory("component.app-primary-button", "AppPrimaryButton") {
118
+ AppPrimaryButton(
119
+ text = "Primary",
120
+ onClick = {},
121
+ modifier = Modifier.semantics { testTag = "story_primary" },
122
+ )
123
+ AppPrimaryButton(
124
+ text = "Primary — disabled",
125
+ onClick = {},
126
+ enabled = false,
127
+ modifier = Modifier.semantics { testTag = "story_primary_disabled" },
128
+ )
129
+ },
130
+ variantsStory("component.app-text-button", "AppTextButton") {
131
+ AppTextButton(
132
+ text = "Text button",
133
+ onClick = {},
134
+ modifier = Modifier.semantics { testTag = "story_text" },
135
+ )
136
+ AppTextButton(
137
+ text = "Text button — disabled",
138
+ onClick = {},
139
+ enabled = false,
140
+ modifier = Modifier.semantics { testTag = "story_text_disabled" },
141
+ )
142
+ },
143
+ // The four-state contract: all four arms of the container, stacked.
144
+ variantsStory("component.content-state-container", "ContentStateContainer") {
145
+ ContentStateContainer<List<String>>(
146
+ state = ContentUiState.Loading,
147
+ screenTag = "story_loading",
148
+ modifier = Modifier.height(180.dp),
149
+ ) { }
150
+ ContentStateContainer<List<String>>(
151
+ state = ContentUiState.Empty,
152
+ screenTag = "story_empty",
153
+ modifier = Modifier.height(180.dp),
154
+ ) { }
155
+ ContentStateContainer<List<String>>(
156
+ state = ContentUiState.Error("Something went wrong."),
157
+ screenTag = "story_error",
158
+ onRetry = {},
159
+ modifier = Modifier.height(180.dp),
160
+ ) { }
161
+ ContentStateContainer(
162
+ state = ContentUiState.Content(listOf("First item", "Second item")),
163
+ screenTag = "story_content",
164
+ modifier = Modifier.height(180.dp),
165
+ ) { data ->
166
+ Column(verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard)) {
167
+ data.forEachIndexed { i, title ->
168
+ ListItemCard(
169
+ title = title,
170
+ onClick = {},
171
+ modifier = Modifier.semantics { testTag = "story_state_item_$i" },
172
+ )
173
+ }
174
+ }
175
+ }
176
+ },
177
+ variantsStory("component.empty-state", "EmptyState") {
178
+ EmptyState(
179
+ screenTag = "story",
180
+ modifier = Modifier.height(220.dp),
181
+ body = "Items you add will show up here.",
182
+ action = {
183
+ AppTextButton(
184
+ text = "Add an item",
185
+ onClick = {},
186
+ modifier = Modifier.semantics { testTag = "story_empty_action" },
187
+ )
188
+ },
189
+ )
190
+ },
191
+ variantsStory("component.error-state", "ErrorState") {
192
+ ErrorState(
193
+ message = "Something went wrong.",
194
+ screenTag = "story",
195
+ onRetry = {},
196
+ modifier = Modifier.height(220.dp),
197
+ )
198
+ },
199
+ // List vocabulary.
200
+ variantsStory("component.list-item-card", "ListItemCard") {
201
+ ListItemCard(
202
+ title = "Title only",
203
+ onClick = {},
204
+ modifier = Modifier.semantics { testTag = "story_item_1" },
205
+ )
206
+ ListItemCard(
207
+ title = "With subtitle",
208
+ subtitle = "Secondary line",
209
+ onClick = {},
210
+ modifier = Modifier.semantics { testTag = "story_item_2" },
211
+ )
212
+ ListItemCard(
213
+ title = "With a leading slot",
214
+ subtitle = "Leading content precedes the text column",
215
+ onClick = {},
216
+ modifier = Modifier.semantics { testTag = "story_item_3" },
217
+ leading = { Icon(Icons.Filled.Person, contentDescription = null) },
218
+ )
219
+ },
220
+ story("component.list-skeleton", "ContentStateDefaults.ListSkeleton") {
221
+ Box(Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage)) {
222
+ ContentStateDefaults.ListSkeleton(screenTag = "story")
223
+ }
224
+ },
225
+ variantsStory("component.list-item-skeleton", "ListItemSkeleton") {
226
+ ListItemSkeleton()
227
+ },
228
+ story("component.spinner", "ContentStateDefaults.Spinner") {
229
+ ContentStateDefaults.Spinner(screenTag = "story")
230
+ },
231
+ )
232
+
233
+ /**
234
+ * `component.<kebab-name>` entry hosting [content] on the plain story surface.
235
+ * The id is passed as a full literal (never concatenated) so the lane's parity
236
+ * gate (qa/lib/component-stories.mjs) and a plain grep both find it.
237
+ */
238
+ private fun story(
239
+ id: String,
240
+ title: String,
241
+ content: @Composable BoxScope.() -> Unit,
242
+ ): ScreenPreview = ScreenPreview(id, "$title — component story") {
243
+ StoryHost(content)
244
+ }
245
+
246
+ /** Stacked-variants flavor: the story surface with a padded, token-gapped column. */
247
+ private fun variantsStory(
248
+ id: String,
249
+ title: String,
250
+ content: @Composable ColumnScope.() -> Unit,
251
+ ): ScreenPreview = story(id, title) {
252
+ Column(
253
+ modifier = Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage),
254
+ verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard),
255
+ content = content,
256
+ )
257
+ }
258
+
259
+ /**
260
+ * The plain tokened surface every story renders on: theme background, nothing
261
+ * else — the component is the only subject. Internal (not private) so the
262
+ * generated registry can host the PlaceholderScreen story on custom-tab
263
+ * scaffolds (PlaceholderScreen ships only when a configured tab has no
264
+ * feature yet, so its story rides PreviewRegistry.kt, not this file).
265
+ */
266
+ @Composable
267
+ internal fun StoryHost(content: @Composable BoxScope.() -> Unit) {
268
+ Box(Modifier.fillMaxSize().background(__THEME_PREFIX__Colors.Background)) { content() }
269
+ }
@@ -4,12 +4,19 @@ import androidx.compose.foundation.layout.Box
4
4
  import androidx.compose.foundation.layout.fillMaxSize
5
5
  import androidx.compose.runtime.Composable
6
6
  import androidx.compose.ui.Modifier
7
+ import __PACKAGE__.domain.model.DomainError
8
+ import __PACKAGE__.domain.model.Item
9
+ import __PACKAGE__.domain.repository.ItemRepository
10
+ import __PACKAGE__.domain.result.AppResult
11
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
7
12
  import __PACKAGE__.presentation.components.BaseScreen
8
13
  import __PACKAGE__.presentation.home.DetailScreen
9
14
  import __PACKAGE__.presentation.home.HomeScreen
15
+ import __PACKAGE__.presentation.home.HomeViewModel
10
16
  import __PACKAGE__.presentation.navigation.AppShell
11
17
  import __PACKAGE__.presentation.navigation.appTabs
12
18
  import __PACKAGE__.presentation.profile.ProfileScreen
19
+ import kotlinx.coroutines.awaitCancellation
13
20
 
14
21
  /**
15
22
  * One previewable screen: a stable [id] (the `-Pscreen=` selector and output directory
@@ -29,6 +36,11 @@ import __PACKAGE__.presentation.profile.ProfileScreen
29
36
  * preview-only fakes behind its usual parameters). Every entry renders the same way
30
37
  * (gallery card, `-Pscreen=` selector, golden baseline), so loading/empty/error states
31
38
  * sit side by side with the default seeded state.
39
+ *
40
+ * Component stories (`component.<kebab-name>` ids, ComponentStories.kt) are appended
41
+ * below — one isolated render per `presentation/components` composable. The console
42
+ * keeps them out of the Screens grid and shows each at the top of its Components-page
43
+ * entry; the verify lane's `componentStories` step enforces one story per component.
32
44
  */
33
45
  data class ScreenPreview(
34
46
  val id: String,
@@ -49,8 +61,21 @@ fun previewRegistry(): List<ScreenPreview> = listOf(
49
61
  ScreenPreview("home", "Home tab") { TabHost { HomeScreen(onItemClick = {}) } },
50
62
  ScreenPreview("profile", "Profile tab") { TabHost { ProfileScreen() } },
51
63
  ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
64
+ // State variants (§6.5, component-system-deep-dive.md): the same ContentUiState arms
65
+ // ContentStateContainer dispatches on, forced via a preview-only repository — the
66
+ // console's genesis workbench and the golden baselines get loading/empty/error as
67
+ // first-class screens beside the default seeded "home" entry.
68
+ ScreenPreview("home@loading", "Home — loading") {
69
+ TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { awaitCancellation() }) }
70
+ },
71
+ ScreenPreview("home@empty", "Home — empty") {
72
+ TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { AppResult.Success(emptyList()) }) }
73
+ },
74
+ ScreenPreview("home@error", "Home — error") {
75
+ TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { AppResult.Failure(DomainError.Network) }) }
76
+ },
52
77
  // cmp:anchor preview-registry
53
- )
78
+ ) + componentStories()
54
79
 
55
80
  /**
56
81
  * Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
@@ -62,3 +87,14 @@ private fun TabHost(content: @Composable () -> Unit) {
62
87
  Box(Modifier.fillMaxSize()) { content() }
63
88
  }
64
89
  }
90
+
91
+ /**
92
+ * Forces one `ContentUiState` arm on a real [HomeViewModel] for the state-variant previews
93
+ * above. `desktopMain` cannot depend on `commonTest`'s `FakeItemRepository` (test sources
94
+ * never leak into main), so this is a minimal, self-contained equivalent — the real
95
+ * ViewModel and screen render unmodified, only the repository result is forced.
96
+ */
97
+ private fun previewHomeViewModel(result: suspend () -> AppResult<List<Item>>): HomeViewModel =
98
+ HomeViewModel(GetItemsUseCase(object : ItemRepository {
99
+ override suspend fun getItems(): AppResult<List<Item>> = result()
100
+ }))
@@ -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.",
132
158
  offenders,
133
- "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the file's key nodes.",
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.",
178
+ offenders,
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,147 @@ 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
+
155
343
  // SPEC: SHELL-05
156
344
  @Test
157
345
  fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
@@ -159,7 +347,11 @@ class ArchitectureConformanceTest {
159
347
  // insets at all (bare Column at the nav layer) passes that rule while rendering
160
348
  // under the status bar. Tab screens are exempt — AppShell wraps them — so the rule
161
349
  // targets exactly the destinations registered directly on the NavHost.
162
- val navHost = sources(commonMain).firstOrNull { it.name == "AppNavHost.kt" } ?: return
350
+ // Filename first; a renamed nav host is still found by content (the `composable(`
351
+ // registrations this rule parses) so the gate cannot go silently vacuous.
352
+ val navHost = sources(commonMain).firstOrNull { it.name == "AppNavHost.kt" }
353
+ ?: sources(commonMain).firstOrNull { under(it, "navigation") && it.readText().contains("composable(") }
354
+ ?: return
163
355
  val text = navHost.readText()
164
356
  val screenCall = Regex("""([A-Z][A-Za-z0-9]*Screen)\s*\(""")
165
357
  // A call with only a trailing lambda has no paren — `BaseScreen { … }` — so match both.
@@ -201,7 +393,7 @@ class ArchitectureConformanceTest {
201
393
  val offenders = sources(commonMain)
202
394
  .filterNot { under(it, "components") }
203
395
  .filterNot { under(it, "navigation") }
204
- .filter { insetApi.containsMatchIn(it.readText()) }
396
+ .filter { file -> nonCommentLines(file).any { insetApi.containsMatchIn(it) } }
205
397
  .map { it.path }
206
398
  if (offenders.isNotEmpty()) fail(
207
399
  violation(