create-cmp-cli 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +6 -2
  2. package/packages/harness/package.json +38 -0
  3. package/packages/harness/src/approve.mjs +247 -0
  4. package/packages/harness/src/arch-doc.mjs +69 -0
  5. package/packages/harness/src/comment.mjs +76 -0
  6. package/packages/harness/src/lib/a11y.mjs +113 -0
  7. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  8. package/packages/harness/src/lib/approvals.mjs +1403 -0
  9. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  10. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  11. package/packages/harness/src/lib/comments.mjs +252 -0
  12. package/packages/harness/src/lib/component-stories.mjs +183 -0
  13. package/packages/harness/src/lib/determinism.mjs +179 -0
  14. package/packages/harness/src/lib/device-lease.mjs +249 -0
  15. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  16. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  17. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  18. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  19. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  20. package/packages/harness/src/lib/harness-region.mjs +159 -0
  21. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  22. package/packages/harness/src/lib/reachability.mjs +211 -0
  23. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  24. package/packages/harness/src/lib/render.mjs +254 -0
  25. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  26. package/packages/harness/src/lib/step-cache.mjs +221 -0
  27. package/packages/harness/src/lib/token-drift.mjs +94 -0
  28. package/packages/harness/src/lib/tree.mjs +108 -0
  29. package/packages/harness/src/preview-gallery.mjs +122 -0
  30. package/packages/harness/src/receipt-check.mjs +96 -0
  31. package/packages/harness/src/record-audit.mjs +83 -0
  32. package/packages/harness/src/refusal-demo.mjs +498 -0
  33. package/packages/harness/src/retrospective.mjs +51 -0
  34. package/packages/harness/src/scaffold-feature.mjs +723 -0
  35. package/packages/harness/src/setup-hooks.mjs +33 -0
  36. package/packages/harness/src/verify.mjs +1723 -0
  37. package/packages/harness/src/walkthrough.mjs +499 -0
  38. package/packages/harness/src/watch.mjs +622 -0
  39. package/packages/receipts/package.json +36 -0
  40. package/packages/receipts/src/index.mjs +16 -0
  41. package/packages/receipts/src/inputs-hash.mjs +194 -0
  42. package/packages/receipts/src/receipt-validate.mjs +234 -0
  43. package/src/commands/upgrade.mjs +115 -1
  44. package/src/lib/harness-upgrade.mjs +193 -5
  45. package/src/scaffold.mjs +60 -1
  46. package/template/AGENTS.md +5 -0
  47. package/template/CLAUDE.md +30 -0
  48. package/template/gitignore +8 -0
  49. package/template/qa/lib/harness-lock.mjs +147 -0
  50. package/template/qa/lib/harness-region.mjs +159 -0
  51. package/template/qa/lib/inputs-hash.mjs +1 -1
  52. package/template/qa/lib/receipt-validate.mjs +1 -1
  53. package/template/qa/preview-gallery.mjs +17 -2
  54. package/template/qa/verify.mjs +110 -2
  55. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  56. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  57. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  58. package/template/.gradle/8.11.1/gc.properties +0 -0
  59. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  60. package/template/.gradle/buildOutputCleanup/cache.properties +0 -2
  61. package/template/.gradle/vcs-1/gc.properties +0 -0
@@ -40,8 +40,9 @@ import path from "node:path";
40
40
  import { spawnSync } from "node:child_process";
41
41
 
42
42
  import { listFiles } from "./fsutil.mjs";
43
- import { isBinaryPath } from "./tokens.mjs";
43
+ import { isBinaryPath, replaceTokens } from "./tokens.mjs";
44
44
  import { BACKUP_SUFFIX } from "./upgrade.mjs";
45
+ import { isHarnessFile } from "../../packages/harness/src/lib/harness-region.mjs";
45
46
 
46
47
  /** Sidecar suffix for the new engine content beside a conflicted file. */
47
48
  export const SIDECAR_SUFFIX = ".cmp-new";
