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
@@ -21,7 +21,7 @@
21
21
  // that spans both, applied together.
22
22
  //
23
23
  // feature (default) — all 11 files; DI repo+usecase+viewModel; nav
24
- // route+import; spec FEATURE-01..06.
24
+ // route+import; spec FEATURE-01..07.
25
25
  // repository <Entity> — ONLY the 5 data/domain files; DI repo+usecase ONLY;
26
26
  // no nav, no viewModel, no spec file, zero SPEC tags. The
27
27
  // positional arg IS the entity (no --entity, no feature name).
@@ -94,23 +94,35 @@ if (!IDENTIFIER_RE.test(entityName)) {
94
94
  // `repository` preset has no feature name (no nav/presentation/spec slice), so
95
95
  // F/f/F_UPPER are never read for it — the rename map still needs harmless
96
96
  // values to build (its feature-shaped entries never match repository-preset
97
- // file contents, which only reference Item/ItemRepository/GetItemsUseCase).
97
+ // file contents, which only reference the exemplar's own entity, e.g.
98
+ // Item/ItemRepository/GetItemsUseCase for the default `home` exemplar).
98
99
  const F = featureName ?? entityName; // PascalCase feature, e.g. Favorites
99
100
  const f = F[0].toLowerCase() + F.slice(1); // camelCase/package segment, e.g. favorites
100
101
  const F_UPPER = F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); // FAVORITES
101
102
  const E = entityName; // PascalCase entity, e.g. Favorite
102
103
 
103
104
  // ── Resolve the target project's real package ───────────────────────────────
104
- // This script runs POST-scaffold, so __PACKAGE__ is already resolved in the
105
- // target project. Parse it from composeApp/build.gradle.kts (namespace) or,
106
- // failing that, from any source file's `package` line.
105
+ // This script runs POST-scaffold, so the package placeholder token is already
106
+ // resolved in the target project. Parse it from composeApp/build.gradle.kts
107
+ // (namespace) or, failing that, from any source file's `package` line.
108
+ //
109
+ // Detect "still unresolved" by TOKEN SHAPE (`/^__[A-Z_]+__$/`), never by
110
+ // comparing against the literal placeholder string: this script is itself a
111
+ // template file that goes through the scaffold's own token-replacement pass,
112
+ // which does a blind text substitution of that literal placeholder -> the
113
+ // real package wherever it appears in file CONTENT — including a string
114
+ // literal sitting right here. A literal comparison would silently become
115
+ // `m[1] !== "<the real package>"` post-stamp (always true for the very
116
+ // project it should detect as unresolved) and never fire. A shape regex never
117
+ // spells the placeholder out, so the pipeline has nothing to match.
118
+ const UNRESOLVED_TOKEN_RE = /^__[A-Z_]+__$/;
107
119
 
108
120
  function resolvePackage() {
109
121
  const gradleFile = path.join(ROOT, "composeApp", "build.gradle.kts");
110
122
  if (fs.existsSync(gradleFile)) {
111
123
  const contents = fs.readFileSync(gradleFile, "utf8");
112
124
  const m = contents.match(/namespace\s*=\s*"([^"]+)"/);
113
- if (m && m[1] !== "__PACKAGE__") return m[1];
125
+ if (m && !UNRESOLVED_TOKEN_RE.test(m[1])) return m[1];
114
126
  }
