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,651 @@
1
+ /**
2
+ * Where the steps come from — gathered, de-duplicated, and labelled with how much each one
3
+ * is worth.
4
+ *
5
+ * A deep check needs journeys. If a person has to write them, the tool is a second job and
6
+ * it gets abandoned in a fortnight, so this folder exists to make sure nobody ever does.
7
+ * The sources are ranked, best first, and the ranking is not a preference — it is a
8
+ * statement about evidence:
9
+ *
10
+ * code Read out of the source. Free, exact, and it sees every door there is,
11
+ * including the ones nobody has opened since they were written.
12
+ * suite The project's own tests, run under instrumentation. Somebody already wrote
13
+ * them, they already walk real paths, and they cost one run to harvest.
14
+ * recorded One session somebody actually performed. Real, and narrow, and it goes stale
15
+ * when the interface moves.
16
+ * explored An agent opened one named gap and froze what it found. A fact about one path
17
+ * an agent happened to take — worth having, never worth confusing with the
18
+ * three above.
19
+ *
20
+ * Every journey that leaves this file says which of those it came from, because a finding
21
+ * from a `code` journey is a fact about the product and a finding from an `explored` journey
22
+ * is a fact about one path an agent chose, and a reader who cannot tell them apart will
23
+ * eventually trust the wrong one.
24
+ *
25
+ * THE FRONT DOOR RULE. A journey that does not do the same thing twice on the same build is
26
+ * rejected here, at birth, rather than admitted and condemned later as a flake. Version 1
27
+ * learned this the expensive way: a flaky check does not get fixed, it gets ignored, and a
28
+ * tool nobody trusts is worse than no tool because somebody believed it once.
29
+ */
30
+
31
+ import path from 'node:path';
32
+
33
+ import { measureWobble } from '../observation.js';
34
+ import { journeysFromCode } from './from-routes.js';
35
+ import { harvestJourneys } from './from-suite.js';
36
+ import { loadJourneyFolder, whatWillNotReplay } from './record.js';
37
+
38
+ /** @typedef {import('../types.js').Journey} Journey */
39
+ /** @typedef {import('../types.js').JourneySource} JourneySource */
40
+ /** @typedef {import('../types.js').Surface} Surface */
41
+ /** @typedef {import('../types.js').Capture} Capture */
42
+ /** @typedef {import('../types.js').BuildFingerprint} BuildFingerprint */
43
+ /** @typedef {import('../types.js').CoverageGap} CoverageGap */
44
+ /** @typedef {import('../types.js').Channel} Channel */
45
+ /** @typedef {import('../adapters/contract.js').Missing} Missing */
46
+ /** @typedef {import('../adapters/source.js').Door} Door */
47
+
48
+ export { journeysFromCode, journeysFromDoors, irreversibility } from './from-routes.js';
49
+ export { detectRunner, harvestJourneys, listTestFiles } from './from-suite.js';
50
+ export { startRecording, recordSession, saveJourneys, loadJourneys, loadJourneyFolder, redact } from './record.js';
51
+
52
+ /**
53
+ * A journey with everything this folder knows about where it came from.
54
+ *
55
+ * @typedef {Journey & {
56
+ * touched?: import('./from-suite.js').Touched,
57
+ * reproducible?: {how: string, at: string},
58
+ * replaced?: string[],
59
+ * }} GatheredJourney
60
+ */
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // How much a source is worth
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /** Best first. The order decides which copy survives when two sources describe one journey. */
67
+ export const SOURCE_TRUST = /** @type {JourneySource[]} */ (['code', 'suite', 'recorded', 'explored']);
68
+
69
+ /** One plain sentence per source, for anything that has to explain itself to a reader. */
70
+ export const PROVENANCE = Object.freeze({
71
+ code: 'read straight out of the source, so it is exact and it costs nothing — but it only knocks on the door, it does not know what is behind it',
72
+ suite: "harvested from the project's own tests, so it walks real paths with real arguments that somebody already thought about",
73
+ recorded: 'a session somebody actually performed, so it is real — and narrow, and it will go stale when the interface moves',
74
+ explored: 'an agent went looking and froze what it found, so it is one path an agent happened to take rather than a fact about the product',
75
+ });
76
+
77
+ /** What to call each source in the middle of a sentence. */
78
+ export const SOURCE_LABEL = Object.freeze({
79
+ code: 'journey read out of the code',
80
+ suite: 'journey harvested from the test suite',
81
+ recorded: 'recorded session',
82
+ explored: 'journey an agent found by exploring',
83
+ });
84
+
85
+ /**
86
+ * @param {JourneySource} source
87
+ * @returns {number} lower is more trustworthy
88
+ */
89
+ export function trustOf(source) {
90
+ const index = SOURCE_TRUST.indexOf(source);
91
+ return index === -1 ? SOURCE_TRUST.length : index;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Is this a usable journey at all?
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const NAME_RULE = /^[a-z0-9][a-z0-9-]*$/;
99
+
100
+ /**
101
+ * What is wrong with this journey, in plain English. Empty means it is fine.
102
+ *
103
+ * Checked in one place rather than in each producer, so a journey that arrives from a file
104
+ * somebody wrote by hand is held to exactly the same rules as one this tool generated.
105
+ *
106
+ * @param {Journey} journey
107
+ * @returns {string[]}
108
+ */
109
+ export function validateJourney(journey) {
110
+ /** @type {string[]} */
111
+ const problems = [];
112
+ if (!journey || typeof journey !== 'object') return ['It is not an object.'];
113
+ if (typeof journey.name !== 'string' || !NAME_RULE.test(journey.name)) {
114
+ problems.push(
115
+ `Its name ("${journey.name}") has to be lowercase letters, numbers and dashes — it becomes a folder name and the head of every address the journey produces.`,
116
+ );
117
+ }
118
+ if (typeof journey.describe !== 'string' || journey.describe.trim() === '') {
119
+ problems.push('It needs one plain sentence saying what it does. That sentence is what an agent reads when this journey finds something.');
120
+ }
121
+ if (!SOURCE_TRUST.includes(journey.source)) {
122
+ problems.push(`It has to say where it came from — one of ${SOURCE_TRUST.join(', ')} — and it says "${journey.source}".`);
123
+ }
124
+ if (typeof journey.surface !== 'string' || journey.surface.trim().length === 0) {
125
+ problems.push('It has to say which surface it runs against.');
126
+ }
127
+ const steps = journey.steps ?? [];
128
+ if (steps.length === 0) {
129
+ problems.push('It has no steps, so walking it would do nothing.');
130
+ } else {
131
+ for (const [index, step] of steps.entries()) {
132
+ if (!step || typeof step.act !== 'string' || step.act.trim() === '') {
133
+ problems.push(`Step ${index + 1} does not say what to do.`);
134
+ }
135
+ }
136
+ }
137
+ return problems;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // The same journey, twice
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * What makes two journeys the same journey: the same steps, in the same order.
146
+ *
147
+ * Not the name and not the sentence — those are how it is described. Two sources that
148
+ * generated the same walk through the same doors produced one journey, however differently
149
+ * they chose to name it, and walking it twice would cost twice and prove once.
150
+ *
151
+ * @param {Journey} journey
152
+ * @returns {string}
153
+ */
154
+ export function journeyKey(journey) {
155
+ const steps = (journey.steps ?? []).map((step) => {
156
+ /** @type {Record<string, unknown>} */
157
+ const stripped = {};
158
+ for (const key of Object.keys(step).sort()) {
159
+ // `note` and `why` are prose for a reader. Two journeys that differ only in their
160
+ // wording are the same journey.
161
+ if (key === 'note' || key === 'why') continue;
162
+ stripped[key] = /** @type {any} */ (step)[key];
163
+ }
164
+ return stripped;
165
+ });
166
+ return `${journey.surface}::${JSON.stringify(steps)}`;
167
+ }
168
+
169
+ /**
170
+ * Keep one copy of each journey, and say which copies were dropped.
171
+ *
172
+ * The survivor is the one from the most trustworthy source, because that is the one whose
173
+ * findings mean the most — and the names of the ones it replaced ride along on `replaced`,
174
+ * so nothing disappears without a trace.
175
+ *
176
+ * @param {GatheredJourney[]} journeys
177
+ * @returns {{journeys: GatheredJourney[], dropped: {name: string, insteadOf: string, why: string}[]}}
178
+ */
179
+ export function dedupeJourneys(journeys) {
180
+ /** @type {Map<string, GatheredJourney>} */
181
+ const best = new Map();
182
+ /** @type {{name: string, insteadOf: string, why: string}[]} */
183
+ const dropped = [];
184
+ /** @type {Set<string>} */
185
+ const namesTaken = new Set();
186
+
187
+ for (const journey of journeys) {
188
+ const key = journeyKey(journey);
189
+ const already = best.get(key);
190
+ if (!already) {
191
+ best.set(key, journey);
192
+ continue;
193
+ }
194
+ const winner = trustOf(journey.source) < trustOf(already.source) ? journey : already;
195
+ const loser = winner === journey ? already : journey;
196
+ winner.replaced = [...(winner.replaced ?? []), loser.name];
197
+ best.set(key, winner);
198
+ dropped.push({
199
+ name: loser.name,
200
+ insteadOf: winner.name,
201
+ why: `It walks exactly the same steps, and the copy that was kept came ${PROVENANCE[winner.source]}.`,
202
+ });
203
+ }
204
+
205
+ /** @type {GatheredJourney[]} */
206
+ const out = [];
207
+ for (const journey of [...best.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
208
+ if (namesTaken.has(journey.name)) {
209
+ // Two different journeys that chose the same name would land in one folder and
210
+ // overwrite each other's record, which reads afterwards as the product changing.
211
+ let suffix = 2;
212
+ while (namesTaken.has(`${journey.name}-${suffix}`)) suffix++;
213
+ journey.name = `${journey.name}-${suffix}`;
214
+ }
215
+ namesTaken.add(journey.name);
216
+ out.push(journey);
217
+ }
218
+ return { journeys: out, dropped };
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Does it do the same thing twice?
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * @typedef {object} ReproducibilityResult
227
+ * @property {GatheredJourney[]} kept Journeys that repeat, or that already proved it.
228
+ * @property {{journey: string, why: string}[]} rejected
229
+ * @property {{journey: string, why: string}[]} unchecked
230
+ * Nothing here could be walked or regenerated, so
231
+ * nothing was proved. Missing evidence, never a pass.
232
+ * @property {string} how Plain English: what was actually done.
233
+ */
234
+
235
+ /**
236
+ * Prove each journey does the same thing twice on the same build — or say plainly that
237
+ * nothing proved it.
238
+ *
239
+ * Three ways, strongest first.
240
+ * - WALKED. Hand in something that can walk a journey, and each one is walked twice
241
+ * against the same build. Anything that appears or vanishes between two runs of
242
+ * identical bytes means the journey does not describe a repeatable thing. Values that
243
+ * wobble are fine and expected — that is what the wobble measurement is for — but a path
244
+ * that exists on one run and not the other is a journey arguing with itself.
245
+ * - REGENERATED. For journeys read out of the code there is a cheaper honest check: run
246
+ * the generator again and see whether it produces the same journeys. That catches a real
247
+ * failure — a generator whose output depends on the order a folder was read in — and it
248
+ * is not the same as walking, so it says which one it did.
249
+ * - NEITHER. Everything comes back `unchecked`, with the reason. It is not a pass.
250
+ *
251
+ * @param {GatheredJourney[]} journeys
252
+ * @param {object} [opts]
253
+ * @param {(req: {journey: Journey, build: BuildFingerprint, run: 'a'|'b'|'single', which: 'candidate'|'reference'}) => Promise<Capture>} [opts.walk]
254
+ * @param {BuildFingerprint} [opts.build] Required with `walk`.
255
+ * @param {() => Promise<Journey[]>} [opts.regenerate]
256
+ * @param {(message: string) => void} [opts.log]
257
+ * @param {AbortSignal} [opts.signal]
258
+ * @returns {Promise<ReproducibilityResult>}
259
+ */
260
+ export async function checkReproducible(journeys, opts = {}) {
261
+ /** @type {ReproducibilityResult} */
262
+ const result = { kept: [], rejected: [], unchecked: [], how: '' };
263
+ const log = opts.log ?? (() => {});
264
+
265
+ /** @type {Set<string>|null} */
266
+ let regenerated = null;
267
+ if (!opts.walk && opts.regenerate) {
268
+ try {
269
+ regenerated = new Set((await opts.regenerate()).map(journeyKey));
270
+ } catch (error) {
271
+ regenerated = null;
272
+ log(`The journeys could not be generated a second time: ${error instanceof Error ? error.message : String(error)}`);
273
+ }
274
+ }
275
+
276
+ for (const journey of journeys) {
277
+ if (opts.signal?.aborted) {
278
+ result.unchecked.push({ journey: journey.name, why: 'The check was stopped before this journey was reached.' });
279
+ continue;
280
+ }
281
+ if (journey.reproducible) {
282
+ result.kept.push(journey);
283
+ continue;
284
+ }
285
+ if (opts.walk && opts.build) {
286
+ try {
287
+ const a = await opts.walk({ journey, build: opts.build, run: 'a', which: 'candidate' });
288
+ const b = await opts.walk({ journey, build: opts.build, run: 'b', which: 'candidate' });
289
+ const wobble = measureWobble(a, b);
290
+ const shapeChanged = wobble.entries.filter((entry) => entry.kind !== 'changed');
291
+ if (a.observations.length === 0) {
292
+ result.rejected.push({ journey: journey.name, why: 'Walking it produced nothing at all, so there is nothing it could ever prove.' });
293
+ continue;
294
+ }
295
+ if (shapeChanged.length > 0) {
296
+ const example = shapeChanged[0];
297
+ result.rejected.push({
298
+ journey: journey.name,
299
+ why: `Two walks of the same build disagreed about what exists — "${example.path}" ${example.kind === 'appeared' ? 'turned up only the second time' : 'was there the first time and gone the second'}. A journey that argues with itself cannot say anything about a change.`,
300
+ });
301
+ continue;
302
+ }
303
+ if (wobble.steady === 0) {
304
+ result.rejected.push({
305
+ journey: journey.name,
306
+ why: 'Every single thing it observed came out different on the second walk, so none of it can ever be evidence.',
307
+ });
308
+ continue;
309
+ }
310
+ journey.reproducible = {
311
+ how: `It was walked twice against the same build: ${wobble.steady} of what it observed held still and ${wobble.unstable.length} did not.`,
312
+ at: new Date().toISOString(),
313
+ };
314
+ result.kept.push(journey);
315
+ } catch (error) {
316
+ result.rejected.push({
317
+ journey: journey.name,
318
+ why: `Walking it failed: ${error instanceof Error ? error.message : String(error)}`,
319
+ });
320
+ }
321
+ continue;
322
+ }
323
+ if (regenerated && journey.source === 'code') {
324
+ if (regenerated.has(journeyKey(journey))) {
325
+ journey.reproducible = {
326
+ how: 'The code was read a second time and produced exactly this journey again.',
327
+ at: new Date().toISOString(),
328
+ };
329
+ result.kept.push(journey);
330
+ } else {
331
+ result.rejected.push({
332
+ journey: journey.name,
333
+ why: 'Reading the code a second time produced a different journey, so what it walks depends on something other than the code.',
334
+ });
335
+ }
336
+ continue;
337
+ }
338
+ // Kept, not rejected. Rejection is for a journey PROVED to argue with itself; a journey
339
+ // nobody could check is missing evidence, and throwing those away would leave a project
340
+ // with no walker holding no journeys at all — which looks exactly like a clean run.
341
+ result.kept.push(journey);
342
+ result.unchecked.push({
343
+ journey: journey.name,
344
+ why: journey.source === 'code'
345
+ ? 'Nothing here could walk it, and the code was not read a second time, so whether it does the same thing twice is not known.'
346
+ : `Nothing here could walk it, and a ${SOURCE_LABEL[journey.source]} cannot be generated a second time the way a journey read out of the code can, so whether it does the same thing twice is not known.`,
347
+ });
348
+ }
349
+
350
+ result.how = opts.walk
351
+ ? 'Each journey was walked twice against the same build, and anything that disagreed with itself about what exists was rejected.'
352
+ : regenerated
353
+ ? 'The journeys were generated a second time and compared. That proves the generator is steady; it does not prove the product is.'
354
+ : 'Nothing proved these journeys repeat. That is missing evidence, not a pass.';
355
+ return result;
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Gathering
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /**
363
+ * @typedef {object} GatherOptions
364
+ * @property {string} root
365
+ * @property {Surface} [surface]
366
+ * @property {false|Parameters<typeof journeysFromCode>[0]['journeys']} [code]
367
+ * Journeys read out of the source. On by default: it reads files and runs nothing.
368
+ * @property {false|true|Partial<import('./from-suite.js').HarvestOptions>} [suite]
369
+ * Journeys harvested from the project's own tests. OFF by default, and deliberately:
370
+ * harvesting RUNS the suite, which starts processes and takes minutes. Nothing that
371
+ * expensive should happen because somebody called a function called `gather`. When it is
372
+ * off, the report says what it would have unlocked.
373
+ * @property {false|{dir?: string, files?: string[]}} [recorded]
374
+ * Recorded sessions. On by default: it only reads files. Defaults to `.staysfixed/journeys`.
375
+ * @property {Journey[]} [explored] Journeys an agent produced, handed straight in.
376
+ * @property {boolean} [verify] Check the code journeys repeat by generating them
377
+ * again. On by default; it costs one more read.
378
+ * @property {(message: string) => void} [log]
379
+ * @property {AbortSignal} [signal]
380
+ */
381
+
382
+ /**
383
+ * @typedef {object} GatherReport
384
+ * @property {Record<JourneySource, number>} bySource How many journeys each source produced.
385
+ * @property {number} total
386
+ * @property {number} steps
387
+ * @property {number} irreversible Journeys with a step that must be stopped at the call.
388
+ * @property {{name: string, problems: string[]}[]} invalid
389
+ * @property {{name: string, insteadOf: string, why: string}[]} duplicates
390
+ * @property {{journey: string, why: string}[]} rejected
391
+ * @property {{journey: string, why: string}[]} unchecked
392
+ * @property {string} reproducibility Plain English: what was actually proved.
393
+ * @property {CoverageGap[]} gaps Everything not covered, ready to fold into Coverage.
394
+ * @property {Missing[]} missing What would unlock more, with the command where known.
395
+ * @property {string[]} notes
396
+ * @property {number} durationMs
397
+ */
398
+
399
+ /**
400
+ * Gather journeys from every source that is available, and report where each one came from.
401
+ *
402
+ * The doors come back alongside the journeys, and that is not a convenience. The coverage
403
+ * ledger is a join between the doors the code reader found and the journeys anything
404
+ * actually walked, and reading the source a second time to get the other half of that join
405
+ * would let the two halves drift: a door added between the two reads would show up as a door
406
+ * nothing covers, when the truth is that nothing had a chance to. One read, both halves.
407
+ *
408
+ * @param {GatherOptions} opts
409
+ * @returns {Promise<{journeys: GatheredJourney[], report: GatherReport, doors: Door[]}>}
410
+ */
411
+ export async function gather(opts) {
412
+ const started = Date.now();
413
+ const root = path.resolve(opts.root);
414
+ const log = opts.log ?? (() => {});
415
+
416
+ /** @type {GatheredJourney[]} */
417
+ const collected = [];
418
+ /** @type {CoverageGap[]} */
419
+ const gaps = [];
420
+ /** @type {Missing[]} */
421
+ const missing = [];
422
+ /** @type {string[]} */
423
+ const notes = [];
424
+ /** @type {Door[]} */
425
+ let doors = [];
426
+ /** @type {(() => Promise<Journey[]>)|undefined} */
427
+ let regenerate;
428
+
429
+ // ---- read the code -------------------------------------------------------
430
+ if (opts.code !== false) {
431
+ log('Reading the doors out of the source.');
432
+ const read = await journeysFromCode({ root, journeys: { surface: opts.surface, ...(opts.code || {}) } });
433
+ collected.push(...read.journeys);
434
+ doors = read.doors;
435
+ for (const left of read.report.left) {
436
+ gaps.push({
437
+ what: left.what,
438
+ why: left.why,
439
+ unlockedBy: 'Nothing needs installing. These are doors this tool cannot knock on from outside, so a journey through the suite or a recorded session is the way to reach them.',
440
+ channel: 'contract',
441
+ doors: left.doors,
442
+ });
443
+ }
444
+ notes.push(
445
+ `The code reader found ${read.report.doors} doors in ${read.report.filesRead} files and turned ${read.report.doorsCovered} of them into ${read.report.journeys} journeys, in ${read.report.readMs}ms, without running anything.`,
446
+ );
447
+ regenerate = async () => {
448
+ const again = await journeysFromCode({ root, journeys: { surface: opts.surface, ...(opts.code || {}) } });
449
+ return again.journeys;
450
+ };
451
+ } else {
452
+ gaps.push({
453
+ what: 'The doors this product opens were never counted.',
454
+ why: 'Reading the code was switched off, so there is no list of routes, exported names, commands and IPC channels to measure coverage against — and without a denominator a clean run means only that nothing anybody walked changed.',
455
+ unlockedBy: 'Leave the code reader on. It reads files, runs nothing, and on Terminal Deck it finishes in under two seconds.',
456
+ channel: 'contract',
457
+ });
458
+ }
459
+
460
+ // ---- run the suite -------------------------------------------------------
461
+ if (opts.suite) {
462
+ log("Harvesting journeys from the project's own tests.");
463
+ /** @type {import('./from-suite.js').HarvestOptions} */
464
+ const suiteOptions = {
465
+ surface: opts.surface,
466
+ log: opts.log,
467
+ signal: opts.signal,
468
+ ...(opts.suite === true ? {} : opts.suite),
469
+ // Last on purpose: whatever else a caller asked for, the suite is harvested from the
470
+ // project this gather was pointed at, and nowhere else.
471
+ root,
472
+ };
473
+ const harvest = await harvestJourneys(suiteOptions);
474
+ collected.push(...harvest.journeys);
475
+ gaps.push(...suiteGaps(harvest.report));
476
+ missing.push(...harvest.report.missing);
477
+ notes.push(...harvest.report.notes);
478
+ } else {
479
+ gaps.push({
480
+ what: "The project's own test suite was not harvested, so every path its tests walk is invisible to this check.",
481
+ why: 'Harvesting runs the suite one file at a time, which starts processes and takes minutes, so it never happens unless it is asked for.',
482
+ unlockedBy: 'Ask for it: gather({suite: true}). Every test file that repeats twice becomes a journey nobody had to write.',
483
+ });
484
+ }
485
+
486
+ // ---- recorded sessions ---------------------------------------------------
487
+ if (opts.recorded !== false) {
488
+ const dir = opts.recorded?.dir ?? path.join(root, '.staysfixed', 'journeys');
489
+ const loaded = await loadJourneyFolder(dir);
490
+ for (const journey of loaded.journeys) {
491
+ const willNotReplay = whatWillNotReplay(journey);
492
+ if (willNotReplay.length > 0) {
493
+ gaps.push({
494
+ what: `The recorded journey "${journey.name}" may not replay.`,
495
+ why: willNotReplay.join(' '),
496
+ unlockedBy: 'Record it again, or reach the same thing from the code or the test suite, where nothing goes stale.',
497
+ });
498
+ }
499
+ collected.push(journey);
500
+ }
501
+ for (const problem of loaded.problems) notes.push(problem);
502
+ if (loaded.files.length > 0) {
503
+ notes.push(`${loaded.journeys.length} recorded journeys were read from ${loaded.files.length} files in ${path.relative(root, dir) || dir}.`);
504
+ }
505
+ }
506
+
507
+ // ---- what an agent explored ---------------------------------------------
508
+ for (const journey of opts.explored ?? []) {
509
+ collected.push({ ...journey, source: 'explored' });
510
+ }
511
+
512
+ // ---- clean up ------------------------------------------------------------
513
+ /** @type {{name: string, problems: string[]}[]} */
514
+ const invalid = [];
515
+ /** @type {GatheredJourney[]} */
516
+ const valid = [];
517
+ for (const journey of collected) {
518
+ const problems = validateJourney(journey);
519
+ if (problems.length > 0) invalid.push({ name: journey.name ?? '(no name)', problems });
520
+ else valid.push(journey);
521
+ }
522
+ for (const bad of invalid) {
523
+ gaps.push({
524
+ what: `The journey "${bad.name}" was thrown away before it was walked.`,
525
+ why: bad.problems.join(' '),
526
+ unlockedBy: 'Fix the journey, or the thing that produced it.',
527
+ });
528
+ }
529
+
530
+ const deduped = dedupeJourneys(valid);
531
+
532
+ // ---- does it repeat? -----------------------------------------------------
533
+ const verified =
534
+ opts.verify === false
535
+ ? { kept: deduped.journeys, rejected: [], unchecked: [], how: 'Nobody asked for the repeat check, so nothing proved these journeys do the same thing twice.' }
536
+ : await checkReproducible(deduped.journeys, { regenerate, log: opts.log, signal: opts.signal });
537
+
538
+ if (verified.unchecked.length > 0) {
539
+ gaps.push({
540
+ what: `${verified.unchecked.length} journeys are being used without anything having proved they do the same thing twice.`,
541
+ why: 'Nothing here could walk them, and only journeys read out of the code can be checked by reading it again.',
542
+ unlockedBy: 'Hand gather a way to walk a journey — checkReproducible({walk, build}) — and every one of them gets walked twice before it is used.',
543
+ });
544
+ }
545
+ for (const rejection of verified.rejected) {
546
+ gaps.push({
547
+ what: `The journey "${rejection.journey}" was rejected before it was ever used.`,
548
+ why: rejection.why,
549
+ unlockedBy: 'Nothing to install. A journey that does not repeat has to be made steady, or left out on purpose.',
550
+ });
551
+ }
552
+
553
+ /** @type {Record<JourneySource, number>} */
554
+ const bySource = { code: 0, suite: 0, recorded: 0, explored: 0 };
555
+ let steps = 0;
556
+ let irreversible = 0;
557
+ for (const journey of verified.kept) {
558
+ bySource[journey.source] = (bySource[journey.source] ?? 0) + 1;
559
+ steps += journey.steps?.length ?? 0;
560
+ if (journey.irreversible) irreversible++;
561
+ }
562
+
563
+ /** @type {GatherReport} */
564
+ const report = {
565
+ bySource,
566
+ total: verified.kept.length,
567
+ steps,
568
+ irreversible,
569
+ invalid,
570
+ duplicates: deduped.dropped,
571
+ rejected: verified.rejected,
572
+ unchecked: verified.unchecked,
573
+ reproducibility: verified.how,
574
+ gaps,
575
+ missing,
576
+ notes,
577
+ durationMs: Date.now() - started,
578
+ };
579
+ return { journeys: verified.kept, report, doors };
580
+ }
581
+
582
+ /**
583
+ * @param {import('./from-suite.js').HarvestReport} report
584
+ * @returns {CoverageGap[]}
585
+ */
586
+ function suiteGaps(report) {
587
+ /** @type {CoverageGap[]} */
588
+ const gaps = [];
589
+ for (const rejected of report.rejected) {
590
+ gaps.push({
591
+ what: `The tests in ${rejected.file} are not being used as a journey.`,
592
+ why: rejected.why,
593
+ unlockedBy: 'Nothing to install. Either that file is not repeatable, or it needs something the harvest did not give it.',
594
+ });
595
+ }
596
+ if (report.runner === 'none') {
597
+ gaps.push({
598
+ what: 'No test suite could be harvested.',
599
+ why: report.why,
600
+ unlockedBy: "Point the project at vitest or Node's own test runner, and every test file becomes a journey.",
601
+ });
602
+ }
603
+ if (report.journeys > 0 && !report.touchedMeasured) {
604
+ gaps.push({
605
+ what: 'The harvested journeys do not know which files they touch.',
606
+ why: 'Nothing could measure what the tests exercised, so these journeys cannot say which of the doors in the code they open.',
607
+ unlockedBy: report.missing.find((m) => m.what.includes('coverage'))?.howToGet ?? 'Install the coverage package for the test runner.',
608
+ });
609
+ }
610
+ return gaps;
611
+ }
612
+
613
+ // ---------------------------------------------------------------------------
614
+ // Saying it in plain English
615
+ // ---------------------------------------------------------------------------
616
+
617
+ /**
618
+ * What was gathered, said the way it should appear in a summary somebody actually reads.
619
+ *
620
+ * @param {GatherReport} report
621
+ * @returns {string[]} one line each
622
+ */
623
+ export function describeGathering(report) {
624
+ /** @type {string[]} */
625
+ const lines = [];
626
+ const parts = SOURCE_TRUST.filter((source) => report.bySource[source] > 0).map(
627
+ (source) => `${report.bySource[source]} ${source === 'code' ? 'from the code' : source === 'suite' ? 'from the test suite' : source === 'recorded' ? 'recorded' : 'found by an agent'}`,
628
+ );
629
+ lines.push(
630
+ report.total === 0
631
+ ? 'No journeys could be gathered, so a check now would prove nothing.'
632
+ : `${report.total} journeys, ${parts.join(', ')} — ${report.steps} steps in all, and nobody wrote any of them.`,
633
+ );
634
+ lines.push(report.reproducibility);
635
+ if (report.rejected.length > 0) {
636
+ lines.push(`${report.rejected.length} were rejected for not doing the same thing twice on the same build.`);
637
+ }
638
+ if (report.unchecked.length > 0) {
639
+ lines.push(`${report.unchecked.length} could not be checked for repeating at all, so nothing is claimed about them.`);
640
+ }
641
+ if (report.duplicates.length > 0) {
642
+ lines.push(`${report.duplicates.length} were the same walk found by two different sources, so only the better-evidenced copy was kept.`);
643
+ }
644
+ if (report.irreversible > 0) {
645
+ lines.push(`${report.irreversible} contain a step that would spend money, send a message or destroy data. Those are watched at the call and stopped there.`);
646
+ }
647
+ if (report.gaps.length > 0) {
648
+ lines.push(`${report.gaps.length} things are not covered by these journeys, and each one says what would fix it.`);
649
+ }
650
+ return lines;
651
+ }