staysfixed 0.9.1 → 0.11.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +107 -3
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/check.js +526 -19
  23. package/src/v2/cli.js +345 -3
  24. package/src/v2/cluster.js +112 -4
  25. package/src/v2/coverage.js +293 -8
  26. package/src/v2/detect.js +182 -9
  27. package/src/v2/doctor.js +253 -30
  28. package/src/v2/init.js +102 -10
  29. package/src/v2/mcp/server.js +4 -1
  30. package/src/v2/mcp/tools.js +291 -24
  31. package/src/v2/normalise.js +11 -0
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
@@ -55,6 +55,7 @@ import { StaysFixedError } from '../core/errors.js';
55
55
  import { safeName } from '../core/paths.js';
56
56
  import { setReference, referencePointer, loadBuild, listBuilds, listCaptures, loadCapture, ensureStore } from './store.js';
57
57
  import { measureWobble } from './observation.js';
58
+ import { isAnswer, answeredAnything, CHANNELS_ONLY_A_RUNNING_PRODUCT_FILLS } from './refusal.js';
58
59
 
59
60
  /** @typedef {import('./types.js').Store} Store */
60
61
  /** @typedef {import('./types.js').Capture} Capture */
@@ -142,7 +143,7 @@ const MAX_CHECK_LOG = 40;
142
143
  *
143
144
  * @typedef {object} CutDecision
144
145
  * @property {boolean} ok
145
- * @property {'clean'|'accounted-for'|'already-the-reference'|'never-checked'|'broken'|'blocked'|'not-stored'} state
146
+ * @property {'clean'|'accounted-for'|'already-the-reference'|'never-checked'|'broken'|'blocked'|'not-stored'|'nothing-observed'} state
146
147
  * @property {string} why Plain English, whichever way it went.
147
148
  * @property {string} [refusal] The full refusal, present only when `ok` is false.
148
149
  * @property {boolean} needsForce True when only `force: true` would get past this.
@@ -309,6 +310,48 @@ async function withLock(lock, work) {
309
310
  }
310
311
  }
311
312
 
313
+ /**
314
+ * Which journeys of a build actually saw the product, and which only ever met a refusal.
315
+ *
316
+ * `contract` and `counters` are deliberately not counted: those are doors read out of the
317
+ * SOURCE, and a door read is not a door opened — which is coverage.js's own rule, one notch
318
+ * further along. Only the channels a running product fills can prove it ran.
319
+ *
320
+ * @param {Store} store
321
+ * @param {string} buildId
322
+ * @returns {Promise<{walked: string[], refused: string[]}>}
323
+ */
324
+ async function whatWasActuallyObserved(store, buildId) {
325
+ /** @type {string[]} */
326
+ const walked = [];
327
+ /** @type {string[]} */
328
+ const refused = [];
329
+ try {
330
+ const { listCaptures, latestCapture } = await import('./store.js');
331
+ const refs = await listCaptures(store, { buildId });
332
+ for (const journey of [...new Set(refs.map((/** @type {any} */ r) => r.journey))].sort()) {
333
+ const capture = await latestCapture(store, { buildId, journey });
334
+ if (!capture) continue;
335
+ const fromTheProduct = (capture.observations ?? []).filter((/** @type {any} */ o) =>
336
+ CHANNELS_ONLY_A_RUNNING_PRODUCT_FILLS.has(o.channel),
337
+ );
338
+ if (fromTheProduct.length === 0) continue;
339
+ // Decided on the VALUE now, not on `meta.refused`. The two are different questions and
340
+ // this one was reading the wrong one: `meta.refused` is also stamped on an observation
341
+ // holding a REAL value that was only partly read — a stdout too big to keep whole — so
342
+ // a journey whose only product-channel observation was a truncated one was filed as
343
+ // having refused, and a healthy release could be blocked by a large log file. What
344
+ // matters here is whether there is an answer in the capture at all.
345
+ if (answeredAnything(capture)) walked.push(journey);
346
+ else refused.push(journey);
347
+ }
348
+ } catch {
349
+ // What could not be read is left out rather than guessed at in either direction: this
350
+ // gate must never refuse a healthy release because the store would not open.
351
+ }
352
+ return { walked, refused };
353
+ }
354
+
312
355
  /**
313
356
  * A sortable, file-safe id for one cut.
314
357
  * @param {Date} [now]
@@ -382,6 +425,10 @@ export async function measureStability(store, buildId) {
382
425
  let paths = 0;
383
426
  let steady = 0;
384
427
  let measuredJourneys = 0;
428
+ // Addresses the product could not answer at, which used to be counted as steady. Kept as
429
+ // its own number so the note can say what came off rather than showing a smaller total
430
+ // with no explanation.
431
+ let refusedPaths = 0;
385
432
 
386
433
  for (const journey of journeys) {
387
434
  const looked = await twoRunsOf(store, buildId, journey);
@@ -420,9 +467,25 @@ export async function measureStability(store, buildId) {
420
467
  }
421
468
 
422
469
  measuredJourneys++;
423
- const seen = wobble.steady + wobble.unstable.length;
470
+ // A REFUSAL IS NOT AN ADDRESS THAT ANSWERED THE SAME WAY TWICE.
471
+ //
472
+ // `measureWobble` compares the two stored runs as values, and two refusals are equal
473
+ // values, so every address the product could not answer at came back inside
474
+ // `wobble.steady`. That number is what `staysfixed ship` prints as its headline, and on
475
+ // 2026-08-31 it printed "All 7 addresses it was watched at answered the same way twice"
476
+ // about a command that threw on its first line and had answered at none of them. The
477
+ // sentence was true and meant nothing, and it is the sentence that made a refusal the
478
+ // definition of working. Refusals are subtracted from `steady` here so the reference
479
+ // records how much really held still, and the count of them is kept so the note can say
480
+ // what was taken off rather than quietly showing a smaller number.
481
+ const refusedHere = pair.a.observations.filter(
482
+ (o) => CHANNELS_ONLY_A_RUNNING_PRODUCT_FILLS.has(o.channel) && !isAnswer(o.value),
483
+ ).length;
484
+ const steadyAnswers = Math.max(0, wobble.steady - refusedHere);
485
+ refusedPaths += refusedHere;
486
+ const seen = steadyAnswers + wobble.unstable.length;
424
487
  paths += seen;
425
- steady += wobble.steady;
488
+ steady += steadyAnswers;
426
489
  for (const p of wobble.unstable) unstablePaths.push(p);
427
490
 
428
491
  /** @type {JourneyStability} */
