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
@@ -502,6 +502,232 @@ export function compareTrees(before, after) {
502
502
  return changes;
503
503
  }
504
504
 
505
+ // ---------------------------------------------------------------------------
506
+ // Never waiting for ever
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /**
510
+ * The three pieces below exist because of one measured symptom, and they are used by every
511
+ * adapter in this folder that starts a program.
512
+ *
513
+ * On 2026-08-30 an Electron check produced no output at all and simply never came back. On
514
+ * 2026-08-31 it was reproduced twice, deliberately, against a fake app that starts, prints one
515
+ * line and leaves a `sleep` behind that inherited its standard output:
516
+ *
517
+ * 1. `runCommand` was asked for a two second limit and had still not returned twenty
518
+ * seconds later. Its limit fired exactly on time and killed the shell — but the promise
519
+ * is settled by the child's `close` event, and `close` does not mean "the program ended",
520
+ * it means "nobody is holding its pipes any more". The orphan was holding them, so
521
+ * `close` never arrived and the limit may as well not have existed.
522
+ *
523
+ * 2. A run that had finished all of its work — the app opened, read, closed and PROVED gone,
524
+ * the teardown printing "The next run starts alone" — then sat there for ever, because
525
+ * the same orphan was still holding the writing end of a pipe this process was reading.
526
+ * A pipe being read keeps Node's event loop awake, so the tool never exited.
527
+ *
528
+ * `child.js` says the same thing about servers started from a start command, and it is right:
529
+ * the whole group has to be signalled and the pipes have to be TORN DOWN rather than trusted
530
+ * to close. These are the same two rules for the children that adapters start directly.
531
+ *
532
+ * The reason this matters more than an ordinary bug: a tool somebody runs before a release
533
+ * that never comes back cannot be told apart from a tool that is broken, or from a product
534
+ * that is broken. A run that gives up after a bounded wait and says exactly what it was
535
+ * waiting for is worse news and better information.
536
+ */
537
+
538
+ /**
539
+ * A number of milliseconds that is definitely a number of milliseconds.
540
+ *
541
+ * Every limit in this tool can be set from a project's own settings file, and a settings file
542
+ * is written by a person. `Number("30s")` is NaN — and NaN is the most dangerous value a limit
543
+ * can take, because `Date.now() + NaN` is NaN, `Date.now() > NaN` is false for ever, and a
544
+ * loop written around that runs until somebody kills it and never says why. A limit that is
545
+ * not a real, positive, finite number becomes the default instead, and one that is absurd is
546
+ * capped, because "wait thirty days" is a typo every time.
547
+ *
548
+ * @param {unknown} value
549
+ * @param {number} fallback
550
+ * @param {number} [ceiling]
551
+ * @returns {number}
552
+ */
553
+ export function boundedMs(value, fallback, ceiling = 30 * 60_000) {
554
+ const asked = typeof value === 'number' ? value : Number(value);
555
+ if (!Number.isFinite(asked) || asked <= 0) return fallback;
556
+ return Math.min(asked, ceiling);
557
+ }
558
+
559
+ /**
560
+ * A count that is definitely a count. The same guard as `boundedMs`, for the numbers that are
561
+ * a number of times round a loop rather than a number of milliseconds.
562
+ *
563
+ * @param {unknown} value
564
+ * @param {number} fallback
565
+ * @param {number} ceiling
566
+ * @returns {number}
567
+ */
568
+ export function boundedCount(value, fallback, ceiling) {
569
+ const asked = typeof value === 'number' ? value : Number(value);
570
+ if (!Number.isFinite(asked) || asked < 1) return fallback;
571
+ return Math.min(Math.round(asked), ceiling);
572
+ }
573
+
574
+ /**
575
+ * Let go of a child completely, so nothing it left behind can hold this tool open.
576
+ *
577
+ * The pipes are destroyed rather than left to close on their own, because whatever the child
578
+ * started inherited the writing end of them and a survivor this file did not start must not
579
+ * be able to keep a finished check awake. This is the same tear-down `child.js` does for a
580
+ * start command's server, for the same measured reason.
581
+ *
582
+ * @param {import('node:child_process').ChildProcess|null|undefined} child
583
+ * @returns {void}
584
+ */
585
+ export function letGoOf(child) {
586
+ if (!child) return;
587
+ for (const stream of [child.stdout, child.stderr, child.stdin]) {
588
+ try { stream?.destroy(); } catch { /* already gone, which is the outcome wanted */ }
589
+ }
590
+ try { child.unref(); } catch { /* not every child can be unreferenced; it has been let go either way */ }
591
+ }
592
+
593
+ /**
594
+ * Stop a child, and everything it started, as firmly as the platform allows.
595
+ *
596
+ * Signalling a negative pid signals the whole process GROUP, which is the only way to reach
597
+ * the grandchildren — a start command's shell runs npm which runs node, and killing the shell
598
+ * leaves the other two running. That only works for a child that was started in a group of
599
+ * its own, so `group` is the caller's promise that it spawned with `detached`. Passing it for
600
+ * a child that is in OUR group would ask this process to kill itself.
601
+ *
602
+ * @param {import('node:child_process').ChildProcess} child
603
+ * @param {NodeJS.Signals} signal
604
+ * @param {boolean} group
605
+ * @returns {void}
606
+ */
607
+ export function tellItToStop(child, signal, group) {
608
+ const pid = child.pid;
609
+ if (group && pid && process.platform !== 'win32') {
610
+ try {
611
+ process.kill(-pid, signal);
612
+ return;
613
+ } catch { /* no group of that name, or already gone; fall through and ask the one process */ }
614
+ }
615
+ try { child.kill(signal); } catch { /* already gone, which is the outcome wanted */ }
616
+ }
617
+
618
+ /**
619
+ * Wait for a child to be GONE, with a limit that always fires and always says something.
620
+ *
621
+ * The important line in here is that it settles on `exit` and not on `close`. `exit` means the
622
+ * program ended. `close` means the program ended AND nobody anywhere is holding its pipes any
623
+ * more, which is a completely different claim and one an orphaned grandchild can refuse for
624
+ * ever. Every `close`-shaped wait in this folder was a hang waiting to happen, and one of them
625
+ * was measured hanging on 2026-08-31.
626
+ *
627
+ * After `exit` the last of the output is still worth having, so there is a short drain — but
628
+ * it is a drain with a clock on it, not a wait.
629
+ *
630
+ * @param {import('node:child_process').ChildProcess} child
631
+ * @param {object} [opts]
632
+ * @param {number} [opts.limitMs] How long the program itself may take. Default two minutes.
633
+ * @param {number} [opts.drainMs] How long to wait for the last of its output after it ends.
634
+ * @param {number} [opts.graceMs] Between asking it to stop and insisting.
635
+ * @param {boolean} [opts.group] True only when it was spawned with `detached`.
636
+ * @param {string} [opts.what] Named in the sentence, so a give-up can be acted on.
637
+ * @returns {Promise<{code: number|null, signal: string|null, gaveUp: boolean, why: string}>}
638
+ */
639
+ export function endOfChild(child, opts = {}) {
640
+ const limitMs = boundedMs(opts.limitMs, 120_000);
641
+ const drainMs = boundedMs(opts.drainMs, 250, 10_000);
642
+ const graceMs = boundedMs(opts.graceMs, 2000, 60_000);
643
+ const group = opts.group === true;
644
+ const what = opts.what ?? 'the program it started';
645
+
646
+ return new Promise((resolve) => {
647
+ let settled = false;
648
+ /** @type {ReturnType<typeof setTimeout>[]} */
649
+ const timers = [];
650
+ /** @param {number} ms @param {() => void} fn */
651
+ const later = (ms, fn) => {
652
+ const timer = setTimeout(fn, ms);
653
+ // The limit itself must never be the thing holding the loop open.
654
+ if (typeof timer.unref === 'function') timer.unref();
655
+ timers.push(timer);
656
+ return timer;
657
+ };
658
+
659
+ // Set the moment the limit fires, and read by every path out of here. A program that
660
+ // stops promptly when it is asked to still ran out of time, and reporting that as a clean
661
+ // exit is how a run that checked nothing comes back looking green.
662
+ let ranOutOfTime = false;
663
+ const gaveUpBecause = () => `Stays Fixed gave up waiting for ${what} after ${Math.round(limitMs / 1000)} seconds and stopped it. Nothing it would have done after that point was checked.`;
664
+
665
+ /** @param {string} why */
666
+ const finish = (why) => {
667
+ if (settled) return;
668
+ settled = true;
669
+ for (const timer of timers) clearTimeout(timer);
670
+ letGoOf(child);
671
+ resolve({ code: child.exitCode, signal: child.signalCode, gaveUp: ranOutOfTime, why: ranOutOfTime ? gaveUpBecause() : why });
672
+ };
673
+
674
+ child.once('close', () => finish(`${what} finished.`));
675
+ child.once('exit', () => {
676
+ later(drainMs, () => finish(`${what} finished, and the last of its output was cut off after ${Math.round(drainMs)}ms because something it started was still holding its pipes open.`));
677
+ });
678
+ child.once('error', (error) => finish(`${what} could not be run: ${error.message}`));
679
+
680
+ later(limitMs, () => {
681
+ ranOutOfTime = true;
682
+ tellItToStop(child, 'SIGTERM', group);
683
+ later(graceMs, () => {
684
+ tellItToStop(child, 'SIGKILL', group);
685
+ // Even SIGKILL cannot make a pipe close while an orphan holds it, so this is where
686
+ // the waiting stops whatever anything else does.
687
+ finish(gaveUpBecause());
688
+ });
689
+ });
690
+
691
+ // It may already be over before anybody looked.
692
+ if (child.exitCode !== null || child.signalCode !== null) {
693
+ later(drainMs, () => finish(`${what} had already finished.`));
694
+ }
695
+ });
696
+ }
697
+
698
+ /**
699
+ * Put a limit on any wait at all, and make the limit say what it was waiting for.
700
+ *
701
+ * For the waits that are not a child process: a socket that accepts a connection and then goes
702
+ * quiet, a window that never opens, a handler that never answers. The sentence is the whole
703
+ * point of it — "it timed out" and "it never came back" are the same non-answer, while "it
704
+ * gave up after sixty seconds waiting for the app to open its main-process debugging
705
+ * connection" is something a person can act on.
706
+ *
707
+ * `what` may be a function, so that a wait made of several stages can name the stage it was
708
+ * actually stuck in rather than the whole job. "It gave up opening the app" sends somebody
709
+ * looking everywhere; "it gave up while waiting for the app to open a window" sends them to one
710
+ * place.
711
+ *
712
+ * @template T
713
+ * @param {Promise<T>} promise
714
+ * @param {{limitMs: number, what: string|(() => string), fallbackMs?: number}} opts
715
+ * @returns {Promise<T>}
716
+ */
717
+ export function withLimit(promise, opts) {
718
+ const limitMs = boundedMs(opts.limitMs, opts.fallbackMs ?? 60_000);
719
+ /** @type {ReturnType<typeof setTimeout>} */
720
+ let timer;
721
+ const giveUp = new Promise((_resolve, reject) => {
722
+ timer = setTimeout(
723
+ () => reject(new Error(`Stays Fixed gave up after ${Math.round(limitMs / 1000)} seconds waiting for ${typeof opts.what === 'function' ? opts.what() : opts.what}.`)),
724
+ limitMs,
725
+ );
726
+ if (typeof timer.unref === 'function') timer.unref();
727
+ });
728
+ return Promise.race([promise, giveUp]).finally(() => clearTimeout(timer));
729
+ }
730
+
505
731
  // ---------------------------------------------------------------------------
