create-cmp-cli 0.11.0 → 0.13.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 (51) hide show
  1. package/README.md +11 -9
  2. package/bin/create-cmp.mjs +3 -0
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +287 -0
  5. package/src/lib/harness-upgrade.mjs +364 -0
  6. package/src/lib/package-name.mjs +72 -0
  7. package/src/scaffold.mjs +7 -2
  8. package/template/.claude/settings.json +30 -0
  9. package/template/CLAUDE.md +51 -6
  10. package/template/README.md +4 -0
  11. package/template/composeApp/build.gradle.kts +44 -0
  12. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  29. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  30. package/template/docs/ARCHITECTURE.md +41 -2
  31. package/template/docs/TESTING.md +165 -0
  32. package/template/gradle/libs.versions.toml +15 -0
  33. package/template/manifest.json +1 -0
  34. package/template/qa/evidence/schema.json +20 -2
  35. package/template/qa/lib/affected-tests.mjs +147 -0
  36. package/template/qa/lib/audit-cadence.mjs +290 -0
  37. package/template/qa/lib/determinism.mjs +179 -0
  38. package/template/qa/lib/device-lease.mjs +249 -0
  39. package/template/qa/lib/evidence-badge.mjs +158 -0
  40. package/template/qa/lib/evidence-level.mjs +117 -0
  41. package/template/qa/lib/flight-recorder.mjs +332 -0
  42. package/template/qa/lib/inputs-hash.mjs +16 -1
  43. package/template/qa/lib/spec-coverage.mjs +54 -3
  44. package/template/qa/lib/step-cache.mjs +221 -0
  45. package/template/qa/receipt-check.mjs +22 -2
  46. package/template/qa/record-audit.mjs +83 -0
  47. package/template/qa/retrospective.mjs +51 -0
  48. package/template/qa/scaffold-feature.mjs +20 -1
  49. package/template/qa/verify.mjs +934 -57
  50. package/template/qa/watch.mjs +622 -0
  51. 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,10 +33,18 @@ 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";
30
- import { scanCitations, scanSpecClauses, walkFiles } from "./lib/spec-coverage.mjs";
36
+ import { clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from "./lib/spec-coverage.mjs";
31
37
  import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
32
38
  import { evaluateReachability } from "./lib/reachability.mjs";
39
+ import { evidenceLevel } from "./lib/evidence-level.mjs";
40
+ import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
41
+ import { memoizeStep } from "./lib/step-cache.mjs";
42
+ import { changedWorkingTreePaths, deriveAffectedFilter } from "./lib/affected-tests.mjs";
43
+ import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./lib/device-lease.mjs";
33
44
  import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
45
+ import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./lib/determinism.mjs";
46
+ import { evaluateAuditCadence } from "./lib/audit-cadence.mjs";
47
+ import { appendFlightRecord, buildFlightEntry } from "./lib/flight-recorder.mjs";
34
48
 
35
49
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
36
50
  const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
@@ -42,7 +56,7 @@ const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
42
56
  // killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
43
57
  // refuses an unknown artifact by name rather than guessing: an unknown
44
58
  // argument here is refused by name, not swallowed into "run everything".
45
- const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci] [--json] [--help]
59
+ const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci|release] [--fast] [--json] [--help]
46
60
 
47
61
  The verify lane — this project's single verification gate. Runs every
48
62
  verification step this project carries, aggregates a typed PASS/FAIL
@@ -50,7 +64,34 @@ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
50
64
  it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
51
65
 
52
66
  Flags:
53
- --profile <scaffold|local|ci> which step set to run (default: local)
67
+ --profile <scaffold|local|ci|release>
68
+ which step set to run (default: local)
69
+ --fast INNER LOOP ONLY — run the resolved profile
70
+ minus the device/release tier (releaseBuild,
71
+ tokenDrift, e2eSmoke, androidChecks,
72
+ releaseSmoke), unconditionally, device
73
+ attached or not. Also reuses the pure-Node
74
+ steps' last PASS when their inputs are
75
+ unchanged (verdict CACHED), lets Gradle's
76
+ up-to-date checks stand (no --rerun), and
77
+ scopes unit tests to the working-tree change
78
+ (broad-impact changes run everything). The
79
+ receipt records mode "fast", derives no
80
+ evidence rung, and can NEVER satisfy the
81
+ done-gate — run the full lane once before
82
+ you call it done
83
+ --determinism run the timezone determinism probe: the JVM
84
+ test tier (unit + golden + the other
85
+ desktop suites) executes TWICE, under
86
+ TZ=Etc/GMT+12 (UTC-12) and TZ=Etc/GMT-14
87
+ (UTC+14), and the probe FAILs naming every
88
+ test whose verdict or failure output
89
+ differs — a nondeterminism leak ARCH-13's
90
+ static net missed. Bare (no --profile) it
91
+ runs JUST the probe and writes no receipt;
92
+ with --profile ci (or release) it runs
93
+ inside the lane and lands on the receipt.
94
+ Never combinable with --fast
54
95
  --json print the receipt as JSON instead of the
55
96
  human-readable step-by-step log
56
97
  --help, -h print this usage and exit 0 without
@@ -62,6 +103,9 @@ Profiles:
62
103
  local everything; device-dependent steps SKIP when no device is
63
104
  attached
64
105
  ci everything; SKIPs are recorded so the pipeline stays honest
