staysfixed 0.3.0 → 0.4.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/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
package/src/v2/cause.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof instead of a story.
|
|
3
|
+
*
|
|
4
|
+
* Ranking can say a difference is far from the edit. Ranking cannot say the edit
|
|
5
|
+
* caused it — that is a guess dressed as a number, and an agent under pressure
|
|
6
|
+
* to finish will believe whichever guess lets it stop. So this file does the one
|
|
7
|
+
* thing that settles it: it takes the change you think is to blame, undoes just
|
|
8
|
+
* that change in a scratch copy of the repository, walks the journey again, and
|
|
9
|
+
* looks. If the difference goes away, the change caused it. If the difference is
|
|
10
|
+
* still there, the assumption was wrong and the finding gets louder rather than
|
|
11
|
+
* quieter.
|
|
12
|
+
*
|
|
13
|
+
* It is cheap — one hunk, one journey — and it is a fact rather than a story.
|
|
14
|
+
*
|
|
15
|
+
* Two rules hold this file up. It NEVER touches the real working tree: every
|
|
16
|
+
* file it writes lives in a temporary folder, and the checkout is a `git
|
|
17
|
+
* worktree` removed again even when something throws halfway. And it undoes the
|
|
18
|
+
* change by reverse-applying the hunk on top of the full working diff, rather
|
|
19
|
+
* than by building a patch with the hunk left out, because the line numbers are
|
|
20
|
+
* exactly right that way round and only approximately right the other.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fsp from 'node:fs/promises';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import { promisify } from 'node:util';
|
|
28
|
+
|
|
29
|
+
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
30
|
+
import { indexByPath, sameValue } from './observation.js';
|
|
31
|
+
import { journeysOf } from './cluster.js';
|
|
32
|
+
import { whatChanged } from './rank.js';
|
|
33
|
+
|
|
34
|
+
/** @typedef {import('./types.js').Finding} Finding */
|
|
35
|
+
/** @typedef {import('./types.js').Difference} Difference */
|
|
36
|
+
/** @typedef {import('./types.js').Journey} Journey */
|
|
37
|
+
/** @typedef {import('./types.js').Capture} Capture */
|
|
38
|
+
/** @typedef {import('./types.js').Observation} Observation */
|
|
39
|
+
/** @typedef {import('./types.js').BuildFingerprint} BuildFingerprint */
|
|
40
|
+
/** @typedef {import('./rank.js').ChangedHunk} ChangedHunk */
|
|
41
|
+
/** @typedef {import('./rank.js').Changed} Changed */
|
|
42
|
+
/** @typedef {import('./run.js').Walker} Walker */
|
|
43
|
+
/** @typedef {import('./run.js').CheckEvents} CheckEvents */
|
|
44
|
+
|
|
45
|
+
const run = promisify(execFile);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What came of the attempt.
|
|
49
|
+
*
|
|
50
|
+
* @typedef {object} CauseProof
|
|
51
|
+
* @property {'caused by that change'|'not caused by that change'|'could not test'} verdict
|
|
52
|
+
* @property {boolean} escalates True when the difference outlived the revert. The agent's
|
|
53
|
+
* assumption was wrong and somebody has to look.
|
|
54
|
+
* @property {string} what One plain sentence, for whoever reads the summary.
|
|
55
|
+
* @property {{file: string, header: string}|null} hunk
|
|
56
|
+
* @property {number} checked How many of the finding's differences were re-checked.
|
|
57
|
+
* @property {number} disappeared How many of them went away.
|
|
58
|
+
* @property {string} [why] Why it could not be tested, when it could not.
|
|
59
|
+
* @property {ChangedHunk[]} [candidates] Hunks it could have tested, when it could not choose.
|
|
60
|
+
* @property {string} [worktree] Where it ran, when `keep` was asked for.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How many of a finding's differences are worth re-checking. A finding can stand
|
|
65
|
+
* for two hundred addresses; if the first handful come back the same way, the
|
|
66
|
+
* two hundredth will too, and walking them all buys nothing.
|
|
67
|
+
*/
|
|
68
|
+
const CHECK_AT_MOST = 5;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Prove, or disprove, that one change caused one finding.
|
|
72
|
+
*
|
|
73
|
+
* @param {Finding} finding
|
|
74
|
+
* @param {{
|
|
75
|
+
* cwd: string,
|
|
76
|
+
* walk: Walker,
|
|
77
|
+
* journeys: Journey[],
|
|
78
|
+
* candidate: BuildFingerprint,
|
|
79
|
+
* hunk?: ChangedHunk,
|
|
80
|
+
* changed?: Changed,
|
|
81
|
+
* normalise?: (capture: Capture) => Capture,
|
|
82
|
+
* events?: CheckEvents,
|
|
83
|
+
* signal?: AbortSignal,
|
|
84
|
+
* keep?: boolean,
|
|
85
|
+
* }} opts
|
|
86
|
+
* @returns {Promise<CauseProof>}
|
|
87
|
+
*/
|
|
88
|
+
export async function proveCause(finding, opts) {
|
|
89
|
+
if (!opts || typeof opts.walk !== 'function') {
|
|
90
|
+
throw new StaysFixedError('There is nothing to walk the journey with.', {
|
|
91
|
+
hint: 'Pass the same walk function the check ran with. The shape is Walker in src/v2/run.js.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const changed = opts.changed ?? (await whatChanged(opts.cwd));
|
|
96
|
+
if (!changed.ok) return cannot(changed.why ?? 'The working tree could not be read.', null);
|
|
97
|
+
if (changed.hunks.length === 0 && changed.untracked.length === 0) {
|
|
98
|
+
return cannot('Nothing in the working tree has changed, so there is no change to undo.', null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @type {{hunk: ChangedHunk|null, candidates: ChangedHunk[]}} */
|
|
102
|
+
const chosen = opts.hunk ? { hunk: opts.hunk, candidates: [] } : pickSuspect(finding, changed);
|
|
103
|
+
if (!chosen.hunk) {
|
|
104
|
+
return {
|
|
105
|
+
...cannot(
|
|
106
|
+
chosen.candidates.length === 0
|
|
107
|
+
? 'None of your changes look related to this finding, so there is nothing obvious to undo. Name the change you suspect and it will be tested.'
|
|
108
|
+
: 'More than one of your changes could have caused this. Name the one you suspect and it will be tested.',
|
|
109
|
+
null,
|
|
110
|
+
),
|
|
111
|
+
candidates: chosen.candidates,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const hunk = chosen.hunk;
|
|
115
|
+
|
|
116
|
+
const names = journeysOf(finding);
|
|
117
|
+
const journeys = opts.journeys.filter((j) => names.includes(j.name));
|
|
118
|
+
if (journeys.length === 0) {
|
|
119
|
+
return cannot('None of the journeys this finding came from are available to walk again.', hunk);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-cause-'));
|
|
123
|
+
const tree = path.join(base, 'tree');
|
|
124
|
+
// Belt and braces. Everything below writes files and reverts changes, and the
|
|
125
|
+
// one thing that must never happen is any of it landing in the real project.
|
|
126
|
+
if (!path.resolve(tree).startsWith(path.resolve(os.tmpdir()))) {
|
|
127
|
+
throw new StaysFixedError('Refusing to run the proof outside a temporary folder.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let checkedOut = false;
|
|
131
|
+
try {
|
|
132
|
+
await gitOrThrow(['worktree', 'add', '--detach', tree, 'HEAD'], changed.root);
|
|
133
|
+
checkedOut = true;
|
|
134
|
+
|
|
135
|
+
// Everything you changed, applied to a clean copy of the last commit.
|
|
136
|
+
if (changed.patch.trim().length > 0) {
|
|
137
|
+
const workingPatch = path.join(base, 'working.patch');
|
|
138
|
+
await fsp.writeFile(workingPatch, endWithNewline(changed.patch), 'utf8');
|
|
139
|
+
await gitOrThrow(['apply', '--whitespace=nowarn', workingPatch], tree);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// New files git has never seen are not in the diff, so they are carried over
|
|
143
|
+
// by hand — all of them except the suspect, which is left out, because
|
|
144
|
+
// leaving a file out IS undoing the change that added it.
|
|
145
|
+
const suspectIsNew = changed.untracked.includes(hunk.file);
|
|
146
|
+
for (const file of changed.untracked) {
|
|
147
|
+
if (file === hunk.file) continue;
|
|
148
|
+
await copyInto(changed.root, tree, file);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// And now undo the one change under suspicion.
|
|
152
|
+
if (!suspectIsNew) {
|
|
153
|
+
const suspectPatch = path.join(base, 'suspect.patch');
|
|
154
|
+
await fsp.writeFile(suspectPatch, endWithNewline(hunk.patch), 'utf8');
|
|
155
|
+
// Straight first. `--recount` is the fallback for a hunk whose header
|
|
156
|
+
// counts are off — it fixes those and breaks a hunk whose counts were
|
|
157
|
+
// right, so it is never the first thing tried.
|
|
158
|
+
let undone = await gitQuiet(['apply', '--reverse', '--whitespace=nowarn', suspectPatch], tree);
|
|
159
|
+
if (!undone.ok) {
|
|
160
|
+
undone = await gitQuiet(['apply', '--reverse', '--recount', '--whitespace=nowarn', suspectPatch], tree);
|
|
161
|
+
}
|
|
162
|
+
if (!undone.ok) {
|
|
163
|
+
return cannot(
|
|
164
|
+
`That change could not be undone on its own: ${undone.why}. It probably overlaps another change in the same place.`,
|
|
165
|
+
hunk,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const events = opts.events;
|
|
171
|
+
if (events) {
|
|
172
|
+
events.emit({
|
|
173
|
+
type: 'proof:start',
|
|
174
|
+
at: events.elapsed(),
|
|
175
|
+
message: `Undoing one change in ${hunk.file} in a scratch copy and walking ${names.join(', ')} again, to see whether it was the cause.`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @type {Map<string, Map<string, Observation>>} */
|
|
180
|
+
const without = new Map();
|
|
181
|
+
for (const journey of journeys) {
|
|
182
|
+
const capture = await opts.walk({
|
|
183
|
+
journey,
|
|
184
|
+
build: opts.candidate,
|
|
185
|
+
run: 'single',
|
|
186
|
+
which: 'candidate',
|
|
187
|
+
dir: tree,
|
|
188
|
+
events,
|
|
189
|
+
signal: opts.signal,
|
|
190
|
+
});
|
|
191
|
+
const settled = opts.normalise ? opts.normalise(capture) : capture;
|
|
192
|
+
without.set(journey.name, indexByPath(settled.observations));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const differences = finding.differences.slice(0, CHECK_AT_MOST);
|
|
196
|
+
let disappeared = 0;
|
|
197
|
+
for (const d of differences) if (gone(d, without)) disappeared += 1;
|
|
198
|
+
|
|
199
|
+
const proved = differences.length > 0 && disappeared === differences.length;
|
|
200
|
+
/** @type {CauseProof} */
|
|
201
|
+
const result = {
|
|
202
|
+
verdict:
|
|
203
|
+
differences.length === 0 ? 'could not test' : proved ? 'caused by that change' : 'not caused by that change',
|
|
204
|
+
escalates: differences.length > 0 && !proved,
|
|
205
|
+
what:
|
|
206
|
+
differences.length === 0
|
|
207
|
+
? 'This finding carries no differences, so there was nothing to re-check.'
|
|
208
|
+
: proved
|
|
209
|
+
? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained.`
|
|
210
|
+
: `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet.`,
|
|
211
|
+
hunk: { file: hunk.file, header: hunk.header },
|
|
212
|
+
checked: differences.length,
|
|
213
|
+
disappeared,
|
|
214
|
+
};
|
|
215
|
+
if (opts.keep === true) result.worktree = tree;
|
|
216
|
+
if (events) events.emit({ type: 'proof:done', at: events.elapsed(), message: result.what });
|
|
217
|
+
return result;
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return cannot(messageOf(e), hunk);
|
|
220
|
+
} finally {
|
|
221
|
+
// Even when it throws. A leftover worktree makes the next `git status`
|
|
222
|
+
// confusing and the one after that frightening.
|
|
223
|
+
if (checkedOut && opts.keep !== true) await gitQuiet(['worktree', 'remove', '--force', tree], changed.root);
|
|
224
|
+
await gitQuiet(['worktree', 'prune'], changed.root);
|
|
225
|
+
if (opts.keep !== true) await fsp.rm(base, { recursive: true, force: true }).catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Is this particular difference gone now?
|
|
231
|
+
*
|
|
232
|
+
* @param {Difference} d
|
|
233
|
+
* @param {Map<string, Map<string, Observation>>} without Journey name, then what it did with the change undone.
|
|
234
|
+
* @returns {boolean}
|
|
235
|
+
*/
|
|
236
|
+
export function gone(d, without) {
|
|
237
|
+
const here = without.get(d.journey ?? '');
|
|
238
|
+
// The journey did not run. Never call that a pass.
|
|
239
|
+
if (!here) return false;
|
|
240
|
+
const seen = here.get(d.path);
|
|
241
|
+
|
|
242
|
+
switch (d.kind) {
|
|
243
|
+
case 'appeared':
|
|
244
|
+
// It was not there before the change. It is gone if it is not there now.
|
|
245
|
+
return seen === undefined;
|
|
246
|
+
case 'vanished':
|
|
247
|
+
case 'changed':
|
|
248
|
+
return seen !== undefined && sameValue(seen.value, d.reference);
|
|
249
|
+
default:
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Which of your changes is the obvious suspect for this finding.
|
|
256
|
+
*
|
|
257
|
+
* It only answers when the answer is unambiguous. Guessing between three hunks
|
|
258
|
+
* and testing the wrong one produces a confident "not caused by that change",
|
|
259
|
+
* which is worse than no answer at all — so when it cannot tell, it says so and
|
|
260
|
+
* hands back the shortlist.
|
|
261
|
+
*
|
|
262
|
+
* @param {Finding} finding
|
|
263
|
+
* @param {Changed} changed
|
|
264
|
+
* @returns {{hunk: ChangedHunk|null, candidates: ChangedHunk[]}}
|
|
265
|
+
*/
|
|
266
|
+
export function pickSuspect(finding, changed) {
|
|
267
|
+
const files = finding.nearFiles ?? [];
|
|
268
|
+
if (files.length > 0) {
|
|
269
|
+
const near = changed.hunks.filter((h) => files.some((f) => sameFile(f, h.file)));
|
|
270
|
+
if (near.length === 1) return { hunk: near[0], candidates: near };
|
|
271
|
+
if (near.length > 1) return { hunk: null, candidates: near };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Nothing says where the finding lives. One change in the whole tree is still
|
|
275
|
+
// an unambiguous answer; more than one is not.
|
|
276
|
+
if (changed.hunks.length === 1) return { hunk: changed.hunks[0], candidates: changed.hunks };
|
|
277
|
+
return { hunk: null, candidates: changed.hunks };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Two paths naming the same file, one of them possibly absolute.
|
|
282
|
+
* @param {string} a
|
|
283
|
+
* @param {string} b
|
|
284
|
+
*/
|
|
285
|
+
function sameFile(a, b) {
|
|
286
|
+
if (a === b) return true;
|
|
287
|
+
const left = a.replace(/\\/g, '/');
|
|
288
|
+
const right = b.replace(/\\/g, '/');
|
|
289
|
+
return left.endsWith('/' + right) || right.endsWith('/' + left);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} why
|
|
294
|
+
* @param {ChangedHunk|null} hunk
|
|
295
|
+
* @returns {CauseProof}
|
|
296
|
+
*/
|
|
297
|
+
function cannot(why, hunk) {
|
|
298
|
+
return {
|
|
299
|
+
verdict: 'could not test',
|
|
300
|
+
// Not proven either way is not the same as proven innocent, and it must
|
|
301
|
+
// never be reported as if it were.
|
|
302
|
+
escalates: false,
|
|
303
|
+
what: `This could not be tested by undoing a change. ${why}`,
|
|
304
|
+
hunk: hunk ? { file: hunk.file, header: hunk.header } : null,
|
|
305
|
+
checked: 0,
|
|
306
|
+
disappeared: 0,
|
|
307
|
+
why,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Copy one file from the real project into the scratch checkout, folders and all.
|
|
313
|
+
* @param {string} from
|
|
314
|
+
* @param {string} to
|
|
315
|
+
* @param {string} file repo-relative
|
|
316
|
+
*/
|
|
317
|
+
async function copyInto(from, to, file) {
|
|
318
|
+
const source = path.resolve(from, file);
|
|
319
|
+
const target = path.resolve(to, file);
|
|
320
|
+
// A path that climbs out of the scratch tree is never copied.
|
|
321
|
+
if (!target.startsWith(path.resolve(to))) return;
|
|
322
|
+
try {
|
|
323
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
324
|
+
await fsp.copyFile(source, target);
|
|
325
|
+
} catch {
|
|
326
|
+
// A file that vanished between listing it and copying it is not worth
|
|
327
|
+
// failing a proof over.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @param {string} text */
|
|
332
|
+
function endWithNewline(text) {
|
|
333
|
+
return text.endsWith('\n') ? text : text + '\n';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {string[]} args
|
|
338
|
+
* @param {string} cwd
|
|
339
|
+
* @returns {Promise<string>}
|
|
340
|
+
*/
|
|
341
|
+
async function gitOrThrow(args, cwd) {
|
|
342
|
+
try {
|
|
343
|
+
const { stdout } = await run('git', args, { cwd, timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
344
|
+
return stdout;
|
|
345
|
+
} catch (e) {
|
|
346
|
+
const said = /** @type {{stderr?: string}} */ (e).stderr;
|
|
347
|
+
throw new StaysFixedError(`git ${args[0]} failed. ${String(said ?? messageOf(e)).trim()}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* @param {string[]} args
|
|
353
|
+
* @param {string} cwd
|
|
354
|
+
* @returns {Promise<{ok: boolean, why: string}>}
|
|
355
|
+
*/
|
|
356
|
+
async function gitQuiet(args, cwd) {
|
|
357
|
+
try {
|
|
358
|
+
await run('git', args, { cwd, timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
359
|
+
return { ok: true, why: '' };
|
|
360
|
+
} catch (e) {
|
|
361
|
+
const said = /** @type {{stderr?: string}} */ (e).stderr;
|
|
362
|
+
return { ok: false, why: String(said ?? messageOf(e)).trim() };
|
|
363
|
+
}
|
|
364
|
+
}
|