staysfixed 0.4.0 → 0.6.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.
@@ -748,12 +748,16 @@ function methodNames(tokens, nameAt) {
748
748
  * @param {string} opts.root Project root. Only ever read.
749
749
  * @param {string[]} [opts.folders] Subfolders to read. Defaults to the usual ones.
750
750
  * @param {boolean} [opts.includeTests] Count doors found in test files. Default false.
751
- * @param {number} [opts.maxFileBytes] Skip anything bigger. Default 2MB.
751
+ * @param {number} [opts.maxFileBytes] Skip anything bigger. Default 24MB — a built
752
+ * bundle is a legitimate thing to read, and Terminal Deck's is 3.5MB. At the old 2MB
753
+ * the whole main process was skipped and the reader then said it had found no source
754
+ * at all, which is the exact shape of failure this tool exists to prevent: a silence
755
+ * that reads like an all-clear.
752
756
  * @returns {Promise<ContractReading>}
753
757
  */
754
758
  export async function readContract(opts) {
755
759
  const root = path.resolve(opts.root);
756
- const maxFileBytes = opts.maxFileBytes ?? 2 * 1024 * 1024;
760
+ const maxFileBytes = opts.maxFileBytes ?? 24 * 1024 * 1024;
757
761
  const found = await collectFiles(root, opts.folders ?? SOURCE_FOLDERS, maxFileBytes);
758
762
 
759
763
  /** @type {RawDoor[]} */
@@ -767,6 +771,12 @@ export async function readContract(opts) {
767
771
  filesRead: 0, filesSkipped: found.skipped, testFiles: 0, lexRecoveries: 0,
768
772
  typesStripped: 0, unnamed: 0, viaConstant: 0, duplicates: 0, problems: [], counts: {},
769
773
  };
774
+ for (const where of found.unreadable) {
775
+ report.problems.push(`${where} could not be opened, so any door behind it is invisible to this run.`);
776
+ }
777
+ for (const big of found.tooBig) {
778
+ report.problems.push(`${big} is bigger than the ${Math.round(maxFileBytes / (1024 * 1024))}MB this reader will open, so its doors were not read.`);
779
+ }
770
780
 
771
781
  for (const rel of found.files) {
772
782
  let text;
@@ -854,6 +864,10 @@ function describeError(error) {
854
864
  * @param {number} maxFileBytes
855
865
  */
856
866
  async function collectFiles(root, folders, maxFileBytes) {
867
+ /** @type {string[]} Files skipped for size, named so the gap can be reported. */
868
+ const tooBig = [];
869
+ /** @type {string[]} A folder that could not be opened at all, named for the same reason. */
870
+ const unreadable = [];
857
871
  /** @type {string[]} */
858
872
  const files = [];
859
873
  let skipped = 0;
@@ -864,7 +878,12 @@ async function collectFiles(root, folders, maxFileBytes) {
864
878
  let entries;
865
879
  try {
866
880
  entries = await fsp.readdir(dir, { withFileTypes: true });
867
- } catch {
881
+ } catch (e) {
882
+ // A folder that will not open — a permission, a broken mount, a case-clash — used to
883
+ // vanish without a word, and every door behind it vanished with it. That is the same
884
+ // bug as the 2MB file limit wearing a different hat: fewer doors reported, and nothing
885
+ // anywhere saying so. Name it.
886
+ unreadable.push(`${path.relative(root, dir) || '.'} (${describeError(e)})`);
868
887
  return;
869
888
  }
870
889
  for (const entry of entries) {
@@ -879,8 +898,16 @@ async function collectFiles(root, folders, maxFileBytes) {
879
898
  if (!CODE_EXTENSIONS.has(path.extname(entry.name))) continue;
880
899
  if (/\.d\.[cm]?ts$/.test(entry.name)) continue; // declarations describe, they open nothing
881
900
  try {
882
- if ((await fsp.stat(full)).size > maxFileBytes) { skipped++; continue; }
883
- } catch {
901
+ // A file too big to read is a hole, and a hole has to be named. Recording the
902
+ // path — not just a count — is what lets the coverage ledger say WHICH door it
903
+ // cannot see rather than quietly reporting fewer of them.
904
+ if ((await fsp.stat(full)).size > maxFileBytes) {
905
+ skipped++;
906
+ tooBig.push(path.relative(root, full));
907
+ continue;
908
+ }
909
+ } catch (e) {
910
+ unreadable.push(`${path.relative(root, full)} (${describeError(e)})`);
884
911
  continue;
885
912
  }
886
913
  files.push(path.relative(root, full));
@@ -890,7 +917,11 @@ async function collectFiles(root, folders, maxFileBytes) {
890
917
  const roots = folders.map((f) => path.join(root, f)).filter((d) => fs.existsSync(d));
891
918
  for (const dir of roots.length > 0 ? roots : [root]) await walk(dir);
892
919
  files.sort();
893
- return { files, skipped };
920
+ // A source folder that was asked for and is not there at all is worth saying too: a typo in
921
+ // "folders" reads exactly like a project with no code in it.
922
+ const asked = folders.map((f) => path.join(root, f));
923
+ const present = asked.filter((d) => fs.existsSync(d));
924
+ return { files, skipped, tooBig, unreadable, lookedIn: present.length > 0 ? present.map((d) => path.relative(root, d)) : ['the whole project folder'] };
894
925
  }
895
926
 
896
927
  // ---------------------------------------------------------------------------
@@ -902,12 +933,18 @@ async function collectFiles(root, folders, maxFileBytes) {
902
933
  * Both layouts are handled: an app folder, where a `route` file's exported method names are
903
934
  * the verbs, and a pages/api folder, where the file itself is the route.
904
935
  *
936
+ * A folder that cannot be opened takes every route behind it, so it is named rather than
937
+ * skipped. This is the same bug as the one fixed in the file walk on 2026-08-30 — a hole that
938
+ * looks exactly like a project with no routes in it — and it was still here in this function.
939
+ *
905
940
  * @param {string} root
906
- * @returns {Promise<Door[]>}
941
+ * @returns {Promise<{doors: Door[], problems: string[]}>}
907
942
  */
908
943
  export async function readFileRoutes(root) {
909
944
  /** @type {Door[]} */
910
945
  const doors = [];
946
+ /** @type {string[]} */
947
+ const problems = [];
911
948
 
912
949
  /**
913
950
  * @param {string} base
@@ -921,7 +958,12 @@ export async function readFileRoutes(root) {
921
958
  const dir = /** @type {string} */ (stack.pop());
922
959
  /** @type {import('node:fs').Dirent[]} */
923
960
  let entries;
924
- try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { continue; }
961
+ try {
962
+ entries = await fsp.readdir(dir, { withFileTypes: true });
963
+ } catch (e) {
964
+ problems.push(`${path.relative(root, dir) || '.'} could not be opened, so any route behind it is invisible to this run (${describeError(e)}).`);
965
+ continue;
966
+ }
925
967
  for (const entry of entries) {
926
968
  const full = path.join(dir, entry.name);
927
969
  if (entry.isDirectory()) {
@@ -972,7 +1014,7 @@ export async function readFileRoutes(root) {
972
1014
  });
973
1015
  }
974
1016
 
975
- return doors;
1017
+ return { doors, problems };
976
1018
  }
977
1019
 
978
1020
  /**
@@ -1162,7 +1204,10 @@ export const sourceAdapter = defineAdapter({
1162
1204
  /** @param {import('./contract.js').AdapterProject} project */
1163
1205
  async detect(project) {
1164
1206
  const folders = project.config?.folders ?? SOURCE_FOLDERS;
1165
- const found = await collectFiles(project.root, folders, 2 * 1024 * 1024);
1207
+ // The same limit readContract uses. It was 2MB here and 24MB there, so a project
1208
+ // whose source is one big bundle was declared to have no source at all — and then
1209
+ // never read, even though the reader could have read it perfectly well.
1210
+ const found = await collectFiles(project.root, folders, 24 * 1024 * 1024);
1166
1211
  const canStrip = typeof nodeModule.stripTypeScriptTypes === 'function';
1167
1212
  /** @type {import('./contract.js').Missing[]} */
1168
1213
  const missing = [];
@@ -1214,7 +1259,13 @@ export const sourceAdapter = defineAdapter({
1214
1259
  */
1215
1260
  async run(journey, build) {
1216
1261
  const reading = await readContract({ root: build.root });
1217
- reading.doors.push(...await readFileRoutes(build.root));
1262
+ const fileRoutes = await readFileRoutes(build.root);
1263
+ reading.doors.push(...fileRoutes.doors);
1264
+ // A folder the route walk could not open is a hole in the door list, and the door list is
1265
+ // the channel that catches doors disappearing. It goes where every other reading problem
1266
+ // goes: into the report, which becomes an observation of its own, so it shows up as a
1267
+ // difference the moment it starts or stops happening.
1268
+ reading.report.problems.push(...fileRoutes.problems);
1218
1269
  reading.doors.push(...await readPackageCommands(build.root));
1219
1270
  reading.report.counts = {};
1220
1271
  for (const found of reading.doors) {
@@ -42,8 +42,8 @@ import path from 'node:path';
42
42
  import { spawn } from 'node:child_process';
43
43
 
44
44
  import {
45
- countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
46
- trimForStorage, undoOurFootprint,
45
+ countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
46
+ timeBucket, trimForStorage, undoOurFootprint,
47
47
  } from './contract.js';
48
48
  import { copyForScratch, frozenEnvironment } from './process.js';
49
49
  import { freePort, looksDestructive, waitForServer } from './http.js';
@@ -624,13 +624,13 @@ export const webAdapter = defineAdapter({
624
624
  out.push(...describeComplaints(journey, handle.consoleErrors()));
625
625
 
626
626
  out.push(
627
- observation({
627
+ howLongItTook({
628
628
  channel: 'counters',
629
629
  path: joinPath('count', journey.name, 'how long the steps took'),
630
- value: timeBucket(Date.now() - started),
631
- says: `Walking the steps of "${journey.name}" took ${timeBucket(Date.now() - started)}, not counting opening the browser, which is our time and not the app's. Deliberately rough: exact timings differ on every run and would drown everything else.`,
630
+ ms: Date.now() - started,
631
+ what: `Walking the steps of "${journey.name}"`,
632
+ andAlso: 'This does not count opening the browser, which is our time and not the app\'s.',
632
633
  journey: journey.name,
633
- surface: 'web',
634
634
  }),
635
635
  );
636
636
 
package/src/v2/cause.js CHANGED
@@ -60,12 +60,17 @@ const run = promisify(execFile);
60
60
  * @property {string} [worktree] Where it ran, when `keep` was asked for.
61
61
  */
62
62
 
63
- /**
64
- * How many of a finding's differences are worth re-checking. A finding can stand
65
- * for two hundred addresses; if the first handful come back the same way, the
66
- * two hundredth will too, and walking them all buys nothing.
67
- */
68
- const CHECK_AT_MOST = 5;
63
+ // EVERY difference in the finding is re-checked, and there is deliberately no ceiling.
64
+ //
65
+ // Until 2026-08-30 this checked the first five and then said "caused by that change" about
66
+ // the whole finding. A cluster of three hundred addresses where the first five went away and
67
+ // the other two hundred and ninety-five did not came back PROVED — the agent then had a
68
+ // machine-checked reason to wave the whole thing through, and the rest of the break went with
69
+ // it. That is this tool's worst failure shape: a confident sentence covering a silence.
70
+ //
71
+ // The comment that used to sit here said walking them all buys nothing. It buys the claim.
72
+ // And it costs nothing to buy: the journeys were re-walked already, the observations are in
73
+ // the map below, and each extra difference is one lookup in it.
69
74
 
70
75
  /**
71
76
  * Prove, or disprove, that one change caused one finding.
@@ -192,7 +197,7 @@ export async function proveCause(finding, opts) {
192
197
  without.set(journey.name, indexByPath(settled.observations));
193
198
  }
194
199
 
195
- const differences = finding.differences.slice(0, CHECK_AT_MOST);
200
+ const differences = finding.differences;
196
201
  let disappeared = 0;
197
202
  for (const d of differences) if (gone(d, without)) disappeared += 1;
198
203
 
@@ -207,7 +212,9 @@ export async function proveCause(finding, opts) {
207
212
  ? 'This finding carries no differences, so there was nothing to re-check.'
208
213
  : proved
209
214
  ? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained.`
210
- : `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet.`,
215
+ : disappeared > 0
216
+ ? `Undoing that change in ${hunk.file} took away ${disappeared} of the ${differences.length} addresses in this finding and left ${differences.length - disappeared} exactly as ${differences.length - disappeared === 1 ? 'it was' : 'they were'}. So that change explains part of this and not the rest, and the rest has another cause nothing has looked for yet. It is not covered by undoing that one change.`
217
+ : `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet.`,
211
218
  hunk: { file: hunk.file, header: hunk.header },
212
219
  checked: differences.length,
213
220
  disappeared,
package/src/v2/check.js CHANGED
@@ -27,7 +27,8 @@ import fsp from 'node:fs/promises';
27
27
  import { existsSync } from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import os from 'node:os';
30
- import { execFile } from 'node:child_process';
30
+ import { execFile, spawn } from 'node:child_process';
31
+ import { createHash } from 'node:crypto';
31
32
  import { promisify } from 'node:util';
32
33
 
33
34
  import { StaysFixedError, messageOf } from '../core/errors.js';
@@ -270,6 +271,7 @@ export async function check(options = {}) {
270
271
  product: project.product,
271
272
  candidate: project.candidate,
272
273
  journeys: project.journeys,
274
+ gaps: project.gaps,
273
275
  walk: project.walk,
274
276
  cwd: project.root,
275
277
  bootReference: project.bootReference,
@@ -695,6 +697,10 @@ function short(value) {
695
697
  * @property {BuildFingerprint} candidate
696
698
  * @property {string} [against] The reference build's own id, once a name has been resolved.
697
699
  * @property {Journey[]} journeys
700
+ * @property {CoverageGap[]} gaps Holes found while working out WHAT to walk, before a
701
+ * single journey ran. An adapter that fell over listing its journeys belongs here, and it
702
+ * has to reach the verdict: a channel that silently dropped out is the worst thing this
703
+ * tool can do.
698
704
  * @property {import('./run.js').Walker} walk
699
705
  * @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
700
706
  * @property {(capture: Capture) => Capture} normalise
@@ -732,17 +738,11 @@ async function openProject(options) {
732
738
  const evidenceDir = path.join(scratch, 'evidence');
733
739
  await fsp.mkdir(evidenceDir, { recursive: true });
734
740
 
735
- const candidate = await fingerprintWorkingTree(root, product);
736
- await saveBuild(store, candidate);
737
-
738
- // A name like "HEAD", "v0.13.0" or a branch is what a person types; the store only knows
739
- // builds. Turning the name into a commit here, and putting that commit in the store, is
740
- // what lets a check be aimed at any point in history without every commit having been
741
- // walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
742
- const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
743
- if (reference) await saveBuild(store, reference);
744
-
745
- const journeys = narrowToTarget(await gatherJourneys({ root, config, options }), aim);
741
+ // Working out what there is to walk comes FIRST, before anything is asked of git. Somebody
742
+ // standing in a folder they have not set up yet should be told to run `init`, not told
743
+ // about a git requirement they have no reason to care about yet.
744
+ const gathered = await gatherJourneys({ root, config, options });
745
+ const journeys = narrowToTarget(gathered.journeys, aim);
746
746
  if (journeys.length === 0) {
747
747
  await fsp.rm(scratch, { recursive: true, force: true });
748
748
  // Two different situations wear the same symptom, and the difference is the
@@ -762,6 +762,16 @@ async function openProject(options) {
762
762
  });
763
763
  }
764
764
 
765
+ const candidate = await fingerprintWorkingTree(root, product);
766
+ await saveBuild(store, candidate);
767
+
768
+ // A name like "HEAD", "v0.13.0" or a branch is what a person types; the store only knows
769
+ // builds. Turning the name into a commit here, and putting that commit in the store, is
770
+ // what lets a check be aimed at any point in history without every commit having been
771
+ // walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
772
+ const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
773
+ if (reference) await saveBuild(store, reference);
774
+
765
775
  const rules = mergeRules(DEFAULT_RULES, [
766
776
  ...machineRules({ root, home: os.homedir(), tmp: os.tmpdir() }),
767
777
  ...machineRules({ root: scratch }),
@@ -793,6 +803,7 @@ async function openProject(options) {
793
803
  candidate,
794
804
  against: reference ? reference.id : options.against,
795
805
  journeys,
806
+ gaps: gathered.gaps,
796
807
  walk,
797
808
  bootReference,
798
809
  normalise,
@@ -1063,11 +1074,13 @@ function adapterFor(journey) {
1063
1074
  * all, and it is the only channel that sees a door nobody has ever walked through.
1064
1075
  *
1065
1076
  * @param {{root: string, config: Record<string, any>, options: CheckOptions}} a
1066
- * @returns {Promise<Journey[]>}
1077
+ * @returns {Promise<{journeys: Journey[], gaps: CoverageGap[]}>}
1067
1078
  */
1068
1079
  async function gatherJourneys({ root, config, options }) {
1069
1080
  /** @type {Journey[]} */
1070
1081
  const journeys = [];
1082
+ /** @type {CoverageGap[]} */
1083
+ const gaps = [];
1071
1084
 
1072
1085
  const named = options.journeys && options.journeys !== 'code' && options.journeys !== 'config' ? options.journeys : null;
1073
1086
  if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
@@ -1082,21 +1095,44 @@ async function gatherJourneys({ root, config, options }) {
1082
1095
  let detection;
1083
1096
  try {
1084
1097
  detection = await adapter.detect(project);
1085
- } catch {
1098
+ } catch (e) {
1099
+ // BOTH of these used to be swallowed without a word, and that is the same shape of
1100
+ // failure as the source reader skipping a 3.5MB bundle: a whole channel drops out of
1101
+ // the run, nothing is walked, and the verdict says "nothing that worked has changed".
1102
+ // An adapter that FALLS OVER is a hole. An adapter that says "this is not my kind of
1103
+ // project" is not, which is why only the throw is recorded here.
1104
+ gaps.push({
1105
+ what: `Nothing was checked through the "${adapter.name}" adapter, because it could not even work out whether it applies to this project.`,
1106
+ why: messageOf(e),
1107
+ unlockedBy: `Run \`staysfixed doctor\` to see what the ${adapter.name} adapter needs here. Until then, anything only it can see is not being watched.`,
1108
+ });
1086
1109
  continue;
1087
1110
  }
1088
1111
  if (!detection.applies) continue;
1089
1112
  if (adapter !== sourceAdapter && named) continue;
1090
1113
  try {
1091
1114
  journeys.push(...(await adapter.journeys(project)));
1092
- } catch {
1093
- // An adapter that cannot list its journeys contributes none. It is not a reason to
1094
- // throw away the ones that could.
1115
+ } catch (e) {
1116
+ gaps.push({
1117
+ what: `The "${adapter.name}" adapter applies to this project and could not say what it would walk, so it walked nothing.`,
1118
+ why: messageOf(e),
1119
+ unlockedBy: `Fix what it is complaining about, or name the steps yourself in a journeys file. This is a hole, not a pass.`,
1120
+ });
1095
1121
  }
1096
1122
  }
1097
1123
 
1098
1124
  const only = options.only ?? [];
1099
1125
  const chosen = only.length > 0 ? journeys.filter((j) => only.some((n) => j.name === n || j.name.includes(n))) : journeys;
1126
+ if (only.length > 0) {
1127
+ for (const wanted of only) {
1128
+ if (chosen.some((j) => j.name === wanted || j.name.includes(wanted))) continue;
1129
+ gaps.push({
1130
+ what: `You asked for the journey "${wanted}" and there is no journey by that name, so it was not walked.`,
1131
+ why: 'A name that matches nothing narrows the run to nothing rather than to what you meant.',
1132
+ unlockedBy: `The journeys this project has are: ${journeys.map((j) => j.name).slice(0, 12).join(', ') || 'none'}.`,
1133
+ });
1134
+ }
1135
+ }
1100
1136
 
1101
1137
  // Two journeys with one name would write into one another's records.
1102
1138
  /** @type {Journey[]} */
@@ -1107,7 +1143,7 @@ async function gatherJourneys({ root, config, options }) {
1107
1143
  seen.add(j.name);
1108
1144
  out.push(j);
1109
1145
  }
1110
- return out;
1146
+ return { journeys: out, gaps };
1111
1147
  }
1112
1148
 
1113
1149
  /**
@@ -1188,19 +1224,46 @@ async function readConfig(configFile) {
1188
1224
  */
1189
1225
  async function fingerprintWorkingTree(root, product) {
1190
1226
  const sha = await git(root, ['rev-parse', 'HEAD']);
1191
- const diff = (await git(root, ['diff', 'HEAD'])) ?? '';
1192
- const untracked = (await git(root, ['ls-files', '--others', '--exclude-standard'])) ?? '';
1193
- const dirty = diff.trim() !== '' || untracked.trim() !== '';
1227
+ if (!sha) {
1228
+ // REFUSING IS THE ONLY HONEST ANSWER HERE, and the alternative is the worst bug this
1229
+ // tool could have. Without git there is nothing to tell one build from another, so every
1230
+ // run would be fingerprinted identically, the build you just changed would carry the same
1231
+ // id as the build you were happy with, and comparing a build against itself produces zero
1232
+ // differences — a permanent, confident, completely false all-clear.
1233
+ throw new StaysFixedError(
1234
+ 'This folder is not a git repository with a commit in it, and Stays Fixed tells one build from another by what git says is in it.',
1235
+ {
1236
+ hint:
1237
+ 'Run it inside your project (or `git init && git commit` first). Without git every run would look like the same build, ' +
1238
+ 'and a check that compares a build against itself always comes back clean — which would be a lie, so it is refused instead.',
1239
+ },
1240
+ );
1241
+ }
1242
+ // Streamed into a hash rather than read into a string. `git diff` used to go through a
1243
+ // buffer with a 32MB ceiling, and a diff over the ceiling made the git call FAIL — which
1244
+ // was caught, treated as an empty diff, and the working tree was then declared clean. A
1245
+ // big uncommitted change therefore got the id of the commit it sat on top of; if that
1246
+ // commit was the reference, the check compared the build against itself and reported that
1247
+ // nothing had changed. Nothing about a diff's size may ever decide whether a change exists.
1248
+ const diff = await gitDigest(root, ['diff', 'HEAD']);
1249
+ const untracked = await gitDigest(root, ['ls-files', '--others', '--exclude-standard']);
1250
+ if (!diff.ok || !untracked.ok) {
1251
+ throw new StaysFixedError(
1252
+ `Git could not say what has changed in this working tree, so there is no way to tell this build apart from the last one. ${diff.why ?? untracked.why ?? ''}`.trim(),
1253
+ { hint: 'Fix that and run again. Guessing "nothing has changed" here would make every later answer worthless.' },
1254
+ );
1255
+ }
1256
+ const dirty = !diff.empty || !untracked.empty;
1194
1257
  const version = await packageVersion(root);
1195
1258
 
1196
1259
  /** @type {BuildFingerprint} */
1197
1260
  const build = {
1198
- id: dirty ? `work-${sha256(`${sha ?? ''}\n${diff}\n${untracked}`).slice(0, 12)}` : `git-${(sha ?? 'unknown').slice(0, 12)}`,
1261
+ id: dirty ? `work-${sha256(`${sha}\n${diff.digest}\n${untracked.digest}`).slice(0, 12)}` : `git-${sha.slice(0, 12)}`,
1199
1262
  product,
1200
1263
  platform: `${process.platform}-${process.arch}`,
1201
1264
  builtAt: new Date().toISOString(),
1202
1265
  };
1203
- if (sha) build.gitSha = sha;
1266
+ build.gitSha = sha;
1204
1267
  if (version) build.version = dirty ? `${version} with uncommitted changes` : version;
1205
1268
  if (dirty) build.dirty = true;
1206
1269
  const branch = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
@@ -1276,6 +1339,41 @@ async function exportBuild(root, reference, scratch) {
1276
1339
  // Small things
1277
1340
  // ---------------------------------------------------------------------------
1278
1341
 
1342
+ /**
1343
+ * Run a git command and hash its output as it arrives, without ever holding it in memory.
1344
+ *
1345
+ * This exists because of a specific failure: reading `git diff` into a string through a
1346
+ * buffer with a ceiling turns "your change is enormous" into "the command failed", and the
1347
+ * caller then has to guess. A hash costs nothing, has no ceiling, and answers the only two
1348
+ * questions the fingerprint asks — was there anything, and was it the same thing as last time.
1349
+ *
1350
+ * @param {string} cwd
1351
+ * @param {string[]} args
1352
+ * @returns {Promise<{ok: boolean, empty: boolean, digest: string, why?: string}>}
1353
+ */
1354
+ function gitDigest(cwd, args) {
1355
+ return new Promise((resolve) => {
1356
+ const hash = createHash('sha256');
1357
+ let bytes = 0;
1358
+ /** @type {string[]} */
1359
+ const complaints = [];
1360
+ const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
1361
+ child.stdout.on('data', (chunk) => {
1362
+ bytes += chunk.length;
1363
+ hash.update(chunk);
1364
+ });
1365
+ child.stderr.on('data', (chunk) => complaints.push(String(chunk)));
1366
+ child.on('error', (e) => resolve({ ok: false, empty: true, digest: '', why: messageOf(e) }));
1367
+ child.on('close', (code) => {
1368
+ if (code !== 0) {
1369
+ resolve({ ok: false, empty: true, digest: '', why: complaints.join(' ').trim() || `git exited with ${code}` });
1370
+ return;
1371
+ }
1372
+ resolve({ ok: true, empty: bytes === 0, digest: hash.digest('hex') });
1373
+ });
1374
+ });
1375
+ }
1376
+
1279
1377
  /**
1280
1378
  * @param {string} cwd
1281
1379
  * @param {string[]} args
package/src/v2/cli.js CHANGED
@@ -344,9 +344,15 @@ async function runSelfCheck(ctx, asJson) {
344
344
  const { selfcheck } = await import('./selfcheck.js');
345
345
  const result = await selfcheck({ only: ctx.list('only') });
346
346
 
347
+ // Three outcomes, not two. "Every break was caught", "a break got through", and "one case
348
+ // behaved on the second run and not the first, so nobody knows" are different facts, and
349
+ // the third one has to be able to say so instead of being rounded to either neighbour.
350
+ const untellable = result.cases.filter((one) => one.verdict === 'could not tell');
351
+ const reallyWrong = result.cases.filter((one) => !one.caught && one.verdict !== 'could not tell');
352
+
347
353
  if (asJson) {
348
354
  process.stdout.write(JSON.stringify(result) + '\n');
349
- return result.passed ? EXIT.ok : EXIT.failed;
355
+ return result.passed ? EXIT.ok : reallyWrong.length > 0 ? EXIT.failed : EXIT.error;
350
356
  }
351
357
 
352
358
  heading('Stays Fixed — checking that it can still catch things');
@@ -379,8 +385,15 @@ async function runSelfCheck(ctx, asJson) {
379
385
  ok(`All ${result.cases.length} of them behaved: every break caught, every clean pair silent.`);
380
386
  return EXIT.ok;
381
387
  }
382
- const wrong = result.cases.filter((one) => !one.caught).length;
383
- fail(`It got ${wrong} of ${result.cases.length} wrong. Until that is fixed, a clean check means nothing.`);
388
+ if (reallyWrong.length === 0) {
389
+ fail(
390
+ `${untellable.length} of ${result.cases.length} could not be told either way: ${untellable.length === 1 ? 'it' : 'they'} behaved on the second run and not on the first. ` +
391
+ 'That is not a pass and not a failure — it is no answer. Run it again on a machine that is not busy.',
392
+ );
393
+ return EXIT.error;
394
+ }
395
+ fail(`It got ${reallyWrong.length} of ${result.cases.length} wrong, twice in a row each. Until that is fixed, a clean check means nothing.`);
396
+ if (untellable.length > 0) fail(`${untellable.length} more could not be told either way.`);
384
397
  return EXIT.failed;
385
398
  }
386
399
 
@@ -729,8 +729,16 @@ export async function ledger(store, product, opts = {}) {
729
729
  caveats.push('The doors were handed in by the code reader as this ledger was drawn up, so it knows about doors added since the last run.');
730
730
  } else if (opts.root) {
731
731
  const reading = await readContract({ root: opts.root });
732
- reading.doors.push(...(await readFileRoutes(opts.root)));
732
+ const fileRoutes = await readFileRoutes(opts.root);
733
+ reading.doors.push(...fileRoutes.doors);
733
734
  reading.doors.push(...(await readPackageCommands(opts.root)));
735
+ for (const problem of fileRoutes.problems) {
736
+ holes.push({
737
+ what: problem,
738
+ why: 'Doors nobody can see are not counted here, so this ledger is smaller than the product is.',
739
+ unlockedBy: 'Make that folder readable by whoever runs the check.',
740
+ });
741
+ }
734
742
  doors = reading.doors.map(doorFact);
735
743
  caveats.push(`The code was read as this ledger was drawn up: ${reading.report.filesRead} files, ${reading.doors.length} doors, and nothing was run.`);
736
744
  } else {
package/src/v2/detect.js CHANGED
@@ -805,7 +805,7 @@ async function readTheSource(root) {
805
805
  const reading = await readContract({ root });
806
806
  const fileRoutes = await readFileRoutes(root);
807
807
  const commands = await readPackageCommands(root);
808
- const doors = [...reading.doors, ...fileRoutes, ...commands];
808
+ const doors = [...reading.doors, ...fileRoutes.doors, ...commands];
809
809
  /** @type {Record<string, number>} */
810
810
  const counts = {};
811
811
  for (const door of doors) counts[door.kind] = (counts[door.kind] ?? 0) + 1;
package/src/v2/doctor.js CHANGED
@@ -32,7 +32,7 @@ import { existsSync, accessSync, readFileSync, readdirSync, constants as fsConst
32
32
  import { execFile } from 'node:child_process';
33
33
  import { promisify } from 'node:util';
34
34
 
35
- import { findConfigFile } from '../core/paths.js';
35
+ import { findConfigFile, rootForConfig } from '../core/paths.js';
36
36
  import { platformTag } from '../drive/find.js';
37
37
  import { isRepo } from '../core/git.js';
38
38
  import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
@@ -174,7 +174,12 @@ export async function capabilities(opts = {}) {
174
174
  const cwd = opts.cwd ?? process.cwd();
175
175
  const offline = opts.offline === true || process.env.STAYSFIXED_OFFLINE !== undefined;
176
176
  const configFile = opts.configFile ? path.resolve(cwd, opts.configFile) : findConfigFile(cwd);
177
- const root = configFile ? path.dirname(configFile) : cwd;
177
+ // rootForConfig, never path.dirname: a config kept at `.staysfixed/config.json` — one of
178
+ // the six names this tool looks for — would otherwise make the project root the
179
+ // `.staysfixed` folder itself, and every adapter would be asked about an empty directory.
180
+ // Measured on Terminal Deck: doctor reported "a built APK is still missing" with the APK
181
+ // sitting two folders up, and told the agent to go and build one that was already there.
182
+ const root = configFile ? rootForConfig(configFile) : cwd;
178
183
 
179
184
  // The browser survey comes first because three different answers below depend
180
185
  // on it, and asking this machine the same question three times would be both
@@ -258,6 +263,8 @@ const PERMANENT_LIMITS = [
258
263
  'It checks the journeys it has. It cannot enumerate every possible state, and the coverage ledger names the doors it has never opened rather than pretending they are covered.',
259
264
  'Two builds of a real phone in your hand cannot be run side by side. Real devices fall back to comparing against the stored record, and say so.',
260
265
  'If the old build can no longer be compiled, comparison falls back to the stored record from the last time it ran. That is genuinely weaker and announces itself on every run.',
266
+ 'It will not tell you your product got slower. How long something took is recorded and printed, and never compared: a stopwatch on a shared machine measures how busy the machine is at least as much as it measures the product, so comparing it invents a slowdown every time the machine is busy. A build that HANGS is still caught, because it is stopped for taking too long and how it finished is compared exactly.',
267
+ 'A change buried in the middle of an output larger than 64KB can be missed. The two ends are kept and compared along with the exact number of bytes discarded, so a middle that grew or shrank shows up; one that changed without changing length does not. The whole text is written to the evidence folder and the run says it only compared the ends.',
261
268
  ];
262
269
 
263
270
  /**
@@ -658,7 +665,12 @@ function findDesktopApp(cwd) {
658
665
  }
659
666
  }
660
667
 
661
- const root = configFile ? path.dirname(configFile) : cwd;
668
+ // rootForConfig, never path.dirname: a config kept at `.staysfixed/config.json` — one of
669
+ // the six names this tool looks for — would otherwise make the project root the
670
+ // `.staysfixed` folder itself, and every adapter would be asked about an empty directory.
671
+ // Measured on Terminal Deck: doctor reported "a built APK is still missing" with the APK
672
+ // sitting two folders up, and told the agent to go and build one that was already there.
673
+ const root = configFile ? rootForConfig(configFile) : cwd;
662
674
  const ext = process.platform === 'darwin' ? '.app' : process.platform === 'win32' ? '.exe' : '.AppImage';
663
675
  for (const folder of ['dist', 'out', 'release', 'build']) {
664
676
  const dir = path.join(root, folder);
@@ -436,7 +436,9 @@ export function journeysFromDoors(doors, options = {}) {
436
436
  export async function journeysFromCode(opts) {
437
437
  const started = Date.now();
438
438
  const reading = await readContract({ root: opts.root, folders: opts.folders });
439
- reading.doors.push(...(await readFileRoutes(opts.root)));
439
+ const fileRoutes = await readFileRoutes(opts.root);
440
+ reading.doors.push(...fileRoutes.doors);
441
+ reading.report.problems.push(...fileRoutes.problems);
440
442
  reading.doors.push(...(await readPackageCommands(opts.root)));
441
443
 
442
444
  const surface =