staysfixed 0.4.0 → 0.6.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 CHANGED
@@ -4,9 +4,162 @@ 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
- ## [Unreleased]
7
+ ## [0.6.0] — 2026-08-30
8
8
 
9
- Nothing yet.
9
+ The second sweep for silences, done the same way as the first: read all of
10
+ `src/v2` looking for anything that can drop something and then report nothing
11
+ found. Five more were there, and two of them were in the safety machinery
12
+ itself — the gate that decides what an agent may never wave through, and the
13
+ proof that decides whether a change explains a break.
14
+
15
+ Every fix below has a test that fails against yesterday's code and passes
16
+ against today's. That was checked by putting the old behaviour back, one line at
17
+ a time, in a copy of the repository, and watching each test go red.
18
+
19
+ ### Fixed — silences, second sweep
20
+
21
+ - **Only the first 80 differences of a finding were read when deciding whether it
22
+ was one of the five things nobody may wave through.** A cluster can hold
23
+ hundreds of addresses; a refund sitting at address 150 of 300 was classified
24
+ ordinary, which means an agent could waive it and it would never reach a
25
+ person. Every difference is read now. A cap on the one gate that cannot have
26
+ one is not a performance decision, it is a hole.
27
+ - **The causal proof re-checked five addresses and then spoke for all of them.**
28
+ A finding of three hundred where the first five went away came back "caused by
29
+ that change" — a machine-checked reason for an agent to close a break it had
30
+ only half explained. It re-checks every address now, and when a change explains
31
+ part of a finding and not the rest it says exactly that, with both numbers. It
32
+ costs nothing: the journeys were already re-walked and each extra address is one
33
+ lookup.
34
+ - **A wobble big enough to swallow the comparison was still subtracted, and the
35
+ run came back clean.** If the second run of the new build falls over, or the
36
+ product writes hash-named files, or stamps a fresh id on every line, most of its
37
+ addresses are unsteady, nearly every difference is dropped before anything is
38
+ compared, and the verdict reads "nothing that already worked has changed". A
39
+ build that disagrees with itself about more than half of its own addresses now
40
+ gets **no verdict**: the summary opens with `NO ANSWER FROM THIS RUN`, the
41
+ reason is a named hole in the coverage, and `ok` is false. No number here
42
+ decides whether any difference is real — it decides only whether the run has
43
+ earned the word clean.
44
+ - **Two facts written at one address lost one of them, silently.** Every index in
45
+ the engine keeps the first observation at a path, so a second one is never
46
+ compared with anything and a door that broke behind it is invisible. The
47
+ detector for this was written on the first day of v2 and nothing ever called it.
48
+ Every walk is checked now and each clash is named — which address, and what the
49
+ ignored answer was. Identical repeats are not reported, because they hide
50
+ nothing.
51
+ - **The route reader skipped a folder it could not open, and every route behind
52
+ it, without a word.** This is the same bug as the one fixed yesterday in the
53
+ file walk, in a second function that the first fix did not touch. It names the
54
+ folder now.
55
+
56
+ ### Added
57
+
58
+ - A twelfth case in the self-check corpus, and a third kind of expectation with
59
+ it: a product so unsteady that the comparison is thrown away before it happens,
60
+ where the only honest answer is that the run says nothing. Confirmed to fail
61
+ against 0.5.0 and pass against this. Measured after the change: twelve of
62
+ twelve, three times running, with the project's own suite in parallel and the
63
+ machine's load average between 208 and 343.
64
+ - Six more tests in `test/v2/silences.test.js`, one per fix above, each confirmed
65
+ red against the previous code.
66
+
67
+ ### Changed
68
+
69
+ - `readFileRoutes(root)` returns `{ doors, problems }` rather than an array. The
70
+ problems are folders it could not open, and they are reported as missing
71
+ coverage by every caller.
72
+
73
+ ### Known, and written down rather than fixed
74
+
75
+ - What a normalisation rule rubbed out is not itemised per run. The receipt
76
+ exists in the code and nothing calls it. The rules are in your repository and
77
+ the capture is stamped with which set it used, so a comparison across a rule
78
+ change is announced — but a broad rule is still how you go blind on purpose.
79
+ - Ranking reads up to 4,000 source files of up to 400KB. Past that a finding is
80
+ still reported and still counted; it may just not sort where it deserves to.
81
+ Nothing is dropped for being far from the change.
82
+
83
+ ## [0.5.0] — 2026-08-30
84
+
85
+ The release about the one way this tool can be catastrophically wrong: reporting
86
+ nothing while something is broken. Five separate places could do that, and one of
87
+ them could do it permanently.
88
+
89
+ ### Fixed — silences
90
+
91
+ Each of these produced a clean-looking run while something was invisible.
92
+
93
+ - **A `git diff` too big to read was treated as no diff at all**, so a large
94
+ uncommitted change was fingerprinted as a clean checkout of the commit it sat
95
+ on. If that commit was the reference, the check was comparing the build against
96
+ itself, and a comparison of a build with itself can only ever come back clean.
97
+ The diff is now streamed into a hash with no ceiling, and a git command that
98
+ fails is refused loudly rather than read as "nothing has changed".
99
+ - **A folder with no git in it** gave every build the same identity, with the same
100
+ consequence, quietly and for ever. It is now refused, with a sentence saying
101
+ why.
102
+ - **The two ends of a huge output were kept with only a rough size**, so a break
103
+ in the middle that was thrown away left a byte-identical record. The exact byte
104
+ count is kept now, and a truncated value is reported as missing coverage rather
105
+ than passed over.
106
+ - **An adapter that threw while listing what it would walk had its journeys
107
+ dropped in silence** — a whole surface disappearing from a run whose verdict
108
+ then read "nothing that worked has changed". It is recorded as a hole, by name.
109
+ - **A folder the source reader could not open was skipped without a word**, taking
110
+ every door behind it. It is named now, along with any file still too big to read.
111
+ - **The normalisation-rule stamp on a stored capture was written and never read.**
112
+ Comparing a record tidied up under one set of rules against a run tidied up
113
+ under another produces differences that are about the rules; the run now says so.
114
+
115
+ ### Changed — how long something took is recorded and never compared
116
+
117
+ A stopwatch on a shared machine measures how busy the machine is at least as much
118
+ as it measures the product. Measured on the corpus's own fixture: thirty runs of
119
+ the same one-line program on an idle Mac took 48ms to 96ms, against a bucket
120
+ boundary at 100ms — four milliseconds of headroom, so any load at all crossed it.
121
+ That is what made the self-check come back "1 of 9 wrong" one busy evening and
122
+ then pass five times in a row on a quiet machine.
123
+
124
+ Durations are still measured and still shown, in the sentence beside the address,
125
+ where a person can read them. They are no longer differenced, on any platform.
126
+ **Stays Fixed will not tell you your product got slower.** A build that hangs is
127
+ still caught, because it is stopped for taking too long and how it finished is
128
+ compared exactly, and every counter that comes from the product rather than from
129
+ the clock is compared exactly as before.
130
+
131
+ ### Added
132
+
133
+ - **Two more cases in the self-check corpus**, eleven in total: a break buried in
134
+ the middle of an output too big to store, and a build ten times slower that must
135
+ stay silent. Both were confirmed to fail against the previous code and pass
136
+ against this one.
137
+ - **The corpus re-runs a case before accusing the engine.** Fail twice and it is a
138
+ real failure. Behave the second time and it is reported as *could not tell* —
139
+ not a pass, not a failure, exit code 2 — with the machine's load beside it.
140
+ Measured after the change: eleven of eleven, three times running, with the
141
+ project's own test suite in parallel and load average between 227 and 334.
142
+ - `test/v2/silences.test.js`, holding each of the above shut.
143
+
144
+ ## [0.4.0] — 2026-08-30
145
+
146
+ The difference engine, published. Seven observation channels flattened into one
147
+ address space; the new build run twice so the product's own wobble is measured
148
+ and subtracted rather than guessed at; differences clustered, ranked by distance
149
+ from the changed code, and their cause proven by reverting the suspect change and
150
+ running again. The reference is cut by saying ship, and an agent may waive within
151
+ four gates but can never decide what "working" means. Adapters for processes,
152
+ HTTP, source reading, the web, Electron, Android, iOS and Windows, and a
153
+ self-check corpus of deliberately broken builds.
154
+
155
+ ## [0.3.0] — 2026-08-29
156
+
157
+ Checks tick off live as they run, and guards show what they assert.
158
+
159
+ ## [0.2.0] — 2026-08-29
160
+
161
+ Pictures you can look at, motion, and a report that says what was checked rather
162
+ than only how long it took.
10
163
 
