staysfixed 0.6.2 → 0.7.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/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/src/v2/run.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
subtractWobble,
|
|
32
32
|
sameValue,
|
|
33
33
|
indexByPath,
|
|
34
|
+
wobbleStorm,
|
|
34
35
|
} from './observation.js';
|
|
35
36
|
import { ensureStore, saveBuild, saveCapture, latestCapture, referenceFor, listBuilds } from './store.js';
|
|
36
37
|
import { clusterDifferences } from './cluster.js';
|
|
@@ -236,6 +237,13 @@ export async function runCheck(opts) {
|
|
|
236
237
|
// `before` says which of the two it came from, and the mode has to be recorded as it
|
|
237
238
|
// happens or not at all.
|
|
238
239
|
let liveWalks = 0;
|
|
240
|
+
// Which journeys really got compared against the old build, by either road. An empty
|
|
241
|
+
// `before` used to produce an empty difference list, which produced no findings, which
|
|
242
|
+
// produced "Nothing that worked has changed" — the tool's all-clear sentence, said about
|
|
243
|
+
// a run in which nothing was compared with anything. Counted here so the two can never
|
|
244
|
+
// come out of the same exit again.
|
|
245
|
+
/** @type {string[]} */
|
|
246
|
+
const comparedJourneys = [];
|
|
239
247
|
/** @type {Wobble[]} */
|
|
240
248
|
const wobbles = [];
|
|
241
249
|
/** @type {Wobble[]} */
|
|
@@ -251,9 +259,41 @@ export async function runCheck(opts) {
|
|
|
251
259
|
const a = await walkOnce(opts, journey, opts.candidate, 'a', 'candidate', undefined, events);
|
|
252
260
|
gaps.push(...duplicateGaps(a.observations, journey));
|
|
253
261
|
const b = await walkOnce(opts, journey, opts.candidate, 'b', 'candidate', undefined, events);
|
|
262
|
+
// The second pass too. A collision that only happens on the second run still eats a
|
|
263
|
+
// fact — the wobble measurement indexes by path exactly the same way — and only the
|
|
264
|
+
// first pass was ever checked.
|
|
265
|
+
gaps.push(...duplicateGaps(b.observations, journey, 'on the second run of the new build'));
|
|
254
266
|
const wobble = measureWobble(a, b);
|
|
255
267
|
walked.set(journey.name, { a, b, wobble });
|
|
256
268
|
wobbles.push(wobble);
|
|
269
|
+
// Per journey, not only over the whole run. The share is worked out again at the end
|
|
270
|
+
// across everything, and one journey that threw its whole comparison away hides inside
|
|
271
|
+
// nine that did not: ten journeys, one of them stormy, and the merged share never gets
|
|
272
|
+
// near half. That journey's answer is gone all the same, and this is where it is said.
|
|
273
|
+
// A journey that came back with nothing at all. It IS in `walked`, so the "produced
|
|
274
|
+
// nothing" check at the end never fires for it; it compares an empty list against an
|
|
275
|
+
// empty list, finds no differences, and counts as a journey that was walked. An
|
|
276
|
+
// adapter that started, found nothing to look at and returned politely is exactly how
|
|
277
|
+
// a whole surface goes dark without one sentence being written about it.
|
|
278
|
+
if (a.observations.length === 0) {
|
|
279
|
+
gaps.push({
|
|
280
|
+
what: `"${journey.describe || journey.name}" was walked and came back with nothing at all to look at.`,
|
|
281
|
+
why:
|
|
282
|
+
`${a.note ?? 'The adapter that drives this ran and produced no observations.'} ` +
|
|
283
|
+
'An empty walk compares to an empty walk and finds no differences, which is not the same as there being none.',
|
|
284
|
+
unlockedBy: 'Run this journey on its own to see what it does. If the product really has nothing to observe here, the journey is not covering anything and should say so or go.',
|
|
285
|
+
surface: journey.surface,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const weather = wobbleStorm(wobble);
|
|
289
|
+
if (weather.stormy) {
|
|
290
|
+
gaps.push({
|
|
291
|
+
what: `"${journey.describe || journey.name}" could not be compared: the new build did not answer it the same way twice.`,
|
|
292
|
+
why: weather.why,
|
|
293
|
+
unlockedBy: 'Run it again on a quiet machine. If it happens twice, something in the product does not survive being started a second time.',
|
|
294
|
+
surface: journey.surface,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
257
297
|
say({
|
|
258
298
|
type: 'journey:done',
|
|
259
299
|
at: events.elapsed(),
|
|
@@ -288,6 +328,8 @@ export async function runCheck(opts) {
|
|
|
288
328
|
// is the whole difference between a weaker answer and a wrong one.
|
|
289
329
|
if (wasA.observations.some((o) => o.meta?.refused !== true)) {
|
|
290
330
|
before.set(journey.name, wasA);
|
|
331
|
+
comparedJourneys.push(journey.name);
|
|
332
|
+
gaps.push(...duplicateGaps(wasA.observations, journey, 'while walking the old build'));
|
|
291
333
|
referenceWobbles.push(measureWobble(wasA, wasB));
|
|
292
334
|
liveWalks += 1;
|
|
293
335
|
continue;
|
|
@@ -304,6 +346,14 @@ export async function runCheck(opts) {
|
|
|
304
346
|
});
|
|
305
347
|
}
|
|
306
348
|
const stored = await storedReference(opts.store, reference.id, journey.name);
|
|
349
|
+
for (const problem of stored.problems) {
|
|
350
|
+
gaps.push({
|
|
351
|
+
what: `Part of the old build's record of "${journey.describe || journey.name}" could not be read.`,
|
|
352
|
+
why: problem,
|
|
353
|
+
unlockedBy: 'Run a paired check, which walks the old build live rather than reading a record, or ship again to cut a fresh reference.',
|
|
354
|
+
surface: journey.surface,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
307
357
|
if (!stored.capture) {
|
|
308
358
|
gaps.push({
|
|
309
359
|
what: `The journey "${journey.describe || journey.name}" has never been walked against ${nameOf(reference)}.`,
|
|
@@ -314,6 +364,20 @@ export async function runCheck(opts) {
|
|
|
314
364
|
continue;
|
|
315
365
|
}
|
|
316
366
|
before.set(journey.name, stored.capture);
|
|
367
|
+
comparedJourneys.push(journey.name);
|
|
368
|
+
gaps.push(...duplicateGaps(stored.capture.observations, journey, 'in the stored record of the old build'));
|
|
369
|
+
// A torn record of the old build is missing addresses, and every one of them reads as
|
|
370
|
+
// an address that has just APPEARED in the new build. The candidate's own torn captures
|
|
371
|
+
// were already reported; the reference's never were, and it is the side whose absences
|
|
372
|
+
// turn into findings.
|
|
373
|
+
if (stored.capture.complete === false) {
|
|
374
|
+
gaps.push({
|
|
375
|
+
what: `The stored record of "${journey.describe || journey.name}" against ${nameOf(reference)} was read back torn.`,
|
|
376
|
+
why: `${stored.capture.note ?? 'The run that wrote it stopped partway.'} Part of what the old build did is missing from it, so anything in that part looks like something the new build has just invented.`,
|
|
377
|
+
unlockedBy: 'Run a paired check, which walks the old build live instead of trusting the record, or ship again to cut a fresh reference.',
|
|
378
|
+
surface: journey.surface,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
317
381
|
// The rules stamp exists so a run can notice this, and until 2026-08-30 nothing ever
|
|
318
382
|
// read it. A stored capture normalised under one set of rules compared against a fresh
|
|
319
383
|
// one normalised under another produces differences that are about the RULES — either a
|
|
@@ -334,6 +398,12 @@ export async function runCheck(opts) {
|
|
|
334
398
|
}
|
|
335
399
|
|
|
336
400
|
stop();
|
|
401
|
+
// Booting the old build is not the same as having walked it. When every live walk came
|
|
402
|
+
// back holes-only — a built artifact that no checkout of the old commit contains — the
|
|
403
|
+
// run falls back to the stored record, and calling that a paired run would be the
|
|
404
|
+
// report's single most misleading sentence. See the gap pushed in the walk loop.
|
|
405
|
+
const walkedLive = liveWalks > 0;
|
|
406
|
+
const mode = /** @type {'paired'|'stored-record'} */ (walkedLive ? 'paired' : 'stored-record');
|
|
337
407
|
const wobble = wobbles.length > 0 ? mergeWobble(wobbles) : unmeasuredWobble(opts.candidate.id, '*');
|
|
338
408
|
say({
|
|
339
409
|
type: 'wobble',
|
|
@@ -345,7 +415,23 @@ export async function runCheck(opts) {
|
|
|
345
415
|
: `${wobble.unstable.length} ${plural(wobble.unstable.length, 'address', 'addresses')} this build cannot answer the same way twice. Subtracted, not counted.`,
|
|
346
416
|
});
|
|
347
417
|
|
|
348
|
-
|
|
418
|
+
// Things the closing paragraph owes the reader that are not findings and not coverage.
|
|
419
|
+
// The paragraph is what a person reads and what an agent quotes; a fact that only lands
|
|
420
|
+
// in the gap list is a fact most readers will never meet.
|
|
421
|
+
/** @type {string[]} */
|
|
422
|
+
const runNotes = [];
|
|
423
|
+
const kept = await remember(opts, walked);
|
|
424
|
+
if (kept.why) {
|
|
425
|
+
runNotes.push(
|
|
426
|
+
`WHAT THIS RUN SAW WAS NOT SAVED: ${kept.why} The answer here still stands — everything was walked and compared — but nothing reached the disk, so the next check will report these journeys as never having been walked.`,
|
|
427
|
+
);
|
|
428
|
+
gaps.push({
|
|
429
|
+
what: 'What this run saw was NOT saved, so the next run has nothing from today to compare against.',
|
|
430
|
+
why: `${kept.why} The answer below is still good — everything was walked and compared — but none of it reached the disk, so the next check will report these journeys as never having been walked.`,
|
|
431
|
+
unlockedBy: 'Free some disk space, or fix the permissions on the .staysfixed folder, and run the check again.',
|
|
432
|
+
});
|
|
433
|
+
say({ type: 'note', at: events.elapsed(), message: `This run could not be saved. ${kept.why}` });
|
|
434
|
+
}
|
|
349
435
|
|
|
350
436
|
// Nothing on record to compare against. That is the cold start on any
|
|
351
437
|
// product that has not been shipped once with the hook in place, and it is
|
|
@@ -361,7 +447,54 @@ export async function runCheck(opts) {
|
|
|
361
447
|
noise: 0,
|
|
362
448
|
newlyUnstable: [],
|
|
363
449
|
coverage: foldCoverage(walked, journeys, gaps),
|
|
364
|
-
summary:
|
|
450
|
+
summary: [
|
|
451
|
+
`Nothing to compare against yet: no build of ${opts.product} is on record as working.`,
|
|
452
|
+
// "This run has been kept" was said unconditionally, including on the runs where
|
|
453
|
+
// it had not been. On a cold start that sentence is the entire value of the run.
|
|
454
|
+
kept.kept
|
|
455
|
+
? 'This run has been kept, so the next one has something to measure against.'
|
|
456
|
+
: opts.remember === false
|
|
457
|
+
? 'It was not asked to keep this run, so the next one will start from nothing as well.'
|
|
458
|
+
: 'AND IT COULD NOT BE KEPT, so the next run will start from nothing as well.',
|
|
459
|
+
NO_REFERENCE_WARNING,
|
|
460
|
+
...runNotes,
|
|
461
|
+
].join(' '),
|
|
462
|
+
startedAt,
|
|
463
|
+
started,
|
|
464
|
+
events,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// There IS a build on record as working, and not one journey could be put beside it.
|
|
469
|
+
//
|
|
470
|
+
// Every road out of the loop above that fails — the old build had no record for this
|
|
471
|
+
// journey, or it was booted and there was nothing in it to run — ends in `continue`, and
|
|
472
|
+
// an empty `before` map makes an empty difference list, no findings, ok: true, and the
|
|
473
|
+
// sentence "Nothing that worked has changed." That sentence is this tool's whole promise
|
|
474
|
+
// and it was being said about a run that compared nothing with anything. It is the same
|
|
475
|
+
// shape as the wobble storm: not a pass, not a failure, no answer.
|
|
476
|
+
if (comparedJourneys.length === 0) {
|
|
477
|
+
const why =
|
|
478
|
+
`NO ANSWER FROM THIS RUN. None of the ${journeys.length} ${plural(journeys.length, 'journey', 'journeys')} could be put beside ${nameOf(reference)}: ` +
|
|
479
|
+
`there is no record of the old build doing any of them, and it could not be booted and walked either. ` +
|
|
480
|
+
`Nothing was compared with anything, so this run says nothing at all about whether the product still works — which is not the same as it being fine. ` +
|
|
481
|
+
`The coverage list below names each journey and what is missing for it.`;
|
|
482
|
+
gaps.push({
|
|
483
|
+
what: 'Nothing at all was compared on this run.',
|
|
484
|
+
why: `${journeys.length} ${plural(journeys.length, 'journey was', 'journeys were')} walked against the new build, and none of them had anything on the old build's side to be compared against.`,
|
|
485
|
+
unlockedBy: 'Run a paired check so the old build is built and walked here, or ship once with the reference hook in place so a record exists.',
|
|
486
|
+
});
|
|
487
|
+
return finish(opts, {
|
|
488
|
+
ok: false,
|
|
489
|
+
mode,
|
|
490
|
+
modeWarning: modeWarning(mode, walkedLive, reference),
|
|
491
|
+
reference,
|
|
492
|
+
findings: [],
|
|
493
|
+
real: 0,
|
|
494
|
+
noise: 0,
|
|
495
|
+
newlyUnstable: [],
|
|
496
|
+
coverage: foldCoverage(walked, journeys, gaps),
|
|
497
|
+
summary: [why, ...runNotes].join(' '),
|
|
365
498
|
startedAt,
|
|
366
499
|
started,
|
|
367
500
|
events,
|
|
@@ -371,10 +504,19 @@ export async function runCheck(opts) {
|
|
|
371
504
|
// 4 — compare, then subtract the noise.
|
|
372
505
|
/** @type {Difference[]} */
|
|
373
506
|
const raw = [];
|
|
507
|
+
// The addresses that really were put side by side. `wobble.steady` used to stand in for
|
|
508
|
+
// this in the closing sentence, and it is a different number: it counts what the NEW
|
|
509
|
+
// build answered the same way twice, whether or not the old build had anything to say
|
|
510
|
+
// about it. On a run where nine journeys of ten had no record, the summary still quoted
|
|
511
|
+
// every address the new build produced and read like a full comparison.
|
|
512
|
+
/** @type {Set<string>} */
|
|
513
|
+
const comparedAddresses = new Set();
|
|
374
514
|
for (const journey of journeys) {
|
|
375
515
|
const was = before.get(journey.name);
|
|
376
516
|
const is = walked.get(journey.name);
|
|
377
517
|
if (!was || !is) continue;
|
|
518
|
+
for (const o of was.observations) comparedAddresses.add(`${journey.name} ${o.path}`);
|
|
519
|
+
for (const o of is.a.observations) comparedAddresses.add(`${journey.name} ${o.path}`);
|
|
378
520
|
raw.push(...diffCaptures(was, is.a));
|
|
379
521
|
}
|
|
380
522
|
const subtraction = subtractWobble(raw, wobble, {
|
|
@@ -404,12 +546,6 @@ export async function runCheck(opts) {
|
|
|
404
546
|
// does too is dropped silently and counted. That silence is the point: it is
|
|
405
547
|
// what keeps this list short enough to read every word of.
|
|
406
548
|
let survivors = subtraction.real;
|
|
407
|
-
// Booting the old build is not the same as having walked it. When every live walk came
|
|
408
|
-
// back holes-only — a built artifact that no checkout of the old commit contains — the
|
|
409
|
-
// run fell back to the stored record above, and calling that a paired run would be the
|
|
410
|
-
// report's single most misleading sentence. See the gap pushed in the walk loop.
|
|
411
|
-
const walkedLive = liveWalks > 0;
|
|
412
|
-
const mode = /** @type {'paired'|'stored-record'} */ (walkedLive ? 'paired' : 'stored-record');
|
|
413
549
|
let provedLive = walkedLive;
|
|
414
550
|
// How many suspicions the old build turned out to have as well. Naming this
|
|
415
551
|
// number is what makes the short list believable: it says how much work the
|
|
@@ -480,7 +616,11 @@ export async function runCheck(opts) {
|
|
|
480
616
|
coverage: foldCoverage(walked, journeys, gaps),
|
|
481
617
|
summary:
|
|
482
618
|
(subtraction.couldNotTell === true ? `NO ANSWER FROM THIS RUN. ${subtraction.couldNotTellWhy} ` : '') +
|
|
483
|
-
summarise(ranked.findings, subtraction,
|
|
619
|
+
summarise(ranked.findings, subtraction, warning, [...runNotes, ...ranked.notes], reference, provedLive, dropped, {
|
|
620
|
+
compared: comparedJourneys.length,
|
|
621
|
+
asked: journeys.length,
|
|
622
|
+
addresses: comparedAddresses.size,
|
|
623
|
+
}),
|
|
484
624
|
startedAt,
|
|
485
625
|
started,
|
|
486
626
|
events,
|
|
@@ -518,14 +658,20 @@ export async function runCheck(opts) {
|
|
|
518
658
|
export async function resolveReference(store, product, against) {
|
|
519
659
|
if (against) {
|
|
520
660
|
const wanted = against.trim();
|
|
521
|
-
|
|
661
|
+
/** @type {string[]} */
|
|
662
|
+
const skipped = [];
|
|
663
|
+
const builds = await listBuilds(store, { product, onProblem: (m) => skipped.push(m) });
|
|
522
664
|
const hit = builds.find((b) => namesBuild(b.fingerprint, wanted));
|
|
523
665
|
if (!hit) {
|
|
666
|
+
// Saying "nothing on record matches" while a build folder was skipped for being
|
|
667
|
+
// unreadable is an answer that sounds certain and is not. The skipped ones are named,
|
|
668
|
+
// because one of them may well be the build being asked for.
|
|
669
|
+
const couldNotRead = skipped.length > 0 ? ` ${skipped.length} build ${skipped.length === 1 ? 'folder was' : 'folders were'} skipped and one of them may be the one you mean: ${skipped.join(' ')}` : '';
|
|
524
670
|
throw new StaysFixedError(`Nothing on record matches "${against}", so there is nothing to compare against.`, {
|
|
525
671
|
hint:
|
|
526
|
-
builds.length === 0
|
|
672
|
+
(builds.length === 0
|
|
527
673
|
? 'No builds of this product have been stored yet. Run a check once to store one.'
|
|
528
|
-
: `Builds on record: ${builds.slice(0, 8).map((b) => nameOf(b.fingerprint)).join(', ')}
|
|
674
|
+
: `Builds on record: ${builds.slice(0, 8).map((b) => nameOf(b.fingerprint)).join(', ')}${builds.length > 8 ? `, and ${builds.length - 8} more` : ''}.`) + couldNotRead,
|
|
529
675
|
});
|
|
530
676
|
}
|
|
531
677
|
return hit.fingerprint;
|
|
@@ -557,20 +703,26 @@ function namesBuild(build, wanted) {
|
|
|
557
703
|
* @param {Store} store
|
|
558
704
|
* @param {string} buildId
|
|
559
705
|
* @param {string} journey
|
|
560
|
-
* @returns {Promise<{capture: Capture|null, wobble: Wobble|null}>}
|
|
706
|
+
* @returns {Promise<{capture: Capture|null, wobble: Wobble|null, problems: string[]}>}
|
|
561
707
|
*/
|
|
562
708
|
async function storedReference(store, buildId, journey) {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
709
|
+
/** @type {string[]} */
|
|
710
|
+
const problems = [];
|
|
711
|
+
/** @param {string} m */
|
|
712
|
+
const onProblem = (m) => problems.push(m);
|
|
713
|
+
const a =
|
|
714
|
+
(await latestCapture(store, { buildId, journey, run: 'a', onProblem })) ??
|
|
715
|
+
(await latestCapture(store, { buildId, journey, onProblem }));
|
|
716
|
+
if (!a) return { capture: null, wobble: null, problems };
|
|
717
|
+
const b = await latestCapture(store, { buildId, journey, run: 'b', onProblem });
|
|
718
|
+
if (!b || b.id === a.id) return { capture: a, wobble: null, problems };
|
|
567
719
|
try {
|
|
568
|
-
return { capture: a, wobble: measureWobble(a, b) };
|
|
720
|
+
return { capture: a, wobble: measureWobble(a, b), problems };
|
|
569
721
|
} catch {
|
|
570
722
|
// Two captures of different builds or journeys got into the same folder.
|
|
571
723
|
// Losing the steadiness measurement is a shame; failing the run over it
|
|
572
724
|
// would be worse.
|
|
573
|
-
return { capture: a, wobble: null };
|
|
725
|
+
return { capture: a, wobble: null, problems };
|
|
574
726
|
}
|
|
575
727
|
}
|
|
576
728
|
|
|
@@ -588,11 +740,14 @@ async function storedReference(store, buildId, journey) {
|
|
|
588
740
|
*
|
|
589
741
|
* @param {Observation[]} observations
|
|
590
742
|
* @param {Journey} journey
|
|
743
|
+
* @param {string} [where] Which pass this was, when it was not the first walk of the new
|
|
744
|
+
* build — the same clash on the old build's record means something
|
|
745
|
+
* different to the reader and has to say so.
|
|
591
746
|
* @returns {CoverageGap[]}
|
|
592
747
|
*/
|
|
593
|
-
export function duplicateGaps(observations, journey) {
|
|
748
|
+
export function duplicateGaps(observations, journey, where = '') {
|
|
594
749
|
return findDuplicatePaths(observations).map((clash) => ({
|
|
595
|
-
what: `Two different answers were written down at the same address, ${clash.path}, while walking "${journey.describe || journey.name}".`,
|
|
750
|
+
what: `Two different answers were written down at the same address, ${clash.path}, while walking "${journey.describe || journey.name}"${where ? ` ${where}` : ''}.`,
|
|
596
751
|
why:
|
|
597
752
|
`Only the first is kept, so ${clash.values.slice(1).map((v) => JSON.stringify(v)).join(' and ')} ` +
|
|
598
753
|
`${clash.values.length > 2 ? 'were' : 'was'} never compared against anything at all. Whatever produced that address is giving one name to more than one thing.`,
|
|
@@ -728,19 +883,26 @@ export function proveAgainstLive(suspicions, live, now) {
|
|
|
728
883
|
*
|
|
729
884
|
* @param {CheckRun} opts
|
|
730
885
|
* @param {Map<string, {a: Capture, b: Capture}>} walked
|
|
731
|
-
* @returns {Promise<boolean>}
|
|
886
|
+
* @returns {Promise<{kept: boolean, why: string}>} `why` is empty when it worked, and empty
|
|
887
|
+
* when nobody asked for it to be kept. It carries a sentence only when it was asked for
|
|
888
|
+
* and failed, because that is the only case anybody has to be told about.
|
|
732
889
|
*/
|
|
733
890
|
async function remember(opts, walked) {
|
|
734
|
-
if (opts.remember === false) return false;
|
|
891
|
+
if (opts.remember === false) return { kept: false, why: '' };
|
|
735
892
|
try {
|
|
736
893
|
await saveBuild(opts.store, opts.candidate, { captures: walked.size * 2 });
|
|
737
894
|
for (const { a, b } of walked.values()) {
|
|
738
895
|
await saveCapture(opts.store, a);
|
|
739
896
|
await saveCapture(opts.store, b);
|
|
740
897
|
}
|
|
741
|
-
return true;
|
|
742
|
-
} catch {
|
|
743
|
-
|
|
898
|
+
return { kept: true, why: '' };
|
|
899
|
+
} catch (e) {
|
|
900
|
+
// The doc above has always said a full disk is a reason to SAY SO. It was not: the
|
|
901
|
+
// failure was swallowed here and the one caller threw the answer away, so a run whose
|
|
902
|
+
// captures never reached the disk looked exactly like one whose captures did — and the
|
|
903
|
+
// next run, finding no record, reported the whole product as never having been walked.
|
|
904
|
+
// His disk hit zero bytes on 2026-08-30, so this is not a thought experiment.
|
|
905
|
+
return { kept: false, why: messageOf(e) };
|
|
744
906
|
}
|
|
745
907
|
}
|
|
746
908
|
|
|
@@ -804,24 +966,47 @@ function warningGaps(mode, provedLive) {
|
|
|
804
966
|
*
|
|
805
967
|
* @param {Finding[]} findings
|
|
806
968
|
* @param {import('./types.js').WobbleSubtraction} subtraction
|
|
807
|
-
* @param {Wobble} wobble
|
|
808
969
|
* @param {string|undefined} warning
|
|
809
970
|
* @param {string[]} notes
|
|
810
971
|
* @param {BuildFingerprint} reference
|
|
811
972
|
* @param {boolean} provedLive
|
|
812
973
|
* @param {number} dropped Suspicions the old build turned out to have as well.
|
|
974
|
+
* @param {{compared: number, asked: number, addresses: number}} how How much of the run
|
|
975
|
+
* this sentence covers: journeys that had an old-build side, journeys asked for, and the
|
|
976
|
+
* addresses really put side by side.
|
|
813
977
|
* @returns {string}
|
|
814
978
|
*/
|
|
815
|
-
function summarise(findings, subtraction,
|
|
979
|
+
function summarise(findings, subtraction, warning, notes, reference, provedLive, dropped, how) {
|
|
816
980
|
const against = provedLive ? `${nameOf(reference)}, run live` : `the stored record of ${nameOf(reference)}`;
|
|
817
981
|
const parts = [];
|
|
818
|
-
|
|
819
|
-
|
|
982
|
+
// How much of the run this sentence is actually about. A run that compared four of its
|
|
983
|
+
// seventeen journeys is not a run that found nothing; it is a run that mostly did not look,
|
|
984
|
+
// and the first sentence is the only one some readers get.
|
|
985
|
+
const missed = how.asked - how.compared;
|
|
986
|
+
const reach =
|
|
987
|
+
missed > 0
|
|
988
|
+
? ` ${how.compared} of ${how.asked} journeys had anything on the old build's side to be compared against; the other ${missed} ${plural(missed, 'was', 'were')} not compared at all, and ${plural(missed, 'is', 'are')} named in the coverage list.`
|
|
989
|
+
: '';
|
|
990
|
+
if (findings.length === 0 && subtraction.newlyUnstable.length > 0) {
|
|
991
|
+
// Findings and newly unpredictable addresses are two different lists, and only the first
|
|
992
|
+
// one was ever in the headline. A run with no findings and four addresses that have
|
|
993
|
+
// stopped sitting still opened with "Nothing that worked has changed", which is the
|
|
994
|
+
// sentence somebody stops reading after — while `ok` was false and the reason sat three
|
|
995
|
+
// sentences down. Reported on 2026-08-30 as the tool announcing all-clear over a verdict
|
|
996
|
+
// that needed a person.
|
|
997
|
+
const n = subtraction.newlyUnstable.length;
|
|
998
|
+
parts.push(
|
|
999
|
+
`Nothing behaves differently, but ${n} ${plural(n, 'address', 'addresses')} that used to give the same answer every time ${plural(n, 'does', 'do')} not any more. ` +
|
|
1000
|
+
`That is a change too: something is now unpredictable that was not. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} compared against ${against}.${reach}`,
|
|
1001
|
+
);
|
|
1002
|
+
} else if (findings.length === 0) {
|
|
1003
|
+
parts.push(`Nothing that worked has changed. ${how.addresses} ${plural(how.addresses, 'address', 'addresses')} checked against ${against}.${reach}`);
|
|
820
1004
|
} else {
|
|
821
1005
|
const sealed = findings.filter((f) => f.sealed).length;
|
|
822
1006
|
parts.push(
|
|
823
1007
|
`${findings.length} ${plural(findings.length, 'thing behaves', 'things behave')} differently, checked against ${against}.` +
|
|
824
|
-
(sealed > 0 ? ` ${sealed} of them ${plural(sealed, 'is', 'are')} in a class nobody may wave through.` : '')
|
|
1008
|
+
(sealed > 0 ? ` ${sealed} of them ${plural(sealed, 'is', 'are')} in a class nobody may wave through.` : '') +
|
|
1009
|
+
reach,
|
|
825
1010
|
);
|
|
826
1011
|
}
|
|
827
1012
|
parts.push(subtraction.note);
|