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.
- 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 +96 -0
- package/src/lib/harness-upgrade.mjs +159 -2
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +30 -0
- package/template/gitignore +8 -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 +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- 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
|
+
}
|