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
@@ -0,0 +1,113 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.content.Context
4
+ import android.media.AudioManager
5
+ import androidx.test.platform.app.InstrumentationRegistry
6
+
7
+ /**
8
+ * Snapshot/restore of device audio state (ringer mode, Do Not Disturb) so audio-routing
9
+ * behavior can be asserted — "does the alert still sound when the phone is on silent?"
10
+ * is THE question that shipped broken repeatedly, because ringer mode only exists on a
11
+ * device and every JVM tier is deaf to it.
12
+ *
13
+ * Being honest about what instrumentation can and cannot control:
14
+ *
15
+ * CAN (this file):
16
+ * - Read and set the ringer mode. Setting SILENT/VIBRATE via AudioManager flips the
17
+ * device through DND on modern Android, so both knobs are driven through the shell
18
+ * (`cmd notification set_dnd`, `settings global zen_mode`) with the instrumentation's
19
+ * shell uid ([Shell.exec]) — no notification-policy grant dance, works on a stock emulator.
20
+ * - Read/set DND (zen mode) as a global: off / priority-only / total-silence / alarms-only.
21
+ * - Observe playback routing: which stream/usage an active player is on
22
+ * (AudioManager.activePlaybackConfigurations — see PlatformBehaviorSeamTest for the
23
+ * assertion shape: a sound with USAGE_ALARM is what survives a silenced ringer).
24
+ *
25
+ * CANNOT (manual tier — document the claim in your spec, verify it by hand per release):
26
+ * - OEM sound policy: whether a channel's sound reaches the alarm stream on a Samsung
27
+ * is Samsung's decision; assert your app plays with USAGE_ALARM, not that a given
28
+ * handset makes air move.
29
+ * - Physical volume keys / hardware mute switches, Bluetooth routing, and whether the
30
+ * speaker is audible — there is no API for "a human heard it".
31
+ * - Lock-screen rendering: whether a takeover actually draws over a locked, OEM-skinned
32
+ * screen stays manual. Doze delivery, by contrast, stopped being a manual rehearsal
33
+ * when [DozeControl] landed — force the idle state and watch the alarm arrive.
34
+ *
35
+ * Plain helpers, not a TestRule: state here is two scalars, and an explicit
36
+ * snapshot-in-@Before / restore-in-@After (or [withRingerMode]) keeps the mechanism
37
+ * visible in the test. Wrap it in a rule when a suite grows enough tests to justify one.
38
+ */
39
+ object SystemState {
40
+
41
+ /** The restorable slice of device audio state. */
42
+ data class Snapshot(val ringerMode: Int, val zenMode: Int)
43
+
44
+ /** DND (zen) global values — `settings get global zen_mode`. */
45
+ const val ZEN_OFF = 0
46
+ const val ZEN_PRIORITY_ONLY = 1
47
+ const val ZEN_TOTAL_SILENCE = 2
48
+ const val ZEN_ALARMS_ONLY = 3
49
+
50
+ private val audio: AudioManager
51
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
52
+ .getSystemService(Context.AUDIO_SERVICE) as AudioManager
53
+
54
+ /** Capture the current ringer + DND state. Take it BEFORE the test mutates anything. */
55
+ fun snapshot(): Snapshot = Snapshot(ringerMode = audio.ringerMode, zenMode = readZen())
56
+
57
+ /** Put the device back exactly as [snapshot] found it — call from @After, always. */
58
+ fun restore(s: Snapshot) {
59
+ setZen(s.zenMode)
60
+ setRingerMode(s.ringerMode)
61
+ }
62
+
63
+ /**
64
+ * Set the ringer mode (AudioManager.RINGER_MODE_NORMAL / _VIBRATE / _SILENT).
65
+ * SILENT is driven as alarms-only DND + the mode itself — on API 23+ that is what the
66
+ * volume-key gesture actually does, and it is the honest reproduction of "the user
67
+ * silenced the phone" (alarms are still allowed to sound; a total-silence test should
68
+ * say so explicitly via [setZen] with [ZEN_TOTAL_SILENCE]).
69
+ */
70
+ fun setRingerMode(mode: Int) {
71
+ when (mode) {
72
+ AudioManager.RINGER_MODE_SILENT -> setZen(ZEN_ALARMS_ONLY)
73
+ else -> setZen(ZEN_OFF)
74
+ }
75
+ // Best-effort after the zen change; on emulators this lands reliably. Poll-check
76
+ // rather than trust: callers get the real mode back and can assume-skip if the
77
+ // device refused (an OEM with a hardware mute state may).
78
+ audio.ringerMode = mode
79
+ }
80
+
81
+ /** Set DND directly (one of the ZEN_* constants). Shell-driven; applies globally. */
82
+ fun setZen(zen: Int) {
83
+ val arg = when (zen) {
84
+ ZEN_PRIORITY_ONLY -> "priority"
85
+ ZEN_TOTAL_SILENCE -> "none"
86
+ ZEN_ALARMS_ONLY -> "alarms"
87
+ else -> "off"
88
+ }
89
+ Shell.exec("cmd notification set_dnd $arg")
90
+ }
91
+
92
+ /** The device's current DND global (ZEN_* value; unreadable/absent reads as off). */
93
+ fun readZen(): Int =
94
+ Shell.exec("settings get global zen_mode").trim().toIntOrNull() ?: ZEN_OFF
95
+
96
+ /**
97
+ * Run [block] with the ringer in [mode], restoring the full snapshot afterwards even
98
+ * when the block throws — the composable form for a single-assertion test:
99
+ *
100
+ * SystemState.withRingerMode(AudioManager.RINGER_MODE_SILENT) {
101
+ * // trigger the alert path, then assert a player is active with USAGE_ALARM
102
+ * }
103
+ */
104
+ fun <T> withRingerMode(mode: Int, block: () -> T): T {
105
+ val before = snapshot()
106
+ return try {
107
+ setRingerMode(mode)
108
+ block()
109
+ } finally {
110
+ restore(before)
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,114 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.os.SystemClock
4
+
5
+ /**
6
+ * Device-clock manipulation for the on-device tier — the primitive that turns "the alarm
7
+ * ladder is unit-tested to the minute" into "we watched the alarm actually arrive".
8
+ *
9
+ * Why this exists: scheduled delivery is the one claim the other helpers still could not
10
+ * close. [AlarmAsserts] proves REGISTRATION (the OS holds the alarm), unit tests prove the
11
+ * ladder's arithmetic, and yet "the notification arrives at 08:00" shipped untested every
12
+ * time — nobody waits two days inside a test run. Warping the device clock past the
13
+ * trigger time makes delivery observable in seconds: schedule for T, warp to T+ε, and the
14
+ * OS either fires the alarm or it doesn't. The same mechanism makes DST transitions and
15
+ * date rollovers ([withTimeZone]) a test input instead of a twice-a-year production
16
+ * surprise.
17
+ *
18
+ * EMULATOR-ONLY, NON-NEGOTIABLE — every entry point runs [assumeOnEmulator] first:
19
+ * warping a real phone's clock corrupts the owner's world, not just the test's — alarms
20
+ * re-fire or vanish, TLS certificates fall outside their validity window, auth tokens
21
+ * expire or refuse to, and messaging apps reorder history. A QA emulator is disposable
22
+ * state; a person's device never is. On a non-emulator the guard SKIPs (assumption
23
+ * violation), never fails — the suite stays honest about where it can run.
24
+ *
25
+ * Constraints the code can't show:
26
+ * - No root involved. `adb root` is refused on production (user-build) images, including
27
+ * the stock Play-image AVDs — but `cmd alarm set-time` / `set-timezone` work from the
28
+ * SHELL uid, which is exactly what [Shell.exec]'s UiAutomation channel provides.
29
+ * Time-warp proofs therefore run on any stock emulator, no special image.
30
+ * - Network time sync fights the warp: with `auto_time` on, the device re-corrects the
31
+ * clock underneath the test. Every warp is bracketed — sync off, warp, run, restore,
32
+ * sync back on — and the restore runs in `finally`, so a crashed block still leaves
33
+ * the device usable for the next test.
34
+ * - The restore is not "set the clock back to what it was": real time keeps passing
35
+ * while the block runs, and restoring the ORIGINAL instant would leave the clock
36
+ * slow by the block's duration (compounding across a suite). The restore target is
37
+ * `original + elapsed`, with elapsed measured on [SystemClock.elapsedRealtime] —
38
+ * the monotonic clock, immune to the warp itself.
39
+ * - Inside a warped block, bound your waits on monotonic time (`CountDownLatch.await`,
40
+ * [SystemClock.elapsedRealtime]), never on `System.currentTimeMillis()` — wall-clock
41
+ * deadlines computed before the warp are nonsense after it.
42
+ */
43
+ object TimeWarp {
44
+
45
+ /**
46
+ * SKIPs the test unless it is running on an emulator (`ro.kernel.qemu == 1`).
47
+ * Called by every warp entry point; call it yourself at the top of a warp test so
48
+ * the skip names the test, not a helper frame. See the header for why a real
49
+ * device is refused: a warped personal phone breaks the owner's alarms, tokens,
50
+ * and messaging — QA emulators only.
51
+ */
52
+ fun assumeOnEmulator() {
53
+ Shell.assumeOnEmulator(
54
+ "TimeWarp runs only on emulators (ro.kernel.qemu != 1 here). Warping a real " +
55
+ "device's clock corrupts its owner's alarms, TLS validity, and auth " +
56
+ "tokens — run this suite on a stock QA AVD instead.",
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Run [block] with the device wall clock set to [targetEpochMillis], restoring
62
+ * everything afterwards even when the block throws:
63
+ *
64
+ * 1. `auto_time` off — otherwise network time sync re-corrects mid-block;
65
+ * 2. `cmd alarm set-time target` — the warp (shell uid, root-free);
66
+ * 3. the block — schedule/await/assert;
67
+ * 4. finally: `set-time (original + elapsed)` then `auto_time` back on.
68
+ *
69
+ * The restore adds the block's real duration (monotonic-measured) to the original
70
+ * instant so the device clock never falls behind true time — see the header.
71
+ */
72
+ fun <T> withWarpedClock(targetEpochMillis: Long, block: () -> T): T {
73
+ assumeOnEmulator()
74
+ val autoTimeBefore = readGlobal("auto_time")
75
+ val originalEpochMillis = System.currentTimeMillis()
76
+ val startElapsed = SystemClock.elapsedRealtime()
77
+ Shell.exec("settings put global auto_time 0")
78
+ try {
79
+ Shell.exec("cmd alarm set-time $targetEpochMillis")
80
+ return block()
81
+ } finally {
82
+ val elapsed = SystemClock.elapsedRealtime() - startElapsed
83
+ Shell.exec("cmd alarm set-time ${originalEpochMillis + elapsed}")
84
+ Shell.exec("settings put global auto_time ${autoTimeBefore ?: "1"}")
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Run [block] with the device in time zone [olsonId] (e.g. `"Pacific/Chatham"`,
90
+ * `"America/New_York"`), restoring the original zone and `auto_time_zone` afterwards
91
+ * even when the block throws. This is the DST/date-rollover assertion primitive:
92
+ * combine with [withWarpedClock] to place the device just before a transition and
93
+ * watch what a "tomorrow at 08:00" schedule actually does. No elapsed-time math here
94
+ * — a zone, unlike a clock, does not drift while the block runs.
95
+ */
96
+ fun <T> withTimeZone(olsonId: String, block: () -> T): T {
97
+ assumeOnEmulator()
98
+ val autoZoneBefore = readGlobal("auto_time_zone")
99
+ val originalZone = Shell.exec("getprop persist.sys.timezone").trim()
100
+ Shell.exec("settings put global auto_time_zone 0")
101
+ try {
102
+ Shell.exec("cmd alarm set-timezone $olsonId")
103
+ return block()
104
+ } finally {
105
+ if (originalZone.isNotEmpty()) {
106
+ Shell.exec("cmd alarm set-timezone $originalZone")
107
+ }
108
+ Shell.exec("settings put global auto_time_zone ${autoZoneBefore ?: "1"}")
109
+ }
110
+ }
111
+
112
+ /** A global setting's current value, or null when unset — see [Shell.readSetting]. */
113
+ private fun readGlobal(key: String): String? = Shell.readSetting("global", key)
114
+ }
@@ -5,7 +5,7 @@ import __PACKAGE__.domain.repository.ItemRepository
5
5
  import __PACKAGE__.domain.usecase.GetItemsUseCase
6
6
  import __PACKAGE__.presentation.home.HomeViewModel
7
7
  // cmp:anchor di-imports
8
- import org.koin.core.module.dsl.viewModelOf
8
+ import org.koin.core.module.dsl.viewModel
9
9
  import org.koin.dsl.module
10
10
 
11
11
  val repositoryModule = module {
@@ -19,7 +19,10 @@ val useCaseModule = module {
19
19
  }
20
20
 
21
21
  val viewModelModule = module {
22
- viewModelOf(::HomeViewModel)
22
+ // Explicit factories only — never reflection-based viewModelOf (ARCH-14): it silently
23
+ // ignores constructor default parameter values, turning a compile-time wiring error
24
+ // into a runtime resolution crash. One get() per constructor dependency.
25
+ viewModel { HomeViewModel(get()) }
23
26
  // cmp:anchor di-viewmodels
24
27
  }
25
28
 
@@ -118,7 +118,7 @@ internal fun NavItem(
118
118
  .clip(RoundedCornerShape(8.dp))
119
119
  .clickable(onClick = onClick)
120
120
  // a11y: guarantee the 48dp minimum touch target regardless of label width
121
- // (the inspector's audit_a11y flags anything smaller).
121
+ // (the verify lane's a11y step flags anything smaller).
122
122
  .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
123
123
  // Durable selection handle (tests/E2E select by testTag, never display text).
124
124
  .semantics { testTag = navItemTag(label) }
@@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp
11
11
  /**
12
12
  * The filled call-to-action button: M3 `Button` with a 48 dp minimum touch target
13
13
  * applied. Stock M3 buttons sit below that floor by default; wrapping them here clears
14
- * WCAG 2.2 SC 2.5.8 and the harness's `audit_a11y` bar once, for every call site.
14
+ * WCAG 2.2 SC 2.5.8 and the verify lane's a11y bar once, for every call site.
15
15
  */
16
16
  @Composable
17
17
  fun AppPrimaryButton(
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
13
13
  * The registry's icon button: M3 `IconButton` held to the 48 dp touch-target floor.
14
14
  *
15
15
  * Stock M3 `IconButton` defaults to a 40x40 dp target — below WCAG 2.2 SC 2.5.8 and this
16
- * harness's own `audit_a11y` bar — so every raw use is a violation waiting to be measured
16
+ * verify lane's own a11y bar — so every raw use is a violation waiting to be measured
17
17
  * (and historically why a text link masqueraded as a back button). This wrapper clears the
18
18
  * floor once, by construction, the same way [AppPrimaryButton] does for filled buttons.
19
19
  *
@@ -378,6 +378,64 @@ class ArchitectureConformanceTest {
378
378
  )
379
379
  }
380
380
 
381
+ // SPEC: ARCH-13
382
+ @Test
383
+ fun `ARCH-13 ambient time is read only inside the designated time provider`() {
384
+ // The classic "inject the clock" rule. Ambient time reads make rendered structure
385
+ // and test evidence a function of WHEN you run them — a golden tree that passes at
386
+ // 23:00 and fails at 09:00 with no code change, an assertion that only reds out
387
+ // across a midnight boundary. Time is injected from one provider (a package ending
388
+ // in `.core.time`) so tests can pin it; everywhere else these APIs are banned.
389
+ // Scanned at reference level (imports AND fully-qualified inline calls), the same
390
+ // pragmatic granularity as the layer-boundary rules above. Covers kotlinx-datetime
391
+ // and kotlin.time (`Clock.System`), the JVM clock (`System.currentTimeMillis`),
392
+ // java.time (`LocalDate.now()` et al.), and the ambient zone
393
+ // (`TimeZone.currentSystemDefault()`) — the zone is time-of-run state too.
394
+ val ambientTime = Regex(
395
+ """Clock\.System|System\.currentTimeMillis|LocalDate\.now\s*\(|LocalDateTime\.now\s*\(|""" +
396
+ """LocalTime\.now\s*\(|Instant\.now\s*\(|TimeZone\.currentSystemDefault\s*\("""
397
+ )
398
+ val offenders = sources(commonMain)
399
+ .filterNot { it.path.replace(File.separatorChar, '/').contains("/core/time/") }
400
+ .filter { file -> nonCommentLines(file).any { ambientTime.containsMatchIn(it) } }
401
+ .map { it.path }
402
+ if (offenders.isNotEmpty()) fail(
403
+ violation(
404
+ "ARCH-13", "ambient time APIs (Clock.System, System.currentTimeMillis, *.now(), " +
405
+ "TimeZone.currentSystemDefault()) are called only inside the designated time " +
406
+ "provider (a `core/time` package) — everywhere else, time is an injected value.",
407
+ offenders,
408
+ "inject a clock/time provider (constructor parameter, wired in di/) that lives in " +
409
+ "`core/time`, and read the current moment through it — never ambiently. Tests then " +
410
+ "pin the provider instead of inheriting the wall clock.",
411
+ )
412
+ )
413
+ }
414
+
415
+ // SPEC: ARCH-14
416
+ @Test
417
+ fun `ARCH-14 ViewModels are registered with explicit viewModel factories - viewModelOf is banned`() {
418
+ // Koin's reflection-based `viewModelOf(::X)` silently ignores Kotlin default
419
+ // parameter values: it resolves EVERY constructor parameter from the graph, so a
420
+ // ViewModel that compiles and previews fine crashes at runtime resolution the
421
+ // first time a defaulted parameter matters (the default masked a dependency the
422
+ // graph never registered). An explicit `viewModel { X(get(), …) }` factory states
423
+ // each dependency and fails at compile time instead.
424
+ val offenders = sources(commonMain)
425
+ .filter { file -> nonCommentLines(file).any { it.contains("viewModelOf") } }
426
+ .map { it.path }
427
+ if (offenders.isNotEmpty()) fail(
428
+ violation(
429
+ "ARCH-14", "ViewModels are registered with explicit `viewModel { … }` factories — " +
430
+ "reflection-based `viewModelOf` is banned (it silently ignores constructor " +
431
+ "default parameter values; failures move from compile time to runtime resolution).",
432
+ offenders,
433
+ "replace `viewModelOf(::XViewModel)` with `viewModel { XViewModel(get(), …) }` " +
434
+ "(import org.koin.core.module.dsl.viewModel), one get() per constructor dependency.",
435
+ )
436
+ )
437
+ }
438
+
381
439
  // SPEC: SHELL-05
382
440
  @Test
383
441
  fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
@@ -21,6 +21,7 @@ app").
21
21
  | Maintainability | An AI session adds a feature; the lane names any layer violation as a clause, not a style nit | `[enforced: ARCH-01..05]` |
22
22
  | Reliability | A source fails; the failure crosses layers as a typed `DomainError`, never a raw exception, and the screen shows a mapped error state | `[enforced: ARCH-06/07/08]` |
23
23
  | Reliability (offline) | Network drops mid-session; cached Room data still renders, UI shows degraded state | `[advisory]` today — `NetworkMonitor` and Room ship as infrastructure (§4, §7) but no repository wires the cache-first fallback yet; clause candidate |
24
+ | Reliability (platform behavior) | A feature that alarms or notifies actually alerts on a real device — the claim crosses the process boundary instead of stopping at a green JVM | instrumented tier (`androidInstrumentedTest`, lane step `androidChecks`); platform-behavior spec clauses cite instrumented tests, never desktop ones |
24
25
  | Interaction capability (a11y) | Every interactive element is perceivable by assistive tech and automation | `[enforced: SHELL-04 + A11y gates]` |
25
26
  | Security | The debug inspector HTTP server (§3) never ships in a release build | `[advisory]` today — true by source-set placement (`androidDebug`), not yet gated; clause candidate (`DEBUG-01`) |
26
27
 
@@ -79,6 +80,7 @@ app").
79
80
  | `androidMain` | Android entry point (`AppApplication`), platform `actual`s. |
80
81
  | `androidDebug` | Debug-only additions layered on `androidMain` — the inspector HTTP server, crash recorder, DB inspector. Never in `androidRelease`. |
81
82
  | `androidRelease` | Release-only twins (currently a no-op inspector stub) that make the `androidDebug` additions compile out cleanly. |
83
+ | `androidInstrumentedTest` | The on-device behavior tier: platform facts no JVM test can see (alarms, notifications, PendingIntent identity, audio routing) asserted in the app's real process — generic helpers in `testing/`, exemplar in `PlatformBehaviorSeamTest`. Runs via the lane's `androidChecks` step (`connectedDebugAndroidTest`; SKIPs without a device). See `docs/TESTING.md` §"The instrumented tier". |
82
84
  | `iosMain` | iOS entry point (`MainViewController.kt`, `KoinHelper.kt`), platform `actual`s. |
83
85
  | `desktopMain` | The JVM tier: dev-client window/hot-reload, the preview-render harness, and desktop DI — **harness infrastructure**, not a shipped app target (project ADR `0003`). |
84
86
  | `desktopTest` | Conformance gates (this document's enforced clauses), Compose UI Tests, golden-tree structural baselines — the fast, device-free verification tier. |
@@ -180,7 +182,7 @@ Three named scenarios ground that loop in what actually happens on this codebase
180
182
  Room when `NetworkMonitor.isOnline` is false. Both pieces of infrastructure exist (§3, §7);
181
183
  no repository reads them today — hence the offline goal's `[advisory]` status in §1.
182
184
  3. **Navigate + process death.** `AppNavHost` (single-Activity/single-`ComposeUIViewController`)
183
- owns the back stack; each screen's `ViewModel` (scoped via `viewModelOf`/Koin) survives
185
+ owns the back stack; each screen's `ViewModel` (a Koin `viewModel { }` factory) survives
184
186
  configuration change but not process death — a killed-and-restored process re-runs cold
185
187
  start and reloads from the repository, the same path as scenario 1.
186
188
 
@@ -232,7 +234,22 @@ injected, not hardcoded, so tests can pass a test dispatcher. Do **not** add `wi
232
234
  around calls that are already main-safe by contract; it's dead weight that hides where the
233
235
  real guarantee lives.
234
236
 
235
- ### DI `[advisory]`
237
+ ### Time (inject the clock) `[enforced: ARCH-13]`
238
+
239
+ **Ambient time reads are banned outside the designated time provider.** `Clock.System`,
240
+ `System.currentTimeMillis`, `LocalDate.now()` / `LocalDateTime.now()` / `LocalTime.now()` /
241
+ `Instant.now()`, and `TimeZone.currentSystemDefault()` may be referenced only inside a
242
+ `core/time` package (the one place allowed to touch the wall clock; a fresh scaffold ships
243
+ no time usage at all, so the package appears with your first time-dependent feature).
244
+ Everywhere else, the current moment arrives as an **injected value** — a clock/time-provider
245
+ interface declared in `core/time` and wired through Koin like any other dependency.
246
+
247
+ The rationale is determinism of evidence: an ambient time read makes rendered structure and
248
+ test results a function of *when* you run them — a golden tree that passes at 23:00 and
249
+ fails at 09:00 with no code change, a "today" grouping that flips across a midnight
250
+ boundary, a defaulted `Clock.System` constructor parameter that no test can pin. Injected
251
+ time gives tests one seam to freeze, and gives every screen's rendered output a single
252
+ source of "now" the harness can control.
236
253
 
237
254
  One Koin module per concern: `repositoryModule` / `useCaseModule` / `viewModelModule`
238
255
  (`di/AppModule.kt`, common to every platform), plus one platform module for platform-only
@@ -243,6 +260,21 @@ no service-locator lookups inside domain or presentation code. Platform modules
243
260
  `actual`-backed singletons (`NetworkMonitor`, `AppDatabase`) that common modules then depend on
244
261
  via the domain-facing interface, not the concrete platform type.
245
262
 
263
+ **ViewModels are registered with explicit `viewModel { … }` factories; reflection-based
264
+ `viewModelOf` is disallowed** `[enforced: ARCH-14]`. Koin's `viewModelOf` silently ignores
265
+ Kotlin constructor default parameter values — a ViewModel that compiles and previews fine
266
+ crashes at runtime resolution the first time a defaulted parameter matters (the default was
267
+ masking a dependency the graph never registered). An explicit factory states every
268
+ dependency (`viewModel { XViewModel(get(), …) }`) and fails at compile time instead.
269
+
270
+ Shared-vs-per-destination ViewModel scope is an **intent** decision no static rule can make
271
+ for you `[advisory]`: a screen that default-instantiates its own ViewModel gets a fresh
272
+ instance per destination, so state silently resets on re-entry. State meant to be shared
273
+ across screens must be scoped explicitly — register it once and resolve the same instance
274
+ at each destination (e.g. a `single { … }`-scoped holder, or a factory resolved against a
275
+ shared owner) rather than letting each screen construct its own. Decide the scope when you
276
+ introduce the ViewModel, not after the reset surprises you.
277
+
246
278
  ### Logging `[advisory — no shared logger ships today]`
247
279
 
248
280
  The scaffold ships **no cross-platform logging library**. The only logging present is Koin's
@@ -274,6 +306,13 @@ map `ItemEntity` → the domain `Item` inside `data/` — and schema changes get
274
306
  bump. `fallbackToDestructiveMigration` is a scaffold-stage convenience; replace it before
275
307
  shipping user data you can't afford to lose.
276
308
 
309
+ Room's exported schema history (`composeApp/schemas/`) is **append-only**
310
+ `[enforced: schemaHistory lane step]`: every version below the current highest is a frozen
311
+ record of a database that shipped, and migrations are validated against those exact bytes.
312
+ The lane fails if a historical `<version>.json` is rewritten or deleted (`git checkout --
313
+ <file>` restores it); only the current highest version — the live, in-progress schema — may
314
+ change between commits.
315
+
277
316
  ### Design tokens `[enforced: ARCH-05]`
278
317
 
279
318
  `presentation/theme/` (`Tokens.kt`, `Theme.kt`, `Typography.kt`, `Shape.kt`) is the only source
@@ -9,6 +9,7 @@ copy their shape.
9
9
  | Conformance gates (ARCH clauses) | `composeApp/src/desktopTest/…/conformance` | same task |
10
10
  | Screen behavior — Compose UI Test (spec-cited) | `composeApp/src/desktopTest/…/presentation` | same task |
11
11
  | Golden trees (structure) | `qa/golden/` + `HomeGoldenTreeTest` | same task |
12
+ | Instrumented behavior (platform facts) | `composeApp/src/androidInstrumentedTest` | `./gradlew :composeApp:connectedDebugAndroidTest` (device attached) |
12
13
  <!-- >>> cmp:feature e2e -->
13
14
  | E2E smoke (few) | `qa/e2e/*.yaml` (Maestro) | `maestro test qa/e2e/smoke.yaml` |
14
15
  <!-- <<< cmp:feature e2e -->
@@ -47,6 +48,82 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
47
48
  Never delete, weaken, or `@Ignore` a failing test to get green. Fix the behavior — or if the
48
49
  test is genuinely wrong, change it and say so explicitly in your PR/summary.
49
50
 
51
+ ## The instrumented tier — platform behavior
52
+
53
+ This seam exists because platform behavior escapes every desktop tier. Alarms,
54
+ notifications, lock-screen/full-screen intents, notification channels, PendingIntent
55
+ identity, audio routing, process death, and runtime permissions are OS facts:
56
+ `desktopTest` runs on a JVM, golden trees pin structure, the conformance suite is static,
57
+ and the E2E smoke taps UI without asserting anything about the shade or the alarm table.
58
+ A feature whose whole point is "the phone alerts" can ship fully green from every other
59
+ tier and never alert — that class of defect escaped to production repeatedly before this
60
+ tier existed. When your feature touches alarms, notifications, or locks, its behavior
61
+ test lives here.
62
+
63
+ **What belongs here:** claims only the OS can witness — a notification actually reached
64
+ the shade, a channel holds the importance the feature needs, N logical alarms occupy N
65
+ PendingIntent slots, the alert path plays with `USAGE_ALARM` on a silenced ringer, the
66
+ app survives process death, a permission-gated path degrades correctly.
67
+
68
+ **What does not:** logic (→ `commonTest`), rendered structure (→ golden trees),
69
+ architecture rules (→ conformance), UI journeys (→ E2E flows). If a JVM test can prove
70
+ it, a JVM test is where it goes — this tier is the most expensive seat in the house.
71
+
72
+ **Cost model:** needs a device/emulator, so it runs at checkpoint cadence — the lane's
73
+ `androidChecks` step (`connectedDebugAndroidTest`) SKIPs honestly when no device is
74
+ attached and runs in `local`/`ci` when one is, with the `release` profile adding the
75
+ release-APK smoke on top. It is not an inner-loop tier; don't reach for it per-edit.
76
+
77
+ **The helpers** (`androidInstrumentedTest/…/testing/`), small and composable — see
78
+ `PlatformBehaviorSeamTest` for the exemplar shape:
79
+
80
+ - `NotificationAsserts` — bounded-poll wait for a posted notification (id/tag or
81
+ predicate), channel-exists with an importance floor, full-screen-intent capability
82
+ (API-aware).
83
+ - `AlarmAsserts` — the OS alarm table (`dumpsys alarm`) parsed per-package;
84
+ alarm-registered and N-distinct-alarms assertions (the PendingIntent-identity
85
+ collision, caught mechanically).
86
+ - `SystemState` — snapshot/restore of ringer mode and DND so audio-routing claims are
87
+ testable; its header is honest about what instrumentation cannot control (OEM sound
88
+ policy, "a human heard it") — those stay a documented manual tier.
89
+
90
+ ### Runtime state control — put the system into the state the claim is about
91
+
92
+ The observation helpers above answer "what did the OS do?"; this organ family answers
93
+ the prior question — "can the test even reach the state the claim is about?" Most
94
+ unprovable mobile claims are unprovable exactly because the state is hard to reach: you
95
+ would have to wait hours for Doze, ship to a user who denies the permission, drop the
96
+ network by hand, or hope the OS reclaims your process while you watch. Each organ
97
+ reaches one such state on demand — emulator-only (every entry point SKIPs on a real
98
+ device), root-free on stock user-build images (every shell command verified from the
99
+ shell uid), and bracket-shaped (`withX { }` — snapshot, act, restore in `finally`) so
100
+ organs compose:
101
+
102
+ - `TimeWarp` — the device clock and time zone ("the 08:00 alarm actually arrives", DST).
103
+ - `DozeControl` — forced light/deep idle; composed with TimeWarp it proves
104
+ `setExactAndAllowWhileIdle` does what its name promises (the flagship exemplar in
105
+ `RuntimeStateSeamTest`).
106
+ - `PermissionControl` — the denied state (the fresh-install default) as a test input;
107
+ one-way grants. Its header documents the trap that shapes it: revoking a held runtime
108
+ permission kills the holding process — this seam's own process.
109
+ - `ProcessControl` — real OS-driven activity destruction and saved-state rebuild
110
+ (don't-keep-activities — flipped live via the same binder call the Developer-options
111
+ toggle uses, because `settings put global always_finish_activities` alone is read only
112
+ at boot; verified on API 35); honest about why in-process "kill my own process" is
113
+ structurally impossible under instrumentation.
114
+ - `NetworkControl` — offline (airplane mode) and per-transport wifi/data brackets; the
115
+ path to proving `core/connectivity/NetworkMonitor` tracks the world.
116
+ - `ConfigControl` — dark mode, font scale, per-app locale (API 33+); configuration
117
+ change as the other state-loss lever.
118
+
119
+ What device-state control cannot reproduce, honestly: the path INTO a state (real Doze's
120
+ descent ladder and maintenance windows — forced idle teleports and holds), OEM policy
121
+ (battery managers, sound routing, skinned lock screens), app-standby buckets, real-network
122
+ character (bandwidth, captive portals, flaky RSSI — emulator transports are the host's
123
+ connection wearing costumes), true cold-process start under instrumentation, and anything
124
+ whose proof is a human's senses. Each organ's header states its own gaps; a green test is
125
+ evidence for exactly the state the organ produced, nothing wider.
126
+
50
127
  <!-- >>> cmp:feature e2e -->
51
128
  ## E2E
52
129
 
@@ -70,3 +147,91 @@ asserts are for static post-navigation elements only.
70
147
  attached. It writes the evidence receipt to `qa/evidence/latest.json`; **commit the receipt
71
148
  with your change.** SKIPped steps are recorded honestly — green-with-gaps is visible, never
72
149
  silent.
150
+
151
+ **`--fast` is the lane's inner loop.** `node qa/verify.mjs --fast` runs the resolved
152
+ profile minus the device/release tier (`releaseBuild`, `tokenDrift`, `e2eSmoke`,
153
+ `androidChecks`, `releaseSmoke`) — unconditionally, device attached or not. It exists
154
+ because device/release evidence is the scarce, slow tier (R8 compile, emulator, Maestro,
155
+ instrumented runner): batch it at the checkpoint, don't pay for it on every small edit.
156
+ The rest of the profile runs cheaply, three ways:
157
+
158
+ - **Unchanged pure-Node steps are reused, not re-run.** `specCoverage`, `approvals`,
159
+ `componentStories`, `reachability`, and `archDoc` are pure functions of files on disk;
160
+ a fast run reuses each one's last PASS when a content hash of its declared inputs is
161
+ unchanged, shown as `⚡ name: CACHED (unchanged since …)`. Only a PASS is ever reused —
162
+ a FAIL or SKIP always re-runs — and the cache
163
+ (`composeApp/build/.cmp-step-cache.json`) is a gitignored cache, never evidence: the
164
+ full lane never reads it (it always executes every step; it only writes entries so the
165
+ next fast run benefits).
166
+ - **Gradle's up-to-date checks stand.** The full lane forces test execution with
167
+ `--rerun` (evidence integrity — a receipt must attest tests that ran). A fast receipt
168
+ is already declared non-evidence, so fast mode omits the flag and unchanged test tasks
169
+ cost nothing.
170
+ - **Unit tests are scoped to the change.** Changed `.kt` files (git diff + untracked)
171
+ map to `--tests "*<feature>*"` filters, reported honestly in the step line and the
172
+ receipt. Broad-impact changes — build files, DI, theme/tokens, shared components,
173
+ `qa/` itself, anything outside `composeApp/src` — disable filtering and run the whole
174
+ suite, as does any uncertain case (no git, unmappable change): fail open, never fail
175
+ silent. A filtered fast run can miss a cross-feature regression by design; the full,
176
+ unfiltered suite at the checkpoint is what decides done.
177
+
178
+ A fast run is mechanically unable to claim done — its receipt records `"mode": "fast"`,
179
+ derives **no** evidence rung, and `qa/receipt-check.mjs` (the Stop hook, CI, pre-push)
180
+ refuses it by name. Iterate on `--fast`; run the full lane once, deliberately, when the
181
+ change is done.
182
+
183
+ **Watch mode makes the inner loop resident.** `node qa/watch.mjs` watches
184
+ `composeApp/src`, `specs/`, and `qa/` and re-runs `node qa/verify.mjs --fast` (as a
185
+ subprocess — it inherits every fast-mode economy above for free) on every save, debounced
186
+ so a multi-file save storm triggers one run and changes landing mid-run coalesce into
187
+ exactly one follow-up. Each run re-prints the step table with any failing step's reason
188
+ verbatim — the errors-on-save loop an IDE gives a human, for the agent. What it is **not**:
189
+ evidence. It runs the fast tier only, so nothing it produces can satisfy the done-gate,
190
+ and every run says so in a standing footer; the checkpoint remains one deliberate full
191
+ `node qa/verify.mjs` run. Coordination: it never launches while a verify lane
192
+ (`.cmp-lane-in-progress`) or a preview-daemon render (`.cmp-render-in-progress`) holds the
193
+ project — it waits and coalesces, so watch mode, the preview daemon, and the lane can all
194
+ be resident on one project without two Gradle invocations ever colliding. `--once` runs a
195
+ single coordinated pass for scripting; `--json` emits one JSON object per run for
196
+ programmatic consumption.
197
+
198
+ ## One device, one driver
199
+
200
+ The machine typically has ONE Android device/emulator, and it is the scarcest, slowest,
201
+ most fragile resource the harness touches — two concurrent drivers produce wedged adbd,
202
+ `device offline` while `adb devices` looks fine, crossed app state, and false reds that
203
+ have nothing to do with the app. So device evidence is **batched, never an inner loop**:
204
+ the lane sequences its device steps (`tokenDrift` live tier, `e2eSmoke`, `androidChecks`,
205
+ `releaseSmoke`) once, last — don't hand-run `connectedDebugAndroidTest`, `maestro test`,
206
+ or `adb install` mid-task to "check something".
207
+
208
+ Mechanically, the first device step takes a **machine-global lease** on the device's adb
209
+ serial (`<os tmpdir>/create-cmp/device-leases/<serial>.json` — `qa/lib/device-lease.mjs`
210
+ documents the contract), held until the lane exits. It is machine-global on purpose: two
211
+ different projects (a scratch app in /tmp, the real one) share the same emulator, and
212
+ per-project markers cannot see each other.
213
+
214
+ **Contention is a SKIP, never a FAIL** — nothing is broken; another run legitimately holds
215
+ the device, and the reason names it: `held by "verify lane e2eSmoke" (pid 4711,
216
+ /tmp/scratch-x, 2m ago)`. A SKIPped device step does not buy its rung, so contention
217
+ visibly *lowers* the evidence level (L2 falls back to L1) instead of corrupting the run —
218
+ re-run when the holder finishes to earn the full rung. To see who holds a device:
219
+ `cat "${TMPDIR:-/tmp}/create-cmp/device-leases/<serial>.json"`. A crashed holder never
220
+ wedges the machine: a lease whose pid is dead, or older than 30 minutes, is silently
221
+ reclaimed by the next run. The live inspector tier (`connect_live`,
222
+ `navigate_and_inspect`) checks the same lease and refuses to drive a leased device by
223
+ naming the holder instead of failing with a mysterious transport error.
224
+
225
+ ## The evidence ladder
226
+
227
+ Every PASS receipt names its rung (`evidenceLevel` in the receipt; derived by
228
+ `qa/lib/evidence-level.mjs` from which steps actually ran and PASSed — never declared, and
229
+ a SKIPped step never buys a rung: an unsigned-keystore `releaseSmoke` SKIP is not L3). The
230
+ rung is the coarse grade; the per-step list stays the fine print. A FAILed lane has no rung.
231
+
232
+ | Rung | What it proves | What it does NOT prove |
233
+ |---|---|---|
234
+ | **L0 scaffold** | Stamp-time green: the build compiles and the unit tests pass. | Nothing about conformance, rendered structure, a11y, or the release variant — and nothing on a device. |
235
+ | **L1 desktop** | Full static + JVM evidence: build, unit tests, conformance, golden trees, a11y, release COMPILE, and the pure-Node gates. | That the app runs on a device at all — no APK was installed or driven; platform behavior (alarms, notifications) is invisible from this rung. |
236
+ | **L2 device** | L1 plus executed on-device evidence: the debug APK installed and driven (`e2eSmoke`), instrumented platform assertions (`androidChecks`), and/or live token drift. | That the release variant behaves (R8 differs from debug — that is L3's job), nor that alarms/notifications actually land unless an instrumented behavior test asserts them. |
237
+ | **L3 release** | L2 plus `releaseSmoke` PASSed: the signed release APK installed and driven on a device. | Real-backend behavior (the emulator/dev backend is a documented tier boundary — see the instrumented-tier section) and store-review compliance. |
@@ -22,6 +22,14 @@ compose-hot-reload = "1.1.1"
22
22
  coil = "3.1.0"
23
23
  androidx-core = "1.15.0"
24
24
  androidx-activity = "1.10.1"
25
+ # ── Android instrumentation tier (androidInstrumentedTest). The alarm table, the
26
+ # ── notification shade, channels, and audio routing are OS facts no JVM test can see —
27
+ # ── this tier is the only evidence that crosses the process boundary onto a real device.
28
+ androidx-test-runner = "1.6.2"
29
+ androidx-test-core = "1.6.1"
30
+ androidx-test-ext-junit = "1.2.1"
31
+ androidx-uiautomator = "2.3.0"
32
+ junit4 = "4.13.2"
25
33
 
26
34
  [libraries]
27
35
  # Compose Multiplatform
@@ -82,6 +90,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx
82
90
  androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
83
91
  android-desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version = "2.1.3" }
84
92
 
93
+ # Android instrumentation (androidInstrumentedTest — the on-device behavior tier)
94
+ androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
95
+ androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
96
+ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
97
+ androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
98
+ junit4 = { module = "junit:junit", version.ref = "junit4" }
99
+
85
100
  # Test
86
101
  kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
87
102
  koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }