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
@@ -0,0 +1,159 @@
1
+ // The harness region — which files in a stamped app are MACHINE-OWNED.
2
+ //
3
+ // A create-cmp app carries two kinds of file. App-owned files are the app: its
4
+ // screens, specs, goldens, approvals, e2e flows. Machine-owned files are the
5
+ // verify lane itself — executable harness code that is identical in every app
6
+ // ever stamped, carrying no app content whatsoever.
7
+ //
8
+ // Treating the second kind like the first is what made upgrades expensive: a
9
+ // three-way merge over 10k lines of engine code produced ~1,000 conflicted
10
+ // lines per app with ZERO app-specific tokens in them. The right operation for
11
+ // a derived artifact is replace, not merge. This module draws that line.
12
+ //
13
+ // The rule is deliberately mechanical, with no per-file list to keep in sync:
14
+ //
15
+ // machine-owned == the .mjs files directly under qa/ and qa/lib/
16
+ //
17
+ // Everything else under qa/ is app state (approvals.json, comments.json,
18
+ // evidence/, golden/) or app content (e2e/*.yaml — seeded once at stamp time,
19
+ // app-owned forever after, because apps edit their smoke flow as tabs change).
20
+ //
21
+ // Three consequences, each load-bearing:
22
+ //
23
+ // 1. NEVER STAMPED. The region is copied byte-identical from the engine —
24
+ // token replacement must not touch it. It used to: qa/lib/approvals.mjs
25
+ // carries a comment warning that a literal "__PACKAGE__" in lane source
26
+ // gets silently rewritten at stamp time, and qa/scaffold-feature.mjs
27
+ // shipped an error message that meant to name the unresolved token and
28
+ // instead named the app's real package. Anything app-specific the lane
29
+ // needs is read at RUNTIME from create-cmp.json.
30
+ //
31
+ // 2. VERIFIABLE. Because the copy is byte-identical to a known version, an
32
+ // app can prove offline that its lane is the real one. Without this a
33
+ // receipt is unfalsifiable: edit qa/verify.mjs to force every step green
34
+ // and the receipt still validates, since the edited file is simply part
35
+ // of the hashed surface.
36
+ //
37
+ // 3. REPLACEABLE. `create-cmp upgrade --harness` overwrites the region
38
+ // wholesale instead of merging it.
39
+ //
40
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/harness-region.mjs in the
41
+ // create-cmp repo. The copy in a generated project's qa/lib/ is vendored
42
+ // byte-identical at scaffold time — edit the package source, then run
43
+ // `node scripts/sync-harness.mjs`.
44
+
45
+ import { createHash } from "node:crypto";
46
+ import fs from "node:fs";
47
+ import path from "node:path";
48
+
49
+ /**
50
+ * Directories whose direct `.mjs` children are machine-owned, relative to the
51
+ * project root. Direct children only — a nested directory added later is not
52
+ * silently swept into the region without someone editing this list.
53
+ */
54
+ export const HARNESS_DIRS = ["qa", "qa/lib"];
55
+
56
+ /**
57
+ * Is this project-relative path part of the machine-owned harness region?
58
+ * @param {string} relPath project-relative path, "/"-separated
59
+ * @returns {boolean}
60
+ */
61
+ export function isHarnessFile(relPath) {
62
+ if (typeof relPath !== "string" || !relPath.endsWith(".mjs")) return false;
63
+ const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
64
+ return HARNESS_DIRS.includes(dir);
65
+ }
66
+
67
+ /**
68
+ * Every machine-owned file present under `root`, as project-relative posix
69
+ * paths, sorted — so the list (and any hash over it) is deterministic.
70
+ * @param {string} root project root
71
+ * @returns {string[]}
72
+ */
73
+ export function listHarnessFiles(root) {
74
+ const found = [];
75
+ for (const dir of HARNESS_DIRS) {
76
+ let names;
77
+ try {
78
+ names = fs.readdirSync(path.join(root, dir), { withFileTypes: true });
79
+ } catch {
80
+ continue; // a project without qa/lib yet is not an error here
81
+ }
82
+ for (const ent of names) {
83
+ if (!ent.isFile()) continue;
84
+ const rel = `${dir}/${ent.name}`;
85
+ if (isHarnessFile(rel)) found.push(rel);
86
+ }
87
+ }
88
+ return found.sort();
89
+ }
90
+
91
+ /** sha256 of one file's bytes, hex. */
92
+ function fileHash(abs) {
93
+ return createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
94
+ }
95
+
96
+ /**
97
+ * Content hash of the whole region, plus the per-file hashes it was built from.
98
+ *
99
+ * The digest covers PATHS as well as content, so moving a file between the two
100
+ * harness directories changes the hash even if no byte of any file changed.
101
+ * NUL separators keep the encoding unambiguous — no filename can forge a
102
+ * boundary.
103
+ *
104
+ * @param {string} root project root
105
+ * @returns {{sha256: string, fileCount: number, files: Record<string,string>}}
106
+ */
107
+ export function hashHarnessRegion(root) {
108
+ const rels = listHarnessFiles(root);
109
+ const files = {};
110
+ const digest = createHash("sha256");
111
+ for (const rel of rels) {
112
+ const h = fileHash(path.join(root, rel));
113
+ files[rel] = h;
114
+ digest.update(rel, "utf8").update("\0").update(h, "utf8").update("\n");
115
+ }
116
+ return { sha256: digest.digest("hex"), fileCount: rels.length, files };
117
+ }
118
+
119
+ /**
120
+ * Compare a tree's region against a recorded manifest of per-file hashes.
121
+ * Reports WHICH files differ, not just that something did — an app that
122
+ * patched its lane needs to see the list, and an upgrade needs it to decide
123
+ * what to preserve.
124
+ *
125
+ * @param {string} root project root
126
+ * @param {{sha256?: string, files?: Record<string,string>}} recorded
127
+ * @returns {{intact: boolean, sha256: string, modified: string[],
128
+ * missing: string[], extra: string[]}}
129
+ * modified present in both, different content
130
+ * missing recorded but absent from the tree
131
+ * extra present in the tree but not recorded
132
+ */
133
+ export function compareHarnessRegion(root, recorded) {
134
+ const actual = hashHarnessRegion(root);
135
+ // `typeof null === "object"`, and an array would enumerate as index keys —
136
+ // a manifest that is absent or malformed must read as NOT intact, never crash
137
+ // the lane step that calls this.
138
+ const f = recorded?.files;
139
+ const expected = f && typeof f === "object" && !Array.isArray(f) ? f : {};
140
+ const modified = [];
141
+ const missing = [];
142
+ const extra = [];
143
+
144
+ for (const [rel, hash] of Object.entries(expected)) {
145
+ if (!(rel in actual.files)) missing.push(rel);
146
+ else if (actual.files[rel] !== hash) modified.push(rel);
147
+ }
148
+ for (const rel of Object.keys(actual.files)) {
149
+ if (!(rel in expected)) extra.push(rel);
150
+ }
151
+
152
+ return {
153
+ intact: modified.length === 0 && missing.length === 0 && extra.length === 0,
154
+ sha256: actual.sha256,
155
+ modified: modified.sort(),
156
+ missing: missing.sort(),
157
+ extra: extra.sort(),
158
+ };
159
+ }
@@ -8,7 +8,7 @@
8
8
  // create-cmp repo (the `cmp-receipts` package). The copy in a generated