115
127
  const homeViewModel = path.join(
116
128
  ROOT,
@@ -149,46 +161,126 @@ function guessPackageDirFromDisk() {
149
161
  const PACKAGE = resolvePackage();
150
162
  const PACKAGE_DIR = PACKAGE.split(".").join("/");
151
163
 
164
+ // ── The file set (§4) ───────────────────────────────────────────────────────
165
+ // Source paths are relative to composeApp/src/<sourceSet>/kotlin/<PACKAGE_DIR>.
166
+
167
+ const SRC = (sourceSet) => path.join(ROOT, "composeApp/src", sourceSet, "kotlin", PACKAGE_DIR);
168
+
169
+ // ── Configurable exemplar (GENESIS-FLOW-DESIGN.md §1) ───────────────────────
170
+ // This stamper clones from the CONFIGURED exemplar (qa/approvals.json's
171
+ // `exemplarFeature`, defaulting to `home`) — the same resolution
172
+ // qa/lib/approvals.mjs's governed-artifact registry uses, so a stamped feature
173
+ // always matches what the registry hashes. Tolerant of a missing/pre-genesis-flow
174
+ // qa/lib/approvals.mjs (mirrors the seedUnreviewed step further down): a broken
175
+ // or absent import falls back to the hardcoded `home`/`Item` shape that predates
176
+ // configurability — this script must never refuse to stamp because the approvals
177
+ // ledger's tooling isn't present.
178
+ let approvalsLib = null;
179
+ try {
180
+ approvalsLib = await import("./lib/approvals.mjs");
181
+ } catch {
182
+ approvalsLib = null;
183
+ }
184
+
185
+ function resolveSourceNames() {
186
+ if (approvalsLib && typeof approvalsLib.resolveExemplarNames === "function") {
187
+ return approvalsLib.resolveExemplarNames(ROOT);
188
+ }
189
+ return { f: "home", F: "Home", F_UPPER: "HOME", E: "Item" };
190
+ }
191
+
192
+ const { f: SOURCE_f, F: SOURCE_F, F_UPPER: SOURCE_F_UPPER, E: SOURCE_E } = resolveSourceNames();
193
+
194
+ // Extras beyond the canonical 11-file shape (§1: "clone the canonical set and
195
+ // WARN, listing what it skipped — never silently"). Scoped by NAME, not by
196
+ // directory membership: the exemplar's presentation directory can legitimately
197
+ // hold files that aren't "this feature's own" (e.g. the shipped `home`
198
+ // exemplar's directory also holds `DetailScreen.kt` — a deliberately
199
+ // NOT-cloned, permanent fixture per add-feature's SKILL.md, not drift) — so a
200
+ // file only counts as an extra when its name is actually built FROM the
201
+ // exemplar's own identifiers (starts with `${sourceF}` in presentation/, or
202
+ // contains the entity as a whole word in the shared domain/data directories).
203
+ function escapeRegExp(s) {
204
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
205
+ }
206
+
207
+ function scanDirForExtras(sourceSet, dirRel, canonicalBasenames, matches) {
208
+ const dirAbs = path.join(SRC(sourceSet), dirRel);
209
+ if (!fs.existsSync(dirAbs)) return [];
210
+ return fs
211
+ .readdirSync(dirAbs, { withFileTypes: true })
212
+ .filter((e) => e.isFile() && e.name.endsWith(".kt") && matches(e.name) && !canonicalBasenames.has(e.name))
213
+ .map((e) => path.relative(ROOT, path.join(dirAbs, e.name)));
214
+ }
215
+
216
+ function findExemplarExtras(sourceF, sourceLowerF, sourceEntity) {
217
+ const namedForFeature = (n) => n.startsWith(sourceF);
218
+ const entityWholeWord = new RegExp(`\\b${escapeRegExp(sourceEntity)}\\b`);
219
+ return [
220
+ ...scanDirForExtras("commonMain", `presentation/${sourceLowerF}`, new Set([`${sourceF}Screen.kt`, `${sourceF}ViewModel.kt`]), namedForFeature),
221
+ ...scanDirForExtras("commonTest", `presentation/${sourceLowerF}`, new Set([`${sourceF}ViewModelTest.kt`]), namedForFeature),
222
+ ...scanDirForExtras("desktopTest", `presentation/${sourceLowerF}`, new Set([`${sourceF}ScreenTest.kt`, `${sourceF}GoldenTreeTest.kt`]), namedForFeature),
223
+ ...scanDirForExtras("commonMain", "domain/model", new Set([`${sourceEntity}.kt`]), (n) => entityWholeWord.test(n)),
224
+ ...scanDirForExtras("commonMain", "domain/repository", new Set([`${sourceEntity}Repository.kt`]), (n) => entityWholeWord.test(n)),
225
+ ...scanDirForExtras("commonMain", "domain/usecase", new Set([`Get${sourceEntity}sUseCase.kt`]), (n) => entityWholeWord.test(n)),
226
+ ...scanDirForExtras("commonMain", "data/remote", new Set([`${sourceEntity}RepositoryImpl.kt`]), (n) => entityWholeWord.test(n)),
227
+ ...scanDirForExtras("commonTest", "testing/fakes", new Set([`Fake${sourceEntity}Repository.kt`]), (n) => entityWholeWord.test(n)),
228
+ ];
229
+ }
230
+
231
+ const exemplarExtras = findExemplarExtras(SOURCE_F, SOURCE_f, SOURCE_E);
232
+ if (exemplarExtras.length > 0) {
233
+ console.log(
234
+ `WARNING: the "${SOURCE_f}" exemplar has files beyond the canonical 11-file shape — only the canonical set is cloned, these are SKIPPED:\n` +
235
+ exemplarExtras.map((p) => ` - ${p}`).join("\n"),
236
+ );
237
+ }
238
+
152
239
  // ── The rename map (§3) ──────────────────────────────────────────────────────
153
240
  // Whole-word (\b-delimited), applied LONGEST KEY FIRST so compound entries
154
- // (ItemRepositoryImpl) resolve before their substrings (ItemRepository, Item).
155
- // Anything not in this list is left untouched by design (see design doc §3
156
- // "LEAVE GENERIC" awaitItem, items, item, goldenItems, itemId, onItemClick,
157
- // id, title, subtitle, and every androidx./kotlinx./org.koin./kotlin. token).
241
+ // (<Entity>RepositoryImpl) resolve before their substrings (<Entity>Repository,
242
+ // <Entity>). Generated from the CLONE-SOURCE's own names (SOURCE_F/SOURCE_f/
243
+ // SOURCE_E/SOURCE_F_UPPER) rather than hardcoded `Home`/`home`/`Item`/`HOME` when
244
+ // the source is the shipped `home` exemplar these resolve to exactly those
245
+ // literals, so stamping from `home` is byte-identical to before configurability
246
+ // existed (pinned by test/genesis-flow.test.mjs's parity pin). Anything not in this
247
+ // list is left untouched by design (see design doc §3 "LEAVE GENERIC" —
248
+ // awaitItem, items, item, goldenItems, itemId, onItemClick, id, title, subtitle,
249
+ // and every androidx./kotlinx./org.koin./kotlin. token).
158
250
 
159
251
  const RENAME_MAP = [
160
- ["HomeScreenTest", `${F}ScreenTest`],
161
- ["HomeViewModelTest", `${F}ViewModelTest`],
162
- ["HomeGoldenTreeTest", `${F}GoldenTreeTest`],
163
- ["HomeScreen", `${F}Screen`],
164
- ["HomeViewModel", `${F}ViewModel`],
165
- ["HomeUiState", `${F}UiState`],
166
- ["home_title", `${f}_title`],
167
- ["home_error", `${f}_error`],
168
- ["FakeItemRepository", `Fake${E}Repository`],
169
- ["ItemRepositoryImpl", `${E}RepositoryImpl`],
170
- ["ItemRepository", `${E}Repository`],
171
- ["GetItemsUseCase", `Get${E}sUseCase`],
172
- ["getItemsCallCount", `get${E}sCallCount`],
173
- ["getItems", `get${E}s`],
174
- ["Item", E],
175
- // Spec + test SPEC-tag retargeting (§6): HOME-0N -> <F_UPPER>-0N, then the
176
- // bare HOME -> <F_UPPER> (must run AFTER the -0 form or "HOME-0" would be
177
- // partially consumed oddly — longest-key-first already orders this).
178
- ["HOME-0", `${F_UPPER}-0`],
179
- ["HOME", F_UPPER],
252
+ [`${SOURCE_F}ScreenTest`, `${F}ScreenTest`],
253
+ [`${SOURCE_F}ViewModelTest`, `${F}ViewModelTest`],
254
+ [`${SOURCE_F}GoldenTreeTest`, `${F}GoldenTreeTest`],
255
+ [`${SOURCE_F}Screen`, `${F}Screen`],
256
+ [`${SOURCE_F}ViewModel`, `${F}ViewModel`],
257
+ [`${SOURCE_f}_title`, `${f}_title`],
258
+ [`${SOURCE_f}_error`, `${f}_error`],
259
+ [`${SOURCE_f}_empty`, `${f}_empty`],
260
+ [`${SOURCE_f}_loading`, `${f}_loading`],
261
+ [`${SOURCE_f}_retry`, `${f}_retry`],
262
+ [`${SOURCE_f}_item_`, `${f}_item_`],
263
+ [`Fake${SOURCE_E}Repository`, `Fake${E}Repository`],
264
+ [`${SOURCE_E}RepositoryImpl`, `${E}RepositoryImpl`],
265
+ [`${SOURCE_E}Repository`, `${E}Repository`],
266
+ [`Get${SOURCE_E}sUseCase`, `Get${E}sUseCase`],
267
+ [`get${SOURCE_E}sCallCount`, `get${E}sCallCount`],
268
+ [`get${SOURCE_E}s`, `get${E}s`],
269
+ [SOURCE_E, E],
270
+ // Spec + test SPEC-tag retargeting (§6): <SOURCE_F_UPPER>-0N -> <F_UPPER>-0N,
271
+ // then the bare <SOURCE_F_UPPER> -> <F_UPPER> (must run AFTER the -0 form or
272
+ // "<SOURCE_F_UPPER>-0" would be partially consumed oddly — longest-key-first
273
+ // already orders this).
274
+ [`${SOURCE_F_UPPER}-0`, `${F_UPPER}-0`],
275
+ [SOURCE_F_UPPER, F_UPPER],
180
276
  // Package segment / path / golden filename / display text. Order matters:
181
- // must run after HomeXxx / home_xxx above so those compounds are already
182
- // resolved; the bare `home` word only matches the standalone package
183
- // segment, golden filename stem, and prose by this point.
184
- ["home", f],
185
- ["Home", F],
277
+ // must run after <SOURCE_F>Xxx / <SOURCE_f>_xxx above so those compounds are
278
+ // already resolved; the bare source-feature word only matches the standalone
279
+ // package segment, golden filename stem, and prose by this point.
280
+ [SOURCE_f, f],
281
+ [SOURCE_F, F],
186
282
  ].sort((a, b) => b[0].length - a[0].length);
187
283
 
188
- function escapeRegExp(s) {
189
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
190
- }
191
-
192
284
  const COMPILED_RENAMES = RENAME_MAP.map(([from, to]) => [new RegExp(`\\b${escapeRegExp(from)}\\b`, "g"), to]);
193
285
 
194
286
  function applyRename(text) {
@@ -197,27 +289,30 @@ function applyRename(text) {
197
289
  return out;
198
290
  }
199
291
 
200
- // ── The file set (§4) ───────────────────────────────────────────────────────
201
- // Source paths are relative to composeApp/src/<sourceSet>/kotlin/<PACKAGE_DIR>.
202
-
203
- const SRC = (sourceSet) => path.join(ROOT, "composeApp/src", sourceSet, "kotlin", PACKAGE_DIR);
204
-
205
292
  // Every entry is tagged with the presets it belongs to. `feature` gets all 11
206
293
  // (the union); `repository` gets just the 5 data/domain files; `screen` gets
207
294
  // just the 6 presentation+tests+spec files. Filtered by the active preset
208
295
  // right after definition — nothing below this point sees the untagged list.
296
+ //
297
+ // `from:` sides are built from the CLONE-SOURCE names (SOURCE_F/SOURCE_f/
298
+ // SOURCE_E) instead of hardcoded `home`/`Item` literals — this is exactly the
299
+ // canonical-11-file SHAPE `exemplarKotlinFileSet` in qa/lib/approvals.mjs
300
+ // encodes (that function is the registry's copy; this one is the stamper's,
301
+ // kept independently so the stamper works even without qa/lib/approvals.mjs —
302
+ // see the resolveSourceNames() fallback above). test/approvals-exemplar-list.test.mjs
303
+ // pins that the two never disagree for a real project.
209
304
  const ALL_FILES = [
210
- { from: path.join(SRC("commonMain"), "domain/model/Item.kt"), to: path.join(SRC("commonMain"), `domain/model/${E}.kt`), presets: ["feature", "repository"] },
211
- { from: path.join(SRC("commonMain"), "domain/repository/ItemRepository.kt"), to: path.join(SRC("commonMain"), `domain/repository/${E}Repository.kt`), presets: ["feature", "repository"] },
212
- { from: path.join(SRC("commonMain"), "domain/usecase/GetItemsUseCase.kt"), to: path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`), presets: ["feature", "repository"] },
213
- { from: path.join(SRC("commonMain"), "data/remote/ItemRepositoryImpl.kt"), to: path.join(SRC("commonMain"), `data/remote/${E}RepositoryImpl.kt`), presets: ["feature", "repository"] },
214
- { from: path.join(SRC("commonTest"), "testing/fakes/FakeItemRepository.kt"), to: path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`), presets: ["feature", "repository"] },
215
- { from: path.join(SRC("commonMain"), "presentation/home/HomeScreen.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"], wrapInBaseScreen: true },
216
- { from: path.join(SRC("commonMain"), "presentation/home/HomeViewModel.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}ViewModel.kt`), presets: ["feature", "screen"] },
217
- { from: path.join(SRC("commonTest"), "presentation/home/HomeViewModelTest.kt"), to: path.join(SRC("commonTest"), `presentation/${f}/${F}ViewModelTest.kt`), presets: ["feature", "screen"] },
218
- { from: path.join(SRC("desktopTest"), "presentation/home/HomeScreenTest.kt"), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}ScreenTest.kt`), presets: ["feature", "screen"] },
219
- { from: path.join(SRC("desktopTest"), "presentation/home/HomeGoldenTreeTest.kt"), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}GoldenTreeTest.kt`), presets: ["feature", "screen"] },
220
- { from: path.join(ROOT, "specs/home.spec.md"), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
305
+ { from: path.join(SRC("commonMain"), `domain/model/${SOURCE_E}.kt`), to: path.join(SRC("commonMain"), `domain/model/${E}.kt`), presets: ["feature", "repository"] },
306
+ { from: path.join(SRC("commonMain"), `domain/repository/${SOURCE_E}Repository.kt`), to: path.join(SRC("commonMain"), `domain/repository/${E}Repository.kt`), presets: ["feature", "repository"] },
307
+ { from: path.join(SRC("commonMain"), `domain/usecase/Get${SOURCE_E}sUseCase.kt`), to: path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`), presets: ["feature", "repository"] },
308
+ { from: path.join(SRC("commonMain"), `data/remote/${SOURCE_E}RepositoryImpl.kt`), to: path.join(SRC("commonMain"), `data/remote/${E}RepositoryImpl.kt`), presets: ["feature", "repository"] },
309
+ { from: path.join(SRC("commonTest"), `testing/fakes/Fake${SOURCE_E}Repository.kt`), to: path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`), presets: ["feature", "repository"] },
310
+ { from: path.join(SRC("commonMain"), `presentation/${SOURCE_f}/${SOURCE_F}Screen.kt`), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"], wrapInBaseScreen: true },
311
+ { from: path.join(SRC("commonMain"), `presentation/${SOURCE_f}/${SOURCE_F}ViewModel.kt`), to: path.join(SRC("commonMain"), `presentation/${f}/${F}ViewModel.kt`), presets: ["feature", "screen"] },
312
+ { from: path.join(SRC("commonTest"), `presentation/${SOURCE_f}/${SOURCE_F}ViewModelTest.kt`), to: path.join(SRC("commonTest"), `presentation/${f}/${F}ViewModelTest.kt`), presets: ["feature", "screen"] },
313
+ { from: path.join(SRC("desktopTest"), `presentation/${SOURCE_f}/${SOURCE_F}ScreenTest.kt`), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}ScreenTest.kt`), presets: ["feature", "screen"] },
314
+ { from: path.join(SRC("desktopTest"), `presentation/${SOURCE_f}/${SOURCE_F}GoldenTreeTest.kt`), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}GoldenTreeTest.kt`), presets: ["feature", "screen"] },
315
+ { from: path.join(ROOT, `specs/${SOURCE_f}.spec.md`), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
221
316
  ];
222
317
 
223
318
  const FILES = ALL_FILES.filter((file) => file.presets.includes(preset));
@@ -229,8 +324,8 @@ for (const file of FILES) {
229
324
  if (!fs.existsSync(file.from)) {
230
325
  die(
231
326
  `exemplar source file missing: ${path.relative(ROOT, file.from)}\n` +
232
- "This script must run in an unmodified (or already-featured) create-cmp scaffold " +
233
- "where the `home` exemplar still exists.",
327
+ `This script must run in an unmodified (or already-featured) create-cmp scaffold ` +
328
+ `where the "${SOURCE_f}" exemplar (qa/approvals.json's "exemplarFeature") still exists in its canonical 11-file shape.`,
234
329
  );
235
330
  }
236
331
  }
@@ -273,7 +368,7 @@ if (FILES.some((file) => file.isDefaultSpec) && fs.existsSync(path.join(ROOT, `s
273
368
  function defaultSpec() {
274
369
  return `# Spec: ${f}
275
370
 
276
- > Generated by \`scaffold-feature.mjs\` from the \`home\` exemplar shape. Refine the clause
371
+ > Generated by \`scaffold-feature.mjs\` from the \`${SOURCE_f}\` exemplar shape. Refine the clause
277
372
  > prose below for ${F}'s real behavior (ids stay fixed) before running the verify lane.
278
373
 
279
374
  - **${F_UPPER}-01** — Given the ${F} screen opens, When ${f} are being loaded, Then a loading
@@ -281,14 +376,17 @@ function defaultSpec() {
281
376
  - **${F_UPPER}-02** — Given the repository returns ${f}, When loading completes, Then the ${f}
282
377
  are listed with their title and subtitle, and no error is shown.
283
378
  - **${F_UPPER}-03** — Given the repository fails, When loading completes, Then a human-readable
284
- error message is shown (\`${f}_error\`) and no ${f} are visible.
379
+ error message is shown (\`${f}_error\`) and no ${f} are visible — the copy is mapped in
380
+ presentation from the failure's \`DomainError\` kind, never a raw exception message.
285
381
  - **${F_UPPER}-04** — Given a load has failed, When the data source recovers and the user
286
- triggers a reload, Then the error clears and the ${f} render.
382
+ triggers a reload (\`${f}_retry\`), Then the error clears and the ${f} render.
287
383
  - **${F_UPPER}-05** — Given ${f} are listed, When the user taps an item, Then the app navigates
288
384
  to that item's detail.
289
385
  - **${F_UPPER}-06** — Given the ${F} screen renders, When its structure is inspected, Then the
290
386
  screen matches its committed golden tree (\`qa/golden/${f}.json\`) — structural regressions
291
387
  are intentional, declared changes only.
388
+ - **${F_UPPER}-07** — Given the repository succeeds with zero ${f}, When loading completes, Then
389
+ the empty state is shown (\`${f}_empty\`) and neither ${f} nor an error are visible.
292
390
  `;
293
391
  }
294
392
 
@@ -309,7 +407,7 @@ function wrapScreenInBaseScreen(content, relPathForErrors) {
309
407
  const themeImportIdx = lines.findIndex((l) => /^import .+\.presentation\.theme\./.test(l));
310
408
  if (themeImportIdx === -1) {
311
409
  die(
312
- `no presentation.theme import found in ${relPathForErrors} — the HomeScreen exemplar ` +
410
+ `no presentation.theme import found in ${relPathForErrors} — the ${SOURCE_F}Screen exemplar ` +
313
411
  "drifted from the shape this stamper wraps; cannot place the BaseScreen import.",
314
412
  );
315
413
  }
@@ -319,11 +417,14 @@ function wrapScreenInBaseScreen(content, relPathForErrors) {
319
417
  );
320
418
  lines.splice(themeImportIdx, 0, importLine);
321
419
 
322
- // 2. Root container start: the exemplar's body root is a top-level ` Column(`.
323
- const rootIdx = lines.findIndex((l) => l === " Column(");
420
+ // 2. Root container start: the exemplar's body root is a top-level ` ScreenColumn(`
421
+ // call (the component-vocabulary rewrite see docs/proposals/component-system-deep-dive.md
422
+ // §5). The tag literal inside the call (`screenTag = "home"`) is renamed separately by
423
+ // RENAME_MAP, so match on the call shape only, not the full line.
424
+ const rootIdx = lines.findIndex((l) => /^ {4}ScreenColumn\(/.test(l));
324
425
  if (rootIdx === -1) {
325
426
  die(
326
- `root " Column(" not found in ${relPathForErrors} — the HomeScreen exemplar drifted ` +
427
+ `root " ScreenColumn(" not found in ${relPathForErrors} — the ${SOURCE_F}Screen exemplar drifted ` +
327
428
  "from the shape this stamper wraps in BaseScreen.",
328
429
  );
329
430
  }
@@ -339,7 +440,7 @@ function wrapScreenInBaseScreen(content, relPathForErrors) {
339
440
  if (funCloseIdx === -1 || lines[funCloseIdx - 1] !== " }") {
340
441
  die(
341
442
  `could not locate the root container's closing brace in ${relPathForErrors} — the ` +
342
- "HomeScreen exemplar drifted from the shape this stamper wraps in BaseScreen.",
443
+ `${SOURCE_F}Screen exemplar drifted from the shape this stamper wraps in BaseScreen.`,
343
444
  );
344
445
  }
345
446
  const rootCloseIdx = funCloseIdx - 1;
@@ -547,7 +648,7 @@ if (dryRun) {
547
648
  );
548
649
  }
549
650
  if (writesSpec) {
550
- console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..06.`);
651
+ console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..07.`);
551
652
  } else {
552
653
  console.log("\nNo spec file written by this preset (zero SPEC clauses/tags added).");
553
654
  }
@@ -580,7 +681,23 @@ for (const result of fileResults) {
580
681
 
581
682
  console.log(`✓ Scaffolded ${planLabel} [preset: ${preset}] — ${filesWritten} files written, ${injectionsApplied} anchor injections applied.`);
582
683
  if (writesSpec) {
583
- console.log(` specs/${f}.spec.md written with default clauses ${F_UPPER}-01..06 — refine the prose next.`);
684
+ console.log(` specs/${f}.spec.md written with default clauses ${F_UPPER}-01..07 — refine the prose next.`);
685
+
686
+ // Approvals seeding (VERIFICATION-LAYER-DESIGN.md §2): the new feature's spec
687
+ // is a governed artifact (`feature-spec:<name>`) — seed it unreviewed so the
688
+ // verify lane's `approvals` gate SKIP-warns until a human signs off. v1 does
689
+ // NOT refuse to stamp over this (warn-then-enforce-at-verify is the honest
690
+ // default) — reuses `approvalsLib` (already resolved above, tolerantly, for
691
+ // clone-source resolution) so a missing or out-of-date qa/lib/approvals.mjs
692
+ // (an older, pre-approvals scaffold) never blocks the stamp itself.
693
+ try {
694
+ if (!approvalsLib || typeof approvalsLib.seedUnreviewed !== "function") throw new Error("no seedUnreviewed export");
695
+ const artifactId = `feature-spec:${f}`;
696
+ approvalsLib.seedUnreviewed(ROOT, artifactId);
697
+ console.log(` Approval: specs/${f}.spec.md is unreviewed — run \`node qa/approve.mjs ${artifactId}\` once you've reviewed it.`);
698
+ } catch {
699
+ console.log(" (approvals seeding skipped — qa/lib/approvals.mjs not found in this scaffold)");
700
+ }
584
701
  } else {
585
702
  console.log(" No spec file written by this preset (zero SPEC clauses/tags added).");
586
703
  }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // Enable this project's shipped git hooks — one-time, idempotent, no dependency
3
+ // and no hook-manager. Run it once after `git init`: it points git at the
4
+ // tracked .githooks/ directory (core.hooksPath) and makes the hooks executable.
5
+ //
6
+ // The pre-push hook it activates gates a push on the evidence receipt attesting
7
+ // HEAD — the same cheap check CI runs, before your code leaves the machine.
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ function main() {
14
+ try {
15
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { stdio: "ignore" });
16
+ } catch {
17
+ console.error("Not a git repository yet. Run `git init` first, then `node qa/setup-hooks.mjs`.");
18
+ process.exit(1);
19
+ }
20
+ // core.hooksPath (git 2.9+) makes the tracked .githooks/ the hooks directory,
21
+ // so the hooks live in the repo and survive as one source of truth.
22
+ execFileSync("git", ["config", "core.hooksPath", ".githooks"], { stdio: "ignore" });
23
+ try {
24
+ fs.chmodSync(path.join(".githooks", "pre-push"), 0o755);
25
+ } catch {
26
+ // best-effort — on a filesystem without exec bits the hook still runs via core.hooksPath
27
+ }
28
+ console.log("✓ git hooks enabled (core.hooksPath = .githooks).");
29
+ console.log(" pre-push now blocks a push whose committed receipt doesn't attest HEAD.");
30
+ console.log(" Bypass in a pinch with `git push --no-verify`; CI still enforces the check.");
31
+ }
32
+
33
+ main();
@@ -26,6 +26,9 @@ import { fileURLToPath } from "node:url";
26
26
 
27
27
  import { computeInputsHash } from "./lib/inputs-hash.mjs";
28
28
  import { compareTokenDrift } from "./lib/token-drift.mjs";
29
+ import { evaluateApprovalsGate } from "./lib/approvals.mjs";
30
+ import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
31
+ import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
29
32
 
30
33
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
31
34
  const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
@@ -176,6 +179,77 @@ function stepSpecCoverage() {
176
179
  };
177
180
  }
178
181
 
182
+ // Human-approval gate (VERIFICATION-LAYER-DESIGN.md §2) — pure Node, no Gradle,
183
+ // same grouping as specCoverage. The decision itself lives in
184
+ // qa/lib/approvals.mjs (evaluateApprovalsGate); this step only adds the
185
+ // name/duration bookkeeping every step in this file carries.
186
+ function stepApprovals() {
187
+ const started = Date.now();
188
+ const { verdict, reason, statuses } = evaluateApprovalsGate(ROOT);
189
+ return {
190
+ name: "approvals",
191
+ verdict,
192
+ reason,
193
+ durationMs: Date.now() - started,
194
+ details: { artifacts: statuses.map((s) => ({ id: s.id, status: s.status, hash: s.hash })) },
195
+ };
196
+ }
197
+
198
+ // Component ↔ story parity gate (STUDIO-REDESIGN.md §3.3) — pure Node, no
199
+ // Gradle, same grouping as specCoverage/approvals. The decision itself lives
200
+ // in qa/lib/component-stories.mjs (evaluateComponentStoryParity); this step
201
+ // only adds the name/duration bookkeeping every step in this file carries.
202
+ function stepComponentStories() {
203
+ const started = Date.now();
204
+ const { verdict, reason, details } = evaluateComponentStoryParity(ROOT);
205
+ return { name: "componentStories", verdict, reason, durationMs: Date.now() - started, details };
206
+ }
207
+
208
+ // Architecture-doc freshness gate (Wave B, docs/proposals/architecture-document-
209
+ // standard.md §6) — pure Node, no Gradle, same grouping as specCoverage/
210
+ // approvals. The decision itself lives in qa/lib/arch-doc.mjs
211
+ // (regenerateArchDoc); this step only adds the name/duration bookkeeping every
212
+ // step in this file carries, plus wording the FAIL reason for an AI
213
+ // collaborator (name the stale/missing section, name the fix command).
214
+ function stepArchDoc() {
215
+ const started = Date.now();
216
+ const elapsed = () => Date.now() - started;
217
+
218
+ const result = regenerateArchDoc(ROOT);
219
+ if (!result.ok) {
220
+ return { name: "archDoc", verdict: "SKIP", reason: `${result.reason} — nothing to check`, durationMs: elapsed() };
221
+ }
222
+ if (result.unknownSections.length > 0) {
223
+ return {
224
+ name: "archDoc",
225
+ verdict: "FAIL",
226
+ reason: `${ARCH_DOC_REL_PATH} has cmp:generated marker(s) with no registered generator: ${result.unknownSections.join(", ")} — add a generator in qa/lib/arch-doc.mjs or remove the marker.`,
227
+ durationMs: elapsed(),
228
+ };
229
+ }
230
+
231
+ const stale = result.changed || result.missingSections.length > 0;
232
+ if (!stale) {
233
+ return { name: "archDoc", verdict: "PASS", durationMs: elapsed(), details: { sectionsChecked: SECTION_IDS.length } };
234
+ }
235
+
236
+ const lines = [`${ARCH_DOC_REL_PATH} is stale — a generated section no longer matches the tree:`];
237
+ for (const id of result.changedSections) {
238
+ lines.push(` [${id}] regenerating would change this section.`);
239
+ }
240
+ for (const id of result.missingSections) {
241
+ lines.push(` [${id}] marker missing from the doc entirely — never generated.`);
242
+ }
243
+ lines.push("Run: node qa/arch-doc.mjs");
244
+ return {
245
+ name: "archDoc",
246
+ verdict: "FAIL",
247
+ reason: lines.join("\n"),
248
+ durationMs: elapsed(),
249
+ details: { changedSections: result.changedSections, missingSections: result.missingSections },
250
+ };
251
+ }
252
+
179
253
  function stepBuild() {
180
254
  const res = sh(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
181
255
  return {
@@ -372,15 +446,44 @@ function stepE2eSmoke() {
372
446
  // that steal focus over the app — a Maestro assert would then see only the dialog;
373
447
  // - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
374
448
  // up on a slow emulator (the built-in default gives up too early under load).
375
- // Both are benign, reversible, and only touch the device while the lane is driving it.
449
+ // Both are benign, reversible, and only touch the device while the lane is driving it
450
+ // hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
451
+ // to its default) in the finally below, on every exit path.
452
+ // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
453
+ // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
454
+ // them. The eyes must report what automation stability had to hide.
455
+ const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
376
456
  sh("adb shell settings put global hide_error_dialogs 1");
377
- const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
378
- return {
379
- name: "e2eSmoke",
380
- verdict: res.ok ? "PASS" : "FAIL",
381
- reason: res.ok ? undefined : `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
382
- durationMs: install.durationMs + res.durationMs,
383
- };
457
+ sh("adb logcat -c"); // clear so the post-run dump only reflects this run
458
+ try {
459
+ const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
460
+ if (!res.ok) {
461
+ return {
462
+ name: "e2eSmoke",
463
+ verdict: "FAIL",
464
+ reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
465
+ durationMs: install.durationMs + res.durationMs,
466
+ };
467
+ }
468
+ const anrDump = sh("adb logcat -d -b system,crash,main");
469
+ const anrRe = /ANR in |FATAL EXCEPTION/i;
470
+ if (anrDump.ok && anrRe.test(anrDump.out)) {
471
+ const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
472
+ return {
473
+ name: "e2eSmoke",
474
+ verdict: "FAIL",
475
+ reason: `Maestro smoke passed, but the device log shows an ANR/crash during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${anrLines}`,
476
+ durationMs: install.durationMs + res.durationMs,
477
+ };
478
+ }
479
+ return { name: "e2eSmoke", verdict: "PASS", durationMs: install.durationMs + res.durationMs };
480
+ } finally {
481
+ if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
482
+ sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
483
+ } else {
484
+ sh("adb shell settings delete global hide_error_dialogs");
485
+ }
486
+ }
384
487
  }
385
488
 
386
489
  // ── Lane ───────────────────────────────────────────────────────────────────
@@ -388,9 +491,12 @@ function stepE2eSmoke() {
388
491
  const stepsForProfile = {
389
492
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
390
493
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
391
- scaffold: [stepSpecCoverage, stepBuild, stepUnitTests],
494
+ scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepArchDoc, stepBuild, stepUnitTests],
392
495
  local: [
393
496
  stepSpecCoverage,
497
+ stepApprovals,
498
+ stepComponentStories,
499
+ stepArchDoc,
394
500
  stepBuild,
395
501
  stepUnitTests,
396
502
  stepConformance,
@@ -465,6 +571,9 @@ const receipt = {
465
571
 
466
572
  fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
467
573
  fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(receipt, null, 2)}\n`);
574
+ // latest.json is the single receipt-of-record. Commit it with your change: the
575
+ // studio console's Evidence audit trail reconstructs the full history from the
576
+ // git log of this file — every commit is one verified, attributed state.
468
577
 
469
578
  if (asJson) console.log(JSON.stringify(receipt, null, 2));
470
579
  else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} — receipt written to qa/evidence/latest.json (commit it with your change)`);
@@ -7,8 +7,7 @@
7
7
 
8
8
  - **ARCH-01** — Given any file in `presentation`, When its imports **and fully-qualified
9
9
  inline references** are inspected, Then none resolve into the `data` layer (presentation
10
- depends on domain only; qualifying the name inline instead of importing is the same
11
- violation).
10
+ depends on domain only).
12
11
  - **ARCH-02** — Given any file in `domain`, When its imports **and fully-qualified inline
13
12
  references** are inspected, Then none resolve into `presentation`, `data`, or `di`, and
14
13
  none reference Compose, Koin, or platform types (domain is pure Kotlin).
@@ -16,11 +15,37 @@
16
15
  corresponding `*ViewModelTest` exists (no untested presentation state).
17
16
  - **ARCH-04** — Given any file in a `presentation` feature package that contains a
18
17
  `@Composable` function, When its source is inspected, Then it declares at least one
19
- `testTag` (scoped by content, not `*Screen.kt` filename split `Content.kt` UI files are
20
- covered, ViewModel-only files are exempt).
18
+ literal `testTag` **or** passes a `screenTag =` argument to a component imported from
19
+ `presentation.components` (scoped by content, not `*Screen.kt` filename — split
20
+ `Content.kt` UI files are covered, ViewModel-only files are exempt). Component-derived
21
+ tags count as tag provenance: a screen built entirely from `ScreenColumn`/`AppHeader`/
22
+ `ContentStateContainer`/etc. is automation-reachable through the tags those components
23
+ emit (`<screenTag>_screen`, `<screenTag>_title`, `<screenTag>_loading`, …) even without a
24
+ literal `testTag` of its own.
21
25
  - **ARCH-05** — Given any file outside `presentation/theme`, When its source is inspected,
22
26
  Then it constructs no literal `Color(0x…)` values (design colors come from the token
23
27
  catalog).
28
+ - **ARCH-06** — Given any repository interface in `domain/repository`, When its one-shot
29
+ operations (`suspend fun`s) are inspected, Then each declares an `AppResult<…>` return
30
+ type — raw exceptions never cross the data → domain boundary; failures travel as typed
31
+ `DomainError` values assigned inside the data implementation.
32
+ - **ARCH-07** — Given any ViewModel in `presentation`, When its source is inspected, Then it
33
+ contains no `try`/`catch`/`runCatching` — ViewModels fold over `AppResult` and map
34
+ `DomainError` kinds to user-facing copy; a raw exception message is never shown to a user.
35
+ - **ARCH-08** — Given any file in the `data` layer, When its source is inspected, Then the
36
+ only exception-catching mechanism is the shared `suspendRunCatching` helper
37
+ (`data/AppResultCatching.kt`), and the helper always rethrows `CancellationException` —
38
+ cancellation is never swallowed into a failure state.
39
+ - **ARCH-09** — Given any file in `data`, When its imports and fully-qualified inline
40
+ references are inspected, Then none resolve into `presentation` or `di` (data serves
41
+ domain contracts; it never reaches upward).
42
+ - **ARCH-10** — Given any file in `core`, When its imports and fully-qualified inline
43
+ references are inspected, Then none resolve into `presentation`, `data`, or `di` (core
44
+ is leaf utility code; `domain` at most).
45
+ - **ARCH-11** — Given any file in a presentation feature package (`components/` excluded),
46
+ When its source is inspected, Then it references neither `CircularProgressIndicator` nor
47
+ `LinearProgressIndicator` directly — loading is presented through the components
48
+ registry (`ContentStateContainer`/`ContentStateDefaults`), never hand-rolled per screen.
24
49
 
25
50
  ## App shell
26
51
 
@@ -33,6 +58,18 @@
33
58
  - **SHELL-04** — Given the app renders any screen, When interactive elements are present,
34
59
  Then each is perceivable by automation: it exposes a testTag, text, or content description.
35
60
  - **SHELL-05** — Given any screen registered directly on the NavHost (not a shell tab), When
36
- it renders, Then its content is composed inside `BaseScreen` — a bare destination that
37
- never touches inset APIs still renders under the status bar, which SHELL-03 alone cannot
38
- catch.
61
+ it renders, Then its content is composed inside `BaseScreen`.
62
+
63
+ ## Component vocabulary
64
+
65
+ > Component *contracts* — the shared state/a11y behavior every screen inherits from
66
+ > `presentation/components/*.kt` (the governed `components` artifact). Feature clauses
67
+ > (e.g. `HOME-NN`) keep citing feature behavior; these clauses are covered once, here.
68
+
69
+ - **COMP-01** — Given any screen with a data-backed state, When it renders, Then
70
+ loading/error/empty are presented by `ContentStateContainer` with tags
71
+ `<screen>_loading` / `<screen>_error` / `<screen>_empty`.
72
+ - **COMP-02** — Given a recoverable load failure and a retry handler, When the error state
73
+ renders, Then a `<screen>_retry` control of at least 48 dp is present.
74
+ - **COMP-03** — Given any interactive registry component, When it renders, Then its
75
+ pointer target is at least 48×48 dp.