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