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