create-cmp-cli 0.13.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.
Files changed (54) 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 +1709 -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 +96 -0
  44. package/src/lib/harness-upgrade.mjs +159 -2
  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 +95 -1
@@ -0,0 +1,332 @@
1
+ // flight-recorder.mjs — the lane's append-only journal of its own runs.
2
+ //
3
+ // The retrospective that reshaped this harness was only possible because
4
+ // session transcripts happened to exist. This module removes the "happened
5
+ // to": every verify-lane run appends one JSON line — profile, mode, verdict,
6
+ // evidence rung, per-step verdicts, every SKIP reason VERBATIM, and every
7
+ // degraded-path activation — so "did this project drift from its tooling?"
8
+ // is answerable mechanically (qa/retrospective.mjs), from the repo alone.
9
+ // The SKIP reasons are the signal that matters: they are where a harness
10
+ // quietly stops being used ("no device attached" forever, "maestro not
11
+ // installed" forever) without any single run ever failing.
12
+ //
13
+ // GROUND RULES, each load-bearing:
14
+ //
15
+ // - IN-REPO ONLY, NO PHONE-HOME. That is a product constraint, not a
16
+ // preference: the journal is the app's own artifact, and nothing here
17
+ // records a machine, a hostname, or a user beyond what git itself
18
+ // already records (the commit sha).
19
+ //
20
+ // - THE RECORDER MUST NEVER BREAK THE LANE. A recorder that fails the
21
+ // thing it observes is worse than no recorder: every write is wrapped,
22
+ // every failure degrades to {ok: false, reason} for the lane to NOTE in
23
+ // its own output — never to throw, never to change a verdict.
24
+ //
25
+ // - THE JOURNAL IS COMMITTED, NOT GITIGNORED — deliberately. The console's
26
+ // Evidence timeline reconstructs history from the git log of committed
27
+ // receipts; a gitignored journal could answer nothing about the past on
28
+ // a fresh clone, which is exactly the question this file exists to
29
+ // answer. It follows qa/evidence/latest.json's precedent: a lane output
30
+ // that is committed with the change and EXCLUDED from the receipt's
31
+ // hashed input surface (qa/lib/inputs-hash.mjs), because a lane output
32
+ // inside the hash would invalidate the very receipt that produced it.
33
+ //
34
+ // - THE READER STATES ONLY WHAT THE JOURNAL RECORDED. Summaries are
35
+ // counts and dates, never extrapolation: a short journal says it is
36
+ // short, a single full run yields no "stretch" arithmetic, and nothing
37
+ // here editorializes about the developer — the journal records lane
38
+ // runs, not people.
39
+ //
40
+ // ONE DELIBERATE GAP, stated because a silent one would be a lie: qa/watch.mjs
41
+ // passes `--no-journal`, so save-triggered fast runs are never journaled. The
42
+ // rule it follows is the same one the README evidence badge follows — THE INNER
43
+ // LOOP DOES NOT WRITE TO COMMITTED FILES. A watcher that appended on every save
44
+ // would add hundreds of lines a day to a committed file, turn the app's history
45
+ // into keystroke noise, and leave a permanently-dirty tree inside the very loop
46
+ // this recorder exists to observe. What survives is every full lane and every
47
+ // deliberate fast run — which is what the retrospective's questions actually
48
+ // rest on (SKIP reasons, degraded paths, the longest stretch with no full
49
+ // lane). renderFlightReport states the gap in its own output.
50
+
51
+ import fs from "node:fs";
52
+ import path from "node:path";
53
+
54
+ export const FLIGHT_JOURNAL_REL_PATH = "qa/flight-recorder.jsonl";
55
+ export const FLIGHT_SCHEMA = "cmp-flight/1";
56
+
57
+ // Below this many entries the report carries an explicit shortness note —
58
+ // two entries are two facts, not a trend, and the report must say so rather
59
+ // than let a reader infer a pattern from a journal that cannot support one.
60
+ const SHORT_JOURNAL_FLOOR = 5;
61
+
62
+ /**
63
+ * Shape one lane run into a journal entry. Pure — verify.mjs passes what it
64
+ * already computed for the receipt, so the journal can never disagree with
65
+ * the receipt about the same run.
66
+ *
67
+ * @param {object} run
68
+ * @param {string} run.profile lane profile (scaffold | local | ci | release)
69
+ * @param {string} run.mode "full" | "fast"
70
+ * @param {string} run.verdict "PASS" | "FAIL"
71
+ * @param {{rung: string}|null} run.evidenceLevel the derived rung (or null —
72
+ * fast runs and FAILed lanes carry none, and the journal records that
73
+ * honestly rather than borrowing a rung from elsewhere)
74
+ * @param {Array<{name: string, verdict: string, reason?: string}>} run.steps
75
+ * the lane's step results, verbatim
76
+ * @param {string|null} run.sha parent HEAD at run time (null before git init)
77
+ * @param {number} run.durationMs wall time of the step loop
78
+ * @param {string[]} run.onDeviceSteps device-tier steps that actually PASSed
79
+ * (verify.mjs's own strength derivation — reused, not recomputed, so the
80
+ * two can never drift)
81
+ * @param {string[]} run.degraded degraded-path activations the lane observed
82
+ * (self-heals, fallbacks) — each a short verbatim description
83
+ * @returns {object} one journal entry (JSON-serializable)
84
+ */
85
+ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps, sha, durationMs, onDeviceSteps, degraded }) {
86
+ const stepList = Array.isArray(steps) ? steps.filter((s) => s && typeof s.name === "string") : [];
87
+ return {
88
+ schema: FLIGHT_SCHEMA,
89
+ at: new Date().toISOString(),
90
+ commit: sha ?? null,
91
+ profile,
92
+ mode,
93
+ verdict,
94
+ evidenceRung: evidenceLevel?.rung ?? null,
95
+ durationMs,
96
+ steps: stepList.map((s) => ({ name: s.name, verdict: s.verdict })),
97
+ // SKIP reasons verbatim — the journal's core signal (see file header).
98
+ skips: stepList.filter((s) => s.verdict === "SKIP").map((s) => ({ step: s.name, reason: s.reason ?? "" })),
99
+ deviceSteps: Array.isArray(onDeviceSteps) ? onDeviceSteps : [],
100
+ degraded: Array.isArray(degraded) ? degraded : [],
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Append one entry to the journal. NEVER throws — a recorder that breaks the
106
+ * lane is worse than no recorder (see ground rules). The caller is expected
107
+ * to surface a failed append in the lane's own output.
108
+ * @param {string} root project root (absolute)
109
+ * @param {object} entry a buildFlightEntry() result
110
+ * @returns {{ok: true}|{ok: false, reason: string}}
111
+ */
112
+ export function appendFlightRecord(root, entry) {
113
+ try {
114
+ const p = path.join(root, FLIGHT_JOURNAL_REL_PATH);
115
+ fs.mkdirSync(path.dirname(p), { recursive: true });
116
+ fs.appendFileSync(p, `${JSON.stringify(entry)}\n`);
117
+ return { ok: true };
118
+ } catch (err) {
119
+ return { ok: false, reason: err?.message ?? String(err) };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Read the journal. Absent is not an error — it is the honest "no flight
125
+ * data recorded yet" state. Unparsable lines are counted, never silently
126
+ * dropped into the totals: the report must be able to say "N lines could
127
+ * not be read" instead of quietly under-counting.
128
+ * @param {string} root project root (absolute)
129
+ * @returns {{exists: boolean, entries: object[], malformed: number, error?: string}}
130
+ */
131
+ export function readFlightJournal(root) {
132
+ const p = path.join(root, FLIGHT_JOURNAL_REL_PATH);
133
+ if (!fs.existsSync(p)) return { exists: false, entries: [], malformed: 0 };
134
+ let raw;
135
+ try {
136
+ raw = fs.readFileSync(p, "utf8");
137
+ } catch (err) {
138
+ return { exists: true, entries: [], malformed: 0, error: err?.message ?? String(err) };
139
+ }
140
+ const entries = [];
141
+ let malformed = 0;
142
+ for (const line of raw.split("\n")) {
143
+ if (!line.trim()) continue;
144
+ try {
145
+ const parsed = JSON.parse(line);
146
+ if (parsed && typeof parsed === "object") entries.push(parsed);
147
+ else malformed += 1;
148
+ } catch {
149
+ malformed += 1;
150
+ }
151
+ }
152
+ return { exists: true, entries, malformed };
153
+ }
154
+
155
+ function countBy(entries, keyFn) {
156
+ const out = new Map();
157
+ for (const e of entries) {
158
+ const k = keyFn(e);
159
+ if (k === undefined || k === null) continue;
160
+ out.set(k, (out.get(k) ?? 0) + 1);
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function fmtDay(iso) {
166
+ return typeof iso === "string" ? iso.slice(0, 10) : "unknown";
167
+ }
168
+
169
+ function fmtGap(ms) {
170
+ const days = Math.floor(ms / 86_400_000);
171
+ const hours = Math.round((ms % 86_400_000) / 3_600_000);
172
+ if (days > 0) return `${days}d ${hours}h`;
173
+ const mins = Math.round((ms % 3_600_000) / 60_000);
174
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
175
+ }
176
+
177
+ /**
178
+ * Summarize journal entries into the facts the retrospective report prints.
179
+ * Pure arithmetic over recorded entries — no clock reads except the caller-
180
+ * supplied `now` (used only for the clearly-labeled "as of" distance to the
181
+ * last full run), no filesystem, no extrapolation.
182
+ * @param {object[]} entries parsed journal entries, in append (chronological) order
183
+ * @param {{now?: Date}} [opts]
184
+ * @returns {object} summary — see the field-by-field construction below
185
+ */
186
+ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
187
+ const runs = entries.filter((e) => e && typeof e === "object");
188
+ const byMode = countBy(runs, (e) => e.mode ?? "unknown");
189
+ const byProfile = countBy(runs, (e) => e.profile ?? "unknown");
190
+ const byVerdict = countBy(runs, (e) => e.verdict ?? "unknown");
191
+
192
+ // SKIP reasons, grouped VERBATIM — the reason string is the key on purpose:
193
+ // paraphrasing or normalizing would erase exactly the signal the journal
194
+ // exists to keep (two different reasons are two different problems).
195
+ const skipGroups = new Map();
196
+ for (const e of runs) {
197
+ for (const s of Array.isArray(e.skips) ? e.skips : []) {
198
+ // JSON-array key: reasons are arbitrary text, so a delimiter-joined
199
+ // string key would be ambiguous — and ambiguity here merges two
200
+ // different problems into one count.
201
+ const key = JSON.stringify([s.step ?? "?", s.reason ?? ""]);
202
+ skipGroups.set(key, (skipGroups.get(key) ?? 0) + 1);
203
+ }
204
+ }
205
+ const skipReasons = [...skipGroups.entries()]
206
+ .map(([key, count]) => {
207
+ const [step, reason] = JSON.parse(key);
208
+ return { step, reason, count };
209
+ })
210
+ .sort((a, b) => b.count - a.count || a.step.localeCompare(b.step));
211
+
212
+ const degradedGroups = countBy(
213
+ runs.flatMap((e) => (Array.isArray(e.degraded) ? e.degraded : [])).map((d) => ({ d })),
214
+ (x) => x.d,
215
+ );
216
+
217
+ const fullRuns = runs.filter((e) => e.mode === "full");
218
+ const deviceReached = runs.filter((e) => Array.isArray(e.deviceSteps) && e.deviceSteps.length > 0);
219
+ const rungOrder = { L0: 0, L1: 1, L2: 2, L3: 3 };
220
+ const highestRung = runs
221
+ .map((e) => e.evidenceRung)
222
+ .filter((r) => typeof r === "string" && r in rungOrder)
223
+ .sort((a, b) => rungOrder[b] - rungOrder[a])[0] ?? null;
224
+
225
+ // Longest stretch with no full lane — only computable BETWEEN two recorded
226
+ // full runs. One full run is a date, not a stretch; the report says so
227
+ // instead of inventing a gap against "now" or the journal's edges.
228
+ let longestFullGap = null;
229
+ for (let i = 1; i < fullRuns.length; i += 1) {
230
+ const a = Date.parse(fullRuns[i - 1].at);
231
+ const b = Date.parse(fullRuns[i].at);
232
+ if (Number.isNaN(a) || Number.isNaN(b)) continue;
233
+ const gap = b - a;
234
+ if (!longestFullGap || gap > longestFullGap.ms) {
235
+ longestFullGap = { ms: gap, from: fullRuns[i - 1].at, to: fullRuns[i].at };
236
+ }
237
+ }
238
+
239
+ const lastFull = fullRuns.length ? fullRuns[fullRuns.length - 1].at : null;
240
+ const lastFullAgoMs = lastFull && !Number.isNaN(Date.parse(lastFull)) ? now.getTime() - Date.parse(lastFull) : null;
241
+
242
+ return {
243
+ total: runs.length,
244
+ span: runs.length ? { from: runs[0].at, to: runs[runs.length - 1].at } : null,
245
+ short: runs.length > 0 && runs.length < SHORT_JOURNAL_FLOOR,
246
+ byMode: Object.fromEntries(byMode),
247
+ byProfile: Object.fromEntries(byProfile),
248
+ byVerdict: Object.fromEntries(byVerdict),
249
+ skipReasons,
250
+ degraded: [...degradedGroups.entries()].map(([what, count]) => ({ what, count })),
251
+ fullRuns: {
252
+ count: fullRuns.length,
253
+ last: lastFull,
254
+ lastAgoMs: lastFullAgoMs,
255
+ longestGap: longestFullGap,
256
+ },
257
+ device: { reachedRuns: deviceReached.length, highestRung },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Render the summary as the plain-text report a human reads in ten seconds.
263
+ * Every line is a recorded fact; the honesty notes (short journal, single
264
+ * full run, malformed lines) are part of the report, not caveats around it.
265
+ * @param {object} summary a summarizeFlightJournal() result
266
+ * @param {{malformed?: number}} [opts]
267
+ * @returns {string[]} report lines
268
+ */
269
+ export function renderFlightReport(summary, { malformed = 0 } = {}) {
270
+ const lines = [];
271
+ if (summary.total === 0) {
272
+ lines.push("flight recorder: journal exists but holds no readable entries");
273
+ if (malformed > 0) lines.push(` ${malformed} line(s) could not be parsed`);
274
+ return lines;
275
+ }
276
+
277
+ lines.push(`flight recorder — ${summary.total} lane run(s) recorded (${fmtDay(summary.span.from)} → ${fmtDay(summary.span.to)})`);
278
+ if (malformed > 0) lines.push(` ${malformed} line(s) could not be parsed and are not counted`);
279
+ if (summary.short) {
280
+ lines.push(` only ${summary.total} run(s) recorded — the counts below are individual facts, not a trend`);
281
+ }
282
+
283
+ const modeBits = ["full", "fast"].map((m) => `${summary.byMode[m] ?? 0} ${m}`).join(" · ");
284
+ lines.push(`modes: ${modeBits}`);
285
+ // Disclosure, not a footnote: qa/watch.mjs passes --no-journal, so
286
+ // save-triggered fast runs are deliberately absent (a committed journal must
287
+ // not grow by hundreds of lines a day, and the inner loop must not leave the
288
+ // tree dirty). The fast count is therefore DELIBERATE fast runs only, and
289
+ // saying so here keeps the ratio from being read as a complete census.
290
+ lines.push(" (fast = deliberate runs only; qa/watch.mjs save-triggered runs are not journaled)");
291
+ lines.push(`verdicts: ${Object.entries(summary.byVerdict).map(([v, n]) => `${n} ${v}`).join(" · ")}`);
292
+ lines.push(`profiles: ${Object.entries(summary.byProfile).map(([p, n]) => `${p} ${n}`).join(" · ")}`);
293
+
294
+ if (summary.device.reachedRuns > 0) {
295
+ lines.push(
296
+ `device tier: reached in ${summary.device.reachedRuns} of ${summary.total} run(s)${summary.device.highestRung ? ` (highest evidence rung recorded: ${summary.device.highestRung})` : ""}`,
297
+ );
298
+ } else {
299
+ lines.push("device tier: never reached in any recorded run (no device-tier step ever PASSed)");
300
+ }
301
+
302
+ if (summary.fullRuns.count === 0) {
303
+ lines.push("full lane: never recorded — every recorded run was --fast (the inner loop; no run earned evidence)");
304
+ } else {
305
+ if (summary.fullRuns.longestGap) {
306
+ lines.push(
307
+ `full lane: longest recorded stretch with no full run: ${fmtGap(summary.fullRuns.longestGap.ms)} (${fmtDay(summary.fullRuns.longestGap.from)} → ${fmtDay(summary.fullRuns.longestGap.to)})`,
308
+ );
309
+ } else {
310
+ lines.push(`full lane: one full run recorded (${fmtDay(summary.fullRuns.last)}) — no stretch to measure between full runs`);
311
+ }
312
+ if (summary.fullRuns.lastAgoMs !== null) {
313
+ lines.push(` last full lane: ${fmtDay(summary.fullRuns.last)} (${fmtGap(summary.fullRuns.lastAgoMs)} before this report)`);
314
+ }
315
+ }
316
+
317
+ if (summary.skipReasons.length > 0) {
318
+ lines.push("skip reasons (verbatim, grouped):");
319
+ for (const s of summary.skipReasons) {
320
+ lines.push(` ${s.count}× [${s.step}] ${s.reason.split("\n")[0]}`);
321
+ }
322
+ } else {
323
+ lines.push("skip reasons: none recorded");
324
+ }
325
+
326
+ if (summary.degraded.length > 0) {
327
+ lines.push("degraded paths activated:");
328
+ for (const d of summary.degraded) lines.push(` ${d.count}× ${d.what}`);
329
+ }
330
+
331
+ return lines;
332
+ }
@@ -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
+ }
@@ -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
+ }