create-cmp-cli 0.11.0 → 0.13.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/bin/create-cmp.mjs +3 -0
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +287 -0
- package/src/lib/harness-upgrade.mjs +364 -0
- 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 +51 -6
- package/template/README.md +4 -0
- 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/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- 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/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +934 -57
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Core logic for `create-cmp upgrade --harness`: refresh the ENGINE-OWNED
|
|
2
|
+
// files of a stamped app after the engine improved.
|
|
3
|
+
//
|
|
4
|
+
// WHY: `create-cmp` stamps an app from template/ and walks away. When the
|
|
5
|
+
// engine later fixes a stamped file (a verify-lane bug, a Gradle wiring fix,
|
|
6
|
+
// a new hook), apps stamped from the OLD engine never receive it — the dead
|
|
7
|
+
// androidDebug/AndroidManifest.xml fixed at engine commit b972c19 was carried
|
|
8
|
+
// by every app this template ever stamped. This module closes that gap with a
|
|
9
|
+
// three-way merge, exactly like dpkg conffile handling or a Yeoman
|
|
10
|
+
// regeneration:
|
|
11
|
+
//
|
|
12
|
+
// base = what the engine WOULD have stamped at the version this app was
|
|
13
|
+
// stamped from (old template + CURRENT stamp pipeline — see below)
|
|
14
|
+
// new = what the CURRENT engine stamps
|
|
15
|
+
// theirs = the app's working tree today
|
|
16
|
+
//
|
|
17
|
+
// Both base and new are produced by stamping with the app's OWN recorded
|
|
18
|
+
// config (create-cmp.json), so tokens (package, app name, theme prefix)
|
|
19
|
+
// resolve identically and every base→new diff is pure engine change.
|
|
20
|
+
//
|
|
21
|
+
// NOTE on the base approximation: base is "old template + current pipeline".
|
|
22
|
+
// When the stamp PIPELINE itself changed between versions (tokenization,
|
|
23
|
+
// marker stripping), base can differ from what the old engine literally
|
|
24
|
+
// produced. This is deliberate — running old engine code against a current
|
|
25
|
+
// config is strictly worse — and only affects files whose tokenization
|
|
26
|
+
// changed; those surface as conflicts rather than silent clobbers.
|
|
27
|
+
//
|
|
28
|
+
// App-authored files (the app's own feature screens) appear in neither base
|
|
29
|
+
// nor new, so they are INVISIBLE to this sweep — by design. The sweep only
|
|
30
|
+
// ever considers paths the engine stamped at one version or the other.
|
|
31
|
+
//
|
|
32
|
+
// Pure decision logic lives here so tests can drive it with in-memory/temp
|
|
33
|
+
// fixtures and zero npm network access; CLI + npm-pack orchestration lives in
|
|
34
|
+
// src/commands/upgrade.mjs. No dependencies beyond the Node stdlib (git is
|
|
35
|
+
// already a hard requirement of this repo).
|
|
36
|
+
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import os from "node:os";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { spawnSync } from "node:child_process";
|
|
41
|
+
|
|
42
|
+
import { listFiles } from "./fsutil.mjs";
|
|
43
|
+
import { isBinaryPath } from "./tokens.mjs";
|
|
44
|
+
import { BACKUP_SUFFIX } from "./upgrade.mjs";
|
|
45
|
+
|
|
46
|
+
/** Sidecar suffix for the new engine content beside a conflicted file. */
|
|
47
|
+
export const SIDECAR_SUFFIX = ".cmp-new";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hard exclusion list — the app's own state, or its secrets, that the engine
|
|
51
|
+
* also seeds. A diff here is noise or danger, never an upgrade. Matched
|
|
52
|
+
* against the project-relative path (posix separators):
|
|
53
|
+
* - a bare name (no `/`) matches that basename at ANY depth — this is what
|
|
54
|
+
* keeps `keystore.properties` and `google-services.json` excluded wherever
|
|
55
|
+
* they live (e.g. composeApp/google-services.json), because those files
|
|
56
|
+
* must never be read, written, or printed by this code;
|
|
57
|
+
* - `dir/**` matches everything under `dir` (root-anchored);
|
|
58
|
+
* - `**` + `/dir/` + `**` matches everything under a `dir` segment at any depth;
|
|
59
|
+
* - `**` + `/name` matches that basename at any depth.
|
|
60
|
+
*/
|
|
61
|
+
export const EXCLUDED_PATTERNS = [
|
|
62
|
+
"create-cmp.json",
|
|
63
|
+
"qa/evidence/**",
|
|
64
|
+
"qa/approvals.json",
|
|
65
|
+
"qa/comments.json",
|
|
66
|
+
"qa/golden/**",
|
|
67
|
+
".git/**",
|
|
68
|
+
"build/**",
|
|
69
|
+
"**/build/**",
|
|
70
|
+
".gradle/**",
|
|
71
|
+
"local.properties",
|
|
72
|
+
"keystore.properties",
|
|
73
|
+
"google-services.json",
|
|
74
|
+
"**/GoogleService-Info.plist",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Does one exclusion pattern match a project-relative posix path?
|
|
79
|
+
* @param {string} relPath project-relative path, "/"-separated
|
|
80
|
+
* @param {string} pattern one of EXCLUDED_PATTERNS (see grammar above)
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function matchesPattern(relPath, pattern) {
|
|
84
|
+
if (pattern.endsWith("/**")) {
|
|
85
|
+
const dir = pattern.slice(0, -3);
|
|
86
|
+
if (dir.startsWith("**/")) {
|
|
87
|
+
// "**/build/**": any DIRECTORY segment equal to the name.
|
|
88
|
+
const seg = dir.slice(3);
|
|
89
|
+
return relPath.split("/").slice(0, -1).includes(seg);
|
|
90
|
+
}
|
|
91
|
+
return relPath === dir || relPath.startsWith(dir + "/");
|
|
92
|
+
}
|
|
93
|
+
if (pattern.startsWith("**/")) {
|
|
94
|
+
const name = pattern.slice(3);
|
|
95
|
+
return relPath === name || relPath.endsWith("/" + name);
|
|
96
|
+
}
|
|
97
|
+
if (!pattern.includes("/")) {
|
|
98
|
+
// Bare name: basename match at any depth (never risk touching a nested
|
|
99
|
+
// secret because it wasn't at the root).
|
|
100
|
+
return relPath === pattern || relPath.endsWith("/" + pattern);
|
|
101
|
+
}
|
|
102
|
+
return relPath === pattern;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Is this project-relative path on the hard exclusion list?
|
|
107
|
+
* Checked BEFORE any file content is read — excluded files (state, secrets)
|
|
108
|
+
* are never opened by this module.
|
|
109
|
+
* @param {string} relPath project-relative path, "/"-separated
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
export function isExcludedPath(relPath) {
|
|
113
|
+
return EXCLUDED_PATTERNS.some((p) => matchesPattern(relPath, p));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Three-way merge via `git merge-file -p --diff3 <theirs> <base> <new>`.
|
|
118
|
+
* The three sides are written to temp files; git's exit code is 0 for a clean
|
|
119
|
+
* merge, >0 for the number of conflicts, <0 / spawn error for trouble — both
|
|
120
|
+
* of the latter are treated as a conflict (the caller then writes a sidecar
|
|
121
|
+
* instead of touching the app's file, so "treat as conflict" is always safe).
|
|
122
|
+
* @param {Buffer} theirs the app's current content
|
|
123
|
+
* @param {Buffer} base the old engine's stamped content
|
|
124
|
+
* @param {Buffer} next the current engine's stamped content
|
|
125
|
+
* @returns {{clean: boolean, content: Buffer|null}} merged content when clean
|
|
126
|
+
*/
|
|
127
|
+
export function mergeThreeWay(theirs, base, next) {
|
|
128
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cmp-harness-merge-"));
|
|
129
|
+
try {
|
|
130
|
+
const t = path.join(dir, "theirs");
|
|
131
|
+
const b = path.join(dir, "base");
|
|
132
|
+
const n = path.join(dir, "new");
|
|
133
|
+
fs.writeFileSync(t, theirs);
|
|
134
|
+
fs.writeFileSync(b, base);
|
|
135
|
+
fs.writeFileSync(n, next);
|
|
136
|
+
const r = spawnSync("git", ["merge-file", "-p", "--diff3", t, b, n], {
|
|
137
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
138
|
+
});
|
|
139
|
+
if (r.error || r.status === null || r.status !== 0) {
|
|
140
|
+
return { clean: false, content: null };
|
|
141
|
+
}
|
|
142
|
+
return { clean: true, content: r.stdout };
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The decision table — one file, three sides, one verdict. Buckets:
|
|
150
|
+
* unchanged engine never changed it (base == new) → silent
|
|
151
|
+
* current app already matches the new engine → silent
|
|
152
|
+
* applied app never touched it, engine changed it → write new
|
|
153
|
+
* merged both changed, three-way merge is clean → write merged
|
|
154
|
+
* conflicted both changed the same region (or binary, or the app deleted
|
|
155
|
+
* a file the engine changed) → NEVER clobber: leave the app's
|
|
156
|
+
* file byte-for-byte alone, emit a `.cmp-new` sidecar with the
|
|
157
|
+
* new engine's content
|
|
158
|
+
* added new engine file absent from the app → write new
|
|
159
|
+
* removed engine deleted it and the app never touched it → delete
|
|
160
|
+
* orphaned engine deleted it but the app modified it → keep, report
|
|
161
|
+
*
|
|
162
|
+
* Binary files (isBinaryPath) never three-way merge: replace when the app
|
|
163
|
+
* never touched them, otherwise conflicted.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} params
|
|
166
|
+
* @param {string} params.relPath project-relative path (for binary sniffing)
|
|
167
|
+
* @param {Buffer|null} params.base old engine's content (null = not stamped then)
|
|
168
|
+
* @param {Buffer|null} params.next current engine's content (null = engine deleted it)
|
|
169
|
+
* @param {Buffer|null} params.theirs the app's content (null = absent in the app)
|
|
170
|
+
* @param {(theirs:Buffer, base:Buffer, next:Buffer)=>{clean:boolean,content:Buffer|null}} [params.merge]
|
|
171
|
+
* three-way merge fn, injectable for tests (default: git merge-file)
|
|
172
|
+
* @returns {{bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}|null}
|
|
173
|
+
* null when the path is in neither base nor new (app-authored — invisible)
|
|
174
|
+
*/
|
|
175
|
+
export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
|
|
176
|
+
const none = (bucket) => ({ bucket, write: null, sidecar: null, remove: false });
|
|
177
|
+
const write = (bucket, content) => ({ bucket, write: content, sidecar: null, remove: false });
|
|
178
|
+
const conflict = () => ({ bucket: "conflicted", write: null, sidecar: next, remove: false });
|
|
179
|
+
const eq = (a, b) => a !== null && b !== null && a.equals(b);
|
|
180
|
+
|
|
181
|
+
if (base !== null && next !== null) {
|
|
182
|
+
if (eq(base, next)) return none("unchanged"); // engine never changed it
|
|
183
|
+
// Engine changed it:
|
|
184
|
+
if (theirs === null) return conflict(); // app deleted it — never resurrect silently
|
|
185
|
+
if (eq(theirs, next)) return none("current"); // already up to date
|
|
186
|
+
if (eq(theirs, base)) return write("applied", next); // app never touched it
|
|
187
|
+
// All three differ:
|
|
188
|
+
if (isBinaryPath(relPath)) return conflict(); // binaries never merge
|
|
189
|
+
const m = merge(theirs, base, next);
|
|
190
|
+
if (m.clean && m.content !== null) {
|
|
191
|
+
// A merge that reproduces the app's file byte-for-byte means the app
|
|
192
|
+
// already carries the engine change (e.g. a previous --harness run) —
|
|
193
|
+
// report current, keep re-runs idempotent and quiet.
|
|
194
|
+
if (m.content.equals(theirs)) return none("current");
|
|
195
|
+
return write("merged", m.content);
|
|
196
|
+
}
|
|
197
|
+
return conflict();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (base === null && next !== null) {
|
|
201
|
+
// New engine file.
|
|
202
|
+
if (theirs === null) return write("added", next);
|
|
203
|
+
if (eq(theirs, next)) return none("current");
|
|
204
|
+
return conflict(); // the app already has something different there
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (base !== null && next === null) {
|
|
208
|
+
// Engine deleted it.
|
|
209
|
+
if (theirs === null) return none("current"); // already gone
|
|
210
|
+
if (eq(theirs, base)) return { bucket: "removed", write: null, sidecar: null, remove: true };
|
|
211
|
+
return none("orphaned"); // app modified it — keep it, report it
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return null; // in neither base nor new: app-authored, invisible to the sweep
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readIfPresent(dir, relPath) {
|
|
218
|
+
try {
|
|
219
|
+
return fs.readFileSync(path.join(dir, relPath));
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function toRel(root, abs) {
|
|
226
|
+
return path.relative(root, abs).split(path.sep).join("/");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Walk base ∪ new, classify every path through the decision table, and return
|
|
231
|
+
* the full plan. Excluded paths are counted WITHOUT ever reading their
|
|
232
|
+
* content (state files and secrets stay unopened). Reads the three trees but
|
|
233
|
+
* never writes anything — applying is applyHarnessPlan's job.
|
|
234
|
+
* @param {object} params
|
|
235
|
+
* @param {string} params.baseDir stamped tree of the old engine
|
|
236
|
+
* @param {string} params.newDir stamped tree of the current engine
|
|
237
|
+
* @param {string} params.projectDir the app's working tree
|
|
238
|
+
* @param {Function} [params.merge] three-way merge fn, injectable for tests
|
|
239
|
+
* @returns {{entries: Array<{relPath:string, bucket:string, write?:Buffer|null,
|
|
240
|
+
* sidecar?:Buffer|null, remove?:boolean}>,
|
|
241
|
+
* counts: Record<string, number>}}
|
|
242
|
+
*/
|
|
243
|
+
export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeThreeWay }) {
|
|
244
|
+
const rels = new Set();
|
|
245
|
+
for (const f of listFiles(baseDir)) rels.add(toRel(baseDir, f));
|
|
246
|
+
for (const f of listFiles(newDir)) rels.add(toRel(newDir, f));
|
|
247
|
+
|
|
248
|
+
const counts = {
|
|
249
|
+
excluded: 0,
|
|
250
|
+
unchanged: 0,
|
|
251
|
+
current: 0,
|
|
252
|
+
applied: 0,
|
|
253
|
+
merged: 0,
|
|
254
|
+
conflicted: 0,
|
|
255
|
+
added: 0,
|
|
256
|
+
removed: 0,
|
|
257
|
+
orphaned: 0,
|
|
258
|
+
};
|
|
259
|
+
const entries = [];
|
|
260
|
+
for (const relPath of [...rels].sort()) {
|
|
261
|
+
if (isExcludedPath(relPath)) {
|
|
262
|
+
counts.excluded += 1;
|
|
263
|
+
entries.push({ relPath, bucket: "excluded", write: null, sidecar: null, remove: false });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const decision = decideFile({
|
|
267
|
+
relPath,
|
|
268
|
+
base: readIfPresent(baseDir, relPath),
|
|
269
|
+
next: readIfPresent(newDir, relPath),
|
|
270
|
+
theirs: readIfPresent(projectDir, relPath),
|
|
271
|
+
merge,
|
|
272
|
+
});
|
|
273
|
+
if (decision === null) continue;
|
|
274
|
+
counts[decision.bucket] += 1;
|
|
275
|
+
entries.push({ relPath, ...decision });
|
|
276
|
+
}
|
|
277
|
+
return { entries, counts };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Apply a plan to the app's working tree. Every file that gets written over
|
|
282
|
+
* or deleted is backed up first as `<file>${BACKUP_SUFFIX}` (same suffix as
|
|
283
|
+
* the version-catalog upgrade path, so one revert story covers both modes).
|
|
284
|
+
* Conflicted entries never touch the app's file — only the `.cmp-new` sidecar
|
|
285
|
+
* is written. Returns what happened so the CLI can print revert commands.
|
|
286
|
+
* @param {string} projectDir
|
|
287
|
+
* @param {Array<{relPath:string, bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}>} entries
|
|
288
|
+
* @returns {{written:string[], created:string[], deleted:string[],
|
|
289
|
+
* sidecars:string[], backups:string[]}}
|
|
290
|
+
* written rel paths overwritten (backup exists)
|
|
291
|
+
* created rel paths newly created (no previous content, no backup)
|
|
292
|
+
* deleted rel paths removed (backup exists)
|
|
293
|
+
* sidecars rel paths of `.cmp-new` files written beside conflicts
|
|
294
|
+
* backups rel paths that have a `${BACKUP_SUFFIX}` copy
|
|
295
|
+
*/
|
|
296
|
+
export function applyHarnessPlan(projectDir, entries) {
|
|
297
|
+
const written = [];
|
|
298
|
+
const created = [];
|
|
299
|
+
const deleted = [];
|
|
300
|
+
const sidecars = [];
|
|
301
|
+
const backups = [];
|
|
302
|
+
for (const e of entries) {
|
|
303
|
+
const abs = path.join(projectDir, e.relPath);
|
|
304
|
+
if (e.sidecar !== null && e.sidecar !== undefined) {
|
|
305
|
+
const sidecarPath = abs + SIDECAR_SUFFIX;
|
|
306
|
+
fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
|
|
307
|
+
fs.writeFileSync(sidecarPath, e.sidecar);
|
|
308
|
+
sidecars.push(e.relPath + SIDECAR_SUFFIX);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (e.remove) {
|
|
312
|
+
fs.copyFileSync(abs, abs + BACKUP_SUFFIX);
|
|
313
|
+
backups.push(e.relPath);
|
|
314
|
+
fs.rmSync(abs);
|
|
315
|
+
deleted.push(e.relPath);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (e.write !== null && e.write !== undefined) {
|
|
319
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
320
|
+
if (fs.existsSync(abs)) {
|
|
321
|
+
fs.copyFileSync(abs, abs + BACKUP_SUFFIX);
|
|
322
|
+
backups.push(e.relPath);
|
|
323
|
+
fs.writeFileSync(abs, e.write);
|
|
324
|
+
written.push(e.relPath);
|
|
325
|
+
} else {
|
|
326
|
+
fs.writeFileSync(abs, e.write);
|
|
327
|
+
created.push(e.relPath);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { written, created, deleted, sidecars, backups };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Reconstruct the engine config from a parsed create-cmp.json record.
|
|
336
|
+
* Key names differ slightly (record: name/bundleId ↔ config:
|
|
337
|
+
* appName/iosBundleId). Fields the record predates default to
|
|
338
|
+
* "feature absent" — a record written before a toggle existed describes an
|
|
339
|
+
* app whose tree does NOT carry that feature, so stamping without it mirrors
|
|
340
|
+
* the app best.
|
|
341
|
+
* @param {object} record parsed create-cmp.json
|
|
342
|
+
* @param {string} targetDir where the reconstructed stamp should land
|
|
343
|
+
* @returns {object} engine config (options.schema.json shape)
|
|
344
|
+
*/
|
|
345
|
+
export function configFromSpecRecord(record, targetDir) {
|
|
346
|
+
return {
|
|
347
|
+
appName: record.name,
|
|
348
|
+
package: record.package,
|
|
349
|
+
iosBundleId: record.bundleId,
|
|
350
|
+
region: record.region ?? "us-central1",
|
|
351
|
+
themePrefix: record.themePrefix,
|
|
352
|
+
platforms: record.platforms ?? { android: true, ios: true },
|
|
353
|
+
firebase: record.firebase ?? { enabled: false },
|
|
354
|
+
room: record.room ?? false,
|
|
355
|
+
e2e: record.e2e ?? false,
|
|
356
|
+
inspector: record.inspector ?? false,
|
|
357
|
+
devClient: record.devClient ?? false,
|
|
358
|
+
tabs: record.tabs ?? [
|
|
359
|
+
{ label: "Home", icon: "home" },
|
|
360
|
+
{ label: "Profile", icon: "person" },
|
|
361
|
+
],
|
|
362
|
+
targetDir,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Semantic validation for the `package` option, beyond what the JSON-schema
|
|
2
|
+
// pattern in options.schema.json can express.
|
|
3
|
+
//
|
|
4
|
+
// The pattern already enforces the SHAPE of a reverse-DNS id (lowercase start,
|
|
5
|
+
// at least two segments, no digit-leading segments). What it cannot encode is
|
|
6
|
+
// that every segment also has to be a legal *Java identifier*: the value
|
|
7
|
+
// becomes the Android namespace and the Kotlin package, and AGP rejects a
|
|
8
|
+
// reserved word outright —
|
|
9
|
+
//
|
|
10
|
+
// Namespace 'com.final.proof' is not a valid Java package name as 'final'
|
|
11
|
+
// is a Java keyword
|
|
12
|
+
//
|
|
13
|
+
// Observed for real: `--package com.final.proof` was accepted, stamped a full
|
|
14
|
+
// project, and only failed at the first Gradle configure — late, and as a raw
|
|
15
|
+
// Gradle stack rather than an input error. Refuse it at the door instead, and
|
|
16
|
+
// name the offending segment.
|
|
17
|
+
|
|
18
|
+
// Java SE reserved words (JLS §3.9), plus the three reserved literals and the
|
|
19
|
+
// lone underscore (reserved since Java 9). `var`/`yield`/`record`/`sealed` and
|
|
20
|
+
// friends are contextual, not reserved — they are legal package segments, so
|
|
21
|
+
// they are deliberately absent.
|
|
22
|
+
export const JAVA_KEYWORDS = new Set([
|
|
23
|
+
"abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
|
|
24
|
+
"class", "const", "continue", "default", "do", "double", "else", "enum",
|
|
25
|
+
"extends", "final", "finally", "float", "for", "goto", "if", "implements",
|
|
26
|
+
"import", "instanceof", "int", "interface", "long", "native", "new",
|
|
27
|
+
"package", "private", "protected", "public", "return", "short", "static",
|
|
28
|
+
"strictfp", "super", "switch", "synchronized", "this", "throw", "throws",
|
|
29
|
+
"transient", "try", "void", "volatile", "while",
|
|
30
|
+
// reserved literals
|
|
31
|
+
"true", "false", "null",
|
|
32
|
+
// reserved identifier (Java 9+)
|
|
33
|
+
"_",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Segments of `pkg` that cannot be Java identifiers.
|
|
38
|
+
* @param {string} pkg
|
|
39
|
+
* @returns {string[]} offending segments, in source order (may repeat)
|
|
40
|
+
*/
|
|
41
|
+
export function reservedSegments(pkg) {
|
|
42
|
+
if (typeof pkg !== "string" || pkg.length === 0) return [];
|
|
43
|
+
return pkg.split(".").filter((seg) => JAVA_KEYWORDS.has(seg));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate the package id's segments. Same error shape as schema.mjs's
|
|
48
|
+
* validate(), so callers can merge the two lists and format them together.
|
|
49
|
+
* @param {string} pkg
|
|
50
|
+
* @param {string} [path] error path label
|
|
51
|
+
* @returns {{ valid: boolean, errors: Array<{path: string, message: string}> }}
|
|
52
|
+
*/
|
|
53
|
+
export function validatePackageName(pkg, path = "package") {
|
|
54
|
+
const bad = reservedSegments(pkg);
|
|
55
|
+
if (bad.length === 0) return { valid: true, errors: [] };
|
|
56
|
+
const uniq = [...new Set(bad)];
|
|
57
|
+
const which = uniq.map((s) => `'${s}'`).join(", ");
|
|
58
|
+
const lead = uniq.length > 1
|
|
59
|
+
? `segments ${which} are Java keywords and cannot be package segments`
|
|
60
|
+
: `segment ${which} is a Java keyword and cannot be a package segment`;
|
|
61
|
+
return {
|
|
62
|
+
valid: false,
|
|
63
|
+
errors: [
|
|
64
|
+
{
|
|
65
|
+
path,
|
|
66
|
+
message:
|
|
67
|
+
`${lead} — Gradle will refuse the namespace. Rename it ` +
|
|
68
|
+
`(e.g. com.final.proof \u2192 com.finalproof).`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import path from "node:path";
|
|
|
18
18
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
19
|
|
|
20
20
|
import { validate, formatErrors } from "./lib/schema.mjs";
|
|
21
|
+
import { validatePackageName } from "./lib/package-name.mjs";
|
|
21
22
|
import { buildTokenMap, replaceTokens, replacePathTokens, isBinaryPath, slugifyAppName } from "./lib/tokens.mjs";
|
|
22
23
|
import { renamePackageDirs } from "./lib/rename.mjs";
|
|
23
24
|
import {
|
|
@@ -51,8 +52,12 @@ export function loadSchema(opts = {}) {
|
|
|
51
52
|
*/
|
|
52
53
|
export function validateConfig(config, opts = {}) {
|
|
53
54
|
const schema = loadSchema(opts);
|
|
54
|
-
const {
|
|
55
|
-
|
|
55
|
+
const { errors } = validate(config, schema);
|
|
56
|
+
// The schema pattern proves the SHAPE of the package id; this proves its
|
|
57
|
+
// segments are legal Java identifiers. Merged into one list so a config with
|
|
58
|
+
// both problems reports both at once.
|
|
59
|
+
errors.push(...validatePackageName(config?.package).errors);
|
|
60
|
+
if (errors.length > 0) {
|
|
56
61
|
const err = new Error(`Invalid config:\n${formatErrors(errors)}`);
|
|
57
62
|
err.validationErrors = errors;
|
|
58
63
|
throw err;
|
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This app is governed by its delivery contract (CLAUDE.md): behavior starts in specs/, done is `node qa/verify.mjs` with a committed receipt, approvals gate signed artifacts. The cmp-inspector MCP tools (preview loop, live tier) are the expected eyes — if they are absent from this session, that is a fault to diagnose (plugin disabled, session predates plugin enablement, or stale plugin copy; see cmp-doctor), not a cue to fall back to screenshots or blind adb.\"}}'"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"PreToolUse": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Bash",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "grep -qE 'screencap|uiautomator dump' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: raw pixels/blind taps lose structure. If cmp-inspector is connected, inspect_tree reads the semantic tree and navigate_and_inspect gives verified taps; if its tools are missing, diagnose first (cmp-doctor, Inspector MCP section).\"}}' || true"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "command",
|
|
24
|
+
"command": "grep -qE 'connected[A-Za-z]*AndroidTest|maestro test|adb (-s [^ ]+ )?(install|uninstall)' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: device evidence is lane-owned and batched. node qa/verify.mjs sequences the device steps once, last, under a machine-global per-serial lease (qa/lib/device-lease.mjs) — the one device is scarce, slow, and fragile, so device proof is a checkpoint, never an inner loop. Driving it by hand mid-task risks colliding with a running lane (wedged adbd, device offline, false reds, crossed app state). Ad-hoc debugging stays allowed; batch the evidence into the lane.\"}}' || true"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"type": "command",
|
|
28
|
+
"command": "in=$(cat); printf '%s' \"$in\" | grep -qE 'node qa/verify\\.mjs' && ! printf '%s' \"$in\" | grep -q -- '--fast' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: for inner-loop iteration, node qa/verify.mjs --fast skips the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke) and is much quicker. Run the full lane once, deliberately, before reporting work done — never speculatively, and never to re-confirm a result you already have. A --fast receipt never satisfies the done-gate.\"}}' || true"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
3
33
|
"Stop": [
|
|
4
34
|
{
|
|
5
35
|
"matcher": "",
|
package/template/CLAUDE.md
CHANGED
|
@@ -15,7 +15,10 @@ tests, and gates the whole tree to produce the receipt, so it is slow by design;
|
|
|
15
15
|
after every edit wastes the minutes it takes. Iterate on the fast tier, and run the lane
|
|
16
16
|
once — when you believe the change is done.
|
|
17
17
|
|
|
18
|
-
- **Inner loop — run continuously (seconds):** the preview loop (below) for UI
|
|
18
|
+
- **Inner loop — run continuously (seconds):** the preview loop (below) for UI;
|
|
19
|
+
`node qa/watch.mjs` for verification — a resident watcher that re-runs the fast tier
|
|
20
|
+
(`node qa/verify.mjs --fast`) on every save and re-prints the step table, so the
|
|
21
|
+
did-I-break-anything signal is free the way an IDE's errors-on-save are free; and
|
|
19
22
|
`./gradlew :composeApp:desktopTest` for the unit tests your change touches. This is where
|
|
20
23
|
you catch your own mistakes.
|
|
21
24
|
- **Checkpoint — run once, at done:** `node qa/verify.mjs`. It writes the receipt; commit
|
|
@@ -85,9 +88,25 @@ the tree. The governed `architecture` artifact (below) hashes the document along
|
|
|
85
88
|
- Never delete, weaken, or `@Ignore` a failing test to reach green. Fix the behavior — or,
|
|
86
89
|
if the test itself is wrong, say so in your summary and justify the change.
|
|
87
90
|
|
|
91
|
+
**Platform behavior tests live in `composeApp/src/androidInstrumentedTest`** — when a
|
|
92
|
+
feature touches alarms, notifications, lock-screen intents, or audio routing, its behavior
|
|
93
|
+
test goes there, because no desktop tier can see those OS facts. Assertion helpers:
|
|
94
|
+
`NotificationAsserts`, `AlarmAsserts`, `SystemState`. **Runtime state control** — put the
|
|
95
|
+
device into the state your claim is about, instead of waiting for it: `TimeWarp` (clock,
|
|
96
|
+
timezone), `DozeControl` (forced idle), `PermissionControl`, `ProcessControl`,
|
|
97
|
+
`NetworkControl`, `ConfigControl` (dark mode, font scale, per-app locale). They compose —
|
|
98
|
+
the exemplar proves an `allowWhileIdle` alarm delivers from inside forced deep idle by
|
|
99
|
+
nesting a clock warp in a Doze bracket. Exemplars: `PlatformBehaviorSeamTest`,
|
|
100
|
+
`RuntimeStateSeamTest`. Each organ's header states what it does NOT reproduce; read it
|
|
101
|
+
before claiming more than it proves. The lane's `androidChecks` step runs them when a
|
|
102
|
+
device is attached; see `docs/TESTING.md`.
|
|
103
|
+
|
|
88
104
|
## Evidence
|
|
89
105
|
|
|
90
106
|
`node qa/verify.mjs` writes `qa/evidence/latest.json` (schema: `qa/evidence/schema.json`).
|
|
107
|
+
Each PASS receipt names its **evidence rung** (L0 scaffold / L1 desktop / L2 device /
|
|
108
|
+
L3 release), derived from which steps actually ran and passed — never declared, and a
|
|
109
|
+
SKIPped step never upgrades it (see `docs/TESTING.md` §"The evidence ladder").
|
|
91
110
|
Commit it with your change; git history is the audit ledger. Binary artifacts under
|
|
92
111
|
`qa-artifacts/` are hashed into the receipt, never committed. The studio console's Evidence
|
|
93
112
|
page reconstructs the full audit trail from the git log of `latest.json` — every commit is
|
|
@@ -305,16 +324,35 @@ and tells you what your edit changed.
|
|
|
305
324
|
3. `preview_diff { screen }` proves the change in one call: `proven-clean` /
|
|
306
325
|
`changed-with-regressions` / `no-change`. No snapshot bookkeeping.
|
|
307
326
|
|
|
308
|
-
**
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
**If the tools are missing:** capability absence is a fault to diagnose and report — never
|
|
328
|
+
a silent fallback. If ToolSearch finds no `cmp-inspector` tools, STOP and tell the human
|
|
329
|
+
which it is: the plugin is disabled (`enabledPlugins` in `~/.claude/settings.json` or the
|
|
330
|
+
project settings); the session predates the plugin's enablement (MCP servers attach at
|
|
331
|
+
session start — restart the session; no in-session retry will surface them); or the plugin
|
|
332
|
+
copy is stale/broken (run cmp-doctor's inspector-MCP check group). Report before degrading.
|
|
333
|
+
|
|
334
|
+
**Degraded path** — for environments where the plugin is genuinely unavailable (CI, other
|
|
335
|
+
agents), and only after the fault is reported: `./gradlew :composeApp:renderScreens` renders
|
|
336
|
+
every screen to `composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>`
|
|
337
|
+
for one); `node qa/preview-gallery.mjs` builds a self-contained gallery page from the
|
|
338
|
+
output. What this loses: on-save re-render, changed-screen attribution, compile errors
|
|
339
|
+
in-band, and the `preview_diff` change proof — structured feedback replaced by pixels.
|
|
311
340
|
|
|
312
341
|
**Live tier — the human's live device view (standing step).** Whenever `connect_live`
|
|
313
342
|
succeeds, OFFER the `remoteUrl` it returns (`http://127.0.0.1:9500/inspect/remote`) to the
|
|
314
343
|
human — every time, not as a maybe. It is a self-contained browser page that mirrors the
|
|
315
344
|
running app (~700ms refresh) with click-to-tap driving the real device: they watch and drive
|
|
316
|
-
the actual app while you assert on the tree (`navigate_and_inspect`
|
|
317
|
-
`
|
|
345
|
+
the actual app while you assert on the tree (`navigate_and_inspect` — its before/after delta
|
|
346
|
+
is the change proof live — and `inspect_tree`). It is also the right way for a human to
|
|
347
|
+
*watch* an e2e run.
|
|
348
|
+
|
|
349
|
+
Asserting persisted state: `db_query` reads bounded rows from the running app's database;
|
|
350
|
+
use it when a flow's proof is a row existing (or not) after an action, instead of shelling
|
|
351
|
+
into sqlite or trusting the UI.
|
|
352
|
+
|
|
353
|
+
When the app crashes or misbehaves on device: `runtime_crashes` returns persisted crashes
|
|
354
|
+
with cause attribution and `runtime_logs` bounded structured logcat for the app's pid; use
|
|
355
|
+
these before hand-grepping `adb logcat`.
|
|
318
356
|
|
|
319
357
|
Screens come from `inspector/PreviewRegistry.kt` (desktopMain). The `add-feature` and
|
|
320
358
|
`add-screen` stampers auto-register stamped screens at the `// cmp:anchor preview-registry`
|
|
@@ -342,8 +380,15 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
342
380
|
| Command | What |
|
|
343
381
|
|---|---|
|
|
344
382
|
| `node qa/verify.mjs` | The verify lane (profile `local`) — the done checkpoint, run once |
|
|
383
|
+
| `node qa/verify.mjs --fast` | **Inner loop — NOT the done-gate**: skips the device/release tier (`releaseBuild`, `tokenDrift`, `e2eSmoke`, `androidChecks`, `releaseSmoke`), reuses unchanged pure-Node step results (`CACHED`, content-hashed inputs), and scopes unit tests to the working-tree change (broad-impact changes — build files, DI, theme, shared components, `qa/` — run the full suite). Its receipt records `"mode": "fast"`, earns no evidence rung, and the Stop hook refuses it — run the full lane once at done |
|
|
384
|
+
| `node qa/watch.mjs` | **Resident inner loop — never a gate**: watches `composeApp/src`, `specs/`, `qa/` and re-runs `node qa/verify.mjs --fast` on save (debounced — a save storm is one run; defers while a verify lane or a preview render holds the project). `--once` for a single pass, `--json` for line-per-run output. The done-gate stays one deliberate full `node qa/verify.mjs` run |
|
|
345
385
|
| `./gradlew :composeApp:desktopTest` | Unit tests only (fast inner loop) |
|
|
346
386
|
| `node qa/setup-hooks.mjs` | Enable the pre-push receipt gate (one-time, after `git init`) |
|
|
347
387
|
| `./gradlew :composeApp:assembleDebug` | Android debug build |
|
|
348
388
|
| `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
|
|
349
389
|
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
390
|
+
| `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
|
|
391
|
+
| `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
|
|
392
|
+
| `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
|
|
393
|
+
| `node qa/record-audit.mjs <subsystem>` | Record that a `cmp-audit` of an androidMain subsystem happened (appends subsystem + HEAD sha + timestamp to `qa/audits.jsonl`; refuses dirty/unknown targets). `--list` shows every derived subsystem and its audit status |
|
|
394
|
+
| `node qa/retrospective.mjs` | How this project actually uses its harness, from `qa/flight-recorder.jsonl` (appended by every lane run): fast vs full ratio, verbatim SKIP reasons grouped, whether the device tier is ever reached, longest stretch with no full lane. States only what the journal recorded |
|
package/template/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# __APP_NAME__
|
|
2
2
|
|
|
3
|
+
<!-- cmp:generated evidence -->
|
|
4
|
+
[](https://github.com/kvdm-co-pilot/create-cmp) — no verify receipt yet. Run `node qa/verify.mjs`.
|
|
5
|
+
<!-- /cmp:generated -->
|
|
6
|
+
|
|
3
7
|
A Kotlin / Compose Multiplatform app, generated by
|
|
4
8
|
[create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a **verification harness**:
|
|
5
9
|
the architecture, testing conventions, and definition of done are enforced mechanically, not
|