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