staysfixed 0.7.2 → 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 +342 -0
  2. package/README.md +191 -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 +285 -45
  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
@@ -36,6 +36,7 @@
36
36
  * Every refusal is reported as a hole in the check. None of them is ever reported as a pass.
37
37
  */
38
38
 
39
+ import crypto from 'node:crypto';
39
40
  import fs from 'node:fs';
40
41
  import fsp from 'node:fs/promises';
41
42
  import path from 'node:path';
@@ -70,6 +71,26 @@ const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 };
70
71
  /** Folders that never hold a page worth walking. */
71
72
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.staysfixed']);
72
73
 
74
+ /**
75
+ * Why a folder would not open, in words somebody can act on.
76
+ *
77
+ * The code alone is no use to the person who has to fix it: `EACCES` on its own has sent
78
+ * more than one person looking for a bug in the tool.
79
+ *
80
+ * @param {unknown} error
81
+ * @returns {string}
82
+ */
83
+ function whyNotOpened(error) {
84
+ const code = String(/** @type {any} */ (error)?.code ?? '');
85
+ if (code === 'EACCES' || code === 'EPERM') return 'this account does not have permission to open it';
86
+ if (code === 'ENOENT') return 'it was there when the walk started and is not there now';
87
+ if (code === 'ENOTDIR') return 'something in the way is a file, not a folder';
88
+ if (code === 'ELOOP') return 'the links in it point round in a circle';
89
+ if (code === 'EMFILE' || code === 'ENFILE') return 'this machine ran out of open files while reading it';
90
+ const said = String(/** @type {any} */ (error)?.message ?? error ?? '').trim();
91
+ return said === '' ? 'the reason was not given' : said;
92
+ }
93
+
73
94
  /**
74
95
  * The pages a project has, read out of its folder names.
75
96
  *
@@ -83,12 +104,29 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.nex
83
104
  * reported as needing a sample value rather than guessed at, because "we did not check
84
105
  * this" and "this is fine" must never be allowed to look alike.
85
106
  *
107
+ * A FOLDER THIS CANNOT OPEN IS NAMED, not skipped. It used to `continue` on the read that
108
+ * failed, which threw away that folder and every page underneath it without one word — and
109
+ * a page that was never listed is never walked, never counted, and never missed. The run
110
+ * then reported that nothing had changed about a section of the site it had not looked at.
111
+ * This is the same shape of bug as three others that were found and closed elsewhere in the
112
+ * tool; this was the fourth place it was living.
113
+ *
86
114
  * @param {string} root
115
+ * @param {{unreadable?: {folder: string, why: string}[]}} [collect]
116
+ * Hand in an object and every folder that could not be opened is pushed onto
117
+ * `collect.unreadable`, with the reason in plain English. Callers that pass nothing get
118
+ * the old shape back and lose the holes, so the two callers inside this file both pass
119
+ * one; `detect` turns them into something a person can act on and `journeys` turns them
120
+ * into missing coverage, which is what stops a folder nobody could read reading as a
121
+ * folder with nothing in it.
87
122
  * @returns {Promise<{url: string, file: string, needs: string[]}[]>}
88
123
  */
