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
@@ -0,0 +1,938 @@
1
+ /**
2
+ * Observations, paths, and the arithmetic of difference.
3
+ *
4
+ * This is the engine room. Everything the tool learns about a product — on a phone, in a
5
+ * terminal, over HTTP, out of the source — arrives here as `path -> value` and is compared
6
+ * the same way. There is exactly one comparison in the tool, and it lives in this file.
7
+ *
8
+ * Three things happen here and nothing else does:
9
+ * 1. Facts are made, and a malformed one is rejected AT THE SOURCE. A bad path found three
10
+ * layers later is a mystery; a bad path found at `makeObservation` is a stack trace
11
+ * pointing at the collector that wrote it.
12
+ * 2. Two captures are compared, including the paths that appeared and the paths that
13
+ * vanished — the findings that matter most and the ones pixels never see.
14
+ * 3. The product's own noise is MEASURED, by running the same build twice, and subtracted.
15
+ * There are no tolerance settings in v2 and there is no place to add one.
16
+ */
17
+
18
+ import { StaysFixedError } from '../core/errors.js';
19
+
20
+ /**
21
+ * @typedef {import('./types.js').Observation} Observation
22
+ * @typedef {import('./types.js').ObservedValue} ObservedValue
23
+ * @typedef {import('./types.js').ObservationMeta} ObservationMeta
24
+ * @typedef {import('./types.js').Channel} Channel
25
+ * @typedef {import('./types.js').Capture} Capture
26
+ * @typedef {import('./types.js').Difference} Difference
27
+ * @typedef {import('./types.js').DifferenceKind} DifferenceKind
28
+ * @typedef {import('./types.js').Wobble} Wobble
29
+ * @typedef {import('./types.js').WobbleEntry} WobbleEntry
30
+ * @typedef {import('./types.js').WobbleSubtraction} WobbleSubtraction
31
+ */
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Channels
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** @type {Channel[]} */
38
+ export const CHANNELS = [
39
+ 'meaning',
40
+ 'effects',
41
+ 'complaints',
42
+ 'results',
43
+ 'contract',
44
+ 'counters',
45
+ 'pixels',
46
+ ];
47
+
48
+ /**
49
+ * What each channel is, in the words we would use to a person. Printed by `doctor` and handed
50
+ * to any agent that asks the tool to describe itself.
51
+ * @type {Record<Channel, string>}
52
+ */
53
+ export const CHANNEL_NOTES = {
54
+ meaning: 'What the interface says a control is and does — its role, its name, whether it is on, off or disabled. Not the underlying markup.',
55
+ effects: 'What the product sent out into the world: calls made, files written, processes started, things saved.',
56
+ complaints: 'What the product complained about: console messages, errors, crashes, the code it exited with.',
57
+ results: 'What the product gave back: what it printed, what it answered, what it offers other code.',
58
+ contract: 'The doors the source code says exist: routes, exported functions, message channels. Read without running anything.',
59
+ counters: 'Rough counts and rough timings. Deliberately rough — precise timing is noise, not information.',
60
+ pixels: 'What it looked like. Used to show a person a problem another channel already found.',
61
+ };
62
+
63
+ /**
64
+ * @param {unknown} value
65
+ * @returns {value is Channel}
66
+ */
67
+ export function isChannel(value) {
68
+ return typeof value === 'string' && /** @type {string[]} */ (CHANNELS).includes(value);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Paths — the address space the whole tool is built on
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Longest path we will accept. A path is an address; a runaway value must never become one. */
76
+ const MAX_PATH_LENGTH = 512;
77
+
78
+ /** Deepest value we will store. Past this something is recursing, not observing. */
79
+ const MAX_VALUE_DEPTH = 64;
80
+
81
+ /**
82
+ * The share of its own addresses a build may disagree with itself about before the run stops
83
+ * counting as a measurement at all. Half is not a tuned number and nothing depends on its
84
+ * exact value: it is the point past which more of the comparison has been thrown away than
85
+ * kept, and no answer computed from what is left deserves to be called clean.
86
+ */
87
+ const STORM_SHARE = 0.5;
88
+
89
+ /** Below this many addresses the share means nothing — three out of four is not a storm. */
90
+ const STORM_FLOOR = 12;
91
+
92
+ /** Control characters and newlines, which would break the store and every log line. */
93
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
94
+
95
+ /**
96
+ * The path grammar, written out so it can be printed to whoever is wiring the tool up.
97
+ *
98
+ * A path is segments joined with dots, read left to right from the widest thing to the
99
+ * narrowest: surface, then place, then thing, then the property of it.
100
+ *
101
+ * api.GET./users.status
102
+ * cli.build.exit
103
+ * ipc.session:create.registered
104
+ * screen.home.tree.button:Save.enabled
105
+ *
106
+ * The first segment names the surface so paths from a phone and paths from a terminal sit in
107
+ * one list without colliding. Nothing enforces the vocabulary — a project may invent its own
108
+ * heads — but sticking to these keeps findings readable across products.
109
+ */
110
+ export const PATH_RULES = {
111
+ separator: '.',
112
+ maxLength: MAX_PATH_LENGTH,
113
+ minSegments: 2,
114
+ escaped: 'A dot inside one segment is written %2E, and a literal percent is %25. Use joinPath() and you never have to think about it.',
115
+ commonHeads: ['api', 'cli', 'ipc', 'screen', 'file', 'proc', 'store', 'net', 'export', 'route', 'log', 'count'],
116
+ examples: [
117
+ 'api.GET./users.status',
118
+ 'cli.build.exit',
119
+ 'ipc.session:create.registered',
120
+ 'screen.home.tree.button:Save.enabled',
121
+ ],
122
+ };
123
+
124
+ /**
125
+ * Make one segment safe to sit inside a path.
126
+ *
127
+ * Escaping rather than stripping matters: `v1.2` and `v12` are different names, and a
128
+ * stripping scheme would quietly merge two different buttons into one address.
129
+ *
130
+ * @param {string} segment
131
+ * @returns {string}
132
+ */
133
+ export function escapeSegment(segment) {
134
+ return String(segment).replace(/%/g, '%25').replace(/\./g, '%2E');
135
+ }
136
+
137
+ /**
138
+ * @param {string} segment
139
+ * @returns {string}
140
+ */
141
+ export function unescapeSegment(segment) {
142
+ return String(segment).replace(/%2E/gi, '.').replace(/%25/g, '%');
143
+ }
144
+
145
+ /**
146
+ * Build a path out of parts, escaping each one.
147
+ * @param {(string|number)[]} segments
148
+ * @returns {string}
149
+ */
150
+ export function joinPath(segments) {
151
+ return segments.map((s) => escapeSegment(String(s))).join('.');
152
+ }
153
+
154
+ /**
155
+ * Split a path back into its unescaped parts.
156
+ * @param {string} path
157
+ * @returns {string[]}
158
+ */
159
+ export function splitPath(path) {
160
+ return String(path).split('.').map(unescapeSegment);
161
+ }
162
+
163
+ /**
164
+ * Is this a usable path? Returns the reason it is not, or null when it is fine.
165
+ *
166
+ * Kept separate from `assertPath` so a collector can filter a noisy source without throwing
167
+ * on every stray line.
168
+ *
169
+ * @param {unknown} path
170
+ * @returns {string|null}
171
+ */
172
+ export function pathProblem(path) {
173
+ if (typeof path !== 'string') return `a path must be a string, got ${typeof path}`;
174
+ if (path.length === 0) return 'a path cannot be empty';
175
+ if (path.length > MAX_PATH_LENGTH) return `a path cannot be longer than ${MAX_PATH_LENGTH} characters (this one is ${path.length})`;
176
+ if (path !== path.trim()) return 'a path cannot start or end with a space';
177
+ // If this fires, a value has leaked into the address.
178
+ if (CONTROL_CHARS.test(path)) return 'a path cannot contain control characters or newlines';
179
+ if (path.startsWith('.') || path.endsWith('.')) return 'a path cannot start or end with a dot';
180
+ if (path.includes('..')) return 'a path cannot contain an empty segment (two dots in a row)';
181
+ const segments = path.split('.');
182
+ if (segments.length < PATH_RULES.minSegments) {
183
+ return `a path needs at least ${PATH_RULES.minSegments} parts — an address, not a name. Try something like "cli.${path}" or "screen.home.${path}"`;
184
+ }
185
+ for (const s of segments) {
186
+ if (s.trim().length === 0) return 'a path cannot contain a blank segment';
187
+ }
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * @param {unknown} path
193
+ * @returns {string}
194
+ */
195
+ export function assertPath(path) {
196
+ const problem = pathProblem(path);
197
+ if (problem) {
198
+ throw new StaysFixedError(`Bad observation path: ${problem}.`, {
199
+ hint: `Paths look like ${PATH_RULES.examples[0]}. ${PATH_RULES.escaped}`,
200
+ });
201
+ }
202
+ return /** @type {string} */ (path);
203
+ }
204
+
205
+ /**
206
+ * Match a path against a pattern.
207
+ *
208
+ * `*` matches anything inside one segment. `**` matches any number of segments. Used by the
209
+ * normalisation rules and by anything that wants to talk about a family of paths at once.
210
+ *
211
+ * matchPath('api.GET./users.status', 'api.*.*.status') -> true
212
+ * matchPath('screen.home.tree.button:Save.enabled', 'screen.**') -> true
213
+ *
214
+ * @param {string} path
215
+ * @param {string} pattern
216
+ * @returns {boolean}
217
+ */
218
+ export function matchPath(path, pattern) {
219
+ if (pattern === '**' || pattern === path) return true;
220
+ return matchFrom(path.split('.'), 0, pattern.split('.'), 0);
221
+ }
222
+
223
+ /**
224
+ * @param {string[]} p
225
+ * @param {number} startPi
226
+ * @param {string[]} g
227
+ * @param {number} startGi
228
+ * @returns {boolean}
229
+ */
230
+ function matchFrom(p, startPi, g, startGi) {
231
+ let pi = startPi;
232
+ let gi = startGi;
233
+ while (gi < g.length) {
234
+ const part = g[gi];
235
+ if (part === '**') {
236
+ // `**` at the end swallows the rest; otherwise try every split point.
237
+ if (gi === g.length - 1) return true;
238
+ for (let k = pi; k <= p.length; k++) {
239
+ if (matchFrom(p, k, g, gi + 1)) return true;
240
+ }
241
+ return false;
242
+ }
243
+ if (pi >= p.length) return false;
244
+ if (!matchSegment(p[pi], part)) return false;
245
+ pi++;
246
+ gi++;
247
+ }
248
+ return pi === p.length;
249
+ }
250
+
251
+ /**
252
+ * @param {string} segment
253
+ * @param {string} pattern
254
+ * @returns {boolean}
255
+ */
256
+ function matchSegment(segment, pattern) {
257
+ if (pattern === '*') return true;
258
+ if (!pattern.includes('*')) return segment === pattern;
259
+ const source = '^' + pattern.split('*').map(escapeRegExp).join('[^.]*') + '$';
260
+ return new RegExp(source).test(segment);
261
+ }
262
+
263
+ /**
264
+ * @param {string} s
265
+ * @returns {string}
266
+ */
267
+ function escapeRegExp(s) {
268
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
269
+ }
270
+
271
+ /**
272
+ * A stable order for paths, so two captures always list the same way and a diff of two
273
+ * reports is readable.
274
+ *
275
+ * Segment by segment, and a segment that is all digits compares as a number — otherwise
276
+ * `item.10` sorts before `item.2` and every report reads wrong. Plain `<` rather than
277
+ * `localeCompare` on purpose: locale collation varies with the ICU build, and an order that
278
+ * changes with the machine is exactly the kind of noise this tool exists to remove.
279
+ *
280
+ * @param {string} a
281
+ * @param {string} b
282
+ * @returns {number}
283
+ */
284
+ export function comparePaths(a, b) {
285
+ if (a === b) return 0;
286
+ const as = a.split('.');
287
+ const bs = b.split('.');
288
+ const n = Math.min(as.length, bs.length);
289
+ for (let i = 0; i < n; i++) {
290
+ const x = as[i];
291
+ const y = bs[i];
292
+ if (x === y) continue;
293
+ const xn = /^\d+$/.test(x);
294
+ const yn = /^\d+$/.test(y);
295
+ if (xn && yn) {
296
+ const d = Number(x) - Number(y);
297
+ if (d !== 0) return d < 0 ? -1 : 1;
298
+ // '007' and '7' are the same number — fall back to byte order so it is still stable.
299
+ return x < y ? -1 : 1;
300
+ }
301
+ if (xn !== yn) return xn ? -1 : 1; // numbers before words, consistently
302
+ return x < y ? -1 : 1;
303
+ }
304
+ if (as.length === bs.length) return 0;
305
+ return as.length < bs.length ? -1 : 1;
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Values — canonical form, equality, and how far apart two of them are
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * @param {unknown} v
314
+ * @returns {v is Record<string, unknown>}
315
+ */
316
+ function isPlainObject(v) {
317
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
318
+ }
319
+
320
+ /**
321
+ * A canonical string for a value: object keys sorted, so two objects that say the same thing
322
+ * in a different key order are equal.
323
+ *
324
+ * Non-finite numbers are written out as text rather than left to `JSON.stringify`, which
325
+ * turns NaN and Infinity into `null` — and a NaN that reads as null is a real difference
326
+ * hidden by a serialiser, which is the one thing this tool must never do.
327
+ *
328
+ * @param {ObservedValue} value
329
+ * @returns {string}
330
+ */
331
+ export function canonicalJson(value) {
332
+ return JSON.stringify(canonicalise(value, 0)) ?? 'null';
333
+ }
334
+
335
+ /**
336
+ * @param {ObservedValue} value
337
+ * @param {number} depth
338
+ * @returns {unknown}
339
+ */
340
+ function canonicalise(value, depth) {
341
+ if (depth > MAX_VALUE_DEPTH) return '<too deep>';
342
+ if (typeof value === 'number' && !Number.isFinite(value)) return `<number:${String(value)}>`;
343
+ if (Array.isArray(value)) return value.map((v) => canonicalise(v, depth + 1));
344
+ if (isPlainObject(value)) {
345
+ /** @type {Record<string, unknown>} */
346
+ const out = {};
347
+ for (const key of Object.keys(value).sort()) {
348
+ out[key] = canonicalise(/** @type {ObservedValue} */ (value[key]), depth + 1);
349
+ }
350
+ return out;
351
+ }
352
+ return value;
353
+ }
354
+
355
+ /**
356
+ * @param {ObservedValue|undefined} a
357
+ * @param {ObservedValue|undefined} b
358
+ * @returns {boolean}
359
+ */
360
+ export function sameValue(a, b) {
361
+ if (a === undefined || b === undefined) return a === b;
362
+ if (a === b) return true;
363
+ return canonicalJson(a) === canonicalJson(b);
364
+ }
365
+
366
+ /**
367
+ * Roughly how far apart two values are, 0 (identical) to 1 (nothing in common).
368
+ *
369
+ * READ THIS BEFORE USING IT: the number is for sorting a list and for writing a sentence a
370
+ * person can read. It is NOT a threshold and nothing in v2 compares it against one. Whether
371
+ * something differs is decided by equality; whether it counts is decided by measured wobble.
372
+ * Distance only decides what to show first.
373
+ *
374
+ * @param {ObservedValue|undefined} a
375
+ * @param {ObservedValue|undefined} b
376
+ * @param {number} [depth]
377
+ * @returns {number}
378
+ */
379
+ export function valueDistance(a, b, depth = 0) {
380
+ if (a === undefined || b === undefined) return a === b ? 0 : 1;
381
+ if (sameValue(a, b)) return 0;
382
+ if (depth > 8) return 1;
383
+
384
+ if (typeof a === 'number' && typeof b === 'number') {
385
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return 1;
386
+ const scale = Math.max(Math.abs(a), Math.abs(b), 1);
387
+ return clamp01(Math.abs(a - b) / scale);
388
+ }
389
+ if (typeof a === 'string' && typeof b === 'string') return stringDistance(a, b);
390
+ if (Array.isArray(a) && Array.isArray(b)) {
391
+ const n = Math.max(a.length, b.length);
392
+ if (n === 0) return 0;
393
+ let sum = 0;
394
+ for (let i = 0; i < n; i++) {
395
+ sum += i < a.length && i < b.length ? valueDistance(a[i], b[i], depth + 1) : 1;
396
+ }
397
+ return clamp01(sum / n);
398
+ }
399
+ if (isPlainObject(a) && isPlainObject(b)) {
400
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
401
+ if (keys.size === 0) return 0;
402
+ let sum = 0;
403
+ for (const k of keys) {
404
+ const av = /** @type {ObservedValue|undefined} */ (a[k]);
405
+ const bv = /** @type {ObservedValue|undefined} */ (b[k]);
406
+ sum += av === undefined || bv === undefined ? 1 : valueDistance(av, bv, depth + 1);
407
+ }
408
+ return clamp01(sum / keys.size);
409
+ }
410
+ // Different shapes entirely — a string where a number used to be. As far apart as it gets.
411
+ return 1;
412
+ }
413
+
414
+ /**
415
+ * How much of two strings is shared at their ends. Cheap on purpose: a real edit distance is
416
+ * quadratic and stdout observations run to megabytes.
417
+ * @param {string} a
418
+ * @param {string} b
419
+ * @returns {number}
420
+ */
421
+ function stringDistance(a, b) {
422
+ const total = a.length + b.length;
423
+ if (total === 0) return 0;
424
+ let prefix = 0;
425
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
426
+ let suffix = 0;
427
+ while (
428
+ suffix < a.length - prefix &&
429
+ suffix < b.length - prefix &&
430
+ a[a.length - 1 - suffix] === b[b.length - 1 - suffix]
431
+ ) {
432
+ suffix++;
433
+ }
434
+ return clamp01(1 - (2 * (prefix + suffix)) / total);
435
+ }
436
+
437
+ /**
438
+ * @param {number} n
439
+ * @returns {number}
440
+ */
441
+ function clamp01(n) {
442
+ if (!Number.isFinite(n)) return 1;
443
+ return n < 0 ? 0 : n > 1 ? 1 : n;
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Making an observation
448
+ // ---------------------------------------------------------------------------
449
+
450
+ /**
451
+ * Check a value is something we can store and compare, and say plainly what is wrong if not.
452
+ * @param {unknown} value
453
+ * @param {string} where Where inside the value we are, for the error message.
454
+ * @param {number} depth
455
+ * @param {Set<unknown>} seen
456
+ * @returns {string|null}
457
+ */
458
+ function valueProblem(value, where, depth, seen) {
459
+ if (depth > MAX_VALUE_DEPTH) return `${where} nests deeper than ${MAX_VALUE_DEPTH} levels`;
460
+ if (value === null) return null;
461
+ const t = typeof value;
462
+ if (t === 'string' || t === 'number' || t === 'boolean') return null;
463
+ if (t === 'undefined') {
464
+ return `${where} is undefined — a fact we do not have is an absent path, not a path holding nothing`;
465
+ }
466
+ if (t === 'function' || t === 'symbol' || t === 'bigint') return `${where} is a ${t}, which cannot be stored or compared`;
467
+ if (value instanceof Date) return `${where} is a Date — write it out as a string first, and let the clock rule normalise it`;
468
+ if (Array.isArray(value) || isPlainObject(value)) {
469
+ if (seen.has(value)) return `${where} contains itself`;
470
+ seen.add(value);
471
+ /** @type {[string, unknown][]} */
472
+ const entries = Array.isArray(value)
473
+ ? value.map((v, i) => /** @type {[string, unknown]} */ ([`${where}[${i}]`, v]))
474
+ : Object.entries(value).map(([k, v]) => /** @type {[string, unknown]} */ ([`${where}.${k}`, v]));
475
+ for (const [childWhere, child] of entries) {
476
+ const problem = valueProblem(child, childWhere, depth + 1, seen);
477
+ if (problem) return problem;
478
+ }
479
+ seen.delete(value);
480
+ return null;
481
+ }
482
+ return `${where} is a ${Object.prototype.toString.call(value)}, which cannot be stored or compared`;
483
+ }
484
+
485
+ /**
486
+ * Make one observation, and refuse to make a broken one.
487
+ *
488
+ * Validation lives here rather than at the store, at the diff or in a report, because a bad
489
+ * path found later is a mystery and a bad path found here is a stack trace pointing straight
490
+ * at the collector that produced it.
491
+ *
492
+ * @param {string} path
493
+ * @param {Channel} channel
494
+ * @param {ObservedValue} value
495
+ * @param {ObservationMeta} [meta]
496
+ * @returns {Observation}
497
+ */
498
+ export function makeObservation(path, channel, value, meta) {
499
+ const safePath = assertPath(path);
500
+ if (!isChannel(channel)) {
501
+ throw new StaysFixedError(`Unknown observation channel "${String(channel)}" for ${safePath}.`, {
502
+ hint: `The channels are: ${CHANNELS.join(', ')}.`,
503
+ });
504
+ }
505
+ const problem = valueProblem(value, 'the value', 0, new Set());
506
+ if (problem) {
507
+ throw new StaysFixedError(`Cannot observe ${safePath}: ${problem}.`, {
508
+ hint: 'Observations hold strings, numbers, booleans, null, and arrays or plain objects of those.',
509
+ });
510
+ }
511
+ /** @type {Observation} */
512
+ const observation = { path: safePath, channel, value };
513
+ if (meta && Object.keys(meta).length > 0) observation.meta = meta;
514
+ return observation;
515
+ }
516
+
517
+ /**
518
+ * Put a list of observations in the canonical order.
519
+ * @param {Observation[]} observations
520
+ * @returns {Observation[]}
521
+ */
522
+ export function sortObservations(observations) {
523
+ return [...observations].sort((a, b) => comparePaths(a.path, b.path));
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Indexing and diffing
528
+ // ---------------------------------------------------------------------------
529
+
530
+ /**
531
+ * @param {Capture|Observation[]} x
532
+ * @returns {Observation[]}
533
+ */
534
+ function observationsOf(x) {
535
+ return Array.isArray(x) ? x : x.observations;
536
+ }
537
+
538
+ /**
539
+ * @param {Capture|Observation[]} x
540
+ * @returns {string|undefined}
541
+ */
542
+ function journeyOf(x) {
543
+ return Array.isArray(x) ? undefined : x.journey;
544
+ }
545
+
546
+ /**
547
+ * Index observations by path.
548
+ *
549
+ * The FIRST observation at a path wins. Two facts at one address is a bug in whatever
550
+ * collected them — see `findDuplicatePaths` — and letting the last one win would hide it
551
+ * behind whatever order the collector happened to emit in.
552
+ *
553
+ * @param {Observation[]} observations
554
+ * @returns {Map<string, Observation>}
555
+ */
556
+ export function indexByPath(observations) {
557
+ /** @type {Map<string, Observation>} */
558
+ const map = new Map();
559
+ for (const o of observations) {
560
+ if (!map.has(o.path)) map.set(o.path, o);
561
+ }
562
+ return map;
563
+ }
564
+
565
+ /**
566
+ * Paths a capture claimed twice with two different answers. A collector bug, always —
567
+ * reported rather than thrown so one bad address does not lose a whole run.
568
+ *
569
+ * @param {Observation[]} observations
570
+ * @returns {{path: string, values: ObservedValue[]}[]}
571
+ */
572
+ export function findDuplicatePaths(observations) {
573
+ /** @type {Map<string, ObservedValue[]>} */
574
+ const seen = new Map();
575
+ for (const o of observations) {
576
+ const list = seen.get(o.path);
577
+ if (list) list.push(o.value);
578
+ else seen.set(o.path, [o.value]);
579
+ }
580
+ /** @type {{path: string, values: ObservedValue[]}[]} */
581
+ const out = [];
582
+ for (const [path, values] of seen) {
583
+ if (values.length < 2) continue;
584
+ /** @type {ObservedValue[]} */
585
+ const distinct = [];
586
+ for (const v of values) {
587
+ if (!distinct.some((d) => sameValue(d, v))) distinct.push(v);
588
+ }
589
+ if (distinct.length > 1) out.push({ path, values: distinct });
590
+ }
591
+ return out.sort((a, b) => comparePaths(a.path, b.path));
592
+ }
593
+
594
+ /**
595
+ * Compare a reference capture against a candidate capture.
596
+ *
597
+ * Three kinds come out, and the last two are what this tool exists for:
598
+ * changed — the same address now answers differently
599
+ * appeared — an address that did not exist before
600
+ * vanished — an address that has stopped existing. A door that closed. No screenshot
601
+ * comparison has ever noticed one of these.
602
+ *
603
+ * @param {Capture|Observation[]} reference
604
+ * @param {Capture|Observation[]} candidate
605
+ * @returns {Difference[]}
606
+ */
607
+ export function diffCaptures(reference, candidate) {
608
+ const ref = indexByPath(observationsOf(reference));
609
+ const cand = indexByPath(observationsOf(candidate));
610
+ const journey = journeyOf(candidate) ?? journeyOf(reference);
611
+
612
+ /** @type {Difference[]} */
613
+ const out = [];
614
+
615
+ for (const [path, r] of ref) {
616
+ const c = cand.get(path);
617
+ if (!c) {
618
+ out.push(difference(path, r.channel, 'vanished', r.value, undefined, journey, r, undefined));
619
+ continue;
620
+ }
621
+ if (!sameValue(r.value, c.value)) {
622
+ out.push(difference(path, c.channel, 'changed', r.value, c.value, journey, r, c));
623
+ }
624
+ }
625
+
626
+ for (const [path, c] of cand) {
627
+ if (!ref.has(path)) {
628
+ out.push(difference(path, c.channel, 'appeared', undefined, c.value, journey, undefined, c));
629
+ }
630
+ }
631
+
632
+ return out.sort((a, b) => comparePaths(a.path, b.path));
633
+ }
634
+
635
+ /**
636
+ * @param {string} path
637
+ * @param {Channel} channel
638
+ * @param {DifferenceKind} kind
639
+ * @param {ObservedValue|undefined} referenceValue
640
+ * @param {ObservedValue|undefined} candidateValue
641
+ * @param {string|undefined} journey
642
+ * @param {Observation|undefined} refObs
643
+ * @param {Observation|undefined} candObs
644
+ * @returns {Difference}
645
+ */
646
+ function difference(path, channel, kind, referenceValue, candidateValue, journey, refObs, candObs) {
647
+ /** @type {Difference} */
648
+ const d = {
649
+ path,
650
+ channel,
651
+ kind,
652
+ distance: valueDistance(referenceValue, candidateValue),
653
+ };
654
+ if (referenceValue !== undefined) d.reference = referenceValue;
655
+ if (candidateValue !== undefined) d.candidate = candidateValue;
656
+ if (journey) d.journey = journey;
657
+
658
+ const describe = candObs?.meta?.describe ?? refObs?.meta?.describe;
659
+ if (describe) d.describe = describe;
660
+ const evidence = candObs?.meta?.evidence ?? refObs?.meta?.evidence;
661
+ if (evidence) d.evidence = evidence;
662
+
663
+ // One address arriving on two different channels is worth saying out loud: two collectors
664
+ // are both claiming it, and one of them is wrong about what it is looking at.
665
+ if (refObs && candObs && refObs.channel !== candObs.channel) {
666
+ const prefix = d.describe ? d.describe + ' ' : '';
667
+ d.describe = `${prefix}(this address was observed as ${refObs.channel} before and ${candObs.channel} now — two collectors are claiming it)`;
668
+ }
669
+ return d;
670
+ }
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // Wobble — the product arguing with itself
674
+ // ---------------------------------------------------------------------------
675
+
676
+ /**
677
+ * Measure what a build disagrees with itself about, by comparing two runs of the SAME build.
678
+ *
679
+ * This replaces every tolerance setting the old tool had. A tolerance is a guess about how
680
+ * much noise a product makes; this is the measurement. Two runs, same bytes, same machine,
681
+ * minutes apart — anything that differs was not caused by anybody's change.
682
+ *
683
+ * @param {Capture|Observation[]} runA
684
+ * @param {Capture|Observation[]} runB
685
+ * @returns {Wobble}
686
+ */
687
+ export function measureWobble(runA, runB) {
688
+ const a = Array.isArray(runA) ? undefined : runA;
689
+ const b = Array.isArray(runB) ? undefined : runB;
690
+
691
+ if (a && b && a.build.id !== b.build.id) {
692
+ throw new StaysFixedError(
693
+ `Wobble has to be measured on one build, but these captures are of different builds (${a.build.id} and ${b.build.id}).`,
694
+ { hint: 'Run the same build twice. Comparing two different builds gives a difference, not a wobble.' },
695
+ );
696
+ }
697
+ if (a && b && a.journey !== b.journey) {
698
+ throw new StaysFixedError(
699
+ `Wobble has to be measured on one journey, but these captures walked "${a.journey}" and "${b.journey}".`,
700
+ );
701
+ }
702
+
703
+ const indexA = indexByPath(observationsOf(runA));
704
+ const indexB = indexByPath(observationsOf(runB));
705
+
706
+ /** @type {WobbleEntry[]} */
707
+ const entries = [];
708
+ let steady = 0;
709
+
710
+ for (const [path, oa] of indexA) {
711
+ const ob = indexB.get(path);
712
+ if (!ob) {
713
+ entries.push({ path, channel: oa.channel, kind: 'vanished', a: oa.value, distance: 1 });
714
+ continue;
715
+ }
716
+ if (sameValue(oa.value, ob.value)) {
717
+ steady++;
718
+ continue;
719
+ }
720
+ entries.push({
721
+ path,
722
+ channel: ob.channel,
723
+ kind: 'changed',
724
+ a: oa.value,
725
+ b: ob.value,
726
+ distance: valueDistance(oa.value, ob.value),
727
+ });
728
+ }
729
+
730
+ for (const [path, ob] of indexB) {
731
+ if (!indexA.has(path)) {
732
+ entries.push({ path, channel: ob.channel, kind: 'appeared', b: ob.value, distance: 1 });
733
+ }
734
+ }
735
+
736
+ entries.sort((x, y) => comparePaths(x.path, y.path));
737
+
738
+ return {
739
+ buildId: a?.build.id ?? b?.build.id ?? '',
740
+ journey: a?.journey ?? b?.journey ?? '',
741
+ runs: [a?.id ?? 'a', b?.id ?? 'b'],
742
+ entries,
743
+ unstable: entries.map((e) => e.path),
744
+ steady,
745
+ measured: true,
746
+ };
747
+ }
748
+
749
+ /**
750
+ * A wobble record for the case where the build was only run once.
751
+ *
752
+ * It subtracts nothing, and it exists so the rest of the pipeline never has to ask whether it
753
+ * has a measurement — it asks `measured`, and a run that could not measure says so in its
754
+ * summary instead of pretending its list is clean.
755
+ *
756
+ * @param {string} buildId
757
+ * @param {string} journey
758
+ * @returns {Wobble}
759
+ */
760
+ export function unmeasuredWobble(buildId, journey) {
761
+ return { buildId, journey, runs: ['', ''], entries: [], unstable: [], steady: 0, measured: false };
762
+ }
763
+
764
+ /**
765
+ * Fold several journeys' wobble into one record, so a whole-product run subtracts in one go.
766
+ *
767
+ * One journey that could not be measured twice makes the whole record unmeasured, because the
768
+ * alternative is a summary claiming a clean subtraction over a list that is partly raw.
769
+ *
770
+ * @param {Wobble[]} wobbles
771
+ * @returns {Wobble}
772
+ */
773
+ export function mergeWobble(wobbles) {
774
+ if (wobbles.length === 1) return wobbles[0];
775
+ /** @type {WobbleEntry[]} */
776
+ const entries = [];
777
+ const seen = new Set();
778
+ let steady = 0;
779
+ let measured = wobbles.length > 0;
780
+ for (const w of wobbles) {
781
+ if (!w.measured) measured = false;
782
+ steady += w.steady;
783
+ for (const e of w.entries) {
784
+ if (seen.has(e.path)) continue;
785
+ seen.add(e.path);
786
+ entries.push(e);
787
+ }
788
+ }
789
+ entries.sort((a, b) => comparePaths(a.path, b.path));
790
+ return {
791
+ buildId: wobbles[0]?.buildId ?? '',
792
+ journey: '*',
793
+ runs: [wobbles[0]?.runs[0] ?? '', wobbles[0]?.runs[1] ?? ''],
794
+ entries,
795
+ unstable: entries.map((e) => e.path),
796
+ steady,
797
+ measured,
798
+ };
799
+ }
800
+
801
+ /**
802
+ * When a wobble measurement stops being a measurement.
803
+ *
804
+ * Subtraction is set subtraction: a difference at an address the build cannot answer the same
805
+ * way twice is dropped. That is right, and it has one failure shape, which is the worst shape
806
+ * this tool has. If the second run of the new build FALLS OVER — the app crashed half way, a
807
+ * port was taken, a device went to sleep, a first run wrote a cache the second one read — then
808
+ * most of the addresses the first run answered are missing from the second, every one of them
809
+ * is filed as unsteady, every real difference at them is subtracted, and the run ends
810
+ * "nothing that already worked has changed". Confident, clean, and about nothing.
811
+ *
812
+ * So the share is looked at. A product that disagrees with itself about a handful of addresses
813
+ * is normal — a timestamp, an id, a port. A product that disagrees with itself about MOST of
814
+ * them did not wobble; something went wrong with the run. This is not a tolerance: no number
815
+ * here decides whether any difference is real. It decides one thing only — whether this run is
816
+ * entitled to say the word "clean".
817
+ *
818
+ * @param {Wobble} wobble
819
+ * @returns {{stormy: boolean, share: number, looked: number, vanished: number, why: string}}
820
+ */
821
+ export function wobbleStorm(wobble) {
822
+ const unstable = wobble.unstable.length;
823
+ const looked = unstable + wobble.steady;
824
+ const vanished = wobble.entries.filter((e) => e.kind === 'vanished').length;
825
+ const share = looked === 0 ? 0 : unstable / looked;
826
+ if (!wobble.measured || looked < STORM_FLOOR || share <= STORM_SHARE) {
827
+ return { stormy: false, share, looked, vanished, why: '' };
828
+ }
829
+ const percent = Math.round(share * 100);
830
+ const why =
831
+ `The new build was run twice and gave a different answer at ${unstable} of the ${looked} addresses it was asked about — ${percent}% of them` +
832
+ (vanished > 0 ? `, and ${vanished} address${vanished === 1 ? '' : 'es'} the first run answered were missing from the second altogether` : '') +
833
+ '. That is not a product wobbling; that is a run that went wrong. Everything it disagreed with itself about is dropped before anything is compared, so on this run the comparison covered almost nothing. This is not a pass and not a failure — there is no answer here. Run it again on a quiet machine, and if it happens twice, something in the product or its setup does not survive being started a second time.';
834
+ return { stormy: true, share, looked, vanished, why };
835
+ }
836
+
837
+ /**
838
+ * Subtract the measured noise from the differences.
839
+ *
840
+ * The rule is set subtraction and nothing cleverer: if a path will not sit still between two
841
+ * runs of the same build, a difference at that path proves nothing, whatever its size. Any
842
+ * "but it changed by MORE than the wobble did" rule is a tolerance wearing a disguise, and
843
+ * tolerances are how tools like this die — too loose to catch the real thing, too tight to
844
+ * leave switched on.
845
+ *
846
+ * The third result is the one no other tool produces. A path that was steady in the reference
847
+ * and wobbles now is a finding in itself: the change made something unpredictable. Nothing is
848
+ * "wrong" at that address and it still needs fixing.
849
+ *
850
+ * @param {Difference[]} differences
851
+ * @param {Wobble} wobble Measured on the candidate build.
852
+ * @param {{referenceWobble?: Wobble, steadyInReference?: string[]}} [opts]
853
+ * @returns {WobbleSubtraction}
854
+ */
855
+ export function subtractWobble(differences, wobble, opts = {}) {
856
+ const unstableNow = new Set(wobble.unstable);
857
+ // NOT symmetric, and that is deliberate. Subtracting the OLD build's wobble as well was
858
+ // tried on 2026-08-30 and taken straight back out: a path the old build answered randomly
859
+ // and the new build now answers the same way every time is a REAL change — somebody made
860
+ // something deterministic, or hard-coded what used to vary — and subtracting the old
861
+ // build's wobble is exactly what would hide it. Where both builds wobble at a path, the
862
+ // candidate's own wobble already covers it, so nothing is lost by leaving this alone.
863
+
864
+ /** @type {Difference[]} */
865
+ const real = [];
866
+ /** @type {Difference[]} */
867
+ const noise = [];
868
+
869
+ for (const d of differences) {
870
+ const wobbling = unstableNow.has(d.path);
871
+ // Copy rather than mutate: the caller's list is often the stored diff, and a flag written
872
+ // into it becomes a fact nobody can trace back to whoever decided it.
873
+ const flagged = { ...d, real: !wobbling, wobbling };
874
+ if (wobbling) noise.push(flagged);
875
+ else real.push(flagged);
876
+ }
877
+
878
+ const referenceWobble = opts.referenceWobble;
879
+ const steadyBefore = opts.steadyInReference ? new Set(opts.steadyInReference) : null;
880
+ const couldTell = Boolean((referenceWobble && referenceWobble.measured) || steadyBefore);
881
+
882
+ /** @type {WobbleEntry[]} */
883
+ let newlyUnstable = [];
884
+ if (couldTell) {
885
+ const unstableBefore = new Set(referenceWobble?.unstable ?? []);
886
+ newlyUnstable = wobble.entries.filter((e) => {
887
+ if (unstableBefore.has(e.path)) return false;
888
+ // With an explicit steady list we only claim the paths it names. Without one, anything
889
+ // the reference did not record as unstable counts.
890
+ return steadyBefore ? steadyBefore.has(e.path) : true;
891
+ });
892
+ }
893
+
894
+ const storm = wobbleStorm(wobble);
895
+ /** @type {WobbleSubtraction} */
896
+ const out = {
897
+ real,
898
+ noise,
899
+ newlyUnstable,
900
+ couldTellNewlyUnstable: couldTell,
901
+ note: subtractionNote(wobble, couldTell, real.length, noise.length, newlyUnstable.length),
902
+ };
903
+ if (storm.stormy) {
904
+ out.couldNotTell = true;
905
+ out.couldNotTellWhy = storm.why;
906
+ out.note = `${storm.why} ${out.note}`;
907
+ }
908
+ return out;
909
+ }
910
+
911
+ /**
912
+ * The sentence that goes in the summary. Written here so every caller says the same honest
913
+ * thing rather than inventing its own wording.
914
+ *
915
+ * @param {Wobble} wobble
916
+ * @param {boolean} couldTell
917
+ * @param {number} realCount
918
+ * @param {number} noiseCount
919
+ * @param {number} newlyUnstableCount
920
+ * @returns {string}
921
+ */
922
+ function subtractionNote(wobble, couldTell, realCount, noiseCount, newlyUnstableCount) {
923
+ if (!wobble.measured) {
924
+ return `The new build was only run once, so none of its own noise has been subtracted. All ${realCount} difference${realCount === 1 ? '' : 's'} here may include things that change on every run. Run it twice for a clean list.`;
925
+ }
926
+ const parts = [
927
+ `Running the new build twice showed ${wobble.unstable.length} address${wobble.unstable.length === 1 ? '' : 'es'} that will not sit still, and ${wobble.steady} that ${wobble.steady === 1 ? 'does' : 'do'}.`,
928
+ `${noiseCount} difference${noiseCount === 1 ? '' : 's'} landed on the unsteady ones and ${noiseCount === 1 ? 'was' : 'were'} dropped; ${realCount} remain${realCount === 1 ? 's' : ''}.`,
929
+ ];
930
+ if (!couldTell) {
931
+ parts.push('There is no record of how steady the old build was, so nothing can be reported as newly unpredictable.');
932
+ } else if (newlyUnstableCount > 0) {
933
+ parts.push(`${newlyUnstableCount} address${newlyUnstableCount === 1 ? ' was' : 'es were'} steady before and unpredictable now — the change made something non-deterministic.`);
934
+ } else {
935
+ parts.push('Nothing that used to be steady has become unpredictable.');
936
+ }
937
+ return parts.join(' ');
938
+ }