staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/src/v2/check.js CHANGED
@@ -36,13 +36,13 @@ import { warn, detail } from '../core/log.js';
36
36
  import { findConfigFile, rootForConfig } from '../core/paths.js';
37
37
  import { sha256 } from '../core/hash.js';
38
38
 
39
- import { openStore, ensureStore, saveBuild, newCaptureId, storeExists } from './store.js';
39
+ import { openStore, ensureStore, saveBuild, newCaptureId, storeExists, referenceFor, listBuilds, pruneBuild, removeBuild, sweepIncomplete } from './store.js';
40
40
  import { decide, noDecisions, readDecisions, rememberCheck, readCheckRecord } from './escalate.js';
41
41
  import { sortObservations } from './observation.js';
42
42
  import { DEFAULT_RULES, machineRules, mergeRules, normaliseCapture, loadRules } from './normalise.js';
43
43
  import { runCheck, makeCheckEvents } from './run.js';
44
44
  import { proveCause } from './cause.js';
45
- import { whatChanged } from './rank.js';
45
+ import { whatChanged, NOT_THE_TOOLS_OWN_FOLDER } from './rank.js';
46
46
 
47
47
  import { attachWatcher, watchOptionsFrom } from './watch/index.js';
48
48
  import { guardTheScreen, describeGuard } from './watch/focus.js';
@@ -95,7 +95,7 @@ const exec = promisify(execFile);
95
95
  * quietly checked something else and found nothing", and those two read identically
96
96
  * without this. It is only ever set when the run really did reach that surface.
97
97
  *
