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,564 @@
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'} 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 value that used to be steady is now random',
215
+ breaks:
216
+ '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.',
217
+ expect: 'a finding',
218
+ // No `mustSay` here, and that is deliberate. A newly unpredictable address
219
+ // is reported as an address, not as a value, and this corpus does not get to
220
+ // dictate what the engine names its addresses. What it does get to demand is
221
+ // that SOMETHING was flagged as having stopped sitting still, and that it was
222
+ // not quietly filed as an ordinary changed value.
223
+ mustBeUnstable: true,
224
+ build: (broken) => ({
225
+ 'package.json': PKG,
226
+ 'cli.js': [broken ? 'console.log(`batch id ${Math.floor(Math.random() * 1e9)}`);' : 'console.log(`batch id 4242`);', "console.log('two orders');", ''].join('\n'),
227
+ }),
228
+ },
229
+ ];
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Running it
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * @typedef {object} CaseResult
237
+ * @property {string} name
238
+ * @property {boolean} caught True when the case behaved: the break was found, or the clean pair stayed silent.
239
+ * @property {string} [why] Why it did not, in one plain sentence.
240
+ * @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'} verdict
241
+ */
242
+
243
+ /**
244
+ * @typedef {object} SelfcheckResult
245
+ * @property {boolean} passed
246
+ * @property {CaseResult[]} cases
247
+ * @property {boolean} ran False when the engine could not be driven at all.
248
+ * @property {string} [why] Why it could not run.
249
+ * @property {string} [workDir]
250
+ */
251
+
252
+ /**
253
+ * Build every case, run the engine over each, and report what got through.
254
+ *
255
+ * The shape of the answer is the one `staysfixed check --selfcheck` prints, so
256
+ * the command and this function can never drift apart.
257
+ *
258
+ * @param {{cwd?: string, configFile?: string, only?: string[], keep?: boolean}} [opts]
259
+ * @returns {Promise<SelfcheckResult>}
260
+ */
261
+ export async function selfcheck(opts = {}) {
262
+ const engine = await loadEngine();
263
+ const check = engine.parts.check;
264
+
265
+ if (!check) {
266
+ return {
267
+ passed: false,
268
+ ran: false,
269
+ cases: [],
270
+ 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}).',
271
+ };
272
+ }
273
+
274
+ if (!(await haveGit())) {
275
+ return {
276
+ passed: false,
277
+ ran: false,
278
+ cases: [],
279
+ 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.',
280
+ };
281
+ }
282
+
283
+ const workDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-selfcheck-'));
284
+ const wanted = opts.only?.length ? CASES.filter((c) => opts.only?.some((n) => c.name.toLowerCase().includes(n.toLowerCase()))) : CASES;
285
+
286
+ /** @type {CaseResult[]} */
287
+ const cases = [];
288
+
289
+ for (const c of wanted) {
290
+ const dir = path.join(workDir, safe(c.name));
291
+ /** @type {string} */
292
+ let working;
293
+ try {
294
+ working = await plant(dir, c);
295
+ } catch (e) {
296
+ cases.push({ name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` });
297
+ continue;
298
+ }
299
+
300
+ /** @type {any} */
301
+ let result;
302
+ try {
303
+ // Exactly the call an agent makes, with exactly the arguments an agent
304
+ // sends. A corpus that reached past the front door would prove the engine
305
+ // works when driven in a way nobody drives it.
306
+ result = await check({
307
+ cwd: dir,
308
+ configFile: undefined,
309
+ against: working,
310
+ paired: true,
311
+ journeys: path.join(dir, 'journeys.json'),
312
+ only: [],
313
+ });
314
+ } catch (e) {
315
+ cases.push({ name: c.name, caught: false, verdict: 'could not run', why: `the engine threw: ${why(e)}` });
316
+ continue;
317
+ }
318
+
319
+ cases.push(judge(c, result));
320
+ }
321
+
322
+ if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true });
323
+
324
+ return {
325
+ passed: cases.length > 0 && cases.every((r) => r.caught),
326
+ ran: true,
327
+ cases,
328
+ ...(opts.keep ? { workDir } : {}),
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Did the engine do what this case demands?
334
+ *
335
+ * The two failing verdicts are named differently on purpose. "escaped" is a
336
+ * break that got through; "false alarm" is a clean pair that raised findings.
337
+ * They are equally fatal and they need completely different fixes, so they must
338
+ * never be reported under one word.
339
+ *
340
+ * @param {Case} c
341
+ * @param {any} result
342
+ * @returns {CaseResult}
343
+ */
344
+ function judge(c, result) {
345
+ if (result?.verdict === 'blocked') {
346
+ return { name: c.name, caught: false, verdict: 'could not run', why: `the engine was blocked${result.note ? `: ${result.note}` : ''}` };
347
+ }
348
+
349
+ const findings = Array.isArray(result?.findings) ? result.findings : [];
350
+ const unstable = Array.isArray(result?.newlyUnstable) ? result.newlyUnstable : [];
351
+
352
+ if (c.expect === 'nothing') {
353
+ if (findings.length === 0 && unstable.length === 0) return { name: c.name, caught: true, verdict: 'quiet' };
354
+ const what = findings.length ? `${findings.length} finding${findings.length === 1 ? '' : 's'}: ${describe(findings[0])}` : `${unstable.length} newly unpredictable address${unstable.length === 1 ? '' : 'es'}: ${unstable[0]}`;
355
+ return { name: c.name, caught: false, verdict: 'false alarm', why: `two builds that should have looked the same produced ${what}` };
356
+ }
357
+
358
+ // The one case that must NOT arrive as an ordinary finding. A value that stopped
359
+ // sitting still is a loss of determinism, and reporting it as a changed value
360
+ // would let an agent waive it as "the number is meant to be different now".
361
+ if (c.mustBeUnstable) {
362
+ if (unstable.length > 0) return { name: c.name, caught: true, verdict: 'caught' };
363
+ if (findings.length > 0) {
364
+ return {
365
+ name: c.name,
366
+ caught: false,
367
+ verdict: 'escaped',
368
+ 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',
369
+ };
370
+ }
371
+ return { name: c.name, caught: false, verdict: 'escaped', why: 'it reported nothing at all' };
372
+ }
373
+
374
+ if (findings.length === 0) return { name: c.name, caught: false, verdict: 'escaped', why: 'it reported nothing at all' };
375
+
376
+ const patterns = c.mustSay ?? [];
377
+ const matching = findings.filter((/** @type {any} */ f) => {
378
+ const haystack = describe(f);
379
+ return patterns.every((p) => p.test(haystack));
380
+ });
381
+ if (matching.length === 0) {
382
+ return {
383
+ name: c.name,
384
+ caught: false,
385
+ verdict: 'escaped',
386
+ why: `it reported ${findings.length} thing${findings.length === 1 ? '' : 's'}, none of them this one. The first was: ${describe(findings[0])}`,
387
+ };
388
+ }
389
+
390
+ return { name: c.name, caught: true, verdict: 'caught' };
391
+ }
392
+
393
+ /**
394
+ * Everything one finding says, flattened, so a pattern can be matched against
395
+ * the whole of it rather than against a field name somebody guessed.
396
+ * @param {any} f
397
+ * @returns {string}
398
+ */
399
+ function describe(f) {
400
+ if (!f || typeof f !== 'object') return String(f);
401
+ // `title` is the finding's sentence and `reference`/`candidate` are the two values,
402
+ // both straight out of the contract in src/v2/types.js. This used to read `summary`,
403
+ // `was` and `now`, which nothing produces - so every pattern here would have been
404
+ // matched against the word "undefined" and the corpus would have failed for a reason
405
+ // that had nothing to do with the engine.
406
+ const sample = f.sample ? `${f.sample.path} ${JSON.stringify(f.sample.reference)} ${JSON.stringify(f.sample.candidate)}` : '';
407
+ const everyValue = Array.isArray(f.differences)
408
+ ? f.differences.map((/** @type {any} */ d) => `${d.path} ${JSON.stringify(d.reference)} ${JSON.stringify(d.candidate)}`)
409
+ : [];
410
+ return [f.title ?? f.summary, ...(Array.isArray(f.paths) ? f.paths : []), sample, ...everyValue].filter(Boolean).join(' | ');
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Building one product
415
+ // ---------------------------------------------------------------------------
416
+
417
+ /**
418
+ * Write the working product, commit it, then apply the break on top and leave it
419
+ * uncommitted.
420
+ *
421
+ * Uncommitted on purpose: that is the state an agent is in when it calls this
422
+ * tool, and it is the state the ranking needs, because "how far is this from the
423
+ * code you just edited" is answered from the uncommitted diff.
424
+ *
425
+ * @param {string} dir
426
+ * @param {Case} c
427
+ * @returns {Promise<string>} the commit that counts as working
428
+ */
429
+ async function plant(dir, c) {
430
+ await fsp.mkdir(dir, { recursive: true });
431
+ await git(dir, ['init', '-q']);
432
+ await git(dir, ['config', 'user.email', 'selfcheck@staysfixed.local']);
433
+ await git(dir, ['config', 'user.name', 'Stays Fixed self-check']);
434
+
435
+ await writeAll(dir, c.build(false));
436
+ await fsp.writeFile(path.join(dir, 'journeys.json'), JSON.stringify(journeysFor(c), null, 2) + '\n');
437
+ await fsp.writeFile(path.join(dir, '.gitignore'), 'out/\n');
438
+ await git(dir, ['add', '-A']);
439
+ await git(dir, ['commit', '-q', '-m', 'the build that works']);
440
+ const working = (await git(dir, ['rev-parse', 'HEAD'])).trim();
441
+
442
+ await writeAll(dir, c.build(true));
443
+ return working;
444
+ }
445
+
446
+ /**
447
+ * How to walk each fixture.
448
+ *
449
+ * Every one of them is a program you run, deliberately: a corpus that needed a
450
+ * browser, a simulator or a database could not run on a machine that has none of
451
+ * those, and a self-check nobody can run is a self-check nobody runs.
452
+ *
453
+ * @param {Case} c
454
+ * @returns {Record<string, unknown>[]}
455
+ */
456
+ function journeysFor(c) {
457
+ return [
458
+ {
459
+ name: 'run-it',
460
+ describe: `Run ${c.name} once and watch everything it does.`,
461
+ source: 'code',
462
+ surface: 'cli',
463
+ steps: [{ act: 'run', run: 'node cli.js', note: 'the whole product, start to finish' }],
464
+ },
465
+ ];
466
+ }
467
+
468
+ /**
469
+ * @param {string} dir
470
+ * @param {Record<string, string>} files
471
+ */
472
+ async function writeAll(dir, files) {
473
+ for (const [name, body] of Object.entries(files)) {
474
+ const file = path.join(dir, name);
475
+ await fsp.mkdir(path.dirname(file), { recursive: true });
476
+ await fsp.writeFile(file, body);
477
+ }
478
+ }
479
+
480
+ /**
481
+ * @param {string} cwd
482
+ * @param {string[]} args
483
+ * @returns {Promise<string>}
484
+ */
485
+ async function git(cwd, args) {
486
+ const { stdout } = await run('git', args, { cwd, timeout: 20_000 });
487
+ return stdout;
488
+ }
489
+
490
+ /** @returns {Promise<boolean>} */
491
+ async function haveGit() {
492
+ try {
493
+ await run('git', ['--version'], { timeout: 10_000 });
494
+ return true;
495
+ } catch {
496
+ return false;
497
+ }
498
+ }
499
+
500
+ /** @param {unknown} e */
501
+ function why(e) {
502
+ return e instanceof Error ? e.message : String(e);
503
+ }
504
+
505
+ /** @param {string} s */
506
+ function safe(s) {
507
+ return s.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'case';
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Running it on its own
512
+ // ---------------------------------------------------------------------------
513
+
514
+ /**
515
+ * `node src/v2/selfcheck.js`. `staysfixed check --selfcheck` calls `selfcheck`
516
+ * directly and prints it in the CLI's own voice; this exists so the corpus can
517
+ * be run before anybody has wired a command up for it.
518
+ *
519
+ * @param {string[]} [argv]
520
+ * @returns {Promise<number>}
521
+ */
522
+ export async function main(argv = process.argv.slice(2)) {
523
+ const json = argv.includes('--json');
524
+ const keep = argv.includes('--keep');
525
+ /** @type {string[]} */
526
+ const only = [];
527
+ for (let i = 0; i < argv.length; i += 1) {
528
+ if (argv[i] === '--only' && argv[i + 1]) only.push(argv[i + 1]);
529
+ }
530
+
531
+ const result = await selfcheck({ only, keep });
532
+
533
+ if (json) {
534
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
535
+ return result.passed ? 0 : result.ran ? 1 : 2;
536
+ }
537
+
538
+ if (!result.ran) {
539
+ process.stderr.write(`Could not run the self-check.\n${result.why ?? ''}\n`);
540
+ return 2;
541
+ }
542
+
543
+ /** @type {string[]} */
544
+ const out = ['Stays Fixed - checking that it can still catch things', ''];
545
+ for (const r of result.cases) {
546
+ out.push(`${(r.caught ? 'ok' : 'FAILED').padEnd(8)} ${r.name}`);
547
+ if (!r.caught) out.push(` ${r.why ?? 'it did not behave, and said nothing useful about why'}`);
548
+ }
549
+ out.push('');
550
+ if (result.passed) {
551
+ out.push(`All ${result.cases.length} behaved: every break was caught, and every pair that should have been silent was silent.`);
552
+ } else {
553
+ const bad = result.cases.filter((r) => !r.caught);
554
+ out.push(`${bad.length} of ${result.cases.length} did not behave. Until that is fixed, a clean check from this tool does not mean what it says.`);
555
+ }
556
+ if (result.workDir) out.push(`The products were left in ${result.workDir}.`);
557
+
558
+ process.stdout.write(out.join('\n') + '\n');
559
+ return result.passed ? 0 : 1;
560
+ }
561
+
562
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
563
+ process.exitCode = await main();
564
+ }