staysfixed 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/README.md +193 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +549 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +345 -47
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
package/src/v2/rank.js CHANGED
@@ -55,6 +55,14 @@ const NOT_SOURCE = new Set([
55
55
 
56
56
  const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts', '.vue', '.svelte'];
57
57
 
58
+ /**
59
+ * How many files a journey may touch and still be used to say where a finding lives.
60
+ *
61
+ * A journey that went through four hundred files touches everything, and letting it answer
62
+ * would put every finding at distance zero and quietly switch the whole ranking off.
63
+ */
64
+ const MAX_JOURNEY_FILES = 25;
65
+
58
66
  /** Ceilings, so a check on a very large repo never turns into a crawl of it. */
59
67
  const MAX_FILES = 4000;
60
68
  const MAX_FILE_BYTES = 400_000;
@@ -122,6 +130,14 @@ const CHANNEL_WEIGHT = {
122
130
  * @property {boolean} beyond The source file is in this project and no path of at
123
131
  * most `maxHops` imports leads to it from anything changed.
124
132
  * @property {string} [beyondFile] Which file, so the sentence can name it.
133
+ * @property {{journey: string, files: number}} [tooBroad]
134
+ * Nothing named the finding's own source, and the journey
135
+ * it came from touched so many files that using them would
136
+ * have put everything at distance zero. That is a THIRD
137
+ * kind of not knowing, and until 2026-08-30 it was worded
138
+ * as the first — the reader was told nothing said which
139
+ * code this came from, when in fact plenty did and all of
140
+ * it was too broad to be worth anything.
125
141
  */
126
142
 
127
143
  /**
@@ -151,6 +167,17 @@ const CHANNEL_WEIGHT = {
151
167
  * everything downstream has to be told which of the two
152
168
  * it is looking at.
153
169
  * @property {string} [patchUnreadWhy]
170
+ * @property {string} base What the diff was measured FROM: 'HEAD' when only the
171
+ * working tree was read, or the reference build's commit when
172
+ * one was named. A hunk in this patch can only be reverted in
173
+ * a checkout of THIS commit, which is why it travels with it.
174
+ * @property {boolean} committed True when the diff was measured from the reference build's
175
+ * commit rather than from HEAD, so work that has already been
176
+ * committed is inside it too. False means the working tree was
177
+ * all that was read, and nothing downstream may then say a
178
+ * word about whether the product changed — only about whether
179
+ * the working tree did.
180
+ * @property {string} [baseWhy] Why the commit that was asked for was not used.
154
181
  */
155
182
 
156
183
  /**
@@ -163,15 +190,21 @@ const CHANNEL_WEIGHT = {
163
190
  * guards?: string[],
164
191
  * touches?: Record<string, string[]>,
165
192
  * changed?: Changed,
193
+ * since?: string,
166
194
  * maxHops?: number,
167
195
  * }} opts
196
+ * `since` is the reference build's commit. Pass it and a change that has been COMMITTED is
197
+ * measured like any other; leave it out and only the working tree is read, which goes blind
198
+ * the moment an agent commits its work — and committing at the end of a task is what an
199
+ * agent does.
168
200
  * @returns {Promise<{findings: Finding[], notes: string[], youChanged: string[]}>}
169
201
  */
170
202
  export async function rankFindings(findings, opts) {
171
203
  /** @type {string[]} */
172
204
  const notes = [];
173
205
  const guards = opts.guards ?? [];
174
- const changed = opts.changed ?? (await whatChanged(opts.cwd));
206
+ const changed = opts.changed ?? (await whatChanged(opts.cwd, { since: opts.since }));
207
+ if (changed.baseWhy) notes.push(changed.baseWhy);
175
208
  if (!changed.ok && changed.why) {
176
209
  notes.push(
177
210
  `${changed.why} Findings are ordered by what kind of thing they are instead of by how far they sit from your edit.`,
@@ -208,13 +241,20 @@ export async function rankFindings(findings, opts) {
208
241
  `${graph.unreadable.length} source ${graph.unreadable.length === 1 ? 'file' : 'files'} could not be opened for the distance measure: ${graph.unreadable.slice(0, 3).join(', ')}. The same warning applies — what they import looks unconnected.`,
209
242
  );
210
243
  }
244
+ if (graph.unreadableDirs.length > 0) {
245
+ notes.push(
246
+ `${graph.unreadableDirs.length} ${graph.unreadableDirs.length === 1 ? 'folder' : 'folders'} could not be opened for the distance measure: ${graph.unreadableDirs.slice(0, 3).join(', ')}${graph.unreadableDirs.length > 3 ? ', and others' : ''}. Nothing inside ${graph.unreadableDirs.length === 1 ? 'it' : 'them'} is in the graph at all, so a difference coming from there cannot be placed near or far from your edit.`,
247
+ );
248
+ }
211
249
  distances = distancesFrom(graph.neighbours, seeds, hops);
212
250
  } else if (changed.ok && changed.patchUnread !== true) {
213
- notes.push('Nothing in the working tree has changed, so none of this can be blamed on an edit you just made.');
251
+ notes.push(`${nothingChanged(changed)} None of this can be blamed on an edit you just made.`);
214
252
  }
215
253
 
254
+ /** @type {string[]} */
255
+ const blindSpots = [];
216
256
  const ranked = findings.map((finding) => {
217
- const sealedClass = classOf(finding, guards);
257
+ const sealedClass = classOf(finding, guards, (what) => blindSpots.push(what));
218
258
  const how = distanceFor(finding, distances, changed.root, opts.touches ?? {}, known);
219
259
  /** @type {Finding} */
220
260
  const out = {
@@ -222,13 +262,19 @@ export async function rankFindings(findings, opts) {
222
262
  class: sealedClass,
223
263
  sealed: sealedClass !== 'ordinary',
224
264
  rank: scoreOf(finding, sealedClass, how),
225
- why: explain(finding, sealedClass, how, seeds.length > 0, hops),
265
+ why: explain(finding, sealedClass, how, seeds.length > 0, hops, changed),
226
266
  };
227
267
  const near = nearestFiles(finding, distances, changed.root);
228
268
  if (near.length > 0) out.nearFiles = near;
229
269
  return out;
230
270
  });
231
271
 
272
+ if (blindSpots.length > 0) {
273
+ notes.push(
274
+ `${blindSpots.slice(0, 3).join(' ')}${blindSpots.length > 3 ? ` And ${blindSpots.length - 3} more like it.` : ''} Anything filed as ordinary that came from one of those is ordinary only as far as anybody could see.`,
275
+ );
276
+ }
277
+
232
278
  ranked.sort((a, b) => b.rank - a.rank || (b.count ?? 0) - (a.count ?? 0) || a.id.localeCompare(b.id));
233
279
  return { findings: ranked, notes, youChanged: [...changed.files, ...changed.untracked] };
234
280
  }
@@ -246,9 +292,13 @@ export async function rankFindings(findings, opts) {
246
292
  *
247
293
  * @param {Finding} finding
248
294
  * @param {string[]} guards Guard names, so a difference touching one is sealed by name.
295
+ * @param {(what: string) => void} [onBlind] Told when a value could not be turned into text
296
+ * at all. That value was not searched for money, signing in, losing data, a crash or a
297
+ * guard — so `ordinary` coming back means "nothing found in what could be read", which is
298
+ * not the same claim and must not be made silently.
249
299
  * @returns {FindingClass}
250
300
  */
251
- export function classOf(finding, guards) {
301
+ export function classOf(finding, guards, onBlind) {
252
302
  const haystack = [
253
303
  finding.title,
254
304
  finding.signature ?? '',
@@ -272,9 +322,27 @@ export function classOf(finding, guards) {
272
322
  // and diffed several times over.
273
323
  /** @type {string[]} */
274
324
  const values = [];
325
+ let blind = 0;
326
+ /** @param {unknown} value */
327
+ const add = (value) => {
328
+ const said = asText(value);
329
+ if (said.ok) values.push(said.text);
330
+ else blind += 1;
331
+ };
275
332
  for (const d of finding.differences) {
276
- if (d.reference !== undefined) values.push(asText(d.reference));
277
- if (d.candidate !== undefined) values.push(asText(d.candidate));
333
+ if (d.reference !== undefined) add(d.reference);
334
+ if (d.candidate !== undefined) add(d.candidate);
335
+ }
336
+ // A value that will not turn into text — something holding a loop, a BigInt — used to come
337
+ // back as the empty string and go into the search alongside the real ones. Nothing matched
338
+ // in it, of course, and the finding was filed `ordinary`, which is exactly the class an
339
+ // agent is allowed to wave through on its own. The whole point of this function is that a
340
+ // crash or a charge cannot be waved through, so being unable to look inside a value has to
341
+ // travel out of here rather than be spent as a quiet nothing-found.
342
+ if (blind > 0 && onBlind) {
343
+ onBlind(
344
+ `${blind} ${blind === 1 ? 'value' : 'values'} in "${finding.title}" could not be read as text, so nothing looked inside ${blind === 1 ? 'it' : 'them'} for money, signing in, losing data, a crash or a guard.`,
345
+ );
278
346
  }
279
347
  /** @param {RegExp} rx */
280
348
  const says = (rx) => rx.test(haystack) || values.some((v) => rx.test(v));
@@ -312,14 +380,30 @@ export function classOf(finding, guards) {
312
380
  * empty string rather than taking the run down over a sealing check.
313
381
  *
314
382
  * @param {unknown} value
315
- * @returns {string}
383
+ * @returns {{ok: boolean, text: string}} `ok` is false only when the value could not be
384
+ * turned into anything searchable at all. An empty string that came back from a value with
385
+ * genuinely no words in it, and an empty string standing in for a value nobody could read,
386
+ * used to be indistinguishable here.
316
387
  */
317
388
  function asText(value) {
318
- if (typeof value === 'string') return value;
389
+ if (typeof value === 'string') return { ok: true, text: value };
319
390
  try {
320
- return JSON.stringify(value) ?? '';
391
+ const json = JSON.stringify(value);
392
+ // undefined, a function or a symbol. There are no words in any of those, and saying so
393
+ // is a true answer rather than a failure.
394
+ if (json === undefined) return { ok: true, text: '' };
395
+ return { ok: true, text: json };
321
396
  } catch {
322
- return '';
397
+ // A BigInt, or a value holding a loop. Last try: whatever it says about itself.
398
+ try {
399
+ const said = String(value);
400
+ // "[object Object]" is not the value, it is the shape of the value, and searching it
401
+ // for the word "charge" proves nothing at all.
402
+ if (said && said !== '[object Object]') return { ok: true, text: said };
403
+ } catch {
404
+ // Even its own toString threw. There is nothing left to read.
405
+ }
406
+ return { ok: false, text: '' };
323
407
  }
324
408
  }
325
409
 
@@ -359,17 +443,21 @@ function scoreOf(finding, sealedClass, how) {
359
443
  * @param {HowFar} how
360
444
  * @param {boolean} knewWhatChanged
361
445
  * @param {number} hops How far out the measure walked before it stopped.
446
+ * @param {Changed} changed
362
447
  */
363
- function explain(finding, sealedClass, how, knewWhatChanged, hops) {
448
+ function explain(finding, sealedClass, how, knewWhatChanged, hops, changed) {
364
449
  if (sealedClass !== 'ordinary') {
365
450
  return `Nobody may wave this through on their own: it touches ${SEAL_WORDS[sealedClass]}. It goes to a person whatever caused it.`;
366
451
  }
367
452
  if (!knewWhatChanged) {
368
- return 'Nothing in the working tree has changed, so there is no edit to measure this against.';
453
+ return `${nothingChanged(changed)} There is no edit to measure this against.`;
369
454
  }
370
455
  if (how.beyond) {
371
456
  return `This comes from ${how.beyondFile}, which is source code the project has and which nothing you changed reaches within ${hops} steps. That is as far from your edit as this measure goes — the strongest shape a side effect has.`;
372
457
  }
458
+ if (how.tooBroad) {
459
+ return `The only thing that says where this comes from is "${how.tooBroad.journey}", and that journey goes through ${how.tooBroad.files} files — too many for it to mean anything about where this lives, so how far it sits from your edit is unknown. Treat it as unexplained until you have checked.`;
460
+ }
373
461
  if (how.distance === null) {
374
462
  return 'Nothing says which code this comes from, so how far it sits from your edit is unknown. Treat it as unexplained until you have checked.';
375
463
  }
@@ -378,6 +466,23 @@ function explain(finding, sealedClass, how, knewWhatChanged, hops) {
378
466
  return `This is ${how.distance} steps away from anything you changed. That is what a side effect looks like.`;
379
467
  }
380
468
 
469
+ /**
470
+ * The true version of "nothing has changed", which depends on what was actually looked at.
471
+ *
472
+ * Reading only the working tree and finding it clean says nothing whatever about whether the
473
+ * product changed — the change may be sitting in a commit. Saying "nothing has changed" on
474
+ * the back of that was this file's own blind spot described in the tool's most confident
475
+ * voice, four times over, on a run that had just found four differences.
476
+ *
477
+ * @param {Changed} changed
478
+ * @returns {string}
479
+ */
480
+ function nothingChanged(changed) {
481
+ return changed.committed
482
+ ? 'Nothing has changed between the build you were happy with and this one — not in a commit and not in the working tree.'
483
+ : 'Nothing in the working tree has changed, and nothing here looked at what may already be committed.';
484
+ }
485
+
381
486
  /**
382
487
  * How far a finding sits from the nearest thing the agent edited.
383
488
  *
@@ -418,10 +523,16 @@ function distanceFor(finding, distances, root, touches, known) {
418
523
 
419
524
  for (const file of finding.nearFiles ?? []) look(file);
420
525
 
526
+ /** @type {{journey: string, files: number}[]} */
527
+ const tooBroad = [];
421
528
  if (found.length === 0 && seenButUnreached.length === 0) {
422
529
  for (const journey of journeysOf(finding)) {
423
530
  const files = touches[journey];
424
- if (!files || files.length === 0 || files.length > 25) continue;
531
+ if (!files || files.length === 0) continue;
532
+ if (files.length > MAX_JOURNEY_FILES) {
533
+ tooBroad.push({ journey, files: files.length });
534
+ continue;
535
+ }
425
536
  for (const file of files) look(file);
426
537
  }
427
538
  }
@@ -429,6 +540,7 @@ function distanceFor(finding, distances, root, touches, known) {
429
540
  // ceiling, and a list that is bounded today is bounded by a constant somebody may raise.
430
541
  if (found.length > 0) return { distance: found.reduce((best, n) => (n < best ? n : best), found[0]), beyond: false };
431
542
  if (seenButUnreached.length > 0) return { distance: null, beyond: true, beyondFile: seenButUnreached[0] };
543
+ if (tooBroad.length > 0) return { distance: null, beyond: false, tooBroad: tooBroad[0] };
432
544
  return { distance: null, beyond: false };
433
545
  }
434
546
 
@@ -455,6 +567,33 @@ function nearestFiles(finding, distances, root) {
455
567
  // What the agent just changed
456
568
  // ---------------------------------------------------------------------------
457
569
 
570
+ /**
571
+ * Everything this tool writes about your project, kept out of what your project IS.
572
+ *
573
+ * A build is told from another build, and an edit is told from the rest of the tree, by what
574
+ * git says is in the working tree: the diff, plus the list of files git does not know about.
575
+ * Stays Fixed's own folder is a file git does not know about, and it gains files on every
576
+ * single run. Left in, it is counted as something the agent just changed.
577
+ *
578
+ * That cost the fingerprint first, and it was the worse half: two runs on identical source
579
+ * were two different builds, so the second could never find the first one's record, a clean
580
+ * checkout was never clean, `--against HEAD` matched nothing, and the stored-record
581
+ * comparison the whole design rests on could not work at all. Measured on a scratch product:
582
+ * five runs, one unchanged source file, five different build ids.
583
+ *
584
+ * It cost the ranking too, and that half went unfixed until 2026-08-30. `whatChanged` reads
585
+ * the same two git calls to work out how far a finding sits from the edit, so the tool's own
586
+ * output was one of the places the walk started from — a clean tree never got the sentence
587
+ * saying it was clean, `youChanged` named `.staysfixed/...` back to the person as a file
588
+ * they had edited, and the causal proof copied the whole store into its scratch checkout and
589
+ * believed there was a change to undo when there was none.
590
+ *
591
+ * Excluded rather than gitignored, and that difference matters: gitignoring it would fix the
592
+ * fingerprint and would also throw away the observation files the design says to keep
593
+ * forever. What a project's own tooling wrote about a project is never part of the project.
594
+ */
595
+ export const NOT_THE_TOOLS_OWN_FOLDER = ':(exclude,top).staysfixed';
596
+
458
597
  /**
459
598
  * Read the working tree's diff: which files, and which hunks inside them.
460
599
  *
@@ -462,10 +601,23 @@ function nearestFiles(finding, distances, root) {
462
601
  * checked - it just gets its findings ordered by kind rather than by distance,
463
602
  * and it is told so.
464
603
  *
604
+ * A CHANGE THAT HAS BEEN COMMITTED IS STILL A CHANGE. This used to read the working tree and
605
+ * nothing else, so the moment an agent committed its work — which is what an agent does at
606
+ * the end of a task, and the reference is a shipped build, which is by definition a commit —
607
+ * the diff came back empty, the distance measure went blind, the ranking lost its ordering,
608
+ * and every finding carried the sentence "nothing in the working tree has changed, so there
609
+ * is no edit to measure this against" while the tool sat there holding four differences it
610
+ * had just found. The change was neither missing nor unknowable: it is `git diff <the
611
+ * reference's commit>`, which covers what was committed and what is still uncommitted in one
612
+ * patch. `since` is that commit, and with it "nothing has changed" becomes a thing this can
613
+ * say truthfully instead of a thing it says whenever the tree happens to be clean.
614
+ *
465
615
  * @param {string} cwd
616
+ * @param {{since?: string}} [opts] `since` is the reference build's commit. Without it, only
617
+ * the working tree is read, which is right for a caller that has no reference to name.
466
618
  * @returns {Promise<Changed>}
467
619
  */
468
- export async function whatChanged(cwd) {
620
+ export async function whatChanged(cwd, opts = {}) {
469
621
  const root = await git(['rev-parse', '--show-toplevel'], cwd);
470
622
  if (!root) {
471
623
  return {
@@ -476,6 +628,8 @@ export async function whatChanged(cwd) {
476
628
  hunks: [],
477
629
  patch: '',
478
630
  root: cwd,
631
+ base: 'HEAD',
632
+ committed: false,
479
633
  };
480
634
  }
481
635
  const head = await git(['rev-parse', '--verify', 'HEAD'], cwd);
@@ -488,12 +642,38 @@ export async function whatChanged(cwd) {
488
642
  hunks: [],
489
643
  patch: '',
490
644
  root,
645
+ base: 'HEAD',
646
+ committed: false,
491
647
  };
492
648
  }
493
649
 
494
- const diff = await gitTry(['diff', 'HEAD', '-U3', '--no-color', '--no-ext-diff'], cwd);
495
- const names = await gitTry(['diff', 'HEAD', '--name-only'], cwd);
496
- const others = await gitTry(['ls-files', '--others', '--exclude-standard'], cwd);
650
+ // A commit that is not in this checkout is not a reason to give up — the working tree is
651
+ // still worth reading but it IS a reason to say so, because everything measured after it
652
+ // is measured from somewhere other than where the caller asked.
653
+ let base = 'HEAD';
654
+ let baseWhy = '';
655
+ if (opts.since) {
656
+ const resolved = await git(['rev-parse', '--verify', `${opts.since}^{commit}`], root);
657
+ if (resolved) base = resolved;
658
+ else {
659
+ baseWhy = `The build you were happy with is at ${opts.since}, and that commit is not in this checkout, so your change was measured from the working tree alone. Anything you have already committed is invisible to the ordering below.`;
660
+ }
661
+ }
662
+
663
+ // Asked from the repository root, not from wherever the caller was standing. `git diff`
664
+ // answers for the whole repository whatever folder it is run in; `git ls-files --others`
665
+ // does not — it lists only what is under the current folder, and it names those files
666
+ // relative to it. So a check run from a subfolder used to hand back new files under a path
667
+ // that resolved against the root to somewhere that does not exist, and never mentioned a
668
+ // new file anywhere else in the repository at all. The distance measure then started from
669
+ // a file that is not there, and the causal proof quietly declined to carry the real one
670
+ // into its scratch checkout because it "could not find" it.
671
+ // One argument, not a range. `git diff <commit>` is that commit's tree against the WORKING
672
+ // TREE, so committed work and uncommitted work arrive together in one patch that applies
673
+ // cleanly to a checkout of that commit — which is exactly what the causal proof needs.
674
+ const diff = await gitTry(['diff', base, '-U3', '--no-color', '--no-ext-diff', '--', NOT_THE_TOOLS_OWN_FOLDER], root);
675
+ const names = await gitTry(['diff', base, '--name-only', '--', NOT_THE_TOOLS_OWN_FOLDER], root);
676
+ const others = await gitTry(['ls-files', '--others', '--exclude-standard', '--', NOT_THE_TOOLS_OWN_FOLDER], root);
497
677
 
498
678
  // Not knowing WHICH files changed is a different and worse failure than not being able to
499
679
  // read the diff of them, so it is reported as not knowing anything rather than as an empty
@@ -508,6 +688,8 @@ export async function whatChanged(cwd) {
508
688
  hunks: [],
509
689
  patch: '',
510
690
  root,
691
+ base,
692
+ committed: base !== 'HEAD',
511
693
  };
512
694
  }
513
695
 
@@ -519,7 +701,10 @@ export async function whatChanged(cwd) {
519
701
  hunks: diff.ok ? parseHunks(diff.text) : [],
520
702
  patch: diff.ok ? diff.text : '',
521
703
  root,
704
+ base,
705
+ committed: base !== 'HEAD',
522
706
  };
707
+ if (baseWhy) changed.baseWhy = baseWhy;
523
708
  if (!diff.ok) {
524
709
  // A diff that is too big for the buffer, or a git that took too long, used to come back
525
710
  // as the empty string — which every reader downstream read as "the working tree is
@@ -616,7 +801,7 @@ export function parseHunks(patch) {
616
801
  *
617
802
  * @param {string} root
618
803
  * @param {{maxFiles?: number}} [opts]
619
- * @returns {Promise<{neighbours: Map<string, Set<string>>, files: string[], truncated: boolean, tooBig: string[], unreadable: string[]}>}
804
+ * @returns {Promise<{neighbours: Map<string, Set<string>>, files: string[], truncated: boolean, tooBig: string[], unreadable: string[], unreadableDirs: string[]}>}
620
805
  */
621
806
  export async function importGraph(root, opts = {}) {
622
807
  const limit = opts.maxFiles ?? MAX_FILES;
@@ -665,7 +850,7 @@ export async function importGraph(root, opts = {}) {
665
850
  }
666
851
  }
667
852
 
668
- return { neighbours, files: files.list, truncated: files.truncated, tooBig, unreadable };
853
+ return { neighbours, files: files.list, truncated: files.truncated, tooBig, unreadable, unreadableDirs: files.unreadableDirs };
669
854
  }
670
855
 
671
856
  /**
@@ -707,13 +892,15 @@ export function distancesFrom(neighbours, seeds, maxHops) {
707
892
  *
708
893
  * @param {string} root
709
894
  * @param {number} limit
710
- * @returns {Promise<{list: string[], truncated: boolean}>}
895
+ * @returns {Promise<{list: string[], truncated: boolean, unreadableDirs: string[]}>}
711
896
  */
712
897
  async function sourceFiles(root, limit) {
713
898
  /** @type {string[]} */
714
899
  const list = [];
715
900
  /** @type {string[]} */
716
901
  const queue = [root];
902
+ /** @type {string[]} */
903
+ const unreadableDirs = [];
717
904
  let truncated = false;
718
905
  while (queue.length > 0) {
719
906
  const dir = queue.pop();
@@ -723,6 +910,12 @@ async function sourceFiles(root, limit) {
723
910
  try {
724
911
  entries = await fsp.readdir(dir, { withFileTypes: true });
725
912
  } catch {
913
+ // A folder that will not open is every source file inside it, gone from the graph
914
+ // without a word — and this one was worse than the file-level version above it,
915
+ // because a folder is not one file, it is a whole branch of the product. Everything
916
+ // under it then looks like code nothing imports, so a side effect in it is either
917
+ // ranked as "we have no idea where this came from" or missed by the graph entirely.
918
+ unreadableDirs.push(path.relative(root, dir) || dir);
726
919
  continue;
727
920
  }
728
921
  for (const entry of entries) {
@@ -737,12 +930,12 @@ async function sourceFiles(root, limit) {
737
930
  if (!SOURCE_EXTENSIONS.includes(path.extname(entry.name))) continue;
738
931
  if (list.length >= limit) {
739
932
  truncated = true;
740
- return { list, truncated };
933
+ return { list, truncated, unreadableDirs };
741
934
  }
742
935
  list.push(path.join(dir, entry.name));
743
936
  }
744
937
  }
745
- return { list, truncated };
938
+ return { list, truncated, unreadableDirs };
746
939
  }
747
940
 
748
941
  /**
@@ -308,12 +308,16 @@ export async function measureStability(store, buildId) {
308
308
  let measuredJourneys = 0;
309
309
 
310
310
  for (const journey of journeys) {
311
- const pair = await twoRunsOf(store, buildId, journey);
311
+ const looked = await twoRunsOf(store, buildId, journey);
312
+ const pair = looked.pair;
312
313
  if (!pair) {
313
314
  byJourney.push({
314
315
  journey,
315
316
  measured: false,
316
- why: 'This build only ever walked this journey once, so nothing here says how steady it was.',
317
+ why:
318
+ looked.unreadable > 0
319
+ ? `${looked.unreadable} of the ${looked.stored} stored runs of this journey could not be read, so the pair needed to measure how steady it was is not there. This build DID walk it more than once — the evidence is on the disk and it is damaged, which is a different problem from never having walked it.`
320
+ : 'This build only ever walked this journey once, so nothing here says how steady it was.',
317
321
  paths: 0,
318
322
  steady: 0,
319
323
  unstableCount: 0,
@@ -412,36 +416,47 @@ function stabilityNote(measured, journeys, measuredJourneys, steady, unstable) {
412
416
  * would measure the difference between two afternoons and call it wobble. So: take the
413
417
  * newest second run, then the newest first run that came before it.
414
418
  *
419
+ * WHY IT SAYS HOW MANY IT COULD NOT READ. One unreadable capture must never take the whole
420
+ * stability record with it, and it never did — but it used to vanish into a `continue`, and
421
+ * a journey that lost its pair that way came back as the same plain `null` a journey that
422
+ * was genuinely only ever walked once comes back as. The caller then wrote "this build only
423
+ * ever walked this journey once, so nothing here says how steady it was" onto the reference,
424
+ * for good, about a journey that was walked twice and whose evidence is sitting on the disk
425
+ * damaged. That sentence sends somebody to walk it again; the truth would have sent them to
426
+ * look at their store.
427
+ *
415
428
  * @param {Store} store
416
429
  * @param {string} buildId
417
430
  * @param {string} journey
418
- * @returns {Promise<{a: Capture, b: Capture}|null>}
431
+ * @returns {Promise<{pair: {a: Capture, b: Capture}|null, stored: number, unreadable: number}>}
419
432
  */
420
433
  async function twoRunsOf(store, buildId, journey) {
421
434
  const refs = await listCaptures(store, { buildId, journey });
422
- if (refs.length < 2) return null;
435
+ if (refs.length < 2) return { pair: null, stored: refs.length, unreadable: 0 };
423
436
 
424
437
  /** @type {Capture[]} */
425
438
  const captures = [];
439
+ let unreadable = 0;
426
440
  for (const ref of refs) {
427
441
  /** @type {Capture|null} */
428
442
  let capture = null;
429
443
  try {
430
444
  capture = await loadCapture(store, ref);
431
445
  } catch {
432
- // One unreadable file must never take the whole stability record with it.
446
+ unreadable += 1;
433
447
  continue;
434
448
  }
435
449
  if (capture) captures.push(capture);
450
+ else unreadable += 1;
436
451
  }
437
452
 
438
453
  for (let i = captures.length - 1; i >= 0; i--) {
439
454
  if (captures[i].run !== 'b') continue;
440
455
  for (let j = i - 1; j >= 0; j--) {
441
- if (captures[j].run === 'a') return { a: captures[j], b: captures[i] };
456
+ if (captures[j].run === 'a') return { pair: { a: captures[j], b: captures[i] }, stored: refs.length, unreadable };
442
457
  }
443
458
  }
444
- return null;
459
+ return { pair: null, stored: refs.length, unreadable };
445
460
  }
446
461
 
447
462
  // ---------------------------------------------------------------------------
@@ -1032,11 +1047,26 @@ export async function currentReference(store, product) {
1032
1047
  * The ship hook uses this to work out which product it is looking at when nobody said, and
1033
1048
  * a `doctor` or summary uses it to name the products that are not being checked at all.
1034
1049
  *
1050
+ * BOTH OF THOSE USES ARE RUINED BY A LIST THAT IS QUIETLY SHORT. A build record that cannot
1051
+ * be read is a build that is not in the list, and a product whose only builds are damaged
1052
+ * records is a product that is not in the list at all — so the ship hook sees one product
1053
+ * where there are two and blesses the wrong one without a word, and the summary says a
1054
+ * product is not being checked when it is. Until 2026-08-30 this asked for the builds without
1055
+ * asking to be told about the ones that were skipped, so neither could have known.
1056
+ *
1057
+ * The problems come back BESIDE the list rather than through a callback nobody has to pass,
1058
+ * because a caller that does not want to hear them now has to say so on purpose.
1059
+ *
1035
1060
  * @param {Store} store
1036
- * @returns {Promise<{product: string, hasReference: boolean, builds: number}[]>}
1061
+ * @returns {Promise<{products: {product: string, hasReference: boolean, builds: number}[], problems: string[]}>}
1062
+ * `problems` is empty when every build folder could be read. Each entry is a plain
1063
+ * sentence naming a folder that could not be, and anything built on this list is weaker
1064
+ * for as long as one is there.
1037
1065
  */
1038
1066
  export async function productsKnown(store) {
1039
- const builds = await listBuilds(store);
1067
+ /** @type {string[]} */
1068
+ const problems = [];
1069
+ const builds = await listBuilds(store, { onProblem: (message) => problems.push(message) });
1040
1070
  /** @type {Map<string, {product: string, hasReference: boolean, builds: number}>} */
1041
1071
  const seen = new Map();
1042
1072
  for (const record of builds) {
@@ -1047,5 +1077,5 @@ export async function productsKnown(store) {
1047
1077
  if (record.isReference) entry.hasReference = true;
1048
1078
  seen.set(product, entry);
1049
1079
  }
1050
- return [...seen.values()].sort((a, b) => a.product.localeCompare(b.product));
1080
+ return { products: [...seen.values()].sort((a, b) => a.product.localeCompare(b.product)), problems };
1051
1081
  }