staysfixed 0.3.0 → 0.4.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 (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
package/src/v2/run.js ADDED
@@ -0,0 +1,964 @@
1
+ /**
2
+ * The loop this whole tool exists to perform.
3
+ *
4
+ * Run the build you just changed TWICE, so the product's own wobble is measured
5
+ * instead of guessed at. Compare what it did against the record of the build you
6
+ * were last happy with. Subtract the wobble arithmetically. Whatever still looks
7
+ * different gets the old build booted for real and walked again, and only what
8
+ * survives that reaches anybody. Then group it, order it, hand back a short list.
9
+ *
10
+ * Everything platform-shaped sits behind one function — `walk` — so this file
11
+ * never learns whether it is looking at a CLI tool, a browser, a desktop app or
12
+ * a phone. The loop is the same on all of them and is worth writing once.
13
+ *
14
+ * Two things this file refuses to do, both on purpose. It never guesses a
15
+ * tolerance: a difference is real or it is wobble, and running twice is what
16
+ * decides. And it never quietly downgrades — when the old build could not be
17
+ * booted, the verdict says so in plain words on every single run, because a
18
+ * weaker check that looks like a strong one is worse than no check at all.
19
+ */
20
+
21
+ import { createRequire } from 'node:module';
22
+
23
+ import { makeEvents } from '../core/events.js';
24
+ import { StaysFixedError, messageOf } from '../core/errors.js';
25
+ import {
26
+ diffCaptures,
27
+ measureWobble,
28
+ mergeWobble,
29
+ unmeasuredWobble,
30
+ subtractWobble,
31
+ sameValue,
32
+ indexByPath,
33
+ } from './observation.js';
34
+ import { ensureStore, saveBuild, saveCapture, latestCapture, referenceFor, listBuilds } from './store.js';
35
+ import { clusterDifferences } from './cluster.js';
36
+ import { rankFindings } from './rank.js';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ /** Read off package.json so what a verdict claims about itself can never drift from what shipped. */
41
+ const VERSION = /** @type {{version?: string}} */ (require('../../package.json')).version ?? '0.0.0';
42
+
43
+ /** @typedef {import('./types.js').Store} Store */
44
+ /** @typedef {import('./types.js').Capture} Capture */
45
+ /** @typedef {import('./types.js').CaptureRun} CaptureRun */
46
+ /** @typedef {import('./types.js').Observation} Observation */
47
+ /** @typedef {import('./types.js').Journey} Journey */
48
+ /** @typedef {import('./types.js').BuildFingerprint} BuildFingerprint */
49
+ /** @typedef {import('./types.js').Difference} Difference */
50
+ /** @typedef {import('./types.js').Finding} Finding */
51
+ /** @typedef {import('./types.js').Verdict} Verdict */
52
+ /** @typedef {import('./types.js').Wobble} Wobble */
53
+ /** @typedef {import('./types.js').WobbleEntry} WobbleEntry */
54
+ /** @typedef {import('./types.js').Coverage} Coverage */
55
+ /** @typedef {import('./types.js').CoverageGap} CoverageGap */
56
+ /** @typedef {import('./types.js').Channel} Channel */
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // The one door between this loop and every platform
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * The old build, built and running, ready to be walked.
64
+ *
65
+ * @typedef {object} LiveBuild
66
+ * @property {BuildFingerprint} build
67
+ * @property {string} [dir] A checkout it was built from, if any.
68
+ * @property {() => Promise<void>} release Always called, including when the run throws.
69
+ * @property {string} [why] Anything the summary should say about it.
70
+ */
71
+
72
+ /**
73
+ * One request to walk one journey against one build.
74
+ *
75
+ * @typedef {object} WalkRequest
76
+ * @property {Journey} journey
77
+ * @property {BuildFingerprint} build
78
+ * @property {CaptureRun} run 'a' and 'b' are the two passes of the same build.
79
+ * @property {'candidate'|'reference'} which
80
+ * @property {LiveBuild} [live] Present when the reference has been booted.
81
+ * @property {string} [dir] A checkout to build and run from instead of the
82
+ * working tree. The causal proof uses this.
83
+ * @property {CheckEvents} [events]
84
+ * @property {AbortSignal} [signal]
85
+ */
86
+
87
+ /**
88
+ * Walk one journey and flatten everything seen to path and value.
89
+ *
90
+ * Everything platform-specific lives behind this signature: a child process for
91
+ * a CLI, Playwright for the web, the CDP driver for Electron, a simulator for a
92
+ * phone. It must never refuse to come back — a journey that broke returns a
93
+ * capture whose coverage says so, because a thrown error loses the other
94
+ * journeys' work.
95
+ *
96
+ * @typedef {(req: WalkRequest) => Promise<Capture>} Walker
97
+ */
98
+
99
+ /**
100
+ * What a check needs to run.
101
+ *
102
+ * @typedef {object} CheckRun
103
+ * @property {Store} store
104
+ * @property {string} product One repo can build five products. This names one.
105
+ * @property {BuildFingerprint} candidate The build you just made.
106
+ * @property {Journey[]} journeys
107
+ * @property {Walker} walk
108
+ * @property {string} cwd Project root — where the working diff is read.
109
+ * @property {(candidate: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} [bootReference]
110
+ * Build and boot the reference build so it can be walked live. Absent, or
111
+ * answering null, means the run falls back to the stored record and says so.
112
+ * @property {string} [against] A marker, tag, version or commit naming the
113
+ * reference instead of the stored pointer.
114
+ * @property {boolean} [paired] Boot the old build live from the start.
115
+ * @property {boolean} [storedOnly] Never boot the old build, not even to prove a suspicion.
116
+ * @property {boolean} [remember] Default true: keep this run's captures for next time.
117
+ * @property {string[]} [guards] Guard names, so a difference touching one is sealed.
118
+ * @property {(capture: Capture) => Capture} [normalise] The rules from normalise.js, already bound.
119
+ * @property {CheckEvents} [events]
120
+ * @property {AbortSignal} [signal]
121
+ */
122
+
123
+ /**
124
+ * The v1 event stream, carrying v2's vocabulary.
125
+ *
126
+ * @typedef {object} CheckEvent
127
+ * @property {'check:start'|'reference'|'journey:start'|'journey:done'|'wobble'|'suspicion'|'proof:start'|'proof:done'|'cluster'|'note'|'check:done'} type
128
+ * @property {number} at
129
+ * @property {string} [message] Always plain English. This is the line a person reads.
130
+ * @property {string} [journey]
131
+ * @property {string} [run]
132
+ * @property {number} [count]
133
+ * @property {number} [durationMs]
134
+ * @property {Verdict} [verdict]
135
+ */
136
+
137
+ /**
138
+ * @typedef {object} CheckEvents
139
+ * @property {(event: CheckEvent) => void} emit
140
+ * @property {(listener: (event: CheckEvent) => void) => () => void} on
141
+ * @property {() => number} elapsed
142
+ * @property {() => CheckEvent[]} history
143
+ */
144
+
145
+ /**
146
+ * A stream for one check.
147
+ *
148
+ * It is v1's stream underneath, deliberately. The two rules that make it worth
149
+ * having — a late listener is handed everything it missed, and a listener that
150
+ * throws can never take the run down — are already written and already tested,
151
+ * and a second copy would only be a second thing to get wrong.
152
+ *
153
+ * @returns {CheckEvents}
154
+ */
155
+ export function makeCheckEvents() {
156
+ return /** @type {CheckEvents} */ (/** @type {unknown} */ (makeEvents()));
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // The check
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Prove that nothing which already worked has changed.
165
+ *
166
+ * @param {CheckRun} opts
167
+ * @returns {Promise<Verdict>}
168
+ */
169
+ export async function runCheck(opts) {
170
+ const startedAt = new Date();
171
+ const started = Date.now();
172
+ const events = opts.events ?? makeCheckEvents();
173
+ /** @param {CheckEvent} e */
174
+ const say = (e) => events.emit(e);
175
+ const stop = () => {
176
+ if (opts.signal?.aborted) throw new StaysFixedError('The check was stopped before it finished.');
177
+ };
178
+
179
+ const journeys = (opts.journeys ?? []).filter((j) => !j.skip);
180
+ /** @type {CoverageGap[]} */
181
+ const gaps = [];
182
+ for (const skipped of (opts.journeys ?? []).filter((j) => j.skip)) {
183
+ // A switched-off journey is missing coverage, never a pass. Anything else
184
+ // lets a product go quiet by having its checks turned off one at a time.
185
+ gaps.push({
186
+ what: `The journey "${skipped.describe || skipped.name}" was not walked.`,
187
+ why: skipped.skip || 'It is switched off in the journeys file.',
188
+ unlockedBy: 'Switch it back on, or delete it if it is no longer true of the product.',
189
+ surface: skipped.surface,
190
+ });
191
+ }
192
+ if (journeys.length === 0) {
193
+ throw new StaysFixedError('There are no journeys to walk, so there is nothing this check could prove.', {
194
+ hint: 'Point it at your test suite with --journeys suite, or read the doors out of the source with --journeys code.',
195
+ });
196
+ }
197
+
198
+ say({ type: 'check:start', at: 0, message: 'Checking that nothing which already worked has changed.' });
199
+
200
+ await ensureStore(opts.store);
201
+
202
+ // 1 — what counts as working.
203
+ const reference = await resolveReference(opts.store, opts.product, opts.against);
204
+ say({
205
+ type: 'reference',
206
+ at: events.elapsed(),
207
+ message: reference
208
+ ? `Comparing against ${nameOf(reference)}.`
209
+ : 'There is no build on record as working yet, so there is nothing to compare against.',
210
+ });
211
+ stop();
212
+
213
+ /** @type {LiveBuild|null} */
214
+ let live = null;
215
+ try {
216
+ // 2 — the same build, twice. This is the wobble measurement, not a retry.
217
+ if (opts.paired === true && reference) {
218
+ live = await bootReference(opts, reference, events);
219
+ if (!live) {
220
+ throw new StaysFixedError(`A paired run was asked for, but ${nameOf(reference)} cannot be built here.`, {
221
+ hint: 'Run without --paired to compare against the stored record instead.',
222
+ });
223
+ }
224
+ }
225
+
226
+ /** @type {Map<string, {a: Capture, b: Capture, wobble: Wobble}>} */
227
+ const walked = new Map();
228
+ /** @type {Map<string, Capture>} */
229
+ const before = new Map();
230
+ /** @type {Wobble[]} */
231
+ const wobbles = [];
232
+ /** @type {Wobble[]} */
233
+ const referenceWobbles = [];
234
+ /** @type {string[]} */
235
+ const steadyInReference = [];
236
+ let referenceWobbleMeasured = true;
237
+
238
+ for (const journey of journeys) {
239
+ stop();
240
+ say({ type: 'journey:start', at: events.elapsed(), journey: journey.name, message: `Walking ${journey.describe || journey.name}.` });
241
+
242
+ const a = await walkOnce(opts, journey, opts.candidate, 'a', 'candidate', undefined, events);
243
+ const b = await walkOnce(opts, journey, opts.candidate, 'b', 'candidate', undefined, events);
244
+ const wobble = measureWobble(a, b);
245
+ walked.set(journey.name, { a, b, wobble });
246
+ wobbles.push(wobble);
247
+ say({
248
+ type: 'journey:done',
249
+ at: events.elapsed(),
250
+ journey: journey.name,
251
+ count: a.observations.length,
252
+ message: `${a.observations.length} ${plural(a.observations.length, 'thing', 'things')} looked at, ${wobble.unstable.length} of which this build cannot answer the same way twice.`,
253
+ });
254
+
255
+ if (!reference) continue;
256
+
257
+ // 3 — what the old build did. Live if we booted it, otherwise the record
258
+ // it left the last time it ran.
259
+ if (live) {
260
+ // The old build is walked TWICE as well, for one reason that is worth the
261
+ // extra run: without knowing what the old build could not answer the same
262
+ // way twice, "your change made this unpredictable" cannot be said at all.
263
+ // Walking it once leaves the sharpest finding this tool has permanently
264
+ // switched off, and nothing in the output would say so.
265
+ const wasA = await walkOnce(opts, journey, live.build, 'a', 'reference', live, events);
266
+ const wasB = await walkOnce(opts, journey, live.build, 'b', 'reference', live, events);
267
+ before.set(journey.name, wasA);
268
+ referenceWobbles.push(measureWobble(wasA, wasB));
269
+ continue;
270
+ }
271
+ const stored = await storedReference(opts.store, reference.id, journey.name);
272
+ if (!stored.capture) {
273
+ gaps.push({
274
+ what: `The journey "${journey.describe || journey.name}" has never been walked against ${nameOf(reference)}.`,
275
+ why: 'There is no stored record of the old build doing this, so there is nothing to compare against.',
276
+ unlockedBy: 'Run a paired check once, or ship again with the journey in place.',
277
+ surface: journey.surface,
278
+ });
279
+ continue;
280
+ }
281
+ before.set(journey.name, stored.capture);
282
+ if (stored.wobble) steadyInReference.push(...steadyPaths(stored.capture, stored.wobble));
283
+ else referenceWobbleMeasured = false;
284
+ }
285
+
286
+ stop();
287
+ const wobble = wobbles.length > 0 ? mergeWobble(wobbles) : unmeasuredWobble(opts.candidate.id, '*');
288
+ say({
289
+ type: 'wobble',
290
+ at: events.elapsed(),
291
+ count: wobble.unstable.length,
292
+ message:
293
+ wobble.unstable.length === 0
294
+ ? 'This build gives the same answer twice, everywhere.'
295
+ : `${wobble.unstable.length} ${plural(wobble.unstable.length, 'address', 'addresses')} this build cannot answer the same way twice. Subtracted, not counted.`,
296
+ });
297
+
298
+ await remember(opts, walked);
299
+
300
+ // Nothing on record to compare against. That is the cold start on any
301
+ // product that has not been shipped once with the hook in place, and it is
302
+ // not a failure — but it must never look like a pass either.
303
+ if (!reference) {
304
+ return finish(opts, {
305
+ ok: true,
306
+ mode: 'stored-record',
307
+ modeWarning: NO_REFERENCE_WARNING,
308
+ reference: emptyFingerprint(opts.product),
309
+ findings: [],
310
+ real: 0,
311
+ noise: 0,
312
+ newlyUnstable: [],
313
+ coverage: foldCoverage(walked, journeys, gaps),
314
+ summary: `Nothing to compare against yet: no build of ${opts.product} is on record as working. This run has been kept, so the next one has something to measure against. ${NO_REFERENCE_WARNING}`,
315
+ startedAt,
316
+ started,
317
+ events,
318
+ });
319
+ }
320
+
321
+ // 4 — compare, then subtract the noise.
322
+ /** @type {Difference[]} */
323
+ const raw = [];
324
+ for (const journey of journeys) {
325
+ const was = before.get(journey.name);
326
+ const is = walked.get(journey.name);
327
+ if (!was || !is) continue;
328
+ raw.push(...diffCaptures(was, is.a));
329
+ }
330
+ const subtraction = subtractWobble(raw, wobble, {
331
+ referenceWobble: referenceWobbles.length > 0 ? mergeWobble(referenceWobbles) : undefined,
332
+ steadyInReference: referenceWobbleMeasured && steadyInReference.length > 0 ? steadyInReference : undefined,
333
+ });
334
+ say({
335
+ type: 'suspicion',
336
+ at: events.elapsed(),
337
+ count: subtraction.real.length,
338
+ message: subtraction.note,
339
+ });
340
+ stop();
341
+
342
+ // 5 — expensive proof, only where it is owed. Everything the live old build
343
+ // does too is dropped silently and counted. That silence is the point: it is
344
+ // what keeps this list short enough to read every word of.
345
+ let survivors = subtraction.real;
346
+ const mode = /** @type {'paired'|'stored-record'} */ (live ? 'paired' : 'stored-record');
347
+ let provedLive = Boolean(live);
348
+ // How many suspicions the old build turned out to have as well. Naming this
349
+ // number is what makes the short list believable: it says how much work the
350
+ // expensive half did rather than leaving the reader to assume it did none.
351
+ let dropped = 0;
352
+ if (survivors.length > 0 && !live && opts.storedOnly !== true) {
353
+ live = await bootReference(opts, reference, events);
354
+ if (live) {
355
+ const touched = unique(survivors.map((d) => d.journey));
356
+ say({
357
+ type: 'proof:start',
358
+ at: events.elapsed(),
359
+ count: touched.length,
360
+ message: `Booting ${nameOf(reference)} and walking ${touched.length} ${plural(touched.length, 'journey', 'journeys')} again, to see which of these are real.`,
361
+ });
362
+ /** @type {Map<string, Capture>} */
363
+ const liveNow = new Map();
364
+ for (const name of touched) {
365
+ const journey = journeys.find((j) => j.name === name);
366
+ if (!journey) continue;
367
+ liveNow.set(name, await walkOnce(opts, journey, live.build, 'single', 'reference', live, events));
368
+ }
369
+ const kept = proveAgainstLive(survivors, liveNow, walked);
370
+ dropped = survivors.length - kept.length;
371
+ survivors = kept;
372
+ provedLive = true;
373
+ say({
374
+ type: 'proof:done',
375
+ at: events.elapsed(),
376
+ count: survivors.length,
377
+ message:
378
+ dropped === 0
379
+ ? `All ${survivors.length} survived the old build being run again.`
380
+ : `${dropped} of them were the old build's own behaviour and have been dropped. ${survivors.length} left.`,
381
+ });
382
+ }
383
+ }
384
+
385
+ // 6 — group it, order it, explain it.
386
+ const clustered = clusterDifferences(survivors, { sources: sourceMap(walked) });
387
+ say({
388
+ type: 'cluster',
389
+ at: events.elapsed(),
390
+ count: clustered.length,
391
+ message:
392
+ clustered.length === survivors.length
393
+ ? `${clustered.length} ${plural(clustered.length, 'finding', 'findings')}.`
394
+ : `${survivors.length} differences are ${clustered.length} actual ${plural(clustered.length, 'finding', 'findings')}.`,
395
+ });
396
+ const ranked = await rankFindings(clustered, {
397
+ cwd: opts.cwd,
398
+ guards: opts.guards ?? [],
399
+ touches: touchMap(walked),
400
+ });
401
+
402
+ const warning = modeWarning(mode, provedLive, reference);
403
+ if (warning) gaps.push(...warningGaps(mode, provedLive));
404
+
405
+ return finish(opts, {
406
+ ok: ranked.findings.length === 0 && subtraction.newlyUnstable.length === 0,
407
+ mode,
408
+ modeWarning: warning,
409
+ reference,
410
+ findings: ranked.findings,
411
+ real: subtraction.real.length,
412
+ noise: subtraction.noise.length,
413
+ newlyUnstable: subtraction.newlyUnstable,
414
+ coverage: foldCoverage(walked, journeys, gaps),
415
+ summary: summarise(ranked.findings, subtraction, wobble, warning, ranked.notes, reference, provedLive, dropped),
416
+ startedAt,
417
+ started,
418
+ events,
419
+ });
420
+ } finally {
421
+ // Whatever happened, put the old build away. A left-behind app is the thing
422
+ // that makes the NEXT run look broken.
423
+ if (live) {
424
+ try {
425
+ await live.release();
426
+ } catch (e) {
427
+ say({ type: 'note', at: events.elapsed(), message: `The old build did not shut down cleanly. ${messageOf(e)}` });
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // The reference
435
+ // ---------------------------------------------------------------------------
436
+
437
+ /**
438
+ * Which build counts as working.
439
+ *
440
+ * With no `against`, this is whatever the store's reference pointer names — and
441
+ * that pointer is only ever moved by a person saying ship. With an `against`, it
442
+ * is the stored build whose version, tag, commit or id matches, so a check can
443
+ * be aimed at any build the store still knows about without rebuilding history.
444
+ *
445
+ * @param {Store} store
446
+ * @param {string} product
447
+ * @param {string} [against]
448
+ * @returns {Promise<BuildFingerprint|null>}
449
+ */
450
+ export async function resolveReference(store, product, against) {
451
+ if (against) {
452
+ const wanted = against.trim();
453
+ const builds = await listBuilds(store, { product });
454
+ const hit = builds.find((b) => namesBuild(b.fingerprint, wanted));
455
+ if (!hit) {
456
+ throw new StaysFixedError(`Nothing on record matches "${against}", so there is nothing to compare against.`, {
457
+ hint:
458
+ builds.length === 0
459
+ ? 'No builds of this product have been stored yet. Run a check once to store one.'
460
+ : `Builds on record: ${builds.slice(0, 8).map((b) => nameOf(b.fingerprint)).join(', ')}.`,
461
+ });
462
+ }
463
+ return hit.fingerprint;
464
+ }
465
+ const record = await referenceFor(store, product);
466
+ return record ? record.fingerprint : null;
467
+ }
468
+
469
+ /**
470
+ * Does this name pick out this build? Version, tag, commit or store id — the
471
+ * things a person actually types.
472
+ *
473
+ * @param {BuildFingerprint} build
474
+ * @param {string} wanted
475
+ */
476
+ function namesBuild(build, wanted) {
477
+ if (build.id === wanted || build.version === wanted) return true;
478
+ if (build.gitSha && (build.gitSha === wanted || build.gitSha.startsWith(wanted))) return true;
479
+ return false;
480
+ }
481
+
482
+ /**
483
+ * The reference build's stored record for one journey, and how steady it was.
484
+ *
485
+ * The second run matters as much as the first. Without it nothing can say
486
+ * whether a path that wobbles now also wobbled then, and "your change made this
487
+ * unpredictable" becomes a guess rather than a measurement.
488
+ *
489
+ * @param {Store} store
490
+ * @param {string} buildId
491
+ * @param {string} journey
492
+ * @returns {Promise<{capture: Capture|null, wobble: Wobble|null}>}
493
+ */
494
+ async function storedReference(store, buildId, journey) {
495
+ const a = (await latestCapture(store, { buildId, journey, run: 'a' })) ?? (await latestCapture(store, { buildId, journey }));
496
+ if (!a) return { capture: null, wobble: null };
497
+ const b = await latestCapture(store, { buildId, journey, run: 'b' });
498
+ if (!b || b.id === a.id) return { capture: a, wobble: null };
499
+ try {
500
+ return { capture: a, wobble: measureWobble(a, b) };
501
+ } catch {
502
+ // Two captures of different builds or journeys got into the same folder.
503
+ // Losing the steadiness measurement is a shame; failing the run over it
504
+ // would be worse.
505
+ return { capture: a, wobble: null };
506
+ }
507
+ }
508
+
509
+ /**
510
+ * @param {CheckRun} opts
511
+ * @param {BuildFingerprint} reference
512
+ * @param {CheckEvents} events
513
+ * @returns {Promise<LiveBuild|null>}
514
+ */
515
+ async function bootReference(opts, reference, events) {
516
+ if (!opts.bootReference) return null;
517
+ try {
518
+ return await opts.bootReference(reference, { events, signal: opts.signal });
519
+ } catch (e) {
520
+ events.emit({
521
+ type: 'note',
522
+ at: events.elapsed(),
523
+ message: `${nameOf(reference)} could not be started, so this run falls back to the stored record. ${messageOf(e)}`,
524
+ });
525
+ return null;
526
+ }
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Walking, and what comes back
531
+ // ---------------------------------------------------------------------------
532
+
533
+ /**
534
+ * @param {CheckRun} opts
535
+ * @param {Journey} journey
536
+ * @param {BuildFingerprint} build
537
+ * @param {CaptureRun} run
538
+ * @param {'candidate'|'reference'} which
539
+ * @param {LiveBuild|undefined} live
540
+ * @param {CheckEvents} events
541
+ * @returns {Promise<Capture>}
542
+ */
543
+ async function walkOnce(opts, journey, build, run, which, live, events) {
544
+ const capture = await opts.walk({
545
+ journey,
546
+ build,
547
+ run,
548
+ which,
549
+ live,
550
+ dir: live?.dir,
551
+ events,
552
+ signal: opts.signal,
553
+ });
554
+ // Normalisation happens here rather than inside every collector, so one rule
555
+ // set covers every platform and a rule can never be applied to one side of a
556
+ // comparison and not the other.
557
+ return opts.normalise ? opts.normalise(capture) : capture;
558
+ }
559
+
560
+ /**
561
+ * The expensive half: the old build was booted and walked again, so a real
562
+ * difference can be told apart from one the old build had as well.
563
+ *
564
+ * @param {Difference[]} suspicions
565
+ * @param {Map<string, Capture>} live Journey name to what the old build just did.
566
+ * @param {Map<string, {a: Capture}>} now
567
+ * @returns {Difference[]}
568
+ */
569
+ export function proveAgainstLive(suspicions, live, now) {
570
+ /** @type {Map<string, Map<string, Observation>>} */
571
+ const liveIndex = new Map();
572
+ for (const [name, capture] of live) liveIndex.set(name, indexByPath(capture.observations));
573
+ /** @type {Map<string, Map<string, Observation>>} */
574
+ const nowIndex = new Map();
575
+ for (const [name, pair] of now) nowIndex.set(name, indexByPath(pair.a.observations));
576
+
577
+ /** @type {Difference[]} */
578
+ const kept = [];
579
+ for (const d of suspicions) {
580
+ const journey = d.journey ?? '';
581
+ const wasLive = liveIndex.get(journey);
582
+ if (!wasLive) {
583
+ // The old build could not walk this journey. Never call that a pass: the
584
+ // difference stays, and says it is still only suspected.
585
+ kept.push({ ...d, proven: false });
586
+ continue;
587
+ }
588
+ const was = wasLive.get(d.path);
589
+ const is = nowIndex.get(journey)?.get(d.path);
590
+ if (!was && !is) continue;
591
+ if (was && is && sameValue(was.value, is.value)) continue;
592
+ if (!was && is) {
593
+ kept.push({ ...d, kind: 'appeared', reference: undefined, candidate: is.value, proven: true });
594
+ continue;
595
+ }
596
+ if (was && !is) {
597
+ kept.push({ ...d, kind: 'vanished', reference: was.value, candidate: undefined, proven: true });
598
+ continue;
599
+ }
600
+ if (!was || !is) continue;
601
+ kept.push({ ...d, kind: 'changed', reference: was.value, candidate: is.value, proven: true });
602
+ }
603
+ return kept;
604
+ }
605
+
606
+ /**
607
+ * Keep this run's captures, so the next run has something to compare against and
608
+ * so the wobble measured today can be checked against the wobble measured then.
609
+ *
610
+ * Storing is never allowed to fail a check: a full disk is a reason to say so,
611
+ * not a reason to throw away the answer somebody just waited for.
612
+ *
613
+ * @param {CheckRun} opts
614
+ * @param {Map<string, {a: Capture, b: Capture}>} walked
615
+ * @returns {Promise<boolean>}
616
+ */
617
+ async function remember(opts, walked) {
618
+ if (opts.remember === false) return false;
619
+ try {
620
+ await saveBuild(opts.store, opts.candidate, { captures: walked.size * 2 });
621
+ for (const { a, b } of walked.values()) {
622
+ await saveCapture(opts.store, a);
623
+ await saveCapture(opts.store, b);
624
+ }
625
+ return true;
626
+ } catch {
627
+ return false;
628
+ }
629
+ }
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // Saying what happened
633
+ // ---------------------------------------------------------------------------
634
+
635
+ /** The words the design insists on, said the same way every time this run is the weaker kind. */
636
+ const STORED_ONLY_WARNING =
637
+ 'This run compared against the stored record from the last time the old build ran, not against the old build run live. That is genuinely weaker: every difference that came from the days in between is still in this list.';
638
+
639
+ const PROVEN_LIVE_WARNING =
640
+ 'Everything reported here was re-checked against the old build booted live, so none of it is drift. What is NOT reported rests on the stored record from the last time the old build ran — a difference the store never captured cannot show up in this list.';
641
+
642
+ const NO_REFERENCE_WARNING =
643
+ 'Until you ship once with the reference hook in place there is nothing to compare against, so this run proves nothing about what still works.';
644
+
645
+ /**
646
+ * @param {'paired'|'stored-record'} mode
647
+ * @param {boolean} provedLive
648
+ * @param {BuildFingerprint} reference
649
+ * @returns {string|undefined}
650
+ */
651
+ function modeWarning(mode, provedLive, reference) {
652
+ if (mode === 'paired') return undefined;
653
+ return provedLive ? PROVEN_LIVE_WARNING : `${STORED_ONLY_WARNING} The old build here is ${nameOf(reference)}.`;
654
+ }
655
+
656
+ /**
657
+ * A weaker run is missing coverage, not just a warning. Putting it in the
658
+ * coverage list is what makes it countable rather than a sentence somebody
659
+ * skims past.
660
+ *
661
+ * @param {'paired'|'stored-record'} mode
662
+ * @param {boolean} provedLive
663
+ * @returns {CoverageGap[]}
664
+ */
665
+ function warningGaps(mode, provedLive) {
666
+ if (mode === 'paired') return [];
667
+ if (provedLive) {
668
+ return [
669
+ {
670
+ what: 'Anything the old build never had a record for.',
671
+ why: 'The old build was only booted to re-check the differences already suspected, not to walk everything.',
672
+ unlockedBy: 'Run with --paired to boot the old build and walk every journey against it.',
673
+ },
674
+ ];
675
+ }
676
+ return [
677
+ {
678
+ what: 'Every difference that came from the days between the two builds.',
679
+ why: 'The old build was not run live, so nothing separates a real change from a change in the machine around it.',
680
+ unlockedBy: 'Make the old build buildable here, or run with --paired.',
681
+ },
682
+ ];
683
+ }
684
+
685
+ /**
686
+ * The paragraph a person reads and an agent quotes. One place, so no two exits
687
+ * can describe the same run differently.
688
+ *
689
+ * @param {Finding[]} findings
690
+ * @param {import('./types.js').WobbleSubtraction} subtraction
691
+ * @param {Wobble} wobble
692
+ * @param {string|undefined} warning
693
+ * @param {string[]} notes
694
+ * @param {BuildFingerprint} reference
695
+ * @param {boolean} provedLive
696
+ * @param {number} dropped Suspicions the old build turned out to have as well.
697
+ * @returns {string}
698
+ */
699
+ function summarise(findings, subtraction, wobble, warning, notes, reference, provedLive, dropped) {
700
+ const against = provedLive ? `${nameOf(reference)}, run live` : `the stored record of ${nameOf(reference)}`;
701
+ const parts = [];
702
+ if (findings.length === 0) {
703
+ parts.push(`Nothing that worked has changed. ${wobble.steady} ${plural(wobble.steady, 'address', 'addresses')} checked against ${against}.`);
704
+ } else {
705
+ const sealed = findings.filter((f) => f.sealed).length;
706
+ parts.push(
707
+ `${findings.length} ${plural(findings.length, 'thing behaves', 'things behave')} differently, checked against ${against}.` +
708
+ (sealed > 0 ? ` ${sealed} of them ${plural(sealed, 'is', 'are')} in a class nobody may wave through.` : ''),
709
+ );
710
+ }
711
+ parts.push(subtraction.note);
712
+ if (dropped > 0) {
713
+ parts.push(
714
+ `${dropped} of those turned out to be things the old build does too, once it was booted and walked again, and ${dropped === 1 ? 'it was' : 'they were'} dropped.`,
715
+ );
716
+ }
717
+ for (const note of notes) parts.push(note);
718
+ if (warning) parts.push(warning);
719
+ return parts.join(' ');
720
+ }
721
+
722
+ /**
723
+ * @param {CheckRun} opts
724
+ * @param {{
725
+ * ok: boolean,
726
+ * mode: 'paired'|'stored-record',
727
+ * modeWarning: string|undefined,
728
+ * reference: BuildFingerprint,
729
+ * findings: Finding[],
730
+ * real: number,
731
+ * noise: number,
732
+ * newlyUnstable: WobbleEntry[],
733
+ * coverage: Coverage,
734
+ * summary: string,
735
+ * startedAt: Date,
736
+ * started: number,
737
+ * events: CheckEvents,
738
+ * }} parts
739
+ * @returns {Verdict}
740
+ */
741
+ function finish(opts, parts) {
742
+ /** @type {Verdict} */
743
+ const verdict = {
744
+ runId: runId(parts.startedAt),
745
+ product: opts.product,
746
+ ok: parts.ok,
747
+ mode: parts.mode,
748
+ reference: parts.reference,
749
+ candidate: opts.candidate,
750
+ findings: parts.findings,
751
+ differencesReal: parts.real,
752
+ differencesNoise: parts.noise,
753
+ newlyUnstable: parts.newlyUnstable,
754
+ coverage: parts.coverage,
755
+ summary: parts.summary,
756
+ durationMs: Date.now() - parts.started,
757
+ startedAt: parts.startedAt.toISOString(),
758
+ tool: `staysfixed ${VERSION}`,
759
+ };
760
+ if (parts.modeWarning) verdict.modeWarning = parts.modeWarning;
761
+ parts.events.emit({ type: 'check:done', at: parts.events.elapsed(), message: verdict.summary, verdict });
762
+ return verdict;
763
+ }
764
+
765
+ // ---------------------------------------------------------------------------
766
+ // Small things
767
+ // ---------------------------------------------------------------------------
768
+
769
+ /**
770
+ * Everything this run could not see, gathered in one place. Never empty on a
771
+ * real run, because a coverage list that comes back clean is a coverage list
772
+ * nobody is filling in.
773
+ *
774
+ * @param {Map<string, {a: Capture}>} walked
775
+ * @param {Journey[]} journeys
776
+ * @param {CoverageGap[]} extra
777
+ * @returns {Coverage}
778
+ */
779
+ export function foldCoverage(walked, journeys, extra) {
780
+ /** @type {Partial<Record<Channel, number>>} */
781
+ const byChannel = {};
782
+ /** @type {Set<string>} */
783
+ const paths = new Set();
784
+ /** @type {CoverageGap[]} */
785
+ const gaps = [...extra];
786
+ let doorsKnown = 0;
787
+ let doorsWalked = 0;
788
+
789
+ for (const { a } of walked.values()) {
790
+ for (const o of a.observations) {
791
+ paths.add(o.path);
792
+ byChannel[o.channel] = (byChannel[o.channel] ?? 0) + 1;
793
+ if (o.meta?.refused) {
794
+ // A refusal is the one thing that must never be silently rolled into a
795
+ // pass. It is what the tool did NOT do, said out loud.
796
+ gaps.push({
797
+ what: o.meta.describe ?? `"${o.path}" was observed at the call and stopped there.`,
798
+ why: o.meta.refusedWhy ?? 'Going further would have done something that cannot be undone.',
799
+ unlockedBy: 'Nothing. This is deliberate and permanent — the effect is watched at the call, never at the result.',
800
+ channel: o.channel,
801
+ });
802
+ }
803
+ }
804
+ if (a.coverage) {
805
+ doorsKnown += a.coverage.doorsKnown ?? 0;
806
+ doorsWalked += a.coverage.doorsWalked ?? 0;
807
+ gaps.push(...(a.coverage.gaps ?? []));
808
+ }
809
+ if (a.complete === false) {
810
+ gaps.push({
811
+ what: `The record of "${a.journey}" was read back torn.`,
812
+ why: 'The run that wrote it stopped partway, so some of what it saw is missing.',
813
+ unlockedBy: 'Run the check again; a complete capture replaces the torn one.',
814
+ });
815
+ }
816
+ }
817
+
818
+ /** @type {Coverage} */
819
+ const coverage = {
820
+ paths: paths.size,
821
+ journeys: walked.size,
822
+ byChannel,
823
+ gaps: dedupeGaps(gaps),
824
+ };
825
+ if (doorsKnown > 0) {
826
+ coverage.doorsKnown = doorsKnown;
827
+ coverage.doorsWalked = doorsWalked;
828
+ if (doorsWalked < doorsKnown) {
829
+ coverage.gaps.push({
830
+ what: `${doorsKnown - doorsWalked} of the ${doorsKnown} doors the code opens have never been walked through.`,
831
+ why: 'No journey reaches them, so a break behind one of them is invisible to this tool.',
832
+ unlockedBy: 'Add a journey that opens them, or point the check at the test suite that already does.',
833
+ doors: doorsKnown - doorsWalked,
834
+ });
835
+ }
836
+ }
837
+ // Journeys that were asked for and never produced a capture at all.
838
+ for (const journey of journeys) {
839
+ if (walked.has(journey.name)) continue;
840
+ coverage.gaps.push({
841
+ what: `The journey "${journey.describe || journey.name}" produced nothing.`,
842
+ why: 'It was asked for and no capture came back.',
843
+ unlockedBy: 'Run it on its own to see what it does.',
844
+ surface: journey.surface,
845
+ });
846
+ }
847
+ return coverage;
848
+ }
849
+
850
+ /**
851
+ * @param {CoverageGap[]} gaps
852
+ * @returns {CoverageGap[]}
853
+ */
854
+ function dedupeGaps(gaps) {
855
+ /** @type {CoverageGap[]} */
856
+ const out = [];
857
+ const seen = new Set();
858
+ for (const gap of gaps) {
859
+ const key = `${gap.what}|${gap.why}`;
860
+ if (seen.has(key)) continue;
861
+ seen.add(key);
862
+ out.push(gap);
863
+ }
864
+ return out;
865
+ }
866
+
867
+ /**
868
+ * Address to the source file it came from, so ranking can measure distance.
869
+ * @param {Map<string, {a: Capture}>} walked
870
+ * @returns {Record<string, string>}
871
+ */
872
+ function sourceMap(walked) {
873
+ /** @type {Record<string, string>} */
874
+ const out = {};
875
+ for (const { a } of walked.values()) {
876
+ for (const o of a.observations) {
877
+ const source = o.meta?.source;
878
+ if (source && out[o.path] === undefined) out[o.path] = source;
879
+ }
880
+ }
881
+ return out;
882
+ }
883
+
884
+ /**
885
+ * Journey to the source files it went through. Only useful when the list is
886
+ * short — see distanceFor in rank.js for why a long one is thrown away.
887
+ *
888
+ * @param {Map<string, {a: Capture}>} walked
889
+ * @returns {Record<string, string[]>}
890
+ */
891
+ function touchMap(walked) {
892
+ /** @type {Record<string, string[]>} */
893
+ const out = {};
894
+ for (const [name, { a }] of walked) {
895
+ const files = unique(a.observations.map((o) => o.meta?.source));
896
+ if (files.length > 0) out[name] = files;
897
+ }
898
+ return out;
899
+ }
900
+
901
+ /**
902
+ * The addresses a stored reference held steady, so a path that wobbles now can
903
+ * be told apart from one that always did.
904
+ *
905
+ * @param {Capture} capture
906
+ * @param {Wobble} wobble
907
+ * @returns {string[]}
908
+ */
909
+ function steadyPaths(capture, wobble) {
910
+ const unstable = new Set(wobble.unstable);
911
+ return capture.observations.map((o) => o.path).filter((p) => !unstable.has(p));
912
+ }
913
+
914
+ /**
915
+ * A build, named the way a person would name it.
916
+ * @param {BuildFingerprint} build
917
+ */
918
+ export function nameOf(build) {
919
+ if (build.version) return build.version;
920
+ if (build.gitSha) return build.gitSha.slice(0, 7);
921
+ return build.id || 'the build with no name';
922
+ }
923
+
924
+ /**
925
+ * A stand-in for "there is no reference". An empty id is the signal, and every
926
+ * reader of a Verdict can check it in one comparison.
927
+ *
928
+ * @param {string} product
929
+ * @returns {BuildFingerprint}
930
+ */
931
+ function emptyFingerprint(product) {
932
+ return { id: '', product };
933
+ }
934
+
935
+ /** @param {Date} at */
936
+ function runId(at) {
937
+ const p = (n = 0) => String(n).padStart(2, '0');
938
+ return `${at.getFullYear()}${p(at.getMonth() + 1)}${p(at.getDate())}-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`;
939
+ }
940
+
941
+ /**
942
+ * @param {(string|undefined)[]} values
943
+ * @returns {string[]}
944
+ */
945
+ function unique(values) {
946
+ /** @type {string[]} */
947
+ const out = [];
948
+ const seen = new Set();
949
+ for (const v of values) {
950
+ if (typeof v !== 'string' || v.length === 0 || seen.has(v)) continue;
951
+ seen.add(v);
952
+ out.push(v);
953
+ }
954
+ return out;
955
+ }
956
+
957
+ /**
958
+ * @param {number} n
959
+ * @param {string} one
960
+ * @param {string} many
961
+ */
962
+ function plural(n, one, many) {
963
+ return n === 1 ? one : many;
964
+ }