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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|