staysfixed 0.10.0 → 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.
@@ -79,6 +79,9 @@ import {
79
79
  trimForStorage,
80
80
  } from './contract.js';
81
81
  import { RemoteLinkLost, remoteRunner } from '../remote.js';
82
+ // Every wait here has a limit and every limit says what it was waiting for; the pieces are in
83
+ // process.js so there is one of each rather than one per adapter.
84
+ import { endOfChild, letGoOf } from './process.js';
82
85
 
83
86
  /** @typedef {import('./contract.js').Build} Build */
84
87
  /** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
@@ -814,21 +817,35 @@ export function asList(value) {
814
817
  */
815
818
  export async function pushBuild(host, localDir, remoteDir) {
816
819
  const started = Date.now();
817
- return await new Promise((resolve) => {
818
- const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
819
- const ssh = spawn('ssh', ['-o', 'BatchMode=yes', host, `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
820
- let trouble = '';
821
- tar.stdout.pipe(ssh.stdin);
822
- ssh.stderr.on('data', (d) => { trouble += String(d); });
823
- tar.stderr.on('data', (d) => { trouble += String(d); });
824
- ssh.on('close', (code) => {
825
- const ms = Date.now() - started;
826
- resolve(code === 0
827
- ? { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` }
828
- : { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `tar exited ${code}`}` });
829
- });
830
- ssh.on('error', (e) => resolve({ ok: false, ms: Date.now() - started, why: `Could not start the copy: ${e.message}` }));
831
- });
820
+ // `ConnectTimeout` because ssh with no answer at the far end will sit on a half-open socket
821
+ // for as long as the network lets it, and the whole point of this pass is that nothing here
822
+ // is allowed to wait without a clock on it.
823
+ const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
824
+ const ssh = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', host, `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
825
+ let trouble = '';
826
+ tar.stdout.pipe(ssh.stdin);
827
+ ssh.stderr.on('data', (d) => { trouble += String(d); });
828
+ tar.stderr.on('data', (d) => { trouble += String(d); });
829
+ // Nothing wants what ssh prints on its way through, and that is exactly why it has to be
830
+ // read. A pipe nobody empties fills up, and a full pipe blocks its writer for ever — the
831
+ // same hang as an unclosed one, arriving from the other direction.
832
+ ssh.stdout?.resume();
833
+ // A pipe with nobody reading it fills up and blocks the writer for ever, so if ssh is gone
834
+ // tar has to be told rather than left leaning on a dead pipe.
835
+ ssh.on('error', () => { try { tar.kill('SIGKILL'); } catch { /* already gone */ } });
836
+ tar.on('error', () => { try { ssh.kill('SIGKILL'); } catch { /* already gone */ } });
837
+
838
+ // Thirty minutes for a whole build over a network, which is far longer than it has ever
839
+ // taken and still a limit. A copy that never finishes and never says so is the shape of the
840
+ // hang this whole pass exists to remove.
841
+ const ended = await endOfChild(ssh, { limitMs: 30 * 60_000, what: `the copy of this build to ${host}` });
842
+ try { tar.kill('SIGKILL'); } catch { /* it finished on its own */ }
843
+ letGoOf(tar);
844
+
845
+ const ms = Date.now() - started;
846
+ if (ended.gaveUp) return { ok: false, ms, why: `${ended.why} ${trouble.trim().slice(0, 200)}`.trim() };
847
+ if (ended.code === 0) return { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` };
848
+ return { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `the copy ended with ${ended.code ?? ended.signal}`}` };
832
849
  }
833
850
 
834
851
  // ---------------------------------------------------------------------------
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
 
@@ -362,6 +362,10 @@ export async function check(options = {}) {
362
362
 
363
363
  /** @type {CheckOutcome} */
364
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}`;
365
369
  // Only a run that really did reach the surface it was aimed at may say so. The
366
370
  // confirmation is what lets a caller tell "it went there and found nothing" from
367
371
  // "it checked something else and found nothing", and those are not the same answer.
@@ -465,7 +469,14 @@ async function settle(verdict, store, product, guards) {
465
469
  verdict.findings = decided.reported;
466
470
  verdict.accounted = decided.accounting;
467
471
  if (verdict.blocked !== true) {
468
- 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;
469
480
  // The count goes into the sentence a person and an agent both read, not into a field
470
481
  // one of them has to know to look for.
471
482
  if (decided.accounting.waived > 0 || decided.accounting.expiredWaivers > 0) {
@@ -657,7 +668,11 @@ export function whatWasNotChecked(coverage) {
657
668
  if (parts.length === 0) {
658
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.`;
659
670
  }
660
- 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.`;
661
676
  }
662
677
 
663
678
  /**
@@ -902,6 +917,13 @@ export async function sweepAbandonedScratch() {
902
917
  } catch {
903
918
  return;
904
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
+
905
927
  let taken = 0;
906
928
  for (const name of names) {
907
929
  if (taken >= MOST_PER_RUN) break;
@@ -921,11 +943,168 @@ export async function sweepAbandonedScratch() {
921
943
  }
922
944
  }
923
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);
924
953
  await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
925
954
  taken += 1;
926
955
  }
927
956
  }
928
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
+
929
1108
  /**
930
1109
  * Is that process still running? Signal 0 asks without sending anything.
931
1110
  * @param {number} pid
@@ -961,8 +1140,20 @@ function plainly(what) {
961
1140
  * @returns {CheckOutcome}
962
1141
  */
963
1142
  function blocked(options, e, storeTrouble) {
964
- 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);
965
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 ?? '';
966
1157
  return {
967
1158
  runId: new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14),
968
1159
  product,
@@ -985,7 +1176,7 @@ function blocked(options, e, storeTrouble) {
985
1176
  // The hint is the half that tells a person what to DO about it, and dropping it
986
1177
  // turns a helpful error into a dead end. Anything that blocks a run has to carry
987
1178
  // both halves all the way out to whoever reads the summary.
988
- 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)}${
989
1180
  e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
990
1181
  }`,
991
1182
  durationMs: 0,
@@ -1393,6 +1584,10 @@ async function waitForItsWindow(pid, stopped) {
1393
1584
  * @property {string} storeTrouble Empty on a normal run. A plain sentence when the store
1394
1585
  * would not take this run's records — the run went ahead anyway, and every answer it
1395
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.
1396
1591
  * @property {import('./run.js').Walker} walk
1397
1592
  * @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
1398
1593
  * @property {(capture: Capture) => Capture} normalise
@@ -1409,11 +1604,98 @@ async function waitForItsWindow(pid, stopped) {
1409
1604
  * @returns {string}
1410
1605
  */
1411
1606
  function projectRootFor(options) {
1412
- const from = path.resolve(options.cwd ?? options.root ?? process.cwd());
1607
+ const from = startedIn(options);
1413
1608
  const config = options.configFile ?? findConfigFile(from);
1414
1609
  return config ? rootForConfig(config) : from;
1415
1610
  }
1416
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
+
1417
1699
  /**
1418
1700
  * How many builds keep their whole record before the old ones are thinned out.
1419
1701
  *
@@ -1695,9 +1977,17 @@ async function openProject(options) {
1695
1977
  }
1696
1978
 
1697
1979
  await sweepAbandonedScratch();
1698
- const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
1699
- const evidenceDir = path.join(scratch, 'evidence');
1700
- 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
+ }
1701
1991
  // Who this belongs to, so a later run can tell an abandoned copy from one in use.
1702
1992
  await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
1703
1993
 
@@ -1786,6 +2076,12 @@ async function openProject(options) {
1786
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.`,
1787
2077
  });
1788
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);
1789
2085
  const stale = await builtBeforeItsSource(root, config);
1790
2086
  if (stale) gaps.push(stale);
1791
2087
  if (storeTrouble.length > 0) {
@@ -1830,6 +2126,7 @@ async function openProject(options) {
1830
2126
  journeys,
1831
2127
  gaps,
1832
2128
  storeTrouble: storeTrouble.join(' '),
2129
+ elsewhere: elsewhere?.note ?? '',
1833
2130
  walk,
1834
2131
  bootReference,
1835
2132
  normalise,
@@ -2424,6 +2721,19 @@ function nameOfReference(reference, asked) {
2424
2721
  async function exportBuild(root, reference, scratch) {
2425
2722
  const sha = reference.gitSha;
2426
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;
2427
2737
  const dir = path.join(scratch, `reference-${sha.slice(0, 12)}`);
2428
2738
  await fsp.mkdir(dir, { recursive: true });
2429
2739
  try {