create-cmp-cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +11 -9
  2. package/package.json +1 -1
  3. package/src/lib/package-name.mjs +72 -0
  4. package/src/scaffold.mjs +7 -2
  5. package/template/.claude/settings.json +30 -0
  6. package/template/CLAUDE.md +48 -6
  7. package/template/composeApp/build.gradle.kts +44 -0
  8. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  9. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  10. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  11. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  12. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  25. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  26. package/template/docs/ARCHITECTURE.md +41 -2
  27. package/template/docs/TESTING.md +165 -0
  28. package/template/gradle/libs.versions.toml +15 -0
  29. package/template/manifest.json +1 -0
  30. package/template/qa/evidence/schema.json +20 -2
  31. package/template/qa/lib/affected-tests.mjs +147 -0
  32. package/template/qa/lib/device-lease.mjs +249 -0
  33. package/template/qa/lib/evidence-level.mjs +117 -0
  34. package/template/qa/lib/spec-coverage.mjs +54 -3
  35. package/template/qa/lib/step-cache.mjs +221 -0
  36. package/template/qa/receipt-check.mjs +22 -2
  37. package/template/qa/scaffold-feature.mjs +20 -1
  38. package/template/qa/verify.mjs +637 -56
  39. package/template/qa/watch.mjs +622 -0
  40. package/template/specs/app-base.spec.md +11 -0
