create-cmp-cli 0.9.0 → 0.10.1

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 (29) hide show
  1. package/README.md +16 -0
  2. package/package.json +1 -1
  3. package/src/lib/tabs.mjs +6 -0
  4. package/template/CLAUDE.md +24 -6
  5. package/template/README.md +9 -0
  6. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  7. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +108 -19
  8. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  9. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  10. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  11. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +3 -3
  12. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +11 -2
  13. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  14. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +14 -5
  15. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +10 -0
  16. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  17. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +33 -2
  18. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  19. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  20. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  21. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +43 -1
  22. package/template/docs/ARCHITECTURE.md +55 -0
  23. package/template/docs/TESTING.md +7 -0
  24. package/template/qa/e2e/smoke.yaml +6 -0
  25. package/template/qa/lib/a11y.mjs +17 -8
  26. package/template/qa/lib/approvals.mjs +44 -28
  27. package/template/qa/verify.mjs +63 -6
  28. package/template/qa/walkthrough.mjs +499 -0
  29. package/template/specs/app-base.spec.md +5 -0
@@ -49,6 +49,30 @@ function sh(cmd, opts = {}) {
49
49
  return { ok, status: res.status, error: res.error?.message, out: `${res.stdout ?? ""}${res.stderr ?? ""}`, durationMs: Date.now() - started };
50
50
  }
51
51
 
