create-cmp-cli 0.10.1 → 0.12.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 (52) hide show
  1. package/README.md +11 -9
  2. package/package.json +1 -1
  3. package/src/lib/package-name.mjs +72 -0
  4. package/src/lib/tabs.mjs +26 -0
  5. package/src/scaffold.mjs +7 -2
  6. package/template/.claude/settings.json +30 -0
  7. package/template/.claude/skills/add-feature/SKILL.md +20 -0
  8. package/template/.claude/skills/add-repository/SKILL.md +6 -0
  9. package/template/.claude/skills/add-screen/SKILL.md +6 -0
  10. package/template/CLAUDE.md +129 -8
  11. package/template/composeApp/build.gradle.kts +69 -0
  12. package/template/composeApp/proguard-rules.pro +12 -0
  13. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  25. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
  32. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  33. package/template/docs/ARCHITECTURE.md +41 -2
  34. package/template/docs/TESTING.md +165 -0
  35. package/template/gradle/libs.versions.toml +15 -0
  36. package/template/manifest.json +1 -0
  37. package/template/qa/approve.mjs +119 -11
  38. package/template/qa/evidence/schema.json +20 -2
  39. package/template/qa/lib/affected-tests.mjs +147 -0
  40. package/template/qa/lib/approvals.mjs +602 -21
  41. package/template/qa/lib/device-lease.mjs +249 -0
  42. package/template/qa/lib/evidence-level.mjs +117 -0
  43. package/template/qa/lib/feature-brief.mjs +324 -0
  44. package/template/qa/lib/inputs-hash.mjs +43 -6
  45. package/template/qa/lib/reachability.mjs +211 -0
  46. package/template/qa/lib/spec-coverage.mjs +131 -0
  47. package/template/qa/lib/step-cache.mjs +221 -0
  48. package/template/qa/receipt-check.mjs +22 -2
  49. package/template/qa/scaffold-feature.mjs +20 -1
  50. package/template/qa/verify.mjs +776 -95
  51. package/template/qa/watch.mjs +622 -0
  52. package/template/specs/app-base.spec.md +11 -0
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // The verify lane — this project's single verification gate.
3
3
  //
4
- // node qa/verify.mjs [--profile scaffold|local|ci] [--json]
4
+ // node qa/verify.mjs [--profile scaffold|local|ci|release] [--fast] [--json]
5
5
  //
6
6
  // Runs every verification step this project carries, aggregates a typed
7
7
  // PASS/FAIL verdict, and writes the evidence receipt to qa/evidence/latest.json.
8
+ // `--fast` is the INNER LOOP: the resolved profile minus the device/release
9
+ // tier, with unchanged pure-Node steps reused from the step cache (CACHED)
10
+ // and unit tests scoped to the working-tree change — its receipt records
11
+ // mode "fast" and can never satisfy the done-gate.
8
12
  // The receipt is COMMITTED with your change (see CLAUDE.md — a change is not
9
13
  // done without it). Binary artifacts under qa-artifacts/ are never committed;
10
14
  // the receipt references them by path + sha256.
@@ -17,6 +21,8 @@
17
21
  // scaffold — spec coverage + build + unit tests (what `create-cmp --verify` proves at stamp time)
18
22
  // local — everything; device-dependent steps SKIP when no device is attached
19
23
  // ci — everything; SKIPs are recorded so the pipeline stays honest
24
+ // release — everything ci proves PLUS the release-APK smoke (releaseSmoke): the
25
+ // ship-time profile, run before cutting a release, never per-change
20
26
 
21
27
  import { execSync, spawnSync } from "node:child_process";
22
28
  import { createHash } from "node:crypto";
@@ -27,19 +33,104 @@ import { fileURLToPath } from "node:url";
27
33
  import { computeInputsHash } from "./lib/inputs-hash.mjs";
28
34
  import { compareTokenDrift } from "./lib/token-drift.mjs";
29
35
  import { evaluateApprovalsGate } from "./lib/approvals.mjs";
36
+ import { clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from "./lib/spec-coverage.mjs";
30
37
  import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
38
+ import { evaluateReachability } from "./lib/reachability.mjs";
39
+ import { evidenceLevel } from "./lib/evidence-level.mjs";
40
+ import { memoizeStep } from "./lib/step-cache.mjs";
41
+ import { changedWorkingTreePaths, deriveAffectedFilter } from "./lib/affected-tests.mjs";
42
+ import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./lib/device-lease.mjs";
31
43
  import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
32
44
 
33
45
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
46
  const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
35
47
  const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
36
48
 
37
- const args = process.argv.slice(2);
49
+ // ── Argument parsing — strict, and first thing this file does ──────────────
50
+ // An unrecognized flag used to fall through silently and start the full
51
+ // multi-minute lane (`--help` ran the whole lane for ~2 minutes before being
52
+ // killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
53
+ // refuses an unknown artifact by name rather than guessing: an unknown
54
+ // argument here is refused by name, not swallowed into "run everything".
55
+ const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci|release] [--fast] [--json] [--help]
56
+
57
+ The verify lane — this project's single verification gate. Runs every
58
+ verification step this project carries, aggregates a typed PASS/FAIL
59
+ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
60
+ it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
61
+
62
+ Flags:
63
+ --profile <scaffold|local|ci|release>
64
+ which step set to run (default: local)
65
+ --fast INNER LOOP ONLY — run the resolved profile
66
+ minus the device/release tier (releaseBuild,
67
+ tokenDrift, e2eSmoke, androidChecks,
68
+ releaseSmoke), unconditionally, device
69
+ attached or not. Also reuses the pure-Node
70
+ steps' last PASS when their inputs are
71
+ unchanged (verdict CACHED), lets Gradle's
72
+ up-to-date checks stand (no --rerun), and
73
+ scopes unit tests to the working-tree change
74
+ (broad-impact changes run everything). The
75
+ receipt records mode "fast", derives no
76
+ evidence rung, and can NEVER satisfy the
77
+ done-gate — run the full lane once before
78
+ you call it done
79
+ --json print the receipt as JSON instead of the
80
+ human-readable step-by-step log
81
+ --help, -h print this usage and exit 0 without
82
+ running anything
83
+
84
+ Profiles:
85
+ scaffold spec coverage + build + unit tests (what \`create-cmp --verify\`
86
+ proves at stamp time)
87
+ local everything; device-dependent steps SKIP when no device is
88
+ attached
89
+ ci everything; SKIPs are recorded so the pipeline stays honest
90
+ release everything ci proves PLUS the release-APK smoke (releaseSmoke) —
91
+ the ship-time profile; run it before cutting a release, never
92
+ per-change
93
+ `;
94
+
95
+ const rawArgs = process.argv.slice(2);
96
+
97
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
98
+ console.log(USAGE);
99
+ process.exit(0);
100
+ }
101
+
102
+ const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast"]);
103
+ for (let i = 0; i < rawArgs.length; i += 1) {
104
+ const arg = rawArgs[i];
105
+ if (arg === "--profile") {
106
+ i += 1; // consume its value (missing/invalid value keeps the existing exit-2 behavior below)
107
+ continue;
108
+ }
109
+ if (RECOGNIZED_FLAGS.has(arg)) continue;
110
+ console.error(`unknown argument "${arg}" — run node qa/verify.mjs --help`);
111
+ process.exit(2);
112
+ }
113
+
114
+ const args = rawArgs;
38
115
  const profile = args.includes("--profile") ? args[args.indexOf("--profile") + 1] : "local";
39
116
  const asJson = args.includes("--json");
117
+ const fast = args.includes("--fast");
118
+ const mode = fast ? "fast" : "full";
40
119
 
41
120
  const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
42
121
 
