staysfixed 0.4.0 → 0.6.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.
@@ -78,6 +78,17 @@ const MAX_PATH_LENGTH = 512;
78
78
  /** Deepest value we will store. Past this something is recursing, not observing. */
79
79
  const MAX_VALUE_DEPTH = 64;
80
80
 
81
+ /**
82
+ * The share of its own addresses a build may disagree with itself about before the run stops
83
+ * counting as a measurement at all. Half is not a tuned number and nothing depends on its
84
+ * exact value: it is the point past which more of the comparison has been thrown away than
85
+ * kept, and no answer computed from what is left deserves to be called clean.
86
+ */
87
+ const STORM_SHARE = 0.5;
88
+
89
+ /** Below this many addresses the share means nothing — three out of four is not a storm. */
90
+ const STORM_FLOOR = 12;
91
+
81
92
  /** Control characters and newlines, which would break the store and every log line. */
82
93
  const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
83
94
 
@@ -787,6 +798,42 @@ export function mergeWobble(wobbles) {
787
798
  };
788
799
  }
789
800
 
801
+ /**
802
+ * When a wobble measurement stops being a measurement.
803
+ *
804
+ * Subtraction is set subtraction: a difference at an address the build cannot answer the same
805
+ * way twice is dropped. That is right, and it has one failure shape, which is the worst shape
806
+ * this tool has. If the second run of the new build FALLS OVER — the app crashed half way, a
807
+ * port was taken, a device went to sleep, a first run wrote a cache the second one read — then
808
+ * most of the addresses the first run answered are missing from the second, every one of them
809
+ * is filed as unsteady, every real difference at them is subtracted, and the run ends
810
+ * "nothing that already worked has changed". Confident, clean, and about nothing.
811
+ *
812
+ * So the share is looked at. A product that disagrees with itself about a handful of addresses
813
+ * is normal — a timestamp, an id, a port. A product that disagrees with itself about MOST of
814
+ * them did not wobble; something went wrong with the run. This is not a tolerance: no number
815
+ * here decides whether any difference is real. It decides one thing only — whether this run is
816
+ * entitled to say the word "clean".
817
+ *
818
+ * @param {Wobble} wobble
819
+ * @returns {{stormy: boolean, share: number, looked: number, vanished: number, why: string}}
820
+ */
821
+ export function wobbleStorm(wobble) {
822
+ const unstable = wobble.unstable.length;
823
+ const looked = unstable + wobble.steady;
824
+ const vanished = wobble.entries.filter((e) => e.kind === 'vanished').length;
825
+ const share = looked === 0 ? 0 : unstable / looked;
826
+ if (!wobble.measured || looked < STORM_FLOOR || share <= STORM_SHARE) {
827
+ return { stormy: false, share, looked, vanished, why: '' };
828
+ }
829
+ const percent = Math.round(share * 100);
830
+ const why =
831
+ `The new build was run twice and gave a different answer at ${unstable} of the ${looked} addresses it was asked about — ${percent}% of them` +
832
+ (vanished > 0 ? `, and ${vanished} address${vanished === 1 ? '' : 'es'} the first run answered were missing from the second altogether` : '') +
833
+ '. That is not a product wobbling; that is a run that went wrong. Everything it disagreed with itself about is dropped before anything is compared, so on this run the comparison covered almost nothing. This is not a pass and not a failure — there is no answer here. Run it again on a quiet machine, and if it happens twice, something in the product or its setup does not survive being started a second time.';
834
+ return { stormy: true, share, looked, vanished, why };
835
+ }
836
+
790
837
  /**
791
838
  * Subtract the measured noise from the differences.
792
839
  *
@@ -807,6 +854,12 @@ export function mergeWobble(wobbles) {
807
854
  */
808
855
  export function subtractWobble(differences, wobble, opts = {}) {
809
856
  const unstableNow = new Set(wobble.unstable);
857
+ // NOT symmetric, and that is deliberate. Subtracting the OLD build's wobble as well was
858
+ // tried on 2026-08-30 and taken straight back out: a path the old build answered randomly
859
+ // and the new build now answers the same way every time is a REAL change — somebody made
860
+ // something deterministic, or hard-coded what used to vary — and subtracting the old
861
+ // build's wobble is exactly what would hide it. Where both builds wobble at a path, the
862
+ // candidate's own wobble already covers it, so nothing is lost by leaving this alone.
810
863
 
811
864
  /** @type {Difference[]} */
812
865
  const real = [];
@@ -838,13 +891,21 @@ export function subtractWobble(differences, wobble, opts = {}) {
838
891
  });