11
164
  ## [0.1.0] — 2026-08-29
12
165
 
@@ -57,5 +210,8 @@ not been used by many people yet.
57
210
  - No phone or tablet simulators.
58
211
  - No hosted service, no dashboard, no accounts.
59
212
 
60
- [Unreleased]: https://github.com/asadev/staysfixed/compare/v0.1.0...HEAD
213
+ [0.5.0]: https://github.com/asadev/staysfixed/compare/v0.4.0...v0.5.0
214
+ [0.4.0]: https://github.com/asadev/staysfixed/compare/v0.3.1...v0.4.0
215
+ [0.3.0]: https://github.com/asadev/staysfixed/compare/v0.2.3...v0.3.0
216
+ [0.2.0]: https://github.com/asadev/staysfixed/compare/v0.1.1...v0.2.0
61
217
  [0.1.0]: https://github.com/asadev/staysfixed/releases/tag/v0.1.0
package/README.md CHANGED
@@ -149,7 +149,7 @@ pretend otherwise.
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
151
  | 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 | Not yet. `doctor` says so rather than reporting a green run that never touched the phone. |
152
+ | 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
153
  | 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
154
 
155
155
  `staysfixed check` is the front door for both. Version 1's flags still mean
@@ -210,7 +210,7 @@ evidence for something another channel already found.
210
210
  | `complaints` | What the product complained about: console messages, errors, crashes, the code it exited with. |
