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