@@ -430,7 +493,7 @@ export async function measureStability(store, buildId) {
430
493
  journey,
431
494
  measured: true,
432
495
  paths: seen,
433
- steady: wobble.steady,
496
+ steady: steadyAnswers,
434
497
  unstableCount: wobble.unstable.length,
435
498
  unstable: wobble.unstable.slice(0, MAX_UNSTABLE_LISTED),
436
499
  runs: wobble.runs,
@@ -451,7 +514,7 @@ export async function measureStability(store, buildId) {
451
514
  unstable: unstablePaths.length,
452
515
  unstablePaths: listed,
453
516
  byJourney,
454
- note: stabilityNote(measured, journeys.length, measuredJourneys, steady, unstablePaths.length),
517
+ note: stabilityNote(measured, journeys.length, measuredJourneys, steady, unstablePaths.length, refusedPaths),
455
518
  };
456
519
  }
457
520
 
@@ -464,9 +527,10 @@ export async function measureStability(store, buildId) {
464
527
  * @param {number} measuredJourneys
465
528
  * @param {number} steady
466
529
  * @param {number} unstable
530
+ * @param {number} [refused] Addresses the product could not answer at, which are not steady.
467
531
  * @returns {string}
468
532
  */
469
- function stabilityNote(measured, journeys, measuredJourneys, steady, unstable) {
533
+ function stabilityNote(measured, journeys, measuredJourneys, steady, unstable, refused = 0) {
470
534
  if (journeys === 0) {
471
535
  return 'Nothing has ever been walked against this build, so this reference has no record of what it does or how steady it is.';
472
536
  }
@@ -477,11 +541,22 @@ function stabilityNote(measured, journeys, measuredJourneys, steady, unstable) {
477
541
  measuredJourneys < journeys
478
542
  ? ` ${journeys - measuredJourneys} of its ${journeys} ${plural(journeys, 'journey', 'journeys')} ran only once and carry no steadiness record.`
479
543
  : '';
544
+ // Said out loud, always, when there were any. The headline of `staysfixed ship` is built
545
+ // from this sentence, and on 2026-08-31 it read "all 7 addresses it was watched at
546
+ // answered the same way twice" about a product that answered at none of them — the
547
+ // refusals were being counted as steady, and two refusals do agree with each other.
548
+ const held =
549
+ refused > 0
550
+ ? ` ${refused} further ${plural(refused, 'address', 'addresses')} ${plural(refused, 'was', 'were')} not counted at all: the product refused there, and a refusal is not an answer that held still.`
551
+ : '';
552
+ if (steady === 0 && unstable === 0) {
553
+ return `Measured across ${measuredJourneys} ${plural(measuredJourneys, 'journey', 'journeys')}: NOT ONE address answered. Everything this build was asked came back a refusal, so this reference records what could not be read rather than what the product does.${held}${partial}`;
554
+ }
480
555
  if (unstable === 0) {
481
- const all = steady === 1 ? 'the one address it was watched at' : `all ${steady} addresses`;
482
- return `Measured across ${measuredJourneys} ${plural(measuredJourneys, 'journey', 'journeys')}: ${all} answered the same way twice.${partial}`;
556
+ const all = steady === 1 ? 'the one address it answered at' : `all ${steady} addresses it answered at`;
557
+ return `Measured across ${measuredJourneys} ${plural(measuredJourneys, 'journey', 'journeys')}: ${all} answered the same way twice.${held}${partial}`;
483
558
  }
484
- return `Measured across ${measuredJourneys} ${plural(measuredJourneys, 'journey', 'journeys')}: ${steady} ${plural(steady, 'address', 'addresses')} answered the same way twice and ${unstable} did not. ${unstable === 1 ? 'That one was' : `Those ${unstable} were`} already unpredictable when this shipped, so a later run must not blame a change for ${plural(unstable, 'it', 'them')}.${partial}`;
559
+ return `Measured across ${measuredJourneys} ${plural(measuredJourneys, 'journey', 'journeys')}: ${steady} ${plural(steady, 'address', 'addresses')} answered the same way twice and ${unstable} did not. ${unstable === 1 ? 'That one was' : `Those ${unstable} were`} already unpredictable when this shipped, so a later run must not blame a change for ${plural(unstable, 'it', 'them')}.${held}${partial}`;
485
560
  }
486
561
 
487
562
  /**
@@ -879,6 +954,43 @@ export async function shouldCut(store, product, build) {
879
954
  };
880
955
  }
881
956
 
957
+ // AND: did the product actually do anything while it was watched?
958
+ //
959
+ // The three questions above — was there a check, was it blocked, did it leave anything
960
+ // unaccounted for — are all answered by a run in which EVERY journey refused exactly the
961
+ // way a healthy run answers them. It was not blocked: it ran to the end. It found no
962
+ // differences: there was nothing to differ. So `ship` blessed a product that cannot start,
963
+ // and every later check then compared refusal against refusal, found them equal, and
964
+ // reported "Nothing that worked has changed" about a server that throws on the first line.
965
+ // Measured 2026-08-31, and the recovery was worse than the lie: fixing the product produced
966
+ // thirteen findings nobody caused.
967
+ //
968
+ // The same gate lives in ship.js, where it was written. It belongs here too, because
969
+ // `cutReference` is exported and the ship command is not its only caller — a gate one
970
+ // caller deep is a gate the next caller walks around.
971
+ const saw = await whatWasActuallyObserved(store, buildId);
972
+ if (saw.walked.length === 0 && saw.refused.length > 0) {
973
+ return {
974
+ ok: false,
975
+ state: 'nothing-observed',
976
+ needsForce: true,
977
+ buildId,
978
+ checkedAt: check.at,
979
+ findings: check.unaccounted,
980
+ why: `Nothing was actually observed of ${name}.`,
981
+ // The same sentence ship.js uses, deliberately word for word. Two gates that catch the
982
+ // same thing must not describe it two ways: whichever one fires, the person reads the
983
+ // same explanation.
984
+ refusal: [
985
+ `Refusing to make ${name} the standard for ${product}: the run behind it never got the product to do anything.`,
986
+ `All ${saw.refused.length} of the ${saw.refused.length === 1 ? 'journey' : 'journeys'} on record for this build came back refused — it did not start, or could not be reached — so what would be written down as "working" is the words "could not be read": ${saw.refused.slice(0, 4).join(', ')}${saw.refused.length > 4 ? ', and more' : ''}.`,
987
+ 'A standard made of refusals is a standard that says nothing: there is no answer on either side of any address, so a later check watches none of it. It no longer comes back looking clean — it says so in its coverage list, on every run, for as long as this reference stands.',
988
+ RUBBER_STAMP,
989
+ 'Get the product running, run `staysfixed check`, and ship again.',
990
+ ].join(' '),
991
+ };
992
+ }
993
+
882
994
  if (check.unaccounted > 0) {
883
995
  const sealed = check.sealed ?? 0;
884
996
  return {
@@ -1046,12 +1158,19 @@ function summarise(cut, name, decision) {
1046
1158
  else parts.push('Nothing was being compared against before this — from now on it is.');
1047
1159
 
1048
1160
  if (cut.stability.measured) {
1161
+ // "WATCHED AT" WAS THE WRONG WORD and it mattered. A build was watched at every address
1162
+ // it was asked about, including every one it refused to answer, and two refusals do
1163
+ // answer the same way twice — so this line said "all 7 addresses it was watched at
1164
+ // answered the same way twice" about a command that threw on its first line and answered
1165
+ // at none of them (measured 2026-08-31). It counts answers now, and it says so.
1049
1166
  parts.push(
1050
- cut.stability.unstable === 0
1051
- ? cut.stability.steady === 1
1052
- ? 'The one address it was watched at answered the same way twice.'
1053
- : `All ${cut.stability.steady} addresses it was watched at answered the same way twice.`
1054
- : `${cut.stability.unstable} of the ${cut.stability.paths} ${plural(cut.stability.paths, 'address', 'addresses')} it was watched at ${plural(cut.stability.unstable, 'was', 'were')} already unpredictable, and that is written down so nothing blames a future change for ${plural(cut.stability.unstable, 'it', 'them')}.`
1167
+ cut.stability.steady === 0 && cut.stability.unstable === 0
1168
+ ? 'NOT ONE address it was asked about gave an answer, so this reference records what could not be read rather than what the product does.'
1169
+ : cut.stability.unstable === 0
1170
+ ? cut.stability.steady === 1
1171
+ ? 'The one address it answered at answered the same way twice.'
1172
+ : `All ${cut.stability.steady} addresses it answered at answered the same way twice.`
1173
+ : `${cut.stability.unstable} of the ${cut.stability.paths} ${plural(cut.stability.paths, 'address', 'addresses')} it answered at ${plural(cut.stability.unstable, 'was', 'were')} already unpredictable, and that is written down so nothing blames a future change for ${plural(cut.stability.unstable, 'it', 'them')}.`
1055
1174
  );
1056
1175
  } else {
1057
1176
  parts.push('It carries no steadiness record, so "this used to be steady and now it wobbles" cannot be reported against it.');
@@ -0,0 +1,389 @@
1
+ /**
2
+ * "The question could not be answered" is not an answer.
3
+ *
4
+ * This file exists because of one measurement, taken on 2026-08-31 against a Node command
5
+ * that threw on its first line. `staysfixed check` recorded what it saw, `staysfixed ship`
6
+ * blessed it, and from that moment on the tool reported a product that could not start as
7
+ * one where nothing had changed. Three separate wrongs came out of one cause, and all three
8
+ * were reproduced end to end before a line of this was written:
9
+ *
10
+ * 1. TWO REFUSALS COMPARE EQUAL. A refusal was stored as an ordinary value — the words
11
+ * "not checked — the thing being observed fell over before it could be read", or a
12
+ * crash record, or nothing at all — and two of those are the same string. So the diff
13
+ * found no difference and the run ended "Nothing that worked has changed. 7 addresses
14
+ * checked", about a product whose entire output had been rewritten in between.
15
+ * 2. SHIP BLESSED IT. The stability record said "all 7 addresses it was watched at
16
+ * answered the same way twice", which was true and meant nothing: two refusals do
17
+ * answer the same way twice. A refusal became the definition of working.
18
+ * 3. FIXING THE PRODUCT PRODUCED FINDINGS NOBODY CAUSED. The day it started answering,
19
+ * every real value differed from the stored refusal. Four of them on the tiny fixture,
20
+ * thirteen on the three-route server, and one of them landed in the money class, which
21
+ * no agent may wave through — so a phantom went to a person and stayed there.
22
+ *
23
+ * THE RULE, and it is one rule. A refusal is a DIFFERENT KIND OF THING from a value. It is
24
+ * never compared with a value, never compared with another refusal as if both were answers,
25
+ * and never written down as what "working" means. It is a hole, with the reason attached,
26
+ * exactly the way this tool already treats a guard that timed out.
27
+ *
28
+ * WHY A MARKED OBJECT AND NOT A SPECIAL STRING. A product's own output is a string, and any
29
+ * sentinel string a product could print by accident is a sentinel that stops working the day
30
+ * somebody prints it. An observed value can already be a plain object, so a plain object with
31
+ * a reserved key costs nothing on disk, survives the JSONL store untouched, and cannot be
32
+ * produced by a product talking about itself. `meta.refused` is not enough on its own either:
33
+ * meta is never compared, and it is also set on observations that hold a REAL value that was
34
+ * only partly read — a truncated stdout is still an answer, and treating it as a refusal
35
+ * would throw away a comparison that works.
36
+ *
37
+ * TWO KINDS LIVE HERE, because there are two ways an address can be incomparable and only
38
+ * one of them is a hole:
39
+ *
40
+ * NO ANSWER the adapter was asked and could not answer. A hole. Counted, reported,
41
+ * and refused at ship.
42
+ * NEVER COMPARED the tool has an answer and has decided on purpose never to compare it —
43
+ * how long something took, which measures the machine as much as the
44
+ * product. Not a hole; the coverage list already explains it. It still must
45
+ * never become a difference, and on 2026-08-31 it did: `count.pay.duration`
46
+ * APPEARED where the reference had no record of that journey at all, and
47
+ * the finding came back classed as money.
48
+ */
49
+
50
+ import { diffCaptures, indexByPath, splitPath } from './observation.js';
51
+
52
+ /**
53
+ * @typedef {import('./types.js').Observation} Observation
54
+ * @typedef {import('./types.js').ObservedValue} ObservedValue
55
+ * @typedef {import('./types.js').Capture} Capture
56
+ * @typedef {import('./types.js').Difference} Difference
57
+ * @typedef {import('./types.js').Channel} Channel
58
+ */
59
+
60
+ /**
61
+ * The reserved key. An at-sign leads it because no path segment, no header name and no
62
+ * JSON body a product prints has ever started one of its own keys that way, and because it
63
+ * reads as "this is the tool talking, not the product" to anyone who opens the store.
64
+ */
65
+ export const NO_ANSWER_KEY = '@no-answer';
66
+
67
+ /**
68
+ * The two sentences the adapters have been writing since before this file existed.
69
+ *
70
+ * They are recognised rather than left behind, for two reasons. Every store on every machine
71
+ * already holds them — a reference cut last week is made of these strings, and a fix that
72
+ * only understands the new shape would report that whole reference as changed the first time
73
+ * it ran. And `notCovered` lives in the adapter contract, which this lane does not own; until
74
+ * that one line is changed, this is what a refusal arrives as.
75
+ */
76
+ export const NOT_CHECKED_PREFIX = 'not checked — ';
77
+ export const NEVER_COMPARED_PREFIX = 'not compared — ';
78
+
79
+ /** What a value is, for the one purpose of deciding whether it may be compared. */
80
+ /** @typedef {'answer'|'no-answer'|'never-compared'} Comparability */
81
+
82
+ /**
83
+ * The channels a running product has to fill. A door read out of the source is not a door
84
+ * opened, so `contract` and `counters` prove nothing about whether anything ran — which is
85
+ * the same line `shouldCut` and `ship` already draw, written here once so all three cannot
86
+ * come to disagree about it.
87
+ *
88
+ * @type {Set<Channel>}
89
+ */
90
+ export const CHANNELS_ONLY_A_RUNNING_PRODUCT_FILLS = new Set(
91
+ /** @type {Channel[]} */ (['meaning', 'effects', 'complaints', 'results', 'pixels']),
92
+ );
93
+
94
+ /**
95
+ * A refusal, as a value.
96
+ *
97
+ * @param {string} reason Short and machine-readable: `crashed`, `refused`, `irreversible`.
98
+ * @param {string} why One plain sentence a person reads.
99
+ * @returns {ObservedValue}
100
+ */
101
+ export function noAnswer(reason, why) {
102
+ return { [NO_ANSWER_KEY]: String(reason || 'refused'), why: String(why || '') };
103
+ }
104
+
105
+ /**
106
+ * @param {unknown} value
107
+ * @returns {value is Record<string, unknown>}
108
+ */
109
+ function isPlainObject(value) {
110
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
111
+ }
112
+
113
+ /**
114
+ * What kind of thing is this value?
115
+ *
116
+ * Everything that is not explicitly one of the two incomparable kinds is an answer. That
117
+ * direction of default is deliberate: mistaking an answer for a refusal loses a real
118
+ * comparison silently, which is the failure this whole tool exists to prevent, so the new
119
+ * kind has to be claimed rather than guessed at.
120
+ *
121
+ * @param {unknown} value
122
+ * @returns {Comparability}
123
+ */
124
+ export function comparability(value) {
125
+ if (isPlainObject(value) && typeof value[NO_ANSWER_KEY] === 'string') {
126
+ return value[NO_ANSWER_KEY] === 'measures the machine' ? 'never-compared' : 'no-answer';
127
+ }
128
+ if (typeof value === 'string') {
129
+ if (value.startsWith(NOT_CHECKED_PREFIX)) return 'no-answer';
130
+ if (value.startsWith(NEVER_COMPARED_PREFIX)) return 'never-compared';
131
+ }
132
+ return 'answer';
133
+ }
134
+
135
+ /**
136
+ * @param {unknown} value
137
+ * @returns {boolean}
138
+ */
139
+ export function isNoAnswer(value) {
140
+ return comparability(value) === 'no-answer';
141
+ }
142
+
143
+ /**
144
+ * @param {unknown} value
145
+ * @returns {boolean}
146
+ */
147
+ export function isNeverCompared(value) {
148
+ return comparability(value) === 'never-compared';
149
+ }
150
+
151
+ /**
152
+ * May these two values be put side by side at all?
153
+ * @param {unknown} value
154
+ * @returns {boolean}
155
+ */
156
+ export function isAnswer(value) {
157
+ return comparability(value) === 'answer';
158
+ }
159
+
160
+ /**
161
+ * Why there is no answer here, in the words the adapter used.
162
+ *
163
+ * @param {unknown} value
164
+ * @param {Observation} [observation] Its meta carries a longer reason when the adapter set one.
165
+ * @returns {string}
166
+ */
167
+ export function whyNoAnswer(value, observation) {
168
+ if (isPlainObject(value) && typeof value.why === 'string' && value.why) return value.why;
169
+ if (typeof value === 'string' && value.startsWith(NOT_CHECKED_PREFIX)) {
170
+ return value.slice(NOT_CHECKED_PREFIX.length);
171
+ }
172
+ if (typeof value === 'string' && value.startsWith(NEVER_COMPARED_PREFIX)) {
173
+ return value.slice(NEVER_COMPARED_PREFIX.length);
174
+ }
175
+ const meta = /** @type {{refusedWhy?: string}|undefined} */ (observation?.meta);
176
+ return meta?.refusedWhy ?? 'no reason was recorded';
177
+ }
178
+
179
+ /**
180
+ * Turn the old string form into the marked form, leaving everything else exactly as it is.
181
+ *
182
+ * Used on the way OUT of the store, so that one shape reaches the comparison, the reference
183
+ * and the report however old the file it came from. It is not a migration and it never
184
+ * rewrites anything on disk: the stored line stays as it was written, and this is what the
185
+ * reader hands on.
186
+ *
187
+ * @param {ObservedValue} value
188
+ * @returns {ObservedValue}
189
+ */
190
+ export function asMarkedValue(value) {
191
+ const kind = comparability(value);
192
+ if (kind === 'answer') return value;
193
+ if (isPlainObject(value)) return value;
194
+ const text = String(value);
195
+ const prefix = kind === 'no-answer' ? NOT_CHECKED_PREFIX : NEVER_COMPARED_PREFIX;
196
+ const why = text.slice(prefix.length);
197
+ return { [NO_ANSWER_KEY]: kind === 'never-compared' ? 'measures the machine' : 'refused', why };
198
+ }
199
+
200
+ /**
201
+ * The observations of a capture, or the list itself when that is what was handed over.
202
+ * @param {Capture|Observation[]} x
203
+ * @returns {Observation[]}
204
+ */
205
+ function observationsOf(x) {
206
+ return Array.isArray(x) ? x : x.observations;
207
+ }
208
+
209
+ /**
210
+ * The addresses an adapter uses to say, in one place, whether the product was reached at all.
211
+ *
212
+ * `cli.<journey>.ran at all` and `api.<journey>.answered at all` are a convention that
213
+ * predates this file — `cluster.js` groups them and `check.js` reads them — and it is exactly
214
+ * the sentence needed here. An adapter is the only thing that knows whether it got to the
215
+ * product, and this is where it already says so.
216
+ */
217
+ const REACHED_THE_PRODUCT_AT_ALL = ['ran at all', 'answered at all'];
218
+
219
+ /**
220
+ * Did this walk get the product to say anything at all?
221
+ *
222
+ * Three ways to answer no, and the first one is the one that needs an adapter's word for it:
223
+ *
224
+ * 1. The adapter said outright that it never reached the product — a refusal at
225
+ * `<surface>.<journey>.ran at all`. This is the case a channel count cannot see. A
226
+ * command that throws on its first line still fills the complaints channel with a real
227
+ * stack trace and a real exit code, and those ARE facts, and they are facts about a
228
+ * crash rather than about the product. Two builds that crash identically then agree at
229
+ * every address, and on 2026-08-31 that agreement came back as "Nothing that worked has
230
+ * changed" over a product whose entire output had been rewritten in between.
231
+ * 2. Every product-channel observation it has is a refusal. The adapter was asked and said
232
+ * it could not.
233
+ * 3. Nothing else. A walk with no product-channel observations at all — the source reader,
234
+ * which only lists doors it has read — is neither evidence that the product ran nor
235
+ * evidence that it would not, so it answers `true` and is left to the coverage ledger,
236
+ * which is where a door nobody walked through is already counted.
237
+ *
238
+ * @param {Capture|Observation[]} capture
239
+ * @returns {boolean}
240
+ */
241
+ export function answeredAnything(capture) {
242
+ const all = observationsOf(capture);
243
+ const journey = Array.isArray(capture) ? undefined : capture.journey;
244
+ for (const o of all) {
245
+ if (!isNoAnswer(o.value)) continue;
246
+ if (!REACHED_THE_PRODUCT_AT_ALL.some((tail) => o.path.endsWith(`.${tail}`))) continue;
247
+ // THIS journey's own address, not any address shaped like one. The adapters build it as
248
+ // `<surface>.<journey name>.ran at all`, so the second segment names whose walk it is.
249
+ // Without this check one refusal shaped like the sentence would take down a walk it says
250
+ // nothing about, and every real difference in that walk would go with it — the whole
251
+ // point of the change is to stop losing comparisons, not to lose more of them.
252
+ if (journey === undefined || splitPath(o.path)[1] === journey) return false;
253
+ }
254
+ const fromTheProduct = all.filter((o) => CHANNELS_ONLY_A_RUNNING_PRODUCT_FILLS.has(o.channel));
255
+ if (fromTheProduct.length === 0) return true;
256
+ return fromTheProduct.some((o) => isAnswer(o.value));
257
+ }
258
+
259
+ /**
260
+ * The refusals in a list, so a caller can name them rather than count them.
261
+ * @param {Capture|Observation[]} capture
262
+ * @returns {Observation[]}
263
+ */
264
+ export function refusalsIn(capture) {
265
+ return observationsOf(capture).filter((o) => isNoAnswer(o.value));
266
+ }
267
+
268
+ /**
269
+ * One address that could not be put side by side, and which side was missing.
270
+ *
271
+ * @typedef {object} Uncompared
272
+ * @property {string} path
273
+ * @property {Channel} channel
274
+ * @property {'lost'|'recovered'|'never-answered'} kind
275
+ * `lost` — the standard has an answer here and this build has none. Coverage this
276
+ * build took away, and the one shape of this that is bad news.
277
+ * `recovered` — the standard has no answer here and this build does. Good news, and the
278
+ * thing that used to arrive as a pile of findings nobody caused.
279
+ * `never-answered` — neither side answered. Silent until today; the comparison covered
280
+ * nothing here and said nothing about it.
281
+ * @property {string} why
282
+ * @property {string} [journey]
283
+ * @property {string} [describe]
284
+ */
285
+
286
+ /**
287
+ * Compare two captures, putting only answers beside answers.
288
+ *
289
+ * This is `diffCaptures` with the one rule this file exists for wrapped around it: an address
290
+ * where either side holds a refusal is not compared at all, and comes back in the second list
291
+ * instead of the first. Nothing is lost by that — a refusal never carried a fact about the
292
+ * product — and what is gained is that the tool stops turning "I could not look" into either
293
+ * "it is fine" or "you broke it", which are the only two things it could say before.
294
+ *
295
+ * @param {Capture|Observation[]} reference
296
+ * @param {Capture|Observation[]} candidate
297
+ * @returns {{differences: Difference[], uncompared: Uncompared[]}}
298
+ */
299
+ export function compareAnswers(reference, candidate) {
300
+ const refIndex = indexByPath(observationsOf(reference));
301
+ const candIndex = indexByPath(observationsOf(candidate));
302
+ const journey = Array.isArray(candidate)
303
+ ? Array.isArray(reference)
304
+ ? undefined
305
+ : reference.journey
306
+ : candidate.journey;
307
+
308
+ /** @type {Uncompared[]} */
309
+ const uncompared = [];
310
+ // Every address that is incomparable on EITHER side, so it can be taken out of BOTH before
311
+ // anything is diffed. Filtering only the side that holds the refusal is not enough and was
312
+ // the first way this was written: drop the reference's refusal and the candidate's real
313
+ // answer at the same address has nothing opposite it, so it comes back as an address that
314
+ // has just APPEARED — which is the phantom finding, arriving by a new road.
315
+ /** @type {Set<string>} */
316
+ const notComparable = new Set();
317
+
318
+ for (const path of new Set([...refIndex.keys(), ...candIndex.keys()])) {
319
+ const was = refIndex.get(path);
320
+ const now = candIndex.get(path);
321
+ const before = was ? comparability(was.value) : 'absent';
322
+ const after = now ? comparability(now.value) : 'absent';
323
+ if (before === 'answer' && after === 'answer') continue;
324
+ if (before === 'answer' && after === 'absent') continue; // vanished — a real finding
325
+ if (before === 'absent' && after === 'answer') continue; // appeared — a real finding
326
+ notComparable.add(path);
327
+ // A value the tool has decided never to compare is not a hole and never a difference,
328
+ // in any direction. This is the branch that stops `count.<journey>.duration` arriving
329
+ // as a money-class finding the first time a refused journey starts running.
330
+ if (before === 'never-compared' || after === 'never-compared') continue;
331
+
332
+ /** @type {Uncompared['kind']} */
333
+ let kind;
334
+ if (before === 'answer') kind = 'lost';
335
+ else if (after === 'answer') kind = 'recovered';
336
+ else kind = 'never-answered';
337
+
338
+ const holder = kind === 'lost' ? now : kind === 'recovered' ? was : (now ?? was);
339
+ const why =
340
+ kind === 'lost'
341
+ ? whyNoAnswer(now?.value, now)
342
+ : kind === 'recovered'
343
+ ? whyNoAnswer(was?.value, was)
344
+ : whyNoAnswer(now?.value ?? was?.value, now ?? was);
345
+
346
+ /** @type {Uncompared} */
347
+ const entry = {
348
+ path,
349
+ channel: (now ?? was)?.channel ?? 'results',
350
+ kind,
351
+ why,
352
+ };
353
+ if (journey) entry.journey = journey;
354
+ const describe = holder?.meta?.describe;
355
+ if (describe) entry.describe = describe;
356
+ uncompared.push(entry);
357
+ }
358
+
359
+ uncompared.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
360
+
361
+ // Only answers go into the arithmetic. Filtering here rather than inside `diffCaptures`
362
+ // keeps the one comparison in the tool exactly where the design put it, in observation.js,
363
+ // and keeps this rule readable in one place instead of threaded through it.
364
+ const differences = diffCaptures(
365
+ onlyAnswers(reference, refIndex, notComparable),
366
+ onlyAnswers(candidate, candIndex, notComparable),
367
+ );
368
+
369
+ return { differences, uncompared };
370
+ }
371
+
372
+ /**
373
+ * The same capture with every incomparable address taken out of it, keeping the journey name
374
+ * so a difference can still say which walk it came from.
375
+ *
376
+ * The address list is shared between the two sides on purpose: an address is comparable only
377
+ * when BOTH sides hold an answer at it, so both sides have to lose it together or the
378
+ * surviving one reads as having appeared or vanished.
379
+ *
380
+ * @param {Capture|Observation[]} capture
381
+ * @param {Map<string, Observation>} index
382
+ * @param {Set<string>} notComparable
383
+ * @returns {Capture|Observation[]}
384
+ */
385
+ function onlyAnswers(capture, index, notComparable) {
386
+ const kept = [...index.values()].filter((o) => isAnswer(o.value) && !notComparable.has(o.path));
387
+ if (Array.isArray(capture)) return kept;
388
+ return { ...capture, observations: kept };
389
+ }