@@ -62,6 +63,10 @@ export const EXCLUDED_PATTERNS = [
62
63
  "create-cmp.json",
63
64
  "qa/evidence/**",
64
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",
65
70
  "qa/comments.json",
66
71
  "qa/golden/**",
67
72
  ".git/**",
@@ -172,7 +177,147 @@ export function mergeThreeWay(theirs, base, next) {
172
177
  * @returns {{bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}|null}
173
178
  * null when the path is in neither base nor new (app-authored — invisible)
174
179
  */
175
- export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
180
+
181
+
182
+ /**
183
+ * A `stampBase` function for an app's recorded config — reproduces what a
184
+ * pre-0.14.0 engine would have written into that app's lane, so a file the
185
+ * stamper touched is not mistaken for a local edit. Binary-safe: content that
186
+ * does not round-trip through UTF-8 is returned unchanged.
187
+ * @param {Array<[string,string]>} tokenMap from buildTokenMap(config)
188
+ * @returns {(base: Buffer) => Buffer}
189
+ */
190
+ export function stampBaseWith(tokenMap) {
191
+ return (base) => {
192
+ const text = base.toString("utf8");
193
+ if (!Buffer.from(text, "utf8").equals(base)) return base;
194
+ return Buffer.from(replaceTokens(text, tokenMap), "utf8");
195
+ };
196
+ }
197
+
198
+ /** Where a preserved local patch to machine-owned lane code is written. */
199
+ export const LOCAL_PATCH_PATH = "qa/harness-local.patch";
200
+
201
+ /**
202
+ * Unified diff from `base` to `theirs`, rewritten so it applies against the
203
+ * project tree (`git apply qa/harness-local.patch`). git diff --no-index exits
204
+ * 1 when there ARE differences, which is the only case we call it in.
205
+ * @param {Buffer} base
206
+ * @param {Buffer} theirs
207
+ * @param {string} relPath project-relative path the patch should name
208
+ * @returns {string} patch text, or "" if git could not produce one
209
+ */
210
+ export function diffPatch(base, theirs, relPath) {
211
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cmp-harness-diff-"));
212
+ try {
213
+ const b = path.join(dir, "base");
214
+ const t = path.join(dir, "theirs");
215
+ fs.writeFileSync(b, base);
216
+ fs.writeFileSync(t, theirs);
217
+ const r = spawnSync("git", ["diff", "--no-index", "--no-color", "--", b, t], {
218
+ encoding: "utf8",
219
+ maxBuffer: 64 * 1024 * 1024,
220
+ });
221
+ // 0 = identical (we never call it then), 1 = differences, >1 = trouble.
222
+ if (r.error || (r.status !== 0 && r.status !== 1) || !r.stdout) return "";
223
+ return r.stdout
224
+ .split("\n")
225
+ .map((line) => {
226
+ if (line.startsWith("diff --git ")) return `diff --git a/${relPath} b/${relPath}`;
227
+ if (line.startsWith("--- ")) return `--- a/${relPath}`;
228
+ if (line.startsWith("+++ ")) return `+++ b/${relPath}`;
229
+ return line;
230
+ })
231
+ .join("\n");
232
+ } finally {
233
+ fs.rmSync(dir, { recursive: true, force: true });
234
+ }
235
+ }
236
+
237
+ /**
238
+ * The decision table for MACHINE-OWNED lane files — a different question from
239
+ * the one decideFile asks about app-shaped files.
240
+ *
241
+ * Lane code is byte-identical in every create-cmp app and carries no app
242
+ * content, so it is a derived artifact, and the right operation on a derived
243
+ * artifact is REPLACE, not merge. Three-way merging it is what made upgrades
244
+ * expensive: the 0.13.0 pilots produced ~1,000 conflicted lines per app whose
245
+ * diffs contained zero app-specific tokens.
246
+ *
247
+ * The region is therefore always taken to the new engine's content. What
248
+ * varies is how honestly we account for what the app had:
249
+ *
250
+ * region-current already the new content → silent
251
+ * region-clean untouched since stamp → replace
252
+ * region-absorbed locally edited, but the edit is ALREADY in the new
253
+ * content (the app hand-mirrored engine work — the
254
+ * pilots' overwhelmingly common case) → replace, silent
255
+ * region-patched a genuine local fork of lane code → replace, AND
256
+ * preserve base→theirs as a patch so nothing is lost
257
+ * added / removed as for any other file
258
+ *
259
+ * "region-patched" deliberately does NOT block or merge. A local edit to lane
260
+ * code is a fork the app is maintaining; making it explicit (a reviewable
261
+ * patch file plus a loud report) is better than the status quo, where the
262
+ * divergence was invisible until it cost a thousand hand-resolved lines at
263
+ * the next upgrade.
264
+ *
265
+ * @param {object} params
266
+ * @param {string} params.relPath
267
+ * @param {Buffer|null} params.base
268
+ * @param {Buffer|null} params.next
269
+ * @param {Buffer|null} params.theirs
270
+ * @param {Function} [params.merge] injectable three-way merge (classifier only)
271
+ * @returns {{bucket:string, write:Buffer|null, sidecar:null, remove:boolean, patch?:string}|null}
272
+ */
273
+ export function decideRegionFile({ relPath, base, next, theirs, merge = mergeThreeWay, stampBase = null }) {
274
+ const eq = (a, b) => a !== null && b !== null && a.equals(b);
275
+ const replace = (bucket, extra = {}) => ({
276
+ bucket,
277
+ write: next,
278
+ sidecar: null,
279
+ remove: false,
280
+ ...extra,
281
+ });
282
+
283
+ if (next === null) {
284
+ // The engine dropped this lane file. Nothing app-owned can live here, so
285
+ // there is no "orphaned" case to protect — remove it.
286
+ if (theirs === null) return { bucket: "current", write: null, sidecar: null, remove: false };
287
+ return { bucket: "region-removed", write: null, sidecar: null, remove: true };
288
+ }
289
+ if (theirs === null) return replace(base === null ? "added" : "region-restored");
290
+ if (eq(theirs, next)) return { bucket: "current", write: null, sidecar: null, remove: false };
291
+ if (base === null) return replace("region-patched", { patch: "" }); // no base to diff against
292
+ if (eq(theirs, base)) return replace("region-clean");
293
+
294
+ // The app's copy may be what its OWN engine produced rather than what the
295
+ // base tree holds, because engines before 0.14.0 ran lane code through the
296
+ // token stamper. Such an app carries `Fuelled` where the base template says
297
+ // `__APP_NAME__` — a difference the engine created, not the app. Asking
298
+ // "does this match the base as THIS app would have had it stamped?" is the
299
+ // accurate form of "did the app never touch it", and it needs no version
300
+ // comparison: for a 0.14.0+ base there is nothing left to stamp, so the
301
+ // check simply never fires.
302
+ if (stampBase !== null) {
303
+ const asStamped = stampBase(base);
304
+ if (eq(theirs, asStamped)) return replace("region-clean");
305
+ }
306
+
307
+ // The app edited lane code. Is that edit already carried by the new engine?
308
+ // A clean three-way merge landing exactly on `next` means the local change
309
+ // contributed nothing beyond it — the hand-mirror case.
310
+ const m = merge(theirs, base, next);
311
+ if (m.clean && m.content !== null && m.content.equals(next)) return replace("region-absorbed");
312
+
313
+ return replace("region-patched", { patch: diffPatch(base, theirs, relPath) });
314
+ }
315
+
316
+ export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay, stampBase = null }) {
317
+ // Machine-owned lane files answer a different question — see decideRegionFile.
318
+ if (isHarnessFile(relPath) && !(base === null && next === null)) {
319
+ return decideRegionFile({ relPath, base, next, theirs, merge, stampBase });
320
+ }
176
321
  const none = (bucket) => ({ bucket, write: null, sidecar: null, remove: false });
177
322
  const write = (bucket, content) => ({ bucket, write: content, sidecar: null, remove: false });
178
323
  const conflict = () => ({ bucket: "conflicted", write: null, sidecar: next, remove: false });
@@ -240,7 +385,7 @@ function toRel(root, abs) {
240
385
  * sidecar?:Buffer|null, remove?:boolean}>,
241
386
  * counts: Record<string, number>}}
