staysfixed 0.4.0 → 0.6.1

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.
@@ -56,7 +56,7 @@ const run = promisify(execFile);
56
56
  * @typedef {object} Case
57
57
  * @property {string} name A sentence, because it is read back as one.
58
58
  * @property {string} breaks What is wrong, in plain English.
59
- * @property {'a finding'|'nothing'} expect
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
62
  * @property {(broken: boolean) => Record<string, string>} build
@@ -210,6 +210,46 @@ export const CASES = [
210
210
  }),
211
211
  },
212
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
+
213
253
  {
214
254
  name: 'a value that used to be steady is now random',
215
255
  breaks:
@@ -226,6 +266,28 @@ export const CASES = [
226
266
  'cli.js': [broken ? 'console.log(`batch id ${Math.floor(Math.random() * 1e9)}`);' : 'console.log(`batch id 4242`);', "console.log('two orders');", ''].join('\n'),
227
267
  }),
228
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
+ },
229
291
  ];
230
292
 
231
293
  // ---------------------------------------------------------------------------
@@ -237,7 +299,7 @@ export const CASES = [
237
299
  * @property {string} name
238
300
  * @property {boolean} caught True when the case behaved: the break was found, or the clean pair stayed silent.
239
301
  * @property {string} [why] Why it did not, in one plain sentence.
240
- * @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'} verdict
302
+ * @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'|'could not tell'|'said it could not tell'} verdict
241
303
  */
242
304
 
243
305
  /**
@@ -245,6 +307,8 @@ export const CASES = [
245
307
  * @property {boolean} passed
246
308
  * @property {CaseResult[]} cases
247
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.
248
312
  * @property {string} [why] Why it could not run.
249
313
  * @property {string} [workDir]
250
314
  */
@@ -287,48 +351,108 @@ export async function selfcheck(opts = {}) {
287
351
  const cases = [];
288
352
 
289
353
  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)}` });
354
+ const first = await runOne(check, workDir, c, 1);
355
+ if (first.caught) {
356
+ cases.push(first);
297
357
  continue;
298
358
  }
299
359
 
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)}` });
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)` });
316
377
  continue;
317
378
  }
318
-
319
- cases.push(judge(c, result));
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
+ });
320
389
  }
321
390
 
322
391
  if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true });
323
392
 
393
+ const untellable = cases.some((r) => r.verdict === 'could not tell');
324
394
  return {
325
395
  passed: cases.length > 0 && cases.every((r) => r.caught),
326
396
  ran: true,
397
+ certain: !untellable,
327
398
  cases,
328
399
  ...(opts.keep ? { workDir } : {}),
329
400
  };
330
401
  }
331
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
+
332
456
  /**
333
457
  * Did the engine do what this case demands?
334
458
  *
@@ -349,9 +473,35 @@ function judge(c, result) {
349
473
  const findings = Array.isArray(result?.findings) ? result.findings : [];
350
474
  const unstable = Array.isArray(result?.newlyUnstable) ? result.newlyUnstable : [];
351
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
+
352
496
  if (c.expect === 'nothing') {
353
497
  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]}`;
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('; ')}`;
355
505
  return { name: c.name, caught: false, verdict: 'false alarm', why: `two builds that should have looked the same produced ${what}` };
356
506
  }
357
507
 
@@ -532,13 +682,16 @@ export async function main(argv = process.argv.slice(2)) {
532
682
 
533
683
  if (json) {
534
684
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
535
- return result.passed ? 0 : result.ran ? 1 : 2;
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;
536
688
  }
537
689
 
538
690
  if (!result.ran) {
539
691
  process.stderr.write(`Could not run the self-check.\n${result.why ?? ''}\n`);
540
692
  return 2;
541
693
  }
694
+ const untellable = result.cases.filter((r) => r.verdict === 'could not tell');
542
695
 
543
696
  /** @type {string[]} */
544
697
  const out = ['Stays Fixed - checking that it can still catch things', ''];
@@ -549,14 +702,26 @@ export async function main(argv = process.argv.slice(2)) {
549
702
  out.push('');
550
703
  if (result.passed) {
551
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
+ );
552
712
  } 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.`);
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.`);
555
716
  }
556
717
  if (result.workDir) out.push(`The products were left in ${result.workDir}.`);
557
718
 
558
719
  process.stdout.write(out.join('\n') + '\n');
559
- return result.passed ? 0 : 1;
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;
560
725
  }
561
726
 
562
727
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
package/src/v2/types.js CHANGED
@@ -235,6 +235,12 @@
235
235
  * False when we had no stability record for the reference,
236
236
  * so `newlyUnstable` is empty for lack of evidence rather
237
237
  * than because nothing became unstable.
238
+ * @property {boolean} [couldNotTell] True when the wobble measurement was too big to be a
239
+ * measurement — the same build answered differently at
240
+ * most of its own addresses, so subtracting it subtracts
241
+ * the answer. A run in this state has no verdict, and it
242
+ * must never be reported as a clean one.
243
+ * @property {string} [couldNotTellWhy] Said plainly, with the numbers in it.
238
244
  * @property {string} note One plain sentence stating exactly that.
239
245
  */
240
246