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.
- package/CHANGELOG.md +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/v2/coverage.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
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
|
-
|
|
1112
|
-
|
|
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
|
-
?
|
|
1115
|
-
|
|
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
|
});
|