242
387
  */
243
- export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeThreeWay }) {
388
+ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeThreeWay, stampBase = null }) {
244
389
  const rels = new Set();
245
390
  for (const f of listFiles(baseDir)) rels.add(toRel(baseDir, f));
246
391
  for (const f of listFiles(newDir)) rels.add(toRel(newDir, f));
@@ -255,6 +400,12 @@ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeT
255
400
  added: 0,
256
401
  removed: 0,
257
402
  orphaned: 0,
403
+ // Machine-owned lane buckets (decideRegionFile).
404
+ "region-clean": 0,
405
+ "region-absorbed": 0,
406
+ "region-patched": 0,
407
+ "region-restored": 0,
408
+ "region-removed": 0,
258
409
  };
259
410
  const entries = [];
260
411
  for (const relPath of [...rels].sort()) {
@@ -269,9 +420,10 @@ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeT
269
420
  next: readIfPresent(newDir, relPath),
270
421
  theirs: readIfPresent(projectDir, relPath),
271
422
  merge,
423
+ stampBase,
272
424
  });
273
425
  if (decision === null) continue;
274
- counts[decision.bucket] += 1;
426
+ counts[decision.bucket] = (counts[decision.bucket] ?? 0) + 1;
275
427
  entries.push({ relPath, ...decision });
