staysfixed 0.3.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +159 -3
  2. package/README.md +611 -402
  3. package/package.json +8 -3
  4. package/src/cli/index.js +14 -0
  5. package/src/v2/adapters/android-driver.js +1705 -0
  6. package/src/v2/adapters/android.js +1117 -0
  7. package/src/v2/adapters/contract.js +643 -0
  8. package/src/v2/adapters/electron.js +1594 -0
  9. package/src/v2/adapters/http.js +734 -0
  10. package/src/v2/adapters/ios-driver.js +1551 -0
  11. package/src/v2/adapters/ios.js +989 -0
  12. package/src/v2/adapters/isolate.js +739 -0
  13. package/src/v2/adapters/process.js +931 -0
  14. package/src/v2/adapters/source.js +1292 -0
  15. package/src/v2/adapters/web-driver.js +1532 -0
  16. package/src/v2/adapters/web.js +1009 -0
  17. package/src/v2/adapters/windows.js +1329 -0
  18. package/src/v2/browsers.js +1203 -0
  19. package/src/v2/cause.js +371 -0
  20. package/src/v2/check.js +1429 -0
  21. package/src/v2/ci.js +1209 -0
  22. package/src/v2/cli.js +670 -0
  23. package/src/v2/cluster.js +372 -0
  24. package/src/v2/coverage.js +1124 -0
  25. package/src/v2/detect.js +1199 -0
  26. package/src/v2/doctor.js +1702 -0
  27. package/src/v2/escalate.js +679 -0
  28. package/src/v2/init.js +1394 -0
  29. package/src/v2/intent.js +659 -0
  30. package/src/v2/journeys/from-routes.js +500 -0
  31. package/src/v2/journeys/from-suite.js +988 -0
  32. package/src/v2/journeys/index.js +651 -0
  33. package/src/v2/journeys/record.js +516 -0
  34. package/src/v2/mcp/server.js +374 -0
  35. package/src/v2/mcp/tools.js +1571 -0
  36. package/src/v2/normalise.js +783 -0
  37. package/src/v2/observation.js +938 -0
  38. package/src/v2/rank.js +672 -0
  39. package/src/v2/reference.js +1051 -0
  40. package/src/v2/remote.js +910 -0
  41. package/src/v2/run.js +1080 -0
  42. package/src/v2/sealed.js +568 -0
  43. package/src/v2/selfcheck.js +729 -0
  44. package/src/v2/ship.js +684 -0
  45. package/src/v2/store.js +703 -0
  46. package/src/v2/types.js +509 -0
  47. package/src/v2/waiver.js +511 -0
  48. package/src/v2/watch/focus.js +215 -0
