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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|