staysfixed 0.4.0 → 0.6.1
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 +170 -3
- package/README.md +92 -6
- package/package.json +1 -1
- package/src/v2/adapters/android-driver.js +8 -1
- package/src/v2/adapters/android.js +36 -4
- package/src/v2/adapters/contract.js +84 -6
- package/src/v2/adapters/electron.js +5 -5
- package/src/v2/adapters/http.js +7 -6
- package/src/v2/adapters/process.js +17 -6
- package/src/v2/adapters/source.js +62 -11
- package/src/v2/adapters/web.js +6 -6
- package/src/v2/cause.js +15 -8
- package/src/v2/check.js +121 -23
- package/src/v2/cli.js +16 -3
- package/src/v2/coverage.js +9 -1
- package/src/v2/detect.js +1 -1
- package/src/v2/doctor.js +15 -3
- package/src/v2/journeys/from-routes.js +3 -1
- package/src/v2/observation.js +62 -1
- package/src/v2/remote.js +4 -5
- package/src/v2/run.js +125 -9
- package/src/v2/sealed.js +10 -6
- package/src/v2/selfcheck.js +197 -32
- package/src/v2/types.js +6 -0
- package/src/v2/watch/events.js +1087 -0
- package/src/v2/watch/focus.js +215 -0
- package/src/v2/watch/index.js +382 -0
- package/src/v2/watch/panel.js +1660 -0
- package/src/v2/watch/window.js +1671 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,173 @@ All notable changes to this project are recorded here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the version
|
|
5
5
|
numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.6.1] — 2026-08-30
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Documentation only, and it is a correction rather than a polish. The README
|
|
10
|
+
described where journeys come from in the words of the design — read the code,
|
|
11
|
+
then harvest the project's own test suite, then recorded sessions — and only the
|
|
12
|
+
first of those is wired into `staysfixed check`. The suite harvest, session
|
|
13
|
+
recording and the flake register are written and tested in `src/v2/journeys/` and
|
|
14
|
+
nothing on the check path calls them. Both places that implied otherwise now say
|
|
15
|
+
so plainly. A feature that exists in the repository and not in the run is not a
|
|
16
|
+
feature you have.
|
|
17
|
+
|
|
18
|
+
## [0.6.0] — 2026-08-30
|
|
19
|
+
|
|
20
|
+
The second sweep for silences, done the same way as the first: read all of
|
|
21
|
+
`src/v2` looking for anything that can drop something and then report nothing
|
|
22
|
+
found. Five more were there, and two of them were in the safety machinery
|
|
23
|
+
itself — the gate that decides what an agent may never wave through, and the
|
|
24
|
+
proof that decides whether a change explains a break.
|
|
25
|
+
|
|
26
|
+
Every fix below has a test that fails against yesterday's code and passes
|
|
27
|
+
against today's. That was checked by putting the old behaviour back, one line at
|
|
28
|
+
a time, in a copy of the repository, and watching each test go red.
|
|
29
|
+
|
|
30
|
+
### Fixed — silences, second sweep
|
|
31
|
+
|
|
32
|
+
- **Only the first 80 differences of a finding were read when deciding whether it
|
|
33
|
+
was one of the five things nobody may wave through.** A cluster can hold
|
|
34
|
+
hundreds of addresses; a refund sitting at address 150 of 300 was classified
|
|
35
|
+
ordinary, which means an agent could waive it and it would never reach a
|
|
36
|
+
person. Every difference is read now. A cap on the one gate that cannot have
|
|
37
|
+
one is not a performance decision, it is a hole.
|
|
38
|
+
- **The causal proof re-checked five addresses and then spoke for all of them.**
|
|
39
|
+
A finding of three hundred where the first five went away came back "caused by
|
|
40
|
+
that change" — a machine-checked reason for an agent to close a break it had
|
|
41
|
+
only half explained. It re-checks every address now, and when a change explains
|
|
42
|
+
part of a finding and not the rest it says exactly that, with both numbers. It
|
|
43
|
+
costs nothing: the journeys were already re-walked and each extra address is one
|
|
44
|
+
lookup.
|
|
45
|
+
- **A wobble big enough to swallow the comparison was still subtracted, and the
|
|
46
|
+
run came back clean.** If the second run of the new build falls over, or the
|
|
47
|
+
product writes hash-named files, or stamps a fresh id on every line, most of its
|
|
48
|
+
addresses are unsteady, nearly every difference is dropped before anything is
|
|
49
|
+
compared, and the verdict reads "nothing that already worked has changed". A
|
|
50
|
+
build that disagrees with itself about more than half of its own addresses now
|
|
51
|
+
gets **no verdict**: the summary opens with `NO ANSWER FROM THIS RUN`, the
|
|
52
|
+
reason is a named hole in the coverage, and `ok` is false. No number here
|
|
53
|
+
decides whether any difference is real — it decides only whether the run has
|
|
54
|
+
earned the word clean.
|
|
55
|
+
- **Two facts written at one address lost one of them, silently.** Every index in
|
|
56
|
+
the engine keeps the first observation at a path, so a second one is never
|
|
57
|
+
compared with anything and a door that broke behind it is invisible. The
|
|
58
|
+
detector for this was written on the first day of v2 and nothing ever called it.
|
|
59
|
+
Every walk is checked now and each clash is named — which address, and what the
|
|
60
|
+
ignored answer was. Identical repeats are not reported, because they hide
|
|
61
|
+
nothing.
|
|
62
|
+
- **The route reader skipped a folder it could not open, and every route behind
|
|
63
|
+
it, without a word.** This is the same bug as the one fixed yesterday in the
|
|
64
|
+
file walk, in a second function that the first fix did not touch. It names the
|
|
65
|
+
folder now.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- A twelfth case in the self-check corpus, and a third kind of expectation with
|
|
70
|
+
it: a product so unsteady that the comparison is thrown away before it happens,
|
|
71
|
+
where the only honest answer is that the run says nothing. Confirmed to fail
|
|
72
|
+
against 0.5.0 and pass against this. Measured after the change: twelve of
|
|
73
|
+
twelve, three times running, with the project's own suite in parallel and the
|
|
74
|
+
machine's load average between 208 and 343.
|
|
75
|
+
- Six more tests in `test/v2/silences.test.js`, one per fix above, each confirmed
|
|
76
|
+
red against the previous code.
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
|
|
80
|
+
- `readFileRoutes(root)` returns `{ doors, problems }` rather than an array. The
|
|
81
|
+
problems are folders it could not open, and they are reported as missing
|
|
82
|
+
coverage by every caller.
|
|
83
|
+
|
|
84
|
+
### Known, and written down rather than fixed
|
|
85
|
+
|
|
86
|
+
- What a normalisation rule rubbed out is not itemised per run. The receipt
|
|
87
|
+
exists in the code and nothing calls it. The rules are in your repository and
|
|
88
|
+
the capture is stamped with which set it used, so a comparison across a rule
|
|
89
|
+
change is announced — but a broad rule is still how you go blind on purpose.
|
|
90
|
+
- Ranking reads up to 4,000 source files of up to 400KB. Past that a finding is
|
|
91
|
+
still reported and still counted; it may just not sort where it deserves to.
|
|
92
|
+
Nothing is dropped for being far from the change.
|
|
93
|
+
|
|
94
|
+
## [0.5.0] — 2026-08-30
|
|
95
|
+
|
|
96
|
+
The release about the one way this tool can be catastrophically wrong: reporting
|
|
97
|
+
nothing while something is broken. Five separate places could do that, and one of
|
|
98
|
+
them could do it permanently.
|
|
99
|
+
|
|
100
|
+
### Fixed — silences
|
|
101
|
+
|
|
102
|
+
Each of these produced a clean-looking run while something was invisible.
|
|
103
|
+
|
|
104
|
+
- **A `git diff` too big to read was treated as no diff at all**, so a large
|
|
105
|
+
uncommitted change was fingerprinted as a clean checkout of the commit it sat
|
|
106
|
+
on. If that commit was the reference, the check was comparing the build against
|
|
107
|
+
itself, and a comparison of a build with itself can only ever come back clean.
|
|
108
|
+
The diff is now streamed into a hash with no ceiling, and a git command that
|
|
109
|
+
fails is refused loudly rather than read as "nothing has changed".
|
|
110
|
+
- **A folder with no git in it** gave every build the same identity, with the same
|
|
111
|
+
consequence, quietly and for ever. It is now refused, with a sentence saying
|
|
112
|
+
why.
|
|
113
|
+
- **The two ends of a huge output were kept with only a rough size**, so a break
|
|
114
|
+
in the middle that was thrown away left a byte-identical record. The exact byte
|
|
115
|
+
count is kept now, and a truncated value is reported as missing coverage rather
|
|
116
|
+
than passed over.
|
|
117
|
+
- **An adapter that threw while listing what it would walk had its journeys
|
|
118
|
+
dropped in silence** — a whole surface disappearing from a run whose verdict
|
|
119
|
+
then read "nothing that worked has changed". It is recorded as a hole, by name.
|
|
120
|
+
- **A folder the source reader could not open was skipped without a word**, taking
|
|
121
|
+
every door behind it. It is named now, along with any file still too big to read.
|
|
122
|
+
- **The normalisation-rule stamp on a stored capture was written and never read.**
|
|
123
|
+
Comparing a record tidied up under one set of rules against a run tidied up
|
|
124
|
+
under another produces differences that are about the rules; the run now says so.
|
|
125
|
+
|
|
126
|
+
### Changed — how long something took is recorded and never compared
|
|
127
|
+
|
|
128
|
+
A stopwatch on a shared machine measures how busy the machine is at least as much
|
|
129
|
+
as it measures the product. Measured on the corpus's own fixture: thirty runs of
|
|
130
|
+
the same one-line program on an idle Mac took 48ms to 96ms, against a bucket
|
|
131
|
+
boundary at 100ms — four milliseconds of headroom, so any load at all crossed it.
|
|
132
|
+
That is what made the self-check come back "1 of 9 wrong" one busy evening and
|
|
133
|
+
then pass five times in a row on a quiet machine.
|
|
134
|
+
|
|
135
|
+
Durations are still measured and still shown, in the sentence beside the address,
|
|
136
|
+
where a person can read them. They are no longer differenced, on any platform.
|
|
137
|
+
**Stays Fixed will not tell you your product got slower.** A build that hangs is
|
|
138
|
+
still caught, because it is stopped for taking too long and how it finished is
|
|
139
|
+
compared exactly, and every counter that comes from the product rather than from
|
|
140
|
+
the clock is compared exactly as before.
|
|
141
|
+
|
|
142
|
+
### Added
|
|
143
|
+
|
|
144
|
+
- **Two more cases in the self-check corpus**, eleven in total: a break buried in
|
|
145
|
+
the middle of an output too big to store, and a build ten times slower that must
|
|
146
|
+
stay silent. Both were confirmed to fail against the previous code and pass
|
|
147
|
+
against this one.
|
|
148
|
+
- **The corpus re-runs a case before accusing the engine.** Fail twice and it is a
|
|
149
|
+
real failure. Behave the second time and it is reported as *could not tell* —
|
|
150
|
+
not a pass, not a failure, exit code 2 — with the machine's load beside it.
|
|
151
|
+
Measured after the change: eleven of eleven, three times running, with the
|
|
152
|
+
project's own test suite in parallel and load average between 227 and 334.
|
|
153
|
+
- `test/v2/silences.test.js`, holding each of the above shut.
|
|
154
|
+
|
|
155
|
+
## [0.4.0] — 2026-08-30
|
|
156
|
+
|
|
157
|
+
The difference engine, published. Seven observation channels flattened into one
|
|
158
|
+
address space; the new build run twice so the product's own wobble is measured
|
|
159
|
+
and subtracted rather than guessed at; differences clustered, ranked by distance
|
|
160
|
+
from the changed code, and their cause proven by reverting the suspect change and
|
|
161
|
+
running again. The reference is cut by saying ship, and an agent may waive within
|
|
162
|
+
four gates but can never decide what "working" means. Adapters for processes,
|
|
163
|
+
HTTP, source reading, the web, Electron, Android, iOS and Windows, and a
|
|
164
|
+
self-check corpus of deliberately broken builds.
|
|
165
|
+
|
|
166
|
+
## [0.3.0] — 2026-08-29
|
|
167
|
+
|
|
168
|
+
Checks tick off live as they run, and guards show what they assert.
|
|
169
|
+
|
|
170
|
+
## [0.2.0] — 2026-08-29
|
|
171
|
+
|
|
172
|
+
Pictures you can look at, motion, and a report that says what was checked rather
|
|
173
|
+
than only how long it took.
|
|
10
174
|
|
|
11
175
|
## [0.1.0] — 2026-08-29
|
|
12
176
|
|
|
@@ -57,5 +221,8 @@ not been used by many people yet.
|
|
|
57
221
|
- No phone or tablet simulators.
|
|
58
222
|
- No hosted service, no dashboard, no accounts.
|
|
59
223
|
|
|
60
|
-
[
|
|
224
|
+
[0.5.0]: https://github.com/asadev/staysfixed/compare/v0.4.0...v0.5.0
|
|
225
|
+
[0.4.0]: https://github.com/asadev/staysfixed/compare/v0.3.1...v0.4.0
|
|
226
|
+
[0.3.0]: https://github.com/asadev/staysfixed/compare/v0.2.3...v0.3.0
|
|
227
|
+
[0.2.0]: https://github.com/asadev/staysfixed/compare/v0.1.1...v0.2.0
|
|
61
228
|
[0.1.0]: https://github.com/asadev/staysfixed/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -148,8 +148,9 @@ pretend otherwise.
|
|
|
148
148
|
| The reference cut when you ship, sealed intents, the waiver budget, and escalations in your closing summary | **Works.** This page describes what it actually does. |
|
|
149
149
|
| 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). |
|
|
150
150
|
| Aiming a check at one kind of product, and refusing by name rather than checking something else | **Works.** |
|
|
151
|
+
| Steps taken from your own test suite, from a recorded session, or rejected at birth for not repeating twice | **Written, not wired.** The code is in `src/v2/journeys/` with tests around it, and nothing on the check path calls it yet. Journeys today come from what each adapter reads out of your source, plus any `--journeys` file you name. |
|
|
151
152
|
| 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 emulator snapshots restoring byte-identically is unproven, so Android compares against the stored record and says which mode it used. |
|
|
152
|
-
| The iOS simulator |
|
|
153
|
+
| 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 like Android it compares against the stored record and says which mode it used. Ask `doctor` what it is actually covering on your machine before trusting a clean run. |
|
|
153
154
|
| Native Windows GUI (a real Win32 app, not an Electron one) | **The probe is here**, driven over ssh to any machine that reaches a Windows desktop — a WSL shell on one counts, and nothing is installed on it. Windows shows one desktop, so two builds can never run at once: the comparison is genuinely weaker here than anywhere else. |
|
|
154
155
|
|
|
155
156
|
`staysfixed check` is the front door for both. Version 1's flags still mean
|
|
@@ -210,7 +211,7 @@ evidence for something another channel already found.
|
|
|
210
211
|
| `complaints` | What the product complained about: console messages, errors, crashes, the code it exited with. |
|
|
211
212
|
| `results` | What the product gave back: what it printed, what it answered, what it offers other code. |
|
|
212
213
|
| `contract` | The doors the source says exist: routes, exported functions, message channels. Read without running anything. Free, and exact. |
|
|
213
|
-
| `counters` | Rough counts
|
|
214
|
+
| `counters` | Rough counts — files written, calls made, doors answered. Compared exactly. How long something took is **recorded and never compared**: see [what it will never do](#what-it-will-never-do). |
|
|
214
215
|
| `pixels` | What it looked like. Used to show a person a problem another channel already found. |
|
|
215
216
|
|
|
216
217
|
An address reads left to right, widest thing first:
|
|
@@ -232,6 +233,14 @@ under instrumentation → recorded real sessions → the agent exploring one nam
|
|
|
232
233
|
gap and freezing it into a replayable file → never a person clicking through an
|
|
233
234
|
app.
|
|
234
235
|
|
|
236
|
+
What is actually wired into `staysfixed check` today is the first of those and a
|
|
237
|
+
journeys file you point it at: each adapter reads your source and offers the
|
|
238
|
+
journeys it can walk — routes, commands, screens, message channels — and
|
|
239
|
+
`--journeys <file>` names steps by hand. The suite harvest, recorded sessions and
|
|
240
|
+
the flake register are written and tested in `src/v2/journeys/`, and **nothing on
|
|
241
|
+
the check path calls them yet**. Saying so is the point: a feature that exists in
|
|
242
|
+
the repository and not in the run is not a feature you have.
|
|
243
|
+
|
|
235
244
|
## Keeping it quiet
|
|
236
245
|
|
|
237
246
|
Four layers before anything reaches the agent: **normalise** volatile shapes by
|
|
@@ -321,6 +330,27 @@ verdict reading *nothing that worked has changed*. It is arithmetically true and
|
|
|
321
330
|
it would let a real regression through. That run comes back as **`NOTHING WAS
|
|
322
331
|
ACTUALLY COMPARED`**, it is not a pass, and it exits non-zero.
|
|
323
332
|
|
|
333
|
+
### The silences that were found and closed
|
|
334
|
+
|
|
335
|
+
Every one of these produced a clean-looking run while something was invisible.
|
|
336
|
+
They are listed because a tool like this earns trust by naming the ways it has
|
|
337
|
+
been wrong, not by claiming it never was. All five were found on 2026-08-29 and
|
|
338
|
+
2026-08-30 by reading the whole engine looking for the same shape as the first
|
|
339
|
+
one, and each has a case in the corpus or a test holding it shut.
|
|
340
|
+
|
|
341
|
+
| It used to | Now |
|
|
342
|
+
| --- | --- |
|
|
343
|
+
| Skip any source file over 2MB **without a word**, then report that it had found no source at all — so a desktop app whose main process is one 3.5MB bundle had all 452 of its message channels silently unread | Reads up to 24MB, and names any file it still cannot open |
|
|
344
|
+
| Skip a folder it could not open, and every door behind it, silently | Names the folder and the reason, as missing coverage |
|
|
345
|
+
| Keep the two ends of a huge output and a **rough** size, so a break in the discarded middle left a byte-identical record | Keeps the exact byte count, and says out loud that only the ends were compared |
|
|
346
|
+
| Drop a whole adapter's journeys when it threw while listing them — a surface disappears and the verdict reads "nothing has changed" | Records it as a hole, by name, in the coverage |
|
|
347
|
+
| Treat a `git diff` too big to read as **no diff**, so a large uncommitted change was fingerprinted as a clean checkout — and if that commit was the reference, the check compared the build against itself and could only ever come back clean | Streams the diff into a hash with no ceiling, and refuses outright rather than guessing. A folder with no git in it is refused for the same reason |
|
|
348
|
+
| Read only the first **80** differences of a cluster when deciding whether it is one of the five things nobody may wave through — so a refund at address 150 of 300 was classified ordinary, and an agent could waive the lot | Reads every difference in the finding. There is no ceiling on the one gate that cannot have one |
|
|
349
|
+
| Re-check the first **five** addresses of a finding when proving whose change caused it, then say "caused by that change" about all three hundred — a machine-checked reason for an agent to close a break it had only half explained | Re-checks every address, and says in words when a change explains part of a finding and not the rest. It costs nothing: the walk already happened |
|
|
350
|
+
| Subtract the new build's own wobble even when the wobble had swallowed the comparison — a second run that fell over makes almost every address unsteady, everything is dropped before it is compared, and the run ends "nothing that already worked has changed" | A build that disagrees with itself about most of its own addresses gets **no verdict**: the run says NO ANSWER FROM THIS RUN, in those words, and is not a pass |
|
|
351
|
+
| Keep the **first** of two facts written at one address and ignore the second, so a door that broke behind a duplicated address could never be compared with anything. The detector for this was written on day one and never called | Every walk is checked for it, and each clash is named in the coverage: which address, and what the ignored answer was |
|
|
352
|
+
| Skip a folder it could not open **while looking for routes**, and every route behind it — the same bug as the one above, in a second place, still silent | Names the folder, and the routes behind it are reported as unread rather than as absent |
|
|
353
|
+
|
|
324
354
|
---
|
|
325
355
|
|
|
326
356
|
## Aiming a check at one thing
|
|
@@ -601,10 +631,32 @@ of it is in [docs/how-v2-works.md](docs/how-v2-works.md).
|
|
|
601
631
|
A tool that reports "nothing changed" looks exactly like a tool that is broken,
|
|
602
632
|
and there is no way to tell the two apart from the outside. So:
|
|
603
633
|
|
|
604
|
-
**It has to prove it still catches things.** `staysfixed check --selfcheck`
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
634
|
+
**It has to prove it still catches things.** `staysfixed check --selfcheck`
|
|
635
|
+
builds twelve tiny products — each a real repository with a working commit and an
|
|
636
|
+
uncommitted change on top, which is the shape an agent actually points this tool
|
|
637
|
+
at — and requires the engine to behave on every one. Eight are breaks it must
|
|
638
|
+
catch. Three are the other half of the same promise: pairs that must produce **no
|
|
639
|
+
findings at all**, because a tool that cries wolf gets switched off, and a tool
|
|
640
|
+
that is switched off catches nothing. The twelfth is the third kind, added on
|
|
641
|
+
2026-08-30: a product so unsteady that the comparison is thrown away before it
|
|
642
|
+
happens, where the only correct answer is that this run says **nothing** — and
|
|
643
|
+
saying "nothing changed" there is the worst thing the tool can do.
|
|
644
|
+
|
|
645
|
+
**And it has to be honest when it cannot tell.** A case that misbehaves is built
|
|
646
|
+
again from scratch and run again before that becomes an accusation. Fail twice
|
|
647
|
+
and it is reported as a real failure. Behave the second time and it is reported
|
|
648
|
+
as *could not tell* — not a pass, not a failure, exit code 2, with the machine's
|
|
649
|
+
load printed beside it. This exists because the corpus once came back "1 of 9
|
|
650
|
+
wrong" with a test suite running alongside it and then passed five times in a row
|
|
651
|
+
on a quiet machine, and a corpus that can be perturbed by a busy laptop is worth
|
|
652
|
+
nothing on a busy laptop. The cause was found and removed — see
|
|
653
|
+
[what it will never do](#what-it-will-never-do) — and the re-run stayed, so that
|
|
654
|
+
the next machine-shaped thing to creep in lands as "nobody knows" rather than as
|
|
655
|
+
a false accusation people learn to ignore. Measured on 2026-08-30: eleven of
|
|
656
|
+
eleven, three times running, with the project's own suite running in parallel and
|
|
657
|
+
the machine's load average between 227 and 334; and after the second sweep of
|
|
658
|
+
silences, twelve of twelve, three times running, with the suite in parallel again
|
|
659
|
+
and the load average between 208 and 343.
|
|
608
660
|
|
|
609
661
|
**The unstable app.** `fixtures/unstable-app` is a page built to be impossible to
|
|
610
662
|
observe consistently: a clock ticking ten times a second, an endless spinner, a
|
|
@@ -633,6 +685,40 @@ Honestly, so you know before you invest an afternoon.
|
|
|
633
685
|
by design and permanently. A refusal is reported as a gap in coverage, never as
|
|
634
686
|
a pass.
|
|
635
687
|
- **A migration that destroys data is refused, not run twice.**
|
|
688
|
+
- **It will not tell you your product got slower.** How long something took is
|
|
689
|
+
recorded and shown to you, and it is never compared. A stopwatch on a shared
|
|
690
|
+
machine measures how busy the machine is at least as much as it measures the
|
|
691
|
+
product: measured here, thirty runs of the same one-line program on an idle Mac
|
|
692
|
+
took between 48ms and 96ms, against a bucket boundary at 100ms. Comparing that
|
|
693
|
+
invents a slowdown nobody caused every time the machine is busy, and a tool that
|
|
694
|
+
cries wolf gets switched off. A build that **hangs** is still caught — it gets
|
|
695
|
+
stopped for taking too long, and how it finished is compared exactly.
|
|
696
|
+
- **A change buried in the middle of a huge output can be missed.** Anything a
|
|
697
|
+
product prints over 64KB has its two ends kept and compared, plus the exact
|
|
698
|
+
number of bytes thrown away — so a middle that grew or shrank is caught. A
|
|
699
|
+
middle that changed without changing its length is not, the whole text is
|
|
700
|
+
written to the evidence folder either way, and the run says out loud that it
|
|
701
|
+
only compared the ends.
|
|
702
|
+
- **A build that will not answer the same way twice gets no verdict at all.**
|
|
703
|
+
Differences at addresses the new build cannot hold steady are dropped — that is
|
|
704
|
+
the whole design, and it has one failure shape. If the second run falls over,
|
|
705
|
+
or the product writes hash-named files, or stamps a fresh id on every line, then
|
|
706
|
+
most of its addresses are unsteady, almost everything is dropped before it is
|
|
707
|
+
compared, and what is left is not an answer. A run in that state now says **NO
|
|
708
|
+
ANSWER FROM THIS RUN**, in those words, and is not a pass. Fix it by writing a
|
|
709
|
+
normalisation rule for whatever is moving, not by trusting the clean-looking run
|
|
710
|
+
underneath it.
|
|
711
|
+
- **What normalisation rubbed out is not itemised on every run.** The rules are in
|
|
712
|
+
your repository, they are listed by `staysfixed rules`, and the capture is
|
|
713
|
+
stamped with which set was used — a run comparing against a record tidied by a
|
|
714
|
+
different set says so. What it does not yet do is print, per run, every value a
|
|
715
|
+
rule rewrote. Anything a rule covers is not being watched, and that is the point
|
|
716
|
+
of the rule; just know that adding a broad one is how you go blind on purpose.
|
|
717
|
+
- **Ranking reads your source, and it gives up on very large trees.** Distance
|
|
718
|
+
from the code you just changed is what sorts the list, and it reads up to 4,000
|
|
719
|
+
files of up to 400KB each to work it out. Past that a finding is still reported
|
|
720
|
+
and still counted — it just may not sort where it deserves to. Nothing is
|
|
721
|
+
dropped for being far away.
|
|
636
722
|
- **A race that already existed will not show.** Subtracting the wobble floor
|
|
637
723
|
actively hides intermittent bugs. Running the new build twice recovers half of
|
|
638
724
|
this by flagging anything newly unstable. Only half. That is the sharpest
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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 — as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1183,7 +1183,14 @@ export function looksIrreversible(method, url) {
|
|
|
1183
1183
|
* in the background is filed separately, and this limit is stated in the adapter's own
|
|
1184
1184
|
* description rather than hidden here.
|
|
1185
1185
|
*/
|
|
1186
|
-
|
|
1186
|
+
/*
|
|
1187
|
+
* Every one of these was seen leaving a Google APIs emulator on 2026-08-30 while Terminal
|
|
1188
|
+
* Deck — an app that talks to one relay and nothing else — sat on its first screen. The
|
|
1189
|
+
* ones added that day are on the second line: `googleadservices` and `youtube` were being
|
|
1190
|
+
* filed as calls THE APP made, which reads as "your change made the app phone YouTube" and
|
|
1191
|
+
* is the most alarming false alarm this adapter is capable of producing.
|
|
1192
|
+
*/
|
|
1193
|
+
const DEVICE_HOSTS = /(^|\.)(google|googleapis|gstatic|android|gvt1|gvt2|doubleclick|crashlytics|firebaseinstallations)\.com$|(^|\.)(googleadservices|googlesyndication|googletagmanager|googleusercontent|google-analytics|youtube|ytimg|ggpht|appspot)\.com$|(^|\.)google\.[a-z.]+$/i;
|
|
1187
1194
|
|
|
1188
1195
|
/**
|
|
1189
1196
|
* @param {string} host
|
|
@@ -953,22 +953,54 @@ export const androidAdapter = defineAdapter({
|
|
|
953
953
|
const key = `${call.method} ${call.host}${call.route}`;
|
|
954
954
|
byCall.set(key, (byCall.get(key) ?? 0) + 1);
|
|
955
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* The phone's own background chatter is COUNTED, never given an address of its own.
|
|
958
|
+
*
|
|
959
|
+
* A device-wide proxy sees the whole phone, and on a Google APIs emulator most of what
|
|
960
|
+
* it sees is Play services: checking connectivity, fetching fonts, asking about digital
|
|
961
|
+
* asset links, phoning gmscompliance. None of it belongs to the app being checked, none
|
|
962
|
+
* of it is the same twice, and every one of those hosts given an address of its own is
|
|
963
|
+
* an address that appears in one run and is gone in the next.
|
|
964
|
+
*
|
|
965
|
+
* Measured on Terminal Deck on 2026-08-30, on a build with NOTHING changed in it: eight
|
|
966
|
+
* findings, seven of them a Google host that came or went. Running the build twice
|
|
967
|
+
* subtracted fifteen more and still could not subtract those, because this traffic is
|
|
968
|
+
* not merely unstable — it is episodic, so a run pair can agree with itself and still
|
|
969
|
+
* disagree with the run pair before it. Left as it was, an Android check reports noise
|
|
970
|
+
* on a build nobody touched, and the first thing anybody does with a tool that cries
|
|
971
|
+
* wolf is switch it off.
|
|
972
|
+
*
|
|
973
|
+
* So: the app's own calls keep one address each, which is the signal worth having, and
|
|
974
|
+
* the phone's own are reduced to a count that sits on one rung of a coarse ladder. What
|
|
975
|
+
* was seen is still said in plain English, and the ledger still records that nothing
|
|
976
|
+
* inside those requests was ever opened.
|
|
977
|
+
*/
|
|
978
|
+
let fromThePhone = 0;
|
|
956
979
|
for (const [key, times] of [...byCall.entries()].sort()) {
|
|
957
980
|
const call = calls.find((c) => `${c.method} ${c.host}${c.route}` === key);
|
|
958
981
|
if (!call) continue;
|
|
959
|
-
|
|
960
|
-
|
|
982
|
+
if (isDeviceHost(call.host)) {
|
|
983
|
+
fromThePhone += 1;
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
fromTheApp += 1;
|
|
961
987
|
out.push(observation({
|
|
962
988
|
channel: 'effects',
|
|
963
|
-
path: joinPath('net', journey.name,
|
|
989
|
+
path: joinPath('net', journey.name, 'the app', key),
|
|
964
990
|
value: { asked: countBucket(times), reached: call.allowed ? 'let through' : 'stopped here' },
|
|
965
|
-
says:
|
|
991
|
+
says: `the app tried to call ${key}${times > 1 ? ` ${times} times` : ''} — ${call.why}`,
|
|
966
992
|
covered: call.how === 'encrypted' ? false : undefined,
|
|
967
993
|
reason: call.how === 'encrypted' ? 'not supported here' : undefined,
|
|
968
994
|
journey: journey.name,
|
|
969
995
|
surface: 'android',
|
|
970
996
|
}));
|
|
971
997
|
}
|
|
998
|
+
out.push(notCovered({
|
|
999
|
+
channel: 'effects',
|
|
1000
|
+
path: joinPath('net', journey.name, 'the phone itself'),
|
|
1001
|
+
reason: 'not supported here',
|
|
1002
|
+
says: `${fromThePhone === 0 ? 'nothing else on the phone' : `${fromThePhone} other thing${fromThePhone === 1 ? '' : 's'} on the phone`} reached out while this ran — Android\'s own services, not this app. It is watched and stopped here like everything else, and it is not compared, because a device-wide proxy cannot tell which program made a call and Google\'s background traffic is different every single run.`,
|
|
1003
|
+
}));
|
|
972
1004
|
out.push(observation({
|
|
973
1005
|
channel: 'counters',
|
|
974
1006
|
path: joinPath('count', journey.name, 'calls the app made'),
|
|
@@ -46,7 +46,7 @@ export { CHANNELS };
|
|
|
46
46
|
* Why a thing was not observed. Short vocabulary on purpose: the engine counts these and
|
|
47
47
|
* reports them as holes, and a free-text reason cannot be counted.
|
|
48
48
|
*
|
|
49
|
-
* @typedef {'irreversible'|'missing tool'|'refused'|'too big'|'timed out'|'not supported here'|'crashed'|'needs a sample'} NotCoveredReason
|
|
49
|
+
* @typedef {'irreversible'|'missing tool'|'refused'|'too big'|'timed out'|'not supported here'|'crashed'|'needs a sample'|'measures the machine'} NotCoveredReason
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
52
|
/** @type {Record<NotCoveredReason, string>} */
|
|
@@ -59,6 +59,8 @@ export const NOT_COVERED_MEANING = Object.freeze({
|
|
|
59
59
|
'not supported here': 'this platform cannot be observed this way, and saying so is the honest answer',
|
|
60
60
|
crashed: 'the thing being observed fell over before it could be read',
|
|
61
61
|
'needs a sample': 'a real value has to be supplied before this can be tried at all',
|
|
62
|
+
'measures the machine':
|
|
63
|
+
'a stopwatch measures how busy this machine was at least as much as it measures the product, so the number is recorded and never compared',
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
// ---------------------------------------------------------------------------
|
|
@@ -465,6 +467,61 @@ export function timeBucket(ms) {
|
|
|
465
467
|
return 'over five minutes';
|
|
466
468
|
}
|
|
467
469
|
|
|
470
|
+
/**
|
|
471
|
+
* How long something took, recorded and DELIBERATELY NOT COMPARED.
|
|
472
|
+
*
|
|
473
|
+
* This used to be an ordinary observation whose value was the bucket the run landed in, and
|
|
474
|
+
* it was the single worst thing in the tool, for a reason that is arithmetic rather than
|
|
475
|
+
* theoretical. A wall clock on a shared machine measures how busy the machine is at least as
|
|
476
|
+
* much as it measures the product. Two runs of identical code, one while a test suite is
|
|
477
|
+
* running and one on a quiet laptop, land on different rungs of any ladder you care to draw
|
|
478
|
+
* — and the tool then reported a difference nobody caused, or worse, reported the address as
|
|
479
|
+
* "newly unpredictable", which is its sharpest accusation.
|
|
480
|
+
*
|
|
481
|
+
* Measured on this Mac on 2026-08-30, on the self-check corpus's own fixture: thirty runs of
|
|
482
|
+
* the same one-line program, machine idle, ran 48ms to 96ms — with the first rung boundary at
|
|
483
|
+
* 100ms. Four milliseconds of headroom. Anything at all happening on the machine crosses it,
|
|
484
|
+
* and that is exactly what happened the night the self-check came back "1 of 9 wrong" while
|
|
485
|
+
* the test suite ran alongside it, and passed five times in a row afterwards.
|
|
486
|
+
*
|
|
487
|
+
* The fix is not a wider bucket — every ladder has a boundary and every boundary has this
|
|
488
|
+
* problem — and it is certainly not a tolerance, which this tool does not have and will not
|
|
489
|
+
* grow. It is to stop claiming something a stopwatch cannot tell you. The number is still
|
|
490
|
+
* recorded, in the sentence, where a person can read it. It is never differenced.
|
|
491
|
+
*
|
|
492
|
+
* WHAT THIS GIVES UP, said plainly: Stays Fixed will not tell you your product got slower.
|
|
493
|
+
* WHAT IT DOES NOT GIVE UP: a build that hangs is still caught, because it gets killed for
|
|
494
|
+
* taking too long and how it finished IS compared; and every counter that comes from the
|
|
495
|
+
* product rather than from the clock — files written, calls made, doors answered — is still
|
|
496
|
+
* compared exactly.
|
|
497
|
+
*
|
|
498
|
+
* @param {object} spec
|
|
499
|
+
* @param {Channel} spec.channel
|
|
500
|
+
* @param {string|(string|number)[]} spec.path
|
|
501
|
+
* @param {number} spec.ms What it actually took, for the sentence.
|
|
502
|
+
* @param {string} spec.what What was being timed, in the reader's words.
|
|
503
|
+
* @param {string} [spec.andAlso] Anything else worth saying in the same breath.
|
|
504
|
+
* @param {string} [spec.journey]
|
|
505
|
+
* @returns {Observation}
|
|
506
|
+
*/
|
|
507
|
+
export function howLongItTook(spec) {
|
|
508
|
+
return observation({
|
|
509
|
+
channel: spec.channel,
|
|
510
|
+
path: spec.path,
|
|
511
|
+
// One fixed string, so this address is identical in every capture of every build and can
|
|
512
|
+
// never become a difference. The measurement lives in the sentence, which is never compared.
|
|
513
|
+
value: `not compared — ${NOT_COVERED_MEANING['measures the machine']}`,
|
|
514
|
+
says:
|
|
515
|
+
`${spec.what} took ${timeBucket(spec.ms)}. That is recorded and NOT compared: a stopwatch on a shared machine ` +
|
|
516
|
+
`measures the machine as much as the product, so a busy laptop would otherwise invent a slowdown that nobody caused. ` +
|
|
517
|
+
`A build that hangs is still caught — it gets stopped for taking too long, and how it finished is compared.` +
|
|
518
|
+
(spec.andAlso ? ` ${spec.andAlso}` : ''),
|
|
519
|
+
covered: false,
|
|
520
|
+
reason: 'measures the machine',
|
|
521
|
+
journey: spec.journey,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
468
525
|
/**
|
|
469
526
|
* Sizes, on the same principle as time. A response body that grew by two bytes is not news;
|
|
470
527
|
* one that doubled is.
|
|
@@ -542,10 +599,12 @@ export function undoOurFootprint(text, footprint) {
|
|
|
542
599
|
/**
|
|
543
600
|
* Keep a piece of text at a size worth storing.
|
|
544
601
|
*
|
|
545
|
-
* Anything longer gets its head and tail kept — the two ends are where the interesting
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
602
|
+
* Anything longer gets its head and tail kept — the two ends are where the interesting lines
|
|
603
|
+
* are — plus the EXACT number of bytes left out, so a middle that grew or shrank still shows
|
|
604
|
+
* as a difference. A middle that changed without changing length does NOT, and that hole is
|
|
605
|
+
* stated rather than hidden: the caller marks the observation as not fully covered and writes
|
|
606
|
+
* the whole text to the evidence folder. See the comment in the body for why a digest of the
|
|
607
|
+
* whole text cannot be used here.
|
|
549
608
|
*
|
|
550
609
|
* @param {string} text
|
|
551
610
|
* @param {number} [limit] bytes
|
|
@@ -557,8 +616,27 @@ export function trimForStorage(text, limit = 64 * 1024) {
|
|
|
557
616
|
const keep = Math.floor(limit / 2);
|
|
558
617
|
const head = text.slice(0, keep);
|
|
559
618
|
const tail = text.slice(-keep);
|
|
619
|
+
// The marker used to carry a COARSE size bucket, and the doc above it claimed a fingerprint
|
|
620
|
+
// of the whole that was never actually computed. Both halves of that were wrong, and the
|
|
621
|
+
// result was the worst thing this tool can produce: a change that happened entirely in the
|
|
622
|
+
// discarded middle of a large output left a byte-identical stored value, so the comparison
|
|
623
|
+
// saw nothing and the run reported that nothing had changed. A silence that reads like an
|
|
624
|
+
// all-clear.
|
|
625
|
+
//
|
|
626
|
+
// The exact byte count goes in instead. A digest of the whole text would be strictly
|
|
627
|
+
// better AND IT CANNOT GO HERE: normalisation runs after the adapter, on the head and the
|
|
628
|
+
// tail, so a digest taken now would include every timestamp and every id the rules exist to
|
|
629
|
+
// rub out — the address would then disagree with itself on every run, be measured as wobble,
|
|
630
|
+
// and get subtracted, which would switch off the comparison of large outputs altogether.
|
|
631
|
+
// An exact length survives normalisation, because almost everything volatile (a timestamp,
|
|
632
|
+
// a uuid, a hex id) has a fixed width.
|
|
633
|
+
//
|
|
634
|
+
// What is left uncovered is real and it is named rather than hidden: a change confined to
|
|
635
|
+
// the middle that keeps the length identical is not seen. The caller marks the observation
|
|
636
|
+
// as not fully covered, the coverage ledger states the hole, and the whole text is written
|
|
637
|
+
// to the evidence folder so anybody can look.
|
|
560
638
|
return {
|
|
561
|
-
text: `${head}\n... ${
|
|
639
|
+
text: `${head}\n... exactly ${bytes - keep * 2} bytes left out of the middle of ${bytes} ...\n${tail}`,
|
|
562
640
|
truncated: true,
|
|
563
641
|
bytes,
|
|
564
642
|
};
|
|
@@ -51,8 +51,8 @@ import { connect } from '../../drive/cdp.js';
|
|
|
51
51
|
import { resolveElectronBinary } from '../../drive/find.js';
|
|
52
52
|
import { splitPath } from '../observation.js';
|
|
53
53
|
import {
|
|
54
|
-
countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket,
|
|
55
|
-
trimForStorage, undoOurFootprint,
|
|
54
|
+
countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
55
|
+
timeBucket, trimForStorage, undoOurFootprint,
|
|
56
56
|
} from './contract.js';
|
|
57
57
|
import { compareTrees, snapshotTree } from './process.js';
|
|
58
58
|
import {
|
|
@@ -1172,11 +1172,11 @@ export function describeApp(input) {
|
|
|
1172
1172
|
: `What this journey did, in order: ${did.join('; ')}.`,
|
|
1173
1173
|
journey: id,
|
|
1174
1174
|
}));
|
|
1175
|
-
out.push(
|
|
1175
|
+
out.push(howLongItTook({
|
|
1176
1176
|
channel: 'counters',
|
|
1177
1177
|
path: joinPath('count', id, 'time to open'),
|
|
1178
|
-
|
|
1179
|
-
|
|
1178
|
+
ms: app.openedInMs,
|
|
1179
|
+
what: 'Opening the app and getting a window on screen',
|
|
1180
1180
|
journey: id,
|
|
1181
1181
|
}));
|
|
1182
1182
|
if (reading?.helpers) {
|
package/src/v2/adapters/http.js
CHANGED
|
@@ -34,7 +34,7 @@ import path from 'node:path';
|
|
|
34
34
|
import { spawn } from 'node:child_process';
|
|
35
35
|
import {
|
|
36
36
|
defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
|
|
37
|
-
timeBucket, trimForStorage, undoOurFootprint,
|
|
37
|
+
howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
|
|
38
38
|
} from './contract.js';
|
|
39
39
|
import {
|
|
40
40
|
compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
|
|
@@ -306,7 +306,7 @@ export const httpAdapter = defineAdapter({
|
|
|
306
306
|
.find((name) => name in dependencies);
|
|
307
307
|
|
|
308
308
|
const reading = await readContract({ root: project.root });
|
|
309
|
-
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...await readFileRoutes(project.root)];
|
|
309
|
+
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
|
|
310
310
|
|
|
311
311
|
if (!config.start) {
|
|
312
312
|
missing.push({
|
|
@@ -355,7 +355,7 @@ export const httpAdapter = defineAdapter({
|
|
|
355
355
|
const config = project.config ?? {};
|
|
356
356
|
const samples = config.samples ?? {};
|
|
357
357
|
const reading = await readContract({ root: project.root });
|
|
358
|
-
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...await readFileRoutes(project.root)];
|
|
358
|
+
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
|
|
359
359
|
|
|
360
360
|
/** @type {Map<string, import('./contract.js').Journey>} */
|
|
361
361
|
const journeys = new Map();
|
|
@@ -722,11 +722,12 @@ export function describeRequest(input) {
|
|
|
722
722
|
}));
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
-
out.push(
|
|
725
|
+
out.push(howLongItTook({
|
|
726
726
|
channel: 'counters',
|
|
727
727
|
path: joinPath('count', id, 'duration'),
|
|
728
|
-
|
|
729
|
-
|
|
728
|
+
ms,
|
|
729
|
+
what: asked,
|
|
730
|
+
journey: id,
|
|
730
731
|
}));
|
|
731
732
|
|
|
732
733
|
return out;
|