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.
- package/CHANGELOG.md +182 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +526 -19
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +293 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +253 -30
- package/src/v2/init.js +102 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/normalise.js +11 -0
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/src/v2/reference.js
CHANGED
|
@@ -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
|
-
|
|
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 +=
|
|
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:
|
|
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
|
|
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
|
-
?
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
+
}
|