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
@@ -0,0 +1,499 @@
1
+ #!/usr/bin/env node
2
+ // walkthrough.mjs — the generated, committable walkthrough report (A2/C8/C9),
3
+ // and the run-to-run diff (A3).
4
+ //
5
+ // node qa/walkthrough.mjs [--port 9500] [--settle 1200] [--out <dir>]
6
+ // node qa/walkthrough.mjs --compare <runDirA> <runDirB> [--out <dir>]
7
+ //
8
+ // WHAT THIS IS. Evidence, not decoration: one run walks the live app and emits
9
+ // `qa/evidence/walkthrough/<stamp>/` containing per-screen pixels + tree +
10
+ // a11y — captured from the SAME frame (pixels are read before and after the
11
+ // tree; a capture only counts when both reads hash identically) — plus a DB
12
+ // appendix read at capture time, and a self-contained report.html styled from
13
+ // the app's own design-system catalog (that is why every app's report arrives
14
+ // auto-branded in its own tokens). `manifest.json` is the machine half: the
15
+ // console's Walkthrough section and `--compare` both consume it, never the HTML.
16
+ //
17
+ // COVERAGE MODEL — route-jumps for coverage, taps only where the shell demands
18
+ // them, honesty about the rest:
19
+ // • shell tabs: discovered live (descendants of `app_bottom_nav` tagged
20
+ // `nav_<slug>`), visited by tapping — tabs are in-shell state, not routes.
21
+ // • parameterless routes from Routes (Screen.kt): visited via the debug
22
+ // inspector's `/inspect/navigate` — mechanical, no guessed tap coordinates.
23
+ // • parameterized routes (`detail/{itemId}`): NOT walked, listed in
24
+ // `notWalked` with the reason. Entity-bearing routes need a behaviour flow
25
+ // (a real tap on a real row), which is e2e's job, not coverage's.
26
+ // • per-screen `@state` variants (home@empty…): stitched from tier-0 renders
27
+ // under composeApp/build/previews, labelled `tier-0` — full four-arm
28
+ // coverage, honestly sourced (C8): the live walk shows the app's real
29
+ // state; contrived arms come from the renderer and say so.
30
+ //
31
+ // Requires: the debug app running with its inspector reachable (default
32
+ // http://127.0.0.1:9500 — `adb forward tcp:9500 tcp:9500`), adb on PATH
33
+ // (BACK key between route visits), Node 18+.
34
+
35
+ import { createHash } from "node:crypto";
36
+ import { execFileSync } from "node:child_process";
37
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
38
+ import path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+
41
+ import { auditA11y } from "./lib/a11y.mjs";
42
+
43
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
+ const PREVIEWS_DIR = path.join(ROOT, "composeApp", "build", "previews");
45
+ const SPECS_DIR = path.join(ROOT, "specs");
46
+ const EVIDENCE_ROOT = path.join(ROOT, "qa", "evidence", "walkthrough");
47
+
48
+ const args = process.argv.slice(2);
49
+ const flag = (name, fallback) => {
50
+ const i = args.indexOf(`--${name}`);
51
+ return i >= 0 && args[i + 1] != null ? args[i + 1] : fallback;
52
+ };
53
+ const PORT = Number(flag("port", 9500));
54
+ const SETTLE_MS = Number(flag("settle", 1200));
55
+ const BASE = `http://127.0.0.1:${PORT}`;
56
+
57
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
58
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
+
60
+ async function getJson(p) {
61
+ const res = await fetch(`${BASE}${p}`);
62
+ const body = await res.text();
63
+ if (!res.ok) throw new Error(`GET ${p} -> ${res.status}: ${body.slice(0, 200)}`);
64
+ return JSON.parse(body);
65
+ }
66
+ async function getBytes(p) {
67
+ const res = await fetch(`${BASE}${p}`);
68
+ if (!res.ok) throw new Error(`GET ${p} -> ${res.status}`);
69
+ return Buffer.from(await res.arrayBuffer());
70
+ }
71
+ async function postTap(x, y) {
72
+ const res = await fetch(`${BASE}/inspect/tap`, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json" },
75
+ body: JSON.stringify({ x, y }),
76
+ });
77
+ if (!res.ok) throw new Error(`POST /inspect/tap -> ${res.status}: ${await res.text()}`);
78
+ }
79
+
80
+ /** Same-frame capture: pixels → tree → pixels, accepted only when both pixel reads hash alike. */
81
+ async function captureStable({ maxAttempts = 4, settleMs = 400 } = {}) {
82
+ let last = null;
83
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
84
+ const a = await getBytes("/inspect/screenshot");
85
+ const tree = await getJson("/inspect/tree");
86
+ const b = await getBytes("/inspect/screenshot");
87
+ const ha = sha256(a);
88
+ if (ha === sha256(b)) {
89
+ let route = null;
90
+ try {
91
+ route = (await getJson("/inspect/nav"))?.currentRoute ?? null;
92
+ } catch {
93
+ /* older app without /inspect/nav — capture stands without the label */
94
+ }
95
+ return { png: a, sha256: ha, tree, route, attempts: attempt };
96
+ }
97
+ last = ha;
98
+ if (attempt < maxAttempts) await sleep(settleMs);
99
+ }
100
+ throw new Error(`frame never stabilised (${maxAttempts} attempts, last ${last?.slice(0, 12)}…) — UI still animating?`);
101
+ }
102
+
103
+ const walkTree = function* (node, p = "root") {
104
+ yield { node, path: p };
105
+ let i = 0;
106
+ for (const c of node.children || []) yield* walkTree(c, `${p}.${i++}`);
107
+ };
108
+
109
+ /**
110
+ * The settle rule, applied to the walk itself (the ledger's e2e lesson): a
111
+ * stable FRAME is not a settled SCREEN — a loading skeleton is perfectly
112
+ * stable. Poll the tree until it stops changing between polls AND no
113
+ * registry loading vocabulary (`*_loading`, skeleton) is on screen. On
114
+ * timeout the capture still happens — with `settled:false` recorded, because
115
+ * an honest "captured mid-load" beats a silent one.
116
+ */
117
+ async function waitForSettled({ timeoutMs = 8_000, pollMs = 500 } = {}) {
118
+ let prev = null;
119
+ const deadline = Date.now() + timeoutMs;
120
+ while (Date.now() < deadline) {
121
+ const t = await getJson("/inspect/tree");
122
+ const root = t.root ?? t;
123
+ const h = sha256(Buffer.from(JSON.stringify(t)));
124
+ const loading = tagsOf(root).some((tag) => tag.endsWith("_loading") || tag.includes("skeleton"));
125
+ if (!loading && h === prev) return true;
126
+ prev = h;
127
+ await sleep(pollMs);
128
+ }
129
+ return false;
130
+ }
131
+ const countNodes = (root) => [...walkTree(root)].length;
132
+ const tagsOf = (root) => [...walkTree(root)].map(({ node }) => node.testTag).filter(Boolean);
133
+ const findTag = (root, tag) => [...walkTree(root)].find(({ node }) => node.testTag === tag)?.node ?? null;
134
+
135
+ /** Parameterless routes from Screen.kt's Routes object; parameterized ones reported, not walked. */
136
+ function discoverRoutes() {
137
+ const navDir = readdirSync(path.join(ROOT, "composeApp", "src", "commonMain", "kotlin"), { recursive: true })
138
+ .map(String)
139
+ .find((f) => f.endsWith(path.join("presentation", "navigation", "Screen.kt")));
140
+ if (!navDir) return { jumpable: [], parameterized: [] };
141
+ const src = readFileSync(path.join(ROOT, "composeApp", "src", "commonMain", "kotlin", navDir), "utf8");
142
+ const routes = [...src.matchAll(/const\s+val\s+[A-Z_]+\s*=\s*"([^"]+)"/g)].map((m) => m[1]);
143
+ return {
144
+ jumpable: routes.filter((r) => r !== "shell" && !r.includes("{")),
145
+ parameterized: routes.filter((r) => r.includes("{")),
146
+ };
147
+ }
148
+
149
+ /** Spec deep-links: `<slug>_screen` root tag -> specs/<slug>.spec.md + its clause ids. */
150
+ function specFor(slug) {
151
+ const file = path.join(SPECS_DIR, `${slug}.spec.md`);
152
+ if (!existsSync(file)) return null;
153
+ const clauses = [...readFileSync(file, "utf8").matchAll(/\*\*([A-Z]+-\d+)\*\*/g)].map((m) => m[1]);
154
+ return { file: path.relative(ROOT, file), clauses: [...new Set(clauses)] };
155
+ }
156
+
157
+ /** C8 — tier-0 rendered `@state` variants for a screen, honestly labelled by source. */
158
+ function variantsFor(slug, outDir) {
159
+ if (!existsSync(PREVIEWS_DIR)) return [];
160
+ return readdirSync(PREVIEWS_DIR)
161
+ .filter((d) => d.startsWith(`${slug}@`))
162
+ .flatMap((d) => {
163
+ const png = path.join(PREVIEWS_DIR, d, "screen.png");
164
+ if (!existsSync(png)) return [];
165
+ const state = d.slice(slug.length + 1);
166
+ const dest = `variants-${slug}@${state}.png`;
167
+ writeFileSync(path.join(outDir, dest), readFileSync(png));
168
+ return [{ state, png: dest, source: "tier-0" }];
169
+ });
170
+ }
171
+
172
+ function designSystemColors(ds) {
173
+ const c = ds?.colors || {};
174
+ return {
175
+ bg: c.Background || "#0d0f0d",
176
+ surface: c.Surface || "#151815",
177
+ onSurface: c.OnSurface || "#f2f4f2",
178
+ onSurfaceVariant: c.OnSurfaceVariant || "#a9b0a9",
179
+ primary: c.Primary || "#b4f04a",
180
+ outline: c.OutlineVariant || c.Outline || "#2a2e2a",
181
+ error: c.Error || "#ff6b6b",
182
+ };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // The walk
187
+ // ---------------------------------------------------------------------------
188
+
189
+ async function runWalk() {
190
+ let health;
191
+ try {
192
+ health = await getJson("/inspect/health");
193
+ } catch (err) {
194
+ console.error(
195
+ `✗ inspector unreachable at ${BASE} — is the DEBUG app running and forwarded ` +
196
+ `(adb forward tcp:${PORT} tcp:${PORT})? (${err.message})`
197
+ );
198
+ process.exit(1);
199
+ }
200
+
201
+ let ds = null;
202
+ let dsSource = "none";
203
+ try {
204
+ ds = await getJson("/inspect/design-system");
205
+ dsSource = "live";
206
+ } catch {
207
+ const f = path.join(PREVIEWS_DIR, "design-system.json");
208
+ if (existsSync(f)) {
209
+ ds = JSON.parse(readFileSync(f, "utf8"));
210
+ dsSource = "tier-0";
211
+ }
212
+ }
213
+
214
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
215
+ const outDir = path.resolve(flag("out", path.join(EVIDENCE_ROOT, stamp)));
216
+ mkdirSync(outDir, { recursive: true });
217
+
218
+ const screens = [];
219
+ const notWalked = [];
220
+
221
+ const record = async (id, kind, extra = {}) => {
222
+ const settled = await waitForSettled();
223
+ const cap = await captureStable();
224
+ const root = cap.tree.root ?? cap.tree;
225
+ writeFileSync(path.join(outDir, `${id}.png`), cap.png);
226
+ writeFileSync(path.join(outDir, `${id}.tree.json`), JSON.stringify(cap.tree, null, 2));
227
+ const slug = tagsOf(root).find((t) => t.endsWith("_screen"))?.replace(/_screen$/, "") ?? id;
228
+ const a11y = auditA11y(root);
229
+ screens.push({
230
+ id,
231
+ kind,
232
+ slug,
233
+ route: cap.route,
234
+ png: `${id}.png`,
235
+ treeJson: `${id}.tree.json`,
236
+ sha256: cap.sha256,
237
+ captureAttempts: cap.attempts,
238
+ settled,
239
+ nodes: countNodes(root),
240
+ tags: tagsOf(root),
241
+ a11y: { violations: a11y.violations, warnings: a11y.warnings, passCount: a11y.passCount },
242
+ spec: specFor(slug),
243
+ variants: variantsFor(slug, outDir),
244
+ ...extra,
245
+ });
246
+ console.log(` ✓ ${id} (${cap.route ?? "route unknown"}, ${countNodes(root)} nodes, a11y ${a11y.violations.length} violations)`);
247
+ return cap;
248
+ };
249
+
250
+ // 1. Shell tabs — discovered live, visited by tap (they are state, not routes).
251
+ console.log("walking shell tabs…");
252
+ const first = await captureStable();
253
+ const firstRoot = first.tree.root ?? first.tree;
254
+ const navTags = tagsOf(firstRoot).filter((t) => t.startsWith("nav_"));
255
+ if (navTags.length === 0) console.log(" (no nav_* tags found — single-screen app?)");
256
+ for (const tag of navTags) {
257
+ // Re-read the tree each round: bounds may shift with selection state.
258
+ const tree = await getJson("/inspect/tree");
259
+ const node = findTag(tree.root ?? tree, tag);
260
+ if (!node?.bounds) {
261
+ notWalked.push({ target: tag, reason: "nav tag present but no bounds — not tappable from here" });
262
+ continue;
263
+ }
264
+ await postTap(
265
+ Math.round(node.bounds.x + node.bounds.width / 2),
266
+ Math.round(node.bounds.y + node.bounds.height / 2)
267
+ );
268
+ await sleep(SETTLE_MS);
269
+ await record(tag.replace(/^nav_/, ""), "tab", { visitedVia: `tap ${tag}` });
270
+ }
271
+
272
+ // 2. Parameterless routes — mechanical coverage via /inspect/navigate.
273
+ const { jumpable, parameterized } = discoverRoutes();
274
+ if (jumpable.length) console.log("walking routes…");
275
+ for (const route of jumpable) {
276
+ try {
277
+ const res = await fetch(`${BASE}/inspect/navigate?route=${encodeURIComponent(route)}`);
278
+ if (!res.ok) {
279
+ notWalked.push({ target: route, reason: `navigate -> ${res.status}: ${(await res.text()).slice(0, 120)}` });
280
+ continue;
281
+ }
282
+ await sleep(SETTLE_MS);
283
+ await record(route.replace(/[^a-z0-9]+/gi, "-"), "route", { visitedVia: `/inspect/navigate?route=${route}` });
284
+ execFileSync("adb", ["shell", "input", "keyevent", "4"]); // BACK — return to shell for the next visit
285
+ await sleep(SETTLE_MS);
286
+ } catch (err) {
287
+ notWalked.push({ target: route, reason: err.message.slice(0, 160) });
288
+ }
289
+ }
290
+ for (const route of parameterized) {
291
+ notWalked.push({ target: route, reason: "parameterized — needs a behaviour flow (e2e), not blind coverage" });
292
+ }
293
+
294
+ // 3. C9 — the DB appendix, read AT CAPTURE TIME: rows are the persistence receipt.
295
+ let db = null;
296
+ try {
297
+ const schema = await getJson("/inspect/db");
298
+ const tables = [];
299
+ for (const t of schema.tables ?? []) {
300
+ const name = typeof t === "string" ? t : t.name;
301
+ try {
302
+ const q = await getJson(`/inspect/db?table=${encodeURIComponent(name)}&limit=5`);
303
+ tables.push({ name, rowCount: q.rowCount ?? (q.rows ? q.rows.length : null), sample: q.rows ?? [] });
304
+ } catch (err) {
305
+ tables.push({ name, error: err.message.slice(0, 120) });
306
+ }
307
+ }
308
+ db = { source: "GET /inspect/db at capture time", tables };
309
+ } catch {
310
+ db = null; // no Room / endpoint absent — the report states the absence honestly
311
+ }
312
+
313
+ const manifest = {
314
+ schemaVersion: 1,
315
+ generatedAt: new Date().toISOString(),
316
+ appId: health.appId,
317
+ processStartedAtMs: health.processStartedAtMs ?? null,
318
+ inspector: BASE,
319
+ designSystemSource: dsSource,
320
+ screens,
321
+ notWalked,
322
+ db,
323
+ };
324
+ writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
325
+ writeFileSync(path.join(outDir, "report.html"), reportHtml(manifest, ds));
326
+ console.log(`\n✅ walkthrough -> ${path.relative(ROOT, outDir)} (${screens.length} screens, ${notWalked.length} not walked)`);
327
+ console.log(` report: ${path.join(path.relative(ROOT, outDir), "report.html")}`);
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // report.html — styled from the app's own tokens (that's the auto-branding)
332
+ // ---------------------------------------------------------------------------
333
+
334
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
335
+
336
+ function reportHtml(m, ds) {
337
+ const c = designSystemColors(ds);
338
+ const cards = m.screens
339
+ .map((s) => {
340
+ const a11yLine =
341
+ s.a11y.violations.length === 0
342
+ ? `<span class="ok">a11y: 0 violations</span>`
343
+ : `<span class="bad">a11y: ${s.a11y.violations.length} violations</span>`;
344
+ const spec = s.spec
345
+ ? `<div class="meta">spec: ${esc(s.spec.file)} — ${s.spec.clauses.map(esc).join(", ") || "no clauses"}</div>`
346
+ : `<div class="meta dim">no per-feature spec file</div>`;
347
+ const variants = s.variants.length
348
+ ? `<div class="variants">${s.variants
349
+ .map((v) => `<figure><img src="${esc(v.png)}" loading="lazy"><figcaption>@${esc(v.state)} · ${esc(v.source)}</figcaption></figure>`)
350
+ .join("")}</div>`
351
+ : "";
352
+ return `<section class="card">
353
+ <h2>${esc(s.id)} <span class="chip">${esc(s.kind)}</span></h2>
354
+ <div class="meta">route: <code>${esc(s.route ?? "—")}</code> · via ${esc(s.visitedVia ?? "—")} · ${s.nodes} nodes · frame ${esc(s.sha256.slice(0, 12))}… · ${a11yLine}</div>
355
+ ${spec}
356
+ <img class="shot" src="${esc(s.png)}" loading="lazy">
357
+ ${variants}
358
+ </section>`;
359
+ })
360
+ .join("\n");
361
+
362
+ const notWalked = m.notWalked.length
363
+ ? `<section class="card"><h2>Not walked</h2><ul>${m.notWalked
364
+ .map((n) => `<li><code>${esc(n.target)}</code> — ${esc(n.reason)}</li>`)
365
+ .join("")}</ul></section>`
366
+ : "";
367
+
368
+ const db = m.db
369
+ ? `<section class="card"><h2>DB appendix <span class="chip">persistence receipt</span></h2>
370
+ <div class="meta">${esc(m.db.source)}</div>
371
+ ${m.db.tables
372
+ .map((t) =>
373
+ t.error
374
+ ? `<h3>${esc(t.name)}</h3><div class="meta bad">${esc(t.error)}</div>`
375
+ : `<h3>${esc(t.name)} <span class="dim">(${t.rowCount ?? "?"} rows)</span></h3><pre>${esc(
376
+ JSON.stringify(t.sample, null, 1).slice(0, 2000)
377
+ )}</pre>`
378
+ )
379
+ .join("")}</section>`
380
+ : `<section class="card"><h2>DB appendix</h2><div class="meta dim">no DB endpoint (Room off, or app predates /inspect/db)</div></section>`;
381
+
382
+ return `<!doctype html><meta charset="utf-8">
383
+ <title>${esc(m.appId)} — walkthrough ${esc(m.generatedAt)}</title>
384
+ <style>
385
+ :root { color-scheme: dark; }
386
+ body { background:${c.bg}; color:${c.onSurface}; font: 15px/1.5 system-ui, sans-serif; margin: 0 auto; max-width: 900px; padding: 24px; }
387
+ h1 { font-size: 22px; } h2 { font-size: 17px; margin: 0 0 6px; } h3 { font-size: 14px; margin: 14px 0 4px; }
388
+ .card { background:${c.surface}; border: 1px solid ${c.outline}; border-radius: 12px; padding: 16px 18px; margin: 14px 0; }
389
+ .meta { color:${c.onSurfaceVariant}; font-size: 13px; margin: 2px 0; }
390
+ .dim { opacity:.7 } .ok { color:${c.primary} } .bad { color:${c.error} }
391
+ .chip { background:${c.bg}; border:1px solid ${c.outline}; border-radius:999px; padding:1px 9px; font-size:11px; vertical-align:2px; color:${c.onSurfaceVariant} }
392
+ img.shot { width: 260px; border-radius: 10px; border:1px solid ${c.outline}; margin-top: 8px; }
393
+ .variants { display:flex; gap:10px; margin-top:10px; flex-wrap:wrap }
394
+ .variants img { width: 150px; border-radius:8px; border:1px solid ${c.outline} }
395
+ .variants figcaption { font-size:11px; color:${c.onSurfaceVariant}; text-align:center }
396
+ pre { background:${c.bg}; border-radius:8px; padding:10px; overflow-x:auto; font-size:12px }
397
+ code { color:${c.primary} }
398
+ </style>
399
+ <h1>${esc(m.appId)} — walkthrough</h1>
400
+ <div class="meta">${esc(m.generatedAt)} · inspector ${esc(m.inspector)} · process started ${esc(
401
+ m.processStartedAtMs ? new Date(m.processStartedAtMs).toISOString() : "unknown"
402
+ )} · design tokens: ${esc(m.designSystemSource)}</div>
403
+ <div class="meta">Evidence, not decoration — every card is pixels + tree + a11y from one proven frame; variants are tier-0 renders and say so; the DB appendix was read at capture time.</div>
404
+ ${cards}
405
+ ${notWalked}
406
+ ${db}`;
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // --compare — A3: two runs, side by side, screen by screen
411
+ // ---------------------------------------------------------------------------
412
+
413
+ function runCompare(dirA, dirB) {
414
+ const load = (d) => {
415
+ const f = path.join(path.resolve(d), "manifest.json");
416
+ if (!existsSync(f)) {
417
+ console.error(`✗ no manifest.json in ${d} — is this a walkthrough run directory?`);
418
+ process.exit(1);
419
+ }
420
+ return JSON.parse(readFileSync(f, "utf8"));
421
+ };
422
+ const A = load(dirA);
423
+ const B = load(dirB);
424
+ const outDir = path.resolve(flag("out", path.join(EVIDENCE_ROOT, `diff-${Date.now()}`)));
425
+ mkdirSync(outDir, { recursive: true });
426
+ const relA = (p) => path.join(path.relative(outDir, path.resolve(dirA)), p);
427
+ const relB = (p) => path.join(path.relative(outDir, path.resolve(dirB)), p);
428
+
429
+ const ids = [...new Set([...A.screens.map((s) => s.id), ...B.screens.map((s) => s.id)])];
430
+ const rows = ids.map((id) => {
431
+ const a = A.screens.find((s) => s.id === id) ?? null;
432
+ const b = B.screens.find((s) => s.id === id) ?? null;
433
+ return {
434
+ id,
435
+ inA: !!a,
436
+ inB: !!b,
437
+ pixelsChanged: a && b ? a.sha256 !== b.sha256 : null,
438
+ nodesDelta: a && b ? b.nodes - a.nodes : null,
439
+ a11yDelta: a && b ? b.a11y.violations.length - a.a11y.violations.length : null,
440
+ tagsAdded: a && b ? b.tags.filter((t) => !a.tags.includes(t)) : [],
441
+ tagsRemoved: a && b ? a.tags.filter((t) => !b.tags.includes(t)) : [],
442
+ };
443
+ });
444
+
445
+ const diff = { schemaVersion: 1, runA: { dir: dirA, generatedAt: A.generatedAt }, runB: { dir: dirB, generatedAt: B.generatedAt }, rows };
446
+ writeFileSync(path.join(outDir, "diff.json"), JSON.stringify(diff, null, 2));
447
+
448
+ const cards = rows
449
+ .map((r) => {
450
+ const a = A.screens.find((s) => s.id === r.id);
451
+ const b = B.screens.find((s) => s.id === r.id);
452
+ const verdict = !r.inA
453
+ ? `<span class="chip">new in B</span>`
454
+ : !r.inB
455
+ ? `<span class="chip">removed in B</span>`
456
+ : r.pixelsChanged
457
+ ? `<span class="bad">pixels changed</span> · nodes ${r.nodesDelta >= 0 ? "+" : ""}${r.nodesDelta} · a11y ${r.a11yDelta >= 0 ? "+" : ""}${r.a11yDelta}`
458
+ : `<span class="ok">identical pixels</span>`;
459
+ const tagNotes =
460
+ r.tagsAdded.length || r.tagsRemoved.length
461
+ ? `<div class="meta">tags: ${r.tagsAdded.map((t) => `+${esc(t)}`).join(" ")} ${r.tagsRemoved.map((t) => `−${esc(t)}`).join(" ")}</div>`
462
+ : "";
463
+ return `<section class="card"><h2>${esc(r.id)}</h2><div class="meta">${verdict}</div>${tagNotes}
464
+ <div class="pair">${a ? `<figure><img src="${esc(relA(a.png))}" loading="lazy"><figcaption>A · ${esc(A.generatedAt)}</figcaption></figure>` : ""}
465
+ ${b ? `<figure><img src="${esc(relB(b.png))}" loading="lazy"><figcaption>B · ${esc(B.generatedAt)}</figcaption></figure>` : ""}</div></section>`;
466
+ })
467
+ .join("\n");
468
+
469
+ writeFileSync(
470
+ path.join(outDir, "diff.html"),
471
+ `<!doctype html><meta charset="utf-8"><title>walkthrough diff</title>
472
+ <style>
473
+ :root{color-scheme:dark} body{background:#0d0f0d;color:#f2f4f2;font:15px/1.5 system-ui;margin:0 auto;max-width:960px;padding:24px}
474
+ .card{background:#151815;border:1px solid #2a2e2a;border-radius:12px;padding:16px 18px;margin:14px 0}
475
+ .meta{color:#a9b0a9;font-size:13px}.ok{color:#b4f04a}.bad{color:#ff6b6b}
476
+ .chip{border:1px solid #2a2e2a;border-radius:999px;padding:1px 9px;font-size:11px;color:#a9b0a9}
477
+ .pair{display:flex;gap:14px;margin-top:10px}.pair img{width:240px;border-radius:10px;border:1px solid #2a2e2a}
478
+ figcaption{font-size:11px;color:#a9b0a9;text-align:center}h2{font-size:17px;margin:0 0 6px}
479
+ </style>
480
+ <h1>Walkthrough diff</h1><div class="meta">A: ${esc(dirA)} (${esc(A.generatedAt)})<br>B: ${esc(dirB)} (${esc(B.generatedAt)})</div>
481
+ ${cards}`
482
+ );
483
+ const changed = rows.filter((r) => r.pixelsChanged).length;
484
+ console.log(`✅ diff -> ${path.relative(ROOT, outDir)} (${rows.length} screens, ${changed} with pixel changes)`);
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+
489
+ const compareIdx = args.indexOf("--compare");
490
+ if (compareIdx >= 0) {
491
+ const [a, b] = [args[compareIdx + 1], args[compareIdx + 2]];
492
+ if (!a || !b) {
493
+ console.error("usage: node qa/walkthrough.mjs --compare <runDirA> <runDirB> [--out <dir>]");
494
+ process.exit(1);
495
+ }
496
+ runCompare(a, b);
497
+ } else {
498
+ runWalk();
499
+ }
@@ -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,42 @@
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.
49
+ - **ARCH-12** — Given a `sample*` preview fixture declared in a `commonMain` presentation
50
+ file, When any OTHER `commonMain` file references it, Then the conformance gate fails —
51
+ a sample is the UI-first preview seam (the stateless screen's own default parameter,
52
+ plus the preview registry/stories and tests), never production wiring. Fake data
53
+ resolving a nav route or seeding a repository is exactly the drift this stops.
24
54
 
25
55
  ## App shell
26
56
 
@@ -33,6 +63,18 @@
33
63
  - **SHELL-04** — Given the app renders any screen, When interactive elements are present,
34
64
  Then each is perceivable by automation: it exposes a testTag, text, or content description.
35
65
  - **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.
66
+ it renders, Then its content is composed inside `BaseScreen`.
67
+
68
+ ## Component vocabulary
69
+
70
+ > Component *contracts* — the shared state/a11y behavior every screen inherits from
71
+ > `presentation/components/*.kt` (the governed `components` artifact). Feature clauses
72
+ > (e.g. `HOME-NN`) keep citing feature behavior; these clauses are covered once, here.
73
+
74
+ - **COMP-01** — Given any screen with a data-backed state, When it renders, Then
75
+ loading/error/empty are presented by `ContentStateContainer` with tags
76
+ `<screen>_loading` / `<screen>_error` / `<screen>_empty`.
77
+ - **COMP-02** — Given a recoverable load failure and a retry handler, When the error state
78
+ renders, Then a `<screen>_retry` control of at least 48 dp is present.
79
+ - **COMP-03** — Given any interactive registry component, When it renders, Then its
80
+ pointer target is at least 48×48 dp.
@@ -8,11 +8,14 @@
8
8
  - **HOME-02** — Given the repository returns items, When loading completes, Then the items are
9
9
  listed with their title and subtitle, and no error is shown.
10
10
  - **HOME-03** — Given the repository fails, When loading completes, Then a human-readable
11
- error message is shown (`home_error`) and no items are visible.
11
+ error message is shown (`home_error`) and no items are visible — the copy is mapped in
12
+ presentation from the failure's `DomainError` kind, never a raw exception message.
12
13
  - **HOME-04** — Given a load has failed, When the data source recovers and the user triggers a
13
- reload, Then the error clears and the items render.
14
+ reload (`home_retry`), Then the error clears and the items render.
14
15
  - **HOME-05** — Given items are listed, When the user taps an item, Then the app navigates to
15
16
  that item's detail.
16
17
  - **HOME-06** — Given the Home screen renders, When its structure is inspected, Then the
17
- screen matches its committed golden tree (`qa/golden/home.json`) — structural regressions
18
- are intentional, declared changes only.
18
+ screen matches its committed golden tree (`qa/golden/home.json`) — structural change must
19
+ be intentional and declared.
20
+ - **HOME-07** — Given the repository succeeds with zero items, When loading completes, Then
21
+ the empty state is shown (`home_empty`) and neither items nor an error are visible.
@@ -0,0 +1,50 @@
1
+ # Intent brief
2
+
3
+ > The root artifact everything else traces to (GENESIS-FLOW-DESIGN.md §0, conversation 0 of
4
+ > the genesis walk). This is prose, not a clause spec — no `// SPEC:` tags apply here, and
5
+ > `specCoverage` never scans this file (it only scans `specs/*.spec.md`). The `cmp-new`
6
+ > interview fills in the sections below before the design-language, architecture, components,
7
+ > and exemplar-feature conversations begin; each of those is expressed in the vocabulary this
8
+ > brief establishes.
9
+
10
+ ## Purpose
11
+
12
+ _not yet captured — filled by the cmp-new interview._ What is this app, in one or two
13
+ sentences? What problem does it solve, and for whom?
14
+
15
+ ## Audience
16
+
17
+ _not yet captured — filled by the cmp-new interview._ Who uses this app? One primary
18
+ audience beats a vague list — name them.
19
+
20
+ ## Platforms
21
+
22
+ _not yet captured — filled by the cmp-new interview._ Android, iOS, or both? Any platform-
23
+ specific constraints (e.g. iOS-only feature, Android-first launch)?
24
+
25
+ ## Brand feel
26
+
27
+ _not yet captured — filled by the cmp-new interview._ Two or three adjectives for how this
28
+ app should feel (e.g. "calm, trustworthy, minimal" vs. "playful, bold, energetic"). This
29
+ seeds conversation 1 (design language) — the candidates it proposes react to what's written
30
+ here.
31
+
32
+ ## Reference apps
33
+
34
+ _not yet captured — filled by the cmp-new interview._ One to three apps whose look, feel, or
35
+ interaction patterns this app should be judged against ("closer to X than Y").
36
+
37
+ ## First screens
38
+
39
+ _not yet captured — filled by the cmp-new interview._ What are the first 2–4 screens you see
40
+ in your head? This grounds conversation 4 — the exemplar feature is the user's *actual* first
41
+ feature, not a generic placeholder.
42
+
43
+ ## Glossary
44
+
45
+ _not yet captured — filled by the cmp-new interview._ The domain nouns specific to this app —
46
+ one line each, in the app's own vocabulary, not generic template terms (e.g. "**Trip** — a
47
+ single planned outing with an itinerary and companions", not "**Item**"). Usually falls out of
48
+ the Purpose/First screens answers above; confirm the list with the human rather than guessing.
49
+ This section is lifted verbatim into `docs/ARCHITECTURE.md` §8 — write it in the form you want
50
+ published: a Markdown bullet list, `**Term** — definition` per line.