staysfixed 0.9.1 → 0.11.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 (42) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +107 -3
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/check.js +526 -19
  23. package/src/v2/cli.js +345 -3
  24. package/src/v2/cluster.js +112 -4
  25. package/src/v2/coverage.js +293 -8
  26. package/src/v2/detect.js +182 -9
  27. package/src/v2/doctor.js +253 -30
  28. package/src/v2/init.js +102 -10
  29. package/src/v2/mcp/server.js +4 -1
  30. package/src/v2/mcp/tools.js +291 -24
  31. package/src/v2/normalise.js +11 -0
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
package/src/v2/check.js CHANGED
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
32
32
  import { promisify } from 'node:util';
33
33
 
34
34
  import { StaysFixedError, messageOf } from '../core/errors.js';
35
- import { warn, detail } from '../core/log.js';
35
+ import { warn, detail, shortPath } from '../core/log.js';
36
36
  import { findConfigFile, rootForConfig } from '../core/paths.js';
37
37
  import { sha256 } from '../core/hash.js';
38
38
 
@@ -326,20 +326,46 @@ export async function check(options = {}) {
326
326
  // the word "guard" exactly zero times. A tool built to catch silent breakage must not do
327
327
  // nothing silently.
328
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
- ];
329
+ if (named.length > 0) {
330
+ const walked = await walkTheGuards(project.root, options);
331
+ if (walked.ran) {
332
+ // A guard that failed is a bug somebody already had, coming back. It is sealed by
333
+ // name one of the five classes no agent may wave through, whatever the reason.
334
+ for (const bad of walked.failed) {
335
+ verdict.findings = [
336
+ ...(verdict.findings ?? []),
337
+ /** @type {any} */ ({
338
+ id: `guard-${sha256(bad.name).slice(0, 6)}`,
339
+ title: `A bug that was already fixed is back: ${bad.name}`,
340
+ why: bad.message ?? 'The guard written for it does not hold any more.',
341
+ class: 'named guard',
342
+ guard: bad.name,
343
+ differences: [],
344
+ rank: 0,
345
+ count: 1,
346
+ }),
347
+ ];
348
+ }
349
+ verdict.summary = `${walked.said} ${verdict.summary}`;
350
+ if (walked.failed.length > 0) verdict.ok = false;
351
+ } else if (verdict.coverage) {
352
+ verdict.coverage.gaps = [
353
+ ...(verdict.coverage.gaps ?? []),
354
+ {
355
+ what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
356
+ why: `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check: ${walked.why}`,
357
+ unlockedBy: 'Give the settings an address to open — `url` beside `start` in the `web` block, or `electron.binary` — and every check walks them from then on.',
358
+ },
359
+ ];
360
+ }
339
361
  }
340
362
 
341
363
  /** @type {CheckOutcome} */
342
364
  const outcome = await settle(verdict, project.store, project.product, named);
365
+ // FIRST, before anything the run found. Which product was walked is the frame every
366
+ // other sentence here has to be read inside, and a person who was told it at the end
367
+ // has already read the clean result as being about the folder they are standing in.
368
+ if (project.elsewhere !== '') outcome.summary = `${project.elsewhere} ${outcome.summary}`;
343
369
  // Only a run that really did reach the surface it was aimed at may say so. The
344
370
  // confirmation is what lets a caller tell "it went there and found nothing" from
345
371
  // "it checked something else and found nothing", and those are not the same answer.