@@ -0,0 +1,221 @@
1
+ // step-cache.mjs — fast-mode memoization for the lane's pure-Node steps.
2
+ //
3
+ // The steps this serves (specCoverage, approvals, componentStories,
4
+ // reachability, archDoc) are pure functions of files on disk: no Gradle, no
5
+ // git, no network, no clock in the verdict. For those — and ONLY those — a
6
+ // verdict can be safely reused when a content hash of the step's declared
7
+ // input set is unchanged since the last run that produced it.
8
+ //
9
+ // GROUND RULES, each load-bearing:
10
+ //
11
+ // - THE CACHE IS A CACHE, NEVER EVIDENCE. It lives in the gitignored build
12
+ // dir (composeApp/build/.cmp-step-cache.json), must never be committed,
13
+ // and must never be read by qa/receipt-check.mjs or any other receipt
14
+ // consumer. Deleting it can only cost time, never correctness.
15
+ //
16
+ // - THE FULL LANE NEVER READS IT. This is deliberate: it keeps the full
17
+ // lane's integrity property absolute rather than "absolute unless a cache
18
+ // says otherwise". A full run WRITES entries (so the next fast run
19
+ // benefits) but always executes every step itself.
20
+ //
21
+ // - ONLY A CACHED PASS IS EVER REUSED. A cached FAIL is always re-run so
22
+ // the failure detail is fresh; a cached SKIP is re-derived (these steps
23
+ // are cheap enough that only the PASS case is worth reusing, and a SKIP's
24
+ // reason — e.g. which approvals are pending — must stay current).
25
+ //
26
+ // - THE DECLARED INPUT SET IS THE WHOLE SAFETY ARGUMENT. A step's inputs
27
+ // must cover everything it reads; a wrong input set is a silently-stale
28
+ // gate — the worst possible bug this file could host. Callers over-declare
29
+ // on purpose (a too-broad set only costs cache misses; a too-narrow one
30
+ // costs truth). The declarations live next to the steps in qa/verify.mjs.
31
+ //
32
+ // - THE CACHE MUST NEVER BREAK THE LANE. Missing, corrupt, unreadable,
33
+ // unwritable — every failure mode degrades to "execute the step", never
34
+ // to an error and never to a reused verdict.
35
+
36
+ import { createHash } from "node:crypto";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+
40
+ export const STEP_CACHE_REL_PATH = "composeApp/build/.cmp-step-cache.json";
41
+ export const STEP_CACHE_SCHEMA = "cmp-step-cache/1";
42
+
43
+ function toPosix(p) {
44
+ return p.split(path.sep).join("/");
45
+ }
46
+
47
+ /** Every file under `dir` (recursive), absolute paths. Missing dir → []. */
48
+ function walkAllFiles(dir) {
49
+ const out = [];
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ } catch {
54
+ return out;
55
+ }
56
+ for (const e of entries) {
57
+ const p = path.join(dir, e.name);
58
+ if (e.isDirectory()) out.push(...walkAllFiles(p));
59
+ else if (e.isFile()) out.push(p);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Content-hash one step's declared input set: sha256 over the sorted list of
66
+ * `relpath\0bytes` entries. Deterministic: same paths + same bytes → same
67
+ * hash, independent of declaration order, walk order, and platform separators.
68
+ * A file appearing, disappearing, moving, or changing content all change the
69
+ * hash — which is exactly the set of events that can change a pure-Node
70
+ * step's verdict.
71
+ *
72
+ * @param {string} root project root (absolute)
73
+ * @param {string[]} inputs paths relative to root — each a file or a
74
+ * directory (walked recursively). Missing entries contribute nothing (their
75
+ * later appearance changes the hash).
76
+ * @returns {{hash: string, fileCount: number}}
77
+ */
78
+ export function computeStepInputsHash(root, inputs) {
79
+ const relFiles = new Set();
80
+ for (const rel of inputs ?? []) {
81
+ const abs = path.join(root, rel);
82
+ let stat;
83
+ try {
84
+ stat = fs.statSync(abs);
85
+ } catch {
86
+ continue; // absent input — contributes nothing until it exists
87
+ }
88
+ if (stat.isFile()) {
89
+ relFiles.add(toPosix(rel));
90
+ } else if (stat.isDirectory()) {
91
+ for (const f of walkAllFiles(abs)) {
92
+ relFiles.add(toPosix(path.relative(root, f)));
93
+ }
94
+ }
95
+ }
96
+ // Code-unit sort (default String sort), same stance as inputs-hash.mjs: the
97
+ // hash depends on iteration order and must be identical on every machine.
98
+ const sorted = [...relFiles].sort();
99
+ const overall = createHash("sha256");
100
+ for (const rel of sorted) {
101
+ overall.update(rel);
102
+ overall.update("\0");
103
+ overall.update(fs.readFileSync(path.join(root, rel)));
104
+ overall.update("\n");
105
+ }
106
+ return { hash: overall.digest("hex"), fileCount: sorted.length };
107
+ }
108
+
109
+ /**
110
+ * Load the cache file. Absent, corrupt, or wrong-schema is TOLERATED and
111
+ * returns an empty cache — a cache that cannot be read is a cache miss,
112
+ * never an error (see ground rules).
113
+ * @param {string} root
114
+ * @returns {{schema: string, steps: Record<string, {inputsHash: string, verdict: string, at: string}>}}
115
+ */
116
+ export function loadStepCache(root) {
117
+ try {
118
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, STEP_CACHE_REL_PATH), "utf8"));
119
+ if (!parsed || parsed.schema !== STEP_CACHE_SCHEMA || typeof parsed.steps !== "object" || parsed.steps === null || Array.isArray(parsed.steps)) {
120
+ return { schema: STEP_CACHE_SCHEMA, steps: {} };
121
+ }
122
+ return { schema: STEP_CACHE_SCHEMA, steps: parsed.steps };
123
+ } catch {
124
+ return { schema: STEP_CACHE_SCHEMA, steps: {} };
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The reuse decision: return the cached entry iff the step's last EXECUTED
130
+ * verdict was PASS and its inputs hash exactly matches `inputsHash`. A cached
131
+ * FAIL or SKIP is never reused (re-run so the detail is fresh); a hash
132
+ * mismatch is a miss; a malformed entry is a miss.
133
+ * @param {string} root
134
+ * @param {string} stepName
135
+ * @param {string} inputsHash
136
+ * @returns {{inputsHash: string, verdict: string, at: string}|null}
137
+ */
138
+ export function lookupCachedPass(root, stepName, inputsHash) {
139
+ const entry = loadStepCache(root).steps[stepName];
140
+ if (!entry || typeof entry !== "object") return null;
141
+ if (entry.verdict !== "PASS") return null; // FAIL/SKIP are never reused
142
+ if (typeof inputsHash !== "string" || entry.inputsHash !== inputsHash) return null;
143
+ if (typeof entry.at !== "string") return null;
144
+ return entry;
145
+ }
146
+
147
+ /**
148
+ * Record a step's EXECUTED result (any verdict — the entry always reflects
149
+ * the last real execution; only lookupCachedPass decides reusability).
150
+ * Write failures are swallowed: an unwritable cache costs the next run time,
151
+ * never correctness.
152
+ * @param {string} root
153
+ * @param {string} stepName
154
+ * @param {{inputsHash: string, verdict: string, at?: string}} entry
155
+ */
156
+ export function writeStepCacheEntry(root, stepName, { inputsHash, verdict, at = new Date().toISOString() }) {
157
+ try {
158
+ const cache = loadStepCache(root);
159
+ cache.steps[stepName] = { inputsHash, verdict, at };
160
+ const p = path.join(root, STEP_CACHE_REL_PATH);
161
+ fs.mkdirSync(path.dirname(p), { recursive: true });
162
+ fs.writeFileSync(p, `${JSON.stringify(cache, null, 2)}\n`);
163
+ } catch {
164
+ // never a lane failure — see ground rules
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The one memoization flow, shared by every memoized step so the mode rules
170
+ * cannot drift per step:
171
+ *
172
+ * fast mode: hash inputs → cached PASS with matching hash → return a
173
+ * CACHED result (verdict "CACHED", distinct from PASS so a fast
174
+ * receipt can never be mistaken for a fully-executed one);
175
+ * otherwise execute, record, return the real result.
176
+ * full mode: ALWAYS execute — the cache is never consulted (the full
177
+ * lane's integrity property stays absolute; see ground rules) —
178
+ * then record, so the next fast run benefits.
179
+ *
180
+ * Any cache-machinery error (hashing, read, write) degrades to plain
181
+ * execution.
182
+ *
183
+ * @param {object} args
184
+ * @param {boolean} args.fast whether this is a --fast run
185
+ * @param {string} args.root project root
186
+ * @param {string} args.stepName the lane step's name (the cache key)
187
+ * @param {string[]} args.inputs the step's declared input set (see ground rules)
188
+ * @param {() => object} args.run the real step function
189
+ * @returns {object} the step result — either `run()`'s verbatim, or a
190
+ * `{name, verdict: "CACHED", note, durationMs, details}` reuse marker
191
+ */
192
+ export function memoizeStep({ fast, root, stepName, inputs, run }) {
193
+ const started = Date.now();
194
+ let inputsHash = null;
195
+ try {
196
+ // Hashed BEFORE execution so the recorded entry binds the verdict to the
197
+ // tree the step actually saw, not to edits made while it ran.
198
+ inputsHash = computeStepInputsHash(root, inputs).hash;
199
+ } catch {
200
+ inputsHash = null;
201
+ }
202
+
203
+ if (fast && inputsHash) {
204
+ const hit = lookupCachedPass(root, stepName, inputsHash);
205
+ if (hit) {
206
+ return {
207
+ name: stepName,
208
+ verdict: "CACHED",
209
+ note: `unchanged since ${hit.at}`,
210
+ durationMs: Date.now() - started,
211
+ details: { inputsHash },
212
+ };
213
+ }
214
+ }
215
+
216
+ const result = run();
217
+ if (inputsHash && result && typeof result.verdict === "string") {
218
+ writeStepCacheEntry(root, stepName, { inputsHash, verdict: result.verdict });
219
+ }
220
+ return result;
221
+ }
@@ -46,7 +46,25 @@ function evaluate() {
46
46
  if (receipt === null) {
47
47
  return { valid: false, reason: "no receipt — run `node qa/verify.mjs`", profile: undefined };
48
48
  }
49
- return evaluateReceipt(receipt, () => computeInputsHash(ROOT));
49
+ // A fast-mode receipt (verify --fast) is an inner-loop signal, never done
50
+ // evidence — refused here before the hash is even recomputed, so a session
51
+ // can never end on "done" while its evidence trail's last run was --fast.
52
+ if (receipt.mode === "fast") {
53
+ return {
54
+ valid: false,
55
+ reason: "the last verify run was --fast (inner-loop only); run the full lane (`node qa/verify.mjs`) before finishing",
56
+ profile: receipt.profile,
57
+ };
58
+ }
59
+ const result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
60
+ // Surface the receipt's evidence rung (the ladder — qa/lib/evidence-level.mjs)
61
+ // alongside the verdict: the rung is the receipt's own derived field, read
62
+ // verbatim, never recomputed here. Older receipts without it stay valid.
63
+ const level = receipt.evidenceLevel;
64
+ if (level && typeof level === "object" && typeof level.rung === "string") {
65
+ result.evidenceLevel = level;
66
+ }
67
+ return result;
50
68
  }
51
69
 
52
70
  const result = evaluate();
@@ -65,10 +83,12 @@ if (asHook) {
65
83
  process.exit(0);
66
84
  }
67
85
 
86
+ const rungSuffix = result.evidenceLevel ? ` — evidence ${result.evidenceLevel.rung} · ${result.evidenceLevel.name}` : "";
87
+
68
88
  if (asJson) {
69
89
  console.log(JSON.stringify(result, null, 2));
70
90
  } else if (result.valid) {
71
- console.log(`VALID — ${result.reason}`);
91
+ console.log(`VALID — ${result.reason}${rungSuffix}`);
72
92
  } else {
73
93
  console.error(`INVALID — ${result.reason}`);
74
94
  }
@@ -370,6 +370,9 @@ function defaultSpec() {
370
370
 
371
371
  > Generated by \`scaffold-feature.mjs\` from the \`${SOURCE_f}\` exemplar shape. Refine the clause
372
372
  > prose below for ${F}'s real behavior (ids stay fixed) before running the verify lane.
373
+ > Platform-behavior clauses (alarms, notifications, lock screen, audio routing) cannot be
374
+ > proven by any desktop tier — cite them from an instrumented test in
375
+ > \`composeApp/src/androidInstrumentedTest\` (see docs/TESTING.md, "The instrumented tier").
373
376
 
374
377
  - **${F_UPPER}-01** — Given the ${F} screen opens, When ${f} are being loaded, Then a loading
375
378
  indicator is shown and no ${f} are visible.
@@ -553,6 +556,22 @@ const APP_NAV_HOST = path.join(SRC("commonMain"), "presentation/navigation/AppNa
553
556
  // up with zero hand edits — the same reason we wire nav/DI automatically.
554
557
  const PREVIEW_REGISTRY = path.join(SRC("desktopMain"), "inspector/PreviewRegistry.kt");
555
558
 
559
+ // ARCH-14: ViewModels are registered with EXPLICIT `viewModel { … }` factories, never
560
+ // reflection-based `viewModelOf` (it silently ignores Kotlin constructor default
561
+ // parameter values — a compile-time wiring error becomes a runtime resolution crash).
562
+ // The stamped registration is cloned from the EXEMPLAR's own factory line in
563
+ // AppModule.kt (rename applied), so its get() arity always matches the cloned
564
+ // ViewModel's constructor whatever shape the configured exemplar has. The canonical
565
+ // single-use-case shape is the fallback when the exemplar's line isn't found.
566
+ function explicitViewModelRegistration(appModuleContent) {
567
+ const exemplarFactory = new RegExp(
568
+ `viewModel\\s*\\{\\s*${escapeRegExp(SOURCE_F)}ViewModel\\([^)]*\\)\\s*\\}`,
569
+ );
570
+ const m = appModuleContent.match(exemplarFactory);
571
+ if (m) return applyRename(m[0].replace(/\s+/g, " "));
572
+ return `viewModel { ${F}ViewModel(get()) }`;
573
+ }
574
+
556
575
  // Each step is tagged with the presets it belongs to, same mechanism as
557
576
  // FILES above: `repository` gets repo+usecase DI (+ imports) only; `screen`
558
577
  // gets viewModel DI (+ import) + nav route + import only; `feature` gets the
@@ -567,7 +586,7 @@ const ALL_INJECTION_PLANS = [
567
586
  { presets: ["feature", "screen"], apply: (c) => injectImport(c, APP_MODULE, `import ${PACKAGE}.presentation.${f}.${F}ViewModel`) },
568
587
  { presets: ["feature", "repository"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-repositories", `single<${E}Repository> { ${E}RepositoryImpl() }`) },
569
588
  { presets: ["feature", "repository"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-usecases", `factory { Get${E}sUseCase(get()) }`) },
570
- { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-viewmodels", `viewModelOf(::${F}ViewModel)`) },
589
+ { presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-viewmodels", explicitViewModelRegistration(c)) },
571
590
  ],
572
591
  },
573
592
  {