staysfixed 0.7.2 → 0.9.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/src/v2/cluster.js CHANGED
@@ -27,14 +27,6 @@ import { splitPath, sameValue } from './observation.js';
27
27
  /** @typedef {import('./types.js').Channel} Channel */
28
28
  /** @typedef {import('./types.js').ObservedValue} ObservedValue */
29
29
 
30
- /** How much of a cluster a finding carries with it. Enough to orient, not enough to bury. */
31
- const KEEP_NEAR_FILES = 5;
32
-
33
- /**
34
- * How many of a cluster's addresses travel with the finding. `count` always says how
35
- * many there really are, so a long cluster cannot hide its size behind a short list.
36
- */
37
- const KEEP_PATHS = 20;
38
30
 
39
31
  /**
40
32
  * How each channel introduces itself. This is the first half of every sentence
@@ -55,8 +47,25 @@ const CHANNEL_WORDS = {
55
47
  /**
56
48
  * Last segments too vague to identify anything alone. When an address ends in
57
49
  * one of these, the segment before it comes along for the ride.
50
+ *
51
+ * WHAT PUTS A WORD IN HERE. Every address ends either in something the PRODUCT
52
+ * named — an exported function, a route, a control, a field — or in a word this
53
+ * tool wrote to say what was being asked about it. The first kind identifies
54
+ * something on its own. The second kind is the same word on every journey, every
55
+ * door and every screen in the product, so a sentence built out of it names
56
+ * nothing: `"declared" is gone` was a real title, about a route that had been
57
+ * renamed, and the route was sitting one segment to the left the whole time.
58
+ * That sentence is not only read by an agent — it goes verbatim into the block a
59
+ * person reads in the closing summary, and the owner of this tool is not a coder.
60
+ *
61
+ * So this holds the words the tool writes, checked against the addresses the
62
+ * adapters actually produce rather than guessed at. `smartLeaf names the half a
63
+ * reader can act on` in the tests sweeps one real address of every shape through
64
+ * here; a new adapter that ends an address in a shared word belongs in that list
65
+ * and in this set on the same day.
58
66
  */
