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.
- package/CHANGELOG.md +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
package/src/v2/browsers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
593
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
532
594
|
} catch {
|
|
533
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
243
|
-
|
|
244
|
-
const
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
?
|
|
255
|
-
:
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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);
|