staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  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 +564 -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/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
@@ -270,6 +270,16 @@ function testingBrowsers() {
270
270
  take('chromium', path.join(inner, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'));
271
271
  take('chrome-for-testing', path.join(inner, 'chrome-linux', 'chrome'));
272
272
  take('chrome-for-testing', path.join(inner, 'chrome-win', 'chrome.exe'));
273
+ // The 64 matters, and leaving it off made this tool blind to full Chrome on every
274
+ // machine that is not a Mac. Playwright unpacks Linux into `chrome-linux64` and
275
+ // Windows into `chrome-win64`; Puppeteer does the same. Only macOS uses the names
276
+ // above, which is why it was never noticed here. On Linux this meant `npx playwright
277
+ // install chromium` - the command THIS FILE tells people to run - left a browser the
278
+ // survey could not see, and checks quietly fell back to the headless shell, or said
279
+ // there was no browser at all when the shell was not there too. Measured on a real
280
+ // Linux box on 2026-08-30: three Chromes present, none of them found.
281
+ take('chrome-for-testing', path.join(inner, 'chrome-linux64', 'chrome'));
282
+ take('chrome-for-testing', path.join(inner, 'chrome-win64', 'chrome.exe'));
273
283
  take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell'), true);
274
284
  take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-x64', 'chrome-headless-shell'), true);
275
285
  take('headless-shell', path.join(inner, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), true);
@@ -439,7 +449,15 @@ async function takeSurvey(opts) {
439
449
  * download and a package, nothing else — no licence, no account, no clicking.
440
450
  * That is why doctor reports it as something the agent does without asking.
441
451
  */
442
- export const INSTALL_COMMAND = 'npm install --save-dev playwright && npx playwright install chromium';
452
+ // One command, and it installs nothing into anybody's project.
453
+ //
454
+ // It used to be `npm install --save-dev playwright && npx playwright install chromium`, which
455
+ // puts a 150MB package into a stranger's dependencies to solve a problem this tool has already
456
+ // solved: the driver ships with the tool, and the only thing that can be missing is a browser.
457
+ // `npx` fetches the downloader for the length of one command and leaves nothing behind, and the
458
+ // browser it downloads lands in a shared folder outside every project, where `surveyBrowsers`
459
+ // looks and where it survives every reinstall.
460
+ export const INSTALL_COMMAND = 'npx playwright install chromium';
443
461
 
444
462
  /**
445
463
  * @param {BrowserFound|null} chosen
@@ -526,11 +544,56 @@ function killNow(pid, home) {
526
544
  } catch {
527
545
  // Already gone. That is the outcome we wanted.
528
546
  }
547
+ // SIGKILL is a request to the kernel, not something that has already happened, and a
548
+ // browser is not one process. While the parent is being reaped its children are still
549
+ // writing into the profile, so deleting the folder in the same breath loses the race:
550
+ // the sweep starts, a file appears behind it, the directory is not empty, and the
551
+ // profile outlives the run - which is the one thing `nothing it opened outlives the
552
+ // run` promises. It passed on macOS and on an idle Linux box and failed on a loaded CI
553
+ // runner for four releases, which is exactly how a race behaves.
554
+ for (let i = 0; i < 40 && stillThere(pid); i++) waitSync(10);
529
555
  }
556
+ // Then remove it, and more than once. One rmSync is a snapshot; a file recreated a
557
+ // millisecond after the sweep began turns it into ENOTEMPTY and a leftover folder.
558
+ for (let attempt = 0; attempt < 5; attempt += 1) {
559
+ try {
560
+ fs.rmSync(home, { recursive: true, force: true });
561
+ return;
562
+ } catch {
563
+ waitSync(20);
564
+ }
565
+ }
566
+ // A profile left in the temporary folder is untidy, not harmful — and it is exactly what
567
+ // `staysfixed browsers --clean` is for.
568
+ }
569
+
570
+ /**
571
+ * Is that process still running? Signal 0 asks without sending anything.
572
+ * @param {number} pid
573
+ * @returns {boolean}
574
+ */
575
+ function stillThere(pid) {
576
+ try {
577
+ process.kill(pid, 0);
578
+ return true;
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * A real sleep, in the one place nothing may be awaited: this runs inside
586
+ * `process.on('exit')`, where the event loop is already over and a promise never
587
+ * resolves. `Atomics.wait` is the only thing that actually pauses here.
588
+ *
589
+ * @param {number} ms
590
+ */
591
+ function waitSync(ms) {
530
592
  try {
531
- fs.rmSync(home, { recursive: true, force: true });
593
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
532
594
  } catch {
533
- // A profile left in the temporary folder is untidy, not harmful.
595
+ // No SharedArrayBuffer here. Falling straight through is still better than throwing
596
+ // out of a cleanup handler.
534
597
  }
535
598
  }
536
599
 
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);