staysfixed 0.6.2 → 0.7.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 +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
|
@@ -61,6 +61,89 @@ const alive = new Map();
|
|
|
61
61
|
/** @param {number} ms */
|
|
62
62
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
63
|
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Saying out loud that something was opened
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What this tool opened, and enough about it to be able to look after it.
|
|
70
|
+
*
|
|
71
|
+
* The screen is the reason this exists. A check that starts a desktop app has to be
|
|
72
|
+
* able to say, afterwards and to something outside this file: THAT application, with
|
|
73
|
+
* THAT unix id, is mine — I opened it, I will close it, and while it is up it does
|
|
74
|
+
* not get to keep taking the screen from whoever is using this machine. Nothing else
|
|
75
|
+
* on the machine can work that out for itself: a scratch copy of an app and the
|
|
76
|
+
* person's own copy of the same app are indistinguishable from the outside.
|
|
77
|
+
*
|
|
78
|
+
* @typedef {object} OpenedApp
|
|
79
|
+
* @property {string} name The application's name, as macOS reports it.
|
|
80
|
+
* @property {number} pid
|
|
81
|
+
* @property {string} binary What was actually run.
|
|
82
|
+
* @property {string} label Which build it is, in plain English.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Everybody who wants to be told. A Set rather than a single hook because a check, a
|
|
87
|
+
* panel and a test can all reasonably want to know at once.
|
|
88
|
+
* @type {Set<(app: OpenedApp) => void>}
|
|
89
|
+
*/
|
|
90
|
+
const told = new Set();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Be told whenever this tool opens a desktop app.
|
|
94
|
+
*
|
|
95
|
+
* Called before anything is started, and the listener runs the moment the process
|
|
96
|
+
* exists — before the app has drawn a window, which is what makes it early enough to
|
|
97
|
+
* be useful. A listener that throws is ignored: nothing that merely wants to WATCH a
|
|
98
|
+
* run may break one.
|
|
99
|
+
*
|
|
100
|
+
* @param {(app: OpenedApp) => void} listener
|
|
101
|
+
* @returns {() => void} stop being told
|
|
102
|
+
*/
|
|
103
|
+
export function onAppStarted(listener) {
|
|
104
|
+
told.add(listener);
|
|
105
|
+
return () => {
|
|
106
|
+
told.delete(listener);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {OpenedApp} app
|
|
112
|
+
* @returns {void}
|
|
113
|
+
*/
|
|
114
|
+
function announce(app) {
|
|
115
|
+
for (const listener of [...told]) {
|
|
116
|
+
try {
|
|
117
|
+
listener(app);
|
|
118
|
+
} catch {
|
|
119
|
+
// Watching is never allowed to be the reason a run fails.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The application name macOS will use for this binary.
|
|
126
|
+
*
|
|
127
|
+
* A Mac app is run through the executable buried inside its bundle —
|
|
128
|
+
* `Foo.app/Contents/MacOS/Foo` — but everything that talks about windows and the
|
|
129
|
+
* foreground talks about "Foo". The bundle is what to read: the executable inside it
|
|
130
|
+
* is often called something else entirely, and for a development build it is called
|
|
131
|
+
* `Electron`.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} binary
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function appNameFor(binary) {
|
|
137
|
+
const text = String(binary ?? '');
|
|
138
|
+
const parts = text.split(path.sep);
|
|
139
|
+
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
|
140
|
+
if (parts[i].toLowerCase().endsWith('.app')) return parts[i].slice(0, -4);
|
|
141
|
+
}
|
|
142
|
+
const base = path.basename(text);
|
|
143
|
+
const dot = base.lastIndexOf('.');
|
|
144
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
145
|
+
}
|
|
146
|
+
|
|
64
147
|
// ---------------------------------------------------------------------------
|
|
65
148
|
// The shapes
|
|
66
149
|
// ---------------------------------------------------------------------------
|
|
@@ -516,6 +599,12 @@ export function startIsolated(isolation, opts) {
|
|
|
516
599
|
// A quit later on must never take the tool down with an unhandled error event.
|
|
517
600
|
child.on('error', () => {});
|
|
518
601
|
|
|
602
|
+
// Said out loud the moment the process exists, and before it has drawn anything.
|
|
603
|
+
// Whoever is looking after the screen during this check needs to know that this
|
|
604
|
+
// application belongs to the tool BEFORE it appears, or its first appearance is
|
|
605
|
+
// read as the person choosing it.
|
|
606
|
+
announce({ name: appNameFor(opts.binary), pid: child.pid ?? -1, binary: opts.binary, label: isolation.label });
|
|
607
|
+
|
|
519
608
|
return {
|
|
520
609
|
child,
|
|
521
610
|
pid: child.pid ?? -1,
|
package/src/v2/cause.js
CHANGED
|
@@ -58,6 +58,11 @@ const run = promisify(execFile);
|
|
|
58
58
|
* @property {string} [why] Why it could not be tested, when it could not.
|
|
59
59
|
* @property {ChangedHunk[]} [candidates] Hunks it could have tested, when it could not choose.
|
|
60
60
|
* @property {string} [worktree] Where it ran, when `keep` was asked for.
|
|
61
|
+
* @property {string} [leftBehind] Set when the scratch checkout could not be removed. It says
|
|
62
|
+
* exactly what is still on disk and how to get rid of it,
|
|
63
|
+
* because a cleanup that fails in silence leaves a stale
|
|
64
|
+
* entry in the real repository's worktree list and the next
|
|
65
|
+
* `git status` there is frightening for no reason.
|
|
61
66
|
*/
|
|
62
67
|
|
|
63
68
|
// EVERY difference in the finding is re-checked, and there is deliberately no ceiling.
|
|
@@ -99,6 +104,15 @@ export async function proveCause(finding, opts) {
|
|
|
99
104
|
|
|
100
105
|
const changed = opts.changed ?? (await whatChanged(opts.cwd));
|
|
101
106
|
if (!changed.ok) return cannot(changed.why ?? 'The working tree could not be read.', null);
|
|
107
|
+
// "git could not hand over the diff" and "there is no diff" both left `hunks` empty, and
|
|
108
|
+
// the sentence below was said about both. Telling somebody their tree is clean when it is
|
|
109
|
+
// not is worse than telling them nothing, because they act on it.
|
|
110
|
+
if (changed.patchUnread === true && changed.hunks.length === 0) {
|
|
111
|
+
return cannot(
|
|
112
|
+
`${changed.patchUnreadWhy ?? 'The working diff could not be read.'} That is not the same as nothing having changed — ${changed.files.length} tracked ${changed.files.length === 1 ? 'file has' : 'files have'} edits in ${changed.files.length === 1 ? 'it' : 'them'} — so no change could be undone and nothing here is proved either way.`,
|
|
113
|
+
null,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
102
116
|
if (changed.hunks.length === 0 && changed.untracked.length === 0) {
|
|
103
117
|
return cannot('Nothing in the working tree has changed, so there is no change to undo.', null);
|
|
104
118
|
}
|
|
@@ -133,6 +147,19 @@ export async function proveCause(finding, opts) {
|
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
let checkedOut = false;
|
|
150
|
+
// The answer is held rather than only returned, so the tidy-up below can write on it. A
|
|
151
|
+
// cleanup that fails after the verdict is decided must still reach whoever reads the
|
|
152
|
+
// verdict; `gitQuiet` hands back whether it worked and nothing used to look.
|
|
153
|
+
/** @type {{proof: CauseProof|null}} */
|
|
154
|
+
const held = { proof: null };
|
|
155
|
+
/**
|
|
156
|
+
* @param {CauseProof} p
|
|
157
|
+
* @returns {CauseProof}
|
|
158
|
+
*/
|
|
159
|
+
const done = (p) => {
|
|
160
|
+
held.proof = p;
|
|
161
|
+
return p;
|
|
162
|
+
};
|
|
136
163
|
try {
|
|
137
164
|
await gitOrThrow(['worktree', 'add', '--detach', tree, 'HEAD'], changed.root);
|
|
138
165
|
checkedOut = true;
|
|
@@ -148,9 +175,22 @@ export async function proveCause(finding, opts) {
|
|
|
148
175
|
// by hand — all of them except the suspect, which is left out, because
|
|
149
176
|
// leaving a file out IS undoing the change that added it.
|
|
150
177
|
const suspectIsNew = changed.untracked.includes(hunk.file);
|
|
178
|
+
/** @type {string[]} */
|
|
179
|
+
const notCarried = [];
|
|
151
180
|
for (const file of changed.untracked) {
|
|
152
181
|
if (file === hunk.file) continue;
|
|
153
|
-
await copyInto(changed.root, tree, file);
|
|
182
|
+
const copied = await copyInto(changed.root, tree, file);
|
|
183
|
+
if (!copied.ok) notCarried.push(`${file} (${copied.why})`);
|
|
184
|
+
}
|
|
185
|
+
// A new file that did not make it into the scratch checkout is a file the re-walk runs
|
|
186
|
+
// WITHOUT. The difference then disappears because the file is missing, not because the
|
|
187
|
+
// suspect change caused it — and the proof comes back "caused by that change", stamped,
|
|
188
|
+
// machine-checked and wrong. That is a verdict built on a silence, so there is no verdict.
|
|
189
|
+
if (notCarried.length > 0) {
|
|
190
|
+
return done(cannot(
|
|
191
|
+
`${notCarried.length} new ${notCarried.length === 1 ? 'file' : 'files'} could not be copied into the scratch checkout: ${notCarried.join('; ')}. Anything that went away in a copy missing those files would have gone away for the wrong reason, so nothing is claimed here.`,
|
|
192
|
+
hunk,
|
|
193
|
+
));
|
|
154
194
|
}
|
|
155
195
|
|
|
156
196
|
// And now undo the one change under suspicion.
|
|
@@ -165,10 +205,10 @@ export async function proveCause(finding, opts) {
|
|
|
165
205
|
undone = await gitQuiet(['apply', '--reverse', '--recount', '--whitespace=nowarn', suspectPatch], tree);
|
|
166
206
|
}
|
|
167
207
|
if (!undone.ok) {
|
|
168
|
-
return cannot(
|
|
208
|
+
return done(cannot(
|
|
169
209
|
`That change could not be undone on its own: ${undone.why}. It probably overlaps another change in the same place.`,
|
|
170
210
|
hunk,
|
|
171
|
-
);
|
|
211
|
+
));
|
|
172
212
|
}
|
|
173
213
|
}
|
|
174
214
|
|
|
@@ -221,15 +261,83 @@ export async function proveCause(finding, opts) {
|
|
|
221
261
|
};
|
|
222
262
|
if (opts.keep === true) result.worktree = tree;
|
|
223
263
|
if (events) events.emit({ type: 'proof:done', at: events.elapsed(), message: result.what });
|
|
224
|
-
return result;
|
|
264
|
+
return done(result);
|
|
225
265
|
} catch (e) {
|
|
226
|
-
return cannot(messageOf(e), hunk);
|
|
266
|
+
return done(cannot(messageOf(e), hunk));
|
|
227
267
|
} finally {
|
|
228
268
|
// Even when it throws. A leftover worktree makes the next `git status`
|
|
229
|
-
// confusing and the one after that frightening
|
|
230
|
-
|
|
231
|
-
await
|
|
232
|
-
if (
|
|
269
|
+
// confusing and the one after that frightening — and until 2026-08-30, if the removal
|
|
270
|
+
// itself failed, it failed in complete silence and left one behind anyway.
|
|
271
|
+
const mess = await tidyUp({ tree, base, root: changed.root, checkedOut, keep: opts.keep === true });
|
|
272
|
+
if (mess) {
|
|
273
|
+
if (held.proof) held.proof.leftBehind = mess;
|
|
274
|
+
if (opts.events) opts.events.emit({ type: 'note', at: opts.events.elapsed(), message: mess });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Put the scratch checkout away, and say so plainly when it will not go.
|
|
281
|
+
*
|
|
282
|
+
* `git worktree remove` fails for ordinary reasons — a file still open, a folder the run
|
|
283
|
+
* itself is sitting in, a permission. When it does, the folder is deleted directly and the
|
|
284
|
+
* registration is pruned, which is the same outcome by another road. If even that does not
|
|
285
|
+
* work, the one thing left worth doing is saying exactly what is on disk and the one command
|
|
286
|
+
* that clears it, rather than leaving the person to find it themselves in a month.
|
|
287
|
+
*
|
|
288
|
+
* @param {{tree: string, base: string, root: string, checkedOut: boolean, keep: boolean}} where
|
|
289
|
+
* @returns {Promise<string>} empty when everything was cleared away
|
|
290
|
+
*/
|
|
291
|
+
async function tidyUp(where) {
|
|
292
|
+
if (where.keep) return '';
|
|
293
|
+
/** @type {string[]} */
|
|
294
|
+
const problems = [];
|
|
295
|
+
|
|
296
|
+
if (where.checkedOut) {
|
|
297
|
+
const removed = await gitQuiet(['worktree', 'remove', '--force', where.tree], where.root);
|
|
298
|
+
if (!removed.ok && (await stillThere(where.tree))) {
|
|
299
|
+
if (!(await wipe(where.tree))) problems.push(`the scratch checkout is still at ${where.tree} (${removed.why})`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const pruned = await gitQuiet(['worktree', 'prune'], where.root);
|
|
303
|
+
if (!pruned.ok) {
|
|
304
|
+
problems.push(`the stale worktree entry could not be pruned from ${where.root} (${pruned.why}), so \`git worktree list\` there may still name a folder that is gone`);
|
|
305
|
+
}
|
|
306
|
+
if (!(await wipe(where.base)) && (await stillThere(where.base))) {
|
|
307
|
+
problems.push(`the temporary folder ${where.base} could not be deleted`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (problems.length === 0) return '';
|
|
311
|
+
return (
|
|
312
|
+
`Proving the cause left something behind: ${problems.join('; ')}. ` +
|
|
313
|
+
`Nothing of yours was touched — all of it is inside a temporary folder — but it will not clear itself. ` +
|
|
314
|
+
`To remove it: git -C ${where.root} worktree prune && rm -rf ${where.base}`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* @param {string} dir
|
|
320
|
+
* @returns {Promise<boolean>} true when it was removed or was never there
|
|
321
|
+
*/
|
|
322
|
+
async function wipe(dir) {
|
|
323
|
+
try {
|
|
324
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
325
|
+
return true;
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {string} dir
|
|
333
|
+
* @returns {Promise<boolean>}
|
|
334
|
+
*/
|
|
335
|
+
async function stillThere(dir) {
|
|
336
|
+
try {
|
|
337
|
+
await fsp.stat(dir);
|
|
338
|
+
return true;
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
233
341
|
}
|
|
234
342
|
}
|
|
235
343
|
|
|
@@ -317,21 +425,52 @@ function cannot(why, hunk) {
|
|
|
317
425
|
|
|
318
426
|
/**
|
|
319
427
|
* Copy one file from the real project into the scratch checkout, folders and all.
|
|
428
|
+
*
|
|
429
|
+
* A file that vanished between git listing it and this copying it really is nothing to fail
|
|
430
|
+
* a proof over — it is not in the working tree any more either, so the scratch checkout is
|
|
431
|
+
* right to be without it. Every OTHER failure is different in kind: the file IS there, the
|
|
432
|
+
* scratch copy does not have it, and the re-walk is therefore running a different product.
|
|
433
|
+
* Those two used to be the same empty catch.
|
|
434
|
+
*
|
|
320
435
|
* @param {string} from
|
|
321
436
|
* @param {string} to
|
|
322
437
|
* @param {string} file repo-relative
|
|
438
|
+
* @returns {Promise<{ok: boolean, why: string}>}
|
|
323
439
|
*/
|
|
324
440
|
async function copyInto(from, to, file) {
|
|
325
441
|
const source = path.resolve(from, file);
|
|
326
442
|
const target = path.resolve(to, file);
|
|
327
443
|
// A path that climbs out of the scratch tree is never copied.
|
|
328
|
-
if (!target.startsWith(path.resolve(to)))
|
|
444
|
+
if (!target.startsWith(path.resolve(to) + path.sep)) {
|
|
445
|
+
return { ok: false, why: 'its path points outside the scratch checkout' };
|
|
446
|
+
}
|
|
329
447
|
try {
|
|
330
448
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
331
449
|
await fsp.copyFile(source, target);
|
|
450
|
+
return { ok: true, why: '' };
|
|
451
|
+
} catch (e) {
|
|
452
|
+
const code = /** @type {{code?: string}} */ (e).code;
|
|
453
|
+
if (code === 'ENOENT' && !(await exists(source))) return { ok: true, why: '' };
|
|
454
|
+
if (code === 'EISDIR') {
|
|
455
|
+
// git only lists a bare directory when it is asked for untracked directories rather
|
|
456
|
+
// than untracked files, which is not how this asks — but if it ever happens, a folder
|
|
457
|
+
// silently not copied is a whole subtree the re-walk does not have.
|
|
458
|
+
return { ok: false, why: 'it is a folder, not a file' };
|
|
459
|
+
}
|
|
460
|
+
return { ok: false, why: e instanceof Error ? e.message : String(e) };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* @param {string} file
|
|
466
|
+
* @returns {Promise<boolean>}
|
|
467
|
+
*/
|
|
468
|
+
async function exists(file) {
|
|
469
|
+
try {
|
|
470
|
+
await fsp.stat(file);
|
|
471
|
+
return true;
|
|
332
472
|
} catch {
|
|
333
|
-
|
|
334
|
-
// failing a proof over.
|
|
473
|
+
return false;
|
|
335
474
|
}
|
|
336
475
|
}
|
|
337
476
|
|