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
package/src/v2/doctor.js
CHANGED
|
@@ -39,6 +39,8 @@ import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
|
|
|
39
39
|
import { POWERSHELL_PATHS, describeRemote } from './remote.js';
|
|
40
40
|
import { deviceToMake } from './adapters/android.js';
|
|
41
41
|
import { describeWindows } from './adapters/windows.js';
|
|
42
|
+
import { describeLinuxDesktop } from './adapters/linux.js';
|
|
43
|
+
import { describeMacos } from './adapters/macos.js';
|
|
42
44
|
import { messageOf, EXIT } from '../core/errors.js';
|
|
43
45
|
import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
|
|
44
46
|
import { loadPlaywright } from './adapters/web-driver.js';
|
|
@@ -906,7 +908,7 @@ function findDesktopApp(cwd) {
|
|
|
906
908
|
*
|
|
907
909
|
* @param {string} root
|
|
908
910
|
* @param {string|null} settingsText
|
|
909
|
-
* @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}>}
|
|
911
|
+
* @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}>}
|
|
910
912
|
*/
|
|
911
913
|
async function phoneApps(root, settingsText) {
|
|
912
914
|
// Comments taken away first, for the same reason `findDesktopApp` does it: a
|
|
@@ -923,15 +925,35 @@ async function phoneApps(root, settingsText) {
|
|
|
923
925
|
};
|
|
924
926
|
|
|
925
927
|
/**
|
|
926
|
-
* A named key
|
|
927
|
-
*
|
|
928
|
+
* A named key looked for INSIDE one settings block, never across the whole file.
|
|
929
|
+
*
|
|
930
|
+
* `remoteExe` means "the built program on that machine" under `windows:` and exactly the
|
|
931
|
+
* same thing under `linux:`, so a flat search finds one and reports it as the other — and
|
|
932
|
+
* doctor would tell somebody with a GTK app that they have a native Windows program.
|
|
933
|
+
* Written the day the Linux surface landed, 2026-08-31, before it could be true.
|
|
934
|
+
*
|
|
935
|
+
* @param {string} block
|
|
928
936
|
* @param {string} key
|
|
929
|
-
* @param {(value: string) => boolean} looksRight
|
|
937
|
+
* @param {(value: string) => boolean} [looksRight]
|
|
930
938
|
* @returns {FoundApp|null}
|
|
931
939
|
*/
|
|
932
|
-
const
|
|
933
|
-
const
|
|
934
|
-
|
|
940
|
+
const inBlock = (block, key, looksRight) => {
|
|
941
|
+
const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(settings);
|
|
942
|
+
if (!at) return null;
|
|
943
|
+
let depth = 0;
|
|
944
|
+
let end = at.index + at[0].length;
|
|
945
|
+
for (; end < settings.length; end += 1) {
|
|
946
|
+
if (settings[end] === '{') depth += 1;
|
|
947
|
+
else if (settings[end] === '}') {
|
|
948
|
+
if (depth === 0) break;
|
|
949
|
+
depth -= 1;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const inside = settings.slice(at.index + at[0].length, end);
|
|
953
|
+
const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(inside);
|
|
954
|
+
if (!found) return null;
|
|
955
|
+
if (looksRight && !looksRight(found[1])) return null;
|
|
956
|
+
return { where: found[1], how: `your settings name it under ${block}.${key}` };
|
|
935
957
|
};
|
|
936
958
|
|
|
937
959
|
/**
|
|
@@ -974,7 +996,11 @@ async function phoneApps(root, settingsText) {
|
|
|
974
996
|
// name one" about a project whose settings named one. Measured 2026-08-31 against a real
|
|
975
997
|
// TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
|
|
976
998
|
// other block can never be mistaken for this one.
|
|
977
|
-
|
|
999
|
+
// Scoped to the `ios` block, not searched across the whole file. `app` means an iPhone
|
|
1000
|
+
// bundle under `ios:` and a Mac bundle under `macos:` — both end in `.app`, so a flat
|
|
1001
|
+
// search finds one and announces the other. Written the day the Mac surface landed,
|
|
1002
|
+
// 2026-08-31, before it could be true.
|
|
1003
|
+
inBlock('ios', 'app', (v) => v.endsWith('.app')) ??
|
|
978
1004
|
named('xcworkspace') ??
|
|
979
1005
|
built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
|
|
980
1006
|
(there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
|
|
@@ -983,9 +1009,11 @@ async function phoneApps(root, settingsText) {
|
|
|
983
1009
|
? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
|
|
984
1010
|
: null);
|
|
985
1011
|
|
|
986
|
-
const windows =
|
|
1012
|
+
const windows = inBlock('windows', 'remoteExe') ?? inBlock('windows', 'exe', (v) => /\.exe$/i.test(v));
|
|
1013
|
+
const linux = inBlock('linux', 'remoteExe') ?? inBlock('linux', 'exe');
|
|
1014
|
+
const macos = inBlock('macos', 'app', (v) => v.endsWith('.app'));
|
|
987
1015
|
|
|
988
|
-
return { android, ios, windows };
|
|
1016
|
+
return { android, ios, windows, linux, macos };
|
|
989
1017
|
}
|
|
990
1018
|
|
|
991
1019
|
/**
|
|
@@ -1071,9 +1099,9 @@ function couldNotAsk(name, why) {
|
|
|
1071
1099
|
/**
|
|
1072
1100
|
* The platforms that arrive as an adapter of their own, and know their own requirements.
|
|
1073
1101
|
* The built-in five are described by hand above, because they are older than this
|
|
1074
|
-
* mechanism and their wording is tested; these
|
|
1102
|
+
* mechanism and their wording is tested; these four answer for themselves.
|
|
1075
1103
|
*/
|
|
1076
|
-
const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
|
|
1104
|
+
const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows', 'linux', 'macos'];
|
|
1077
1105
|
|
|
1078
1106
|
/**
|
|
1079
1107
|
* Ask each separate adapter what IT is missing, in its own words.
|
|
@@ -1189,7 +1217,7 @@ async function whatThisCopyCanDrive() {
|
|
|
1189
1217
|
// file that would not load, so a check on this copy is not running at all — and both
|
|
1190
1218
|
// the command line and the MCP surface say that in their own words already.
|
|
1191
1219
|
const why = `This copy could not be asked what it can drive: ${messageOf(e)}`;
|
|
1192
|
-
for (const surface of ['android', 'ios', 'windows']) out.push({ surface, present: false, why });
|
|
1220
|
+
for (const surface of ['android', 'ios', 'windows', 'linux', 'macos']) out.push({ surface, present: false, why });
|
|
1193
1221
|
}
|
|
1194
1222
|
return out;
|
|
1195
1223
|
}
|
|
@@ -1550,6 +1578,7 @@ function settingsFromText(text) {
|
|
|
1550
1578
|
// as a result: it could not see the machine the settings named, nor the built program,
|
|
1551
1579
|
// so it asked for both while both were sitting in the file.
|
|
1552
1580
|
windows: ['host', 'remoteExe', 'exe'],
|
|
1581
|
+
linux: ['host', 'remoteExe', 'exe'],
|
|
1553
1582
|
};
|
|
1554
1583
|
for (const [block, keys] of Object.entries(wanted)) {
|
|
1555
1584
|
const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
|
|
@@ -1679,7 +1708,7 @@ async function findReference(root) {
|
|
|
1679
1708
|
* @param {import('./browsers.js').BrowserSurvey} browsers
|
|
1680
1709
|
* @param {{where: string, how: string}|null} desktopApp
|
|
1681
1710
|
* @param {DriverReport[]} drivers What this copy of the tool can drive at all.
|
|
1682
|
-
* @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}} phones
|
|
1711
|
+
* @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}} phones
|
|
1683
1712
|
* @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
|
|
1684
1713
|
* @param {{commands: number, imports: number}} [wires]
|
|
1685
1714
|
* What this project's own settings wire for the command-line surface. A surface with
|
|
@@ -1728,7 +1757,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1728
1757
|
name: 'command-line tools and libraries',
|
|
1729
1758
|
status: wiredForCli ? 'ready' : 'partial',
|
|
1730
1759
|
summary: wiredForCli
|
|
1731
|
-
? '
|
|
1760
|
+
? 'What it printed, what it exited with, what it wrote, what it called out to, what it exports, and what each exported function answers when it is called with a fixed list of inputs. Not covered: any input outside that list, and any function whose name says it deletes, sends, publishes or charges — those are never called. A clean result covers what was walked; `staysfixed coverage` lists what was not.'
|
|
1732
1761
|
: configured
|
|
1733
1762
|
? 'This machine can cover it in full — what a command printed, what it exited with, what it wrote, what it called out to, what it exports — but these settings wire no command to run and nothing to import, so a check runs none of it and a clean result says nothing about any of it.'
|
|
1734
1763
|
: 'This machine can cover it in full, but nothing is set up in this folder yet, so a check cannot run here at all.',
|
|
@@ -1861,7 +1890,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1861
1890
|
: !canDrive('android')
|
|
1862
1891
|
? `An Android app is here (${phones.android.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('android')}`
|
|
1863
1892
|
: androidReady
|
|
1864
|
-
? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether
|
|
1893
|
+
? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether a snapshot restore repeats was measured on 2026-08-31: one build walked ten times with a restore between every walk, 301 of 309 addresses agreeing in all five pairs, and the eight that moved were the app's own freshly-made identity code — ordinary wobble, which this tool already subtracts. So a paired run is offered. It also needs a copy of the OLD build's package, because an APK is a build output that no checkout of the old commit contains: name it with {"reference": "path/to/the-old.apk"} under "android" in the settings. Without it the run falls back to the stored record and says so.`
|
|
1865
1894
|
: androidPartly
|
|
1866
1895
|
? `Most of your Android app can be checked: every screen another app can reach is opened and read. What is missing is ${plainList(androidMissing)}, and without ${androidMissing.length === 1 ? 'it' : 'them'} nothing is typed, pressed or saved — so a clean result covers the screens and not what the app DOES.`
|
|
1867
1896
|
: `An Android app is here (${phones.android.how}), and ${plainList(androidMissing)} ${androidMissing.length === 1 ? 'is' : 'are'} still missing. ${androidWants.every((n) => n.automatic) ? `${androidMissing.length === 1 ? 'It installs' : 'They all install'} without anybody clicking anything, so nobody needs to be asked.` : 'Some of it needs a person, and each one says what it is and what it unlocks.'}`,
|
|
@@ -2039,6 +2068,84 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
2039
2068
|
if (windowsHost && !windowsDriver) {
|
|
2040
2069
|
impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
|
|
2041
2070
|
}
|
|
2071
|
+
|
|
2072
|
+
// ── native Mac apps ───────────────────────────────────────────────────────────────────
|
|
2073
|
+
//
|
|
2074
|
+
// No remote option here, unlike Windows and Linux: the Accessibility API only answers
|
|
2075
|
+
// inside a signed-in graphical session on the machine itself, so a Mac app is checked on
|
|
2076
|
+
// the Mac it is on or not at all. And one person has to allow it once — macOS will not let
|
|
2077
|
+
// any program grant itself permission to read another app's window.
|
|
2078
|
+
const macDriver = canDrive('macos');
|
|
2079
|
+
const onAMacHere = process.platform === 'darwin';
|
|
2080
|
+
const macNeeds = asked.get('macos') ?? [];
|
|
2081
|
+
const macAllowed = onAMacHere && macDriver && !macNeeds.some((n) => /permission/i.test(String(n.what)));
|
|
2082
|
+
const macUsable = phones.macos !== null && macAllowed;
|
|
2083
|
+
surfaces.push({
|
|
2084
|
+
id: 'macos',
|
|
2085
|
+
name: 'native Mac apps',
|
|
2086
|
+
status: macUsable ? 'ready' : 'unavailable',
|
|
2087
|
+
summary: phones.macos === null
|
|
2088
|
+
? 'Nothing to check: this project names no native Mac app in its settings. Most Mac products are Electron, and those are covered over their debug port from any machine.'
|
|
2089
|
+
: !onAMacHere
|
|
2090
|
+
? 'Cannot run here: a native Mac window can only be read from a Mac.'
|
|
2091
|
+
: !macDriver
|
|
2092
|
+
? `A Mac app is named in the settings, and this copy of Stays Fixed cannot drive one. ${noDriver('macos')}`
|
|
2093
|
+
// The adapter's own paragraph, not a second one written here. It knows whether this
|
|
2094
|
+
// Mac has been allowed to read another app's window, and that changes the answer
|
|
2095
|
+
// completely.
|
|
2096
|
+
: describeMacos({ darwin: true, allowed: macAllowed }),
|
|
2097
|
+
canCheck: macUsable ? [...withoutADriver, 'meaning', 'pixels'] : [],
|
|
2098
|
+
cannotCheck: macUsable ? [] : CHANNELS.map((c) => c.id),
|
|
2099
|
+
needs: onAMacHere && macDriver && phones.macos !== null ? macNeeds : [],
|
|
2100
|
+
});
|
|
2101
|
+
if (phones.macos === null) {
|
|
2102
|
+
notInThisProject.add('macos');
|
|
2103
|
+
impossible.set('macos', 'This project names no native Mac app in its settings, so there is nothing to open. If yours is built somewhere else, name the built .app under macos.app. Most Mac products are Electron, and those are covered over their debug port from any machine.');
|
|
2104
|
+
} else if (!onAMacHere) {
|
|
2105
|
+
impossible.set('macos', 'A native Mac window can only be read from a Mac. Everything else on this list is unaffected — check the Mac app from a Mac, and let this machine cover the rest.');
|
|
2106
|
+
} else if (!macDriver) {
|
|
2107
|
+
impossible.set('macos', `${noDriver('macos')} Nothing you install on this machine changes it. Update Stays Fixed to a copy that has it.`);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// ── native Linux desktop apps ─────────────────────────────────────────────────────────
|
|
2111
|
+
//
|
|
2112
|
+
// The project is asked before the machine, the same as everywhere else: a repository with
|
|
2113
|
+
// no native Linux program named in it does not need a Linux desktop, and telling somebody
|
|
2114
|
+
// to go and find one is work that changes nothing.
|
|
2115
|
+
const linuxDriver = canDrive('linux');
|
|
2116
|
+
const linuxHost = hosts.find((h) => h.reachable && h.windows !== true);
|
|
2117
|
+
const linuxUsable = phones.linux !== null && linuxHost !== undefined && linuxDriver;
|
|
2118
|
+
surfaces.push({
|
|
2119
|
+
id: 'linux',
|
|
2120
|
+
name: 'native Linux desktop apps',
|
|
2121
|
+
status: linuxUsable ? 'partial' : 'unavailable',
|
|
2122
|
+
summary: phones.linux === null
|
|
2123
|
+
? 'Nothing to check: this project names no native Linux program in its settings. Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
|
|
2124
|
+
: !linuxDriver
|
|
2125
|
+
? `A native Linux program is named (${phones.linux.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('linux')}`
|
|
2126
|
+
: !linuxHost
|
|
2127
|
+
? nobodyWasDialled
|
|
2128
|
+
? 'No machine was dialled, so whether a Linux desktop can be reached from here is unknown. Name one under `linux: { host: "..." }` in your settings and it is asked every time, or run `staysfixed doctor --machines`.'
|
|
2129
|
+
: 'No Linux desktop is reachable from here. A native Linux window can only be read from the desktop it is running on.'
|
|
2130
|
+
// The adapter's own paragraph, never a second one written here — it knows whether
|
|
2131
|
+
// that machine has a desktop session at all, and a summary kept in this file could
|
|
2132
|
+
// only guess at it.
|
|
2133
|
+
: linuxHost.detail
|
|
2134
|
+
? describeLinuxDesktop(linuxHost.detail)
|
|
2135
|
+
: `A Linux machine answers through "${linuxHost.name}". A native Linux app is read through the accessibility bus every screen reader already uses, and nothing has to be installed there.`,
|
|
2136
|
+
canCheck: linuxUsable ? withoutADriver : [],
|
|
2137
|
+
cannotCheck: linuxUsable ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
|
|
2138
|
+
needs: linuxUsable ? (asked.get('linux') ?? []) : [],
|
|
2139
|
+
});
|
|
2140
|
+
if (phones.linux === null) {
|
|
2141
|
+
notInThisProject.add('linux');
|
|
2142
|
+
impossible.set(
|
|
2143
|
+
'linux',
|
|
2144
|
+
'This project has no native Linux program named in its settings, so there is nothing to open on a Linux desktop. '
|
|
2145
|
+
+ 'If yours is built somewhere else, name it under linux.remoteExe (already on that machine) or linux.exe (copied over each run). '
|
|
2146
|
+
+ 'Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2042
2149
|
if (!windowsHost) {
|
|
2043
2150
|
impossible.set(
|
|
2044
2151
|
'windows',
|
|
@@ -2119,9 +2226,9 @@ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
|
|
|
2119
2226
|
if (!setUpHere) {
|
|
2120
2227
|
out.canRunHere = false;
|
|
2121
2228
|
parts.push('Nothing is set up in this folder, so a check cannot run here at all and would cover nothing. Run `staysfixed init` first.');
|
|
2122
|
-
parts.push(full.length > 0 ? `Once it is set up, this machine
|
|
2229
|
+
parts.push(full.length > 0 ? `Once it is set up, this machine can drive ${plainList(out.covered)}.` : 'Even set up, this machine can drive none of these.');
|
|
2123
2230
|
} else {
|
|
2124
|
-
parts.push(full.length > 0 ? `A check here
|
|
2231
|
+
parts.push(full.length > 0 ? `A check here can drive ${plainList(out.covered)}. That is what this machine is able to walk, not what any run has walked.` : 'A check here can drive none of these.');
|
|
2125
2232
|
}
|
|
2126
2233
|
if (some.length > 0) parts.push(`It covers ${plainList(some.map((s) => s.name))} only partly — read the summary for each before treating a clean result as proof.`);
|
|
2127
2234
|
if (missing.length > 0) {
|
package/src/v2/escalate.js
CHANGED
|
@@ -437,7 +437,7 @@ export async function rememberCheck(store, what) {
|
|
|
437
437
|
*
|
|
438
438
|
* @typedef {object} Escalation
|
|
439
439
|
* @property {string} id
|
|
440
|
-
* @property {'sealed'|'budget'|'unpredictable'|'blocked'|'no-reference'} kind
|
|
440
|
+
* @property {'sealed'|'difference'|'budget'|'unpredictable'|'blocked'|'no-reference'} kind
|
|
441
441
|
* @property {string} what What changed.
|
|
442
442
|
* @property {string} why Why no agent could wave it through.
|
|
443
443
|
* @property {string} todo What to do about it.
|
|
@@ -500,11 +500,26 @@ export async function escalationsFor(store, product) {
|
|
|
500
500
|
* @returns {Escalations}
|
|
501
501
|
*/
|
|
502
502
|
function buildEscalations(product, record, verdict) {
|
|
503
|
+
// TWO PILES, AND THE ORDER BETWEEN THEM IS THE FIX.
|
|
504
|
+
//
|
|
505
|
+
// `real` is everything that is the product behaving differently, or the check not having
|
|
506
|
+
// happened at all. `steadiness` is the one item that is neither: addresses that used to
|
|
507
|
+
// give the same answer every run and now do not. That is worth a person's attention and it
|
|
508
|
+
// is NOT a difference — nothing in it has a wrong value.
|
|
509
|
+
//
|
|
510
|
+
// WHAT WENT WRONG, 2026-08-31. A run caught one real change correctly and also measured 242
|
|
511
|
+
// addresses as newly unsteady. Only the second reached this block, because an ordinary
|
|
512
|
+
// difference has never been put in here at all — so the only sentence the owner read told
|
|
513
|
+
// him to hold the release over a wobble measurement, and never mentioned the change the
|
|
514
|
+
// tool had actually found. Piling them separately makes that ordering structural instead of
|
|
515
|
+
// accidental, and `crowdedOut` below makes sure a real change is never the thing left out.
|
|
503
516
|
/** @type {Escalation[]} */
|
|
504
|
-
const
|
|
517
|
+
const real = [];
|
|
518
|
+
/** @type {Escalation[]} */
|
|
519
|
+
const steadiness = [];
|
|
505
520
|
|
|
506
521
|
if (verdict.blocked === true) {
|
|
507
|
-
|
|
522
|
+
real.push({
|
|
508
523
|
id: 'blocked',
|
|
509
524
|
kind: 'blocked',
|
|
510
525
|
what: `Stays Fixed could not check ${product} at all on this run, so nothing about it has been proved either way.`,
|
|
@@ -512,7 +527,7 @@ function buildEscalations(product, record, verdict) {
|
|
|
512
527
|
todo: `Something is in the way and it needs clearing — the run said: ${oneLine(verdict.summary, 200)}`,
|
|
513
528
|
});
|
|
514
529
|
} else if (!verdict.reference || verdict.reference.id === '') {
|
|
515
|
-
|
|
530
|
+
real.push({
|
|
516
531
|
id: 'no-reference',
|
|
517
532
|
kind: 'no-reference',
|
|
518
533
|
what: `There is no build of ${product} on record as working yet, so this run had nothing to compare against.`,
|
|
@@ -527,7 +542,7 @@ function buildEscalations(product, record, verdict) {
|
|
|
527
542
|
|
|
528
543
|
for (const f of record.findings) {
|
|
529
544
|
if (f.unwaivable !== true) continue;
|
|
530
|
-
|
|
545
|
+
real.push({
|
|
531
546
|
id: f.id,
|
|
532
547
|
kind: 'sealed',
|
|
533
548
|
what: oneLine(f.title, 220),
|
|
@@ -542,7 +557,7 @@ function buildEscalations(product, record, verdict) {
|
|
|
542
557
|
}
|
|
543
558
|
|
|
544
559
|
if (record.accounting.left === 0 && record.accounting.reported > record.accounting.unwaivable) {
|
|
545
|
-
|
|
560
|
+
real.push({
|
|
546
561
|
id: 'budget',
|
|
547
562
|
kind: 'budget',
|
|
548
563
|
what: `The agent has used all ${record.accounting.budget} of the differences it is allowed to record as intended on ${product}, and there are still differences left over.`,
|
|
@@ -553,7 +568,7 @@ function buildEscalations(product, record, verdict) {
|
|
|
553
568
|
|
|
554
569
|
if (record.newlyUnstable.length > 0) {
|
|
555
570
|
const n = record.newlyUnstable.length;
|
|
556
|
-
|
|
571
|
+
steadiness.push({
|
|
557
572
|
id: 'unpredictable',
|
|
558
573
|
kind: 'unpredictable',
|
|
559
574
|
what: `${n} ${n === 1 ? 'thing in' : 'things in'} ${product} used to give the same answer every single run and now ${n === 1 ? 'does' : 'do'} not: ${record.newlyUnstable.slice(0, 3).join(', ')}${n > 3 ? ', and more' : ''}.`,
|
|
@@ -561,15 +576,36 @@ function buildEscalations(product, record, verdict) {
|
|
|
561
576
|
todo: 'Have it looked into before shipping. Something in the change made the product unpredictable.',
|
|
562
577
|
paths: record.newlyUnstable.slice(0, 6),
|
|
563
578
|
});
|
|
579
|
+
// A REAL CHANGE IS NEVER THE THING LEFT OUT.
|
|
580
|
+
//
|
|
581
|
+
// An ordinary difference is the agent's problem and does not get its own item — that is
|
|
582
|
+
// deliberate, and it stops a handful of items a month becoming a feed nobody reads. But it
|
|
583
|
+
// stops being right the moment the ONLY thing in this block is a wobble measurement,
|
|
584
|
+
// because then the sentence a person reads is an alarm about a non-difference with no
|
|
585
|
+
// mention of the difference the tool did find. So a real change gets one line here, and it
|
|
586
|
+
// goes first, exactly when it would otherwise have been the thing crowded out.
|
|
587
|
+
const crowdedOut = record.findings.filter((f) => f.waivedBy === undefined && f.unwaivable !== true);
|
|
588
|
+
if (real.length === 0 && crowdedOut.length > 0) {
|
|
589
|
+
const worst = crowdedOut[0];
|
|
590
|
+
real.push({
|
|
591
|
+
id: 'differences',
|
|
592
|
+
kind: 'difference',
|
|
593
|
+
what: `${product} behaves differently from the build you were happy with in ${crowdedOut.length} ${crowdedOut.length === 1 ? 'place' : 'places'}: ${oneLine(worst.title, 180)}${crowdedOut.length > 1 ? ', and more' : ''}.`,
|
|
594
|
+
why: 'This is a real change in the product, which is what the check is for — it is named before the steadiness note below so it cannot be read past.',
|
|
595
|
+
todo: 'The agent has to deal with each one: fix it, or record it as intended and say why. Nothing is the new normal until you ship.',
|
|
596
|
+
paths: (worst.paths ?? []).slice(0, 6),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
564
599
|
}
|
|
565
600
|
|
|
601
|
+
const all = [...real, ...steadiness];
|
|
566
602
|
return {
|
|
567
603
|
product,
|
|
568
604
|
at: record.at,
|
|
569
|
-
items,
|
|
605
|
+
items: all,
|
|
570
606
|
waived: record.accounting.waived,
|
|
571
607
|
expiredWaivers: record.accounting.expiredWaivers,
|
|
572
|
-
note: summaryNote(product,
|
|
608
|
+
note: summaryNote(product, all, record),
|
|
573
609
|
};
|
|
574
610
|
}
|
|
575
611
|
|
|
@@ -595,11 +631,21 @@ function sealedTodo(f) {
|
|
|
595
631
|
* @returns {string}
|
|
596
632
|
*/
|
|
597
633
|
function summaryNote(product, items, record) {
|
|
634
|
+
// NOTHING NEEDING YOUR WORD IS NOT THE SAME AS NOTHING BEING WRONG.
|
|
635
|
+
//
|
|
636
|
+
// This line used to read "nothing on X needs your word" on a run that had found real
|
|
637
|
+
// differences the agent had not dealt with — true about who has to decide, and read by
|
|
638
|
+
// anybody skimming as an all-clear. One clause fixes it, and it is added rather than the
|
|
639
|
+
// sentence replaced, because who has to decide is still the thing this block is about.
|
|
640
|
+
const left = record.accounting.reported;
|
|
641
|
+
const outstanding = left > 0
|
|
642
|
+
? ` The agent still has ${left} ${left === 1 ? 'difference' : 'differences'} of its own to deal with on this build.`
|
|
643
|
+
: '';
|
|
598
644
|
if (items.length === 0) {
|
|
599
645
|
const waived = record.accounting.waived;
|
|
600
646
|
return waived > 0
|
|
601
|
-
? `Stays Fixed: nothing on ${product} needs your word
|
|
602
|
-
: `Stays Fixed: nothing on ${product} needs your word
|
|
647
|
+
? `Stays Fixed: nothing on ${product} needs your word.${outstanding} ${waived} ${waived === 1 ? 'difference was' : 'differences were'} recorded as intended by the agent and ${waived === 1 ? 'is' : 'are'} waiting on your next ship.`
|
|
648
|
+
: `Stays Fixed: nothing on ${product} needs your word.${outstanding}`;
|
|
603
649
|
}
|
|
604
650
|
return `Stays Fixed: ${items.length} ${items.length === 1 ? 'thing needs' : 'things need'} your word on ${product}.`;
|
|
605
651
|
}
|