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
package/src/v2/cli.js ADDED
@@ -0,0 +1,657 @@
1
+ /**
2
+ * The command line for version 2 — the difference engine.
3
+ *
4
+ * `staysfixed check` stays the front door, because it is the thing everybody
5
+ * already types, but it now means something bigger: run the build you have
6
+ * against the build you were happy with, and report only what changed that
7
+ * nobody asked for.
8
+ *
9
+ * NOTHING THAT WORKED THIS MORNING MAY BREAK. Somebody installed this yesterday
10
+ * and has `staysfixed check --guards` in a git hook. So the version 1 commands
11
+ * are not removed, not renamed and not deprecated with a warning: the same flags
12
+ * they always typed still reach the same code. `--pictures`, `--guards` and
13
+ * `--watch` are the version 1 check, exactly as before. `check` with none of
14
+ * them is the difference engine. That is the whole migration.
15
+ *
16
+ * This module deliberately holds no engine logic. It parses, it delegates, and
17
+ * it says what came back in plain English — which is the one job that has to
18
+ * sound the same whether a person or an agent is reading it.
19
+ */
20
+
21
+ import { StaysFixedError, EXIT, messageOf } from '../core/errors.js';
22
+ import { say, ok, warn, fail, blank, heading, paint, duration, setLogLevel } from '../core/log.js';
23
+ import { openStore } from './store.js';
24
+ import { SHIP_COMMANDS } from './ship.js';
25
+ import { escalationBlock, escalationsFor, productFor, writeEscalations } from './escalate.js';
26
+
27
+ /**
28
+ * What comes back from a check. Everything that did not change never appears
29
+ * here at all, which is the entire point of the tool. The full shape is
30
+ * `Verdict` in src/v2/run.js; these are the parts the command line reads.
31
+ *
32
+ * @typedef {import('./types.js').Verdict} Verdict
33
+ * @typedef {import('./types.js').Finding} Finding
34
+ */
35
+
36
+ /**
37
+ * How many lines any one list in the report is allowed to run to. Past this it
38
+ * stops being something a person reads and starts being something they skim,
39
+ * and the rest is one count away in --json.
40
+ */
41
+ const MOST_LINES = 6;
42
+
43
+ /** The flags the difference engine adds. Declared once so help and parsing agree. */
44
+ const V2_SPEC = {
45
+ booleans: ['paired', 'selfcheck', 'json'],
46
+ strings: ['against', 'journeys', 'escalations', 'surface', 'at'],
47
+ };
48
+
49
+ /** @type {[string, string][]} */
50
+ const V2_OPTIONS = [
51
+ ['--against <ref>', 'Compare against this marker, tag or commit instead of the newest reference.'],
52
+ ['--paired', 'Boot the old build live from the start instead of trusting the stored record. Slower, and the strongest answer there is.'],
53
+ ['--journeys <source>', 'Where the steps come from: suite, code, recorded, or a path to a journeys file.'],
54
+ ['--surface <kind>', 'Check only one kind of product: cli, library, server, web, electron, android, ios or windows. Nothing else is walked, and a run that cannot reach it says so instead of quietly checking something else.'],
55
+ ['--at <where>', 'Where that product is: a URL for the web, the built app for a desktop, the APK or the .app for a phone.'],
56
+ ['--selfcheck', 'Run the deliberately broken builds and prove the engine still catches them.'],
57
+ ['--json', 'Print the whole result as JSON and nothing else. This is what an agent reads.'],
58
+ ['--escalations <file>', 'Write the handful of things a person has to rule on into a file, in plain English, ready to paste into a closing summary.'],
59
+ ];
60
+
61
+ /** The version 1 flags, kept working word for word. */
62
+ const V1_SPEC = {
63
+ booleans: ['guards', 'pictures', 'record', 'report', 'watch', 'watch-front', 'keep-open', 'profile'],
64
+ strings: ['watch-side', 'watch-width'],
65
+ arrays: ['only'],
66
+ };
67
+
68
+ /** @type {[string, string][]} */
69
+ const V1_OPTIONS = [
70
+ ['--pictures', 'The version 1 picture check, unchanged.'],
71
+ ['--guards', 'The version 1 guards, unchanged.'],
72
+ ['--only <name>', 'Just this journey, screen or guard. Repeat it for several.'],
73
+ ['--watch', 'Open the version 1 panel beside your app and watch it happen.'],
74
+ ];
75
+
76
+ /**
77
+ * The commands version 2 replaces, in exactly the shape `src/cli/index.js`
78
+ * already uses for its own. Merging this over the existing table is the whole
79
+ * of the wiring:
80
+ *
81
+ * import { V2_COMMANDS } from '../v2/cli.js';
82
+ * Object.assign(COMMANDS, V2_COMMANDS);
83
+ *
84
+ * @type {Record<string, {summary: string, usage: string, describe: string, options: [string,string][], examples: string[], spec: {booleans?: string[], strings?: string[], arrays?: string[]}, load: () => Promise<{run: (ctx: any) => Promise<number>}>}>}
85
+ */
86
+ export const V2_COMMANDS = {
87
+ // `staysfixed ship` comes from src/v2/ship.js. It is merged in here rather than into the
88
+ // version 1 command table so that wiring version 2 into the front door stays the one
89
+ // import it has always been.
90
+ //
91
+ // `staysfixed init` from src/v2/init.js is deliberately NOT merged in yet, and this is
92
+ // the same call as the MCP server: version 2's init is a BREAKING change. Version 1's
93
+ // init writes settings for any folder; version 2's reads the project first and writes
94
+ // nothing when it cannot tell what the project is, which is better and is not what the
95
+ // three tests in test/cli.test.js describe. Somebody installed this last week and has
96
+ // `staysfixed init` in a setup script. Adding one line here —
97
+ //
98
+ // import { INIT_COMMANDS } from './init.js'; ...INIT_COMMANDS,
99
+ //
100
+ // switches it over, and those three tests have to be rewritten in the same change to
101
+ // say what the new one does. That is a decision, not an oversight, and it belongs in a
102
+ // change of its own rather than arriving as a side effect of wiring up the phones.
103
+ ...SHIP_COMMANDS,
104
+
105
+ check: {
106
+ summary: 'Prove nothing that already worked has changed. This is the one you run.',
107
+ usage: 'staysfixed check [--against <ref>] [--paired] [--journeys <source>] [--json]',
108
+ describe:
109
+ 'Runs your product through the same steps twice, compares it against the build you\nwere last happy with, subtracts anything the product disagrees with itself about,\nand reports only the differences that are left. Nothing that was already the same\nis mentioned at all — that is the point, and it is what keeps the answer short\nenough for an agent to read every word of it.\n\nSaying what you meant to change, and marking a difference as intended, are not\ndone from here. They need the files you expect to touch, and they are checked\nand counted, so they live where an agent works: the staysfixed_intent and\nstaysfixed_waive tools on the MCP server.\n\nThe version 1 picture check is still here: add --pictures, --guards or --watch\nand nothing about your old command changes.',
110
+ options: [...V2_OPTIONS, ...V1_OPTIONS],
111
+ examples: [
112
+ 'staysfixed check',
113
+ 'staysfixed check --json',
114
+ 'staysfixed check --against v0.13.0',
115
+ 'staysfixed check --paired',
116
+ 'staysfixed check --surface web --at http://localhost:3000',
117
+ 'staysfixed check --selfcheck',
118
+ 'staysfixed check --pictures # exactly what version 1 did',
119
+ ],
120
+ spec: {
121
+ booleans: [...V2_SPEC.booleans, ...V1_SPEC.booleans],
122
+ strings: [...V2_SPEC.strings, ...V1_SPEC.strings],
123
+ arrays: [...V1_SPEC.arrays],
124
+ },
125
+ load: async () => ({ run }),
126
+ },
127
+
128
+ doctor: {
129
+ summary: 'What this tool can and cannot check on this machine, and what would unlock more.',
130
+ usage: 'staysfixed doctor [--json] [--offline] [--fix]',
131
+ describe:
132
+ 'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.',
133
+ options: [
134
+ ['--json', 'The whole answer as one JSON object. For agents.'],
135
+ ['--offline', 'Do not dial any other machine. Faster, and reports no runners.'],
136
+ ['--fix', 'Repair the small local things that can be repaired safely.'],
137
+ ],
138
+ examples: ['staysfixed doctor', 'staysfixed doctor --json'],
139
+ spec: { booleans: ['json', 'offline', 'fix'] },
140
+ load: async () => ({ run: doctorRun }),
141
+ },
142
+ };
143
+
144
+ /**
145
+ * `staysfixed doctor`. `--fix` is version 1's repair pass, which is still the
146
+ * only thing in the tool that changes a file on disk without being asked twice.
147
+ *
148
+ * @param {import('../cli/index.js').CliContext} ctx
149
+ * @returns {Promise<number>}
150
+ */
151
+ export async function doctorRun(ctx) {
152
+ if (ctx.bool('fix')) {
153
+ const v1 = await import('../cli/doctor.js');
154
+ return await v1.run(ctx);
155
+ }
156
+ const v2 = await import('./doctor.js');
157
+ return await v2.run(ctx);
158
+ }
159
+
160
+ /**
161
+ * `staysfixed check`.
162
+ *
163
+ * @param {import('../cli/index.js').CliContext} ctx
164
+ * @returns {Promise<number>}
165
+ */
166
+ export async function run(ctx) {
167
+ // Version 1 first. Anybody whose command line names pictures, guards or the
168
+ // watch panel gets exactly the run they got yesterday, byte for byte.
169
+ if (wantsVersionOne(ctx)) {
170
+ const v1 = await import('../cli/check.js');
171
+ return await v1.run(ctx);
172
+ }
173
+
174
+ // --json means the answer belongs to a machine. Every line meant for a person
175
+ // is switched off before anything else runs, rather than trusted not to
176
+ // print: one stray sentence on standard output and the JSON will not parse.
177
+ // Warnings and errors still go to standard error, where they cannot corrupt
178
+ // it.
179
+ const asJson = ctx.bool('json');
180
+ if (asJson) setLogLevel({ quiet: true });
181
+
182
+ if (ctx.bool('selfcheck')) return await runSelfCheck(ctx, asJson);
183
+
184
+ const check = await engineCheck();
185
+ /** @type {Verdict} */
186
+ const verdict = await check(checkOptions(ctx));
187
+
188
+ // Write down what this check concluded, before printing anything.
189
+ //
190
+ // `shouldCut` refuses to make a build the reference unless that build was
191
+ // actually checked — which is the whole safeguard against a broken build
192
+ // quietly becoming the definition of working. Without this line a person who
193
+ // checks on the command line and then ships is told their build was "never
194
+ // checked", and the safeguard fires on the honest case instead of the careless
195
+ // one. The agent surface records its own; this is the command line's half.
196
+ try {
197
+ const { recordCheck } = await import('./reference.js');
198
+ const { openStore } = await import('./store.js');
199
+ await recordCheck(openStore({ root: ctx.cwd ?? process.cwd() }), {
200
+ buildId: verdict.candidate?.id,
201
+ product: verdict.product,
202
+ ok: verdict.ok,
203
+ blocked: /** @type {any} */ (verdict).blocked === true,
204
+ findings: verdict.findings.length,
205
+ by: 'staysfixed check',
206
+ });
207
+ } catch {
208
+ // Never let bookkeeping cost somebody the result they came for.
209
+ }
210
+
211
+ if (asJson) {
212
+ process.stdout.write(JSON.stringify(verdict) + '\n');
213
+ } else {
214
+ report(verdict);
215
+ }
216
+
217
+ await sayWhatNeedsAPerson(ctx, verdict, asJson);
218
+
219
+ // A run with nothing on record to compare against has not proved your product
220
+ // is fine — it has proved nothing at all, and it must not exit 0 and let a
221
+ // release through on the strength of it.
222
+ if (nothingToCompare(verdict)) return EXIT.error;
223
+ return verdict.ok ? EXIT.ok : EXIT.failed;
224
+ }
225
+
226
+ /**
227
+ * The handful of things a person has to rule on, and nothing else.
228
+ *
229
+ * This is the whole of what version 2 asks of him. Everything else — what is different,
230
+ * whether it is noise, whether the agent caused it — is answered by the machine or by the
231
+ * agent. What lands here is the small class no agent may wave through, and it is written to
232
+ * be pasted straight into a closing summary rather than read from a screen.
233
+ *
234
+ * @param {import('../cli/index.js').CliContext} ctx
235
+ * @param {Verdict} verdict
236
+ * @param {boolean} asJson
237
+ * @returns {Promise<void>}
238
+ */
239
+ async function sayWhatNeedsAPerson(ctx, verdict, asJson) {
240
+ const store = openStore({ root: ctx.cwd });
241
+ const product = verdict.product || (await productFor(ctx.cwd));
242
+ const escalations = await escalationsFor(store, product).catch(() => null);
243
+ if (!escalations) return;
244
+
245
+ const file = ctx.str('escalations');
246
+ if (file) {
247
+ const written = await writeEscalations(store, product, file);
248
+ if (!asJson) {
249
+ say(paint.grey(` ${written.count === 0 ? 'Nothing needs a person' : `${written.count} thing${written.count === 1 ? '' : 's'} for a person`} — written to ${written.file}`));
250
+ blank();
251
+ }
252
+ }
253
+
254
+ if (asJson || escalations.items.length === 0) return;
255
+ heading('Put this in your summary');
256
+ blank();
257
+ for (const line of escalationBlock(escalations).split('\n')) say(line === '' ? '' : ` ${line}`);
258
+ blank();
259
+ }
260
+
261
+ /**
262
+ * Was there anything to compare this build against at all?
263
+ *
264
+ * The engine says there was not by handing back a reference with an empty id.
265
+ * That is the cold start, and it happens on every product that has not been
266
+ * shipped once with the reference hook in place.
267
+ *
268
+ * @param {Verdict} verdict
269
+ * @returns {boolean}
270
+ */
271
+ function nothingToCompare(verdict) {
272
+ return !verdict.reference || verdict.reference.id === '';
273
+ }
274
+
275
+ /**
276
+ * A build, named the way a person would name it.
277
+ *
278
+ * The same rule as `nameOf` in src/v2/run.js, written again rather than
279
+ * imported: this file has to keep working, and keep explaining itself, on a
280
+ * copy where the engine will not even load.
281
+ *
282
+ * @param {import('./types.js').BuildFingerprint} build
283
+ * @returns {string}
284
+ */
285
+ function nameOfBuild(build) {
286
+ if (!build) return 'the build with no name';
287
+ if (build.version) return build.version;
288
+ if (build.gitSha) return build.gitSha.slice(0, 7);
289
+ return build.id || 'the build with no name';
290
+ }
291
+
292
+ /**
293
+ * Did the person ask for the version 1 run? Only an explicit version 1 flag
294
+ * counts. Guessing here — "no journeys are configured, so they probably meant
295
+ * pictures" — is how a tool quietly does something other than what it was told.
296
+ *
297
+ * @param {import('../cli/index.js').CliContext} ctx
298
+ * @returns {boolean}
299
+ */
300
+ export function wantsVersionOne(ctx) {
301
+ return ctx.bool('pictures') || ctx.bool('guards') || ctx.bool('watch') || ctx.bool('record');
302
+ }
303
+
304
+ /**
305
+ * The command line, turned into what the engine takes. Kept separate from `run`
306
+ * so the MCP server can build the same object from its own arguments and be
307
+ * certain the two front doors mean identical things.
308
+ *
309
+ * The key names are the ones the two shipped callers of the engine's front door
310
+ * already use — src/v2/selfcheck.js and src/v2/mcp/tools.js both pass `cwd`. A
311
+ * command line that sent the same value under a different name would be the one
312
+ * caller in three getting a silent undefined.
313
+ *
314
+ * @param {import('../cli/index.js').CliContext} ctx
315
+ * @returns {{cwd: string, configFile: string|undefined, against: string|undefined, paired: boolean, journeys: string|undefined, surface: string|undefined, at: string|undefined, only: string[]}}
316
+ */
317
+ export function checkOptions(ctx) {
318
+ return {
319
+ cwd: ctx.cwd,
320
+ configFile: ctx.configFile,
321
+ against: ctx.str('against'),
322
+ paired: ctx.bool('paired'),
323
+ journeys: ctx.str('journeys'),
324
+ surface: ctx.str('surface'),
325
+ at: ctx.str('at'),
326
+ only: ctx.list('only'),
327
+ };
328
+ }
329
+
330
+ /**
331
+ * `--selfcheck`: run the corpus of deliberately broken builds and require the
332
+ * engine to catch every one.
333
+ *
334
+ * A tool that reports "nothing changed" looks exactly like a tool that is
335
+ * broken, and there is no way to tell the two apart from the outside. This is
336
+ * the only way to tell them apart from the inside — which is why "it could not
337
+ * run" is reported as a failure here and never as a quiet pass.
338
+ *
339
+ * @param {import('../cli/index.js').CliContext} ctx
340
+ * @param {boolean} asJson
341
+ * @returns {Promise<number>}
342
+ */
343
+ async function runSelfCheck(ctx, asJson) {
344
+ const { selfcheck } = await import('./selfcheck.js');
345
+ const result = await selfcheck({ only: ctx.list('only') });
346
+
347
+ if (asJson) {
348
+ process.stdout.write(JSON.stringify(result) + '\n');
349
+ return result.passed ? EXIT.ok : EXIT.failed;
350
+ }
351
+
352
+ heading('Stays Fixed — checking that it can still catch things');
353
+ blank();
354
+
355
+ if (!result.ran) {
356
+ fail(result.why ?? 'The engine could not be driven, so nothing was tested.');
357
+ blank();
358
+ fail('This is not a pass. Until it can run, a clean check means nothing.');
359
+ return EXIT.failed;
360
+ }
361
+
362
+ if (result.cases.length === 0) {
363
+ // "Nothing matched" and "everything behaved" both leave an empty list, and
364
+ // filing the first one under the second is exactly the silence this whole
365
+ // corpus exists to make impossible.
366
+ fail('Nothing matched what --only asked for, so no product was tested. This is not a pass.');
367
+ return EXIT.failed;
368
+ }
369
+
370
+ for (const one of result.cases) {
371
+ if (one.caught) ok(`${one.name} — ${saidOf(one)}`);
372
+ // A false alarm is as fatal as a miss: a tool that cries wolf gets switched
373
+ // off, and a tool that is switched off catches nothing. It is not a warning.
374
+ else fail(`${one.name} — ${saidOf(one)}`);
375
+ }
376
+ blank();
377
+
378
+ if (result.passed) {
379
+ ok(`All ${result.cases.length} of them behaved: every break caught, every clean pair silent.`);
380
+ return EXIT.ok;
381
+ }
382
+ const wrong = result.cases.filter((one) => !one.caught).length;
383
+ fail(`It got ${wrong} of ${result.cases.length} wrong. Until that is fixed, a clean check means nothing.`);
384
+ return EXIT.failed;
385
+ }
386
+
387
+ /**
388
+ * One case of the corpus, in a sentence.
389
+ *
390
+ * The corpus only writes a reason when a case misbehaved, so the two good
391
+ * outcomes are named here — and named differently, because catching a break and
392
+ * staying silent on a clean pair are two separate promises.
393
+ *
394
+ * @param {import('./selfcheck.js').CaseResult} one
395
+ * @returns {string}
396
+ */
397
+ function saidOf(one) {
398
+ if (one.why) return one.why;
399
+ if (one.verdict === 'caught') return 'caught, as it has to be';
400
+ if (one.verdict === 'quiet') return 'said nothing, which is the right answer here';
401
+ return one.verdict;
402
+ }
403
+
404
+ /**
405
+ * The engine's front door.
406
+ *
407
+ * The engine is assembled alongside this file rather than inside it, so a
408
+ * missing module is a real possibility while version 2 is being built and it has
409
+ * to read as itself. An agent told "the engine is not wired up in this copy" can
410
+ * act on that; an agent handed a module resolution stack trace cannot.
411
+ *
412
+ * It looks in exactly the place src/v2/mcp/tools.js looks, on purpose. If the
413
+ * command line and the MCP server ever found the engine in different places,
414
+ * they would be checking two different things and reporting it as one.
415
+ *
416
+ * @returns {Promise<(options: any) => Promise<Verdict>>}
417
+ */
418
+ async function engineCheck() {
419
+ /** @type {unknown} */
420
+ let missing = null;
421
+
422
+ // The specifiers are built from a variable rather than written as literals.
423
+ // src/v2/check.js is the small piece that turns a command line into a run, and
424
+ // it may not be written yet; a literal would be resolved when this file is
425
+ // type-checked and fail there, instead of being explained in words here.
426
+ for (const where of ['./check.js', './run.js']) {
427
+ /** @type {Record<string, unknown>} */
428
+ let module;
429
+ try {
430
+ module = await import(where);
431
+ } catch (cause) {
432
+ const code = /** @type {{code?: string}} */ (Object(cause)).code;
433
+ if (code !== 'ERR_MODULE_NOT_FOUND') {
434
+ throw new StaysFixedError(`The difference engine could not be loaded: ${messageOf(cause)}`, { cause });
435
+ }
436
+ missing = cause;
437
+ continue;
438
+ }
439
+ const check = module.check;
440
+ if (typeof check === 'function') return /** @type {(options: any) => Promise<Verdict>} */ (check);
441
+ }
442
+
443
+ throw new StaysFixedError('The difference engine has no front door for the command line to call.', {
444
+ hint:
445
+ 'src/v2/run.js has runCheck(), but that takes a run somebody has already assembled: a store, the journeys, and something that can walk them. What is missing is the piece in between — check({cwd, configFile, against, paired, journeys, only}) returning a Verdict, exported from src/v2/check.js, which is where src/v2/mcp/tools.js and src/v2/selfcheck.js both look for it. Until it lands, `staysfixed check --pictures` and `--guards` do everything version 1 did.',
446
+ cause: missing ?? undefined,
447
+ });
448
+ }
449
+
450
+ // ── saying what happened ────────────────────────────────────────────────────
451
+
452
+ /**
453
+ * The verdict, in words.
454
+ *
455
+ * Everything unchanged is silent on purpose. A list of things that are fine is
456
+ * the exact thing this design exists to stop producing: it costs an agent its
457
+ * context and it teaches a person to skim.
458
+ *
459
+ * @param {Verdict} verdict
460
+ */
461
+ export function report(verdict) {
462
+ if (nothingToCompare(verdict)) {
463
+ // One sentence, and no table. There is no build on record as working, so
464
+ // there are no findings, no counts and no coverage worth printing — laying
465
+ // out half a report around an empty middle is how a run that proved nothing
466
+ // gets read as a run that proved everything is fine.
467
+ warn(verdict.summary);
468
+ return;
469
+ }
470
+
471
+ // The weakening admission goes first, and it is labelled, because this is the
472
+ // tool telling you it is less sure than usual. A reader who has already seen a
473
+ // green tick does not come back up the page for it. It is said again at the
474
+ // end: the engine puts it into the summary itself, so it lands either side of
475
+ // everything below.
476
+ if (verdict.modeWarning) {
477
+ warn('This was not a full paired run, so it is weaker than usual. Read this before anything below it:');
478
+ warn(verdict.modeWarning);
479
+ }
480
+
481
+ const sealed = verdict.findings.filter((f) => f.sealed);
482
+ const rest = verdict.findings.filter((f) => !f.sealed);
483
+
484
+ if (sealed.length > 0) {
485
+ heading('A person has to look at these');
486
+ blank();
487
+ for (const finding of sealed) printFinding(finding);
488
+ blank();
489
+ }
490
+
491
+ if (rest.length > 0) {
492
+ heading(sealed.length > 0 ? 'And these' : 'What changed that nobody asked for');
493
+ blank();
494
+ for (const finding of rest) printFinding(finding);
495
+ blank();
496
+ }
497
+
498
+ // Paths that were steady before the change and disagree with themselves now.
499
+ // Nothing here has a "wrong" value, which is exactly why it needs its own
500
+ // section: without it a run can come back failed with no findings and no
501
+ // explanation of what failed.
502
+ const unstable = verdict.newlyUnstable ?? [];
503
+ if (unstable.length > 0) {
504
+ heading('These used to give the same answer every time, and now they do not');
505
+ blank();
506
+ for (const entry of unstable.slice(0, MOST_LINES)) {
507
+ say(` ${entry.path}`);
508
+ say(paint.grey(' two runs of this same build disagree about it, and the old build did not'));
509
+ }
510
+ if (unstable.length > MOST_LINES) say(paint.grey(` and ${unstable.length - MOST_LINES} more.`));
511
+ blank();
512
+ }
513
+
514
+ if (verdict.ok) ok(`${verdict.summary}${verdict.durationMs ? ` — ${duration(verdict.durationMs)}` : ''}`);
515
+ else fail(verdict.summary);
516
+
517
+ sizeLine(verdict);
518
+ provenLine(verdict);
519
+ blank();
520
+ notCheckedBlock(verdict);
521
+ blank();
522
+ }
523
+
524
+ /**
525
+ * @param {Finding} finding
526
+ */
527
+ function printFinding(finding) {
528
+ const label = finding.sealed ? paint.red(`[${finding.class}] `) : '';
529
+ say(` ${label}${finding.title}`);
530
+
531
+ const example = finding.differences?.[0];
532
+ if (example) {
533
+ // "was undefined" is how a tool tells you nothing. Something that was not
534
+ // there before, or is not there now, has to say so in those words.
535
+ const { path: where, reference: was, candidate: now } = example;
536
+ if (example.kind === 'appeared') say(paint.grey(` ${where}: was not there before, and now it is ${show(now)}`));
537
+ else if (example.kind === 'vanished') say(paint.grey(` ${where}: was ${show(was)}, and now it is not there at all`));
538
+ else say(paint.grey(` ${where}: was ${show(was)}, now ${show(now)}`));
539
+ }
540
+
541
+ // The count is optional, so it is only worth a line when the engine actually
542
+ // filled it in and it says more than the list above already did.
543
+ const count = finding.count ?? finding.differences.length;
544
+ if (count > 1) say(paint.grey(` the same thing in ${count} places`));
545
+ if (finding.why) say(paint.grey(` ${finding.why}`));
546
+ if (finding.evidence) say(paint.grey(` evidence: ${finding.evidence}`));
547
+ }
548
+
549
+ /**
550
+ * A value, short enough to read on one line.
551
+ * @param {unknown} value
552
+ * @returns {string}
553
+ */
554
+ function show(value) {
555
+ const text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
556
+ return text.length > 80 ? `${text.slice(0, 77)}…` : text;
557
+ }
558
+
559
+ /**
560
+ * How much was looked at. Said on a clean run as well as a dirty one: quiet that
561
+ * cannot be shown to be earned is indistinguishable from a broken tool.
562
+ *
563
+ * How much was SUBTRACTED is deliberately not repeated here. The engine already
564
+ * spells that arithmetic out inside `summary`, in its own words, and two
565
+ * differently-worded versions of one number on two neighbouring lines is how a
566
+ * reader starts wondering which of them to believe. Nor is there a line for how
567
+ * many suspicions the old build turned out to share: the verdict carries no such
568
+ * figure, and inventing one would be worse than the sentence that is already true.
569
+ *
570
+ * @param {Verdict} verdict
571
+ */
572
+ function sizeLine(verdict) {
573
+ const paths = verdict.coverage?.paths ?? 0;
574
+ const journeys = verdict.coverage?.journeys ?? 0;
575
+ if (paths === 0) return;
576
+ say(paint.grey(` Looked at ${paths} ${paths === 1 ? 'address' : 'addresses'} across ${journeys} ${journeys === 1 ? 'journey' : 'journeys'}.`));
577
+ }
578
+
579
+ /**
580
+ * What it was compared against, and how. Two facts, and they are not the same
581
+ * one: the fingerprint names WHICH build, and the mode names whether that build
582
+ * was actually booted here or only remembered.
583
+ * @param {Verdict} verdict
584
+ */
585
+ function provenLine(verdict) {
586
+ const how = verdict.mode === 'paired' ? 'booted and walked again on this machine' : 'the record it left the last time it ran';
587
+ say(paint.grey(` Compared against ${nameOfBuild(verdict.reference)} — ${how}.`));
588
+ }
589
+
590
+ /**
591
+ * What it did NOT look at, under its own heading, every single time.
592
+ *
593
+ * This used to be a few grey lines at the bottom, and grey lines at the bottom are what a
594
+ * reader skips. The whole honesty of the tool rests on this block: a clean run on a
595
+ * product with three hundred doors nobody has ever opened is TRUE and it is not what it
596
+ * looks like. So it gets a heading, it is printed on good runs as well as bad ones, and
597
+ * the count of unopened doors goes first — it is the number that most often turns "it is
598
+ * fine" back into "it is fine as far as anybody looked".
599
+ *
600
+ * The engine says the same thing in one sentence inside `summary`, which is printed just
601
+ * above. That is not a duplicate: the sentence is what somebody quotes, and this is the
602
+ * list they act on.
603
+ *
604
+ * @param {Verdict} verdict
605
+ */
606
+ function notCheckedBlock(verdict) {
607
+ const coverage = verdict.coverage;
608
+ if (!coverage) {
609
+ warn('This run did not say what it covered, so how much of your product was actually looked at is unknown. Treat the result above as unproven.');
610
+ return;
611
+ }
612
+
613
+ heading('What this run did not check');
614
+ blank();
615
+
616
+ const known = coverage.doorsKnown ?? 0;
617
+ const unopened = Math.max(0, known - (coverage.doorsWalked ?? 0));
618
+ if (unopened > 0) {
619
+ say(
620
+ known === 1
621
+ ? ' The only way into this product has never been walked through.'
622
+ : ` ${unopened} of the ${known} ways into this product ${unopened === 1 ? 'has' : 'have'} never been walked through.`,
623
+ );
624
+ say(
625
+ paint.grey(
626
+ ` A break behind ${unopened === 1 ? 'it' : 'any of them'} is invisible to this tool. Point a journey at ${unopened === 1 ? 'it' : 'them'}, or run the project’s own test suite as journeys.`,
627
+ ),
628
+ );
629
+ }
630
+
631
+ // Several holes can share one sentence — the coverage count's own caveats all do — and
632
+ // printing that sentence three times reads as three separate holes rather than as one
633
+ // heading with three reasons under it.
634
+ /** @type {Map<string, string[]>} */
635
+ const gaps = new Map();
636
+ for (const gap of coverage.gaps ?? []) {
637
+ if (typeof gap.doors === 'number') continue;
638
+ const reasons = gaps.get(gap.what) ?? [];
639
+ reasons.push(`${gap.why}${gap.unlockedBy ? ` ${gap.unlockedBy}` : ''}`);
640
+ gaps.set(gap.what, reasons);
641
+ }
642
+ let shown = 0;
643
+ for (const [what, reasons] of gaps) {
644
+ if (shown >= MOST_LINES) break;
645
+ shown += 1;
646
+ say(` ${what}`);
647
+ for (const reason of reasons) say(paint.grey(` ${reason}`));
648
+ }
649
+ if (gaps.size > shown) {
650
+ say(paint.grey(` and ${gaps.size - shown} more. All of them: staysfixed check --json`));
651
+ }
652
+
653
+ if (unopened === 0 && gaps.size === 0) {
654
+ say(' Everything this run knows how to walk was walked.');
655
+ say(paint.grey(' That is not every possible state of your product — nothing can enumerate that. It is every way in this tool knows about.'));
656
+ }
657
+ }