106
+ release everything ci proves PLUS the release-APK smoke (releaseSmoke) —
107
+ the ship-time profile; run it before cutting a release, never
108
+ per-change
65
109
  `;
66
110
 
67
111
  const rawArgs = process.argv.slice(2);
@@ -71,7 +115,7 @@ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
71
115
  process.exit(0);
72
116
  }
73
117
 
74
- const RECOGNIZED_FLAGS = new Set(["--profile", "--json"]);
118
+ const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast", "--determinism"]);
75
119
  for (let i = 0; i < rawArgs.length; i += 1) {
76
120
  const arg = rawArgs[i];
77
121
  if (arg === "--profile") {
@@ -86,9 +130,51 @@ for (let i = 0; i < rawArgs.length; i += 1) {
86
130
  const args = rawArgs;
87
131
  const profile = args.includes("--profile") ? args[args.indexOf("--profile") + 1] : "local";
88
132
  const asJson = args.includes("--json");
133
+ const fast = args.includes("--fast");
134
+ // --no-journal suppresses the flight-recorder append (qa/watch.mjs passes it).
135
+ // See the append site below for why the inner loop must not write here.
136
+ const noJournal = args.includes("--no-journal");
137
+ const mode = fast ? "fast" : "full";
138
+
139
+ // ── --determinism: the timezone double-run probe (roadmap §10 item 8) ───────
140
+ // Refusals up front, by name (same stance as unknown arguments above):
141
+ // - never with --fast: the probe deliberately runs the JVM test tier twice,
142
+ // and --fast is the inner loop that exists to not pay such costs — the
143
+ // combination is a contradiction, so it is refused rather than silently
144
+ // resolved either way.
145
+ // - only the ci profile (and release, which inherits ci) carries the probe's
146
+ // lane row; asking for it in local/scaffold is refused with the two ways
147
+ // that DO work, instead of silently running a step the requested profile
148
+ // does not own.
149
+ const determinism = args.includes("--determinism");
150
+ const profileExplicit = args.includes("--profile");
151
+ if (determinism && fast) {
152
+ console.error(
153
+ "--determinism cannot be combined with --fast: the probe runs the JVM test tier twice by design, and --fast is the inner loop. Run it alone (node qa/verify.mjs --determinism) or inside a full ci/release lane (--profile ci --determinism).",
154
+ );
155
+ process.exit(2);
156
+ }
157
+ if (determinism && profileExplicit && profile !== "ci" && profile !== "release") {
158
+ console.error(
159
+ `--determinism belongs to the ci profile (release inherits it), not "${profile}" — run --profile ci --determinism, or bare --determinism to run the probe alone.`,
160
+ );
161
+ process.exit(2);
162
+ }
89
163
 
90
164
  const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
91
165
 
166
+ // ── `--rerun` is scoped to FULL mode ────────────────────────────────────────
167
+ // `--rerun` exists for evidence integrity (see stepUnitTests's comment): it
168
+ // stops Gradle's build cache replaying a PASS recorded against a different
169
+ // tree into a receipt that claims tests executed. That mechanism belongs to
170
+ // the runs that produce integrity-bearing artifacts — and a --fast run does
171
+ // not: its receipt already declares itself non-evidence (mode "fast", no
172
+ // evidence rung, refused by qa/receipt-check.mjs), so forcing execution there
173
+ // paid an integrity tax to protect an artifact with nothing to protect. Fast
174
+ // mode therefore omits the flag and lets Gradle's up-to-date/cache machinery
175
+ // do its job; full mode keeps it, byte-identical to before.
176
+ const RERUN = fast ? "" : " --rerun";
177
+
92
178
  function sh(cmd, opts = {}) {
93
179
  const started = Date.now();
94
180
  // maxBuffer: first-run Gradle output easily exceeds spawnSync's 1MB default,
@@ -116,6 +202,14 @@ function sh(cmd, opts = {}) {
116
202
  const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
117
203
  const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
118
204
 
205
+ // Degraded-path activations observed during this run — self-heals and
206
+ // fallbacks that kept the lane moving without failing it. Collected for the
207
+ // flight recorder (qa/lib/flight-recorder.mjs): a degradation that fires
208
+ // once is a shrug, one that fires every run for a month is the tooling
209
+ // quietly rotting under a green lane — and only a journal can tell those
210
+ // two apart.
211
+ const DEGRADED_PATHS = [];
212
+
119
213
  // The daemon's half of defense 2 above — pid + ISO timestamp, mirroring
120
214
  // LANE_MARKER's own content shape (see where LANE_MARKER is stamped, below).
121
215
  const RENDER_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-render-in-progress");
@@ -155,6 +249,7 @@ function shGradle(cmd, opts = {}) {
155
249
  const retry = sh(cmd, opts);
156
250
  retry.durationMs += first.durationMs;
157
251
  retry.selfHealed = "ksp-cache-collision";
252
+ DEGRADED_PATHS.push("ksp-cache-collision: cleared kspCaches and retried the Gradle step");
158
253
  return retry;
159
254
  }
160
255
 
@@ -182,19 +277,31 @@ function tryGitLines(cmd) {
182
277
  }
183
278
  }
184
279
 
280
+ // Recursive: desktopTest writes TEST-*.xml flat, but connected (instrumented) results
281
+ // land one directory level down per device (build/outputs/androidTest-results/connected/
282
+ // debug/<device>/TEST-*.xml) — both shapes are summarized by the same walk.
185
283
  function junitSummary(dir) {
186
284
  if (!fs.existsSync(dir)) return null;
187
285
  let tests = 0, failures = 0, errors = 0, skipped = 0;
188
- for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("TEST-") && f.endsWith(".xml"))) {
189
- const xml = fs.readFileSync(path.join(dir, f), "utf8");
190
- const m = xml.match(/<testsuite[^>]*tests="(\d+)"[^>]*skipped="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"/);
191
- if (m) {
192
- tests += Number(m[1]);
193
- skipped += Number(m[2]);
194
- failures += Number(m[3]);
195
- errors += Number(m[4]);
286
+ const walk = (d) => {
287
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
288
+ const p = path.join(d, entry.name);
289
+ if (entry.isDirectory()) {
290
+ walk(p);
291
+ continue;
292
+ }
293
+ if (!entry.name.startsWith("TEST-") || !entry.name.endsWith(".xml")) continue;
294
+ const xml = fs.readFileSync(p, "utf8");
295
+ const m = xml.match(/<testsuite[^>]*tests="(\d+)"[^>]*skipped="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"/);
296
+ if (m) {
297
+ tests += Number(m[1]);
298
+ skipped += Number(m[2]);
299
+ failures += Number(m[3]);
300
+ errors += Number(m[4]);
301
+ }
196
302
  }
197
- }
303
+ };
304
+ walk(dir);
198
305
  return { tests, failures, errors, skipped };
199
306
  }
200
307
 
@@ -204,6 +311,103 @@ function deviceAttached() {
204
311
  return res.out.split("\n").slice(1).some((l) => /\tdevice$/.test(l.trim().replace(/\s+/g, "\t")));
205
312
  }
206
313
 
314
+ // ── The machine-global device lease (qa/lib/device-lease.mjs) ───────────────
315
+ // LANE_MARKER above is per-PROJECT; the device is machine-GLOBAL. A scratch app
316
+ // in /tmp and the real app each stamp their own marker and still share the one
317
+ // emulator — nothing stopped two lanes (or a lane and a live console session)
318
+ // driving it at once, which is the observed wedged-adbd / `device offline` /
319
+ // crossed-app-state failure class. Every device-touching step below takes the
320
+ // lease before touching the device.
321
+ //
322
+ // SCOPE DECISION — once per run, not per step: the lease is acquired lazily by
323
+ // the FIRST device step that actually reaches the device and held until the
324
+ // lane exits (released in the same `finally` as LANE_MARKER). A single run must
325
+ // not thrash acquire/release between adjacent device steps, and holding through
326
+ // the desktop steps interleaved among them (a11y sits between tokenDrift and
327
+ // e2eSmoke) costs nothing — nothing else should drive the device mid-lane
328
+ // anyway, which is the whole point.
329
+ //
330
+ // ON CONTENTION THE STEP RETURNS SKIP — NEVER FAIL: nothing is broken; another
331
+ // run legitimately holds the device. This composes with the evidence ladder
332
+ // (qa/lib/evidence-level.mjs): a SKIPped device step simply does not buy its
333
+ // rung, so contention visibly DEGRADES the evidence level (L2 falls back to L1)
334
+ // instead of corrupting the run with a false red — that degradation being
335
+ // honest and visible is exactly why SKIP is the right verdict.
336
+ let laneDeviceLease = null;
337
+
338
+ /** Serials of devices currently in `device` state (same parse as deviceAttached). */
339
+ function attachedDeviceSerials() {
340
+ const res = sh("adb devices", { timeout: 10_000 });
341
+ if (!res.ok) return [];
342
+ return res.out
343
+ .split("\n")
344
+ .slice(1)
345
+ .map((l) => l.trim())
346
+ .filter(Boolean)
347
+ .map((l) => l.split(/\s+/))
348
+ .filter(([, state]) => state === "device")
349
+ .map(([serial]) => serial);
350
+ }
351
+
352
+ /**
353
+ * Acquire (or confirm) the lane's device lease for a device step.
354
+ * Returns null when the lane holds the device; otherwise the SKIP result the
355
+ * step should return verbatim. The serial leased is the one the lane will
356
+ * actually drive: the single attached device, or ANDROID_SERIAL when several
357
+ * are attached (adb/Gradle/Maestro honor the same variable). Ambiguity is
358
+ * SKIPped by name — leasing a guess would protect the wrong device.
359
+ */
360
+ function leaseDeviceForStep(stepName) {
361
+ if (laneDeviceLease) return null; // already held for this run
362
+ const serials = attachedDeviceSerials();
363
+ if (serials.length === 0) return null; // each step's own guard SKIPs "no device" with its precise reason
364
+ let serial = serials[0];
365
+ if (serials.length > 1) {
366
+ const chosen = process.env.ANDROID_SERIAL;
367
+ if (chosen && serials.includes(chosen)) {
368
+ serial = chosen;
369
+ } else {
370
+ return {
371
+ name: stepName,
372
+ verdict: "SKIP",
373
+ 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.`,
374
+ durationMs: 0,
375
+ };
376
+ }
377
+ }
378
+ const res = acquireDeviceLease({ serial, holder: `verify lane ${stepName}`, root: ROOT });
379
+ if (!res.ok) {
380
+ return {
381
+ name: stepName,
382
+ verdict: "SKIP",
383
+ reason: `device ${serial} is held by ${formatHolder(res.heldBy)} — device evidence is batched, not concurrent; wait for it or run once when it finishes`,
384
+ durationMs: 0,
385
+ };
386
+ }
387
+ if (res.reclaimed) {
388
+ console.error(`· reclaimed a dead device lease on ${serial} (was ${formatHolder(res.reclaimed)})`);
389
+ }
390
+ laneDeviceLease = res.handle;
391
+ return null;
392
+ }
393
+
394
+ // Settle adb before handing the device to whatever drives it next (Maestro, the
395
+ // instrumented runner). An install task returning 0 means the package manager accepted
396
+ // the APK — NOT that the device is ready to be driven: a reinstall over a running app
397
+ // briefly drops the emulator's adb transport. `adb devices` still says `device`, but a
398
+ // fresh adb client (Maestro's dadb, Gradle's ddmlib) gets `device offline` and dies
399
+ // before the first assertion (observed 4/4 when the live-inspector tier ran earlier in
400
+ // the lane — its port-forward traffic widens the window — and 0/4 when it was skipped).
401
+ // wait-for-device blocks only while the transport is actually down; the kill/start pair
402
+ // ahead of it clears a stale server-side transport entry that survives the device coming
403
+ // back. Neither weakens any assertion — every downstream check still passes on its own
404
+ // merits.
405
+ function settleAdb() {
406
+ sh("adb kill-server");
407
+ sh("adb start-server");
408
+ sh("adb wait-for-device");
409
+ }
410
+
207
411
  // ── Steps ──────────────────────────────────────────────────────────────────
