staysfixed 0.12.0 → 0.13.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 +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
package/src/v2/browsers.js
CHANGED
|
@@ -47,6 +47,7 @@ import { StaysFixedError, isExpected } from '../core/errors.js';
|
|
|
47
47
|
import { findChrome, freePort, resolveElectronBinary } from '../drive/find.js';
|
|
48
48
|
import { waitForEndpoint } from '../drive/cdp.js';
|
|
49
49
|
import { keepOutput, stopProcess } from '../drive/browser.js';
|
|
50
|
+
import { stopTree } from '../core/stop-tree.js';
|
|
50
51
|
|
|
51
52
|
const exec = promisify(execFile);
|
|
52
53
|
|
|
@@ -309,6 +310,21 @@ export async function probeBrowser(binary) {
|
|
|
309
310
|
const version = /\d+\.\d+\.\d+(\.\d+)?/.exec(said)?.[0];
|
|
310
311
|
// It answered, but with no version in it. That is what a broken bundle does:
|
|
311
312
|
// it exits zero and prints the library it could not load.
|
|
313
|
+
//
|
|
314
|
+
// It is ALSO what a perfectly good Windows browser does when a copy of itself is already
|
|
315
|
+
// open: the new process hands the request to the running one, prints "Opening in existing
|
|
316
|
+
// browser session." and exits zero, and no amount of waiting produces a version. On a real
|
|
317
|
+
// Windows 11 machine on 2026-08-31 that was true of every browser on the box, so the tool
|
|
318
|
+
// said "There is no browser on this machine that will run" while standing next to a
|
|
319
|
+
// perfectly good Edge — which is the whole product unusable there, not a cosmetic wrong
|
|
320
|
+
// note. So Windows gets a second question, and only in this one case: the version Windows
|
|
321
|
+
// keeps inside the file, which starts nothing and cannot be answered on by another copy.
|
|
322
|
+
// A browser that failed outright still falls through to the catch below and is still
|
|
323
|
+
// reported broken, so nothing about a genuinely broken browser has been softened.
|
|
324
|
+
if (!version && process.platform === 'win32') {
|
|
325
|
+
const fromTheFile = await windowsFileVersion(binary);
|
|
326
|
+
if (fromTheFile) return { ok: true, version: fromTheFile };
|
|
327
|
+
}
|
|
312
328
|
if (!version) return { ok: false, why: said.split('\n')[0]?.slice(0, 200) || 'it printed nothing when asked its version' };
|
|
313
329
|
return { ok: true, version };
|
|
314
330
|
} catch (e) {
|
|
@@ -318,6 +334,52 @@ export async function probeBrowser(binary) {
|
|
|
318
334
|
}
|
|
319
335
|
}
|
|
320
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The version Windows keeps inside the .exe, read without starting it.
|
|
339
|
+
*
|
|
340
|
+
* Every Windows program carries its version as part of the file, and PowerShell reads it out
|
|
341
|
+
* in one line. Nothing is launched, so a browser the person already has open cannot answer on
|
|
342
|
+
* this one's behalf. Returns null whenever the answer is not a version, and the caller then
|
|
343
|
+
* falls back to asking the program itself.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} binary
|
|
346
|
+
* @returns {Promise<string|null>}
|
|
347
|
+
*/
|
|
348
|
+
async function windowsFileVersion(binary) {
|
|
349
|
+
try {
|
|
350
|
+
const { stdout } = await exec(
|
|
351
|
+
'powershell.exe',
|
|
352
|
+
[
|
|
353
|
+
'-NoProfile',
|
|
354
|
+
'-NonInteractive',
|
|
355
|
+
'-ExecutionPolicy',
|
|
356
|
+
'Bypass',
|
|
357
|
+
'-Command',
|
|
358
|
+
`(Get-Item -LiteralPath ${powershellString(binary)}).VersionInfo.ProductVersion`,
|
|
359
|
+
],
|
|
360
|
+
{ timeout: PROBE_MS, maxBuffer: 1 << 20, windowsHide: true },
|
|
361
|
+
);
|
|
362
|
+
return /\d+\.\d+\.\d+(\.\d+)?/.exec(String(stdout))?.[0] ?? null;
|
|
363
|
+
} catch {
|
|
364
|
+
// No PowerShell, or a file with no version in it. Either way the caller has a fallback.
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* One PowerShell string literal, with the quotes it needs.
|
|
371
|
+
*
|
|
372
|
+
* Single quotes, because PowerShell does not look inside them for variables — a path with a
|
|
373
|
+
* `$` in it is a path, not something to expand. A single quote inside the value is doubled,
|
|
374
|
+
* which is how PowerShell escapes one.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} value
|
|
377
|
+
* @returns {string}
|
|
378
|
+
*/
|
|
379
|
+
function powershellString(value) {
|
|
380
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
381
|
+
}
|
|
382
|
+
|
|
321
383
|
/**
|
|
322
384
|
* @param {BrowserKind} kind
|
|
323
385
|
* @returns {{name: string, why: string}}
|
|
@@ -549,9 +611,13 @@ function installGuards() {
|
|
|
549
611
|
* @returns {Promise<void>}
|
|
550
612
|
*/
|
|
551
613
|
async function removeStubbornly(home) {
|
|
552
|
-
for (let attempt = 0; attempt <
|
|
614
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
553
615
|
try {
|
|
554
|
-
|
|
616
|
+
// `maxRetries` as well as the loop, because the two answer different questions: the
|
|
617
|
+
// loop is for a file that reappears behind the sweep, and the retries are for Windows
|
|
618
|
+
// refusing to delete a folder anything still has a handle open inside. Measured on a
|
|
619
|
+
// real Windows 11 machine on 2026-08-31, where the second is what actually happens.
|
|
620
|
+
await fsp.rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
555
621
|
if (!fs.existsSync(home)) return;
|
|
556
622
|
} catch {
|
|
557
623
|
// A file recreated a millisecond after the sweep began. Wait for whoever wrote it to
|
|
@@ -570,11 +636,13 @@ async function removeStubbornly(home) {
|
|
|
570
636
|
*/
|
|
571
637
|
function killNow(pid, home) {
|
|
572
638
|
if (pid) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
639
|
+
// The whole tree. `process.kill` stops the one process it is given, and a browser is a
|
|
640
|
+
// parent plus a renderer for every page it has open — on Windows those renderers survive
|
|
641
|
+
// their parent, keep writing into the throwaway profile, and the sweep below can then
|
|
642
|
+
// never finish. Measured on a real Windows 11 machine on 2026-08-31: the profile of an
|
|
643
|
+
// interrupted run was still on disk afterwards. `stopTree` is deliberately synchronous,
|
|
644
|
+
// because this runs from an exit handler where there is no event loop left to await on.
|
|
645
|
+
stopTree(pid, 'SIGKILL');
|
|
578
646
|
// SIGKILL is a request to the kernel, not something that has already happened, and a
|
|
579
647
|
// browser is not one process. While the parent is being reaped its children are still
|
|
580
648
|
// writing into the profile, so deleting the folder in the same breath loses the race:
|
|
@@ -586,12 +654,21 @@ function killNow(pid, home) {
|
|
|
586
654
|
}
|
|
587
655
|
// Then remove it, and more than once. One rmSync is a snapshot; a file recreated a
|
|
588
656
|
// millisecond after the sweep began turns it into ENOTEMPTY and a leftover folder.
|
|
589
|
-
|
|
657
|
+
//
|
|
658
|
+
// The budget is bigger than it looks it needs to be, because Windows does not fail the same
|
|
659
|
+
// way. There a folder cannot be deleted while ANY process still has a handle open inside it,
|
|
660
|
+
// and the operating system releases those handles a little after the processes are gone —
|
|
661
|
+
// so the answer is not ENOTEMPTY, it is EBUSY, and it stays EBUSY for as long as the reaping
|
|
662
|
+
// takes. Measured on a real Windows 11 machine on 2026-08-31: five attempts a hundred
|
|
663
|
+
// milliseconds apart were not enough, and a run that fell over left its throwaway profile
|
|
664
|
+
// on disk. Twelve attempts is about a third of a second in the worst case, which is what an
|
|
665
|
+
// exit handler can honestly spend.
|
|
666
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
590
667
|
try {
|
|
591
|
-
fs.rmSync(home, { recursive: true, force: true });
|
|
668
|
+
fs.rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 });
|
|
592
669
|
return;
|
|
593
670
|
} catch {
|
|
594
|
-
waitSync(
|
|
671
|
+
waitSync(25);
|
|
595
672
|
}
|
|
596
673
|
}
|
|
597
674
|
// A profile left in the temporary folder is untidy, not harmful — and it is exactly what
|
|
@@ -1093,10 +1170,18 @@ async function isOurs(pid, userDataDir) {
|
|
|
1093
1170
|
if (!userDataDir.startsWith(SCRATCH_ROOT)) return false;
|
|
1094
1171
|
if (!isAlive(pid)) return false;
|
|
1095
1172
|
if (process.platform === 'win32') {
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
|
|
1173
|
+
// Windows is asked the same question, in Windows' words.
|
|
1174
|
+
//
|
|
1175
|
+
// This used to answer `true` here without asking anything, on the reasoning that the
|
|
1176
|
+
// profile folder was ours and the record was written by us. That reasoning drops the half
|
|
1177
|
+
// that matters: the record says which process id was ours THEN, and the paragraph above
|
|
1178
|
+
// is about an id that has since been handed to somebody else. Measured on a real Windows
|
|
1179
|
+
// 11 machine on 2026-08-31 — a record naming a stranger's running program was reported as
|
|
1180
|
+
// one of our browsers, and `--clean` would have killed it. Windows has no `ps`, so the
|
|
1181
|
+
// command line is read out of the process table instead; a query that cannot be answered
|
|
1182
|
+
// means no, because claiming somebody else's program is the one outcome forbidden here.
|
|
1183
|
+
const line = await windowsCommandLine(pid);
|
|
1184
|
+
return line !== null && line.includes(userDataDir);
|
|
1100
1185
|
}
|
|
1101
1186
|
try {
|
|
1102
1187
|
const { stdout } = await exec('ps', ['-o', 'command=', '-p', String(pid)], { timeout: PROBE_MS, windowsHide: true });
|
|
@@ -1106,6 +1191,38 @@ async function isOurs(pid, userDataDir) {
|
|
|
1106
1191
|
}
|
|
1107
1192
|
}
|
|
1108
1193
|
|
|
1194
|
+
/**
|
|
1195
|
+
* The command line one running process was started with, on Windows.
|
|
1196
|
+
*
|
|
1197
|
+
* This is Windows' `ps -o command=`. `tasklist` cannot do it — it reports the program's name
|
|
1198
|
+
* and nothing about its arguments, and the argument is the whole question here. Returns null
|
|
1199
|
+
* when the process is gone or the question could not be asked at all, and the caller reads
|
|
1200
|
+
* null as "not ours".
|
|
1201
|
+
*
|
|
1202
|
+
* @param {number} pid
|
|
1203
|
+
* @returns {Promise<string|null>}
|
|
1204
|
+
*/
|
|
1205
|
+
async function windowsCommandLine(pid) {
|
|
1206
|
+
try {
|
|
1207
|
+
const { stdout } = await exec(
|
|
1208
|
+
'powershell.exe',
|
|
1209
|
+
[
|
|
1210
|
+
'-NoProfile',
|
|
1211
|
+
'-NonInteractive',
|
|
1212
|
+
'-ExecutionPolicy',
|
|
1213
|
+
'Bypass',
|
|
1214
|
+
'-Command',
|
|
1215
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`,
|
|
1216
|
+
],
|
|
1217
|
+
{ timeout: PROBE_MS, maxBuffer: 1 << 20, windowsHide: true },
|
|
1218
|
+
);
|
|
1219
|
+
const said = String(stdout).trim();
|
|
1220
|
+
return said.length > 0 ? said : null;
|
|
1221
|
+
} catch {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1109
1226
|
/**
|
|
1110
1227
|
* Quit everything an earlier run left behind, and delete its throwaway profiles.
|
|
1111
1228
|
*
|
|
@@ -1134,17 +1251,12 @@ export async function cleanStrays() {
|
|
|
1134
1251
|
continue;
|
|
1135
1252
|
}
|
|
1136
1253
|
if (stray.running && stray.pid !== null) {
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1254
|
+
// The tree again, for the same reason as `killNow`: a leftover browser's renderers are
|
|
1255
|
+
// separate processes, and on Windows they hold the profile folder open long after their
|
|
1256
|
+
// parent has gone. `--clean` that leaves half a browser behind has not cleaned anything.
|
|
1257
|
+
stopTree(stray.pid, 'SIGTERM');
|
|
1142
1258
|
await waitForGone(stray.pid, GRACE_MS);
|
|
1143
|
-
|
|
1144
|
-
process.kill(stray.pid, 'SIGKILL');
|
|
1145
|
-
} catch {
|
|
1146
|
-
// Already gone, which is what we wanted.
|
|
1147
|
-
}
|
|
1259
|
+
stopTree(stray.pid, 'SIGKILL');
|
|
1148
1260
|
quit.push(stray);
|
|
1149
1261
|
} else {
|
|
1150
1262
|
swept.push(stray);
|
package/src/v2/cause.js
CHANGED
|
@@ -55,6 +55,15 @@ const run = promisify(execFile);
|
|
|
55
55
|
* @property {{file: string, header: string}|null} hunk
|
|
56
56
|
* @property {number} checked How many of the finding's differences were re-checked.
|
|
57
57
|
* @property {number} disappeared How many of them went away.
|
|
58
|
+
* @property {number} reran How many journeys were actually walked again. Zero means
|
|
59
|
+
* nothing was measured at all — no build was started, no
|
|
60
|
+
* journey was walked, and whatever this says is not a
|
|
61
|
+
* measurement. On 2026-08-31 a `prove` on a real website
|
|
62
|
+
* came back in five seconds, with zero server starts in the
|
|
63
|
+
* run log, wearing the same sentence as a real eleven-minute
|
|
64
|
+
* measurement. This number is what tells the two apart, so
|
|
65
|
+
* it is carried on every answer this file gives, including
|
|
66
|
+
* the ones it gives from the catch block.
|
|
58
67
|
* @property {string} [why] Why it could not be tested, when it could not.
|
|
59
68
|
* @property {ChangedHunk[]} [candidates] Hunks it could have tested, when it could not choose.
|
|
60
69
|
* @property {string} [worktree] Where it ran, when `keep` was asked for.
|
|
@@ -170,6 +179,14 @@ export async function proveCause(finding, opts) {
|
|
|
170
179
|
// verdict; `gitQuiet` hands back whether it worked and nothing used to look.
|
|
171
180
|
/** @type {{proof: CauseProof|null}} */
|
|
172
181
|
const held = { proof: null };
|
|
182
|
+
// How many journeys have actually been walked again by the time anything answers.
|
|
183
|
+
//
|
|
184
|
+
// Held out here, rather than inside the try, because the catch block below answers with
|
|
185
|
+
// `cannot()` and that answer has to say whether any measuring happened. A throw halfway
|
|
186
|
+
// through the second of three journeys is a genuinely different thing from a throw before
|
|
187
|
+
// the scratch checkout existed, and until 2026-08-31 both came back wearing the same
|
|
188
|
+
// sentence as a full eleven-minute measurement.
|
|
189
|
+
const walked = { count: 0 };
|
|
173
190
|
/**
|
|
174
191
|
* @param {CauseProof} p
|
|
175
192
|
* @returns {CauseProof}
|
|
@@ -212,6 +229,7 @@ export async function proveCause(finding, opts) {
|
|
|
212
229
|
return done(cannot(
|
|
213
230
|
`${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.`,
|
|
214
231
|
hunk,
|
|
232
|
+
walked.count,
|
|
215
233
|
));
|
|
216
234
|
}
|
|
217
235
|
|
|
@@ -230,6 +248,7 @@ export async function proveCause(finding, opts) {
|
|
|
230
248
|
return done(cannot(
|
|
231
249
|
`That change could not be undone on its own: ${undone.why}. It probably overlaps another change in the same place.`,
|
|
232
250
|
hunk,
|
|
251
|
+
walked.count,
|
|
233
252
|
));
|
|
234
253
|
}
|
|
235
254
|
}
|
|
@@ -257,6 +276,9 @@ export async function proveCause(finding, opts) {
|
|
|
257
276
|
});
|
|
258
277
|
const settled = opts.normalise ? opts.normalise(capture) : capture;
|
|
259
278
|
without.set(journey.name, indexByPath(settled.observations));
|
|
279
|
+
// Counted AFTER the walk returns, never before it starts. A journey that threw halfway
|
|
280
|
+
// was not walked, and saying it was is how "could not test" starts sounding measured.
|
|
281
|
+
walked.count += 1;
|
|
260
282
|
}
|
|
261
283
|
|
|
262
284
|
const differences = finding.differences;
|
|
@@ -292,22 +314,23 @@ export async function proveCause(finding, opts) {
|
|
|
292
314
|
: partly
|
|
293
315
|
? `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
316
|
: proved
|
|
295
|
-
? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained.`
|
|
317
|
+
? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained. That rests on real work: ${walked.count} ${walked.count === 1 ? 'journey was' : 'journeys were'} walked again with the change undone, and ${rechecked.length === 1 ? 'the one address that was re-checked matched' : `all ${rechecked.length} re-checked addresses matched`} the old build again.`
|
|
296
318
|
: disappeared > 0
|
|
297
319
|
? `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}`,
|
|
320
|
+
: `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet. That was measured, not assumed: ${walked.count} ${walked.count === 1 ? 'journey was' : 'journeys were'} walked again without your change and ${rechecked.length === 1 ? 'the one address that was re-checked still differed' : `all ${rechecked.length} re-checked addresses still differed`}.${unseen}`,
|
|
299
321
|
hunk: { file: hunk.file, header: hunk.header },
|
|
300
322
|
// What was actually re-walked. It used to be every difference in the finding, including
|
|
301
323
|
// the ones no journey ever went near, so the number said the work had been done.
|
|
302
324
|
checked: rechecked.length,
|
|
303
325
|
disappeared,
|
|
326
|
+
reran: walked.count,
|
|
304
327
|
};
|
|
305
328
|
if (notRechecked > 0) result.why = unseen.trim();
|
|
306
329
|
if (opts.keep === true) result.worktree = tree;
|
|
307
330
|
if (events) events.emit({ type: 'proof:done', at: events.elapsed(), message: result.what });
|
|
308
331
|
return done(result);
|
|
309
332
|
} catch (e) {
|
|
310
|
-
return done(cannot(messageOf(e), hunk));
|
|
333
|
+
return done(cannot(messageOf(e), hunk, walked.count));
|
|
311
334
|
} finally {
|
|
312
335
|
// Even when it throws. A leftover worktree makes the next `git status`
|
|
313
336
|
// confusing and the one after that frightening — and until 2026-08-30, if the removal
|
|
@@ -449,20 +472,38 @@ function sameFile(a, b) {
|
|
|
449
472
|
}
|
|
450
473
|
|
|
451
474
|
/**
|
|
475
|
+
* "I could not test this" — which is a third answer, not a soft version of the second one.
|
|
476
|
+
*
|
|
477
|
+
* The `what` sentence says out loud whether anything was re-walked, because the number
|
|
478
|
+
* alone travels badly. On 2026-08-31 the facade in src/v2/check.js forwarded only
|
|
479
|
+
* `{gone, verdict, escalates, detail}` to the MCP surface, so a caller reading `detail`
|
|
480
|
+
* had no way to know that the five-second answer in front of it had started no build and
|
|
481
|
+
* walked no journey. A fact that matters this much belongs in the sentence as well as in
|
|
482
|
+
* the field, so that it survives every surface that only passes the words along.
|
|
483
|
+
*
|
|
452
484
|
* @param {string} why
|
|
453
485
|
* @param {ChangedHunk|null} hunk
|
|
486
|
+
* @param {number} [reran] Journeys walked again before this gave up. Usually none — but the
|
|
487
|
+
* catch block in `proveCause` reaches here AFTER a walk may already
|
|
488
|
+
* have happened, and claiming "nothing was re-run" there would be
|
|
489
|
+
* its own small lie.
|
|
454
490
|
* @returns {CauseProof}
|
|
455
491
|
*/
|
|
456
|
-
function cannot(why, hunk) {
|
|
492
|
+
function cannot(why, hunk, reran = 0) {
|
|
493
|
+
const measured =
|
|
494
|
+
reran === 0
|
|
495
|
+
? ' Nothing was re-run: no build was started and no journey was walked again, so nothing here is a measurement of your product.'
|
|
496
|
+
: ` ${reran} ${reran === 1 ? 'journey was' : 'journeys were'} walked again before this gave up, and that work proved nothing either way.`;
|
|
457
497
|
return {
|
|
458
498
|
verdict: 'could not test',
|
|
459
499
|
// Not proven either way is not the same as proven innocent, and it must
|
|
460
500
|
// never be reported as if it were.
|
|
461
501
|
escalates: false,
|
|
462
|
-
what: `This could not be tested by undoing a change. ${why}`,
|
|
502
|
+
what: `This could not be tested by undoing a change, so your edit is neither cleared nor blamed. ${why}${measured}`,
|
|
463
503
|
hunk: hunk ? { file: hunk.file, header: hunk.header } : null,
|
|
464
504
|
checked: 0,
|
|
465
505
|
disappeared: 0,
|
|
506
|
+
reran,
|
|
466
507
|
why,
|
|
467
508
|
};
|
|
468
509
|
}
|