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