create-cmp-cli 0.12.0 → 0.14.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 +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1709 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +383 -0
- package/src/lib/harness-upgrade.mjs +521 -0
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +34 -1
- package/template/README.md +4 -0
- package/template/gitignore +8 -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/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +17 -2
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/verify.mjs +400 -10
- package/template/qa/watch.mjs +2 -2
|
@@ -0,0 +1,521 @@
|
|
|
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
|
+
import { isHarnessFile } from "../../packages/harness/src/lib/harness-region.mjs";
|
|
46
|
+
|
|
47
|
+
/** Sidecar suffix for the new engine content beside a conflicted file. */
|
|
48
|
+
export const SIDECAR_SUFFIX = ".cmp-new";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hard exclusion list — the app's own state, or its secrets, that the engine
|
|
52
|
+
* also seeds. A diff here is noise or danger, never an upgrade. Matched
|
|
53
|
+
* against the project-relative path (posix separators):
|
|
54
|
+
* - a bare name (no `/`) matches that basename at ANY depth — this is what
|
|
55
|
+
* keeps `keystore.properties` and `google-services.json` excluded wherever
|
|
56
|
+
* they live (e.g. composeApp/google-services.json), because those files
|
|
57
|
+
* must never be read, written, or printed by this code;
|
|
58
|
+
* - `dir/**` matches everything under `dir` (root-anchored);
|
|
59
|
+
* - `**` + `/dir/` + `**` matches everything under a `dir` segment at any depth;
|
|
60
|
+
* - `**` + `/name` matches that basename at any depth.
|
|
61
|
+
*/
|
|
62
|
+
export const EXCLUDED_PATTERNS = [
|
|
63
|
+
"create-cmp.json",
|
|
64
|
+
"qa/evidence/**",
|
|
65
|
+
"qa/approvals.json",
|
|
66
|
+
// Derived state, rewritten explicitly once the region has landed. Sweeping
|
|
67
|
+
// it would copy a stale manifest in, back up a value that was about to be
|
|
68
|
+
// replaced anyway, and count a guaranteed no-op as actionable work.
|
|
69
|
+
"qa/harness.lock.json",
|
|
70
|
+
"qa/comments.json",
|
|
71
|
+
"qa/golden/**",
|
|
72
|
+
".git/**",
|
|
73
|
+
"build/**",
|
|
74
|
+
"**/build/**",
|
|
75
|
+
".gradle/**",
|
|
76
|
+
"local.properties",
|
|
77
|
+
"keystore.properties",
|
|
78
|
+
"google-services.json",
|
|
79
|
+
"**/GoogleService-Info.plist",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Does one exclusion pattern match a project-relative posix path?
|
|
84
|
+
* @param {string} relPath project-relative path, "/"-separated
|
|
85
|
+
* @param {string} pattern one of EXCLUDED_PATTERNS (see grammar above)
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function matchesPattern(relPath, pattern) {
|
|
89
|
+
if (pattern.endsWith("/**")) {
|
|
90
|
+
const dir = pattern.slice(0, -3);
|
|
91
|
+
if (dir.startsWith("**/")) {
|
|
92
|
+
// "**/build/**": any DIRECTORY segment equal to the name.
|
|
93
|
+
const seg = dir.slice(3);
|
|
94
|
+
return relPath.split("/").slice(0, -1).includes(seg);
|
|
95
|
+
}
|
|
96
|
+
return relPath === dir || relPath.startsWith(dir + "/");
|
|
97
|
+
}
|
|
98
|
+
if (pattern.startsWith("**/")) {
|
|
99
|
+
const name = pattern.slice(3);
|
|
100
|
+
return relPath === name || relPath.endsWith("/" + name);
|
|
101
|
+
}
|
|
102
|
+
if (!pattern.includes("/")) {
|
|
103
|
+
// Bare name: basename match at any depth (never risk touching a nested
|
|
104
|
+
// secret because it wasn't at the root).
|
|
105
|
+
return relPath === pattern || relPath.endsWith("/" + pattern);
|
|
106
|
+
}
|
|
107
|
+
return relPath === pattern;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Is this project-relative path on the hard exclusion list?
|
|
112
|
+
* Checked BEFORE any file content is read — excluded files (state, secrets)
|
|
113
|
+
* are never opened by this module.
|
|
114
|
+
* @param {string} relPath project-relative path, "/"-separated
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
export function isExcludedPath(relPath) {
|
|
118
|
+
return EXCLUDED_PATTERNS.some((p) => matchesPattern(relPath, p));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Three-way merge via `git merge-file -p --diff3 <theirs> <base> <new>`.
|
|
123
|
+
* The three sides are written to temp files; git's exit code is 0 for a clean
|
|
124
|
+
* merge, >0 for the number of conflicts, <0 / spawn error for trouble — both
|
|
125
|
+
* of the latter are treated as a conflict (the caller then writes a sidecar
|
|
126
|
+
* instead of touching the app's file, so "treat as conflict" is always safe).
|
|
127
|
+
* @param {Buffer} theirs the app's current content
|
|
128
|
+
* @param {Buffer} base the old engine's stamped content
|
|
129
|
+
* @param {Buffer} next the current engine's stamped content
|
|
130
|
+
* @returns {{clean: boolean, content: Buffer|null}} merged content when clean
|
|
131
|
+
*/
|
|
132
|
+
export function mergeThreeWay(theirs, base, next) {
|
|
133
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cmp-harness-merge-"));
|
|
134
|
+
try {
|
|
135
|
+
const t = path.join(dir, "theirs");
|
|
136
|
+
const b = path.join(dir, "base");
|
|
137
|
+
const n = path.join(dir, "new");
|
|
138
|
+
fs.writeFileSync(t, theirs);
|
|
139
|
+
fs.writeFileSync(b, base);
|
|
140
|
+
fs.writeFileSync(n, next);
|
|
141
|
+
const r = spawnSync("git", ["merge-file", "-p", "--diff3", t, b, n], {
|
|
142
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
143
|
+
});
|
|
144
|
+
if (r.error || r.status === null || r.status !== 0) {
|
|
145
|
+
return { clean: false, content: null };
|
|
146
|
+
}
|
|
147
|
+
return { clean: true, content: r.stdout };
|
|
148
|
+
} finally {
|
|
149
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The decision table — one file, three sides, one verdict. Buckets:
|
|
155
|
+
* unchanged engine never changed it (base == new) → silent
|
|
156
|
+
* current app already matches the new engine → silent
|
|
157
|
+
* applied app never touched it, engine changed it → write new
|
|
158
|
+
* merged both changed, three-way merge is clean → write merged
|
|
159
|
+
* conflicted both changed the same region (or binary, or the app deleted
|
|
160
|
+
* a file the engine changed) → NEVER clobber: leave the app's
|
|
161
|
+
* file byte-for-byte alone, emit a `.cmp-new` sidecar with the
|
|
162
|
+
* new engine's content
|
|
163
|
+
* added new engine file absent from the app → write new
|
|
164
|
+
* removed engine deleted it and the app never touched it → delete
|
|
165
|
+
* orphaned engine deleted it but the app modified it → keep, report
|
|
166
|
+
*
|
|
167
|
+
* Binary files (isBinaryPath) never three-way merge: replace when the app
|
|
168
|
+
* never touched them, otherwise conflicted.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} params
|
|
171
|
+
* @param {string} params.relPath project-relative path (for binary sniffing)
|
|
172
|
+
* @param {Buffer|null} params.base old engine's content (null = not stamped then)
|
|
173
|
+
* @param {Buffer|null} params.next current engine's content (null = engine deleted it)
|
|
174
|
+
* @param {Buffer|null} params.theirs the app's content (null = absent in the app)
|
|
175
|
+
* @param {(theirs:Buffer, base:Buffer, next:Buffer)=>{clean:boolean,content:Buffer|null}} [params.merge]
|
|
176
|
+
* three-way merge fn, injectable for tests (default: git merge-file)
|
|
177
|
+
* @returns {{bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}|null}
|
|
178
|
+
* null when the path is in neither base nor new (app-authored — invisible)
|
|
179
|
+
*/
|
|
180
|
+
|
|
181
|
+
/** Where a preserved local patch to machine-owned lane code is written. */
|
|
182
|
+
export const LOCAL_PATCH_PATH = "qa/harness-local.patch";
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Unified diff from `base` to `theirs`, rewritten so it applies against the
|
|
186
|
+
* project tree (`git apply qa/harness-local.patch`). git diff --no-index exits
|
|
187
|
+
* 1 when there ARE differences, which is the only case we call it in.
|
|
188
|
+
* @param {Buffer} base
|
|
189
|
+
* @param {Buffer} theirs
|
|
190
|
+
* @param {string} relPath project-relative path the patch should name
|
|
191
|
+
* @returns {string} patch text, or "" if git could not produce one
|
|
192
|
+
*/
|
|
193
|
+
export function diffPatch(base, theirs, relPath) {
|
|
194
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cmp-harness-diff-"));
|
|
195
|
+
try {
|
|
196
|
+
const b = path.join(dir, "base");
|
|
197
|
+
const t = path.join(dir, "theirs");
|
|
198
|
+
fs.writeFileSync(b, base);
|
|
199
|
+
fs.writeFileSync(t, theirs);
|
|
200
|
+
const r = spawnSync("git", ["diff", "--no-index", "--no-color", "--", b, t], {
|
|
201
|
+
encoding: "utf8",
|
|
202
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
203
|
+
});
|
|
204
|
+
// 0 = identical (we never call it then), 1 = differences, >1 = trouble.
|
|
205
|
+
if (r.error || (r.status !== 0 && r.status !== 1) || !r.stdout) return "";
|
|
206
|
+
return r.stdout
|
|
207
|
+
.split("\n")
|
|
208
|
+
.map((line) => {
|
|
209
|
+
if (line.startsWith("diff --git ")) return `diff --git a/${relPath} b/${relPath}`;
|
|
210
|
+
if (line.startsWith("--- ")) return `--- a/${relPath}`;
|
|
211
|
+
if (line.startsWith("+++ ")) return `+++ b/${relPath}`;
|
|
212
|
+
return line;
|
|
213
|
+
})
|
|
214
|
+
.join("\n");
|
|
215
|
+
} finally {
|
|
216
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The decision table for MACHINE-OWNED lane files — a different question from
|
|
222
|
+
* the one decideFile asks about app-shaped files.
|
|
223
|
+
*
|
|
224
|
+
* Lane code is byte-identical in every create-cmp app and carries no app
|
|
225
|
+
* content, so it is a derived artifact, and the right operation on a derived
|
|
226
|
+
* artifact is REPLACE, not merge. Three-way merging it is what made upgrades
|
|
227
|
+
* expensive: the 0.13.0 pilots produced ~1,000 conflicted lines per app whose
|
|
228
|
+
* diffs contained zero app-specific tokens.
|
|
229
|
+
*
|
|
230
|
+
* The region is therefore always taken to the new engine's content. What
|
|
231
|
+
* varies is how honestly we account for what the app had:
|
|
232
|
+
*
|
|
233
|
+
* region-current already the new content → silent
|
|
234
|
+
* region-clean untouched since stamp → replace
|
|
235
|
+
* region-absorbed locally edited, but the edit is ALREADY in the new
|
|
236
|
+
* content (the app hand-mirrored engine work — the
|
|
237
|
+
* pilots' overwhelmingly common case) → replace, silent
|
|
238
|
+
* region-patched a genuine local fork of lane code → replace, AND
|
|
239
|
+
* preserve base→theirs as a patch so nothing is lost
|
|
240
|
+
* added / removed as for any other file
|
|
241
|
+
*
|
|
242
|
+
* "region-patched" deliberately does NOT block or merge. A local edit to lane
|
|
243
|
+
* code is a fork the app is maintaining; making it explicit (a reviewable
|
|
244
|
+
* patch file plus a loud report) is better than the status quo, where the
|
|
245
|
+
* divergence was invisible until it cost a thousand hand-resolved lines at
|
|
246
|
+
* the next upgrade.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} params
|
|
249
|
+
* @param {string} params.relPath
|
|
250
|
+
* @param {Buffer|null} params.base
|
|
251
|
+
* @param {Buffer|null} params.next
|
|
252
|
+
* @param {Buffer|null} params.theirs
|
|
253
|
+
* @param {Function} [params.merge] injectable three-way merge (classifier only)
|
|
254
|
+
* @returns {{bucket:string, write:Buffer|null, sidecar:null, remove:boolean, patch?:string}|null}
|
|
255
|
+
*/
|
|
256
|
+
export function decideRegionFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
|
|
257
|
+
const eq = (a, b) => a !== null && b !== null && a.equals(b);
|
|
258
|
+
const replace = (bucket, extra = {}) => ({
|
|
259
|
+
bucket,
|
|
260
|
+
write: next,
|
|
261
|
+
sidecar: null,
|
|
262
|
+
remove: false,
|
|
263
|
+
...extra,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (next === null) {
|
|
267
|
+
// The engine dropped this lane file. Nothing app-owned can live here, so
|
|
268
|
+
// there is no "orphaned" case to protect — remove it.
|
|
269
|
+
if (theirs === null) return { bucket: "current", write: null, sidecar: null, remove: false };
|
|
270
|
+
return { bucket: "region-removed", write: null, sidecar: null, remove: true };
|
|
271
|
+
}
|
|
272
|
+
if (theirs === null) return replace(base === null ? "added" : "region-restored");
|
|
273
|
+
if (eq(theirs, next)) return { bucket: "current", write: null, sidecar: null, remove: false };
|
|
274
|
+
if (base === null) return replace("region-patched", { patch: "" }); // no base to diff against
|
|
275
|
+
if (eq(theirs, base)) return replace("region-clean");
|
|
276
|
+
|
|
277
|
+
// The app edited lane code. Is that edit already carried by the new engine?
|
|
278
|
+
// A clean three-way merge landing exactly on `next` means the local change
|
|
279
|
+
// contributed nothing beyond it — the hand-mirror case.
|
|
280
|
+
const m = merge(theirs, base, next);
|
|
281
|
+
if (m.clean && m.content !== null && m.content.equals(next)) return replace("region-absorbed");
|
|
282
|
+
|
|
283
|
+
return replace("region-patched", { patch: diffPatch(base, theirs, relPath) });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
|
|
287
|
+
// Machine-owned lane files answer a different question — see decideRegionFile.
|
|
288
|
+
if (isHarnessFile(relPath) && !(base === null && next === null)) {
|
|
289
|
+
return decideRegionFile({ relPath, base, next, theirs, merge });
|
|
290
|
+
}
|
|
291
|
+
const none = (bucket) => ({ bucket, write: null, sidecar: null, remove: false });
|
|
292
|
+
const write = (bucket, content) => ({ bucket, write: content, sidecar: null, remove: false });
|
|
293
|
+
const conflict = () => ({ bucket: "conflicted", write: null, sidecar: next, remove: false });
|
|
294
|
+
const eq = (a, b) => a !== null && b !== null && a.equals(b);
|
|
295
|
+
|
|
296
|
+
if (base !== null && next !== null) {
|
|
297
|
+
if (eq(base, next)) return none("unchanged"); // engine never changed it
|
|
298
|
+
// Engine changed it:
|
|
299
|
+
if (theirs === null) return conflict(); // app deleted it — never resurrect silently
|
|
300
|
+
if (eq(theirs, next)) return none("current"); // already up to date
|
|
301
|
+
if (eq(theirs, base)) return write("applied", next); // app never touched it
|
|
302
|
+
// All three differ:
|
|
303
|
+
if (isBinaryPath(relPath)) return conflict(); // binaries never merge
|
|
304
|
+
const m = merge(theirs, base, next);
|
|
305
|
+
if (m.clean && m.content !== null) {
|
|
306
|
+
// A merge that reproduces the app's file byte-for-byte means the app
|
|
307
|
+
// already carries the engine change (e.g. a previous --harness run) —
|
|
308
|
+
// report current, keep re-runs idempotent and quiet.
|
|
309
|
+
if (m.content.equals(theirs)) return none("current");
|
|
310
|
+
return write("merged", m.content);
|
|
311
|
+
}
|
|
312
|
+
return conflict();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (base === null && next !== null) {
|
|
316
|
+
// New engine file.
|
|
317
|
+
if (theirs === null) return write("added", next);
|
|
318
|
+
if (eq(theirs, next)) return none("current");
|
|
319
|
+
return conflict(); // the app already has something different there
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (base !== null && next === null) {
|
|
323
|
+
// Engine deleted it.
|
|
324
|
+
if (theirs === null) return none("current"); // already gone
|
|
325
|
+
if (eq(theirs, base)) return { bucket: "removed", write: null, sidecar: null, remove: true };
|
|
326
|
+
return none("orphaned"); // app modified it — keep it, report it
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return null; // in neither base nor new: app-authored, invisible to the sweep
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function readIfPresent(dir, relPath) {
|
|
333
|
+
try {
|
|
334
|
+
return fs.readFileSync(path.join(dir, relPath));
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function toRel(root, abs) {
|
|
341
|
+
return path.relative(root, abs).split(path.sep).join("/");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Walk base ∪ new, classify every path through the decision table, and return
|
|
346
|
+
* the full plan. Excluded paths are counted WITHOUT ever reading their
|
|
347
|
+
* content (state files and secrets stay unopened). Reads the three trees but
|
|
348
|
+
* never writes anything — applying is applyHarnessPlan's job.
|
|
349
|
+
* @param {object} params
|
|
350
|
+
* @param {string} params.baseDir stamped tree of the old engine
|
|
351
|
+
* @param {string} params.newDir stamped tree of the current engine
|
|
352
|
+
* @param {string} params.projectDir the app's working tree
|
|
353
|
+
* @param {Function} [params.merge] three-way merge fn, injectable for tests
|
|
354
|
+
* @returns {{entries: Array<{relPath:string, bucket:string, write?:Buffer|null,
|
|
355
|
+
* sidecar?:Buffer|null, remove?:boolean}>,
|
|
356
|
+
* counts: Record<string, number>}}
|
|
357
|
+
*/
|
|
358
|
+
export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeThreeWay }) {
|
|
359
|
+
const rels = new Set();
|
|
360
|
+
for (const f of listFiles(baseDir)) rels.add(toRel(baseDir, f));
|
|
361
|
+
for (const f of listFiles(newDir)) rels.add(toRel(newDir, f));
|
|
362
|
+
|
|
363
|
+
const counts = {
|
|
364
|
+
excluded: 0,
|
|
365
|
+
unchanged: 0,
|
|
366
|
+
current: 0,
|
|
367
|
+
applied: 0,
|
|
368
|
+
merged: 0,
|
|
369
|
+
conflicted: 0,
|
|
370
|
+
added: 0,
|
|
371
|
+
removed: 0,
|
|
372
|
+
orphaned: 0,
|
|
373
|
+
// Machine-owned lane buckets (decideRegionFile).
|
|
374
|
+
"region-clean": 0,
|
|
375
|
+
"region-absorbed": 0,
|
|
376
|
+
"region-patched": 0,
|
|
377
|
+
"region-restored": 0,
|
|
378
|
+
"region-removed": 0,
|
|
379
|
+
};
|
|
380
|
+
const entries = [];
|
|
381
|
+
for (const relPath of [...rels].sort()) {
|
|
382
|
+
if (isExcludedPath(relPath)) {
|
|
383
|
+
counts.excluded += 1;
|
|
384
|
+
entries.push({ relPath, bucket: "excluded", write: null, sidecar: null, remove: false });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const decision = decideFile({
|
|
388
|
+
relPath,
|
|
389
|
+
base: readIfPresent(baseDir, relPath),
|
|
390
|
+
next: readIfPresent(newDir, relPath),
|
|
391
|
+
theirs: readIfPresent(projectDir, relPath),
|
|
392
|
+
merge,
|
|
393
|
+
});
|
|
394
|
+
if (decision === null) continue;
|
|
395
|
+
counts[decision.bucket] = (counts[decision.bucket] ?? 0) + 1;
|
|
396
|
+
entries.push({ relPath, ...decision });
|
|
397
|
+
}
|
|
398
|
+
return { entries, counts };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Apply a plan to the app's working tree. Every file that gets written over
|
|
403
|
+
* or deleted is backed up first as `<file>${BACKUP_SUFFIX}` (same suffix as
|
|
404
|
+
* the version-catalog upgrade path, so one revert story covers both modes).
|
|
405
|
+
* Conflicted entries never touch the app's file — only the `.cmp-new` sidecar
|
|
406
|
+
* is written. Returns what happened so the CLI can print revert commands.
|
|
407
|
+
* @param {string} projectDir
|
|
408
|
+
* @param {Array<{relPath:string, bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}>} entries
|
|
409
|
+
* @returns {{written:string[], created:string[], deleted:string[],
|
|
410
|
+
* sidecars:string[], backups:string[]}}
|
|
411
|
+
* written rel paths overwritten (backup exists)
|
|
412
|
+
* created rel paths newly created (no previous content, no backup)
|
|
413
|
+
* deleted rel paths removed (backup exists)
|
|
414
|
+
* sidecars rel paths of `.cmp-new` files written beside conflicts
|
|
415
|
+
* backups rel paths that have a `${BACKUP_SUFFIX}` copy
|
|
416
|
+
*/
|
|
417
|
+
export function applyHarnessPlan(projectDir, entries) {
|
|
418
|
+
const written = [];
|
|
419
|
+
const created = [];
|
|
420
|
+
const deleted = [];
|
|
421
|
+
const sidecars = [];
|
|
422
|
+
const backups = [];
|
|
423
|
+
const patched = [];
|
|
424
|
+
const patchChunks = [];
|
|
425
|
+
for (const e of entries) {
|
|
426
|
+
if (e.bucket === "region-patched" && e.patch) {
|
|
427
|
+
patched.push(e.relPath);
|
|
428
|
+
patchChunks.push(e.patch.endsWith("\n") ? e.patch : `${e.patch}\n`);
|
|
429
|
+
}
|
|
430
|
+
const abs = path.join(projectDir, e.relPath);
|
|
431
|
+
if (e.sidecar !== null && e.sidecar !== undefined) {
|
|
432
|
+
const sidecarPath = abs + SIDECAR_SUFFIX;
|
|
433
|
+
fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
|
|
434
|
+
fs.writeFileSync(sidecarPath, e.sidecar);
|
|
435
|
+
sidecars.push(e.relPath + SIDECAR_SUFFIX);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (e.remove) {
|
|
439
|
+
fs.copyFileSync(abs, abs + BACKUP_SUFFIX);
|
|
440
|
+
backups.push(e.relPath);
|
|
441
|
+
fs.rmSync(abs);
|
|
442
|
+
deleted.push(e.relPath);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (e.write !== null && e.write !== undefined) {
|
|
446
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
447
|
+
if (fs.existsSync(abs)) {
|
|
448
|
+
fs.copyFileSync(abs, abs + BACKUP_SUFFIX);
|
|
449
|
+
backups.push(e.relPath);
|
|
450
|
+
fs.writeFileSync(abs, e.write);
|
|
451
|
+
written.push(e.relPath);
|
|
452
|
+
} else {
|
|
453
|
+
fs.writeFileSync(abs, e.write);
|
|
454
|
+
created.push(e.relPath);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// One patch file for the whole run — the app's genuine divergence from the
|
|
459
|
+
// lane, preserved rather than discarded. Written only when there is
|
|
460
|
+
// something to preserve, and only after the writes above succeeded.
|
|
461
|
+
let patchPath = null;
|
|
462
|
+
if (patchChunks.length > 0) {
|
|
463
|
+
const abs = path.join(projectDir, LOCAL_PATCH_PATH);
|
|
464
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
465
|
+
fs.writeFileSync(
|
|
466
|
+
abs,
|
|
467
|
+
`# Local changes to machine-owned lane code, preserved by \`create-cmp upgrade --harness\`.\n` +
|
|
468
|
+
`#\n` +
|
|
469
|
+
`# The lane was replaced with the new engine's version. These edits were NOT\n` +
|
|
470
|
+
`# re-applied — a local change to lane code is a fork this app is maintaining,\n` +
|
|
471
|
+
`# and re-applying it silently would hide that.\n` +
|
|
472
|
+
`#\n` +
|
|
473
|
+
`# This diff is against the lane you WERE on, so it may not apply cleanly to\n` +
|
|
474
|
+
`# the new one. That is not a defect in the patch: it is the merge this tool\n` +
|
|
475
|
+
`# deliberately declined to do behind your back. To attempt it:\n` +
|
|
476
|
+
`#\n` +
|
|
477
|
+
`# git apply --reject ${LOCAL_PATCH_PATH}\n` +
|
|
478
|
+
`#\n` +
|
|
479
|
+
`# What applies, applies; the rest lands in *.rej for you to judge. If it does\n` +
|
|
480
|
+
`# conflict, that IS the finding — the engine has moved under this fork.\n` +
|
|
481
|
+
`#\n` +
|
|
482
|
+
`# Better still: upstream the change so the next upgrade carries it for you.\n` +
|
|
483
|
+
`# Delete this file once you have decided.\n\n` +
|
|
484
|
+
patchChunks.join("\n"),
|
|
485
|
+
);
|
|
486
|
+
patchPath = LOCAL_PATCH_PATH;
|
|
487
|
+
}
|
|
488
|
+
return { written, created, deleted, sidecars, backups, patched, patchPath };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Reconstruct the engine config from a parsed create-cmp.json record.
|
|
493
|
+
* Key names differ slightly (record: name/bundleId ↔ config:
|
|
494
|
+
* appName/iosBundleId). Fields the record predates default to
|
|
495
|
+
* "feature absent" — a record written before a toggle existed describes an
|
|
496
|
+
* app whose tree does NOT carry that feature, so stamping without it mirrors
|
|
497
|
+
* the app best.
|
|
498
|
+
* @param {object} record parsed create-cmp.json
|
|
499
|
+
* @param {string} targetDir where the reconstructed stamp should land
|
|
500
|
+
* @returns {object} engine config (options.schema.json shape)
|
|
501
|
+
*/
|
|
502
|
+
export function configFromSpecRecord(record, targetDir) {
|
|
503
|
+
return {
|
|
504
|
+
appName: record.name,
|
|
505
|
+
package: record.package,
|
|
506
|
+
iosBundleId: record.bundleId,
|
|
507
|
+
region: record.region ?? "us-central1",
|
|
508
|
+
themePrefix: record.themePrefix,
|
|
509
|
+
platforms: record.platforms ?? { android: true, ios: true },
|
|
510
|
+
firebase: record.firebase ?? { enabled: false },
|
|
511
|
+
room: record.room ?? false,
|
|
512
|
+
e2e: record.e2e ?? false,
|
|
513
|
+
inspector: record.inspector ?? false,
|
|
514
|
+
devClient: record.devClient ?? false,
|
|
515
|
+
tabs: record.tabs ?? [
|
|
516
|
+
{ label: "Home", icon: "home" },
|
|
517
|
+
{ label: "Profile", icon: "person" },
|
|
518
|
+
],
|
|
519
|
+
targetDir,
|
|
520
|
+
};
|
|
521
|
+
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -20,6 +20,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
20
20
|
import { validate, formatErrors } from "./lib/schema.mjs";
|
|
21
21
|
import { validatePackageName } from "./lib/package-name.mjs";
|
|
22
22
|
import { buildTokenMap, replaceTokens, replacePathTokens, isBinaryPath, slugifyAppName } from "./lib/tokens.mjs";
|
|
23
|
+
import { isHarnessFile, listHarnessFiles } from "../packages/harness/src/lib/harness-region.mjs";
|
|
24
|
+
import { writeHarnessLock } from "../packages/harness/src/lib/harness-lock.mjs";
|
|
23
25
|
import { renamePackageDirs } from "./lib/rename.mjs";
|
|
24
26
|
import {
|
|
25
27
|
stripFeatureBlocks,
|
|
@@ -78,7 +80,22 @@ export function loadManifest(templateDir) {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
|
-
* Apply token replacement to every text file's CONTENT under projectDir
|
|
83
|
+
* Apply token replacement to every text file's CONTENT under projectDir —
|
|
84
|
+
* except the machine-owned harness region, which is COPIED, never stamped.
|
|
85
|
+
*
|
|
86
|
+
* The lane carries no app content, so there is nothing in it to stamp; running
|
|
87
|
+
* it through token replacement only ever corrupted it. Two live examples the
|
|
88
|
+
* region rule retires: qa/lib/approvals.mjs had to detect unresolved tokens by
|
|
89
|
+
* SHAPE because writing the literal `__PACKAGE__` in its own source got
|
|
90
|
+
* rewritten out from under it, and qa/scaffold-feature.mjs shipped an error
|
|
91
|
+
* message that meant to name the unresolved token and instead named the app's
|
|
92
|
+
* real package ("found com.acme.demo unresolved"). Anything app-specific the
|
|
93
|
+
* lane needs is read at RUNTIME from create-cmp.json.
|
|
94
|
+
*
|
|
95
|
+
* Skipping the region is also what makes it content-hashable: every stamped
|
|
96
|
+
* app carries byte-identical lane files, so a receipt can name the exact lane
|
|
97
|
+
* version that issued it and an app can prove its copy is unmodified offline.
|
|
98
|
+
* test/harness-parity.test.mjs pins that byte-equality through the scaffold.
|
|
82
99
|
*/
|
|
83
100
|
function replaceContents(projectDir, tokenMap, manifestRel) {
|
|
84
101
|
for (const file of listFiles(projectDir)) {
|
|
@@ -86,6 +103,7 @@ function replaceContents(projectDir, tokenMap, manifestRel) {
|
|
|
86
103
|
if (manifestRel && path.resolve(file) === path.resolve(path.join(projectDir, manifestRel))) {
|
|
87
104
|
continue;
|
|
88
105
|
}
|
|
106
|
+
if (isHarnessFile(path.relative(projectDir, file).split(path.sep).join("/"))) continue;
|
|
89
107
|
if (isBinaryPath(file)) continue;
|
|
90
108
|
let content;
|
|
91
109
|
try {
|
|
@@ -255,6 +273,41 @@ function applyAppNameSlug(projectDir, appName) {
|
|
|
255
273
|
* @param {string} projectDir
|
|
256
274
|
* @param {object} config validated engine config
|
|
257
275
|
*/
|
|
276
|
+
/**
|
|
277
|
+
* The harness version this engine ships — read from the package that owns the
|
|
278
|
+
* lane, NOT from the engine's own package.json. The two version independently:
|
|
279
|
+
* the lane changes far more often than the template's app shape, and fusing
|
|
280
|
+
* them is what forced an app-shape merge every time a lane fix shipped.
|
|
281
|
+
* @returns {string} semver, or "unknown" if the manifest is unreadable
|
|
282
|
+
*/
|
|
283
|
+
function harnessVersion() {
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(
|
|
286
|
+
fs.readFileSync(path.join(REPO_ROOT, "packages/harness/package.json"), "utf8")
|
|
287
|
+
).version;
|
|
288
|
+
} catch {
|
|
289
|
+
return "unknown"; // best-effort — never fail the stamp over version metadata
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Record which lane this app carries, and a sha256 per machine-owned file, so
|
|
295
|
+
* the lane can prove offline on every run that it is unmodified since stamp.
|
|
296
|
+
* Runs LAST, after stamping and feature-stripping, so it hashes exactly the
|
|
297
|
+
* bytes the app will ship with.
|
|
298
|
+
* @param {string} projectDir
|
|
299
|
+
*/
|
|
300
|
+
function writeLaneLock(projectDir) {
|
|
301
|
+
const version = harnessVersion();
|
|
302
|
+
if (version === "unknown") return; // nothing honest to record
|
|
303
|
+
// No lane, no lock. A config that strips the harness (or a template that
|
|
304
|
+
// never carried one) must not get a manifest describing an empty region —
|
|
305
|
+
// it would attest nothing while creating the very qa/ directory the strip
|
|
306
|
+
// just removed.
|
|
307
|
+
if (listHarnessFiles(projectDir).length === 0) return;
|
|
308
|
+
writeHarnessLock(projectDir, { version });
|
|
309
|
+
}
|
|
310
|
+
|
|
258
311
|
function writeSpecOfRecord(projectDir, config) {
|
|
259
312
|
let engineVersion = "unknown";
|
|
260
313
|
try {
|
|
@@ -436,6 +489,12 @@ export async function scaffold(config, opts = {}) {
|
|
|
436
489
|
// are visible spec changes, not drift.
|
|
437
490
|
writeSpecOfRecord(projectDir, config);
|
|
438
491
|
|
|
492
|
+
// Lock the lane LAST — after stamping, feature-stripping and every other
|
|
493
|
+
// mutation — so the manifest hashes exactly the bytes this app will ship
|
|
494
|
+
// with. From here the app can prove offline, on every lane run, that its
|
|
495
|
+
// harness is the one it was given.
|
|
496
|
+
writeLaneLock(projectDir);
|
|
497
|
+
|
|
439
498
|
ok("Scaffold complete.");
|
|
440
499
|
|
|
441
500
|
// (f) verify gate
|
package/template/AGENTS.md
CHANGED
|
@@ -6,3 +6,8 @@ device-free **UI feedback loop** (render every real screen headlessly and see ex
|
|
|
6
6
|
what your edit changed) — lives in [CLAUDE.md](./CLAUDE.md).
|
|
7
7
|
|
|
8
8
|
Read CLAUDE.md before making changes. It applies to every coding agent, not only Claude.
|
|
9
|
+
|
|
10
|
+
One rule worth knowing before you touch anything: the `.mjs` files directly under `qa/`
|
|
11
|
+
and `qa/lib/` are **machine-owned harness code**, byte-identical in every create-cmp app
|
|
12
|
+
and hash-locked by `qa/harness.lock.json`. Editing them fails the lane's first step. Fix
|
|
13
|
+
the engine upstream instead — see "The lane is not yours to edit" in CLAUDE.md.
|
package/template/CLAUDE.md
CHANGED
|
@@ -112,6 +112,36 @@ Commit it with your change; git history is the audit ledger. Binary artifacts un
|
|
|
112
112
|
page reconstructs the full audit trail from the git log of `latest.json` — every commit is
|
|
113
113
|
one verified, attributed state — so committing each receipt is what builds the record.
|
|
114
114
|
|
|
115
|
+
## The lane is not yours to edit
|
|
116
|
+
|
|
117
|
+
Every `.mjs` file directly under `qa/` and `qa/lib/` is **machine-owned**: harness code
|
|
118
|
+
that is byte-identical in every create-cmp app and carries no app content at all. It
|
|
119
|
+
belongs to `create-cmp-harness`, versioned independently of the engine that stamped this
|
|
120
|
+
app's shape, and `qa/harness.lock.json` records a sha256 of every one of those files.
|
|
121
|
+
|
|
122
|
+
`node qa/verify.mjs` checks that lock first, on every run. Editing lane code fails the
|
|
123
|
+
`harnessIntegrity` step and names the file — because a lane that has been modified cannot
|
|
124
|
+
honestly vouch for itself. Without that check the receipt was unfalsifiable in one
|
|
125
|
+
specific way: force every step to PASS in `qa/verify.mjs` and the receipt still validated,
|
|
126
|
+
since the edited file was simply part of the hashed input surface.
|
|
127
|
+
|
|
128
|
+
**So: do not edit `qa/*.mjs` or `qa/lib/*.mjs`.** If the lane is wrong, the fix is
|
|
129
|
+
upstream in the engine, not here. If you genuinely must fork it, know that
|
|
130
|
+
`npx create-cmp-cli upgrade --harness` will replace the region and preserve your edits as
|
|
131
|
+
`qa/harness-local.patch` for you to re-apply or upstream — nothing is lost, but the fork
|
|
132
|
+
stops being invisible.
|
|
133
|
+
|
|
134
|
+
Everything else under `qa/` **is** yours: `approvals.json`, `comments.json`, `golden/`,
|
|
135
|
+
`evidence/`, and `e2e/*.yaml` (seeded once at stamp time, app-owned forever after). So is
|
|
136
|
+
`specs/`, and so is every line under `composeApp/src/`.
|
|
137
|
+
|
|
138
|
+
Upgrading the lane is safe to do unattended — it touches no app content and no signed
|
|
139
|
+
artifact:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npx create-cmp-cli upgrade --harness
|
|
143
|
+
```
|
|
144
|
+
|
|
115
145
|
## Approvals — governed artifacts need a human's sign-off
|
|
116
146
|
|
|
117
147
|
Some artifacts are **governed**: a human approves them, and the approval is bound to the
|
|
@@ -388,4 +418,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
388
418
|
| `./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
419
|
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
390
420
|
| `./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`) |
|
|
421
|
+
| `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`) |
|
|
422
|
+
| `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 |
|
|
423
|
+
| `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 |
|
|
424
|
+
| `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 |
|