create-cmp-cli 0.2.0 → 0.3.1
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 +65 -27
- package/bin/create-cmp.mjs +2 -1
- package/options.schema.json +5 -2
- package/package.json +2 -2
- package/src/commands/create.mjs +26 -3
- package/src/lib/toggle.mjs +2 -2
- package/template/.claude/settings.json +12 -0
- package/template/.claude/skills/add-feature/SKILL.md +131 -0
- package/template/.claude/skills/add-repository/SKILL.md +106 -0
- package/template/.claude/skills/add-screen/SKILL.md +130 -0
- package/template/.github/workflows/verify.yml +30 -8
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/template/.gradle/vcs-1/gc.properties +0 -0
- package/template/CHANGELOG.md +14 -0
- package/template/CLAUDE.md +70 -0
- package/template/CONTRIBUTING.md +39 -0
- package/template/README.md +103 -0
- package/template/composeApp/build.gradle.kts +15 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
- package/template/docs/ARCHITECTURE.md +51 -0
- package/template/docs/TESTING.md +64 -0
- package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
- package/template/docs/adr/template.md +17 -0
- package/template/gitignore +8 -1
- package/template/gradle/libs.versions.toml +2 -0
- package/template/manifest.json +11 -8
- package/template/qa/e2e/README.md +29 -0
- package/template/qa/e2e/smoke.yaml +35 -0
- package/template/qa/evidence/schema.json +56 -0
- package/template/qa/golden/home.json +15 -0
- package/template/qa/lib/inputs-hash.mjs +112 -0
- package/template/qa/lib/token-drift.mjs +94 -0
- package/template/qa/receipt-check.mjs +113 -0
- package/template/qa/refusal-demo.mjs +491 -0
- package/template/qa/scaffold-feature.mjs +488 -0
- package/template/qa/verify.mjs +467 -0
- package/template/specs/README.md +33 -0
- package/template/specs/app-base.spec.md +30 -0
- package/template/specs/home.spec.md +18 -0
- package/template/qa/appium/README.md +0 -23
- package/template/qa/appium/lib/appium-client.mjs +0 -225
- package/template/qa/appium/package.json +0 -8
- package/template/qa/appium/run-android-smoke.mjs +0 -39
- package/template/tests/appium/cmp/conftest.py +0 -96
- package/template/tests/appium/cmp/test_smoke.py +0 -17
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-screen
|
|
3
|
+
description: >-
|
|
4
|
+
Add a presentation-only slice — Screen + ViewModel + tests + golden tree + spec + nav route —
|
|
5
|
+
for an EXISTING entity/repository in this Compose Multiplatform app, cloned deterministically
|
|
6
|
+
from the `home` exemplar's presentation layer. Requires the entity's data layer (model,
|
|
7
|
+
repository, use case, fake) to already exist. Use this when the user wants to "add a screen",
|
|
8
|
+
"add a screen for an existing entity", "add a screen backed by a repository I already have",
|
|
9
|
+
"put a UI on this data", or names an existing domain noun they want a screen for (e.g. "add a
|
|
10
|
+
screen for Tag", "I need a Bookmarks screen — the repository's already there"). Works with NO
|
|
11
|
+
create-cmp plugin installed — the stamper (`qa/scaffold-feature.mjs --preset screen`) and this
|
|
12
|
+
skill both ship inside the generated project.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# add-screen — stamp a presentation slice over an existing entity
|
|
16
|
+
|
|
17
|
+
> Deterministic-stamp, gate-proven. The script (`qa/scaffold-feature.mjs --preset screen`) does
|
|
18
|
+
> the mechanical work — copy the `home` exemplar's presentation files, whole-word identifier
|
|
19
|
+
> rename, DI injection for the ViewModel only, nav route + import. You (the AI) only refine spec
|
|
20
|
+
> wording and adapt the screen to the entity's real shape. You are not done until
|
|
21
|
+
> `node qa/verify.mjs` PASSes and the receipt is committed — see this project's `CLAUDE.md`.
|
|
22
|
+
|
|
23
|
+
This is the `screen` subset of `add-feature` — same stamper, same rename mechanic, filtered to
|
|
24
|
+
the 6 presentation+tests+spec files, **composed on top of an entity that already exists** (via
|
|
25
|
+
`add-repository` or a prior `add-feature` run). If the entity doesn't exist yet, this will refuse
|
|
26
|
+
to stamp anything — see step 2.
|
|
27
|
+
|
|
28
|
+
## Why a stamper and not hand-written files
|
|
29
|
+
|
|
30
|
+
Every hand-written file is a drift chance in this project's architecture. `qa/scaffold-feature.mjs
|
|
31
|
+
--preset screen` produces a **conforming skeleton by construction**: a Screen composable with a
|
|
32
|
+
testTagged root, a ViewModel (+ test), a Compose UI test, a golden-tree test, and a six-clause
|
|
33
|
+
spec — wired into Koin and the nav graph. Your job is to make it *behave* like the real screen,
|
|
34
|
+
not to make it *structurally correct*.
|
|
35
|
+
|
|
36
|
+
## The flow
|
|
37
|
+
|
|
38
|
+
### 1. Name the feature and confirm the entity
|
|
39
|
+
|
|
40
|
+
Ask the human for the feature/screen name (PascalCase, plural-ish noun — e.g. `Tags`,
|
|
41
|
+
`Bookmarks`) and the entity it's backed by (e.g. `Tag`, `Bookmark`). If they don't give an
|
|
42
|
+
entity, the script defaults it by stripping a trailing `s`/`ies` from the feature name — naive
|
|
43
|
+
de-pluralization is unreliable for irregular nouns, so **always show the proposed entity and let
|
|
44
|
+
the human confirm or override it** before proceeding (`--entity <EntityName>`).
|
|
45
|
+
|
|
46
|
+
The entity must already exist — its use case, fake, and model must be present
|
|
47
|
+
(`domain/usecase/Get<Entity>sUseCase.kt`, `testing/fakes/Fake<Entity>Repository.kt`,
|
|
48
|
+
`domain/model/<Entity>.kt`). If it doesn't, the stamper will refuse before writing anything and
|
|
49
|
+
tell you to run `add-repository` first (or use plain `add-feature` instead, which generates both
|
|
50
|
+
layers together).
|
|
51
|
+
|
|
52
|
+
### 2. Dry-run
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName> --preset screen --dry-run
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Show the human the file plan (Screen, ViewModel, ViewModel test, Compose UI test, golden-tree
|
|
59
|
+
test, spec) and the injection diffs (ViewModel DI binding, nav route + import — no repository or
|
|
60
|
+
use case wiring, since those already exist). Confirm before stamping for real — this is the last
|
|
61
|
+
chance to catch a wrong entity name.
|
|
62
|
+
|
|
63
|
+
If the entity doesn't exist, this step (and the real stamp) exits non-zero with an actionable
|
|
64
|
+
message rather than half-stamping a screen that won't compile.
|
|
65
|
+
|
|
66
|
+
### 3. Stamp
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
node qa/scaffold-feature.mjs <FeatureName> --entity <EntityName> --preset screen
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This writes the Screen/ViewModel/tests/golden-tree-test, wires the ViewModel into
|
|
73
|
+
`di/AppModule.kt` and a route into `presentation/navigation/Screen.kt` +
|
|
74
|
+
`presentation/navigation/AppNavHost.kt` at their `// cmp:anchor` markers, and writes
|
|
75
|
+
`specs/<feature>.spec.md` with a default six-clause set (`<FEATURE>-01..06`) cited by the three
|
|
76
|
+
generated test files.
|
|
77
|
+
|
|
78
|
+
If it exits non-zero, read the message — it is actionable. Do not hand-edit around a stamper
|
|
79
|
+
failure.
|
|
80
|
+
|
|
81
|
+
### 4. Refine the spec, then the behavior
|
|
82
|
+
|
|
83
|
+
Rewrite the clause prose in `specs/<feature>.spec.md` for the entity's real behavior — the six
|
|
84
|
+
clause **ids stay fixed** (specCoverage binds tests to ids, not prose). Propose the rewritten
|
|
85
|
+
clauses to the human; get them confirmed before moving on.
|
|
86
|
+
|
|
87
|
+
Then adapt the generated code to match the entity's actual shape (which may differ from the
|
|
88
|
+
`home` exemplar's `{id, title, subtitle}` list): update the screen's rendering in
|
|
89
|
+
`presentation/<feature>/<Feature>Screen.kt` and the copied tests
|
|
90
|
+
(`<Feature>ViewModelTest.kt`, `<Feature>ScreenTest.kt`) together, consistent with whatever
|
|
91
|
+
`<Entity>.kt` already looks like. The gate (step 6) will name exactly what you missed.
|
|
92
|
+
|
|
93
|
+
### 5. Capture the golden tree
|
|
94
|
+
|
|
95
|
+
The golden baseline is **not** copied (a copied one would silently mismatch the adapted screen).
|
|
96
|
+
Generate it fresh once the screen renders the real behavior:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
UPDATE_GOLDEN=1 ./gradlew :composeApp:desktopTest --tests "*<Feature>GoldenTree*"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Review `qa/golden/<feature>.json` briefly — it should reflect the structure you intended. Commit
|
|
103
|
+
it alongside the feature.
|
|
104
|
+
|
|
105
|
+
### 6. Gate
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
node qa/verify.mjs
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This must PASS. It proves: the spec's six clauses are all bound to a citing test
|
|
112
|
+
(specCoverage — `<FEATURE>-01..06` newly bound), the build compiles, unit tests pass, architecture
|
|
113
|
+
conformance holds, the golden tree matches, and accessibility holds. **Not done until this is
|
|
114
|
+
PASS and the evidence receipt (`qa/evidence/latest.json`) is committed with your change** — this
|
|
115
|
+
project's standing definition of done (see `CLAUDE.md`).
|
|
116
|
+
|
|
117
|
+
If it fails: read the failing step's reason, fix the actual behavior or spec/test binding, and
|
|
118
|
+
re-run. Do not delete or weaken a test to reach green.
|
|
119
|
+
|
|
120
|
+
## Guardrails
|
|
121
|
+
|
|
122
|
+
- Works identically with or without the create-cmp Claude Code plugin installed — everything it
|
|
123
|
+
needs (`qa/scaffold-feature.mjs`, this file) ships inside the generated project.
|
|
124
|
+
- Respect whichever toggles this project was stamped with (e.g. if Room or Appium/Maestro were
|
|
125
|
+
disabled at scaffold time, don't reintroduce them for the new screen).
|
|
126
|
+
- `add-screen` generates a pushed-nav-route screen, not a bottom-nav tab, and does not generate a
|
|
127
|
+
"tap → detail" destination — same MVP scope as `add-feature`.
|
|
128
|
+
- If the entity doesn't exist yet, don't try to work around the stamper's refusal by hand-writing
|
|
129
|
+
the missing use case/fake/model — run `add-repository` first (or use plain `add-feature` for
|
|
130
|
+
both layers together), then retry.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# CI for your Compose Multiplatform app — stamped in by create-cmp.
|
|
2
2
|
#
|
|
3
|
-
# What runs on every push/PR: a toolchain report (advisory) and the
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# What runs on every push/PR: a toolchain report (advisory) and the VERIFY LANE
|
|
4
|
+
# (qa/verify.mjs) — build + unit tests + every other gate this project carries,
|
|
5
|
+
# producing the evidence receipt. Green here = your frozen version set still
|
|
6
|
+
# builds AND the harness's checks pass.
|
|
6
7
|
#
|
|
7
8
|
# iOS: a ready-to-enable macOS job is included (commented out) at the bottom.
|
|
8
9
|
# macOS runners cost ~10x Linux minutes, so it's opt-in.
|
|
@@ -35,11 +36,32 @@ jobs:
|
|
|
35
36
|
continue-on-error: true
|
|
36
37
|
run: npx --yes create-cmp-cli@latest doctor --yes --no-install --no-ios
|
|
37
38
|
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
# Receipt attests HEAD: the committed evidence receipt (qa/evidence/latest.json)
|
|
40
|
+
# must validly attest the checked-out tree — verdict PASS and the inputs-hash
|
|
41
|
+
# still matching the verified surface — BEFORE we spend a runner re-running the
|
|
42
|
+
# lane. Robust to rebase/merge/squash: validity is a content hash of the inputs,
|
|
43
|
+
# not a parent SHA (docs/adr/0005). This is the same predicate the local Stop
|
|
44
|
+
# hook runs (qa/receipt-check.mjs) — one check, two locations — so a stale or
|
|
45
|
+
# hand-edited receipt fails here even if the author bypassed the local hook.
|
|
46
|
+
- name: Receipt attests HEAD
|
|
47
|
+
run: node qa/receipt-check.mjs
|
|
48
|
+
|
|
49
|
+
# The verify lane: build + unit tests + all shipped gates, with a typed
|
|
50
|
+
# verdict and an evidence receipt. If the build step fails after a
|
|
51
|
+
# dependency bump, remember: Kotlin / KSP / Compose / Room / AGP move as
|
|
52
|
+
# ONE set (see the comments in gradle/libs.versions.toml).
|
|
53
|
+
- name: Verify lane
|
|
54
|
+
run: node qa/verify.mjs --profile ci
|
|
55
|
+
|
|
56
|
+
# The receipt this run produced, kept as a build artifact. The receipt
|
|
57
|
+
# committed by the author (qa/evidence/latest.json) lives in the repo —
|
|
58
|
+
# this artifact is CI's independent re-verification of the same change.
|
|
59
|
+
- name: Upload evidence receipt
|
|
60
|
+
if: always()
|
|
61
|
+
uses: actions/upload-artifact@v4
|
|
62
|
+
with:
|
|
63
|
+
name: verify-evidence
|
|
64
|
+
path: qa/evidence/latest.json
|
|
43
65
|
|
|
44
66
|
# ── iOS (opt-in) ──────────────────────────────────────────────────────────
|
|
45
67
|
# Uncomment to build the iOS app on every push to main. Uses the exact
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to __APP_NAME__. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
4
|
+
versioning: [SemVer](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] — scaffold
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial scaffold generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp):
|
|
12
|
+
Compose Multiplatform app with Clean Architecture, the `home` exemplar feature (with tests),
|
|
13
|
+
theme token catalog, and the verification harness (`qa/verify.mjs` + evidence receipts +
|
|
14
|
+
[`CLAUDE.md`](./CLAUDE.md) contract).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# __APP_NAME__ — AI delivery contract
|
|
2
|
+
|
|
3
|
+
This project was generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a
|
|
4
|
+
verification harness. Any AI session working in this repo follows this contract.
|
|
5
|
+
|
|
6
|
+
## Definition of done
|
|
7
|
+
|
|
8
|
+
You are **not done until `node qa/verify.mjs` reports PASS** and the receipt it writes
|
|
9
|
+
(`qa/evidence/latest.json`) is included in your commit. Claiming completion without a PASS
|
|
10
|
+
receipt is a failure. SKIPped steps are recorded in the receipt — never present green-with-gaps
|
|
11
|
+
as fully verified.
|
|
12
|
+
|
|
13
|
+
## Specifications — behavior starts here
|
|
14
|
+
|
|
15
|
+
**New behavior begins as a spec clause** in `specs/<feature>.spec.md` (Given/When/Then with a
|
|
16
|
+
stable id — see [`specs/README.md`](./specs/README.md)). Propose the clause, get it confirmed,
|
|
17
|
+
then implement. Durable tests cite their clause (`// SPEC: HOME-02`).
|
|
18
|
+
[`specs/app-base.spec.md`](./specs/app-base.spec.md) states the architecture and shell
|
|
19
|
+
invariants the conformance gates enforce.
|
|
20
|
+
|
|
21
|
+
## Architecture (violations will be named by the conformance gates)
|
|
22
|
+
|
|
23
|
+
- **Layers:** `presentation` → `domain` ← `data`. `domain` imports nothing app-internal;
|
|
24
|
+
`presentation` never imports `data`. Koin wires implementations in `di/`.
|
|
25
|
+
- **Every screen:** a `*Screen` composable with a `testTag`ged root, a ViewModel with a test.
|
|
26
|
+
- **Design values** (colors / spacing / typography / radii) come from the theme's token catalog
|
|
27
|
+
(`presentation/theme/`). Never hardcode literals in screens.
|
|
28
|
+
- The `home` feature is the **exemplar** — it shows the full pattern through every layer,
|
|
29
|
+
including the tests. When you add a feature, mirror it exactly: Screen → ViewModel (+ test) →
|
|
30
|
+
UseCase (+ test) → Repository interface in `domain` + impl in `data` (+ test) → DI module
|
|
31
|
+
entry → navigation route.
|
|
32
|
+
|
|
33
|
+
## Testing (the pyramid this project uses)
|
|
34
|
+
|
|
35
|
+
- **Unit** (`composeApp/src/commonTest`, run via `./gradlew :composeApp:desktopTest`):
|
|
36
|
+
kotlin-test + coroutines-test + Turbine. **Hand-written fakes** in `testing/fakes/` — never
|
|
37
|
+
mocking frameworks. Every new ViewModel/UseCase/Repository gets a test in the exemplar's
|
|
38
|
+
style: Arrange-Act-Assert, behavior-named backtick tests, one behavior per test.
|
|
39
|
+
- **Conformance + screen tests** (`composeApp/src/desktopTest`): dependency-free
|
|
40
|
+
source-scanning architecture gates (they enforce `specs/app-base.spec.md`'s ARCH clauses) + Compose UI Tests (durable,
|
|
41
|
+
spec-cited, testTag selectors) + the golden-tree structural baseline (`qa/golden/`). Golden
|
|
42
|
+
drift you did not intend = fix your change; intended drift = regenerate explicitly
|
|
43
|
+
(`UPDATE_GOLDEN=1`) and declare it.
|
|
44
|
+
- **E2E** (`qa/e2e/*.yaml`): Maestro flows; smoke covers boot + bottom nav. Selectors by
|
|
45
|
+
testTag — never by display text.
|
|
46
|
+
- Do not delete, weaken, or `@Ignore` a failing test to get to green. Fix the behavior, or if
|
|
47
|
+
the test itself is wrong, say so explicitly in your summary and justify the change.
|
|
48
|
+
|
|
49
|
+
## Evidence
|
|
50
|
+
|
|
51
|
+
`node qa/verify.mjs` writes `qa/evidence/latest.json` (schema: `qa/evidence/schema.json`).
|
|
52
|
+
Commit it with your change — git history is the audit ledger. Binary artifacts under
|
|
53
|
+
`qa-artifacts/` are hashed into the receipt; never commit them.
|
|
54
|
+
|
|
55
|
+
## Docs
|
|
56
|
+
|
|
57
|
+
[`README.md`](./README.md) (front page) · [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)
|
|
58
|
+
(layers + how to add a feature) · [`docs/TESTING.md`](./docs/TESTING.md) (pyramid +
|
|
59
|
+
conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional Commits) ·
|
|
60
|
+
[`docs/adr/`](./docs/adr/) (decision records — significant decisions get one). Keep
|
|
61
|
+
[`CHANGELOG.md`](./CHANGELOG.md) current under `[Unreleased]` as part of your change.
|
|
62
|
+
|
|
63
|
+
## Commands
|
|
64
|
+
|
|
65
|
+
| Command | What |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `node qa/verify.mjs` | The verify lane (profile `local`) — your definition of done |
|
|
68
|
+
| `./gradlew :composeApp:desktopTest` | Unit tests only (fast inner loop) |
|
|
69
|
+
| `./gradlew :composeApp:assembleDebug` | Android debug build |
|
|
70
|
+
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## The workflow
|
|
4
|
+
|
|
5
|
+
1. Branch from `main` (`feat/<name>`, `fix/<name>`).
|
|
6
|
+
2. Make the change — new features mirror the `home` exemplar
|
|
7
|
+
(see [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)), with tests at every layer
|
|
8
|
+
(see [`docs/TESTING.md`](./docs/TESTING.md)).
|
|
9
|
+
3. Run the verify lane: `node qa/verify.mjs`.
|
|
10
|
+
4. Commit **including the updated receipt** (`qa/evidence/latest.json`). A change without a
|
|
11
|
+
PASS receipt is not done — CI re-runs the same lane and will say so.
|
|
12
|
+
5. Open a PR. Keep it one concern; note any intended golden/baseline changes explicitly.
|
|
13
|
+
|
|
14
|
+
## Definition of done
|
|
15
|
+
|
|
16
|
+
- `node qa/verify.mjs` → **PASS**, receipt committed.
|
|
17
|
+
- New behavior has tests; existing tests untouched unless the behavior intentionally changed
|
|
18
|
+
(say so in the PR).
|
|
19
|
+
- No hardcoded design values; testTags on anything E2E needs to reach.
|
|
20
|
+
- Significant decisions recorded as an ADR (`docs/adr/`).
|
|
21
|
+
|
|
22
|
+
## Commit style — Conventional Commits
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
<type>(<scope>): <imperative summary>
|
|
26
|
+
|
|
27
|
+
feat(home): add pull-to-refresh
|
|
28
|
+
fix(nav): keep state on tab re-selection
|
|
29
|
+
test(profile): cover error retry path
|
|
30
|
+
docs(adr): record image-loading choice
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Types: `feat` `fix` `test` `refactor` `docs` `build` `ci` `chore`. Scope = feature/module.
|
|
34
|
+
|
|
35
|
+
## Code style
|
|
36
|
+
|
|
37
|
+
- Kotlin official style; match the surrounding file's idiom.
|
|
38
|
+
- Comments explain constraints the code can't (never narrate what the next line does).
|
|
39
|
+
- Public surface of `domain` stays framework-free.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A Kotlin / Compose Multiplatform app — generated by
|
|
4
|
+
[create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a **verification harness**:
|
|
5
|
+
the architecture, testing conventions, and definition of done are enforced mechanically, not
|
|
6
|
+
by convention. Start with [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md); AI collaborators
|
|
7
|
+
follow the contract in [`CLAUDE.md`](./CLAUDE.md).
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Android (device/emulator attached)
|
|
13
|
+
./gradlew :composeApp:installDebug
|
|
14
|
+
|
|
15
|
+
<!-- >>> cmp:feature dev-client -->
|
|
16
|
+
# Desktop dev-client with Compose Hot Reload — the daily dev loop
|
|
17
|
+
./gradlew :composeApp:hotRunDesktop --auto
|
|
18
|
+
<!-- <<< cmp:feature dev-client -->
|
|
19
|
+
|
|
20
|
+
# Unit tests (fast inner loop)
|
|
21
|
+
./gradlew :composeApp:desktopTest
|
|
22
|
+
|
|
23
|
+
# The verify lane — build + tests + every shipped gate, with an evidence receipt
|
|
24
|
+
node qa/verify.mjs
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
<!-- >>> cmp:feature ios -->
|
|
28
|
+
**iOS:** `cd iosApp && xcodegen generate && pod install`, then open
|
|
29
|
+
`iosApp.xcworkspace` in Xcode and run. (First time: `brew install xcodegen`.)
|
|
30
|
+
<!-- <<< cmp:feature ios -->
|
|
31
|
+
|
|
32
|
+
<!-- >>> cmp:feature firebase -->
|
|
33
|
+
**Firebase:** the scaffold ships a placeholder `google-services.json`. Wire your real project
|
|
34
|
+
before shipping — from Claude Code, the `cmp-firebase-connect` skill drives it end to end.
|
|
35
|
+
<!-- <<< cmp:feature firebase -->
|
|
36
|
+
|
|
37
|
+
## Project structure
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
specs/ behavior specifications — new behavior starts here
|
|
41
|
+
composeApp/src/commonMain/ shared UI + logic (presentation / domain / data / di)
|
|
42
|
+
composeApp/src/commonTest/ unit tests — exemplar pattern in presentation/home
|
|
43
|
+
composeApp/src/desktopTest/ conformance gates + Compose UI tests + golden trees (JVM tier)
|
|
44
|
+
composeApp/src/androidMain/ Android entry point + platform actuals
|
|
45
|
+
<!-- >>> cmp:feature ios -->
|
|
46
|
+
composeApp/src/iosMain/ iOS actuals · iosApp/ is the Xcode shell
|
|
47
|
+
<!-- <<< cmp:feature ios -->
|
|
48
|
+
<!-- >>> cmp:feature dev-client -->
|
|
49
|
+
composeApp/src/desktopMain/ desktop dev-client (see docs/dev-client.md)
|
|
50
|
+
<!-- <<< cmp:feature dev-client -->
|
|
51
|
+
qa/verify.mjs the verify lane — this repo's definition of done
|
|
52
|
+
qa/evidence/latest.json the committed evidence receipt (see CONTRIBUTING.md)
|
|
53
|
+
<!-- >>> cmp:feature e2e -->
|
|
54
|
+
qa/e2e/ Maestro E2E flows (device smoke)
|
|
55
|
+
<!-- <<< cmp:feature e2e -->
|
|
56
|
+
docs/ architecture, testing, ADRs
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Documentation
|
|
60
|
+
|
|
61
|
+
| Doc | What |
|
|
62
|
+
|---|---|
|
|
63
|
+
| [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) | Layers, data flow, how to add a feature |
|
|
64
|
+
| [`docs/TESTING.md`](./docs/TESTING.md) | The test pyramid, conventions, how to run everything |
|
|
65
|
+
| [`docs/adr/`](./docs/adr/) | Architecture decision records |
|
|
66
|
+
| [`CONTRIBUTING.md`](./CONTRIBUTING.md) | Workflow, definition of done, commit style |
|
|
67
|
+
| [`CLAUDE.md`](./CLAUDE.md) | The AI delivery contract |
|
|
68
|
+
| [`CHANGELOG.md`](./CHANGELOG.md) | Notable changes (Keep a Changelog) |
|
|
69
|
+
|
|
70
|
+
## Verification
|
|
71
|
+
|
|
72
|
+
Every change must pass the verify lane (`node qa/verify.mjs`) and commit its updated receipt
|
|
73
|
+
(`qa/evidence/latest.json`). CI re-runs the same lane on every push — see
|
|
74
|
+
[`.github/workflows/verify.yml`](./.github/workflows/verify.yml).
|
|
75
|
+
|
|
76
|
+
## Verification enforcement
|
|
77
|
+
|
|
78
|
+
This project ships a **Stop hook** (`.claude/settings.json`) that makes `CLAUDE.md`'s definition
|
|
79
|
+
of done mechanical instead of honor-system, for AI sessions using Claude Code.
|
|
80
|
+
|
|
81
|
+
**What it does:** when a session tries to end, the hook runs `node qa/receipt-check.mjs --hook`.
|
|
82
|
+
That script recomputes a sha256 hash over this project's "verified surface" (`composeApp/`,
|
|
83
|
+
`specs/`, `qa/`, and the Gradle build files — see `qa/lib/inputs-hash.mjs`) and compares it to the
|
|
84
|
+
`inputs.hash` recorded in the committed `qa/evidence/latest.json`. If the receipt is a `PASS` and
|
|
85
|
+
its hash matches the current tree, the session ends silently. If source changed without a fresh
|
|
86
|
+
`PASS` receipt — or the receipt is missing, a `FAIL`, or predates this mechanism — the hook blocks
|
|
87
|
+
with the specific reason and asks you to run `node qa/verify.mjs` and commit the receipt. **It runs
|
|
88
|
+
no build and no tests — only file hashing —** so it costs milliseconds, and it never fires twice in
|
|
89
|
+
a row for the same stop.
|
|
90
|
+
|
|
91
|
+
Doc-only edits (`*.md`, `README`, `.github/`, `.claude/`) are deliberately **outside** the verified
|
|
92
|
+
surface, so editing docs never invalidates a good receipt or forces a needless re-run — the intent
|
|
93
|
+
is transparent enforcement, not a hostile one.
|
|
94
|
+
|
|
95
|
+
**Why:** `CLAUDE.md` already says a change is "not done" without a `PASS` receipt committed. The
|
|
96
|
+
Stop hook is what makes that check happen automatically instead of relying on the AI session to
|
|
97
|
+
remember to run it.
|
|
98
|
+
|
|
99
|
+
**Escape hatch:** this is your project. If you don't want the hook, delete or comment out the
|
|
100
|
+
`Stop` block in [`.claude/settings.json`](./.claude/settings.json) — nothing else depends on it
|
|
101
|
+
locally. Note that CI independently enforces the same "receipt attests HEAD" check on every push
|
|
102
|
+
(see `.github/workflows/verify.yml`), so disabling the local hook only trades an immediate local
|
|
103
|
+
signal for a later one in CI.
|
|
@@ -51,16 +51,16 @@ kotlin {
|
|
|
51
51
|
}
|
|
52
52
|
// <<< cmp:feature ios
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
54
|
+
// The JVM tier — harness infrastructure, NOT feature-gated. This target hosts the fast
|
|
55
|
+
// verification loop: unit tests, conformance gates, golden-tree renders, and Compose UI
|
|
56
|
+
// Tests all run here (`:composeApp:desktopTest`), device-free. The dev-client window
|
|
57
|
+
// feature merely reuses it.
|
|
57
58
|
jvm("desktop") {
|
|
58
59
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
|
59
60
|
compilerOptions {
|
|
60
61
|
jvmTarget.set(JvmTarget.JVM_17)
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
|
-
// <<< cmp:feature dev-client
|
|
64
64
|
|
|
65
65
|
sourceSets {
|
|
66
66
|
commonMain.dependencies {
|
|
@@ -130,7 +130,7 @@ kotlin {
|
|
|
130
130
|
}
|
|
131
131
|
// <<< cmp:feature ios
|
|
132
132
|
|
|
133
|
-
//
|
|
133
|
+
// JVM tier deps (harness infrastructure — see the jvm("desktop") target note).
|
|
134
134
|
val desktopMain by getting {
|
|
135
135
|
dependencies {
|
|
136
136
|
implementation(compose.desktop.currentOs)
|
|
@@ -138,12 +138,21 @@ kotlin {
|
|
|
138
138
|
implementation(libs.ktor.client.cio)
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
// <<< cmp:feature dev-client
|
|
142
141
|
|
|
143
142
|
commonTest.dependencies {
|
|
144
143
|
implementation(libs.kotlin.test)
|
|
145
144
|
implementation(libs.koin.test)
|
|
146
145
|
implementation(libs.kotlinx.coroutines.test)
|
|
146
|
+
implementation(libs.turbine)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// JVM-tier test deps: Compose UI Tests, the golden-tree serializer, and the
|
|
150
|
+
// conformance gates all run here (the verify lane's fast, device-free steps).
|
|
151
|
+
val desktopTest by getting {
|
|
152
|
+
dependencies {
|
|
153
|
+
implementation(compose.desktop.uiTestJUnit4)
|
|
154
|
+
implementation(compose.desktop.currentOs)
|
|
155
|
+
}
|
|
147
156
|
}
|
|
148
157
|
}
|
|
149
158
|
}
|
|
@@ -225,9 +234,7 @@ dependencies {
|
|
|
225
234
|
add("kspIosX64", libs.room.compiler)
|
|
226
235
|
add("kspIosArm64", libs.room.compiler)
|
|
227
236
|
// <<< cmp:feature ios
|
|
228
|
-
// >>> cmp:feature dev-client
|
|
229
237
|
add("kspDesktop", libs.room.compiler)
|
|
230
|
-
// <<< cmp:feature dev-client
|
|
231
238
|
// <<< cmp:feature room
|
|
232
239
|
add("coreLibraryDesugaring", libs.android.desugar.jdk)
|
|
233
240
|
}
|
|
@@ -4,19 +4,23 @@ import __PACKAGE__.data.remote.ItemRepositoryImpl
|
|
|
4
4
|
import __PACKAGE__.domain.repository.ItemRepository
|
|
5
5
|
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
6
6
|
import __PACKAGE__.presentation.home.HomeViewModel
|
|
7
|
+
// cmp:anchor di-imports
|
|
7
8
|
import org.koin.core.module.dsl.viewModelOf
|
|
8
9
|
import org.koin.dsl.module
|
|
9
10
|
|
|
10
11
|
val repositoryModule = module {
|
|
11
12
|
single<ItemRepository> { ItemRepositoryImpl() }
|
|
13
|
+
// cmp:anchor di-repositories
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
val useCaseModule = module {
|
|
15
17
|
factory { GetItemsUseCase(get()) }
|
|
18
|
+
// cmp:anchor di-usecases
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
val viewModelModule = module {
|
|
19
22
|
viewModelOf(::HomeViewModel)
|
|
23
|
+
// cmp:anchor di-viewmodels
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
// Aggregated common modules, started from AppApplication (Android) and KoinHelper (iOS).
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt
CHANGED
|
@@ -8,6 +8,8 @@ import androidx.compose.material3.Text
|
|
|
8
8
|
import androidx.compose.material3.TextButton
|
|
9
9
|
import androidx.compose.runtime.Composable
|
|
10
10
|
import androidx.compose.ui.Modifier
|
|
11
|
+
import androidx.compose.ui.semantics.semantics
|
|
12
|
+
import androidx.compose.ui.semantics.testTag
|
|
11
13
|
import __PACKAGE__.presentation.components.BaseScreen
|
|
12
14
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
13
15
|
import __PACKAGE__.presentation.theme.designToken
|
|
@@ -27,8 +29,14 @@ fun DetailScreen(
|
|
|
27
29
|
)
|
|
28
30
|
.padding(__THEME_PREFIX__Tokens.PaddingPage),
|
|
29
31
|
) {
|
|
30
|
-
TextButton(onClick = onBack
|
|
31
|
-
|
|
32
|
+
TextButton(onClick = onBack, modifier = Modifier.semantics { testTag = "detail_back" }) {
|
|
33
|
+
Text("← Back")
|
|
34
|
+
}
|
|
35
|
+
Text(
|
|
36
|
+
"Detail",
|
|
37
|
+
style = MaterialTheme.typography.headlineMedium,
|
|
38
|
+
modifier = Modifier.semantics { testTag = "detail_title" },
|
|
39
|
+
)
|
|
32
40
|
Text(
|
|
33
41
|
"Item id: $itemId",
|
|
34
42
|
style = MaterialTheme.typography.bodyLarge,
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt
CHANGED
|
@@ -47,10 +47,20 @@ fun HomeScreen(
|
|
|
47
47
|
modifier = Modifier.semantics { testTag = "home_title" }.padding(bottom = 12.dp),
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
+
val errorMessage = state.errorMessage
|
|
50
51
|
if (state.isLoading) {
|
|
51
52
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
52
53
|
CircularProgressIndicator()
|
|
53
54
|
}
|
|
55
|
+
} else if (errorMessage != null) {
|
|
56
|
+
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
57
|
+
Text(
|
|
58
|
+
text = errorMessage,
|
|
59
|
+
style = MaterialTheme.typography.bodyLarge,
|
|
60
|
+
color = MaterialTheme.colorScheme.error,
|
|
61
|
+
modifier = Modifier.semantics { testTag = "home_error" },
|
|
62
|
+
)
|
|
63
|
+
}
|
|
54
64
|
} else {
|
|
55
65
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard)) {
|
|
56
66
|
items(state.items, key = { it.id }) { item ->
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt
CHANGED
|
@@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
|
|
|
12
12
|
data class HomeUiState(
|
|
13
13
|
val isLoading: Boolean = true,
|
|
14
14
|
val items: List<Item> = emptyList(),
|
|
15
|
+
val errorMessage: String? = null,
|
|
15
16
|
)
|
|
16
17
|
|
|
17
18
|
class HomeViewModel(
|
|
@@ -28,8 +29,11 @@ class HomeViewModel(
|
|
|
28
29
|
fun load() {
|
|
29
30
|
viewModelScope.launch {
|
|
30
31
|
_state.value = HomeUiState(isLoading = true)
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
_state.value = try {
|
|
33
|
+
HomeUiState(isLoading = false, items = getItems())
|
|
34
|
+
} catch (e: Exception) {
|
|
35
|
+
HomeUiState(isLoading = false, errorMessage = e.message ?: "Something went wrong")
|
|
36
|
+
}
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
}
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt
CHANGED
|
@@ -5,11 +5,12 @@ sealed class Screen(val route: String) {
|
|
|
5
5
|
data object Shell : Screen(Routes.SHELL)
|
|
6
6
|
// Example detail destination reachable from a tab. Add your own below.
|
|
7
7
|
data object Detail : Screen(Routes.DETAIL)
|
|
8
|
+
// cmp:anchor screen-objects
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
object Routes {
|
|
11
12
|
const val SHELL = "shell"
|
|
12
13
|
const val DETAIL = "detail/{itemId}"
|
|
13
|
-
|
|
14
|
+
// cmp:anchor route-consts
|
|
14
15
|
fun detail(itemId: String) = "detail/$itemId"
|
|
15
16
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package __PACKAGE__.data.remote
|
|
2
|
+
|
|
3
|
+
import kotlin.test.Test
|
|
4
|
+
import kotlin.test.assertEquals
|
|
5
|
+
import kotlin.test.assertTrue
|
|
6
|
+
import kotlinx.coroutines.test.runTest
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The exemplar data-layer test. [ItemRepositoryImpl] is the template's dependency-light
|
|
10
|
+
* example source; when you swap it for a real Firestore/Ktor + Room implementation, keep
|
|
11
|
+
* this shape: test the repository through its DOMAIN contract, under `runTest` virtual
|
|
12
|
+
* time (the simulated I/O delay costs nothing here — delays are skipped, not slept).
|
|
13
|
+
*/
|
|
14
|
+
class ItemRepositoryImplTest {
|
|
15
|
+
|
|
16
|
+
private val repository = ItemRepositoryImpl()
|
|
17
|
+
|
|
18
|
+
@Test
|
|
19
|
+
fun `returns the seeded example items`() = runTest {
|
|
20
|
+
val items = repository.getItems()
|
|
21
|
+
|
|
22
|
+
assertTrue(items.isNotEmpty(), "example source should seed items")
|
|
23
|
+
assertEquals(items.size, items.map { it.id }.toSet().size, "item ids must be unique")
|
|
24
|
+
assertTrue(items.all { it.title.isNotBlank() }, "every item needs a title")
|
|
25
|
+
}
|
|
26
|
+
}
|