122
+ // ── `--rerun` is scoped to FULL mode ────────────────────────────────────────
123
+ // `--rerun` exists for evidence integrity (see stepUnitTests's comment): it
124
+ // stops Gradle's build cache replaying a PASS recorded against a different
125
+ // tree into a receipt that claims tests executed. That mechanism belongs to
126
+ // the runs that produce integrity-bearing artifacts — and a --fast run does
127
+ // not: its receipt already declares itself non-evidence (mode "fast", no
128
+ // evidence rung, refused by qa/receipt-check.mjs), so forcing execution there
129
+ // paid an integrity tax to protect an artifact with nothing to protect. Fast
130
+ // mode therefore omits the flag and lets Gradle's up-to-date/cache machinery
131
+ // do its job; full mode keeps it, byte-identical to before.
132
+ const RERUN = fast ? "" : " --rerun";
133
+
43
134
  function sh(cmd, opts = {}) {
44
135
  const started = Date.now();
45
136
  // maxBuffer: first-run Gradle output easily exceeds spawnSync's 1MB default,
@@ -53,16 +144,52 @@ function sh(cmd, opts = {}) {
53
144
  // The preview daemon (the eyes) and this lane both spawn Gradle against this
54
145
  // project and share composeApp/build/kspCaches, whose KSP incremental storage
55
146
  // 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
147
+ // registered" and one side dies. Three defenses, all automatic:
148
+ // 1. COORDINATE (this lane -> the daemon): this lane stamps a marker file
149
+ // for its duration; the preview service defers renders while it exists
150
+ // (mtime-bounded, so a crashed lane never wedges the eyes for long).
151
+ // 2. COORDINATE (the daemon -> this lane), the symmetric half: the daemon
152
+ // stamps its OWN marker for the duration of a render's Gradle build;
153
+ // shGradle waits for it to clear (or go stale) before launching this
154
+ // lane's own Gradle command — same mtime-bounded shape, so a crashed
155
+ // daemon never wedges the lane for long either.
156
+ // 3. SELF-HEAL: a Gradle step that still hits the collision clears kspCaches
61
157
  // and retries once — the manual recovery that always worked, automated.
62
158
  const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
63
159
  const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
64
160
 
161
+ // The daemon's half of defense 2 above — pid + ISO timestamp, mirroring
162
+ // LANE_MARKER's own content shape (see where LANE_MARKER is stamped, below).
163
+ const RENDER_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-render-in-progress");
164
+ const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // older than this = a crashed daemon's stale marker, ignore it
165
+ const RENDER_WAIT_TIMEOUT_MS = 3 * 60 * 1000; // give up waiting after this long regardless
166
+ const RENDER_WAIT_POLL_MS = 2000;
167
+
168
+ /**
169
+ * Defer this lane's next Gradle command while the preview daemon's render
170
+ * marker is present AND fresh (mtime younger than RENDER_MARKER_FRESH_MS).
171
+ * Polls every RENDER_WAIT_POLL_MS; gives up and proceeds anyway after
172
+ * RENDER_WAIT_TIMEOUT_MS, or the moment the marker disappears or goes stale —
173
+ * whichever comes first. A missing/unreadable marker returns immediately:
174
+ * this is a coexistence courtesy, never a hard dependency on the daemon.
175
+ */
176
+ function waitForRenderMarker() {
177
+ const deadline = Date.now() + RENDER_WAIT_TIMEOUT_MS;
178
+ for (;;) {
179
+ let stat;
180
+ try {
181
+ stat = fs.statSync(RENDER_MARKER);
182
+ } catch {
183
+ return; // no render in flight
184
+ }
185
+ if (Date.now() - stat.mtimeMs >= RENDER_MARKER_FRESH_MS) return; // gone stale
186
+ if (Date.now() >= deadline) return; // waited long enough — proceed regardless
187
+ sh(`sleep ${RENDER_WAIT_POLL_MS / 1000}`);
188
+ }
189
+ }
190
+
65
191
  function shGradle(cmd, opts = {}) {
192
+ waitForRenderMarker();
66
193
  const first = sh(cmd, opts);
67
194
  if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
68
195
  console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
@@ -97,19 +224,31 @@ function tryGitLines(cmd) {
97
224
  }
98
225
  }
99
226
 
227
+ // Recursive: desktopTest writes TEST-*.xml flat, but connected (instrumented) results
228
+ // land one directory level down per device (build/outputs/androidTest-results/connected/
229
+ // debug/<device>/TEST-*.xml) — both shapes are summarized by the same walk.
100
230
  function junitSummary(dir) {
101
231
  if (!fs.existsSync(dir)) return null;
102
232
  let tests = 0, failures = 0, errors = 0, skipped = 0;
103
- for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("TEST-") && f.endsWith(".xml"))) {
104
- const xml = fs.readFileSync(path.join(dir, f), "utf8");
105
- const m = xml.match(/<testsuite[^>]*tests="(\d+)"[^>]*skipped="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"/);
106
- if (m) {
107
- tests += Number(m[1]);
108
- skipped += Number(m[2]);
109
- failures += Number(m[3]);
110
- errors += Number(m[4]);
233
+ const walk = (d) => {
234
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
235
+ const p = path.join(d, entry.name);
236
+ if (entry.isDirectory()) {
237
+ walk(p);
238
+ continue;
239
+ }
240
+ if (!entry.name.startsWith("TEST-") || !entry.name.endsWith(".xml")) continue;
241
+ const xml = fs.readFileSync(p, "utf8");
242
+ const m = xml.match(/<testsuite[^>]*tests="(\d+)"[^>]*skipped="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"/);
243
+ if (m) {
244
+ tests += Number(m[1]);
245
+ skipped += Number(m[2]);
246
+ failures += Number(m[3]);
247
+ errors += Number(m[4]);
248
+ }
111
249
  }
112
- }
250
+ };
251
+ walk(dir);
113
252
  return { tests, failures, errors, skipped };
114
253
  }
115
254
 
@@ -119,27 +258,111 @@ function deviceAttached() {
119
258
  return res.out.split("\n").slice(1).some((l) => /\tdevice$/.test(l.trim().replace(/\s+/g, "\t")));
120
259
  }
121
260
 