52
+ // ── Preview-daemon coexistence ──────────────────────────────────────────────
53
+ // The preview daemon (the eyes) and this lane both spawn Gradle against this
54
+ // project and share composeApp/build/kspCaches, whose KSP incremental storage
55
+ // is single-owner — two concurrent builds throw "Storage for [...] is already
56
+ // registered" and one side dies. Two defenses, both automatic:
57
+ // 1. COORDINATE: this lane stamps a marker file for its duration; the preview
58
+ // service defers renders while it exists (mtime-bounded, so a crashed lane
59
+ // never wedges the eyes for long).
60
+ // 2. SELF-HEAL: a Gradle step that still hits the collision clears kspCaches
61
+ // and retries once — the manual recovery that always worked, automated.
62
+ const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
63
+ const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
64
+
65
+ function shGradle(cmd, opts = {}) {
66
+ const first = sh(cmd, opts);
67
+ if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
68
+ console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
69
+ fs.rmSync(path.join(ROOT, "composeApp", "build", "kspCaches"), { recursive: true, force: true });
70
+ const retry = sh(cmd, opts);
71
+ retry.durationMs += first.durationMs;
72
+ retry.selfHealed = "ksp-cache-collision";
73
+ return retry;
74
+ }
75
+
52
76
  function tryGit(cmd) {
53
77
  try {
54
78
  return execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -57,6 +81,22 @@ function tryGit(cmd) {
57
81
  }
58
82
  }
59
83
 
84
+ /**
85
+ * Line-oriented git output, WITHOUT [tryGit]'s trim. `git status --porcelain`
86
+ * has significant leading whitespace: an unstaged modification is `" M path"`,
87
+ * so trimming the whole blob eats the first line's leading space — and a fixed
88
+ * `slice(3)` then swallows that path's first character. The receipt would name
89
+ * a file that does not exist. Only trailing newlines are dropped here.
90
+ */
91
+ function tryGitLines(cmd) {
92
+ try {
93
+ const out = execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
94
+ return out.replace(/\n+$/, "").split("\n").filter(Boolean);
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
60
100
  function junitSummary(dir) {
61
101
  if (!fs.existsSync(dir)) return null;
62
102
  let tests = 0, failures = 0, errors = 0, skipped = 0;
@@ -251,7 +291,7 @@ function stepArchDoc() {
251
291
  }
252
292
 
253
293
  function stepBuild() {
254
- const res = sh(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
294
+ const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
255
295
  return {
256
296
  name: "build",
257
297
  verdict: res.ok ? "PASS" : "FAIL",
@@ -265,7 +305,7 @@ function stepBuild() {
265
305
  // cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
266
306
  function gradleTestStep(name, testsFilter, failHint) {
267
307
  return () => {
268
- const res = sh(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
308
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
269
309
  return {
270
310
  name,
271
311
  verdict: res.ok ? "PASS" : "FAIL",
@@ -283,7 +323,7 @@ function stepUnitTests() {
283
323
  // produce byte-identical sources, and golden baselines aren't compile inputs), so the
284
324
  // receipt would attest tests that never executed. Compilation stays cached — only the
285
325
  // test execution is forced.
286
- const res = sh(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
326
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
287
327
  const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
288
328
  return {
289
329
  name: "unitTests",
@@ -436,7 +476,7 @@ function stepE2eSmoke() {
436
476
  if (!maestroAvailable()) {
437
477
  return { name: "e2eSmoke", verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
438
478
  }
439
- const install = sh(`${GRADLEW} :composeApp:installDebug --console=plain`);
479
+ const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
440
480
  if (!install.ok) {
441
481
  return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
442
482
  }
@@ -513,7 +553,12 @@ if (!stepsForProfile[profile]) {
513
553
  process.exit(2);
514
554
  }
515
555
 
556
+ // Stamp the lane marker for the run's duration (coexistence defense 1 above);
557
+ // always removed, even on a failing step, so the eyes only ever defer briefly.
558
+ fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
559
+ fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
516
560
  const steps = [];
561
+ try {
517
562
  for (const step of stepsForProfile[profile]) {
518
563
  const result = step();
519
564
  steps.push(result);
@@ -523,9 +568,20 @@ for (const step of stepsForProfile[profile]) {
523
568
  }
524
569
  if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
525
570
  }
571
+ } finally {
572
+ fs.rmSync(LANE_MARKER, { force: true });
573
+ }
526
574
 
527
575
  const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
528
576
 
577
+ // Receipt STRENGTH — a desktop-only green and an on-device green are different
578
+ // claims, and the difference should never live only in the SKIP lines. Device-
579
+ // dependent steps that actually RAN (PASSed) are named on the receipt and in the
580
+ // verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
581
+ const DEVICE_STEPS = ["e2eSmoke", "tokenDrift"];
582
+ const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
583
+ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
584
+
529
585
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
530
586
  const artifacts = [];
531
587
  if (fs.existsSync(ARTIFACTS_DIR)) {
@@ -554,13 +610,14 @@ const receipt = {
554
610
  verdict,
555
611
  commit: {
556
612
  sha: tryGit("rev-parse HEAD"),
557
- dirty: (tryGit("status --porcelain") ?? "").split("\n").filter(Boolean).map((l) => l.slice(3)).sort(),
613
+ dirty: tryGitLines("status --porcelain").map((l) => l.slice(3)).sort(),
558
614
  },
559
615
  inputs: {
560
616
  hash: inputs.hash,
561
617
  fileCount: inputs.fileCount,
562
618
  },
563
619
  steps,
620
+ strength: { onDeviceSteps },
564
621
  artifacts,
565
622
  toolVersions: {
566
623
  node: process.version,
@@ -576,6 +633,6 @@ fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(recei
576
633
  // git log of this file — every commit is one verified, attributed state.
577
634
 
578
635
  if (asJson) console.log(JSON.stringify(receipt, null, 2));
579
- else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} — receipt written to qa/evidence/latest.json (commit it with your change)`);
636
+ else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
580
637
 
581
638
  process.exit(verdict === "PASS" ? 0 : 1);
@@ -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
+ }
@@ -46,6 +46,11 @@
46
46
  When its source is inspected, Then it references neither `CircularProgressIndicator` nor
47
47
  `LinearProgressIndicator` directly — loading is presented through the components
48
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.
49
54
 
50
55
  ## App shell
51
56