staysfixed 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) 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
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,565 @@
1
+ /**
2
+ * The adapter interface — written once, so the engine never knows what it is driving.
3
+ *
4
+ * The engine's job is arithmetic: run the new build twice, subtract the wobble, compare
5
+ * what is left against the reference. It does that over a flat list of `path -> value`
6
+ * observations and nothing else. An adapter's job is to turn one platform — a CLI, a
7
+ * server, a browser, a phone — into that flat list. Six more adapters will be written
8
+ * against this file, so the shape has to be right the first time.
9
+ *
10
+ * The five methods, in the order the engine calls them:
11
+ *
12
+ * detect(project) Can you drive this project on this machine, right now? Also:
13
+ * what is missing that would let you drive more of it? This is
14
+ * what `doctor` reports, and it is the first thing an agent asks.
15
+ * journeys(project) What steps do you know how to walk? Read out of the code, out
16
+ * of the project's own test suite, out of a recording — never
17
+ * out of a person.
18
+ * prepare(build) Get one build ready to be walked. Unpack it, install it into a
19
+ * scratch copy, boot it. Called once per build, not per journey,
20
+ * because booting is the expensive part.
21
+ * run(journey, build, ctx) Walk one journey against one prepared build and report what
22
+ * you saw, as Observations.
23
+ * teardown() Put everything back. Kill only what you started.
24
+ *
25
+ * Three rules every adapter obeys, and the engine cannot enforce for you:
26
+ *
27
+ * 1. NEVER TOUCH THE REAL PROJECT. Work in a scratch copy. The person running this has
28
+ * the real thing open in an editor.
29
+ * 2. NEVER LET SOMETHING IRREVERSIBLE HAPPEN. Money, messages, deleted data. Watch the
30
+ * call go out, record that it was asked for, and refuse it at the wire. A refusal is
31
+ * reported with `covered: false` — missing coverage, never a pass.
32
+ * 3. NEVER KILL WHAT YOU DID NOT START. Somebody's own app may be running.
33
+ */
34
+
35
+ import { CHANNELS, isChannel, joinPath as joinSegments, makeObservation } from '../observation.js';
36
+
37
+ export { CHANNELS };
38
+
39
+ /** @typedef {import('../types.js').Channel} Channel */
40
+ /** @typedef {import('../types.js').Surface} Surface */
41
+ /** @typedef {import('../types.js').Observation} Observation */
42
+ /** @typedef {import('../types.js').ObservedValue} JsonValue */
43
+ /** @typedef {import('../types.js').Journey} Journey */
44
+
45
+ /**
46
+ * Why a thing was not observed. Short vocabulary on purpose: the engine counts these and
47
+ * reports them as holes, and a free-text reason cannot be counted.
48
+ *
49
+ * @typedef {'irreversible'|'missing tool'|'refused'|'too big'|'timed out'|'not supported here'|'crashed'|'needs a sample'} NotCoveredReason
50
+ */
51
+
52
+ /** @type {Record<NotCoveredReason, string>} */
53
+ export const NOT_COVERED_MEANING = Object.freeze({
54
+ irreversible: 'doing this for real would spend money, send a message, or destroy data',
55
+ 'missing tool': 'something this machine does not have would be needed',
56
+ refused: 'the project asked us not to',
57
+ 'too big': 'the value was too large to keep, so only a fingerprint of it was kept',
58
+ 'timed out': 'it did not finish in the time allowed',
59
+ 'not supported here': 'this platform cannot be observed this way, and saying so is the honest answer',
60
+ crashed: 'the thing being observed fell over before it could be read',
61
+ 'needs a sample': 'a real value has to be supplied before this can be tried at all',
62
+ });
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // The shapes
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * One build, as the engine hands it to an adapter.
70
+ *
71
+ * `root` is a directory the adapter may read. It is NOT the person's working copy unless
72
+ * the engine says so, and an adapter must not write into it either way.
73
+ *
74
+ * @typedef {object} Build
75
+ * @property {string} id Content-addressed id of this build.
76
+ * @property {string} label Plain English: 'the build you shipped', 'your change'.
77
+ * @property {'reference'|'candidate'} role
78
+ * @property {string} root Directory holding the source or the unpacked artifact.
79
+ * @property {string} [artifact] Path to a packaged artifact, when there is one.
80
+ * @property {string|null} [gitSha]
81
+ */
82
+
83
+ /**
84
+ * A build that has been made ready to walk.
85
+ *
86
+ * @typedef {object} PreparedBuild
87
+ * @property {Build} build
88
+ * @property {string} root Where the walkable copy lives. Scratch, always.
89
+ * @property {boolean} ready False means prepare gave up; `why` says so in English.
90
+ * @property {string} why Plain English, always filled in — including on success.
91
+ * @property {Record<string, string|number|boolean|undefined>} [facts]
92
+ * Anything a journey needs: a port, a binary path, a pid.
93
+ * A fact may be undefined: a web app read at a fixed address
94
+ * has no port of its own, and an adapter should be able to
95
+ * say so rather than invent a value to satisfy a type.
96
+ * @property {() => Promise<void>} dispose Undo just this build's preparation.
97
+ */
98
+
99
+ /**
100
+ * One repeatable set of steps.
101
+ *
102
+ * Journeys are never written by a person. They come from the code (free and exact), from
103
+ * the project's own test suite (already written, sitting there unused), from a recorded
104
+ * session, or from an agent that explored one named gap and froze it into a file.
105
+ *
106
+ * @property {string} id Stable, file-safe. Becomes the first path segment.
107
+ * @property {string} name Plain English. 'run the help text', 'GET /api/sessions'.
108
+ * @property {'code'|'suite'|'recording'|'config'|'agent'} from Where these steps came from.
109
+ * @property {string} [why] Plain English: why this is worth walking.
110
+ * @property {string} kind Adapter-specific: 'command', 'import', 'request', ...
111
+ * @property {JsonValue} detail Adapter-specific payload. The adapter that produced
112
+ * the journey is the only thing that reads it.
113
+ * @property {boolean} [irreversible] True means walking this for real would spend money,
114
+ * send a message or destroy data. The adapter must
115
+ * observe it at the call boundary and refuse the effect.
116
+ * @property {number} [timeoutMs]
117
+ */
118
+
119
+ /**
120
+ * What an adapter says about a project when asked whether it can drive it.
121
+ *
122
+ * This is the honest-limits channel, and it is the first thing an agent installing the tool
123
+ * reads. `missing` is the important half: not "no", but "no, and here is the one thing that
124
+ * would turn this into a yes".
125
+ *
126
+ * @typedef {object} Detection
127
+ * @property {boolean} applies Can this adapter drive this project at all?
128
+ * @property {number} confidence 0..1. How sure. Below 0.5 the engine asks first.
129
+ * @property {string} why Plain English, always filled in, including for 'no'.
130
+ * @property {Missing[]} missing What would unlock more. Empty when nothing would.
131
+ * @property {string[]} [notes] Anything else worth saying in one line each.
132
+ */
133
+
134
+ /**
135
+ * One thing that is not here, and what having it would buy.
136
+ *
137
+ * @typedef {object} Missing
138
+ * @property {string} what 'a Java runtime', 'a database snapshot to restore'.
139
+ * @property {string} unlocks Plain English: what becomes possible once it is there.
140
+ * @property {string} [howToGet] The exact command or link, filled in where detectable.
141
+ * @property {boolean} [blocking] True means nothing works without it.
142
+ */
143
+
144
+ /**
145
+ * Everything a run is allowed to use, handed in rather than reached for, so a run can be
146
+ * cancelled, redirected to a scratch disk, or replayed.
147
+ *
148
+ * @typedef {object} RunContext
149
+ * @property {AbortSignal} [signal] Cancel. Adapters must honour it.
150
+ * @property {string} scratchDir Somewhere to write. Wiped between runs by the engine.
151
+ * @property {string} evidenceDir Somewhere to keep things too big to inline.
152
+ * @property {number} seed The one seed. Same for both builds, both runs.
153
+ * @property {string} clock ISO time the product should believe it is.
154
+ * @property {(message: string) => void} [log] Progress, in plain English.
155
+ * @property {Record<string, any>} [config] The project's config for THIS adapter — the slice
156
+ * under a key matching the adapter's name. Handed in
157
+ * here as well as to `detect` because `prepare` needs
158
+ * it too, and an adapter that remembered it between
159
+ * calls would be one shared mutable variable away
160
+ * from two builds reading each other's settings.
161
+ * @property {boolean} [allowIrreversible] Default false, and the engine never sets it true.
162
+ * It exists so the refusal is a decision in the code
163
+ * rather than an accident of omission.
164
+ */
165
+
166
+ /**
167
+ * A platform, taught to the engine.
168
+ *
169
+ * @typedef {object} Adapter
170
+ * @property {string} name Short id: 'process', 'http', 'source'.
171
+ * @property {string} title Plain English: 'CLI tools and libraries'.
172
+ * @property {string} describe One sentence an agent can read to know what this
173
+ * adapter watches and what it cannot see.
174
+ * @property {Channel[]} channels Which of the seven this adapter can fill. Honest —
175
+ * the coverage ledger is built from these.
176
+ * @property {(project: AdapterProject) => Promise<Detection>} detect
177
+ * @property {(project: AdapterProject) => Promise<Journey[]>} journeys
178
+ * @property {(build: Build, ctx: RunContext) => Promise<PreparedBuild>} prepare
179
+ * @property {(journey: Journey, build: PreparedBuild, ctx: RunContext) => Promise<Observation[]>} run
180
+ * @property {() => Promise<void>} teardown
181
+ */
182
+
183
+ /**
184
+ * The slice of a project an adapter is allowed to see. Deliberately small: an adapter that
185
+ * can reach the whole engine ends up depending on it.
186
+ *
187
+ * @typedef {object} AdapterProject
188
+ * @property {string} root The real project root. READ ONLY, always.
189
+ * @property {Record<string, any>} [config] Whatever the project put in its config for this
190
+ * adapter, under a key matching the adapter's name.
191
+ * @property {Observation[]} [contract] The static contract, when the source adapter has
192
+ * already read it. This is how the HTTP adapter learns
193
+ * its routes without crawling.
194
+ */
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Building an adapter
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const REQUIRED_METHODS = ['detect', 'journeys', 'prepare', 'run', 'teardown'];
201
+
202
+ /**
203
+ * Check an adapter before the engine trusts it, and say what is wrong in plain English.
204
+ *
205
+ * Separate from {@link defineAdapter} so a test, or `doctor`, can ask the question without
206
+ * building anything.
207
+ *
208
+ * @param {Partial<Adapter>} spec
209
+ * @returns {string[]} problems, empty when it is fine
210
+ */
211
+ export function checkAdapter(spec) {
212
+ /** @type {string[]} */
213
+ const problems = [];
214
+ if (!spec || typeof spec !== 'object') return ['An adapter has to be an object.'];
215
+
216
+ if (typeof spec.name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(spec.name)) {
217
+ problems.push('An adapter needs a short lowercase name like "process" or "http".');
218
+ }
219
+ if (typeof spec.title !== 'string' || spec.title.trim() === '') {
220
+ problems.push(`Adapter "${spec.name}" needs a title a person can read, like "CLI tools and libraries".`);
221
+ }
222
+ if (typeof spec.describe !== 'string' || spec.describe.trim() === '') {
223
+ problems.push(`Adapter "${spec.name}" needs one sentence saying what it watches and what it cannot see.`);
224
+ }
225
+ if (!Array.isArray(spec.channels) || spec.channels.length === 0) {
226
+ problems.push(`Adapter "${spec.name}" has to say which channels it fills.`);
227
+ } else {
228
+ for (const channel of spec.channels) {
229
+ if (!isChannel(channel)) {
230
+ problems.push(`Adapter "${spec.name}" claims a channel called "${channel}", which is not one of the seven.`);
231
+ }
232
+ }
233
+ }
234
+ for (const method of REQUIRED_METHODS) {
235
+ if (typeof (/** @type {any} */ (spec)[method]) !== 'function') {
236
+ problems.push(`Adapter "${spec.name}" is missing ${method}().`);
237
+ }
238
+ }
239
+ return problems;
240
+ }
241
+
242
+ /**
243
+ * Take an adapter spec and hand back something the engine can hold.
244
+ *
245
+ * Frozen, because six adapters sharing one engine is exactly the shape where one of them
246
+ * quietly reaches over and patches another.
247
+ *
248
+ * @param {Adapter} spec
249
+ * @returns {Adapter}
250
+ */
251
+ export function defineAdapter(spec) {
252
+ const problems = checkAdapter(spec);
253
+ if (problems.length > 0) {
254
+ throw new Error(`This adapter cannot be used yet:\n - ${problems.join('\n - ')}`);
255
+ }
256
+ return Object.freeze({ ...spec });
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Paths
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /**
264
+ * Build an observation path out of parts.
265
+ *
266
+ * A thin variadic wrapper over the engine's own `joinPath`, which owns the rules: dots
267
+ * between the parts, a dot inside a part escaped so it cannot be mistaken for a separator.
268
+ * It is wrapped rather than re-exported only because reading
269
+ * `path(kind, journey, 'status')` at a call site is easier than reading an array literal,
270
+ * and because every adapter going through one function is what keeps six of them agreeing.
271
+ *
272
+ * The FIRST part is the kind of thing being observed — `api`, `cli`, `ipc`, `route`, `file`,
273
+ * `proc`, `net`, `export`, `count` — not the journey. That ordering is what lets the engine
274
+ * cluster and rank by what broke rather than by which journey happened to find it.
275
+ *
276
+ * @param {...(string|number)} segments
277
+ * @returns {string}
278
+ */
279
+ export function joinPath(...segments) {
280
+ return joinSegments(segments.filter((s) => s !== '' && s !== null && s !== undefined).map(String));
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Values
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Put a value into the one shape the comparison engine understands.
289
+ *
290
+ * A pre-pass before the engine's own validator, which rejects anything that is not a
291
+ * string, a number, a boolean, null, or a list or plain object of those. Rather than let an
292
+ * adapter throw because something handed it a Date or a Buffer, everything is turned into
293
+ * something describable first — so a diff can say "this used to be a function" instead of
294
+ * "this used to be nothing".
295
+ *
296
+ * Object keys are sorted, because two runs of the same code can build the same object in a
297
+ * different order and that is not a difference.
298
+ *
299
+ * @param {unknown} value
300
+ * @param {WeakSet<object>} [seen]
301
+ * @returns {JsonValue}
302
+ */
303
+ export function stableValue(value, seen = new WeakSet()) {
304
+ if (value === null || value === undefined) return null;
305
+ const type = typeof value;
306
+ if (type === 'string' || type === 'boolean') return /** @type {string|boolean} */ (value);
307
+ if (type === 'number') {
308
+ const n = /** @type {number} */ (value);
309
+ if (Number.isNaN(n)) return '(not a number)';
310
+ if (!Number.isFinite(n)) return n > 0 ? '(infinity)' : '(negative infinity)';
311
+ return n;
312
+ }
313
+ if (type === 'bigint') return `${value}n`;
314
+ if (type === 'function') return `(a function called ${/** @type {Function} */ (value).name || 'nothing'})`;
315
+ if (type === 'symbol') return `(the symbol ${String(value)})`;
316
+
317
+ const object = /** @type {object} */ (value);
318
+ if (seen.has(object)) return '(refers back to itself)';
319
+ seen.add(object);
320
+
321
+ if (Array.isArray(object)) return object.map((item) => stableValue(item, seen));
322
+ if (object instanceof Date) return object.toISOString();
323
+ if (object instanceof RegExp) return String(object);
324
+ if (object instanceof Error) return `${object.name}: ${object.message}`;
325
+ if (object instanceof Map) {
326
+ return stableValue(Object.fromEntries([...object.entries()].map(([k, v]) => [String(k), v])), seen);
327
+ }
328
+ if (object instanceof Set) return [...object].map((item) => stableValue(item, seen)).sort(compareJson);
329
+ if (ArrayBuffer.isView(object) || object instanceof ArrayBuffer) {
330
+ const bytes = object instanceof ArrayBuffer ? object.byteLength : /** @type {ArrayBufferView} */ (object).byteLength;
331
+ return `(${bytes} bytes)`;
332
+ }
333
+
334
+ /** @type {Record<string, JsonValue>} */
335
+ const out = {};
336
+ for (const key of Object.keys(object).sort()) {
337
+ out[key] = stableValue(/** @type {any} */ (object)[key], seen);
338
+ }
339
+ return out;
340
+ }
341
+
342
+ /**
343
+ * A total order over stable values, so lists that have no natural order still come out the
344
+ * same way twice. Cheap and only used for sorting.
345
+ * @param {JsonValue} a
346
+ * @param {JsonValue} b
347
+ */
348
+ export function compareJson(a, b) {
349
+ const left = typeof a === 'string' ? a : JSON.stringify(a);
350
+ const right = typeof b === 'string' ? b : JSON.stringify(b);
351
+ return left < right ? -1 : left > right ? 1 : 0;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Making an observation
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Make one observation.
360
+ *
361
+ * Everything an adapter sees goes through here, so the shape is identical whatever produced
362
+ * it and so there is exactly one place that knows how an adapter's words map onto the
363
+ * engine's `Observation`. Two rules are enforced here and nowhere else:
364
+ *
365
+ * - `value` is the ONLY thing compared. The sentence, the file it came from and the
366
+ * picture backing it up all go into `meta`, which the engine never compares. If any of
367
+ * them leaked into `value`, a file move or a reworded sentence would report as a
368
+ * regression.
369
+ * - `says` is required. It is not decoration: it is what an agent reads when a difference
370
+ * lands in its lap, and it is the reason nothing about this tool needs documentation.
371
+ *
372
+ * @param {object} spec
373
+ * @param {Channel} spec.channel
374
+ * @param {string|(string|number)[]} spec.path A finished path, or parts to join.
375
+ * @param {unknown} spec.value
376
+ * @param {string} spec.says
377
+ * @param {boolean} [spec.covered] False means we did not really look. See `reason`.
378
+ * @param {NotCoveredReason} [spec.reason]
379
+ * @param {{file?: string, line?: number, url?: string}} [spec.where]
380
+ * @param {string} [spec.evidence]
381
+ * @param {string} [spec.journey]
382
+ * @param {Surface} [spec.surface]
383
+ * @returns {Observation}
384
+ */
385
+ export function observation(spec) {
386
+ const path = Array.isArray(spec.path) ? joinPath(...spec.path) : spec.path;
387
+ if (!spec.says) throw new Error(`The observation at "${path}" needs one plain sentence saying what it is.`);
388
+ if (!isChannel(spec.channel)) {
389
+ throw new Error(`The observation at "${path}" claims channel "${spec.channel}", which is not one of the seven.`);
390
+ }
391
+ /** @type {import('../types.js').ObservationMeta} */
392
+ const meta = { describe: spec.says };
393
+ if (spec.where?.file) meta.source = spec.where.file;
394
+ else if (spec.where?.url) meta.source = spec.where.url;
395
+ if (spec.where?.line !== undefined) meta.line = spec.where.line;
396
+ if (spec.evidence) meta.evidence = spec.evidence;
397
+ if (spec.journey) meta.journey = spec.journey;
398
+ if (spec.surface) meta.surface = spec.surface;
399
+ if (spec.covered === false) {
400
+ // The engine reads `refused` when it builds the coverage ledger. Everything an adapter
401
+ // could not look at lands here, whichever of the reasons it was — a payment it would not
402
+ // make, a runtime this machine does not have, a parameter nobody supplied. They are all
403
+ // the same thing to a reader: a hole, with the reason attached, and never a pass.
404
+ meta.refused = true;
405
+ meta.refusedWhy = `${NOT_COVERED_MEANING[spec.reason ?? 'refused']} (${spec.reason ?? 'refused'})`;
406
+ }
407
+ return makeObservation(path, spec.channel, stableValue(spec.value), meta);
408
+ }
409
+
410
+ /**
411
+ * A hole, recorded honestly.
412
+ *
413
+ * The rule the whole tool rests on: a refusal is reported as missing coverage, never as a
414
+ * pass. A run that quietly skipped the payment path and said "nothing changed" is worse
415
+ * than no run at all, because somebody believed it.
416
+ *
417
+ * @param {object} spec
418
+ * @param {Channel} spec.channel
419
+ * @param {string|(string|number)[]} spec.path
420
+ * @param {NotCoveredReason} spec.reason
421
+ * @param {string} spec.says What we would have looked at, and why we did not.
422
+ * @param {{file?: string, line?: number, url?: string}} [spec.where]
423
+ * @returns {Observation}
424
+ */
425
+ export function notCovered(spec) {
426
+ return observation({
427
+ channel: spec.channel,
428
+ path: spec.path,
429
+ value: `not checked — ${NOT_COVERED_MEANING[spec.reason]}`,
430
+ says: spec.says,
431
+ covered: false,
432
+ reason: spec.reason,
433
+ where: spec.where,
434
+ });
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Coarse measures
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * The time ladder. Roughly three times apart, so ordinary variance stays inside one rung
443
+ * and a real slowdown crosses one. Exact milliseconds are never observed: they differ on
444
+ * every run, they would swamp every diff, and nobody has ever fixed a bug because a command
445
+ * took 412ms instead of 389ms.
446
+ */
447
+ const TIME_LADDER = /** @type {const} */ ([
448
+ [100, 'instant'],
449
+ [300, 'quick'],
450
+ [1000, 'under a second'],
451
+ [3000, 'a few seconds'],
452
+ [10000, 'several seconds'],
453
+ [30000, 'half a minute'],
454
+ [90000, 'a minute or so'],
455
+ [300000, 'a few minutes'],
456
+ ]);
457
+
458
+ /**
459
+ * @param {number} ms
460
+ * @returns {string} a plain-English bucket
461
+ */
462
+ export function timeBucket(ms) {
463
+ if (!Number.isFinite(ms) || ms < 0) return 'unknown';
464
+ for (const [limit, label] of TIME_LADDER) if (ms < limit) return label;
465
+ return 'over five minutes';
466
+ }
467
+
468
+ /**
469
+ * Sizes, on the same principle as time. A response body that grew by two bytes is not news;
470
+ * one that doubled is.
471
+ * @param {number} bytes
472
+ * @returns {string}
473
+ */
474
+ export function sizeBucket(bytes) {
475
+ if (!Number.isFinite(bytes) || bytes < 0) return 'unknown';
476
+ if (bytes === 0) return 'empty';
477
+ const ladder = /** @type {const} */ ([
478
+ [128, 'a line or two'],
479
+ [1024, 'under a kilobyte'],
480
+ [10240, 'a few kilobytes'],
481
+ [102400, 'tens of kilobytes'],
482
+ [1048576, 'hundreds of kilobytes'],
483
+ [10485760, 'a few megabytes'],
484
+ [104857600, 'tens of megabytes'],
485
+ ]);
486
+ for (const [limit, label] of ladder) if (bytes < limit) return label;
487
+ return 'over a hundred megabytes';
488
+ }
489
+
490
+ /**
491
+ * Counts, bucketed once they get big enough that the exact number is noise. Small counts
492
+ * stay exact, because going from three files to four IS the finding.
493
+ * @param {number} n
494
+ * @returns {number|string}
495
+ */
496
+ export function countBucket(n) {
497
+ if (!Number.isFinite(n) || n < 0) return 'unknown';
498
+ if (n <= 20) return n;
499
+ if (n < 50) return 'between 21 and 50';
500
+ if (n < 100) return 'between 51 and 100';
501
+ if (n < 500) return 'in the hundreds';
502
+ if (n < 1000) return 'many hundreds';
503
+ return 'thousands';
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Text that has to be the same twice
508
+ // ---------------------------------------------------------------------------
509
+
510
+ /**
511
+ * Rub out the things THIS TOOL varied, and nothing else.
512
+ *
513
+ * This is a deliberately short list, and keeping it short is the point. Every product has
514
+ * volatile output — version strings, ids, dates — and it is tempting to scrub all of it
515
+ * here. Do not. That is the noise-control layer's job, its rules live in the project's git
516
+ * so a person can see and argue with them, and the wobble measurement catches most of it
517
+ * for free. What belongs HERE is only the variation the harness itself introduced: the
518
+ * scratch directory it chose, the port it picked, the temp folder the operating system
519
+ * handed it. Rubbing those out is not judgement, it is undoing our own footprint.
520
+ *
521
+ * @param {string} text
522
+ * @param {object} footprint
523
+ * @param {string[]} [footprint.dirs] Absolute directories we created for this run.
524
+ * @param {number[]} [footprint.ports] Ports we picked.
525
+ * @param {string} [footprint.projectRoot] The real project root, when it appears in output.
526
+ * @returns {string}
527
+ */
528
+ export function undoOurFootprint(text, footprint) {
529
+ let out = String(text).replace(/\r\n/g, '\n');
530
+ for (const dir of (footprint.dirs ?? []).slice().sort((a, b) => b.length - a.length)) {
531
+ if (!dir) continue;
532
+ out = out.split(dir).join('<the scratch folder>');
533
+ }
534
+ if (footprint.projectRoot) out = out.split(footprint.projectRoot).join('<the project>');
535
+ for (const port of footprint.ports ?? []) {
536
+ if (!port) continue;
537
+ out = out.split(`:${port}`).join(':<the port we picked>');
538
+ }
539
+ return out;
540
+ }
541
+
542
+ /**
543
+ * Keep a piece of text at a size worth storing.
544
+ *
545
+ * Anything longer gets its head and tail kept — the two ends are where the interesting
546
+ * lines are — plus a fingerprint of the whole, so a change in the middle still shows as a
547
+ * difference even though the middle itself was never stored. The caller writes the full
548
+ * text to the evidence folder and points at it.
549
+ *
550
+ * @param {string} text
551
+ * @param {number} [limit] bytes
552
+ * @returns {{text: string, truncated: boolean, bytes: number}}
553
+ */
554
+ export function trimForStorage(text, limit = 64 * 1024) {
555
+ const bytes = Buffer.byteLength(text, 'utf8');
556
+ if (bytes <= limit) return { text, truncated: false, bytes };
557
+ const keep = Math.floor(limit / 2);
558
+ const head = text.slice(0, keep);
559
+ const tail = text.slice(-keep);
560
+ return {
561
+ text: `${head}\n... ${sizeBucket(bytes - keep * 2)} left out of the middle ...\n${tail}`,
562
+ truncated: true,
563
+ bytes,
564
+ };
565
+ }