208
412
  // Each returns { name, verdict, reason?, durationMs, details? }. Failure
209
413
  // reasons are worded for an AI collaborator to act on.
@@ -229,6 +433,12 @@ function stepSpecCoverage() {
229
433
  const orphanTags = tags.filter((t) => !clauses.has(t.id) || clauses.get(t.id).withdrawn);
230
434
 
231
435
  if (orphanClauses.length === 0 && orphanTags.length === 0) {
436
+ // Tier visibility, not a gate (industry rule: instrument before you police). A clause
437
+ // cited only from desktop-tier tests can still hide a platform-behavior bug — both
438
+ // production apps shipped alarm/notification defects behind clauses that were
439
+ // "covered" by JVM tests androidMain never ran under. The line names them; the
440
+ // instrumented seam (androidChecks) is where such clauses earn a citation.
441
+ const tiers = clauseTierCoverage(clauses, tags);
232
442
  return {
233
443
  name: "specCoverage",
234
444
  verdict: "PASS",
@@ -238,6 +448,7 @@ function stepSpecCoverage() {
238
448
  withdrawn: [...clauses.values()].filter((c) => c.withdrawn).length,
239
449
  tags: tags.length,
240
450
  files: files.length,
451
+ tierNote: tiers.summaryLine,
241
452
  },
242
453
  };
243
454
  }
@@ -358,6 +569,82 @@ function stepArchDoc() {
358
569
  };
359
570
  }
360
571
 
