create-cmp-cli 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -25,7 +25,8 @@ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --veri
25
25
  Deterministic (stamps a frozen, CI-verified template), fully non-interactive with flags, and
26
26
  exits non-zero on failure. Every generated project ships its own verify lane — `node qa/verify.mjs`,
27
27
  8 gates, evidence receipts — with nothing installed. Agent-readable: [llms.txt](./llms.txt) ·
28
- [options.schema.json](./options.schema.json).
28
+ [options.schema.json](./options.schema.json). Also answers to `npm create compose-multiplatform`
29
+ and `npm create kmp` — official aliases ([packages/aliases](packages/aliases)) that delegate here.
29
30
 
30
31
  ## What is this, in plain words
31
32
 
package/llms.txt CHANGED
@@ -12,6 +12,8 @@ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --veri
12
12
 
13
13
  Other flags: `--bundle-id`, `--region`, `--theme-prefix`, `--ios/--no-ios`, `--firebase/--no-firebase`, `--auth <email|phone|both|none>`, `--room/--no-room`, `--e2e/--no-e2e`, `--inspector/--no-inspector`, `--dev-client/--no-dev-client`, `--tabs Home:home,Profile:person`, `--target-dir`, `--force`. Subcommands `doctor`, `upgrade`, `clean`, and `verify` work on any KMP project.
14
14
 
15
+ Official alias packages (same tool, same flags, same maintainer): `npm create compose-multiplatform@latest my-app` and `npm create kmp@latest my-app` both delegate to create-cmp-cli.
16
+
15
17
  create-cmp is also invokable as a Claude Code plugin (`/plugin marketplace add kvdm-co-pilot/create-cmp`, then `/plugin install create-cmp`) with eight skills and the `cmp-inspector` MCP server. Generated projects self-verify without the plugin installed: `node qa/verify.mjs` runs 8 gates (spec coverage, build, unit tests, conformance, golden trees, token drift, a11y, on-device E2E) and writes a content-hash-bound evidence receipt; a Stop hook and CI both refuse "done" without a fresh PASS receipt.
16
18
 
17
19
  ## Docs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,7 +71,11 @@ export const checks = [
71
71
  },
72
72
  {
73
73
  id: "jdk",
74
- label: "JDK 17 (Temurin)",
74
+ // Label states the actual requirement (17+), and detect() reports the
75
+ // resolved major — previously the row read "JDK 17 (Temurin)" while
76
+ // happily accepting JDK 21, a label/evidence contradiction that erodes
77
+ // trust in every other row (field-report finding 2.6).
78
+ label: "JDK (17+ required)",
75
79
  platforms: ["darwin", "linux"],
76
80
  detect() {
77
81
  const r = probe("javac", ["-version"]);
@@ -80,7 +84,10 @@ export const checks = [
80
84
  const m = out.match(/(\d+)(\.\d+)?/);
81
85
  const major = m ? parseInt(m[1], 10) : 0;
82
86
  if (!out) return { present: false, detail: "not found" };
83
- return { present: major >= 17, detail: out.split("\n")[0] };
87
+ return {
88
+ present: major >= 17,
89
+ detail: `resolved major ${major} — ${out.split("\n")[0]}`,
90
+ };
84
91
  },
85
92
  installCommand: () =>
86
93
  isMac ? "brew install --cask temurin@17" : "sdk install java 17.0.13-tem # (sdkman)",
@@ -66,8 +66,15 @@ export async function runVerify({ projectDir, manifest, config, dryRun = false }
66
66
  results.push({ platform: item.platform, command: item.command, code: 0, ran: false });
67
67
  continue;
68
68
  }
69
+ const startedAt = Date.now();
69
70
  const { code } = await runCommand(item.command, projectDir);
70
- results.push({ platform: item.platform, command: item.command, code, ran: true });
71
+ results.push({
72
+ platform: item.platform,
73
+ command: item.command,
74
+ code,
75
+ ran: true,
76
+ durationMs: Date.now() - startedAt,
77
+ });
71
78
  if (code !== 0) green = false;
72
79
  }
73
80
 
@@ -94,4 +101,20 @@ export function printVerifyVerdict(verdict) {
94
101
  ? `\n${colors.green("GREEN — build proven.")}\n`
95
102
  : `\n${colors.red("FAIL — build did not go green.")}\n`
96
103
  );
104
+
105
+ // Machine-readable verdict, one greppable line (field-report finding 2.3):
106
+ // a verify run can exceed 170k log lines, where "-Werror=" clang flags and
107
+ // Xcode phase names false-positive naive error greps. Agents anchor on this
108
+ // marker instead of parsing raw Gradle/xcodebuild output.
109
+ process.stdout.write(
110
+ `::create-cmp-verdict::${JSON.stringify({
111
+ green: verdict.green,
112
+ results: verdict.results.map((r) => ({
113
+ platform: r.platform,
114
+ green: r.code === 0,
115
+ ran: r.ran,
116
+ durationMs: r.durationMs ?? null,
117
+ })),
118
+ })}\n`
119
+ );
97
120
  }