276
428
  }
277
429
  return { entries, counts };
@@ -299,7 +451,13 @@ export function applyHarnessPlan(projectDir, entries) {
299
451
  const deleted = [];
300
452
  const sidecars = [];
301
453
  const backups = [];
454
+ const patched = [];
455
+ const patchChunks = [];
302
456
  for (const e of entries) {
457
+ if (e.bucket === "region-patched" && e.patch) {
458
+ patched.push(e.relPath);
459
+ patchChunks.push(e.patch.endsWith("\n") ? e.patch : `${e.patch}\n`);
460
+ }
303
461
  const abs = path.join(projectDir, e.relPath);
304
462
  if (e.sidecar !== null && e.sidecar !== undefined) {
305
463
  const sidecarPath = abs + SIDECAR_SUFFIX;
@@ -328,7 +486,37 @@ export function applyHarnessPlan(projectDir, entries) {
328
486
  }
329
487
  }
330
488
  }
331
- return { written, created, deleted, sidecars, backups };
489
+ // One patch file for the whole run — the app's genuine divergence from the
490
+ // lane, preserved rather than discarded. Written only when there is
491
+ // something to preserve, and only after the writes above succeeded.
492
+ let patchPath = null;
493
+ if (patchChunks.length > 0) {
494
+ const abs = path.join(projectDir, LOCAL_PATCH_PATH);
495
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
496
+ fs.writeFileSync(
497
+ abs,
498
+ `# Local changes to machine-owned lane code, preserved by \`create-cmp upgrade --harness\`.\n` +
499
+ `#\n` +
500
+ `# The lane was replaced with the new engine's version. These edits were NOT\n` +
501
+ `# re-applied — a local change to lane code is a fork this app is maintaining,\n` +
502
+ `# and re-applying it silently would hide that.\n` +
503
+ `#\n` +
504
+ `# This diff is against the lane you WERE on, so it may not apply cleanly to\n` +
505
+ `# the new one. That is not a defect in the patch: it is the merge this tool\n` +
506
+ `# deliberately declined to do behind your back. To attempt it:\n` +
507
+ `#\n` +
508
+ `# git apply --reject ${LOCAL_PATCH_PATH}\n` +
509
+ `#\n` +
510
+ `# What applies, applies; the rest lands in *.rej for you to judge. If it does\n` +
511
+ `# conflict, that IS the finding — the engine has moved under this fork.\n` +
512
+ `#\n` +
513
+ `# Better still: upstream the change so the next upgrade carries it for you.\n` +
514
+ `# Delete this file once you have decided.\n\n` +
515
+ patchChunks.join("\n"),
516
+ );
517
+ patchPath = LOCAL_PATCH_PATH;
518
+ }
519
+ return { written, created, deleted, sidecars, backups, patched, patchPath };
332
520
  }