98
- * @typedef {Verdict & {blocked?: boolean, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
98
+ * @typedef {Verdict & {blocked?: boolean, comparedNothing?: 'no reference'|'no stored record'|null, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
99
99
  */
100
100
 
101
101
  /**
@@ -109,7 +109,8 @@ const exec = promisify(execFile);
109
109
  * @property {string} [against] A commit, tag or stored build to compare against.
110
110
  * @property {boolean} [paired] Boot the old build live from the start.
111
111
  * @property {boolean} [storedOnly] Never boot the old build, not even to prove a suspicion.
112
- * @property {string} [journeys] A path to a journeys file, or 'code' / 'config'.
112
+ * @property {string} [journeys] A path to a journeys file, or 'code' / 'config' / 'suite'.
113
+ * 'suite' RUNS the project's own tests — see gatherJourneys.
113
114
  * @property {Surface|'auto'} [surface] Aim the whole run at one kind of product.
114
115
  * @property {string} [at] Where that product is: a URL for the web, the built app
115
116
  * for a desktop, an APK or an .app bundle for a phone.
@@ -314,8 +315,31 @@ export async function check(options = {}) {
314
315
  // "nothing was walked" on a run that walked plenty.
315
316
  await countTheDoors(verdict, project);
316
317
 
318
+ // THE GUARDS. `settle` takes the names so that a difference touching a bug somebody
319
+ // already had once is sealed and no agent may wave it through — and both call sites used
320
+ // to hand it nothing, so that class was empty on every run this tool has ever done. The
321
+ // one seal that exists because a person was burned before could never fire.
322
+ //
323
+ // Running them is version 1's job and needs its browser driver, which this path does not
324
+ // have. That is a real hole and it is now SAID rather than left silent: measured on
325
+ // 2026-08-30, a check on a project with a guard sitting in `.staysfixed/guards` printed
326
+ // the word "guard" exactly zero times. A tool built to catch silent breakage must not do
327
+ // nothing silently.
328
+ const named = await guardNames(project.root);
329
+ if (named.length > 0 && verdict.coverage) {
330
+ verdict.coverage.gaps = [
331
+ ...(verdict.coverage.gaps ?? []),
332
+ {
333
+ what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
334
+ why:
335
+ `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check. ` +
336
+ `\`staysfixed check --guards\` walks them. ${named.map((n) => `"${n}"`).join(', ')}`,
337
+ },
338
+ ];
339
+ }
340
+
317
341
  /** @type {CheckOutcome} */
318
- const outcome = await settle(verdict, project.store, project.product);
342
+ const outcome = await settle(verdict, project.store, project.product, named);
319
343
  // Only a run that really did reach the surface it was aimed at may say so. The
320
344
  // confirmation is what lets a caller tell "it went there and found nothing" from
321
345
  // "it checked something else and found nothing", and those are not the same answer.
@@ -326,6 +350,12 @@ export async function check(options = {}) {
326
350
  // the coverage sentence went on the end. Left there, a window would show a greener,
327
351
  // shorter answer than the terminal beside it, and the two would disagree about the same
328
352
  // run. So it is told again, with the settled one, and only then put away.
353
+ // Housekeeping, after the answer is settled and never before it. It can only ever remove
354
+ // recordings of builds nothing is comparing against, and if it says anything at all it
355
+ // says it on the same summary as everything else.
356
+ const tidied = await tidyTheStore(project, outcome);
357
+ if (tidied) outcome.summary = `${outcome.summary} ${tidied}`;
358
+
329
359
  events.emit({ type: 'check:done', at: events.elapsed(), message: outcome.summary, verdict: outcome });
330
360
  if (screen) {
331
361
  await screen.finish();
@@ -333,7 +363,11 @@ export async function check(options = {}) {
333
363
  }
334
364
  return outcome;
335
365
  } catch (e) {
336
- const outcome = blocked(options, e);
366
+ // A store that would not take this run's records is very often the whole reason the
367
+ // run then failed — a reference named by a commit cannot be resolved if nothing could
368
+ // register it — and without this the person is handed a bare permission error from a
369
+ // folder they have never heard of, with nothing joining the two facts up.
370
+ const outcome = blocked(options, e, project?.storeTrouble);
337
371
  // A run that never happened still has to reach a person, because "no answer" looks
338
372
  // exactly like "nothing changed" from the outside. It is only written down where a
339
373
  // store already exists: a check aimed at a folder that was never set up must not leave
@@ -391,12 +425,18 @@ export async function check(options = {}) {
391
425
  async function settle(verdict, store, product, guards) {
392
426
  /** @type {import('./escalate.js').Decisions} */
393
427
  let decisions;
428
+ /** @type {string} */
429
+ let bookkeepingTrouble = '';
394
430
  try {
395
431
  decisions = await readDecisions(store, product);
396
- } catch {
432
+ } catch (e) {
397
433
  // Unreadable bookkeeping means nothing is accounted for, which reports MORE than it
398
- // should rather than less. That is the only safe direction for this to fail in.
434
+ // should rather than less. That is the only safe direction for this to fail in — and it
435
+ // is still a fact the reader needs. Without it the accounting says nothing was waived,
436
+ // which reads as "there were no waivers" rather than "nobody could read them", and an
437
+ // agent looking at a wall of findings it waived last week has no idea why they are back.
399
438
  decisions = noDecisions(product);
439
+ bookkeepingTrouble = `The record of what was already accounted for could not be read (${messageOf(e)}), so nothing was treated as accounted for. Anything you waived before is in this list again.`;
400
440
  }
401
441
 
402
442
  const decided = decide(verdict.findings ?? [], decisions, { guards: guards ?? [] });
@@ -414,9 +454,21 @@ async function settle(verdict, store, product, guards) {
414
454
  // that worked has changed". It is arithmetically true — nothing was compared, so
415
455
  // nothing came back different — and it is the exact sentence that would let a real
416
456
  // regression through. It is not a pass. It is no answer at all.
417
- if (comparedNothing(verdict.coverage)) {
457
+ const nothing = comparedNothing(verdict);
458
+ // Carried as a FIELD, not only folded into a sentence. Every other surface has to be
459
+ // able to ask this question without reading English: the MCP server rendered its own
460
+ // headline from the difference count alone, never looked at `ok`, and answered a
461
+ // machine "NOTHING UNACCOUNTED FOR. Everything that worked before still works" with
462
+ // `ok: true` and `isError: false` on a project that had nothing on record at all —
463
+ // while the terminal, on the same run, correctly said it was no answer. The agent is
464
+ // the reader that matters most here, and it was the one being told the untrue thing.
465
+ verdict.comparedNothing = nothing;
466
+ if (nothing) {
418
467
  verdict.ok = false;
419
- verdict.summary = `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
468
+ verdict.summary =
469
+ nothing === 'no reference'
470
+ ? `NOTHING WAS ACTUALLY COMPARED. There is no build of this product on record as working, so this run had nothing whatever to hold today's behaviour against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`
471
+ : `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
420
472
  }
421
473
 
422
474
  // And what was NOT looked at, in the same breath as the good news, on every run
@@ -426,11 +478,18 @@ async function settle(verdict, store, product, guards) {
426
478
  // already reads. It goes last so it is the thing left in the reader's head.
427
479
  verdict.summary = `${verdict.summary} ${whatWasNotChecked(verdict.coverage)}`;
428
480
  }
481
+ if (bookkeepingTrouble) verdict.summary = `${verdict.summary} ${bookkeepingTrouble}`;
429
482
 
430
483
  try {
431
484
  await rememberCheck(store, { product, verdict, decided });
432
- } catch {
433
- // Nothing here is worth failing a finished check over.
485
+ } catch (e) {
486
+ // Nothing here is worth failing a finished check over — but it is worth a sentence.
487
+ // This record is the only thing that knows what the finding ids in this reply mean, so
488
+ // when it is not written the agent's very next move, `staysfixed_explain <id>` or
489
+ // `staysfixed_prove <id>`, answers "the last check has no finding called that". Which
490
+ // is a flat denial of something it was handed thirty seconds earlier, and it used to
491
+ // arrive with nothing anywhere explaining why.
492
+ verdict.summary = `${verdict.summary} This run could not be written down (${messageOf(e)}), so asking to explain or to prove one of the ids above will say it has never heard of it.`;
434
493
  }
435
494
  return verdict;
436
495
  }
@@ -567,25 +626,123 @@ export function whatWasNotChecked(coverage) {
567
626
  /**
568
627
  * Was there anything on the other side to compare against at all?
569
628
  *
570
- * The engine records one gap per journey it had no stored record for. When that count
571
- * reaches every journey that was walked, the run compared nothing whatever — and a run
572
- * that compared nothing produces zero differences, which is indistinguishable from a
573
- * product that did not change.
629
+ * TWO WAYS TO COMPARE NOTHING, and only one of them was caught here.
574
630
  *
575
- * The gaps are recognised by the words the engine writes into them. A test walks a real
576
- * project into exactly this state and requires the verdict not to read as a pass, so if
577
- * those words are ever reworded the guard fails loudly instead of quietly switching off.
631
+ * The first is per journey: the engine records one gap for each journey it had no stored
632
+ * record for, and when that count reaches every journey walked, the run held nothing against
633
+ * anything. The gaps are recognised by the words the engine writes into them, and a test
634
+ * walks a real project into exactly this state, so a rewording fails loudly rather than
635
+ * quietly switching the guard off.
578
636
  *
579
- * @param {Coverage|undefined} coverage
580
- * @returns {boolean}
637
+ * The second is the COLD START, and it never records a per-journey gap at all, so this used
638
+ * to miss it completely. A product nobody has ever shipped with the hook in place has no
639
+ * reference: there is no old build, so there is no journey to be missing a record FOR. The
640
+ * run walked everything, compared none of it, found no differences, and set `ok: true`.
641
+ *
642
+ * The command line has always caught that one on its own and exits 2. `--json` and the MCP
643
+ * surface do not read the command line's arithmetic — they read this verdict — so the two
644
+ * interfaces an agent actually uses answered `ok: true` to "did I break anything" on a
645
+ * project where nothing had been compared. That is the exact failure this whole tool exists
646
+ * to prevent, produced by the tool, to a reader with no way of noticing.
647
+ *
648
+ * @param {CheckOutcome} verdict
649
+ * @returns {'no reference'|'no stored record'|null}
581
650
  */
582
- function comparedNothing(coverage) {
651
+ function comparedNothing(verdict) {
652
+ if (!verdict.reference || verdict.reference.id === '') return 'no reference';
653
+ const coverage = verdict.coverage;
583
654
  const walked = coverage?.journeys ?? 0;
584
- if (walked === 0) return false;
655
+ if (walked === 0) return null;
585
656
  const nothingToCompare = (coverage?.gaps ?? []).filter((gap) =>
586
657
  /never been walked against|no stored record of the old build/i.test(`${gap.what} ${gap.why}`),
587
658
  ).length;
588
- return nothingToCompare >= walked;
659
+ return nothingToCompare >= walked ? 'no stored record' : null;
660
+ }
661
+
662
+ /**
663
+ * The guards this project has, by name.
664
+ *
665
+ * Names only: sealing a difference by the guard it touches needs the name, and nothing on
666
+ * this path can run one. `loadGuards` wants only the folder, and it is the same reader
667
+ * version 1 uses, so a guard version 1 accepts is a guard this counts.
668
+ *
669
+ * @param {string} root
670
+ * @returns {Promise<string[]>}
671
+ */
672
+ export async function guardNames(root) {
673
+ try {
674
+ const { loadGuards } = await import('../guard/load.js');
675
+ const guards = await loadGuards(/** @type {any} */ ({ paths: { guards: path.join(root, '.staysfixed', 'guards') } }));
676
+ return guards.map((g) => String(g?.name ?? '')).filter(Boolean);
677
+ } catch {
678
+ // A guards folder that will not load must never stop a check running. Version 1 says the
679
+ // same about the same folder, and a check that refuses is worse than one without a seal.
680
+ return [];
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Copies left behind by runs that never finished.
686
+ *
687
+ * A check copies the whole project into a scratch folder, and a run that is killed — Ctrl-C,
688
+ * a laptop closing, a CI job cancelled — never gets to delete it. Nothing else ever did
689
+ * either: measured on 2026-08-30, an ordinary machine had 777 MB of `staysfixed-check-*`
690
+ * sitting in the temporary folder, one copy of it 485 MB, and a later run added to the pile
691
+ * rather than clearing it. A tool that quietly fills somebody's disk is not one they keep.
692
+ *
693
+ * The rule is the one the browser sweep already uses: never touch something in use. A folder
694
+ * whose owner is still running is left completely alone, and one with no owner recorded is
695
+ * only taken once it is far older than any real run could be. A dead process id that has
696
+ * since been reused reads as "still running" and the folder survives, which is the safe way
697
+ * round to be wrong.
698
+ *
699
+ * @returns {Promise<void>}
700
+ */
701
+ export async function sweepAbandonedScratch() {
702
+ const AN_HOUR = 60 * 60 * 1000;
703
+ const MOST_PER_RUN = 20;
704
+ let names = [];
705
+ try {
706
+ names = await fsp.readdir(os.tmpdir());
707
+ } catch {
708
+ return;
709
+ }
710
+ let taken = 0;
711
+ for (const name of names) {
712
+ if (taken >= MOST_PER_RUN) break;
713
+ if (!name.startsWith('staysfixed-check-')) continue;
714
+ const dir = path.join(os.tmpdir(), name);
715
+ let abandoned = false;
716
+ try {
717
+ const owner = JSON.parse(await fsp.readFile(path.join(dir, 'owner.json'), 'utf8'));
718
+ abandoned = typeof owner?.pid !== 'number' || !processAlive(owner.pid);
719
+ } catch {
720
+ // No owner recorded: either an older copy or one that died before it could say. Age is
721
+ // all there is to go on, and an hour is well past the longest run this tool makes.
722
+ try {
723
+ abandoned = Date.now() - (await fsp.stat(dir)).mtimeMs > AN_HOUR;
724
+ } catch {
725
+ abandoned = false;
726
+ }
727
+ }
728
+ if (!abandoned) continue;
729
+ await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
730
+ taken += 1;
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Is that process still running? Signal 0 asks without sending anything.
736
+ * @param {number} pid
737
+ * @returns {boolean}
738
+ */
739
+ function processAlive(pid) {
740
+ try {
741
+ process.kill(pid, 0);
742
+ return true;
743
+ } catch {
744
+ return false;
745
+ }
589
746
  }
590
747
 
591
748
  /**
@@ -603,9 +760,12 @@ function plainly(what) {
603
760
  *
604
761
  * @param {CheckOptions} options
605
762
  * @param {unknown} e
763
+ * @param {string} [storeTrouble] What the store would not do earlier in this same run,
764
+ * when there was any. It goes in front of the error, because it usually IS the error's
765
+ * cause and the error on its own reads as something else entirely.
606
766
  * @returns {CheckOutcome}
607
767
  */
608
- function blocked(options, e) {
768
+ function blocked(options, e, storeTrouble) {
609
769
  const product = options.product ?? path.basename(path.resolve(options.cwd ?? options.root ?? process.cwd()));
610
770
  const empty = { id: '', product };
611
771
  return {
@@ -621,11 +781,16 @@ function blocked(options, e) {
621
781
  differencesReal: 0,
622
782
  differencesNoise: 0,
623
783
  newlyUnstable: [],
624
- coverage: { paths: 0, journeys: 0, byChannel: {}, gaps: [{ what: 'Everything.', why: messageOf(e) }] },
784
+ coverage: {
785
+ paths: 0,
786
+ journeys: 0,
787
+ byChannel: {},
788
+ gaps: [{ what: 'Everything.', why: storeTrouble ? `${storeTrouble} ${messageOf(e)}` : messageOf(e) }],
789
+ },
625
790
  // The hint is the half that tells a person what to DO about it, and dropping it
626
791
  // turns a helpful error into a dead end. Anything that blocks a run has to carry
627
792
  // both halves all the way out to whoever reads the summary.
628
- summary: `The check could not be run, so this is not a pass and not a failure. ${messageOf(e)}${
793
+ summary: `The check could not be run, so this is not a pass and not a failure. ${storeTrouble ? `${storeTrouble} ` : ''}${messageOf(e)}${
629
794
  e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
630
795
  }`,
631
796
  durationMs: 0,
@@ -662,7 +827,10 @@ export async function prove(options = {}) {
662
827
 
663
828
  const project = await openProject(options);
664
829
  try {
665
- const changed = await whatChanged(project.root);
830
+ // From the commit the old build is at, not from the working tree. Without this, an agent
831
+ // that committed its work before asking to prove a cause was told there was no change to
832
+ // undo — about a change sitting one commit back, which git can hand over exactly.
833
+ const changed = await whatChanged(project.root, { since: project.referenceSha });
666
834
  const wanted = (options.revert ?? []).map((f) => f.replace(/^\.\//, ''));
667
835
  const narrowed = wanted.length
668
836
  ? { ...changed, hunks: changed.hunks.filter((h) => wanted.some((w) => h.file === w || h.file.startsWith(`${w}/`))) }
@@ -720,7 +888,14 @@ export async function explain(options = {}) {
720
888
  else out.push(` ${d.path} — was ${short(d.reference)}, now ${short(d.candidate)}`);
721
889
  }
722
890
  if (differences.length > 40) out.push(` and ${differences.length - 40} more.`);
723
- if (f.nearFiles?.length) out.push('', `Nearest code: ${f.nearFiles.slice(0, 6).join(', ')}.`);
891
+ // The count goes on the end when the list was cut. Six file names with nothing after them
892
+ // read as the whole list, so an agent that opened all six believed it had seen everywhere
893
+ // this finding lives — and the file it needed was the seventh.
894
+ if (f.nearFiles?.length) {
895
+ const shown = f.nearFiles.slice(0, 6);
896
+ const more = f.nearFiles.length - shown.length;
897
+ out.push('', `Nearest code: ${shown.join(', ')}${more > 0 ? `, and ${more} more ${more === 1 ? 'file' : 'files'} this finding also comes from` : ''}.`);
898
+ }
724
899
  // No full stop of our own: the reason is already a whole sentence and adding one gave the
725
900
  // agent "...costs a real person real money.." on the reply it reads when it is trying to
726
901
  // understand something it is not allowed to waive.
@@ -1009,11 +1184,20 @@ async function waitForItsWindow(pid, stopped) {
1009
1184
  * @property {import('./types.js').Store} store
1010
1185
  * @property {BuildFingerprint} candidate
1011
1186
  * @property {string} [against] The reference build's own id, once a name has been resolved.
1187
+ * @property {number} keepBuilds How many builds of this product other than the reference keep
1188
+ * their full record. Everything older is thinned out at the end of a run.
1189
+ * @property {string} [referenceSha] The commit the build you were happy with is at. It is
1190
+ * what makes a COMMITTED change measurable: the change from that commit to the working
1191
+ * tree is the change, and reading the working tree alone goes blind the moment an agent
1192
+ * commits its work — which is what an agent does at the end of a task.
1012
1193
  * @property {Journey[]} journeys
1013
1194
  * @property {CoverageGap[]} gaps Holes found while working out WHAT to walk, before a
1014
1195
  * single journey ran. An adapter that fell over listing its journeys belongs here, and it
1015
1196
  * has to reach the verdict: a channel that silently dropped out is the worst thing this
1016
1197
  * tool can do.
1198
+ * @property {string} storeTrouble Empty on a normal run. A plain sentence when the store
1199
+ * would not take this run's records — the run went ahead anyway, and every answer it
1200
+ * produces has to carry the admission that nothing about it was kept.
1017
1201
  * @property {import('./run.js').Walker} walk
1018
1202
  * @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
1019
1203
  * @property {(capture: Capture) => Capture} normalise
@@ -1035,6 +1219,254 @@ function projectRootFor(options) {
1035
1219
  return config ? rootForConfig(config) : from;
1036
1220
  }
1037
1221
 
1222
+ /**
1223
+ * How many builds keep their whole record before the old ones are thinned out.
1224
+ *
1225
+ * A setting rather than a constant, because a project running fifty checks a day and one
1226
+ * running three a week want different answers and neither of them is wrong. Five is the
1227
+ * default: enough that `--against` and `staysfixed_prove` can still reach back over a few
1228
+ * commits, small enough that a record committed into somebody's repository stops growing.
1229
+ */
1230
+ const KEEP_BUILDS = 5;
1231
+
1232
+ /**
1233
+ * The fewest captures any build keeps. One is still a stored record a later check can be
1234
+ * compared against; nought would delete the evidence that the build ever ran.
1235
+ */
1236
+ const KEEP_CAPTURES_PER_JOURNEY = 1;
1237
+
1238
+ /**
1239
+ * THE RETENTION POLICY, and why it is a count rather than an age.
1240
+ *
1241
+ * Thinning was never enough on its own. `pruneBuild` throws away captures INSIDE a build and
1242
+ * can never remove the folder, so the thing that actually grows — one build folder per check,
1243
+ * in a directory this tool asks people to commit — grew anyway. Nine folders in one afternoon
1244
+ * on a throwaway project, and nothing in the tool could ever have removed one.
1245
+ *
1246
+ * So there are three tiers, and this is the third:
1247
+ *
1248
+ * 1. The newest `keepBuilds` keep every recording they took. This is the working set —
1249
+ * `--against` and `staysfixed_prove` reach back over these.
1250
+ * 2. Everything behind them is thinned to one recording per journey. The build is still
1251
+ * there and can still be compared against; it just stops holding every take.
1252
+ * 3. Past `keepBuilds` times this number, the folder goes altogether — oldest first.
1253
+ *
1254
+ * WHY NOT AN AGE. An age bounds nothing. A project checked on every commit writes fifty
1255
+ * folders a day, so thirty days of them is fifteen hundred folders in somebody's git history
1256
+ * — and the same rule on a project checked twice a week deletes a two-month-old build that is
1257
+ * the only other thing in the store. The count is what grows, so the count is what is capped.
1258
+ *
1259
+ * WHY A SECOND TIER AT ALL, rather than removing everything past `keepBuilds`. The thinned
1260
+ * tier IS the grace period, and it is a cheap one: a thinned build holds one recording per
1261
+ * journey, so fifteen of them cost about what one untouched build costs. It buys back the
1262
+ * case this policy would otherwise get wrong — somebody who ran thirty checks in an afternoon
1263
+ * and then wants `--against` on the build from before lunch.
1264
+ *
1265
+ * Four, so a `keepBuilds` of five means at most twenty folders of this product, and the
1266
+ * sentence a person reads has one number in it rather than two.
1267
+ */
1268
+ const KEEP_THINNED_MULTIPLE = 4;
1269
+
1270
+ /**
1271
+ * @param {Record<string, any>} config
1272
+ * @returns {number}
1273
+ */
1274
+ function keepBuildsFrom(config) {
1275
+ const asked = Number(config?.keepBuilds);
1276
+ return Number.isFinite(asked) && asked >= 1 ? Math.floor(asked) : KEEP_BUILDS;
1277
+ }
1278
+
1279
+ /**
1280
+ * How long the test-suite harvest gets, out of the settings file: `suite: {budgetMs}`.
1281
+ *
1282
+ * Ninety seconds is the right DEFAULT — it is a statement about how long anybody waits inside
1283
+ * an edit-and-check loop before switching the tool off — but it was also the only answer
1284
+ * available. A project whose suite takes four minutes got ninety seconds and the rest of its
1285
+ * files named as gaps, with no way anywhere in the tool to say "I am willing to wait". A
1286
+ * limit that decides something has to be visible and, where it safely can be, adjustable.
1287
+ *
1288
+ * Zero means no budget: harvest every file however long it takes. That is a thing to ask for
1289
+ * on purpose and it is never a default.
1290
+ *
1291
+ * Nothing said comes back as null rather than as the default, so the number lives in one
1292
+ * place — `DEFAULT_HARVEST_BUDGET_MS`, in the file that applies it — instead of being copied
1293
+ * here where the two could drift.
1294
+ *
1295
+ * Exported so the reading of the setting can be tested on its own. Everything downstream of
1296
+ * it costs a real test suite being run twice, and a setting nobody can check the reading of
1297
+ * is a setting that will one day quietly stop being read.
1298
+ *
1299
+ * @param {Record<string, any>} config
1300
+ * @returns {number|null} Null when the settings say nothing, or say something that is not a
1301
+ * number of milliseconds.
1302
+ */
1303
+ export function suiteBudgetFrom(config) {
1304
+ const asked = Number(config?.suite?.budgetMs);
1305
+ return Number.isFinite(asked) && asked >= 0 ? Math.floor(asked) : null;
1306
+ }
1307
+
1308
+ /**
1309
+ * Thin out the record of builds nobody is going to ask about again.
1310
+ *
1311
+ * WHY THIS EXISTS AT ALL. `.staysfixed/` is deliberately kept in git — the record of what
1312
+ * working means is the promise, and a fresh checkout with no record has nothing to compare
1313
+ * against. That decision is right and it has a bill attached: one build folder per check,
1314
+ * for ever, inside somebody's repository history. Measured on a throwaway Express project
1315
+ * after about ten checks: 492KB across 118 files and nine build folders in one afternoon.
1316
+ *
1317
+ * WHAT IS NEVER TOUCHED. The build this product calls working — `pruneBuild` and `removeBuild`
1318
+ * both refuse it outright and this refuses it again before asking. The build just walked. The
1319
+ * build named by `--against`, because somebody is plainly still using it. And the newest few
1320
+ * after that. See `KEEP_THINNED_MULTIPLE` for what happens to everything else, and why.
1321
+ *
1322
+ * WHEN IT DOES NOT RUN. On a blocked run there is no answer to trust and nothing is touched.
1323
+ * And, most importantly, when the list of builds came back with anything missing from it: a
1324
+ * damaged record is now reported rather than silently omitted, and deciding what is old on a
1325
+ * list that is short is how the evidence for "this used to work" gets deleted. That holds for
1326
+ * the removal tier above all — a folder that is thinned can be walked again, a folder that is
1327
+ * gone cannot. It is said out loud rather than skipped quietly, because a housekeeping step
1328
+ * that stops running is exactly the sort of thing nobody notices for a year.
1329
+ *
1330
+ * @param {Project} project
1331
+ * @param {CheckOutcome} outcome
1332
+ * @returns {Promise<string>} One plain sentence when something was removed or when it was
1333
+ * deliberately not attempted. Empty when there was simply nothing to do.
1334
+ */
1335
+ async function tidyTheStore(project, outcome) {
1336
+ /** @type {string[]} */
1337
+ const said = [];
1338
+
1339
+ // A run clears up after itself first, and before the `blocked` gate, because a run that was
1340
+ // blocked is exactly the kind that died holding a half-written file. Scoped to the build
1341
+ // this run just wrote: clearing up after itself must never reach into another product's
1342
+ // folder in the same store. They are invisible — nothing reads a `.part` file — and an
1343
+ // invisible pile of half-written megabytes is how a tool gets blamed for a full disk.
1344
+ try {
1345
+ const swept = await sweepIncomplete(project.store, { buildId: project.candidate.id, olderThanMs: 0 });
1346
+ if (swept.removed > 0) {
1347
+ said.push(`${swept.removed} half-written ${swept.removed === 1 ? 'file' : 'files'} left behind by an earlier run that died were cleared away.`);
1348
+ }
1349
+ } catch (e) {
1350
+ // Never fatal. A folder that will not be swept costs disk; losing the verdict over it
1351
+ // would cost the whole run.
1352
+ said.push(`Half-written files from earlier runs could not be cleared away: ${messageOf(e)}`);
1353
+ }
1354
+
1355
+ if (outcome.blocked === true) return said.join(' ');
1356
+
1357
+ /** @type {string[]} */
1358
+ const problems = [];
1359
+ /** @type {import('./types.js').BuildRecord[]} */
1360
+ let builds;
1361
+ try {
1362
+ builds = await listBuilds(project.store, { product: project.product, onProblem: (m) => problems.push(m) });
1363
+ } catch (e) {
1364
+ said.push(`The stored record could not be listed, so nothing old was cleared out of it: ${messageOf(e)}`);
1365
+ return said.join(' ');
1366
+ }
1367
+ if (problems.length > 0) {
1368
+ said.push(`Nothing old was cleared out of the stored record this time. ${problems.join(' ')} Deciding what is old from a list that is missing something is how the evidence for "this used to work" gets deleted, so it was not attempted.`);
1369
+ return said.join(' ');
1370
+ }
1371
+
1372
+ const spared = new Set([project.candidate.id]);
1373
+ if (project.against) spared.add(project.against);
1374
+ for (const record of builds) if (record.isReference) spared.add(record.fingerprint.id);
1375
+
1376
+ // `listBuilds` hands them back newest first, so the newest few survive the slice.
1377
+ const unspared = builds.filter((b) => !spared.has(b.fingerprint.id));
1378
+ const cap = project.keepBuilds * KEEP_THINNED_MULTIPLE;
1379
+ const thinning = unspared.slice(project.keepBuilds, cap);
1380
+ const doomed = unspared.slice(cap);
1381
+ if (thinning.length === 0 && doomed.length === 0) return said.join(' ');
1382
+
1383
+ let removed = 0;
1384
+ let thinned = 0;
1385
+ let folders = 0;
1386
+ let evidence = 0;
1387
+ /** @type {string[]} */
1388
+ const refused = [];
1389
+ for (const record of thinning) {
1390
+ try {
1391
+ const done = await pruneBuild(project.store, record.fingerprint.id, { keepPerJourney: KEEP_CAPTURES_PER_JOURNEY });
1392
+ if (done.removed > 0) {
1393
+ removed += done.removed;
1394
+ thinned += 1;
1395
+ }
1396
+ } catch (e) {
1397
+ // A build that will not be pruned is kept, which is the safe direction — and it is
1398
+ // still worth naming, because a store that quietly stops being tidied grows for ever.
1399
+ refused.push(`${record.fingerprint.id} (${messageOf(e)})`);
1400
+ }
1401
+ }
1402
+ // Oldest first, which is what `unspared` already is once the newest have been sliced off
1403
+ // the front. A removal that stops half way therefore leaves the NEWER of the old builds
1404
+ // standing, which is the direction anybody would choose if asked.
1405
+ for (const record of doomed.slice().reverse()) {
1406
+ try {
1407
+ const done = await removeBuild(project.store, record.fingerprint.id);
1408
+ folders += 1;
1409
+ evidence += done.captures;
1410
+ } catch (e) {
1411
+ refused.push(`${record.fingerprint.id} (${messageOf(e)})`);
1412
+ }
1413
+ }
1414
+
1415
+ if (removed > 0) {
1416
+ said.push(
1417
+ `${removed} old ${removed === 1 ? 'recording was' : 'recordings were'} cleared out of ${thinned} ${thinned === 1 ? 'build' : 'builds'} nobody is comparing against any more.`,
1418
+ );
1419
+ }
1420
+ if (folders > 0) {
1421
+ said.push(
1422
+ // "at most `cap`" counts only the ones this is allowed to touch. The build you called
1423
+ // working, the one just walked and anything named by --against are outside the count
1424
+ // entirely, so the sentence says so rather than quoting a number that is not the number
1425
+ // of folders on the disk.
1426
+ `${folders} ${folders === 1 ? 'build older than that was' : 'builds older than that were'} removed from the stored record altogether, taking ${evidence} ${evidence === 1 ? 'recording' : 'recordings'} with ${evidence === 1 ? 'it' : 'them'}: besides the ones never touched, this product keeps at most ${cap} builds, and ${folders === 1 ? 'that one had' : 'those had'} fallen off the end.`,
1427
+ );
1428
+ }
1429
+ if (removed > 0 || folders > 0) {
1430
+ said.push(`The build you were happy with, the one just walked and the newest ${project.keepBuilds} were left alone.`);
1431
+ }
1432
+ if (refused.length > 0) {
1433
+ said.push(`${refused.length} older ${refused.length === 1 ? 'build was' : 'builds were'} left as ${refused.length === 1 ? 'it is' : 'they are'} because ${refused.length === 1 ? 'it' : 'they'} could not be tidied: ${refused.join('; ')}`);
1434
+ }
1435
+ return said.join(' ');
1436
+ }
1437
+
1438
+ /**
1439
+ * The rules that rewrite the two folders this run happens to be using.
1440
+ *
1441
+ * TWO ROOTS, TWO IDS, and that is the whole of this function. `mergeRules` keys by rule id
1442
+ * and the later one wins, so calling `machineRules` twice for two different folders handed
1443
+ * the same id — `path.project-root` — to both, and the second one, the scratch copy, deleted
1444
+ * the first. The rule that was supposed to rewrite somebody's real checkout to `<project>`
1445
+ * was therefore not in the rule set on any run this tool has ever done, and an absolute path
1446
+ * under their actual project was compared literally.
1447
+ *
1448
+ * Both are wanted. A product's output carries paths under the real checkout AND under the
1449
+ * throwaway copy it is walked in, and neither of those is a fact about the product.
1450
+ *
1451
+ * Exported so the collision can be tested for directly. It came back the moment two folders
1452
+ * were normalised in one run, and it will come back again the moment there are three.
1453
+ *
1454
+ * @param {{root: string, scratch: string}} where
1455
+ * @returns {NormaliseRule[]}
1456
+ */
1457
+ export function pathRules(where) {
1458
+ return [
1459
+ ...machineRules({ root: where.root, home: os.homedir(), tmp: os.tmpdir() }),
1460
+ ...machineRules({ root: where.scratch }).map((rule) => ({
1461
+ ...rule,
1462
+ id: 'path.scratch-copy',
1463
+ what: 'The throwaway folder this run copied the build into to walk it.',
1464
+ why: 'It is a fresh temporary folder every run, so any path under it differs between two runs of the same build.',
1465
+ wouldHide: 'Nothing about the product. It only ever replaces a prefix this tool chose a moment ago.',
1466
+ })),
1467
+ ];
1468
+ }
1469
+
1038
1470
  /**
1039
1471
  * @param {CheckOptions} options
1040
1472
  * @returns {Promise<Project>}
@@ -1045,14 +1477,34 @@ async function openProject(options) {
1045
1477
  const configFile = options.configFile ?? findConfigFile(root) ?? null;
1046
1478
  const aim = aimOf(options);
1047
1479
  const config = aimAt(await readConfig(configFile), aim);
1048
- const product = options.product ?? String(config.product ?? (await packageName(root)) ?? path.basename(root));
1480
+ const fromPackage = await packageName(root);
1481
+ const product = options.product ?? String(config.product ?? fromPackage.name ?? path.basename(root));
1049
1482
 
1050
1483
  const store = openStore({ root });
1051
- await ensureStore(store);
1484
+ /** Everything about the store that would not work, in the words the reader gets. */
1485
+ /** @type {string[]} */
1486
+ const storeTrouble = [];
1487
+ // A damaged package.json only matters when it was going to be what named this product —
1488
+ // and then it matters a great deal, because the name is the key everything is stored
1489
+ // under. Said out loud rather than fixed silently: guessing the old name would be worse.
1490
+ // It is NOT store trouble: the run is written down perfectly well, just under a name
1491
+ // nothing else in this project's history uses.
1492
+ const namingTrouble =
1493
+ fromPackage.damaged !== '' && options.product === undefined && config.product === undefined
1494
+ ? fromPackage.damaged
1495
+ : '';
1496
+ try {
1497
+ await ensureStore(store);
1498
+ } catch (e) {
1499
+ storeTrouble.push(`The folder Stays Fixed keeps its records in could not be made: ${messageOf(e)}`);
1500
+ }
1052
1501
 
1502
+ await sweepAbandonedScratch();
1053
1503
  const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
1054
1504
  const evidenceDir = path.join(scratch, 'evidence');
1055
1505
  await fsp.mkdir(evidenceDir, { recursive: true });
1506
+ // Who this belongs to, so a later run can tell an abandoned copy from one in use.
1507
+ await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
1056
1508
 
1057
1509
  // Working out what there is to walk comes FIRST, before anything is asked of git. Somebody
1058
1510
  // standing in a folder they have not set up yet should be told to run `init`, not told
@@ -1079,18 +1531,76 @@ async function openProject(options) {
1079
1531
  }
1080
1532
 
1081
1533
  const candidate = await fingerprintWorkingTree(root, product);
1082
- await saveBuild(store, candidate);
1534
+ // BOOKKEEPING MAY NOT COST THE ANSWER. Until 2026-08-30 these two writes were unguarded,
1535
+ // so a store that would not take them — a full disk, a permission taken away, a read-only
1536
+ // checkout — stopped the check dead before it had walked a single thing. What that threw
1537
+ // away is much larger than what it protected: the run could have opened the product,
1538
+ // walked it twice, compared it against the old build and handed back a real answer, and
1539
+ // all that was really lost was the note saying it had happened.
1540
+ //
1541
+ // So a failure here is remembered and said out loud, and the run carries on. Where the
1542
+ // record turns out to have been load-bearing after all — a reference named by a commit
1543
+ // that nothing can now register — the run still ends blocked, but it ends blocked SAYING
1544
+ // the store is the reason, instead of handing somebody a bare permission error from a
1545
+ // folder they have never heard of.
1546
+ try {
1547
+ await saveBuild(store, candidate);
1548
+ } catch (e) {
1549
+ storeTrouble.push(`The record of the build you have could not be written: ${messageOf(e)}`);
1550
+ }
1083
1551
 
1084
1552
  // A name like "HEAD", "v0.13.0" or a branch is what a person types; the store only knows
1085
1553
  // builds. Turning the name into a commit here, and putting that commit in the store, is
1086
1554
  // what lets a check be aimed at any point in history without every commit having been
1087
1555
  // walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
1088
1556
  const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
1089
- if (reference) await saveBuild(store, reference);
1557
+ if (reference) {
1558
+ try {
1559
+ await saveBuild(store, reference);
1560
+ } catch (e) {
1561
+ storeTrouble.push(`The record of ${nameOfReference(reference, options.against)} could not be written, so a check aimed at it by name may find nothing on record to match: ${messageOf(e)}`);
1562
+ }
1563
+ }
1564
+
1565
+ // Which commit the build you were happy with is at. With `--against` it is the commit that
1566
+ // was just named; without it, it is whatever the store's own reference pointer holds, which
1567
+ // is the usual case because a reference is cut by a person shipping. Either way it is the
1568
+ // thing that makes a committed change measurable, and nothing here had ever asked for it.
1569
+ /** @type {string|undefined} */
1570
+ let referenceSha = reference?.gitSha ?? undefined;
1571
+ if (!referenceSha) {
1572
+ try {
1573
+ const record = await referenceFor(store, product);
1574
+ referenceSha = record?.fingerprint?.gitSha ?? undefined;
1575
+ } catch (e) {
1576
+ // A damaged reference pointer is loud elsewhere. Here it costs the ordering, not the run.
1577
+ storeTrouble.push(`Which build counts as working could not be read, so a change you have already committed cannot be measured: ${messageOf(e)}`);
1578
+ }
1579
+ }
1580
+
1581
+ // It goes in the coverage list because that is the one list every reader already meets —
1582
+ // the command line prints it, the build server's table prints it, and the closing sentence
1583
+ // counts it. A fact that only exists on a field somebody has to know to look for is a fact
1584
+ // most readers never meet.
1585
+ /** @type {CoverageGap[]} */
1586
+ const gaps = [...gathered.gaps];
1587
+ if (namingTrouble !== '') {
1588
+ gaps.push({
1589
+ what: `This run was recorded against a product called "${product}", which is the name of the folder rather than the name of the project.`,
1590
+ why: `${namingTrouble} Nothing else names this product either, so the folder name was used. Anything recorded under the name inside that file is a different product as far as this run is concerned, including the build you called working.`,
1591
+ unlockedBy: `Fix package.json, or put the name you want in your settings file as product: '<name>'. Until then every comparison starts from nothing.`,
1592
+ });
1593
+ }
1594
+ if (storeTrouble.length > 0) {
1595
+ gaps.push({
1596
+ what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
1597
+ why: `${storeTrouble.join(' ')} Whatever this run reports below still stands — the product was walked and compared exactly as usual — but none of it reached the disk.`,
1598
+ unlockedBy: 'Free some disk space, or fix the permissions on the .staysfixed folder, and run the check again.',
1599
+ });
1600
+ }
1090
1601
 
1091
1602
  const rules = mergeRules(DEFAULT_RULES, [
1092
- ...machineRules({ root, home: os.homedir(), tmp: os.tmpdir() }),
1093
- ...machineRules({ root: scratch }),
1603
+ ...pathRules({ root, scratch }),
1094
1604
  ...(await loadRules(path.join(root, '.staysfixed', 'rules.json'))),
1095
1605
  ]);
1096
1606
 
@@ -1117,9 +1627,12 @@ async function openProject(options) {
1117
1627
  product,
1118
1628
  store,
1119
1629
  candidate,
1630
+ keepBuilds: keepBuildsFrom(config),
1631
+ referenceSha,
1120
1632
  against: reference ? reference.id : options.against,
1121
1633
  journeys,
1122
- gaps: gathered.gaps,
1634
+ gaps,
1635
+ storeTrouble: storeTrouble.join(' '),
1123
1636
  walk,
1124
1637
  bootReference,
1125
1638
  normalise,
@@ -1401,9 +1914,63 @@ async function gatherJourneys({ root, config, options }) {
1401
1914
  /** @type {CoverageGap[]} */
1402
1915
  const gaps = [];
1403
1916
 
1404
- const named = options.journeys && options.journeys !== 'code' && options.journeys !== 'config' ? options.journeys : null;
1917
+ const named =
1918
+ options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
1405
1919
  if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
1406
1920
 
1921
+ // The project's own test suite, when somebody asked for it in those words and never
1922
+ // otherwise. This RUNS their tests — twice each, inside the same scratch clone everything
1923
+ // else uses, under a time budget — and that is a cost nobody gets charged by accident, so
1924
+ // it is off unless `--journeys suite` says so.
1925
+ //
1926
+ // It is worth switching on because it sees what walking a product cannot. On the fixture
1927
+ // where a total quietly stops rounding pennies, and the command line only ever adds whole
1928
+ // pounds, the discovered journeys produce nothing at all — the output does not move by one
1929
+ // character — and the harvested ones produce five findings.
1930
+ //
1931
+ // Loaded here rather than at the top of the file: a copy of this tool without the harvest
1932
+ // in it still runs every other kind of check, and saying so is better than failing to start.
1933
+ if (options.journeys === 'suite') {
1934
+ try {
1935
+ const { journeysFromSuite, DEFAULT_HARVEST_BUDGET_MS } = await import('./journeys/index.js');
1936
+ // The settings file gets a say in how long this is allowed to take. Left out, the
1937
+ // harvest applies its own default, which is why nothing is passed rather than the
1938
+ // default being copied to here — see `suiteBudgetFrom`.
1939
+ const budgetMs = suiteBudgetFrom(config);
1940
+ const suite = await journeysFromSuite({
1941
+ root,
1942
+ surface: options.surface === 'auto' ? undefined : options.surface,
1943
+ ...(budgetMs === null ? {} : { suite: { budgetMs } }),
1944
+ // The harvest talks while it works, and it can take most of a minute. Its sentences
1945
+ // go into the same stream as everything else rather than nowhere.
1946
+ log: (message) => options.events?.emit({ type: 'note', at: options.events.elapsed(), message }),
1947
+ signal: options.signal,
1948
+ });
1949
+ // Said out loud, always, and before the harvest's own findings. A run held to ninety
1950
+ // seconds and a run allowed four minutes produce different amounts of coverage, and if
1951
+ // the number that decided it is invisible the two runs read as the same run.
1952
+ const applied = budgetMs ?? DEFAULT_HARVEST_BUDGET_MS;
1953
+ options.events?.emit({
1954
+ type: 'note',
1955
+ at: options.events.elapsed(),
1956
+ message:
1957
+ applied === 0
1958
+ ? 'The test-suite harvest was given no time budget at all, so every test file was run however long it took. Your settings asked for that with suite.budgetMs: 0.'
1959
+ : `The test-suite harvest was held to ${Math.round(applied / 1000)} seconds${budgetMs === null ? ', which is the default' : ', which your settings asked for'}. Anything it did not reach in that time is named below rather than skipped quietly; change it with suite.budgetMs.`,
1960
+ });
1961
+ journeys.push(...suite.journeys);
1962
+ gaps.push(...suite.gaps);
1963
+ } catch (e) {
1964
+ // A harvest that fell over is a hole, never a pass. Everything else this project has is
1965
+ // still walked, and the verdict carries the fact that its tests were not among it.
1966
+ gaps.push({
1967
+ what: 'Nothing was checked through this project\'s own test suite, because it could not be harvested.',
1968
+ why: messageOf(e),
1969
+ unlockedBy: 'Run the suite yourself to see what it does, or point the check at a journeys file instead. Nothing your tests can see is being watched until this works.',
1970
+ });
1971
+ }
1972
+ }
1973
+
1407
1974
  for (const adapter of ADAPTERS) {
1408
1975
  if (adapter === sourceAdapter && named && options.journeys !== 'code') {
1409
1976
  // A journeys file names exactly what to walk. The contract read is still added,
@@ -1448,7 +2015,10 @@ async function gatherJourneys({ root, config, options }) {
1448
2015
  gaps.push({
1449
2016
  what: `You asked for the journey "${wanted}" and there is no journey by that name, so it was not walked.`,
1450
2017
  why: 'A name that matches nothing narrows the run to nothing rather than to what you meant.',
1451
- unlockedBy: `The journeys this project has are: ${journeys.map((j) => j.name).slice(0, 12).join(', ') || 'none'}.`,
2018
+ // The count goes on the end when the list was cut. Twelve names with nothing after
2019
+ // them read as the whole list, and somebody hunting for a name they mistyped would
2020
+ // conclude it is not there — when it is, at number thirteen.
2021
+ unlockedBy: `The journeys this project has are: ${journeys.map((j) => j.name).slice(0, 12).join(', ') || 'none'}${journeys.length > 12 ? `, and ${journeys.length - 12} more` : ''}.`,
1452
2022
  });
1453
2023
  }
1454
2024
  }
@@ -1530,28 +2100,10 @@ async function readConfig(configFile) {
1530
2100
  // Which build is which
1531
2101
  // ---------------------------------------------------------------------------
1532
2102
 
1533
- /**
1534
- * Everything this tool writes about your project, kept out of what your project IS.
1535
- *
1536
- * This one line is load-bearing and it was missing, and the bug it caused reached all the
1537
- * way to the front door. A build is told from another build by what git says is in the
1538
- * working tree — the diff, plus the list of files git does not know about. Stays Fixed's own
1539
- * folder is a file git does not know about, and it gains files every single time the tool
1540
- * runs. So the untracked list changed on every run, the digest changed with it, and every
1541
- * run of an UNCHANGED project produced a brand new build id.
1542
- *
1543
- * The consequences were all silent. Two runs on identical source were two different builds,
1544
- * so the second could never find the first one's record. A clean checkout was never clean, so
1545
- * it never got its commit's id, so `--against HEAD` matched nothing and the stored-record
1546
- * comparison — the fast path the whole design rests on — could not work at all. Measured on
1547
- * a scratch product: five runs, one unchanged source file, five different build ids and five
1548
- * runs reporting NOTHING WAS ACTUALLY COMPARED.
1549
- *
1550
- * Excluded rather than gitignored, and that difference matters: gitignoring it would fix the
1551
- * fingerprint and would also throw away the observation files the design says to keep
1552
- * forever. What a project's own tooling wrote about a project is never part of the project.
1553
- */
1554
- const NOT_THE_TOOLS_OWN_FOLDER = ':(exclude).staysfixed';
2103
+ // The pathspec that keeps Stays Fixed's own folder out of "what has changed here" lives in
2104
+ // rank.js, next to the other reader of the same two git calls. One name, so the fingerprint
2105
+ // and the distance measure can never disagree about what counts as the agent's edit — they
2106
+ // did for a fortnight, and only the fingerprint half had been fixed.
1555
2107
 
1556
2108
  /**
1557
2109
  * The build you have, named by what is actually in it.
@@ -1564,7 +2116,7 @@ const NOT_THE_TOOLS_OWN_FOLDER = ':(exclude).staysfixed';
1564
2116
  * @param {string} product
1565
2117
  * @returns {Promise<BuildFingerprint>}
1566
2118
  */
1567
- async function fingerprintWorkingTree(root, product) {
2119
+ export async function fingerprintWorkingTree(root, product) {
1568
2120
  const sha = await git(root, ['rev-parse', 'HEAD']);
1569
2121
  if (!sha) {
1570
2122
  // REFUSING IS THE ONLY HONEST ANSWER HERE, and the alternative is the worst bug this
@@ -1636,6 +2188,17 @@ async function fingerprintCommit(root, product, name) {
1636
2188
  return build;
1637
2189
  }
1638
2190
 
2191
+ /**
2192
+ * What to call a reference build in a sentence: the name a person typed, or its id.
2193
+ *
2194
+ * @param {BuildFingerprint} reference
2195
+ * @param {string} [asked]
2196
+ * @returns {string}
2197
+ */
2198
+ function nameOfReference(reference, asked) {
2199
+ return asked && asked.trim() !== '' ? `${asked} (${reference.id})` : reference.id;
2200
+ }
2201
+
1639
2202
  /**
1640
2203
  * Put the old build back on this machine so it can be walked live.
1641
2204
  *
@@ -1731,24 +2294,45 @@ async function git(cwd, args) {
1731
2294
  }
1732
2295
 
1733
2296
  /**
2297
+ * The project's package.json, and — separately — whether there is one that could not be read.
2298
+ *
2299
+ * NO PACKAGE.JSON AND A DAMAGED ONE USED TO BE THE SAME NULL, and the difference decides what
2300
+ * a product is CALLED. With nothing else naming it, the name falls back to the folder name, so
2301
+ * a package.json with a stray comma in it silently renames the product: the store keys every
2302
+ * record under the new name, and every record kept under the real one — including the build
2303
+ * somebody called working — is orphaned. The run then says, perfectly calmly, that nothing has
2304
+ * ever been recorded here.
2305
+ *
1734
2306
  * @param {string} root
1735
- * @returns {Promise<Record<string, any>|null>}
2307
+ * @returns {Promise<{pkg: Record<string, any>|null, damaged: string}>}
2308
+ * `damaged` is empty except when the file is there and unreadable, when it is the sentence
2309
+ * a person gets.
1736
2310
  */
1737
2311
  async function packageJson(root) {
2312
+ const file = path.join(root, 'package.json');
2313
+ /** @type {string} */
2314
+ let raw;
1738
2315
  try {
1739
- return JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
1740
- } catch {
1741
- return null;
2316
+ raw = await fsp.readFile(file, 'utf8');
2317
+ } catch (e) {
2318
+ const code = /** @type {{code?: string}} */ (e)?.code;
2319
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { pkg: null, damaged: '' };
2320
+ return { pkg: null, damaged: `${file} is there and could not be read: ${messageOf(e)}` };
2321
+ }
2322
+ try {
2323
+ return { pkg: JSON.parse(raw), damaged: '' };
2324
+ } catch (e) {
2325
+ return { pkg: null, damaged: `${file} is not readable as JSON: ${messageOf(e)}` };
1742
2326
  }
1743
2327
  }
1744
2328
 
1745
2329
  /**
1746
2330
  * @param {string} root
1747
- * @returns {Promise<string|null>}
2331
+ * @returns {Promise<{name: string|null, damaged: string}>}
1748
2332
  */
1749
2333
  async function packageName(root) {
1750
- const pkg = await packageJson(root);
1751
- return typeof pkg?.name === 'string' ? pkg.name : null;
2334
+ const { pkg, damaged } = await packageJson(root);
2335
+ return { name: typeof pkg?.name === 'string' ? pkg.name : null, damaged };
1752
2336
  }
1753
2337
 
1754
2338
  /**
@@ -1756,7 +2340,7 @@ async function packageName(root) {
1756
2340
  * @returns {Promise<string|null>}
1757
2341
  */
1758
2342
  async function packageVersion(root) {
1759
- const pkg = await packageJson(root);
2343
+ const { pkg } = await packageJson(root);
1760
2344
  return typeof pkg?.version === 'string' ? pkg.version : null;
1761
2345
  }
1762
2346