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/coverage.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
import { asAddress } from './adapters/electron.js';
|
|
49
49
|
import { readContract, readFileRoutes, readPackageCommands } from './adapters/source.js';
|
|
50
50
|
import { familyOf, irreversibility, isRunnable } from './journeys/from-routes.js';
|
|
51
|
-
import { joinPath, splitPath } from './observation.js';
|
|
51
|
+
import { CHANNELS, joinPath, splitPath } from './observation.js';
|
|
52
52
|
import { listBuilds, listCaptures, loadCapture, referencePointer } from './store.js';
|
|
53
53
|
|
|
54
54
|
/** @typedef {import('./types.js').Channel} Channel */
|
|
@@ -101,6 +101,18 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
|
|
|
101
101
|
* @property {string[]} [doors] Door keys the journey's steps name, for steps that
|
|
102
102
|
* named a door with nothing to tell apart from
|
|
103
103
|
* another of the same name. See doorKey.
|
|
104
|
+
* @property {{door: string, status: number}[]} [onlyRedirected]
|
|
105
|
+
* Doors that answered with a redirect. Walked — the
|
|
106
|
+
* bounce is real behaviour — but what is behind them
|
|
107
|
+
* was never seen.
|
|
108
|
+
* @property {{door: string, status: number}[]} [knockedShut]
|
|
109
|
+
* Doors a step knocked on where the running build
|
|
110
|
+
* answered that they are not there. Knocking is not
|
|
111
|
+
* walking, and these have proved nothing.
|
|
112
|
+
* @property {string[]} [notTried] Doors this journey's steps name, on a walk that
|
|
113
|
+
* never happened — the adapter refused it and wrote
|
|
114
|
+
* down only that. Nobody knocked, so nothing here
|
|
115
|
+
* is walked.
|
|
104
116
|
* @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
|
|
105
117
|
* enough to build one. A route step knows its verb,
|
|
106
118
|
* and GET /x and POST /x are two doors that share a
|
|
@@ -384,6 +396,14 @@ function asDoor(door) {
|
|
|
384
396
|
* file: reading a door out of the source is how we know it exists, never evidence that
|
|
385
397
|
* anybody opened it.
|
|
386
398
|
*
|
|
399
|
+
* A refusal is dropped for the same reason. When an adapter cannot look at something it
|
|
400
|
+
* writes that down at the address it would have looked at, marked `refused` — a payment it
|
|
401
|
+
* would not make, a server that never started, a route with a parameter nobody supplied.
|
|
402
|
+
* Left in, the note saying "we did not look here" became the evidence that we did.
|
|
403
|
+
*
|
|
404
|
+
* Both still count in `byChannel`: they were written down, and the tallies say how much was
|
|
405
|
+
* written down. It is only the "somebody opened this door" set they are kept out of.
|
|
406
|
+
*
|
|
387
407
|
* @param {Observation[]} observations
|
|
388
408
|
* @returns {{paths: string[], byChannel: Partial<Record<Channel, number>>}}
|
|
389
409
|
*/
|
|
@@ -395,12 +415,22 @@ export function addressesTouched(observations) {
|
|
|
395
415
|
for (const o of observations) {
|
|
396
416
|
byChannel[o.channel] = (byChannel[o.channel] ?? 0) + 1;
|
|
397
417
|
if (o.channel === 'contract') continue;
|
|
418
|
+
if (o.meta?.refused === true) continue;
|
|
398
419
|
const parts = String(o.path).split('.');
|
|
399
420
|
for (let i = 1; i <= parts.length; i++) paths.add(parts.slice(0, i).join('.'));
|
|
400
421
|
}
|
|
401
422
|
return { paths: [...paths], byChannel };
|
|
402
423
|
}
|
|
403
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Answers that mean the door is not there in the build that ran.
|
|
427
|
+
*
|
|
428
|
+
* A 500 is deliberately NOT here: the route exists and it broke, which is a real difference
|
|
429
|
+
* and exactly what a check is for. These four are the codes that say the thing the source
|
|
430
|
+
* declares was never reachable, so nothing has been proved about it either way.
|
|
431
|
+
*/
|
|
432
|
+
const NOTHING_THERE = new Set([404, 405, 410, 501]);
|
|
433
|
+
|
|
404
434
|
/**
|
|
405
435
|
* A walk, built from one stored capture and, when it is to hand, the journey behind it.
|
|
406
436
|
*
|
|
@@ -418,6 +448,26 @@ export function walkFromCapture(capture, journey) {
|
|
|
418
448
|
buildId: capture.build?.id,
|
|
419
449
|
paths: touched.paths,
|
|
420
450
|
};
|
|
451
|
+
// NOTHING WAS TRIED IS NOT A WALK.
|
|
452
|
+
//
|
|
453
|
+
// Every adapter has branches where it runs nothing and says so: the server never came up,
|
|
454
|
+
// the route has a `:id` nobody supplied a value for, the command spends money and there is
|
|
455
|
+
// nothing watching to stop it. Each one writes a single observation marked `refused` and
|
|
456
|
+
// returns. No `api.<door>.status` comes back, because no request went out.
|
|
457
|
+
//
|
|
458
|
+
// The status rule below reads a missing status as "it answered something we are happy
|
|
459
|
+
// with", so every one of those doors counted as walked. Measured 2026-08-31 with the HTTP
|
|
460
|
+
// adapter's own output: one route, one journey, the server never started, and the ledger
|
|
461
|
+
// came back doorsWalked 1 of 1 — full coverage of a product that had not been run — in the
|
|
462
|
+
// same report whose only line read "was not tried: It never started."
|
|
463
|
+
//
|
|
464
|
+
// A capture whose entire non-contract record is refusals is a walk that did nothing. Its
|
|
465
|
+
// steps opened nothing, and the doors they name are listed rather than dropped.
|
|
466
|
+
const seen = capture.observations ?? [];
|
|
467
|
+
const refusals = seen.filter((o) => o?.meta?.refused === true);
|
|
468
|
+
const anythingHappened = seen.some((o) => o?.channel !== 'contract' && o?.meta?.refused !== true);
|
|
469
|
+
const nothingWasTried = refusals.length > 0 && !anythingHappened;
|
|
470
|
+
|
|
421
471
|
if (journey?.steps) {
|
|
422
472
|
// Two lists, because a doorKey is kind and name only. That is right for an IPC channel or
|
|
423
473
|
// an exported name, where the name IS the door; it is wrong for a route, where GET /basket
|
|
@@ -426,8 +476,62 @@ export function walkFromCapture(capture, journey) {
|
|
|
426
476
|
// would report POST as walked too, which is the coverage ledger lying in the one direction
|
|
427
477
|
// it must never lie in.
|
|
428
478
|
const named = journey.steps.filter((s) => typeof s.door === 'string' && typeof s.kind === 'string');
|
|
429
|
-
|
|
430
|
-
|
|
479
|
+
|
|
480
|
+
// KNOCKING IS NOT WALKING.
|
|
481
|
+
//
|
|
482
|
+
// A door was counted as walked because a STEP said it knocked on it — whatever came back.
|
|
483
|
+
// So a route the source declares and the running build answers 404 counted as covered; and
|
|
484
|
+
// behind a login wall, where every request is bounced to /login, every door in the product
|
|
485
|
+
// counted as walked and the run came back clean. That is the coverage ledger lying in the
|
|
486
|
+
// one direction this file says it must never lie in.
|
|
487
|
+
//
|
|
488
|
+
// What answered is on the record: the http adapter writes `api.<door>.status`. A 404, 405,
|
|
489
|
+
// 410 or 501 means the thing the code declares is not there in the build that ran, so
|
|
490
|
+
// nothing was proved about it and it stays shut.
|
|
491
|
+
const answered = new Map();
|
|
492
|
+
for (const ob of capture.observations ?? []) {
|
|
493
|
+
const found = /^api\.(.+)\.status$/.exec(String(ob?.path ?? ''));
|
|
494
|
+
if (found) answered.set(found[1], Number(ob.value));
|
|
495
|
+
}
|
|
496
|
+
/** @type {{door: string, status: number}[]} */
|
|
497
|
+
const shut = [];
|
|
498
|
+
/** @type {{door: string, status: number}[]} */
|
|
499
|
+
const bounced = [];
|
|
500
|
+
/** @type {string[]} */
|
|
501
|
+
const untried = [];
|
|
502
|
+
/** @param {any} s @returns {boolean} */
|
|
503
|
+
const reallyWalked = (s) => {
|
|
504
|
+
// The door is the ROUTE (`/reports`); the observation is addressed by method and route
|
|
505
|
+
// together (`api.GET /reports.status`), because GET and POST on one path are two doors.
|
|
506
|
+
// Try the composite first and the bare name after, so both shapes of step are covered.
|
|
507
|
+
const keys = [
|
|
508
|
+
typeof s.doorDetail === 'string' && s.doorDetail ? `${s.doorDetail} ${s.door}` : null,
|
|
509
|
+
typeof s.method === 'string' && s.method ? `${s.method} ${s.door}` : null,
|
|
510
|
+
String(s.door),
|
|
511
|
+
].filter(Boolean);
|
|
512
|
+
if (nothingWasTried) {
|
|
513
|
+
untried.push(String(keys[0] ?? s.door));
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
const key = keys.find((k) => answered.has(/** @type {string} */ (k)));
|
|
517
|
+
const code = key === undefined ? undefined : answered.get(/** @type {string} */ (key));
|
|
518
|
+
// A redirect is real behaviour and it IS walked — but what you saw is the bounce, not the
|
|
519
|
+
// thing behind it. Behind a login wall every door answers 302 to /login, and the run
|
|
520
|
+
// then reports full coverage of a product it never got into.
|
|
521
|
+
if (typeof code === 'number' && code >= 300 && code < 400) {
|
|
522
|
+
bounced.push({ door: String(key), status: code });
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
if (typeof code !== 'number' || !NOTHING_THERE.has(code)) return true;
|
|
526
|
+
shut.push({ door: String(key), status: code });
|
|
527
|
+
return false;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '').filter(reallyWalked);
|
|
531
|
+
const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '').filter(reallyWalked);
|
|
532
|
+
if (shut.length > 0) walk.knockedShut = shut;
|
|
533
|
+
if (bounced.length > 0) walk.onlyRedirected = bounced;
|
|
534
|
+
if (untried.length > 0) walk.notTried = untried;
|
|
431
535
|
if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
|
|
432
536
|
if (exact.length > 0) {
|
|
433
537
|
walk.doorAddresses = exact.map((s) =>
|
|
@@ -497,6 +601,138 @@ function trimmedAddress(door) {
|
|
|
497
601
|
return joinPath(['ipc', asAddress(door.name)]);
|
|
498
602
|
}
|
|
499
603
|
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// One order, every time
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* TWO IDENTICAL RUNS HAVE TO SAY THE SAME THING.
|
|
610
|
+
*
|
|
611
|
+
* This tool's whole method is running the same thing twice and subtracting what disagrees.
|
|
612
|
+
* A report that disagrees with itself between two identical runs is not untidy — it is the
|
|
613
|
+
* measurement contradicting the method, and a reader who spots it has no reason to believe
|
|
614
|
+
* the rest.
|
|
615
|
+
*
|
|
616
|
+
* It was doing exactly that. A capture id ends in three random bytes, deliberately, so two
|
|
617
|
+
* captures written inside the same second do not overwrite each other — and the store hands
|
|
618
|
+
* captures back in id order, so those random bytes decide which walk is read first. Measured
|
|
619
|
+
* 2026-08-31 on one unchanged product, three runs: help, the-code, the-code, help; then
|
|
620
|
+
* the-code, help, the-code, help; then help, the-code, the-code, help.
|
|
621
|
+
*
|
|
622
|
+
* Everything the ledger built in that order came out shuffled with it — which of two
|
|
623
|
+
* journeys got named as the evidence for a door, which six of eight shut doors got listed by
|
|
624
|
+
* name in the caveat, the order of the gaps, the key order of the tallies. So the walks are
|
|
625
|
+
* put in an order of the ledger's own before anything reads them, and that order is a
|
|
626
|
+
* function of what is IN each walk, never of when it arrived.
|
|
627
|
+
*
|
|
628
|
+
* @param {Walk[]} walks
|
|
629
|
+
* @returns {Walk[]}
|
|
630
|
+
*/
|
|
631
|
+
function inWalkOrder(walks) {
|
|
632
|
+
return [...walks]
|
|
633
|
+
.map((walk) => ({ walk, key: walkOrderKey(walk) }))
|
|
634
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
635
|
+
.map(({ walk }) => walk);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The whole of one walk, squeezed into a single sortable string.
|
|
640
|
+
*
|
|
641
|
+
* Every field that can reach the report is in here, so two walks only ever tie when they
|
|
642
|
+
* would produce the same words either way round. The addresses are the exception: a walk on
|
|
643
|
+
* a big product carries tens of thousands of them, and holding all of that in a sort key
|
|
644
|
+
* costs more than the ordering is worth. How many there are, plus a fingerprint of them,
|
|
645
|
+
* tells two walks apart just as well and stays one short string.
|
|
646
|
+
*
|
|
647
|
+
* @param {Walk} walk
|
|
648
|
+
* @returns {string}
|
|
649
|
+
*/
|
|
650
|
+
function walkOrderKey(walk) {
|
|
651
|
+
return [
|
|
652
|
+
walk.journey ?? '',
|
|
653
|
+
walk.at ?? '',
|
|
654
|
+
walk.buildId ?? '',
|
|
655
|
+
walk.source ?? '',
|
|
656
|
+
String((walk.paths ?? []).length),
|
|
657
|
+
digestOf(walk.paths ?? []),
|
|
658
|
+
(walk.doors ?? []).join('\u0001'),
|
|
659
|
+
(walk.doorAddresses ?? []).join('\u0001'),
|
|
660
|
+
(walk.knockedShut ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
|
|
661
|
+
(walk.onlyRedirected ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
|
|
662
|
+
(walk.notTried ?? []).join('\u0001'),
|
|
663
|
+
(walk.touchedFiles ?? []).join('\u0001'),
|
|
664
|
+
(walk.touchedFunctions ?? []).join('\u0001'),
|
|
665
|
+
String(walk.functionsNotListed ?? 0),
|
|
666
|
+
].join('\u0000');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A short fingerprint of a list of strings. FNV-1a, written out rather than imported,
|
|
671
|
+
* because this file has no business opening a crypto library to decide a sort order.
|
|
672
|
+
*
|
|
673
|
+
* @param {string[]} items
|
|
674
|
+
* @returns {string}
|
|
675
|
+
*/
|
|
676
|
+
function digestOf(items) {
|
|
677
|
+
let hash = 0x811c9dc5;
|
|
678
|
+
for (const item of items) {
|
|
679
|
+
for (let i = 0; i < item.length; i++) {
|
|
680
|
+
hash ^= item.charCodeAt(i);
|
|
681
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
682
|
+
}
|
|
683
|
+
hash ^= 0x0a;
|
|
684
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
685
|
+
}
|
|
686
|
+
return hash.toString(16).padStart(8, '0');
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Sort a list of pairs by the first of the two. Used wherever a caveat names only the first
|
|
691
|
+
* few of something: which few get named has to be decided by their names.
|
|
692
|
+
*
|
|
693
|
+
* @param {[string, unknown]} a
|
|
694
|
+
* @param {[string, unknown]} b
|
|
695
|
+
* @returns {number}
|
|
696
|
+
*/
|
|
697
|
+
function byFirst(a, b) {
|
|
698
|
+
return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The channel tallies written out in one fixed order — the channel list's own, not the order
|
|
703
|
+
* the observations happened to arrive in. Same reason as {@link inWalkOrder}: this object is
|
|
704
|
+
* printed by `--json`, and JSON keeps the order keys were added in.
|
|
705
|
+
*
|
|
706
|
+
* @param {Partial<Record<Channel, number>>} byChannel
|
|
707
|
+
* @returns {Partial<Record<Channel, number>>}
|
|
708
|
+
*/
|
|
709
|
+
function inChannelOrder(byChannel) {
|
|
710
|
+
/** @type {Record<string, number>} */
|
|
711
|
+
const out = {};
|
|
712
|
+
const held = /** @type {Record<string, number>} */ (byChannel);
|
|
713
|
+
for (const channel of CHANNELS) if (held[channel] !== undefined) out[channel] = held[channel];
|
|
714
|
+
// A channel this build of the tool has never heard of is still evidence somebody wrote
|
|
715
|
+
// down, and dropping it would make the report smaller than the run was.
|
|
716
|
+
for (const channel of Object.keys(held).sort()) if (out[channel] === undefined) out[channel] = held[channel];
|
|
717
|
+
return out;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Sort gaps into an order of their own. Only for gaps whose order carries no meaning — the
|
|
722
|
+
* ones read back out of stored captures, which arrive in whatever order the disk listed
|
|
723
|
+
* them. The gaps `toCoverage` builds are in rank order and are left exactly as they are.
|
|
724
|
+
*
|
|
725
|
+
* @param {CoverageGap[]} list
|
|
726
|
+
* @returns {CoverageGap[]}
|
|
727
|
+
*/
|
|
728
|
+
function inGapOrder(list) {
|
|
729
|
+
return [...list].sort((a, b) => {
|
|
730
|
+
const left = `${a.what}\u0000${a.why}\u0000${a.unlockedBy ?? ''}`;
|
|
731
|
+
const right = `${b.what}\u0000${b.why}\u0000${b.unlockedBy ?? ''}`;
|
|
732
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
500
736
|
// ---------------------------------------------------------------------------
|
|
501
737
|
// The ledger
|
|
502
738
|
// ---------------------------------------------------------------------------
|
|
@@ -525,7 +761,7 @@ function trimmedAddress(door) {
|
|
|
525
761
|
export function buildLedger(input) {
|
|
526
762
|
/** @type {DoorEntry[]} */
|
|
527
763
|
const entries = [];
|
|
528
|
-
const walks = input.walks.map((walk) => ({ walk, paths: new Set(walk.paths) }));
|
|
764
|
+
const walks = inWalkOrder(input.walks).map((walk) => ({ walk, paths: new Set(walk.paths) }));
|
|
529
765
|
|
|
530
766
|
/** @type {Record<string, KindTally>} */
|
|
531
767
|
const byKind = {};
|
|
@@ -613,6 +849,42 @@ export function buildLedger(input) {
|
|
|
613
849
|
`${cutFunctions} functions that really did run were cut from the coverage lists to keep them readable, so up to that many of the doors counted as never opened were in fact opened. This ledger undercounts, and it undercounts by no more than ${cutFunctions}.`,
|
|
614
850
|
);
|
|
615
851
|
}
|
|
852
|
+
// Named, never silently dropped. A route the code declares and the build answers 404 to is
|
|
853
|
+
// not a covered route and it is not an absent one either — it is a disagreement between the
|
|
854
|
+
// source and the thing that ran, and that is worth more than most differences.
|
|
855
|
+
/** @type {Map<string, number>} */
|
|
856
|
+
const shutDoors = new Map();
|
|
857
|
+
for (const { walk } of walks) for (const d of walk.knockedShut ?? []) shutDoors.set(d.door, d.status);
|
|
858
|
+
if (shutDoors.size > 0) {
|
|
859
|
+
// Sorted, so which six get named is decided by their names and not by which capture the
|
|
860
|
+
// disk happened to hand back first. See `inWalkOrder` for why that was ever in doubt.
|
|
861
|
+
const listed = [...shutDoors.entries()].sort(byFirst).slice(0, 6).map(([door, code]) => `${door} answered ${code}`).join(', ');
|
|
862
|
+
caveats.push(
|
|
863
|
+
`${shutDoors.size} ${shutDoors.size === 1 ? 'door the code declares was' : 'doors the code declares were'} knocked on and answered as not being there (${listed}${shutDoors.size > 6 ? ', and more' : ''}). Knocking is not walking: nothing has been proved about ${shutDoors.size === 1 ? 'it' : 'them'}, and the source and the build that ran disagree about whether ${shutDoors.size === 1 ? 'it exists' : 'they exist'}.`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
/** @type {Map<string, number>} */
|
|
867
|
+
const bouncedDoors = new Map();
|
|
868
|
+
for (const { walk } of walks) for (const d of walk.onlyRedirected ?? []) bouncedDoors.set(d.door, d.status);
|
|
869
|
+
if (bouncedDoors.size > 0) {
|
|
870
|
+
const all = bouncedDoors.size >= Math.max(1, opened);
|
|
871
|
+
caveats.push(
|
|
872
|
+
`${bouncedDoors.size} ${bouncedDoors.size === 1 ? 'door' : 'doors'} answered with a redirect rather than with ${bouncedDoors.size === 1 ? 'a page' : 'pages'} — ${[...bouncedDoors.entries()].sort(byFirst).slice(0, 5).map(([door, code]) => `${door} answered ${code}`).join(', ')}${bouncedDoors.size > 5 ? ', and more' : ''}. What was seen is the bounce, not what is behind it.${all ? ' EVERY door that answered did this, which is what a sign-in wall looks like from out here: this run has not been inside the product at all.' : ''}`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
// A door whose journey was refused before anything ran. Nobody knocked on it, so it is not
|
|
876
|
+
// shut and it is not bounced — it is untouched, and the only wrong answer is to leave it
|
|
877
|
+
// out. This says the number out loud so nobody has to notice a door that quietly stopped
|
|
878
|
+
// being counted as walked.
|
|
879
|
+
/** @type {Set<string>} */
|
|
880
|
+
const untriedDoors = new Set();
|
|
881
|
+
for (const { walk } of walks) for (const door of walk.notTried ?? []) untriedDoors.add(door);
|
|
882
|
+
if (untriedDoors.size > 0) {
|
|
883
|
+
const listed = [...untriedDoors].sort().slice(0, 6).join(', ');
|
|
884
|
+
caveats.push(
|
|
885
|
+
`${untriedDoors.size} ${untriedDoors.size === 1 ? 'door was' : 'doors were'} never tried at all (${listed}${untriedDoors.size > 6 ? ', and more' : ''}). The journey that would have opened ${untriedDoors.size === 1 ? 'it' : 'them'} was refused before anything ran — the thing it needed did not start, or a value it needed was never supplied — and the reason is on the record beside it. ${untriedDoors.size === 1 ? 'It is' : 'They are'} counted here as never opened, because nothing knocked.`,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
616
888
|
if (input.doors.length === 0) {
|
|
617
889
|
caveats.push('No doors are known at all, so this ledger cannot say what is uncovered — which is not the same as there being nothing uncovered.');
|
|
618
890
|
}
|
|
@@ -634,8 +906,11 @@ export function buildLedger(input) {
|
|
|
634
906
|
entries,
|
|
635
907
|
byKind,
|
|
636
908
|
journeys: new Set(walks.map(({ walk }) => walk.journey)).size,
|
|
637
|
-
|
|
638
|
-
|
|
909
|
+
// Both of these are printed by `--json`, and JSON keeps the order keys were added in.
|
|
910
|
+
// Added in the order the walks arrived, they came out shuffled between two identical
|
|
911
|
+
// runs — see `inWalkOrder`. Written out in an order of their own, they do not.
|
|
912
|
+
byJourneySource: Object.fromEntries(Object.entries(byJourneySource).sort(byFirst)),
|
|
913
|
+
byChannel: inChannelOrder(input.byChannel ?? {}),
|
|
639
914
|
captures: input.captures ?? walks.length,
|
|
640
915
|
builds: input.builds ?? 0,
|
|
641
916
|
caveats,
|
|
@@ -798,7 +1073,11 @@ export async function ledger(store, product, opts = {}) {
|
|
|
798
1073
|
captures,
|
|
799
1074
|
builds: wanted.length,
|
|
800
1075
|
caveats,
|
|
801
|
-
|
|
1076
|
+
// These holes were collected build by build and capture by capture, in the order the
|
|
1077
|
+
// store listed them — which is the order the random end of a capture id put them in.
|
|
1078
|
+
// Nothing about one unreadable record makes it more urgent than another, so they go in
|
|
1079
|
+
// an order of their own and two identical runs list them the same way round.
|
|
1080
|
+
gaps: inGapOrder(dedupeGaps(holes)),
|
|
802
1081
|
});
|
|
803
1082
|
}
|
|
804
1083
|
|
|
@@ -890,7 +1169,13 @@ export function gaps(led, opts = {}) {
|
|
|
890
1169
|
});
|
|
891
1170
|
}
|
|
892
1171
|
|
|
893
|
-
|
|
1172
|
+
// Whole families tie here routinely — ten folders of four unopened exports each score the
|
|
1173
|
+
// same and hold the same number of doors — and a tie used to be settled by whichever came
|
|
1174
|
+
// out of the map first. The family's own name settles it instead, so the list this file
|
|
1175
|
+
// hands back is the same list every time and the cut at the end falls in the same place.
|
|
1176
|
+
const ranked = jobs.sort(
|
|
1177
|
+
(a, b) => b.rank - a.rank || b.doors - a.doors || (a.group < b.group ? -1 : a.group > b.group ? 1 : 0),
|
|
1178
|
+
);
|
|
894
1179
|
if (ranked.length <= worst) return ranked;
|
|
895
1180
|
|
|
896
1181
|
// The cut is real and it used to be invisible. `toCoverage` asks for eight jobs; a product
|