89
- export async function readPageRoutes(root) {
124
+ export async function readPageRoutes(root, collect = {}) {
90
125
  /** @type {Map<string, {url: string, file: string, needs: string[]}>} */
91
126
  const found = new Map();
127
+ const unreadable = collect.unreadable;
128
+ /** @type {Set<string>} */
129
+ const alreadySaid = new Set();
92
130
 
93
131
  /**
94
132
  * @param {string} base
@@ -104,7 +142,12 @@ export async function readPageRoutes(root) {
104
142
  let entries;
105
143
  try {
106
144
  entries = fs.readdirSync(dir, { withFileTypes: true });
107
- } catch {
145
+ } catch (error) {
146
+ const folder = path.relative(root, dir) || '.';
147
+ if (unreadable && !alreadySaid.has(folder)) {
148
+ alreadySaid.add(folder);
149
+ unreadable.push({ folder, why: whyNotOpened(error) });
150
+ }
108
151
  continue;
109
152
  }
110
153
  for (const entry of entries) {
@@ -163,9 +206,20 @@ export async function readPageRoutes(root) {
163
206
  * already has them), a journeys file if one was named, and the pages read out of the folder
164
207
  * names for everything neither of those covers. Never invented, never crawled.
165
208
  *
209
+ * TWO SCREENS WITH ONE NAME ARE TWO SCREENS. They used to be one: the list is built in a
210
+ * Map keyed by name, so the second `set` overwrote the first, and the screen that lost was
211
+ * never walked, never compared and never counted as a door nobody opened — missing from the
212
+ * very ledger that exists to catch exactly this. Now the second one is told apart by a
213
+ * number and both are walked. Two entries that are the same in every respect really are one
214
+ * screen written down twice, and only those are folded together.
215
+ *
166
216
  * @param {object} input
167
217
  * @param {Record<string, any>} input.config
168
218
  * @param {{url: string, file: string, needs: string[]}[]} input.pages
219
+ * @param {{folder: string, why: string}[]} [input.unreadable]
220
+ * Folders `readPageRoutes` could not open. Each becomes a journey that says it was not
221
+ * walked and why, which the engine records as missing coverage. A page behind one of them
222
+ * was never listed, so nothing else in the run would ever mention it.
169
223
  * @returns {Journey[]}
170
224
  */
171
225
  export function journeysFrom(input) {
@@ -173,19 +227,50 @@ export function journeysFrom(input) {
173
227
  const samples = config.samples ?? {};
174
228
  /** @type {Map<string, Journey>} */
175
229
  const journeys = new Map();
230
+ /** @type {Map<string, string>} name -> what that journey is, so a true duplicate is spotted */
231
+ const shapes = new Map();
232
+
233
+ /**
234
+ * A name nothing else is using. Numbering rather than overwriting: a screen that is
235
+ * dropped here is dropped everywhere, silently, for the rest of the run.
236
+ *
237
+ * @param {string} wanted
238
+ * @returns {string}
239
+ */
240
+ const freeName = (wanted) => {
241
+ if (!journeys.has(wanted)) return wanted;
242
+ for (let n = 2; ; n += 1) {
243
+ const tried = `${wanted} (${n})`;
244
+ if (!journeys.has(tried)) return tried;
245
+ }
246
+ };
176
247
 
177
248
  // v1 called these "screens" and this reads them unchanged, on purpose: nobody should have
178
249
  // to write their steps twice to get a second kind of check out of them.
179
250
  for (const screen of [...(config.screens ?? []), ...(config.journeys ?? [])]) {
180
251
  if (!screen || typeof screen !== 'object') continue;
181
- const name = String(screen.name ?? screen.url ?? 'a screen');
252
+ const wanted = String(screen.name ?? screen.url ?? 'a screen');
182
253
  /** @type {Record<string, any>[]} */
183
254
  const steps = [];
184
255
  if (screen.url !== undefined) steps.push({ act: 'open', goto: String(screen.url), note: `open ${screen.url}` });
185
256
  for (const step of screen.steps ?? []) steps.push({ act: actOf(step), ...step });
257
+
258
+ // The same name AND the same steps is one screen listed twice — the settings and the
259
+ // journeys file both naming it, most often — and folding those together loses nothing.
260
+ // A different set of steps under the same name is a different screen.
261
+ const shape = JSON.stringify(steps);
262
+ if (shapes.get(wanted) === shape) continue;
263
+ const name = freeName(wanted);
264
+ shapes.set(name, shape);
186
265
  journeys.set(name, {
187
266
  name,
188
- describe: String(screen.describe ?? screen.why ?? `walk ${name}`),
267
+ describe: String(
268
+ screen.describe ??
269
+ screen.why ??
270
+ (name === wanted
271
+ ? `walk ${name}`
272
+ : `walk ${name} — the settings name two different screens "${wanted}", so this is the second of them`)
273
+ ),
189
274
  source: 'code',
190
275
  surface: 'web',
191
276
  from: 'the project settings',
@@ -205,8 +290,7 @@ export function journeysFrom(input) {
205
290
  if (sample === undefined) unfilled.push(need);
206
291
  else url = url.replace(new RegExp(`\\[\\.{0,3}${need}\\]|:${need}`), encodeURIComponent(String(sample)));
207
292
  }
208
- const name = `page ${page.url}`;
209
- if (journeys.has(name)) continue;
293
+ const name = freeName(`page ${page.url}`);
210
294
  journeys.set(name, {
211
295
  name,
212
296
  describe: `open ${page.url} and read what the screen says`,
@@ -230,6 +314,28 @@ export function journeysFrom(input) {
230
314
  });
231
315
  }
232
316
 
317
+ // Last, and after the front-page fallback so a project made entirely of holes still gets
318
+ // one real journey. A folder nobody could open is a journey that says out loud it was not
319
+ // walked: the engine turns a journey carrying `skip` into missing coverage, which is the
320
+ // only route from here to the ledger. Without it, a page behind that folder was never
321
+ // listed, so nothing anywhere in the run would ever have mentioned it — and a page nobody
322
+ // listed reads exactly like a page that is fine.
323
+ for (const hole of input.unreadable ?? []) {
324
+ const name = freeName(`the pages under ${hole.folder}`);
325
+ journeys.set(name, {
326
+ name,
327
+ describe: `the pages under ${hole.folder}`,
328
+ source: 'code',
329
+ surface: 'web',
330
+ from: hole.folder,
331
+ channels: [],
332
+ steps: /** @type {any} */ ([]),
333
+ skip:
334
+ `"${hole.folder}" could not be opened while looking for this project's pages — ${hole.why} — so nothing under it was listed. ` +
335
+ 'Any page in there was not walked and is not in the count of pages that were. This is a hole, not a pass.',
336
+ });
337
+ }
338
+
233
339
  return [...journeys.values()].sort((a, b) => a.name.localeCompare(b.name));
234
340
  }
235
341
 
@@ -290,7 +396,16 @@ export const webAdapter = defineAdapter({
290
396
  const framework = ['next', 'react', 'vue', 'svelte', 'astro', '@remix-run/react', 'nuxt', 'solid-js', 'preact', 'vite']
291
397
  .find((name) => name in dependencies);
292
398
 
293
- const pages = await readPageRoutes(project.root);
399
+ /** @type {{unreadable: {folder: string, why: string}[]}} */
400
+ const collect = { unreadable: [] };
401
+ const pages = await readPageRoutes(project.root, collect);
402
+ if (collect.unreadable.length > 0) {
403
+ missing.push({
404
+ what: `permission to read ${collect.unreadable.length === 1 ? 'a folder' : `${collect.unreadable.length} folders`} this project's pages live in: ${collect.unreadable.map((u) => `${u.folder} (${u.why})`).join(', ')}`,
405
+ unlocks: 'listing the pages under them at all. Nothing under a folder that will not open is walked, counted, or missed, so the run is quiet about that part of the site',
406
+ howToGet: `Give this account permission to read ${collect.unreadable.length === 1 ? 'it' : 'them'} — on a Mac or Linux that is: chmod +rx ${collect.unreadable[0].folder}`,
407
+ });
408
+ }
294
409
  const address = config.url ?? config.baseUrl ?? null;
295
410
 
296
411
  if (!config.start && !address) {
@@ -338,7 +453,10 @@ export const webAdapter = defineAdapter({
338
453
 
339
454
  /** @param {import('./contract.js').AdapterProject} project */
340
455
  async journeys(project) {
341
- return journeysFrom({ config: project.config ?? {}, pages: await readPageRoutes(project.root) });
456
+ /** @type {{unreadable: {folder: string, why: string}[]}} */
457
+ const collect = { unreadable: [] };
458
+ const pages = await readPageRoutes(project.root, collect);
459
+ return journeysFrom({ config: project.config ?? {}, pages, unreadable: collect.unreadable });
342
460
  },
343
461
 
344
462
  /**
@@ -525,6 +643,9 @@ export const webAdapter = defineAdapter({
525
643
  const viewport = viewportFrom(config);
526
644
  const window = await openWindow({
527
645
  chromium: held.playwright.chromium,
646
+ // Which browser was chosen for this machine. Without it a driver that downloads no
647
+ // browser of its own looks for one that is not there.
648
+ executable: held.playwright.executable,
528
649
  scratchDir: ctx.scratchDir,
529
650
  viewport,
530
651
  colorScheme: config.colorScheme ?? 'light',
@@ -1001,9 +1122,21 @@ export function complaintKey(message) {
1001
1122
  }
1002
1123
 
1003
1124
  /**
1125
+ * A name a picture can be saved under.
1126
+ *
1127
+ * Cut with a fingerprint on the end, never cut alone. This is a FILE name: two checkpoints
1128
+ * whose names agreed for eighty characters were saved over each other, so the picture
1129
+ * offered as evidence for one finding was a photograph of a different screen — and nothing
1130
+ * about it looked wrong. Long file names are the normal case here, because the name is the
1131
+ * build, the journey and the checkpoint run together.
1132
+ *
1004
1133
  * @param {string} name
1005
1134
  * @returns {string}
1006
1135
  */
1007
1136
  function fileSafe(name) {
1008
- return String(name).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'checkpoint';
1137
+ const clean = String(name).replace(/[^A-Za-z0-9._-]+/g, '-');
1138
+ if (clean === '') return 'checkpoint';
1139
+ if (clean.length <= 80) return clean;
1140
+ const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
1141
+ return `${clean.slice(0, 71)}-${mark}`;
1009
1142
  }
@@ -1201,7 +1201,24 @@ export const windowsAdapter = defineAdapter({
1201
1201
  // Pixels last, and only as evidence. A picture is written to the evidence folder and
1202
1202
  // pointed at; it is never the thing compared.
1203
1203
  const shot = await runner.call('shot', { hwnd: window.hwnd }, { timeoutMs: 45_000 });
1204
- if (shot.ok && shot.png && Number(shot.bytes) <= MAX_SHOT_BYTES) {
1204
+ // THREE WAYS OUT OF HERE AND TWO OF THEM USED TO BE SILENT. A picture that failed,
1205
+ // came back empty, or came back bigger than the cap simply produced no observation
1206
+ // at all — so the pixels channel dropped out of the run without a word, and the
1207
+ // ledger reported the same coverage as a run where every window was photographed.
1208
+ // A cap is a decision and a decision has to be visible; a failure is a hole and a
1209
+ // hole has to be named. Neither is a reason to lose the rest of the walk.
1210
+ const tooBig = shot.ok === true && Boolean(shot.png) && Number(shot.bytes) > MAX_SHOT_BYTES;
1211
+ if (!shot.ok || !shot.png || tooBig) {
1212
+ seen.push(notCovered({
1213
+ channel: 'pixels',
1214
+ path: joinPath('screen', label, 'picture'),
1215
+ reason: tooBig ? 'too big' : 'crashed',
1216
+ says: tooBig
1217
+ ? `The picture of "${label}" came back at ${sizeBucket(Number(shot.bytes))}, over the ${sizeBucket(MAX_SHOT_BYTES)} this keeps, so it was not stored. Every other channel still looked at that window; only the picture is missing.`
1218
+ : `No picture of "${label}" could be taken${shot.error ? `: ${String(shot.error)}` : '.'} Every other channel still looked at that window; only the picture is missing.`,
1219
+ }));
1220
+ }
1221
+ if (shot.ok && shot.png && !tooBig) {
1205
1222
  const file = path.join(ctx.evidenceDir, `windows-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
1206
1223
  await fsp.writeFile(file, Buffer.from(String(shot.png), 'base64'));
1207
1224
  seen.push(observation({
@@ -439,7 +439,15 @@ async function takeSurvey(opts) {
439
439
  * download and a package, nothing else — no licence, no account, no clicking.
440
440
  * That is why doctor reports it as something the agent does without asking.
441
441
  */
442
- export const INSTALL_COMMAND = 'npm install --save-dev playwright && npx playwright install chromium';
442
+ // One command, and it installs nothing into anybody's project.
443
+ //
444
+ // It used to be `npm install --save-dev playwright && npx playwright install chromium`, which
445
+ // puts a 150MB package into a stranger's dependencies to solve a problem this tool has already
446
+ // solved: the driver ships with the tool, and the only thing that can be missing is a browser.
447
+ // `npx` fetches the downloader for the length of one command and leaves nothing behind, and the
448
+ // browser it downloads lands in a shared folder outside every project, where `surveyBrowsers`
449
+ // looks and where it survives every reinstall.
450
+ export const INSTALL_COMMAND = 'npx playwright install chromium';
443
451
 
444
452
  /**
445
453
  * @param {BrowserFound|null} chosen
package/src/v2/cause.js CHANGED
@@ -88,6 +88,7 @@ const run = promisify(execFile);
88
88
  * candidate: BuildFingerprint,
89
89
  * hunk?: ChangedHunk,
90
90
  * changed?: Changed,
91
+ * since?: string,
91
92
  * normalise?: (capture: Capture) => Capture,
92
93
  * events?: CheckEvents,
93
94
  * signal?: AbortSignal,
@@ -102,7 +103,11 @@ export async function proveCause(finding, opts) {
102
103
  });
103
104
  }
104
105
 
105
- const changed = opts.changed ?? (await whatChanged(opts.cwd));
106
+ // `since` is the reference build's commit, and without it this could only ever undo a
107
+ // change that was still uncommitted. An agent that committed its work before asking — which
108
+ // is the normal end of a task — got "nothing in the working tree has changed, so there is
109
+ // no change to undo" about a change sitting one commit back.
110
+ const changed = opts.changed ?? (await whatChanged(opts.cwd, { since: opts.since }));
106
111
  if (!changed.ok) return cannot(changed.why ?? 'The working tree could not be read.', null);
107
112
  // "git could not hand over the diff" and "there is no diff" both left `hunks` empty, and
108
113
  // the sentence below was said about both. Telling somebody their tree is clean when it is
@@ -114,7 +119,12 @@ export async function proveCause(finding, opts) {
114
119
  );
115
120
  }
116
121
  if (changed.hunks.length === 0 && changed.untracked.length === 0) {
117
- return cannot('Nothing in the working tree has changed, so there is no change to undo.', null);
122
+ return cannot(
123
+ changed.committed
124
+ ? 'Nothing has changed between the build you were happy with and this one — not in a commit and not in the working tree — so there is no change to undo.'
125
+ : 'Nothing in the working tree has changed, so there is no change to undo. Nothing here looked at what may already be committed: name the commit the old build is at and a committed change can be undone the same way.',
126
+ null,
127
+ );
118
128
  }
119
129
 
120
130
  /** @type {{hunk: ChangedHunk|null, candidates: ChangedHunk[]}} */
@@ -137,6 +147,14 @@ export async function proveCause(finding, opts) {
137
147
  if (journeys.length === 0) {
138
148
  return cannot('None of the journeys this finding came from are available to walk again.', hunk);
139
149
  }
150
+ // SOME of them, and not all, is its own answer and it used to have none.
151
+ //
152
+ // A finding gathered from three journeys where only two can be walked again was walked
153
+ // twice, and the third journey's addresses were then read out of a map that has nothing in
154
+ // it for them. `gone` answers false for those — correctly, it will not call an absent
155
+ // journey a pass — and the arithmetic below turned that into "still here with that change
156
+ // undone, so it is not what caused it". A sentence about addresses nobody looked at.
157
+ const missing = names.filter((name) => !opts.journeys.some((j) => j.name === name));
140
158
 
141
159
  const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-cause-'));
142
160
  const tree = path.join(base, 'tree');
@@ -161,10 +179,14 @@ export async function proveCause(finding, opts) {
161
179
  return p;
162
180
  };
163
181
  try {
164
- await gitOrThrow(['worktree', 'add', '--detach', tree, 'HEAD'], changed.root);
182
+ // The commit the patch was measured FROM, which is not always HEAD. A change that has
183
+ // been committed is in the patch and is also already in HEAD, so applying it on top of
184
+ // HEAD would try to add the same lines twice and fail — and before this travelled with
185
+ // the patch, that is exactly what would have happened.
186
+ await gitOrThrow(['worktree', 'add', '--detach', tree, changed.base], changed.root);
165
187
  checkedOut = true;
166
188
 
167
- // Everything you changed, applied to a clean copy of the last commit.
189
+ // Everything you changed, applied to a clean copy of the build you were happy with.
168
190
  if (changed.patch.trim().length > 0) {
169
191
  const workingPatch = path.join(base, 'working.patch');
170
192
  await fsp.writeFile(workingPatch, endWithNewline(changed.patch), 'utf8');
@@ -238,27 +260,49 @@ export async function proveCause(finding, opts) {
238
260
  }
239
261
 
240
262
  const differences = finding.differences;
263
+ // An address whose journey was never walked again was not re-checked, and it is counted
264
+ // apart from the ones that were. Folding it in with the survivors is how "nobody looked"
265
+ // came out of here dressed as "it is still there".
266
+ const rechecked = differences.filter((d) => without.has(d.journey ?? ''));
267
+ const notRechecked = differences.length - rechecked.length;
241
268
  let disappeared = 0;
242
- for (const d of differences) if (gone(d, without)) disappeared += 1;
243
-
244
- const proved = differences.length > 0 && disappeared === differences.length;
269
+ for (const d of rechecked) if (gone(d, without)) disappeared += 1;
270
+
271
+ const unseen = notRechecked > 0
272
+ ? ` ${notRechecked} of the ${differences.length} ${differences.length === 1 ? 'address' : 'addresses'} in this finding ${notRechecked === 1 ? 'was' : 'were'} not re-checked at all, because ${missing.length === 1 ? `the journey "${missing[0]}" is` : `the journeys ${missing.map((n) => `"${n}"`).join(', ')} are`} not available to walk again. Nothing here says anything about ${notRechecked === 1 ? 'it' : 'them'} either way.`
273
+ : '';
274
+ const proved = rechecked.length > 0 && disappeared === rechecked.length && notRechecked === 0;
275
+ // Everything that could be re-walked went away, and something else could not be walked at
276
+ // all. That is not a proof: the part nobody saw may be the part that matters, and a
277
+ // stamped "caused by that change" over the top of it is the worst thing this file can
278
+ // produce — a machine-checked reason for an agent to stop looking.
279
+ const partly = rechecked.length > 0 && disappeared === rechecked.length && notRechecked > 0;
245
280
  /** @type {CauseProof} */
246
281
  const result = {
247
282
  verdict:
248
- differences.length === 0 ? 'could not test' : proved ? 'caused by that change' : 'not caused by that change',
249
- escalates: differences.length > 0 && !proved,
250
- what:
251
- differences.length === 0
252
- ? 'This finding carries no differences, so there was nothing to re-check.'
283
+ rechecked.length === 0 || partly
284
+ ? 'could not test'
253
285
  : proved
254
- ? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained.`
255
- : disappeared > 0
256
- ? `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.`
257
- : `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet.`,
286
+ ? 'caused by that change'
287
+ : 'not caused by that change',
288
+ escalates: rechecked.length > 0 && !proved && !partly,
289
+ what:
290
+ rechecked.length === 0
291
+ ? `Nothing in this finding could be re-checked.${unseen || ' It carries no differences, so there was nothing to re-check.'}`
292
+ : partly
293
+ ? `Undoing that one change in ${hunk.file} took away every address that could be re-walked.${unseen} So this is not proved: what was not walked may be the half that matters.`
294
+ : proved
295
+ ? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained.`
296
+ : disappeared > 0
297
+ ? `Undoing that change in ${hunk.file} took away ${disappeared} of the ${rechecked.length} addresses that were re-checked and left ${rechecked.length - disappeared} exactly as ${rechecked.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.${unseen}`
298
+ : `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet.${unseen}`,
258
299
  hunk: { file: hunk.file, header: hunk.header },
259
- checked: differences.length,
300
+ // What was actually re-walked. It used to be every difference in the finding, including
301
+ // the ones no journey ever went near, so the number said the work had been done.
302
+ checked: rechecked.length,
260
303
  disappeared,
261
304
  };
305
+ if (notRechecked > 0) result.why = unseen.trim();
262
306
  if (opts.keep === true) result.worktree = tree;
263
307
  if (events) events.emit({ type: 'proof:done', at: events.elapsed(), message: result.what });
264
308
  return done(result);