staysfixed 0.3.0 → 0.4.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 (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,1116 @@
1
+ /**
2
+ * Coverage — and mostly, what was never looked at.
3
+ *
4
+ * The code reader gets 5,805 doors out of Terminal Deck in under two seconds without
5
+ * starting it, 452 of them IPC channels. Almost none of those doors have ever been opened
6
+ * by this tool. That is fine. What is not fine is a run that comes back clean and lets a
7
+ * person read it as "nothing changed", when what it actually said was "nothing I looked at
8
+ * changed" — and it never mentioned that it looked at forty of five thousand.
9
+ *
10
+ * That gap between the two sentences is the only thing this file exists to close.
11
+ *
12
+ * THREE RULES, AND THEY ARE THE WHOLE DESIGN.
13
+ *
14
+ * 1. A door read out of the source is NOT a door that was walked. The contract channel is
15
+ * how we learn a door exists; it can never be the evidence that anything opened it.
16
+ * Every observation on the `contract` channel is deliberately ignored when this file
17
+ * works out what was walked. Getting that backwards would report perfect coverage on a
18
+ * product nobody ever ran, which is the exact lie this file is here to prevent.
19
+ *
20
+ * 2. Never a percentage, and there is not one in this file. A percentage invites a target,
21
+ * a target invites gaming, and a gamed coverage number is worse than no number because
22
+ * somebody trusts it. Counts, and the names of the things that are missing.
23
+ *
24
+ * 3. Undercount rather than overcount. Where the evidence is ambiguous this file records
25
+ * the door as unopened and says why in a caveat. A ledger that flatters itself is a
26
+ * ledger that hides work.
27
+ *
28
+ * WHAT COUNTS AS HAVING OPENED A DOOR, strongest evidence first. Every entry carries the
29
+ * sentence saying which of these it rests on, because they are not equally good.
30
+ *
31
+ * a step A journey has a step naming exactly this door, and a capture exists for
32
+ * that journey. This is the tool knocking on the door on purpose, and it is
33
+ * the only evidence that works for every kind of door.
34
+ * an address Something was observed at the door's own address — `ipc.session:save…`
35
+ * came back from the running app. The product answered at its own door.
36
+ * a function The test suite's own coverage says this exact function ran. Exported names
37
+ * only, and it is as strong as the other two: the code ran.
38
+ * a file The file that declares the door executed, but nothing addressed the door
39
+ * itself. This is NOT counted as opened. It is its own weaker column,
40
+ * `reached`, and it is reported separately for exactly that reason.
41
+ *
42
+ * DOORS THAT CANNOT BE WALKED FROM HERE AT ALL are separated out rather than left sitting in
43
+ * the work queue looking like laziness: a setting is read and not opened, a name built while
44
+ * the program runs has nothing to knock on, and a door whose name says it charges a card is
45
+ * watched at the call and refused at the effect, permanently and on purpose.
46
+ */
47
+
48
+ import { asAddress } from './adapters/electron.js';
49
+ import { readContract, readFileRoutes, readPackageCommands } from './adapters/source.js';
50
+ import { familyOf, irreversibility, isRunnable } from './journeys/from-routes.js';
51
+ import { joinPath, splitPath } from './observation.js';
52
+ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store.js';
53
+
54
+ /** @typedef {import('./types.js').Channel} Channel */
55
+ /** @typedef {import('./types.js').Capture} Capture */
56
+ /** @typedef {import('./types.js').Coverage} Coverage */
57
+ /** @typedef {import('./types.js').CoverageGap} CoverageGap */
58
+ /** @typedef {import('./types.js').Journey} Journey */
59
+ /** @typedef {import('./types.js').JourneySource} JourneySource */
60
+ /** @typedef {import('./types.js').Observation} Observation */
61
+ /** @typedef {import('./types.js').Store} Store */
62
+ /** @typedef {import('./types.js').Verdict} Verdict */
63
+ /** @typedef {import('./adapters/source.js').Door} Door */
64
+
65
+ /**
66
+ * A journey that may have arrived with what the test suite says it touched.
67
+ * @typedef {Journey & {touched?: {files: string[], functions: string[], ranButNotListed?: number}}} JourneyWithTouch
68
+ */
69
+
70
+ /**
71
+ * A door, reduced to what the ledger needs and nothing else.
72
+ *
73
+ * Separate from `Door` because a ledger can be built from doors read live out of the source
74
+ * OR recovered from stored contract observations, and those two arrive in different shapes.
75
+ * Everything downstream sees only this.
76
+ *
77
+ * @typedef {object} DoorFact
78
+ * @property {'ipc'|'route'|'export'|'command'|'env'} kind
79
+ * @property {string} name
80
+ * @property {string} address Where an observation about this door would live.
81
+ * @property {string} [detail]
82
+ * @property {string} [file]
83
+ * @property {number} [line]
84
+ * @property {string} [via] How the code reader worked the name out.
85
+ * @property {boolean} [named] False when the name is built while the program runs.
86
+ * @property {boolean} [inTest]
87
+ * @property {string} [describe]
88
+ */
89
+
90
+ /**
91
+ * One walk that really happened: a journey that produced a capture, and everything that
92
+ * walk knows about what it touched.
93
+ *
94
+ * @typedef {object} Walk
95
+ * @property {string} journey
96
+ * @property {string} [at] ISO timestamp of the capture.
97
+ * @property {JourneySource} [source]
98
+ * @property {string} [buildId]
99
+ * @property {string[]} paths Non-contract observation addresses, and every
100
+ * prefix of each, so a lookup is one set hit.
101
+ * @property {string[]} [doors] Door keys the journey's steps name. See doorKey.
102
+ * @property {string[]} [touchedFiles]
103
+ * @property {string[]} [touchedFunctions] 'file:name', from the suite's own coverage.
104
+ * @property {number} [functionsNotListed] Functions that ran and were cut from the list to
105
+ * keep it readable. Every one of them is a door
106
+ * this ledger will call unopened when it was not.
107
+ */
108
+
109
+ /**
110
+ * One door, and what this tool has ever managed to do with it.
111
+ *
112
+ * @typedef {object} DoorEntry
113
+ * @property {string} address
114
+ * @property {'ipc'|'route'|'export'|'command'|'env'} kind
115
+ * @property {string} name
116
+ * @property {'opened'|'reached'|'never'} state
117
+ * @property {string} how One plain sentence. Always filled in, including
118
+ * for a door nothing has ever been near.
119
+ * @property {string[]} journeys Journeys that opened it.
120
+ * @property {string|null} lastWalkedAt ISO, or null.
121
+ * @property {boolean} walkable False when nothing here could ever open it.
122
+ * @property {string} [whyNot] Why not, in plain English. Set when walkable is false.
123
+ * @property {boolean} [irreversible] Opening it for real cannot be undone. Watched at
124
+ * the call, refused at the effect, forever.
125
+ * @property {string} [file]
126
+ * @property {number} [line]
127
+ * @property {string} [group] The family it belongs to, for the work queue.
128
+ * @property {string} [groupLabel]
129
+ */
130
+
131
+ /**
132
+ * What this tool has ever seen of a product's doors.
133
+ *
134
+ * @typedef {object} Ledger
135
+ * @property {string} product
136
+ * @property {string} at ISO, when the ledger was drawn up.
137
+ * @property {'per door'|'counts only'} knows Whether the entries name individual doors, or
138
+ * the ledger only had totals to work from.
139
+ * @property {number} doors Doors the code reader knows about.
140
+ * @property {number} opened
141
+ * @property {number} reached Code ran; the door itself was never addressed.
142
+ * @property {number} never
143
+ * @property {number} unwalkable Of `never`, the ones nothing here could ever open.
144
+ * @property {number} work never minus unwalkable. The queue that is real.
145
+ * @property {number} irreversible Doors watched at the call and refused at the effect.
146
+ * @property {DoorEntry[]} entries Empty when `knows` is 'counts only'.
147
+ * @property {Record<string, KindTally>} byKind
148
+ * @property {number} journeys Distinct journeys that produced a capture.
149
+ * @property {Record<string, number>} byJourneySource
150
+ * @property {Partial<Record<Channel, number>>} byChannel Observations per channel.
151
+ * @property {number} captures
152
+ * @property {number} builds
153
+ * @property {string[]} caveats Every reason this ledger is less exact than it
154
+ * looks. Never empty when a shortcut was taken.
155
+ * @property {CoverageGap[]} gaps Holes carried over from the captures themselves.
156
+ */
157
+
158
+ /**
159
+ * @typedef {object} KindTally
160
+ * @property {number} doors
161
+ * @property {number} opened
162
+ * @property {number} reached
163
+ * @property {number} never
164
+ */
165
+
166
+ /**
167
+ * One thing worth covering next, written as a job rather than a statistic.
168
+ *
169
+ * @typedef {object} WorkItem
170
+ * @property {string} group
171
+ * @property {string} what
172
+ * @property {string} why
173
+ * @property {string} howTo The concrete next move.
174
+ * @property {number} doors
175
+ * @property {number} openedHere How many of that family are already open.
176
+ * @property {string[]} examples Up to five door names, so it is not abstract.
177
+ * @property {string[]} files Where they live, commonest first.
178
+ * @property {number} rank Higher is more worth doing.
179
+ */
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Naming
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /** @type {Record<string, string>} */
186
+ const KIND_ONE = {
187
+ ipc: 'IPC channel', route: 'route', export: 'exported name', command: 'command',
188
+ env: 'setting it reads',
189
+ };
190
+
191
+ /** @type {Record<string, string>} */
192
+ const KIND_MANY = {
193
+ ipc: 'IPC channels', route: 'routes', export: 'exported names', command: 'commands',
194
+ env: 'settings it reads',
195
+ };
196
+
197
+ /**
198
+ * How much a never-opened door of each kind is worth covering.
199
+ *
200
+ * An IPC channel and a route are how the outside world reaches the product, so a break
201
+ * behind one is a break somebody hits. An exported name matters to whoever imports it. A
202
+ * setting is read rather than opened, and the contract channel already watches whether it
203
+ * disappears, so there is no journey to write and it scores nothing.
204
+ *
205
+ * @type {Record<string, number>}
206
+ */
207
+ const KIND_WEIGHT = { ipc: 10, route: 10, command: 6, export: 3, env: 0 };
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Doors
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Where an observation about this door would live.
215
+ *
216
+ * This has to agree exactly with `pathForDoor` in the source adapter, because the whole
217
+ * ledger is a join between the addresses the code reader writes down and the addresses the
218
+ * running product answers at. If those two ever drift apart, every door reads as never
219
+ * opened and the tool starts asking for work that is already done.
220
+ *
221
+ * @param {{kind: string, name: string, detail?: string, file?: string}} door
222
+ * @returns {string}
223
+ */
224
+ export function doorAddress(door) {
225
+ switch (door.kind) {
226
+ case 'ipc': return joinPath(['ipc', door.name]);
227
+ case 'route': return joinPath(['route', door.detail ?? '', door.name]);
228
+ case 'export': return joinPath(['export', door.file ?? '', door.name]);
229
+ case 'command': return joinPath(['cli', door.name]);
230
+ default: return joinPath(['proc', 'env', door.name]);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The identity two different sources use to mean the same door — a journey step, and a door
236
+ * read out of the code. Kind and name, and nothing else: the file a door is declared in can
237
+ * move without the door changing.
238
+ *
239
+ * @param {{kind: string, name: string}} door
240
+ * @returns {string}
241
+ */
242
+ export function doorKey(door) {
243
+ return `${door.kind} ${door.name}`;
244
+ }
245
+
246
+ /**
247
+ * A door from the code reader, reduced to what the ledger needs.
248
+ * @param {Door} door
249
+ * @returns {DoorFact}
250
+ */
251
+ export function doorFact(door) {
252
+ return {
253
+ kind: door.kind,
254
+ name: door.name,
255
+ address: doorAddress(door),
256
+ detail: door.detail,
257
+ file: door.file,
258
+ line: door.line,
259
+ via: door.via,
260
+ named: door.named,
261
+ inTest: door.inTest,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Recover the door list from stored contract observations.
267
+ *
268
+ * This is what makes `ledger(store, product)` work with nothing but a store: the addresses
269
+ * the code reader wrote down are still sitting in the captures, so the door list can be read
270
+ * back out of them without going anywhere near the project's source. It is poorer than
271
+ * reading the code again — a door added since the last capture is not in here at all — and
272
+ * the ledger says so in a caveat rather than letting it pass.
273
+ *
274
+ * @param {Observation[]} observations
275
+ * @returns {DoorFact[]}
276
+ */
277
+ export function doorsFromObservations(observations) {
278
+ /** @type {Map<string, DoorFact>} */
279
+ const found = new Map();
280
+ for (const o of observations) {
281
+ if (o.channel !== 'contract') continue;
282
+ const parts = splitPath(o.path);
283
+ if (parts[0] === 'count' || parts[1] === 'unreadable') continue;
284
+ /** @type {DoorFact|null} */
285
+ let door = null;
286
+ const last = parts[parts.length - 1];
287
+ if (parts[0] === 'ipc' && parts.length >= 3 && last === 'registered') {
288
+ door = { kind: 'ipc', name: parts.slice(1, -1).join('.'), address: '' };
289
+ } else if (parts[0] === 'route' && parts.length >= 4 && last === 'declared') {
290
+ door = { kind: 'route', detail: parts[1], name: parts.slice(2, -1).join('.'), address: '' };
291
+ } else if (parts[0] === 'cli' && parts.length >= 3 && last === 'declared') {
292
+ door = { kind: 'command', name: parts.slice(1, -1).join('.'), address: '' };
293
+ } else if (parts[0] === 'proc' && parts[1] === 'env' && parts.length >= 3) {
294
+ door = { kind: 'env', name: parts.slice(2).join('.'), address: '' };
295
+ } else if (parts[0] === 'export' && parts.length >= 3) {
296
+ door = { kind: 'export', file: parts[1], name: parts.slice(2).join('.'), address: '' };
297
+ }
298
+ if (!door) continue;
299
+ door.address = o.path.replace(/\.(registered|declared)$/, '');
300
+ door.named = !(typeof o.value === 'string' && o.value.startsWith('there, but we cannot read its name'));
301
+ if (o.meta?.source) door.file = o.meta.source;
302
+ if (o.meta?.line !== undefined) door.line = o.meta.line;
303
+ if (o.meta?.describe) door.describe = o.meta.describe;
304
+ const key = doorKey(door);
305
+ if (!found.has(key)) found.set(key, door);
306
+ }
307
+ return [...found.values()];
308
+ }
309
+
310
+ /**
311
+ * Could anything here ever open this door, and if not, why not?
312
+ *
313
+ * Kept apart from "has it been opened" on purpose. A door nobody has walked is work; a door
314
+ * nothing can walk is a permanent hole. Mixing the two produces a queue that never empties,
315
+ * and a queue that never empties gets ignored.
316
+ *
317
+ * @param {DoorFact} door
318
+ * @returns {{walkable: boolean, whyNot?: string, irreversible?: boolean}}
319
+ */
320
+ export function walkability(door) {
321
+ if (door.named === false) {
322
+ return {
323
+ walkable: false,
324
+ whyNot: 'Its name is worked out while the program runs, so there is nothing to knock on. The contract channel still notices if it disappears.',
325
+ };
326
+ }
327
+ if (door.kind === 'env') {
328
+ return {
329
+ walkable: false,
330
+ whyNot: 'A setting is read, not opened. The contract channel watches whether the product still reads it, and that is everything this kind of door has to give.',
331
+ };
332
+ }
333
+ if (door.kind === 'command' && !isRunnable(asDoor(door))) {
334
+ return {
335
+ walkable: false,
336
+ whyNot: 'It is a flag rather than a program. A flag changes what a command does; it is not something a journey can walk through on its own.',
337
+ };
338
+ }
339
+ const risk = irreversibility(asDoor(door));
340
+ if (risk.irreversible) {
341
+ return {
342
+ walkable: false,
343
+ irreversible: true,
344
+ whyNot: `${risk.why} It is watched at the call and stopped there, so the call going out can be compared but the door is never really opened. That is deliberate and permanent.`,
345
+ };
346
+ }
347
+ return { walkable: true };
348
+ }
349
+
350
+ /**
351
+ * A DoorFact in the shape the code reader's own helpers expect.
352
+ * @param {DoorFact} door
353
+ * @returns {Door}
354
+ */
355
+ function asDoor(door) {
356
+ return {
357
+ kind: door.kind,
358
+ name: door.name,
359
+ detail: door.detail ?? '',
360
+ file: door.file ?? '',
361
+ line: door.line ?? 0,
362
+ inTest: door.inTest ?? false,
363
+ named: door.named ?? true,
364
+ via: door.via ?? '',
365
+ };
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // What a walk touched
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /**
373
+ * Every address a capture observed, plus every prefix of each, so asking "did anything
374
+ * happen at this door" is one set lookup instead of a scan of every path.
375
+ *
376
+ * Contract observations are dropped here, and that single line is the honesty of the whole
377
+ * file: reading a door out of the source is how we know it exists, never evidence that
378
+ * anybody opened it.
379
+ *
380
+ * @param {Observation[]} observations
381
+ * @returns {{paths: string[], byChannel: Partial<Record<Channel, number>>}}
382
+ */
383
+ export function addressesTouched(observations) {
384
+ /** @type {Set<string>} */
385
+ const paths = new Set();
386
+ /** @type {Partial<Record<Channel, number>>} */
387
+ const byChannel = {};
388
+ for (const o of observations) {
389
+ byChannel[o.channel] = (byChannel[o.channel] ?? 0) + 1;
390
+ if (o.channel === 'contract') continue;
391
+ const parts = String(o.path).split('.');
392
+ for (let i = 1; i <= parts.length; i++) paths.add(parts.slice(0, i).join('.'));
393
+ }
394
+ return { paths: [...paths], byChannel };
395
+ }
396
+
397
+ /**
398
+ * A walk, built from one stored capture and, when it is to hand, the journey behind it.
399
+ *
400
+ * @param {Capture} capture
401
+ * @param {JourneyWithTouch} [journey]
402
+ * @returns {Walk}
403
+ */
404
+ export function walkFromCapture(capture, journey) {
405
+ const touched = addressesTouched(capture.observations);
406
+ /** @type {Walk} */
407
+ const walk = {
408
+ journey: capture.journey,
409
+ at: capture.startedAt,
410
+ source: capture.source ?? journey?.source,
411
+ buildId: capture.build?.id,
412
+ paths: touched.paths,
413
+ };
414
+ 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) }));
418
+ }
419
+ if (journey?.touched?.files) walk.touchedFiles = journey.touched.files;
420
+ if (journey?.touched?.functions) walk.touchedFunctions = journey.touched.functions;
421
+ if (journey?.touched?.ranButNotListed) walk.functionsNotListed = journey.touched.ranButNotListed;
422
+ return walk;
423
+ }
424
+
425
+ /**
426
+ * Kinds where an observation sharing a door's address really is that door.
427
+ *
428
+ * `command` is missing on purpose. A command's runtime observations are addressed
429
+ * `cli.<journey name>…`, not `cli.<command name>…`, so a journey that happened to be named
430
+ * after a command would count that command as walked when nothing had run it. Undercounting
431
+ * a command leaves a job on the queue; overcounting one is a lie, so the address rule is
432
+ * switched off for commands and only a journey step can open one.
433
+ */
434
+ const ADDRESS_RULE = new Set(['ipc', 'route', 'export', 'env']);
435
+
436
+ /**
437
+ * What one walk did with one door, or null if it did nothing with it.
438
+ *
439
+ * @param {DoorFact} door
440
+ * @param {Walk} walk
441
+ * @param {Set<string>} paths walk.paths, as a set.
442
+ * @returns {{state: 'opened'|'reached', how: string}|null}
443
+ */
444
+ export function whatTheWalkDid(door, walk, paths) {
445
+ if (walk.doors?.includes(doorKey(door))) {
446
+ return { state: 'opened', how: `"${walk.journey}" has a step that knocks on it directly.` };
447
+ }
448
+ if (ADDRESS_RULE.has(door.kind)) {
449
+ if (paths.has(door.address) || paths.has(trimmedAddress(door))) {
450
+ return { state: 'opened', how: `"${walk.journey}" saw the product answer at its own address.` };
451
+ }
452
+ if (door.kind === 'export' && paths.has(joinPath(['export', walk.journey, door.name]))) {
453
+ return { state: 'opened', how: `"${walk.journey}" read it off the module's exported surface.` };
454
+ }
455
+ }
456
+ if (door.kind === 'export' && door.file && walk.touchedFunctions?.includes(`${door.file}:${door.name}`)) {
457
+ return { state: 'opened', how: `"${walk.journey}" ran it — the test suite's own coverage says the function executed.` };
458
+ }
459
+ if (door.file && walk.touchedFiles?.includes(door.file)) {
460
+ return {
461
+ state: 'reached',
462
+ how: `"${walk.journey}" ran ${door.file}, so the code around it executed, but nothing addressed this door itself.`,
463
+ };
464
+ }
465
+ return null;
466
+ }
467
+
468
+ /**
469
+ * The address a running product would actually use, for a name long enough to have been cut
470
+ * short on the way into a path. Adapters shorten a long segment; the code reader does not,
471
+ * so the two only ever meet if the ledger shortens too.
472
+ *
473
+ * @param {DoorFact} door
474
+ * @returns {string}
475
+ */
476
+ function trimmedAddress(door) {
477
+ if (door.kind !== 'ipc') return door.address;
478
+ return joinPath(['ipc', asAddress(door.name)]);
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // The ledger
483
+ // ---------------------------------------------------------------------------
484
+
485
+ /**
486
+ * @typedef {object} LedgerInput
487
+ * @property {string} product
488
+ * @property {DoorFact[]} doors
489
+ * @property {Walk[]} walks
490
+ * @property {Partial<Record<Channel, number>>} [byChannel]
491
+ * @property {number} [captures]
492
+ * @property {number} [builds]
493
+ * @property {string[]} [caveats]
494
+ * @property {CoverageGap[]} [gaps]
495
+ * @property {string} [at]
496
+ */
497
+
498
+ /**
499
+ * Draw up the ledger. Pure: hand it doors and walks, get the answer — no disk, and no clock
500
+ * beyond the one stamp saying when. Everything that touches a store lives in {@link ledger},
501
+ * so this half can be tested with lists written by hand.
502
+ *
503
+ * @param {LedgerInput} input
504
+ * @returns {Ledger}
505
+ */
506
+ export function buildLedger(input) {
507
+ /** @type {DoorEntry[]} */
508
+ const entries = [];
509
+ const walks = input.walks.map((walk) => ({ walk, paths: new Set(walk.paths) }));
510
+
511
+ /** @type {Record<string, KindTally>} */
512
+ const byKind = {};
513
+ /** @type {Record<string, number>} */
514
+ const byJourneySource = {};
515
+ let opened = 0;
516
+ let reached = 0;
517
+ let never = 0;
518
+ let unwalkable = 0;
519
+ let irreversibleDoors = 0;
520
+
521
+ for (const door of input.doors) {
522
+ const can = walkability(door);
523
+ /** @type {{journey: string, at?: string, how: string, state: 'opened'|'reached'}[]} */
524
+ const hits = [];
525
+ for (const { walk, paths } of walks) {
526
+ const did = whatTheWalkDid(door, walk, paths);
527
+ if (did) hits.push({ journey: walk.journey, at: walk.at, how: did.how, state: did.state });
528
+ }
529
+ const openedBy = hits.filter((h) => h.state === 'opened');
530
+ const reachedBy = hits.filter((h) => h.state === 'reached');
531
+ /** @type {DoorEntry['state']} */
532
+ const state = openedBy.length > 0 ? 'opened' : reachedBy.length > 0 ? 'reached' : 'never';
533
+ const evidence = openedBy.length > 0 ? openedBy : reachedBy;
534
+ const best = evidence[0];
535
+ const stamps = evidence.map((h) => h.at).filter((at) => typeof at === 'string').sort();
536
+
537
+ const family = familyOf(asDoor(door));
538
+
539
+ /** @type {DoorEntry} */
540
+ const entry = {
541
+ address: door.address,
542
+ kind: door.kind,
543
+ name: door.name,
544
+ state,
545
+ how: best
546
+ ? best.how
547
+ : can.walkable
548
+ ? `Nothing has ever opened it, so a break behind this ${KIND_ONE[door.kind] ?? 'door'} would not be seen.`
549
+ : /** @type {string} */ (can.whyNot),
550
+ journeys: [...new Set(evidence.map((h) => h.journey))],
551
+ lastWalkedAt: stamps.length > 0 ? /** @type {string} */ (stamps[stamps.length - 1]) : null,
552
+ walkable: can.walkable,
553
+ group: family.group,
554
+ groupLabel: family.label,
555
+ };
556
+ if (!can.walkable) entry.whyNot = can.whyNot;
557
+ if (can.irreversible) entry.irreversible = true;
558
+ if (door.file) entry.file = door.file;
559
+ if (door.line !== undefined) entry.line = door.line;
560
+ entries.push(entry);
561
+
562
+ const tally = byKind[door.kind] ?? { doors: 0, opened: 0, reached: 0, never: 0 };
563
+ byKind[door.kind] = tally;
564
+ tally.doors++;
565
+ if (state === 'opened') { opened++; tally.opened++; }
566
+ else if (state === 'reached') { reached++; tally.reached++; }
567
+ else { never++; tally.never++; }
568
+ if (state === 'never' && !can.walkable) unwalkable++;
569
+ if (can.irreversible) irreversibleDoors++;
570
+ }
571
+
572
+ for (const { walk } of walks) {
573
+ const source = walk.source ?? 'unknown';
574
+ byJourneySource[source] = (byJourneySource[source] ?? 0) + 1;
575
+ }
576
+
577
+ const caveats = [...(input.caveats ?? [])];
578
+ const namedSteps = walks.some(({ walk }) => (walk.doors?.length ?? 0) > 0);
579
+ const knewFunctions = walks.some(({ walk }) => (walk.touchedFunctions?.length ?? 0) > 0);
580
+ const blind = (byKind.route?.never ?? 0) + (byKind.command?.never ?? 0);
581
+ if (walks.length > 0 && !namedSteps && blind > 0) {
582
+ caveats.push(
583
+ `No journey here has steps that name the door they knock on, so a door counts as opened only when something was observed at its own address. A route and a command are both addressed to the journey rather than to the door, so those ${blind} are undercounted. Walk the journeys the code reader generates and the answer becomes exact.`,
584
+ );
585
+ }
586
+ if (walks.length > 0 && !knewFunctions && (byKind.export?.never ?? 0) > 0) {
587
+ caveats.push(
588
+ `Nothing here knows which functions the test suite ran, so an exported name counts as opened only if a journey addressed it directly — which is why ${byKind.export?.never} of them read as never opened. Install the test runner's coverage package and the suite says exactly what it touched.`,
589
+ );
590
+ }
591
+ const cutFunctions = walks.reduce((n, { walk }) => n + (walk.functionsNotListed ?? 0), 0);
592
+ if (cutFunctions > 0) {
593
+ caveats.push(
594
+ `${cutFunctions} functions that really did run were cut from the coverage lists to keep them readable, so up to that many of the doors counted as never opened were in fact opened. This ledger undercounts, and it undercounts by no more than ${cutFunctions}.`,
595
+ );
596
+ }
597
+ if (input.doors.length === 0) {
598
+ caveats.push('No doors are known at all, so this ledger cannot say what is uncovered — which is not the same as there being nothing uncovered.');
599
+ }
600
+ if (walks.length === 0) {
601
+ caveats.push('Nothing has ever been walked against this product, so every door here is unopened by definition and no run has proved anything about any of them.');
602
+ }
603
+
604
+ return {
605
+ product: input.product,
606
+ at: input.at ?? new Date().toISOString(),
607
+ knows: 'per door',
608
+ doors: input.doors.length,
609
+ opened,
610
+ reached,
611
+ never,
612
+ unwalkable,
613
+ work: never - unwalkable,
614
+ irreversible: irreversibleDoors,
615
+ entries,
616
+ byKind,
617
+ journeys: new Set(walks.map(({ walk }) => walk.journey)).size,
618
+ byJourneySource,
619
+ byChannel: input.byChannel ?? {},
620
+ captures: input.captures ?? walks.length,
621
+ builds: input.builds ?? 0,
622
+ caveats,
623
+ gaps: input.gaps ?? [],
624
+ };
625
+ }
626
+
627
+ /**
628
+ * @typedef {object} LedgerOptions
629
+ * @property {Door[]} [doors] The code reader's own output. The best answer there is.
630
+ * @property {string} [root] Read the code now to get the doors. Reads, runs nothing.
631
+ * @property {JourneyWithTouch[]} [journeys]
632
+ * The journeys behind the captures. With these, a door
633
+ * is matched by the step that knocks on it, which is
634
+ * exact; without them the ledger falls back to addresses
635
+ * and says so out loud.
636
+ * @property {number} [maxBuilds] How far back to look. Default 20, newest first.
637
+ * @property {string[]} [builds] Exactly these build ids, instead of the newest.
638
+ * @property {boolean} [includeTests] Count doors registered inside test files. Off: a fake
639
+ * registration in a test is not a door the product answers on.
640
+ * @property {(message: string) => void} [log]
641
+ */
642
+
643
+ /**
644
+ * Everything this tool has ever managed to walk of one product, door by door.
645
+ *
646
+ * Works with nothing but a store, because the door list can be recovered from the contract
647
+ * observations already sitting in the captures. Hand it `root` or `doors` and it gets
648
+ * better: a door added since the last capture is invisible to the store-only answer, and
649
+ * the ledger names which of the two it used.
650
+ *
651
+ * @param {Store} store
652
+ * @param {string} product
653
+ * @param {LedgerOptions} [opts]
654
+ * @returns {Promise<Ledger>}
655
+ */
656
+ export async function ledger(store, product, opts = {}) {
657
+ const log = opts.log ?? (() => {});
658
+ /** @type {string[]} */
659
+ const caveats = [];
660
+ /** @type {CoverageGap[]} */
661
+ const holes = [];
662
+
663
+ const all = await listBuilds(store, { product });
664
+ const wanted = opts.builds
665
+ ? all.filter((b) => opts.builds?.includes(b.fingerprint.id))
666
+ : all.slice(0, opts.maxBuilds ?? 20);
667
+ if (!opts.builds && all.length > wanted.length) {
668
+ caveats.push(
669
+ `Only the newest ${wanted.length} of this product's ${all.length} builds were read. A door opened once, longer ago than that, reads here as never opened.`,
670
+ );
671
+ }
672
+
673
+ /** @type {Map<string, JourneyWithTouch>} */
674
+ const byName = new Map();
675
+ for (const journey of opts.journeys ?? []) byName.set(journey.name, journey);
676
+
677
+ /** @type {Walk[]} */
678
+ const walks = [];
679
+ /** @type {Observation[]} */
680
+ const contractSeen = [];
681
+ /** @type {Partial<Record<Channel, number>>} */
682
+ const byChannel = {};
683
+ let captures = 0;
684
+
685
+ for (const build of wanted) {
686
+ const refs = await listCaptures(store, { buildId: build.fingerprint.id });
687
+ for (const ref of refs) {
688
+ /** @type {Capture|null} */
689
+ let capture = null;
690
+ try {
691
+ capture = await loadCapture(store, ref);
692
+ } catch {
693
+ capture = null;
694
+ }
695
+ if (!capture) {
696
+ holes.push({
697
+ what: `One stored record of "${ref.journey}" could not be read.`,
698
+ why: 'The file is missing or unreadable, so whatever that walk saw is not counted here.',
699
+ unlockedBy: 'Walk the journey again; a good capture replaces the unreadable one.',
700
+ });
701
+ continue;
702
+ }
703
+ captures++;
704
+ log(`Reading ${ref.journey} from ${build.fingerprint.id}.`);
705
+ if (capture.complete === false) {
706
+ holes.push({
707
+ what: `The record of "${capture.journey}" was read back torn.`,
708
+ why: 'The run that wrote it stopped partway, so some of what it walked is missing from this ledger.',
709
+ unlockedBy: 'Walk it again; a complete capture replaces the torn one.',
710
+ });
711
+ }
712
+ const touched = addressesTouched(capture.observations);
713
+ if (!opts.doors && !opts.root) {
714
+ for (const o of capture.observations) if (o.channel === 'contract') contractSeen.push(o);
715
+ }
716
+ for (const [channel, n] of Object.entries(touched.byChannel)) {
717
+ const key = /** @type {Channel} */ (channel);
718
+ byChannel[key] = (byChannel[key] ?? 0) + n;
719
+ }
720
+ walks.push(walkFromCapture(capture, byName.get(capture.journey)));
721
+ for (const gap of capture.coverage?.gaps ?? []) holes.push(gap);
722
+ }
723
+ }
724
+
725
+ /** @type {DoorFact[]} */
726
+ let doors;
727
+ if (opts.doors) {
728
+ doors = opts.doors.map(doorFact);
729
+ caveats.push('The doors were handed in by the code reader as this ledger was drawn up, so it knows about doors added since the last run.');
730
+ } else if (opts.root) {
731
+ const reading = await readContract({ root: opts.root });
732
+ reading.doors.push(...(await readFileRoutes(opts.root)));
733
+ reading.doors.push(...(await readPackageCommands(opts.root)));
734
+ doors = reading.doors.map(doorFact);
735
+ caveats.push(`The code was read as this ledger was drawn up: ${reading.report.filesRead} files, ${reading.doors.length} doors, and nothing was run.`);
736
+ } else {
737
+ doors = doorsFromObservations(contractSeen);
738
+ caveats.push(
739
+ 'The door list came from what previous runs wrote down, not from the code as it stands now, so a door added since the last run is not in this ledger at all. Pass `root` and it reads the source instead.',
740
+ );
741
+ }
742
+ if (!opts.includeTests) {
743
+ const before = doors.length;
744
+ doors = doors.filter((d) => d.inTest !== true);
745
+ if (before > doors.length) {
746
+ caveats.push(`${before - doors.length} doors registered inside test files were left out: a fake registration in a test is not a door the product answers on.`);
747
+ }
748
+ }
749
+
750
+ const pointer = await referencePointer(store, product);
751
+ if (!pointer) {
752
+ caveats.push('This product has no reference build yet, so nothing here has ever been compared against a build somebody called working.');
753
+ }
754
+
755
+ return buildLedger({
756
+ product,
757
+ doors,
758
+ walks,
759
+ byChannel,
760
+ captures,
761
+ builds: wanted.length,
762
+ caveats,
763
+ gaps: dedupeGaps(holes),
764
+ });
765
+ }
766
+
767
+ /**
768
+ * The same picture, for one run.
769
+ *
770
+ * A verdict does not carry its observations, so on its own this can only report totals — and
771
+ * it says `knows: 'counts only'` rather than pretending to a per-door answer it does not
772
+ * have. Hand it the doors and the walks from that run and it upgrades to the full ledger.
773
+ *
774
+ * @param {Verdict} verdict
775
+ * @param {{doors?: (Door|DoorFact)[], walks?: Walk[]}} [opts]
776
+ * @returns {Ledger}
777
+ */
778
+ export function coverageOf(verdict, opts = {}) {
779
+ /** @type {Coverage} */
780
+ const coverage = verdict.coverage ?? { paths: 0, journeys: 0, byChannel: {}, gaps: [] };
781
+ if (opts.doors && opts.walks) {
782
+ const doors = opts.doors.map((d) => ('address' in d ? d : doorFact(d)));
783
+ return buildLedger({
784
+ product: verdict.product,
785
+ doors,
786
+ walks: opts.walks,
787
+ byChannel: coverage.byChannel,
788
+ captures: opts.walks.length,
789
+ builds: 1,
790
+ at: verdict.startedAt,
791
+ caveats: [`This is one run — ${nameRun(verdict)} — not everything this tool has ever walked.`],
792
+ gaps: coverage.gaps ?? [],
793
+ });
794
+ }
795
+
796
+ const doors = coverage.doorsKnown ?? 0;
797
+ const opened = coverage.doorsWalked ?? 0;
798
+ /** @type {string[]} */
799
+ const caveats = [
800
+ `This is one run — ${nameRun(verdict)} — not everything this tool has ever walked.`,
801
+ 'A verdict carries totals rather than addresses, so this cannot name which doors were left shut. Call ledger(store, product) for that.',
802
+ ];
803
+ if (verdict.mode === 'stored-record') {
804
+ caveats.push(
805
+ verdict.modeWarning
806
+ ?? '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.',
807
+ );
808
+ }
809
+ if (doors === 0) {
810
+ 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".');
811
+ }
812
+
813
+ return {
814
+ product: verdict.product,
815
+ at: verdict.startedAt,
816
+ knows: 'counts only',
817
+ doors,
818
+ opened,
819
+ reached: 0,
820
+ never: Math.max(0, doors - opened),
821
+ unwalkable: 0,
822
+ work: Math.max(0, doors - opened),
823
+ irreversible: 0,
824
+ entries: [],
825
+ byKind: {},
826
+ journeys: coverage.journeys ?? 0,
827
+ byJourneySource: {},
828
+ byChannel: coverage.byChannel ?? {},
829
+ captures: coverage.journeys ?? 0,
830
+ builds: 1,
831
+ caveats,
832
+ gaps: coverage.gaps ?? [],
833
+ };
834
+ }
835
+
836
+ /**
837
+ * @param {Verdict} verdict
838
+ * @returns {string}
839
+ */
840
+ function nameRun(verdict) {
841
+ const candidate = verdict.candidate?.version || verdict.candidate?.id || 'this build';
842
+ return `${candidate}, ${verdict.mode === 'paired' ? 'against the old build booted live' : 'against the stored record'}`;
843
+ }
844
+
845
+ // ---------------------------------------------------------------------------
846
+ // Saying it out loud
847
+ // ---------------------------------------------------------------------------
848
+
849
+ /**
850
+ * The ledger in plain English, honest and specific, one line each.
851
+ *
852
+ * The headline is the number nobody wants to publish, and it goes first on purpose:
853
+ * "452 doors, 61 opened, 391 never opened. A clean result says nothing about those 391."
854
+ *
855
+ * There is no percentage anywhere in here, and that is not an oversight. A percentage
856
+ * invites a target, a target invites gaming, and a gamed coverage number is worse than none
857
+ * because somebody believes it.
858
+ *
859
+ * @param {Ledger} led
860
+ * @returns {string[]} join with a space for a paragraph, or a newline for a list
861
+ */
862
+ export function describeCoverage(led) {
863
+ /** @type {string[]} */
864
+ const lines = [];
865
+
866
+ if (led.doors === 0) {
867
+ 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.');
868
+ } else {
869
+ const parts = [count(led.doors, 'door'), `${led.opened} opened`];
870
+ if (led.reached > 0) parts.push(`${led.reached} in code that ran but never addressed`);
871
+ parts.push(`${led.never} never opened`);
872
+ lines.push(`${parts.join(', ')}.`);
873
+ if (led.never > 0) lines.push(`A clean result says nothing about those ${led.never}.`);
874
+ }
875
+
876
+ if (led.unwalkable > 0) {
877
+ const permanent = led.irreversible > 0
878
+ ? `${led.irreversible} that would spend money, send a message or destroy something and are stopped at the call on purpose`
879
+ : 'doors there is nothing to knock on';
880
+ lines.push(
881
+ `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.`,
882
+ );
883
+ } else if (led.work > 0 && led.doors > 0) {
884
+ lines.push(`All ${led.work} of the unopened ones could be covered.`);
885
+ }
886
+
887
+ const kinds = Object.entries(led.byKind)
888
+ .filter(([, k]) => k.doors > 0)
889
+ .sort((a, b) => b[1].never - a[1].never)
890
+ .slice(0, 4)
891
+ .map(([kind, k]) => `${k.opened} of ${k.doors} ${k.doors === 1 ? KIND_ONE[kind] ?? kind : KIND_MANY[kind] ?? kind}`);
892
+ if (kinds.length > 0) lines.push(`By kind: ${kinds.join(', ')}.`);
893
+
894
+ if (led.journeys === 0) {
895
+ lines.push('No journey has ever been walked against this product, so nothing here rests on anything.');
896
+ } else {
897
+ const sources = Object.entries(led.byJourneySource)
898
+ .filter(([, n]) => n > 0)
899
+ .sort((a, b) => b[1] - a[1])
900
+ .map(([source, n]) => `${n} ${SOURCE_PHRASE[source] ?? source}`);
901
+ lines.push(
902
+ `${count(led.journeys, 'journey')} produced ${count(led.captures, 'capture')}${sources.length > 0 ? ` — ${sources.join(', ')}` : ''}.`,
903
+ );
904
+ }
905
+
906
+ for (const caveat of led.caveats) lines.push(caveat);
907
+ if (led.gaps.length > 0) {
908
+ lines.push(`${count(led.gaps.length, 'other thing')} could not be looked at, and each one says what would fix it.`);
909
+ }
910
+ return lines;
911
+ }
912
+
913
+ /** @type {Record<string, string>} */
914
+ const SOURCE_PHRASE = {
915
+ code: 'read out of the code',
916
+ suite: "harvested from the project's own tests",
917
+ recorded: 'recorded from a real session',
918
+ explored: 'found by an agent exploring',
919
+ unknown: 'of unrecorded origin',
920
+ };
921
+
922
+ /**
923
+ * @param {number} n
924
+ * @param {string} noun
925
+ * @returns {string}
926
+ */
927
+ function count(n, noun) {
928
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
929
+ }
930
+
931
+ // ---------------------------------------------------------------------------
932
+ // The work queue
933
+ // ---------------------------------------------------------------------------
934
+
935
+ /**
936
+ * @typedef {object} GapsOptions
937
+ * @property {number} [worst] How many jobs to hand back. Default 12.
938
+ * @property {boolean} [includeUnwalkable] Include doors nothing here could ever open. Off:
939
+ * they belong in the honest total, not in a queue,
940
+ * and describeCoverage names them anyway.
941
+ * @property {number} [minDoors] Ignore families smaller than this. Default 1.
942
+ */
943
+
944
+ /**
945
+ * The doors most worth covering next, grouped into jobs and ranked.
946
+ *
947
+ * A list of five thousand unopened doors is a wall, and a wall gets ignored. The same doors
948
+ * grouped by family — the IPC channels that start with "session", the routes under /api/deck,
949
+ * what the files in src/main/store export — is a morning's work with a beginning and an end.
950
+ *
951
+ * Ranked by how much the kind of door matters, how many of them are dark, and hardest of
952
+ * all, whether the WHOLE family is dark. A family with nothing walked is a part of the
953
+ * product this tool has never once seen, and that is worth more than another door in an area
954
+ * it already knows something about.
955
+ *
956
+ * @param {Ledger} led
957
+ * @param {GapsOptions} [opts]
958
+ * @returns {WorkItem[]}
959
+ */
960
+ export function gaps(led, opts = {}) {
961
+ const worst = opts.worst ?? 12;
962
+ const minDoors = opts.minDoors ?? 1;
963
+
964
+ /** @type {Map<string, {label: string, kind: string, never: DoorEntry[], opened: number, reached: number, files: Map<string, number>}>} */
965
+ const families = new Map();
966
+ for (const entry of led.entries) {
967
+ if (!opts.includeUnwalkable && !entry.walkable) continue;
968
+ const key = entry.group ?? entry.kind;
969
+ const family = families.get(key) ?? {
970
+ label: entry.groupLabel ?? KIND_MANY[entry.kind] ?? entry.kind,
971
+ kind: entry.kind,
972
+ never: /** @type {DoorEntry[]} */ ([]),
973
+ opened: 0,
974
+ reached: 0,
975
+ files: /** @type {Map<string, number>} */ (new Map()),
976
+ };
977
+ if (entry.state === 'opened') family.opened++;
978
+ else if (entry.state === 'reached') family.reached++;
979
+ else family.never.push(entry);
980
+ if (entry.file) family.files.set(entry.file, (family.files.get(entry.file) ?? 0) + 1);
981
+ families.set(key, family);
982
+ }
983
+
984
+ // Telling somebody to harvest a suite that has already been harvested is the kind of
985
+ // advice that gets a tool switched off, so the queue checks first.
986
+ const harvested = (led.byJourneySource.suite ?? 0) > 0;
987
+
988
+ /** @type {WorkItem[]} */
989
+ const jobs = [];
990
+ for (const [group, family] of families) {
991
+ if (family.never.length < minDoors) continue;
992
+ const total = family.never.length + family.opened + family.reached;
993
+ const allDark = family.opened === 0;
994
+ const weight = KIND_WEIGHT[family.kind] ?? 3;
995
+ // Size counts, but under a square root, so one family of four hundred cannot bury twenty
996
+ // families of ten that between them cover far more of the product.
997
+ const rank = Math.round(
998
+ weight * Math.sqrt(family.never.length) * (allDark ? 2 : 1) + (family.reached > 0 ? 2 : 0),
999
+ );
1000
+ jobs.push({
1001
+ group,
1002
+ what: `${family.label} — ${family.never.length} of ${total} never opened.`,
1003
+ why: allDark
1004
+ ? `Nothing has ever walked any of this. If it broke, no run of this tool would notice.${
1005
+ family.reached > 0
1006
+ ? ` ${family.reached} of them sit in code the tests do run, so the break would be right beside a path that looks covered.`
1007
+ : ''
1008
+ }`
1009
+ : `${family.opened} of them are covered and ${family.never.length} are not, so a clean run here means less than it looks like it does.`,
1010
+ howTo: howToCover(family.kind, family.never, harvested),
1011
+ doors: family.never.length,
1012
+ openedHere: family.opened,
1013
+ examples: family.never.slice(0, 5).map((e) => e.name),
1014
+ files: [...family.files.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([file]) => file),
1015
+ rank,
1016
+ });
1017
+ }
1018
+
1019
+ return jobs.sort((a, b) => (b.rank === a.rank ? b.doors - a.doors : b.rank - a.rank)).slice(0, worst);
1020
+ }
1021
+
1022
+ /**
1023
+ * The concrete next move for a family of unopened doors. Written for whoever reads it next,
1024
+ * which is usually an agent and occasionally a person, and never assumes either of them has
1025
+ * read any documentation.
1026
+ *
1027
+ * @param {string} kind
1028
+ * @param {DoorEntry[]} never
1029
+ * @param {boolean} [harvested] The project's own tests have already been harvested, so
1030
+ * telling anyone to go and harvest them would be noise.
1031
+ * @returns {string}
1032
+ */
1033
+ function howToCover(kind, never, harvested = false) {
1034
+ const first = never[0]?.name ?? 'one of them';
1035
+ switch (kind) {
1036
+ case 'ipc':
1037
+ return `Walk them. The code reader already turns these into a journey that invokes each channel: run the check with the journeys read out of the code switched on, or harvest the tests that already call "${first}".`;
1038
+ case 'route':
1039
+ return `Ask for them. A journey that requests each route writes down the status, the shape of the answer and what went out. Start with "${first}".`;
1040
+ case 'command':
1041
+ return `Run them. Each one is a program with a stdout, a stderr and an exit code, and all three are compared. Start with "${first}".`;
1042
+ case 'export':
1043
+ return harvested
1044
+ ? `The project's own tests have already been harvested and they do not reach these, so nothing existing covers them. Either they are dead code worth deleting, or they need a test that calls them — starting with "${first}".`
1045
+ : `Harvest the project's own test suite. Its tests already call most of these, and running them under coverage tells the ledger exactly which — including "${first}".`;
1046
+ default:
1047
+ return `Add a journey that reaches "${first}" and the ones beside it.`;
1048
+ }
1049
+ }
1050
+
1051
+ // ---------------------------------------------------------------------------
1052
+ // Into the shape the rest of the tool speaks
1053
+ // ---------------------------------------------------------------------------
1054
+
1055
+ /**
1056
+ * The ledger folded into the `Coverage` shape a verdict carries, so a run can report the
1057
+ * whole picture without anything else having to learn what a ledger is.
1058
+ *
1059
+ * @param {Ledger} led
1060
+ * @param {{worst?: number}} [opts]
1061
+ * @returns {Coverage}
1062
+ */
1063
+ export function toCoverage(led, opts = {}) {
1064
+ /** @type {CoverageGap[]} */
1065
+ const out = [...led.gaps];
1066
+ if (led.doors > 0 && led.never > 0) {
1067
+ out.push({
1068
+ what: `${led.never} of this product's ${led.doors} doors have never been opened by this tool.`,
1069
+ why: 'No journey reaches them, so a break behind one of them would not show up in any run — clean or otherwise.',
1070
+ unlockedBy: led.work > 0
1071
+ ? `${led.work} of them could be covered. Harvest the project's own test suite, or switch on the journeys read out of the code.`
1072
+ : 'Nothing. Every one of them is a door this tool cannot open from here, and each says why.',
1073
+ channel: 'contract',
1074
+ doors: led.never,
1075
+ });
1076
+ }
1077
+ for (const job of gaps(led, { worst: opts.worst ?? 8 })) {
1078
+ out.push({ what: job.what, why: job.why, unlockedBy: job.howTo, channel: 'contract', doors: job.doors });
1079
+ }
1080
+ for (const caveat of led.caveats) {
1081
+ out.push({
1082
+ what: 'This coverage count is less exact than it looks.',
1083
+ why: caveat,
1084
+ unlockedBy: 'Read the caveat: it says what would make it exact.',
1085
+ });
1086
+ }
1087
+ /** @type {Coverage} */
1088
+ const coverage = {
1089
+ paths: Object.values(led.byChannel).reduce((a, b) => a + b, 0),
1090
+ journeys: led.journeys,
1091
+ byChannel: led.byChannel,
1092
+ gaps: dedupeGaps(out),
1093
+ };
1094
+ if (led.doors > 0) {
1095
+ coverage.doorsKnown = led.doors;
1096
+ coverage.doorsWalked = led.opened;
1097
+ }
1098
+ return coverage;
1099
+ }
1100
+
1101
+ /**
1102
+ * @param {CoverageGap[]} list
1103
+ * @returns {CoverageGap[]}
1104
+ */
1105
+ function dedupeGaps(list) {
1106
+ /** @type {CoverageGap[]} */
1107
+ const out = [];
1108
+ const seen = new Set();
1109
+ for (const gap of list) {
1110
+ const key = `${gap.what}|${gap.why}`;
1111
+ if (seen.has(key)) continue;
1112
+ seen.add(key);
1113
+ out.push(gap);
1114
+ }
1115
+ return out;
1116
+ }