333
521
 
334
522
  /**
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
@@ -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.
@@ -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
@@ -26,3 +26,11 @@ iosApp/*.xcworkspace/
26
26
  iosApp/build/
27
27
  xcuserdata/
28
28
  *.xcuserstate
29
+
30
+ # `create-cmp upgrade`'s pre-write backups. In a git repo the previous commit IS
31
+ # the backup, so these are redundant the moment they are created — and committing
32
+ # them alongside the upgrade puts a stale copy of every touched file in history.
33
+ # Kept on disk (an upgrade run in a dirty or non-git tree still needs them),
34
+ # ignored by git. Delete them once you have reviewed the upgrade's diff.
35
+ *.bak-upgrade
36
+ *.cmp-new
@@ -0,0 +1,147 @@
1
+ // qa/harness.lock.json — which lane this app carries, and whether it is intact.
2
+ //
3
+ // The lock is written at stamp time and rewritten by `create-cmp upgrade
4
+ // --harness`. It names the harness version and records a sha256 per
5
+ // machine-owned file, so two different questions get two different answers:
6
+ //
7
+ // INTEGRITY "is my lane unmodified since it was installed?"
8
+ // Answered LOCALLY, offline, on every lane run. Needs nothing
9
+ // but the tree and this file.
10
+ //
11
+ // AUTHENTICITY "is my lane the real published create-cmp-harness@X?"
12
+ // Answered REMOTELY, on request, by comparing this file's
13
+ // `sha256` against the published version's — `create-cmp
14
+ // upgrade --harness` does it, and so can any third party
15
+ // holding a receipt.
16
+ //
17
+ // Being honest about that split matters. Someone who edits the lane AND
18
+ // rewrites this lock defeats the local check — of course they do; it is a
19
+ // checksum, not a signature. What it cannot survive is the remote comparison,
20
+ // because the attacker cannot change what the registry published under that
21
+ // version number. Local integrity catches the accident and the drift (an
22
+ // agent "fixing" a lane file, a half-applied upgrade); the remote comparison
23
+ // catches the lie. Neither claim is stretched to cover the other's job.
24
+ //
25
+ // The lock is deliberately NOT a .mjs file, so it is not part of the region it
26
+ // describes — a manifest inside its own manifest could never settle.
27
+ //
28
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/harness-lock.mjs in the
29
+ // create-cmp repo — edit there, then run `node scripts/sync-harness.mjs`.
30
+
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import { hashHarnessRegion, compareHarnessRegion } from "./harness-region.mjs";
34
+
35
+ export const LOCK_PATH = "qa/harness.lock.json";
36
+ export const LOCK_SCHEMA = "cmp-harness-lock/1";
37
+
38
+ /**
39
+ * Read the lock, or null when it is absent or unparsable. An unreadable lock
40
+ * is not distinguished from a missing one on purpose: both mean "this tree
41
+ * cannot tell me what lane it carries", and both get the same honest verdict
42
+ * from checkHarnessIntegrity — unknown, never intact.
43
+ * @param {string} root project root
44
+ * @returns {object|null}
45
+ */
46
+ export function readHarnessLock(root) {
47
+ try {
48
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, LOCK_PATH), "utf8"));
49
+ return parsed && typeof parsed === "object" ? parsed : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Hash the tree's region and write the lock describing it.
57
+ * Called at stamp time and after an upgrade replaces the region — never by
58
+ * the lane itself, which must only ever READ the lock. A lane that rewrote
59
+ * its own manifest could not fail the integrity check it exists to run.
60
+ * @param {string} root project root
61
+ * @param {{name?: string, version: string}} harness identity to record
62
+ * @returns {{sha256: string, fileCount: number}}
63
+ */
64
+ export function writeHarnessLock(root, { name = "create-cmp-harness", version }) {
65
+ if (typeof version !== "string" || version.length === 0) {
66
+ throw new Error("writeHarnessLock: a harness version is required");
67
+ }
68
+ const region = hashHarnessRegion(root);
69
+ const lock = {
70
+ schema: LOCK_SCHEMA,
71
+ name,
72
+ version,
73
+ sha256: region.sha256,
74
+ fileCount: region.fileCount,
75
+ files: region.files,
76
+ };
77
+ const abs = path.join(root, LOCK_PATH);
78
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
79
+ fs.writeFileSync(abs, `${JSON.stringify(lock, null, 2)}\n`);
80
+ return { sha256: region.sha256, fileCount: region.fileCount };
81
+ }
82
+
83
+ /**
84
+ * Compare the tree's region against its lock.
85
+ *
86
+ * @param {string} root project root
87
+ * @returns {{status: "intact"|"modified"|"unlocked", name: string|null,
88
+ * version: string|null, sha256: string, recordedSha256: string|null,
89
+ * modified: string[], missing: string[], extra: string[],
90
+ * fileCount: number}}
91
+ * status "unlocked" means no readable lock — an app stamped before locks
92
+ * existed, or one whose lock was deleted. Reported as its own state rather
93
+ * than folded into "modified": nothing is known to be wrong, but nothing is
94
+ * proven either, and a gate that cannot tell those apart teaches people to
95
+ * ignore it.
96
+ */
97
+ export function checkHarnessIntegrity(root) {
98
+ const lock = readHarnessLock(root);
99
+ const region = hashHarnessRegion(root);
100
+
101
+ if (!lock || typeof lock.files !== "object" || lock.files === null) {
102
+ return {
103
+ status: "unlocked",
104
+ name: lock?.name ?? null,
105
+ version: typeof lock?.version === "string" ? lock.version : null,
106
+ sha256: region.sha256,
107
+ recordedSha256: typeof lock?.sha256 === "string" ? lock.sha256 : null,
108
+ modified: [],
109
+ missing: [],
110
+ extra: [],
111
+ fileCount: region.fileCount,
112
+ };
113
+ }
114
+
115
+ const cmp = compareHarnessRegion(root, lock);
116
+ return {
117
+ status: cmp.intact ? "intact" : "modified",
118
+ name: typeof lock.name === "string" ? lock.name : null,
119
+ version: typeof lock.version === "string" ? lock.version : null,
120
+ sha256: cmp.sha256,
121
+ recordedSha256: typeof lock.sha256 === "string" ? lock.sha256 : null,
122
+ modified: cmp.modified,
123
+ missing: cmp.missing,
124
+ extra: cmp.extra,
125
+ fileCount: region.fileCount,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * One-line human summary of an integrity result — shared by the lane step and
131
+ * the upgrade command so both describe the same state the same way.
132
+ * @param {ReturnType<typeof checkHarnessIntegrity>} r
133
+ * @returns {string}
134
+ */
135
+ export function describeIntegrity(r) {
136
+ if (r.status === "intact") {
137
+ return `${r.name ?? "harness"} ${r.version ?? "?"} — ${r.fileCount} files verified`;
138
+ }
139
+ if (r.status === "unlocked") {
140
+ return `no ${LOCK_PATH} — this app's lane version is unrecorded`;
141
+ }
142
+ const parts = [];
143
+ if (r.modified.length) parts.push(`${r.modified.length} modified`);
144
+ if (r.missing.length) parts.push(`${r.missing.length} missing`);
145
+ if (r.extra.length) parts.push(`${r.extra.length} unrecorded`);
146
+ return `${r.name ?? "harness"} ${r.version ?? "?"} — ${parts.join(", ")}`;
147
+ }