506
732
  // Running one command
507
733
  // ---------------------------------------------------------------------------
@@ -521,12 +747,27 @@ export function compareTrees(before, after) {
521
747
  */
522
748
 
523
749
  /**
524
- * Run a command and wait for it, with a hard limit.
750
+ * Run a command and wait for it, with a hard limit that is actually hard.
525
751
  *
526
752
  * Killed with SIGTERM first and SIGKILL after a grace period, because a program that traps
527
753
  * SIGTERM and hangs would otherwise hold the whole run open — and killed is reported as
528
754
  * killed, never quietly as an exit code.
529
755
  *
756
+ * Two things in here are not decoration, and both were measured on 2026-08-31 against a start
757
+ * command that leaves an orphan behind — the shape `npm run dev` has, and the shape that had
758
+ * already been recorded once as an Electron check that gave no output at all and never came
759
+ * back.
760
+ *
761
+ * The command runs in a process GROUP of its own, because the thing spawned is a shell and the
762
+ * program is its child or grandchild. Killing the shell leaves the program running, and the
763
+ * limit then achieves nothing at all. This is the same finding `child.js` made about servers.
764
+ *
765
+ * And the answer is settled by `endOfChild`, which waits for the command to END rather than
766
+ * for its pipes to close. Waiting for `close` was the actual bug: asked for a two second
767
+ * limit, this function had not returned twenty seconds later, because the orphan was still
768
+ * holding the writing end of the pipes and `close` will not fire until every last holder lets
769
+ * go. The limit fired perfectly and nobody was listening.
770
+ *
530
771
  * @param {string} command Run through the shell, so a project can write what it means.
531
772
  * @param {object} opts
532
773
  * @param {string} opts.cwd
@@ -536,69 +777,68 @@ export function compareTrees(before, after) {
536
777
  * @param {AbortSignal} [opts.signal]
537
778
  * @returns {Promise<CommandResult>}
538
779
  */
539
- export function runCommand(command, opts) {
540
- const timeoutMs = opts.timeoutMs ?? 120000;
541
- return new Promise((resolve) => {
542
- const started = Date.now();
543
- const child = spawn(command, {
544
- shell: true,
545
- cwd: opts.cwd,
546
- env: opts.env,
547
- stdio: ['pipe', 'pipe', 'pipe'],
548
- });
549
- /** @type {Buffer[]} */
550
- const out = [];
551
- /** @type {Buffer[]} */
552
- const err = [];
553
- let timedOut = false;
554
- let settled = false;
780
+ export async function runCommand(command, opts) {
781
+ const timeoutMs = boundedMs(opts.timeoutMs, 120000);
782
+ const started = Date.now();
783
+ // Its own process group, so the limit can reach the program and not only the shell in front
784
+ // of it. There are no groups of this kind on Windows, where signalling the child is the best
785
+ // that can be done.
786
+ const inItsOwnGroup = process.platform !== 'win32';
787
+ const child = spawn(command, {
788
+ shell: true,
789
+ cwd: opts.cwd,
790
+ env: opts.env,
791
+ stdio: ['pipe', 'pipe', 'pipe'],
792
+ detached: inItsOwnGroup,
793
+ });
555
794
 
556
- child.stdout?.on('data', (chunk) => out.push(chunk));
557
- child.stderr?.on('data', (chunk) => err.push(chunk));
558
- if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
559
- else child.stdin?.end();
795
+ /** @type {Buffer[]} */
796
+ const out = [];
797
+ /** @type {Buffer[]} */
798
+ const err = [];
799
+
800
+ child.stdout?.on('data', (chunk) => out.push(chunk));
801
+ child.stderr?.on('data', (chunk) => err.push(chunk));
802
+ if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
803
+ else child.stdin?.end();
804
+
805
+ /** @type {string|undefined} */
806
+ let couldNotStart;
807
+ child.on('error', (error) => {
808
+ // Nothing ran. Said out loud rather than folded into "exit code null", which is what a
809
+ // killed run also looks like — and which is identical on both builds, so the comparison
810
+ // saw no difference and the run passed for the worst possible reason.
811
+ couldNotStart = error.message;
812
+ err.push(Buffer.from(`${error.message}\n`));
813
+ });
560
814
 
561
- /** @type {string|undefined} */
562
- let couldNotStart;
815
+ // The whole group, not the shell. Stopping the shell and leaving npm and node running is
816
+ // how a cancelled run leaves a dev server on somebody's machine.
817
+ const onAbort = () => { tellItToStop(child, 'SIGTERM', inItsOwnGroup); };
818
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
819
+
820
+ const ended = await endOfChild(child, {
821
+ limitMs: timeoutMs,
822
+ graceMs: 5000,
823
+ group: inItsOwnGroup,
824
+ // Both ends of the command, never just the first eighty characters. A command that lives
825
+ // under a long scratch path is all path for the first eighty characters, so cutting from
826
+ // the front produces a sentence naming a FOLDER and never the thing it gave up on.
827
+ what: `"${trimForStorage(String(command), 120).text}"`,
828
+ });
829
+ opts.signal?.removeEventListener('abort', onAbort);
563
830
 
564
- const finish = (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
565
- if (settled) return;
566
- settled = true;
567
- clearTimeout(alarm);
568
- clearTimeout(hardStop);
569
- opts.signal?.removeEventListener('abort', onAbort);
570
- resolve({
571
- stdout: Buffer.concat(out).toString('utf8'),
572
- stderr: Buffer.concat(err).toString('utf8'),
573
- code,
574
- signal,
575
- timedOut,
576
- ms: Date.now() - started,
577
- ...(couldNotStart ? { couldNotStart } : {}),
578
- });
579
- };
831
+ if (ended.gaveUp) err.push(Buffer.from(`${ended.why}\n`));
580
832
 
581
- /** @type {NodeJS.Timeout} */
582
- let hardStop;
583
- const alarm = setTimeout(() => {
584
- timedOut = true;
585
- child.kill('SIGTERM');
586
- hardStop = setTimeout(() => child.kill('SIGKILL'), 5000);
587
- }, timeoutMs);
588
-
589
- const onAbort = () => { child.kill('SIGTERM'); };
590
- opts.signal?.addEventListener('abort', onAbort, { once: true });
591
-
592
- child.on('error', (error) => {
593
- // Nothing ran. Said out loud rather than folded into "exit code null", which is what a
594
- // killed run also looks like — and which is identical on both builds, so the comparison
595
- // saw no difference and the run passed for the worst possible reason.
596
- couldNotStart = error.message;
597
- err.push(Buffer.from(`${error.message}\n`));
598
- finish(null, null);
599
- });
600
- child.on('close', (code, signal) => finish(code, signal));
601
- });
833
+ return {
834
+ stdout: Buffer.concat(out).toString('utf8'),
835
+ stderr: Buffer.concat(err).toString('utf8'),
836
+ code: ended.code,
837
+ signal: ended.signal,
838
+ timedOut: ended.gaveUp,
839
+ ms: Date.now() - started,
840
+ ...(couldNotStart ? { couldNotStart } : {}),
841
+ };
602
842
  }
603
843
 
604
844
  // ---------------------------------------------------------------------------
@@ -728,38 +968,27 @@ export async function copyForScratch(from, to, opts = {}) {
728
968
  * @param {AbortSignal} [signal]
729
969
  * @returns {Promise<boolean>}
730
970
  */
731
- function cloneOne(source, target, signal) {
971
+ async function cloneOne(source, target, signal) {
732
972
  // Windows has no reflink through `cp`, and there is no `cp`. Straight to the fallback.
733
- if (process.platform === 'win32') return Promise.resolve(false);
973
+ if (process.platform === 'win32') return false;
734
974
  const args = process.platform === 'darwin'
735
975
  ? ['-Rc', source, target]
736
976
  : ['-a', '--reflink=auto', source, target];
737
- return new Promise((resolve) => {
738
- let settled = false;
739
- /** @param {boolean} ok */
740
- const done = (ok) => {
741
- if (settled) return;
742
- settled = true;
743
- resolve(ok);
744
- };
745
- let child;
746
- try {
747
- child = spawn('cp', args, { stdio: 'ignore', signal });
748
- } catch {
749
- done(false);
750
- return;
751
- }
752
- child.on('error', () => done(false));
753
- child.on('close', (code) => {
754
- if (code === 0) {
755
- done(true);
756
- return;
757
- }
758
- // A half-written target from a failed clone would make the fallback copy merge into
759
- // it. Clear it out so the fallback starts from nothing.
760
- fsp.rm(target, { recursive: true, force: true }).then(() => done(false), () => done(false));
761
- });
762
- });
977
+ let child;
978
+ try {
979
+ child = spawn('cp', args, { stdio: 'ignore', signal });
980
+ } catch {
981
+ return false;
982
+ }
983
+ // Ten minutes is far longer than a clone of a project tree has ever taken and still a limit.
984
+ // `cp` on a network mount that stops answering hangs for ever otherwise, and a copy that
985
+ // never finishes looks to whoever ran the check exactly like the check being broken.
986
+ const ended = await endOfChild(child, { limitMs: 10 * 60_000, what: `copying ${path.basename(source)} into the scratch build` });
987
+ if (!ended.gaveUp && ended.code === 0) return true;
988
+ // A half-written target from a failed clone would make the fallback copy merge into it.
989
+ // Clear it out so the fallback starts from nothing.
990
+ await fsp.rm(target, { recursive: true, force: true }).catch(() => {});
991
+ return false;
763
992
  }
764
993
 
765
994
  // ---------------------------------------------------------------------------
@@ -1428,15 +1657,44 @@ export async function describeRun(input) {
1428
1657
  }
1429
1658
 
1430
1659
  // ---- how it finished
1431
- if (result.couldNotStart) {
1660
+ // DID THIS WALK EVER REACH THE PRODUCT? Said out loud, in one place, because it is the one
1661
+ // question nothing downstream can work out for itself.
1662
+ //
1663
+ // A command that fails to SPAWN has always been reported here. The other half was missing
1664
+ // until 2026-08-31: a command that spawns perfectly, throws on its first line, prints
1665
+ // nothing and exits non-zero. That walk fills the complaints channel with a real stack
1666
+ // trace and a real exit code — both genuine facts, and both facts about a crash rather than
1667
+ // about the product. Two builds that crash the same way agree at every one of those
1668
+ // addresses, so the run came back "Nothing that worked has changed. 7 addresses checked"
1669
+ // about a product whose entire output had been rewritten in between; `ship` blessed it; and
1670
+ // the day it was fixed, every real value differed from the stored crash and four findings
1671
+ // arrived that nobody had caused.
1672
+ //
1673
+ // The stdout test is what keeps this narrow, and it is the honest line. A command that
1674
+ // printed something got somewhere, and what it printed is a real observation of the product
1675
+ // however it ended — a linter that exits 1 with a list of problems is being observed
1676
+ // properly and must go on being compared. A command that printed nothing and then died
1677
+ // never reached the product at all.
1678
+ const neverReachedIt =
1679
+ result.couldNotStart
1680
+ || (result.stdout.trim() === '' && (result.timedOut || result.signal !== null || (result.code !== null && result.code !== 0)));
1681
+ if (neverReachedIt) {
1682
+ const why = result.couldNotStart
1683
+ ? `it never started: ${result.couldNotStart}`
1684
+ : result.timedOut
1685
+ ? 'it was still running when its time ran out and had printed nothing'
1686
+ : result.signal
1687
+ ? `it was killed by ${result.signal} having printed nothing`
1688
+ : `it exited ${result.code} without printing anything at all`;
1432
1689
  out.push(notCovered({
1433
1690
  channel: 'complaints',
1434
1691
  path: joinPath('cli', id, 'ran at all'),
1435
1692
  reason: 'crashed',
1436
1693
  says:
1437
- `"${journey.describe}" never started: ${result.couldNotStart}. Nothing about the product was observed here, ` +
1438
- `and a command that fails to start fails the same way on both builds — so without this line the comparison ` +
1439
- `would have found no difference and called it clean.`,
1694
+ `"${journey.describe}" did not get far enough to observe the product: ${why}. What it complained about and how ` +
1695
+ `it finished are recorded below and are facts about the crash, not about the product — so nothing here is ` +
1696
+ `compared with the other build. A command that fails the same way on both builds otherwise agrees at every ` +
1697
+ `address, and that agreement reads exactly like a clean run.`,
1440
1698
  }));
1441
1699
  }
1442
1700
  out.push(observation({
@@ -729,6 +729,9 @@ export const IRREVERSIBLE = Object.freeze([
729
729
  * @property {JsonValue} [answered] The answer, for the app's own calls that speak JSON.
730
730
  * @property {JsonValue} [shape] The fields the answer carries and what type each one is.
731
731
  * @property {string} [failed] Why it never finished.
732
+ * @property {boolean} [unfinishedAtTeardown] It was still in flight when the walk closed the
733
+ * page, so how it ended was never seen. A hole, and never
734
+ * a complaint: the abort is our own doing.
732
735
  * @property {number} times
733
736
  */
734
737
 
@@ -838,10 +841,28 @@ export async function watchTheWire(page, opts) {
838
841
  );
839
842
  });
840
843
 
844
+ // Whether the walk has started packing up. A request that was still in flight when WE
845
+ // closed the page is aborted by the closing, and that is the measurement's own footprint
846
+ // rather than anything the product did — the browser reports it exactly like a real
847
+ // failure. Next.js starts a prefetch behind every internal link, so on a two-page app the
848
+ // address `.../never finished` appeared in roughly four runs in five, at random, and two
849
+ // byte-identical runs disagreed about whether the product had a problem. Measured
850
+ // 2026-08-31.
851
+ let packingUp = false;
852
+ const OUR_OWN_DOING = /ERR_ABORTED|context or browser has been closed|Target closed/i;
853
+
841
854
  page.on('requestfailed', (/** @type {any} */ request) => {
842
855
  const entry = entryFor(String(request.method()).toUpperCase(), String(request.url()));
843
856
  if (entry.refused) return;
844
- entry.failed = String(request.failure()?.errorText ?? 'it did not finish');
857
+ const failure = String(request.failure()?.errorText ?? 'it did not finish');
858
+ if (packingUp && OUR_OWN_DOING.test(failure)) {
859
+ // Recorded as a HOLE, not dropped and not turned into a complaint. Something was still
860
+ // being asked for and we never saw how it ended, which is missing coverage — louder
861
+ // than a complaint, never quieter.
862
+ entry.unfinishedAtTeardown = true;
863
+ return;
864
+ }
865
+ entry.failed = failure;
845
866
  });
846
867
 
847
868
  return {
@@ -850,6 +871,7 @@ export async function watchTheWire(page, opts) {
850
871
  await withLimit(Promise.all(reading.splice(0)), 15000, []);
851
872
  },
852
873
  stop: async () => {
874
+ packingUp = true;
853
875
  try {
854
876
  await page.unroute('**/*');
855
877
  } catch {
@@ -524,6 +524,11 @@ export const webAdapter = defineAdapter({
524
524
  tmp,
525
525
  extra: {
526
526
  PORT: String(port),
527
+ // Asked for, not relied on. Plenty of dev servers never look at HOST — Vite is one,
528
+ // measured on 2026-08-31: it ignores both HOST and PORT and binds the name
529
+ // `localhost`, which this Mac resolves to the IPv6 loopback. That is why the boot
530
+ // check knocks on both loopback addresses and uses whichever one answers, rather
531
+ // than trusting this line to have been obeyed.
527
532
  HOST: '127.0.0.1',
528
533
  NODE_ENV: config.nodeEnv ?? 'production',
529
534
  ...config.env,
@@ -556,7 +561,21 @@ export const webAdapter = defineAdapter({
556
561
  exited = `The app stopped before it answered - exit code ${code}${signal ? `, killed by ${signal}` : ''}.`;
557
562
  });
558
563
 
559
- const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
564
+ // WHY THIS HANDS OVER SO MUCH. Measured on 2026-08-31 on a freshly scaffolded Vite app:
565
+ // this wait took 90.6 seconds and then said "the server never answered on port 64912",
566
+ // which names neither the command that was run nor the fact that the server was up the
567
+ // whole time on the other loopback address. A whole `check --paired` on that app took
568
+ // 3 minutes 2 seconds and reported nothing a person could act on. So the wait is now
569
+ // given the command, everything the command has printed, and somewhere to say what it is
570
+ // waiting for while it waits — that is what turns ninety seconds of silence into a
571
+ // sentence, usually in the first second or two.
572
+ const up = await waitForServer(port, {
573
+ timeoutMs: config.startTimeoutMs ?? 90000,
574
+ crashed: () => exited,
575
+ command: String(config.start),
576
+ announced: () => Buffer.concat(said).toString('utf8'),
577
+ say: (message) => ctx.log?.(message),
578
+ });
560
579
  if (!up.up) {
561
580
  await stopServer(child);
562
581
  return {
@@ -571,13 +590,17 @@ export const webAdapter = defineAdapter({
571
590
  };
572
591
  }
573
592
 
574
- const baseUrl = `http://127.0.0.1:${port}`;
593
+ // The address that ANSWERED, not the address that was assumed. A server told to listen on
594
+ // `localhost` lands on whichever of the two loopback addresses this machine resolves that
595
+ // name to, and on this Mac that is the IPv6 one — so handing the browser a hard-coded
596
+ // `http://127.0.0.1:...` would open a page that cannot load even though the app is up.
597
+ const baseUrl = up.baseUrl ?? `http://127.0.0.1:${port}`;
575
598
  running.set(build.id, { base, baseUrl, port, child, config, playwright, paired: true, work, home, tmp });
576
599
  return {
577
600
  build,
578
601
  root: work,
579
602
  ready: true,
580
- why: `${copy.why} It came up on port ${port} in ${timeBucket(up.ms)}, in a browser profile nobody else is using.${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`,
603
+ why: `${copy.why} It came up at ${baseUrl} in ${timeBucket(up.ms)}, in a browser profile nobody else is using.${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`,
581
604
  facts: { baseUrl, port, paired: true },
582
605
  dispose: async () => {
583
606
  const held = running.get(build.id);
@@ -1022,6 +1045,22 @@ export function describeTraffic(journey, calls, footprint) {
1022
1045
  surface: 'web',
1023
1046
  }),
1024
1047
  );
1048
+ } else if (call.unfinishedAtTeardown) {
1049
+ // Missing coverage, not a complaint. The page was still asking for this when the walk
1050
+ // ended, and the abort that followed is this tool closing the page — reporting it as
1051
+ // the product failing made an unchanged tree look broken in four runs out of five.
1052
+ // Recorded rather than dropped: something was being asked for and how it ended was
1053
+ // never seen, which is a hole, and a hole is louder than a complaint, never quieter.
1054
+ out.push(
1055
+ notCovered({
1056
+ channel: 'effects',
1057
+ path: `${where}.how it finished`,
1058
+ reason: 'refused',
1059
+ says:
1060
+ `The page was still asking for ${asked} when the walk ended, so how that finished was never seen. ` +
1061
+ `The request was cancelled by this tool closing the page, which is not the product doing anything wrong.`,
1062
+ }),
1063
+ );
1025
1064
  }
1026
1065
  if (call.status !== undefined) {
1027
1066
  out.push(