create-cmp-cli 0.3.1 → 0.4.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 +251 -209
- package/bin/create-cmp.mjs +3 -1
- package/llms.txt +27 -0
- package/package.json +15 -4
- package/src/lib/tabs.mjs +370 -0
- package/src/scaffold.mjs +13 -17
- package/template/README.md +4 -0
- package/template/composeApp/build.gradle.kts +13 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +6 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +4 -3
- package/template/qa/e2e/smoke.yaml +7 -3
- package/template/qa/refusal-demo.mjs +9 -2
- package/template/qa/verify.mjs +9 -4
package/README.md
CHANGED
|
@@ -2,56 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
# create-cmp
|
|
4
4
|
|
|
5
|
-
**The AI delivery harness for Kotlin/Compose Multiplatform
|
|
5
|
+
**The AI delivery harness for Kotlin/Compose Multiplatform.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Scaffold a green-building Android + iOS app in minutes — then let AI extend it, held to a
|
|
8
|
+
machine-enforced definition of done.
|
|
8
9
|
|
|
9
10
|
[](https://github.com/kvdm-co-pilot/create-cmp/actions/workflows/ci.yml)
|
|
10
11
|
[](https://www.npmjs.com/package/create-cmp-cli)
|
|
11
12
|
[](./LICENSE)
|
|
12
13
|
[](./CONTRIBUTING.md)
|
|
13
14
|
[](https://kotlinlang.org/docs/multiplatform.html)
|
|
14
|
-
[](#
|
|
15
|
+
[](#the-claude-code-plugin-8-skills)
|
|
15
16
|
|
|
16
17
|
</div>
|
|
17
18
|
|
|
18
19
|
---
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
21
|
+
```bash
|
|
22
|
+
npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --verify
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Deterministic (stamps a frozen, CI-verified template), fully non-interactive with flags, and
|
|
26
|
+
exits non-zero on failure. Every generated project ships its own verify lane — `node qa/verify.mjs`,
|
|
27
|
+
8 gates, evidence receipts — with nothing installed. Agent-readable: [llms.txt](./llms.txt) ·
|
|
28
|
+
[options.schema.json](./options.schema.json).
|
|
29
|
+
|
|
30
|
+
## What is this, in plain words
|
|
31
|
+
|
|
32
|
+
**Day one, it's a scaffolder.** One command gives you a working Compose Multiplatform app —
|
|
33
|
+
Android and iOS, navigation and insets solved, Clean Architecture wired, tests passing, build
|
|
34
|
+
green. It *stamps* a frozen, CI-verified template; it never asks an AI to freehand your project,
|
|
35
|
+
so every scaffold is identical and every scaffold builds.
|
|
36
|
+
|
|
37
|
+
**Every day after, it's a harness.** AI writes code fast, and confidently — including confidently
|
|
38
|
+
wrong. The scarce thing is no longer code; it's a **machine-checkable definition of "correct"**.
|
|
39
|
+
Every project this tool generates carries that definition inside it: behavior specs, an
|
|
40
|
+
executable verify lane, generators that extend the app the right way by construction, and
|
|
41
|
+
enforcement that refuses "done" without evidence. An AI session working in your repo doesn't
|
|
42
|
+
*promise* the feature works — it has to *prove* it, and it gets blocked when it can't.
|
|
43
|
+
|
|
44
|
+
**See it live:** [create-cmp-showcase](https://github.com/kvdm-co-pilot/create-cmp-showcase) is a
|
|
45
|
+
public repo built entirely by this tool — every commit carries its evidence receipt, and
|
|
46
|
+
[PR #1](https://github.com/kvdm-co-pilot/create-cmp-showcase/pull/1) shows the harness *refusing*
|
|
47
|
+
a bad change and naming the exact rule it broke.
|
|
48
|
+
|
|
49
|
+
## The core loop
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
spec clause → generate from exemplar → verify lane (8 gates) → evidence receipt
|
|
53
|
+
↑ │
|
|
54
|
+
└────────────── enforcement: Stop hook + CI refuse "done" without it ──┘
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Behavior starts as a written spec clause. Code is cloned from a proven exemplar. The verify lane
|
|
58
|
+
checks everything — spec coverage, build, tests, architecture, UI structure, design tokens,
|
|
59
|
+
accessibility, on-device E2E — and writes a receipt bound to a content hash of the code it
|
|
60
|
+
verified. The Stop hook and CI both check that receipt. You cannot hand-forge it, and a stale one
|
|
61
|
+
doesn't pass.
|
|
55
62
|
|
|
56
63
|
## Quick start
|
|
57
64
|
|
|
@@ -62,208 +69,243 @@ npx create-cmp-cli@latest
|
|
|
62
69
|
…or non-interactively:
|
|
63
70
|
|
|
64
71
|
```bash
|
|
65
|
-
npx create-cmp-cli@latest --name Acme --package com.acme.app --yes --verify
|
|
72
|
+
npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --verify
|
|
66
73
|
```
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
It interviews you (or takes flags), checks your toolchain, stamps the template, and **builds the
|
|
76
|
+
app to prove it's green** before reporting success.
|
|
70
77
|
|
|
71
|
-
> **
|
|
72
|
-
>
|
|
73
|
-
> reuse either. The installed *command* is still `create-cmp`. You can also run straight from GitHub
|
|
74
|
-
> with no install: `npx github:kvdm-co-pilot/create-cmp`, or use the
|
|
75
|
-
> [Claude Code plugin](#use-it-from-claude-code).
|
|
78
|
+
> **Name note:** the npm package is `create-cmp-cli` (the bare `create-cmp` name was already
|
|
79
|
+
> squatted); the installed command is still `create-cmp`.
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
---
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
except `create` works on **any** KMP project, not only ones it scaffolded:
|
|
83
|
+
# The features, one by one
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
| `create-cmp [dir]` / `create-cmp create` | Scaffold a new app from the frozen golden template (the default command). |
|
|
85
|
-
| `create-cmp doctor [--fix]` | Toolchain preflight (JDK/SDK/Xcode/Appium — legacy driver install for the pre-Maestro path, consent-gated installs) **plus** project diagnosis when run inside a Gradle project: kotlin↔ksp lockstep, drift vs proven-green sets, the KSP2/iOS catch-22, sdk.dir, `~/.konan` bloat, disk space. `--fix` applies the safe heals. |
|
|
86
|
-
| `create-cmp upgrade [--dry-run]` | Migrate `gradle/libs.versions.toml` to the next proven-green version set — diff table first, surgical in-place edits with `.bak-upgrade` backups, kotlin↔ksp lockstep guardrail, `--verify` to prove the result. |
|
|
87
|
-
| `create-cmp clean` | Cache & build-output hygiene: stale `~/.konan` toolchains + project `build/`/`.gradle/` dirs (sizes shown, consent-gated); `~/.gradle/caches` is size-reported only. |
|
|
88
|
-
| `create-cmp verify [--target-dir .]` | Run the green-build gate (Android, and iOS on macOS) against an existing project. |
|
|
89
|
-
|
|
90
|
-
> **North-star (a goal, measured honestly — not a benchmark):** *time-to-green* — a running app on
|
|
91
|
-
> the Android emulator **and** the iOS simulator, smoke-passing, with zero manual steps modulo the
|
|
92
|
-
> one Xcode App Store install. Target: **under 5 minutes on a clean machine.** Every scaffold ends
|
|
93
|
-
> with a `--verify` build gate, so success is *proven*, not assumed.
|
|
94
|
-
|
|
95
|
-
## What you get (the pre-solved moat)
|
|
96
|
-
|
|
97
|
-
Everything below is **stamped from a template that builds green on Android + iOS** — not regenerated
|
|
98
|
-
per run, so it can't silently drift:
|
|
99
|
-
|
|
100
|
-
- **A pinned, version-locked dependency set** — Kotlin, KSP, Compose Multiplatform, Room, AGP, Koin,
|
|
101
|
-
Ktor, GitLive Firebase KMP, Navigation Compose, Lifecycle — chosen to actually agree with each
|
|
102
|
-
other, including the iOS Room/KSP2 catch-22 (`ksp.useKSP2=true`). Frozen and CI-gated.
|
|
103
|
-
- **The iOS shell, in the order that builds** — `project.yml` (XcodeGen), `Podfile` (Firebase static
|
|
104
|
-
linkage), `Info.plist`, `iOSApp.swift` (AppDelegate + `FirebaseApp.configure()` before Koin),
|
|
105
|
-
`ContentView.swift` (ComposeUIViewController bridge).
|
|
106
|
-
- **The Android shell** — `AndroidManifest`, `MainActivity` with `enableEdgeToEdge()`, `Application`
|
|
107
|
-
starting Koin + Room, adaptive-icon structure.
|
|
108
|
-
- **Navigation + insets, solved once** — a generic `BaseScreen` Scaffold owning window insets /
|
|
109
|
-
status- and nav-bar padding, plus a data-driven `AppShell` + bottom nav fed by your `tabs` list
|
|
110
|
-
(not role-hardcoded), with type-safe routes.
|
|
111
|
-
- **Clean Architecture, wired** — `core / data{local,remote} / domain{model,repository,usecase} /
|
|
112
|
-
presentation{components,theme,navigation,<feature>} / di` with Koin modules registered and **one
|
|
113
|
-
example feature wired end-to-end** as the copy-paste pattern.
|
|
114
|
-
- **Theme & tokens** — `<Prefix>Theme`, `<Prefix>Tokens`, `<Prefix>Colors`, DM Sans.
|
|
115
|
-
- **The test pyramid, pre-built** — unit exemplars (Turbine + fakes), dependency-free architecture
|
|
116
|
-
conformance gates, Compose UI Tests, golden-tree structural baselines, and Maestro E2E flows —
|
|
117
|
-
bound to a committed behavior spec (`specs/*.spec.md`, Given/When/Then, stable clause ids) so a
|
|
118
|
-
`specCoverage` gate catches untested or untraceable behavior — plus the verify lane
|
|
119
|
-
(`qa/verify.mjs`) that runs it all and emits a typed PASS/FAIL/SKIP verdict + evidence receipt.
|
|
120
|
-
- **Extend it with Claude Code, no plugin required** — every generated project ships its own
|
|
121
|
-
`add-feature`/`add-screen`/`add-repository` skills (backed by the deterministic
|
|
122
|
-
`qa/scaffold-feature.mjs` stamper) that clone the exemplar feature end-to-end: Screen →
|
|
123
|
-
ViewModel → UseCase → Repository → DI → nav, tests at every layer, golden baseline included.
|
|
124
|
-
- **Mechanically enforced, not honor-system** — a generated `.claude/settings.json` Stop hook
|
|
125
|
-
refuses "done" unless a fresh, evidence-bound PASS receipt covers the changed surface (content-
|
|
126
|
-
hash bound, rebase/merge-proof), and CI independently re-checks the committed receipt still
|
|
127
|
-
attests `HEAD`. Proven against real violations: hardcoded colors, illegal layer imports, deleted
|
|
128
|
-
spec tests, and structural regressions are each refused and named by clause, 4/4 in the
|
|
129
|
-
repo's refusal-demo script.
|
|
130
|
-
- **A toolchain doctor** — diagnoses *and* heals JDK, Android SDK + AVD, Xcode/CLT, CocoaPods,
|
|
131
|
-
XcodeGen, Appium + drivers (the legacy pre-Maestro path), Node. Idempotent, OS-aware, consent-gated.
|
|
132
|
-
The E2E flows themselves run on Maestro — install with
|
|
133
|
-
`curl -fsSL https://get.maestro.mobile.dev | bash`.
|
|
134
|
-
- **AI-inspectable by default** — every generated app ships a debug-only live inspector
|
|
135
|
-
(`127.0.0.1:9500`, loopback, structurally absent from release). An agent runs `connect_live` and
|
|
136
|
-
reads the running UI as JSON — hierarchy, geometry, resolved design tokens, real navigation
|
|
137
|
-
state. Never screenshots. Toggle with `--no-inspector`; `create-cmp doctor` verifies it stays
|
|
138
|
-
debug-only and that every declared theme token is served on `/inspect/design-system`.
|
|
139
|
-
- **Live device view** — with the debug app running, open `http://127.0.0.1:9500/inspect/remote`
|
|
140
|
-
(after `adb forward tcp:9500 tcp:9500`) to watch the real device and click the image to tap it,
|
|
141
|
-
while the agent drives `navigate_and_inspect` on the semantics tree and proves every change with
|
|
142
|
-
`prove_change`. Pixels for the human, structure for the AI.
|
|
143
|
-
- **Desktop dev-client** — a phone-sized JVM window running the shared UI with Compose Hot Reload
|
|
144
|
-
attached (`./gradlew :composeApp:hotRunDesktop --auto`); Firebase never initializes on desktop
|
|
145
|
-
(offline DI fakes). Toggle with `--no-dev-client`.
|
|
146
|
-
|
|
147
|
-
`google-services.json` / `GoogleService-Info.plist` are intentionally **not** real — you get
|
|
148
|
-
placeholders and clear "drop your Firebase config here" instructions.
|
|
85
|
+
Three surfaces: the **CLI**, the **Claude Code plugin**, and — most importantly — **what every
|
|
86
|
+
generated project carries inside it**.
|
|
149
87
|
|
|
150
|
-
##
|
|
88
|
+
## The CLI (5 commands)
|
|
151
89
|
|
|
152
|
-
|
|
153
|
-
|---|---|---|
|
|
154
|
-
| Platforms | Android (always) + iOS | iOS on |
|
|
155
|
-
| App name / package / iOS bundle id | — | required / derived |
|
|
156
|
-
| Firebase region | any Firebase region | `us-central1` |
|
|
157
|
-
| Firebase (GitLive KMP) | on / off | on |
|
|
158
|
-
| Auth | `email` / `phone` / `both` / `none` | `both` |
|
|
159
|
-
| Firebase services | Firestore · Storage · Functions · FCM | all on |
|
|
160
|
-
| Room local cache | on / off | on |
|
|
161
|
-
| E2E flows (Maestro) | on / off | on |
|
|
162
|
-
| Bottom-nav tabs | label + icon, any count | Home, Profile |
|
|
90
|
+
Everything except `create` works on **any** KMP project, not just ones this tool made.
|
|
163
91
|
|
|
164
|
-
|
|
92
|
+
| Command | Plain-speech: what it does |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `create-cmp [dir]` | Makes a new app. Asks questions (or takes flags), stamps the template, renames everything to your package, removes features you turned off, builds it, tells you GREEN or FAIL. |
|
|
95
|
+
| `create-cmp doctor [--fix]` | Checks your machine (JDK, Android SDK, emulator, Xcode, CocoaPods, XcodeGen, Node) **and** your project (do Kotlin and KSP versions agree? is the version catalog drifting? is `~/.konan` eating your disk?). `--fix` applies safe repairs — always asking first. |
|
|
96
|
+
| `create-cmp upgrade [--dry-run]` | Moves your `libs.versions.toml` to the next **proven-green** version set. Shows a diff first, edits surgically with backups, guards the Kotlin↔KSP lockstep, and can re-verify the build after. |
|
|
97
|
+
| `create-cmp clean` | Reclaims disk: stale Kotlin/Native toolchains, `build/` dirs. Shows sizes, asks before deleting. |
|
|
98
|
+
| `create-cmp verify` | Runs the green-build gate against an existing project. Exit 0 = green. Useful in scripts and CI. |
|
|
165
99
|
|
|
166
|
-
|
|
100
|
+
Hit a KMP build error? [Common CMP/KMP build errors and fixes](docs/errors/README.md) — kotlin↔KSP
|
|
101
|
+
mismatch, the KSP2/iOS catch-22, `SDK location not found`, `No space left on device`, version drift.
|
|
167
102
|
|
|
168
|
-
|
|
169
|
-
shared engine, two front doors. Install from the bundled marketplace:
|
|
103
|
+
## The Claude Code plugin (8 skills)
|
|
170
104
|
|
|
171
105
|
```text
|
|
172
106
|
/plugin marketplace add kvdm-co-pilot/create-cmp
|
|
173
107
|
/plugin install create-cmp
|
|
174
108
|
```
|
|
175
109
|
|
|
176
|
-
|
|
177
|
-
[the settings.json fallback in `docs/USAGE.md`](./docs/USAGE.md#2-setup) — it's a real two-step
|
|
178
|
-
install (marketplace registration, then plugin install), not a one-line tweak.
|
|
179
|
-
|
|
180
|
-
It bundles eight skills (plus the `cmp-inspector` MCP server), each with a deterministic engine
|
|
181
|
-
behind it:
|
|
182
|
-
|
|
183
|
-
- **cmp-new** — conversational interview, then shells out to the same engine to scaffold, and
|
|
184
|
-
generates your tab screens from the example-feature pattern.
|
|
185
|
-
- **cmp-doctor** — toolchain bootstrap + project diagnosis on any KMP project (consent-gated).
|
|
186
|
-
- **cmp-upgrade** — migrate to the next proven-green version set (diff → apply → verify).
|
|
187
|
-
- **cmp-inspect** — see and drive a rendered Compose UI as structured JSON: hierarchy, geometry,
|
|
188
|
-
resolved design tokens, drift diffs, golden-tree snapshots, a11y audit, live navigation
|
|
189
|
-
(`navigate_and_inspect`), wireframe rendering, and `prove_change` — the verified dev loop.
|
|
190
|
-
- **cmp-dev-client** — run the shared UI in a phone-sized desktop window with Compose Hot Reload
|
|
191
|
-
(`./gradlew :composeApp:hotRunDesktop --auto`) — the Expo-style dev loop for KMP.
|
|
192
|
-
- **cmp-firebase-connect** — post-scaffold onboarding: create/reuse a Firebase project, register
|
|
193
|
-
the app, and replace the placeholder config via the Firebase CLI (consent-gated), proven by a
|
|
194
|
-
green build.
|
|
195
|
-
- **cmp-test** — generate the regression suite by *observing* the app: read the running UI's
|
|
196
|
-
semantics tree and emit Maestro E2E flows plus golden-tree snapshots in the shipped harness style.
|
|
197
|
-
- **cmp-qa-prep** — brings up the emulator + Maestro flow run + smoke (legacy Appium bring-up
|
|
198
|
-
path also supported).
|
|
110
|
+
Same engine as the CLI, conversational front door. Each skill is a guided flow, not a wrapper:
|
|
199
111
|
|
|
200
|
-
|
|
112
|
+
| Skill | Plain-speech: what it does |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `cmp-new` | "Make me an app." Interviews you in chat, scaffolds via the engine, generates your bottom-nav tab screens from the exemplar pattern, proves the build green. |
|
|
115
|
+
| `cmp-doctor` | "Why won't my KMP project build?" Runs the doctor, explains the findings, applies consented fixes. |
|
|
116
|
+
| `cmp-upgrade` | "Bump my dependencies safely." Diff → apply → verify, with the lockstep guardrails. |
|
|
117
|
+
| `cmp-inspect` | "What did the UI actually render?" Reads a **running** app as structured JSON — hierarchy, geometry, resolved design tokens, navigation state. Never screenshots. Can assert tokens, find drift against your design system, audit accessibility, diff before/after. |
|
|
118
|
+
| `cmp-dev-client` | "Let me iterate fast." Runs your shared UI in a phone-sized desktop window with hot reload — save a file, see it change. No emulator needed. Firebase stays off on desktop (offline fakes). |
|
|
119
|
+
| `cmp-firebase-connect` | "Wire up my real Firebase." Drives the Firebase CLI: create/reuse a project, register the app, drop the real `google-services.json` over the placeholder, prove it with a green build. Every cloud action asks first. |
|
|
120
|
+
| `cmp-test` | "Write tests for my app." *Observes* the running app's semantics tree — what's actually on screen, what's tappable, where navigation goes — and derives the regression suite from that. Tests come from rendered reality, not guesses. |
|
|
121
|
+
| `cmp-qa-prep` | "Get my test environment up." Emulator + app install + E2E smoke run, with the gotchas handled. |
|
|
201
122
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
123
|
+
Plus the **`cmp-inspector` MCP server** (14 tools) — the machine-readable window into a running
|
|
124
|
+
Compose UI that `cmp-inspect`, `cmp-test`, and the verified dev loop are built on. One tree
|
|
125
|
+
contract, three sources: render a screen headlessly, connect to the live app, or read a device
|
|
126
|
+
via UIAutomator.
|
|
205
127
|
|
|
206
|
-
|
|
207
|
-
JS bridge, no separate native-module dance for the common case.
|
|
208
|
-
- **Native performance and platform access** without the RN bridge tax.
|
|
209
|
-
- **Reproducible by construction.** A frozen, version-locked, CI-gated template means the build that
|
|
210
|
-
was green yesterday is green today — the exact property ad-hoc CMP setups lack.
|
|
211
|
-
- **Proven, not assumed.** Every scaffold ends on the verify lane — real build + the full JVM
|
|
212
|
-
test tier — with a typed GREEN/FAIL verdict and an evidence receipt.
|
|
128
|
+
## What every generated project carries (the harness itself)
|
|
213
129
|
|
|
214
|
-
|
|
215
|
-
the same for multiplatform mobile.
|
|
130
|
+
This is the product. Delete the plugin, uninstall the CLI — your generated repo keeps all of it.
|
|
216
131
|
|
|
217
|
-
|
|
132
|
+
### 1. Specs — behavior is written down first
|
|
133
|
+
`specs/*.spec.md` — plain Given/When/Then clauses with stable ids (`HOME-01`, `ARCH-05`). New
|
|
134
|
+
behavior starts as a clause; durable tests cite their clause (`// SPEC: HOME-02`). The `home`
|
|
135
|
+
feature ships as the fully-cited example.
|
|
218
136
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
│ one shared engine, two front doors
|
|
222
|
-
Engine (Node, deterministic): copy → token-replace → rename packages → toggle features → VERIFY
|
|
223
|
-
Golden template (frozen, CI'd): pinned versions · iOS shell · nav+insets · Clean Arch · DI · test pyramid
|
|
224
|
-
```
|
|
137
|
+
### 2. The verify lane — one command, eight gates
|
|
138
|
+
`node qa/verify.mjs` runs everything and writes a typed PASS/FAIL/SKIP receipt:
|
|
225
139
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
140
|
+
| Gate | Plain-speech: what it catches |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `specCoverage` | Behavior nobody tests, and test citations pointing at nothing. Every clause needs a test; every citation needs a clause. |
|
|
143
|
+
| `build` | The app doesn't compile. |
|
|
144
|
+
| `unitTests` | A behavior broke. ViewModels/UseCases/Repositories, tested with hand-written fakes. |
|
|
145
|
+
| `conformance` | Architecture violations, **named by rule**: UI importing the data layer, hardcoded colors outside the theme, a screen without a ViewModel test. |
|
|
146
|
+
| `goldenTrees` | A screen's *structure* changed when you didn't mean it to. Compares the rendered semantics tree against a committed baseline — no pixels, no flake. |
|
|
147
|
+
| `tokenDrift` | The running app's design tokens drifting from the declared catalog — queried live from the debug inspector. Hardcode a color and it shows up here too. |
|
|
148
|
+
| `a11y` | Missing content descriptions, undersized touch targets. |
|
|
149
|
+
| `e2eSmoke` | The app doesn't actually boot and navigate on a device. Real Maestro run, hardened for slow emulators. |
|
|
150
|
+
|
|
151
|
+
No device attached? Device-dependent gates record an honest **SKIP** — never a fake green.
|
|
152
|
+
|
|
153
|
+
### 3. Evidence — receipts you can't forge
|
|
154
|
+
The lane writes `qa/evidence/latest.json`: verdict, per-gate results, durations, and an
|
|
155
|
+
`inputs.hash` — a content hash of every file that could affect the verdict. You commit the
|
|
156
|
+
receipt with your change; git history becomes the audit ledger. Because validity is a *content*
|
|
157
|
+
hash (not a commit SHA), rebases and merges don't invalidate honest receipts — but editing the
|
|
158
|
+
verdict by hand, or reusing a stale receipt, fails immediately. The lane also forces test
|
|
159
|
+
*execution* (`--rerun`), so a receipt can never launder a cached result from a different tree.
|
|
160
|
+
|
|
161
|
+
### 4. Enforcement — "done" is mechanical, not honor-system
|
|
162
|
+
- **Stop hook** (`.claude/settings.json`): when an AI session tries to end, it re-hashes the
|
|
163
|
+
verified surface and compares against the committed receipt. Changed code without a fresh PASS
|
|
164
|
+
receipt → the session is blocked, with the reason. Costs milliseconds (hashing only). Doc-only
|
|
165
|
+
edits never trigger it — enforcement is transparent, not hostile.
|
|
166
|
+
- **CI receipt gate** (`.github/workflows/verify.yml`): every push re-checks that the committed
|
|
167
|
+
receipt attests `HEAD`, then independently re-runs the whole lane.
|
|
168
|
+
- **The refusal demo** (`node qa/refusal-demo.mjs`): four staged violations — hardcoded color,
|
|
169
|
+
illegal layer import, deleted spec test, structural regression — each caught and **named by
|
|
170
|
+
clause**, 4/4. Run it to watch the harness say no.
|
|
171
|
+
|
|
172
|
+
### 5. In-project generators — extend without the plugin
|
|
173
|
+
Three skills ship *inside* the generated repo (`.claude/skills/`), backed by a deterministic
|
|
174
|
+
stamper (`qa/scaffold-feature.mjs`):
|
|
175
|
+
|
|
176
|
+
- **`add-feature`** — a full vertical slice cloned from the `home` exemplar: Screen → ViewModel →
|
|
177
|
+
UseCase → Repository → DI → nav route, **with tests at every layer** and a golden baseline slot.
|
|
178
|
+
- **`add-screen`** — presentation only, for an entity whose data layer already exists.
|
|
179
|
+
- **`add-repository`** — data/domain only: model, repository interface + impl, use case, fake.
|
|
180
|
+
|
|
181
|
+
Any plain Claude Code session — no create-cmp plugin installed — finds these and extends the app
|
|
182
|
+
correctly by construction.
|
|
183
|
+
|
|
184
|
+
### 6. The live inspector — AI-readable UI
|
|
185
|
+
Every debug build serves `127.0.0.1:9500` (loopback-only, structurally absent from release): the
|
|
186
|
+
UI tree as JSON, the design-token catalog, a screenshot route, a tap route, and a live device
|
|
187
|
+
view for humans (`/inspect/remote` — watch the real device in a browser, click to tap). Agents
|
|
188
|
+
read structure; humans see pixels.
|
|
189
|
+
|
|
190
|
+
### 7. The daily-driver extras
|
|
191
|
+
- **Desktop dev-client** — shared UI in a phone-sized JVM window, Compose Hot Reload attached.
|
|
192
|
+
- **CI workflow** — Android job on every push; iOS job ready to un-comment.
|
|
193
|
+
- **`CLAUDE.md`** — the AI delivery contract itself, stating everything above as rules any AI
|
|
194
|
+
session in the repo must follow.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
# Workflows — how it fits together
|
|
199
|
+
|
|
200
|
+
**New app → green.** `cmp-new` (or `npx create-cmp-cli`) → interview → stamp → green build proven
|
|
201
|
+
→ tab screens generated. Then `cmp-firebase-connect` to wire your real backend.
|
|
202
|
+
|
|
203
|
+
**The daily UI loop.** `./gradlew :composeApp:hotRunDesktop --auto` → edit Compose → save → see
|
|
204
|
+
it. No emulator, no Firebase, sub-second feedback.
|
|
205
|
+
|
|
206
|
+
**The verified dev loop (the flagship).** For any UI change: snapshot the live tree → make the
|
|
207
|
+
edit → reload → `prove_change` compares before/after structure, token drift, and a11y, and returns
|
|
208
|
+
a verdict. The agent doesn't say "I centered the title" — it shows *"title bounds moved, tokens
|
|
209
|
+
unchanged, no a11y regressions: proven clean."*
|
|
210
|
+
|
|
211
|
+
**Add a feature with AI (no plugin).** Ask any Claude Code session for a feature → it reads
|
|
212
|
+
`CLAUDE.md` → proposes the spec clause first → runs `add-feature` → runs the lane → commits code
|
|
213
|
+
+ receipt together. If it violates the architecture, the gates name the broken rule; if it tries
|
|
214
|
+
to stop early, the Stop hook blocks it.
|
|
215
|
+
|
|
216
|
+
**Tests that write themselves.** `cmp-test` reads the running app's semantics tree and emits the
|
|
217
|
+
regression suite — existence, interaction, navigation, golden trees — in the shipped harness style.
|
|
218
|
+
|
|
219
|
+
**Maintenance, for the life of the repo.** `doctor` when anything misbehaves, `upgrade` when you
|
|
220
|
+
want newer versions without the version-matrix gamble, `clean` when disk fills, `verify` as the
|
|
221
|
+
standalone gate. All of it works on any KMP project.
|
|
222
|
+
|
|
223
|
+
## Agent flows — who does what
|
|
224
|
+
|
|
225
|
+
- **A plain AI session** in a generated repo is the common case: the contract (`CLAUDE.md`), the
|
|
226
|
+
generators, the lane, and the hook are all local files — the session follows the loop above
|
|
227
|
+
with nothing installed.
|
|
228
|
+
- **The `cmp-orchestrator` agent** (ships with the plugin) splits bigger jobs: it delegates
|
|
229
|
+
generation and mechanical work to sub-agents with self-contained briefs, then **gates every
|
|
230
|
+
hand-off through the verify lane** before accepting it. Nothing is reported done on prose —
|
|
231
|
+
only on a receipt.
|
|
232
|
+
- **The MCP tools** are how any agent *sees*: `inspect_tree`, `get_node`, `assert_token`,
|
|
233
|
+
`layout_gaps`, `diff_against_design_system`, `find_drift`, `snapshot_save`, `snapshot_diff`,
|
|
234
|
+
`audit_a11y`, `connect_live`, `navigate_and_inspect`, `render_tree`, `render_screen`,
|
|
235
|
+
`prove_change`. Structure in, structure out — never pixels in model context.
|
|
236
|
+
|
|
237
|
+
## The philosophy (why it's built this way)
|
|
238
|
+
|
|
239
|
+
1. **Stamp, don't generate.** The skeleton comes from a frozen, CI-verified template. LLMs are
|
|
240
|
+
never in the hot path for code that must be identical every time. Determinism is the moat.
|
|
241
|
+
2. **Evidence over claims.** "It works" is a claim. A committed receipt from an executed lane is
|
|
242
|
+
evidence. The whole harness exists to convert one into the other.
|
|
243
|
+
3. **Specs before code.** If behavior isn't written as a clause, the coverage gate calls it
|
|
244
|
+
untested. New behavior starts in `specs/`, not in a diff.
|
|
245
|
+
4. **Exemplars over documentation.** The `home` feature *is* the architecture guide — a running,
|
|
246
|
+
tested pattern that generators clone and humans copy. Patterns you can execute don't rot.
|
|
247
|
+
5. **Refusal is a feature.** A green checkmark is cheap. A red one that names the violated clause
|
|
248
|
+
is what makes the green one mean something. The refusal demo is part of the product.
|
|
249
|
+
6. **Honest SKIPs.** No device → the gate says SKIP, visibly, in the receipt. Green-with-gaps
|
|
250
|
+
presented as fully verified is treated as a bug — in the harness itself.
|
|
251
|
+
7. **Structure, not pixels.** Golden trees, token assertions, and semantic diffs instead of
|
|
252
|
+
screenshot comparisons: platform-stable, flake-free, and machine-readable.
|
|
253
|
+
8. **The contract lives in the project.** Everything enforcing correctness ships in the generated
|
|
254
|
+
repo, not the tool. Your repo stays verifiable after the tool is gone.
|
|
255
|
+
9. **Enforcement must be cheap and fair.** The hook hashes files in milliseconds; doc edits never
|
|
256
|
+
invalidate evidence; rebases don't force re-runs. Gates that punish honest work get disabled —
|
|
257
|
+
so they're designed not to.
|
|
258
|
+
10. **Dogfood in public.** The [showcase](https://github.com/kvdm-co-pilot/create-cmp-showcase) is
|
|
259
|
+
rebuilt from the published package, receipts and refusals included. Two of the last three
|
|
260
|
+
releases fixed bugs the dogfooding itself caught — the harness catching its own tool is the
|
|
261
|
+
system working.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Options
|
|
266
|
+
|
|
267
|
+
| Option | Choices | Default |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| Platforms | Android (always) + iOS | iOS on |
|
|
270
|
+
| App name / package / iOS bundle id | — | required / derived |
|
|
271
|
+
| Firebase (GitLive KMP) | on / off | on |
|
|
272
|
+
| Auth | `email` / `phone` / `both` / `none` | `both` |
|
|
273
|
+
| Firebase region + services | any region · Firestore/Storage/Functions/FCM | `us-central1` · all on |
|
|
274
|
+
| Room local cache | on / off | on |
|
|
275
|
+
| E2E flows (Maestro) | on / off (`--e2e` / `--no-e2e`; `--appium` is a deprecated alias) | on |
|
|
276
|
+
| Live inspector | on / off | on |
|
|
277
|
+
| Desktop dev-client | on / off | on |
|
|
278
|
+
| Bottom-nav tabs | label + icon, any count | Home, Profile |
|
|
279
|
+
|
|
280
|
+
_Web/PWA is intentionally out of scope — Android + iOS only._
|
|
234
281
|
|
|
235
282
|
## Requirements
|
|
236
283
|
|
|
237
|
-
- **Node.js ≥ 18**
|
|
238
|
-
- **macOS** for iOS output
|
|
239
|
-
- Everything else
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
-
|
|
255
|
-
|
|
256
|
-
loopback debug server. See [`docs/INSPECTOR-PLAN.md`](./docs/INSPECTOR-PLAN.md).
|
|
257
|
-
- [x] Android build matrix in CI on every push; iOS matrix runs on manual dispatch.
|
|
258
|
-
- [ ] Publish `create-cmp-cli@0.3.0` to npm (needs an interactive OTP session) + record the
|
|
259
|
-
asciinema demo (scaffold → `add-feature` → gate refuses a violation → fix → PASS).
|
|
260
|
-
- [ ] More example features and nav shapes.
|
|
284
|
+
- **Node.js ≥ 18** for the tool itself.
|
|
285
|
+
- **macOS** for iOS output; Android works on macOS or Linux.
|
|
286
|
+
- Everything else (JDK 17, Android SDK + emulator, CocoaPods, XcodeGen) the `doctor` detects and —
|
|
287
|
+
with consent — installs. Maestro installs with `curl -fsSL https://get.maestro.mobile.dev | bash`.
|
|
288
|
+
Xcode itself is the one manual App Store step.
|
|
289
|
+
|
|
290
|
+
## Why CMP, not React Native
|
|
291
|
+
|
|
292
|
+
The only place CMP loses to RN on a new app is time-to-first-green-build — a tooling problem, not
|
|
293
|
+
a merits problem. With one language, real native UI, no bridge, and a reproducible frozen
|
|
294
|
+
template, CMP's `npx`-and-go is now competitive. If `create-next-app` made React the web default
|
|
295
|
+
by deleting setup friction, the goal here is the same for multiplatform mobile.
|
|
296
|
+
|
|
297
|
+
## Docs
|
|
298
|
+
|
|
299
|
+
[`docs/USAGE.md`](./docs/USAGE.md) — the complete usage guide (every command, skill, MCP tool,
|
|
300
|
+
workflow) · [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) — engine design ·
|
|
301
|
+
[`docs/HARNESS-PLAN.md`](./docs/HARNESS-PLAN.md) — the harness, layer by layer ·
|
|
302
|
+
[`docs/adr/`](./docs/adr/) — decision records · [`docs/ROADMAP.md`](./docs/ROADMAP.md) — what's next.
|
|
261
303
|
|
|
262
304
|
## Contributing
|
|
263
305
|
|
|
264
|
-
Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) and
|
|
306
|
+
Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) and the
|
|
265
307
|
[Code of Conduct](./CODE_OF_CONDUCT.md). The golden template is CI-gated: an upstream version bump
|
|
266
|
-
must fail CI, not your generated project.
|
|
308
|
+
must fail our CI, not your generated project.
|
|
267
309
|
|
|
268
310
|
## License
|
|
269
311
|
|
package/bin/create-cmp.mjs
CHANGED
|
@@ -63,7 +63,9 @@ async function main() {
|
|
|
63
63
|
|
|
64
64
|
function printHelp() {
|
|
65
65
|
process.stdout.write(
|
|
66
|
-
`create-cmp —
|
|
66
|
+
`create-cmp — the AI delivery harness for Kotlin/Compose Multiplatform (Android + iOS)\n` +
|
|
67
|
+
`Scaffolds a green-building app in minutes; every generated project carries a spec-driven\n` +
|
|
68
|
+
`verify lane, evidence receipts, and mechanical enforcement of "done".\n\n` +
|
|
67
69
|
`Usage:\n` +
|
|
68
70
|
` npx create-cmp [target-dir] [flags] scaffold a new app (default command)\n` +
|
|
69
71
|
` npx create-cmp create [target-dir] same, explicit\n` +
|
package/llms.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# create-cmp
|
|
2
|
+
|
|
3
|
+
> The AI delivery harness for Kotlin/Compose Multiplatform (Android + iOS): scaffolds a deterministic, green-building app in which every project carries a spec-driven verify lane, evidence receipts, and mechanical enforcement of "done".
|
|
4
|
+
|
|
5
|
+
create-cmp (npm package: `create-cmp-cli`) stamps a frozen, CI-verified template — it never asks an LLM to freehand project code, so every scaffold is identical and every scaffold builds. It runs fully non-interactively with flags, validates them against a machine-readable schema (`options.schema.json`), and exits non-zero on failure. The `--verify` flag builds the generated app before reporting success.
|
|
6
|
+
|
|
7
|
+
Non-interactive one-liner (flags per `options.schema.json` and `create-cmp --help`):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --verify
|
|
11
|
+
```
|
|
12
|
+
|
|
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
|
+
|
|
15
|
+
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
|
+
|
|
17
|
+
## Docs
|
|
18
|
+
|
|
19
|
+
- [README](https://github.com/kvdm-co-pilot/create-cmp/blob/main/README.md): full feature tour — CLI commands, plugin skills, and the harness every generated project carries
|
|
20
|
+
- [Usage guide](https://github.com/kvdm-co-pilot/create-cmp/blob/main/docs/USAGE.md): every command, skill, MCP tool, and workflow in detail
|
|
21
|
+
- [Options schema](https://github.com/kvdm-co-pilot/create-cmp/blob/main/options.schema.json): machine-readable JSON Schema for the engine config the flags build
|
|
22
|
+
- [Changelog](https://github.com/kvdm-co-pilot/create-cmp/blob/main/CHANGELOG.md): release history, Keep-a-Changelog format
|
|
23
|
+
|
|
24
|
+
## Examples
|
|
25
|
+
|
|
26
|
+
- [create-cmp-showcase](https://github.com/kvdm-co-pilot/create-cmp-showcase): public repo built entirely by this tool, every commit carrying its evidence receipt
|
|
27
|
+
- [Refusal PR #1](https://github.com/kvdm-co-pilot/create-cmp-showcase/pull/1): live proof of the harness blocking a bad change and naming the violated rule
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The AI delivery harness for Kotlin/Compose Multiplatform — scaffolds a green-building app (Android + iOS) in minutes
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|
|
7
7
|
"create-cmp": "bin/create-cmp.mjs",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"bin",
|
|
15
15
|
"src",
|
|
16
16
|
"template",
|
|
17
|
-
"options.schema.json"
|
|
17
|
+
"options.schema.json",
|
|
18
|
+
"llms.txt"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"test": "node --test",
|
|
@@ -24,13 +25,23 @@
|
|
|
24
25
|
"keywords": [
|
|
25
26
|
"create-cmp",
|
|
26
27
|
"create-cmp-cli",
|
|
28
|
+
"compose multiplatform",
|
|
27
29
|
"compose-multiplatform",
|
|
30
|
+
"kotlin multiplatform",
|
|
28
31
|
"kotlin-multiplatform",
|
|
32
|
+
"kotlin",
|
|
33
|
+
"compose",
|
|
29
34
|
"kmp",
|
|
30
35
|
"cmp",
|
|
31
36
|
"scaffold",
|
|
37
|
+
"project generator",
|
|
38
|
+
"cli",
|
|
39
|
+
"claude code",
|
|
40
|
+
"ai agent",
|
|
32
41
|
"android",
|
|
33
|
-
"ios"
|
|
42
|
+
"ios",
|
|
43
|
+
"verify",
|
|
44
|
+
"e2e"
|
|
34
45
|
],
|
|
35
46
|
"license": "MIT",
|
|
36
47
|
"author": "Karel van der Merwe",
|
package/src/lib/tabs.mjs
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Tab-driven surface regeneration (scaffold pipeline step b.3).
|
|
2
|
+
//
|
|
3
|
+
// The template ships the DEFAULT tabs (Home:home, Profile:person) baked into
|
|
4
|
+
// three surfaces; this module rewrites them from `config.tabs` at stamp time
|
|
5
|
+
// so a non-default tabs config can never go stale:
|
|
6
|
+
// - presentation/navigation/AppTab.kt (the appTabs() list — one entry per tab)
|
|
7
|
+
// - presentation/navigation/AppNavHost.kt (the appTabs(...) call-site wiring)
|
|
8
|
+
// - qa/e2e/smoke.yaml (Maestro taps/asserts per tab)
|
|
9
|
+
// Tabs without a shipped feature screen (anything that doesn't slug to
|
|
10
|
+
// `home`/`profile`) get a generated PlaceholderScreen stub carrying the
|
|
11
|
+
// `<slug>_title` testTag the smoke flow asserts.
|
|
12
|
+
//
|
|
13
|
+
// Everything is written in TEMPLATE-TOKEN form (__PACKAGE__ contents, literal
|
|
14
|
+
// com/example/app paths) BEFORE token replacement / package rename, so the
|
|
15
|
+
// normal pipeline stamps these files like any other template file. For the
|
|
16
|
+
// default tabs config the output is byte-identical to the static template
|
|
17
|
+
// files — golden/deterministic scaffolding is a core product property.
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
// --- slug + naming ------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Nav-item tag slug for a tab label.
|
|
26
|
+
* MUST MIRROR `navItemTag` in
|
|
27
|
+
* template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt:
|
|
28
|
+
* lowercase, every run of chars outside [a-z0-9] collapsed to a single "_",
|
|
29
|
+
* leading/trailing "_" trimmed. ("My Stuff!" → "my_stuff")
|
|
30
|
+
* @param {string} label
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function navSlug(label) {
|
|
34
|
+
return String(label)
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
37
|
+
.replace(/^_+|_+$/g, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Kotlin hard keywords — a derived parameter name colliding with one must be backticked.
|
|
41
|
+
const KOTLIN_HARD_KEYWORDS = new Set([
|
|
42
|
+
"as", "break", "class", "continue", "do", "else", "false", "for", "fun",
|
|
43
|
+
"if", "in", "interface", "is", "null", "object", "package", "return",
|
|
44
|
+
"super", "this", "throw", "true", "try", "typealias", "typeof", "val",
|
|
45
|
+
"var", "when", "while",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Kotlin parameter name for a tab: camelCase of the slug ("my_stuff" → "myStuff"),
|
|
50
|
+
* backticked when it collides with a hard keyword or starts with a digit.
|
|
51
|
+
* @param {string} slug
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function kotlinParamName(slug) {
|
|
55
|
+
const parts = slug.split("_").filter(Boolean);
|
|
56
|
+
const name = parts
|
|
57
|
+
.map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1)))
|
|
58
|
+
.join("");
|
|
59
|
+
if (KOTLIN_HARD_KEYWORDS.has(name) || /^[0-9]/.test(name)) return `\`${name}\``;
|
|
60
|
+
return name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Material icon symbol for a CLI icon key: PascalCase the key's alphanumeric
|
|
65
|
+
* parts ("home" → "Home", "shopping_cart" → "ShoppingCart", "ShoppingCart"
|
|
66
|
+
* stays as-is). The template depends on compose.materialIconsExtended, so any
|
|
67
|
+
* `Icons.Filled.*` symbol from the extended set resolves.
|
|
68
|
+
* @param {string} key
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function iconSymbol(key) {
|
|
72
|
+
return String(key)
|
|
73
|
+
.split(/[^A-Za-z0-9]+/)
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
76
|
+
.join("");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Escape a label for use inside a Kotlin double-quoted string literal. */
|
|
80
|
+
function kotlinString(s) {
|
|
81
|
+
return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Derive per-tab info (slug, param, icon) and validate the set: every label
|
|
86
|
+
* must produce a non-empty, unique slug (the slug is the nav testTag AND the
|
|
87
|
+
* Kotlin parameter name — collisions or empties cannot compile/automate).
|
|
88
|
+
* @param {Array<{label:string, icon:string}>} tabs
|
|
89
|
+
*/
|
|
90
|
+
export function tabInfos(tabs) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
return tabs.map((tab) => {
|
|
93
|
+
const slug = navSlug(tab.label);
|
|
94
|
+
if (!slug) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`tab label ${JSON.stringify(tab.label)} produces an empty nav slug — labels need at least one [a-z0-9] character`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (seen.has(slug)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`tab labels ${JSON.stringify(seen.get(slug))} and ${JSON.stringify(tab.label)} both slug to "${slug}" — nav testTags must be unique`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
seen.set(slug, tab.label);
|
|
105
|
+
return { label: tab.label, slug, param: kotlinParamName(slug), icon: iconSymbol(tab.icon) };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- generators ----------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render AppTab.kt (template-token form). For the default tabs this reproduces
|
|
113
|
+
* the static template file byte-for-byte.
|
|
114
|
+
* @param {ReturnType<typeof tabInfos>} infos
|
|
115
|
+
*/
|
|
116
|
+
export function renderAppTabsKt(infos) {
|
|
117
|
+
const iconImports = [...new Set(infos.map((t) => t.icon))]
|
|
118
|
+
.sort()
|
|
119
|
+
.map((i) => `import androidx.compose.material.icons.filled.${i}`)
|
|
120
|
+
.join("\n");
|
|
121
|
+
const params = infos.map((t) => ` ${t.param}: @Composable () -> Unit,`).join("\n");
|
|
122
|
+
const entries = infos
|
|
123
|
+
.map((t) => ` AppTab("${kotlinString(t.label)}", Icons.Filled.${t.icon}, ${t.param}),`)
|
|
124
|
+
.join("\n");
|
|
125
|
+
|
|
126
|
+
return `package __PACKAGE__.presentation.navigation
|
|
127
|
+
|
|
128
|
+
import androidx.compose.material.icons.Icons
|
|
129
|
+
${iconImports}
|
|
130
|
+
import androidx.compose.runtime.Composable
|
|
131
|
+
import androidx.compose.ui.graphics.vector.ImageVector
|
|
132
|
+
|
|
133
|
+
// A single bottom-nav tab: its label, icon, and the screen it renders.
|
|
134
|
+
data class AppTab(
|
|
135
|
+
val label: String,
|
|
136
|
+
val icon: ImageVector,
|
|
137
|
+
val content: @Composable () -> Unit,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// The tab list drives AppShell + AppBottomNav generically (no role-hardcoded shells).
|
|
141
|
+
// The scaffolder regenerates this list from the configured \`tabs\`.
|
|
142
|
+
@Composable
|
|
143
|
+
fun appTabs(
|
|
144
|
+
${params}
|
|
145
|
+
): List<AppTab> = listOf(
|
|
146
|
+
${entries}
|
|
147
|
+
)
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Render qa/e2e/smoke.yaml (template-token form): launch, wait for the first
|
|
153
|
+
* tab's content, then for each subsequent tab tap nav_<slug> and assert its
|
|
154
|
+
* <slug>_title + the shell, then return to the first tab. Byte-identical to
|
|
155
|
+
* the static template file for the default tabs.
|
|
156
|
+
* @param {ReturnType<typeof tabInfos>} infos
|
|
157
|
+
*/
|
|
158
|
+
export function renderSmokeYaml(infos) {
|
|
159
|
+
const [first, ...rest] = infos;
|
|
160
|
+
const lines = [];
|
|
161
|
+
lines.push(`# E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02.
|
|
162
|
+
#
|
|
163
|
+
# Proves the real app boots on a device/emulator and the bottom-nav shell works.
|
|
164
|
+
# Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
|
|
165
|
+
# never by display text. Nav-item ids are nav_<label-slug> — the slug rule lives in
|
|
166
|
+
# AppShell.kt's navItemTag (lowercase, non-[a-z0-9] runs collapsed to "_", trimmed);
|
|
167
|
+
# keep these ids in sync with it if the configured tabs change.
|
|
168
|
+
#
|
|
169
|
+
# Run: maestro test qa/e2e/smoke.yaml (device/emulator attached)
|
|
170
|
+
# The verify lane's e2eSmoke step runs this automatically when maestro + a device are present.
|
|
171
|
+
appId: __PACKAGE__
|
|
172
|
+
---
|
|
173
|
+
- launchApp:
|
|
174
|
+
clearState: true
|
|
175
|
+
|
|
176
|
+
# SPEC: SHELL-01 — the app boots and the first tab renders inside the shell, bottom nav visible.
|
|
177
|
+
# Cold-start after clearState can take longer than a bare assert's default window on a slow/CI
|
|
178
|
+
# emulator (first Compose frame under load). Waiting for the first frame IS the SHELL-01 boot
|
|
179
|
+
# proof, so wait explicitly with a generous budget rather than asserting immediately.
|
|
180
|
+
- extendedWaitUntil:
|
|
181
|
+
visible:
|
|
182
|
+
id: "${first.slug}_title"
|
|
183
|
+
timeout: 60000
|
|
184
|
+
- assertVisible:
|
|
185
|
+
id: "app_bottom_nav"`);
|
|
186
|
+
|
|
187
|
+
rest.forEach((tab, i) => {
|
|
188
|
+
lines.push("");
|
|
189
|
+
if (i === 0) lines.push("# SPEC: SHELL-02 — switching tabs keeps the shell");
|
|
190
|
+
lines.push(`- tapOn:
|
|
191
|
+
id: "nav_${tab.slug}"
|
|
192
|
+
- assertVisible:
|
|
193
|
+
id: "${tab.slug}_title"
|
|
194
|
+
- assertVisible:
|
|
195
|
+
id: "app_bottom_nav"`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (rest.length > 0) {
|
|
199
|
+
lines.push("");
|
|
200
|
+
lines.push(`# and back
|
|
201
|
+
- tapOn:
|
|
202
|
+
id: "nav_${first.slug}"
|
|
203
|
+
- assertVisible:
|
|
204
|
+
id: "${first.slug}_title"`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return `${lines.join("\n")}\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Call-site content lambda for one tab in AppNavHost's appTabs(...) call. */
|
|
211
|
+
function navHostTabArg(tab) {
|
|
212
|
+
if (tab.slug === "home") {
|
|
213
|
+
return ` ${tab.param} = {
|
|
214
|
+
HomeScreen(
|
|
215
|
+
onItemClick = { itemId -> navController.navigate(Routes.detail(itemId)) },
|
|
216
|
+
)
|
|
217
|
+
},`;
|
|
218
|
+
}
|
|
219
|
+
if (tab.slug === "profile") {
|
|
220
|
+
return ` ${tab.param} = { ProfileScreen() },`;
|
|
221
|
+
}
|
|
222
|
+
return ` ${tab.param} = { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") },`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The exact template blocks AppNavHost rewriting anchors on. If the template
|
|
226
|
+
// drifts, rewriteNavHost throws instead of silently shipping stale wiring.
|
|
227
|
+
const NAVHOST_IMPORTS_ANCHOR = `import __PACKAGE__.presentation.home.HomeScreen
|
|
228
|
+
import __PACKAGE__.presentation.profile.ProfileScreen`;
|
|
229
|
+
const NAVHOST_TABS_OPEN = " val tabs = appTabs(\n";
|
|
230
|
+
const NAVHOST_TABS_CLOSE = "\n )\n";
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rewrite AppNavHost.kt content: the screen imports and the appTabs(...) call
|
|
234
|
+
* site, one argument per configured tab. Home/Profile slugs wire the shipped
|
|
235
|
+
* feature screens; anything else wires a PlaceholderScreen stub.
|
|
236
|
+
* @param {string} content template-token AppNavHost.kt content
|
|
237
|
+
* @param {ReturnType<typeof tabInfos>} infos
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
export function rewriteNavHost(content, infos) {
|
|
241
|
+
if (!content.includes(NAVHOST_IMPORTS_ANCHOR)) {
|
|
242
|
+
throw new Error("AppNavHost.kt: screen-imports block not found — template drifted from the tab rewriter");
|
|
243
|
+
}
|
|
244
|
+
const start = content.indexOf(NAVHOST_TABS_OPEN);
|
|
245
|
+
const end = start === -1 ? -1 : content.indexOf(NAVHOST_TABS_CLOSE, start);
|
|
246
|
+
if (start === -1 || end === -1) {
|
|
247
|
+
throw new Error("AppNavHost.kt: `val tabs = appTabs(` block not found — template drifted from the tab rewriter");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const imports = [];
|
|
251
|
+
if (infos.some((t) => t.slug !== "home" && t.slug !== "profile")) {
|
|
252
|
+
imports.push("import __PACKAGE__.presentation.components.PlaceholderScreen");
|
|
253
|
+
}
|
|
254
|
+
if (infos.some((t) => t.slug === "home")) {
|
|
255
|
+
imports.push("import __PACKAGE__.presentation.home.HomeScreen");
|
|
256
|
+
}
|
|
257
|
+
if (infos.some((t) => t.slug === "profile")) {
|
|
258
|
+
imports.push("import __PACKAGE__.presentation.profile.ProfileScreen");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tabsBlock =
|
|
262
|
+
NAVHOST_TABS_OPEN + infos.map(navHostTabArg).join("\n") + NAVHOST_TABS_CLOSE;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
content.slice(0, start) + tabsBlock + content.slice(end + NAVHOST_TABS_CLOSE.length)
|
|
266
|
+
).replace(NAVHOST_IMPORTS_ANCHOR, () => imports.join("\n"));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Render the PlaceholderScreen stub (template-token form) — written only when
|
|
271
|
+
* a configured tab has no shipped feature screen, so the default scaffold's
|
|
272
|
+
* output is unchanged. Mirrors ProfileScreen's stub shape; the title testTag
|
|
273
|
+
* is what qa/e2e/smoke.yaml asserts for the tab.
|
|
274
|
+
*/
|
|
275
|
+
export function renderPlaceholderScreenKt() {
|
|
276
|
+
return `package __PACKAGE__.presentation.components
|
|
277
|
+
|
|
278
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
279
|
+
import androidx.compose.foundation.layout.Column
|
|
280
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
281
|
+
import androidx.compose.foundation.layout.padding
|
|
282
|
+
import androidx.compose.material3.MaterialTheme
|
|
283
|
+
import androidx.compose.material3.Text
|
|
284
|
+
import androidx.compose.runtime.Composable
|
|
285
|
+
import androidx.compose.ui.Alignment
|
|
286
|
+
import androidx.compose.ui.Modifier
|
|
287
|
+
import androidx.compose.ui.semantics.semantics
|
|
288
|
+
import androidx.compose.ui.semantics.testTag
|
|
289
|
+
import androidx.compose.ui.unit.dp
|
|
290
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
291
|
+
|
|
292
|
+
// Generated stub for a configured bottom-nav tab that has no feature yet.
|
|
293
|
+
// Build the real feature with the add-feature skill (qa/scaffold-feature.mjs),
|
|
294
|
+
// then swap this out in AppNavHost. The title testTag (\`<slug>_title\`) is what
|
|
295
|
+
// qa/e2e/smoke.yaml asserts for this tab — keep it when you replace the stub.
|
|
296
|
+
@Composable
|
|
297
|
+
fun PlaceholderScreen(title: String, titleTag: String) {
|
|
298
|
+
Column(
|
|
299
|
+
modifier = Modifier
|
|
300
|
+
.fillMaxSize()
|
|
301
|
+
.padding(__THEME_PREFIX__Tokens.PaddingPage),
|
|
302
|
+
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
303
|
+
horizontalAlignment = Alignment.Start,
|
|
304
|
+
) {
|
|
305
|
+
Text(
|
|
306
|
+
text = title,
|
|
307
|
+
style = MaterialTheme.typography.headlineMedium,
|
|
308
|
+
modifier = Modifier.semantics { testTag = titleTag },
|
|
309
|
+
)
|
|
310
|
+
Text(
|
|
311
|
+
text = "This is a generated stub tab. Wire it up like the Home feature.",
|
|
312
|
+
style = MaterialTheme.typography.bodyMedium,
|
|
313
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- pipeline entry --------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
const APPTAB_REL =
|
|
323
|
+
"composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppTab.kt";
|
|
324
|
+
const NAVHOST_REL =
|
|
325
|
+
"composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt";
|
|
326
|
+
const PLACEHOLDER_REL =
|
|
327
|
+
"composeApp/src/commonMain/kotlin/com/example/app/presentation/components/PlaceholderScreen.kt";
|
|
328
|
+
const SMOKE_REL = "qa/e2e/smoke.yaml";
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Rewrite every tab-driven surface present in the copied project dir. Runs
|
|
332
|
+
* BEFORE token replacement and the package rename (files are addressed at
|
|
333
|
+
* their literal com/example/app template paths and written in token form).
|
|
334
|
+
* Surfaces missing from the tree (feature-stripped qa/e2e, synthetic test
|
|
335
|
+
* templates) are skipped; a PRESENT AppNavHost that no longer contains the
|
|
336
|
+
* expected blocks throws.
|
|
337
|
+
* @param {string} projectDir
|
|
338
|
+
* @param {Array<{label:string, icon:string}>} tabs
|
|
339
|
+
* @param {(msg:string)=>void} [log]
|
|
340
|
+
*/
|
|
341
|
+
export function rewriteTabSurfaces(projectDir, tabs, log = () => {}) {
|
|
342
|
+
const infos = tabInfos(tabs);
|
|
343
|
+
|
|
344
|
+
const appTabPath = path.join(projectDir, APPTAB_REL);
|
|
345
|
+
if (fs.existsSync(appTabPath)) {
|
|
346
|
+
fs.writeFileSync(appTabPath, renderAppTabsKt(infos));
|
|
347
|
+
log(` tabs → ${APPTAB_REL}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const navHostPath = path.join(projectDir, NAVHOST_REL);
|
|
351
|
+
if (fs.existsSync(navHostPath)) {
|
|
352
|
+
const rewritten = rewriteNavHost(fs.readFileSync(navHostPath, "utf8"), infos);
|
|
353
|
+
fs.writeFileSync(navHostPath, rewritten);
|
|
354
|
+
log(` tabs → ${NAVHOST_REL}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (infos.some((t) => t.slug !== "home" && t.slug !== "profile")) {
|
|
358
|
+
const placeholderPath = path.join(projectDir, PLACEHOLDER_REL);
|
|
359
|
+
if (fs.existsSync(path.dirname(placeholderPath))) {
|
|
360
|
+
fs.writeFileSync(placeholderPath, renderPlaceholderScreenKt());
|
|
361
|
+
log(` tabs → ${PLACEHOLDER_REL}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const smokePath = path.join(projectDir, SMOKE_REL);
|
|
366
|
+
if (fs.existsSync(smokePath)) {
|
|
367
|
+
fs.writeFileSync(smokePath, renderSmokeYaml(infos));
|
|
368
|
+
log(` tabs → ${SMOKE_REL}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// (b) copy template/ → targetDir
|
|
4
4
|
// (b.2) delete disabled-feature paths (MUST precede the package rename —
|
|
5
5
|
// feature paths are declared against the literal com/example/app)
|
|
6
|
+
// (b.3) regenerate tab-driven surfaces (AppTab.kt, AppNavHost wiring,
|
|
7
|
+
// qa/e2e/smoke.yaml) from config.tabs, in template-token form
|
|
6
8
|
// (c) token-replace contents AND paths
|
|
7
9
|
// (d) rename package source dirs com/example/app → __PACKAGE_PATH__ (atomic)
|
|
8
10
|
// (e) toggle features (strip cmp:feature marker blocks)
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
disabledFeaturesFromConfig,
|
|
24
26
|
deleteDisabledFeaturePaths,
|
|
25
27
|
} from "./lib/toggle.mjs";
|
|
28
|
+
import { rewriteTabSurfaces } from "./lib/tabs.mjs";
|
|
26
29
|
import { copyDir, listFiles, listDirsDeepestFirst } from "./lib/fsutil.mjs";
|
|
27
30
|
import { runVerify, printVerifyVerdict } from "./lib/verify.mjs";
|
|
28
31
|
import { colors, step, ok, warn } from "./lib/log.mjs";
|
|
@@ -181,9 +184,8 @@ function writeLocalProperties(projectDir) {
|
|
|
181
184
|
|
|
182
185
|
/**
|
|
183
186
|
* Replace the (already token-substituted) app display name with a slugified,
|
|
184
|
-
* identifier-safe form in the
|
|
185
|
-
* rootProject.name
|
|
186
|
-
* the name is already slug-safe.
|
|
187
|
+
* identifier-safe form in the one file that demands it: Gradle's
|
|
188
|
+
* rootProject.name. Idempotent and a no-op when the name is already slug-safe.
|
|
187
189
|
* @param {string} projectDir
|
|
188
190
|
* @param {string} appName raw display name (may contain spaces)
|
|
189
191
|
*/
|
|
@@ -201,20 +203,6 @@ function applyAppNameSlug(projectDir, appName) {
|
|
|
201
203
|
);
|
|
202
204
|
if (out !== src) fs.writeFileSync(settings, out);
|
|
203
205
|
}
|
|
204
|
-
|
|
205
|
-
// qa/appium/package.json: "name": "Demo App-appium" -> "demo-app-appium"
|
|
206
|
-
const pkg = path.join(projectDir, "qa", "appium", "package.json");
|
|
207
|
-
if (fs.existsSync(pkg)) {
|
|
208
|
-
try {
|
|
209
|
-
const json = JSON.parse(fs.readFileSync(pkg, "utf8"));
|
|
210
|
-
if (typeof json.name === "string" && json.name.includes(appName)) {
|
|
211
|
-
json.name = json.name.split(appName).join(slug).toLowerCase();
|
|
212
|
-
fs.writeFileSync(pkg, `${JSON.stringify(json, null, 2)}\n`);
|
|
213
|
-
}
|
|
214
|
-
} catch {
|
|
215
|
-
// leave as-is on parse failure
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
206
|
}
|
|
219
207
|
|
|
220
208
|
/**
|
|
@@ -276,6 +264,14 @@ export async function scaffold(config, opts = {}) {
|
|
|
276
264
|
);
|
|
277
265
|
}
|
|
278
266
|
|
|
267
|
+
// (b.3) Regenerate the tab-driven surfaces from config.tabs BEFORE token
|
|
268
|
+
// replacement and the package rename: the rewriter addresses files at their
|
|
269
|
+
// literal com/example/app template paths and writes template-token contents,
|
|
270
|
+
// so the pipeline below stamps them like any other template file. For the
|
|
271
|
+
// default tabs this reproduces the static template files byte-for-byte.
|
|
272
|
+
step("Regenerating tab surfaces from configured tabs…");
|
|
273
|
+
rewriteTabSurfaces(projectDir, config.tabs, (m) => process.stdout.write(`${m}\n`));
|
|
274
|
+
|
|
279
275
|
const tokenMap = buildTokenMap(config);
|
|
280
276
|
|
|
281
277
|
// (c) token-replace contents AND paths
|
package/template/README.md
CHANGED
|
@@ -101,3 +101,7 @@ remember to run it.
|
|
|
101
101
|
locally. Note that CI independently enforces the same "receipt attests HEAD" check on every push
|
|
102
102
|
(see `.github/workflows/verify.yml`), so disabling the local hook only trades an immediate local
|
|
103
103
|
signal for a later one in CI.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
Built with [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) — the AI delivery harness for Compose Multiplatform.
|
|
@@ -262,3 +262,16 @@ compose.desktop {
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
// <<< cmp:feature dev-client
|
|
265
|
+
|
|
266
|
+
// Evidence integrity: golden-tree baselines (qa/golden) and the UPDATE_GOLDEN capture flag are
|
|
267
|
+
// REAL inputs of the JVM test tier, but Gradle can't see either on its own — baselines are read
|
|
268
|
+
// at runtime, not compiled, and env vars aren't tracked. Undeclared, the build cache will happily
|
|
269
|
+
// replay a PASS from a tree whose baselines differed (or serve an UPDATE_GOLDEN capture run from
|
|
270
|
+
// cache so it never writes the baseline at all). Declaring them makes caching honest; the verify
|
|
271
|
+
// lane additionally forces `--rerun` so evidence receipts always attest actual execution.
|
|
272
|
+
tasks.withType<Test>().configureEach {
|
|
273
|
+
inputs.files(fileTree(rootProject.layout.projectDirectory.dir("qa/golden")) { include("*.json") })
|
|
274
|
+
.withPropertyName("goldenBaselines")
|
|
275
|
+
.withPathSensitivity(PathSensitivity.RELATIVE)
|
|
276
|
+
inputs.property("updateGolden", System.getenv("UPDATE_GOLDEN") ?: "")
|
|
277
|
+
}
|
|
@@ -114,6 +114,10 @@ private fun AppBottomNav(
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** Deterministic automation tag for a nav item: `nav_` + the label lowercased with every non-[a-z0-9] run collapsed to `_` and trimmed (e.g. "My Stuff!" → `nav_my_stuff`). Must mirror `navSlug` in create-cmp's engine (src/lib/tabs.mjs), which generates `qa/e2e/smoke.yaml`'s id selectors from the configured tabs — keep the two in sync. */
|
|
118
|
+
private fun navItemTag(label: String): String =
|
|
119
|
+
"nav_" + label.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
|
|
120
|
+
|
|
117
121
|
@Composable
|
|
118
122
|
private fun NavItem(
|
|
119
123
|
label: String,
|
|
@@ -128,6 +132,8 @@ private fun NavItem(
|
|
|
128
132
|
// a11y: guarantee the 48dp minimum touch target regardless of label width
|
|
129
133
|
// (the inspector's audit_a11y flags anything smaller).
|
|
130
134
|
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
|
135
|
+
// Durable selection handle (tests/E2E select by testTag, never display text).
|
|
136
|
+
.semantics { testTag = navItemTag(label) }
|
|
131
137
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
132
138
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
133
139
|
verticalArrangement = Arrangement.Center,
|
|
@@ -11,7 +11,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
|
|
|
11
11
|
import androidx.compose.ui.test.assertIsDisplayed
|
|
12
12
|
import androidx.compose.ui.test.hasTestTag
|
|
13
13
|
import androidx.compose.ui.test.onNodeWithTag
|
|
14
|
-
import androidx.compose.ui.test.onNodeWithText
|
|
15
14
|
import androidx.compose.ui.test.performClick
|
|
16
15
|
import androidx.compose.ui.test.runComposeUiTest
|
|
17
16
|
import __PACKAGE__.testing.awaitNode
|
|
@@ -66,12 +65,14 @@ class AppShellTest {
|
|
|
66
65
|
|
|
67
66
|
awaitNode(hasTestTag("tab_one_content"))
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
// Nav items carry a derived nav_<label-slug> tag (AppShell's navItemTag) —
|
|
69
|
+
// select by it, never by display text.
|
|
70
|
+
onNodeWithTag("nav_tab_two").performClick()
|
|
70
71
|
awaitNode(hasTestTag("tab_two_content"))
|
|
71
72
|
onNodeWithTag("tab_two_content").assertIsDisplayed()
|
|
72
73
|
onNodeWithTag("app_bottom_nav").assertIsDisplayed()
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
onNodeWithTag("nav_tab_one").performClick()
|
|
75
76
|
awaitNode(hasTestTag("tab_one_content"))
|
|
76
77
|
onNodeWithTag("tab_one_content").assertIsDisplayed()
|
|
77
78
|
onNodeWithTag("app_bottom_nav").assertIsDisplayed()
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
#
|
|
3
3
|
# Proves the real app boots on a device/emulator and the bottom-nav shell works.
|
|
4
4
|
# Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
|
|
5
|
-
#
|
|
5
|
+
# never by display text. Nav-item ids are nav_<label-slug> — the slug rule lives in
|
|
6
|
+
# AppShell.kt's navItemTag (lowercase, non-[a-z0-9] runs collapsed to "_", trimmed);
|
|
7
|
+
# keep these ids in sync with it if the configured tabs change.
|
|
6
8
|
#
|
|
7
9
|
# Run: maestro test qa/e2e/smoke.yaml (device/emulator attached)
|
|
8
10
|
# The verify lane's e2eSmoke step runs this automatically when maestro + a device are present.
|
|
@@ -23,13 +25,15 @@ appId: __PACKAGE__
|
|
|
23
25
|
id: "app_bottom_nav"
|
|
24
26
|
|
|
25
27
|
# SPEC: SHELL-02 — switching tabs keeps the shell
|
|
26
|
-
- tapOn:
|
|
28
|
+
- tapOn:
|
|
29
|
+
id: "nav_profile"
|
|
27
30
|
- assertVisible:
|
|
28
31
|
id: "profile_title"
|
|
29
32
|
- assertVisible:
|
|
30
33
|
id: "app_bottom_nav"
|
|
31
34
|
|
|
32
35
|
# and back
|
|
33
|
-
- tapOn:
|
|
36
|
+
- tapOn:
|
|
37
|
+
id: "nav_home"
|
|
34
38
|
- assertVisible:
|
|
35
39
|
id: "home_title"
|
|
@@ -27,7 +27,14 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
|
|
28
28
|
const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
29
|
const REPO_ROOT = path.resolve(TEMPLATE_ROOT, "..");
|
|
30
|
-
|
|
30
|
+
// Scaffolding the throwaway app: inside the create-cmp dev tree the engine sits at
|
|
31
|
+
// ../bin relative to the template — use it directly (fast, offline, tests the local
|
|
32
|
+
// code). In a real generated repo that path doesn't exist, so fall back to the
|
|
33
|
+
// published CLI via npx (needs network on first run).
|
|
34
|
+
const LOCAL_ENGINE = path.join(REPO_ROOT, "bin", "create-cmp.mjs");
|
|
35
|
+
const SCAFFOLD_CMD = fs.existsSync(LOCAL_ENGINE)
|
|
36
|
+
? `node "${LOCAL_ENGINE}"`
|
|
37
|
+
: "npx --yes create-cmp-cli@latest";
|
|
31
38
|
const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
|
32
39
|
|
|
33
40
|
function log(msg) {
|
|
@@ -57,7 +64,7 @@ function scaffold() {
|
|
|
57
64
|
log(`Scaffolding a throwaway app at ${projectDir} (never touching ${TEMPLATE_ROOT})…`);
|
|
58
65
|
|
|
59
66
|
const res = sh(
|
|
60
|
-
|
|
67
|
+
`${SCAFFOLD_CMD} "${projectDir}" --name RefusalDemo --package com.example.refusaldemo --no-ios --yes --no-verify`,
|
|
61
68
|
REPO_ROOT,
|
|
62
69
|
{ timeout: 5 * 60_000 },
|
|
63
70
|
);
|
package/template/qa/verify.mjs
CHANGED
|
@@ -187,11 +187,11 @@ function stepBuild() {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
// Runs a filtered slice of the JVM test tier and names the verdict after the gate it proves.
|
|
190
|
-
// The full suite already ran in unitTests;
|
|
191
|
-
//
|
|
190
|
+
// The full suite already ran in unitTests; the filtered slices stay cheap (compilation is
|
|
191
|
+
// cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
|
|
192
192
|
function gradleTestStep(name, testsFilter, failHint) {
|
|
193
193
|
return () => {
|
|
194
|
-
const res = sh(`${GRADLEW} :composeApp:desktopTest --tests "${testsFilter}" --console=plain`);
|
|
194
|
+
const res = sh(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
|
|
195
195
|
return {
|
|
196
196
|
name,
|
|
197
197
|
verdict: res.ok ? "PASS" : "FAIL",
|
|
@@ -204,7 +204,12 @@ function gradleTestStep(name, testsFilter, failHint) {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
function stepUnitTests() {
|
|
207
|
-
|
|
207
|
+
// `--rerun` is EVIDENCE INTEGRITY, not pedantry: without it, Gradle's build cache can
|
|
208
|
+
// restore a PASS recorded against a *different* tree state (deterministic re-scaffolds
|
|
209
|
+
// produce byte-identical sources, and golden baselines aren't compile inputs), so the
|
|
210
|
+
// receipt would attest tests that never executed. Compilation stays cached — only the
|
|
211
|
+
// test execution is forced.
|
|
212
|
+
const res = sh(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
|
|
208
213
|
const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
|
|
209
214
|
return {
|
|
210
215
|
name: "unitTests",
|