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.
- package/README.md +11 -9
- package/package.json +1 -1
- package/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +48 -6
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +637 -56
- package/template/qa/watch.mjs +622 -0
- 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
|
-
|
|
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",
|
|
589
|
+
{ presets: ["feature", "screen"], apply: (c) => injectAtAnchor(c, APP_MODULE, "di-viewmodels", explicitViewModelRegistration(c)) },
|
|
571
590
|
],
|
|
572
591
|
},
|
|
573
592
|
{
|