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,498 @@
1
+ /**
2
+ * Journeys read straight out of the code — the cheapest source, and the exact one.
3
+ *
4
+ * `adapters/source.js` already reads every door a project opens without running any of it:
5
+ * IPC channels, HTTP routes, exported names, commands, settings. This file turns that list
6
+ * of doors into JOURNEYS — named sequences that go and knock on them. Nobody writes
7
+ * anything, nobody records anything, and it costs about a second on a large project.
8
+ *
9
+ * WHY THIS IS THE BEST SOURCE. A recorded session tells you about one path a person
10
+ * happened to take. The suite tells you about the paths somebody thought to write a test
11
+ * for. The code tells you about every door there is, including the ones nobody has opened
12
+ * since they were written — which is exactly where a silent break hides.
13
+ *
14
+ * WHY DOORS ARE GROUPED RATHER THAN ONE JOURNEY EACH. Terminal Deck has 5,785 doors. A
15
+ * journey is the unit of retry, of the wobble measurement and of the stored record, so
16
+ * 5,785 of them would mean 5,785 folders and four walks each, and one door falling over
17
+ * would lose nothing but would still cost a whole run. Doors are gathered into families
18
+ * that share a prefix — everything on `session:`, everything under `/api/users` — so a
19
+ * large project gets tens of journeys covering thousands of doors, and a difference still
20
+ * lands on the individual door because the PATH names the door, not the journey.
21
+ *
22
+ * WHAT THIS CANNOT DO. It knocks on doors; it does not know what is behind them. A route
23
+ * gets a request with no body, an IPC channel gets a call with no arguments, an exported
24
+ * function is looked at rather than called. Calling something with invented arguments is
25
+ * how a tool invents a failure that is really its own fault — so this file never does it,
26
+ * and the suite source exists for the cases where real arguments matter.
27
+ */
28
+
29
+ import path from 'node:path';
30
+ import {
31
+ readContract,
32
+ readFileRoutes,
33
+ readPackageCommands,
34
+ surfaceOf,
35
+ } from '../adapters/source.js';
36
+
37
+ /** @typedef {import('../types.js').Journey} Journey */
38
+ /** @typedef {import('../types.js').JourneyStep} JourneyStep */
39
+ /** @typedef {import('../types.js').Channel} Channel */
40
+ /** @typedef {import('../types.js').Surface} Surface */
41
+ /** @typedef {import('../adapters/source.js').Door} Door */
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // What must never be knocked on for real
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Words in a door's name that mean opening it for real would be irreversible.
49
+ *
50
+ * This is a NAME-BASED guess and it is deliberately generous: a journey wrongly marked
51
+ * irreversible is observed at the call and not at the effect, which costs a little
52
+ * coverage; a journey wrongly marked safe sends somebody a real email. When those are the
53
+ * two mistakes available, you make the first one.
54
+ *
55
+ * The adapter is what actually stops the effect happening. This flag is how it is told to.
56
+ */
57
+ export const IRREVERSIBLE_WORDS = Object.freeze([
58
+ { word: 'pay', why: 'it sounds like it moves money' },
59
+ { word: 'payment', why: 'it sounds like it moves money' },
60
+ { word: 'charge', why: 'it sounds like it moves money' },
61
+ { word: 'checkout', why: 'it sounds like it moves money' },
62
+ { word: 'refund', why: 'it sounds like it moves money' },
63
+ { word: 'invoice', why: 'it sounds like it moves money' },
64
+ { word: 'subscribe', why: 'it sounds like it starts a paid subscription' },
65
+ { word: 'billing', why: 'it sounds like it moves money' },
66
+ { word: 'send', why: 'it sounds like it sends a message somebody receives' },
67
+ { word: 'email', why: 'it sounds like it sends a message somebody receives' },
68
+ { word: 'sms', why: 'it sounds like it sends a message somebody receives' },
69
+ { word: 'notify', why: 'it sounds like it sends a message somebody receives' },
70
+ { word: 'publish', why: 'it sounds like it makes something public' },
71
+ { word: 'deploy', why: 'it sounds like it changes something that is live' },
72
+ { word: 'release', why: 'it sounds like it changes something that is live' },
73
+ { word: 'delete', why: 'it sounds like it destroys data' },
74
+ { word: 'destroy', why: 'it sounds like it destroys data' },
75
+ { word: 'remove', why: 'it sounds like it destroys data' },
76
+ { word: 'drop', why: 'it sounds like it destroys data' },
77
+ { word: 'purge', why: 'it sounds like it destroys data' },
78
+ { word: 'wipe', why: 'it sounds like it destroys data' },
79
+ { word: 'reset', why: 'it sounds like it throws away what is there' },
80
+ { word: 'migrate', why: 'it sounds like it rewrites stored data in place' },
81
+ { word: 'uninstall', why: 'it sounds like it takes something away that has to be put back' },
82
+ ]);
83
+
84
+ /** Verbs on a route that change something by definition, whatever the route is called. */
85
+ const CHANGING_METHODS = new Set(['DELETE']);
86
+
87
+ /**
88
+ * Package scripts that never finish on their own. A journey that never ends is not a
89
+ * journey, it is a hang, and a hang looks exactly like a broken product.
90
+ */
91
+ const NEVER_EXITS = /^(dev|start|serve|watch|preview|storybook|tunnel)(:|$)/;
92
+
93
+ /**
94
+ * Package scripts that are somebody else's job. Building and releasing are not journeys
95
+ * through a product; they are how the product gets made, and running them here would
96
+ * rebuild the thing being measured underneath the measurement. The test scripts are left
97
+ * out for a different reason: the suite is a far better journey source than a command that
98
+ * runs all of it at once, and `from-suite.js` is where it is read properly.
99
+ */
100
+ const NOT_A_JOURNEY = /^(build|dist|pack|release|version|preversion|postversion|prepare|prepublish|prepublishOnly|postinstall|art|test|check|lint|format|typecheck|coverage)(:|$)/;
101
+
102
+ /**
103
+ * Is this `command` door something that can actually be run?
104
+ *
105
+ * The code reader files three different things under `command`: programs a package
106
+ * installs, scripts in package.json, and every flag a source file mentions. The first two
107
+ * can be walked through. A flag cannot — it modifies a command rather than being one — and
108
+ * turning each into its own journey buries the real commands under a hundred of them.
109
+ *
110
+ * @param {Door} door
111
+ * @returns {boolean}
112
+ */
113
+ export function isRunnable(door) {
114
+ if (door.kind !== 'command') return false;
115
+ if (String(door.name).startsWith('-')) return false;
116
+ return door.via === 'package.json';
117
+ }
118
+
119
+ /**
120
+ * Split a name into the words a person would read in it: `session:createMany` becomes
121
+ * session, create, many. Word-splitting rather than substring matching is what stops
122
+ * `undeleteAll` reading as `delete` and `resend` reading as `send`.
123
+ *
124
+ * @param {string} name
125
+ * @returns {string[]}
126
+ */
127
+ export function wordsIn(name) {
128
+ return String(name)
129
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
130
+ .split(/[^A-Za-z0-9]+/)
131
+ .filter(Boolean)
132
+ .map((w) => w.toLowerCase());
133
+ }
134
+
135
+ /**
136
+ * Kinds of door where walking through actually does something. An exported name is looked
137
+ * at, never called, so a constant called `AGENTS_REMOVE_CHANNEL` is not dangerous to read —
138
+ * and marking it dangerous would bury the handful of doors that really are.
139
+ */
140
+ const CAN_HAVE_AN_EFFECT = new Set(['ipc', 'route', 'command']);
141
+
142
+ /**
143
+ * Would opening this door for real be something that cannot be undone?
144
+ *
145
+ * @param {Door} door
146
+ * @returns {{irreversible: boolean, why: string}}
147
+ */
148
+ export function irreversibility(door) {
149
+ if (!CAN_HAVE_AN_EFFECT.has(door.kind)) return { irreversible: false, why: '' };
150
+ const words = new Set(wordsIn(door.name));
151
+ for (const entry of IRREVERSIBLE_WORDS) {
152
+ if (words.has(entry.word)) {
153
+ return { irreversible: true, why: `The name contains "${entry.word}", so ${entry.why}.` };
154
+ }
155
+ }
156
+ if (door.kind === 'route' && CHANGING_METHODS.has(String(door.detail).toUpperCase())) {
157
+ return { irreversible: true, why: 'It is a DELETE route, so asking for it properly would remove something.' };
158
+ }
159
+ return { irreversible: false, why: '' };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Names
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Turn anything into a name that is safe as a folder and readable in a report.
168
+ *
169
+ * Escaping rather than dropping matters here for the same reason it does in the path
170
+ * grammar: `v1.2` and `v12` are different things, and a scheme that strips would merge
171
+ * two journeys into one address and then report the difference between them as a change.
172
+ *
173
+ * @param {string} text
174
+ * @param {number} [limit]
175
+ * @returns {string}
176
+ */
177
+ export function slug(text, limit = 60) {
178
+ const cleaned = String(text)
179
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
180
+ .toLowerCase()
181
+ .replace(/[^a-z0-9]+/g, '-')
182
+ .replace(/^-+|-+$/g, '');
183
+ const out = cleaned === '' ? 'unnamed' : cleaned;
184
+ return out.length <= limit ? out : `${out.slice(0, limit - 7)}-${shortHash(out)}`;
185
+ }
186
+
187
+ /**
188
+ * A short, stable fingerprint. Only ever used to keep two long names apart, never as an
189
+ * identity anything is compared on.
190
+ * @param {string} text
191
+ * @returns {string}
192
+ */
193
+ export function shortHash(text) {
194
+ let h = 0x811c9dc5;
195
+ for (let i = 0; i < text.length; i++) {
196
+ h ^= text.charCodeAt(i);
197
+ h = Math.imul(h, 0x01000193) >>> 0;
198
+ }
199
+ return h.toString(36).padStart(6, '0').slice(0, 6);
200
+ }
201
+
202
+ /**
203
+ * The family a door belongs to.
204
+ *
205
+ * IPC channels are named `thing:action` almost everywhere, and routes are a folder tree, so
206
+ * both have a natural first level. Exported names group by the file they live in, because
207
+ * that is what a person means by "the store module". Commands stand alone: each one is a
208
+ * separate program run and grouping them would mean one failing command hid the next.
209
+ *
210
+ * @param {Door} door
211
+ * @returns {{group: string, label: string}}
212
+ */
213
+ export function familyOf(door) {
214
+ switch (door.kind) {
215
+ case 'ipc': {
216
+ const head = String(door.name).split(/[:/.]/)[0] || 'other';
217
+ return { group: `ipc-${slug(head)}`, label: `IPC channels starting with "${head}"` };
218
+ }
219
+ case 'route': {
220
+ const segments = String(door.name).split('/').filter(Boolean);
221
+ const head = segments[0] ?? 'root';
222
+ const second = segments[0] === 'api' && segments[1] ? `api/${segments[1]}` : head;
223
+ return { group: `route-${slug(second)}`, label: `routes under /${second}` };
224
+ }
225
+ case 'export': {
226
+ const file = String(door.file).split(path.sep).join('/');
227
+ const folder = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.';
228
+ return {
229
+ group: `export-${slug(folder === '.' ? 'top' : folder)}`,
230
+ label: folder === '.' ? 'what the top-level files export' : `what the files in ${folder} export`,
231
+ };
232
+ }
233
+ case 'command':
234
+ return { group: `cli-${slug(door.name)}`, label: `the command "${door.name}"` };
235
+ default:
236
+ return { group: 'settings', label: 'the settings it reads' };
237
+ }
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Doors to steps
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /** What an adapter is being asked to do at each kind of door. */
245
+ const ACT_FOR_KIND = /** @type {const} */ ({
246
+ ipc: 'invoke',
247
+ route: 'request',
248
+ export: 'inspect',
249
+ command: 'run',
250
+ env: 'read',
251
+ });
252
+
253
+ /** The channels a walk through each kind of door can honestly fill. */
254
+ const CHANNELS_FOR_KIND = /** @type {Record<string, Channel[]>} */ ({
255
+ ipc: ['results', 'complaints', 'effects', 'counters'],
256
+ route: ['results', 'complaints', 'effects', 'counters'],
257
+ export: ['contract', 'results'],
258
+ command: ['results', 'complaints', 'effects', 'counters'],
259
+ env: ['contract'],
260
+ });
261
+
262
+ /**
263
+ * One door, as a step an adapter can act on.
264
+ *
265
+ * Everything the adapter needs is on the step, and nothing it does not: the door's file and
266
+ * line ride along because ranking measures distance from the changed code, and a step that
267
+ * knows which file it came from turns "something broke" into "something broke next to what
268
+ * you just edited".
269
+ *
270
+ * @param {Door} door
271
+ * @returns {JourneyStep}
272
+ */
273
+ export function stepForDoor(door) {
274
+ const risk = irreversibility(door);
275
+ /** @type {JourneyStep} */
276
+ const step = {
277
+ act: ACT_FOR_KIND[door.kind] ?? 'read',
278
+ kind: door.kind,
279
+ door: door.name,
280
+ detail: door.detail,
281
+ file: door.file,
282
+ line: door.line,
283
+ };
284
+ if (door.kind === 'route') {
285
+ step.method = String(door.detail).toUpperCase();
286
+ step.route = door.name;
287
+ }
288
+ if (door.kind === 'command') step.command = door.name;
289
+ if (risk.irreversible) {
290
+ step.irreversible = true;
291
+ step.why = risk.why;
292
+ step.note = 'Watch the call go out and stop it there. Never let the effect happen.';
293
+ }
294
+ return step;
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Doors to journeys
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * @typedef {object} FromRoutesOptions
303
+ * @property {Surface} [surface] What these journeys run against. Read off the project
304
+ * when it is not given.
305
+ * @property {number} [maxSteps] Doors per journey. See the note at the top of the file
306
+ * for why this is not simply "all of them".
307
+ * @property {boolean} [includeTests] Include doors a test file registers. Off, because a
308
+ * fake registration in a test is not a door the product
309
+ * answers on.
310
+ * @property {('ipc'|'route'|'export'|'command'|'env')[]} [kinds] Only these kinds.
311
+ * @property {(door: Door) => boolean} [where] A last filter, for a caller with its own idea.
312
+ */
313
+
314
+ /**
315
+ * @typedef {object} FromRoutesReport
316
+ * @property {number} doors Doors the code reader found.
317
+ * @property {number} doorsCovered Doors a journey now knocks on.
318
+ * @property {number} journeys
319
+ * @property {Record<string, number>} byKind Doors covered, per kind.
320
+ * @property {{what: string, why: string, doors: number}[]} left
321
+ * Doors deliberately not turned into journeys, and why.
322
+ * This is the coverage hole, said out loud rather than
323
+ * left to be discovered.
324
+ */
325
+
326
+ /**
327
+ * Turn a list of doors into journeys.
328
+ *
329
+ * Pure: hand it doors, get journeys. Everything that touches a disk lives in
330
+ * {@link journeysFromCode}, so this half can be tested with a list written by hand.
331
+ *
332
+ * @param {Door[]} doors
333
+ * @param {FromRoutesOptions} [options]
334
+ * @returns {{journeys: Journey[], report: FromRoutesReport}}
335
+ */
336
+ export function journeysFromDoors(doors, options = {}) {
337
+ const maxSteps = options.maxSteps ?? 40;
338
+ const surface = options.surface ?? 'library';
339
+ /** @type {FromRoutesReport} */
340
+ const report = { doors: doors.length, doorsCovered: 0, journeys: 0, byKind: {}, left: [] };
341
+
342
+ /** @type {Map<string, {reason: string, count: number}>} */
343
+ const left = new Map();
344
+ /** @param {string} reason */
345
+ const leaveOut = (reason) => {
346
+ const entry = left.get(reason) ?? { reason, count: 0 };
347
+ entry.count++;
348
+ left.set(reason, entry);
349
+ };
350
+
351
+ /** @type {Map<string, {label: string, kind: Door['kind'], doors: Door[]}>} */
352
+ const families = new Map();
353
+
354
+ for (const door of doors) {
355
+ if (!options.includeTests && door.inTest) { leaveOut('they are registered inside a test file, not by the product'); continue; }
356
+ if (!door.named) { leaveOut('their names are built while the program runs, so there is nothing to knock on'); continue; }
357
+ if (options.kinds && !options.kinds.includes(door.kind)) { leaveOut('their kind was not asked for'); continue; }
358
+ if (door.kind === 'env') { leaveOut('a setting is read, not opened — the contract channel already watches them'); continue; }
359
+ if (door.kind === 'command' && !isRunnable(door)) {
360
+ leaveOut('they are flags rather than programs — a flag modifies a command, it is not something a journey can walk through on its own, and the contract channel already watches every one of them');
361
+ continue;
362
+ }
363
+ if (door.kind === 'command' && NEVER_EXITS.test(door.name.replace(/^npm run /, ''))) {
364
+ leaveOut('they never exit on their own, so a walk through one would hang rather than finish');
365
+ continue;
366
+ }
367
+ if (door.kind === 'command' && NOT_A_JOURNEY.test(door.name.replace(/^npm run /, ''))) {
368
+ leaveOut('they build or release the product rather than use it, and running one would rebuild the thing being measured');
369
+ continue;
370
+ }
371
+ if (options.where && !options.where(door)) { leaveOut('a filter the caller supplied left them out'); continue; }
372
+
373
+ const family = familyOf(door);
374
+ const existing = families.get(family.group);
375
+ if (existing) existing.doors.push(door);
376
+ else families.set(family.group, { label: family.label, kind: door.kind, doors: [door] });
377
+ }
378
+
379
+ /** @type {Journey[]} */
380
+ const journeys = [];
381
+ for (const [group, family] of [...families.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
382
+ const ordered = family.doors
383
+ .slice()
384
+ .sort((a, b) => (a.name === b.name ? compare(`${a.file}:${a.line}`, `${b.file}:${b.line}`) : compare(a.name, b.name)));
385
+ const chunks = chunk(ordered, maxSteps);
386
+ chunks.forEach((part, index) => {
387
+ const name = chunks.length === 1 ? `code-${group}` : `code-${group}-${index + 1}`;
388
+ const steps = part.map(stepForDoor);
389
+ const files = unique(part.map((d) => d.file));
390
+ /** @type {Journey} */
391
+ const journey = {
392
+ name,
393
+ describe:
394
+ chunks.length === 1
395
+ ? `knock on the ${part.length} ${part.length === 1 ? 'door' : 'doors'} in ${family.label}`
396
+ : `knock on ${family.label}, part ${index + 1} of ${chunks.length} (${part.length} doors)`,
397
+ source: 'code',
398
+ surface,
399
+ from: files.length === 1 ? files[0] : `${files.length} files, starting with ${files[0]}`,
400
+ channels: CHANNELS_FOR_KIND[family.kind] ?? ['results'],
401
+ steps,
402
+ };
403
+ if (steps.some((s) => s.irreversible === true)) {
404
+ journey.irreversible = true;
405
+ }
406
+ journeys.push(journey);
407
+ report.doorsCovered += part.length;
408
+ report.byKind[family.kind] = (report.byKind[family.kind] ?? 0) + part.length;
409
+ });
410
+ }
411
+
412
+ report.journeys = journeys.length;
413
+ report.left = [...left.values()]
414
+ .sort((a, b) => b.count - a.count)
415
+ .map((entry) => ({
416
+ what: `${entry.count} ${entry.count === 1 ? 'door is' : 'doors are'} not walked.`,
417
+ why: `They were left out because ${entry.reason}.`,
418
+ doors: entry.count,
419
+ }));
420
+ return { journeys, report };
421
+ }
422
+
423
+ /**
424
+ * Read a project's code and hand back journeys that visit its doors.
425
+ *
426
+ * Reads. Never runs, never writes, never starts anything — which is why this is safe to
427
+ * point at a repository somebody else is working in.
428
+ *
429
+ * @param {object} opts
430
+ * @param {string} opts.root Project root. Read only.
431
+ * @param {string[]} [opts.folders] Folders to read. Defaults to the usual ones.
432
+ * @param {Record<string, any>} [opts.config] The project's config, for the surface.
433
+ * @param {FromRoutesOptions} [opts.journeys]
434
+ * @returns {Promise<{journeys: Journey[], doors: Door[], report: FromRoutesReport & {readMs: number, filesRead: number, unnamed: number}}>}
435
+ */
436
+ export async function journeysFromCode(opts) {
437
+ const started = Date.now();
438
+ const reading = await readContract({ root: opts.root, folders: opts.folders });
439
+ reading.doors.push(...(await readFileRoutes(opts.root)));
440
+ reading.doors.push(...(await readPackageCommands(opts.root)));
441
+
442
+ const surface =
443
+ opts.journeys?.surface ?? /** @type {Surface} */ (surfaceOf({ root: opts.root, config: opts.config }));
444
+ const built = journeysFromDoors(reading.doors, { ...opts.journeys, surface });
445
+
446
+ if (reading.report.unnamed > 0) {
447
+ built.report.left.push({
448
+ what: `${reading.report.unnamed} doors exist whose names are worked out while the program runs.`,
449
+ why: 'The code reader can see that a door is there but not what it is called, so nothing can knock on it.',
450
+ doors: reading.report.unnamed,
451
+ });
452
+ }
453
+
454
+ return {
455
+ journeys: built.journeys,
456
+ doors: reading.doors,
457
+ report: {
458
+ ...built.report,
459
+ readMs: Date.now() - started,
460
+ filesRead: reading.report.filesRead,
461
+ unnamed: reading.report.unnamed,
462
+ },
463
+ };
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Small things
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /**
471
+ * @template T
472
+ * @param {T[]} items
473
+ * @param {number} size
474
+ * @returns {T[][]}
475
+ */
476
+ function chunk(items, size) {
477
+ if (items.length <= size) return items.length === 0 ? [] : [items];
478
+ /** @type {T[][]} */
479
+ const out = [];
480
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
481
+ return out;
482
+ }
483
+
484
+ /**
485
+ * @param {string[]} values
486
+ * @returns {string[]}
487
+ */
488
+ function unique(values) {
489
+ return [...new Set(values.filter(Boolean))];
490
+ }
491
+
492
+ /**
493
+ * @param {string} a
494
+ * @param {string} b
495
+ */
496
+ function compare(a, b) {
497
+ return a < b ? -1 : a > b ? 1 : 0;
498
+ }