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/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,111 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
### The self-check corpus knows about the five false all-clears found on 2026-08-31
|
|
10
|
+
|
|
11
|
+
Five defects were found and fixed that day, each of which could have blessed a broken build,
|
|
12
|
+
and not one of them would have been caught by the corpus that is supposed to make that
|
|
13
|
+
impossible. There is a case for each now, and every one of them was made to fail against the
|
|
14
|
+
code as it stood before its fix before it was believed.
|
|
15
|
+
|
|
16
|
+
- **A build that crashes on its first line is no longer a build that agrees with itself.** Two
|
|
17
|
+
builds that crash the same way used to agree at every address, and agreement is the silence
|
|
18
|
+
this tool reads as "nothing changed".
|
|
19
|
+
- **Coverage this build took away is never handed back as a pass.** The engine decided that
|
|
20
|
+
correctly and the bookkeeping then assigned the verdict rather than narrowing it.
|
|
21
|
+
- **A standard cut from a tree git does not have is never walked out of git.** The old build
|
|
22
|
+
was exported from the commit, walked, and a real regression was dropped as something it does
|
|
23
|
+
too.
|
|
24
|
+
- **With nothing edited, nothing can be a change.** The build being checked and the build on
|
|
25
|
+
record were one build, and whatever flickered between two runs was reported as a change
|
|
26
|
+
nobody made.
|
|
27
|
+
- **What "working" means moves only when somebody ships.** Re-running a check on the shipped
|
|
28
|
+
build quietly replaced the standard with whatever that run happened to see.
|
|
29
|
+
|
|
30
|
+
### A guard can hand `evaluate` a function
|
|
31
|
+
|
|
32
|
+
`page.evaluate` took JavaScript as text and nothing else. Passing it a function — the obvious
|
|
33
|
+
thing, and what every other tool in this space accepts — answered `The app refused the request
|
|
34
|
+
"Runtime.evaluate": Invalid parameters`, which is the debug protocol talking about its own wire
|
|
35
|
+
format to somebody who had done nothing wrong. It now takes either, and the two things it
|
|
36
|
+
genuinely cannot run — a built-in, and a function that asks for an argument it has no way to
|
|
37
|
+
be given — say so in a sentence with an example in it.
|
|
38
|
+
|
|
39
|
+
## [0.12.0] — 2026-08-31
|
|
40
|
+
|
|
41
|
+
Five capabilities that were missing rather than broken. Each was built by a lane working on
|
|
42
|
+
its own, each had to prove itself against a real product before it was believed, and three of
|
|
43
|
+
the five turned up a false all-clear on the way in.
|
|
44
|
+
|
|
45
|
+
### Record yourself using it once, and it is checked for ever
|
|
46
|
+
|
|
47
|
+
Everything this tool checked before, it worked out by READING — routes out of the source,
|
|
48
|
+
exports out of a package, screens out of a router. That finds what the code says it does. It
|
|
49
|
+
never found the way somebody actually uses the thing. `staysfixed record <a-name>` now opens
|
|
50
|
+
your product, follows what you do, and writes it down.
|
|
51
|
+
|
|
52
|
+
- **A click that moves the page is written down as the CLICK, not the destination.** Writing
|
|
53
|
+
the destination would open that page whether or not the button works, so a broken button
|
|
54
|
+
would have come back clean. That is the whole feature failing quietly, and it is guarded.
|
|
55
|
+
- **A recording is walked twice before it is accepted.** Anything that differs between the two
|
|
56
|
+
walks is not a step, it is noise, and the recording is refused rather than kept. A recording
|
|
57
|
+
that never got through its own steps even once is refused too — both walks failing
|
|
58
|
+
identically would otherwise read as perfectly steady.
|
|
59
|
+
- A project that asks for recorded sessions and has none is reported as not checked, never as
|
|
60
|
+
a pass.
|
|
61
|
+
|
|
62
|
+
### Phones compare two builds side by side
|
|
63
|
+
|
|
64
|
+
Both phone surfaces refused a paired run because nobody knew whether an emulator or simulator
|
|
65
|
+
comes back the same twice. Nobody had ever measured it. Measured now, one build walked ten
|
|
66
|
+
times with a restore between every walk:
|
|
67
|
+
|
|
68
|
+
- **iOS: 725 of 725 addresses agreed across five pairs.**
|
|
69
|
+
- **Android: 301 of 309.** The eight that moved were the app's own freshly-made identity code
|
|
70
|
+
— ordinary wobble, which this tool already subtracts. A control run with the restore switched
|
|
71
|
+
off produced the same eight and no others, so the restore contributed exactly zero.
|
|
72
|
+
|
|
73
|
+
So paired is on for both, and every sentence that repeated the old guess now carries the
|
|
74
|
+
number instead. The real obstacle turned out to be somewhere else entirely: a paired phone run
|
|
75
|
+
needs a kept copy of the OLD build's APK or `.app`, because a build output is in no checkout of
|
|
76
|
+
the old commit. That is the new `reference` setting — and pointing it at the SAME file as the
|
|
77
|
+
new build is caught and reported as a hole on every journey, rather than comparing one build
|
|
78
|
+
with itself and calling it unchanged.
|
|
79
|
+
|
|
80
|
+
Also fixed on the way: `simctl` lists the newest iPhone first and the adapter took the last
|
|
81
|
+
one, asking for an iPhone 6s on iOS 27 — which Apple refuses with an empty message. The whole
|
|
82
|
+
iPhone surface was dark on a Mac that had everything it needed.
|
|
83
|
+
|
|
84
|
+
### Three new kinds of product
|
|
85
|
+
|
|
86
|
+
- **Native Mac apps** (Swift or Objective-C, not Electron). Read through the Accessibility API
|
|
87
|
+
by JavaScript handed to macOS's own `osascript` — nothing is installed. Two builds of an
|
|
88
|
+
AppKit app differing by one line came back as exactly that one difference out of 75 things
|
|
89
|
+
read. **Two copies of one Mac app make one of them stop answering, and a silent app looks
|
|
90
|
+
exactly like an app with no controls** — so every read is cross-checked against the window
|
|
91
|
+
server's own list of what is on screen, and runs are one at a time.
|
|
92
|
+
- **Native Linux desktop apps** (GTK or Qt). Read over ssh through the accessibility bus every
|
|
93
|
+
screen reader already uses, with nothing installed on that machine: 190 controls in 305ms,
|
|
94
|
+
the same cost as the Windows surface. A machine with nobody logged in has no accessibility
|
|
95
|
+
bus at all and is told so, rather than reported as an app with no controls.
|
|
96
|
+
- **Browser extensions** (Chromium). The manifest is read as a contract — a permission that
|
|
97
|
+
appears, or a host permission widening from one named site to every site, is a change
|
|
98
|
+
somebody has to agree to, and that half needs no browser. Then the popup and options pages
|
|
99
|
+
are walked, and what a content script does to somebody else's page is measured by opening
|
|
100
|
+
that page with the extension and without it. What the background worker logs and asks the
|
|
101
|
+
network for is a named hole on every run, because both happen before anything can listen.
|
|
102
|
+
|
|
103
|
+
### False all-clears found while building the above
|
|
104
|
+
|
|
105
|
+
- **The browser launcher passes `--disable-extensions` unconditionally**, and Chrome accepts
|
|
106
|
+
that alongside `--load-extension` without a word and loads nothing. Anything routed through
|
|
107
|
+
it would have walked a browser with no extension in it and reported everything as unchanged.
|
|
108
|
+
- **The settings lookup for a built program searched the whole file, not its own block.** `app`
|
|
109
|
+
means an iPhone bundle under `ios:` and a Mac bundle under `macos:`; `remoteExe` means the
|
|
110
|
+
same thing under `windows:` and `linux:`. Every one of them is scoped now, before the new
|
|
111
|
+
surfaces could make it true.
|
|
112
|
+
- **A `reference` pointing at the same file as the new build** compares a build with itself,
|
|
113
|
+
finds nothing, and says nothing changed. Caught by real path, on every journey.
|
|
10
114
|
|
|
11
115
|
## [0.11.1] — 2026-08-31
|
|
12
116
|
|
|
@@ -64,7 +168,9 @@ adapter and tests turned out to hold at least one defect that only running it co
|
|
|
64
168
|
record is filed under a fingerprint of the tree that was walked, because the files checked
|
|
65
169
|
are not the files git has. Paired mode walked the commit and called it the old build, so an
|
|
66
170
|
address the record holds a real value for was reported as *"is there now and was not
|
|
67
|
-
before"*.
|
|
171
|
+
before"*. Asking for a paired run against such a reference is now refused outright, naming
|
|
172
|
+
the reason and what to do instead, rather than quietly giving you something weaker; an
|
|
173
|
+
ordinary check still falls back to the stored record and says so.
|
|
68
174
|
|
|
69
175
|
### It reported things as checked that were never checked
|
|
70
176
|
|
package/README.md
CHANGED
|
@@ -163,13 +163,17 @@ pretend otherwise.
|
|
|
163
163
|
| The coverage ledger — every door counted, the unopened ones named, and the sentence saying so on every reply | **Works.** See [what it did not check](#what-it-did-not-check). |
|
|
164
164
|
| Aiming a check at one kind of product, and refusing by name rather than checking something else | **Works.** |
|
|
165
165
|
| Steps taken from your own test suite | **Works.** `--journeys suite` runs each test file twice inside the scratch copy, reports every check by name and why each failure failed, and stops after 90 seconds naming every file it did not reach. It is opt-in: running a stranger's whole suite twice on every check is not a thing to do by default. It catches what nothing else can — remove the penny-rounding from a `total()` and the product's own output does not move by one character, the discovered journeys say "nothing has changed", and the harvest names the check that turned red. |
|
|
166
|
-
| Steps taken from a recorded session, or rejected at birth for not repeating twice | **
|
|
167
|
-
| Android APKs on an emulator | **The adapter is here.** It reads everything the APK declares with nothing installed and no Java, and where there is an emulator it installs one build at a time and walks it. Whether *this* machine can run one is a separate question, and `doctor` asks the adapter itself rather than keeping a second opinion — most of what it wants installs with a command; accepting Google's licence, once, needs a person. Two
|
|
168
|
-
| The iOS simulator | **The adapter is here.** It reads what the app bundle declares with nothing running, and where Xcode and a simulator runtime are present it installs one build at a time, boots it and reads what is on the screen. It is new. Paired running costs two `xcodebuild` passes, so it is for before a release rather than for every edit, and
|
|
166
|
+
| Steps taken from a recorded session, or rejected at birth for not repeating twice | **Works.** `staysfixed record <name>` opens your product, follows what you do in it, and writes the session down; `--journeys recorded` walks it on every later check. It is the one source that knows which four screens a person actually opens every morning, which no amount of reading the source can work out. Nothing is kept until it has been walked twice against the same build: anything that differs between those two walks is noise, not a step, and the recording is refused with the reason. Passwords and tokens are taken out on the way to the file. Proved end to end on a two-page site — break the second page and the journeys read out of the code say "nothing that worked has changed", because nothing in the source names that page; the recorded session names the sentence that changed. |
|
|
167
|
+
| Android APKs on an emulator | **The adapter is here.** It reads everything the APK declares with nothing installed and no Java, and where there is an emulator it installs one build at a time and walks it. Whether *this* machine can run one is a separate question, and `doctor` asks the adapter itself rather than keeping a second opinion — most of what it wants installs with a command; accepting Google's licence, once, needs a person. Two restores of one build were measured on 2026-08-31: 301 of 309 addresses agreed across five pairs, and the eight that moved were the app's own identity code, which the wobble subtraction already removes. So a paired run is offered — give it a kept copy of the old build's APK with `reference` under `android`, because a checkout of the old commit holds no build output. |
|
|
168
|
+
| The iOS simulator | **The adapter is here.** It reads what the app bundle declares with nothing running, and where Xcode and a simulator runtime are present it installs one build at a time, boots it and reads what is on the screen. It is new. Paired running costs two `xcodebuild` passes, so it is for before a release rather than for every edit, and two restores of one build were measured on 2026-08-31 on an iOS 27.0 simulator and 725 of 725 addresses agreed across five pairs, so a paired run is offered — it needs a kept copy of the old build's `.app` (`reference` under `ios`), because a checkout of the old commit holds no build output. Ask `doctor` what it is actually covering on your machine before trusting a clean run. |
|
|
169
169
|
| Native Windows GUI (a real Win32 app, not an Electron one) | **Works,** and it has now been driven end to end: a real Win32 window on a Windows 11 desktop reached over ssh, 10 addresses read out of the UI Automation tree, a reference cut, and the next run compared against it. Nothing was installed on that machine — the program that reads the screen is sent down the connection each run. Windows shows one desktop, so two builds can never run at once: the comparison is genuinely weaker here than anywhere else, and a run says so rather than hiding it. |
|
|
170
|
+
| Browser extensions (Chromium) | **Works.** The manifest is read as a contract — a permission that appears, or a host permission that widens from one named site to every site, is reported as a change somebody has to agree to, and that half needs no browser at all. Then it loads the extension into a throwaway browser, walks its popup and options pages for what the screen means, and measures what its content scripts do to somebody else's page by opening that page with the extension and without it and comparing the two. Nothing reaches the internet. It does not read what the background worker logs or what it asks the network for — both happen in the instant the browser starts it, before anything can listen — and it says so on every run. |
|
|
171
|
+
| Native Mac GUI (a Swift or Objective-C app, not an Electron one) | **Works,** and it has been driven end to end on a real Mac: two builds of an AppKit app differing by one line, 75 addresses read out of the Accessibility API each, and exactly the one changed checkbox reported. Nothing is installed — the program that reads the screen is JavaScript handed to macOS's own `osascript`. One person has to allow it once under System Settings, Privacy & Security, Accessibility; macOS will not let any program grant itself that. One build at a time: two copies of one Mac app make one of them stop answering, which looks exactly like an app with no controls, so every read is cross-checked against the window server's own list. |
|
|
172
|
+
| Native Linux desktop GUI (GTK or Qt, not an Electron one) | **Works,** and it has been driven end to end: a real GTK window on an Ubuntu 24.04 desktop reached over ssh, every control read out of the accessibility bus every screen reader already uses, two runs of one build with zero unstable addresses, then a build with one checkbox ticked and one button disabled reported as exactly two differences and nothing else. Nothing was installed on that machine. A desktop shows one screen, so two builds can never run at once. A machine with nobody logged in has no accessibility bus at all, and is told so rather than reported as an app with no controls. |
|
|
170
173
|
|
|
171
|
-
All
|
|
172
|
-
Android, iOS
|
|
174
|
+
All eleven surfaces — command-line tools, libraries, servers, websites, browser
|
|
175
|
+
extensions, Electron, Android, iOS, native Windows, native Linux and native Mac — have now
|
|
176
|
+
actually been run against a real
|
|
173
177
|
product, rather than only having an adapter and tests. That sentence was not true
|
|
174
178
|
before 2026-08-31, and the four that were unproven each turned out to have at
|
|
175
179
|
least one defect that only running them could find.
|
|
@@ -283,15 +287,59 @@ under instrumentation → recorded real sessions → the agent exploring one nam
|
|
|
283
287
|
gap and freezing it into a replayable file → never a person clicking through an
|
|
284
288
|
app.
|
|
285
289
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
Each adapter reads your source and offers the journeys it can walk: routes,
|
|
291
|
+
commands, screens, message channels. `--journeys <file>` names steps by hand.
|
|
292
|
+
`--journeys suite` runs each test file twice inside the scratch copy, reports every
|
|
293
|
+
check by name, and stops after 90 seconds naming every file it did not reach.
|
|
294
|
+
`--journeys recorded` walks the sessions you made with `staysfixed record` — the
|
|
295
|
+
one source that knows how a person actually uses the product, because every other
|
|
296
|
+
one worked it out by reading, and reading only ever finds which doors exist, never
|
|
297
|
+
which four somebody opens every morning. A recording is walked twice against the
|
|
298
|
+
same build before it is kept, and refused with the reason if the two walks
|
|
299
|
+
disagree. The flake register is written and tested and **nothing on the check path
|
|
300
|
+
calls it yet**. Saying so is the point: a feature that exists in the repository and
|
|
301
|
+
not in the run is not a feature you have.
|
|
302
|
+
|
|
303
|
+
### Recording the session you actually perform
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
staysfixed record the-morning-round
|
|
307
|
+
staysfixed check --journeys recorded
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The first command opens your product, follows what you do in it, and writes the
|
|
311
|
+
session into `.staysfixed/journeys/`. Close the window when you are done. Commit
|
|
312
|
+
the file: it is the promise, not the evidence.
|
|
313
|
+
|
|
314
|
+
It is the answer to the one thing reading your source can never do. The code says
|
|
315
|
+
which doors exist; it never says which four you open every morning, in which
|
|
316
|
+
order, with what in the boxes. Tell it once and it is checked for ever. On a
|
|
317
|
+
two-page site whose second page is named nowhere in the source, breaking that page
|
|
318
|
+
reports **nothing** through the journeys read out of the code — the run says
|
|
319
|
+
"nothing that worked has changed" — and the recorded session names the sentence
|
|
320
|
+
that changed.
|
|
321
|
+
|
|
322
|
+
Nothing is kept until it repeats. The session is walked twice against the same
|
|
323
|
+
build, by the same walker a later check uses, before a byte of it reaches the
|
|
324
|
+
disk; if the two walks disagree about what exists, or it could not get through its
|
|
325
|
+
own steps, it is refused and told why. A journey that argues with itself goes red
|
|
326
|
+
on somebody else's laptop for no reason, and a check nobody trusts is worse than
|
|
327
|
+
no check.
|
|
328
|
+
|
|
329
|
+
Two details worth knowing. Passwords, tokens, card numbers and one-time codes are
|
|
330
|
+
taken out on the way to the file, and the count of what was hidden is written into
|
|
331
|
+
it. And a page move that a click caused is **not** written down as a step — a
|
|
332
|
+
journey that clicked a link and then opened that address would open the second
|
|
333
|
+
page whether or not the link still worked, which is exactly the false all-clear
|
|
334
|
+
this tool exists to never give.
|
|
335
|
+
|
|
336
|
+
Two limits, said plainly. `staysfixed record` follows you around a **web** app, in
|
|
337
|
+
a browser of the tool's own; nothing yet follows your hands around a desktop or a
|
|
338
|
+
phone, though `--journeys recorded` walks a recording of whatever surface its file
|
|
339
|
+
names. And a recorded journey is the weakest of the sources — one path somebody
|
|
340
|
+
happened to take, which goes stale when the interface moves — so anything reachable
|
|
341
|
+
from your code or your test suite should come from there instead. Before a run
|
|
342
|
+
walks one, it says by name what in it may not replay.
|
|
295
343
|
|
|
296
344
|
## Keeping it quiet
|
|
297
345
|
|
|
@@ -934,11 +982,14 @@ Honestly, so you know before you invest an afternoon.
|
|
|
934
982
|
here.** No paired run is possible on a device in your hand: two builds cannot
|
|
935
983
|
exist on one handset at once. Real iPhones and real Android handsets fall back
|
|
936
984
|
to comparing against the stored record, and say so out loud on every run. The
|
|
937
|
-
emulator and the simulator are the honest answer, and both of them
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
985
|
+
emulator and the simulator are the honest answer, and both of them can be
|
|
986
|
+
paired. That was measured on 2026-08-31: iOS agreed at 725 of 725 addresses
|
|
987
|
+
across five pairs, Android at 301 of 309, and the eight Android ones that moved
|
|
988
|
+
were the app's own identity code — noise the wobble subtraction already removes.
|
|
989
|
+
What a paired phone run needs from you is a kept copy of the old build's APK or
|
|
990
|
+
`.app`, named with `reference` under `android` or `ios`, because a build output
|
|
991
|
+
is not in any checkout of the old commit. Without it the run falls back to the
|
|
992
|
+
stored record and says so on every journey. If you ship a phone app, ask `doctor` what it is actually covering before
|
|
942
993
|
you trust a clean result.
|
|
943
994
|
- **It is not every possible state.** "Deep" means every door the code exposes and
|
|
944
995
|
every journey it was given. Nothing can enumerate every state, and any tool
|
|
@@ -955,6 +1006,13 @@ Honestly, so you know before you invest an afternoon.
|
|
|
955
1006
|
It earns the ask: take the penny-rounding out of a `total()` and the product's
|
|
956
1007
|
own output does not move by one character — the discovered journeys say nothing
|
|
957
1008
|
has changed, and the harvest names the check that turned red.
|
|
1009
|
+
- **A recorded session is the weakest of the sources, and it says so.** It is one
|
|
1010
|
+
path somebody happened to take, and it goes stale the moment the interface it
|
|
1011
|
+
describes moves — so anything reachable from the code or from your test suite
|
|
1012
|
+
comes from there instead. Before a run walks one it says what in it may not
|
|
1013
|
+
replay, by name. What it buys is the one thing the other sources cannot reach:
|
|
1014
|
+
the way a person actually uses the product, which no amount of reading can work
|
|
1015
|
+
out.
|
|
958
1016
|
- **No hosted service, no dashboard, no accounts, no teams, nothing paid.** It is
|
|
959
1017
|
a command and a folder of files in your repository.
|
|
960
1018
|
- **Pictures still do not travel between operating systems.** Text is drawn
|
package/docs/design-v2.md
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
> servers, source reading, the web, Electron, Android, iOS and native Windows over ssh. The
|
|
18
18
|
> MCP surface is seven tools and it is what `staysfixed mcp` serves.
|
|
19
19
|
>
|
|
20
|
-
> **What is written and not wired:**
|
|
21
|
-
>
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
>
|
|
20
|
+
> **What is written and not wired:** nothing. Replaying a recorded session was the last one
|
|
21
|
+
> and it landed on 2026-08-31: `staysfixed record` opens the product, follows what a person
|
|
22
|
+
> does, walks the session twice against the same build before keeping it, and `--journeys
|
|
23
|
+
> recorded` walks it on every later check. Harvesting a project's own test suite was in this
|
|
24
|
+
> list too and is wired — `--journeys suite`, opt-in, held to a 90-second budget, every file
|
|
25
|
+
> it did not reach named. Journeys otherwise come from what each adapter reads out of your
|
|
26
|
+
> source, plus a journeys file you point it at.
|
|
26
27
|
>
|
|
27
28
|
> **What is permanent and will not change:** nothing irreversible is ever run — it is watched
|
|
28
29
|
> at the call and refused at the effect, and the refusal is reported as missing coverage;
|
|
@@ -174,7 +175,7 @@ The FlaUI probe over SSH to his office box. Built only if he ever ships a non-El
|
|
|
174
175
|
|
|
175
176
|
## Honest limits
|
|
176
177
|
|
|
177
|
-
WHERE "EVERY PLATFORM, NO COMPROMISE" DOES NOT HOLD. Web, Electron, CLI, libraries and servers get the full paired treatment. Android and the iOS SIMULATOR get it
|
|
178
|
+
WHERE "EVERY PLATFORM, NO COMPROMISE" DOES NOT HOLD. Web, Electron, CLI, libraries and servers get the full paired treatment. Android and the iOS SIMULATOR get it too — the assumption each was resting on was measured on 2026-08-31 and held: iOS agreed at 725 of 725 addresses across five pairs of restores, Android at 301 of 309, and the eight Android ones that moved were the app's own freshly-made identity code, which the wobble subtraction already removes. What a paired phone run needs instead is a kept copy of the OLD build's APK or .app, because a build output is not in any checkout of the old commit. Real iPhones and real Android handsets do not — no paired run is possible on a device he is holding. Windows native GUI cannot run two builds at once even in principle, because UI Automation reads whatever desktop is in front. And any product whose old build can no longer be compiled — a yanked dependency, a dead toolchain — falls back to comparing against the stored record from the last time it ran. That fallback is genuinely weaker: it reintroduces every cross-day difference the paired design exists to eliminate. It must announce itself in those words on every run, not degrade quietly.
|
|
178
179
|
|
|
179
180
|
WHAT "DEEP" HONESTLY MEANS. Every door the code exposes, and every journey the existing test suite already walks. It is NOT every possible state — nothing can enumerate that, and any tool claiming otherwise is lying. The defensible claim is: it catches breaks reachable from the journeys it has, and the coverage ledger names the doors it has never opened, so the hole is visible instead of pretended away.
|
|
180
181
|
|
package/docs/getting-started.md
CHANGED
|
@@ -88,9 +88,11 @@ What comes back:
|
|
|
88
88
|
finds there — routes, commands, screens, message channels — and `--journeys <file>` names
|
|
89
89
|
steps by hand. `--journeys suite` adds their own test suite: each test file runs twice inside
|
|
90
90
|
the scratch copy, every check is reported by name, and it stops after 90 seconds naming every
|
|
91
|
-
file it did not reach.
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
file it did not reach. `--journeys recorded` walks the sessions they recorded with
|
|
92
|
+
`staysfixed record` — the one source that knows which screens a person actually opens, which
|
|
93
|
+
reading their source cannot work out. Ask for it in a project with no recordings and the run
|
|
94
|
+
stops and says so; nothing quietly substitutes different steps and hands you a clean answer
|
|
95
|
+
about them.
|
|
94
96
|
|
|
95
97
|
### 3. Take the first reading
|
|
96
98
|
|
package/docs/guards.md
CHANGED
|
@@ -126,6 +126,24 @@ export default {
|
|
|
126
126
|
| `app.read(file)` | Read a project file as text. |
|
|
127
127
|
| `app.project` | The resolved config and paths. |
|
|
128
128
|
|
|
129
|
+
### Running your own JavaScript in the page
|
|
130
|
+
|
|
131
|
+
`page.evaluate` takes it either way round — as a piece of JavaScript written as
|
|
132
|
+
text, or as a function it will call inside the app:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
const title = await app.page.evaluate('document.title');
|
|
136
|
+
const title = await app.page.evaluate(() => document.title);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The function is run with nothing passed to it, so one that asks for an argument
|
|
140
|
+
is refused rather than quietly handed `undefined`. Close over what it needs, or
|
|
141
|
+
write the value into the JavaScript itself:
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
await app.page.evaluate(`document.title === ${JSON.stringify(expected)}`);
|
|
145
|
+
```
|
|
146
|
+
|
|
129
147
|
---
|
|
130
148
|
|
|
131
149
|
## The `expect` style
|
package/docs/how-v2-works.md
CHANGED
|
@@ -126,16 +126,39 @@ Ranked, because this is the real workload question:
|
|
|
126
126
|
4. **The agent exploring one named gap** and freezing it into a replayable file.
|
|
127
127
|
5. Never a person clicking through an app.
|
|
128
128
|
|
|
129
|
-
`--journeys <source>` picks between them
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
129
|
+
`--journeys <source>` picks between them. A run walks (1) by default: each adapter
|
|
130
|
+
reads your source and offers what it finds there. `--journeys <file>` names steps
|
|
131
|
+
by hand. `--journeys suite` adds (2), the project's own test suite — each file run
|
|
132
|
+
twice inside the scratch copy, every check reported by name, held to a 90-second
|
|
133
|
+
budget with every file it did not reach named. `--journeys recorded` adds (3), the
|
|
134
|
+
sessions somebody made with `staysfixed record` and kept in `.staysfixed/journeys`.
|
|
135
|
+
|
|
136
|
+
**Why (3) is worth having at all.** Everything above it was worked out by READING:
|
|
137
|
+
routes out of the source, exported names out of a package, screens out of a router.
|
|
138
|
+
Reading finds what the code says it does, and it is completely blind to the way a
|
|
139
|
+
person actually uses the thing — the four screens they open every morning, in that
|
|
140
|
+
order, with that data. A recorded session is the only channel that can learn that,
|
|
141
|
+
and once learned it is checked for ever. On a two-page site whose second page is
|
|
142
|
+
named nowhere in the source, breaking that page reports **nothing** through the
|
|
143
|
+
journeys read out of the code and is caught immediately by the recorded one.
|
|
144
|
+
|
|
145
|
+
**Nothing is recorded that has not repeated.** `staysfixed record` walks a fresh
|
|
146
|
+
session twice against the same build before it writes a single byte, using the very
|
|
147
|
+
same walker a later check uses, and refuses it if the two walks disagree about what
|
|
148
|
+
exists or if it could not get through the steps at all. A journey that argues with
|
|
149
|
+
itself would go red on somebody else's laptop for no reason, and version 1 already
|
|
150
|
+
proved where that ends: a flaky check does not get fixed, it gets ignored.
|
|
151
|
+
|
|
152
|
+
One trap is worth knowing about, because it is the shape of a false all-clear. When
|
|
153
|
+
a person clicks a link, the browser moves. Writing both down — click the link, then
|
|
154
|
+
open that address — makes a journey that opens the second page whether or not the
|
|
155
|
+
link still works, so a broken button comes back clean. A page move that arrives
|
|
156
|
+
right after something the person did is therefore dropped: it is the result of the
|
|
157
|
+
act, and the act is the step.
|
|
158
|
+
|
|
159
|
+
Asking for recorded sessions in a project that has none stops the run and says so,
|
|
160
|
+
naming the folder and the command that makes one. It never falls back to the steps
|
|
161
|
+
read out of the source and calls that a pass.
|
|
139
162
|
|
|
140
163
|
---
|
|
141
164
|
|
|
@@ -286,6 +309,16 @@ RUN`, and the reason is in the coverage as a hole. This is not a tolerance — n
|
|
|
286
309
|
number here decides whether any single difference is real. It decides one thing:
|
|
287
310
|
whether this run has earned the right to use the word clean.
|
|
288
311
|
|
|
312
|
+
**It is asked once per journey, not once per run.** Asking it only of everything
|
|
313
|
+
added together is how a page that told you nothing gets averaged away by nine
|
|
314
|
+
pages that behaved: 179 unsteady addresses out of 2849 is not a storm, while four
|
|
315
|
+
of that run's twelve journeys were unsteady at 69%, 75% and twice 100% of their
|
|
316
|
+
own. `noAnswerJourneys(wobbles)` is the same one comparison asked journey by
|
|
317
|
+
journey, and any journey it names takes the pass away and is listed by name in the
|
|
318
|
+
summary's first sentence. A journey walked twice that came back with no addresses
|
|
319
|
+
at all is in that list too — two empty walks agree about everything, and that is
|
|
320
|
+
not a measurement.
|
|
321
|
+
|
|
289
322
|
### A finding
|
|
290
323
|
|
|
291
324
|
A cluster of differences that share a cause, which is what the agent reads:
|
package/docs/mcp.md
CHANGED
|
@@ -178,10 +178,12 @@ out of your source. `"suite"` walks the project's own test suite as well — eac
|
|
|
178
178
|
test file run twice inside the scratch copy, every check reported by name and why
|
|
179
179
|
each failure failed, stopping after 90 seconds with every file it did not reach
|
|
180
180
|
named. It is worth asking for when a change could break behaviour the product's own
|
|
181
|
-
output would never show.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
output would never show. `"recorded"` walks the sessions a person made with
|
|
182
|
+
`staysfixed record` and kept in `.staysfixed/journeys` — the one source that knows
|
|
183
|
+
which screens somebody actually opens, which reading the source cannot work out. Ask
|
|
184
|
+
for it in a project with no recordings and the reply comes back blocked, naming the
|
|
185
|
+
folder and the command that makes one, rather than giving a clean result about steps
|
|
186
|
+
something quietly chose instead. You can also pass a path to a journeys file.
|
|
185
187
|
|
|
186
188
|
---
|
|
187
189
|
|
package/docs/settings.md
CHANGED
|
@@ -406,12 +406,15 @@ anything behind in them.
|
|
|
406
406
|
## `android` — an APK on an emulator
|
|
407
407
|
|
|
408
408
|
Installed on an emulator of its own, walked, then removed. Compared against the
|
|
409
|
-
stored record
|
|
410
|
-
and
|
|
409
|
+
stored record, or paired against the old build if you keep a copy of it. A snapshot
|
|
410
|
+
restore was measured on 2026-08-31 and repeats: 301 of 309 addresses agreed across
|
|
411
|
+
five pairs, and the eight that moved were the app's own freshly-made identity code,
|
|
412
|
+
which the wobble subtraction already removes. Every run says which mode it used.
|
|
411
413
|
|
|
412
414
|
| Option | What it does |
|
|
413
415
|
| --- | --- |
|
|
414
416
|
| `apk` | The built package. Left out, it looks in the usual build folders. |
|
|
417
|
+
| `reference` | A kept copy of the OLD build's package, for a paired run. An APK is a build output, so a checkout of the old commit has none. Also accepts `{ apk: '...' }`. Left out, a paired run falls back to the stored record and says so. |
|
|
415
418
|
| `avd` | Which emulator to use. Left out, it takes the first one that is **not** a Play Store image. |
|
|
416
419
|
| `serial` | A device already plugged in, or an emulator already running. |
|
|
417
420
|
| `journeys[]` / `screens[]` | Walks through the app: `{ name, steps: [{ tap: 'Sign in' }] }`. Left out, it opens the app and reads the first screen. |
|
|
@@ -459,9 +462,15 @@ Installed on a simulator, opened, and read — the same roles, names and states
|
|
|
459
462
|
person hears read out to them. One build at a time. macOS only: nothing on Linux
|
|
460
463
|
or Windows can run an iOS simulator.
|
|
461
464
|
|
|
465
|
+
A paired run is offered: two restores of one build were measured on 2026-08-31 on
|
|
466
|
+
an iOS 27.0 simulator and 725 of 725 addresses agreed across five pairs. It needs a
|
|
467
|
+
kept copy of the old build's `.app`, because a build output is not in any checkout
|
|
468
|
+
of the old commit.
|
|
469
|
+
|
|
462
470
|
| Option | What it does |
|
|
463
471
|
| --- | --- |
|
|
464
472
|
| `app` | The built app bundle for the simulator. Left out, it looks where builds land. |
|
|
473
|
+
| `reference` | A kept copy of the OLD build's app bundle, for a paired run. A `.app` is a build output, so a checkout of the old commit has none. Also accepts `{ app: '...' }`. Left out, a paired run falls back to the stored record and says so. |
|
|
465
474
|
| `deviceType`, `runtime` | Which simulator and which system: `'iPhone 17'`, `'iOS 26.4'`. Left out it picks a sensible one and says which. |
|
|
466
475
|
| `device` | An exact simulator that already exists, by name or id. |
|
|
467
476
|
| `journeys[]` | Walks through the app: `{ name, steps: [{ tap: 'Sign in' }] }`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers \u2014 as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/approve.js
CHANGED
|
@@ -16,7 +16,10 @@ import { EXIT } from '../core/errors.js';
|
|
|
16
16
|
* @returns {Promise<number>}
|
|
17
17
|
*/
|
|
18
18
|
export async function run(ctx) {
|
|
19
|
-
|
|
19
|
+
// `opening: false` — approving moves a picture the last check already took. It launches
|
|
20
|
+
// nothing. On a project with no screen there is simply never anything waiting, and saying
|
|
21
|
+
// that in one line is the true answer; refusing to load the settings at all was not.
|
|
22
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
20
23
|
const all = ctx.bool('all');
|
|
21
24
|
const names = ctx.args.filter((a) => a.trim() !== '');
|
|
22
25
|
|
package/src/cli/flake.js
CHANGED
|
@@ -17,7 +17,10 @@ import { EXIT } from '../core/errors.js';
|
|
|
17
17
|
* @returns {Promise<number>}
|
|
18
18
|
*/
|
|
19
19
|
export async function run(ctx) {
|
|
20
|
-
|
|
20
|
+
// `opening: false` — the register is a file on disk and reading it opens nothing. Without
|
|
21
|
+
// this, the one command whose whole job is to name unreliable checks was itself refused on
|
|
22
|
+
// every command-line tool and library this tool sets up. Measured 2026-08-31.
|
|
23
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
21
24
|
const history = await loadHistory(project.paths.historyFile);
|
|
22
25
|
|
|
23
26
|
const forgive = ctx.str('clear');
|
package/src/cli/mark.js
CHANGED
|
@@ -13,7 +13,11 @@ import { StaysFixedError, EXIT } from '../core/errors.js';
|
|
|
13
13
|
* @returns {Promise<number>}
|
|
14
14
|
*/
|
|
15
15
|
export async function run(ctx) {
|
|
16
|
-
|
|
16
|
+
// `opening: false` — a marker is a commit, a date and a set of fingerprints written to a
|
|
17
|
+
// file. Nothing is opened and nothing is photographed, so a project with no screen can pin
|
|
18
|
+
// a known-good version exactly like any other. It was refused on every one of them until
|
|
19
|
+
// 2026-08-31, which took `trace` down with it: there was nothing to trace back to.
|
|
20
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
17
21
|
|
|
18
22
|
if (ctx.bool('list')) return showAll(project);
|
|
19
23
|
|
package/src/cli/status.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { loadProject } from '../core/config.js';
|
|
9
9
|
import { projectStatus } from '../run.js';
|
|
10
10
|
import { printStatus } from '../report/console.js';
|
|
11
|
+
import { say, blank, heading, paint } from '../core/log.js';
|
|
11
12
|
import { EXIT } from '../core/errors.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -56,8 +57,59 @@ export function versionTwoState(root) {
|
|
|
56
57
|
* @returns {Promise<number>}
|
|
57
58
|
*/
|
|
58
59
|
export async function run(ctx) {
|
|
59
|
-
|
|
60
|
+
// `opening: false` — this command reads files and prints them. It says so in its own first
|
|
61
|
+
// line. Loading the settings through the check that insists on something to open made the
|
|
62
|
+
// fastest command in the tool refuse outright on every command-line tool, library and
|
|
63
|
+
// server it had just set up, with a paragraph about an `app` key version 2 never writes.
|
|
64
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
60
65
|
const status = await projectStatus(project);
|
|
61
66
|
printStatus(/** @type {any} */ ({ ...status, v2: versionTwoState(project.paths?.root ?? ctx.cwd) }));
|
|
67
|
+
printWhatIsSetUp(project.config);
|
|
62
68
|
return EXIT.ok;
|
|
63
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What these settings actually cover, in one short list.
|
|
73
|
+
*
|
|
74
|
+
* The promise of this command is "what is set up here", and on a project with no screen the
|
|
75
|
+
* picture counters answer it with four zeroes — every one of them true, and together they
|
|
76
|
+
* read as "nothing is set up" about a project whose settings name three commands and two
|
|
77
|
+
* folders of source. So the settings are read back in the words they were written in.
|
|
78
|
+
*
|
|
79
|
+
* Everything here is read out of the settings file. Nothing is inferred and nothing is
|
|
80
|
+
* counted that is not there, because a status line that overstates what is covered is the
|
|
81
|
+
* one kind of wrong this tool cannot afford.
|
|
82
|
+
*
|
|
83
|
+
* @param {import('../types.js').ResolvedConfig} config
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
function printWhatIsSetUp(config) {
|
|
87
|
+
const any = /** @type {Record<string, any>} */ (/** @type {unknown} */ (config));
|
|
88
|
+
/** @type {string[]} */
|
|
89
|
+
const lines = [];
|
|
90
|
+
|
|
91
|
+
const commands = Array.isArray(any.process?.commands) ? any.process.commands.filter((/** @type {any} */ c) => c && !c.skip) : [];
|
|
92
|
+
const imports = Array.isArray(any.process?.imports) ? any.process.imports : [];
|
|
93
|
+
if (commands.length > 0) lines.push(`${commands.length} ${commands.length === 1 ? 'command' : 'commands'} to run and compare word for word`);
|
|
94
|
+
if (imports.length > 0) lines.push(`${imports.length} ${imports.length === 1 ? 'library entry' : 'library entries'} to import and compare what ${imports.length === 1 ? 'it exports' : 'they export'}`);
|
|
95
|
+
if (any.source && typeof any.source === 'object') {
|
|
96
|
+
const folders = Array.isArray(any.source.folders) ? any.source.folders : [];
|
|
97
|
+
lines.push(folders.length > 0 ? `the code in ${folders.join(', ')}, read without running it` : 'the code, read without running it');
|
|
98
|
+
}
|
|
99
|
+
if (any.http && typeof any.http === 'object') lines.push('a server, booted on a spare port and asked for every route');
|
|
100
|
+
if (any.web && typeof any.web === 'object') {
|
|
101
|
+
const screens = Array.isArray(any.web.screens) ? any.web.screens.length : 0;
|
|
102
|
+
lines.push(screens > 0 ? `a website, and ${screens} ${screens === 1 ? 'screen' : 'screens'} of it` : 'a website');
|
|
103
|
+
}
|
|
104
|
+
if (any.electron && typeof any.electron === 'object') lines.push('a desktop app');
|
|
105
|
+
if (any.android && typeof any.android === 'object') lines.push('an Android app');
|
|
106
|
+
if (any.ios && typeof any.ios === 'object') lines.push('an iPhone app');
|
|
107
|
+
if (any.windows && typeof any.windows === 'object') lines.push('a native Windows app');
|
|
108
|
+
|
|
109
|
+
if (lines.length === 0) return;
|
|
110
|
+
heading('What these settings cover');
|
|
111
|
+
for (const line of lines) say(` ${paint.grey('·')} ${line}`);
|
|
112
|
+
blank();
|
|
113
|
+
say(paint.grey(` Run ${paint.cyan('staysfixed check')} to walk it, and ${paint.cyan('staysfixed coverage')} for what a run did NOT look at.`));
|
|
114
|
+
blank();
|
|
115
|
+
}
|