122
- // Recursive directory walker (no glob dependency) — returns files under `dir`
123
- // whose name ends with one of `exts`.
124
- function walkFiles(dir, exts) {
125
- const out = [];
126
- if (!fs.existsSync(dir)) return out;
127
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
128
- const p = path.join(dir, entry.name);
129
- if (entry.isDirectory()) out.push(...walkFiles(p, exts));
130
- else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(p);
261
+ // ── The machine-global device lease (qa/lib/device-lease.mjs) ───────────────
262
+ // LANE_MARKER above is per-PROJECT; the device is machine-GLOBAL. A scratch app
263
+ // in /tmp and the real app each stamp their own marker and still share the one
264
+ // emulator nothing stopped two lanes (or a lane and a live console session)
265
+ // driving it at once, which is the observed wedged-adbd / `device offline` /
266
+ // crossed-app-state failure class. Every device-touching step below takes the
267
+ // lease before touching the device.
268
+ //
269
+ // SCOPE DECISION once per run, not per step: the lease is acquired lazily by
270
+ // the FIRST device step that actually reaches the device and held until the
271
+ // lane exits (released in the same `finally` as LANE_MARKER). A single run must
272
+ // not thrash acquire/release between adjacent device steps, and holding through
273
+ // the desktop steps interleaved among them (a11y sits between tokenDrift and
274
+ // e2eSmoke) costs nothing — nothing else should drive the device mid-lane
275
+ // anyway, which is the whole point.
276
+ //
277
+ // ON CONTENTION THE STEP RETURNS SKIP — NEVER FAIL: nothing is broken; another
278
+ // run legitimately holds the device. This composes with the evidence ladder
279
+ // (qa/lib/evidence-level.mjs): a SKIPped device step simply does not buy its
280
+ // rung, so contention visibly DEGRADES the evidence level (L2 falls back to L1)
281
+ // instead of corrupting the run with a false red — that degradation being
282
+ // honest and visible is exactly why SKIP is the right verdict.
283
+ let laneDeviceLease = null;
284
+
285
+ /** Serials of devices currently in `device` state (same parse as deviceAttached). */
286
+ function attachedDeviceSerials() {
287
+ const res = sh("adb devices", { timeout: 10_000 });
288
+ if (!res.ok) return [];
289
+ return res.out
290
+ .split("\n")
291
+ .slice(1)
292
+ .map((l) => l.trim())
293
+ .filter(Boolean)
294
+ .map((l) => l.split(/\s+/))
295
+ .filter(([, state]) => state === "device")
296
+ .map(([serial]) => serial);
297
+ }
298
+
299
+ /**
300
+ * Acquire (or confirm) the lane's device lease for a device step.
301
+ * Returns null when the lane holds the device; otherwise the SKIP result the
302
+ * step should return verbatim. The serial leased is the one the lane will
303
+ * actually drive: the single attached device, or ANDROID_SERIAL when several
304
+ * are attached (adb/Gradle/Maestro honor the same variable). Ambiguity is
305
+ * SKIPped by name — leasing a guess would protect the wrong device.
306
+ */
307
+ function leaseDeviceForStep(stepName) {
308
+ if (laneDeviceLease) return null; // already held for this run
309
+ const serials = attachedDeviceSerials();
310
+ if (serials.length === 0) return null; // each step's own guard SKIPs "no device" with its precise reason
311
+ let serial = serials[0];
312
+ if (serials.length > 1) {
313
+ const chosen = process.env.ANDROID_SERIAL;
314
+ if (chosen && serials.includes(chosen)) {
315
+ serial = chosen;
316
+ } else {
317
+ return {
318
+ name: stepName,
319
+ verdict: "SKIP",
320
+ reason: `${serials.length} devices attached (${serials.join(", ")}) — the lane cannot tell which one it would drive, so it leases none rather than guessing. Set ANDROID_SERIAL to the device this lane should own, or detach the extras.`,
321
+ durationMs: 0,
322
+ };
323
+ }
324
+ }
325
+ const res = acquireDeviceLease({ serial, holder: `verify lane ${stepName}`, root: ROOT });
326
+ if (!res.ok) {
327
+ return {
328
+ name: stepName,
329
+ verdict: "SKIP",
330
+ reason: `device ${serial} is held by ${formatHolder(res.heldBy)} — device evidence is batched, not concurrent; wait for it or run once when it finishes`,
331
+ durationMs: 0,
332
+ };
333
+ }
334
+ if (res.reclaimed) {
335
+ console.error(`· reclaimed a dead device lease on ${serial} (was ${formatHolder(res.reclaimed)})`);
131
336
  }
132
- return out;
337
+ laneDeviceLease = res.handle;
338
+ return null;
339
+ }
340
+
341
+ // Settle adb before handing the device to whatever drives it next (Maestro, the
342
+ // instrumented runner). An install task returning 0 means the package manager accepted
343
+ // the APK — NOT that the device is ready to be driven: a reinstall over a running app
344
+ // briefly drops the emulator's adb transport. `adb devices` still says `device`, but a
345
+ // fresh adb client (Maestro's dadb, Gradle's ddmlib) gets `device offline` and dies
346
+ // before the first assertion (observed 4/4 when the live-inspector tier ran earlier in
347
+ // the lane — its port-forward traffic widens the window — and 0/4 when it was skipped).
348
+ // wait-for-device blocks only while the transport is actually down; the kill/start pair
349
+ // ahead of it clears a stale server-side transport entry that survives the device coming
350
+ // back. Neither weakens any assertion — every downstream check still passes on its own
351
+ // merits.
352
+ function settleAdb() {
353
+ sh("adb kill-server");
354
+ sh("adb start-server");
355
+ sh("adb wait-for-device");
133
356
  }
134
357
 
135
358
  // ── Steps ──────────────────────────────────────────────────────────────────
136
359
  // Each returns { name, verdict, reason?, durationMs, details? }. Failure
137
360
  // reasons are worded for an AI collaborator to act on.
138
361
 
139
- // Spec ↔ test drift gate — pure Node, no Gradle. Parses clause ids out of
140
- // specs/*.spec.md (live: `- **ID**`, withdrawn: `- ~~**ID**~~`, exempt from
141
- // coverage) and `// SPEC:` / `# SPEC:` citation tags out of composeApp/src and
142
- // qa/e2e, then fails on orphans in either direction.
362
+ // Spec ↔ test drift gate — pure Node, no Gradle. The clause/citation scan
363
+ // itself lives in qa/lib/spec-coverage.mjs the SAME scan feature-brief.mjs
364
+ // derives doneness from, so this gate and the Features view can never disagree
365
+ // about a clause. This step owns only the orphan decision + bookkeeping.
143
366
  function stepSpecCoverage() {
144
367
  const started = Date.now();
145
368
  const specsDir = path.join(ROOT, "specs");
@@ -147,38 +370,22 @@ function stepSpecCoverage() {
147
370
  return { name: "specCoverage", verdict: "SKIP", reason: "no specs/ directory in this project", durationMs: Date.now() - started };
148
371
  }
149
372
 
150
- const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
151
- const clauses = new Map(); // id -> { file, withdrawn }
152
- const specFiles = fs.readdirSync(specsDir).filter((f) => f.endsWith(".spec.md")).map((f) => path.join(specsDir, f));
153
- for (const f of specFiles) {
154
- for (const line of fs.readFileSync(f, "utf8").split("\n")) {
155
- const m = line.match(CLAUSE_LINE_RE);
156
- if (!m) continue;
157
- clauses.set(m[2], { file: path.relative(ROOT, f), withdrawn: Boolean(m[1]) });
158
- }
159
- }
160
-
161
- const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
162
- const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
373
+ const clauses = scanSpecClauses(ROOT);
374
+ const tags = scanCitations(ROOT);
163
375
  const searchDirs = [path.join(ROOT, "composeApp/src"), path.join(ROOT, "qa/e2e")];
164
376
  const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
165
- const tags = [];
166
- for (const f of files) {
167
- fs.readFileSync(f, "utf8").split("\n").forEach((line, i) => {
168
- const trimmed = line.trim();
169
- if (!TAG_LINE_RE.test(trimmed)) return;
170
- const m = trimmed.match(TAG_IDS_RE);
171
- if (!m) return;
172
- const ids = m[1].split(/[,\s]+/).map((s) => s.trim()).filter((s) => /^[A-Z][A-Z0-9]*-\d{2,}$/.test(s));
173
- for (const id of ids) tags.push({ id, file: path.relative(ROOT, f), line: i + 1 });
174
- });
175
- }
176
377
 
177
378
  const citedIds = new Set(tags.map((t) => t.id));
178
379
  const orphanClauses = [...clauses.entries()].filter(([, c]) => !c.withdrawn).filter(([id]) => !citedIds.has(id));
179
380
  const orphanTags = tags.filter((t) => !clauses.has(t.id) || clauses.get(t.id).withdrawn);
180
381
 
181
382
  if (orphanClauses.length === 0 && orphanTags.length === 0) {
383
+ // Tier visibility, not a gate (industry rule: instrument before you police). A clause
384
+ // cited only from desktop-tier tests can still hide a platform-behavior bug — both
385
+ // production apps shipped alarm/notification defects behind clauses that were
386
+ // "covered" by JVM tests androidMain never ran under. The line names them; the
387
+ // instrumented seam (androidChecks) is where such clauses earn a citation.
388
+ const tiers = clauseTierCoverage(clauses, tags);
182
389
  return {
183
390
  name: "specCoverage",
184
391
  verdict: "PASS",
@@ -188,6 +395,7 @@ function stepSpecCoverage() {
188
395
  withdrawn: [...clauses.values()].filter((c) => c.withdrawn).length,
189
396
  tags: tags.length,
190
397
  files: files.length,
398
+ tierNote: tiers.summaryLine,
191
399
  },
192
400
  };
193
401
  }
@@ -235,6 +443,12 @@ function stepApprovals() {
235
443
  };
236
444
  }
237
445
 
446
+ // There is deliberately NO feature-doneness step here (CHANGE-FLOW-DESIGN.md
447
+ // §7): a feature's doneness is DERIVED from gates this lane already runs —
448
+ // specCoverage fails an uncited clause, the test steps fail a broken promise,
449
+ // and the receipt's inputs.hash attests the tree. A second mechanism would be
450
+ // a second truth.
451
+
238
452
  // Component ↔ story parity gate (STUDIO-REDESIGN.md §3.3) — pure Node, no
239
453
  // Gradle, same grouping as specCoverage/approvals. The decision itself lives
240
454
  // in qa/lib/component-stories.mjs (evaluateComponentStoryParity); this step
@@ -245,6 +459,18 @@ function stepComponentStories() {
245
459
  return { name: "componentStories", verdict, reason, durationMs: Date.now() - started, details };
246
460
  }
247
461
 
462
+ // Navigation-reachability gate (task FI-7, docs/AUTONOMY-GAPS.md §3) — pure
463
+ // Node, no Gradle, same grouping as specCoverage/approvals/componentStories.
464
+ // The decision itself lives in qa/lib/reachability.mjs (evaluateReachability);
465
+ // this step only adds the name/duration bookkeeping every step in this file
466
+ // carries. Closes the exact hole a real feature slipped through: every other
467
+ // gate PASSed while its screen was wired into nothing.
468
+ function stepReachability() {
469
+ const started = Date.now();
470
+ const { verdict, reason, details } = evaluateReachability(ROOT);
471
+ return { name: "reachability", verdict, reason, durationMs: Date.now() - started, details };
472
+ }
473
+
248
474
  // Architecture-doc freshness gate (Wave B, docs/proposals/architecture-document-
249
475
  // standard.md §6) — pure Node, no Gradle, same grouping as specCoverage/
250
476
  // approvals. The decision itself lives in qa/lib/arch-doc.mjs
@@ -290,6 +516,82 @@ function stepArchDoc() {
290
516
  };
291
517
  }