59
67
  const VAGUE = new Set([
68
+ // What was asked about a thing: the thing itself is one segment to the left.
60
69
  'value',
61
70
  'text',
62
71
  'name',
@@ -64,14 +73,45 @@ const VAGUE = new Set([
64
73
  'label',
65
74
  'title',
66
75
  'count',
76
+ 'size',
77
+ 'reason',
67
78
  'enabled',
68
79
  'visible',
69
80
  'status',
70
81
  'type',
71
82
  'id',
72
83
  'body',
84
+ 'shape',
73
85
  'result',
74
86
  'exit',
87
+ // Doors, which all end the same way whatever the door is: a route, a command,
88
+ // an IPC channel, a named control on a phone.
89
+ 'declared',
90
+ 'registered',
91
+ 'reached',
92
+ // What happened when we asked, on every surface there is.
93
+ 'asked',
94
+ 'answered',
95
+ 'answered at all',
96
+ 'ran at all',
97
+ 'opened at all',
98
+ 'walked',
99
+ 'read',
100
+ 'typed',
101
+ 'pressed',
102
+ 'done',
103
+ 'started',
104
+ 'finished',
105
+ 'refused',
106
+ 'settled',
107
+ 'held still',
108
+ 'looks like',
109
+ 'written',
110
+ 'still running',
111
+ 'stdout',
112
+ 'stderr',
113
+ 'controls',
114
+ 'picture',
75
115
  ]);
76
116
 
77
117
  /**
@@ -146,20 +186,75 @@ export function findRenames(differences) {
146
186
 
147
187
  /** @type {Map<Difference, {from: string, to: string}>} */
148
188
  const found = new Map();
189
+ /** @type {{was: string, now: string, from: string, to: string}[]} */
190
+ const moves = [];
149
191
  for (const place of places.values()) {
150
192
  if (place.gone.length !== 1 || place.came.length !== 1) continue;
151
193
  const gone = place.gone[0];
152
194
  const came = place.came[0];
153
- if (!sameValue(gone.reference, came.candidate)) continue;
154
195
  const from = leafOf(gone.path);
155
196
  const to = leafOf(came.path);
156
197
  if (from === to) continue;
198
+ // "The values must match" is right for a thing addressed by its position, and wrong for
199
+ // one addressed by its own words. A heading lives at `heading:Nine Bakers` and its value
200
+ // reads `a heading called "Nine Bakers"` — rename it and BOTH move, so the values never
201
+ // match and the rename was never spotted. Measured 2026-08-30: renaming one heading on a
202
+ // page came back as five separate findings, one thing vanishing and a different thing
203
+ // appearing, with nothing anywhere saying "renamed". So a value that changed in exactly
204
+ // the same way the name did counts as the same value. Anything else is still two edits
205
+ // that happened to land side by side, which is what this test exists to keep out.
206
+ if (!sameValue(gone.reference, came.candidate) && !movedWithItsName(gone.reference, came.candidate, from, to)) continue;
157
207
  found.set(gone, { from, to });
158
208
  found.set(came, { from, to });
209
+ moves.push({ was: gone.path, now: came.path, from, to });
210
+ }
211
+
212
+ // A rename takes its children with it. Everything under the old address goes away and the
213
+ // same things arrive under the new one — true, and not a second piece of news. Renaming one
214
+ // heading on a page reported the heading AND the two halves of its own `level`, so one edit
215
+ // a person would describe in four words arrived as four findings. Anything that moved with
216
+ // it, unchanged, belongs to the rename that moved it.
217
+ for (const move of moves) {
218
+ for (const gone of differences) {
219
+ if (gone.kind !== 'vanished' || found.has(gone)) continue;
220
+ if (!gone.path.startsWith(`${move.was}.`)) continue;
221
+ const wanted = move.now + gone.path.slice(move.was.length);
222
+ const came = differences.find(
223
+ (d) => d.kind === 'appeared' && !found.has(d) && d.path === wanted && d.channel === gone.channel && sameValue(gone.reference, d.candidate),
224
+ );
225
+ if (!came) continue;
226
+ found.set(gone, { from: move.from, to: move.to });
227
+ found.set(came, { from: move.from, to: move.to });
228
+ }
159
229
  }
160
230
  return found;
161
231
  }
162
232
 
233
+ /**
234
+ * Did the value change in exactly the way the name did?
235
+ *
236
+ * Only for two addresses naming the same KIND of thing — `heading:X` and `heading:Y`, never
237
+ * `heading:X` and `button:Y` — because the part before the colon is what the thing IS, and a
238
+ * heading becoming a button is not a rename.
239
+ *
240
+ * @param {unknown} before
241
+ * @param {unknown} after
242
+ * @param {string} from
243
+ * @param {string} to
244
+ * @returns {boolean}
245
+ */
246
+ function movedWithItsName(before, after, from, to) {
247
+ if (typeof before !== 'string' || typeof after !== 'string') return false;
248
+ const wasNamed = /^([^:]+):(.+)$/.exec(from);
249
+ const nowNamed = /^([^:]+):(.+)$/.exec(to);
250
+ if (!wasNamed || !nowNamed) return false;
251
+ if (wasNamed[1] !== nowNamed[1]) return false;
252
+ const was = wasNamed[2];
253
+ const now = nowNamed[2];
254
+ if (!was || was === now) return false;
255
+ return before.split(was).join(now) === after;
256
+ }
257
+
163
258
  /**
164
259
  * The grouping key: channel, shape of change, what the address ends in, and the
165
260
  * move from one value to another.
@@ -194,7 +289,15 @@ export function journeysOf(finding) {
194
289
  */
195
290
  function buildFinding(signature, members, rename, sources) {
196
291
  const head = members[0];
197
- const nearFiles = unique(members.map((m) => sources[m.path])).slice(0, KEEP_NEAR_FILES);
292
+ // EVERY source file this finding touches, not the first five. The short list was written
293
+ // as "enough to orient, not enough to bury", and the things reading it are not orienting.
294
+ // `sealed.js` searches these names for the words nobody may wave through, so a finding
295
+ // whose sixth file was src/billing/refund.js was classified ordinary and became waivable;
296
+ // `intent.js` matches them against what the agent declared it was changing, and `cause.js`
297
+ // uses them to work out which edit caused what. A cap on the input to the one gate that
298
+ // cannot have a ceiling is the same bug that was closed for differences. Everything that
299
+ // DISPLAYS this list already cuts it itself and says "and N more" when it does.
300
+ const nearFiles = unique(members.map((m) => sources[m.path]));
198
301
  const evidence = members.find((m) => typeof m.evidence === 'string' && m.evidence.length > 0)?.evidence;
199
302
  // Half the differences in a rename are the "vanished" side, so the count of
200
303
  // places is the count of pairs, not of rows.
@@ -225,7 +328,16 @@ function buildFinding(signature, members, rename, sources) {
225
328
  // everything downstream — the MCP reply lists them, and the self-check corpus
226
329
  // matches its patterns against them — so they are filled in here rather than
227
330
  // left for each reader to dig out of `differences` in its own way.
228
- paths: members.map((m) => m.path).slice(0, KEEP_PATHS),
331
+ // EVERY address, not the first twenty. The short list was there to keep a finding small,
332
+ // and it was buying nothing: the finding already carries `differences`, which holds the
333
+ // same addresses AND both values at each of them, so cutting this list saved a fraction
334
+ // of what was being stored anyway. What it cost was real. A waiver is pinned partly to
335
+ // this list, so two three-hundred-address findings that agreed about their first twenty
336
+ // pinned to the same thing; and the reply an agent reads prints the length of this list
337
+ // under the heading "every address that moved", which was a count of twenty about a
338
+ // finding with three hundred. Readers that want a short list still cut it themselves,
339
+ // and every one of them says "and N more" when it does.
340
+ paths: members.map((m) => m.path),
229
341
  sample: head,
230
342
  };
231
343
  if (nearFiles.length > 0) finding.nearFiles = nearFiles;
@@ -259,8 +371,23 @@ export function describe(d, count, rename, identical = true) {
259
371
  if (rename) return `${where}, "${rename.from}" is now called "${rename.to}".${spread}`;
260
372
 
261
373
  switch (d.kind) {
262
- case 'changed':
263
- return `${where}, "${name}" is now ${describeValue(d.candidate)} where it was ${describeValue(d.reference)}.${spread}`;
374
+ case 'changed': {
375
+ const now = describeValue(d.candidate);
376
+ const was = describeValue(d.reference);
377
+ if (now !== was) return `${where}, "${name}" is now ${now} where it was ${was}.${spread}`;
378
+ // BOTH SIDES CAME OUT IN THE SAME WORDS, so this sentence would say nothing changed
379
+ // while sitting on top of a difference. It is what happens whenever the two values
380
+ // are summarised by their SHAPE and the shape held still: an invoice line that went
381
+ // from "£49.99" to "49.99 GBP" read "is now a set of details (one field: line) where
382
+ // it was a set of details (one field: line)" — twice the same words, on the tool's own
383
+ // flagship example, in the paragraph a person reads rather than an agent. So the
384
+ // summary is put down and the thing that actually moved is named instead.
385
+ const moved = whatMoved(d.reference, d.candidate);
386
+ if (!moved) return `${where}, "${name}" changed, and both versions of it read the same at this length.${spread}`;
387
+ return moved.what === ''
388
+ ? `${where}, "${name}" now reads ${moved.now} where it read ${moved.was}.${spread}`
389
+ : `${where}, "${name}" now has "${moved.what}" reading ${moved.now} where it read ${moved.was}.${spread}`;
390
+ }
264
391
  case 'appeared':
265
392
  return `${where}, "${name}" is there now and was not before. It says ${describeValue(d.candidate)}.${spread}`;
266
393
  case 'vanished':
@@ -354,7 +481,86 @@ export function describeValue(value) {
354
481
  if (Array.isArray(value)) return `a list of ${value.length}`;
355
482
  const keys = Object.keys(/** @type {object} */ (value));
356
483
  if (keys.length === 0) return 'an empty set of details';
357
- return `a set of details (${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', and more' : ''})`;
484
+ // The field names are quoted and counted. Bare, they run into the sentence around them and
485
+ // stop looking like names at all: a shape whose fields are the words "a list of" and "each
486
+ // one" came out as "a set of details (a list of, each one)", which is not a thing anybody
487
+ // can picture. Quoted, it reads as what it is.
488
+ const shown = keys.slice(0, 4).map((k) => JSON.stringify(k)).join(', ');
489
+ return `a set of details (${keys.length === 1 ? 'one field' : `${keys.length} fields`}: ${shown}${keys.length > 4 ? ', and more' : ''})`;
490
+ }
491
+
492
+ /**
493
+ * The smallest thing that actually moved between two values.
494
+ *
495
+ * Only reached when a summary of the two whole values comes out identical, which is exactly
496
+ * when a summary is the wrong thing to print. It walks in until it finds the one field, or
497
+ * the one stretch of text, that is not the same, and hands back that piece with a name for
498
+ * it. `sameValue` is the tool's one comparison, so what counts as "not the same" here is
499
+ * what counts as a difference everywhere else.
500
+ *
501
+ * @param {ObservedValue|undefined} reference
502
+ * @param {ObservedValue|undefined} candidate
503
+ * @param {string[]} [trail]
504
+ * @returns {{what: string, was: string, now: string}|null}
505
+ */
506
+ function whatMoved(reference, candidate, trail = []) {
507
+ if (isSetOfDetails(reference) && isSetOfDetails(candidate)) {
508
+ for (const key of [...new Set([...Object.keys(reference), ...Object.keys(candidate)])].sort()) {
509
+ const a = /** @type {Record<string, any>} */ (reference)[key];
510
+ const b = /** @type {Record<string, any>} */ (candidate)[key];
511
+ if (sameValue(a, b)) continue;
512
+ return whatMoved(a, b, [...trail, key]);
513
+ }
514
+ return null;
515
+ }
516
+ if (Array.isArray(reference) && Array.isArray(candidate)) {
517
+ for (let i = 0; i < Math.max(reference.length, candidate.length); i += 1) {
518
+ if (sameValue(reference[i], candidate[i])) continue;
519
+ return whatMoved(reference[i], candidate[i], [...trail, `number ${i + 1}`]);
520
+ }
521
+ return null;
522
+ }
523
+ const what = trail.join(' / ');
524
+ if (typeof reference === 'string' && typeof candidate === 'string') {
525
+ // Two long strings summarise to their first sixty-odd characters, so if they agree that
526
+ // far they read the same however differently they end. A window round the first place
527
+ // they part company says what neither summary can.
528
+ const spot = firstDifference(reference, candidate);
529
+ return { what, was: JSON.stringify(spot.was), now: JSON.stringify(spot.now) };
530
+ }
531
+ const was = describeValue(reference);
532
+ const now = describeValue(candidate);
533
+ return was === now ? null : { what, was, now };
534
+ }
535
+
536
+ /**
537
+ * A window round the first character two pieces of text stop agreeing at, with enough either
538
+ * side to recognise the place.
539
+ *
540
+ * @param {string} a
541
+ * @param {string} b
542
+ * @returns {{was: string, now: string}}
543
+ */
544
+ function firstDifference(a, b) {
545
+ // Short enough to read whole, so read it whole. A window round the difference is only
546
+ // worth its ellipses when there is genuinely too much text to print.
547
+ if (a.length <= 70 && b.length <= 70) return { was: a, now: b };
548
+ let at = 0;
549
+ while (at < a.length && at < b.length && a[at] === b[at]) at += 1;
550
+ const from = Math.max(0, at - 20);
551
+ /** @param {string} text */
552
+ const window = (text) => `${from > 0 ? '…' : ''}${text.slice(from, at + 40)}${at + 40 < text.length ? '…' : ''}`;
553
+ return { was: window(a), now: window(b) };
554
+ }
555
+
556
+ /**
557
+ * A value made of named fields, as opposed to a list, a number, or a piece of text.
558
+ *
559
+ * @param {unknown} value
560
+ * @returns {value is Record<string, unknown>}
561
+ */
562
+ function isSetOfDetails(value) {
563
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
358
564
  }
359
565
 
360
566
  /**
@@ -98,7 +98,14 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
98
98
  * @property {string} [buildId]
99
99
  * @property {string[]} paths Non-contract observation addresses, and every
100
100
  * prefix of each, so a lookup is one set hit.
101
- * @property {string[]} [doors] Door keys the journey's steps name. See doorKey.
101
+ * @property {string[]} [doors] Door keys the journey's steps name, for steps that
102
+ * named a door with nothing to tell apart from
103
+ * another of the same name. See doorKey.
104
+ * @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
105
+ * enough to build one. A route step knows its verb,
106
+ * and GET /x and POST /x are two doors that share a
107
+ * doorKey — so a step that knows which one it knocked
108
+ * on lands here instead, and the other stays shut.
102
109
  * @property {string[]} [touchedFiles]
103
110
  * @property {string[]} [touchedFunctions] 'file:name', from the suite's own coverage.
104
111
  * @property {number} [functionsNotListed] Functions that ran and were cut from the list to
@@ -412,9 +419,21 @@ export function walkFromCapture(capture, journey) {
412
419
  paths: touched.paths,
413
420
  };
414
421
  if (journey?.steps) {
415
- walk.doors = journey.steps
416
- .filter((s) => typeof s.door === 'string' && typeof s.kind === 'string')
417
- .map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
422
+ // Two lists, because a doorKey is kind and name only. That is right for an IPC channel or
423
+ // an exported name, where the name IS the door; it is wrong for a route, where GET /basket
424
+ // and POST /basket share a key and are two different doors. A step that knows which one it
425
+ // knocked on says so with `doorDetail`, and goes in the exact list — otherwise walking GET
426
+ // would report POST as walked too, which is the coverage ledger lying in the one direction
427
+ // it must never lie in.
428
+ const named = journey.steps.filter((s) => typeof s.door === 'string' && typeof s.kind === 'string');
429
+ const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '');
430
+ const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '');
431
+ if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
432
+ if (exact.length > 0) {
433
+ walk.doorAddresses = exact.map((s) =>
434
+ doorAddress({ kind: String(s.kind), name: String(s.door), detail: String(s.doorDetail), file: typeof s.doorFile === 'string' ? s.doorFile : undefined }),
435
+ );
436
+ }
418
437
  }
419
438
  if (journey?.touched?.files) walk.touchedFiles = journey.touched.files;
420
439
  if (journey?.touched?.functions) walk.touchedFunctions = journey.touched.functions;
@@ -442,7 +461,7 @@ const ADDRESS_RULE = new Set(['ipc', 'route', 'export', 'env']);
442
461
  * @returns {{state: 'opened'|'reached', how: string}|null}
443
462
  */
444
463
  export function whatTheWalkDid(door, walk, paths) {
445
- if (walk.doors?.includes(doorKey(door))) {
464
+ if (walk.doorAddresses?.includes(door.address) || walk.doors?.includes(doorKey(door))) {
446
465
  return { state: 'opened', how: `"${walk.journey}" has a step that knocks on it directly.` };
447
466
  }
448
467
  if (ADDRESS_RULE.has(door.kind)) {
@@ -783,172 +802,6 @@ export async function ledger(store, product, opts = {}) {
783
802
  });
784
803
  }
785
804
 
786
- /**
787
- * The same picture, for one run.
788
- *
789
- * A verdict does not carry its observations, so on its own this can only report totals — and
790
- * it says `knows: 'counts only'` rather than pretending to a per-door answer it does not
791
- * have. Hand it the doors and the walks from that run and it upgrades to the full ledger.
792
- *
793
- * @param {Verdict} verdict
794
- * @param {{doors?: (Door|DoorFact)[], walks?: Walk[]}} [opts]
795
- * @returns {Ledger}
796
- */
797
- export function coverageOf(verdict, opts = {}) {
798
- /** @type {Coverage} */
799
- const coverage = verdict.coverage ?? { paths: 0, journeys: 0, byChannel: {}, gaps: [] };
800
- if (opts.doors && opts.walks) {
801
- const doors = opts.doors.map((d) => ('address' in d ? d : doorFact(d)));
802
- return buildLedger({
803
- product: verdict.product,
804
- doors,
805
- walks: opts.walks,
806
- byChannel: coverage.byChannel,
807
- captures: opts.walks.length,
808
- builds: 1,
809
- at: verdict.startedAt,
810
- caveats: [`This is one run — ${nameRun(verdict)} — not everything this tool has ever walked.`],
811
- gaps: coverage.gaps ?? [],
812
- });
813
- }
814
-
815
- const doors = coverage.doorsKnown ?? 0;
816
- const opened = coverage.doorsWalked ?? 0;
817
- /** @type {string[]} */
818
- const caveats = [
819
- `This is one run — ${nameRun(verdict)} — not everything this tool has ever walked.`,
820
- 'A verdict carries totals rather than addresses, so this cannot name which doors were left shut. Call ledger(store, product) for that.',
821
- ];
822
- if (verdict.mode === 'stored-record') {
823
- caveats.push(
824
- verdict.modeWarning
825
- ?? 'The old build was not booted. This run was compared against observations stored the last time it ran, which lets back in every difference that comes from the day being different.',
826
- );
827
- }
828
- if (doors === 0) {
829
- caveats.push('Nothing counted the doors on this run, so there is no denominator, and "nothing changed" here means only "nothing I looked at changed".');
830
- }
831
-
832
- return {
833
- product: verdict.product,
834
- at: verdict.startedAt,
835
- knows: 'counts only',
836
- doors,
837
- opened,
838
- reached: 0,
839
- never: Math.max(0, doors - opened),
840
- unwalkable: 0,
841
- work: Math.max(0, doors - opened),
842
- irreversible: 0,
843
- entries: [],
844
- byKind: {},
845
- journeys: coverage.journeys ?? 0,
846
- byJourneySource: {},
847
- byChannel: coverage.byChannel ?? {},
848
- captures: coverage.journeys ?? 0,
849
- builds: 1,
850
- caveats,
851
- gaps: coverage.gaps ?? [],
852
- };
853
- }
854
-
855
- /**
856
- * @param {Verdict} verdict
857
- * @returns {string}
858
- */
859
- function nameRun(verdict) {
860
- const candidate = verdict.candidate?.version || verdict.candidate?.id || 'this build';
861
- return `${candidate}, ${verdict.mode === 'paired' ? 'against the old build booted live' : 'against the stored record'}`;
862
- }
863
-
864
- // ---------------------------------------------------------------------------
865
- // Saying it out loud
866
- // ---------------------------------------------------------------------------
867
-
868
- /**
869
- * The ledger in plain English, honest and specific, one line each.
870
- *
871
- * The headline is the number nobody wants to publish, and it goes first on purpose:
872
- * "452 doors, 61 opened, 391 never opened. A clean result says nothing about those 391."
873
- *
874
- * There is no percentage anywhere in here, and that is not an oversight. A percentage
875
- * invites a target, a target invites gaming, and a gamed coverage number is worse than none
876
- * because somebody believes it.
877
- *
878
- * @param {Ledger} led
879
- * @returns {string[]} join with a space for a paragraph, or a newline for a list
880
- */
881
- export function describeCoverage(led) {
882
- /** @type {string[]} */
883
- const lines = [];
884
-
885
- if (led.doors === 0) {
886
- lines.push('Nothing here knows how many doors this product has, so there is no honest way to say how much of it was checked.');
887
- } else {
888
- const parts = [count(led.doors, 'door'), `${led.opened} opened`];
889
- if (led.reached > 0) parts.push(`${led.reached} in code that ran but never addressed`);
890
- parts.push(`${led.never} never opened`);
891
- lines.push(`${parts.join(', ')}.`);
892
- if (led.never > 0) lines.push(`A clean result says nothing about those ${led.never}.`);
893
- }
894
-
895
- if (led.unwalkable > 0) {
896
- const permanent = led.irreversible > 0
897
- ? `${led.irreversible} that would spend money, send a message or destroy something and are stopped at the call on purpose`
898
- : 'doors there is nothing to knock on';
899
- lines.push(
900
- `Of those ${led.never}, ${led.unwalkable} can never be opened from here — settings that are read rather than called, names built while the program runs, and ${permanent}. That leaves ${led.work} that could be covered and are not.`,
901
- );
902
- } else if (led.work > 0 && led.doors > 0) {
903
- lines.push(`All ${led.work} of the unopened ones could be covered.`);
904
- }
905
-
906
- // Every kind, not the worst four. There are five kinds of door in the whole tool, so
907
- // cutting the list saved one line and dropped a whole category of the product out of the
908
- // only sentence that says how much of it is covered.
909
- const kinds = Object.entries(led.byKind)
910
- .filter(([, k]) => k.doors > 0)
911
- .sort((a, b) => b[1].never - a[1].never)
912
- .map(([kind, k]) => `${k.opened} of ${k.doors} ${k.doors === 1 ? KIND_ONE[kind] ?? kind : KIND_MANY[kind] ?? kind}`);
913
- if (kinds.length > 0) lines.push(`By kind: ${kinds.join(', ')}.`);
914
-
915
- if (led.journeys === 0) {
916
- lines.push('No journey has ever been walked against this product, so nothing here rests on anything.');
917
- } else {
918
- const sources = Object.entries(led.byJourneySource)
919
- .filter(([, n]) => n > 0)
920
- .sort((a, b) => b[1] - a[1])
921
- .map(([source, n]) => `${n} ${SOURCE_PHRASE[source] ?? source}`);
922
- lines.push(
923
- `${count(led.journeys, 'journey')} produced ${count(led.captures, 'capture')}${sources.length > 0 ? ` — ${sources.join(', ')}` : ''}.`,
924
- );
925
- }
926
-
927
- for (const caveat of led.caveats) lines.push(caveat);
928
- if (led.gaps.length > 0) {
929
- lines.push(`${count(led.gaps.length, 'other thing')} could not be looked at, and each one says what would fix it.`);
930
- }
931
- return lines;
932
- }
933
-
934
- /** @type {Record<string, string>} */
935
- const SOURCE_PHRASE = {
936
- code: 'read out of the code',
937
- suite: "harvested from the project's own tests",
938
- recorded: 'recorded from a real session',
939
- explored: 'found by an agent exploring',
940
- unknown: 'of unrecorded origin',
941
- };
942
-
943
- /**
944
- * @param {number} n
945
- * @param {string} noun
946
- * @returns {string}
947
- */
948
- function count(n, noun) {
949
- return `${n} ${noun}${n === 1 ? '' : 's'}`;
950
- }
951
-
952
805
  // ---------------------------------------------------------------------------
953
806
  // The work queue
954
807
  // ---------------------------------------------------------------------------
@@ -958,7 +811,7 @@ function count(n, noun) {
958
811
  * @property {number} [worst] How many jobs to hand back. Default 12.
959
812
  * @property {boolean} [includeUnwalkable] Include doors nothing here could ever open. Off:
960
813
  * they belong in the honest total, not in a queue,
961
- * and describeCoverage names them anyway.
814
+ * and the ledger counts them either way.
962
815
  * @property {number} [minDoors] Ignore families smaller than this. Default 1.
963
816
  */
964
817
 
@@ -1108,11 +961,25 @@ export function toCoverage(led, opts = {}) {
1108
961
  const out = [...led.gaps];
1109
962
  if (led.doors > 0 && led.never > 0) {
1110
963
  out.push({
1111
- what: `${led.never} of this product's ${led.doors} doors have never been opened by this tool.`,
1112
- why: 'No journey reaches them, so a break behind one of them would not show up in any run — clean or otherwise.',
964
+ // A product with one door read "1 of this product's 1 doors have never been opened",
965
+ // which is the sentence a reader stops believing the rest of the report over. The count
966
+ // is the whole point of the line, so it is worth the four words it costs to say it in
967
+ // English.
968
+ what: led.doors === 1
969
+ ? "This product's only door has never been opened by this tool."
970
+ : led.never === 1
971
+ ? `1 of this product's ${led.doors} doors has never been opened by this tool.`
972
+ : `${led.never} of this product's ${led.doors} doors have never been opened by this tool.`,
973
+ why: led.never === 1
974
+ ? 'No journey reaches it, so a break behind it would not show up in any run — clean or otherwise.'
975
+ : 'No journey reaches them, so a break behind one of them would not show up in any run — clean or otherwise.',
1113
976
  unlockedBy: led.work > 0
1114
- ? `${led.work} of them could be covered by journeys that reach them — read out of your source, or named by hand in a journeys file.`
1115
- : 'Nothing. Every one of them is a door this tool cannot open from here, and each says why.',
977
+ ? led.work === 1
978
+ ? 'It could be covered by a journey that reaches it read out of your source, or named by hand in a journeys file.'
979
+ : `${led.work} of them could be covered by journeys that reach them — read out of your source, or named by hand in a journeys file.`
980
+ : led.never === 1
981
+ ? 'Nothing. It is a door this tool cannot open from here, and it says why.'
982
+ : 'Nothing. Every one of them is a door this tool cannot open from here, and each says why.',
1116
983
  channel: 'contract',
1117
984
  doors: led.never,
1118
985
  });