create-cmp-cli 0.12.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/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/template/CLAUDE.md +4 -1
- package/template/README.md +4 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/verify.mjs +305 -9
- package/template/qa/watch.mjs +2 -2
|
@@ -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
|
+
}
|
package/template/CLAUDE.md
CHANGED
|
@@ -388,4 +388,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
388
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. |
|
|
389
389
|
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
390
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 release-APK Maestro smoke (`releaseSmoke`) |
|
|
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
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// audit-cadence.mjs — the mechanical nudge that keeps cmp-audit from
|
|
2
|
+
// depending on someone remembering.
|
|
3
|
+
//
|
|
4
|
+
// The adversarial platform-semantics audit (the cmp-audit skill) found six
|
|
5
|
+
// latent defects on its first real outing — and it only ran because a human
|
|
6
|
+
// happened to ask. This module is the cheapest honest replacement for that
|
|
7
|
+
// memory: the release profile's receipt lists which androidMain subsystems
|
|
8
|
+
// changed since their last RECORDED audit, so the ship-time surface itself
|
|
9
|
+
// says "these platform seams moved and nobody has interrogated them since".
|
|
10
|
+
//
|
|
11
|
+
// It is a REPORT, never a gate. Audit debt is a judgment call (a one-line
|
|
12
|
+
// rename is not six latent defects), so this file computes facts and the
|
|
13
|
+
// human decides — a FAIL here would train people to game the ledger, which
|
|
14
|
+
// would destroy the only thing it has: honesty.
|
|
15
|
+
//
|
|
16
|
+
// The ledger (qa/audits.jsonl) is append-only, one JSON object per line:
|
|
17
|
+
// subsystem, the commit sha the audit ran against, an ISO timestamp, and who
|
|
18
|
+
// or what recorded it. Recording is a CLAIM — "this subsystem, as of this
|
|
19
|
+
// commit, was audited" — so recordAudit() derives the sha from HEAD itself
|
|
20
|
+
// and refuses to record when the subsystem's files differ from HEAD: a
|
|
21
|
+
// record claiming a commit the audited bytes did not match would be the
|
|
22
|
+
// exact dishonesty the whole harness exists to prevent.
|
|
23
|
+
//
|
|
24
|
+
// "Subsystem" is DERIVED, never configured: the immediate package directory
|
|
25
|
+
// under the app's androidMain Kotlin source root (the root is resolved from
|
|
26
|
+
// the android namespace in composeApp/build.gradle.kts). This template is
|
|
27
|
+
// stamped into apps whose package names it cannot know; deriving from the
|
|
28
|
+
// tree is the only definition that survives that. Kotlin files sitting
|
|
29
|
+
// directly at the package root belong to no package directory and are
|
|
30
|
+
// reported under the literal name "(root)" rather than invented into one.
|
|
31
|
+
|
|
32
|
+
import { execSync } from "node:child_process";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
export const AUDITS_REL_PATH = "qa/audits.jsonl";
|
|
37
|
+
export const AUDIT_RECORD_SCHEMA = "cmp-audit-record/1";
|
|
38
|
+
|
|
39
|
+
/** The pseudo-subsystem for Kotlin files directly at the androidMain package root. */
|
|
40
|
+
export const ROOT_SUBSYSTEM = "(root)";
|
|
41
|
+
|
|
42
|
+
function tryGit(root, cmd) {
|
|
43
|
+
try {
|
|
44
|
+
return execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tryGitLines(root, cmd) {
|
|
51
|
+
try {
|
|
52
|
+
const out = execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
53
|
+
return out.replace(/\n+$/, "").split("\n").filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the androidMain Kotlin package root for this app, relative to the
|
|
61
|
+
* project root — derived from the android `namespace` (falling back to
|
|
62
|
+
* `applicationId`) in composeApp/build.gradle.kts, never hardcoded.
|
|
63
|
+
* @param {string} root project root (absolute)
|
|
64
|
+
* @returns {{ok: true, rel: string}|{ok: false, reason: string}}
|
|
65
|
+
*/
|
|
66
|
+
export function androidMainPackageRoot(root) {
|
|
67
|
+
let gradle;
|
|
68
|
+
try {
|
|
69
|
+
gradle = fs.readFileSync(path.join(root, "composeApp", "build.gradle.kts"), "utf8");
|
|
70
|
+
} catch {
|
|
71
|
+
return { ok: false, reason: "composeApp/build.gradle.kts not readable — cannot derive the app package" };
|
|
72
|
+
}
|
|
73
|
+
const pkg = gradle.match(/namespace\s*=\s*"([^"]+)"/)?.[1] ?? gradle.match(/applicationId\s*=\s*"([^"]+)"/)?.[1];
|
|
74
|
+
if (!pkg) {
|
|
75
|
+
return { ok: false, reason: "no android namespace/applicationId in composeApp/build.gradle.kts — cannot derive the app package" };
|
|
76
|
+
}
|
|
77
|
+
const rel = path.posix.join("composeApp/src/androidMain/kotlin", ...pkg.split("."));
|
|
78
|
+
if (!fs.existsSync(path.join(root, rel))) {
|
|
79
|
+
return { ok: false, reason: `androidMain has no Kotlin sources under the app package (${rel} absent)` };
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, rel };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* List this app's androidMain subsystems: the immediate directories under
|
|
86
|
+
* the package root (sorted), plus ROOT_SUBSYSTEM when Kotlin files sit
|
|
87
|
+
* directly at the root.
|
|
88
|
+
* @param {string} root project root (absolute)
|
|
89
|
+
* @param {string} pkgRootRel from androidMainPackageRoot()
|
|
90
|
+
* @returns {string[]}
|
|
91
|
+
*/
|
|
92
|
+
export function listSubsystems(root, pkgRootRel) {
|
|
93
|
+
const abs = path.join(root, pkgRootRel);
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
const names = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
101
|
+
if (entries.some((e) => e.isFile() && e.name.endsWith(".kt"))) names.push(ROOT_SUBSYSTEM);
|
|
102
|
+
return names;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the audit ledger. Absent is the honest "no audit ever recorded"
|
|
107
|
+
* state; malformed lines are counted, never silently dropped — the report
|
|
108
|
+
* says how many records it could not read instead of under-counting audits.
|
|
109
|
+
* @param {string} root project root (absolute)
|
|
110
|
+
* @returns {{entries: Array<{subsystem: string, sha: string, at: string, by: string}>, malformed: number}}
|
|
111
|
+
*/
|
|
112
|
+
export function readAuditLedger(root) {
|
|
113
|
+
const p = path.join(root, AUDITS_REL_PATH);
|
|
114
|
+
if (!fs.existsSync(p)) return { entries: [], malformed: 0 };
|
|
115
|
+
let raw;
|
|
116
|
+
try {
|
|
117
|
+
raw = fs.readFileSync(p, "utf8");
|
|
118
|
+
} catch {
|
|
119
|
+
return { entries: [], malformed: 0 };
|
|
120
|
+
}
|
|
121
|
+
const entries = [];
|
|
122
|
+
let malformed = 0;
|
|
123
|
+
for (const line of raw.split("\n")) {
|
|
124
|
+
if (!line.trim()) continue;
|
|
125
|
+
try {
|
|
126
|
+
const e = JSON.parse(line);
|
|
127
|
+
if (e && typeof e === "object" && typeof e.subsystem === "string" && typeof e.sha === "string") entries.push(e);
|
|
128
|
+
else malformed += 1;
|
|
129
|
+
} catch {
|
|
130
|
+
malformed += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { entries, malformed };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Record an audit claim: append {subsystem, sha: HEAD, at, by} to the
|
|
138
|
+
* ledger. Honesty guards, each a refusal rather than a fabrication:
|
|
139
|
+
* - no git HEAD → refused (a claim about no commit is not a claim);
|
|
140
|
+
* - unknown subsystem → refused, naming the derived ones;
|
|
141
|
+
* - the subsystem's files differ from HEAD → refused (the record would
|
|
142
|
+
* claim HEAD while the audited bytes are something else — commit first).
|
|
143
|
+
* @param {string} root project root (absolute)
|
|
144
|
+
* @param {{subsystem: string, by?: string}} claim
|
|
145
|
+
* @returns {{ok: true, sha: string, entry: object}|{ok: false, reason: string}}
|
|
146
|
+
*/
|
|
147
|
+
export function recordAudit(root, { subsystem, by }) {
|
|
148
|
+
const sha = tryGit(root, "rev-parse HEAD");
|
|
149
|
+
if (!sha) {
|
|
150
|
+
return { ok: false, reason: "no git history — an audit record is a claim about a specific commit, and there is none to claim against. Commit first." };
|
|
151
|
+
}
|
|
152
|
+
const pkgRoot = androidMainPackageRoot(root);
|
|
153
|
+
if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
|
|
154
|
+
const known = listSubsystems(root, pkgRoot.rel);
|
|
155
|
+
if (!known.includes(subsystem)) {
|
|
156
|
+
return { ok: false, reason: `unknown subsystem "${subsystem}" — derived subsystems under ${pkgRoot.rel}: ${known.join(", ") || "(none)"}` };
|
|
157
|
+
}
|
|
158
|
+
const scope = subsystem === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, subsystem);
|
|
159
|
+
const dirty = tryGitLines(root, `status --porcelain -- "${scope}"`) ?? [];
|
|
160
|
+
// For "(root)" the porcelain scope is the whole package root; narrow to
|
|
161
|
+
// files directly at the root so a dirty subsystem dir doesn't block a
|
|
162
|
+
// root-level record it has nothing to do with.
|
|
163
|
+
const relevantDirty =
|
|
164
|
+
subsystem === ROOT_SUBSYSTEM
|
|
165
|
+
? dirty.filter((l) => {
|
|
166
|
+
const rel = l.slice(3).trim();
|
|
167
|
+
return path.posix.dirname(rel) === pkgRoot.rel;
|
|
168
|
+
})
|
|
169
|
+
: dirty;
|
|
170
|
+
if (relevantDirty.length > 0) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
reason: `uncommitted changes under ${scope} — the record would claim commit ${sha.slice(0, 7)} but the audited files are not that commit. Commit (or revert) first, then record.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const entry = {
|
|
177
|
+
schema: AUDIT_RECORD_SCHEMA,
|
|
178
|
+
subsystem,
|
|
179
|
+
sha,
|
|
180
|
+
at: new Date().toISOString(),
|
|
181
|
+
by: by || tryGit(root, "config user.name") || "unknown",
|
|
182
|
+
};
|
|
183
|
+
const p = path.join(root, AUDITS_REL_PATH);
|
|
184
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
185
|
+
fs.appendFileSync(p, `${JSON.stringify(entry)}\n`);
|
|
186
|
+
return { ok: true, sha, entry };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The report itself: for every derived subsystem, what the ledger claims
|
|
191
|
+
* and what git says moved since that claim.
|
|
192
|
+
*
|
|
193
|
+
* Statuses, each phrased so the receipt can print the line verbatim:
|
|
194
|
+
* never-audited no ledger entry — says exactly that, implies no staleness
|
|
195
|
+
* changed androidMain files under it changed between the audited
|
|
196
|
+
* sha and HEAD (committed changes only — sha vs HEAD is
|
|
197
|
+
* the honest comparison; the working tree is not history)
|
|
198
|
+
* unchanged no committed change since the audited sha
|
|
199
|
+
* unknown-commit the ledger names a sha this repo's history does not
|
|
200
|
+
* contain — drift cannot be measured, and the report says
|
|
201
|
+
* so instead of guessing
|
|
202
|
+
*
|
|
203
|
+
* @param {string} root project root (absolute)
|
|
204
|
+
* @returns {{ok: false, reason: string}|{ok: true, packageRoot: string,
|
|
205
|
+
* subsystems: Array<{name: string, status: string, audit: object|null, changedFiles: number}>,
|
|
206
|
+
* lines: string[], summary: string, malformed: number}}
|
|
207
|
+
*/
|
|
208
|
+
export function evaluateAuditCadence(root) {
|
|
209
|
+
if (!tryGit(root, "rev-parse HEAD")) {
|
|
210
|
+
// No git history: "changed since the last audit" has no meaning yet.
|
|
211
|
+
// Report NOTHING rather than guessing — an invented staleness signal
|
|
212
|
+
// would be worse than none.
|
|
213
|
+
return { ok: false, reason: "no git history — changed-since-audit cannot be measured" };
|
|
214
|
+
}
|
|
215
|
+
const pkgRoot = androidMainPackageRoot(root);
|
|
216
|
+
if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
|
|
217
|
+
const subsystems = listSubsystems(root, pkgRoot.rel);
|
|
218
|
+
if (subsystems.length === 0) {
|
|
219
|
+
return { ok: false, reason: `no subsystems under ${pkgRoot.rel} — nothing to report` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { entries, malformed } = readAuditLedger(root);
|
|
223
|
+
// Last entry per subsystem wins: the ledger is append-only, so file order
|
|
224
|
+
// IS chronological order — trusted over the `at` timestamps, which are
|
|
225
|
+
// claims a machine's clock made, not facts git can vouch for.
|
|
226
|
+
const latest = new Map();
|
|
227
|
+
for (const e of entries) latest.set(e.subsystem, e);
|
|
228
|
+
|
|
229
|
+
const gitTop = tryGit(root, "rev-parse --show-toplevel");
|
|
230
|
+
// Realpath both sides before re-anchoring diff paths: git reports the
|
|
231
|
+
// toplevel with symlinks resolved (macOS: /var/… vs /private/var/…), and a
|
|
232
|
+
// mismatch here would silently mis-attribute every changed file.
|
|
233
|
+
let rootReal = root;
|
|
234
|
+
try {
|
|
235
|
+
rootReal = fs.realpathSync(root);
|
|
236
|
+
} catch {
|
|
237
|
+
rootReal = root;
|
|
238
|
+
}
|
|
239
|
+
const results = [];
|
|
240
|
+
const lines = [];
|
|
241
|
+
for (const name of subsystems) {
|
|
242
|
+
const audit = latest.get(name) ?? null;
|
|
243
|
+
if (!audit) {
|
|
244
|
+
results.push({ name, status: "never-audited", audit: null, changedFiles: 0 });
|
|
245
|
+
lines.push(`no audit recorded for ${name} — when it gets one (cmp-audit ${name}), record it: node qa/record-audit.mjs ${JSON.stringify(name)}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// A ledger sha is a CLAIM read from a file — validate its shape before it
|
|
249
|
+
// touches a shell, and resolve it against history before trusting it.
|
|
250
|
+
const shaShapeOk = typeof audit.sha === "string" && /^[0-9a-f]{4,40}$/i.test(audit.sha);
|
|
251
|
+
const shaKnown = shaShapeOk && Boolean(tryGit(root, `rev-parse --verify --quiet "${audit.sha}^{commit}"`));
|
|
252
|
+
if (!shaKnown) {
|
|
253
|
+
results.push({ name, status: "unknown-commit", audit, changedFiles: 0 });
|
|
254
|
+
lines.push(`${name}: last audit (${fmtWhen(audit)}) was recorded against ${audit.sha.slice(0, 12)}, which is not in this repo's history — drift since it cannot be measured`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const scope = name === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, name);
|
|
258
|
+
const changedRaw = tryGitLines(root, `diff --name-only ${audit.sha} HEAD -- "${scope}"`) ?? [];
|
|
259
|
+
// Diff paths come back relative to the git toplevel, which may sit above
|
|
260
|
+
// the project root; re-anchor before subsystem attribution.
|
|
261
|
+
const changed = changedRaw
|
|
262
|
+
.map((rel) => (gitTop ? path.relative(rootReal, path.resolve(gitTop, rel)).split(path.sep).join("/") : rel))
|
|
263
|
+
.filter((rel) => (name === ROOT_SUBSYSTEM ? path.posix.dirname(rel) === pkgRoot.rel : true));
|
|
264
|
+
if (changed.length > 0) {
|
|
265
|
+
results.push({ name, status: "changed", audit, changedFiles: changed.length });
|
|
266
|
+
lines.push(
|
|
267
|
+
`${name}: ${changed.length} androidMain file(s) changed since its last recorded audit (${audit.sha.slice(0, 7)}, ${fmtWhen(audit)}) — audit it (cmp-audit ${name}), then record: node qa/record-audit.mjs ${JSON.stringify(name)}`,
|
|
268
|
+
);
|
|
269
|
+
} else {
|
|
270
|
+
results.push({ name, status: "unchanged", audit, changedFiles: 0 });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const changedCount = results.filter((r) => r.status === "changed").length;
|
|
275
|
+
const neverCount = results.filter((r) => r.status === "never-audited").length;
|
|
276
|
+
const unchangedCount = results.filter((r) => r.status === "unchanged").length;
|
|
277
|
+
if (unchangedCount > 0) {
|
|
278
|
+
lines.push(`${unchangedCount} subsystem(s) unchanged since their last recorded audit: ${results.filter((r) => r.status === "unchanged").map((r) => r.name).join(", ")}`);
|
|
279
|
+
}
|
|
280
|
+
if (malformed > 0) {
|
|
281
|
+
lines.push(`${malformed} ledger line(s) in ${AUDITS_REL_PATH} could not be parsed and are not counted`);
|
|
282
|
+
}
|
|
283
|
+
const summary = `${changedCount} changed since audit · ${neverCount} never audited · ${unchangedCount} unchanged`;
|
|
284
|
+
|
|
285
|
+
return { ok: true, packageRoot: pkgRoot.rel, subsystems: results, lines, summary, malformed };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function fmtWhen(audit) {
|
|
289
|
+
return typeof audit.at === "string" ? audit.at.slice(0, 10) : "undated";
|
|
290
|
+
}
|