839
892
  }
840
893
 
841
- return {
894
+ const storm = wobbleStorm(wobble);
895
+ /** @type {WobbleSubtraction} */
896
+ const out = {
842
897
  real,
843
898
  noise,
844
899
  newlyUnstable,
845
900
  couldTellNewlyUnstable: couldTell,
846
901
  note: subtractionNote(wobble, couldTell, real.length, noise.length, newlyUnstable.length),
847
902
  };
903
+ if (storm.stormy) {
904
+ out.couldNotTell = true;
905
+ out.couldNotTellWhy = storm.why;
906
+ out.note = `${storm.why} ${out.note}`;
907
+ }
908
+ return out;
848
909
  }
849
910
 
850
911
  /**
package/src/v2/remote.js CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  import { spawn } from 'node:child_process';
40
40
  import { StaysFixedError } from '../core/errors.js';
41
- import { joinPath, notCovered, observation, sizeBucket, timeBucket, trimForStorage } from './adapters/contract.js';
41
+ import { howLongItTook, joinPath, notCovered, observation, sizeBucket, timeBucket, trimForStorage } from './adapters/contract.js';
42
42
 
43
43
  /** @typedef {import('./types.js').Observation} Observation */
44
44
  /** @typedef {import('./types.js').Journey} Journey */
@@ -629,13 +629,12 @@ export function remoteRunner(opts) {
629
629
  journey: journey.name,
630
630
  surface,
631
631
  }));
