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/drive/browser.js
CHANGED
|
@@ -14,6 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
|
|
15
15
|
import { StaysFixedError, isExpected } from '../core/errors.js';
|
|
16
16
|
import { detail } from '../core/log.js';
|
|
17
|
+
import { stopTree, OWN_PROCESS_GROUP } from '../core/stop-tree.js';
|
|
17
18
|
import { DEFAULT_VIEWPORT } from '../core/config.js';
|
|
18
19
|
import { waitForEndpoint, listTargets, connect } from './cdp.js';
|
|
19
20
|
import { requireChrome, freePort } from './find.js';
|
|
@@ -116,20 +117,17 @@ export function whenExited(child) {
|
|
|
116
117
|
export async function stopProcess(child, graceMs) {
|
|
117
118
|
if (isGone(child)) return;
|
|
118
119
|
const exited = whenExited(child);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
// The tree, not just this one process. A browser is never one process — it is a parent and
|
|
121
|
+
// a renderer for every page — and on Windows killing only the parent leaves the renderers
|
|
122
|
+
// running, still writing into the throwaway profile, so the profile folder cannot be
|
|
123
|
+
// deleted and outlives the run. That is the one thing "nothing it opened outlives the run"
|
|
124
|
+
// promises. Measured on a real Windows 11 machine on 2026-08-31.
|
|
125
|
+
stopTree(child.pid, 'SIGTERM', { child });
|
|
124
126
|
const grace = raceTimer(graceMs, false);
|
|
125
127
|
const stopped = await Promise.race([exited.then(() => true), grace.promise]);
|
|
126
128
|
grace.cancel();
|
|
127
129
|
if (stopped) return;
|
|
128
|
-
|
|
129
|
-
child.kill('SIGKILL');
|
|
130
|
-
} catch {
|
|
131
|
-
// Same race as above.
|
|
132
|
-
}
|
|
130
|
+
stopTree(child.pid, 'SIGKILL', { child });
|
|
133
131
|
const last = raceTimer(1000, false);
|
|
134
132
|
await Promise.race([exited, last.promise]);
|
|
135
133
|
last.cancel();
|
|
@@ -458,7 +456,11 @@ export async function startWebApp(app, opts = {}) {
|
|
|
458
456
|
// Its own process group. A dev server is really a shell that spawns a
|
|
459
457
|
// bundler that spawns a watcher; killing only the shell leaves the port held
|
|
460
458
|
// and the next run fails for a reason nobody can see.
|
|
461
|
-
|
|
459
|
+
//
|
|
460
|
+
// Not on Windows, where `detached: true` means something else entirely — a console
|
|
461
|
+
// WINDOW of its own, flashing up on the person's screen in the middle of a check.
|
|
462
|
+
// Windows stops the tree a different way, in `stopTree`, and needs nothing at spawn time.
|
|
463
|
+
detached: OWN_PROCESS_GROUP,
|
|
462
464
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
463
465
|
});
|
|
464
466
|
const output = keepOutput(child);
|
|
@@ -478,30 +480,17 @@ export async function startWebApp(app, opts = {}) {
|
|
|
478
480
|
stopping ??= (async () => {
|
|
479
481
|
if (isGone(child)) return;
|
|
480
482
|
const exited = whenExited(child);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
child.kill('SIGTERM');
|
|
488
|
-
} catch {
|
|
489
|
-
// Already gone.
|
|
490
|
-
}
|
|
491
|
-
}
|
|
483
|
+
// The whole tree, not just the shell. On Linux and a Mac that is the process group;
|
|
484
|
+
// on Windows it is `taskkill /T`, which is what `stopTree` reaches for. Before this,
|
|
485
|
+
// Windows killed `cmd.exe` and left the dev server holding the port, so the next run
|
|
486
|
+
// failed for a reason nobody could see — the exact outcome the comment above warns
|
|
487
|
+
// about, on the one operating system where the code did not do it. Found 2026-08-31.
|
|
488
|
+
stopTree(pid, 'SIGTERM', { child });
|
|
492
489
|
const grace = raceTimer(5000, false);
|
|
493
490
|
const gone = await Promise.race([exited.then(() => true), grace.promise]);
|
|
494
491
|
grace.cancel();
|
|
495
492
|
if (gone) return;
|
|
496
|
-
|
|
497
|
-
if (pid) process.kill(-pid, 'SIGKILL');
|
|
498
|
-
} catch {
|
|
499
|
-
try {
|
|
500
|
-
child.kill('SIGKILL');
|
|
501
|
-
} catch {
|
|
502
|
-
// Already gone.
|
|
503
|
-
}
|
|
504
|
-
}
|
|
493
|
+
stopTree(pid, 'SIGKILL', { child });
|
|
505
494
|
const last = raceTimer(1000, false);
|
|
506
495
|
await Promise.race([exited, last.promise]);
|
|
507
496
|
last.cancel();
|
package/src/drive/page.js
CHANGED
|
@@ -150,6 +150,73 @@ function removeStyleTagSource(token) {
|
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* What somebody handed `evaluate`, turned into a piece of JavaScript the app can run.
|
|
155
|
+
*
|
|
156
|
+
* A STRING IS NOT THE OBVIOUS THING TO PASS. Every other tool in this space takes a
|
|
157
|
+
* function — `page.evaluate(() => document.title)` is what anybody who has driven a browser
|
|
158
|
+
* before writes first — and this took only text. Handing it a function put a function object
|
|
159
|
+
* where the debug protocol wanted a string, and what came back was, in full, measured while
|
|
160
|
+
* using the tool on 2026-08-31:
|
|
161
|
+
*
|
|
162
|
+
* The app refused the request "Runtime.evaluate": Invalid parameters
|
|
163
|
+
*
|
|
164
|
+
* That is the machine's own words about its own wire format, said to somebody who has done
|
|
165
|
+
* nothing wrong except write the thing that works everywhere else. So a function is now
|
|
166
|
+
* accepted and turned into the call it obviously means, and text goes through untouched.
|
|
167
|
+
* Only what genuinely cannot be run says so — in a sentence naming what it was given and
|
|
168
|
+
* showing the one line that works.
|
|
169
|
+
*
|
|
170
|
+
* It runs with nothing passed to it, which is why a function that declares a parameter is
|
|
171
|
+
* refused rather than quietly given `undefined`: there is no way to send a value into the
|
|
172
|
+
* page here, and the alternative is a guard failing inside the app for a reason that has
|
|
173
|
+
* nothing to do with the app.
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} what A piece of JavaScript as text, or a function to call in the page.
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function asJavaScript(what) {
|
|
179
|
+
if (typeof what === 'string') return what;
|
|
180
|
+
|
|
181
|
+
if (typeof what === 'function') {
|
|
182
|
+
const source = String(what);
|
|
183
|
+
// A built-in — `page.evaluate(Math.max)`, `page.evaluate(document.querySelector)` — has
|
|
184
|
+
// no readable body, so there is nothing to send. Asked FIRST, before anything about the
|
|
185
|
+
// arguments: a built-in usually declares some, and being told to close over them is
|
|
186
|
+
// advice about a function nobody could have sent anyway. Said plainly, too, because
|
|
187
|
+
// "SyntaxError: Unexpected token" out of the page is a worse version of the message this
|
|
188
|
+
// whole function exists to replace.
|
|
189
|
+
if (/\{\s*\[native code\]\s*\}/.test(source)) {
|
|
190
|
+
throw new StaysFixedError('evaluate() was handed a built-in function, and the app cannot be sent one: it has no source to run.', {
|
|
191
|
+
hint: 'Wrap it in a function of your own: page.evaluate(() => document.querySelector(".total").textContent).',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (what.length > 0) {
|
|
195
|
+
throw new StaysFixedError(
|
|
196
|
+
`evaluate() runs a function inside the app with nothing passed to it, and this one asks for ${what.length === 1 ? 'an argument' : `${what.length} arguments`}.`,
|
|
197
|
+
{
|
|
198
|
+
hint: 'Nothing can be sent into the page here. Close over what it needs, or write the value into the JavaScript itself: page.evaluate(`document.title === ${JSON.stringify(expected)}`).',
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
// Shorthand method syntax — `{ title() { ... } }` — is not an expression on its own, so
|
|
203
|
+
// the ordinary wrapping below would send the app something it cannot parse. Put back in
|
|
204
|
+
// the object it was written in and called by name.
|
|
205
|
+
//
|
|
206
|
+
// A leading `async` is taken off before the name is read, and it has to be: leave it on
|
|
207
|
+
// and the pattern happily reads `async () => 1` as a method called "async", because
|
|
208
|
+
// backtracking gives up the optional keyword and matches the word itself.
|
|
209
|
+
const shorthand = /^(?!function\b)([A-Za-z_$][\w$]*)\s*\(/.exec(source.replace(/^async\s+/, ''));
|
|
210
|
+
if (shorthand) return `({ ${source} }).${shorthand[1]}()`;
|
|
211
|
+
return `(${source})()`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
throw new StaysFixedError(
|
|
215
|
+
`evaluate() wants a piece of JavaScript written as text, or a function to run in the app. It was given ${what === null ? 'null' : typeof what}.`,
|
|
216
|
+
{ hint: 'Either way round works: page.evaluate(\'document.title\') or page.evaluate(() => document.title).' },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
153
220
|
/**
|
|
154
221
|
* One line describing whatever the page threw.
|
|
155
222
|
* @param {any} details Runtime.ExceptionDetails
|
|
@@ -283,12 +350,17 @@ export async function createPage(cdp, opts) {
|
|
|
283
350
|
// ---------------------------------------------------------------------------
|
|
284
351
|
|
|
285
352
|
/**
|
|
286
|
-
*
|
|
353
|
+
* Run JavaScript in the page.
|
|
354
|
+
*
|
|
355
|
+
* Takes it as text, or as a function to call — see {@link asJavaScript} for why both, and
|
|
356
|
+
* for the message that used to come back when somebody wrote the function.
|
|
357
|
+
*
|
|
358
|
+
* @param {string|Function} js
|
|
287
359
|
* @returns {Promise<any>}
|
|
288
360
|
*/
|
|
289
361
|
async function evaluate(js) {
|
|
290
362
|
const res = await send('Runtime.evaluate', {
|
|
291
|
-
expression: js,
|
|
363
|
+
expression: asJavaScript(js),
|
|
292
364
|
awaitPromise: true,
|
|
293
365
|
returnByValue: true,
|
|
294
366
|
userGesture: true,
|
package/src/guard/api.js
CHANGED
|
@@ -19,6 +19,7 @@ import { spawn } from 'node:child_process';
|
|
|
19
19
|
import fsp from 'node:fs/promises';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { StaysFixedError } from '../core/errors.js';
|
|
22
|
+
import { stopTree, OWN_PROCESS_GROUP } from '../core/stop-tree.js';
|
|
22
23
|
|
|
23
24
|
/** A plain-language expectation that did not hold. */
|
|
24
25
|
export class ExpectationFailed extends Error {
|
|
@@ -313,7 +314,7 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
313
314
|
const child = spawn(cmd, {
|
|
314
315
|
cwd,
|
|
315
316
|
shell: true,
|
|
316
|
-
detached:
|
|
317
|
+
detached: OWN_PROCESS_GROUP,
|
|
317
318
|
windowsHide: true,
|
|
318
319
|
});
|
|
319
320
|
|
|
@@ -324,15 +325,19 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
324
325
|
let how = 'ran';
|
|
325
326
|
let done = false;
|
|
326
327
|
|
|
327
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* Stop the shell AND everything it started.
|
|
330
|
+
*
|
|
331
|
+
* This used to kill only the child on Windows, with a comment saying that was the
|
|
332
|
+
* best that could be done there. It is not: measured on a real Windows 11 machine on
|
|
333
|
+
* 2026-08-31, "kills a command that was still running when the run gave up" failed,
|
|
334
|
+
* because killing `cmd.exe` left the `node` underneath it running and it finished its
|
|
335
|
+
* work and wrote its file after the run had given up on it — the same defect Linux
|
|
336
|
+
* showed on 2026-08-31, on a different operating system's spelling of it. `stopTree`
|
|
337
|
+
* holds both spellings.
|
|
338
|
+
*/
|
|
328
339
|
const stopEverything = () => {
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
if (process.platform === 'win32') child.kill('SIGKILL');
|
|
332
|
-
else process.kill(-child.pid, 'SIGKILL');
|
|
333
|
-
} catch {
|
|
334
|
-
// Already gone, which is the good case.
|
|
335
|
-
}
|
|
340
|
+
stopTree(child.pid, 'SIGKILL', { child });
|
|
336
341
|
};
|
|
337
342
|
|
|
338
343
|
child.stdout?.setEncoding('utf8');
|
package/src/types.js
CHANGED
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
* @property {(selector: string, opts?: {timeoutMs?: number}) => Promise<void>} waitForGone
|
|
207
207
|
* @property {(selector: string) => Promise<void>} scrollTo
|
|
208
208
|
* @property {(ms: number) => Promise<void>} wait
|
|
209
|
-
* @property {(js: string) => Promise<any>} evaluate
|
|
209
|
+
* @property {(js: string|Function) => Promise<any>} evaluate Text, or a function to run in the page.
|
|
210
210
|
* @property {(selector: string) => Promise<boolean>} visible
|
|
211
211
|
* @property {(selector: string) => Promise<boolean>} exists
|
|
212
212
|
* @property {(selector: string) => Promise<string>} textOf
|
package/src/v2/adapters/child.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import { spawn } from 'node:child_process';
|
|
23
23
|
|
|
24
|
+
import { stopTree, OWN_PROCESS_GROUP } from '../../core/stop-tree.js';
|
|
25
|
+
|
|
24
26
|
/**
|
|
25
27
|
* Start the product, in a group of its own.
|
|
26
28
|
*
|
|
@@ -34,9 +36,10 @@ export function spawnServer(command, opts) {
|
|
|
34
36
|
cwd: opts.cwd,
|
|
35
37
|
env: opts.env,
|
|
36
38
|
stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
|
|
37
|
-
// The whole point.
|
|
38
|
-
//
|
|
39
|
-
|
|
39
|
+
// The whole point. Windows has no process groups of this kind, and `detached` there means
|
|
40
|
+
// a console window of its own instead — so Windows is left alone at spawn time and gets
|
|
41
|
+
// its whole tree stopped by `stopTree` below, which walks the children itself.
|
|
42
|
+
detached: OWN_PROCESS_GROUP,
|
|
40
43
|
});
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -52,21 +55,16 @@ export async function stopServer(child, opts = {}) {
|
|
|
52
55
|
const pid = child.pid;
|
|
53
56
|
const graceMs = opts.graceMs ?? 500;
|
|
54
57
|
|
|
55
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* @param {'SIGTERM'|'SIGKILL'} signal
|
|
60
|
+
*/
|
|
56
61
|
const tellTheGroup = (signal) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// No group, or already gone. Ask the one process we definitely know about.
|
|
64
|
-
try {
|
|
65
|
-
child.kill(signal);
|
|
66
|
-
} catch {
|
|
67
|
-
// Already gone, which is the outcome wanted.
|
|
68
|
-
}
|
|
69
|
-
}
|
|
62
|
+
// The group on Linux and a Mac, the tree of children on Windows. Killing only the shell
|
|
63
|
+
// on Windows left the server running and holding the folder it was started in: measured
|
|
64
|
+
// on a real Windows 11 machine on 2026-08-31, where the whole of `waiting.test.js` failed
|
|
65
|
+
// on being unable to delete its own scratch folder afterwards, because the servers it had
|
|
66
|
+
// asked to stop were all still there.
|
|
67
|
+
stopTree(pid, signal, { child });
|
|
70
68
|
};
|
|
71
69
|
|
|
72
70
|
if (child.exitCode === null && child.signalCode === null) {
|
|
@@ -378,6 +378,10 @@ export function compareJson(a, b) {
|
|
|
378
378
|
* @param {string} spec.says
|
|
379
379
|
* @param {boolean} [spec.covered] False means we did not really look. See `reason`.
|
|
380
380
|
* @param {NotCoveredReason} [spec.reason]
|
|
381
|
+
* @param {string} [spec.detail] What the thing itself said, in its own words, when it
|
|
382
|
+
* said anything. Goes onto the refusal's reason, which
|
|
383
|
+
* is where a person reads why a hole is a hole. Never
|
|
384
|
+
* compared — see `whatItSaid` for why that matters.
|
|
381
385
|
* @param {{file?: string, line?: number, url?: string}} [spec.where]
|
|
382
386
|
* @param {string} [spec.evidence]
|
|
383
387
|
* @param {string} [spec.journey]
|
|
@@ -414,7 +418,16 @@ export function observation(spec) {
|
|
|
414
418
|
// — and never this flag. Written down on 2026-08-31 after the refusal lane found the two
|
|
415
419
|
// meanings sharing one field.
|
|
416
420
|
meta.refused = true;
|
|
417
|
-
|
|
421
|
+
// The reason word stays first and stays in the fixed vocabulary, because the ledger counts
|
|
422
|
+
// these and a free-text reason cannot be counted. What the thing ITSELF said goes on the
|
|
423
|
+
// end. That position is deliberate: `staysfixed coverage` prints this line with a 400
|
|
424
|
+
// character budget and the sentence above it with 160, so this is the place where the real
|
|
425
|
+
// error — the `ModuleNotFoundError`, the `SyntaxError` — reliably survives being trimmed
|
|
426
|
+
// and reaches the person who has to go and fix it. Added 2026-08-31, after three broken
|
|
427
|
+
// products were checked and none of their owners was ever told what was wrong.
|
|
428
|
+
meta.refusedWhy = `${NOT_COVERED_MEANING[spec.reason ?? 'refused']} (${spec.reason ?? 'refused'})${
|
|
429
|
+
spec.detail ? ` — and this is what it said for itself: ${spec.detail}` : ''
|
|
430
|
+
}`;
|
|
418
431
|
}
|
|
419
432
|
return makeObservation(path, spec.channel, stableValue(spec.value), meta);
|
|
420
433
|
}
|
|
@@ -431,6 +444,7 @@ export function observation(spec) {
|
|
|
431
444
|
* @param {string|(string|number)[]} spec.path
|
|
432
445
|
* @param {NotCoveredReason} spec.reason
|
|
433
446
|
* @param {string} spec.says What we would have looked at, and why we did not.
|
|
447
|
+
* @param {string} [spec.detail] What the thing itself said about it, in its own words.
|
|
434
448
|
* @param {{file?: string, line?: number, url?: string}} [spec.where]
|
|
435
449
|
* @returns {Observation}
|
|
436
450
|
*/
|
|
@@ -438,10 +452,15 @@ export function notCovered(spec) {
|
|
|
438
452
|
return observation({
|
|
439
453
|
channel: spec.channel,
|
|
440
454
|
path: spec.path,
|
|
455
|
+
// The VALUE stays the fixed sentence and never carries what the thing said. Two builds
|
|
456
|
+
// that fall over with two different messages would otherwise differ at this address, and
|
|
457
|
+
// the report would call a crash a change in the product. The words go in `says` and in the
|
|
458
|
+
// refusal's reason, neither of which is ever compared.
|
|
441
459
|
value: `not checked — ${NOT_COVERED_MEANING[spec.reason]}`,
|
|
442
460
|
says: spec.says,
|
|
443
461
|
covered: false,
|
|
444
462
|
reason: spec.reason,
|
|
463
|
+
detail: spec.detail,
|
|
445
464
|
where: spec.where,
|
|
446
465
|
});
|
|
447
466
|
}
|
|
@@ -610,6 +629,108 @@ export function undoOurFootprint(text, footprint) {
|
|
|
610
629
|
return out;
|
|
611
630
|
}
|
|
612
631
|
|
|
632
|
+
/**
|
|
633
|
+
* What a program said about itself, cut down to the part a person can act on.
|
|
634
|
+
*
|
|
635
|
+
* WHY THIS EXISTS. Measured on 2026-08-31, against three deliberately broken products: a Node
|
|
636
|
+
* server whose source has a syntax error, a Python command importing a module that is not
|
|
637
|
+
* installed, and a Node command importing a package that is not installed. All three were
|
|
638
|
+
* correctly refused — no false all-clear, and that half worked. But a grep of the whole reply,
|
|
639
|
+
* `--verbose` included, found no mention of `SyntaxError`, `ModuleNotFoundError` or
|
|
640
|
+
* `ERR_MODULE_NOT_FOUND` anywhere in it. Every owner was told "the thing being observed fell
|
|
641
|
+
* over before it could be read" and then had to go and find the reason themselves. Each
|
|
642
|
+
* product had printed the reason, in one line, on its own standard error, and this tool threw
|
|
643
|
+
* it away. Handing somebody a sentence they can act on is the whole design; this is the
|
|
644
|
+
* function that keeps the product's own sentence instead of a paraphrase of it.
|
|
645
|
+
*
|
|
646
|
+
* NOTHING IS INVENTED HERE. What comes out is the program's own words. What is dropped is only
|
|
647
|
+
* what carries nothing for the person reading: blank lines, the `at ...` frames of a stack
|
|
648
|
+
* trace, the caret lines that underline a column of a terminal that is not this one, "... 4
|
|
649
|
+
* more" frame counts, and Node's own version footer. What is left over the budget is dropped
|
|
650
|
+
* from the FRONT, because every runtime in use here — Node, Python, a shell — puts the
|
|
651
|
+
* sentence that says what went wrong at the END of what it printed.
|
|
652
|
+
*
|
|
653
|
+
* This is evidence, not noise, and it belongs in the reply. It is never compared: it goes into
|
|
654
|
+
* the sentence and into the refusal's reason, both of which live in `meta`, so a crash that
|
|
655
|
+
* words itself differently on two machines can never register as a difference in the product.
|
|
656
|
+
*
|
|
657
|
+
* @param {string} text Whatever the thing printed. Run our own footprint out of it
|
|
658
|
+
* first, or a scratch path ends up quoted at a person.
|
|
659
|
+
* @param {object} [opts]
|
|
660
|
+
* @param {number} [opts.mostLines] How many lines of it to keep. Default 6.
|
|
661
|
+
* @param {number} [opts.mostChars] How many characters of it to keep. Default 300.
|
|
662
|
+
* @returns {string} One line, or '' when it said nothing worth repeating.
|
|
663
|
+
*/
|
|
664
|
+
export function whatItSaid(text, opts = {}) {
|
|
665
|
+
const mostLines = opts.mostLines ?? 6;
|
|
666
|
+
const mostChars = opts.mostChars ?? 300;
|
|
667
|
+
const lines = String(text ?? '')
|
|
668
|
+
// Colour codes are how a program makes an error red in a terminal. Printed into a
|
|
669
|
+
// sentence they are unreadable rubbish, and they are not part of what it said.
|
|
670
|
+
.replace(/\u001b\[[0-9;]*m/g, '')
|
|
671
|
+
.split(/\r?\n/)
|
|
672
|
+
.map((line) => line.trim())
|
|
673
|
+
.filter((line) => line !== '' && !NOT_WORTH_REPEATING.some((noise) => noise.test(line)));
|
|
674
|
+
if (lines.length === 0) return '';
|
|
675
|
+
|
|
676
|
+
// ONE LINE MEANS THE ONE THAT NAMES THE TROUBLE, not simply the last one.
|
|
677
|
+
//
|
|
678
|
+
// "The last line" was the first thing tried and it is wrong often enough to matter: measured
|
|
679
|
+
// 2026-08-31 on a Node command importing a package that is not installed, Node printed the
|
|
680
|
+
// whole error object after the message, so the last line was `}` and the headline read "It
|
|
681
|
+
// said: }." — which is worse than saying nothing. So the line that looks like a runtime
|
|
682
|
+
// naming a fault is preferred, and the last line is what happens when none does. Preferred,
|
|
683
|
+
// never required: nothing is dropped by this, it only decides which single line gets quoted
|
|
684
|
+
// where there is room for one, and the fuller quote sits directly underneath it.
|
|
685
|
+
if (mostLines === 1) {
|
|
686
|
+
const named = [...lines].reverse().find((line) => NAMES_THE_TROUBLE.test(line)) ?? lines[lines.length - 1];
|
|
687
|
+
return named.length > mostChars ? `${named.slice(0, mostChars - 3)}...` : named;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** @type {string[]} */
|
|
691
|
+
const kept = [];
|
|
692
|
+
let used = 0;
|
|
693
|
+
for (let i = lines.length - 1; i >= 0 && kept.length < mostLines; i--) {
|
|
694
|
+
const line = lines[i];
|
|
695
|
+
if (kept.length > 0 && used + line.length + 3 > mostChars) break;
|
|
696
|
+
kept.unshift(line);
|
|
697
|
+
used += line.length + 3;
|
|
698
|
+
}
|
|
699
|
+
// One line longer than the whole budget is still the line that says what went wrong, so it
|
|
700
|
+
// is kept and cut rather than dropped for being too long.
|
|
701
|
+
if (kept.length === 1 && kept[0].length > mostChars) kept[0] = `${kept[0].slice(0, mostChars - 3)}...`;
|
|
702
|
+
const left = lines.length - kept.length;
|
|
703
|
+
// Said out loud rather than trimmed silently: a reader who is told six lines were left out
|
|
704
|
+
// knows to open the evidence file, and a reader who is not told assumes they have all of it.
|
|
705
|
+
return `${left > 0 ? `(${left} earlier ${left === 1 ? 'line' : 'lines'} left out) ` : ''}${kept.join(' / ')}`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* A line that reads like a runtime saying what went wrong.
|
|
710
|
+
*
|
|
711
|
+
* The three shapes measured on 2026-08-31, one from each broken product: `SyntaxError:
|
|
712
|
+
* Unexpected end of input`, `ModuleNotFoundError: No module named 'tabulate'`, and `Error
|
|
713
|
+
* [ERR_MODULE_NOT_FOUND]: Cannot find package 'chalk' imported from ...`. The second pattern is
|
|
714
|
+
* for the tools that write their trouble in lower case instead — a compiler, git, a shell.
|
|
715
|
+
*/
|
|
716
|
+
const NAMES_THE_TROUBLE =
|
|
717
|
+
/^[A-Za-z_$][A-Za-z0-9_$.]*(?:Error|Exception|Warning)(?:\s*\[[^\]]*\])?\s*:|^(?:fatal|error|panic|Traceback)\b/i;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Lines that are in the way of the sentence rather than part of it.
|
|
721
|
+
*
|
|
722
|
+
* Deliberately short. Every pattern here has to be something that cannot possibly BE the
|
|
723
|
+
* reason a product fell over — a stack frame under the message, a caret underlining a column,
|
|
724
|
+
* a version footer. Anything else stays, because a rule that guesses which of a program's own
|
|
725
|
+
* lines matter is a rule that will one day drop the only line that did.
|
|
726
|
+
*/
|
|
727
|
+
const NOT_WORTH_REPEATING = [
|
|
728
|
+
/^at\s+\S/, // a stack frame under the message that already said it
|
|
729
|
+
/^[\^~]+$/, // the caret line underlining a column in somebody else's terminal
|
|
730
|
+
/^\.\.\.\s*\d+\s+more$/, // "... 4 more", the frames a runtime left out itself
|
|
731
|
+
/^Node\.js v[\d.]+$/, // Node's version footer, printed under every uncaught throw
|
|
732
|
+
];
|
|
733
|
+
|
|
613
734
|
/**
|
|
614
735
|
* Keep a piece of text at a size worth storing.
|
|
615
736
|
*
|