package/src/scaffold.mjs CHANGED
@@ -205,6 +205,46 @@ function applyAppNameSlug(projectDir, appName) {
205
205
  }
206
206
  }
207
207
 
208
+ /**
209
+ * Persist the fully-resolved engine config as `create-cmp.json` in the project
210
+ * root — the durable spec-of-record (field-report finding 5.5-1 / D6). The
211
+ * conformance/consistency tooling compares code against whatever this file
212
+ * currently says, so a hand-edit is a visible spec change rather than drift.
213
+ * @param {string} projectDir
214
+ * @param {object} config validated engine config
215
+ */
216
+ function writeSpecOfRecord(projectDir, config) {
217
+ let engineVersion = "unknown";
218
+ try {
219
+ engineVersion = JSON.parse(
220
+ fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")
221
+ ).version;
222
+ } catch {
223
+ /* best-effort — never fail the stamp over version metadata */
224
+ }
225
+ const record = {
226
+ schemaVersion: 1,
227
+ name: config.appName,
228
+ package: config.package,
229
+ bundleId: config.iosBundleId,
230
+ themePrefix: config.themePrefix,
231
+ region: config.region,
232
+ platforms: config.platforms,
233
+ firebase: config.firebase,
234
+ room: config.room,
235
+ e2e: config.e2e,
236
+ inspector: config.inspector,
237
+ devClient: config.devClient,
238
+ tabs: config.tabs,
239
+ engineVersion,
240
+ stampedAt: new Date().toISOString(),
241
+ };
242
+ fs.writeFileSync(
243
+ path.join(projectDir, "create-cmp.json"),
244
+ JSON.stringify(record, null, 2) + "\n"
245
+ );
246
+ }
247
+
208
248
  /**
209
249
  * Run the full scaffold pipeline.
210
250
  * @param {object} config engine config object (CONTRACT)
@@ -231,10 +271,20 @@ export async function scaffold(config, opts = {}) {
231
271
 
232
272
  const projectDir = path.resolve(config.targetDir);
233
273
  if (fs.existsSync(projectDir)) {
274
+ // Harmless entries must not force `--force`: our own doctor/session
275
+ // droppings (.claude), VCS metadata, and OS/editor noise. The documented
276
+ // doctor→create flow used to poison its own target dir this way. Anything
277
+ // else is real user content and still refuses — naming the offenders so
278
+ // the caller can decide without an `ls` round-trip.
279
+ const HARMLESS = new Set([".git", ".claude", ".DS_Store", ".idea", ".vscode"]);
234
280
  const entries = fs.readdirSync(projectDir).filter((e) => e !== "." && e !== "..");
235
- if (entries.length > 0 && !opts.force) {
281
+ const blocking = entries.filter((e) => !HARMLESS.has(e) && !e.endsWith(".swp"));
282
+ if (blocking.length > 0 && !opts.force) {
283
+ const ignored = entries.filter((e) => !blocking.includes(e));
236
284
  throw new Error(
237
- `target directory ${projectDir} is not empty (pass force to overwrite)`
285
+ `target directory ${projectDir} is not empty (pass force to overwrite)\n` +
286
+ ` Blocking entries: ${blocking.join(", ")}` +
287
+ (ignored.length > 0 ? `\n (ignored as harmless: ${ignored.join(", ")})` : "")
238
288
  );
239
289
  }
240
290
  }
@@ -322,6 +372,14 @@ export async function scaffold(config, opts = {}) {
322
372
  if (fs.existsSync(abs)) fs.rmSync(abs);
323
373
  }
324
374
 
375
+ // Persist the resolved config as the project's spec-of-record. Until now the
376
+ // config was validated, consumed, and discarded — the only pre-code spec in
377
+ // the system evaporated at stamp time, so nothing could later answer "was
378
+ // this app built to its spec?" (tabs ↔ AppTab ↔ smoke consistency, upgrade
379
+ // intent, re-stamp/resume all need it). Committed with the app; hand-edits
380
+ // are visible spec changes, not drift.
381
+ writeSpecOfRecord(projectDir, config);
382
+
325
383
  ok("Scaffold complete.");
326
384
 
327
385
  // (f) verify gate
@@ -250,7 +250,19 @@ compose.resources {
250
250
 
251
251
  // >>> cmp:feature room
252
252
  room {
253
- schemaDirectory("$projectDir/schemas")
253
+ // Per-target schema directories, NOT one shared dir. With a single directory the
254
+ // copyRoomSchemas aggregation task requires every target's exported schema to be
255
+ // byte-identical — and the first entity edit after scaffold trips a cross-target
256
+ // checksum conflict against the stale intermediate of whichever target built last
257
+ // ("Inconsistency detected exporting Room schema files"). Per-target locations are
258
+ // exactly what that error's remediation asks for.
259
+ schemaDirectory("android", "$projectDir/schemas/android")
260
+ schemaDirectory("desktop", "$projectDir/schemas/desktop")
261
+ // >>> cmp:feature ios
262
+ schemaDirectory("iosSimulatorArm64", "$projectDir/schemas/iosSimulatorArm64")
263
+ schemaDirectory("iosX64", "$projectDir/schemas/iosX64")
264
+ schemaDirectory("iosArm64", "$projectDir/schemas/iosArm64")
265
+ // <<< cmp:feature ios
254
266
  }
255
267
  // <<< cmp:feature room
256
268
 
@@ -0,0 +1,36 @@
1
+ package __PACKAGE__.core.format
2
+
3
+ /**
4
+ * KMP-safe formatting helpers.
5
+ *
6
+ * `String.format` / `"%02d".format(...)` are JVM-only — they compile in `androidMain` but do
7
+ * not exist in `commonMain`, and reaching for them is the single most common first-week
8
+ * porting mistake in a shared module. These cover the cases that actually come up; add here
9
+ * rather than sprinkling `padStart` call sites.
10
+ */
11
+
12
+ /** Pad an Int to two digits: 7 -> "07". The `%02d` you were about to write. */
13
+ fun pad2(n: Int): String = n.toString().padStart(2, '0')
14
+
15
+ /** "HH:mm" from minutes-since-midnight: 555 -> "09:15". */
16
+ fun clockLabel(minutesOfDay: Int): String {
17
+ val m = ((minutesOfDay % (24 * 60)) + 24 * 60) % (24 * 60) // wrap + never negative
18
+ return "${pad2(m / 60)}:${pad2(m % 60)}"
19
+ }
20
+
21
+ /**
22
+ * Fixed decimal places without java.text: 12.5 -> "12.5" (1 dp). Rounds half away from zero
23
+ * via floor(abs + 0.5) — deterministic on every backend (kotlin.math.round's tie behavior and
24
+ * `%.Nf` locale handling both vary). For layout-stable numeric UI text, not accounting math.
25
+ */
26
+ fun fixed(value: Double, decimals: Int = 1): String {
27
+ require(decimals >= 0) { "decimals must be >= 0" }
28
+ var factor = 1L
29
+ repeat(decimals) { factor *= 10 }
30
+ val scaled = kotlin.math.floor(kotlin.math.abs(value) * factor + 0.5).toLong()
31
+ val sign = if (value < 0 && scaled != 0L) "-" else ""
32
+ if (decimals == 0) return "$sign$scaled"
33
+ val whole = scaled / factor
34
+ val frac = (scaled % factor).toString().padStart(decimals, '0')
35
+ return "$sign$whole.$frac"
36
+ }
@@ -0,0 +1,36 @@
1
+ package __PACKAGE__.core.format
2
+
3
+ import kotlin.test.Test
4
+ import kotlin.test.assertEquals
5
+
6
+ class FormatTest {
7
+
8
+ @Test
9
+ fun pad2_pads_single_digits() {
10
+ assertEquals("07", pad2(7))
11
+ assertEquals("00", pad2(0))
12
+ assertEquals("15", pad2(15))
13
+ }
14
+
15
+ @Test
16
+ fun clockLabel_formats_minutes_of_day() {
17
+ assertEquals("09:15", clockLabel(9 * 60 + 15))
18
+ assertEquals("00:00", clockLabel(0))
19
+ assertEquals("23:59", clockLabel(23 * 60 + 59))
20
+ // wraps past midnight and never goes negative
21
+ assertEquals("00:30", clockLabel(24 * 60 + 30))
22
+ assertEquals("23:30", clockLabel(-30))
23
+ }
24
+
25
+ @Test
26
+ fun fixed_renders_stable_decimals() {
27
+ // Binary-exact inputs only — 0.1-style values are not representable and would make
28
+ // these assertions depend on the platform's double formatting.
29
+ assertEquals("12.5", fixed(12.5, 1))
30
+ assertEquals("0.3", fixed(0.25, 1)) // half rounds away from zero
31
+ assertEquals("-0.8", fixed(-0.75, 1))
32
+ assertEquals("3.00", fixed(3.0, 2))
33
+ assertEquals("13", fixed(12.5, 0))
34
+ assertEquals("0.0", fixed(0.0, 1))
35
+ }
36
+ }
@@ -27,22 +27,47 @@ class ArchitectureConformanceTest {
27
27
  private fun imports(file: File): List<String> =
28
28
  file.readLines().filter { it.trimStart().startsWith("import ") }.map { it.trim() }
29
29
 
30
+ /**
31
+ * Source lines with comment lines stripped. Layer-boundary rules scan these for BOTH
32
+ * `import x.y.` statements AND fully-qualified inline references (`x.y.Type(...)`) —
33
+ * import-only matching leaves a one-edit evasion open: delete the import, qualify the
34
+ * name inline, and the gate goes green while the violation remains.
35
+ */
36
+ private fun nonCommentLines(file: File): List<String> =
37
+ file.readLines().filterNot {
38
+ val t = it.trimStart()
39
+ t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
40
+ }
41
+
42
+ private fun bannedReference(file: File, banned: List<String>): Boolean =
43
+ nonCommentLines(file).any { line -> banned.any { line.contains(it) } }
44
+
30
45
  private fun under(file: File, segment: String): Boolean =
31
46
  file.path.replace(File.separatorChar, '/').contains("/$segment/")
32
47
 
48
+ /**
49
+ * True when the file sits in a feature subpackage (presentation/<feature>/…) rather than
50
+ * at the presentation root (App.kt) — the scope for the composable-file rules.
51
+ */
52
+ private fun inPresentationFeatureDir(file: File): Boolean {
53
+ val rel = file.path.replace(File.separatorChar, '/').substringAfter("/presentation/", "")
54
+ return rel.isNotEmpty() && rel.contains('/')
55
+ }
56
+
33
57
  private fun violation(clause: String, rule: String, offenders: List<String>, fix: String): String =
34
58
  "[$clause] $rule\n Offending: ${offenders.joinToString("\n ")}\n Fix: $fix"
35
59
 
36
60
  // SPEC: ARCH-01
37
61
  @Test
38
- fun `ARCH-01 presentation never imports the data layer`() {
62
+ fun `ARCH-01 presentation never references the data layer`() {
39
63
  val offenders = sources(commonMain)
40
64
  .filter { under(it, "presentation") }
41
- .filter { file -> imports(file).any { it.startsWith("import __PACKAGE__.data.") } }
65
+ .filter { bannedReference(it, listOf("__PACKAGE__.data.")) }
42
66
  .map { it.path }
43
67
  if (offenders.isNotEmpty()) fail(
44
68
  violation(
45
- "ARCH-01", "presentation depends on domain only — it never imports the data layer.",
69
+ "ARCH-01", "presentation depends on domain only — it never references the data layer " +
70
+ "(neither imports nor fully-qualified inline names).",
46
71
  offenders,
47
72
  "depend on a domain interface (domain/repository) and let di/ wire the data implementation.",
48
73
  )
@@ -53,16 +78,17 @@ class ArchitectureConformanceTest {
53
78
  @Test
54
79
  fun `ARCH-02 domain is pure - no app layers, no frameworks`() {
55
80
  val banned = listOf(
56
- "import __PACKAGE__.presentation.", "import __PACKAGE__.data.", "import __PACKAGE__.di.",
57
- "import androidx.compose.", "import org.koin.",
81
+ "__PACKAGE__.presentation.", "__PACKAGE__.data.", "__PACKAGE__.di.",
82
+ "androidx.compose.", "org.koin.",
58
83
  )
59
84
  val offenders = sources(commonMain)
60
85
  .filter { under(it, "domain") }
61
- .filter { file -> imports(file).any { imp -> banned.any { imp.startsWith(it) } } }
86
+ .filter { bannedReference(it, banned) }
62
87
  .map { it.path }
63
88
  if (offenders.isNotEmpty()) fail(
64
89
  violation(
65
- "ARCH-02", "domain imports nothing app-internal and no UI/DI frameworks.",
90
+ "ARCH-02", "domain references nothing app-internal and no UI/DI frameworks " +
91
+ "(neither imports nor fully-qualified inline names).",
66
92
  offenders,
67
93
  "move framework-touching code out to presentation/data; domain stays pure Kotlin.",
68
94
  )
@@ -88,17 +114,23 @@ class ArchitectureConformanceTest {
88
114
 
89
115
  // SPEC: ARCH-04
90
116
  @Test
91
- fun `ARCH-04 every Screen composable declares a testTag`() {
117
+ fun `ARCH-04 every feature composable file declares a testTag`() {
118
+ // Scoped by CONTENT (contains @Composable), not by *Screen.kt filename: real apps
119
+ // split features into Screen.kt (often ViewModel-only) and Content.kt (the UI).
120
+ // Filename scoping produced both false negatives (untagged FooContent.kt slid
121
+ // through) and false positives (VM-only FooScreen.kt was flagged) in the field.
92
122
  val offenders = sources(commonMain)
93
- .filter { it.name != "Screen.kt" && it.name.endsWith("Screen.kt") }
94
- .filterNot { under(it, "components") || under(it, "navigation") }
123
+ .filter { inPresentationFeatureDir(it) }
124
+ .filterNot { under(it, "components") || under(it, "navigation") || under(it, "theme") }
125
+ .filter { it.readText().contains("@Composable") }
95
126
  .filterNot { it.readText().contains("testTag") }
96
127
  .map { it.path }
97
128
  if (offenders.isNotEmpty()) fail(
98
129
  violation(
99
- "ARCH-04", "every screen is automation-reachable: *Screen files declare at least one testTag.",
130
+ "ARCH-04", "every feature UI file is automation-reachable: files containing a " +
131
+ "@Composable declare at least one testTag.",
100
132
  offenders,
101
- "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the screen's key nodes.",
133
+ "add Modifier.semantics { testTag = \"<feature>_<element>\" } to the file's key nodes.",
102
134
  )
103
135
  )
104
136
  }
@@ -120,6 +152,45 @@ class ArchitectureConformanceTest {
120
152
  )
121
153
  }
122
154
 
155
+ // SPEC: SHELL-05
156
+ @Test
157
+ fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
158
+ // SHELL-03 bans direct inset-API calls, but a destination that simply never handles
159
+ // insets at all (bare Column at the nav layer) passes that rule while rendering
160
+ // under the status bar. Tab screens are exempt — AppShell wraps them — so the rule
161
+ // targets exactly the destinations registered directly on the NavHost.
162
+ val navHost = sources(commonMain).firstOrNull { it.name == "AppNavHost.kt" } ?: return
163
+ val text = navHost.readText()
164
+ val screenCall = Regex("""([A-Z][A-Za-z0-9]*Screen)\s*\(""")
165
+ // A call with only a trailing lambda has no paren — `BaseScreen { … }` — so match both.
166
+ val baseScreenCall = Regex("""BaseScreen\s*[({]""")
167
+ val allSources = sources(commonMain)
168
+
169
+ val offenders = mutableListOf<String>()
170
+ val chunks = text.split("composable(").drop(1)
171
+ for (chunk in chunks) {
172
+ if (chunk.contains("AppShell(")) continue // shell destination: tabs inherit BaseScreen
173
+ for (m in screenCall.findAll(chunk)) {
174
+ val name = m.groupValues[1]
175
+ if (name == "BaseScreen") continue
176
+ val defining = allSources.firstOrNull { f ->
177
+ Regex("""fun\s+$name\s*\(""").containsMatchIn(f.readText())
178
+ } ?: continue
179
+ if (!baseScreenCall.containsMatchIn(defining.readText())) {
180
+ offenders.add("${defining.path} ($name is a NavHost destination without BaseScreen)")
181
+ }
182
+ }
183
+ }
184
+ if (offenders.isNotEmpty()) fail(
185
+ violation(
186
+ "SHELL-05", "every screen registered directly on the NavHost composes inside " +
187
+ "BaseScreen — otherwise it renders edge-to-edge with no inset handling.",
188
+ offenders.distinct(),
189
+ "wrap the destination's content in BaseScreen { … } (see DetailScreen).",
190
+ )
191
+ )
192
+ }
193
+
123
194
  // SPEC: SHELL-03
124
195
  @Test
125
196
  fun `SHELL-03 insets are owned by BaseScreen - screens never touch inset APIs`() {
@@ -5,15 +5,19 @@
5
5
 
6
6
  ## Architecture invariants
7
7
 
8
- - **ARCH-01** — Given any file in `presentation`, When its imports are inspected, Then none
9
- resolve into the `data` layer (presentation depends on domain only).
10
- - **ARCH-02** Given any file in `domain`, When its imports are inspected, Then none resolve
11
- into `presentation`, `data`, or `di`, and none import Compose, Koin, or platform types
12
- (domain is pure Kotlin).
8
+ - **ARCH-01** — Given any file in `presentation`, When its imports **and fully-qualified
9
+ inline references** are inspected, Then none resolve into the `data` layer (presentation
10
+ depends on domain only; qualifying the name inline instead of importing is the same
11
+ violation).
12
+ - **ARCH-02** — Given any file in `domain`, When its imports **and fully-qualified inline
13
+ references** are inspected, Then none resolve into `presentation`, `data`, or `di`, and
14
+ none reference Compose, Koin, or platform types (domain is pure Kotlin).
13
15
  - **ARCH-03** — Given any ViewModel class, When the test sources are inspected, Then a
14
16
  corresponding `*ViewModelTest` exists (no untested presentation state).
15
- - **ARCH-04** — Given any file containing a `*Screen` composable, When its source is
16
- inspected, Then it declares at least one `testTag` (every screen is automation-reachable).
17
+ - **ARCH-04** — Given any file in a `presentation` feature package that contains a
18
+ `@Composable` function, When its source is inspected, Then it declares at least one
19
+ `testTag` (scoped by content, not `*Screen.kt` filename — split `Content.kt` UI files are
20
+ covered, ViewModel-only files are exempt).
17
21
  - **ARCH-05** — Given any file outside `presentation/theme`, When its source is inspected,
18
22
  Then it constructs no literal `Color(0x…)` values (design colors come from the token
19
23
  catalog).
@@ -28,3 +32,7 @@
28
32
  the safe-area insets owned by `BaseScreen` (edge-to-edge without overlap).
29
33
  - **SHELL-04** — Given the app renders any screen, When interactive elements are present,
30
34
  Then each is perceivable by automation: it exposes a testTag, text, or content description.
35
+ - **SHELL-05** — Given any screen registered directly on the NavHost (not a shell tab), When
36
+ it renders, Then its content is composed inside `BaseScreen` — a bare destination that
37
+ never touches inset APIs still renders under the status bar, which SHELL-03 alone cannot
38
+ catch.