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.
- package/CHANGELOG.md +108 -2
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +43 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- 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/android.js +220 -11
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- 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 +145 -25
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +465 -47
- package/src/v2/cli.js +21 -1
- package/src/v2/coverage.js +556 -19
- package/src/v2/detect.js +742 -42
- package/src/v2/doctor.js +125 -18
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +574 -23
- 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/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +193 -27
- 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/types.js +1 -1
- 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
|
// ---------------------------------------------------------------------------
|
package/src/v2/mcp/tools.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
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.`);
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
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
|
*
|