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.
@@ -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
- if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true });
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) return { name: c.name, caught: true, verdict: 'quiet' };
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
- 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'}`);
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
- out.push(`All ${result.cases.length} behaved: every break was caught, and every pair that should have been silent was silent.`);
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.