staysfixed 0.12.0 → 0.13.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.
@@ -538,7 +538,10 @@ export function toolDefinitions() {
538
538
  title: 'Prove what caused it',
539
539
  annotations: behaves({ title: 'Prove what caused it' }),
540
540
  description:
541
- 'Test a causal claim by undoing a change and running again. You believe your edit to a particular file caused a finding: this puts that file back to the reference, re-runs, and tells you whether the difference went away. If it survives the revert, your edit did not cause it and you were about to fix the wrong thing. Nothing is left reverted.',
541
+ 'Test a causal claim by undoing a change and running again. You believe your edit to a particular file caused a finding: this puts that file back to the reference, re-runs, and tells you whether the difference went away. ' +
542
+ 'It answers one of THREE things, and only two of them are answers: PROVEN CAUSED (undoing it made the difference go away), PROVEN NOT CAUSED (it was re-run without your change and the difference is still there, so you were about to fix the wrong file), ' +
543
+ 'and NOT TESTED (nothing was measured - the file you named was not among the changes, the old build would not build, or nothing was re-run at all). NOT TESTED never means your edit is innocent; it means nobody looked, and it comes back as an error so it cannot be mistaken for a clean answer. ' +
544
+ 'Proving costs a full re-run of the affected journeys - roughly what a check costs - because that is the only thing that settles it. Nothing is left reverted.',
542
545
  inputSchema: {
543
546
  type: 'object',
544
547
  properties: {
@@ -1512,6 +1515,22 @@ async function toolExplain(ctx, input) {
1512
1515
  } else if (deep && typeof deep.text === 'string') {
1513
1516
  out.push('');
1514
1517
  out.push(deep.text);
1518
+ } else {
1519
+ // The deep half is missing and nothing above says so.
1520
+ //
1521
+ // Same shape as the `prove` defect measured on 2026-08-31: a reply that is complete on
1522
+ // its face while a whole half of it was never fetched. Everything above comes from the
1523
+ // stored finding, which this surface can always read; the full list of addresses with
1524
+ // both values comes from the engine, and when the engine cannot be loaded, or has no
1525
+ // `explain` in it, the reply simply ended early and read as the whole answer. A reader
1526
+ // has no way to tell "this is all there is" from "the rest could not be fetched", so it
1527
+ // is said out loud rather than left to be inferred from an absence.
1528
+ out.push('');
1529
+ out.push(
1530
+ engine.parts.explain
1531
+ ? 'The engine returned nothing for this finding, so everything above comes from the stored record of the check and the full side-by-side values are missing. This is not the whole answer.'
1532
+ : `This copy of Stays Fixed has no difference engine to ask, so everything above comes from the stored record of the check alone - the full list of addresses with both values is missing. This is not the whole answer. ${voice.isPerson ? 'Run' : 'Call'} ${voice.capabilities} for what this copy can do.`
1533
+ );
1515
1534
  }
1516
1535
 
1517
1536
  content.push({ type: 'text', text: out.join('\n') });
@@ -1570,7 +1589,10 @@ async function toolProve(ctx, input) {
1570
1589
  return engineMissing(
1571
1590
  engine,
1572
1591
  'prove',
1573
- 'prove({cwd, configFile, finding, revert}) returning {gone: boolean, detail?: string}. src/v2/cause.js already has proveCause(), but it takes an engine-internal finding and a loaded project, which this surface does not have - a small facade in src/v2/check.js is all that is needed.',
1592
+ 'prove({cwd, configFile, finding, revert}) returning {verdict: "caused by that change"|"not caused by that change"|"could not test", detail?: string, reran?: number, checked?: number, escalates?: boolean}. ' +
1593
+ 'The verdict has to be all three of those, not a boolean: "could not test" is a third outcome and reporting it as "not caused" is a false all-clear (measured 2026-08-31). ' +
1594
+ '`reran` and `checked` are what let this reply say whether anything was actually run, so a five-second answer cannot pass for an eleven-minute one. ' +
1595
+ 'src/v2/cause.js already has proveCause() returning exactly that shape, but it takes an engine-internal finding and a loaded project, which this surface does not have - a small facade in src/v2/check.js is all that is needed.',
1574
1596
  voice
1575
1597
  );
1576
1598
  }
@@ -1586,23 +1608,174 @@ async function toolProve(ctx, input) {
1586
1608
 
1587
1609
  /** @type {any} */
1588
1610
  const result = (await run({ cwd: ctx.root, finding: id, revert })) ?? {};
1589
- const gone = result.gone === true;
1611
+
1612
+ // THREE OUTCOMES, THREE SENTENCES. Only two of them are answers.
1613
+ //
1614
+ // Until 2026-08-31 this branched on `result.gone === true` and printed one of two
1615
+ // paragraphs, so everything that was not a proof came out as the confident denial "Your
1616
+ // edit did not cause this, so fixing that file will not help." Measured on a real website
1617
+ // that day: a one-line heading change that had definitely caused the finding was told it
1618
+ // was innocent; naming a completely unrelated file produced the word-for-word identical
1619
+ // denial; and a file that does not exist produced it too. All three came back in about
1620
+ // five seconds, on a project where a real check takes eleven to twenty minutes, and the
1621
+ // run log recorded zero server starts. Nothing had been re-run at all.
1622
+ //
1623
+ // `src/v2/cause.js` had the third outcome the whole time — its `cannot()` path carries a
1624
+ // comment saying "not proven either way is not the same as proven innocent, and it must
1625
+ // never be reported as if it were" — and this is the surface that was reporting it as if
1626
+ // it were. So the verdict is read here, not re-derived from a boolean that cannot carry
1627
+ // three states.
1628
+ const verdict = verdictOf(result);
1590
1629
 
1591
1630
  /** @type {string[]} */
1592
1631
  const out = [];
1593
- if (gone) {
1594
- out.push(`PROVEN: your change caused it. With ${revert.join(', ')} put back, this matched the reference again.`);
1595
- out.push(` ${trim(f.title, 200)}`);
1632
+ const files = revert.join(', ');
1633
+ const title = ` ${trim(f.title, 200)}`;
1634
+ // How much real work the answer rests on, when the engine says. `reran` counts journeys
1635
+ // actually walked again; `checked` counts the finding's addresses that were re-measured.
1636
+ // Both are absent when the facade in src/v2/check.js does not forward them, and an absent
1637
+ // number is never guessed at - it simply goes unsaid.
1638
+ const reran = positive(result.reran);
1639
+ const checked = positive(result.checked);
1640
+
1641
+ if (verdict === 'caused by that change') {
1642
+ out.push(`PROVEN CAUSED: your change caused it. With ${files} put back, this matched the reference again.`);
1643
+ out.push(title);
1644
+ out.push(measuredLine(reran, checked, 'and the difference went away'));
1596
1645
  out.push('So it is yours to fix - or to record as intended, if that is genuinely what you meant and it is not sealed.');
1646
+ } else if (verdict === 'not caused by that change') {
1647
+ out.push(`PROVEN NOT CAUSED: it was re-run with your change undone and the difference is still there.`);
1648
+ out.push(title);
1649
+ out.push(measuredLine(reran, checked, 'and the difference survived'));
1650
+ out.push(`So putting ${files} back does not fix this, and fixing that file will not either. Something else caused it, and nothing knows what yet - this finding is louder now, not quieter.`);
1597
1651
  } else {
1598
- out.push(`NOT PROVEN: it survived the revert. With ${revert.join(', ')} put back, this was still different.`);
1599
- out.push(` ${trim(f.title, 200)}`);
1600
- out.push('Your edit did not cause this, so fixing that file will not help. Something else did, or it was already broken before you started.');
1652
+ // The one that must never sound like the one above it. It says what it is, why, what it
1653
+ // is NOT, and what would actually settle the question.
1654
+ out.push(`NOT TESTED: this was not proved either way. Nobody looked.`);
1655
+ out.push(title);
1656
+ out.push('');
1657
+ const why = trim(dropRepeatedTail(String(result.detail ?? 'The engine did not say why.')), 600);
1658
+ out.push(`Why: ${why}`);
1659
+ out.push('');
1660
+ // The re-run fact, said once. `cause.js` writes it into its own sentence so that it
1661
+ // survives a facade that forwards only the words, so when it is already in `why` there
1662
+ // is nothing to add - and adding a vaguer version underneath ("nothing here says how
1663
+ // much was re-run") would contradict the specific one directly above it.
1664
+ const rerunLine =
1665
+ reran === 0
1666
+ ? 'Nothing was re-run: no build was started and no journey was walked again, so no part of this reply is a measurement of your product.'
1667
+ : reran !== null
1668
+ ? `${reran} ${reran === 1 ? 'journey was' : 'journeys were'} walked again and it still settled nothing.`
1669
+ : /re-run|walked again/i.test(why)
1670
+ ? ''
1671
+ : 'Nothing here says how much was re-run, so do not read any of this as a measurement.';
1672
+ if (rerunLine) out.push(rerunLine);
1673
+ out.push(`This is NOT "your edit did not cause it". ${files} has not been cleared - it was never tested. Keep suspecting it.`);
1674
+ out.push(`To get a real answer: ${voice.isPerson ? 'run' : 'call'} ${voice.check} so there is a fresh run to work from, then ${explainThis(voice, id)} to see which files this finding actually sits near, and name one you really changed.`);
1601
1675
  }
1602
- if (result.detail) out.push('', trim(String(result.detail), 600));
1603
- out.push('', 'The working tree has been put back exactly as it was.');
1604
1676
 
1605
- return { content: [{ type: 'text', text: out.join('\n') }] };
1677
+ if (verdict !== 'could not test' && result.detail) out.push('', trim(dropRepeatedTail(String(result.detail)), 600));
1678
+ out.push('', 'Nothing was left reverted. The working tree is exactly as it was.');
1679
+
1680
+ return {
1681
+ content: [{ type: 'text', text: out.join('\n') }],
1682
+ structuredContent: { finding: id, verdict, reverted: revert, reran, checked, escalates: result.escalates === true },
1683
+ // "Could not test" answers non-zero on purpose, and the CLI's own help has promised
1684
+ // exactly this since the command existed: "It answers 0 when it could test the claim and
1685
+ // 2 when it could not." It exited 0 instead. An agent - or a CI step - that reads a zero
1686
+ // as "asked and answered" is the false all-clear this tool exists to prevent, so the one
1687
+ // outcome that is not an answer is the one outcome that does not come back clean.
1688
+ isError: verdict === 'could not test',
1689
+ };
1690
+ }
1691
+
1692
+ /**
1693
+ * How this reader asks to see THIS finding in full.
1694
+ *
1695
+ * `voice.explainCall` is a worked example carrying the made-up id f-a1b2c3, which is right
1696
+ * where the point is to show the shape of a call and wrong the moment the sentence is about
1697
+ * a finding that has a real id. Telling somebody to run `staysfixed explain f-a1b2c3` about
1698
+ * finding f-15365c reads as a copy-and-paste slip and sends them to look up an id that does
1699
+ * not exist.
1700
+ *
1701
+ * @param {Voice} voice
1702
+ * @param {string} id
1703
+ * @returns {string}
1704
+ */
1705
+ function explainThis(voice, id) {
1706
+ return voice.isPerson ? `\`staysfixed explain ${id}\`` : `staysfixed_explain { "finding": "${id}" }`;
1707
+ }
1708
+
1709
+ /**
1710
+ * Which of the three this is, refusing to invent the difference between two of them.
1711
+ *
1712
+ * The engine's own three-state verdict is used whenever it is there. When it is not - an
1713
+ * older facade, or one that only ever returned `{gone: boolean}` - a `false` is genuinely
1714
+ * ambiguous: it means either "measured, and your edit is innocent" or "could not measure".
1715
+ * Those are the two this whole defect confused, so the unknown resolves to the one that
1716
+ * claims nothing. Reporting "could not test" about something that was really tested costs
1717
+ * somebody one more command; reporting "your edit did not cause this" about something
1718
+ * nobody measured sends them to fix the wrong file, which is what happened on 2026-08-31.
1719
+ *
1720
+ * @param {any} result
1721
+ * @returns {'caused by that change'|'not caused by that change'|'could not test'}
1722
+ */
1723
+ function verdictOf(result) {
1724
+ const said = typeof result?.verdict === 'string' ? result.verdict : null;
1725
+ if (said === 'caused by that change' || said === 'not caused by that change' || said === 'could not test') return said;
1726
+ if (result?.gone === true) return 'caused by that change';
1727
+ return 'could not test';
1728
+ }
1729
+
1730
+ /**
1731
+ * One line saying what the verdict above actually rests on.
1732
+ *
1733
+ * A verdict with no measurement behind it reads exactly like one with eleven minutes behind
1734
+ * it, which is how a five-second reply passed for a check of a whole website. When the
1735
+ * numbers are not forwarded this says so plainly rather than inventing a reassuring one.
1736
+ *
1737
+ * @param {number|null} reran Journeys walked again.
1738
+ * @param {number|null} checked Addresses re-measured.
1739
+ * @param {string} outcome What happened to the difference, in a few words.
1740
+ * @returns {string}
1741
+ */
1742
+ function measuredLine(reran, checked, outcome) {
1743
+ if (reran === null && checked === null) return `That was measured by running it again, ${outcome}.`;
1744
+ const bits = [];
1745
+ if (reran !== null) bits.push(`${reran} ${reran === 1 ? 'journey' : 'journeys'} walked again`);
1746
+ if (checked !== null) bits.push(`${checked} ${checked === 1 ? 'address' : 'addresses'} re-measured`);
1747
+ return `Measured, not assumed: ${bits.join(', ')}, ${outcome}.`;
1748
+ }
1749
+
1750
+ /**
1751
+ * Drop a sentence the engine handed over twice.
1752
+ *
1753
+ * `prove` in src/v2/check.js builds its detail as `${proof.what} ${proof.why}`, and
1754
+ * `proof.what` already ends with `why` - so every "could not test" arrived with its reason
1755
+ * printed twice in a row. That is only noise, but a reply that visibly repeats itself is a
1756
+ * reply people stop reading closely, and this one is asking to be read closely.
1757
+ *
1758
+ * @param {string} detail
1759
+ * @returns {string}
1760
+ */
1761
+ function dropRepeatedTail(detail) {
1762
+ const s = detail.trim();
1763
+ // Walk back from the end looking for a tail that already appeared earlier in the string.
1764
+ // Only whole trailing sentences of real length count, so ordinary repeated words - "the
1765
+ // change", "this file" - are never mistaken for a duplicated reason.
1766
+ for (let cut = Math.floor(s.length / 2); cut >= 30; cut -= 1) {
1767
+ const tail = s.slice(s.length - cut).trim();
1768
+ if (tail.length < 30) break;
1769
+ if (s.slice(0, s.length - cut).includes(tail)) {
1770
+ const kept = s.slice(0, s.length - cut).trim();
1771
+ // The cut can land one character inside the sentence that is being KEPT, because the
1772
+ // repeated tail often starts at ". " and the full stop it takes belongs to the line
1773
+ // before it. Losing it leaves the reason ending mid-air, which reads like the text was
1774
+ // truncated - the one impression this particular reply must never give.
1775
+ return /[.!?]$/.test(kept) ? kept : `${kept}.`;
1776
+ }
1777
+ }
1778
+ return s;
1606
1779
  }
1607
1780
 
1608
1781
  // ---------------------------------------------------------------------------
@@ -829,6 +829,21 @@ export function mergeWobble(wobbles) {
829
829
  * here decides whether any difference is real. It decides one thing only — whether this run is
830
830
  * entitled to say the word "clean".
831
831
  *
832
+ * IT IS ASKED PER JOURNEY, NOT ONLY ONCE OVER THE WHOLE RUN, and that is the scope this rule
833
+ * was missing until 2026-08-31. Measured on a real Next.js site: 179 of 2849 addresses were
834
+ * unsteady across the run, which is nowhere near half, so the whole-run answer was "no storm"
835
+ * and the run exited 0 saying `ok` — while four of its twelve journeys had been unsteady at
836
+ * 69%, 75% and twice 100% of their own addresses. A page where EVERY address disagreed with
837
+ * itself was folded into a passing total by nine pages that behaved. The rule was right and
838
+ * the scope was wrong: applied per journey, the same one comparison catches all four. Whoever
839
+ * calls this owes it one call per journey; `noAnswerJourneys` below is that call.
840
+ *
841
+ * A MEASUREMENT OVER NOTHING IS ALSO NOT A MEASUREMENT. Two runs that each came back with no
842
+ * addresses at all agree about everything, which arithmetically is nought unsteady and nought
843
+ * steady, and `unstable <= steady` waved it through as calm weather. That is the same false
844
+ * all-clear wearing the opposite clothes — a journey with no answer in it counted towards a
845
+ * pass — so it is answered here rather than left to whichever caller thinks to look.
846
+ *
832
847
  * @param {Wobble} wobble
833
848
  * @returns {{stormy: boolean, share: number, looked: number, vanished: number, why: string}}
834
849
  */
@@ -837,6 +852,21 @@ export function wobbleStorm(wobble) {
837
852
  const looked = unstable + wobble.steady;
838
853
  const vanished = wobble.entries.filter((e) => e.kind === 'vanished').length;
839
854
  const share = looked === 0 ? 0 : unstable / looked;
855
+ // NOTHING WAS LOOKED AT, so there is nothing here to be steady or unsteady about. A walk
856
+ // that ran, came back empty, and was run again and came back empty a second time produces
857
+ // nought unsteady out of nought — which reads as perfect agreement to every count below
858
+ // and is the emptiest possible sentence to build a pass on. Said out loud instead.
859
+ if (wobble.measured && looked === 0) {
860
+ return {
861
+ stormy: true,
862
+ share: 0,
863
+ looked: 0,
864
+ vanished: 0,
865
+ why:
866
+ 'The new build was run twice here and neither run found a single address to look at, so there was nothing to measure and nothing to compare. ' +
867
+ 'Two empty walks agree with each other about everything, which is why this used to count towards a clean run. It is not a pass and not a failure — there is no answer here.',
868
+ };
869
+ }
840
870
  // MORE OF IT WOBBLED THAN HELD STILL, and that one comparison is the whole rule. There is
841
871
  // no threshold here to tune and no number to defend: half is the point past which more of
842
872
  // the comparison has been thrown away than kept, and no answer computed from what is left
@@ -866,6 +896,121 @@ export function wobbleStorm(wobble) {
866
896
  return { stormy: true, share, looked, vanished, why };
867
897
  }
868
898
 
899
+ /**
900
+ * Which journeys have no answer in them, asked ONE AT A TIME.
901
+ *
902
+ * This is `wobbleStorm` with the scope it should always have had. The rule inside it — more
903
+ * addresses wobbled than held still — is right, and it was being asked once, of everything
904
+ * added together. Measured 2026-08-31 on a real Next.js site: 179 unsteady addresses out of
905
+ * 2849 across twelve journeys is not a storm by any reading, and underneath that total sat
906
+ * four journeys that were unsteady at 69%, 75% and twice 100% of their own addresses. Two
907
+ * whole pages where every single address disagreed with itself were folded into a passing
908
+ * run by the nine pages that behaved.
909
+ *
910
+ * Adding journeys together is what did it. Each journey is its own measurement — its own
911
+ * pages, its own two walks, its own chance of falling over — and averaging a page that told
912
+ * you nothing with nine that told you plenty produces a number that describes no page at
913
+ * all. So the wobbles come in one per journey and the answer is a list, not a share.
914
+ *
915
+ * A journey in this list is NOT a failure and NOT a pass: whatever it disagreed with itself
916
+ * about was dropped before it could be compared, so the quiet underneath it is the quiet of
917
+ * nothing having been looked at. The caller owes it a named hole in the coverage and a
918
+ * verdict that is not `ok`.
919
+ *
920
+ * @param {Wobble[]} wobbles One per journey, in the order they were walked.
921
+ * @returns {{journey: string, why: string, looked: number, unstable: number, steady: number, share: number}[]}
922
+ */
923
+ export function noAnswerJourneys(wobbles) {
924
+ /** @type {{journey: string, why: string, looked: number, unstable: number, steady: number, share: number}[]} */
925
+ const out = [];
926
+ for (const wobble of wobbles) {
927
+ // The merged record is the whole run wearing one journey's shape, and asking it here
928
+ // would put the very scope bug this function exists to fix straight back in.
929
+ if (wobble.journey === '*') continue;
930
+ const storm = wobbleStorm(wobble);
931
+ if (!storm.stormy) continue;
932
+ out.push({
933
+ journey: wobble.journey,
934
+ why: storm.why,
935
+ looked: storm.looked,
936
+ unstable: wobble.unstable.length,
937
+ steady: wobble.steady,
938
+ share: storm.share,
939
+ });
940
+ }
941
+ return out;
942
+ }
943
+
944
+ /**
945
+ * WHAT KIND of disagreement this was: the answers moving, or the addresses themselves coming
946
+ * and going between the two passes.
947
+ *
948
+ * The two are folded into one number everywhere else — `unstable` — and they are not the
949
+ * same news. An answer that changed is the product wobbling, which is what the measurement
950
+ * is for. An address that only one of the two passes ever saw is the WALK not covering the
951
+ * same ground twice, and it is why the run's own headline count moves.
952
+ *
953
+ * Measured 2026-08-31, three checks of one untouched Next.js site, minutes apart, nothing
954
+ * edited between them: 2364, 2684 and 2861 addresses looked at, and 0, 179 and 500 of them
955
+ * unsteady. A tool whose whole method is running one thing twice and subtracting what
956
+ * disagrees cannot give three answers to one question and expect to be believed. Some of
957
+ * that spread is the product being genuinely unsteady, which is the measurement working;
958
+ * the ADDRESS COUNT moving by five hundred is not the product at all. Separating the two is
959
+ * what lets the run say which is which instead of quoting one number that means neither.
960
+ *
961
+ * @param {Wobble} wobble
962
+ * @returns {{changed: number, appeared: number, vanished: number, drifted: number, bothPasses: number, steady: number, looked: number}}
963
+ */
964
+ export function wobbleShape(wobble) {
965
+ let changed = 0;
966
+ let appeared = 0;
967
+ let vanished = 0;
968
+ for (const e of wobble.entries) {
969
+ if (e.kind === 'changed') changed += 1;
970
+ else if (e.kind === 'appeared') appeared += 1;
971
+ else vanished += 1;
972
+ }
973
+ return {
974
+ changed,
975
+ appeared,
976
+ vanished,
977
+ // The addresses that exist on one pass and not the other. This is the part of the total
978
+ // that is the walk rather than the product, and the part that moves between runs.
979
+ drifted: appeared + vanished,
980
+ // The addresses BOTH passes actually reached. The steadiest count this run owns, and
981
+ // the honest one to quote.
982
+ bothPasses: wobble.steady + changed,
983
+ steady: wobble.steady,
984
+ looked: wobble.steady + wobble.unstable.length,
985
+ };
986
+ }
987
+
988
+ /**
989
+ * The sentence that names the drifting count, so no run has to invent its own wording for
990
+ * the one number readers were quoting at each other.
991
+ *
992
+ * It is said whenever the two passes of one build did not look at the same addresses. Empty
993
+ * when they did, because a sentence that appears on every single run is a sentence people
994
+ * learn to skip, and this one has to land when it is true.
995
+ *
996
+ * @param {Wobble} wobble
997
+ * @returns {string} Empty when both passes covered the same ground.
998
+ */
999
+ export function populationDriftNote(wobble) {
1000
+ if (!wobble.measured) return '';
1001
+ const shape = wobbleShape(wobble);
1002
+ if (shape.drifted === 0) return '';
1003
+ const parts = [];
1004
+ if (shape.vanished > 0) parts.push(`${shape.vanished} ${shape.vanished === 1 ? 'address' : 'addresses'} the first pass saw ${shape.vanished === 1 ? 'was' : 'were'} not there on the second`);
1005
+ if (shape.appeared > 0) parts.push(`${shape.appeared} ${shape.appeared === 1 ? 'address' : 'addresses'} turned up only on the second`);
1006
+ return (
1007
+ `THE TWO PASSES DID NOT LOOK AT THE SAME ADDRESSES: ${parts.join(', and ')}. ` +
1008
+ `That is the walk moving, not the product answering differently — ${shape.changed} ${shape.changed === 1 ? 'address' : 'addresses'} really did give two different answers. ` +
1009
+ `So the total of ${shape.looked} addresses is not a number that will be the same on the next run of the identical build; ` +
1010
+ `${shape.bothPasses} is the count both passes actually reached, and it is the one to quote.`
1011
+ );
1012
+ }
1013
+
869
1014
  /**
870
1015
  * Subtract the measured noise from the differences.
871
1016
  *
package/src/v2/run.js CHANGED
@@ -30,7 +30,9 @@ import {
30
30
  subtractWobble,
31
31
  sameValue,
32
32
  indexByPath,
33
- wobbleStorm,
33
+ noAnswerJourneys,
34
+ populationDriftNote,
35
+ wobbleShape,
34
36
  } from './observation.js';
35
37
  // `diffCaptures` is no longer called from here directly. Everything goes through
36
38
  // `compareAnswers`, which is that same comparison with one rule around it: an address where
@@ -268,6 +270,20 @@ export async function runCheck(opts) {
268
270
  const comparedJourneys = [];
269
271
  /** @type {Wobble[]} */
270
272
  const wobbles = [];
273
+ // JOURNEYS THAT PRODUCED NO ANSWER, kept as a list rather than folded into a total.
274
+ //
275
+ // This is the scope fix of 2026-08-31. The storm rule — more addresses wobbled than held
276
+ // still — was asked once, of every journey added together, and on a real Next.js site
277
+ // 179 unsteady addresses out of 2849 is not a storm by any reading. Underneath that
278
+ // total sat four journeys unsteady at 69%, 75% and twice 100% of their own addresses,
279
+ // every one of them printed in the coverage list as "could not be compared... there is
280
+ // no answer here" — while the run exited 0 and said `ok`. A page where every single
281
+ // address disagreed with itself was folded into a pass by the pages that behaved.
282
+ // Reproduced here the same day on a six-page Next.js site with two pages made unsteady
283
+ // on the server, where the browser freeze cannot reach them: four journeys at 61%, 98%,
284
+ // 98% and 100%, one timed-out walk, `ok: true`, exit code 0.
285
+ /** @type {{journey: string, why: string, looked: number}[]} */
286
+ const noAnswer = [];
271
287
  /** @type {Wobble[]} */
272
288
  const referenceWobbles = [];
273
289
  /** @type {string[]} */
@@ -312,11 +328,19 @@ export async function runCheck(opts) {
312
328
  surface: journey.surface,
313
329
  });
314
330
  }
315
- const weather = wobbleStorm(wobble);
316
- if (weather.stormy) {
331
+ // ASKED OF THIS ONE JOURNEY, and the answer is kept. The same call used to happen
332
+ // here and go nowhere but the coverage list: the gap was written, the verdict never
333
+ // read it, and the run went out `ok: true` with "there is no answer here" printed
334
+ // four times inside it. A hole nothing refuses to pass over is a hole nobody acts on.
335
+ for (const dead of noAnswerJourneys([wobble])) {
336
+ noAnswer.push({ journey: journey.describe || journey.name, why: dead.why, looked: dead.looked });
337
+ // A journey that came back with nothing at all already has its own line, three
338
+ // lines above, in words that fit it better. Saying it twice teaches the reader to
339
+ // skim the one list in this tool that must never be skimmed.
340
+ if (dead.looked === 0) continue;
317
341
  gaps.push({
318
342
  what: `"${journey.describe || journey.name}" could not be compared: the new build did not answer it the same way twice.`,
319
- why: weather.why,
343
+ why: dead.why,
320
344
  unlockedBy: 'Run it again on a quiet machine. If it happens twice, something in the product does not survive being started a second time.',
321
345
  surface: journey.surface,
322
346
  });
@@ -488,6 +512,42 @@ export async function runCheck(opts) {
488
512
  // in the gap list is a fact most readers will never meet.
489
513
  /** @type {string[]} */
490
514
  const runNotes = [];
515
+ // THE NUMBER OF ADDRESSES THIS RUN LOOKED AT IS NOT ALLOWED TO MOVE IN SILENCE.
516
+ //
517
+ // Measured 2026-08-31, three checks of one untouched Next.js site minutes apart with
518
+ // nothing edited between them: 2364, 2684 and 2861 addresses looked at, and 0, 179 and
519
+ // 500 of them unsteady. A tool whose whole method is running one thing twice and
520
+ // subtracting what disagrees cannot give three answers to one question and expect to be
521
+ // believed. Part of that spread is the product genuinely wobbling, which is exactly what
522
+ // the measurement is for and is reported as wobble. The ADDRESS COUNT moving by five
523
+ // hundred is not the product: it is the two passes of one build walking over different
524
+ // ground, because a request was cancelled on one of them, a page finished loading on one
525
+ // of them, or the walk ran out of time on one of them. Every one of those already has a
526
+ // line in this list; what had no line at all was the drift itself, so the total simply
527
+ // came out different each run with nothing anywhere saying why.
528
+ //
529
+ // It cannot be made steady from here — the causes are in the walk, not the arithmetic —
530
+ // so it is named, and the count both passes actually reached is named beside it as the
531
+ // number worth quoting. A number that moves and says nothing is worse than a smaller one
532
+ // that is honest.
533
+ const drift = populationDriftNote(wobble);
534
+ if (drift) {
535
+ const worst = wobbles
536
+ .map((w) => ({ journey: w.journey, drifted: wobbleShape(w).drifted }))
537
+ .filter((w) => w.drifted > 0)
538
+ .sort((x, y) => y.drifted - x.drifted);
539
+ const named = worst.slice(0, 4).map((w) => `${w.journey} (${w.drifted})`).join(', ');
540
+ runNotes.push(drift);
541
+ gaps.push({
542
+ what: 'The two runs of the new build did not walk over the same addresses, so the number this run says it looked at will not be the same number next time.',
543
+ why:
544
+ `${drift} Worst in ${named}${worst.length > 4 ? `, and ${worst.length - 4} more` : ''}. ` +
545
+ 'Every address that only one of the two passes reached was never compared with anything, on either side.',
546
+ unlockedBy:
547
+ 'Find what makes an address turn up on one pass and not the other — a request the browser cancels when the page is torn down, a page that only sometimes finishes loading, a walk that runs out of time — and either make it steady or take it out of what is watched. The timeouts and torn walks that cause it are named separately in this same list.',
548
+ });
549
+ }
550
+
491
551
  const kept = await remember(opts, walked);
492
552
  if (kept.why) {
493
553
  runNotes.push(
@@ -798,6 +858,16 @@ export async function runCheck(opts) {
798
858
  subtraction.newlyUnstable.length === 0 &&
799
859
  subtraction.couldNotTell !== true &&
800
860
  answersLost === 0 &&
861
+ // AND NOT ONE JOURNEY MAY HAVE COME BACK WITH NO ANSWER. `couldNotTell` above is
862
+ // this same law asked of the whole run added together, and adding is what hid it:
863
+ // 179 unsteady addresses out of 2849 is not a storm, and four of that run's twelve
864
+ // journeys were unsteady at 69%, 75% and twice 100% of their own. Whatever those
865
+ // four disagreed with themselves about was dropped before it could be compared, so
866
+ // the quiet underneath them is the quiet of nothing having been looked at — and it
867
+ // was being counted towards a pass by the journeys that behaved. Measured on a real
868
+ // Next.js site, 2026-08-31, where the run exited 0 and printed `ok` with "there is
869
+ // no answer here" written inside it four times.
870
+ noAnswer.length === 0 &&
801
871
  // AND SOMETHING HAS TO HAVE BEEN COMPARED. There is already a branch above for the
802
872
  // case where no journey had an old-build side at all; this is the same law one notch
803
873
  // finer, for the run where every journey HAD a record and every address in it holds
@@ -825,6 +895,12 @@ export async function runCheck(opts) {
825
895
  // for as long as it did is that the silence looked exactly like agreement.
826
896
  unanswered: uncompared.length,
827
897
  lost: answersLost,
898
+ // Named, not counted. "4 journeys could not be compared" sends the reader to a
899
+ // list of thirty-odd coverage lines to find out which four; the names cost one
900
+ // line and are what somebody acts on. `looked` rides along because a journey that
901
+ // disagreed with itself and a journey that saw nothing at all are two different
902
+ // pieces of news and must not be described in one borrowed sentence.
903
+ noAnswer: noAnswer.map((d) => ({ journey: d.journey, looked: d.looked })),
828
904
  }),
829
905
  startedAt,
830
906
  started,
@@ -1304,15 +1380,44 @@ function warningGaps(mode, provedLive) {
1304
1380
  * @param {BuildFingerprint} reference
1305
1381
  * @param {boolean} provedLive
1306
1382
  * @param {number} dropped Suspicions the old build turned out to have as well.
1307
- * @param {{compared: number, asked: number, addresses: number, unanswered?: number, lost?: number}} how
1383
+ * @param {{compared: number, asked: number, addresses: number, unanswered?: number, lost?: number, noAnswer?: {journey: string, looked: number}[]}} how
1308
1384
  * How much of the run this sentence covers: journeys that were really put beside the old
1309
- * build, journeys asked for, the addresses really compared, and the addresses that could
1310
- * not be compared because one side of them was a refusal rather than an answer.
1385
+ * build, journeys asked for, the addresses really compared, the addresses that could not
1386
+ * be compared because one side of them was a refusal rather than an answer, and the
1387
+ * journeys that produced no answer at all because the new build would not answer them the
1388
+ * same way twice.
1311
1389
  * @returns {string}
1312
1390
  */
1313
1391
  function summarise(findings, subtraction, warning, notes, reference, provedLive, dropped, how) {
1314
1392
  const against = provedLive ? `${nameOf(reference)}, run live` : `the stored record of ${nameOf(reference)}`;
1315
1393
  const parts = [];
1394
+ // FIRST, ALWAYS, AND BEFORE THE HEADLINE. A journey with no answer in it is the one thing
1395
+ // that must not be reachable by reading one more sentence: the headline is all some
1396
+ // readers get, and "Nothing that worked has changed" sitting on top of four journeys that
1397
+ // were never compared is the exact false all-clear this whole tool exists to refuse.
1398
+ // Until 2026-08-31 that sentence was printed, `ok` was true, and the run exited 0.
1399
+ const dead = how.noAnswer ?? [];
1400
+ if (dead.length > 0) {
1401
+ const names = dead.map((d) => d.journey);
1402
+ const empty = dead.filter((d) => d.looked === 0).length;
1403
+ const stormy = dead.length - empty;
1404
+ const because = [];
1405
+ if (stormy > 0) {
1406
+ because.push(
1407
+ `The new build disagreed with itself about most of what ${stormy === dead.length ? plural(stormy, 'that journey looks', 'those journeys look') : `${stormy} of them look`} at, so almost everything ${plural(stormy, 'it', 'they')} saw was dropped before it could be compared with anything.`,
1408
+ );
1409
+ }
1410
+ if (empty > 0) {
1411
+ because.push(
1412
+ `${empty === dead.length ? `${plural(empty, 'It was', 'They were')}` : `${empty} of them ${plural(empty, 'was', 'were')}`} walked twice and came back with nothing at all to look at.`,
1413
+ );
1414
+ }
1415
+ parts.push(
1416
+ `NO ANSWER FOR ${dead.length} OF THE ${how.asked} ${plural(how.asked, 'JOURNEY', 'JOURNEYS')} HERE: ${names.slice(0, 4).join(', ')}${names.length > 4 ? `, and ${names.length - 4} more` : ''}. ` +
1417
+ `${because.join(' ')} ` +
1418
+ `The quiet underneath ${plural(dead.length, 'it', 'them')} is the quiet of nothing having been looked at, not of nothing having changed. This is not a pass and not a failure, and the journeys that did answer do not make it one.`,
1419
+ );
1420
+ }
1316
1421
  // How much of the run this sentence is actually about. A run that compared four of its
1317
1422
  // seventeen journeys is not a run that found nothing; it is a run that mostly did not look,
1318
1423
  // and the first sentence is the only one some readers get.
@@ -1357,10 +1462,29 @@ function summarise(findings, subtraction, warning, notes, reference, provedLive,
1357
1462
  // paragraphs down in a list.
1358
1463
  const n = how.lost ?? 0;
1359
1464
  parts.push(
1360
- `Nothing that COULD be compared has changed — but ${n} ${plural(n, 'address', 'addresses')} the old build answers at could not be answered by this build at all, so ${plural(n, 'it was', 'they were')} not compared. That is coverage this build has taken away, and it is not a pass. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
1465
+ `THIS BUILD ANSWERS AT FEWER PLACES THAN THE LAST ONE. Nothing that could still be compared has changed — but ${n} ${plural(n, 'address', 'addresses')} the old build answers at could not be answered by this build at all, so ${plural(n, 'it was', 'they were')} not compared. That is coverage this build has taken away, and it is not a pass. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
1466
+ );
1467
+ } else if (findings.length === 0 && dead.length > 0) {
1468
+ // "Nothing that worked has changed" is not available to a run that could not read part
1469
+ // of itself. What IS true is said instead, with the size of the hole beside it, so the
1470
+ // sentence cannot be quoted as an all-clear by anybody who reads only this far.
1471
+ parts.push(
1472
+ `SOME OF THIS PRODUCT COULD NOT BE READ AT ALL. Nothing that could be compared has changed: ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against} — and the ${plural(dead.length, 'journey', 'journeys')} named above ${plural(dead.length, 'is', 'are')} not among them, so a break inside ${plural(dead.length, 'it', 'them')} would look exactly like this.${reach}`,
1361
1473
  );
1362
1474
  } else if (findings.length === 0) {
1363
- parts.push(`Nothing that worked has changed. ${how.addresses} ${plural(how.addresses, 'address', 'addresses')} checked against ${against}.${reach}`);
1475
+ // A WHOLE JOURNEY WITH NOTHING ON THE OTHER SIDE IS NOT A PASS, and the headline is the
1476
+ // only line some readers get. This happens for real on the day somebody upgrades: the
1477
+ // tool learns to watch something new — calling a library's exported functions, say — and
1478
+ // the record made by the old version has no answers to hold the new ones against. The run
1479
+ // said so, three sentences down, under a headline reading "Nothing that worked has
1480
+ // changed". A broken library upgraded into this state read as clean. Measured 2026-08-31.
1481
+ if (missed > 0) {
1482
+ parts.push(
1483
+ `PART OF THIS RUN HAD NOTHING TO BE COMPARED AGAINST. Nothing that could be compared has changed — but ${missed} of ${how.asked} ${plural(how.asked, 'journey', 'journeys')} had nothing on the old build's side to be held against, so ${plural(missed, 'it was', 'they were')} not checked at all, and a break inside ${plural(missed, 'it', 'them')} would look exactly like this. That is missing coverage rather than a failure — it is what a journey the old build never had looks like, and it is what an upgrade that learned to watch something new looks like. Ship once from a build you are happy with, or run with --paired, and the next check covers ${plural(missed, 'it', 'them')} properly. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
1484
+ );
1485
+ } else {
1486
+ parts.push(`Nothing that worked has changed. ${how.addresses} ${plural(how.addresses, 'address', 'addresses')} checked against ${against}.${reach}`);
1487
+ }
1364
1488
  } else {
1365
1489
  const sealed = findings.filter((f) => f.sealed).length;
1366
1490
  parts.push(