create-cmp-cli 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +11 -9
  2. package/package.json +1 -1
  3. package/src/lib/package-name.mjs +72 -0
  4. package/src/lib/tabs.mjs +26 -0
  5. package/src/scaffold.mjs +7 -2
  6. package/template/.claude/settings.json +30 -0
  7. package/template/.claude/skills/add-feature/SKILL.md +20 -0
  8. package/template/.claude/skills/add-repository/SKILL.md +6 -0
  9. package/template/.claude/skills/add-screen/SKILL.md +6 -0
  10. package/template/CLAUDE.md +129 -8
  11. package/template/composeApp/build.gradle.kts +69 -0
  12. package/template/composeApp/proguard-rules.pro +12 -0
  13. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  25. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
  32. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  33. package/template/docs/ARCHITECTURE.md +41 -2
  34. package/template/docs/TESTING.md +165 -0
  35. package/template/gradle/libs.versions.toml +15 -0
  36. package/template/manifest.json +1 -0
  37. package/template/qa/approve.mjs +119 -11
  38. package/template/qa/evidence/schema.json +20 -2
  39. package/template/qa/lib/affected-tests.mjs +147 -0
  40. package/template/qa/lib/approvals.mjs +602 -21
  41. package/template/qa/lib/device-lease.mjs +249 -0
  42. package/template/qa/lib/evidence-level.mjs +117 -0
  43. package/template/qa/lib/feature-brief.mjs +324 -0
  44. package/template/qa/lib/inputs-hash.mjs +43 -6
  45. package/template/qa/lib/reachability.mjs +211 -0
  46. package/template/qa/lib/spec-coverage.mjs +131 -0
  47. package/template/qa/lib/step-cache.mjs +221 -0
  48. package/template/qa/receipt-check.mjs +22 -2
  49. package/template/qa/scaffold-feature.mjs +20 -1
  50. package/template/qa/verify.mjs +776 -95
  51. package/template/qa/watch.mjs +622 -0
  52. package/template/specs/app-base.spec.md +11 -0
@@ -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
  *
@@ -2,6 +2,7 @@ package __PACKAGE__.presentation.navigation
2
2
 
3
3
  import androidx.compose.runtime.Composable
4
4
  import androidx.compose.runtime.LaunchedEffect
5
+ import androidx.compose.ui.Modifier
5
6
  import androidx.navigation.NavType
6
7
  import androidx.navigation.compose.NavHost
7
8
  import androidx.navigation.compose.composable
@@ -10,6 +11,9 @@ import androidx.navigation.navArgument
10
11
  // Nav 2.9 (multiplatform): backStackEntry.arguments is a SavedState, not an Android Bundle.
11
12
  // Read it via the androidx.savedstate.read extension, NOT Bundle.getString().
12
13
  import androidx.savedstate.read
14
+ // Kept OUTSIDE the screen-imports block below: the tab rewriter (src/lib/tabs.mjs) replaces
15
+ // exactly those two lines with the configured tabs' screen imports.
16
+ import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
13
17
  import __PACKAGE__.presentation.home.HomeScreen
14
18
  import __PACKAGE__.presentation.profile.ProfileScreen
15
19
 
@@ -38,7 +42,18 @@ fun AppNavHost() {
38
42
  }
39
43
  }
40
44
 
41
- NavHost(navController = navController, startDestination = Screen.Shell.route) {
45
+ // Expose Compose testTags to the platform automation layer (Android resource-ids / iOS
46
+ // accessibilityIdentifiers) for the WHOLE graph. The property is inherited by descendants,
47
+ // so it belongs on the graph root, not on a destination: applied inside AppShell it would
48
+ // cover the tabs and nothing else, leaving every destination registered directly here
49
+ // (detail screens, trays) with testTags no id-selector can see — e2e flows could not
50
+ // assert arrival on them at all. Here, a destination added later inherits it without
51
+ // anyone remembering to. Desktop: no-op.
52
+ NavHost(
53
+ navController = navController,
54
+ startDestination = Screen.Shell.route,
55
+ modifier = Modifier.exposeTestTagsForAutomation(),
56
+ ) {
42
57
  composable(Screen.Shell.route) {
43
58
  val tabs = appTabs(
44
59
  home = {
@@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue
10
10
  import androidx.compose.ui.Modifier
11
11
  import __PACKAGE__.presentation.components.AppBottomBar
12
12
  import __PACKAGE__.presentation.components.BaseScreen
13
- import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
14
13
 
15
14
  /**
16
15
  * Generic bottom-nav shell. Parameterized by a [tabs] list — NOT role-hardcoded.
@@ -27,10 +26,8 @@ fun AppShell(tabs: List<AppTab>) {
27
26
  var selected by rememberSaveable { mutableIntStateOf(0) }
28
27
 
29
28
  BaseScreen(
30
- // Expose Compose testTags to the platform automation layer (Android resource-ids /
31
- // iOS accessibilityIdentifiers) so Appium/uiautomator id-selectors and the
32
- // cmp-test-generated suites find them; covers the whole subtree. Desktop: no-op.
33
- modifier = Modifier.exposeTestTagsForAutomation(),
29
+ // testTag exposure for automation is applied once on the NavHost in AppNavHost.kt —
30
+ // the graph root, so every destination inherits it, not just these tabs.
34
31
  // The bottom bar owns the navigation-bar inset; the body must not also pad it.
35
32
  applyNavBarPadding = false,
36
33
  bottomBar = {
@@ -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