staysfixed 0.7.1 → 0.8.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 (59) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/README.md +193 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  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 +549 -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/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +345 -47
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
@@ -374,6 +374,13 @@ export async function runOneFile(opts) {
374
374
 
375
375
  /** @type {NodeJS.ProcessEnv} */
376
376
  const env = { ...process.env, STAYSFIXED_HARVEST: '1' };
377
+ // Node's own test runner marks the processes it starts with NODE_TEST_CONTEXT, and a test
378
+ // file that finds it switches from TAP to a private binary stream meant for its parent. Let
379
+ // that through and every file we run comes back having reported nothing at all, and the
380
+ // harvest says - calmly, one line per file - that this project's suite cannot be walked.
381
+ // It bites whenever the harvest is itself started from inside a test run, which is exactly
382
+ // where anybody would first try it.
383
+ delete env.NODE_TEST_CONTEXT;
377
384
  // Node's own runner writes coverage for the process and every child it spawns, which is
378
385
  // exactly how a test file's own run gets measured. Vitest is asked for its own instead.
379
386
  if (wantCoverage && opts.runner === 'node:test') env.NODE_V8_COVERAGE = coverageDir;
@@ -505,30 +512,149 @@ function runToEnd(command, argv, opts) {
505
512
  // ---------------------------------------------------------------------------
506
513
 
507
514
  /**
508
- * Pull the check names out of TAP.
515
+ * One check a test file reported.
516
+ *
517
+ * `detail` is only filled in for a check that failed, and it is the whole of what the runner
518
+ * said about it. It matters because "this test is still failing" and "this test is failing
519
+ * for a completely different reason now" are two different facts, and a pass-or-fail flag
520
+ * alone cannot tell them apart - which is exactly the half an exit code misses.
521
+ *
522
+ * @typedef {object} Check
523
+ * @property {string} name
524
+ * @property {boolean} ok
525
+ * @property {string} [detail]
526
+ */
527
+
528
+ /**
529
+ * Pull the checks out of TAP, each with whether it passed and what it said when it did not.
509
530
  *
510
531
  * Names rather than counts, because two runs producing thirteen checks each is not the same
511
532
  * evidence as two runs producing the same thirteen checks.
512
533
  *
513
534
  * @param {string} output
514
- * @returns {{tests: string[], passed: number, failed: number}}
535
+ * @returns {Check[]} in the order the runner reported them
515
536
  */
516
- export function parseTap(output) {
517
- /** @type {string[]} */
518
- const tests = [];
519
- let passed = 0;
520
- let failed = 0;
521
- for (const line of output.split('\n')) {
522
- const match = /^\s*(not )?ok\s+\d+\s*-?\s*(.*)$/.exec(line);
537
+ export function parseTapChecks(output) {
538
+ /** @type {Check[]} */
539
+ const checks = [];
540
+ const lines = String(output).split('\n');
541
+ for (let i = 0; i < lines.length; i++) {
542
+ const match = /^\s*(not )?ok\s+\d+\s*-?\s*(.*)$/.exec(lines[i]);
523
543
  if (!match) continue;
524
544
  const name = match[2].replace(/\s*#\s*(SKIP|TODO).*$/i, '').trim();
525
545
  if (name === '') continue;
526
- tests.push(name);
527
- if (match[1]) failed++;
528
- else passed++;
546
+ const ok = !match[1];
547
+ if (ok) {
548
+ checks.push({ name, ok });
549
+ continue;
550
+ }
551
+ // A failing check is followed by an indented block holding what went wrong. It opens on
552
+ // `---` and closes on `...`, and everything between is the reason this check is worth
553
+ // reading rather than counting.
554
+ /** @type {string[]} */
555
+ const said = [];
556
+ let j = i + 1;
557
+ if (/^\s*---\s*$/.test(lines[j] ?? '')) {
558
+ j++;
559
+ while (j < lines.length && !/^\s*\.\.\.\s*$/.test(lines[j])) {
560
+ said.push(lines[j]);
561
+ j++;
562
+ }
563
+ i = j;
564
+ }
565
+ const detail = withoutRunnerTiming(said.join('\n')).trim();
566
+ checks.push(detail === '' ? { name, ok } : { name, ok, detail });
529
567
  }
530
- tests.sort();
531
- return { tests, passed, failed };
568
+ return checks;
569
+ }
570
+
571
+ /**
572
+ * Pull the check names out of TAP.
573
+ *
574
+ * @param {string} output
575
+ * @returns {{tests: string[], passed: number, failed: number}}
576
+ */
577
+ export function parseTap(output) {
578
+ const checks = parseTapChecks(output);
579
+ return {
580
+ tests: checks.map((c) => c.name).sort(),
581
+ passed: checks.filter((c) => c.ok).length,
582
+ failed: checks.filter((c) => !c.ok).length,
583
+ };
584
+ }
585
+
586
+ /**
587
+ * The runner's own stopwatch, taken back out of what it printed.
588
+ *
589
+ * Measured rather than assumed: two runs of the same three-check file, on the same bytes,
590
+ * minutes apart, differ in exactly four lines and every one of them is a `duration_ms`.
591
+ * Nothing else moves. So this one small rule turns the whole of what a test runner printed
592
+ * from noise into something worth comparing word for word - and comparing it is the point,
593
+ * because a program that starts printing a deprecation warning under a test that still
594
+ * passes is invisible to an exit code.
595
+ *
596
+ * WHAT THIS COULD HIDE, said plainly: a test that got slower. That is already the one thing
597
+ * this tool refuses to report on principle - see `howLongItTook` in adapters/contract.js -
598
+ * so nothing is lost here that was ever going to be claimed.
599
+ *
600
+ * @param {string} text
601
+ * @returns {string}
602
+ */
603
+ export function withoutRunnerTiming(text) {
604
+ return String(text)
605
+ .split('\n')
606
+ .filter((line) => !/^\s*#?\s*duration_ms[:\s]/.test(line))
607
+ .join('\n');
608
+ }
609
+
610
+ /** The keys vitest fills in from a clock. A named list, so nothing else is touched. */
611
+ const VITEST_CLOCK_KEYS = new Set([
612
+ 'duration', 'startTime', 'endTime', 'start', 'end', 'setupDuration', 'collectDuration',
613
+ 'prepareDuration', 'environmentSetupDuration', 'runtime', 'heap',
614
+ ]);
615
+
616
+ /**
617
+ * Everything a test runner printed, with its own stopwatch out of it and nothing else.
618
+ *
619
+ * Node's runner narrates its timings as `duration_ms` lines, which come straight out. Vitest
620
+ * hands back a JSON report with the times scattered through it under named keys, so it is
621
+ * parsed and those exact keys - a named list, never a pattern over the text - are replaced.
622
+ * Replaced rather than deleted, because a key vanishing and a key holding a different number
623
+ * are two different facts and only one of them is the clock.
624
+ *
625
+ * Anything that will not parse comes back with the line rule applied and no more. Guessing
626
+ * at the shape of text nobody recognises is how a real change gets rubbed out.
627
+ *
628
+ * @param {Runner} runner
629
+ * @param {string} text
630
+ * @returns {string}
631
+ */
632
+ export function quietenRunnerOutput(runner, text) {
633
+ const plain = withoutRunnerTiming(text);
634
+ if (runner !== 'vitest') return plain;
635
+ const start = plain.indexOf('{');
636
+ if (start < 0) return plain;
637
+ try {
638
+ const report = JSON.parse(plain.slice(start));
639
+ return `${plain.slice(0, start)}${JSON.stringify(hushClock(report), null, 2)}`;
640
+ } catch {
641
+ return plain;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * @param {unknown} value
647
+ * @returns {unknown}
648
+ */
649
+ function hushClock(value) {
650
+ if (Array.isArray(value)) return value.map(hushClock);
651
+ if (value === null || typeof value !== 'object') return value;
652
+ /** @type {Record<string, unknown>} */
653
+ const out = {};
654
+ for (const [key, inner] of Object.entries(value)) {
655
+ out[key] = VITEST_CLOCK_KEYS.has(key) && typeof inner === 'number' ? 'a time, not compared' : hushClock(inner);
656
+ }
657
+ return out;
532
658
  }
533
659
 
534
660
  /**
@@ -537,26 +663,77 @@ export function parseTap(output) {
537
663
  */
538
664
  async function readVitestResults(file) {
539
665
  try {
540
- const report = JSON.parse(await fsp.readFile(file, 'utf8'));
541
- /** @type {string[]} */
542
- const tests = [];
543
- let passed = 0;
544
- let failed = 0;
545
- for (const suite of report.testResults ?? []) {
546
- for (const assertion of suite.assertionResults ?? []) {
547
- const name = [assertion.ancestorTitles?.join(' > '), assertion.title].filter(Boolean).join(' > ');
548
- tests.push(name);
549
- if (assertion.status === 'passed') passed++;
550
- else if (assertion.status === 'failed') failed++;
551
- }
552
- }
553
- tests.sort();
554
- return { tests, passed, failed };
666
+ const checks = checksFromVitestReport(JSON.parse(await fsp.readFile(file, 'utf8')));
667
+ return {
668
+ tests: checks.map((c) => c.name).sort(),
669
+ passed: checks.filter((c) => c.ok).length,
670
+ failed: checks.filter((c) => !c.ok).length,
671
+ };
555
672
  } catch {
556
673
  return { tests: [], passed: 0, failed: 0 };
557
674
  }
558
675
  }
559
676
 
677
+ /**
678
+ * Vitest's own JSON report, as checks.
679
+ *
680
+ * @param {any} report
681
+ * @returns {Check[]}
682
+ */
683
+ export function checksFromVitestReport(report) {
684
+ /** @type {Check[]} */
685
+ const checks = [];
686
+ for (const suite of report?.testResults ?? []) {
687
+ for (const assertion of suite?.assertionResults ?? []) {
688
+ const name = [assertion.ancestorTitles?.join(' > '), assertion.title].filter(Boolean).join(' > ');
689
+ if (name === '') continue;
690
+ const ok = assertion.status === 'passed';
691
+ const detail = withoutRunnerTiming(
692
+ Array.isArray(assertion.failureMessages) ? assertion.failureMessages.join('\n') : '',
693
+ ).trim();
694
+ checks.push(ok || detail === '' ? { name, ok } : { name, ok, detail });
695
+ }
696
+ }
697
+ return checks;
698
+ }
699
+
700
+ /**
701
+ * The checks a walked test file reported, read out of whatever the runner printed.
702
+ *
703
+ * This is the walking half of the harvest. `runOneFile` reads a runner it started itself and
704
+ * knows exactly where the report went; a journey being walked months later has only the text
705
+ * the command printed. The two live next to each other on purpose - a journey read
706
+ * differently from the way it was harvested is not the same journey.
707
+ *
708
+ * `read` false is not "no checks". It means nothing here could tell, and the caller has to
709
+ * report that as a hole rather than as a file with nothing in it.
710
+ *
711
+ * @param {Runner} runner
712
+ * @param {string} stdout
713
+ * @returns {{checks: Check[], read: boolean, why: string}}
714
+ */
715
+ export function readChecks(runner, stdout) {
716
+ const text = String(stdout ?? '');
717
+ if (runner === 'vitest') {
718
+ // Asked for `--reporter=json` with nowhere to put it, vitest prints the report to the
719
+ // screen, sometimes after a banner. The first `{` that parses is the report.
720
+ const start = text.indexOf('{');
721
+ if (start >= 0) {
722
+ try {
723
+ const checks = checksFromVitestReport(JSON.parse(text.slice(start)));
724
+ if (checks.length > 0) return { checks, read: true, why: `Vitest reported ${checks.length} checks.` };
725
+ } catch { /* fall through to TAP, which some vitest setups print instead */ }
726
+ }
727
+ }
728
+ const checks = parseTapChecks(text);
729
+ if (checks.length > 0) return { checks, read: true, why: `${checks.length} checks reported.` };
730
+ return {
731
+ checks: [],
732
+ read: false,
733
+ why: 'The test runner printed nothing this could read as a list of checks, so which checks passed is not known.',
734
+ };
735
+ }
736
+
560
737
  /**
561
738
  * How many functions from one file are worth writing down. A journey that lists nine
562
739
  * hundred function names is not evidence, it is a wall, and nobody reads a wall.
@@ -719,6 +896,11 @@ export function relativeIfInside(url, root) {
719
896
  * @property {string[]} [files] Exact files, relative to the root. Overrides listing.
720
897
  * @property {string[]} [only] Substrings a test file's path must contain.
721
898
  * @property {number} [limit] Stop after this many files. Coverage says so out loud.
899
+ * @property {number} [budgetMs] Stop harvesting once this much time has gone, and name
900
+ * every file that was not reached. Default
901
+ * `DEFAULT_HARVEST_BUDGET_MS`. Zero means no budget,
902
+ * which is a thing to ask for on purpose and never a
903
+ * default.
722
904
  * @property {1|2} [repeat] Runs per file. Two is the default and it is the point:
723
905
  * a journey that does not reproduce twice on the same
724
906
  * build is rejected at birth rather than admitted and
@@ -748,11 +930,36 @@ export function relativeIfInside(url, root) {
748
930
  * @property {{file: string, failed: number}[]} failing
749
931
  * Files whose checks did not all pass. Kept anyway —
750
932
  * what a test exercises is useful even when it is red.
933
+ * @property {string[]} notReached
934
+ * Test files the budget ran out before. Named one by
935
+ * one, never counted: "some of your tests were
936
+ * skipped" tells a reader nothing they can act on, and
937
+ * a reader who cannot tell which half of their suite is
938
+ * being watched will assume it is all of it.
939
+ * @property {number} [budgetMs] The budget this harvest was held to, when it had one.
751
940
  * @property {Missing[]} missing
752
941
  * @property {string[]} notes
753
942
  * @property {number} durationMs
754
943
  */
755
944
 
945
+ /**
946
+ * How long a harvest gets before it stops and says what it did not reach.
947
+ *
948
+ * WHY THERE IS A BUDGET AT ALL. This whole tool is worth using because it is cheap enough to
949
+ * run on every change. A stranger's test suite is not cheap: twenty minutes is an ordinary
950
+ * number for a real one, and the harvest runs every file TWICE to prove it repeats. A check
951
+ * that costs three quarters of an hour is a check nobody runs, and a check nobody runs
952
+ * catches nothing - which is a worse failure than any bug it could have found.
953
+ *
954
+ * Ninety seconds is not a guess about how long suites take. It is a statement about how long
955
+ * somebody will wait inside an edit-and-check loop before deciding to switch the thing off.
956
+ *
957
+ * The budget stops the harvest. It never stops it QUIETLY: every file it did not reach is
958
+ * named in the report and lands in the coverage ledger by name, because a partial harvest
959
+ * reported as a whole one is the tool lying about how much of the product it is watching.
960
+ */
961
+ export const DEFAULT_HARVEST_BUDGET_MS = 90_000;
962
+
756
963
  /**
757
964
  * Harvest journeys out of a project's own test suite.
758
965
  *
@@ -761,7 +968,14 @@ export function relativeIfInside(url, root) {
761
968
  */
762
969
  export async function harvestJourneys(opts) {
763
970
  const started = Date.now();
764
- const root = path.resolve(opts.root);
971
+ // The real path, never the one we were handed. On a Mac /tmp is a symlink to /private/tmp,
972
+ // so a project reached through /tmp runs its tests and gets coverage back full of
973
+ // /private/tmp paths - every one of which looks like a file outside the project, and every
974
+ // one of which is thrown away. The harvest then says, perfectly calmly, that the tests
975
+ // touched nothing at all. That is a silence, and a silence is the one failure this tool
976
+ // exists to prevent, so the path is resolved once, here, before anything is measured
977
+ // against it.
978
+ const root = await realRoot(opts.root);
765
979
  const log = opts.log ?? (() => {});
766
980
  const detection = opts.runner
767
981
  ? { runner: opts.runner, why: 'The runner was named by the caller.', binary: opts.binary, missing: [], notes: [] }
@@ -780,10 +994,13 @@ export async function harvestJourneys(opts) {
780
994
  touchedMeasured: false,
781
995
  rejected: [],
782
996
  failing: [],
997
+ notReached: [],
783
998
  missing: [...(detection.missing ?? [])],
784
999
  notes: [...(detection.notes ?? [])],
785
1000
  durationMs: 0,
786
1001
  };
1002
+ const budgetMs = opts.budgetMs ?? DEFAULT_HARVEST_BUDGET_MS;
1003
+ if (budgetMs > 0) report.budgetMs = budgetMs;
787
1004
 
788
1005
  if (runner === 'none') {
789
1006
  report.durationMs = Date.now() - started;
@@ -820,6 +1037,14 @@ export async function harvestJourneys(opts) {
820
1037
  report.rejected.push({ file, why: 'The harvest was stopped before this file was reached.' });
821
1038
  continue;
822
1039
  }
1040
+ // Checked before starting a file rather than after finishing one, so the budget is a
1041
+ // ceiling on when the harvest STARTS work and not a suggestion that one slow file can
1042
+ // blow through by ten minutes. What it costs: a file that would just have fitted is left
1043
+ // out. What it buys: the number in the budget is the truth.
1044
+ if (budgetMs > 0 && Date.now() - started >= budgetMs) {
1045
+ report.notReached.push(file);
1046
+ continue;
1047
+ }
823
1048
  log(`Running ${file}${repeat > 1 ? ' (twice, to see whether it repeats)' : ''}.`);
824
1049
 
825
1050
  /** @type {OneRun[]} */
@@ -873,6 +1098,15 @@ export async function harvestJourneys(opts) {
873
1098
 
874
1099
  if (!opts.scratchDir) await fsp.rm(scratchDir, { recursive: true, force: true }).catch(() => {});
875
1100
 
1101
+ if (report.notReached.length > 0) {
1102
+ report.notes.push(
1103
+ `The harvest stopped after ${Math.round((Date.now() - started) / 1000)} seconds, which is its budget, with ` +
1104
+ `${report.notReached.length} test ${report.notReached.length === 1 ? 'file' : 'files'} still to go. Whatever ` +
1105
+ `those walk is not being watched. They are named one by one, and a bigger budget or a narrower list of files ` +
1106
+ `is how they get in.`,
1107
+ );
1108
+ }
1109
+
876
1110
  report.journeys = journeys.length;
877
1111
  report.touchedFiles = touchedEverything.size;
878
1112
  report.touchedMeasured = journeys.some((j) => j.touched?.measured === true);
@@ -986,3 +1220,75 @@ export function slugPath(file) {
986
1220
  .replace(/[^a-z0-9]+/g, '-')
987
1221
  .replace(/^-+|-+$/g, '');
988
1222
  }
1223
+
1224
+ /**
1225
+ * The path a project really lives at, with every symlink on the way resolved.
1226
+ *
1227
+ * @param {string} root
1228
+ * @returns {Promise<string>}
1229
+ */
1230
+ async function realRoot(root) {
1231
+ const absolute = path.resolve(root);
1232
+ try {
1233
+ return await fsp.realpath(absolute);
1234
+ } catch {
1235
+ return absolute;
1236
+ }
1237
+ }
1238
+
1239
+ // ---------------------------------------------------------------------------
1240
+ // Only the tests near the change
1241
+ // ---------------------------------------------------------------------------
1242
+
1243
+ /**
1244
+ * Split harvested journeys into the ones worth walking for this change and the ones that
1245
+ * cannot have been touched by it.
1246
+ *
1247
+ * WHY THIS IS ALLOWED TO BE A FILTER AT ALL. Skipping tests to go faster is normally how a
1248
+ * safety net gets holes cut in it, and the usual version of this - guess from the name, walk
1249
+ * `total.test.js` because `total.js` changed - earns that reputation, because a name is not
1250
+ * evidence.
1251
+ *
1252
+ * This is not that. Harvesting a file MEASURES what it executed: Node writes the coverage
1253
+ * itself, and every harvested journey arrives carrying the list of project files that
1254
+ * actually ran. A test file whose measured list holds not one file you touched cannot have
1255
+ * run different code, so walking it proves nothing and costs a process launch.
1256
+ *
1257
+ * THE TWO WAYS THIS COULD BE WRONG, and what is done about each. A test that reaches code by
1258
+ * a path the coverage never saw - a child process, a native module - would be dropped on
1259
+ * false evidence, so a journey that does not KNOW what it touched is always walked and never
1260
+ * filtered. And code reached only on some runs would make the measurement itself unsteady,
1261
+ * which is exactly what the harvest's two runs reject a file for. What is left is filtered on
1262
+ * a measurement, not on a hunch.
1263
+ *
1264
+ * Nothing here decides quietly: the ones left out come back named, for the ledger.
1265
+ *
1266
+ * @param {SuiteJourney[]} journeys
1267
+ * @param {string[]} changed Project files that changed, relative to the root.
1268
+ * @returns {{walk: SuiteJourney[], skipped: {journey: string, why: string}[]}}
1269
+ */
1270
+ export function testsNear(journeys, changed) {
1271
+ const changedFiles = new Set(changed.map((file) => String(file).split(path.sep).join('/')));
1272
+ /** @type {SuiteJourney[]} */
1273
+ const walk = [];
1274
+ /** @type {{journey: string, why: string}[]} */
1275
+ const skipped = [];
1276
+
1277
+ for (const journey of journeys) {
1278
+ const touched = journey.touched;
1279
+ if (!touched?.measured || touched.files.length === 0) {
1280
+ walk.push(journey);
1281
+ continue;
1282
+ }
1283
+ // Its own file counts. A test you edited is the most interesting one there is.
1284
+ const reaches = [...touched.files, journey.from ?? ''].some((file) => changedFiles.has(file));
1285
+ if (reaches) walk.push(journey);
1286
+ else {
1287
+ skipped.push({
1288
+ journey: journey.name,
1289
+ why: 'Nothing it was measured going through was changed, so it would run exactly the same code twice.',
1290
+ });
1291
+ }
1292
+ }
1293
+ return { walk, skipped };
1294
+ }
@@ -32,7 +32,7 @@ import path from 'node:path';
32
32
 
33
33
  import { measureWobble } from '../observation.js';
34
34
  import { journeysFromCode } from './from-routes.js';
35
- import { harvestJourneys } from './from-suite.js';
35
+ import { DEFAULT_HARVEST_BUDGET_MS, harvestJourneys, testsNear } from './from-suite.js';
36
36
  import { loadJourneyFolder, whatWillNotReplay } from './record.js';
37
37
 
38
38
  /** @typedef {import('../types.js').Journey} Journey */
@@ -46,7 +46,7 @@ import { loadJourneyFolder, whatWillNotReplay } from './record.js';
46
46
  /** @typedef {import('../adapters/source.js').Door} Door */
47
47
 
48
48
  export { journeysFromCode, journeysFromDoors, irreversibility } from './from-routes.js';
49
- export { detectRunner, harvestJourneys, listTestFiles } from './from-suite.js';
49
+ export { detectRunner, harvestJourneys, listTestFiles, testsNear, DEFAULT_HARVEST_BUDGET_MS } from './from-suite.js';
50
50
  export { startRecording, recordSession, saveJourneys, loadJourneys, loadJourneyFolder, redact } from './record.js';
51
51
 
52
52
  /**
@@ -369,7 +369,13 @@ export async function checkReproducible(journeys, opts = {}) {
369
369
  * Journeys harvested from the project's own tests. OFF by default, and deliberately:
370
370
  * harvesting RUNS the suite, which starts processes and takes minutes. Nothing that
371
371
  * expensive should happen because somebody called a function called `gather`. When it is
372
- * off, the report says what it would have unlocked.
372
+ * off, the report says what it would have unlocked. When it is on it is held to a time
373
+ * budget - `DEFAULT_HARVEST_BUDGET_MS` unless the caller says otherwise - and every test
374
+ * file the budget did not reach is named, one by one, in the gaps.
375
+ * @property {string[]} [changed]
376
+ * Project files this change touched, relative to the root. Given, the harvested journeys
377
+ * are narrowed to the ones MEASURED going through one of them, and the rest are named in
378
+ * the gaps rather than quietly dropped. Left out, every harvested journey is walked.
373
379
  * @property {false|{dir?: string, files?: string[]}} [recorded]
374
380
  * Recorded sessions. On by default: it only reads files. Defaults to `.staysfixed/journeys`.
375
381
  * @property {Journey[]} [explored] Journeys an agent produced, handed straight in.
@@ -471,15 +477,32 @@ export async function gather(opts) {
471
477
  root,
472
478
  };
473
479
  const harvest = await harvestJourneys(suiteOptions);
474
- collected.push(...harvest.journeys);
480
+ // Narrowed on a measurement, never on a hunch: the harvest recorded which project files
481
+ // each test file actually executed, and one that went through nothing you changed would
482
+ // run identical code twice. Anything left out is named, because a test quietly not run
483
+ // is the difference between a safety net and a story about one.
484
+ const near = opts.changed ? testsNear(harvest.journeys, opts.changed) : { walk: harvest.journeys, skipped: [] };
485
+ collected.push(...near.walk);
486
+ for (const left of near.skipped) {
487
+ gaps.push({
488
+ what: `The tests in "${left.journey}" were not walked for this change.`,
489
+ why: left.why,
490
+ unlockedBy: 'Nothing to install. Leave `changed` out of gather() and every harvested test file is walked, whatever it goes through.',
491
+ });
492
+ }
475
493
  gaps.push(...suiteGaps(harvest.report));
476
494
  missing.push(...harvest.report.missing);
477
495
  notes.push(...harvest.report.notes);
478
496
  } else {
479
497
  gaps.push({
480
498
  what: "The project's own test suite was not harvested, so every path its tests walk is invisible to this check.",
481
- why: 'Harvesting runs the suite one file at a time, which starts processes and takes minutes, so it never happens unless it is asked for.',
482
- unlockedBy: 'Ask for it: gather({suite: true}). Every test file that repeats twice becomes a journey nobody had to write.',
499
+ why:
500
+ 'Harvesting runs the suite one file at a time and twice each, which starts processes and takes minutes. This ' +
501
+ 'tool is worth having because it is cheap enough to run on every change, so nothing that expensive is ever ' +
502
+ 'switched on for somebody without being asked for.',
503
+ unlockedBy:
504
+ `Ask for it: gather({suite: true}). Every test file that repeats twice becomes a journey nobody had to write, ` +
505
+ `and the harvest stops after ${Math.round(DEFAULT_HARVEST_BUDGET_MS / 1000)} seconds and names whatever it did not reach.`,
483
506
  });
484
507
  }
485
508
 
@@ -579,6 +602,63 @@ export async function gather(opts) {
579
602
  return { journeys: verified.kept, report, doors };
580
603
  }
581
604
 
605
+ /**
606
+ * A number of milliseconds as a person would say it. "0 seconds" reads as no budget at all.
607
+ * @param {number} ms
608
+ * @returns {string}
609
+ */
610
+ function inSeconds(ms) {
611
+ return ms < 1000 ? 'under a second' : `${Math.round(ms / 1000)} seconds`;
612
+ }
613
+
614
+ /**
615
+ * The project's own test suite, as journeys ready to walk, in the shape a check wants back.
616
+ *
617
+ * `gather` does everything and is the right door for anything exploring what a project has.
618
+ * This is the narrow one: somebody asked for `--journeys suite`, and what they need back is
619
+ * a list of journeys and a list of holes - nothing else, and no second reading of the source
620
+ * that the caller has already done.
621
+ *
622
+ * WHAT THE CALLER IS SIGNING UP FOR, in one place so it cannot be missed. This RUNS the
623
+ * project's tests, one file at a time and twice each, inside the harvest's own temp folder.
624
+ * It is held to a time budget, and every file the budget did not reach comes back as a
625
+ * named hole. Hand it `changed` and only the test files measured going through one of those
626
+ * are kept, with the rest named too. Nothing is ever skipped quietly.
627
+ *
628
+ * @param {object} opts
629
+ * @param {string} opts.root
630
+ * @param {Surface} [opts.surface]
631
+ * @param {true|Partial<import('./from-suite.js').HarvestOptions>} [opts.suite]
632
+ * @param {string[]} [opts.changed]
633
+ * @param {(message: string) => void} [opts.log]
634
+ * @param {AbortSignal} [opts.signal]
635
+ * @returns {Promise<{journeys: GatheredJourney[], gaps: CoverageGap[], report: GatherReport}>}
636
+ */
637
+ export async function journeysFromSuite(opts) {
638
+ const gathered = await gather({
639
+ root: opts.root,
640
+ surface: opts.surface,
641
+ suite: opts.suite ?? true,
642
+ changed: opts.changed,
643
+ // The caller asked for the suite. Reading the source and loading recordings are other
644
+ // sources with their own costs, and doing them here would charge for work nobody asked
645
+ // for and hand back journeys nobody expected.
646
+ code: false,
647
+ recorded: false,
648
+ log: opts.log,
649
+ signal: opts.signal,
650
+ });
651
+ return {
652
+ journeys: gathered.journeys.filter((journey) => journey.source === 'suite'),
653
+ // Everything except "the doors were never counted". That hole is real when nobody read
654
+ // the source at all, and it is a lie here: the caller asking for the suite reads the
655
+ // source through the contract adapter on the same run, and a ledger carrying a hole
656
+ // somebody has already filled sends a reader looking for work that is done.
657
+ gaps: gathered.report.gaps.filter((gap) => gap.channel !== 'contract'),
658
+ report: gathered.report,
659
+ };
660
+ }
661
+
582
662
  /**
583
663
  * @param {import('./from-suite.js').HarvestReport} report
584
664
  * @returns {CoverageGap[]}
@@ -593,6 +673,19 @@ function suiteGaps(report) {
593
673
  unlockedBy: 'Nothing to install. Either that file is not repeatable, or it needs something the harvest did not give it.',
594
674
  });
595
675
  }
676
+ if (report.notReached.length > 0) {
677
+ gaps.push({
678
+ // Every one of them, by name, however many there are. This used to stop at twenty and
679
+ // add "and 14 more", which is the same failure as "some tests were skipped" wearing a
680
+ // number: a reader cannot act on a name they were not given, and the reader who most
681
+ // needs the full list is precisely the one whose harvest reached almost nothing. The
682
+ // sentence gets long. A ledger that is honest and long beats one that is short and
683
+ // leaves half of somebody's suite unaccounted for.
684
+ what: `${report.notReached.length} test ${report.notReached.length === 1 ? 'file was' : 'files were'} never run, so whatever they walk is not being watched: ${report.notReached.join(', ')}.`,
685
+ why: `The harvest was held to ${inSeconds(report.budgetMs ?? DEFAULT_HARVEST_BUDGET_MS)} so that running it on every change stays affordable, and it ran out before these.`,
686
+ unlockedBy: 'Give it longer — suite: {budgetMs} in your settings file, or gather({suite: {budgetMs}}) in code, and 0 means no budget at all. Or narrow it to the files that matter with gather({suite: {only}}).',
687
+ });
688
+ }
596
689
  if (report.runner === 'none') {
597
690
  gaps.push({
598
691
  what: 'No test suite could be harvested.',
@@ -388,7 +388,7 @@ export function toolDefinitions() {
388
388
  journeys: {
389
389
  type: 'string',
390
390
  description:
391
- "Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels. The other value is a path to a journeys file naming steps by hand. 'suite' (harvest your own test suite) and 'recorded' (replay a recorded session) are written and not yet wired into a run: ask for either and it says so rather than checking something else.",
391
+ "Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels. 'suite' walks the project's own test suite as well: each test file runs twice inside the scratch copy, every check is reported by name, and it stops after 90 seconds naming each file it did not reach. It catches breaks nothing else can - a rounding change the product's own output never shows. It is opt-in because running a stranger's whole suite twice on every check is not something to do by default. You can also pass a path to a journeys file naming steps by hand. 'recorded' (replay a recorded session) is written and not yet wired into a run: ask for it and it says so rather than checking something else.",
392
392
  },
393
393
  surface: {
394
394
  type: 'string',
@@ -756,18 +756,17 @@ async function toolCheck(ctx, input) {
756
756
  const limit = positive(input.limit) ?? DEFAULT_LIMIT;
757
757
  const offset = positive(input.offset) ?? 0;
758
758
 
759
- // A value the engine does not understand must be refused BY NAME. `suite` and
760
- // `recorded` are real ideas with real code behind them in src/v2/journeys/, and
761
- // nothing on the check path calls that code yet - so passing either one down reaches
762
- // the engine as the name of a file, and comes back as "there is no journeys file at
763
- // .../suite". That error sends an agent looking for a file it never asked for. The
764
- // day the harvest is wired, this refusal is what has to be deleted.
759
+ // A value the engine does not understand must be refused BY NAME, never passed down.
760
+ //
761
+ // `suite` is now wired and reaches the harvest. `recorded` is still written and called by
762
+ // nothing, so passing it down would reach the engine as the name of a FILE and come back as
763
+ // "there is no journeys file at .../recorded" an error that sends an agent looking for a
764
+ // file it never asked for. Refusing it by name and saying why is the honest answer, and a
765
+ // clean result about the wrong steps would be worse than no result.
765
766
  const wantedJourneys = text(input.journeys);
766
- if (wantedJourneys === 'suite' || wantedJourneys === 'recorded') {
767
+ if (wantedJourneys === 'recorded') {
767
768
  return problem(
768
- wantedJourneys === 'suite'
769
- ? 'Harvesting your own test suite as journeys is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file. Saying this rather than quietly checking something else is deliberate: a clean result about the wrong steps is worse than no result.'
770
- : 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file.'
769
+ 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, pass "suite" to walk your own test suite, or pass the path to a journeys file.'
771
770
  );
772
771
  }
773
772