9
9
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
10
  // test/receipts-parity.test.mjs — edit the package source, then run
11
- // `node scripts/sync-receipts.mjs`.
11
+ // `node scripts/sync-harness.mjs`.
12
12
  //
13
13
  // See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
14
14
 
@@ -9,7 +9,7 @@
9
9
  // create-cmp repo (the `cmp-receipts` package). The copy in a generated
10
10
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
11
  // test/receipts-parity.test.mjs — edit the package source, then run
12
- // `node scripts/sync-receipts.mjs`.
12
+ // `node scripts/sync-harness.mjs`.
13
13
  //
14
14
  // See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
15
15
 
@@ -23,6 +23,21 @@ const previewsDir = resolve(
23
23
  const { renderTreeSvg } = await import(new URL("./lib/render.mjs", import.meta.url));
24
24
  const { auditA11y } = await import(new URL("./lib/a11y.mjs", import.meta.url));
25
25
 
26
+ // The app's display name is read at RUNTIME from create-cmp.json, never stamped in.
27
+ // Every .mjs under qa/ is machine-owned harness code copied byte-identical from the
28
+ // engine — token substitution must not touch it (see qa/lib/harness-region.mjs), so
29
+ // anything app-specific is looked up from the record that already holds that truth.
30
+ function appName() {
31
+ try {
32
+ const rec = JSON.parse(readFileSync(join(HERE, "..", "create-cmp.json"), "utf8"));
33
+ if (typeof rec.name === "string" && rec.name.trim()) return rec.name;
34
+ } catch {
35
+ // Not stamped, or an unreadable record — the gallery is a report, not a gate.
36
+ }
37
+ return "App";
38
+ }
39
+ const APP_NAME = appName();
40
+
26
41
  const manifest = JSON.parse(readFileSync(join(previewsDir, "manifest.json"), "utf8"));
27
42
  const { width, height, pngScale } = manifest.viewport;
28
43
 
@@ -57,7 +72,7 @@ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replac
57
72
 
58
73
  const html = `<!doctype html>
59
74
  <meta charset="utf-8">
60
- <title>__APP_NAME__ — screen previews (headless)</title>
75
+ <title>${esc(APP_NAME)} — screen previews (headless)</title>
61
76
  <style>
62
77
  :root { color-scheme: light; }
63
78
  body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #F7F9FC; color: #1A1A1A; }
@@ -77,7 +92,7 @@ const html = `<!doctype html>
77
92
  .lbl { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: #9CA3AF; margin: 0 0 4px; }
78
93
  </style>
79
94
  <header>
80
- <h1>__APP_NAME__ — screen previews</h1>
95
+ <h1>${esc(APP_NAME)} — screen previews</h1>
81
96
  <p>Rendered headlessly (no device/emulator) by <code>:composeApp:renderScreens</code> —
82
97
  ${width}×${height}dp, PNG @${pngScale}x · pixels for humans, wireframe+tree for the AI ·
83
98
  regenerate: <code>./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs</code></p>
@@ -45,6 +45,7 @@ import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-do
45
45
  import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./lib/determinism.mjs";
46
46
  import { evaluateAuditCadence } from "./lib/audit-cadence.mjs";
47
47
  import { appendFlightRecord, buildFlightEntry } from "./lib/flight-recorder.mjs";
48
+ import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./lib/harness-lock.mjs";
48
49
 
49
50
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
50
51
  const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
@@ -92,6 +93,14 @@ Flags:
92
93
  with --profile ci (or release) it runs
93
94
  inside the lane and lands on the receipt.
94
95
  Never combinable with --fast
96
+ --no-journal skip the qa/flight-recorder.jsonl append.
97
+ qa/watch.mjs passes this on every
98
+ save-triggered run: journalling each save
99
+ would add hundreds of committed lines a day
100
+ and leave a permanently dirty tree inside
101
+ the loop the recorder exists to observe. The
102
+ gap is disclosed in the retrospective's own
103
+ output
95
104
  --json print the receipt as JSON instead of the
96
105
  human-readable step-by-step log
97
106
  --help, -h print this usage and exit 0 without
@@ -115,7 +124,13 @@ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
115
124
  process.exit(0);
116
125
  }
117
126
 
118
- const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast", "--determinism"]);
127
+ // Every flag this file CONSUMES must be listed here, or the strict check below
128
+ // rejects it. `--no-journal` was consumed but unlisted in 0.13.0, which meant
129
+ // qa/watch.mjs — whose spawn passes exactly these flags — exited 2 on every
130
+ // save without ever running the lane. test/verify-flags.test.mjs now pins
131
+ // consumed ⊆ recognized and watch's spawn ⊆ recognized so the class cannot
132
+ // recur.
133
+ const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast", "--determinism", "--no-journal"]);
119
134
  for (let i = 0; i < rawArgs.length; i += 1) {
120
135
  const arg = rawArgs[i];
121
136
  if (arg === "--profile") {
@@ -416,6 +431,62 @@ function settleAdb() {
416
431
  // itself lives in qa/lib/spec-coverage.mjs — the SAME scan feature-brief.mjs
417
432
  // derives doneness from, so this gate and the Features view can never disagree
418
433
  // about a clause. This step owns only the orphan decision + bookkeeping.
434
+ // The first question any verdict depends on: is the lane that is about to
435
+ // issue it the lane this app was given?
436
+ //
437
+ // Without this the receipt is unfalsifiable in one specific way — edit
438
+ // qa/verify.mjs to force every step PASS and the receipt still validates,
439
+ // because the edited file is simply part of the hashed input surface. Hashing
440
+ // the machine-owned region against qa/harness.lock.json closes that: the lane
441
+ // cannot vouch for itself while modified.
442
+ //
443
+ // DELIBERATELY NOT MEMOIZED. Every other pure-Node step can serve a cached
444
+ // PASS when its inputs are unchanged; a cached PASS on an integrity check is
445
+ // precisely the failure it exists to prevent, and 34 file reads are too cheap
446
+ // to be worth the risk.
447
+ //
448
+ // Three states, three verdicts:
449
+ // intact PASS
450
+ // modified FAIL — named files, with the command that restores them
451
+ // unlocked SKIP — an app stamped before locks existed. Nothing is known to
452
+ // be wrong, but nothing is proven either; recording the gap keeps
453
+ // the pipeline honest instead of quietly passing.
454
+ function stepHarnessIntegrity() {
455
+ const started = Date.now();
456
+ const r = checkHarnessIntegrity(ROOT);
457
+ const base = { name: "harnessIntegrity", durationMs: Date.now() - started, harness: r };
458
+
459
+ if (r.status === "intact") {
460
+ return { ...base, verdict: "PASS", note: describeIntegrity(r) };
461
+ }
462
+ if (r.status === "unlocked") {
463
+ return {
464
+ ...base,
465
+ verdict: "SKIP",
466
+ reason: `no ${LOCK_PATH} — this app was stamped before harness locks existed. ` +
467
+ "`npx create-cmp-cli upgrade --harness` records one.",
468
+ };
469
+ }
470
+
471
+ const named = [
472
+ ...r.modified.map((f) => `modified ${f}`),
473
+ ...r.missing.map((f) => `missing ${f}`),
474
+ ...r.extra.map((f) => `unrecorded ${f}`),
475
+ ];
476
+ return {
477
+ ...base,
478
+ verdict: "FAIL",
479
+ reason:
480
+ `the verify lane has been modified since it was installed — ${describeIntegrity(r)}. ` +
481
+ "Lane code is machine-owned: it is byte-identical in every create-cmp app and carries " +
482
+ "no app content, so a local edit is either an accident, a half-applied upgrade, or an " +
483
+ "attempt to make this receipt say something the lane would not. Restore it with " +
484
+ "`npx create-cmp-cli upgrade --harness`, which also reports any genuine local patch " +
485
+ "instead of discarding it.",
486
+ files: named,
487
+ };
488
+ }
489
+
419
490
  function stepSpecCoverage() {
420
491
  const started = Date.now();
421
492
  const specsDir = path.join(ROOT, "specs");
@@ -1307,8 +1378,11 @@ const stepArchDocMemo = memoized("archDoc", stepArchDoc);
1307
1378
  const stepsForProfile = {
1308
1379
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
1309
1380
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
1310
- scaffold: [stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1381
+ scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1311
1382
  local: [
1383
+ // First, always: every verdict below is only worth what the lane issuing
1384
+ // it is worth.
1385
+ stepHarnessIntegrity,
1312
1386
  stepSpecCoverageMemo,
1313
1387
  stepApprovalsMemo,
1314
1388
  stepComponentStoriesMemo,
@@ -1502,6 +1576,30 @@ if (fs.existsSync(ARTIFACTS_DIR)) {
1502
1576
  // Bind the receipt to the content of the verified surface (ADR-0005), NOT the
1503
1577
  // parent SHA (rebase/merge-fragile). Must be computed before latest.json is
1504
1578
  // written — the receipt is an output and must never hash itself.
1579
+ /**
1580
+ * The receipt's harness summary — compact by design. The per-file detail lives
1581
+ * on the harnessIntegrity step; this is the part a receipt-holder needs to
1582
+ * identify the lane, plus the names of any modified files (an auditor told
1583
+ * "not intact" and not told which files has been given a rumour, not a fact).
1584
+ */
1585
+ function harnessForReceipt() {
1586
+ const row = steps.find((st) => st.name === "harnessIntegrity");
1587
+ const r = row?.harness ?? checkHarnessIntegrity(ROOT);
1588
+ const summary = {
1589
+ name: r.name,
1590
+ version: r.version,
1591
+ sha256: r.sha256,
1592
+ status: r.status,
1593
+ intact: r.status === "intact",
1594
+ };
1595
+ if (r.status === "modified") {
1596
+ summary.modified = r.modified;
1597
+ summary.missing = r.missing;
1598
+ summary.extra = r.extra;
1599
+ }
1600
+ return summary;
1601
+ }
1602
+
1505
1603
  const inputs = computeInputsHash(ROOT);
1506
1604
 
1507
1605
  // The receipt. Deterministic key order; ONE volatile timestamp field.
@@ -1524,6 +1622,16 @@ const receipt = {
1524
1622
  fileCount: inputs.fileCount,
1525
1623
  },
1526
1624
  steps,
1625
+ // WHICH LANE issued this verdict. A receipt that cannot name its own harness
1626
+ // can only be checked against the tree it came from; naming the version and
1627
+ // the region digest lets a third party who holds the receipt ask the harder
1628
+ // question — was this the real published lane? — without the tree at all.
1629
+ //
1630
+ // `intact` is the LOCAL claim only: unmodified since installed. It is a
1631
+ // checksum, not a signature, and someone who edits the lane can edit this
1632
+ // too. What they cannot edit is what the registry published under that
1633
+ // version, which is why `version` + `sha256` travel together.
1634
+ harness: harnessForReceipt(),
1527
1635
  strength: { onDeviceSteps },
1528
1636
  evidenceLevel: level,
1529
1637
  artifacts,
File without changes
@@ -1,2 +0,0 @@
1
- #Mon Jul 06 18:20:40 WAT 2026
2
- gradle.version=8.11.1
File without changes