staysfixed 0.7.2 → 0.8.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 +342 -0
- package/README.md +191 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -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/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +285 -45
- package/src/v2/init.js +162 -61
- 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 +10 -11
- 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 +40 -10
- 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 +29 -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/adapters/web.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* Every refusal is reported as a hole in the check. None of them is ever reported as a pass.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import crypto from 'node:crypto';
|
|
39
40
|
import fs from 'node:fs';
|
|
40
41
|
import fsp from 'node:fs/promises';
|
|
41
42
|
import path from 'node:path';
|
|
@@ -70,6 +71,26 @@ const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 };
|
|
|
70
71
|
/** Folders that never hold a page worth walking. */
|
|
71
72
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.staysfixed']);
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Why a folder would not open, in words somebody can act on.
|
|
76
|
+
*
|
|
77
|
+
* The code alone is no use to the person who has to fix it: `EACCES` on its own has sent
|
|
78
|
+
* more than one person looking for a bug in the tool.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} error
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function whyNotOpened(error) {
|
|
84
|
+
const code = String(/** @type {any} */ (error)?.code ?? '');
|
|
85
|
+
if (code === 'EACCES' || code === 'EPERM') return 'this account does not have permission to open it';
|
|
86
|
+
if (code === 'ENOENT') return 'it was there when the walk started and is not there now';
|
|
87
|
+
if (code === 'ENOTDIR') return 'something in the way is a file, not a folder';
|
|
88
|
+
if (code === 'ELOOP') return 'the links in it point round in a circle';
|
|
89
|
+
if (code === 'EMFILE' || code === 'ENFILE') return 'this machine ran out of open files while reading it';
|
|
90
|
+
const said = String(/** @type {any} */ (error)?.message ?? error ?? '').trim();
|
|
91
|
+
return said === '' ? 'the reason was not given' : said;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
/**
|
|
74
95
|
* The pages a project has, read out of its folder names.
|
|
75
96
|
*
|
|
@@ -83,12 +104,29 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.nex
|
|
|
83
104
|
* reported as needing a sample value rather than guessed at, because "we did not check
|
|
84
105
|
* this" and "this is fine" must never be allowed to look alike.
|
|
85
106
|
*
|
|
107
|
+
* A FOLDER THIS CANNOT OPEN IS NAMED, not skipped. It used to `continue` on the read that
|
|
108
|
+
* failed, which threw away that folder and every page underneath it without one word — and
|
|
109
|
+
* a page that was never listed is never walked, never counted, and never missed. The run
|
|
110
|
+
* then reported that nothing had changed about a section of the site it had not looked at.
|
|
111
|
+
* This is the same shape of bug as three others that were found and closed elsewhere in the
|
|
112
|
+
* tool; this was the fourth place it was living.
|
|
113
|
+
*
|
|
86
114
|
* @param {string} root
|
|
115
|
+
* @param {{unreadable?: {folder: string, why: string}[]}} [collect]
|
|
116
|
+
* Hand in an object and every folder that could not be opened is pushed onto
|
|
117
|
+
* `collect.unreadable`, with the reason in plain English. Callers that pass nothing get
|
|
118
|
+
* the old shape back and lose the holes, so the two callers inside this file both pass
|
|
119
|
+
* one; `detect` turns them into something a person can act on and `journeys` turns them
|
|
120
|
+
* into missing coverage, which is what stops a folder nobody could read reading as a
|
|
121
|
+
* folder with nothing in it.
|
|
87
122
|
* @returns {Promise<{url: string, file: string, needs: string[]}[]>}
|
|
88
123
|
*/
|
|
89
|
-
export async function readPageRoutes(root) {
|
|
124
|
+
export async function readPageRoutes(root, collect = {}) {
|
|
90
125
|
/** @type {Map<string, {url: string, file: string, needs: string[]}>} */
|
|
91
126
|
const found = new Map();
|
|
127
|
+
const unreadable = collect.unreadable;
|
|
128
|
+
/** @type {Set<string>} */
|
|
129
|
+
const alreadySaid = new Set();
|
|
92
130
|
|
|
93
131
|
/**
|
|
94
132
|
* @param {string} base
|
|
@@ -104,7 +142,12 @@ export async function readPageRoutes(root) {
|
|
|
104
142
|
let entries;
|
|
105
143
|
try {
|
|
106
144
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
107
|
-
} catch {
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const folder = path.relative(root, dir) || '.';
|
|
147
|
+
if (unreadable && !alreadySaid.has(folder)) {
|
|
148
|
+
alreadySaid.add(folder);
|
|
149
|
+
unreadable.push({ folder, why: whyNotOpened(error) });
|
|
150
|
+
}
|
|
108
151
|
continue;
|
|
109
152
|
}
|
|
110
153
|
for (const entry of entries) {
|
|
@@ -163,9 +206,20 @@ export async function readPageRoutes(root) {
|
|
|
163
206
|
* already has them), a journeys file if one was named, and the pages read out of the folder
|
|
164
207
|
* names for everything neither of those covers. Never invented, never crawled.
|
|
165
208
|
*
|
|
209
|
+
* TWO SCREENS WITH ONE NAME ARE TWO SCREENS. They used to be one: the list is built in a
|
|
210
|
+
* Map keyed by name, so the second `set` overwrote the first, and the screen that lost was
|
|
211
|
+
* never walked, never compared and never counted as a door nobody opened — missing from the
|
|
212
|
+
* very ledger that exists to catch exactly this. Now the second one is told apart by a
|
|
213
|
+
* number and both are walked. Two entries that are the same in every respect really are one
|
|
214
|
+
* screen written down twice, and only those are folded together.
|
|
215
|
+
*
|
|
166
216
|
* @param {object} input
|
|
167
217
|
* @param {Record<string, any>} input.config
|
|
168
218
|
* @param {{url: string, file: string, needs: string[]}[]} input.pages
|
|
219
|
+
* @param {{folder: string, why: string}[]} [input.unreadable]
|
|
220
|
+
* Folders `readPageRoutes` could not open. Each becomes a journey that says it was not
|
|
221
|
+
* walked and why, which the engine records as missing coverage. A page behind one of them
|
|
222
|
+
* was never listed, so nothing else in the run would ever mention it.
|
|
169
223
|
* @returns {Journey[]}
|
|
170
224
|
*/
|
|
171
225
|
export function journeysFrom(input) {
|
|
@@ -173,19 +227,50 @@ export function journeysFrom(input) {
|
|
|
173
227
|
const samples = config.samples ?? {};
|
|
174
228
|
/** @type {Map<string, Journey>} */
|
|
175
229
|
const journeys = new Map();
|
|
230
|
+
/** @type {Map<string, string>} name -> what that journey is, so a true duplicate is spotted */
|
|
231
|
+
const shapes = new Map();
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A name nothing else is using. Numbering rather than overwriting: a screen that is
|
|
235
|
+
* dropped here is dropped everywhere, silently, for the rest of the run.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} wanted
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
const freeName = (wanted) => {
|
|
241
|
+
if (!journeys.has(wanted)) return wanted;
|
|
242
|
+
for (let n = 2; ; n += 1) {
|
|
243
|
+
const tried = `${wanted} (${n})`;
|
|
244
|
+
if (!journeys.has(tried)) return tried;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
176
247
|
|
|
177
248
|
// v1 called these "screens" and this reads them unchanged, on purpose: nobody should have
|
|
178
249
|
// to write their steps twice to get a second kind of check out of them.
|
|
179
250
|
for (const screen of [...(config.screens ?? []), ...(config.journeys ?? [])]) {
|
|
180
251
|
if (!screen || typeof screen !== 'object') continue;
|
|
181
|
-
const
|
|
252
|
+
const wanted = String(screen.name ?? screen.url ?? 'a screen');
|
|
182
253
|
/** @type {Record<string, any>[]} */
|
|
183
254
|
const steps = [];
|
|
184
255
|
if (screen.url !== undefined) steps.push({ act: 'open', goto: String(screen.url), note: `open ${screen.url}` });
|
|
185
256
|
for (const step of screen.steps ?? []) steps.push({ act: actOf(step), ...step });
|
|
257
|
+
|
|
258
|
+
// The same name AND the same steps is one screen listed twice — the settings and the
|
|
259
|
+
// journeys file both naming it, most often — and folding those together loses nothing.
|
|
260
|
+
// A different set of steps under the same name is a different screen.
|
|
261
|
+
const shape = JSON.stringify(steps);
|
|
262
|
+
if (shapes.get(wanted) === shape) continue;
|
|
263
|
+
const name = freeName(wanted);
|
|
264
|
+
shapes.set(name, shape);
|
|
186
265
|
journeys.set(name, {
|
|
187
266
|
name,
|
|
188
|
-
describe: String(
|
|
267
|
+
describe: String(
|
|
268
|
+
screen.describe ??
|
|
269
|
+
screen.why ??
|
|
270
|
+
(name === wanted
|
|
271
|
+
? `walk ${name}`
|
|
272
|
+
: `walk ${name} — the settings name two different screens "${wanted}", so this is the second of them`)
|
|
273
|
+
),
|
|
189
274
|
source: 'code',
|
|
190
275
|
surface: 'web',
|
|
191
276
|
from: 'the project settings',
|
|
@@ -205,8 +290,7 @@ export function journeysFrom(input) {
|
|
|
205
290
|
if (sample === undefined) unfilled.push(need);
|
|
206
291
|
else url = url.replace(new RegExp(`\\[\\.{0,3}${need}\\]|:${need}`), encodeURIComponent(String(sample)));
|
|
207
292
|
}
|
|
208
|
-
const name = `page ${page.url}
|
|
209
|
-
if (journeys.has(name)) continue;
|
|
293
|
+
const name = freeName(`page ${page.url}`);
|
|
210
294
|
journeys.set(name, {
|
|
211
295
|
name,
|
|
212
296
|
describe: `open ${page.url} and read what the screen says`,
|
|
@@ -230,6 +314,28 @@ export function journeysFrom(input) {
|
|
|
230
314
|
});
|
|
231
315
|
}
|
|
232
316
|
|
|
317
|
+
// Last, and after the front-page fallback so a project made entirely of holes still gets
|
|
318
|
+
// one real journey. A folder nobody could open is a journey that says out loud it was not
|
|
319
|
+
// walked: the engine turns a journey carrying `skip` into missing coverage, which is the
|
|
320
|
+
// only route from here to the ledger. Without it, a page behind that folder was never
|
|
321
|
+
// listed, so nothing anywhere in the run would ever have mentioned it — and a page nobody
|
|
322
|
+
// listed reads exactly like a page that is fine.
|
|
323
|
+
for (const hole of input.unreadable ?? []) {
|
|
324
|
+
const name = freeName(`the pages under ${hole.folder}`);
|
|
325
|
+
journeys.set(name, {
|
|
326
|
+
name,
|
|
327
|
+
describe: `the pages under ${hole.folder}`,
|
|
328
|
+
source: 'code',
|
|
329
|
+
surface: 'web',
|
|
330
|
+
from: hole.folder,
|
|
331
|
+
channels: [],
|
|
332
|
+
steps: /** @type {any} */ ([]),
|
|
333
|
+
skip:
|
|
334
|
+
`"${hole.folder}" could not be opened while looking for this project's pages — ${hole.why} — so nothing under it was listed. ` +
|
|
335
|
+
'Any page in there was not walked and is not in the count of pages that were. This is a hole, not a pass.',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
233
339
|
return [...journeys.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
234
340
|
}
|
|
235
341
|
|
|
@@ -290,7 +396,16 @@ export const webAdapter = defineAdapter({
|
|
|
290
396
|
const framework = ['next', 'react', 'vue', 'svelte', 'astro', '@remix-run/react', 'nuxt', 'solid-js', 'preact', 'vite']
|
|
291
397
|
.find((name) => name in dependencies);
|
|
292
398
|
|
|
293
|
-
|
|
399
|
+
/** @type {{unreadable: {folder: string, why: string}[]}} */
|
|
400
|
+
const collect = { unreadable: [] };
|
|
401
|
+
const pages = await readPageRoutes(project.root, collect);
|
|
402
|
+
if (collect.unreadable.length > 0) {
|
|
403
|
+
missing.push({
|
|
404
|
+
what: `permission to read ${collect.unreadable.length === 1 ? 'a folder' : `${collect.unreadable.length} folders`} this project's pages live in: ${collect.unreadable.map((u) => `${u.folder} (${u.why})`).join(', ')}`,
|
|
405
|
+
unlocks: 'listing the pages under them at all. Nothing under a folder that will not open is walked, counted, or missed, so the run is quiet about that part of the site',
|
|
406
|
+
howToGet: `Give this account permission to read ${collect.unreadable.length === 1 ? 'it' : 'them'} — on a Mac or Linux that is: chmod +rx ${collect.unreadable[0].folder}`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
294
409
|
const address = config.url ?? config.baseUrl ?? null;
|
|
295
410
|
|
|
296
411
|
if (!config.start && !address) {
|
|
@@ -338,7 +453,10 @@ export const webAdapter = defineAdapter({
|
|
|
338
453
|
|
|
339
454
|
/** @param {import('./contract.js').AdapterProject} project */
|
|
340
455
|
async journeys(project) {
|
|
341
|
-
|
|
456
|
+
/** @type {{unreadable: {folder: string, why: string}[]}} */
|
|
457
|
+
const collect = { unreadable: [] };
|
|
458
|
+
const pages = await readPageRoutes(project.root, collect);
|
|
459
|
+
return journeysFrom({ config: project.config ?? {}, pages, unreadable: collect.unreadable });
|
|
342
460
|
},
|
|
343
461
|
|
|
344
462
|
/**
|
|
@@ -525,6 +643,9 @@ export const webAdapter = defineAdapter({
|
|
|
525
643
|
const viewport = viewportFrom(config);
|
|
526
644
|
const window = await openWindow({
|
|
527
645
|
chromium: held.playwright.chromium,
|
|
646
|
+
// Which browser was chosen for this machine. Without it a driver that downloads no
|
|
647
|
+
// browser of its own looks for one that is not there.
|
|
648
|
+
executable: held.playwright.executable,
|
|
528
649
|
scratchDir: ctx.scratchDir,
|
|
529
650
|
viewport,
|
|
530
651
|
colorScheme: config.colorScheme ?? 'light',
|
|
@@ -1001,9 +1122,21 @@ export function complaintKey(message) {
|
|
|
1001
1122
|
}
|
|
1002
1123
|
|
|
1003
1124
|
/**
|
|
1125
|
+
* A name a picture can be saved under.
|
|
1126
|
+
*
|
|
1127
|
+
* Cut with a fingerprint on the end, never cut alone. This is a FILE name: two checkpoints
|
|
1128
|
+
* whose names agreed for eighty characters were saved over each other, so the picture
|
|
1129
|
+
* offered as evidence for one finding was a photograph of a different screen — and nothing
|
|
1130
|
+
* about it looked wrong. Long file names are the normal case here, because the name is the
|
|
1131
|
+
* build, the journey and the checkpoint run together.
|
|
1132
|
+
*
|
|
1004
1133
|
* @param {string} name
|
|
1005
1134
|
* @returns {string}
|
|
1006
1135
|
*/
|
|
1007
1136
|
function fileSafe(name) {
|
|
1008
|
-
|
|
1137
|
+
const clean = String(name).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
1138
|
+
if (clean === '') return 'checkpoint';
|
|
1139
|
+
if (clean.length <= 80) return clean;
|
|
1140
|
+
const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
|
|
1141
|
+
return `${clean.slice(0, 71)}-${mark}`;
|
|
1009
1142
|
}
|
|
@@ -1201,7 +1201,24 @@ export const windowsAdapter = defineAdapter({
|
|
|
1201
1201
|
// Pixels last, and only as evidence. A picture is written to the evidence folder and
|
|
1202
1202
|
// pointed at; it is never the thing compared.
|
|
1203
1203
|
const shot = await runner.call('shot', { hwnd: window.hwnd }, { timeoutMs: 45_000 });
|
|
1204
|
-
|
|
1204
|
+
// THREE WAYS OUT OF HERE AND TWO OF THEM USED TO BE SILENT. A picture that failed,
|
|
1205
|
+
// came back empty, or came back bigger than the cap simply produced no observation
|
|
1206
|
+
// at all — so the pixels channel dropped out of the run without a word, and the
|
|
1207
|
+
// ledger reported the same coverage as a run where every window was photographed.
|
|
1208
|
+
// A cap is a decision and a decision has to be visible; a failure is a hole and a
|
|
1209
|
+
// hole has to be named. Neither is a reason to lose the rest of the walk.
|
|
1210
|
+
const tooBig = shot.ok === true && Boolean(shot.png) && Number(shot.bytes) > MAX_SHOT_BYTES;
|
|
1211
|
+
if (!shot.ok || !shot.png || tooBig) {
|
|
1212
|
+
seen.push(notCovered({
|
|
1213
|
+
channel: 'pixels',
|
|
1214
|
+
path: joinPath('screen', label, 'picture'),
|
|
1215
|
+
reason: tooBig ? 'too big' : 'crashed',
|
|
1216
|
+
says: tooBig
|
|
1217
|
+
? `The picture of "${label}" came back at ${sizeBucket(Number(shot.bytes))}, over the ${sizeBucket(MAX_SHOT_BYTES)} this keeps, so it was not stored. Every other channel still looked at that window; only the picture is missing.`
|
|
1218
|
+
: `No picture of "${label}" could be taken${shot.error ? `: ${String(shot.error)}` : '.'} Every other channel still looked at that window; only the picture is missing.`,
|
|
1219
|
+
}));
|
|
1220
|
+
}
|
|
1221
|
+
if (shot.ok && shot.png && !tooBig) {
|
|
1205
1222
|
const file = path.join(ctx.evidenceDir, `windows-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
|
|
1206
1223
|
await fsp.writeFile(file, Buffer.from(String(shot.png), 'base64'));
|
|
1207
1224
|
seen.push(observation({
|
package/src/v2/browsers.js
CHANGED
|
@@ -439,7 +439,15 @@ async function takeSurvey(opts) {
|
|
|
439
439
|
* download and a package, nothing else — no licence, no account, no clicking.
|
|
440
440
|
* That is why doctor reports it as something the agent does without asking.
|
|
441
441
|
*/
|
|
442
|
-
|
|
442
|
+
// One command, and it installs nothing into anybody's project.
|
|
443
|
+
//
|
|
444
|
+
// It used to be `npm install --save-dev playwright && npx playwright install chromium`, which
|
|
445
|
+
// puts a 150MB package into a stranger's dependencies to solve a problem this tool has already
|
|
446
|
+
// solved: the driver ships with the tool, and the only thing that can be missing is a browser.
|
|
447
|
+
// `npx` fetches the downloader for the length of one command and leaves nothing behind, and the
|
|
448
|
+
// browser it downloads lands in a shared folder outside every project, where `surveyBrowsers`
|
|
449
|
+
// looks and where it survives every reinstall.
|
|
450
|
+
export const INSTALL_COMMAND = 'npx playwright install chromium';
|
|
443
451
|
|
|
444
452
|
/**
|
|
445
453
|
* @param {BrowserFound|null} chosen
|
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);
|