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.
- package/CHANGELOG.md +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
package/src/v2/mcp/tools.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1599
|
-
|
|
1600
|
-
out.push(
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/v2/observation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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:
|
|
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,
|
|
1310
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|