@@ -443,7 +469,14 @@ async function settle(verdict, store, product, guards) {
443
469
  verdict.findings = decided.reported;
444
470
  verdict.accounted = decided.accounting;
445
471
  if (verdict.blocked !== true) {
446
- verdict.ok = decided.reported.length === 0 && (verdict.newlyUnstable ?? []).length === 0;
472
+ // `verdict.ok !== false` first, and it is the whole point of the line. Accounting may
473
+ // take a pass AWAY — a finding nobody waived, an address that stopped being
474
+ // predictable — and it may never hand one back. This assigned instead of narrowing, so
475
+ // every not-a-pass the engine had already decided was thrown away here: a run where the
476
+ // product never answered, and a run drowning in wobble, both came back through this line
477
+ // as `ok: true`. Found by the refusal lane on 2026-08-31, and it had been quietly
478
+ // discarding the wobble verdict before that.
479
+ verdict.ok = verdict.ok !== false && decided.reported.length === 0 && (verdict.newlyUnstable ?? []).length === 0;
447
480
  // The count goes into the sentence a person and an agent both read, not into a field
448
481
  // one of them has to know to look for.
449
482
  if (decided.accounting.waived > 0 || decided.accounting.expiredWaivers > 0) {
@@ -471,6 +504,21 @@ async function settle(verdict, store, product, guards) {
471
504
  : `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}`;
472
505
  }
473
506
 
507
+ // THE CAUSE FIRST, when the cause is that the product never answered.
508
+ //
509
+ // A server that will not start produces a difference at every address it used to answer
510
+ // at — the content type gone, the body gone, "answered at all" arriving. Measured
511
+ // 2026-08-31 on a product whose start command throws: twelve findings, not one of them
512
+ // saying the server had not started, and a person reads "12 things behave differently"
513
+ // and goes looking for a regression in code that is fine. The symptoms are real and they
514
+ // belong in the list; they are just not the news.
515
+ const silent = didNotAnswer(verdict);
516
+ if (silent.length > 0) {
517
+ verdict.summary =
518
+ `THE PRODUCT DID NOT ANSWER. ${silent.length} ${silent.length === 1 ? 'way in was' : 'ways in were'} not tried at all — ${silent.slice(0, 3).join('; ')}${silent.length > 3 ? '; and more' : ''}. Most of what follows is that one fact wearing different clothes, not ${silent.length === 1 ? 'a separate change' : 'separate changes'}: fix the start and check again before reading any of it as a regression. ` +
519
+ verdict.summary;
520
+ }
521
+
474
522
  // And what was NOT looked at, in the same breath as the good news, on every run
475
523
  // including the clean ones. A green verdict on a product with three hundred doors
476
524
  // nobody has ever opened is true and it is not what it looks like, and the only place
@@ -620,7 +668,11 @@ export function whatWasNotChecked(coverage) {
620
668
  if (parts.length === 0) {
621
669
  return `Everything this run knows how to walk was walked — ${coverage.paths} ${coverage.paths === 1 ? 'address' : 'addresses'} across ${coverage.journeys} ${coverage.journeys === 1 ? 'journey' : 'journeys'}. That is not every possible state of your product; nothing can enumerate that, and a clean result only covers what was walked.`;
622
670
  }
623
- return `NOT EVERYTHING WAS CHECKED: ${parts.join(', and ')}. A clean result only covers what was walked — the whole list is in coverage.gaps.`;
671
+ // `staysfixed coverage`, not `coverage.gaps`. This sentence is printed at a person as well
672
+ // as returned to an agent, and a person at a terminal has no JSON field to open — being
673
+ // told to read one is being told to go nowhere. The command is true for both readers, and
674
+ // the agent already had that wording on its own reply.
675
+ return `NOT EVERYTHING WAS CHECKED: ${parts.join(', and ')}. A clean result only covers what was walked — \`staysfixed coverage\` has the whole list.`;
624
676
  }
625
677
 
626
678
  /**
@@ -659,6 +711,45 @@ function comparedNothing(verdict) {
659
711
  return nothingToCompare >= walked ? 'no stored record' : null;
660
712
  }
661
713
 
714
+ /**
715
+ * Walk the guards, if this project can be opened.
716
+ *
717
+ * Guards are the headline of this whole product — one plain-English rule per bug somebody
718
+ * already had — and the default command never ran them. It said so, which was better than
719
+ * pretending, but saying it is not doing it. Version 1 knows how to drive an app and run
720
+ * them; what was missing was anybody calling it from here.
721
+ *
722
+ * It needs an address it can open. Where the settings only say how to START the product,
723
+ * that is version 2's job and this path cannot do it — so it says so instead, and names the
724
+ * one line that would change it.
725
+ *
726
+ * @param {string} root
727
+ * @param {any} options
728
+ * @returns {Promise<{ran: boolean, why: string, said: string, failed: {name: string, message?: string}[]}>}
729
+ */
730
+ async function walkTheGuards(root, options) {
731
+ try {
732
+ const { loadProject } = await import('../core/config.js');
733
+ const { runCheck } = await import('../run.js');
734
+ const project = await loadProject({ cwd: root, configFile: options?.configFile });
735
+ const run = await runCheck(project, { guardsOnly: true, writeReport: false, quiet: true, signal: options?.signal });
736
+ const guards = run.guards ?? [];
737
+ const failed = guards.filter((g) => g.status === 'failed').map((g) => ({ name: g.name, message: g.message }));
738
+ const held = guards.length - failed.length;
739
+ return {
740
+ ran: true,
741
+ why: '',
742
+ said:
743
+ failed.length === 0
744
+ ? `All ${guards.length} guard${guards.length === 1 ? '' : 's'} still hold.`
745
+ : `${failed.length} of ${guards.length} guards failed — ${failed.length === 1 ? 'a bug' : 'bugs'} that ${failed.length === 1 ? 'was' : 'were'} already fixed ${failed.length === 1 ? 'is' : 'are'} back, and no agent may wave ${failed.length === 1 ? 'it' : 'them'} through. ${held} still hold.`,
746
+ failed,
747
+ };
748
+ } catch (e) {
749
+ return { ran: false, why: messageOf(e), said: '', failed: [] };
750
+ }
751
+ }
752
+
662
753
  /**
663
754
  * The guards this project has, by name.
664
755
  *
@@ -681,6 +772,125 @@ export async function guardNames(root) {
681
772
  }
682
773
  }
683
774
 
775
+ /**
776
+ * Is the thing that ran older than the code it was built from?
777
+ *
778
+ * This tool never builds anything — deliberately, because building somebody's project is not
779
+ * its business. But a project whose start command runs `dist/server.js` and whose source
780
+ * lives in `src/` will happily run YESTERDAY's build against today's source, compare it
781
+ * against a reference cut from the same stale output, and answer "Nothing that worked has
782
+ * changed" — about code it has never once executed. Measured 2026-08-31.
783
+ *
784
+ * It cannot be fixed by building; it can be SAID, which is all a coverage gap has to do.
785
+ *
786
+ * @param {string} root
787
+ * @param {any} config
788
+ * @returns {Promise<CoverageGap|null>}
789
+ */
790
+ async function builtBeforeItsSource(root, config) {
791
+ try {
792
+ let pkg = {};
793
+ try {
794
+ pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
795
+ } catch {
796
+ return null;
797
+ }
798
+ const scripts = /** @type {any} */ (pkg).scripts ?? {};
799
+ // `npm run start` says nothing about where the product lives; the answer is one level
800
+ // down, in the script it runs. Following that indirection is the difference between this
801
+ // check firing and never firing, because `init` writes exactly `npm run start`.
802
+ const through = (/** @type {string} */ line) => {
803
+ const run = /(?:npm run|yarn|pnpm run|pnpm)\s+([\w:-]+)/.exec(line);
804
+ const named = run ? scripts[run[1]] : line.includes('npm start') ? scripts.start : null;
805
+ return `${line} ${typeof named === 'string' ? named : ''}`;
806
+ };
807
+ const starts = [
808
+ ...(config?.process?.commands ?? []).map((/** @type {any} */ c) => String(c?.run ?? '')),
809
+ String(config?.http?.start ?? ''),
810
+ String(config?.web?.start ?? ''),
811
+ ].map(through).join(' ');
812
+ const named = /\b(dist|build|out|lib)\b/.exec(starts);
813
+ const builds = typeof scripts.build === 'string';
814
+ if (!named || !builds) return null;
815
+
816
+ const outDir = path.join(root, named[1]);
817
+ const srcDir = path.join(root, 'src');
818
+ const [built, source] = await Promise.all([newestUnder(outDir), newestUnder(srcDir)]);
819
+ if (built === 0 || source === 0 || source <= built) return null;
820
+
821
+ const behind = Math.round((source - built) / 1000);
822
+ /** @param {number} n @param {string} unit */
823
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'}`;
824
+ const howLong =
825
+ behind > 86400 ? plural(Math.round(behind / 86400), 'day') : behind > 3600 ? plural(Math.round(behind / 3600), 'hour') : plural(Math.max(1, Math.round(behind / 60)), 'minute');
826
+ return {
827
+ what: `What ran is older than the code it was built from — \`${named[1]}/\` is ${howLong} behind \`src/\`.`,
828
+ why: `This tool runs your product, it never builds it. So the build in \`${named[1]}/\` is what was walked, and your newer source was not executed at all. A clean result here says nothing whatever about the code you have just written — and the reference it was compared against was cut from the same stale output.`,
829
+ unlockedBy: 'Run your build before the check — `npm run build && npx staysfixed check` — or put the build into the start command in your settings.',
830
+ };
831
+ } catch {
832
+ return null;
833
+ }
834
+ }
835
+
836
+ /**
837
+ * The newest modification time anywhere under a folder, or 0 if there is nothing there.
838
+ * @param {string} dir
839
+ * @returns {Promise<number>}
840
+ */
841
+ async function newestUnder(dir) {
842
+ let newest = 0;
843
+ /** @param {string} at @param {number} depth */
844
+ const walk = async (at, depth) => {
845
+ if (depth > 6) return;
846
+ let entries = [];
847
+ try {
848
+ entries = await fsp.readdir(at, { withFileTypes: true });
849
+ } catch {
850
+ return;
851
+ }
852
+ for (const entry of entries) {
853
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
854
+ const full = path.join(at, entry.name);
855
+ if (entry.isDirectory()) await walk(full, depth + 1);
856
+ else {
857
+ try {
858
+ const at2 = (await fsp.stat(full)).mtimeMs;
859
+ if (at2 > newest) newest = at2;
860
+ } catch {
861
+ // gone between the listing and the question
862
+ }
863
+ }
864
+ }
865
+ };
866
+ await walk(dir, 0);
867
+ return newest;
868
+ }
869
+
870
+ /**
871
+ * The ways into the product that were never tried, and why.
872
+ *
873
+ * A build that would not start does not produce one finding — it produces one at every
874
+ * address it used to answer at, and the real news is nowhere in the list.
875
+ *
876
+ * @param {CheckOutcome} verdict
877
+ * @returns {string[]}
878
+ */
879
+ function didNotAnswer(verdict) {
880
+ /** @type {Set<string>} */
881
+ const said = new Set();
882
+ for (const finding of verdict.findings ?? []) {
883
+ for (const d of finding.differences ?? []) {
884
+ const path = String(d.path ?? '');
885
+ if (!path.endsWith('answered at all')) continue;
886
+ const name = path.split('.').slice(1, -1).join('.') || path;
887
+ const why = typeof d.candidate === 'string' ? d.candidate : typeof d.describe === 'string' ? d.describe : '';
888
+ said.add(why ? `${name} (${String(why).slice(0, 90)})` : name);
889
+ }
890
+ }
891
+ return [...said];
892
+ }
893
+
684
894
  /**
685
895
  * Copies left behind by runs that never finished.
686
896
  *
@@ -707,6 +917,13 @@ export async function sweepAbandonedScratch() {
707
917
  } catch {
708
918
  return;
709
919
  }
920
+ // Programs whose folder has ALREADY gone are swept first, because the loop below can never
921
+ // reach them: it walks folders, and theirs is not there any more. Fifteen `serve` processes
922
+ // were found in exactly that state on 2026-08-31, out of scratch folders deleted the day
923
+ // before. A folder that no longer exists is the strongest possible evidence that its run is
924
+ // over, so nothing that is still going is at risk here.
925
+ await stopRunsWhoseFolderHasGone();
926
+
710
927
  let taken = 0;
711
928
  for (const name of names) {
712
929
  if (taken >= MOST_PER_RUN) break;
@@ -726,11 +943,168 @@ export async function sweepAbandonedScratch() {
726
943
  }
727
944
  }
728
945
  if (!abandoned) continue;
946
+ // The folder is not the whole of what was left behind. Four `vite preview` servers from
947
+ // the day before were still running on this machine on 2026-08-31, out of scratch folders
948
+ // that had already been deleted — started by a run that died before it could stop them.
949
+ // Deleting the folder and leaving its programs running is a tool quietly consuming
950
+ // somebody's machine, and this one is going to be installed on machines that are not its
951
+ // author's. So the programs go first, and only then the folder.
952
+ await stopWhateverIsStillRunningIn(dir);
729
953
  await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
730
954
  taken += 1;
731
955
  }
732
956
  }
733
957
 
958
+ /**
959
+ * Stop programs still running out of a scratch folder that has already been deleted.
960
+ *
961
+ * Every one of these was started by a check and outlived it. They hold ports and memory on a
962
+ * machine that is usually not this tool's author's, and nothing else on it has a
963
+ * `staysfixed-check-` path in its command line, so the match cannot catch a stranger.
964
+ *
965
+ * A folder that still exists is left completely alone here — a run that is going right now
966
+ * has its folder, and stopping its own servers would be this function breaking the check that
967
+ * called it.
968
+ *
969
+ * POSIX only, for the same reason as {@link stopWhateverIsStillRunningIn}: asking Windows
970
+ * this question needs a different command, and a wrong one there could stop something else.
971
+ *
972
+ * @returns {Promise<void>}
973
+ */
974
+ async function stopRunsWhoseFolderHasGone() {
975
+ if (process.platform === 'win32') return;
976
+ /** @type {string} */
977
+ let listing = '';
978
+ try {
979
+ listing = (await exec('/bin/ps', ['-A', '-o', 'pid=,command='], { timeout: 10_000, maxBuffer: 8 * 1024 * 1024 })).stdout;
980
+ } catch {
981
+ return;
982
+ }
983
+ for (const line of listing.split('\n')) {
984
+ const folder = /(\S*staysfixed-check-[A-Za-z0-9]+)/.exec(line);
985
+ if (!folder) continue;
986
+ if (existsSync(folder[1])) continue;
987
+ const pid = Number.parseInt(line.trim().split(/\s+/)[0] ?? '', 10);
988
+ if (!Number.isFinite(pid) || pid <= 1 || pid === process.pid) continue;
989
+ for (const signal of /** @type {const} */ (['SIGTERM', 'SIGKILL'])) {
990
+ try {
991
+ process.kill(-pid, signal);
992
+ } catch {
993
+ try {
994
+ process.kill(pid, signal);
995
+ } catch {
996
+ // Gone already, or not ours to stop.
997
+ }
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Stop anything still running out of an abandoned scratch folder.
1005
+ *
1006
+ * Read from the process list rather than remembered, because the run that started these is
1007
+ * gone — that is what made the folder abandoned. A program is only killed when the folder
1008
+ * being reclaimed appears in its own command line, so nothing of anybody else's is touched.
1009
+ *
1010
+ * The whole group is signalled, not the one process: a server started through `npm run` is
1011
+ * a shell that started Node, and killing the shell alone leaves the server holding its port.
1012
+ *
1013
+ * POSIX only. Windows needs a different question asked of the machine, and a wrong one there
1014
+ * could kill something else, so it is left alone and said so rather than guessed at.
1015
+ *
1016
+ * @param {string} dir
1017
+ * @returns {Promise<void>}
1018
+ */
1019
+ async function stopWhateverIsStillRunningIn(dir) {
1020
+ if (process.platform === 'win32') return;
1021
+ /** @type {string} */
1022
+ let listing = '';
1023
+ try {
1024
+ listing = (await exec('/bin/ps', ['-A', '-o', 'pid=,command='], { timeout: 10_000, maxBuffer: 8 * 1024 * 1024 })).stdout;
1025
+ } catch {
1026
+ return;
1027
+ }
1028
+ for (const line of listing.split('\n')) {
1029
+ if (!line.includes(dir)) continue;
1030
+ const pid = Number.parseInt(line.trim().split(/\s+/)[0] ?? '', 10);
1031
+ if (!Number.isFinite(pid) || pid <= 1 || pid === process.pid) continue;
1032
+ for (const signal of /** @type {const} */ (['SIGTERM', 'SIGKILL'])) {
1033
+ try {
1034
+ process.kill(-pid, signal);
1035
+ } catch {
1036
+ try {
1037
+ process.kill(pid, signal);
1038
+ } catch {
1039
+ // Already gone, or somebody else's to stop. Either way there is nothing to do.
1040
+ }
1041
+ }
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * "There was nowhere to work", said the way every other refusal here is said.
1048
+ *
1049
+ * A check never runs anything against somebody's real folder: it copies the build into a
1050
+ * throwaway folder inside the machine's temporary directory first. When that folder cannot
1051
+ * be made, the run is over before it starts — and what a person was handed for it was the
1052
+ * operating system's own words, `ENOENT: no such file or directory, mkdtemp
1053
+ * '/nowhere/staysfixed-check-FHwIxx'`, pasted straight into the block this tool tells an
1054
+ * agent to put in a summary for the person who owns the product. Measured 2026-08-31 with
1055
+ * TMPDIR pointing at a folder that was not there, and again at one this user could not
1056
+ * write to. Every other refusal in this file is a plain sentence; that one was a stack.
1057
+ *
1058
+ * The three that actually happen each get their own sentence, because the thing to DO about
1059
+ * them is different every time: the folder is not there, the folder will not take writes, or
1060
+ * the disk is full. Anything else keeps the machine's own words, framed as the machine's —
1061
+ * when there is no sentence for it, the raw text is the only information there is, and
1062
+ * dropping it would leave somebody with nothing at all.
1063
+ *
1064
+ * @param {unknown} e
1065
+ * @returns {StaysFixedError}
1066
+ */
1067
+ function noScratchFolder(e) {
1068
+ const tmp = os.tmpdir();
1069
+ const code = String(/** @type {any} */ (e)?.code ?? '');
1070
+ // Worth naming only when a setting in this shell is what chose the folder. On a machine
1071
+ // where nothing set it, saying "TMPDIR" sends somebody looking for a setting they have not
1072
+ // got, and the folder is the operating system's own.
1073
+ const yours = (process.env.TMPDIR ?? '').replace(/\/$/, '') === tmp.replace(/\/$/, '')
1074
+ ? ' That folder is whatever TMPDIR is set to in this shell.'
1075
+ : '';
1076
+ /** @type {{why: string, hint: string}} */
1077
+ const said =
1078
+ code === 'ENOENT'
1079
+ ? {
1080
+ why: `There is no folder at ${tmp}, so there was nowhere to put it.`,
1081
+ hint: `Make that folder, or point TMPDIR at one that exists — or unset TMPDIR to fall back to this machine's own — and run the check again.${yours}`,
1082
+ }
1083
+ : code === 'EACCES' || code === 'EPERM'
1084
+ ? {
1085
+ why: `${tmp} is there, but this user is not allowed to write in it.`,
1086
+ hint: `Give yourself write access to that folder, or point TMPDIR at one you can write to, and run the check again.${yours}`,
1087
+ }
1088
+ : code === 'EROFS'
1089
+ ? {
1090
+ why: `${tmp} is on a disk that is mounted read-only, so nothing can be written there at all.`,
1091
+ hint: `Point TMPDIR at a folder on a disk that takes writes and run the check again.${yours}`,
1092
+ }
1093
+ : code === 'ENOSPC'
1094
+ ? {
1095
+ why: `The disk holding ${tmp} is full.`,
1096
+ hint: 'Free some space and run the check again. A check copies your project, so it needs about as much room as the project takes.',
1097
+ }
1098
+ : {
1099
+ why: `${tmp} would not take it. The machine said: ${messageOf(e)}`,
1100
+ hint: `Check that ${tmp} exists and that you can write in it, then run the check again.${yours}`,
1101
+ };
1102
+ return new StaysFixedError(
1103
+ `Stays Fixed could not make the throwaway folder it works in, so nothing was opened, nothing was walked and nothing was compared. ${said.why}`,
1104
+ { hint: said.hint, cause: e },
1105
+ );
1106
+ }
1107
+
734
1108
  /**
735
1109
  * Is that process still running? Signal 0 asks without sending anything.
736
1110
  * @param {number} pid
@@ -766,8 +1140,20 @@ function plainly(what) {
766
1140
  * @returns {CheckOutcome}
767
1141
  */
768
1142
  function blocked(options, e, storeTrouble) {
769
- const product = options.product ?? path.basename(path.resolve(options.cwd ?? options.root ?? process.cwd()));
1143
+ // The basename of the ROOT, not of the folder the command was typed in. Those are the
1144
+ // same folder on almost every run, and on the one where they differ this record is
1145
+ // written into the root's store — so naming it after the folder somebody happened to be
1146
+ // standing in filed a run under a product that store has never heard of.
1147
+ const root = projectRootFor(options);
1148
+ const product = options.product ?? path.basename(root);
770
1149
  const empty = { id: '', product };
1150
+ // Which folder this is about, said before the reason it could not be done. A blocked run
1151
+ // inside a sub-project is the easiest of all to misread: nothing was walked, so there is
1152
+ // nothing in the answer to give away that it was never about this folder in the first
1153
+ // place. `options.product` is passed rather than the name worked out above, because a name
1154
+ // taken from a folder is not a product name and quoting it as one would put a product in
1155
+ // front of somebody that nothing anywhere calls that.
1156
+ const elsewhere = aboutSomewhereElse({ from: startedIn(options), root, product: options.product })?.note ?? '';
771
1157
  return {
772
1158
  runId: new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14),
773
1159
  product,
@@ -790,7 +1176,7 @@ function blocked(options, e, storeTrouble) {
790
1176
  // The hint is the half that tells a person what to DO about it, and dropping it
791
1177
  // turns a helpful error into a dead end. Anything that blocks a run has to carry
792
1178
  // both halves all the way out to whoever reads the summary.
793
- summary: `The check could not be run, so this is not a pass and not a failure. ${storeTrouble ? `${storeTrouble} ` : ''}${messageOf(e)}${
1179
+ summary: `${elsewhere ? `${elsewhere} ` : ''}The check could not be run, so this is not a pass and not a failure. ${storeTrouble ? `${storeTrouble} ` : ''}${messageOf(e)}${
794
1180
  e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
795
1181
  }`,
796
1182
  durationMs: 0,
@@ -1198,6 +1584,10 @@ async function waitForItsWindow(pid, stopped) {
1198
1584
  * @property {string} storeTrouble Empty on a normal run. A plain sentence when the store
1199
1585
  * would not take this run's records — the run went ahead anyway, and every answer it
1200
1586
  * produces has to carry the admission that nothing about it was kept.
1587
+ * @property {string} elsewhere Empty when the check is about the folder it was typed in.
1588
+ * Otherwise the sentence naming which product this run is really about and where it is,
1589
+ * which goes on the FRONT of the answer so nobody reads a clean result as being about a
1590
+ * folder that was never walked.
1201
1591
  * @property {import('./run.js').Walker} walk
1202
1592
  * @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
1203
1593
  * @property {(capture: Capture) => Capture} normalise
@@ -1214,11 +1604,98 @@ async function waitForItsWindow(pid, stopped) {
1214
1604
  * @returns {string}
1215
1605
  */
1216
1606
  function projectRootFor(options) {
1217
- const from = path.resolve(options.cwd ?? options.root ?? process.cwd());
1607
+ const from = startedIn(options);
1218
1608
  const config = options.configFile ?? findConfigFile(from);
1219
1609
  return config ? rootForConfig(config) : from;
1220
1610
  }
1221
1611
 
1612
+ /**
1613
+ * The folder the command was actually typed in.
1614
+ *
1615
+ * @param {CheckOptions} options
1616
+ * @returns {string}
1617
+ */
1618
+ function startedIn(options) {
1619
+ return path.resolve(options.cwd ?? options.root ?? process.cwd());
1620
+ }
1621
+
1622
+ /**
1623
+ * Files that mean "this folder is a project in its own right".
1624
+ *
1625
+ * Any one of them is enough. They are the files a person points at when asked "where does
1626
+ * this thing live", and every one of them is what a package manager or a language's own
1627
+ * tooling reads as the top of a project.
1628
+ */
1629
+ const A_PROJECT_OF_ITS_OWN = [
1630
+ 'package.json', '.git', 'go.mod', 'Cargo.toml', 'pyproject.toml', 'pom.xml',
1631
+ 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json', 'deno.json',
1632
+ ];
1633
+
1634
+ /**
1635
+ * When a check is not about the folder somebody is standing in, say so.
1636
+ *
1637
+ * Settings are found by walking UP from where the command was typed, which is right: a
1638
+ * check run from `src/` is meant to be about the project `src/` is part of. But the same
1639
+ * walk reaches out of a project and into the one above it. Stand in a sub-folder that is
1640
+ * its own git repository with its own package.json, run a check, and the run quietly
1641
+ * measures the PARENT'S product and comes back clean — measured 2026-08-31, where a folder
1642
+ * holding `child-product` was reported on as `parent-product` with nothing said about the
1643
+ * swap. Somebody reading that has been handed a clean result about a product they were not
1644
+ * asking about, which is the exact failure this tool exists to prevent.
1645
+ *
1646
+ * So every run that is about somewhere else says which product it is about and where that
1647
+ * product is, and a run that stepped out of a project of its own says it loudly and says
1648
+ * what to do instead. Nothing is refused: reaching up is usually right, and being told what
1649
+ * happened is what makes it safe.
1650
+ *
1651
+ * Exported so a test can ask the question without running a whole check.
1652
+ *
1653
+ * @param {{from: string, root: string, product?: string}} where
1654
+ * @returns {{note: string, gap: CoverageGap|null}|null} Null when the check really is
1655
+ * about the folder it was typed in, which is the ordinary case.
1656
+ */
1657
+ export function aboutSomewhereElse(where) {
1658
+ const from = path.resolve(where.from);
1659
+ const root = path.resolve(where.root);
1660
+ if (from === root) return null;
1661
+ const named = where.product ? `"${where.product}"` : path.basename(root);
1662
+
1663
+ const itsOwn = A_PROJECT_OF_ITS_OWN.filter((name) => existsSync(path.join(from, name)));
1664
+ if (itsOwn.length === 0) {
1665
+ // Ordinary and usually wanted: somebody is standing inside the project they meant. One
1666
+ // sentence, so a clean answer still carries the name of what it is a clean answer about.
1667
+ return {
1668
+ note: `This check was aimed at ${named}, the project at ${shortPath(root)}; you ran it from ${shortPath(from)}, which is inside it.`,
1669
+ gap: null,
1670
+ };
1671
+ }
1672
+
1673
+ const because = itsOwn.includes('.git') && itsOwn.length > 1
1674
+ ? `it is its own git repository and has its own ${itsOwn.filter((n) => n !== '.git').join(' and ')}`
1675
+ : itsOwn.includes('.git')
1676
+ ? 'it is its own git repository'
1677
+ : `it has its own ${itsOwn.join(' and ')}`;
1678
+ // "was aimed at", not "walked": this same sentence goes on the front of a blocked run,
1679
+ // where nothing was walked at all, and a frame that claims more than happened would be
1680
+ // the same kind of lie in miniature as the one it is here to stop.
1681
+ const note =
1682
+ `NOT THE FOLDER YOU ARE STANDING IN. This check was aimed at ${named}, the project at ${shortPath(root)} — ` +
1683
+ `not at ${shortPath(from)}, which is where you ran it. That folder is a project in its own right (${because}) ` +
1684
+ `and it has no Stays Fixed settings of its own, so the settings from the folder above it are what this run used. ` +
1685
+ `Nothing inside ${shortPath(from)} was checked, so nothing below says anything about it. ` +
1686
+ `Run \`staysfixed init\` there to check that project on its own.`;
1687
+ return {
1688
+ note,
1689
+ gap: {
1690
+ what: `Everything in ${shortPath(from)}, the folder this check was run from.`,
1691
+ why:
1692
+ `It is a project in its own right (${because}), and it has no Stays Fixed settings of its own. ` +
1693
+ `Settings are found by walking up, so the ones at ${shortPath(root)} were used and ${named} was walked instead.`,
1694
+ unlockedBy: `Run \`staysfixed init\` in ${shortPath(from)} to give that project its own settings, and check it from there.`,
1695
+ },
1696
+ };
1697
+ }
1698
+
1222
1699
  /**
1223
1700
  * How many builds keep their whole record before the old ones are thinned out.
1224
1701
  *
@@ -1500,9 +1977,17 @@ async function openProject(options) {
1500
1977
  }
1501
1978
 
1502
1979
  await sweepAbandonedScratch();
1503
- const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
1504
- const evidenceDir = path.join(scratch, 'evidence');
1505
- await fsp.mkdir(evidenceDir, { recursive: true });
1980
+ /** @type {string} */
1981
+ let scratch;
1982
+ /** @type {string} */
1983
+ let evidenceDir;
1984
+ try {
1985
+ scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
1986
+ evidenceDir = path.join(scratch, 'evidence');
1987
+ await fsp.mkdir(evidenceDir, { recursive: true });
1988
+ } catch (e) {
1989
+ throw noScratchFolder(e);
1990
+ }
1506
1991
  // Who this belongs to, so a later run can tell an abandoned copy from one in use.
1507
1992
  await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
1508
1993
 
@@ -1591,6 +2076,14 @@ async function openProject(options) {
1591
2076
  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
2077
  });
1593
2078
  }
2079
+ // Which folder this answer is about, when it is not the one the command was typed in.
2080
+ // It goes in the coverage list as well as on the front of the summary because the list is
2081
+ // what a build server's table and the closing count both read, and a fact that lives on
2082
+ // one field somebody has to know to look for is a fact most readers never meet.
2083
+ const elsewhere = aboutSomewhereElse({ from: startedIn(options), root, product });
2084
+ if (elsewhere?.gap) gaps.push(elsewhere.gap);
2085
+ const stale = await builtBeforeItsSource(root, config);
2086
+ if (stale) gaps.push(stale);
1594
2087
  if (storeTrouble.length > 0) {
1595
2088
  gaps.push({
1596
2089
  what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
@@ -1633,6 +2126,7 @@ async function openProject(options) {
1633
2126
  journeys,
1634
2127
  gaps,
1635
2128
  storeTrouble: storeTrouble.join(' '),
2129
+ elsewhere: elsewhere?.note ?? '',
1636
2130
  walk,
1637
2131
  bootReference,
1638
2132
  normalise,
@@ -2227,6 +2721,19 @@ function nameOfReference(reference, asked) {
2227
2721
  async function exportBuild(root, reference, scratch) {
2228
2722
  const sha = reference.gitSha;
2229
2723
  if (!sha) return null;
2724
+ // A reference cut from a tree with uncommitted changes is filed under a fingerprint of
2725
+ // that TREE — an id like `work-76ac0155c8b9`, deliberately not the commit's — because the
2726
+ // files that were checked are not the files git has. Exporting the commit and calling it
2727
+ // "the old build" walked different code, and everything downstream believed it: an address
2728
+ // the record holds a real value for was walked against a build that never had it, the
2729
+ // silence was read as proof the address is new, and the reply said "is there now and was
2730
+ // not before" about a value sitting in the record on disk. Measured 2026-08-31.
2731
+ //
2732
+ // Falling back to the stored record is weaker, and the run says so. That is the same choice
2733
+ // this tool already makes everywhere the old build cannot be walked, and it is the honest
2734
+ // one: a weaker comparison you are told about beats a strong-looking comparison against
2735
+ // the wrong build.
2736
+ if (reference.dirty === true) return null;
2230
2737
  const dir = path.join(scratch, `reference-${sha.slice(0, 12)}`);
2231
2738
  await fsp.mkdir(dir, { recursive: true });
2232
2739
  try {