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/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
- await remember(opts, walked);
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: `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}`,
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, wobble, warning, ranked.notes, reference, provedLive, dropped),
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
- const builds = await listBuilds(store, { product });
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
- const a = (await latestCapture(store, { buildId, journey, run: 'a' })) ?? (await latestCapture(store, { buildId, journey }));
564
- if (!a) return { capture: null, wobble: null };
565
- const b = await latestCapture(store, { buildId, journey, run: 'b' });
566
- if (!b || b.id === a.id) return { capture: a, wobble: null };
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
- return false;
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, wobble, warning, notes, reference, provedLive, dropped) {
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
- if (findings.length === 0) {
819
- parts.push(`Nothing that worked has changed. ${wobble.steady} ${plural(wobble.steady, 'address', 'addresses')} checked against ${against}.`);
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);