632
- seen.push(observation({
632
+ seen.push(howLongItTook({
633
633
  channel: 'counters',
634
634
  path: joinPath('remote', host, journey.name, String(index), 'took'),
635
- value: timeBucket(result.ms),
636
- says: `On ${host}, "${label}" took ${timeBucket(result.ms)}.`,
635
+ ms: result.ms,
636
+ what: `On ${host}, "${label}"`,
637
637
  journey: journey.name,
638
- surface,
639
638
  }));
640
639
  } catch (error) {
641
640
  // The link went. Everything from here on is unchecked, and it says so.
package/src/v2/run.js CHANGED
@@ -24,6 +24,7 @@ import { makeEvents } from '../core/events.js';
24
24
  import { StaysFixedError, messageOf } from '../core/errors.js';
25
25
  import {
26
26
  diffCaptures,
27
+ findDuplicatePaths,
27
28
  measureWobble,
28
29
  mergeWobble,
29
30
  unmeasuredWobble,
@@ -104,6 +105,9 @@ const VERSION = /** @type {{version?: string}} */ (require('../../package.json')
104
105
  * @property {string} product One repo can build five products. This names one.
105
106
  * @property {BuildFingerprint} candidate The build you just made.
106
107
  * @property {Journey[]} journeys
108
+ * @property {CoverageGap[]} [gaps] Holes found before any journey ran — an adapter that
109
+ * fell over while listing what it would walk, a name
110
+ * that matched nothing. They belong in the coverage.
107
111
  * @property {Walker} walk
108
112
  * @property {string} cwd Project root — where the working diff is read.
109
113
  * @property {(candidate: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} [bootReference]
@@ -178,7 +182,7 @@ export async function runCheck(opts) {
178
182
 
179
183
  const journeys = (opts.journeys ?? []).filter((j) => !j.skip);
180
184
  /** @type {CoverageGap[]} */
181
- const gaps = [];
185
+ const gaps = [...(opts.gaps ?? [])];
182
186
  for (const skipped of (opts.journeys ?? []).filter((j) => j.skip)) {
183
187
  // A switched-off journey is missing coverage, never a pass. Anything else
184
188
  // lets a product go quiet by having its checks turned off one at a time.
@@ -227,6 +231,11 @@ export async function runCheck(opts) {
227
231
  const walked = new Map();
228
232
  /** @type {Map<string, Capture>} */
229
233
  const before = new Map();
234
+ // Counted rather than inferred. Both the live build and the stored record carry the SAME
235
+ // build id — they are two ways of looking at one build — so nothing about a capture in
236
+ // `before` says which of the two it came from, and the mode has to be recorded as it
237
+ // happens or not at all.
238
+ let liveWalks = 0;
230
239
  /** @type {Wobble[]} */
231
240
  const wobbles = [];
232
241
  /** @type {Wobble[]} */
@@ -240,6 +249,7 @@ export async function runCheck(opts) {
240
249
  say({ type: 'journey:start', at: events.elapsed(), journey: journey.name, message: `Walking ${journey.describe || journey.name}.` });
241
250
 
242
251
  const a = await walkOnce(opts, journey, opts.candidate, 'a', 'candidate', undefined, events);
252
+ gaps.push(...duplicateGaps(a.observations, journey));
243
253
  const b = await walkOnce(opts, journey, opts.candidate, 'b', 'candidate', undefined, events);
244
254
  const wobble = measureWobble(a, b);
245
255
  walked.set(journey.name, { a, b, wobble });
@@ -264,9 +274,34 @@ export async function runCheck(opts) {
264
274
  // switched off, and nothing in the output would say so.
265
275
  const wasA = await walkOnce(opts, journey, live.build, 'a', 'reference', live, events);
266
276
  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;
277
+ // The old build being ON this machine is not the same as the old build having been
278
+ // WALKED. When every observation it came back with is a hole, it was not walked, and
279
+ // treating that as the reference makes the whole product look newly invented: every
280
+ // address in the new build has nothing opposite it, so every one of them 'appeared'.
281
+ //
282
+ // Measured on Terminal Deck's Android app on 2026-08-30. `--paired` exports the old
283
+ // commit with `git archive`; an APK is a build output and is gitignored, so the export
284
+ // has no app in it; the adapter honestly reported one hole per journey; and the run
285
+ // came back with seventeen sealed escalations claiming the sign-in screen and every
286
+ // permission had appeared out of nowhere, with nothing anywhere saying the old build
287
+ // had never run. Falling back to the stored record here is weaker and says so, which
288
+ // is the whole difference between a weaker answer and a wrong one.
289
+ if (wasA.observations.some((o) => o.meta?.refused !== true)) {
290
+ before.set(journey.name, wasA);
291
+ referenceWobbles.push(measureWobble(wasA, wasB));
292
+ liveWalks += 1;
293
+ continue;
294
+ }
295
+ gaps.push({
296
+ what: `The old build could not be walked for "${journey.describe || journey.name}", so this was not a paired comparison after all.`,
297
+ why:
298
+ `${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${
299
+ wasA.observations[0]?.meta?.describe ?? 'the adapter could not open it'
300
+ }. This usually means the product is BUILT rather than committed — an APK, a .app, a packaged desktop app — and a checkout of the old commit does not contain one.`,
301
+ unlockedBy:
302
+ 'Build the old commit before the run, or point the settings at a kept copy of the old build\'s artifact. Until then this journey falls back to the record the old build left last time.',
303
+ surface: journey.surface,
304
+ });
270
305
  }
271
306
  const stored = await storedReference(opts.store, reference.id, journey.name);
272
307
  if (!stored.capture) {
@@ -279,6 +314,21 @@ export async function runCheck(opts) {
279
314
  continue;
280
315
  }
281
316
  before.set(journey.name, stored.capture);
317
+ // The rules stamp exists so a run can notice this, and until 2026-08-30 nothing ever
318
+ // read it. A stored capture normalised under one set of rules compared against a fresh
319
+ // one normalised under another produces differences that are about the RULES — either a
320
+ // wall of noise that reads like a regression, or, when the change was to add a rule,
321
+ // quiet where there should not be any. Either way the reader has to be told.
322
+ if (stored.capture.rules && a.rules && stored.capture.rules !== a.rules) {
323
+ gaps.push({
324
+ what: `"${journey.describe || journey.name}" is being compared across a change to the normalisation rules.`,
325
+ why:
326
+ `The stored record of the old build was tidied up by rule set ${stored.capture.rules} and this run used ${a.rules}. ` +
327
+ 'Some of what you see may be the rules changing rather than the product, and a rule that was added since could be covering something up.',
328
+ unlockedBy: 'Run a paired check, which walks the old build live under today\'s rules, or ship again to cut a fresh reference.',
329
+ surface: journey.surface,
330
+ });
331
+ }
282
332
  if (stored.wobble) steadyInReference.push(...steadyPaths(stored.capture, stored.wobble));
283
333
  else referenceWobbleMeasured = false;
284
334
  }
@@ -337,14 +387,30 @@ export async function runCheck(opts) {
337
387
  count: subtraction.real.length,
338
388
  message: subtraction.note,
339
389
  });
390
+ // A wobble big enough to swallow the comparison is not a result. It is recorded as a hole
391
+ // here and it takes the verdict down at the bottom of this function, because the one thing
392
+ // that must never come out of it is a clean sentence resting on a subtraction that removed
393
+ // most of what was looked at.
394
+ if (subtraction.couldNotTell === true) {
395
+ gaps.push({
396
+ what: 'This run could not tell you anything, because the new build did not answer the same way twice.',
397
+ why: subtraction.couldNotTellWhy ?? 'Most of the addresses it was asked about were unsteady, so almost everything was dropped before it was compared.',
398
+ unlockedBy: 'Run it again when the machine is quiet. If it happens twice, look at what the product does differently on a second start.',
399
+ });
400
+ }
340
401
  stop();
341
402
 
342
403
  // 5 — expensive proof, only where it is owed. Everything the live old build
343
404
  // does too is dropped silently and counted. That silence is the point: it is
344
405
  // what keeps this list short enough to read every word of.
345
406
  let survivors = subtraction.real;
346
- const mode = /** @type {'paired'|'stored-record'} */ (live ? 'paired' : 'stored-record');
347
- let provedLive = Boolean(live);
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
+ let provedLive = walkedLive;
348
414
  // How many suspicions the old build turned out to have as well. Naming this
349
415
  // number is what makes the short list believable: it says how much work the
350
416
  // expensive half did rather than leaving the reader to assume it did none.
@@ -403,7 +469,7 @@ export async function runCheck(opts) {
403
469
  if (warning) gaps.push(...warningGaps(mode, provedLive));
404
470
 
405
471
  return finish(opts, {
406
- ok: ranked.findings.length === 0 && subtraction.newlyUnstable.length === 0,
472
+ ok: ranked.findings.length === 0 && subtraction.newlyUnstable.length === 0 && subtraction.couldNotTell !== true,
407
473
  mode,
408
474
  modeWarning: warning,
409
475
  reference,
@@ -412,7 +478,9 @@ export async function runCheck(opts) {
412
478
  noise: subtraction.noise.length,
413
479
  newlyUnstable: subtraction.newlyUnstable,
414
480
  coverage: foldCoverage(walked, journeys, gaps),
415
- summary: summarise(ranked.findings, subtraction, wobble, warning, ranked.notes, reference, provedLive, dropped),
481
+ summary:
482
+ (subtraction.couldNotTell === true ? `NO ANSWER FROM THIS RUN. ${subtraction.couldNotTellWhy} ` : '') +
483
+ summarise(ranked.findings, subtraction, wobble, warning, ranked.notes, reference, provedLive, dropped),
416
484
  startedAt,
417
485
  started,
418
486
  events,
@@ -506,6 +574,33 @@ async function storedReference(store, buildId, journey) {
506
574
  }
507
575
  }
508
576
 
577
+ /**
578
+ * Two facts written down at one address, with two different answers.
579
+ *
580
+ * Every index in this engine keeps the FIRST observation at a path and ignores the rest, so
581
+ * the second fact has no address of its own: it is never compared with anything, and a door
582
+ * that broke behind it is invisible while the run still says "nothing that already worked has
583
+ * changed". The detector for this existed from the first day of v2 and until 2026-08-30
584
+ * nothing ever called it, which is why it is a named hole now rather than a comment.
585
+ *
586
+ * Identical repeats are not reported. Two log lines that tidy down to the same address AND the
587
+ * same value hide nothing, and reporting those would bury the ones that do.
588
+ *
589
+ * @param {Observation[]} observations
590
+ * @param {Journey} journey
591
+ * @returns {CoverageGap[]}
592
+ */
593
+ export function duplicateGaps(observations, journey) {
594
+ 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}".`,
596
+ why:
597
+ `Only the first is kept, so ${clash.values.slice(1).map((v) => JSON.stringify(v)).join(' and ')} ` +
598
+ `${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.`,
599
+ unlockedBy: 'The adapter that made that address has to give those two things two different names.',
600
+ surface: journey.surface,
601
+ }));
602
+ }
603
+
509
604
  /**
510
605
  * @param {CheckRun} opts
511
606
  * @param {BuildFingerprint} reference
@@ -569,7 +664,28 @@ async function walkOnce(opts, journey, build, run, which, live, events) {
569
664
  export function proveAgainstLive(suspicions, live, now) {
570
665
  /** @type {Map<string, Map<string, Observation>>} */
571
666
  const liveIndex = new Map();
572
- for (const [name, capture] of live) liveIndex.set(name, indexByPath(capture.observations));
667
+ for (const [name, capture] of live) {
668
+ // A capture that came back with nothing it could actually observe is NOT a walk of the
669
+ // old build, and treating it as one is the worst mistake this function can make: every
670
+ // stored before-value is thrown away, every difference is relabelled 'appeared' with no
671
+ // before-value at all, and the whole lot is stamped proven, which the report then reads
672
+ // out as "re-checked against the old build booted live, so none of it is drift".
673
+ //
674
+ // Measured on Terminal Deck's Android app on 2026-08-30. The old build is exported with
675
+ // `git archive`, an APK is a build output and is gitignored, so the exported checkout has
676
+ // no APK in it, prepare gives up, and the adapter correctly returns one uncovered
677
+ // observation saying so. A control that went from greyed out to usable — the exact
678
+ // regression the run was meant to catch — was reported as a control that had appeared out
679
+ // of nowhere, with `false` never mentioned. Any platform whose artifact is built rather
680
+ // than committed hits this, not only phones.
681
+ // `covered` is the ADAPTER's word for this and it does not survive onto an Observation:
682
+ // `observation()` in adapters/contract.js turns `covered: false` into `meta.refused`.
683
+ // Filtering on `o.covered` therefore matched everything and did nothing at all — the
684
+ // fix above was written correctly and then read the wrong field.
685
+ const walked = capture.observations.filter((o) => o.meta?.refused !== true);
686
+ if (walked.length === 0) continue;
687
+ liveIndex.set(name, indexByPath(walked));
688
+ }
573
689
  /** @type {Map<string, Map<string, Observation>>} */
574
690
  const nowIndex = new Map();
575
691
  for (const [name, pair] of now) nowIndex.set(name, indexByPath(pair.a.observations));
package/src/v2/sealed.js CHANGED
@@ -205,8 +205,14 @@ const SHORTEST_GUARD_NAME = 6;
205
205
  /** How much of one value is read. A whole HTTP body would drown the match in noise. */
206
206
  const VALUE_CHARS = 400;
207
207
 
208
- /** How many differences of one finding are read. Clusters can hold hundreds. */
209
- const MAX_DIFFERENCES_READ = 80;
208
+ // EVERY difference of a finding is read, and there is deliberately no ceiling on that.
209
+ // Until 2026-08-30 this file read the first eighty and stopped, which meant a cluster of
210
+ // three hundred addresses could hold the word `refund` at address two hundred and be
211
+ // classified ordinary — waivable by an agent, never seen by a person. A cap here is not a
212
+ // performance decision, it is a hole in the one gate that cannot have one. The cost is a
213
+ // substring search over text already in memory: the values are trimmed to VALUE_CHARS
214
+ // before they are searched, so reading them all costs a constant multiple of the finding
215
+ // itself, which the caller is already holding.
210
216
 
211
217
  // ---------------------------------------------------------------------------
212
218
  // The answer
@@ -340,7 +346,6 @@ export function sayRefusal(verdict, finding) {
340
346
  *
341
347
  * @typedef {object} ReadFinding
342
348
  * @property {{text: string, where: string}[]} pieces
343
- * @property {string} all Every piece joined, for one fast test.
344
349
  * @property {Set<Channel>} channels
345
350
  * @property {Difference[]} differences
346
351
  */
@@ -370,7 +375,7 @@ function readFinding(finding) {
370
375
  for (const file of finding.nearFiles ?? []) add(file, 'a source file this points at');
371
376
  for (const p of finding.paths ?? []) add(p, p);
372
377
 
373
- const differences = (finding.differences ?? []).slice(0, MAX_DIFFERENCES_READ);
378
+ const differences = finding.differences ?? [];
374
379
  for (const d of differences) {
375
380
  add(d.path, d.path);
376
381
  add(d.describe, d.path);
@@ -386,8 +391,7 @@ function readFinding(finding) {
386
391
 
387
392
  return {
388
393
  pieces,
389
- all: pieces.map((p) => p.text).join('\n'),
390
- channels: new Set((finding.differences ?? []).map((d) => d.channel)),
394
+ channels: new Set(differences.map((d) => d.channel)),
391
395
  differences,
392
396
  };
393
397
  }