211
211
  | `results` | What the product gave back: what it printed, what it answered, what it offers other code. |
212
212
  | `contract` | The doors the source says exist: routes, exported functions, message channels. Read without running anything. Free, and exact. |
213
- | `counters` | Rough counts and rough timings. Deliberately rough precise timing is noise, not information. |
213
+ | `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
214
  | `pixels` | What it looked like. Used to show a person a problem another channel already found. |
215
215
 
216
216
  An address reads left to right, widest thing first:
@@ -321,6 +321,27 @@ verdict reading *nothing that worked has changed*. It is arithmetically true and
321
321
  it would let a real regression through. That run comes back as **`NOTHING WAS
322
322
  ACTUALLY COMPARED`**, it is not a pass, and it exits non-zero.
323
323
 
324
+ ### The silences that were found and closed
325
+
326
+ Every one of these produced a clean-looking run while something was invisible.
327
+ They are listed because a tool like this earns trust by naming the ways it has
328
+ been wrong, not by claiming it never was. All five were found on 2026-08-29 and
329
+ 2026-08-30 by reading the whole engine looking for the same shape as the first
330
+ one, and each has a case in the corpus or a test holding it shut.
331
+
332
+ | It used to | Now |
333
+ | --- | --- |
334
+ | 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 |
335
+ | Skip a folder it could not open, and every door behind it, silently | Names the folder and the reason, as missing coverage |
336
+ | 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 |
337
+ | 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 |
338
+ | 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 |
339
+ | 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 |
340
+ | 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 |
341
+ | 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 |
342
+ | 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 |
343
+ | 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 |
344
+
324
345
  ---
325
346
 
326
347
  ## Aiming a check at one thing
@@ -601,10 +622,32 @@ of it is in [docs/how-v2-works.md](docs/how-v2-works.md).
601
622
  A tool that reports "nothing changed" looks exactly like a tool that is broken,
602
623
  and there is no way to tell the two apart from the outside. So:
603
624
 
604
- **It has to prove it still catches things.** `staysfixed check --selfcheck` runs
605
- a corpus of deliberately broken builds and requires the engine to catch every
606
- one. If it misses any, it says so, and until that is fixed a clean check means
607
- nothing.
625
+ **It has to prove it still catches things.** `staysfixed check --selfcheck`
626
+ builds twelve tiny products each a real repository with a working commit and an
627
+ uncommitted change on top, which is the shape an agent actually points this tool
628
+ at — and requires the engine to behave on every one. Eight are breaks it must
629
+ catch. Three are the other half of the same promise: pairs that must produce **no
630
+ findings at all**, because a tool that cries wolf gets switched off, and a tool
631
+ that is switched off catches nothing. The twelfth is the third kind, added on
632
+ 2026-08-30: a product so unsteady that the comparison is thrown away before it
633
+ happens, where the only correct answer is that this run says **nothing** — and
634
+ saying "nothing changed" there is the worst thing the tool can do.
635
+
636
+ **And it has to be honest when it cannot tell.** A case that misbehaves is built
637
+ again from scratch and run again before that becomes an accusation. Fail twice
638
+ and it is reported as a real failure. Behave the second time and it is reported
639
+ as *could not tell* — not a pass, not a failure, exit code 2, with the machine's
640
+ load printed beside it. This exists because the corpus once came back "1 of 9
641
+ wrong" with a test suite running alongside it and then passed five times in a row
642
+ on a quiet machine, and a corpus that can be perturbed by a busy laptop is worth
643
+ nothing on a busy laptop. The cause was found and removed — see
644
+ [what it will never do](#what-it-will-never-do) — and the re-run stayed, so that
645
+ the next machine-shaped thing to creep in lands as "nobody knows" rather than as
646
+ a false accusation people learn to ignore. Measured on 2026-08-30: eleven of
647
+ eleven, three times running, with the project's own suite running in parallel and
648
+ the machine's load average between 227 and 334; and after the second sweep of
649
+ silences, twelve of twelve, three times running, with the suite in parallel again
650
+ and the load average between 208 and 343.
608
651
 
609
652
  **The unstable app.** `fixtures/unstable-app` is a page built to be impossible to
610
653
  observe consistently: a clock ticking ten times a second, an endless spinner, a
@@ -633,6 +676,40 @@ Honestly, so you know before you invest an afternoon.
633
676
  by design and permanently. A refusal is reported as a gap in coverage, never as
634
677
  a pass.
635
678
  - **A migration that destroys data is refused, not run twice.**
679
+ - **It will not tell you your product got slower.** How long something took is
680
+ recorded and shown to you, and it is never compared. A stopwatch on a shared
681
+ machine measures how busy the machine is at least as much as it measures the
682
+ product: measured here, thirty runs of the same one-line program on an idle Mac
683
+ took between 48ms and 96ms, against a bucket boundary at 100ms. Comparing that
684
+ invents a slowdown nobody caused every time the machine is busy, and a tool that
685
+ cries wolf gets switched off. A build that **hangs** is still caught — it gets
686
+ stopped for taking too long, and how it finished is compared exactly.
687
+ - **A change buried in the middle of a huge output can be missed.** Anything a
688
+ product prints over 64KB has its two ends kept and compared, plus the exact
689
+ number of bytes thrown away — so a middle that grew or shrank is caught. A
690
+ middle that changed without changing its length is not, the whole text is
691
+ written to the evidence folder either way, and the run says out loud that it
692
+ only compared the ends.
693
+ - **A build that will not answer the same way twice gets no verdict at all.**
694
+ Differences at addresses the new build cannot hold steady are dropped — that is
695
+ the whole design, and it has one failure shape. If the second run falls over,
696
+ or the product writes hash-named files, or stamps a fresh id on every line, then
697
+ most of its addresses are unsteady, almost everything is dropped before it is
698
+ compared, and what is left is not an answer. A run in that state now says **NO
699
+ ANSWER FROM THIS RUN**, in those words, and is not a pass. Fix it by writing a
700
+ normalisation rule for whatever is moving, not by trusting the clean-looking run
701
+ underneath it.
702
+ - **What normalisation rubbed out is not itemised on every run.** The rules are in
703
+ your repository, they are listed by `staysfixed rules`, and the capture is
704
+ stamped with which set was used — a run comparing against a record tidied by a
705
+ different set says so. What it does not yet do is print, per run, every value a
706
+ rule rewrote. Anything a rule covers is not being watched, and that is the point
707
+ of the rule; just know that adding a broad one is how you go blind on purpose.
708
+ - **Ranking reads your source, and it gives up on very large trees.** Distance
709
+ from the code you just changed is what sorts the list, and it reads up to 4,000
710
+ files of up to 400KB each to work it out. Past that a finding is still reported
711
+ and still counted — it just may not sort where it deserves to. Nothing is
712
+ dropped for being far away.
636
713
  - **A race that already existed will not show.** Subtracting the wobble floor
637
714
  actively hides intermittent bugs. Running the new build twice recovers half of
638
715
  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.4.0",
3
+ "version": "0.6.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 — as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- * lines are — plus a fingerprint of the whole, so a change in the middle still shows as a
547
- * difference even though the middle itself was never stored. The caller writes the full
548
- * text to the evidence folder and points at it.
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... ${sizeBucket(bytes - keep * 2)} left out of the middle ...\n${tail}`,
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, timeBucket,
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(observation({
1175
+ out.push(howLongItTook({
1176
1176
  channel: 'counters',
1177
1177
  path: joinPath('count', id, 'time to open'),
1178
- value: timeBucket(app.openedInMs),
1179
- says: `The app took ${timeBucket(app.openedInMs)} to open and show a window. Deliberately rough: exact timings differ every run.`,
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) {
@@ -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(observation({
725
+ out.push(howLongItTook({
726
726
  channel: 'counters',
727
727
  path: joinPath('count', id, 'duration'),
728
- value: timeBucket(ms),
729
- says: `${asked} took ${timeBucket(ms)}. Deliberately rough: exact timings differ on every run and would drown everything else.`,
728
+ ms,
729
+ what: asked,
730
+ journey: id,
730
731
  }));
731
732
 
732
733
  return out;
@@ -40,7 +40,7 @@ import os from 'node:os';
40
40
  import crypto from 'node:crypto';
41
41
  import { spawn } from 'node:child_process';
42
42
  import {
43
- defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
43
+ defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
44
44
  trimForStorage, undoOurFootprint,
45
45
  } from './contract.js';
46
46
 
@@ -768,9 +768,17 @@ export async function describeRun(input) {
768
768
  value: kept.text,
769
769
  says: text === ''
770
770
  ? `"${journey.describe}" ${nothing}.`
771
- : `What "${journey.describe}" ${sentence}${kept.truncated ? `, with the middle left out — the whole of it is ${sizeBucket(kept.bytes)}` : ''}.`,
771
+ : `What "${journey.describe}" ${sentence}${
772
+ kept.truncated
773
+ ? `. It is ${sizeBucket(kept.bytes)}, so only the two ends were kept and compared. A change confined to the middle that does not change its length would NOT be seen here; the whole of it is in the evidence file`
774
+ : ''
775
+ }.`,
772
776
  evidence,
773
777
  journey: id,
778
+ // Truncation is missing coverage and has to be counted as such. Left unsaid, a break
779
+ // buried in the middle of a big output is a silence that reads like an all-clear.
780
+ covered: kept.truncated ? false : undefined,
781
+ reason: kept.truncated ? 'too big' : undefined,
774
782
  }));
775
783
  }
776
784
 
@@ -852,12 +860,15 @@ export async function describeRun(input) {
852
860
  }));
853
861
  }
854
862
 
855
- // ---- how long, coarsely
856
- out.push(observation({
863
+ // ---- how long. Recorded, never compared. See howLongItTook in contract.js for the
864
+ // measurement that settled this: on an idle machine these fixtures run 48-96ms against a
865
+ // rung boundary at 100ms, so any load at all crossed it and invented a difference.
866
+ out.push(howLongItTook({
857
867
  channel: 'counters',
858
868
  path: joinPath('count', id, 'duration'),
859
- value: timeBucket(result.ms),
860
- says: `"${journey.describe}" took ${timeBucket(result.ms)}. Deliberately rough: exact timings differ on every run and would drown everything else.`,
869
+ ms: result.ms,
870
+ what: `"${journey.describe}"`,
871
+ journey: id,
861
872
  }));
862
873
 
863
874
  return out;