staysfixed 0.3.1 → 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.
Files changed (46) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
@@ -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
+ }