staysfixed 0.6.2 → 0.7.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.
- package/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/src/v2/selfcheck.js
CHANGED
|
@@ -59,6 +59,23 @@ const run = promisify(execFile);
|
|
|
59
59
|
* @property {'a finding'|'nothing'|'no answer'} expect
|
|
60
60
|
* @property {RegExp[]} [mustSay]
|
|
61
61
|
* @property {boolean} [mustBeUnstable] It has to land in `newlyUnstable`, not in the findings.
|
|
62
|
+
* @property {'guard'|'crash'|'data-loss'|'money'|'sign-in'} [mustBeSealed]
|
|
63
|
+
* The finding has to land in a class no agent may wave through. A break that IS one of
|
|
64
|
+
* those and is filed `ordinary` is not caught, however loudly it is reported: `ordinary`
|
|
65
|
+
* is precisely the class an agent is allowed to close on its own, so a mislabelled
|
|
66
|
+
* crash or charge is a silence with a paragraph attached to it.
|
|
67
|
+
* @property {RegExp[]} [summaryMustSay]
|
|
68
|
+
* Things the closing paragraph has to say. Some of what this tool owes a reader is not a
|
|
69
|
+
* finding at all — how much of the run was really compared, whether what it saw was
|
|
70
|
+
* saved — and the only place those appear is the sentence a person actually reads.
|
|
71
|
+
* @property {Record<string, unknown>[]} [journeys]
|
|
72
|
+
* A journeys file of its own, for a case about what happens when a journey cannot be
|
|
73
|
+
* walked. Left out, every case gets the one-step "run it" journey.
|
|
74
|
+
* @property {(dir: string, working: string) => Promise<{ready: boolean, why: string}>} [prepare]
|
|
75
|
+
* Bend the machine around the product before the engine runs — take away permission to
|
|
76
|
+
* write, for instance. Answering `ready: false` means this machine cannot be made to do
|
|
77
|
+
* it (running as root, or a filesystem that ignores permissions), and the case reports
|
|
78
|
+
* itself as untested rather than as a pass.
|
|
62
79
|
* @property {(broken: boolean) => Record<string, string>} build
|
|
63
80
|
*/
|
|
64
81
|
|
|
@@ -288,8 +305,194 @@ export const CASES = [
|
|
|
288
305
|
].join('\n'),
|
|
289
306
|
}),
|
|
290
307
|
},
|
|
308
|
+
|
|
309
|
+
{
|
|
310
|
+
name: 'a crash that only shows in what the program said',
|
|
311
|
+
breaks:
|
|
312
|
+
'The program starts complaining about something fatal, and nothing about WHERE it complained says so. The address is just "stderr"; the finding\'s own sentence carries the first seventy characters of the value and the word is past them. Until 2026-08-30 the words that seal a finding were matched against the addresses and the sentences written about a finding, never against the values themselves — so a crash appearing in the output was filed `ordinary`, which is exactly the class an agent may close on its own without telling anybody.',
|
|
313
|
+
expect: 'a finding',
|
|
314
|
+
mustBeSealed: 'crash',
|
|
315
|
+
build: (broken) => ({
|
|
316
|
+
'package.json': PKG,
|
|
317
|
+
'cli.js': [
|
|
318
|
+
"console.log('report written');",
|
|
319
|
+
broken
|
|
320
|
+
? "console.error('index: 3 of 4 shards answered within the usual time, and one did not, so the run took longer than normal; fatal: the order index is corrupt');"
|
|
321
|
+
: "console.error('index: 4 of 4 shards answered within the usual time, so the run took about as long as it normally does; all good, the order index is fine');",
|
|
322
|
+
'',
|
|
323
|
+
].join('\n'),
|
|
324
|
+
}),
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
{
|
|
328
|
+
name: 'a charge that moved, where nothing in the address mentions money',
|
|
329
|
+
breaks:
|
|
330
|
+
'What goes out to the payment company changed. The address is a plain stdout line and the word that makes this a money question sits deep inside the value, past everything any sentence about the finding quotes. Same blindness as the crash above, and this is the class the design says goes to a person whatever caused it.',
|
|
331
|
+
expect: 'a finding',
|
|
332
|
+
mustBeSealed: 'money',
|
|
333
|
+
build: (broken) => ({
|
|
334
|
+
'package.json': PKG,
|
|
335
|
+
'cli.js': [
|
|
336
|
+
'const order = { id: 7, lines: 3, note: "the customer asked for it to be sent to the office address instead" };',
|
|
337
|
+
`console.log('sending order ' + JSON.stringify(order) + ' to the till with {"currency":"aed","amount":${broken ? '1200' : '1000'}}');`,
|
|
338
|
+
'',
|
|
339
|
+
].join('\n'),
|
|
340
|
+
}),
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
{
|
|
344
|
+
name: 'a journey nothing could walk is named, not counted as clean',
|
|
345
|
+
breaks:
|
|
346
|
+
'Nothing is wrong with the product, and the only journey anybody wrote cannot be walked at all — its step says to run something and never says what. So the closing sentence is about a fraction of what was asked for, and it used to read exactly like a sentence about all of it: "Nothing that worked has changed", followed by a count of every address the new build produced. A clean answer covering less than it appears to is how a check gets trusted for work it never did.',
|
|
347
|
+
expect: 'nothing',
|
|
348
|
+
summaryMustSay: [/not compared at all/],
|
|
349
|
+
journeys: [
|
|
350
|
+
{
|
|
351
|
+
name: 'run-it',
|
|
352
|
+
describe: 'A journey somebody wrote wrong: it says to run something and never says what.',
|
|
353
|
+
source: 'code',
|
|
354
|
+
surface: 'cli',
|
|
355
|
+
steps: [{ act: 'run', note: 'and nothing to run' }],
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
build: () => ({
|
|
359
|
+
'package.json': PKG,
|
|
360
|
+
'cli.js': "console.log('two orders');\n",
|
|
361
|
+
}),
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
{
|
|
365
|
+
name: 'a run that could only compare half of itself says which half',
|
|
366
|
+
breaks:
|
|
367
|
+
'Two journeys, and only one of them can be walked. The break in the walkable one is found — and the closing sentence used to quote every address the new build produced, as though the whole product had been put beside the old one. Half a run reported as a whole one is how a clean-looking answer gets trusted for more than it covers.',
|
|
368
|
+
expect: 'a finding',
|
|
369
|
+
mustSay: [/email/i],
|
|
370
|
+
summaryMustSay: [/not compared at all/],
|
|
371
|
+
journeys: [
|
|
372
|
+
{
|
|
373
|
+
name: 'run-it',
|
|
374
|
+
describe: 'Run the product once and watch everything it does.',
|
|
375
|
+
source: 'code',
|
|
376
|
+
surface: 'cli',
|
|
377
|
+
steps: [{ act: 'run', run: 'node cli.js', note: 'the whole product, start to finish' }],
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: 'the-other-half',
|
|
381
|
+
describe: 'A second journey nobody finished writing.',
|
|
382
|
+
source: 'code',
|
|
383
|
+
surface: 'cli',
|
|
384
|
+
steps: [{ act: 'run', note: 'and nothing to run' }],
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
build: (broken) => ({
|
|
388
|
+
'package.json': PKG,
|
|
389
|
+
'cli.js': [
|
|
390
|
+
'const person = {',
|
|
391
|
+
' id: 7,',
|
|
392
|
+
" name: 'Ada',",
|
|
393
|
+
broken ? null : " email: 'ada@example.com',",
|
|
394
|
+
" city: 'London',",
|
|
395
|
+
'};',
|
|
396
|
+
'console.log(JSON.stringify(person));',
|
|
397
|
+
'',
|
|
398
|
+
]
|
|
399
|
+
.filter((line) => line !== null)
|
|
400
|
+
.join('\n'),
|
|
401
|
+
}),
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
{
|
|
405
|
+
name: 'a run whose record could not be saved says so',
|
|
406
|
+
breaks:
|
|
407
|
+
'The disk will not take the captures. The comparison still happens and the break is still found — and until 2026-08-30 the failure to save was swallowed whole, so the run looked identical to one that had saved everything, and the NEXT run found no record and reported the entire product as never having been walked. His disk hit zero bytes on the night this was written.',
|
|
408
|
+
expect: 'a finding',
|
|
409
|
+
mustSay: [/email/i],
|
|
410
|
+
summaryMustSay: [/was NOT saved/i],
|
|
411
|
+
prepare: makeStoreUnwritable,
|
|
412
|
+
build: (broken) => ({
|
|
413
|
+
'package.json': PKG,
|
|
414
|
+
'cli.js': [
|
|
415
|
+
'const person = {',
|
|
416
|
+
' id: 7,',
|
|
417
|
+
" name: 'Ada',",
|
|
418
|
+
broken ? null : " email: 'ada@example.com',",
|
|
419
|
+
" city: 'London',",
|
|
420
|
+
'};',
|
|
421
|
+
'console.log(JSON.stringify(person));',
|
|
422
|
+
'',
|
|
423
|
+
]
|
|
424
|
+
.filter((line) => line !== null)
|
|
425
|
+
.join('\n'),
|
|
426
|
+
}),
|
|
427
|
+
},
|
|
291
428
|
];
|
|
292
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Take away the store's permission to be written to, and say honestly when this machine
|
|
432
|
+
* will not let that happen.
|
|
433
|
+
*
|
|
434
|
+
* Running as root ignores the mode, and so do some filesystems, and on Windows it means
|
|
435
|
+
* nothing at all. Any of those and the case is not testable HERE — which is a different
|
|
436
|
+
* answer from passing, and gets its own word in the report.
|
|
437
|
+
*
|
|
438
|
+
* @param {string} dir
|
|
439
|
+
* @param {string} working The commit the warm-up run compares against - the build this
|
|
440
|
+
* corpus case has already recorded as working.
|
|
441
|
+
* @returns {Promise<{ready: boolean, why: string}>}
|
|
442
|
+
*/
|
|
443
|
+
async function makeStoreUnwritable(dir, working) {
|
|
444
|
+
const builds = path.join(dir, '.staysfixed', 'v2', 'builds');
|
|
445
|
+
await fsp.mkdir(builds, { recursive: true });
|
|
446
|
+
|
|
447
|
+
// Proved, not assumed. A chmod that returns without doing anything is exactly the shape
|
|
448
|
+
// that would turn this case into a green light that tests nothing.
|
|
449
|
+
const probe = path.join(builds, 'probe');
|
|
450
|
+
await fsp.mkdir(probe, { recursive: true });
|
|
451
|
+
try {
|
|
452
|
+
await fsp.chmod(probe, 0o555);
|
|
453
|
+
await fsp.writeFile(path.join(probe, 'x.txt'), 'x');
|
|
454
|
+
await fsp.chmod(probe, 0o755);
|
|
455
|
+
await fsp.rm(probe, { recursive: true, force: true });
|
|
456
|
+
return {
|
|
457
|
+
ready: false,
|
|
458
|
+
why: 'this account can write into a folder it has no permission to write into — running as root, or a filesystem that ignores permissions',
|
|
459
|
+
};
|
|
460
|
+
} catch {
|
|
461
|
+
await fsp.chmod(probe, 0o755).catch(() => {});
|
|
462
|
+
await fsp.rm(probe, { recursive: true, force: true }).catch(() => {});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// One warm-up run, so every folder the real run will write into already exists.
|
|
466
|
+
//
|
|
467
|
+
// The store is not made unwritable wholesale, deliberately. Doing that stops the check
|
|
468
|
+
// before it starts — which is honest behaviour and says so in those words — and this case
|
|
469
|
+
// is about the OTHER failure: the disk giving out part-way, after the product has been
|
|
470
|
+
// walked and compared and there is a real answer to hand back. So the folders where the
|
|
471
|
+
// captures land are made read-only and everything else is left alone.
|
|
472
|
+
const engine = await loadEngine();
|
|
473
|
+
if (!engine.parts.check) return { ready: false, why: 'the difference engine is not in this build' };
|
|
474
|
+
try {
|
|
475
|
+
await engine.parts.check({ cwd: dir, configFile: undefined, against: working, paired: true, journeys: path.join(dir, 'journeys.json'), only: [] });
|
|
476
|
+
} catch (e) {
|
|
477
|
+
return { ready: false, why: `the warm-up run did not work: ${why(e)}` };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** @type {string[]} */
|
|
481
|
+
const shut = [];
|
|
482
|
+
for (const build of await fsp.readdir(builds, { withFileTypes: true })) {
|
|
483
|
+
if (!build.isDirectory()) continue;
|
|
484
|
+
const inside = path.join(builds, build.name);
|
|
485
|
+
for (const journey of await fsp.readdir(inside, { withFileTypes: true })) {
|
|
486
|
+
if (!journey.isDirectory()) continue;
|
|
487
|
+
const folder = path.join(inside, journey.name);
|
|
488
|
+
await fsp.chmod(folder, 0o555);
|
|
489
|
+
shut.push(folder);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (shut.length === 0) return { ready: false, why: 'the warm-up run stored nothing, so there was nothing to make read-only' };
|
|
493
|
+
return { ready: true, why: '' };
|
|
494
|
+
}
|
|
495
|
+
|
|
293
496
|
// ---------------------------------------------------------------------------
|
|
294
497
|
// Running it
|
|
295
498
|
// ---------------------------------------------------------------------------
|
|
@@ -299,13 +502,15 @@ export const CASES = [
|
|
|
299
502
|
* @property {string} name
|
|
300
503
|
* @property {boolean} caught True when the case behaved: the break was found, or the clean pair stayed silent.
|
|
301
504
|
* @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
|
|
505
|
+
* @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'|'could not tell'|'said it could not tell'|'not testable here'} verdict
|
|
303
506
|
*/
|
|
304
507
|
|
|
305
508
|
/**
|
|
306
509
|
* @typedef {object} SelfcheckResult
|
|
307
510
|
* @property {boolean} passed
|
|
308
511
|
* @property {CaseResult[]} cases
|
|
512
|
+
* @property {number} [notTestableHere] Cases this machine could not be made to perform —
|
|
513
|
+
* not passes, not failures, and named as neither.
|
|
309
514
|
* @property {boolean} ran False when the engine could not be driven at all.
|
|
310
515
|
* @property {boolean} [certain] False when at least one case could not be told either way.
|
|
311
516
|
* A run that is not certain is NOT a pass and NOT a failure.
|
|
@@ -388,13 +593,20 @@ export async function selfcheck(opts = {}) {
|
|
|
388
593
|
});
|
|
389
594
|
}
|
|
390
595
|
|
|
391
|
-
|
|
596
|
+
// Some cases take permissions away to make their point, and a folder nobody may write into
|
|
597
|
+
// is a folder nobody may delete a file out of either. Handing them back first is what stops
|
|
598
|
+
// the corpus leaving its own wreckage in the temp folder — or, as it did the first time this
|
|
599
|
+
// case ran, taking the whole self-check down with an unlink it was not allowed to perform.
|
|
600
|
+
await relax(workDir);
|
|
601
|
+
if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
392
602
|
|
|
393
603
|
const untellable = cases.some((r) => r.verdict === 'could not tell');
|
|
604
|
+
const notTestableHere = cases.filter((r) => r.verdict === 'not testable here').length;
|
|
394
605
|
return {
|
|
395
606
|
passed: cases.length > 0 && cases.every((r) => r.caught),
|
|
396
607
|
ran: true,
|
|
397
608
|
certain: !untellable,
|
|
609
|
+
...(notTestableHere > 0 ? { notTestableHere } : {}),
|
|
398
610
|
cases,
|
|
399
611
|
...(opts.keep ? { workDir } : {}),
|
|
400
612
|
};
|
|
@@ -423,6 +635,21 @@ async function runOne(check, workDir, c, attempt) {
|
|
|
423
635
|
return { name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` };
|
|
424
636
|
}
|
|
425
637
|
|
|
638
|
+
if (c.prepare) {
|
|
639
|
+
/** @type {{ready: boolean, why: string}} */
|
|
640
|
+
let ready;
|
|
641
|
+
try {
|
|
642
|
+
ready = await c.prepare(dir, working);
|
|
643
|
+
} catch (e) {
|
|
644
|
+
return { name: c.name, caught: false, verdict: 'could not run', why: `the machine could not be set up for this one: ${why(e)}` };
|
|
645
|
+
}
|
|
646
|
+
if (!ready.ready) {
|
|
647
|
+
// Not a pass. `caught` is true only so one machine's limits cannot be read as the
|
|
648
|
+
// engine having broken — the count is reported separately and out loud.
|
|
649
|
+
return { name: c.name, caught: true, verdict: 'not testable here', why: ready.why };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
426
653
|
/** @type {any} */
|
|
427
654
|
let result;
|
|
428
655
|
try {
|
|
@@ -444,6 +671,26 @@ async function runOne(check, workDir, c, attempt) {
|
|
|
444
671
|
return judge(c, result);
|
|
445
672
|
}
|
|
446
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Give every folder under here its write permission back.
|
|
676
|
+
*
|
|
677
|
+
* @param {string} dir
|
|
678
|
+
* @returns {Promise<void>}
|
|
679
|
+
*/
|
|
680
|
+
async function relax(dir) {
|
|
681
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
682
|
+
let inside = [];
|
|
683
|
+
try {
|
|
684
|
+
await fsp.chmod(dir, 0o755);
|
|
685
|
+
inside = await fsp.readdir(dir, { withFileTypes: true });
|
|
686
|
+
} catch {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
for (const entry of inside) {
|
|
690
|
+
if (entry.isDirectory()) await relax(path.join(dir, entry.name));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
447
694
|
/** How busy this machine is, in words, so an untellable result can name the likely reason. */
|
|
448
695
|
function loadNow() {
|
|
449
696
|
const [one] = os.loadavg();
|
|
@@ -480,6 +727,15 @@ function judge(c, result) {
|
|
|
480
727
|
if (c.expect === 'no answer') {
|
|
481
728
|
const said = String(result?.summary ?? '');
|
|
482
729
|
if (result?.ok === false && /no answer|not a pass/i.test(said)) {
|
|
730
|
+
for (const pattern of c.summaryMustSay ?? []) {
|
|
731
|
+
if (pattern.test(said)) continue;
|
|
732
|
+
return {
|
|
733
|
+
name: c.name,
|
|
734
|
+
caught: false,
|
|
735
|
+
verdict: 'escaped',
|
|
736
|
+
why: `it refused to call the run clean, and never said what it owed the reader (${pattern}): ${said.slice(0, 300)}`,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
483
739
|
return { name: c.name, caught: true, verdict: 'said it could not tell' };
|
|
484
740
|
}
|
|
485
741
|
return {
|
|
@@ -494,7 +750,19 @@ function judge(c, result) {
|
|
|
494
750
|
}
|
|
495
751
|
|
|
496
752
|
if (c.expect === 'nothing') {
|
|
497
|
-
if (findings.length === 0 && unstable.length === 0)
|
|
753
|
+
if (findings.length === 0 && unstable.length === 0) {
|
|
754
|
+
const said = String(result?.summary ?? '');
|
|
755
|
+
for (const pattern of c.summaryMustSay ?? []) {
|
|
756
|
+
if (pattern.test(said)) continue;
|
|
757
|
+
return {
|
|
758
|
+
name: c.name,
|
|
759
|
+
caught: false,
|
|
760
|
+
verdict: 'escaped',
|
|
761
|
+
why: `it was rightly silent about the product and never said what it owed the reader (${pattern}). It said: ${said.slice(0, 300)}`,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
return { name: c.name, caught: true, verdict: 'quiet' };
|
|
765
|
+
}
|
|
498
766
|
// `unstable` holds WobbleEntry objects, not strings. Interpolating one printed
|
|
499
767
|
// "[object Object]" and turned the most important line in a failure report — the one
|
|
500
768
|
// saying WHAT went wrong — into nothing at all.
|
|
@@ -537,6 +805,33 @@ function judge(c, result) {
|
|
|
537
805
|
};
|
|
538
806
|
}
|
|
539
807
|
|
|
808
|
+
// Reported is not the same as reported to the right person. A crash or a charge filed
|
|
809
|
+
// `ordinary` is a finding an agent may close on its own, so the break reaches nobody —
|
|
810
|
+
// loudly written down and quietly waived is still quiet.
|
|
811
|
+
if (c.mustBeSealed) {
|
|
812
|
+
const sealed = matching.filter((/** @type {any} */ f) => f.class === c.mustBeSealed);
|
|
813
|
+
if (sealed.length === 0) {
|
|
814
|
+
const classes = [...new Set(matching.map((/** @type {any} */ f) => String(f.class ?? 'unlabelled')))];
|
|
815
|
+
return {
|
|
816
|
+
name: c.name,
|
|
817
|
+
caught: false,
|
|
818
|
+
verdict: 'escaped',
|
|
819
|
+
why: `it found this and filed it as ${classes.join(' and ')} rather than "${c.mustBeSealed}", so an agent is allowed to close it without anybody being told. ${describe(matching[0])}`,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const said = String(result?.summary ?? '');
|
|
825
|
+
for (const pattern of c.summaryMustSay ?? []) {
|
|
826
|
+
if (pattern.test(said)) continue;
|
|
827
|
+
return {
|
|
828
|
+
name: c.name,
|
|
829
|
+
caught: false,
|
|
830
|
+
verdict: 'escaped',
|
|
831
|
+
why: `it found the break, and the paragraph a person actually reads never said what it owed them (${pattern}). It said: ${said.slice(0, 300)}`,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
540
835
|
return { name: c.name, caught: true, verdict: 'caught' };
|
|
541
836
|
}
|
|
542
837
|
|
|
@@ -583,7 +878,7 @@ async function plant(dir, c) {
|
|
|
583
878
|
await git(dir, ['config', 'user.name', 'Stays Fixed self-check']);
|
|
584
879
|
|
|
585
880
|
await writeAll(dir, c.build(false));
|
|
586
|
-
await fsp.writeFile(path.join(dir, 'journeys.json'), JSON.stringify(journeysFor(c), null, 2) + '\n');
|
|
881
|
+
await fsp.writeFile(path.join(dir, 'journeys.json'), JSON.stringify(c.journeys ?? journeysFor(c), null, 2) + '\n');
|
|
587
882
|
await fsp.writeFile(path.join(dir, '.gitignore'), 'out/\n');
|
|
588
883
|
await git(dir, ['add', '-A']);
|
|
589
884
|
await git(dir, ['commit', '-q', '-m', 'the build that works']);
|
|
@@ -696,12 +991,22 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
696
991
|
/** @type {string[]} */
|
|
697
992
|
const out = ['Stays Fixed - checking that it can still catch things', ''];
|
|
698
993
|
for (const r of result.cases) {
|
|
699
|
-
|
|
700
|
-
|
|
994
|
+
const word = r.verdict === 'not testable here' ? 'n/a' : r.caught ? 'ok' : 'FAILED';
|
|
995
|
+
out.push(`${word.padEnd(8)} ${r.name}`);
|
|
996
|
+
if (r.verdict === 'not testable here') out.push(` not tested on this machine: ${r.why ?? 'no reason recorded'}`);
|
|
997
|
+
else if (!r.caught) out.push(` ${r.why ?? 'it did not behave, and said nothing useful about why'}`);
|
|
701
998
|
}
|
|
702
999
|
out.push('');
|
|
1000
|
+
const na = result.notTestableHere ?? 0;
|
|
1001
|
+
const tested = result.cases.length - na;
|
|
703
1002
|
if (result.passed) {
|
|
704
|
-
|
|
1003
|
+
// The count that is claimed is the count that was actually run. Folding a case this
|
|
1004
|
+
// machine could not perform into "all of them behaved" would be the corpus telling the
|
|
1005
|
+
// same kind of lie it exists to catch.
|
|
1006
|
+
out.push(`All ${tested} behaved: every break was caught, and every pair that should have been silent was silent.`);
|
|
1007
|
+
if (na > 0) {
|
|
1008
|
+
out.push(`${na} more could not be set up on this machine and ${na === 1 ? 'was' : 'were'} not tested at all — see the n/a ${na === 1 ? 'line' : 'lines'} above. That is neither a pass nor a failure.`);
|
|
1009
|
+
}
|
|
705
1010
|
} else if (untellable.length > 0 && untellable.length === result.cases.filter((r) => !r.caught).length) {
|
|
706
1011
|
// Nothing failed twice. Saying "wrong" here would be an accusation the evidence does not
|
|
707
1012
|
// support, and saying "fine" would be worse.
|