292
518
 
519
+ // Schema-history gate — pure Node + git, no Gradle, same grouping as the other
520
+ // evidence checks. Room's exportSchema writes one <version>.json per database per
521
+ // target under composeApp/schemas/. Every version EXCEPT the current highest is a
522
+ // frozen historical record of a database that shipped: migrations are written and
523
+ // validated against those exact bytes, so a regeneration that rewrites them
524
+ // silently corrupts the baseline every future migration is proven against. Only
525
+ // the highest version is the live, in-progress schema — free to change or appear
526
+ // (that IS the current change). This gate exists because schema regeneration
527
+ // looks like harmless build output right up until a shipped user's upgrade fails.
528
+ function stepSchemaHistory() {
529
+ const started = Date.now();
530
+ const elapsed = () => Date.now() - started;
531
+ const schemasRel = path.join("composeApp", "schemas");
532
+ const schemasRoot = path.join(ROOT, schemasRel);
533
+
534
+ if (!fs.existsSync(schemasRoot)) {
535
+ return { name: "schemaHistory", verdict: "SKIP", reason: "no exported Room schemas (composeApp/schemas/ absent) — nothing frozen to guard", durationMs: elapsed() };
536
+ }
537
+ const gitTop = tryGit("rev-parse --show-toplevel");
538
+ if (!gitTop || !tryGit("rev-parse HEAD")) {
539
+ return { name: "schemaHistory", verdict: "SKIP", reason: "no git history yet — schema versions have no committed baseline to be frozen against", durationMs: elapsed() };
540
+ }
541
+
542
+ // Every directory holding versioned schema JSONs, with its highest version on disk.
543
+ const versionFile = /^(\d+)\.json$/;
544
+ const maxVersionByDir = new Map(); // absolute dir path -> highest N among its N.json files
545
+ const walkSchemas = (dir) => {
546
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
547
+ const p = path.join(dir, entry.name);
548
+ if (entry.isDirectory()) walkSchemas(p);
549
+ else {
550
+ const m = entry.name.match(versionFile);
551
+ if (m) maxVersionByDir.set(dir, Math.max(maxVersionByDir.get(dir) ?? 0, Number(m[1])));
552
+ }
553
+ }
554
+ };
555
+ walkSchemas(schemasRoot);
556
+
557
+ // Tracked schema files whose committed bytes no longer match the tree (staged or
558
+ // unstaged; deletions included). Paths come back relative to the git toplevel.
559
+ // Untracked files never appear here — a brand-new version file is by definition
560
+ // not yet frozen history.
561
+ const dirtyFiles = tryGitLines(`diff --name-only HEAD -- "${schemasRel.replace(/\\/g, "/")}"`);
562
+
563
+ const violations = [];
564
+ for (const rel of dirtyFiles) {
565
+ const abs = path.resolve(gitTop, rel);
566
+ const m = path.basename(abs).match(versionFile);
567
+ if (!m) continue; // not a versioned schema JSON
568
+ const version = Number(m[1]);
569
+ const dirMax = maxVersionByDir.get(path.dirname(abs));
570
+ // The highest version currently on disk is the live schema — dirty is fine.
571
+ // Anything else (a lower version, or a file whose whole directory is gone)
572
+ // is rewritten/deleted history.
573
+ if (dirMax !== undefined && version === dirMax) continue;
574
+ violations.push(rel);
575
+ }
576
+
577
+ if (violations.length === 0) {
578
+ return { name: "schemaHistory", verdict: "PASS", durationMs: elapsed(), details: { schemaDirs: maxVersionByDir.size } };
579
+ }
580
+
581
+ const lines = [
582
+ "Historical Room schema files were modified or deleted — these are frozen records of shipped databases, and regeneration must never rewrite them (migrations are validated against these exact bytes). Only the current highest version may change. Restore each file:",
583
+ ];
584
+ for (const rel of violations) lines.push(` git checkout -- ${rel}`);
585
+ lines.push("If you intended a schema change, bump the database version so a NEW <version>.json is exported instead of overwriting history.");
586
+ return {
587
+ name: "schemaHistory",
588
+ verdict: "FAIL",
589
+ reason: lines.join("\n"),
590
+ durationMs: elapsed(),
591
+ details: { schemaDirs: maxVersionByDir.size, violations },
592
+ };
593
+ }
594
+
293
595
  function stepBuild() {
294
596
  const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
295
597
  return {
@@ -300,12 +602,42 @@ function stepBuild() {
300
602
  };
301
603
  }
302
604
 
605
+ // The build nobody runs until the day they need it.
606
+ //
607
+ // assembleDebug passing says nothing about assembleRelease: R8 and `lintVital` only run on
608
+ // the release variant, and BuildConfig is generated PER BUILD TYPE, so a constant declared
609
+ // in one and not the other is a compile error that only release ever sees. All three of
610
+ // those bit this template at once, and none of them were visible from a green debug lane —
611
+ // the first release build ever attempted (2026-07-29) failed three times over.
612
+ //
613
+ // So release is proven at the checkpoint, not discovered at launch. Unsigned: signing needs
614
+ // a keystore, which belongs to whoever ships the app, and this step is about the shrinker
615
+ // and the build graph rather than the signature.
616
+ function stepReleaseBuild() {
617
+ const res = shGradle(`${GRADLEW} :composeApp:assembleRelease --console=plain`);
618
+ return {
619
+ name: "releaseBuild",
620
+ verdict: res.ok ? "PASS" : "FAIL",
621
+ reason: res.ok
622
+ ? undefined
623
+ : `assembleRelease failed — the shippable build is broken even though the debug one is fine:\n${res.out
624
+ .split("\n")
625
+ .filter((l) => /error|FAILURE|Missing class|Unresolved/i.test(l))
626
+ .slice(0, 12)
627
+ .join("\n")}`,
628
+ durationMs: res.durationMs,
629
+ };
630
+ }
631
+
303
632
  // Runs a filtered slice of the JVM test tier and names the verdict after the gate it proves.
304
633
  // The full suite already ran in unitTests; the filtered slices stay cheap (compilation is
305
634
  // cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
635
+ // In fast mode the flag is omitted (RERUN, defined with the mode flags above): the
636
+ // integrity mechanism belongs to the runs that produce integrity-bearing artifacts, and a
637
+ // fast receipt has already declared itself non-evidence.
306
638
  function gradleTestStep(name, testsFilter, failHint) {
307
639
  return () => {
308
- const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
640
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --tests "${testsFilter}" --console=plain`);
309
641
  return {
310
642
  name,
311
643
  verdict: res.ok ? "PASS" : "FAIL",
@@ -322,17 +654,61 @@ function stepUnitTests() {
322
654
  // restore a PASS recorded against a *different* tree state (deterministic re-scaffolds
323
655
  // produce byte-identical sources, and golden baselines aren't compile inputs), so the
324
656
  // receipt would attest tests that never executed. Compilation stays cached — only the
325
- // test execution is forced.
326
- const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
657
+ // test execution is forced. Scoped to FULL mode (see RERUN above): the integrity
658
+ // mechanism belongs to the runs that produce integrity-bearing artifacts, and a fast
659
+ // receipt is already declared non-evidence.
660
+ //
661
+ // Fast mode additionally scopes the suite to tests plausibly affected by the
662
+ // working-tree change (qa/lib/affected-tests.mjs): changed .kt files map to
663
+ // `--tests "*<segment>*"` patterns, with a mandatory blast-radius escape hatch (build
664
+ // files, DI, theme, shared components, qa/, anything outside composeApp/src → full
665
+ // suite) and fail-open on every uncertain case (no git, unmappable change). FALSE
666
+ // NEGATIVES ARE ACCEPTABLE HERE AND ONLY HERE: the full, unfiltered suite runs at the
667
+ // checkpoint (the full lane), where done is actually decided. The filter that ran is
668
+ // reported in the step's note and recorded in the (fast-only) receipt, so a filtered
669
+ // run can never be mistaken for the full suite.
670
+ let note;
671
+ let testsArgs = "";
672
+ let affected = null;
673
+ if (fast) {
674
+ const changed = changedWorkingTreePaths(ROOT);
675
+ if (changed === null) {
676
+ note = "full suite — git unavailable, cannot derive the change (fail open)";
677
+ } else {
678
+ const filter = deriveAffectedFilter(changed);
679
+ if (filter.mode === "filtered") {
680
+ testsArgs = filter.patterns.map((p) => ` --tests "${p}"`).join("");
681
+ note = `affected: ${filter.patterns.join(", ")} — ${filter.sourcePaths.length} changed source file(s)`;
682
+ affected = { patterns: filter.patterns, changedFiles: filter.sourcePaths.length };
683
+ } else {
684
+ note = `full suite — ${filter.reason}`;
685
+ }
686
+ }
687
+ }
688
+ let res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN}${testsArgs} --console=plain`);
689
+ if (fast && testsArgs && !res.ok && /No tests found for given includes/.test(res.out)) {
690
+ // The heuristic filter matched no test class at all (e.g. a feature with no tests
691
+ // yet). That is the harness's guess being wrong, not the app — fall back to the
692
+ // full suite in-lane rather than false-redding on our own filter. (RERUN is empty
693
+ // here by construction — this branch only exists in fast mode.)
694
+ const retry = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`);
695
+ retry.durationMs += res.durationMs;
696
+ res = retry;
697
+ note = "full suite — the affected-test filter matched no tests (fell back)";
698
+ affected = null;
699
+ }
327
700
  const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
701
+ let details = summary ?? undefined;
702
+ if (affected) details = { ...(summary ?? {}), affected };
328
703
  return {
329
704
  name: "unitTests",
330
705
  verdict: res.ok ? "PASS" : "FAIL",
331
706
  reason: res.ok
332
707
  ? undefined
333
708
  : `desktopTest failed (${summary ? `${summary.failures + summary.errors} of ${summary.tests} tests` : "see output"}). Fix the failing behavior — do not delete or weaken tests to pass:\n${res.out.split("\n").filter((l) => /FAILED|error:/i.test(l)).slice(0, 12).join("\n")}`,
709
+ note,
334
710
  durationMs: res.durationMs,
335
- details: summary ?? undefined,
711
+ details,
336
712
  };
337
713
  }
338
714
 
@@ -408,6 +784,10 @@ function stepTokenDrift() {
408
784
  durationMs: elapsed(),
409
785
  });
410
786
 
787
+ // Machine-global lease before the first device touch (contention = SKIP).
788
+ const leaseSkip = leaseDeviceForStep("tokenDrift");
789
+ if (leaseSkip) return { ...leaseSkip, durationMs: elapsed() };
790
+
411
791
  sh(`adb forward tcp:${INSPECTOR_PORT} tcp:${INSPECTOR_PORT}`);
412
792
  try {
413
793
  let health = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/health`);
@@ -466,32 +846,37 @@ function maestroAvailable() {
466
846
  return sh("maestro --version", { timeout: 15_000 }).ok;
467
847
  }
468
848
 
469
- function stepE2eSmoke() {
849
+ // The e2e guard trio, shared by every step that drives the smoke flow on a device.
850
+ // Returns null when the harness is fully available, else the SKIP result for [name].
851
+ function maestroGuards(name) {
470
852
  if (!fs.existsSync(path.join(ROOT, "qa/e2e"))) {
471
- return { name: "e2eSmoke", verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
853
+ return { name, verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
472
854
  }
473
855
  if (!deviceAttached()) {
474
- return { name: "e2eSmoke", verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
856
+ return { name, verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
475
857
  }
476
858
  if (!maestroAvailable()) {
477
- return { name: "e2eSmoke", verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
859
+ return { name, verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
478
860
  }
479
- const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
480
- if (!install.ok) {
481
- return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
482
- }
483
- // Harden the device for headless/CI automation before driving it. Without this, a slow or
484
- // loaded emulator produces false reds that have nothing to do with the app:
485
- // - hide_error_dialogs=1 stops Android popping ANR/crash dialogs (e.g. SystemUI under load)
486
- // that steal focus over the app — a Maestro assert would then see only the dialog;
487
- // - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
488
- // up on a slow emulator (the built-in default gives up too early under load).
489
- // Both are benign, reversible, and only touch the device while the lane is driving it —
490
- // hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
491
- // to its default) in the finally below, on every exit path.
492
- // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event so after the
493
- // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
494
- // them. The eyes must report what automation stability had to hide.
861
+ return null;
862
+ }
863
+
864
+ // Drives qa/e2e/smoke.yaml against whatever build is installed, with the device hardened
865
+ // for headless/CI automation. Shared by e2eSmoke (debug APK) and releaseSmoke (release
866
+ // APK) so the hardening and the honesty sweep can never drift apart between variants.
867
+ // Without the hardening, a slow or loaded emulator produces false reds that have nothing
868
+ // to do with the app:
869
+ // - hide_error_dialogs=1 stops Android popping ANR/crash dialogs (e.g. SystemUI under load)
870
+ // that steal focus over the app a Maestro assert would then see only the dialog;
871
+ // - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
872
+ // up on a slow emulator (the built-in default gives up too early under load).
873
+ // Both are benign, reversible, and only touch the device while the lane is driving it —
874
+ // hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
875
+ // to its default) in the finally below, on every exit path.
876
+ // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event so after the
877
+ // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
878
+ // them. The eyes must report what automation stability had to hide.
879
+ function runMaestroSmoke(name, priorDurationMs) {
495
880
  const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
496
881
  sh("adb shell settings put global hide_error_dialogs 1");
497
882
  sh("adb logcat -c"); // clear so the post-run dump only reflects this run
@@ -499,10 +884,10 @@ function stepE2eSmoke() {
499
884
  const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
500
885
  if (!res.ok) {
501
886
  return {
502
- name: "e2eSmoke",
887
+ name,
503
888
  verdict: "FAIL",
504
889
  reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
505
- durationMs: install.durationMs + res.durationMs,
890
+ durationMs: priorDurationMs + res.durationMs,
506
891
  };
507
892
  }
508
893
  const anrDump = sh("adb logcat -d -b system,crash,main");
@@ -510,13 +895,13 @@ function stepE2eSmoke() {
510
895
  if (anrDump.ok && anrRe.test(anrDump.out)) {
511
896
  const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
512
897
  return {
513
- name: "e2eSmoke",
898
+ name,
514
899
  verdict: "FAIL",
515
900
  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}`,
516
- durationMs: install.durationMs + res.durationMs,
901
+ durationMs: priorDurationMs + res.durationMs,
517
902
  };
518
903
  }
519
- return { name: "e2eSmoke", verdict: "PASS", durationMs: install.durationMs + res.durationMs };
904
+ return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs };
520
905
  } finally {
521
906
  if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
522
907
  sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
@@ -526,62 +911,341 @@ function stepE2eSmoke() {
526
911
  }
527
912
  }
528
913
 
914
+ function stepE2eSmoke() {
915
+ const guard = maestroGuards("e2eSmoke");
916
+ if (guard) return guard;
917
+ // Machine-global lease before the first device touch (contention = SKIP).
918
+ const leaseSkip = leaseDeviceForStep("e2eSmoke");
919
+ if (leaseSkip) return leaseSkip;
920
+ const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
921
+ if (!install.ok) {
922
+ return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
923
+ }
924
+ settleAdb();
925
+ return runMaestroSmoke("e2eSmoke", install.durationMs);
926
+ }
927
+
928
+ // Instrumented behavior tier (composeApp/src/androidInstrumentedTest) — the one step
929
+ // whose evidence crosses the process boundary. Alarms, notification channels,
930
+ // full-screen intents, PendingIntent identity, and audio routing are OS facts:
931
+ // desktopTest is a JVM, golden trees are structure, the conformance suite is static,
932
+ // and the Maestro smoke taps UI without asserting anything about the shade or the
933
+ // alarm table. Nine escaped platform-semantics defects across two real apps trace to
934
+ // exactly this blind spot; the hand-built precursor of this step caught two bugs the
935
+ // week it landed. `connectedDebugAndroidTest` builds, installs, and runs the
936
+ // instrumented suite in the app's real process on the attached device.
937
+ //
938
+ // SKIP (never FAIL) on missing infrastructure — no device, or no instrumented sources
939
+ // yet — mirroring e2eSmoke's stance: absence of the tier is recorded honestly, only
940
+ // broken behavior fails.
941
+ function stepAndroidChecks() {
942
+ const started = Date.now();
943
+ const instrumentedDir = path.join(ROOT, "composeApp/src/androidInstrumentedTest");
944
+ const hasSources = fs.existsSync(instrumentedDir) &&
945
+ walkFiles(instrumentedDir, [".kt"]).length > 0;
946
+ if (!hasSources) {
947
+ return {
948
+ name: "androidChecks",
949
+ verdict: "SKIP",
950
+ reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
951
+ durationMs: Date.now() - started,
952
+ };
953
+ }
954
+ if (!deviceAttached()) {
955
+ return {
956
+ name: "androidChecks",
957
+ verdict: "SKIP",
958
+ reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
959
+ durationMs: Date.now() - started,
960
+ };
961
+ }
962
+ // Machine-global lease before the first device touch (contention = SKIP).
963
+ const leaseSkip = leaseDeviceForStep("androidChecks");
964
+ if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
965
+ // Settle before Gradle's own install+drive: earlier lane steps (tokenDrift's
966
+ // port-forwards, e2eSmoke's reinstall) can leave the transport stale — see settleAdb.
967
+ settleAdb();
968
+ // `--rerun` for the same evidence-integrity reason as stepUnitTests: the receipt must
969
+ // attest tests that EXECUTED on this tree, never a replayed up-to-date verdict.
970
+ const res = shGradle(`${GRADLEW} :composeApp:connectedDebugAndroidTest --rerun --console=plain`);
971
+ const summary = junitSummary(path.join(ROOT, "composeApp/build/outputs/androidTest-results/connected"));
972
+ return {
973
+ name: "androidChecks",
974
+ verdict: res.ok ? "PASS" : "FAIL",
975
+ reason: res.ok
976
+ ? undefined
977
+ : `connectedDebugAndroidTest failed (${summary ? `${summary.failures + summary.errors} of ${summary.tests} tests` : "see output"}) — an on-device behavior claim is broken. Fix the behavior, not the test:\n${res.out.split("\n").filter((l) => /FAILED|error:|failed/i.test(l)).slice(0, 12).join("\n")}`,
978
+ durationMs: Date.now() - started,
979
+ details: summary ?? undefined,
980
+ };
981
+ }
982
+
983
+ // Release-APK smoke — the behavior half of stepReleaseBuild. assembleRelease proves R8
984
+ // and the build graph COMPILE; two real bugs were only findable by *running* the release
985
+ // variant (R8 behavior differs from debug). Installs the release APK and drives the same
986
+ // Maestro smoke flow against it. Ship-time cost by design: this step exists only in the
987
+ // `release` profile, never per-change.
988
+ //
989
+ // Honesty notes, both deliberate:
990
+ // - A template-fresh app has NO release signingConfig (the keystore belongs to whoever
991
+ // ships), and an unsigned APK cannot be installed. That is a SKIP naming what to
992
+ // configure, never a FAIL — a fresh scaffold must not red-bar on a keystore it was
993
+ // never given.
994
+ // - This step reinstalls NOTHING afterwards: the release build stays on the device,
995
+ // which is the honest state ("what is installed is what was last proven"). The next
996
+ // debug install over it will hit INSTALL_FAILED_UPDATE_INCOMPATIBLE (release and debug
997
+ // signatures differ) — run `adb uninstall <applicationId>` first; the same applies in
998
+ // reverse here, so that raw Gradle error is translated into the actionable message.
999
+ function stepReleaseSmoke() {
1000
+ const guard = maestroGuards("releaseSmoke");
1001
+ if (guard) return guard;
1002
+
1003
+ let gradleText = "";
1004
+ try {
1005
+ gradleText = fs.readFileSync(path.join(ROOT, "composeApp/build.gradle.kts"), "utf8");
1006
+ } catch {
1007
+ gradleText = "";
1008
+ }
1009
+ const applicationId = gradleText.match(/applicationId\s*=\s*"([^"]+)"/)?.[1] ?? "<applicationId>";
1010
+ if (!/signingConfig/.test(gradleText)) {
1011
+ return {
1012
+ name: "releaseSmoke",
1013
+ verdict: "SKIP",
1014
+ reason:
1015
+ "release APK is unsigned — no signingConfig in composeApp/build.gradle.kts. To enable the release smoke: create a keystore (keytool -genkeypair), declare android.signingConfigs { create(\"release\") { … } } from a gitignored keystore.properties, and set buildTypes.release.signingConfig. The keystore is yours to keep out of the repo.",
1016
+ durationMs: 0,
1017
+ };
1018
+ }
1019
+
1020
+ // Machine-global lease before the first device touch (contention = SKIP).
1021
+ // After the signing check on purpose: an unsigned template SKIPs on the
1022
+ // keystore without ever needing the device.
1023
+ const leaseSkip = leaseDeviceForStep("releaseSmoke");
1024
+ if (leaseSkip) return leaseSkip;
1025
+
1026
+ const install = shGradle(`${GRADLEW} :composeApp:installRelease --console=plain`);
1027
+ if (!install.ok) {
1028
+ if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/.test(install.out)) {
1029
+ return {
1030
+ name: "releaseSmoke",
1031
+ verdict: "FAIL",
1032
+ reason: `installRelease refused: the device holds a build with a different signature (usually the debug build from an earlier lane step). Android never installs across signatures — run \`adb uninstall ${applicationId}\` and re-run the release profile. This is a device-state conflict, not a build defect.`,
1033
+ durationMs: install.durationMs,
1034
+ };
1035
+ }
1036
+ if (/SigningConfig|not signed|INSTALL_PARSE_FAILED_NO_CERTIFICATES/i.test(install.out)) {
1037
+ return {
1038
+ name: "releaseSmoke",
1039
+ verdict: "SKIP",
1040
+ reason: "release APK is not installable — signing is not fully configured (see composeApp/build.gradle.kts signingConfigs). Configure a release keystore to enable the release smoke.",
1041
+ durationMs: install.durationMs,
1042
+ };
1043
+ }
1044
+ return {
1045
+ name: "releaseSmoke",
1046
+ verdict: "FAIL",
1047
+ reason: `installRelease failed — the shippable APK could not be installed:\n${install.out.split("\n").filter((l) => /error|FAILURE|INSTALL_/i.test(l)).slice(0, 12).join("\n")}`,
1048
+ durationMs: install.durationMs,
1049
+ };
1050
+ }
1051
+ settleAdb();
1052
+ return runMaestroSmoke("releaseSmoke", install.durationMs);
1053
+ }
1054
+
529
1055
  // ── Lane ───────────────────────────────────────────────────────────────────
530
1056
 
1057
+ // Device-dependent steps, in lane order. Used twice: receipt STRENGTH (which
1058
+ // on-device steps actually PASSed — see below, where the receipt is built) and
1059
+ // the --fast exclusion (with releaseBuild added), so the "device/slow tier"
1060
+ // can never mean two different lists.
1061
+ const DEVICE_STEPS = ["e2eSmoke", "tokenDrift", "androidChecks", "releaseSmoke"];
1062
+
1063
+ // ── Fast-mode memoization of the pure-Node steps (qa/lib/step-cache.mjs) ────
1064
+ // These five steps run no Gradle, shell out to nothing, and are pure functions
1065
+ // of files on disk — so in FAST mode an unchanged input set reuses the last
1066
+ // PASS as verdict "CACHED" (rendered distinctly; only a PASS is ever reused,
1067
+ // a cached FAIL/SKIP always re-runs). THE FULL LANE NEVER CONSULTS THE CACHE —
1068
+ // deliberately: it keeps the integrity property absolute rather than "absolute
1069
+ // unless a cache says otherwise". A full run still WRITES entries so the next
1070
+ // fast run benefits. schemaHistory is NOT here even though it runs no Gradle:
1071
+ // it shells out to git and its verdict depends on HEAD state, not only file
1072
+ // bytes — memoizing it on a content hash could go silently stale.
1073
+ //
1074
+ // Each input set is the step's ACTUAL read surface, over-declared where cheap
1075
+ // (a too-broad set only costs cache misses; a too-narrow one is a
1076
+ // silently-stale gate — the worst possible bug here):
1077
+ // specCoverage reads specs/*.spec.md + citations under composeApp/src
1078
+ // and qa/e2e (qa/lib/spec-coverage.mjs)
1079
+ // approvals reads qa/approvals.json + every governed artifact file:
1080
+ // specs/, docs/features/, docs/ARCHITECTURE.md, and the
1081
+ // exemplar/theme/components Kotlin under composeApp/src
1082
+ // (qa/lib/approvals.mjs listGovernedArtifacts)
1083
+ // componentStories reads commonMain presentation/components and desktopMain
1084
+ // inspector sources — both under composeApp/src
1085
+ // reachability reads commonMain Kotlin (composeApp/src) + the unrouted
1086
+ // declarations in docs/features/
1087
+ // archDoc reads docs/ARCHITECTURE.md, docs/adr/, specs/intent.md
1088
+ // (over-declared to all of specs/), and every source-set's
1089
+ // Kotlin under composeApp/src (qa/lib/arch-doc.mjs)
1090
+ const MEMOIZED_STEP_INPUTS = {
1091
+ specCoverage: ["specs", "composeApp/src", "qa/e2e"],
1092
+ approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
1093
+ componentStories: ["composeApp/src"],
1094
+ reachability: ["composeApp/src", "docs/features"],
1095
+ archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
1096
+ };
1097
+
1098
+ const memoized = (stepName, stepFn) => () =>
1099
+ memoizeStep({ fast, root: ROOT, stepName, inputs: MEMOIZED_STEP_INPUTS[stepName], run: stepFn });
1100
+
1101
+ const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
1102
+ const stepApprovalsMemo = memoized("approvals", stepApprovals);
1103
+ const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
1104
+ const stepReachabilityMemo = memoized("reachability", stepReachability);
1105
+ const stepArchDocMemo = memoized("archDoc", stepArchDoc);
1106
+
531
1107
  const stepsForProfile = {
532
1108
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
533
1109
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
534
- scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepArchDoc, stepBuild, stepUnitTests],
1110
+ scaffold: [stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
535
1111
  local: [
536
- stepSpecCoverage,
537
- stepApprovals,
538
- stepComponentStories,
539
- stepArchDoc,
1112
+ stepSpecCoverageMemo,
1113
+ stepApprovalsMemo,
1114
+ stepComponentStoriesMemo,
1115
+ stepReachabilityMemo,
1116
+ stepArchDocMemo,
1117
+ stepSchemaHistory,
540
1118
  stepBuild,
1119
+ // Release stays OUT of `scaffold`: stamp-time --verify promises a green first build, and
1120
+ // an R8 pass would add minutes to every scaffold to re-prove what this step proves here.
1121
+ // local + ci is where release rot gets caught before it reaches anyone.
1122
+ stepReleaseBuild,
541
1123
  stepUnitTests,
542
1124
  stepConformance,
543
1125
  stepGoldenTrees,
544
1126
  stepTokenDrift,
545
1127
  stepA11y,
546
1128
  stepE2eSmoke,
1129
+ // androidChecks joins local BY the file's own convention, not despite it: local's
1130
+ // contract (see USAGE) is "everything; device-dependent steps SKIP when no device is
1131
+ // attached" — device presence is the opt-in, exactly as e2eSmoke and tokenDrift
1132
+ // already work. A developer with no device attached pays nothing here; one who
1133
+ // attached an emulator has already opted into the device tier's cost. Hiding this
1134
+ // step in ci-only would make local's documented contract a lie and re-open the gap
1135
+ // this tier closes (androidMain test-invisible in the profile people actually run).
1136
+ // Last on purpose: the cheap desktop verdicts and the smoke land first.
1137
+ stepAndroidChecks,
547
1138
  ],
548
1139
  };
549
1140
  stepsForProfile.ci = stepsForProfile.local;
1141
+ // release = everything ci proves PLUS the release-APK behavior smoke. The expensive
1142
+ // proofs are profile-tiered by decision: per-change stays fast (local/ci pay for the
1143
+ // release COMPILE via releaseBuild, already in the set), and the release-variant
1144
+ // *behavior* cost lands once, at ship time. releaseSmoke runs last so the device ends
1145
+ // the run holding the exact build that was proven.
1146
+ stepsForProfile.release = [...stepsForProfile.ci, stepReleaseSmoke];
550
1147
 
551
1148
  if (!stepsForProfile[profile]) {
552
- console.error(`Unknown profile "${profile}" — use scaffold | local | ci.`);
1149
+ console.error(`Unknown profile "${profile}" — use scaffold | local | ci | release.`);
553
1150
  process.exit(2);
554
1151
  }
555
1152
 
1153
+ // ── --fast: the inner loop, mechanically unable to claim done ───────────────
1154
+ // The genuinely slow tier is device/release work — every DEVICE_STEPS entry
1155
+ // (Gradle install + emulator + Maestro + instrumented runner) plus
1156
+ // releaseBuild (R8 + lintVital, the slow release COMPILE). --fast filters
1157
+ // that tier out of whatever profile resolved, UNCONDITIONALLY — device
1158
+ // attached or not — so a small change gets its did-I-break-anything-obvious
1159
+ // signal in JVM time. The rest of the profile still runs — but cheaply: the
1160
+ // pure-Node steps reuse an unchanged PASS from the step cache (CACHED — see
1161
+ // the memoization block above), the Gradle test steps drop --rerun (see
1162
+ // RERUN above), and unitTests scopes itself to the working-tree change
1163
+ // (see stepUnitTests). The loophole is closed at the receipt, not by
1164
+ // convention: mode "fast" is
1165
+ // recorded, no evidence rung is derived (qa/lib/evidence-level.mjs), and
1166
+ // qa/receipt-check.mjs refuses a fast receipt as done evidence.
1167
+ const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
1168
+ const STEP_FN_BY_NAME = {
1169
+ e2eSmoke: stepE2eSmoke,
1170
+ tokenDrift: stepTokenDrift,
1171
+ androidChecks: stepAndroidChecks,
1172
+ releaseSmoke: stepReleaseSmoke,
1173
+ releaseBuild: stepReleaseBuild,
1174
+ };
1175
+ for (const name of FAST_EXCLUDED_NAMES) {
1176
+ if (!STEP_FN_BY_NAME[name]) {
1177
+ // Drift guard: a new device-tier step must be mapped here or --fast would silently run it.
1178
+ console.error(`internal: fast-excluded step "${name}" has no entry in STEP_FN_BY_NAME — fix qa/verify.mjs`);
1179
+ process.exit(2);
1180
+ }
1181
+ }
1182
+ const FAST_EXCLUDED_FNS = new Set(FAST_EXCLUDED_NAMES.map((name) => STEP_FN_BY_NAME[name]));
1183
+ const laneSteps = fast
1184
+ ? stepsForProfile[profile].filter((fn) => !FAST_EXCLUDED_FNS.has(fn))
1185
+ : stepsForProfile[profile];
1186
+ const fastExcluded = fast
1187
+ ? FAST_EXCLUDED_NAMES.filter((name) => stepsForProfile[profile].includes(STEP_FN_BY_NAME[name]))
1188
+ : [];
1189
+
1190
+ if (fast) {
1191
+ console.error(
1192
+ [
1193
+ "⚡⚡ FAST MODE — INNER LOOP ONLY, NOT THE DONE-GATE ⚡⚡",
1194
+ ` skipping the device/release tier: ${fastExcluded.join(", ") || "(none in this profile)"}`,
1195
+ ' this run\'s receipt records mode "fast", earns no evidence rung, and can NEVER satisfy "done"',
1196
+ " run the full lane once (node qa/verify.mjs) before you finish",
1197
+ ].join("\n"),
1198
+ );
1199
+ }
1200
+
556
1201
  // Stamp the lane marker for the run's duration (coexistence defense 1 above);
557
1202
  // always removed, even on a failing step, so the eyes only ever defer briefly.
558
1203
  fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
559
1204
  fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
560
1205
  const steps = [];
561
1206
  try {
562
- for (const step of stepsForProfile[profile]) {
1207
+ for (const step of laneSteps) {
563
1208
  const result = step();
564
1209
  steps.push(result);
565
1210
  if (!asJson) {
566
- const mark = result.verdict === "PASS" ? "✓" : result.verdict === "SKIP" ? "→" : "✗";
567
- console.log(`${mark} ${result.name}: ${result.verdict}${result.reason ? ` ${result.reason.split("\n")[0]}` : ""}`);
1211
+ // CACHED (fast mode only see the memoization block above) renders with
1212
+ // its own mark and "unchanged since" note so a reused verdict is never
1213
+ // mistakable for a fresh execution.
1214
+ const mark = result.verdict === "PASS" ? "✓" : result.verdict === "CACHED" ? "⚡" : result.verdict === "SKIP" ? "→" : "✗";
1215
+ console.log(`${mark} ${result.name}: ${result.verdict}${result.note ? ` (${result.note})` : ""}${result.reason ? ` — ${result.reason.split("\n")[0]}` : ""}`);
568
1216
  }
569
1217
  if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
570
1218
  }
571
1219
  } finally {
572
1220
  fs.rmSync(LANE_MARKER, { force: true });
1221
+ // The device lease (if a device step took it) is held to the very end of the
1222
+ // run — see the scope decision at leaseDeviceForStep. Release is idempotent
1223
+ // and never deletes a foreign holder's lease.
1224
+ if (laneDeviceLease) releaseDeviceLease(laneDeviceLease);
573
1225
  }
574
1226
 
1227
+ // CACHED counts as PASS for the lane verdict (it IS a prior PASS, reused only
1228
+ // in fast mode on an unchanged input set) — but it stays CACHED on the
1229
+ // receipt, visibly distinct, so a fast receipt can never be read as if every
1230
+ // step freshly executed.
575
1231
  const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
576
1232
 
577
1233
  // Receipt STRENGTH — a desktop-only green and an on-device green are different
578
1234
  // claims, and the difference should never live only in the SKIP lines. Device-
579
1235
  // dependent steps that actually RAN (PASSed) are named on the receipt and in the
580
1236
  // verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
581
- const DEVICE_STEPS = ["e2eSmoke", "tokenDrift"];
1237
+ // (DEVICE_STEPS itself is defined above the lane — it also drives --fast.)
582
1238
  const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
583
1239
  const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
584
1240
 
1241
+ // Receipt RUNG — the evidence ladder (qa/lib/evidence-level.mjs): the coarse,
1242
+ // named grade (L0 scaffold / L1 desktop / L2 device / L3 release) DERIVED from
1243
+ // which steps actually ran and PASSed. The strength string above stays as the
1244
+ // fine print; the rung is added alongside, never in place of it. null on FAIL —
1245
+ // a failed lane has no rung. null on a --fast run too: the inner loop is a
1246
+ // signal, never evidence, so a fast receipt derives NO rung at all.
1247
+ const level = evidenceLevel(steps, profile, { mode });
1248
+
585
1249
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
586
1250
  const artifacts = [];
587
1251
  if (fs.existsSync(ARTIFACTS_DIR)) {
@@ -607,6 +1271,10 @@ const inputs = computeInputsHash(ROOT);
607
1271
  const receipt = {
608
1272
  schema: "cmp-evidence/1",
609
1273
  profile,
1274
+ // "full" is the done-gate; "fast" (--fast) excluded the device/release tier
1275
+ // and is REFUSED by qa/receipt-check.mjs — a fast run can never end a session
1276
+ // as "done". Receipts predating this field are treated as full.
1277
+ mode,
610
1278
  verdict,
611
1279
  commit: {
612
1280
  sha: tryGit("rev-parse HEAD"),
@@ -618,6 +1286,7 @@ const receipt = {
618
1286
  },
619
1287
  steps,
620
1288
  strength: { onDeviceSteps },
1289
+ evidenceLevel: level,
621
1290
  artifacts,
622
1291
  toolVersions: {
623
1292
  node: process.version,
@@ -632,7 +1301,19 @@ fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(recei
632
1301
  // studio console's Evidence audit trail reconstructs the full history from the
633
1302
  // git log of this file — every commit is one verified, attributed state.
634
1303
 
635
- if (asJson) console.log(JSON.stringify(receipt, null, 2));
636
- else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
1304
+ if (asJson) {
1305
+ console.log(JSON.stringify(receipt, null, 2));
1306
+ if (fast) {
1307
+ console.error(`⚡⚡ FAST MODE verdict: ${verdict} — INNER LOOP ONLY, not done. Skipped: ${fastExcluded.join(", ") || "(none)"}. Run the full lane (node qa/verify.mjs) before you finish.`);
1308
+ }
1309
+ } else if (fast) {
1310
+ // Deliberately NOT the full lane's verdict-line shape: fast-green must never
1311
+ // be mistakable for done-green.
1312
+ console.log(
1313
+ `\n${verdict === "PASS" ? "⚡⚡" : "❌"} verify lane [FAST — INNER LOOP ONLY, NOT DONE]: ${verdict} (skipped device/release tier: ${fastExcluded.join(", ") || "none"}) — this fast receipt satisfies no done-gate; run the full lane (node qa/verify.mjs) once before you finish`,
1314
+ );
1315
+ } else {
1316
+ console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name}` : ""} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
1317
+ }
637
1318
 
638
1319
  process.exit(verdict === "PASS" ? 0 : 1);