staysfixed 0.11.1 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
@@ -35,6 +35,18 @@ import path from 'node:path';
35
35
  /** The shape of a journeys file on disk. Version it, because a format nobody versioned is a format nobody can change. */
36
36
  export const JOURNEY_FILE_VERSION = 2;
37
37
 
38
+ /**
39
+ * Where recordings live, relative to the project.
40
+ *
41
+ * One name, because three readers used to spell it out for themselves — the gatherer, the
42
+ * check path and the recording command — and three copies of a folder name is three chances
43
+ * for a recording to be written where nothing looks for it, which reads afterwards exactly
44
+ * like a recording that was never made.
45
+ *
46
+ * It is committed, not ignored: a recording is the promise, not the evidence.
47
+ */
48
+ export const RECORDINGS_DIR = path.join('.staysfixed', 'journeys');
49
+
38
50
  // ---------------------------------------------------------------------------
39
51
  // Keeping secrets out
40
52
  // ---------------------------------------------------------------------------
@@ -493,13 +493,13 @@ export function toolDefinitions() {
493
493
  journeys: {
494
494
  type: 'string',
495
495
  description:
496
- "Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels. 'suite' walks the project's own test suite as well: each test file runs twice inside the scratch copy, every check is reported by name, and it stops after 90 seconds naming each file it did not reach. It catches breaks nothing else can - a rounding change the product's own output never shows. It is opt-in because running a stranger's whole suite twice on every check is not something to do by default. You can also pass a path to a journeys file naming steps by hand. 'recorded' (replay a recorded session) is written and not yet wired into a run: ask for it and it says so rather than checking something else.",
496
+ "Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels. 'suite' walks the project's own test suite as well: each test file runs twice inside the scratch copy, every check is reported by name, and it stops after 90 seconds naming each file it did not reach. It catches breaks nothing else can - a rounding change the product's own output never shows. It is opt-in because running a stranger's whole suite twice on every check is not something to do by default. 'recorded' replays the sessions somebody recorded with `staysfixed record` and kept in .staysfixed/journeys - the only source that knows which four screens a person actually opens every morning, which reading the source cannot work out. You can also pass a path to a journeys file naming steps by hand.",
497
497
  },
498
498
  surface: {
499
499
  type: 'string',
500
- enum: ['auto', 'cli', 'library', 'server', 'web', 'electron', 'android', 'ios'],
500
+ enum: ['auto', 'cli', 'library', 'server', 'web', 'electron', 'android', 'ios', 'extension'],
501
501
  description:
502
- "What kind of product to aim at. Default 'auto', which uses the settings. 'web' opens the page in a browser of the tool's own — never yours — and reads what the screen says each control is and does. 'electron' opens the desktop app with its own scratch data folder and drives it over its own debugging port. 'android' installs the APK on a virtual device; 'ios' boots the built app on a simulator. Aim it at something this copy or this machine cannot drive and it refuses by name rather than checking something else and reporting that.",
502
+ "What kind of product to aim at. Default 'auto', which uses the settings. 'web' opens the page in a browser of the tool's own — never yours — and reads what the screen says each control is and does. 'electron' opens the desktop app with its own scratch data folder and drives it over its own debugging port. 'android' installs the APK on a virtual device; 'ios' boots the built app on a simulator. 'extension' loads a browser extension into a throwaway browser: its manifest is read as a contract, its popup and options pages are walked like any other page, and what its content scripts do to somebody else's page is measured by opening that page with the extension and without it and comparing the difference — point 'extension.dir' in the settings at the folder you would load unpacked. Aim it at something this copy or this machine cannot drive and it refuses by name rather than checking something else and reporting that.",
503
503
  },
504
504
  at: {
505
505
  type: 'string',
@@ -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: {
@@ -934,19 +937,12 @@ async function toolCheck(ctx, input) {
934
937
  const limit = positive(input.limit) ?? DEFAULT_LIMIT;
935
938
  const offset = positive(input.offset) ?? 0;
936
939
 
937
- // A value the engine does not understand must be refused BY NAME, never passed down.
938
- //
939
- // `suite` is now wired and reaches the harvest. `recorded` is still written and called by
940
- // nothing, so passing it down would reach the engine as the name of a FILE and come back as
941
- // "there is no journeys file at .../recorded" — an error that sends an agent looking for a
942
- // file it never asked for. Refusing it by name and saying why is the honest answer, and a
943
- // clean result about the wrong steps would be worse than no result.
940
+ // Every word this tool offers now reaches something that walks. `suite` harvests the
941
+ // project's own tests; `recorded` replays the sessions in `.staysfixed/journeys`, which was
942
+ // written and wired to nothing until 2026-08-31 and was refused here by name because of it.
943
+ // A project with no recordings comes back BLOCKED from the engine, naming the folder and the
944
+ // command that makes one - never as a clean result about steps nobody walked.
944
945
  const wantedJourneys = text(input.journeys);
945
- if (wantedJourneys === 'recorded') {
946
- return problem(
947
- 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, pass "suite" to walk your own test suite, or pass the path to a journeys file.'
948
- );
949
- }
950
946
 
951
947
  const surface = text(input.surface);
952
948
  const at = text(input.at);
@@ -1519,6 +1515,22 @@ async function toolExplain(ctx, input) {
1519
1515
  } else if (deep && typeof deep.text === 'string') {
1520
1516
  out.push('');
1521
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
+ );
1522
1534
  }
1523
1535
 
1524
1536
  content.push({ type: 'text', text: out.join('\n') });
@@ -1577,7 +1589,10 @@ async function toolProve(ctx, input) {
1577
1589
  return engineMissing(
1578
1590
  engine,
1579
1591
  'prove',
1580
- '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.',
1581
1596
  voice
1582
1597
  );
1583
1598
  }
@@ -1593,23 +1608,174 @@ async function toolProve(ctx, input) {
1593
1608
 
1594
1609
  /** @type {any} */
1595
1610
  const result = (await run({ cwd: ctx.root, finding: id, revert })) ?? {};
1596
- 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);
1597
1629
 
1598
1630
  /** @type {string[]} */
1599
1631
  const out = [];
1600
- if (gone) {
1601
- out.push(`PROVEN: your change caused it. With ${revert.join(', ')} put back, this matched the reference again.`);
1602
- 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'));
1603
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.`);
1604
1651
  } else {
1605
- out.push(`NOT PROVEN: it survived the revert. With ${revert.join(', ')} put back, this was still different.`);
1606
- out.push(` ${trim(f.title, 200)}`);
1607
- 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.`);
1608
1675
  }
1609
- if (result.detail) out.push('', trim(String(result.detail), 600));
1610
- out.push('', 'The working tree has been put back exactly as it was.');
1611
1676
 
1612
- 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;
1613
1779
  }
1614
1780
 
1615
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
  *