572
+ // Schema-history gate — pure Node + git, no Gradle, same grouping as the other
573
+ // evidence checks. Room's exportSchema writes one <version>.json per database per
574
+ // target under composeApp/schemas/. Every version EXCEPT the current highest is a
575
+ // frozen historical record of a database that shipped: migrations are written and
576
+ // validated against those exact bytes, so a regeneration that rewrites them
577
+ // silently corrupts the baseline every future migration is proven against. Only
578
+ // the highest version is the live, in-progress schema — free to change or appear
579
+ // (that IS the current change). This gate exists because schema regeneration
580
+ // looks like harmless build output right up until a shipped user's upgrade fails.
581
+ function stepSchemaHistory() {
582
+ const started = Date.now();
583
+ const elapsed = () => Date.now() - started;
584
+ const schemasRel = path.join("composeApp", "schemas");
585
+ const schemasRoot = path.join(ROOT, schemasRel);
586
+
587
+ if (!fs.existsSync(schemasRoot)) {
588
+ return { name: "schemaHistory", verdict: "SKIP", reason: "no exported Room schemas (composeApp/schemas/ absent) — nothing frozen to guard", durationMs: elapsed() };
589
+ }
590
+ const gitTop = tryGit("rev-parse --show-toplevel");
591
+ if (!gitTop || !tryGit("rev-parse HEAD")) {
592
+ return { name: "schemaHistory", verdict: "SKIP", reason: "no git history yet — schema versions have no committed baseline to be frozen against", durationMs: elapsed() };
593
+ }
594
+
595
+ // Every directory holding versioned schema JSONs, with its highest version on disk.
596
+ const versionFile = /^(\d+)\.json$/;
597
+ const maxVersionByDir = new Map(); // absolute dir path -> highest N among its N.json files
598
+ const walkSchemas = (dir) => {
599
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
600
+ const p = path.join(dir, entry.name);
601
+ if (entry.isDirectory()) walkSchemas(p);
602
+ else {
603
+ const m = entry.name.match(versionFile);
604
+ if (m) maxVersionByDir.set(dir, Math.max(maxVersionByDir.get(dir) ?? 0, Number(m[1])));
605
+ }
606
+ }
607
+ };
608
+ walkSchemas(schemasRoot);
609
+
610
+ // Tracked schema files whose committed bytes no longer match the tree (staged or
611
+ // unstaged; deletions included). Paths come back relative to the git toplevel.
612
+ // Untracked files never appear here — a brand-new version file is by definition
613
+ // not yet frozen history.
614
+ const dirtyFiles = tryGitLines(`diff --name-only HEAD -- "${schemasRel.replace(/\\/g, "/")}"`);
615
+
616
+ const violations = [];
617
+ for (const rel of dirtyFiles) {
618
+ const abs = path.resolve(gitTop, rel);
619
+ const m = path.basename(abs).match(versionFile);
620
+ if (!m) continue; // not a versioned schema JSON
621
+ const version = Number(m[1]);
622
+ const dirMax = maxVersionByDir.get(path.dirname(abs));
623
+ // The highest version currently on disk is the live schema — dirty is fine.
624
+ // Anything else (a lower version, or a file whose whole directory is gone)
625
+ // is rewritten/deleted history.
626
+ if (dirMax !== undefined && version === dirMax) continue;
627
+ violations.push(rel);
628
+ }
629
+
630
+ if (violations.length === 0) {
631
+ return { name: "schemaHistory", verdict: "PASS", durationMs: elapsed(), details: { schemaDirs: maxVersionByDir.size } };
632
+ }
633
+
634
+ const lines = [
635
+ "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:",
636
+ ];
637
+ for (const rel of violations) lines.push(` git checkout -- ${rel}`);
638
+ lines.push("If you intended a schema change, bump the database version so a NEW <version>.json is exported instead of overwriting history.");
639
+ return {
640
+ name: "schemaHistory",
641
+ verdict: "FAIL",
642
+ reason: lines.join("\n"),
643
+ durationMs: elapsed(),
644
+ details: { schemaDirs: maxVersionByDir.size, violations },
645
+ };
646
+ }
647
+
361
648
  function stepBuild() {
362
649
  const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
363
650
  return {
@@ -398,9 +685,12 @@ function stepReleaseBuild() {
398
685
  // Runs a filtered slice of the JVM test tier and names the verdict after the gate it proves.
399
686
  // The full suite already ran in unitTests; the filtered slices stay cheap (compilation is
400
687
  // cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
688
+ // In fast mode the flag is omitted (RERUN, defined with the mode flags above): the
689
+ // integrity mechanism belongs to the runs that produce integrity-bearing artifacts, and a
690
+ // fast receipt has already declared itself non-evidence.
401
691
  function gradleTestStep(name, testsFilter, failHint) {
402
692
  return () => {
403
- const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
693
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --tests "${testsFilter}" --console=plain`);
404
694
  return {
405
695
  name,
406
696
  verdict: res.ok ? "PASS" : "FAIL",
@@ -417,17 +707,62 @@ function stepUnitTests() {
417
707
  // restore a PASS recorded against a *different* tree state (deterministic re-scaffolds
418
708
  // produce byte-identical sources, and golden baselines aren't compile inputs), so the
419
709
  // receipt would attest tests that never executed. Compilation stays cached — only the
420
- // test execution is forced.
421
- const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
710
+ // test execution is forced. Scoped to FULL mode (see RERUN above): the integrity
711
+ // mechanism belongs to the runs that produce integrity-bearing artifacts, and a fast
712
+ // receipt is already declared non-evidence.
713
+ //
714
+ // Fast mode additionally scopes the suite to tests plausibly affected by the
715
+ // working-tree change (qa/lib/affected-tests.mjs): changed .kt files map to
716
+ // `--tests "*<segment>*"` patterns, with a mandatory blast-radius escape hatch (build
717
+ // files, DI, theme, shared components, qa/, anything outside composeApp/src → full
718
+ // suite) and fail-open on every uncertain case (no git, unmappable change). FALSE
719
+ // NEGATIVES ARE ACCEPTABLE HERE AND ONLY HERE: the full, unfiltered suite runs at the
720
+ // checkpoint (the full lane), where done is actually decided. The filter that ran is
721
+ // reported in the step's note and recorded in the (fast-only) receipt, so a filtered
722
+ // run can never be mistaken for the full suite.
723
+ let note;
724
+ let testsArgs = "";
725
+ let affected = null;
726
+ if (fast) {
727
+ const changed = changedWorkingTreePaths(ROOT);
728
+ if (changed === null) {
729
+ note = "full suite — git unavailable, cannot derive the change (fail open)";
730
+ } else {
731
+ const filter = deriveAffectedFilter(changed);
732
+ if (filter.mode === "filtered") {
733
+ testsArgs = filter.patterns.map((p) => ` --tests "${p}"`).join("");
734
+ note = `affected: ${filter.patterns.join(", ")} — ${filter.sourcePaths.length} changed source file(s)`;
735
+ affected = { patterns: filter.patterns, changedFiles: filter.sourcePaths.length };
736
+ } else {
737
+ note = `full suite — ${filter.reason}`;
738
+ }
739
+ }
740
+ }
741
+ let res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN}${testsArgs} --console=plain`);
742
+ if (fast && testsArgs && !res.ok && /No tests found for given includes/.test(res.out)) {
743
+ // The heuristic filter matched no test class at all (e.g. a feature with no tests
744
+ // yet). That is the harness's guess being wrong, not the app — fall back to the
745
+ // full suite in-lane rather than false-redding on our own filter. (RERUN is empty
746
+ // here by construction — this branch only exists in fast mode.)
747
+ const retry = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`);
748
+ retry.durationMs += res.durationMs;
749
+ res = retry;
750
+ note = "full suite — the affected-test filter matched no tests (fell back)";
751
+ affected = null;
752
+ DEGRADED_PATHS.push("affected-test filter matched no tests — fell back to the full desktopTest suite");
753
+ }
422
754
  const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
755
+ let details = summary ?? undefined;
756
+ if (affected) details = { ...(summary ?? {}), affected };
423
757
  return {
424
758
  name: "unitTests",
425
759
  verdict: res.ok ? "PASS" : "FAIL",
426
760
  reason: res.ok
427
761
  ? undefined
428
762
  : `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")}`,
763
+ note,
429
764
  durationMs: res.durationMs,
430
- details: summary ?? undefined,
765
+ details,
431
766
  };
432
767
  }
433
768
 
@@ -447,6 +782,115 @@ const stepA11y = gradleTestStep(
447
782
  "A11y gate failed (SHELL-04): interactive nodes must expose a testTag, text, or contentDescription:",
448
783
  );
449
784
 
785
+ // ── Determinism probe (roadmap §10 item 8) — opt-in, ci-profile ────────────
786
+ // ARCH-13 statically bans ambient time reads — in APP code. A library the
787
+ // app calls can still read the wall clock, and a golden can still depend on
788
+ // the machine's timezone through a seam the static net cannot see (a
789
+ // ViewModel constructed without its injected clock already caused one
790
+ // overnight golden-tree drift). This probe closes that gap DYNAMICALLY: it
791
+ // runs the JVM test tier twice, under two timezones whose local calendar
792
+ // dates never agree — the offsets are 26 hours apart, so any date-derived
793
+ // value differs between the legs at every instant (see DETERMINISM_TIMEZONES
794
+ // in qa/lib/determinism.mjs for why UTC-12/UTC+14 and not UTC/UTC+14) — and
795
+ // FAILs naming every test whose outcome differs between the legs.
796
+ //
797
+ // Mechanics that carry the honesty:
798
+ // - TZ reaches the test JVM through the environment: Gradle forwards the
799
+ // client's environment to the daemon on every build, and test workers
800
+ // fork from the daemon — so the child env below is inherited all the way
801
+ // down to the JVM whose default timezone the tests see.
802
+ // - BOTH legs force --rerun. Without it Gradle would mark the second leg
803
+ // up-to-date (TZ is not a declared build input) and replay the first
804
+ // leg's results — the probe would then compare a run against its own
805
+ // echo and certify a determinism it never tested (the build-cache-replay
806
+ // lesson, again). The legs use the mode-scoped RERUN like every other
807
+ // desktopTest invocation — and because --determinism is refused alongside
808
+ // --fast up front, RERUN is always " --rerun" by the time a leg runs.
809
+ // - Only verdicts and failure output are compared; durations are never even
810
+ // parsed (qa/lib/determinism.mjs), so a timing wobble is structurally
811
+ // unable to trip the probe.
812
+ function stepDeterminism() {
813
+ const started = Date.now();
814
+ const elapsed = () => Date.now() - started;
815
+ if (!determinism) {
816
+ return {
817
+ name: "determinism",
818
+ verdict: "SKIP",
819
+ reason: "determinism probe is opt-in (it runs the JVM test tier twice) — add --determinism to this lane, or run the probe alone: node qa/verify.mjs --determinism",
820
+ durationMs: elapsed(),
821
+ };
822
+ }
823
+
824
+ const resultsDir = path.join(ROOT, "composeApp/build/test-results/desktopTest");
825
+ const legs = [];
826
+ for (const { tz, label } of DETERMINISM_TIMEZONES) {
827
+ const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`, { env: { ...process.env, TZ: tz } });
828
+ // Parsed NOW, before the next leg overwrites the same results directory.
829
+ const outcomes = parseJUnitOutcomes(resultsDir);
830
+ legs.push({ tz, label, ok: res.ok, outcomes, tail: res.out.split("\n").slice(-8).join("\n") });
831
+ }
832
+ const [a, b] = legs;
833
+ const labelA = `TZ=${a.tz} (${a.label})`;
834
+ const labelB = `TZ=${b.tz} (${b.label})`;
835
+ const countA = Object.keys(a.outcomes).length;
836
+ const countB = Object.keys(b.outcomes).length;
837
+
838
+ if (countA === 0 && countB === 0) {
839
+ // Neither leg produced a single test result: the suite failed before
840
+ // running anything (build error). The probe measured nothing — that is
841
+ // a FAIL that says so, never a PASS by absence of differences.
842
+ return {
843
+ name: "determinism",
844
+ verdict: "FAIL",
845
+ reason: `determinism probe could not execute: desktopTest produced no test results under either timezone — the suite fails before running (fix the build first; this is not a timezone difference):\n${a.tail}`,
846
+ durationMs: elapsed(),
847
+ };
848
+ }
849
+ if (countA === 0 || countB === 0) {
850
+ const ran = countA > 0 ? { label: labelA, n: countA } : { label: labelB, n: countB };
851
+ const empty = countA > 0 ? b : a;
852
+ return {
853
+ name: "determinism",
854
+ verdict: "FAIL",
855
+ reason: `Nondeterminism under timezone shift: desktopTest ran ${ran.n} test(s) under ${ran.label} but produced no results at all under TZ=${empty.tz} (${empty.label}) — the suite itself dies under that zone:\n${empty.tail}`,
856
+ durationMs: elapsed(),
857
+ details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz) },
858
+ };
859
+ }
860
+
861
+ const diffs = compareOutcomes(a.outcomes, b.outcomes, labelA, labelB);
862
+ if (diffs.length > 0) {
863
+ const lines = [
864
+ `Nondeterminism under timezone shift — the same tree produced different outcomes under ${labelA} vs ${labelB}. Something reads ambient time or zone past the ARCH-13 net (a library default, an uninjected clock, a golden that captures "today"):`,
865
+ ];
866
+ for (const d of diffs.slice(0, 20)) lines.push(` [${d.step}] ${d.test} — ${d.detail}`);
867
+ if (diffs.length > 20) lines.push(` … and ${diffs.length - 20} more differing test(s)`);
868
+ return {
869
+ name: "determinism",
870
+ verdict: "FAIL",
871
+ reason: lines.join("\n"),
872
+ durationMs: elapsed(),
873
+ details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), diffs: diffs.slice(0, 50) },
874
+ };
875
+ }
876
+
877
+ const failedIdentically = Object.values(a.outcomes).filter((o) => o.status !== "pass" && o.status !== "skip").length;
878
+ return {
879
+ name: "determinism",
880
+ verdict: "PASS",
881
+ // Identical red is DETERMINISTIC red: the probe's claim ("no timezone
882
+ // dependence") holds, and the failing tests already belong to
883
+ // unitTests/goldenTrees, which fail the lane on their own merits — a
884
+ // second FAIL here would report the same defect twice under a wrong name.
885
+ note:
886
+ failedIdentically > 0
887
+ ? `${failedIdentically} test(s) failed identically under both timezones — deterministic, but red (the owning test steps report it)`
888
+ : undefined,
889
+ durationMs: elapsed(),
890
+ details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), testsCompared: countA },
891
+ };
892
+ }
893
+
450
894
  // Live tokenDrift tier (harness M4-D): when a debug app + device are available,
451
895
  // fetches the declared catalog and the live semantics tree off the debug-only
452
896
  // inspector server (127.0.0.1:9500, see composeApp/src/androidDebug/.../
@@ -503,6 +947,10 @@ function stepTokenDrift() {
503
947
  durationMs: elapsed(),
504
948
  });
505
949
 
950
+ // Machine-global lease before the first device touch (contention = SKIP).
951
+ const leaseSkip = leaseDeviceForStep("tokenDrift");
952
+ if (leaseSkip) return { ...leaseSkip, durationMs: elapsed() };
953
+
506
954
  sh(`adb forward tcp:${INSPECTOR_PORT} tcp:${INSPECTOR_PORT}`);
507
955
  try {
508
956
  let health = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/health`);
@@ -561,32 +1009,37 @@ function maestroAvailable() {
561
1009
  return sh("maestro --version", { timeout: 15_000 }).ok;
562
1010
  }
563
1011
 
564
- function stepE2eSmoke() {
1012
+ // The e2e guard trio, shared by every step that drives the smoke flow on a device.
1013
+ // Returns null when the harness is fully available, else the SKIP result for [name].
1014
+ function maestroGuards(name) {
565
1015
  if (!fs.existsSync(path.join(ROOT, "qa/e2e"))) {
566
- return { name: "e2eSmoke", verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
1016
+ return { name, verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
567
1017
  }
568
1018
  if (!deviceAttached()) {
569
- return { name: "e2eSmoke", verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
1019
+ return { name, verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
570
1020
  }
571
1021
  if (!maestroAvailable()) {
572
- return { name: "e2eSmoke", verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
1022
+ return { name, verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
573
1023
  }
574
- const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
575
- if (!install.ok) {
576
- return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
577
- }
578
- // Harden the device for headless/CI automation before driving it. Without this, a slow or
579
- // loaded emulator produces false reds that have nothing to do with the app:
580
- // - hide_error_dialogs=1 stops Android popping ANR/crash dialogs (e.g. SystemUI under load)
581
- // that steal focus over the app — a Maestro assert would then see only the dialog;
582
- // - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
583
- // up on a slow emulator (the built-in default gives up too early under load).
584
- // Both are benign, reversible, and only touch the device while the lane is driving it —
585
- // hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
586
- // to its default) in the finally below, on every exit path.
587
- // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event so after the
588
- // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
589
- // them. The eyes must report what automation stability had to hide.
1024
+ return null;
1025
+ }
1026
+
1027
+ // Drives qa/e2e/smoke.yaml against whatever build is installed, with the device hardened
1028
+ // for headless/CI automation. Shared by e2eSmoke (debug APK) and releaseSmoke (release
1029
+ // APK) so the hardening and the honesty sweep can never drift apart between variants.
1030
+ // Without the hardening, a slow or loaded emulator produces false reds that have nothing
1031
+ // to do with the app:
1032
+ // - hide_error_dialogs=1 stops Android popping ANR/crash dialogs (e.g. SystemUI under load)
1033
+ // that steal focus over the app a Maestro assert would then see only the dialog;
1034
+ // - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
1035
+ // up on a slow emulator (the built-in default gives up too early under load).
1036
+ // Both are benign, reversible, and only touch the device while the lane is driving it —
1037
+ // hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
1038
+ // to its default) in the finally below, on every exit path.
1039
+ // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event so after the
1040
+ // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
1041
+ // them. The eyes must report what automation stability had to hide.
1042
+ function runMaestroSmoke(name, priorDurationMs) {
590
1043
  const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
591
1044
  sh("adb shell settings put global hide_error_dialogs 1");
592
1045
  sh("adb logcat -c"); // clear so the post-run dump only reflects this run
@@ -594,10 +1047,10 @@ function stepE2eSmoke() {
594
1047
  const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
595
1048
  if (!res.ok) {
596
1049
  return {
597
- name: "e2eSmoke",
1050
+ name,
598
1051
  verdict: "FAIL",
599
1052
  reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
600
- durationMs: install.durationMs + res.durationMs,
1053
+ durationMs: priorDurationMs + res.durationMs,
601
1054
  };
602
1055
  }
603
1056
  const anrDump = sh("adb logcat -d -b system,crash,main");
@@ -605,13 +1058,13 @@ function stepE2eSmoke() {
605
1058
  if (anrDump.ok && anrRe.test(anrDump.out)) {
606
1059
  const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
607
1060
  return {
608
- name: "e2eSmoke",
1061
+ name,
609
1062
  verdict: "FAIL",
610
1063
  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}`,
611
- durationMs: install.durationMs + res.durationMs,
1064
+ durationMs: priorDurationMs + res.durationMs,
612
1065
  };
613
1066
  }
614
- return { name: "e2eSmoke", verdict: "PASS", durationMs: install.durationMs + res.durationMs };
1067
+ return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs };
615
1068
  } finally {
616
1069
  if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
617
1070
  sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
@@ -621,18 +1074,247 @@ function stepE2eSmoke() {
621
1074
  }
622
1075
  }
623
1076
 
1077
+ function stepE2eSmoke() {
1078
+ const guard = maestroGuards("e2eSmoke");
1079
+ if (guard) return guard;
1080
+ // Machine-global lease before the first device touch (contention = SKIP).
1081
+ const leaseSkip = leaseDeviceForStep("e2eSmoke");
1082
+ if (leaseSkip) return leaseSkip;
1083
+ const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
1084
+ if (!install.ok) {
1085
+ return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
1086
+ }
1087
+ settleAdb();
1088
+ return runMaestroSmoke("e2eSmoke", install.durationMs);
1089
+ }
1090
+
1091
+ // Instrumented behavior tier (composeApp/src/androidInstrumentedTest) — the one step
1092
+ // whose evidence crosses the process boundary. Alarms, notification channels,
1093
+ // full-screen intents, PendingIntent identity, and audio routing are OS facts:
1094
+ // desktopTest is a JVM, golden trees are structure, the conformance suite is static,
1095
+ // and the Maestro smoke taps UI without asserting anything about the shade or the
1096
+ // alarm table. Nine escaped platform-semantics defects across two real apps trace to
1097
+ // exactly this blind spot; the hand-built precursor of this step caught two bugs the
1098
+ // week it landed. `connectedDebugAndroidTest` builds, installs, and runs the
1099
+ // instrumented suite in the app's real process on the attached device.
1100
+ //
1101
+ // SKIP (never FAIL) on missing infrastructure — no device, or no instrumented sources
1102
+ // yet — mirroring e2eSmoke's stance: absence of the tier is recorded honestly, only
1103
+ // broken behavior fails.
1104
+ function stepAndroidChecks() {
1105
+ const started = Date.now();
1106
+ const instrumentedDir = path.join(ROOT, "composeApp/src/androidInstrumentedTest");
1107
+ const hasSources = fs.existsSync(instrumentedDir) &&
1108
+ walkFiles(instrumentedDir, [".kt"]).length > 0;
1109
+ if (!hasSources) {
1110
+ return {
1111
+ name: "androidChecks",
1112
+ verdict: "SKIP",
1113
+ reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
1114
+ durationMs: Date.now() - started,
1115
+ };
1116
+ }
1117
+ if (!deviceAttached()) {
1118
+ return {
1119
+ name: "androidChecks",
1120
+ verdict: "SKIP",
1121
+ reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
1122
+ durationMs: Date.now() - started,
1123
+ };
1124
+ }
1125
+ // Machine-global lease before the first device touch (contention = SKIP).
1126
+ const leaseSkip = leaseDeviceForStep("androidChecks");
1127
+ if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
1128
+ // Settle before Gradle's own install+drive: earlier lane steps (tokenDrift's
1129
+ // port-forwards, e2eSmoke's reinstall) can leave the transport stale — see settleAdb.
1130
+ settleAdb();
1131
+ // `--rerun` for the same evidence-integrity reason as stepUnitTests: the receipt must
1132
+ // attest tests that EXECUTED on this tree, never a replayed up-to-date verdict.
1133
+ const res = shGradle(`${GRADLEW} :composeApp:connectedDebugAndroidTest --rerun --console=plain`);
1134
+ const summary = junitSummary(path.join(ROOT, "composeApp/build/outputs/androidTest-results/connected"));
1135
+ return {
1136
+ name: "androidChecks",
1137
+ verdict: res.ok ? "PASS" : "FAIL",
1138
+ reason: res.ok
1139
+ ? undefined
1140
+ : `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")}`,
1141
+ durationMs: Date.now() - started,
1142
+ details: summary ?? undefined,
1143
+ };
1144
+ }
1145
+
1146
+ // Release-APK smoke — the behavior half of stepReleaseBuild. assembleRelease proves R8
1147
+ // and the build graph COMPILE; two real bugs were only findable by *running* the release
1148
+ // variant (R8 behavior differs from debug). Installs the release APK and drives the same
1149
+ // Maestro smoke flow against it. Ship-time cost by design: this step exists only in the
1150
+ // `release` profile, never per-change.
1151
+ //
1152
+ // Honesty notes, both deliberate:
1153
+ // - A template-fresh app has NO release signingConfig (the keystore belongs to whoever
1154
+ // ships), and an unsigned APK cannot be installed. That is a SKIP naming what to
1155
+ // configure, never a FAIL — a fresh scaffold must not red-bar on a keystore it was
1156
+ // never given.
1157
+ // - This step reinstalls NOTHING afterwards: the release build stays on the device,
1158
+ // which is the honest state ("what is installed is what was last proven"). The next
1159
+ // debug install over it will hit INSTALL_FAILED_UPDATE_INCOMPATIBLE (release and debug
1160
+ // signatures differ) — run `adb uninstall <applicationId>` first; the same applies in
1161
+ // reverse here, so that raw Gradle error is translated into the actionable message.
1162
+ function stepReleaseSmoke() {
1163
+ const guard = maestroGuards("releaseSmoke");
1164
+ if (guard) return guard;
1165
+
1166
+ let gradleText = "";
1167
+ try {
1168
+ gradleText = fs.readFileSync(path.join(ROOT, "composeApp/build.gradle.kts"), "utf8");
1169
+ } catch {
1170
+ gradleText = "";
1171
+ }
1172
+ const applicationId = gradleText.match(/applicationId\s*=\s*"([^"]+)"/)?.[1] ?? "<applicationId>";
1173
+ if (!/signingConfig/.test(gradleText)) {
1174
+ return {
1175
+ name: "releaseSmoke",
1176
+ verdict: "SKIP",
1177
+ reason:
1178
+ "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.",
1179
+ durationMs: 0,
1180
+ };
1181
+ }
1182
+
1183
+ // Machine-global lease before the first device touch (contention = SKIP).
1184
+ // After the signing check on purpose: an unsigned template SKIPs on the
1185
+ // keystore without ever needing the device.
1186
+ const leaseSkip = leaseDeviceForStep("releaseSmoke");
1187
+ if (leaseSkip) return leaseSkip;
1188
+
1189
+ const install = shGradle(`${GRADLEW} :composeApp:installRelease --console=plain`);
1190
+ if (!install.ok) {
1191
+ if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/.test(install.out)) {
1192
+ return {
1193
+ name: "releaseSmoke",
1194
+ verdict: "FAIL",
1195
+ 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.`,
1196
+ durationMs: install.durationMs,
1197
+ };
1198
+ }
1199
+ if (/SigningConfig|not signed|INSTALL_PARSE_FAILED_NO_CERTIFICATES/i.test(install.out)) {
1200
+ return {
1201
+ name: "releaseSmoke",
1202
+ verdict: "SKIP",
1203
+ 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.",
1204
+ durationMs: install.durationMs,
1205
+ };
1206
+ }
1207
+ return {
1208
+ name: "releaseSmoke",
1209
+ verdict: "FAIL",
1210
+ 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")}`,
1211
+ durationMs: install.durationMs,
1212
+ };
1213
+ }
1214
+ settleAdb();
1215
+ return runMaestroSmoke("releaseSmoke", install.durationMs);
1216
+ }
1217
+
1218
+ // ── Audit cadence (roadmap §10 item 9) — a REPORT, never a gate ────────────
1219
+ // cmp-audit (the adversarial platform-semantics audit) found six latent
1220
+ // defects the first time a human happened to ask for it — which is exactly
1221
+ // why it must not depend on someone remembering to ask. This step is the
1222
+ // cheapest honest replacement for that memory: at ship time (release
1223
+ // profile) the receipt lists which androidMain subsystems changed since
1224
+ // their last RECORDED audit (qa/audits.jsonl, appended by
1225
+ // node qa/record-audit.mjs). The derivation lives in
1226
+ // qa/lib/audit-cadence.mjs; this step adds only the bookkeeping every step
1227
+ // carries — and by construction it maps every outcome to PASS or SKIP,
1228
+ // never FAIL: audit debt is a judgment call (a rename is not six latent
1229
+ // defects), and a gate here would teach people to game the ledger, which
1230
+ // would destroy the only value it has.
1231
+ function stepAuditCadence() {
1232
+ const started = Date.now();
1233
+ const report = evaluateAuditCadence(ROOT);
1234
+ if (!report.ok) {
1235
+ return { name: "auditCadence", verdict: "SKIP", reason: report.reason, durationMs: Date.now() - started };
1236
+ }
1237
+ return {
1238
+ name: "auditCadence",
1239
+ verdict: "PASS",
1240
+ note: report.summary,
1241
+ durationMs: Date.now() - started,
1242
+ details: {
1243
+ packageRoot: report.packageRoot,
1244
+ subsystems: report.subsystems.map((s) => ({
1245
+ name: s.name,
1246
+ status: s.status,
1247
+ changedFiles: s.changedFiles,
1248
+ lastAudit: s.audit ? { sha: s.audit.sha, at: s.audit.at, by: s.audit.by } : null,
1249
+ })),
1250
+ lines: report.lines,
1251
+ },
1252
+ };
1253
+ }
1254
+
624
1255
  // ── Lane ───────────────────────────────────────────────────────────────────
625
1256
 
1257
+ // Device-dependent steps, in lane order. Used twice: receipt STRENGTH (which
1258
+ // on-device steps actually PASSed — see below, where the receipt is built) and
1259
+ // the --fast exclusion (with releaseBuild added), so the "device/slow tier"
1260
+ // can never mean two different lists.
1261
+ const DEVICE_STEPS = ["e2eSmoke", "tokenDrift", "androidChecks", "releaseSmoke"];
1262
+
1263
+ // ── Fast-mode memoization of the pure-Node steps (qa/lib/step-cache.mjs) ────
1264
+ // These five steps run no Gradle, shell out to nothing, and are pure functions
1265
+ // of files on disk — so in FAST mode an unchanged input set reuses the last
1266
+ // PASS as verdict "CACHED" (rendered distinctly; only a PASS is ever reused,
1267
+ // a cached FAIL/SKIP always re-runs). THE FULL LANE NEVER CONSULTS THE CACHE —
1268
+ // deliberately: it keeps the integrity property absolute rather than "absolute
1269
+ // unless a cache says otherwise". A full run still WRITES entries so the next
1270
+ // fast run benefits. schemaHistory is NOT here even though it runs no Gradle:
1271
+ // it shells out to git and its verdict depends on HEAD state, not only file
1272
+ // bytes — memoizing it on a content hash could go silently stale.
1273
+ //
1274
+ // Each input set is the step's ACTUAL read surface, over-declared where cheap
1275
+ // (a too-broad set only costs cache misses; a too-narrow one is a
1276
+ // silently-stale gate — the worst possible bug here):
1277
+ // specCoverage reads specs/*.spec.md + citations under composeApp/src
1278
+ // and qa/e2e (qa/lib/spec-coverage.mjs)
1279
+ // approvals reads qa/approvals.json + every governed artifact file:
1280
+ // specs/, docs/features/, docs/ARCHITECTURE.md, and the
1281
+ // exemplar/theme/components Kotlin under composeApp/src
1282
+ // (qa/lib/approvals.mjs listGovernedArtifacts)
1283
+ // componentStories reads commonMain presentation/components and desktopMain
1284
+ // inspector sources — both under composeApp/src
1285
+ // reachability reads commonMain Kotlin (composeApp/src) + the unrouted
1286
+ // declarations in docs/features/
1287
+ // archDoc reads docs/ARCHITECTURE.md, docs/adr/, specs/intent.md
1288
+ // (over-declared to all of specs/), and every source-set's
1289
+ // Kotlin under composeApp/src (qa/lib/arch-doc.mjs)
1290
+ const MEMOIZED_STEP_INPUTS = {
1291
+ specCoverage: ["specs", "composeApp/src", "qa/e2e"],
1292
+ approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
1293
+ componentStories: ["composeApp/src"],
1294
+ reachability: ["composeApp/src", "docs/features"],
1295
+ archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
1296
+ };
1297
+
1298
+ const memoized = (stepName, stepFn) => () =>
1299
+ memoizeStep({ fast, root: ROOT, stepName, inputs: MEMOIZED_STEP_INPUTS[stepName], run: stepFn });
1300
+
1301
+ const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
1302
+ const stepApprovalsMemo = memoized("approvals", stepApprovals);
1303
+ const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
1304
+ const stepReachabilityMemo = memoized("reachability", stepReachability);
1305
+ const stepArchDocMemo = memoized("archDoc", stepArchDoc);
1306
+
626
1307
  const stepsForProfile = {
627
1308
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
628
1309
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
629
- scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepReachability, stepArchDoc, stepBuild, stepUnitTests],
1310
+ scaffold: [stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
630
1311
  local: [
631
- stepSpecCoverage,
632
- stepApprovals,
633
- stepComponentStories,
634
- stepReachability,
635
- stepArchDoc,
1312
+ stepSpecCoverageMemo,
1313
+ stepApprovalsMemo,
1314
+ stepComponentStoriesMemo,
1315
+ stepReachabilityMemo,
1316
+ stepArchDocMemo,
1317
+ stepSchemaHistory,
636
1318
  stepBuild,
637
1319
  // Release stays OUT of `scaffold`: stamp-time --verify promises a green first build, and
638
1320
  // an R8 pass would add minutes to every scaffold to re-prove what this step proves here.
@@ -644,44 +1326,165 @@ const stepsForProfile = {
644
1326
  stepTokenDrift,
645
1327
  stepA11y,
646
1328
  stepE2eSmoke,
1329
+ // androidChecks joins local BY the file's own convention, not despite it: local's
1330
+ // contract (see USAGE) is "everything; device-dependent steps SKIP when no device is
1331
+ // attached" — device presence is the opt-in, exactly as e2eSmoke and tokenDrift
1332
+ // already work. A developer with no device attached pays nothing here; one who
1333
+ // attached an emulator has already opted into the device tier's cost. Hiding this
1334
+ // step in ci-only would make local's documented contract a lie and re-open the gap
1335
+ // this tier closes (androidMain test-invisible in the profile people actually run).
1336
+ // Last on purpose: the cheap desktop verdicts and the smoke land first.
1337
+ stepAndroidChecks,
647
1338
  ],
648
1339
  };
649
- stepsForProfile.ci = stepsForProfile.local;
1340
+ // ci = local + the determinism probe's row — the first place ci diverges
1341
+ // from local. The probe is OPT-IN (the step SKIPs unless --determinism was
1342
+ // passed: it doubles the JVM test tier's cost), but its row lives in the ci
1343
+ // profile so a ci receipt always records whether the probe ran — an honest,
1344
+ // visible gap beats an invisible one ("SKIPs are recorded so the pipeline
1345
+ // stays honest", per the profile's own contract). local deliberately does
1346
+ // NOT carry the row: the per-change developer profile is not where a
1347
+ // deliberate double-run belongs.
1348
+ stepsForProfile.ci = [...stepsForProfile.local, stepDeterminism];
1349
+ // release = everything ci proves PLUS the audit-cadence report and the
1350
+ // release-APK behavior smoke. The expensive proofs are profile-tiered by
1351
+ // decision: per-change stays fast (local/ci pay for the release COMPILE via
1352
+ // releaseBuild, already in the set), and the release-variant *behavior* cost
1353
+ // lands once, at ship time. auditCadence (a report, never a gate) also
1354
+ // belongs to ship time — "what moved in androidMain since its last
1355
+ // adversarial audit?" is the question asked before shipping, not per edit.
1356
+ // releaseSmoke runs last so the device ends the run holding the exact build
1357
+ // that was proven.
1358
+ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseSmoke];
650
1359
 
651
1360
  if (!stepsForProfile[profile]) {
652
- console.error(`Unknown profile "${profile}" — use scaffold | local | ci.`);
1361
+ console.error(`Unknown profile "${profile}" — use scaffold | local | ci | release.`);
653
1362
  process.exit(2);
654
1363
  }
655
1364
 
1365
+ // ── Bare --determinism: the probe, nothing else, and NO receipt ─────────────
1366
+ // "Run it alone" means alone: no other steps, and deliberately no
1367
+ // qa/evidence/latest.json. The done-gate (qa/receipt-check.mjs) validates a
1368
+ // receipt by verdict + content hash — a receipt whose steps are one probe
1369
+ // would satisfy it while attesting almost nothing, so a probe-only run must
1370
+ // never mint one. The lane marker IS still stamped: the probe runs Gradle
1371
+ // and owes the preview daemon the same coexistence courtesy as the lane.
1372
+ if (determinism && !profileExplicit) {
1373
+ fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
1374
+ fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
1375
+ let probe;
1376
+ try {
1377
+ probe = stepDeterminism();
1378
+ } finally {
1379
+ fs.rmSync(LANE_MARKER, { force: true });
1380
+ }
1381
+ if (asJson) {
1382
+ console.log(JSON.stringify(probe, null, 2));
1383
+ } else {
1384
+ const mark = probe.verdict === "PASS" ? "✓" : "✗";
1385
+ console.log(`${mark} determinism: ${probe.verdict}${probe.note ? ` (${probe.note})` : ""}${probe.reason ? ` — ${probe.reason}` : ""}`);
1386
+ console.log("\n(probe-only run — no receipt written; the full lane is where evidence is earned)");
1387
+ }
1388
+ process.exit(probe.verdict === "FAIL" ? 1 : 0);
1389
+ }
1390
+
1391
+ // ── --fast: the inner loop, mechanically unable to claim done ───────────────
1392
+ // The genuinely slow tier is device/release work — every DEVICE_STEPS entry
1393
+ // (Gradle install + emulator + Maestro + instrumented runner) plus
1394
+ // releaseBuild (R8 + lintVital, the slow release COMPILE). --fast filters
1395
+ // that tier out of whatever profile resolved, UNCONDITIONALLY — device
1396
+ // attached or not — so a small change gets its did-I-break-anything-obvious
1397
+ // signal in JVM time. The rest of the profile still runs — but cheaply: the
1398
+ // pure-Node steps reuse an unchanged PASS from the step cache (CACHED — see
1399
+ // the memoization block above), the Gradle test steps drop --rerun (see
1400
+ // RERUN above), and unitTests scopes itself to the working-tree change
1401
+ // (see stepUnitTests). The loophole is closed at the receipt, not by
1402
+ // convention: mode "fast" is
1403
+ // recorded, no evidence rung is derived (qa/lib/evidence-level.mjs), and
1404
+ // qa/receipt-check.mjs refuses a fast receipt as done evidence.
1405
+ const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
1406
+ const STEP_FN_BY_NAME = {
1407
+ e2eSmoke: stepE2eSmoke,
1408
+ tokenDrift: stepTokenDrift,
1409
+ androidChecks: stepAndroidChecks,
1410
+ releaseSmoke: stepReleaseSmoke,
1411
+ releaseBuild: stepReleaseBuild,
1412
+ };
1413
+ for (const name of FAST_EXCLUDED_NAMES) {
1414
+ if (!STEP_FN_BY_NAME[name]) {
1415
+ // Drift guard: a new device-tier step must be mapped here or --fast would silently run it.
1416
+ console.error(`internal: fast-excluded step "${name}" has no entry in STEP_FN_BY_NAME — fix qa/verify.mjs`);
1417
+ process.exit(2);
1418
+ }
1419
+ }
1420
+ const FAST_EXCLUDED_FNS = new Set(FAST_EXCLUDED_NAMES.map((name) => STEP_FN_BY_NAME[name]));
1421
+ const laneSteps = fast
1422
+ ? stepsForProfile[profile].filter((fn) => !FAST_EXCLUDED_FNS.has(fn))
1423
+ : stepsForProfile[profile];
1424
+ const fastExcluded = fast
1425
+ ? FAST_EXCLUDED_NAMES.filter((name) => stepsForProfile[profile].includes(STEP_FN_BY_NAME[name]))
1426
+ : [];
1427
+
1428
+ if (fast) {
1429
+ console.error(
1430
+ [
1431
+ "⚡⚡ FAST MODE — INNER LOOP ONLY, NOT THE DONE-GATE ⚡⚡",
1432
+ ` skipping the device/release tier: ${fastExcluded.join(", ") || "(none in this profile)"}`,
1433
+ ' this run\'s receipt records mode "fast", earns no evidence rung, and can NEVER satisfy "done"',
1434
+ " run the full lane once (node qa/verify.mjs) before you finish",
1435
+ ].join("\n"),
1436
+ );
1437
+ }
1438
+
656
1439
  // Stamp the lane marker for the run's duration (coexistence defense 1 above);
657
1440
  // always removed, even on a failing step, so the eyes only ever defer briefly.
658
1441
  fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
659
1442
  fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
1443
+ const laneStartedAt = Date.now(); // for the flight-recorder entry's durationMs
660
1444
  const steps = [];
661
1445
  try {
662
- for (const step of stepsForProfile[profile]) {
1446
+ for (const step of laneSteps) {
663
1447
  const result = step();
664
1448
  steps.push(result);
665
1449
  if (!asJson) {
666
- const mark = result.verdict === "PASS" ? "✓" : result.verdict === "SKIP" ? "→" : "✗";
667
- console.log(`${mark} ${result.name}: ${result.verdict}${result.reason ? ` ${result.reason.split("\n")[0]}` : ""}`);
1450
+ // CACHED (fast mode only see the memoization block above) renders with
1451
+ // its own mark and "unchanged since" note so a reused verdict is never
1452
+ // mistakable for a fresh execution.
1453
+ const mark = result.verdict === "PASS" ? "✓" : result.verdict === "CACHED" ? "⚡" : result.verdict === "SKIP" ? "→" : "✗";
1454
+ console.log(`${mark} ${result.name}: ${result.verdict}${result.note ? ` (${result.note})` : ""}${result.reason ? ` — ${result.reason.split("\n")[0]}` : ""}`);
668
1455
  }
669
1456
  if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
670
1457
  }
671
1458
  } finally {
672
1459
  fs.rmSync(LANE_MARKER, { force: true });
1460
+ // The device lease (if a device step took it) is held to the very end of the
1461
+ // run — see the scope decision at leaseDeviceForStep. Release is idempotent
1462
+ // and never deletes a foreign holder's lease.
1463
+ if (laneDeviceLease) releaseDeviceLease(laneDeviceLease);
673
1464
  }
674
1465
 
1466
+ // CACHED counts as PASS for the lane verdict (it IS a prior PASS, reused only
1467
+ // in fast mode on an unchanged input set) — but it stays CACHED on the
1468
+ // receipt, visibly distinct, so a fast receipt can never be read as if every
1469
+ // step freshly executed.
675
1470
  const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
676
1471
 
677
1472
  // Receipt STRENGTH — a desktop-only green and an on-device green are different
678
1473
  // claims, and the difference should never live only in the SKIP lines. Device-
679
1474
  // dependent steps that actually RAN (PASSed) are named on the receipt and in the
680
1475
  // verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
681
- const DEVICE_STEPS = ["e2eSmoke", "tokenDrift"];
1476
+ // (DEVICE_STEPS itself is defined above the lane — it also drives --fast.)
682
1477
  const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
683
1478
  const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
684
1479
 
1480
+ // Receipt RUNG — the evidence ladder (qa/lib/evidence-level.mjs): the coarse,
1481
+ // named grade (L0 scaffold / L1 desktop / L2 device / L3 release) DERIVED from
1482
+ // which steps actually ran and PASSed. The strength string above stays as the
1483
+ // fine print; the rung is added alongside, never in place of it. null on FAIL —
1484
+ // a failed lane has no rung. null on a --fast run too: the inner loop is a
1485
+ // signal, never evidence, so a fast receipt derives NO rung at all.
1486
+ const level = evidenceLevel(steps, profile, { mode });
1487
+
685
1488
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
686
1489
  const artifacts = [];
687
1490
  if (fs.existsSync(ARTIFACTS_DIR)) {
@@ -707,6 +1510,10 @@ const inputs = computeInputsHash(ROOT);
707
1510
  const receipt = {
708
1511
  schema: "cmp-evidence/1",
709
1512
  profile,
1513
+ // "full" is the done-gate; "fast" (--fast) excluded the device/release tier
1514
+ // and is REFUSED by qa/receipt-check.mjs — a fast run can never end a session
1515
+ // as "done". Receipts predating this field are treated as full.
1516
+ mode,
710
1517
  verdict,
711
1518
  commit: {
712
1519
  sha: tryGit("rev-parse HEAD"),
@@ -718,6 +1525,7 @@ const receipt = {
718
1525
  },
719
1526
  steps,
720
1527
  strength: { onDeviceSteps },
1528
+ evidenceLevel: level,
721
1529
  artifacts,
722
1530
  toolVersions: {
723
1531
  node: process.version,
@@ -732,7 +1540,76 @@ fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(recei
732
1540
  // studio console's Evidence audit trail reconstructs the full history from the
733
1541
  // git log of this file — every commit is one verified, attributed state.
734
1542
 
735
- if (asJson) console.log(JSON.stringify(receipt, null, 2));
736
- else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} (${strengthLabel}) receipt written to qa/evidence/latest.json (commit it with your change)`);
1543
+ // The README's evidence badge is DERIVED from the receipt just written — an
1544
+ // output, never a gate, so it runs after the verdict and cannot change it. It
1545
+ // renders the rung together with the commit it was attested against, so the
1546
+ // sentence stays true as the tree moves on (qa/lib/evidence-badge.mjs).
1547
+ const badge = updateReadmeBadge(ROOT);
1548
+
1549
+ // ── Flight recorder (roadmap §10 item 5) — the lane journals its own run ────
1550
+ // One JSON line per run into qa/flight-recorder.jsonl (committed, and
1551
+ // excluded from the receipt's hashed surface — qa/lib/flight-recorder.mjs
1552
+ // carries the whole rationale). Appended AFTER the receipt so the entry
1553
+ // records the final verdict and rung. A failed append must never fail the
1554
+ // lane — a recorder that breaks the thing it observes is worse than no
1555
+ // recorder — so the failure degrades to a note in the lane's own output,
1556
+ // which is itself the honest record of the degradation.
1557
+ //
1558
+ // --no-journal is the ONE exemption, and qa/watch.mjs passes it on every
1559
+ // save-triggered run. Same rule the README badge obeys, for the same reason:
1560
+ // THE INNER LOOP DOES NOT WRITE TO COMMITTED FILES. A watcher journaling every
1561
+ // save would add hundreds of lines a day to a committed file — turning the
1562
+ // app's history into keystroke noise and leaving a permanently-dirty tree in
1563
+ // the loop the recorder exists to observe. What survives is every full lane
1564
+ // and every DELIBERATE fast run, which is what the retrospective's questions
1565
+ // actually rest on (SKIP reasons, degraded paths, the longest stretch with no
1566
+ // full lane). qa/retrospective.mjs discloses the exemption in its own output
1567
+ // so the fast-vs-full ratio is never read as a complete census.
1568
+ const flight = noJournal
1569
+ ? { ok: true, skipped: true }
1570
+ : appendFlightRecord(
1571
+ ROOT,
1572
+ buildFlightEntry({
1573
+ profile,
1574
+ mode,
1575
+ verdict,
1576
+ evidenceLevel: level,
1577
+ steps,
1578
+ sha: receipt.commit.sha,
1579
+ durationMs: Date.now() - laneStartedAt,
1580
+ onDeviceSteps,
1581
+ degraded: DEGRADED_PATHS,
1582
+ }),
1583
+ );
1584
+ if (!flight.ok) {
1585
+ console.error(`· flight recorder: journal append failed (${flight.reason}) — the lane verdict is unaffected, but this run is missing from qa/flight-recorder.jsonl`);
1586
+ }
1587
+
1588
+ if (asJson) {
1589
+ console.log(JSON.stringify(receipt, null, 2));
1590
+ if (fast) {
1591
+ 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.`);
1592
+ }
1593
+ } else if (fast) {
1594
+ // Deliberately NOT the full lane's verdict-line shape: fast-green must never
1595
+ // be mistakable for done-green.
1596
+ console.log(
1597
+ `\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`,
1598
+ );
1599
+ } else {
1600
+ console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name}` : ""} (${strengthLabel}) — receipt written to qa/evidence/latest.json${badge.changed ? ` and ${README_REL_PATH}'s evidence badge refreshed` : ""} (commit ${badge.changed ? "them" : "it"} with your change)`);
1601
+ }
1602
+
1603
+ // The audit-cadence nudges print in the human path, not only inside the
1604
+ // receipt JSON — a ship-time report that lives only in a JSON field is a
1605
+ // report nobody reads at ship time. Nudges only; a gate this is not.
1606
+ if (!asJson) {
1607
+ const auditStep = steps.find((s) => s.name === "auditCadence");
1608
+ const auditLines = auditStep?.details?.lines ?? [];
1609
+ if (auditLines.length > 0) {
1610
+ console.log("\naudit cadence (report, never a gate):");
1611
+ for (const l of auditLines) console.log(` ${l}`);
1612
+ }
1613
+ }
737
1614
 
738
1615
  process.exit(verdict === "PASS" ? 0 : 1);