staysfixed 0.3.1 → 0.6.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 (48) hide show
  1. package/CHANGELOG.md +159 -3
  2. package/README.md +611 -402
  3. package/package.json +8 -3
  4. package/src/cli/index.js +14 -0
  5. package/src/v2/adapters/android-driver.js +1705 -0
  6. package/src/v2/adapters/android.js +1117 -0
  7. package/src/v2/adapters/contract.js +643 -0
  8. package/src/v2/adapters/electron.js +1594 -0
  9. package/src/v2/adapters/http.js +734 -0
  10. package/src/v2/adapters/ios-driver.js +1551 -0
  11. package/src/v2/adapters/ios.js +989 -0
  12. package/src/v2/adapters/isolate.js +739 -0
  13. package/src/v2/adapters/process.js +931 -0
  14. package/src/v2/adapters/source.js +1292 -0
  15. package/src/v2/adapters/web-driver.js +1532 -0
  16. package/src/v2/adapters/web.js +1009 -0
  17. package/src/v2/adapters/windows.js +1329 -0
  18. package/src/v2/browsers.js +1203 -0
  19. package/src/v2/cause.js +371 -0
  20. package/src/v2/check.js +1429 -0
  21. package/src/v2/ci.js +1209 -0
  22. package/src/v2/cli.js +670 -0
  23. package/src/v2/cluster.js +372 -0
  24. package/src/v2/coverage.js +1124 -0
  25. package/src/v2/detect.js +1199 -0
  26. package/src/v2/doctor.js +1702 -0
  27. package/src/v2/escalate.js +679 -0
  28. package/src/v2/init.js +1394 -0
  29. package/src/v2/intent.js +659 -0
  30. package/src/v2/journeys/from-routes.js +500 -0
  31. package/src/v2/journeys/from-suite.js +988 -0
  32. package/src/v2/journeys/index.js +651 -0
  33. package/src/v2/journeys/record.js +516 -0
  34. package/src/v2/mcp/server.js +374 -0
  35. package/src/v2/mcp/tools.js +1571 -0
  36. package/src/v2/normalise.js +783 -0
  37. package/src/v2/observation.js +938 -0
  38. package/src/v2/rank.js +672 -0
  39. package/src/v2/reference.js +1051 -0
  40. package/src/v2/remote.js +910 -0
  41. package/src/v2/run.js +1080 -0
  42. package/src/v2/sealed.js +568 -0
  43. package/src/v2/selfcheck.js +729 -0
  44. package/src/v2/ship.js +684 -0
  45. package/src/v2/store.js +703 -0
  46. package/src/v2/types.js +509 -0
  47. package/src/v2/waiver.js +511 -0
  48. package/src/v2/watch/focus.js +215 -0