@@ -0,0 +1,729 @@
1
+ /**
2
+ * The corpus of deliberately broken builds.
3
+ *
4
+ * A tool that reports "nothing changed" is indistinguishable from a tool that
5
+ * is broken. Every green run this thing ever produces is worth exactly as much
6
+ * as the evidence that it can still go red, and there is no other way to get
7
+ * that evidence: you cannot test a difference engine by reading it.
8
+ *
9
+ * So this builds nine tiny products, each as a real repository with a working
10
+ * commit and an uncommitted change on top - which is exactly the shape of the
11
+ * thing an agent points this tool at - runs the engine over each, and fails
12
+ * loudly if a break gets through.
13
+ *
14
+ * Six of the nine are breaks that MUST be caught. Three are the other half of
15
+ * the same promise, and they matter just as much: pairs that must produce NO
16
+ * findings at all. A tool that cries wolf gets switched off, and a tool that is
17
+ * switched off catches nothing, so a false alarm fails this run exactly the way
18
+ * a miss does.
19
+ *
20
+ * staysfixed check --selfcheck
21
+ * node src/v2/selfcheck.js --only rounded --keep
22
+ *
23
+ * Exit codes from `main`: 0 every case behaved, 1 something got past the engine
24
+ * or a clean pair raised a false alarm, 2 the corpus could not be run at all.
25
+ * Two is not one: "I could not test this" must never be filed under "nothing
26
+ * escaped".
27
+ */
28
+
29
+ import fsp from 'node:fs/promises';
30
+ import path from 'node:path';
31
+ import os from 'node:os';
32
+ import { execFile } from 'node:child_process';
33
+ import { promisify } from 'node:util';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ // The corpus finds the engine exactly the way the MCP surface does. If those two
37
+ // ever looked in different places, the corpus would be proving something other
38
+ // than what an agent actually runs, which is worse than having no corpus at all.
39
+ import { loadEngine } from './mcp/tools.js';
40
+
41
+ const run = promisify(execFile);
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // The products, and the one thing wrong with each
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * One case: a tiny product written twice, and what the engine has to say about
49
+ * the pair.
50
+ *
51
+ * `mustSay` is matched against everything a finding carries - its sentence, its
52
+ * addresses and its sample values - so a finding that names the right thing in
53
+ * different words still passes. What it cannot do is pass by finding something
54
+ * else entirely, which is the failure mode that makes a corpus worthless.
55
+ *
56
+ * @typedef {object} Case
57
+ * @property {string} name A sentence, because it is read back as one.
58
+ * @property {string} breaks What is wrong, in plain English.
59
+ * @property {'a finding'|'nothing'|'no answer'} expect
60
+ * @property {RegExp[]} [mustSay]
61
+ * @property {boolean} [mustBeUnstable] It has to land in `newlyUnstable`, not in the findings.
62
+ * @property {(broken: boolean) => Record<string, string>} build
63
+ */
64
+
65
+ /** Every fixture is its own tiny package, so nothing leaks between them. */
66
+ const PKG = JSON.stringify({ name: 'widget', version: '1.0.0', type: 'module', bin: { widget: 'cli.js' } }, null, 2) + '\n';
67
+
68
+ /** @type {Case[]} */
69
+ export const CASES = [
70
+ {
71
+ name: 'a route that starts failing',
72
+ breaks: 'A route that used to answer with the orders now fails with a 500.',
73
+ expect: 'a finding',
74
+ mustSay: [/orders/i, /500/],
75
+ build: (broken) => ({
76
+ 'package.json': PKG,
77
+ 'cli.js': [
78
+ "import http from 'node:http';",
79
+ '',
80
+ 'const server = http.createServer((req, res) => {',
81
+ " if (req.url === '/orders') {",
82
+ broken
83
+ ? " res.writeHead(500, { 'content-type': 'application/json' });\n res.end('{\"error\":\"could not load orders\"}');\n return;"
84
+ : " res.writeHead(200, { 'content-type': 'application/json' });\n res.end('{\"orders\":2}');\n return;",
85
+ ' }',
86
+ ' res.writeHead(404);',
87
+ " res.end('not found');",
88
+ '});',
89
+ '',
90
+ "await new Promise((done) => server.listen(0, '127.0.0.1', done));",
91
+ 'const address = server.address();',
92
+ "const port = typeof address === 'object' && address ? address.port : 0;",
93
+ 'const reply = await fetch(`http://127.0.0.1:${port}/orders`);',
94
+ 'console.log(`GET /orders -> ${reply.status}`);',
95
+ 'console.log(await reply.text());',
96
+ 'server.close();',
97
+ '',
98
+ ].join('\n'),
99
+ }),
100
+ },
101
+
102
+ {
103
+ name: 'a field dropped from a reply',
104
+ breaks: 'A field quietly disappeared from a reply that everything downstream reads.',
105
+ expect: 'a finding',
106
+ mustSay: [/email/i],
107
+ build: (broken) => ({
108
+ 'package.json': PKG,
109
+ 'cli.js': [
110
+ 'const person = {',
111
+ ' id: 7,',
112
+ " name: 'Ada',",
113
+ broken ? null : " email: 'ada@example.com',",
114
+ " city: 'London',",
115
+ '};',
116
+ 'console.log(JSON.stringify(person));',
117
+ '',
118
+ ]
119
+ .filter((line) => line !== null)
120
+ .join('\n'),
121
+ }),
122
+ },
123
+
124
+ {
125
+ name: 'a different exit code',
126
+ breaks: 'The program still prints the same thing but stops with a failure code.',
127
+ expect: 'a finding',
128
+ mustSay: [/exit|stopped|status|code/i],
129
+ build: (broken) => ({
130
+ 'package.json': PKG,
131
+ 'cli.js': ["console.log('report written');", ...(broken ? ['process.exit(3);'] : []), ''].join('\n'),
132
+ }),
133
+ },
134
+
135
+ {
136
+ name: 'a file that is no longer written',
137
+ breaks: 'A file that used to be written on every run is not written any more. Nothing errors.',
138
+ expect: 'a finding',
139
+ mustSay: [/report/i],
140
+ build: (broken) => ({
141
+ 'package.json': PKG,
142
+ 'cli.js': [
143
+ "import fs from 'node:fs';",
144
+ "import path from 'node:path';",
145
+ '',
146
+ "const out = path.join(process.cwd(), 'out');",
147
+ 'fs.mkdirSync(out, { recursive: true });',
148
+ broken ? '// the report is no longer written' : "fs.writeFileSync(path.join(out, 'report.txt'), 'two orders\\n');",
149
+ "console.log('done');",
150
+ '',
151
+ ].join('\n'),
152
+ }),
153
+ },
154
+
155
+ {
156
+ name: 'a door removed from the desktop app',
157
+ breaks: 'A channel the desktop app exposes was deleted. Nothing has to run for this one - it is read straight out of the source.',
158
+ expect: 'a finding',
159
+ mustSay: [/save-note/i],
160
+ build: (broken) => ({
161
+ 'package.json': PKG,
162
+ 'cli.js': "console.log('desktop shell');\n",
163
+ 'main.js': [
164
+ "import { ipcMain } from 'electron';",
165
+ '',
166
+ "ipcMain.handle('list-notes', async () => []);",
167
+ broken ? null : "ipcMain.handle('save-note', async (_e, note) => note);",
168
+ "ipcMain.handle('delete-note', async (_e, id) => id);",
169
+ '',
170
+ ]
171
+ .filter((line) => line !== null)
172
+ .join('\n'),
173
+ }),
174
+ },
175
+
176
+ {
177
+ name: 'a total quietly rounded',
178
+ breaks: 'A total is rounded. Nothing errors, nothing looks wrong, and the number is different.',
179
+ expect: 'a finding',
180
+ mustSay: [/10\.0/],
181
+ build: (broken) => ({
182
+ 'package.json': PKG,
183
+ 'cli.js': [
184
+ 'const lines = [3.335, 3.335, 3.335];',
185
+ 'const total = lines.reduce((sum, n) => sum + n, 0);',
186
+ broken ? 'console.log(`total ${(Math.round(total * 100) / 100).toFixed(2)}`);' : 'console.log(`total ${total}`);',
187
+ '',
188
+ ].join('\n'),
189
+ }),
190
+ },
191
+
192
+ {
193
+ name: 'two identical builds stay silent',
194
+ breaks: 'Nothing at all. The engine has to say so by saying nothing.',
195
+ expect: 'nothing',
196
+ build: () => ({
197
+ 'package.json': PKG,
198
+ 'cli.js': ["console.log('total 10.005');", "console.log('two orders');", ''].join('\n'),
199
+ }),
200
+ },
201
+
202
+ {
203
+ name: 'a product that wobbles stays silent',
204
+ breaks:
205
+ 'Nothing, but the product disagrees with itself on every run - a timestamp and a random number. Running the new build twice is what tells that apart from a real difference, and the report has to come back empty.',
206
+ expect: 'nothing',
207
+ build: () => ({
208
+ 'package.json': PKG,
209
+ 'cli.js': ['console.log(`built ${new Date().toISOString()}`);', 'console.log(`run ${Math.floor(Math.random() * 1e9)}`);', "console.log('total 10.005');", ''].join('\n'),
210
+ }),
211
+ },
212
+
213
+ {
214
+ name: 'a break buried in the middle of a huge output',
215
+ breaks:
216
+ 'A program prints more than the tool will store, and the thing that broke is in the middle — past the head it keeps and before the tail it keeps. This is the case the tool used to be blind to: it kept the two ends and a COARSE size, so a change in the discarded middle left a byte-identical record and the run reported that nothing had changed. Exactly the shape of failure this whole thing exists to prevent, and it survived until 2026-08-30.',
217
+ expect: 'a finding',
218
+ // The marker in the middle is what has to have caught it. If some other part of the value
219
+ // reported instead, this case has stopped testing what it was written to test.
220
+ mustSay: [/bytes left out of the middle/],
221
+ build: (broken) => ({
222
+ 'package.json': PKG,
223
+ 'cli.js': [
224
+ "console.log('report begins');",
225
+ 'for (let i = 0; i < 6000; i += 1) {',
226
+ broken
227
+ ? " console.log(i === 3000 ? `row ${i}: could not be loaded at all` : `row ${i}: ok`);"
228
+ : ' console.log(`row ${i}: ok`);',
229
+ '}',
230
+ "console.log('report ends');",
231
+ '',
232
+ ].join('\n'),
233
+ }),
234
+ },
235
+
236
+ {
237
+ name: 'a build that takes ten times longer stays silent',
238
+ breaks:
239
+ 'Nothing, and the product is markedly slower. How long something took is recorded and never compared, because a stopwatch on a shared machine measures the machine as much as the product — and comparing it is what made this corpus fail one case out of nine on a busy laptop while passing five times in a row on a quiet one. This case exists so that decision cannot be quietly undone: put timing back into the comparison and this goes red.',
240
+ expect: 'nothing',
241
+ build: (broken) => ({
242
+ 'package.json': PKG,
243
+ 'cli.js': [
244
+ // A sleep, deliberately, and never a busy loop. Loading the machine to test timing
245
+ // is how you take four other things down with you.
246
+ `await new Promise((done) => setTimeout(done, ${broken ? 900 : 40}));`,
247
+ "console.log('total 10.005');",
248
+ '',
249
+ ].join('\n'),
250
+ }),
251
+ },
252
+
253
+ {
254
+ name: 'a value that used to be steady is now random',
255
+ breaks:
256
+ 'A value that was the same on every single run is now different every run. Nothing is obviously broken, which is exactly why this class of bug survives for months.',
257
+ expect: 'a finding',
258
+ // No `mustSay` here, and that is deliberate. A newly unpredictable address
259
+ // is reported as an address, not as a value, and this corpus does not get to
260
+ // dictate what the engine names its addresses. What it does get to demand is
261
+ // that SOMETHING was flagged as having stopped sitting still, and that it was
262
+ // not quietly filed as an ordinary changed value.
263
+ mustBeUnstable: true,
264
+ build: (broken) => ({
265
+ 'package.json': PKG,
266
+ 'cli.js': [broken ? 'console.log(`batch id ${Math.floor(Math.random() * 1e9)}`);' : 'console.log(`batch id 4242`);', "console.log('two orders');", ''].join('\n'),
267
+ }),
268
+ },
269
+
270
+ {
271
+ name: 'a run that could not answer says so instead of passing',
272
+ breaks:
273
+ 'The break is real and it is hidden by the product itself: this build writes a fresh set of randomly named files on every run and stamps a random id on what it prints, so the same build disagrees with itself about nearly every address it has. Everything that wobbles is subtracted before anything is compared — which is right, and which here removes the comparison altogether. The only honest answer is that this run says nothing, and until 2026-08-30 the engine said "nothing that already worked has changed", which is the same sentence it uses when a product is genuinely fine.',
274
+ expect: 'no answer',
275
+ build: (broken) => ({
276
+ 'package.json': PKG,
277
+ 'cli.js': [
278
+ "import fs from 'node:fs';",
279
+ "fs.mkdirSync('out', { recursive: true });",
280
+ '// A build tool writing hash-named artefacts. Nothing unusual, and every one of them',
281
+ '// is a new address that was not there on the last run.',
282
+ 'for (let i = 0; i < 30; i += 1) {',
283
+ " fs.writeFileSync(`out/chunk-${Math.random().toString(36).slice(2, 10)}.txt`, 'x');",
284
+ '}',
285
+ 'console.log(`request ${Math.random().toString(36).slice(2, 10)}`);',
286
+ broken ? "console.log('orders: could not be loaded');" : "console.log('orders: 2');",
287
+ '',
288
+ ].join('\n'),
289
+ }),
290
+ },
291
+ ];
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Running it
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /**
298
+ * @typedef {object} CaseResult
299
+ * @property {string} name
300
+ * @property {boolean} caught True when the case behaved: the break was found, or the clean pair stayed silent.
301
+ * @property {string} [why] Why it did not, in one plain sentence.
302
+ * @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'|'could not tell'|'said it could not tell'} verdict
303
+ */
304
+
305
+ /**
306
+ * @typedef {object} SelfcheckResult
307
+ * @property {boolean} passed
308
+ * @property {CaseResult[]} cases
309
+ * @property {boolean} ran False when the engine could not be driven at all.
310
+ * @property {boolean} [certain] False when at least one case could not be told either way.
311
+ * A run that is not certain is NOT a pass and NOT a failure.
312
+ * @property {string} [why] Why it could not run.
313
+ * @property {string} [workDir]
314
+ */
315
+
316
+ /**
317
+ * Build every case, run the engine over each, and report what got through.
318
+ *
319
+ * The shape of the answer is the one `staysfixed check --selfcheck` prints, so
320
+ * the command and this function can never drift apart.
321
+ *
322
+ * @param {{cwd?: string, configFile?: string, only?: string[], keep?: boolean}} [opts]
323
+ * @returns {Promise<SelfcheckResult>}
324
+ */
325
+ export async function selfcheck(opts = {}) {
326
+ const engine = await loadEngine();
327
+ const check = engine.parts.check;
328
+
329
+ if (!check) {
330
+ return {
331
+ passed: false,
332
+ ran: false,
333
+ cases: [],
334
+ why: 'The difference engine is not in this build, so nothing could be tested. This is NOT a pass. src/v2/check.js has to export check({cwd, configFile, against, paired, journeys, only}).',
335
+ };
336
+ }
337
+
338
+ if (!(await haveGit())) {
339
+ return {
340
+ passed: false,
341
+ ran: false,
342
+ cases: [],
343
+ why: 'The corpus needs git: each product is a real repository with a working commit and an uncommitted change on top, because that is the shape an agent actually points this tool at. Install git and run it again. This is NOT a pass.',
344
+ };
345
+ }
346
+
347
+ const workDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-selfcheck-'));
348
+ const wanted = opts.only?.length ? CASES.filter((c) => opts.only?.some((n) => c.name.toLowerCase().includes(n.toLowerCase()))) : CASES;
349
+
350
+ /** @type {CaseResult[]} */
351
+ const cases = [];
352
+
353
+ for (const c of wanted) {
354
+ const first = await runOne(check, workDir, c, 1);
355
+ if (first.caught) {
356
+ cases.push(first);
357
+ continue;
358
+ }
359
+
360
+ // IT FAILED. Before that becomes an accusation, it has to reproduce.
361
+ //
362
+ // This is the same rule the engine itself lives by, turned on the corpus: a difference
363
+ // that will not happen twice is not a difference. On the night of 2026-08-29 this corpus
364
+ // came back "1 of 9 wrong" while the test suite was running beside it and then passed
365
+ // five times in a row on a quiet machine — and a corpus that can be perturbed by a busy
366
+ // laptop is worth nothing on a busy laptop, because nobody can tell its noise from its
367
+ // signal. The cause was found and removed (see howLongItTook in adapters/contract.js),
368
+ // and this stays anyway, because the next machine-shaped thing to creep in should land
369
+ // as "I could not tell" rather than as a false accusation somebody learns to ignore.
370
+ //
371
+ // A second run that agrees is a real failure and is reported as one. A second run that
372
+ // disagrees is filed as UNTELLABLE, which is not a pass: the exit code is 2, the same
373
+ // one used for "the corpus could not be run at all", because both mean no answer.
374
+ const second = await runOne(check, workDir, c, 2);
375
+ if (!second.caught) {
376
+ cases.push({ ...second, why: `${second.why ?? 'it did not behave'} (it did this twice in a row, so it is real)` });
377
+ continue;
378
+ }
379
+ cases.push({
380
+ name: c.name,
381
+ caught: false,
382
+ verdict: 'could not tell',
383
+ why:
384
+ `it behaved on the second run and not on the first, so this says nothing either way. ` +
385
+ `The first time: ${first.why ?? 'it did not behave'}. ` +
386
+ `This machine's load was ${loadNow()} — something that comes and goes with how busy the machine is is not evidence about the engine. ` +
387
+ `Run it again on a quiet machine before believing either answer.`,
388
+ });
389
+ }
390
+
391
+ if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true });
392
+
393
+ const untellable = cases.some((r) => r.verdict === 'could not tell');
394
+ return {
395
+ passed: cases.length > 0 && cases.every((r) => r.caught),
396
+ ran: true,
397
+ certain: !untellable,
398
+ cases,
399
+ ...(opts.keep ? { workDir } : {}),
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Build one case fresh and put the engine through it once.
405
+ *
406
+ * A fresh folder every attempt, deliberately. Re-running inside the same folder would leave
407
+ * the first attempt's stored captures sitting there, and the second attempt would be
408
+ * comparing against those rather than against the build that works.
409
+ *
410
+ * @param {any} check
411
+ * @param {string} workDir
412
+ * @param {Case} c
413
+ * @param {number} attempt
414
+ * @returns {Promise<CaseResult>}
415
+ */
416
+ async function runOne(check, workDir, c, attempt) {
417
+ const dir = path.join(workDir, `${safe(c.name)}${attempt > 1 ? `-again-${attempt}` : ''}`);
418
+ /** @type {string} */
419
+ let working;
420
+ try {
421
+ working = await plant(dir, c);
422
+ } catch (e) {
423
+ return { name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` };
424
+ }
425
+
426
+ /** @type {any} */
427
+ let result;
428
+ try {
429
+ // Exactly the call an agent makes, with exactly the arguments an agent
430
+ // sends. A corpus that reached past the front door would prove the engine
431
+ // works when driven in a way nobody drives it.
432
+ result = await check({
433
+ cwd: dir,
434
+ configFile: undefined,
435
+ against: working,
436
+ paired: true,
437
+ journeys: path.join(dir, 'journeys.json'),
438
+ only: [],
439
+ });
440
+ } catch (e) {
441
+ return { name: c.name, caught: false, verdict: 'could not run', why: `the engine threw: ${why(e)}` };
442
+ }
443
+
444
+ return judge(c, result);
445
+ }
446
+
447
+ /** How busy this machine is, in words, so an untellable result can name the likely reason. */
448
+ function loadNow() {
449
+ const [one] = os.loadavg();
450
+ const cores = os.cpus().length || 1;
451
+ const per = one / cores;
452
+ const how = per < 0.4 ? 'quiet' : per < 0.9 ? 'busy' : 'very busy';
453
+ return `${one.toFixed(1)} across ${cores} cores, which is ${how}`;
454
+ }
455
+
456
+ /**
457
+ * Did the engine do what this case demands?
458
+ *
459
+ * The two failing verdicts are named differently on purpose. "escaped" is a
460
+ * break that got through; "false alarm" is a clean pair that raised findings.
461
+ * They are equally fatal and they need completely different fixes, so they must
462
+ * never be reported under one word.
463
+ *
464
+ * @param {Case} c
465
+ * @param {any} result
466
+ * @returns {CaseResult}
467
+ */
468
+ function judge(c, result) {
469
+ if (result?.verdict === 'blocked') {
470
+ return { name: c.name, caught: false, verdict: 'could not run', why: `the engine was blocked${result.note ? `: ${result.note}` : ''}` };
471
+ }
472
+
473
+ const findings = Array.isArray(result?.findings) ? result.findings : [];
474
+ const unstable = Array.isArray(result?.newlyUnstable) ? result.newlyUnstable : [];
475
+
476
+ // The third expectation, and the one the other two cannot express: a run that is entitled
477
+ // to no verdict at all. What is demanded here is narrow on purpose — not that it found the
478
+ // break, which it cannot, but that it refused to call the run clean and said why in words a
479
+ // person can read.
480
+ if (c.expect === 'no answer') {
481
+ const said = String(result?.summary ?? '');
482
+ if (result?.ok === false && /no answer|not a pass/i.test(said)) {
483
+ return { name: c.name, caught: true, verdict: 'said it could not tell' };
484
+ }
485
+ return {
486
+ name: c.name,
487
+ caught: false,
488
+ verdict: 'escaped',
489
+ why:
490
+ result?.ok === false
491
+ ? `it did not pass, but it never said why in a way anybody could read: ${said.slice(0, 200)}`
492
+ : `it reported a clean run over a comparison that had been thrown away: ${said.slice(0, 200)}`,
493
+ };
494
+ }
495
+
496
+ if (c.expect === 'nothing') {
497
+ if (findings.length === 0 && unstable.length === 0) return { name: c.name, caught: true, verdict: 'quiet' };
498
+ // `unstable` holds WobbleEntry objects, not strings. Interpolating one printed
499
+ // "[object Object]" and turned the most important line in a failure report — the one
500
+ // saying WHAT went wrong — into nothing at all.
501
+ const named = unstable.map((/** @type {any} */ u) => (typeof u === 'string' ? u : `${u?.path ?? 'an address'} (was ${JSON.stringify(u?.a)}, then ${JSON.stringify(u?.b)})`));
502
+ const what = findings.length
503
+ ? `${findings.length} finding${findings.length === 1 ? '' : 's'}: ${describe(findings[0])}`
504
+ : `${unstable.length} newly unpredictable address${unstable.length === 1 ? '' : 'es'}: ${named.slice(0, 3).join('; ')}`;
505
+ return { name: c.name, caught: false, verdict: 'false alarm', why: `two builds that should have looked the same produced ${what}` };
506
+ }
507
+
508
+ // The one case that must NOT arrive as an ordinary finding. A value that stopped
509
+ // sitting still is a loss of determinism, and reporting it as a changed value
510
+ // would let an agent waive it as "the number is meant to be different now".
511
+ if (c.mustBeUnstable) {
512
+ if (unstable.length > 0) return { name: c.name, caught: true, verdict: 'caught' };
513
+ if (findings.length > 0) {
514
+ return {
515
+ name: c.name,
516
+ caught: false,
517
+ verdict: 'escaped',
518
+ why: 'it reported this as an ordinary changed value instead of as a loss of determinism, so an agent could wave it through as intended',
519
+ };
520
+ }
521
+ return { name: c.name, caught: false, verdict: 'escaped', why: 'it reported nothing at all' };
522
+ }
523
+
524
+ if (findings.length === 0) return { name: c.name, caught: false, verdict: 'escaped', why: 'it reported nothing at all' };
525
+
526
+ const patterns = c.mustSay ?? [];
527
+ const matching = findings.filter((/** @type {any} */ f) => {
528
+ const haystack = describe(f);
529
+ return patterns.every((p) => p.test(haystack));
530
+ });
531
+ if (matching.length === 0) {
532
+ return {
533
+ name: c.name,
534
+ caught: false,
535
+ verdict: 'escaped',
536
+ why: `it reported ${findings.length} thing${findings.length === 1 ? '' : 's'}, none of them this one. The first was: ${describe(findings[0])}`,
537
+ };
538
+ }
539
+
540
+ return { name: c.name, caught: true, verdict: 'caught' };
541
+ }
542
+
543
+ /**
544
+ * Everything one finding says, flattened, so a pattern can be matched against
545
+ * the whole of it rather than against a field name somebody guessed.
546
+ * @param {any} f
547
+ * @returns {string}
548
+ */
549
+ function describe(f) {
550
+ if (!f || typeof f !== 'object') return String(f);
551
+ // `title` is the finding's sentence and `reference`/`candidate` are the two values,
552
+ // both straight out of the contract in src/v2/types.js. This used to read `summary`,
553
+ // `was` and `now`, which nothing produces - so every pattern here would have been
554
+ // matched against the word "undefined" and the corpus would have failed for a reason
555
+ // that had nothing to do with the engine.
556
+ const sample = f.sample ? `${f.sample.path} ${JSON.stringify(f.sample.reference)} ${JSON.stringify(f.sample.candidate)}` : '';
557
+ const everyValue = Array.isArray(f.differences)
558
+ ? f.differences.map((/** @type {any} */ d) => `${d.path} ${JSON.stringify(d.reference)} ${JSON.stringify(d.candidate)}`)
559
+ : [];
560
+ return [f.title ?? f.summary, ...(Array.isArray(f.paths) ? f.paths : []), sample, ...everyValue].filter(Boolean).join(' | ');
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Building one product
565
+ // ---------------------------------------------------------------------------
566
+
567
+ /**
568
+ * Write the working product, commit it, then apply the break on top and leave it
569
+ * uncommitted.
570
+ *
571
+ * Uncommitted on purpose: that is the state an agent is in when it calls this
572
+ * tool, and it is the state the ranking needs, because "how far is this from the
573
+ * code you just edited" is answered from the uncommitted diff.
574
+ *
575
+ * @param {string} dir
576
+ * @param {Case} c
577
+ * @returns {Promise<string>} the commit that counts as working
578
+ */
579
+ async function plant(dir, c) {
580
+ await fsp.mkdir(dir, { recursive: true });
581
+ await git(dir, ['init', '-q']);
582
+ await git(dir, ['config', 'user.email', 'selfcheck@staysfixed.local']);
583
+ await git(dir, ['config', 'user.name', 'Stays Fixed self-check']);
584
+
585
+ await writeAll(dir, c.build(false));
586
+ await fsp.writeFile(path.join(dir, 'journeys.json'), JSON.stringify(journeysFor(c), null, 2) + '\n');
587
+ await fsp.writeFile(path.join(dir, '.gitignore'), 'out/\n');
588
+ await git(dir, ['add', '-A']);
589
+ await git(dir, ['commit', '-q', '-m', 'the build that works']);
590
+ const working = (await git(dir, ['rev-parse', 'HEAD'])).trim();
591
+
592
+ await writeAll(dir, c.build(true));
593
+ return working;
594
+ }
595
+
596
+ /**
597
+ * How to walk each fixture.
598
+ *
599
+ * Every one of them is a program you run, deliberately: a corpus that needed a
600
+ * browser, a simulator or a database could not run on a machine that has none of
601
+ * those, and a self-check nobody can run is a self-check nobody runs.
602
+ *
603
+ * @param {Case} c
604
+ * @returns {Record<string, unknown>[]}
605
+ */
606
+ function journeysFor(c) {
607
+ return [
608
+ {
609
+ name: 'run-it',
610
+ describe: `Run ${c.name} once and watch everything it does.`,
611
+ source: 'code',
612
+ surface: 'cli',
613
+ steps: [{ act: 'run', run: 'node cli.js', note: 'the whole product, start to finish' }],
614
+ },
615
+ ];
616
+ }
617
+
618
+ /**
619
+ * @param {string} dir
620
+ * @param {Record<string, string>} files
621
+ */
622
+ async function writeAll(dir, files) {
623
+ for (const [name, body] of Object.entries(files)) {
624
+ const file = path.join(dir, name);
625
+ await fsp.mkdir(path.dirname(file), { recursive: true });
626
+ await fsp.writeFile(file, body);
627
+ }
628
+ }
629
+
630
+ /**
631
+ * @param {string} cwd
632
+ * @param {string[]} args
633
+ * @returns {Promise<string>}
634
+ */
635
+ async function git(cwd, args) {
636
+ const { stdout } = await run('git', args, { cwd, timeout: 20_000 });
637
+ return stdout;
638
+ }
639
+
640
+ /** @returns {Promise<boolean>} */
641
+ async function haveGit() {
642
+ try {
643
+ await run('git', ['--version'], { timeout: 10_000 });
644
+ return true;
645
+ } catch {
646
+ return false;
647
+ }
648
+ }
649
+
650
+ /** @param {unknown} e */
651
+ function why(e) {
652
+ return e instanceof Error ? e.message : String(e);
653
+ }
654
+
655
+ /** @param {string} s */
656
+ function safe(s) {
657
+ return s.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'case';
658
+ }
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // Running it on its own
662
+ // ---------------------------------------------------------------------------
663
+
664
+ /**
665
+ * `node src/v2/selfcheck.js`. `staysfixed check --selfcheck` calls `selfcheck`
666
+ * directly and prints it in the CLI's own voice; this exists so the corpus can
667
+ * be run before anybody has wired a command up for it.
668
+ *
669
+ * @param {string[]} [argv]
670
+ * @returns {Promise<number>}
671
+ */
672
+ export async function main(argv = process.argv.slice(2)) {
673
+ const json = argv.includes('--json');
674
+ const keep = argv.includes('--keep');
675
+ /** @type {string[]} */
676
+ const only = [];
677
+ for (let i = 0; i < argv.length; i += 1) {
678
+ if (argv[i] === '--only' && argv[i + 1]) only.push(argv[i + 1]);
679
+ }
680
+
681
+ const result = await selfcheck({ only, keep });
682
+
683
+ if (json) {
684
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
685
+ if (result.passed) return 0;
686
+ if (!result.ran) return 2;
687
+ return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
688
+ }
689
+
690
+ if (!result.ran) {
691
+ process.stderr.write(`Could not run the self-check.\n${result.why ?? ''}\n`);
692
+ return 2;
693
+ }
694
+ const untellable = result.cases.filter((r) => r.verdict === 'could not tell');
695
+
696
+ /** @type {string[]} */
697
+ const out = ['Stays Fixed - checking that it can still catch things', ''];
698
+ for (const r of result.cases) {
699
+ out.push(`${(r.caught ? 'ok' : 'FAILED').padEnd(8)} ${r.name}`);
700
+ if (!r.caught) out.push(` ${r.why ?? 'it did not behave, and said nothing useful about why'}`);
701
+ }
702
+ out.push('');
703
+ if (result.passed) {
704
+ out.push(`All ${result.cases.length} behaved: every break was caught, and every pair that should have been silent was silent.`);
705
+ } else if (untellable.length > 0 && untellable.length === result.cases.filter((r) => !r.caught).length) {
706
+ // Nothing failed twice. Saying "wrong" here would be an accusation the evidence does not
707
+ // support, and saying "fine" would be worse.
708
+ out.push(
709
+ `${untellable.length} of ${result.cases.length} could not be told either way — ${untellable.length === 1 ? 'it' : 'they'} behaved on the second run and not on the first. ` +
710
+ 'That is not a pass and not a failure. Run it again on a quiet machine.',
711
+ );
712
+ } else {
713
+ const bad = result.cases.filter((r) => !r.caught && r.verdict !== 'could not tell');
714
+ out.push(`${bad.length} of ${result.cases.length} did not behave, twice in a row each. Until that is fixed, a clean check from this tool does not mean what it says.`);
715
+ if (untellable.length > 0) out.push(`${untellable.length} more could not be told either way.`);
716
+ }
717
+ if (result.workDir) out.push(`The products were left in ${result.workDir}.`);
718
+
719
+ process.stdout.write(out.join('\n') + '\n');
720
+ if (result.passed) return 0;
721
+ // "I could not test this" is exit 2 and never exit 0, and it is not exit 1 either: one of
722
+ // those says the engine is broken and the other says nobody knows, and they need different
723
+ // reactions from whoever is reading.
724
+ return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
725
+ }
726
+
727
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
728
+ process.exitCode = await main();
729
+ }