@@ -0,0 +1,1429 @@
1
+ /**
2
+ * The engine's front door.
3
+ *
4
+ * `run.js` owns the loop — run the new build twice, subtract the wobble, compare, prove,
5
+ * cluster, rank. It deliberately knows nothing about where journeys come from, what a build
6
+ * is on disk, or how to boot an old one. This file is the part that knows, and it is the
7
+ * only thing the command line and the MCP server ever call.
8
+ *
9
+ * Everything above the loop lives here:
10
+ * - which adapters can drive this project, and which one owns each journey
11
+ * - where the steps come from: a journeys file, the project's config, or the code itself
12
+ * - what counts as "the build you have" and "the build you were happy with"
13
+ * - how the old build is put back on this machine so it can be walked live
14
+ *
15
+ * TWO PROMISES THIS FILE KEEPS.
16
+ *
17
+ * It never writes into the project being checked. The candidate is copied into a scratch
18
+ * folder before anything runs, and the old build is exported out of git with `git archive`,
19
+ * which reads history and touches neither the working tree nor `.git`.
20
+ *
21
+ * And it never reports "could not run" as "nothing changed". A check that was blocked comes
22
+ * back with `blocked` set, and every reader — the command line, the MCP reply, the self-check
23
+ * corpus — treats that as no answer at all rather than as a pass.
24
+ */
25
+
26
+ import fsp from 'node:fs/promises';
27
+ import { existsSync } from 'node:fs';
28
+ import path from 'node:path';
29
+ import os from 'node:os';
30
+ import { execFile, spawn } from 'node:child_process';
31
+ import { createHash } from 'node:crypto';
32
+ import { promisify } from 'node:util';
33
+
34
+ import { StaysFixedError, messageOf } from '../core/errors.js';
35
+ import { findConfigFile, rootForConfig } from '../core/paths.js';
36
+ import { sha256 } from '../core/hash.js';
37
+
38
+ import { openStore, ensureStore, saveBuild, newCaptureId, storeExists } from './store.js';
39
+ import { decide, noDecisions, readDecisions, rememberCheck, readCheckRecord } from './escalate.js';
40
+ import { sortObservations } from './observation.js';
41
+ import { DEFAULT_RULES, machineRules, mergeRules, normaliseCapture, loadRules } from './normalise.js';
42
+ import { runCheck, makeCheckEvents } from './run.js';
43
+ import { proveCause } from './cause.js';
44
+ import { whatChanged } from './rank.js';
45
+
46
+ import { processAdapter } from './adapters/process.js';
47
+ import { sourceAdapter } from './adapters/source.js';
48
+ import { httpAdapter } from './adapters/http.js';
49
+ import { webAdapter } from './adapters/web.js';
50
+ import { electronAdapter } from './adapters/electron.js';
51
+
52
+ const exec = promisify(execFile);
53
+
54
+ /** @typedef {import('./types.js').Verdict} Verdict */
55
+ /** @typedef {import('./types.js').Journey} Journey */
56
+ /** @typedef {import('./types.js').Capture} Capture */
57
+ /** @typedef {import('./types.js').Observation} Observation */
58
+ /** @typedef {import('./types.js').BuildFingerprint} BuildFingerprint */
59
+ /** @typedef {import('./types.js').Coverage} Coverage */
60
+ /** @typedef {import('./types.js').Channel} Channel */
61
+ /** @typedef {import('./types.js').NormaliseRule} NormaliseRule */
62
+ /** @typedef {import('./types.js').Surface} Surface */
63
+ /** @typedef {import('./types.js').CoverageGap} CoverageGap */
64
+ /** @typedef {import('./adapters/contract.js').Adapter} Adapter */
65
+ /** @typedef {import('./run.js').LiveBuild} LiveBuild */
66
+ /** @typedef {import('./run.js').WalkRequest} WalkRequest */
67
+ /** @typedef {import('./run.js').CheckEvents} CheckEvents */
68
+
69
+ /**
70
+ * What a check hands back.
71
+ *
72
+ * A Verdict, plus the two states a Verdict has no room for.
73
+ *
74
+ * BLOCKED: "I could not test this" is neither a pass nor a failure, and filing it under
75
+ * either is the exact failure this tool exists to prevent — so it travels as its own flag
76
+ * with a plain sentence beside it.
77
+ *
78
+ * ACCOUNTED: how much of what the engine found never reached the reader, and why. A verdict
79
+ * that quietly dropped fifty waived findings and a verdict that genuinely found nothing read
80
+ * identically without this, and one of those is a safety net that has been switched off.
81
+ *
82
+ * AIMED AT: what the run says it went for. A caller that aimed the check at a web page or
83
+ * a phone app has to be able to tell "it went there and found nothing" apart from "it
84
+ * quietly checked something else and found nothing", and those two read identically
85
+ * without this. It is only ever set when the run really did reach that surface.
86
+ *
87
+ * @typedef {Verdict & {blocked?: boolean, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
88
+ */
89
+
90
+ /**
91
+ * What the front door takes. Both spellings of the project folder are accepted because the
92
+ * command line says `root` and the MCP surface says `cwd`, and neither is worth a rename.
93
+ *
94
+ * @typedef {object} CheckOptions
95
+ * @property {string} [cwd]
96
+ * @property {string} [root]
97
+ * @property {string} [configFile]
98
+ * @property {string} [against] A commit, tag or stored build to compare against.
99
+ * @property {boolean} [paired] Boot the old build live from the start.
100
+ * @property {boolean} [storedOnly] Never boot the old build, not even to prove a suspicion.
101
+ * @property {string} [journeys] A path to a journeys file, or 'code' / 'config'.
102
+ * @property {Surface|'auto'} [surface] Aim the whole run at one kind of product.
103
+ * @property {string} [at] Where that product is: a URL for the web, the built app
104
+ * for a desktop, an APK or an .app bundle for a phone.
105
+ * @property {string[]} [only] Just these journeys, by name.
106
+ * @property {boolean} [remember]
107
+ * @property {string} [product]
108
+ * @property {CheckEvents} [events]
109
+ * @property {AbortSignal} [signal]
110
+ */
111
+
112
+ /** The adapters compiled into every copy, in the order the engine trusts them. Reading the code is free, so it is first. */
113
+ const BUILT_IN = [sourceAdapter, processAdapter, httpAdapter, webAdapter, electronAdapter];
114
+
115
+ /**
116
+ * The platforms that arrive as a file of their own.
117
+ *
118
+ * They are looked for at run time rather than imported, for one reason: a copy of Stays
119
+ * Fixed without the Android adapter in it must still run every other check, and must say
120
+ * "there is nothing here that can drive an Android app" rather than dying on an import.
121
+ * The alternative — handing an Android journey to whichever adapter happened to be in the
122
+ * table — is the exact bug that bit web and Electron last phase, and it is the worst
123
+ * failure this tool has: a journey nothing walked, reported as covered.
124
+ *
125
+ * @type {{surface: Surface, file: string, exports: string[], missing: string}[]}
126
+ */
127
+ const SEPARATE_ADAPTERS = [
128
+ {
129
+ surface: 'android',
130
+ file: './adapters/android.js',
131
+ exports: ['androidAdapter', 'adapter', 'default'],
132
+ missing: 'This copy has no Android adapter in it, so nothing here can install an APK on an emulator and read what is on its screen.',
133
+ },
134
+ {
135
+ surface: 'ios',
136
+ file: './adapters/ios.js',
137
+ exports: ['iosAdapter', 'adapter', 'default'],
138
+ missing: 'This copy has no iPhone adapter in it, so nothing here can boot the simulator and read what is on its screen.',
139
+ },
140
+ {
141
+ surface: 'windows',
142
+ file: './adapters/windows.js',
143
+ exports: ['windowsAdapter', 'adapter', 'default'],
144
+ missing:
145
+ 'This copy has no native-Windows adapter in it. That is usually fine: a Windows product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
146
+ },
147
+ ];
148
+
149
+ /**
150
+ * Every adapter this copy can actually use. Seeded with the built-in five and widened
151
+ * once, on the first check, by whatever separate adapters are present.
152
+ * @type {Adapter[]}
153
+ */
154
+ const ADAPTERS = [...BUILT_IN];
155
+
156
+ /**
157
+ * Surfaces there is no adapter for, and the plain sentence saying so. Read when a journey
158
+ * cannot be walked, so the gap names what is missing instead of shrugging.
159
+ * @type {Map<string, string>}
160
+ */
161
+ const NO_ADAPTER_FOR = new Map();
162
+
163
+ let adaptersLoaded = false;
164
+
165
+ /**
166
+ * Which adapter owns a journey, by the surface it says it walks.
167
+ *
168
+ * Every surface in the vocabulary appears here, and every one of them names an adapter
169
+ * built for that surface — never a stand-in. A surface whose adapter is not in this copy
170
+ * resolves to nothing, and nothing is walked and it is reported as a hole. A surface
171
+ * pointed at the wrong adapter walks nothing and is reported as COVERED, which is the one
172
+ * outcome this tool must never produce.
173
+ *
174
+ * @type {Record<Surface, string>}
175
+ */
176
+ export const ADAPTER_FOR_SURFACE = {
177
+ cli: 'process',
178
+ library: 'process',
179
+ server: 'http',
180
+ web: 'web',
181
+ electron: 'electron',
182
+ android: 'android',
183
+ ios: 'ios',
184
+ windows: 'windows',
185
+ };
186
+
187
+ /**
188
+ * Load the separate adapters, once per process.
189
+ *
190
+ * Exported so `doctor` and the tests can ask what this copy can actually drive without
191
+ * running a check. A refresh is only useful while Stays Fixed itself is being built and an
192
+ * adapter appears mid-session.
193
+ *
194
+ * @param {boolean} [refresh]
195
+ * @returns {Promise<{adapters: Adapter[], missing: Map<string, string>}>}
196
+ */
197
+ export async function loadAdapters(refresh = false) {
198
+ if (adaptersLoaded && !refresh) return { adapters: ADAPTERS, missing: NO_ADAPTER_FOR };
199
+ if (refresh) {
200
+ ADAPTERS.length = 0;
201
+ ADAPTERS.push(...BUILT_IN);
202
+ NO_ADAPTER_FOR.clear();
203
+ }
204
+
205
+ for (const spec of SEPARATE_ADAPTERS) {
206
+ const wanted = ADAPTER_FOR_SURFACE[spec.surface];
207
+ if (ADAPTERS.some((a) => a.name === wanted)) continue;
208
+ /** @type {Record<string, unknown>} */
209
+ let module;
210
+ try {
211
+ // The specifier is built from a variable on purpose. A literal would be resolved
212
+ // when this file is type-checked and fail there, in a copy where the file simply
213
+ // has not been written yet — which is a fact about this build, not an error.
214
+ const where = spec.file;
215
+ module = await import(where);
216
+ } catch (e) {
217
+ // "It is not here" and "it is here and it is broken" are two different facts and
218
+ // only one of them is fixed by installing a newer copy. Saying the first when the
219
+ // second is true sends somebody to reinstall a tool that is already installed.
220
+ NO_ADAPTER_FOR.set(
221
+ spec.surface,
222
+ existsSync(new URL(spec.file, import.meta.url))
223
+ ? `The ${spec.surface} adapter is in this copy and it will not load: ${messageOf(e)}. Nothing ${spec.surface} can be walked until that is fixed.`
224
+ : spec.missing,
225
+ );
226
+ continue;
227
+ }
228
+ const found = spec.exports.map((name) => module[name]).find((a) => a && typeof a === 'object' && typeof (/** @type {any} */ (a).run) === 'function');
229
+ if (!found) {
230
+ NO_ADAPTER_FOR.set(
231
+ spec.surface,
232
+ `${spec.missing} The file ${spec.file} is there, but it does not export ${spec.exports.join(' or ')}.`,
233
+ );
234
+ continue;
235
+ }
236
+ const adapter = /** @type {Adapter} */ (found);
237
+ if (adapter.name !== wanted) {
238
+ // A mismatch here would leave the adapter loaded and unreachable, which looks
239
+ // exactly like coverage and is not.
240
+ NO_ADAPTER_FOR.set(
241
+ spec.surface,
242
+ `${spec.file} exports an adapter called "${adapter.name}", and a ${spec.surface} journey looks for one called "${wanted}". Nothing will drive it until those agree.`,
243
+ );
244
+ continue;
245
+ }
246
+ ADAPTERS.push(adapter);
247
+ }
248
+
249
+ adaptersLoaded = true;
250
+ return { adapters: ADAPTERS, missing: NO_ADAPTER_FOR };
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // check
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Prove that nothing which already worked has changed.
259
+ *
260
+ * @param {CheckOptions} [options]
261
+ * @returns {Promise<CheckOutcome>}
262
+ */
263
+ export async function check(options = {}) {
264
+ const events = options.events ?? makeCheckEvents();
265
+ /** @type {Project|null} */
266
+ let project = null;
267
+ try {
268
+ project = await openProject(options);
269
+ const verdict = await runCheck({
270
+ store: project.store,
271
+ product: project.product,
272
+ candidate: project.candidate,
273
+ journeys: project.journeys,
274
+ gaps: project.gaps,
275
+ walk: project.walk,
276
+ cwd: project.root,
277
+ bootReference: project.bootReference,
278
+ against: project.against,
279
+ paired: options.paired === true,
280
+ storedOnly: options.storedOnly === true,
281
+ remember: options.remember,
282
+ normalise: project.normalise,
283
+ events,
284
+ signal: options.signal,
285
+ });
286
+ // The real ledger, door by door, before anything says how much was covered. The loop
287
+ // only knows how many doors it read out of the source and that no journey named one;
288
+ // this reads what every capture of this build actually touched and works out which
289
+ // doors were opened. Without it the coverage sentence is built on a count that says
290
+ // "nothing was walked" on a run that walked plenty.
291
+ await countTheDoors(verdict, project);
292
+
293
+ /** @type {CheckOutcome} */
294
+ const outcome = await settle(verdict, project.store, project.product);
295
+ // Only a run that really did reach the surface it was aimed at may say so. The
296
+ // confirmation is what lets a caller tell "it went there and found nothing" from
297
+ // "it checked something else and found nothing", and those are not the same answer.
298
+ if (project.target) outcome.target = project.target;
299
+ return outcome;
300
+ } catch (e) {
301
+ const outcome = blocked(options, e);
302
+ // A run that never happened still has to reach a person, because "no answer" looks
303
+ // exactly like "nothing changed" from the outside. It is only written down where a
304
+ // store already exists: a check aimed at a folder that was never set up must not leave
305
+ // a folder of its own behind as its parting gesture.
306
+ const store = openStore({ root: projectRootFor(options) });
307
+ if (storeExists(store)) await settle(outcome, store, outcome.product);
308
+ return outcome;
309
+ } finally {
310
+ if (project) await project.close();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * The step between "what is different" and "what anybody has to read".
316
+ *
317
+ * The engine's job ends at finding differences. Deciding which of them an agent may stop
318
+ * looking at is a separate job with its own rules, and it is done here rather than inside
319
+ * the loop so that an engine bug can never widen a gate. Three things happen:
320
+ *
321
+ * - findings already recorded as intended, against the reference that is in force NOW, are
322
+ * dropped from what anybody reads — and counted, out loud, on the verdict;
323
+ * - findings in a sealed class are marked unwaivable, so no later code has to re-derive
324
+ * that rule and no later code can get it wrong;
325
+ * - the whole thing is written down, so `staysfixed_explain`, `staysfixed_prove` and the
326
+ * escalation block can all be handed an id and answer honestly.
327
+ *
328
+ * Bookkeeping may never lose an answer. Everything after the arithmetic is wrapped, because
329
+ * a full disk is a reason to lose a record and never a reason to lose a verdict.
330
+ *
331
+ * @param {CheckOutcome} verdict
332
+ * @param {import('./types.js').Store} store
333
+ * @param {string} product
334
+ * @param {string[]} [guards] Guard names, so a difference touching one is sealed by name.
335
+ * @returns {Promise<CheckOutcome>}
336
+ */
337
+ async function settle(verdict, store, product, guards) {
338
+ /** @type {import('./escalate.js').Decisions} */
339
+ let decisions;
340
+ try {
341
+ decisions = await readDecisions(store, product);
342
+ } catch {
343
+ // Unreadable bookkeeping means nothing is accounted for, which reports MORE than it
344
+ // should rather than less. That is the only safe direction for this to fail in.
345
+ decisions = noDecisions(product);
346
+ }
347
+
348
+ const decided = decide(verdict.findings ?? [], decisions, { guards: guards ?? [] });
349
+ verdict.findings = decided.reported;
350
+ verdict.accounted = decided.accounting;
351
+ if (verdict.blocked !== true) {
352
+ verdict.ok = decided.reported.length === 0 && (verdict.newlyUnstable ?? []).length === 0;
353
+ // The count goes into the sentence a person and an agent both read, not into a field
354
+ // one of them has to know to look for.
355
+ if (decided.accounting.waived > 0 || decided.accounting.expiredWaivers > 0) {
356
+ verdict.summary = `${verdict.summary} ${decided.accounting.note}`;
357
+ }
358
+ // The worst shape a reply can have: every journey walked, not one of them with
359
+ // anything on the other side to compare against, and a verdict that reads "nothing
360
+ // that worked has changed". It is arithmetically true — nothing was compared, so
361
+ // nothing came back different — and it is the exact sentence that would let a real
362
+ // regression through. It is not a pass. It is no answer at all.
363
+ if (comparedNothing(verdict.coverage)) {
364
+ verdict.ok = false;
365
+ verdict.summary = `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
366
+ }
367
+
368
+ // And what was NOT looked at, in the same breath as the good news, on every run
369
+ // including the clean ones. A green verdict on a product with three hundred doors
370
+ // nobody has ever opened is true and it is not what it looks like, and the only place
371
+ // that difference can be made impossible to miss is inside the sentence everybody
372
+ // already reads. It goes last so it is the thing left in the reader's head.
373
+ verdict.summary = `${verdict.summary} ${whatWasNotChecked(verdict.coverage)}`;
374
+ }
375
+
376
+ try {
377
+ await rememberCheck(store, { product, verdict, decided });
378
+ } catch {
379
+ // Nothing here is worth failing a finished check over.
380
+ }
381
+ return verdict;
382
+ }
383
+
384
+ /**
385
+ * Replace this run's rough coverage count with the real one.
386
+ *
387
+ * `coverage.js` owns the arithmetic and one rule that makes it worth having: a door READ
388
+ * out of the source is not a door that was WALKED, so every contract observation is
389
+ * ignored when it works out what was opened. Getting that backwards would report perfect
390
+ * coverage on a product nobody ever ran.
391
+ *
392
+ * It is loaded at run time and every failure is swallowed. This runs after the answer is
393
+ * already in hand, and no bookkeeping is worth losing a finished check over — but the
394
+ * count it replaces is the honest-if-crude one the loop produced, so failing here leaves
395
+ * the reader with less detail and never with a rosier picture.
396
+ *
397
+ * @param {CheckOutcome} verdict
398
+ * @param {Project} project
399
+ * @returns {Promise<void>}
400
+ */
401
+ async function countTheDoors(verdict, project) {
402
+ try {
403
+ const { ledger, toCoverage } = await import('./coverage.js');
404
+ const led = await ledger(project.store, project.product, {
405
+ root: project.root,
406
+ journeys: project.journeys,
407
+ builds: [project.candidate.id],
408
+ });
409
+ // No stored captures means the ledger saw none of this run's walking, and every door
410
+ // would read as never opened. That is a worse answer than the one already in hand,
411
+ // not a better one, so it is refused.
412
+ if (led.captures === 0) return;
413
+
414
+ const better = toCoverage(led);
415
+ const run = verdict.coverage;
416
+ /** @type {Coverage} */
417
+ const merged = {
418
+ // What THIS run walked stays this run's own number. The ledger counts every capture
419
+ // of this build, including the second run of each journey, and doubling the address
420
+ // count would make the run look twice as thorough as it was.
421
+ paths: run?.paths ?? better.paths,
422
+ journeys: run?.journeys ?? better.journeys,
423
+ byChannel: run?.byChannel ?? better.byChannel,
424
+ gaps: dedupe([...(run?.gaps ?? []).filter((g) => typeof g.doors !== 'number'), ...better.gaps]),
425
+ };
426
+ if (better.doorsKnown !== undefined) {
427
+ merged.doorsKnown = better.doorsKnown;
428
+ merged.doorsWalked = better.doorsWalked ?? 0;
429
+ }
430
+ verdict.coverage = merged;
431
+ } catch {
432
+ // The ledger could not be drawn up. The run's own count stands, and it errs towards
433
+ // saying less was covered rather than more.
434
+ }
435
+ }
436
+
437
+ /**
438
+ * @param {CoverageGap[]} gaps
439
+ * @returns {CoverageGap[]}
440
+ */
441
+ function dedupe(gaps) {
442
+ /** @type {CoverageGap[]} */
443
+ const out = [];
444
+ const seen = new Set();
445
+ for (const gap of gaps) {
446
+ const key = `${gap.what}|${gap.why}`;
447
+ if (seen.has(key)) continue;
448
+ seen.add(key);
449
+ out.push(gap);
450
+ }
451
+ return out;
452
+ }
453
+
454
+ /**
455
+ * What this run did NOT look at, in words, always.
456
+ *
457
+ * THIS IS THE MOST IMPORTANT SENTENCE THE TOOL PRODUCES, and the reason is arithmetic
458
+ * rather than rhetoric: a tool that says "nothing changed" is indistinguishable from a
459
+ * tool that looked at nothing, and the more useful this becomes the more a clean result
460
+ * will be trusted without being read. So the sentence is never optional, never omitted on
461
+ * a good run, and never phrased as "fully checked" — because nothing ever is. Even a run
462
+ * that walked every door it knows about has only walked the doors it knows about.
463
+ *
464
+ * It returns a sentence in every case. There is deliberately no path through this function
465
+ * that returns nothing, because a caller that could get an empty string back would sooner
466
+ * or later drop the whole line on the runs where it matters most.
467
+ *
468
+ * @param {Coverage|undefined} coverage
469
+ * @returns {string}
470
+ */
471
+ export function whatWasNotChecked(coverage) {
472
+ if (!coverage) {
473
+ return 'This run did not say what it covered, so how much of your product was actually looked at is unknown — treat a clean result as unproven until it does.';
474
+ }
475
+ if ((coverage.paths ?? 0) === 0) {
476
+ return 'Nothing was walked at all, so none of this says anything about your product either way.';
477
+ }
478
+
479
+ const known = coverage.doorsKnown ?? 0;
480
+ const walked = coverage.doorsWalked ?? 0;
481
+ const unopened = Math.max(0, known - walked);
482
+ const gaps = coverage.gaps ?? [];
483
+
484
+ /** @type {string[]} */
485
+ const parts = [];
486
+ if (unopened > 0) {
487
+ parts.push(
488
+ known === 1
489
+ ? 'the only way into this product has never been walked through, so nothing here says anything about it'
490
+ : `${unopened} of the ${known} ways into this product ${unopened === 1 ? 'has' : 'have'} never been walked through, so nothing here says anything about ${unopened === 1 ? 'it' : 'them'}`,
491
+ );
492
+ }
493
+ // The doors gap is already counted above, in its own words. Counting it twice would
494
+ // make the list look longer than it is, and a number a reader can catch out is a number
495
+ // they stop believing. Several gaps can also share one sentence — the coverage count's
496
+ // own caveats all do — and three identical lines read as three separate holes.
497
+ const others = [...new Set(gaps.filter((g) => typeof g.doors !== 'number').map((g) => g.what))];
498
+ if (others.length > 0) {
499
+ // The example is the most concrete one there is. A caveat about how exact the count
500
+ // is, chosen as the illustration, teaches a reader nothing about what was missed.
501
+ const example = others.find((what) => !/less exact than it looks/i.test(what)) ?? others[0];
502
+ parts.push(
503
+ `${others.length} other ${others.length === 1 ? 'thing was' : 'things were'} not looked at (${plainly(example)}${others.length > 1 ? ', and more' : ''})`,
504
+ );
505
+ }
506
+
507
+ if (parts.length === 0) {
508
+ return `Everything this run knows how to walk was walked — ${coverage.paths} ${coverage.paths === 1 ? 'address' : 'addresses'} across ${coverage.journeys} ${coverage.journeys === 1 ? 'journey' : 'journeys'}. That is not every possible state of your product; nothing can enumerate that, and a clean result only covers what was walked.`;
509
+ }
510
+ return `NOT EVERYTHING WAS CHECKED: ${parts.join(', and ')}. A clean result only covers what was walked — the whole list is in coverage.gaps.`;
511
+ }
512
+
513
+ /**
514
+ * Was there anything on the other side to compare against at all?
515
+ *
516
+ * The engine records one gap per journey it had no stored record for. When that count
517
+ * reaches every journey that was walked, the run compared nothing whatever — and a run
518
+ * that compared nothing produces zero differences, which is indistinguishable from a
519
+ * product that did not change.
520
+ *
521
+ * The gaps are recognised by the words the engine writes into them. A test walks a real
522
+ * project into exactly this state and requires the verdict not to read as a pass, so if
523
+ * those words are ever reworded the guard fails loudly instead of quietly switching off.
524
+ *
525
+ * @param {Coverage|undefined} coverage
526
+ * @returns {boolean}
527
+ */
528
+ function comparedNothing(coverage) {
529
+ const walked = coverage?.journeys ?? 0;
530
+ if (walked === 0) return false;
531
+ const nothingToCompare = (coverage?.gaps ?? []).filter((gap) =>
532
+ /never been walked against|no stored record of the old build/i.test(`${gap.what} ${gap.why}`),
533
+ ).length;
534
+ return nothingToCompare >= walked;
535
+ }
536
+
537
+ /**
538
+ * One gap's sentence, trimmed to something that fits inside another sentence.
539
+ * @param {string} what
540
+ * @returns {string}
541
+ */
542
+ function plainly(what) {
543
+ const one = String(what ?? '').replace(/\s+/g, ' ').trim().replace(/\.$/, '');
544
+ return one.length > 120 ? `${one.slice(0, 117)}...` : one;
545
+ }
546
+
547
+ /**
548
+ * A check that never happened, said in a shape every reader already understands.
549
+ *
550
+ * @param {CheckOptions} options
551
+ * @param {unknown} e
552
+ * @returns {CheckOutcome}
553
+ */
554
+ function blocked(options, e) {
555
+ const product = options.product ?? path.basename(path.resolve(options.cwd ?? options.root ?? process.cwd()));
556
+ const empty = { id: '', product };
557
+ return {
558
+ runId: new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14),
559
+ product,
560
+ ok: false,
561
+ blocked: true,
562
+ mode: 'stored-record',
563
+ modeWarning: 'Nothing was compared, so nothing here says anything about your product either way.',
564
+ reference: empty,
565
+ candidate: empty,
566
+ findings: [],
567
+ differencesReal: 0,
568
+ differencesNoise: 0,
569
+ newlyUnstable: [],
570
+ coverage: { paths: 0, journeys: 0, byChannel: {}, gaps: [{ what: 'Everything.', why: messageOf(e) }] },
571
+ // The hint is the half that tells a person what to DO about it, and dropping it
572
+ // turns a helpful error into a dead end. Anything that blocks a run has to carry
573
+ // both halves all the way out to whoever reads the summary.
574
+ summary: `The check could not be run, so this is not a pass and not a failure. ${messageOf(e)}${
575
+ e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
576
+ }`,
577
+ durationMs: 0,
578
+ startedAt: new Date().toISOString(),
579
+ };
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // prove
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /**
587
+ * Undo one change, walk the journey again, and see whether the difference goes away.
588
+ *
589
+ * This is the facade `src/v2/mcp/tools.js` asks for: it takes a finding id and a list of
590
+ * files, both of which an agent already has, and does the loading `proveCause` cannot do
591
+ * for itself.
592
+ *
593
+ * @param {CheckOptions & {finding?: string, revert?: string[]}} options
594
+ * @returns {Promise<{gone: boolean, detail?: string, verdict?: string, escalates?: boolean}>}
595
+ */
596
+ export async function prove(options = {}) {
597
+ const root = projectRootFor(options);
598
+ // The same record `check` writes and the MCP surface reads. One file, so a finding id an
599
+ // agent was handed a moment ago still means the same finding here.
600
+ const last = await readCheckRecord(openStore({ root }));
601
+ const finding = last?.findings?.find((f) => f.id === options.finding);
602
+ if (!finding) {
603
+ return {
604
+ gone: false,
605
+ detail: `The last check has no finding called "${options.finding ?? ''}". Run a check first, then prove one of the ids it gives you.`,
606
+ };
607
+ }
608
+
609
+ const project = await openProject(options);
610
+ try {
611
+ const changed = await whatChanged(project.root);
612
+ const wanted = (options.revert ?? []).map((f) => f.replace(/^\.\//, ''));
613
+ const narrowed = wanted.length
614
+ ? { ...changed, hunks: changed.hunks.filter((h) => wanted.some((w) => h.file === w || h.file.startsWith(`${w}/`))) }
615
+ : changed;
616
+
617
+ const proof = await proveCause(finding, {
618
+ cwd: project.root,
619
+ walk: project.walk,
620
+ journeys: project.journeys,
621
+ candidate: project.candidate,
622
+ changed: narrowed,
623
+ normalise: project.normalise,
624
+ signal: options.signal,
625
+ });
626
+ return {
627
+ gone: proof.verdict === 'caused by that change',
628
+ verdict: proof.verdict,
629
+ escalates: proof.escalates,
630
+ detail: proof.why ? `${proof.what} ${proof.why}` : proof.what,
631
+ };
632
+ } finally {
633
+ await project.close();
634
+ }
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // explain
639
+ // ---------------------------------------------------------------------------
640
+
641
+ /**
642
+ * One finding, with the detail a check reply deliberately left out.
643
+ *
644
+ * The check reply carries one sample value per finding, on purpose: five hundred differences
645
+ * from one missing stylesheet must not cost an agent its whole context. This is where the
646
+ * rest is kept, and it is only ever fetched, never pushed.
647
+ *
648
+ * @param {CheckOptions & {finding?: string, include?: string[]}} options
649
+ * @returns {Promise<{text: string, pictures: string[]}>}
650
+ */
651
+ export async function explain(options = {}) {
652
+ const store = openStore({ root: projectRootFor(options) });
653
+ const last = await readCheckRecord(store);
654
+ const f = last?.findings?.find((x) => x.id === options.finding);
655
+ if (!f) {
656
+ return { text: `The last check has no finding called "${options.finding ?? ''}", so there is nothing to go deeper on.`, pictures: [] };
657
+ }
658
+
659
+ /** @type {string[]} */
660
+ const out = [];
661
+ const differences = f.differences ?? [];
662
+ out.push(`${differences.length} ${differences.length === 1 ? 'address' : 'addresses'} in this finding, in full:`);
663
+ for (const d of differences.slice(0, 40)) {
664
+ if (d.kind === 'appeared') out.push(` ${d.path} — was not there before, and now it is ${short(d.candidate)}`);
665
+ else if (d.kind === 'vanished') out.push(` ${d.path} — was ${short(d.reference)}, and now it is not there at all`);
666
+ else out.push(` ${d.path} — was ${short(d.reference)}, now ${short(d.candidate)}`);
667
+ }
668
+ if (differences.length > 40) out.push(` and ${differences.length - 40} more.`);
669
+ if (f.nearFiles?.length) out.push('', `Nearest code: ${f.nearFiles.slice(0, 6).join(', ')}.`);
670
+ if (f.unwaivable === true) out.push('', `This cannot be recorded as intended by anyone: ${f.unwaivableWhy ?? 'a person has to look at it'}.`);
671
+ if (f.waivedBecause) out.push('', `Already recorded as intended: ${f.waivedBecause}`);
672
+
673
+ const pictures = differences.map((d) => d.evidence).filter((/** @type {string|undefined} */ e) => typeof e === 'string' && /\.png$/i.test(e));
674
+ return { text: out.join('\n'), pictures: /** @type {string[]} */ (pictures) };
675
+ }
676
+
677
+ /**
678
+ * @param {unknown} value
679
+ * @returns {string}
680
+ */
681
+ function short(value) {
682
+ const text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
683
+ return text.length > 200 ? `${text.slice(0, 197)}...` : text;
684
+ }
685
+
686
+ // ---------------------------------------------------------------------------
687
+ // Opening a project
688
+ // ---------------------------------------------------------------------------
689
+
690
+ /**
691
+ * Everything the loop needs, gathered once.
692
+ *
693
+ * @typedef {object} Project
694
+ * @property {string} root
695
+ * @property {string} product
696
+ * @property {import('./types.js').Store} store
697
+ * @property {BuildFingerprint} candidate
698
+ * @property {string} [against] The reference build's own id, once a name has been resolved.
699
+ * @property {Journey[]} journeys
700
+ * @property {CoverageGap[]} gaps Holes found while working out WHAT to walk, before a
701
+ * single journey ran. An adapter that fell over listing its journeys belongs here, and it
702
+ * has to reach the verdict: a channel that silently dropped out is the worst thing this
703
+ * tool can do.
704
+ * @property {import('./run.js').Walker} walk
705
+ * @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
706
+ * @property {(capture: Capture) => Capture} normalise
707
+ * @property {{surface: string, at: string|null}} [target] Set only when the run was aimed
708
+ * at one kind of product AND something here can actually drive it.
709
+ * @property {() => Promise<void>} close
710
+ */
711
+
712
+ /**
713
+ * @param {CheckOptions} options
714
+ * @returns {string}
715
+ */
716
+ function projectRootFor(options) {
717
+ const from = path.resolve(options.cwd ?? options.root ?? process.cwd());
718
+ const config = options.configFile ?? findConfigFile(from);
719
+ return config ? rootForConfig(config) : from;
720
+ }
721
+
722
+ /**
723
+ * @param {CheckOptions} options
724
+ * @returns {Promise<Project>}
725
+ */
726
+ async function openProject(options) {
727
+ await loadAdapters();
728
+ const root = projectRootFor(options);
729
+ const configFile = options.configFile ?? findConfigFile(root) ?? null;
730
+ const aim = aimOf(options);
731
+ const config = aimAt(await readConfig(configFile), aim);
732
+ const product = options.product ?? String(config.product ?? (await packageName(root)) ?? path.basename(root));
733
+
734
+ const store = openStore({ root });
735
+ await ensureStore(store);
736
+
737
+ const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
738
+ const evidenceDir = path.join(scratch, 'evidence');
739
+ await fsp.mkdir(evidenceDir, { recursive: true });
740
+
741
+ // Working out what there is to walk comes FIRST, before anything is asked of git. Somebody
742
+ // standing in a folder they have not set up yet should be told to run `init`, not told
743
+ // about a git requirement they have no reason to care about yet.
744
+ const gathered = await gatherJourneys({ root, config, options });
745
+ const journeys = narrowToTarget(gathered.journeys, aim);
746
+ if (journeys.length === 0) {
747
+ await fsp.rm(scratch, { recursive: true, force: true });
748
+ // Two different situations wear the same symptom, and the difference is the
749
+ // whole of what a person needs to hear. A project that has never been set up
750
+ // should be told to set it up; one that IS set up and still has nothing to walk
751
+ // has a settings file that says nothing, which is a different problem with a
752
+ // different fix. Saying "nothing to walk" to the first is true and useless.
753
+ if (!configFile) {
754
+ throw new StaysFixedError('No Stays Fixed config found here, so there is nothing to check.', {
755
+ hint: 'Run `staysfixed init` in your project to make one. It takes about thirty seconds.',
756
+ });
757
+ }
758
+ throw new StaysFixedError('There is nothing to walk in this project, so a check would prove nothing.', {
759
+ hint:
760
+ 'List the commands worth running under "process": {"commands": [{"name": "help", "run": "node bin/cli.js --help"}]} in your staysfixed config, ' +
761
+ 'or point the check at a journeys file with --journeys <file>.',
762
+ });
763
+ }
764
+
765
+ const candidate = await fingerprintWorkingTree(root, product);
766
+ await saveBuild(store, candidate);
767
+
768
+ // A name like "HEAD", "v0.13.0" or a branch is what a person types; the store only knows
769
+ // builds. Turning the name into a commit here, and putting that commit in the store, is
770
+ // what lets a check be aimed at any point in history without every commit having been
771
+ // walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
772
+ const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
773
+ if (reference) await saveBuild(store, reference);
774
+
775
+ const rules = mergeRules(DEFAULT_RULES, [
776
+ ...machineRules({ root, home: os.homedir(), tmp: os.tmpdir() }),
777
+ ...machineRules({ root: scratch }),
778
+ ...(await loadRules(path.join(root, '.staysfixed', 'rules.json'))),
779
+ ]);
780
+
781
+ /** @type {(capture: Capture) => Capture} */
782
+ const normalise = (capture) => normaliseCapture(capture, rules);
783
+
784
+ /** @type {(() => Promise<void>)[]} */
785
+ const cleanUps = [async () => fsp.rm(scratch, { recursive: true, force: true })];
786
+
787
+ /** @type {import('./run.js').Walker} */
788
+ const walk = async (req) => walkOne(req, { root, scratch, evidenceDir, config });
789
+
790
+ /** @type {Project['bootReference']} */
791
+ const bootReference = async (reference, ctx) => {
792
+ const live = await exportBuild(root, reference, scratch);
793
+ if (live) cleanUps.push(live.release);
794
+ if (live) ctx.events?.emit({ type: 'note', at: ctx.events.elapsed(), message: live.why ?? 'The old build is on this machine.' });
795
+ return live;
796
+ };
797
+
798
+ /** @type {Project} */
799
+ const project = {
800
+ root,
801
+ product,
802
+ store,
803
+ candidate,
804
+ against: reference ? reference.id : options.against,
805
+ journeys,
806
+ gaps: gathered.gaps,
807
+ walk,
808
+ bootReference,
809
+ normalise,
810
+ close: async () => {
811
+ for (const done of cleanUps.reverse()) {
812
+ try {
813
+ await done();
814
+ } catch {
815
+ // Cleaning up is best effort. A scratch folder left behind is untidy; failing a
816
+ // finished check because of one is worse.
817
+ }
818
+ }
819
+ for (const adapter of ADAPTERS) {
820
+ try {
821
+ await adapter.teardown();
822
+ } catch {
823
+ // Same again: an adapter that will not tidy up cannot be allowed to lose the answer.
824
+ }
825
+ }
826
+ },
827
+ };
828
+ if (aim.surface) project.target = { surface: aim.surface, at: aim.at };
829
+ return project;
830
+ }
831
+
832
+ // ---------------------------------------------------------------------------
833
+ // Aiming a run at one kind of product
834
+ // ---------------------------------------------------------------------------
835
+
836
+ /**
837
+ * What the caller aimed this run at, if anything.
838
+ *
839
+ * @param {CheckOptions} options
840
+ * @returns {{surface: Surface|null, at: string|null}}
841
+ */
842
+ function aimOf(options) {
843
+ const asked = options.surface && options.surface !== 'auto' ? String(options.surface) : null;
844
+ const at = typeof options.at === 'string' && options.at.trim() !== '' ? options.at.trim() : null;
845
+ if (asked !== null && !(asked in ADAPTER_FOR_SURFACE)) {
846
+ throw new StaysFixedError(`There is no kind of product called "${asked}".`, {
847
+ hint: `The kinds are: ${Object.keys(ADAPTER_FOR_SURFACE).join(', ')}.`,
848
+ });
849
+ }
850
+ return { surface: /** @type {Surface|null} */ (asked), at };
851
+ }
852
+
853
+ /**
854
+ * Which settings key each adapter reads for "where the product is".
855
+ *
856
+ * An `at` that reached no adapter would be quietly ignored, and the run would come back
857
+ * clean about somewhere else entirely — the most dangerous shape a reply can have. So an
858
+ * `at` with nowhere to put it is refused rather than dropped.
859
+ *
860
+ * @type {Record<string, string[]>}
861
+ */
862
+ const WHERE_KEY = {
863
+ web: ['url', 'baseUrl'],
864
+ server: ['baseUrl', 'url'],
865
+ electron: ['binary'],
866
+ android: ['apk'],
867
+ ios: ['app'],
868
+ };
869
+
870
+ /**
871
+ * Put "where the product is" into the settings the aimed adapter will read.
872
+ *
873
+ * @param {Record<string, any>} config
874
+ * @param {{surface: Surface|null, at: string|null}} aim
875
+ * @returns {Record<string, any>}
876
+ */
877
+ function aimAt(config, aim) {
878
+ if (aim.at === null) return config;
879
+ if (aim.surface === null) {
880
+ throw new StaysFixedError(`You said where to look ("${aim.at}") without saying what kind of product is there.`, {
881
+ hint: 'Name the surface too, e.g. surface: "web" with a URL, or surface: "electron" with the path to the built app.',
882
+ });
883
+ }
884
+ const keys = WHERE_KEY[ADAPTER_FOR_SURFACE[aim.surface]] ?? WHERE_KEY[aim.surface];
885
+ if (!keys) {
886
+ throw new StaysFixedError(`A ${aim.surface} check has nowhere to put "${aim.at}", so it would have been ignored.`, {
887
+ hint: 'Leave "at" out for this kind of product, and let the settings say what to run.',
888
+ });
889
+ }
890
+ const name = ADAPTER_FOR_SURFACE[aim.surface];
891
+ /** @type {Record<string, any>} */
892
+ const slice = { ...(config[name] ?? {}) };
893
+ for (const key of keys) slice[key] = aim.at;
894
+ return { ...config, [name]: slice };
895
+ }
896
+
897
+ /**
898
+ * Keep only the journeys that walk the surface this run was aimed at.
899
+ *
900
+ * Refusing loudly is the whole point. A run aimed at a phone app in a project with no
901
+ * phone journeys, and no adapter that could drive one, must not come back green about the
902
+ * command-line tool that happened to be sitting next to it.
903
+ *
904
+ * @param {Journey[]} journeys
905
+ * @param {{surface: Surface|null, at: string|null}} aim
906
+ * @returns {Journey[]}
907
+ */
908
+ function narrowToTarget(journeys, aim) {
909
+ if (aim.surface === null) return journeys;
910
+ const wanted = ADAPTER_FOR_SURFACE[aim.surface];
911
+ const driver = ADAPTERS.find((a) => a.name === wanted);
912
+ if (!driver) {
913
+ throw new StaysFixedError(
914
+ `This run was aimed at ${aim.surface}, and nothing in this copy can drive ${aim.surface}. ${NO_ADAPTER_FOR.get(aim.surface) ?? ''}`.trim(),
915
+ { hint: 'Run `staysfixed doctor` to see what this copy and this machine can drive, and what would unlock the rest.' },
916
+ );
917
+ }
918
+ const kept = journeys.filter((j) => j.surface === aim.surface);
919
+ if (kept.length === 0) {
920
+ throw new StaysFixedError(`This run was aimed at ${aim.surface}, and this project has no ${aim.surface} journey to walk.`, {
921
+ hint:
922
+ `Nothing was checked rather than something else being checked and reported as though it were the ${aim.surface} one. ` +
923
+ `Add ${/^[aeiou]/i.test(wanted) ? 'an' : 'a'} "${wanted}" section to your Stays Fixed settings naming where the product is, or leave the surface out to check everything this project does have.`,
924
+ });
925
+ }
926
+ return kept;
927
+ }
928
+
929
+ // ---------------------------------------------------------------------------
930
+ // Walking one journey
931
+ // ---------------------------------------------------------------------------
932
+
933
+ /**
934
+ * Walk one journey once, and turn what the adapter saw into a capture.
935
+ *
936
+ * Every walk gets its OWN scratch copy of the build. That is more copying than an adapter
937
+ * would do left to itself, and it is not negotiable: run one journey twice into the same
938
+ * folder and the second run starts with the first run's files already written, so a file
939
+ * the product creates every time looks like a file it created once. That reads as wobble,
940
+ * and wobble is subtracted — which would switch off the whole "a file is no longer written"
941
+ * class of finding without a word of warning.
942
+ *
943
+ * @param {WalkRequest} req
944
+ * @param {{root: string, scratch: string, evidenceDir: string, config: Record<string, any>}} where
945
+ * @returns {Promise<Capture>}
946
+ */
947
+ async function walkOne(req, where) {
948
+ const started = Date.now();
949
+ const startedAt = new Date().toISOString();
950
+ const adapter = adapterFor(req.journey);
951
+ // A reference walk comes with the folder the old build was exported into. A candidate
952
+ // walk reads the working tree.
953
+ const from = req.dir ?? where.root;
954
+
955
+ /** @type {Observation[]} */
956
+ let observations = [];
957
+ /** @type {import('./types.js').CoverageGap[]} */
958
+ const gaps = [];
959
+
960
+ if (!adapter) {
961
+ gaps.push({
962
+ what: `The journey "${req.journey.describe || req.journey.name}" was not walked, so nothing at all is known about it.`,
963
+ // Naming the missing adapter matters more than it looks. "Nothing knows how to
964
+ // drive this" is a shrug; "there is no Android adapter in this copy" is something
965
+ // an agent can act on, and it is the difference between a hole somebody closes and
966
+ // a hole somebody skims past.
967
+ why: NO_ADAPTER_FOR.get(req.journey.surface) ?? `Nothing in this copy drives a ${req.journey.surface} journey.`,
968
+ unlockedBy: `Install a copy of Stays Fixed that has the ${req.journey.surface} adapter in it, or write this journey against something that is here: a command, a module import, an HTTP route, a web page, or a desktop app.`,
969
+ surface: req.journey.surface,
970
+ });
971
+ } else {
972
+ const runId = `${req.build.id}-${req.run}-${req.journey.name}`;
973
+ const ctx = {
974
+ signal: req.signal,
975
+ scratchDir: path.join(where.scratch, safeSegment(runId)),
976
+ evidenceDir: where.evidenceDir,
977
+ seed: 20260829,
978
+ clock: '2026-08-29T09:00:00.000Z',
979
+ config: where.config[adapter.name] ?? {},
980
+ /** @param {string} message */
981
+ log: (message) => req.events?.emit({ type: 'note', at: req.events.elapsed(), message }),
982
+ };
983
+ await fsp.mkdir(ctx.scratchDir, { recursive: true });
984
+
985
+ /** @type {import('./adapters/contract.js').PreparedBuild|null} */
986
+ let prepared = null;
987
+ try {
988
+ prepared = await adapter.prepare(
989
+ {
990
+ id: runId,
991
+ label: req.which === 'reference' ? 'the build you were happy with' : 'the build you have',
992
+ role: req.which,
993
+ root: from,
994
+ gitSha: req.build.gitSha ?? null,
995
+ },
996
+ ctx,
997
+ );
998
+ observations = await adapter.run(req.journey, prepared, ctx);
999
+ } catch (e) {
1000
+ // A journey that fell over is a hole in the coverage, never a silent pass and never
1001
+ // the end of the run — the other journeys' work is worth keeping.
1002
+ gaps.push({
1003
+ what: `The journey "${req.journey.describe || req.journey.name}" stopped partway.`,
1004
+ why: messageOf(e),
1005
+ unlockedBy: 'Run that one journey on its own to see what it does.',
1006
+ surface: req.journey.surface,
1007
+ });
1008
+ } finally {
1009
+ if (prepared) {
1010
+ try {
1011
+ await prepared.dispose();
1012
+ } catch {
1013
+ // Best effort, as above.
1014
+ }
1015
+ }
1016
+ await fsp.rm(ctx.scratchDir, { recursive: true, force: true }).catch(() => {});
1017
+ }
1018
+ }
1019
+
1020
+ /** @type {Partial<Record<Channel, number>>} */
1021
+ const byChannel = {};
1022
+ for (const o of observations) byChannel[o.channel] = (byChannel[o.channel] ?? 0) + 1;
1023
+
1024
+ /** @type {Coverage} */
1025
+ const coverage = {
1026
+ paths: observations.length,
1027
+ journeys: 1,
1028
+ byChannel,
1029
+ gaps,
1030
+ };
1031
+ const doors = observations.filter((o) => o.channel === 'contract').length;
1032
+ if (doors > 0) {
1033
+ coverage.doorsKnown = doors;
1034
+ // Nothing walked through them: the contract channel reads doors out of the source, and
1035
+ // knowing a door exists is not the same as having opened it. Saying so is the coverage
1036
+ // ledger doing its job.
1037
+ coverage.doorsWalked = 0;
1038
+ }
1039
+
1040
+ return {
1041
+ id: newCaptureId(req.run),
1042
+ journey: req.journey.name,
1043
+ source: req.journey.source,
1044
+ build: req.build,
1045
+ run: req.run,
1046
+ startedAt,
1047
+ durationMs: Date.now() - started,
1048
+ observations: sortObservations(observations),
1049
+ coverage,
1050
+ complete: true,
1051
+ };
1052
+ }
1053
+
1054
+ /**
1055
+ * @param {Journey} journey
1056
+ * @returns {Adapter|null}
1057
+ */
1058
+ function adapterFor(journey) {
1059
+ const step = /** @type {{act?: string}} */ (journey.steps?.[0] ?? {});
1060
+ if (step.act === 'read') return sourceAdapter;
1061
+ const wanted = /** @type {Record<string, string>} */ (ADAPTER_FOR_SURFACE)[journey.surface];
1062
+ return ADAPTERS.find((a) => a.name === wanted) ?? null;
1063
+ }
1064
+
1065
+ // ---------------------------------------------------------------------------
1066
+ // Where the steps come from
1067
+ // ---------------------------------------------------------------------------
1068
+
1069
+ /**
1070
+ * Journeys, in the order the design ranks them: read out of the code first, because it is
1071
+ * free and exact, then whatever the project's own config or a journeys file names.
1072
+ *
1073
+ * The contract journey is always added. It costs one read of the source, it runs no code at
1074
+ * all, and it is the only channel that sees a door nobody has ever walked through.
1075
+ *
1076
+ * @param {{root: string, config: Record<string, any>, options: CheckOptions}} a
1077
+ * @returns {Promise<{journeys: Journey[], gaps: CoverageGap[]}>}
1078
+ */
1079
+ async function gatherJourneys({ root, config, options }) {
1080
+ /** @type {Journey[]} */
1081
+ const journeys = [];
1082
+ /** @type {CoverageGap[]} */
1083
+ const gaps = [];
1084
+
1085
+ const named = options.journeys && options.journeys !== 'code' && options.journeys !== 'config' ? options.journeys : null;
1086
+ if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
1087
+
1088
+ for (const adapter of ADAPTERS) {
1089
+ if (adapter === sourceAdapter && named && options.journeys !== 'code') {
1090
+ // A journeys file names exactly what to walk. The contract read is still added,
1091
+ // because it cannot break anything and it sees what no journey does.
1092
+ }
1093
+ /** @type {import('./adapters/contract.js').AdapterProject} */
1094
+ const project = { root, config: config[adapter.name] ?? {} };
1095
+ let detection;
1096
+ try {
1097
+ detection = await adapter.detect(project);
1098
+ } catch (e) {
1099
+ // BOTH of these used to be swallowed without a word, and that is the same shape of
1100
+ // failure as the source reader skipping a 3.5MB bundle: a whole channel drops out of
1101
+ // the run, nothing is walked, and the verdict says "nothing that worked has changed".
1102
+ // An adapter that FALLS OVER is a hole. An adapter that says "this is not my kind of
1103
+ // project" is not, which is why only the throw is recorded here.
1104
+ gaps.push({
1105
+ what: `Nothing was checked through the "${adapter.name}" adapter, because it could not even work out whether it applies to this project.`,
1106
+ why: messageOf(e),
1107
+ unlockedBy: `Run \`staysfixed doctor\` to see what the ${adapter.name} adapter needs here. Until then, anything only it can see is not being watched.`,
1108
+ });
1109
+ continue;
1110
+ }
1111
+ if (!detection.applies) continue;
1112
+ if (adapter !== sourceAdapter && named) continue;
1113
+ try {
1114
+ journeys.push(...(await adapter.journeys(project)));
1115
+ } catch (e) {
1116
+ gaps.push({
1117
+ what: `The "${adapter.name}" adapter applies to this project and could not say what it would walk, so it walked nothing.`,
1118
+ why: messageOf(e),
1119
+ unlockedBy: `Fix what it is complaining about, or name the steps yourself in a journeys file. This is a hole, not a pass.`,
1120
+ });
1121
+ }
1122
+ }
1123
+
1124
+ const only = options.only ?? [];
1125
+ const chosen = only.length > 0 ? journeys.filter((j) => only.some((n) => j.name === n || j.name.includes(n))) : journeys;
1126
+ if (only.length > 0) {
1127
+ for (const wanted of only) {
1128
+ if (chosen.some((j) => j.name === wanted || j.name.includes(wanted))) continue;
1129
+ gaps.push({
1130
+ what: `You asked for the journey "${wanted}" and there is no journey by that name, so it was not walked.`,
1131
+ why: 'A name that matches nothing narrows the run to nothing rather than to what you meant.',
1132
+ unlockedBy: `The journeys this project has are: ${journeys.map((j) => j.name).slice(0, 12).join(', ') || 'none'}.`,
1133
+ });
1134
+ }
1135
+ }
1136
+
1137
+ // Two journeys with one name would write into one another's records.
1138
+ /** @type {Journey[]} */
1139
+ const out = [];
1140
+ const seen = new Set();
1141
+ for (const j of chosen) {
1142
+ if (seen.has(j.name)) continue;
1143
+ seen.add(j.name);
1144
+ out.push(j);
1145
+ }
1146
+ return { journeys: out, gaps };
1147
+ }
1148
+
1149
+ /**
1150
+ * @param {string} file
1151
+ * @returns {Promise<Journey[]>}
1152
+ */
1153
+ async function readJourneyFile(file) {
1154
+ /** @type {string} */
1155
+ let raw;
1156
+ try {
1157
+ raw = await fsp.readFile(file, 'utf8');
1158
+ } catch {
1159
+ throw new StaysFixedError(`There is no journeys file at ${file}.`, {
1160
+ hint: 'A journeys file is a JSON list, each entry with a name, a describe, a surface and its steps.',
1161
+ });
1162
+ }
1163
+ /** @type {unknown} */
1164
+ let parsed;
1165
+ try {
1166
+ parsed = JSON.parse(raw);
1167
+ } catch (e) {
1168
+ throw new StaysFixedError(`The journeys file at ${file} is not readable JSON: ${messageOf(e)}`);
1169
+ }
1170
+ const list = Array.isArray(parsed) ? parsed : /** @type {{journeys?: unknown}} */ (parsed)?.journeys;
1171
+ if (!Array.isArray(list)) {
1172
+ throw new StaysFixedError(`The journeys file at ${file} has to be a list of journeys, or an object with a "journeys" list in it.`);
1173
+ }
1174
+ return list.map((entry, i) => {
1175
+ const j = /** @type {Record<string, any>} */ (entry);
1176
+ if (typeof j?.name !== 'string' || j.name === '') {
1177
+ throw new StaysFixedError(`The journey at position ${i + 1} in ${file} has no name, and a name is what its addresses are built from.`);
1178
+ }
1179
+ return /** @type {Journey} */ ({
1180
+ name: j.name,
1181
+ describe: String(j.describe ?? j.name),
1182
+ source: j.source ?? 'code',
1183
+ surface: j.surface ?? 'cli',
1184
+ from: j.from ?? file,
1185
+ steps: Array.isArray(j.steps) ? j.steps : [],
1186
+ channels: j.channels,
1187
+ irreversible: j.irreversible === true,
1188
+ skip: j.skip,
1189
+ timeoutMs: j.timeoutMs,
1190
+ });
1191
+ });
1192
+ }
1193
+
1194
+ /**
1195
+ * @param {string|null} configFile
1196
+ * @returns {Promise<Record<string, any>>}
1197
+ */
1198
+ async function readConfig(configFile) {
1199
+ if (!configFile) return {};
1200
+ try {
1201
+ if (configFile.endsWith('.json')) return JSON.parse(await fsp.readFile(configFile, 'utf8'));
1202
+ const module = await import(`file://${configFile}`);
1203
+ const raw = module.default ?? module.config ?? module;
1204
+ return /** @type {Record<string, any>} */ (raw);
1205
+ } catch (e) {
1206
+ throw new StaysFixedError(`The settings in ${configFile} could not be read: ${messageOf(e)}`);
1207
+ }
1208
+ }
1209
+
1210
+ // ---------------------------------------------------------------------------
1211
+ // Which build is which
1212
+ // ---------------------------------------------------------------------------
1213
+
1214
+ /**
1215
+ * The build you have, named by what is actually in it.
1216
+ *
1217
+ * A dirty working tree gets an id that includes a digest of the diff, so editing a file
1218
+ * makes a new build rather than adding observations to the record of the last one. That is
1219
+ * what "content-addressed against the build artifact" means when the artifact is source.
1220
+ *
1221
+ * @param {string} root
1222
+ * @param {string} product
1223
+ * @returns {Promise<BuildFingerprint>}
1224
+ */
1225
+ async function fingerprintWorkingTree(root, product) {
1226
+ const sha = await git(root, ['rev-parse', 'HEAD']);
1227
+ if (!sha) {
1228
+ // REFUSING IS THE ONLY HONEST ANSWER HERE, and the alternative is the worst bug this
1229
+ // tool could have. Without git there is nothing to tell one build from another, so every
1230
+ // run would be fingerprinted identically, the build you just changed would carry the same
1231
+ // id as the build you were happy with, and comparing a build against itself produces zero
1232
+ // differences — a permanent, confident, completely false all-clear.
1233
+ throw new StaysFixedError(
1234
+ 'This folder is not a git repository with a commit in it, and Stays Fixed tells one build from another by what git says is in it.',
1235
+ {
1236
+ hint:
1237
+ 'Run it inside your project (or `git init && git commit` first). Without git every run would look like the same build, ' +
1238
+ 'and a check that compares a build against itself always comes back clean — which would be a lie, so it is refused instead.',
1239
+ },
1240
+ );
1241
+ }
1242
+ // Streamed into a hash rather than read into a string. `git diff` used to go through a
1243
+ // buffer with a 32MB ceiling, and a diff over the ceiling made the git call FAIL — which
1244
+ // was caught, treated as an empty diff, and the working tree was then declared clean. A
1245
+ // big uncommitted change therefore got the id of the commit it sat on top of; if that
1246
+ // commit was the reference, the check compared the build against itself and reported that
1247
+ // nothing had changed. Nothing about a diff's size may ever decide whether a change exists.
1248
+ const diff = await gitDigest(root, ['diff', 'HEAD']);
1249
+ const untracked = await gitDigest(root, ['ls-files', '--others', '--exclude-standard']);
1250
+ if (!diff.ok || !untracked.ok) {
1251
+ throw new StaysFixedError(
1252
+ `Git could not say what has changed in this working tree, so there is no way to tell this build apart from the last one. ${diff.why ?? untracked.why ?? ''}`.trim(),
1253
+ { hint: 'Fix that and run again. Guessing "nothing has changed" here would make every later answer worthless.' },
1254
+ );
1255
+ }
1256
+ const dirty = !diff.empty || !untracked.empty;
1257
+ const version = await packageVersion(root);
1258
+
1259
+ /** @type {BuildFingerprint} */
1260
+ const build = {
1261
+ id: dirty ? `work-${sha256(`${sha}\n${diff.digest}\n${untracked.digest}`).slice(0, 12)}` : `git-${sha.slice(0, 12)}`,
1262
+ product,
1263
+ platform: `${process.platform}-${process.arch}`,
1264
+ builtAt: new Date().toISOString(),
1265
+ };
1266
+ build.gitSha = sha;
1267
+ if (version) build.version = dirty ? `${version} with uncommitted changes` : version;
1268
+ if (dirty) build.dirty = true;
1269
+ const branch = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
1270
+ if (branch && branch !== 'HEAD') build.branch = branch;
1271
+ return build;
1272
+ }
1273
+
1274
+ /**
1275
+ * The build you were happy with, found by whatever a person calls it.
1276
+ *
1277
+ * @param {string} root
1278
+ * @param {string} product
1279
+ * @param {string} name A commit, a tag, a branch, or a build id already in the store.
1280
+ * @returns {Promise<BuildFingerprint|null>}
1281
+ */
1282
+ async function fingerprintCommit(root, product, name) {
1283
+ const sha = await git(root, ['rev-parse', '--verify', `${name}^{commit}`]);
1284
+ if (!sha) return null;
1285
+ /** @type {BuildFingerprint} */
1286
+ const build = {
1287
+ id: `git-${sha.slice(0, 12)}`,
1288
+ product,
1289
+ gitSha: sha,
1290
+ platform: `${process.platform}-${process.arch}`,
1291
+ };
1292
+ const described = await git(root, ['describe', '--tags', '--exact-match', sha]);
1293
+ if (described) build.version = described;
1294
+ return build;
1295
+ }
1296
+
1297
+ /**
1298
+ * Put the old build back on this machine so it can be walked live.
1299
+ *
1300
+ * `git archive` is used rather than a checkout or a worktree for one reason: it reads
1301
+ * history and writes nothing at all into the repository it reads from. A worktree adds
1302
+ * bookkeeping inside somebody's `.git`, and this tool has no business leaving anything
1303
+ * behind in the project it is checking.
1304
+ *
1305
+ * @param {string} root
1306
+ * @param {BuildFingerprint} reference
1307
+ * @param {string} scratch
1308
+ * @returns {Promise<LiveBuild|null>}
1309
+ */
1310
+ async function exportBuild(root, reference, scratch) {
1311
+ const sha = reference.gitSha;
1312
+ if (!sha) return null;
1313
+ const dir = path.join(scratch, `reference-${sha.slice(0, 12)}`);
1314
+ await fsp.mkdir(dir, { recursive: true });
1315
+ try {
1316
+ // Straight through a pipe: the archive is never written to disk, so a big repository
1317
+ // does not cost twice the space to look at.
1318
+ await exec('/bin/sh', ['-c', `git -C ${quote(root)} archive --format=tar ${quote(sha)} | tar -x -C ${quote(dir)}`], {
1319
+ timeout: 120_000,
1320
+ maxBuffer: 8 * 1024 * 1024,
1321
+ });
1322
+ } catch (e) {
1323
+ await fsp.rm(dir, { recursive: true, force: true });
1324
+ throw new StaysFixedError(`${sha.slice(0, 7)} could not be put back on this machine, so it cannot be walked live. ${messageOf(e)}`, {
1325
+ hint: 'Check the commit is still in this repository. Without it the check falls back to the stored record, which is weaker.',
1326
+ });
1327
+ }
1328
+ return {
1329
+ build: reference,
1330
+ dir,
1331
+ why: `The old build was exported out of git into a scratch folder. Your working tree was not touched, and nothing was written into .git.`,
1332
+ release: async () => {
1333
+ await fsp.rm(dir, { recursive: true, force: true });
1334
+ },
1335
+ };
1336
+ }
1337
+
1338
+ // ---------------------------------------------------------------------------
1339
+ // Small things
1340
+ // ---------------------------------------------------------------------------
1341
+
1342
+ /**
1343
+ * Run a git command and hash its output as it arrives, without ever holding it in memory.
1344
+ *
1345
+ * This exists because of a specific failure: reading `git diff` into a string through a
1346
+ * buffer with a ceiling turns "your change is enormous" into "the command failed", and the
1347
+ * caller then has to guess. A hash costs nothing, has no ceiling, and answers the only two
1348
+ * questions the fingerprint asks — was there anything, and was it the same thing as last time.
1349
+ *
1350
+ * @param {string} cwd
1351
+ * @param {string[]} args
1352
+ * @returns {Promise<{ok: boolean, empty: boolean, digest: string, why?: string}>}
1353
+ */
1354
+ function gitDigest(cwd, args) {
1355
+ return new Promise((resolve) => {
1356
+ const hash = createHash('sha256');
1357
+ let bytes = 0;
1358
+ /** @type {string[]} */
1359
+ const complaints = [];
1360
+ const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
1361
+ child.stdout.on('data', (chunk) => {
1362
+ bytes += chunk.length;
1363
+ hash.update(chunk);
1364
+ });
1365
+ child.stderr.on('data', (chunk) => complaints.push(String(chunk)));
1366
+ child.on('error', (e) => resolve({ ok: false, empty: true, digest: '', why: messageOf(e) }));
1367
+ child.on('close', (code) => {
1368
+ if (code !== 0) {
1369
+ resolve({ ok: false, empty: true, digest: '', why: complaints.join(' ').trim() || `git exited with ${code}` });
1370
+ return;
1371
+ }
1372
+ resolve({ ok: true, empty: bytes === 0, digest: hash.digest('hex') });
1373
+ });
1374
+ });
1375
+ }
1376
+
1377
+ /**
1378
+ * @param {string} cwd
1379
+ * @param {string[]} args
1380
+ * @returns {Promise<string|null>}
1381
+ */
1382
+ async function git(cwd, args) {
1383
+ try {
1384
+ const { stdout } = await exec('git', args, { cwd, timeout: 20_000, maxBuffer: 32 * 1024 * 1024 });
1385
+ return stdout.trim();
1386
+ } catch {
1387
+ return null;
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * @param {string} root
1393
+ * @returns {Promise<Record<string, any>|null>}
1394
+ */
1395
+ async function packageJson(root) {
1396
+ try {
1397
+ return JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
1398
+ } catch {
1399
+ return null;
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * @param {string} root
1405
+ * @returns {Promise<string|null>}
1406
+ */
1407
+ async function packageName(root) {
1408
+ const pkg = await packageJson(root);
1409
+ return typeof pkg?.name === 'string' ? pkg.name : null;
1410
+ }
1411
+
1412
+ /**
1413
+ * @param {string} root
1414
+ * @returns {Promise<string|null>}
1415
+ */
1416
+ async function packageVersion(root) {
1417
+ const pkg = await packageJson(root);
1418
+ return typeof pkg?.version === 'string' ? pkg.version : null;
1419
+ }
1420
+
1421
+ /** @param {string} text */
1422
+ function quote(text) {
1423
+ return `'${text.split("'").join(`'\\''`)}'`;
1424
+ }
1425
+
1426
+ /** @param {string} name */
1427
+ function safeSegment(name) {
1428
+ return name.replace(/[^a-z0-9._-]+/gi, '-').slice(0, 80) || 'run';
1429
+ }