staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
@@ -36,13 +36,16 @@
36
36
  import fs from 'node:fs';
37
37
  import fsp from 'node:fs/promises';
38
38
  import path from 'node:path';
39
- import os from 'node:os';
40
39
  import crypto from 'node:crypto';
41
40
  import { spawn } from 'node:child_process';
42
41
  import {
43
42
  defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
44
43
  trimForStorage, undoOurFootprint,
45
44
  } from './contract.js';
45
+ // The harvest writes the test-file steps this adapter walks, and it owns reading a runner's
46
+ // output back. One place on purpose: a journey read differently from the way it was
47
+ // harvested is not the same journey.
48
+ import { quietenRunnerOutput, readChecks } from '../journeys/from-suite.js';
46
49
 
47
50
  // ---------------------------------------------------------------------------
48
51
  // The environment every run gets
@@ -150,23 +153,75 @@ export function watcherScript(opts) {
150
153
  "} catch { /* no child_process, nothing to watch */ }",
151
154
  "",
152
155
  "// --- what it tried to reach ----------------------------------------------",
153
- "const loopback = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0', '']);",
156
+ "const loopback = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']);",
154
157
  "try {",
155
158
  " const net = require('node:net');",
156
159
  " const connect = net.Socket.prototype.connect;",
157
160
  " net.Socket.prototype.connect = function (...args) {",
158
- " const first = args[0];",
159
- " const host = typeof first === 'object' && first !== null ? String(first.host ?? first.path ?? '') : String(args[1] ?? '');",
160
- " const port = typeof first === 'object' && first !== null ? first.port : first;",
161
- " const local = loopback.has(host) || (typeof first === 'object' && first !== null && first.path);",
161
+ " // Node normalises the arguments before they ever reach here, so what arrives is",
162
+ " // usually the ARRAY [options, callback] and not the port and host somebody typed.",
163
+ " // Reading `.host` off that array gives undefined, and an empty host used to mean",
164
+ " // 'nowhere named, therefore this machine' - so every fetch, every http.get and every",
165
+ " // net.connect walked straight out through a boundary that then reported nothing at",
166
+ " // all. Measured on 2026-08-30: all three got a 200 back from the open internet and",
167
+ " // the watcher's report was empty. Unwrap it first, and treat a shape nobody",
168
+ " // recognises as somewhere to refuse rather than somewhere to allow, because a",
169
+ " // boundary that fails open is not a boundary.",
170
+ " const given = Array.isArray(args[0]) ? args[0][0] : args[0];",
171
+ " const options = typeof given === 'object' && given !== null ? given : null;",
172
+ " const host = options ? String(options.host ?? '') : String(args[1] ?? '');",
173
+ " const port = options ? options.port : given;",
174
+ " const readable = options !== null || typeof given === 'number' || typeof given === 'string';",
175
+ " // A socket file is on this machine by definition, and a port with no host beside it",
176
+ " // is the one case where 'nowhere named' really does mean here.",
177
+ " const local = Boolean(options && options.path) || (readable && (host === '' || loopback.has(host)));",
162
178
  " if (local && settings.allowLoopback) return connect.apply(this, args);",
179
+
163
180
  " write('reached out', { host: host || 'somewhere it did not name', port: port ?? null });",
164
181
  " // Refused, not allowed through. Whatever this was going to do out there, it does not",
165
182
  " // do it twice, and the run is reported as having a hole rather than as having passed.",
166
- " const error = new Error('Stays Fixed refused a connection to ' + (host || 'an unnamed host') + ': nothing irreversible is allowed out during a check.');",
167
- " error.code = 'ECONNREFUSED';",
168
- " process.nextTick(() => this.emit('error', error));",
169
- " return this;",
183
+ " //",
184
+ " // HOW the refusal arrives matters as much as that it happens. Emitting 'error' on the",
185
+ " // socket ourselves reads correctly and kills the product: at that moment nothing is",
186
+ " // listening on the socket yet, and in Node an 'error' event with no listener is a",
187
+ " // thrown exception. `http.get` and `https.get` on Node 22 - the floor this package",
188
+ " // declares in its own engines field - and a bare `net.connect` on EVERY version all",
189
+ " // died that way, exit 1, and the run then reported the product as broken. A tool",
190
+ " // blaming a product for something the tool itself did is the exact failure this whole",
191
+ " // package exists to prevent. Measured 2026-08-30 against the published 0.8.0 watcher;",
192
+ " // its own CI had been red on this for four releases and nobody read it.",
193
+ " //",
194
+ " // So the refusal is made real rather than simulated: the socket is pointed at a port on",
195
+ " // this machine that nothing can be listening on, and the operating system produces the",
196
+ " // refusal through Node's own plumbing - by which time every listener the runtime wires",
197
+ " // up is in place. The product gets an ordinary ECONNREFUSED, which is exactly what it",
198
+ " // would get if the host were unreachable, and cannot tell the difference.",
199
+ " const named = host || 'an unnamed host';",
200
+ " const explain = 'Stays Fixed refused a connection to ' + named + ': nothing irreversible is allowed out during a check.';",
201
+ " const refusal = () => Object.assign(new Error(explain), { code: 'ECONNREFUSED', refusedBy: 'staysfixed' });",
202
+ " // Said in the error the product actually catches, without swallowing it: prepending a",
203
+ " // listener rewrites the message and still leaves every other handler to run as it would.",
204
+ " this.prependListener('error', (e) => {",
205
+ " if (e && e.code === 'ECONNREFUSED') { e.message = explain; e.refusedBy = 'staysfixed'; }",
206
+ " });",
207
+ " // Belt and braces. If something really is listening down there, the connection is cut",
208
+ " // before one byte can cross it: a boundary that fails open is not a boundary.",
209
+ " this.prependOnceListener('connect', () => { this.destroy(refusal()); });",
210
+ " // And a machine where that port is silently dropped rather than refused would hang",
211
+ " // here instead of failing, which is worse than the bug this replaced: a check that",
212
+ " // never finishes tells you nothing at all. A refusal is owed promptly, so if the",
213
+ " // operating system has not produced one shortly, produce it. Safe to do now, and only",
214
+ " // now, because the listener above means this can never be an unhandled error.",
215
+ " const soon = setTimeout(() => { if (!this.destroyed) this.destroy(refusal()); }, 250);",
216
+ " if (typeof soon.unref === 'function') soon.unref();",
217
+ " this.once('close', () => clearTimeout(soon));",
218
+ " try {",
219
+ " return connect.call(this, { port: 1, host: '127.0.0.1' });",
220
+ " } catch {",
221
+ " // Even the refusal failed. Still never throw into the product.",
222
+ " process.nextTick(() => { if (!this.destroyed) this.destroy(refusal()); });",
223
+ " return this;",
224
+ " }",
170
225
  " };",
171
226
  "} catch { /* no net module, nothing to refuse */ }",
172
227
  "",
@@ -177,9 +232,27 @@ export function watcherScript(opts) {
177
232
  "const settingsRead = new Set();",
178
233
  "try {",
179
234
  " const real = process.env;",
235
+ " const note = (key) => { if (typeof key === 'string') settingsRead.add(key); };",
236
+ " // EVERY trap forwards, and every trap that changes something forwards with the TARGET",
237
+ " // as the receiver. A proxy carrying only the traps we happen to care about is not a",
238
+ " // window, it is a wall with a window in it. With no `set` trap, `process.env.X = 'y'`",
239
+ " // takes the default, which reflects onto the PROXY, which lands on defineProperty",
240
+ " // against Node's own env object and quietly does nothing at all. npm sets",
241
+ " // npm_lifecycle_event and its npm_config_ family that way, reads them back, finds",
242
+ " // nothing and exits 1 without printing one word - so every product whose start command",
243
+ " // went through npm died here, and the report said, in good faith, that the product",
244
+ " // would not boot. `staysfixed init` writes `npm run start` by default, so this was the",
245
+ " // default path. Found by installing the published copy and pointing it at an ordinary",
246
+ " // Express app.",
180
247
  " const watched = new Proxy(real, {",
181
- " get(target, key) { if (typeof key === 'string') settingsRead.add(key); return target[key]; },",
182
- " has(target, key) { if (typeof key === 'string') settingsRead.add(key); return key in target; },",
248
+ " get(target, key) { note(key); return Reflect.get(target, key); },",
249
+ " has(target, key) { note(key); return Reflect.has(target, key); },",
250
+ " set(target, key, value) { return Reflect.set(target, key, value); },",
251
+ " deleteProperty(target, key) { return Reflect.deleteProperty(target, key); },",
252
+ " ownKeys(target) { return Reflect.ownKeys(target); },",
253
+ " getOwnPropertyDescriptor(target, key) { return Reflect.getOwnPropertyDescriptor(target, key); },",
254
+ " defineProperty(target, key, descriptor) { return Reflect.defineProperty(target, key, descriptor); },",
255
+ " getPrototypeOf(target) { return Reflect.getPrototypeOf(target); },",
183
256
  " });",
184
257
  " Object.defineProperty(process, 'env', { value: watched, configurable: true, writable: true });",
185
258
  "} catch { /* some hosts freeze this; the other channels still work */ }",
@@ -197,6 +270,7 @@ export function watcherScript(opts) {
197
270
  * @property {Map<string, number>} ran Command as written, and how many times.
198
271
  * @property {Array<{host: string, port: number|null}>} reachedOut
199
272
  * @property {string[]} settingsRead
273
+ * @property {number} torn Lines of the report that could not be read back.
200
274
  */
201
275
 
202
276
  /**
@@ -207,7 +281,7 @@ export function watcherScript(opts) {
207
281
  */
208
282
  export async function readWatcher(reportFile) {
209
283
  /** @type {WatchedEvents} */
210
- const seen = { inForce: false, ran: new Map(), reachedOut: [], settingsRead: [] };
284
+ const seen = { inForce: false, ran: new Map(), reachedOut: [], settingsRead: [], torn: 0 };
211
285
  let text;
212
286
  try {
213
287
  text = await fsp.readFile(reportFile, 'utf8');
@@ -218,7 +292,9 @@ export async function readWatcher(reportFile) {
218
292
  for (const line of text.split('\n')) {
219
293
  if (line.trim() === '') continue;
220
294
  let event;
221
- try { event = JSON.parse(line); } catch { continue; }
295
+ // A half-written line is a program that started or a host that was reached and is now
296
+ // reported as neither. Counted, so the run can say it saw less than it saw.
297
+ try { event = JSON.parse(line); } catch { seen.torn += 1; continue; }
222
298
  if (event.kind === 'ran') {
223
299
  const command = String(event.what?.command ?? '');
224
300
  seen.ran.set(command, (seen.ran.get(command) ?? 0) + 1);
@@ -243,25 +319,111 @@ export async function readWatcher(reportFile) {
243
319
 
244
320
  /** @typedef {Map<string, string>} TreeSnapshot relative path -> fingerprint of its contents */
245
321
 
246
- /** Folders left out of a snapshot: enormous, and not what anybody means by "it wrote a file". */
247
- const SNAPSHOT_SKIP = new Set(['node_modules', '.git', '.staysfixed']);
322
+ /**
323
+ * Folders left out of a snapshot: enormous, and not what anybody means by "it wrote a file".
324
+ *
325
+ * `node_modules` is the one that had to be argued out rather than assumed. A build step that
326
+ * writes in there — a patch, a generated client, a native rebuild — is a real change to what
327
+ * ships, and leaving it out means that change is invisible. It stays out anyway, because
328
+ * fingerprinting thirty thousand files twice per journey per build turns a check that takes
329
+ * seconds into one that takes minutes, and a check nobody runs catches nothing.
330
+ *
331
+ * What is NOT acceptable is skipping it quietly. Every run says which folders it did not
332
+ * watch, as missing coverage rather than as a clean result, and `process.alsoWatch` takes any
333
+ * of these back off the list for a project that needs it. See `snapshotSkip`.
334
+ */
335
+ export const SNAPSHOT_SKIP = new Set(['node_modules', '.git', '.staysfixed']);
336
+
337
+ /**
338
+ * The folders this run will not watch, after the project has had its say.
339
+ *
340
+ * @param {Record<string, unknown>|undefined} config The `process` section of the settings.
341
+ * @returns {Set<string>}
342
+ */
343
+ export function snapshotSkip(config) {
344
+ const skip = new Set(SNAPSHOT_SKIP);
345
+ const alsoWatch = Array.isArray(config?.alsoWatch) ? config.alsoWatch : [];
346
+ for (const name of alsoWatch) skip.delete(String(name));
347
+ return skip;
348
+ }
349
+
350
+ /** A file recorded by its size because hashing it would have meant reading past the ceiling. */
351
+ export const BY_SIZE_ALONE = 'compared by size alone, ';
352
+
353
+ /** A file or folder nothing could read. Kept in the snapshot so it is never a silence. */
354
+ export const COULD_NOT_READ = 'could not be looked at: ';
355
+
356
+ /**
357
+ * Why a path would not open, in words rather than in an errno.
358
+ * @param {unknown} error
359
+ * @returns {string}
360
+ */
361
+ function whyItWouldNotOpen(error) {
362
+ const code = String(/** @type {{code?: unknown}} */ (error)?.code ?? '');
363
+ if (code === 'EACCES' || code === 'EPERM') return 'no permission to read it';
364
+ if (code === 'EIO') return 'the disk would not answer';
365
+ if (code === 'ELOOP') return 'the symlinks point at each other';
366
+ if (code === 'EMFILE' || code === 'ENFILE') return 'this machine ran out of open files';
367
+ if (code === 'ENAMETOOLONG') return 'the name is longer than this machine allows';
368
+ if (code !== '') return code;
369
+ return error instanceof Error ? error.message : String(error);
370
+ }
371
+
372
+ /** A path that is simply not there any more was not there to begin with. */
373
+ const isGone = (/** @type {unknown} */ error) => {
374
+ const code = String(/** @type {{code?: unknown}} */ (error)?.code ?? '');
375
+ return code === 'ENOENT' || code === 'ENOTDIR';
376
+ };
377
+
378
+ /**
379
+ * Fingerprint one file that is bigger than the read-it-all-at-once limit.
380
+ *
381
+ * Streamed rather than read into memory, so the size of the file is the machine's problem and
382
+ * not this process's. There is still a ceiling, because a file measured in tens of gigabytes
383
+ * would be read twice per journey per build and nobody would wait for it — and above that
384
+ * ceiling the answer is the size bucket, with the marker that says so, so the run can report
385
+ * it as a hole instead of as a match.
386
+ *
387
+ * @param {string} file
388
+ * @param {number} size
389
+ * @param {number} ceilingBytes
390
+ * @returns {Promise<string>}
391
+ */
392
+ async function fingerprintBigFile(file, size, ceilingBytes) {
393
+ if (size > ceilingBytes) return `${BY_SIZE_ALONE}${sizeBucket(size)}`;
394
+ const hash = crypto.createHash('sha256');
395
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
396
+ return hash.digest('hex').slice(0, 16);
397
+ }
248
398
 
249
399
  /**
250
400
  * Fingerprint every file under a folder.
251
401
  *
252
402
  * By CONTENTS, never by timestamp or size. A run that rewrites a file with the same bytes
253
403
  * has not changed anything, and reporting it as a change is how a tool teaches people to
254
- * ignore it. Files too big to hash are recorded by size with a note, so they still show a
255
- * change when they grow, and they say what they are.
404
+ * ignore it.
405
+ *
406
+ * A big file is streamed rather than bucketed. It used to be recorded as "too big to
407
+ * fingerprint, tens of megabytes", which meant a build that wrote a COMPLETELY DIFFERENT
408
+ * forty-megabyte bundle compared equal to the old one as long as the size landed in the same
409
+ * bucket — the exact file a bundler rewrites, silently passing. Reading a large file is cheap
410
+ * next to running the whole product twice, so it is read.
411
+ *
412
+ * Anything that cannot be read at all — a folder with no permission on it, a disk that will
413
+ * not answer — goes into the snapshot as its own entry rather than being dropped. Dropping a
414
+ * folder takes everything under it with it, and a file nobody looked at cannot be seen
415
+ * changing; the run reports those as holes.
256
416
  *
257
417
  * @param {string} root
258
418
  * @param {object} [opts]
259
- * @param {number} [opts.maxBytes] Hash files up to this size. Default 8MB.
419
+ * @param {number} [opts.maxBytes] Read files up to this size in one go. Default 8MB.
420
+ * @param {number} [opts.ceilingBytes] Above this, record the size instead of hashing. Default 4GB.
260
421
  * @param {Set<string>} [opts.skip]
261
422
  * @returns {Promise<TreeSnapshot>}
262
423
  */
263
424
  export async function snapshotTree(root, opts = {}) {
264
425
  const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024;
426
+ const ceilingBytes = opts.ceilingBytes ?? 4 * 1024 * 1024 * 1024;
265
427
  const skip = opts.skip ?? SNAPSHOT_SKIP;
266
428
  /** @type {TreeSnapshot} */
267
429
  const snapshot = new Map();
@@ -270,7 +432,19 @@ export async function snapshotTree(root, opts = {}) {
270
432
  const walk = async (dir) => {
271
433
  /** @type {import('node:fs').Dirent[]} */
272
434
  let entries;
273
- try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
435
+ try {
436
+ entries = await fsp.readdir(dir, { withFileTypes: true });
437
+ } catch (error) {
438
+ // A folder that will not open used to be dropped here without a word, and everything
439
+ // under it with it — so a file inside it could be created, rewritten or deleted and the
440
+ // run would report the folder as unchanged. It goes in the snapshot instead, and
441
+ // `describeRun` reports it as a hole.
442
+ if (!isGone(error)) {
443
+ const at = path.relative(root, dir);
444
+ snapshot.set(at === '' ? '.' : at, `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`);
445
+ }
446
+ return;
447
+ }
274
448
  for (const entry of entries) {
275
449
  const full = path.join(dir, entry.name);
276
450
  if (entry.isDirectory()) {
@@ -285,12 +459,15 @@ export async function snapshotTree(root, opts = {}) {
285
459
  if (!entry.isFile()) continue;
286
460
  try {
287
461
  const stat = await fsp.stat(full);
288
- if (stat.size > maxBytes) {
289
- snapshot.set(relative, `too big to fingerprint, ${sizeBucket(stat.size)}`);
290
- continue;
291
- }
292
- snapshot.set(relative, crypto.createHash('sha256').update(await fsp.readFile(full)).digest('hex').slice(0, 16));
293
- } catch { /* a file that vanished mid-walk was not there to begin with */ }
462
+ snapshot.set(relative, stat.size > maxBytes
463
+ ? await fingerprintBigFile(full, stat.size, ceilingBytes)
464
+ : crypto.createHash('sha256').update(await fsp.readFile(full)).digest('hex').slice(0, 16));
465
+ } catch (error) {
466
+ // A file that vanished between the listing and the read was not there to begin with.
467
+ // Anything else is a file nobody looked at, and staying quiet about it reads exactly
468
+ // like "it did not change".
469
+ if (!isGone(error)) snapshot.set(relative, `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`);
470
+ }
294
471
  }
295
472
  };
296
473
 
@@ -337,6 +514,10 @@ export function compareTrees(before, after) {
337
514
  * @property {string|null} signal Set when it was killed rather than finishing.
338
515
  * @property {boolean} timedOut
339
516
  * @property {number} ms
517
+ * @property {string} [couldNotStart] Why the command never ran at all. A missing exit code
518
+ * means two different things without this — killed, or
519
+ * never started — and the second one compares equal on
520
+ * both builds, which reads exactly like a clean run.
340
521
  */
341
522
 
342
523
  /**
@@ -377,6 +558,9 @@ export function runCommand(command, opts) {
377
558
  if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
378
559
  else child.stdin?.end();
379
560
 
561
+ /** @type {string|undefined} */
562
+ let couldNotStart;
563
+
380
564
  const finish = (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
381
565
  if (settled) return;
382
566
  settled = true;
@@ -390,6 +574,7 @@ export function runCommand(command, opts) {
390
574
  signal,
391
575
  timedOut,
392
576
  ms: Date.now() - started,
577
+ ...(couldNotStart ? { couldNotStart } : {}),
393
578
  });
394
579
  };
395
580
 
@@ -405,6 +590,10 @@ export function runCommand(command, opts) {
405
590
  opts.signal?.addEventListener('abort', onAbort, { once: true });
406
591
 
407
592
  child.on('error', (error) => {
593
+ // Nothing ran. Said out loud rather than folded into "exit code null", which is what a
594
+ // killed run also looks like — and which is identical on both builds, so the comparison
595
+ // saw no difference and the run passed for the worst possible reason.
596
+ couldNotStart = error.message;
408
597
  err.push(Buffer.from(`${error.message}\n`));
409
598
  finish(null, null);
410
599
  });
@@ -416,41 +605,161 @@ export function runCommand(command, opts) {
416
605
  // Making the scratch copy
417
606
  // ---------------------------------------------------------------------------
418
607
 
608
+ /**
609
+ * Folders not worth copying into a scratch build.
610
+ *
611
+ * The bar for this list is deliberately high: anything skipped that turns out to matter
612
+ * produces a run that passes for the wrong reason, and a false pass is the one failure
613
+ * this whole tool exists to prevent. So it holds only things that are *regenerated on
614
+ * demand and read by nothing* — caches and coverage reports — plus the two that are ours
615
+ * and git's. Build output, `node_modules`, lockfiles, fixtures and configuration are all
616
+ * copied, because a check that runs against a different set of files than the real
617
+ * product is not checking the real product.
618
+ */
619
+ export const SKIP_BY_DEFAULT = [
620
+ '.git',
621
+ '.staysfixed',
622
+ '.turbo',
623
+ '.nyc_output',
624
+ 'coverage',
625
+ '.pytest_cache',
626
+ '__pycache__',
627
+ '.DS_Store',
628
+ ];
629
+
419
630
  /**
420
631
  * Copy a project into a scratch folder so a run can write whatever it likes.
421
632
  *
422
- * `node_modules` is cloned rather than copied where the filesystem can do it — on this Mac
423
- * that is one APFS call and no bytes move; on Linux it is a reflink where the filesystem
424
- * has them and a real copy where it does not. Never a symlink and never a hardlink: both
425
- * point back at the real project, which is the one thing this whole function exists to
426
- * protect.
633
+ * ## Why this is a clone and not a copy
634
+ *
635
+ * The real projects this gets pointed at are enormous the one it was built against is
636
+ * twelve gigabytes, most of it an iOS build folder. Copying that byte by byte before every
637
+ * single run would take minutes and fill a disk, and a check nobody can afford to run is
638
+ * a check nobody runs.
639
+ *
640
+ * So it asks the filesystem to *clone* instead: on macOS that is `cp -c`, one APFS call
641
+ * per file that copies no bytes at all and shares the blocks until something writes to
642
+ * them; on Linux it is `cp --reflink=auto`, which does the same where the filesystem
643
+ * supports it and a real copy where it does not. Measured on the twelve-gigabyte project:
644
+ * the six-hundred-megabyte `node_modules` alone went from a long wait to under three
645
+ * seconds, and used no extra disk.
646
+ *
647
+ * Never a symlink and never a hardlink. Both of those point back at the real project,
648
+ * which is the one thing this function exists to protect — the first thing a broken build
649
+ * does is write to a file, and with a link that write lands in his actual working tree.
650
+ *
651
+ * It falls back to a plain recursive copy whenever the clone is unavailable or fails, so
652
+ * a filesystem without reflinks is slower here and never wrong.
427
653
  *
428
654
  * @param {string} from
429
655
  * @param {string} to
430
656
  * @param {object} [opts]
431
- * @param {string[]} [opts.skip] Folder names not to copy. `.git` by default — it is huge
432
- * and nothing a CLI check does needs history.
433
- * @returns {Promise<{copied: boolean, why: string}>}
657
+ * @param {string[]} [opts.skip] Names not to copy. See `SKIP_BY_DEFAULT`.
658
+ * @param {string[]} [opts.also] Extra names to skip, on top of the defaults.
659
+ * @param {AbortSignal} [opts.signal]
660
+ * @returns {Promise<{copied: boolean, why: string, cloned: boolean, tookMs: number, skipped: string[]}>}
434
661
  */
435
662
  export async function copyForScratch(from, to, opts = {}) {
436
- const skip = new Set(opts.skip ?? ['.git', '.staysfixed']);
663
+ const began = Date.now();
664
+ const skip = new Set([...(opts.skip ?? SKIP_BY_DEFAULT), ...(opts.also ?? [])]);
437
665
  await fsp.mkdir(to, { recursive: true });
666
+
667
+ /** @type {import('node:fs').Dirent[]} */
668
+ let entries;
438
669
  try {
439
- await fsp.cp(from, to, {
440
- recursive: true,
441
- force: true,
442
- dereference: false,
443
- preserveTimestamps: true,
444
- filter: (source) => {
445
- const name = path.basename(source);
446
- if (skip.has(name)) return false;
447
- return true;
448
- },
449
- });
450
- return { copied: true, why: `Copied the project into a scratch folder, so the run can write anywhere it likes without touching the real one.` };
670
+ entries = await fsp.readdir(from, { withFileTypes: true });
451
671
  } catch (error) {
452
- return { copied: false, why: `The project could not be copied into a scratch folder: ${error instanceof Error ? error.message : String(error)}` };
672
+ return {
673
+ copied: false, cloned: false, tookMs: Date.now() - began, skipped: [],
674
+ why: `The project could not be read: ${error instanceof Error ? error.message : String(error)}`,
675
+ };
453
676
  }
677
+
678
+ const skipped = entries.filter((e) => skip.has(e.name)).map((e) => e.name);
679
+ const wanted = entries.filter((e) => !skip.has(e.name));
680
+
681
+ let cloned = 0;
682
+ let copied = 0;
683
+ for (const entry of wanted) {
684
+ const source = path.join(from, entry.name);
685
+ const target = path.join(to, entry.name);
686
+ if (await cloneOne(source, target, opts.signal)) {
687
+ cloned += 1;
688
+ continue;
689
+ }
690
+ try {
691
+ await fsp.cp(source, target, {
692
+ recursive: true,
693
+ force: true,
694
+ dereference: false,
695
+ preserveTimestamps: true,
696
+ filter: (p) => !skip.has(path.basename(p)),
697
+ });
698
+ copied += 1;
699
+ } catch (error) {
700
+ return {
701
+ copied: false, cloned: cloned > 0, tookMs: Date.now() - began, skipped,
702
+ why: `The project could not be copied into a scratch folder: ${error instanceof Error ? error.message : String(error)}`,
703
+ };
704
+ }
705
+ }
706
+
707
+ const tookMs = Date.now() - began;
708
+ const how = cloned > 0 && copied === 0
709
+ ? 'The project was cloned into a scratch folder — the filesystem shared the blocks, so no bytes moved'
710
+ : cloned > 0
711
+ ? 'The project was cloned into a scratch folder where the filesystem allowed it and copied where it did not'
712
+ : 'The project was copied into a scratch folder';
713
+ const left = skipped.length ? ` Left behind: ${skipped.join(', ')}.` : '';
714
+ return {
715
+ copied: true,
716
+ cloned: cloned > 0,
717
+ tookMs,
718
+ skipped,
719
+ why: `${how} (${(tookMs / 1000).toFixed(1)}s), so the run can write anywhere it likes without touching the real one.${left}`,
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Ask the filesystem to clone one entry. False means "it would not", not "it broke".
725
+ *
726
+ * @param {string} source
727
+ * @param {string} target
728
+ * @param {AbortSignal} [signal]
729
+ * @returns {Promise<boolean>}
730
+ */
731
+ function cloneOne(source, target, signal) {
732
+ // Windows has no reflink through `cp`, and there is no `cp`. Straight to the fallback.
733
+ if (process.platform === 'win32') return Promise.resolve(false);
734
+ const args = process.platform === 'darwin'
735
+ ? ['-Rc', source, target]
736
+ : ['-a', '--reflink=auto', source, target];
737
+ return new Promise((resolve) => {
738
+ let settled = false;
739
+ /** @param {boolean} ok */
740
+ const done = (ok) => {
741
+ if (settled) return;
742
+ settled = true;
743
+ resolve(ok);
744
+ };
745
+ let child;
746
+ try {
747
+ child = spawn('cp', args, { stdio: 'ignore', signal });
748
+ } catch {
749
+ done(false);
750
+ return;
751
+ }
752
+ child.on('error', () => done(false));
753
+ child.on('close', (code) => {
754
+ if (code === 0) {
755
+ done(true);
756
+ return;
757
+ }
758
+ // A half-written target from a failed clone would make the fallback copy merge into
759
+ // it. Clear it out so the fallback starts from nothing.
760
+ fsp.rm(target, { recursive: true, force: true }).then(() => done(false), () => done(false));
761
+ });
762
+ });
454
763
  }
455
764
 
456
765
  // ---------------------------------------------------------------------------
@@ -471,6 +780,73 @@ export async function copyForScratch(from, to, opts = {}) {
471
780
  * @property {string} module A path inside the project, or a package entry name.
472
781
  */
473
782
 
783
+ /**
784
+ * The command a run line actually opens, named the way the code reader names it.
785
+ *
786
+ * These two lists only ever meet here. A command door is read out of package.json and comes
787
+ * out as either `staysfixed` (something the package installs) or `npm run build` (a script),
788
+ * while a journey is named by whoever wrote the settings — "build the app". So a journey
789
+ * carrying nothing but its own name matched no door at all.
790
+ *
791
+ * `pnpm run build` opens the same door as `npm run build`, because the door is the script in
792
+ * package.json and the package manager standing in front of it is not a second door.
793
+ *
794
+ * A line this cannot read plainly — a pipeline, a shell one-liner, anything with an operator
795
+ * in it — gets null instead of a guess. Missing a walked command leaves a job on the queue;
796
+ * naming the wrong one marks a door walked that nobody touched, and that is the single
797
+ * direction the coverage ledger is never allowed to be wrong in.
798
+ *
799
+ * @param {string} run The command line, exactly as the settings wrote it.
800
+ * @returns {string|null}
801
+ */
802
+ export function commandDoorName(run) {
803
+ const line = String(run ?? '').trim();
804
+ if (line === '') return null;
805
+ // An operator means the line runs more than one thing, and this cannot say which of them
806
+ // the door is.
807
+ if (/[|;&<>`$]/.test(line)) return null;
808
+ const words = line.split(/\s+/);
809
+ // FOO=bar in front of a command is the environment it runs in, not the command.
810
+ while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
811
+ // A runner in front fetches or finds the real command and then runs it; the door is what
812
+ // comes after it.
813
+ while (words.length > 1 && (/^(npx|bunx)$/.test(words[0]) || (/^(pnpm|yarn|bun|npm)$/.test(words[0]) && /^(exec|dlx)$/.test(words[1])))) {
814
+ words.splice(0, words[0] === 'npx' || words[0] === 'bunx' ? 1 : 2);
815
+ }
816
+ // Flags to the runner itself — `npx --yes staysfixed` — belong to the runner.
817
+ while (words.length > 1 && words[0].startsWith('-')) words.shift();
818
+ if (words.length === 0) return null;
819
+ const program = path.basename(words[0]);
820
+ const rest = words.slice(1).filter((w) => !w.startsWith('-'));
821
+ if (/^(npm|pnpm|yarn|bun)$/.test(program)) {
822
+ if (words[1] === 'run' && rest[1]) return `npm run ${rest[1]}`;
823
+ // npm's own shorthands for four scripts. `yarn <anything>` is deliberately not read this
824
+ // way: with yarn a bare word may be a script or a command, and this cannot tell.
825
+ if (program !== 'yarn' && rest[0] && /^(test|start|stop|restart)$/.test(rest[0])) return `npm run ${rest[0]}`;
826
+ return null;
827
+ }
828
+ // An interpreter with something after it is running that something, and the door is whatever
829
+ // that file installs as — which this cannot know. Null, rather than reporting a door called
830
+ // "node" that the code reader never found.
831
+ if (words.length > 1 && /^(node|deno|sh|bash|zsh|dash|env|python|python3|ruby|perl)$/.test(program)) return null;
832
+ return program;
833
+ }
834
+
835
+ /**
836
+ * The door fields a command journey's step carries, or nothing when the command line is not
837
+ * plain enough to name one honestly. `door` in the settings overrides the reading, which is
838
+ * the way out for a project whose command line this cannot make sense of.
839
+ *
840
+ * @param {Record<string, unknown>} entry
841
+ * @returns {{door: string, kind: 'command'}|{}}
842
+ */
843
+ function doorFields(entry) {
844
+ const named = typeof entry.door === 'string' && entry.door.trim() !== ''
845
+ ? entry.door.trim()
846
+ : commandDoorName(String(entry.run ?? ''));
847
+ return named ? { door: named, kind: /** @type {const} */ ('command') } : {};
848
+ }
849
+
474
850
  /** Everything a prepared build needs to remember between journeys. */
475
851
  const prepared = new Map();
476
852
 
@@ -481,7 +857,7 @@ export const processAdapter = defineAdapter({
481
857
  name: 'process',
482
858
  title: 'CLI tools and libraries',
483
859
  describe:
484
- 'Runs a command, or imports a module, in a scratch copy of the project and reports what it printed, what it exited with, every file it created or changed, every program it started, every outbound connection it tried — all of which are refused — and roughly how long it took. Outbound calls and started programs are only visible when the thing being run is Node; for anything else those two channels are reported as not checked rather than as clean.',
860
+ 'Runs a command, imports a module, or walks one of the project\'s own test files, in a scratch copy of the project and reports what it printed, what it exited with, every file it created or changed, every program it started, every outbound connection it tried — all of which are refused — and roughly how long it took. A test file also reports each of its checks by name and why any failing one failed, so a check that goes red on the new build alone names itself. Outbound calls and started programs are only visible when the thing being run is Node; for anything else those two channels are reported as not checked rather than as clean.',
485
861
  channels: ['results', 'complaints', 'effects', 'counters'],
486
862
 
487
863
  /** @param {import('./contract.js').AdapterProject} project */
@@ -535,7 +911,17 @@ export const processAdapter = defineAdapter({
535
911
  surface: 'cli',
536
912
  from: 'the project config',
537
913
  channels: ['results', 'complaints', 'effects', 'counters'],
538
- steps: [{ act: 'run', run: String(entry.run), cwd: entry.cwd, stdin: entry.stdin, env: entry.env }],
914
+ // `door` and `kind` are how the coverage ledger learns this journey ran that command.
915
+ // Without them a command counted as walked only if an observation landed at its own
916
+ // address, and this adapter writes everything under `cli.<journey name>` — so
917
+ // {"name": "build the app", "run": "npm run build"} produced `cli.build the app.*`
918
+ // against a door addressed `cli.npm run build`, and every command in the project read
919
+ // as never walked on a run that had just walked all of them. The address rule cannot
920
+ // rescue this one: it is switched off for commands on purpose.
921
+ steps: [{
922
+ act: 'run', run: String(entry.run), cwd: entry.cwd, stdin: entry.stdin, env: entry.env,
923
+ ...doorFields(entry),
924
+ }],
539
925
  irreversible: entry.irreversible === true,
540
926
  timeoutMs: entry.timeoutMs,
541
927
  });
@@ -548,6 +934,11 @@ export const processAdapter = defineAdapter({
548
934
  surface: 'library',
549
935
  from: 'the project config',
550
936
  channels: ['results', 'complaints', 'effects', 'counters'],
937
+ // No `door` here on purpose, and it was checked rather than assumed: `apiSurface`
938
+ // writes every exported name at `export.<journey name>.<name>`, which is exactly the
939
+ // branch the ledger already reads exports through, so these doors open on their own.
940
+ // Naming the module as the door instead would claim a door of that name that the code
941
+ // reader never found.
551
942
  steps: [{ act: 'import', module: String(entry.module) }],
552
943
  timeoutMs: entry.timeoutMs,
553
944
  });
@@ -567,7 +958,17 @@ export const processAdapter = defineAdapter({
567
958
  await fsp.mkdir(home, { recursive: true });
568
959
  await fsp.mkdir(tmp, { recursive: true });
569
960
 
570
- const copy = await copyForScratch(build.root, work);
961
+ // A project may name more to leave behind — a giant build folder no command reads,
962
+ // say. It can only ADD to the defaults: a setting that could switch off `.git` being
963
+ // skipped would only ever make runs slower.
964
+ const alsoSkip = Array.isArray(ctx.config?.skip) ? ctx.config.skip.map(String) : [];
965
+ const copy = await copyForScratch(build.root, work, { also: alsoSkip, signal: ctx.signal });
966
+ if (copy.copied && copy.tookMs > 20_000) {
967
+ ctx.log?.(
968
+ `Making a scratch copy of this project took ${Math.round(copy.tookMs / 1000)} seconds. ` +
969
+ `If a large folder here is not read by any command, name it under "process.skip" in the config and it will be left behind.`,
970
+ );
971
+ }
571
972
  if (!copy.copied) {
572
973
  return {
573
974
  build, root: work, ready: false, why: copy.why,
@@ -636,15 +1037,23 @@ export const processAdapter = defineAdapter({
636
1037
  extra: {
637
1038
  ...step.env,
638
1039
  // `--import` is how a module gets to run before anything else does. It is appended
639
- // rather than assigned so a project that needs its own options keeps them.
640
- NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import ${pathToUrl(places.watcher)}`.trim(),
1040
+ // rather than assigned so a project that needs its own options keeps them — and the
1041
+ // journey's own NODE_OPTIONS is the one that has to survive, which it did not: this
1042
+ // line spread `step.env` and then overwrote it with the OUTER machine's value, so a
1043
+ // journey that asked for `--max-old-space-size` got the comment's promise and none of
1044
+ // its behaviour.
1045
+ NODE_OPTIONS: `${step.env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? ''} --import ${pathToUrl(places.watcher)}`.trim(),
641
1046
  },
642
1047
  });
643
1048
 
644
1049
  // A step that names nothing to run must say so. Handing `undefined` to a shell runs a
645
1050
  // command called "undefined", which fails identically on both builds and therefore
646
1051
  // reports NO difference - a silent nothing that looks exactly like a clean check.
647
- const nothingToRun = step.act === 'import' ? !step.module : !step.run;
1052
+ const nothingToRun = step.act === 'import'
1053
+ ? !step.module
1054
+ : step.act === 'run-tests'
1055
+ ? !step.file || !(step.command || step.run)
1056
+ : !step.run;
648
1057
  if (nothingToRun) {
649
1058
  return [notCovered({
650
1059
  channel: 'results',
@@ -652,22 +1061,53 @@ export const processAdapter = defineAdapter({
652
1061
  reason: 'refused',
653
1062
  says:
654
1063
  `"${journey.describe}" says nothing to run. A command journey needs a "run" with the command line in it, ` +
655
- `and an import journey needs a "module". Nothing was run, and that is a hole, not a pass.`,
1064
+ `an import journey needs a "module", and a test-file journey needs the "file" it walks and the command ` +
1065
+ `that runs it. Nothing was run, and that is a hole, not a pass.`,
656
1066
  })];
657
1067
  }
658
1068
 
659
- const before = await snapshotTree(places.work);
1069
+ const runner = /** @type {import('../journeys/from-suite.js').Runner} */ (step.runner ?? 'node:test');
1070
+ // The same list for both snapshots, and handed on to the report: a folder that is not
1071
+ // watched has to be named in the run that did not watch it, not left to be discovered.
1072
+ const skip = snapshotSkip(ctx.config);
1073
+ const before = await snapshotTree(places.work, { skip });
660
1074
  const result = step.act === 'import'
661
1075
  ? await runCommand(importProbeCommand(String(step.module)), { cwd, env, timeoutMs: journey.timeoutMs ?? 60000, signal: ctx.signal })
662
- : await runCommand(String(step.run), { cwd, env, timeoutMs: journey.timeoutMs ?? 120000, stdin: step.stdin, signal: ctx.signal });
663
- const after = await snapshotTree(places.work);
1076
+ : step.act === 'run-tests'
1077
+ ? await runCommand(testFileCommand(step), { cwd, env, timeoutMs: journey.timeoutMs ?? 120000, signal: ctx.signal })
1078
+ : await runCommand(String(step.run), { cwd, env, timeoutMs: journey.timeoutMs ?? 120000, stdin: step.stdin, signal: ctx.signal });
1079
+ const after = await snapshotTree(places.work, { skip });
664
1080
  const watched = await readWatcher(reportFile);
665
1081
 
666
1082
  const observations = await describeRun({
667
- journey, result, before, after, watched, ctx,
1083
+ journey, result, before, after, watched, ctx, skipped: [...skip].sort(),
668
1084
  footprint: { dirs: [places.base, places.tmp, places.home], projectRoot: build.build.root },
1085
+ // A test runner narrates its own stopwatch and nothing else moves between two runs of
1086
+ // identical bytes, so taking the stopwatch out is what makes the whole of what it
1087
+ // printed worth comparing. See `withoutRunnerTiming`.
1088
+ quieten: step.act === 'run-tests' ? (text) => quietenRunnerOutput(runner, text) : undefined,
669
1089
  });
670
1090
  if (step.act === 'import') observations.push(...apiSurface(journey, result));
1091
+ if (step.act === 'run-tests') {
1092
+ observations.push(...(await suiteObservations({ journey, step, runner, result, root: places.work })));
1093
+ }
1094
+ // Only the first step is walked, and until now the rest were dropped without a word — a
1095
+ // journey of three steps reported on one of them and read as a clean, complete walk.
1096
+ // Nothing here builds a multi-step CLI journey today; a recording or an agent easily
1097
+ // could, and a silent drop is how that arrives as a false pass.
1098
+ const rest = (journey.steps ?? []).slice(1);
1099
+ if (rest.length > 0) {
1100
+ observations.push(notCovered({
1101
+ channel: 'results',
1102
+ path: joinPath('cli', journey.name, 'the rest of its steps'),
1103
+ reason: 'not supported here',
1104
+ says:
1105
+ `"${journey.describe}" has ${rest.length + 1} steps and this adapter walks one command per journey, so ` +
1106
+ `${rest.length} of them ${rest.length === 1 ? 'was' : 'were'} not walked: ` +
1107
+ `${rest.map((/** @type {any} */ s) => s.run ?? s.module ?? s.file ?? s.act).join(', ')}. ` +
1108
+ `Split them into a journey each. This is a hole, not a pass.`,
1109
+ }));
1110
+ }
671
1111
  return observations;
672
1112
  },
673
1113
 
@@ -688,7 +1128,19 @@ export const processAdapter = defineAdapter({
688
1128
  */
689
1129
  export function importProbeCommand(moduleId) {
690
1130
  const probe = [
691
- "const m = await import(process.argv[1].startsWith('.') || process.argv[1].includes('/') ? new URL(process.argv[1], 'file://' + process.cwd() + '/').href : process.argv[1]);",
1131
+ // A FILE unless it is really a package. The old rule was "starts with a dot, or has a
1132
+ // slash in it" — and `index.js` has neither, so Node was asked for a PACKAGE called
1133
+ // "index.js" and answered ERR_MODULE_NOT_FOUND. `staysfixed init` writes exactly
1134
+ // `{ module: "index.js" }` for an ordinary package entry, so on those projects this
1135
+ // journey failed on every run, failed the SAME way on both builds, produced no
1136
+ // difference, and the check said "Nothing that worked has changed" for ever. Measured
1137
+ // 2026-08-30. So: if a file of that name is really there, it is a file.
1138
+ "const id = process.argv[1];",
1139
+ "const { existsSync } = await import('node:fs');",
1140
+ "const { fileURLToPath } = await import('node:url');",
1141
+ "const asFile = new URL(id, 'file://' + process.cwd() + '/').href;",
1142
+ "const onDisk = (() => { try { return existsSync(fileURLToPath(asFile)); } catch { return false; } })();",
1143
+ "const m = await import(id.startsWith('.') || id.startsWith('/') || id.includes('/') || onDisk ? asFile : id);",
692
1144
  "const out = {};",
693
1145
  "for (const key of Object.keys(m).sort()) {",
694
1146
  " const v = m[key];",
@@ -699,11 +1151,194 @@ export function importProbeCommand(moduleId) {
699
1151
  " : t === 'object' ? ('an object with ' + Object.keys(v).sort().join(', '))",
700
1152
  " : t === 'string' ? 'some text' : t;",
701
1153
  "}",
702
- "process.stdout.write(JSON.stringify(out, null, 2));",
1154
+ "process.stdout.write('\\n' + " + JSON.stringify(EXPORTS_MARKER) + " + '\\n' + JSON.stringify(out, null, 2));",
703
1155
  ].join('\n');
704
1156
  return `node --input-type=module -e ${shellQuote(probe)} ${shellQuote(moduleId)}`;
705
1157
  }
706
1158
 
1159
+ /**
1160
+ * How the probe's answer is told apart from anything the module printed on the way in.
1161
+ *
1162
+ * Without it the whole of stdout was handed to `JSON.parse`, so ONE line printed at import
1163
+ * time — a dotenv banner, a deprecation warning, anything — made the parse fail, and the run
1164
+ * then said "could not be imported, so nothing is known about what it exports". Both halves
1165
+ * false: it imported perfectly, and its whole exported surface was sitting in the same
1166
+ * string. Every export on that module read as never walked, which is the coverage ledger
1167
+ * lying, and the API comparison that is the entire point of an import journey was off.
1168
+ */
1169
+ const EXPORTS_MARKER = '<<< staysfixed: what it exports >>>';
1170
+
1171
+ // ---------------------------------------------------------------------------
1172
+ // Walking a test file the harvest found
1173
+ // ---------------------------------------------------------------------------
1174
+
1175
+ /**
1176
+ * The command line for a test-file journey, exactly as it was harvested.
1177
+ *
1178
+ * The harvest wrote the program and its arguments down separately, and they are put back
1179
+ * together with every part quoted, because a project with a space in its path is not a
1180
+ * project this tool gets to be wrong about.
1181
+ *
1182
+ * ONE SUBSTITUTION, and only one. The program the harvest recorded is an absolute path to the
1183
+ * Node binary on the machine that did the harvesting. A journey saved in a repository and
1184
+ * walked on somebody else's laptop names a file that is not there, and the shell then fails
1185
+ * the same way on BOTH builds - which produces no difference at all and reads exactly like a
1186
+ * clean check. So a missing absolute Node is replaced with the Node running this, and
1187
+ * anything else is left alone for the shell to find on the path.
1188
+ *
1189
+ * @param {{command?: string, argv?: string[], run?: string, file?: string}} step
1190
+ * @returns {string}
1191
+ */
1192
+ export function testFileCommand(step) {
1193
+ if (!step.command) return String(step.run ?? '');
1194
+ let program = String(step.command);
1195
+ if (path.isAbsolute(program) && !exists(program) && /^node(\.exe)?$/.test(path.basename(program))) {
1196
+ program = process.execPath;
1197
+ }
1198
+ return [program, ...(step.argv ?? []).map(String)].map(shellQuote).join(' ');
1199
+ }
1200
+
1201
+ /**
1202
+ * What a walked test file says, beyond what any command says.
1203
+ *
1204
+ * Four things, and each one answers a question an exit code cannot.
1205
+ *
1206
+ * EACH CHECK, BY NAME, passed or failed. A suite that was already red stays red on both
1207
+ * builds and reports nothing, which is right: it was already failing and you did not break
1208
+ * it. A check that goes green-to-red on the new build alone is the finding, and it names
1209
+ * itself instead of arriving as "the exit code changed".
1210
+ *
1211
+ * WHY EACH FAILING CHECK FAILED. "Still failing" and "failing for a completely different
1212
+ * reason" are different facts, and a flag cannot hold both.
1213
+ *
1214
+ * THE CHECKS THE FILE CONTAINS, as a list. Add, rename or delete a test and this moves.
1215
+ *
1216
+ * THE TEST FILE ITSELF, as a fingerprint of its contents. This is the one that stops the
1217
+ * whole feature crying wolf. If you edited the test, then every difference underneath it is
1218
+ * a difference you made on purpose, and the reader has to be told so in the same breath as
1219
+ * the difference rather than left to work it out. It is compared rather than merely noted,
1220
+ * so it appears exactly when it is true and never otherwise.
1221
+ *
1222
+ * FLAKES ARE NOT DEALT WITH HERE, on purpose. A check that flips between two runs of the same
1223
+ * build lands in the wobble measurement like anything else that cannot answer twice, and is
1224
+ * subtracted there. A second mechanism for the same problem is how two mechanisms end up
1225
+ * disagreeing with each other.
1226
+ *
1227
+ * @param {object} input
1228
+ * @param {import('./contract.js').Journey} input.journey
1229
+ * @param {{file?: string, tests?: string[]}} input.step
1230
+ * @param {import('../journeys/from-suite.js').Runner} input.runner
1231
+ * @param {CommandResult} input.result
1232
+ * @param {string} input.root The scratch copy this build was walked in.
1233
+ * @returns {Promise<import('./contract.js').Observation[]>}
1234
+ */
1235
+ export async function suiteObservations(input) {
1236
+ const { journey, step, runner, result, root } = input;
1237
+ const id = journey.name;
1238
+ const file = String(step.file ?? '');
1239
+ /** @type {import('./contract.js').Observation[]} */
1240
+ const out = [];
1241
+
1242
+ out.push(observation({
1243
+ channel: 'results',
1244
+ path: joinPath('test', id, 'the test file itself'),
1245
+ value: await fingerprintOf(path.join(root, file)),
1246
+ says:
1247
+ `${file} as it stands in this build. If this is one of the things that changed, then whatever moved below ` +
1248
+ `moved because you edited the test, and it cannot tell you whether the product still works - run the check ` +
1249
+ `again once the test is the way you want it.`,
1250
+ where: { file },
1251
+ }));
1252
+
1253
+ const read = readChecks(runner, result.stdout);
1254
+ if (!read.read) {
1255
+ out.push(notCovered({
1256
+ channel: 'results',
1257
+ path: joinPath('test', id, 'the checks it reported'),
1258
+ reason: 'crashed',
1259
+ says:
1260
+ `Nothing could be read back from ${file}: ${read.why} It was run, and what it printed and how it finished ` +
1261
+ `are still compared exactly - but which of its checks passed is not known, and that is a hole, not a pass.`,
1262
+ where: { file },
1263
+ }));
1264
+ return out;
1265
+ }
1266
+
1267
+ out.push(observation({
1268
+ channel: 'results',
1269
+ path: joinPath('test', id, 'the checks it contains'),
1270
+ value: read.checks.map((c) => c.name).sort(),
1271
+ says:
1272
+ `The ${read.checks.length} ${read.checks.length === 1 ? 'check' : 'checks'} ${file} reported. A name appearing ` +
1273
+ `or disappearing here means the test file itself was added to or cut down.`,
1274
+ where: { file },
1275
+ }));
1276
+
1277
+ for (const check of read.checks) {
1278
+ out.push(observation({
1279
+ channel: 'results',
1280
+ path: joinPath('test', id, check.name),
1281
+ value: check.ok ? 'passed' : 'failed',
1282
+ says: check.ok
1283
+ ? `"${check.name}" in ${file} passed.`
1284
+ : `"${check.name}" in ${file} failed. If it failed on the build you were happy with too, nothing is ` +
1285
+ `reported - it was already broken, and you did not break it.`,
1286
+ where: { file },
1287
+ }));
1288
+ if (check.detail) {
1289
+ // A long failure message gets its middle cut out, and that used to be stored with
1290
+ // nothing saying so — two different failures whose ends match would then compare equal
1291
+ // and report "still failing for the same reason" when the reason had changed.
1292
+ const kept = trimForStorage(check.detail);
1293
+ out.push(observation({
1294
+ channel: 'complaints',
1295
+ path: joinPath('test', id, check.name, 'why it failed'),
1296
+ value: kept.text,
1297
+ says:
1298
+ `What ${file} said when "${check.name}" failed. A check that was already failing and is now failing for a ` +
1299
+ `different reason is a change, and this is where it shows.` +
1300
+ (kept.truncated
1301
+ ? ` It is ${sizeBucket(kept.bytes)}, so only the two ends are compared: a different failure with the same ` +
1302
+ `ends and the same length would not be seen.`
1303
+ : ''),
1304
+ where: { file },
1305
+ covered: kept.truncated ? false : undefined,
1306
+ reason: kept.truncated ? 'too big' : undefined,
1307
+ }));
1308
+ }
1309
+ }
1310
+
1311
+ out.push(observation({
1312
+ channel: 'counters',
1313
+ path: joinPath('count', id, 'checks that failed'),
1314
+ value: read.checks.filter((c) => !c.ok).length,
1315
+ says:
1316
+ `How many of ${file}'s checks did not pass. Compared against the build you were happy with, so a suite that ` +
1317
+ `was already red is not news.`,
1318
+ where: { file },
1319
+ }));
1320
+
1321
+ return out;
1322
+ }
1323
+
1324
+ /**
1325
+ * A file's contents in one short string, or a plain sentence saying it is not there.
1326
+ *
1327
+ * @param {string} file
1328
+ * @returns {Promise<string>}
1329
+ */
1330
+ async function fingerprintOf(file) {
1331
+ try {
1332
+ const bytes = await fsp.readFile(file);
1333
+ return `${crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 16)} (${bytes.length} bytes)`;
1334
+ } catch (error) {
1335
+ // "It is not here" and "it is here and would not open" are different facts, and one
1336
+ // sentence for both means a permissions problem reads as a deleted test file.
1337
+ if (isGone(error)) return 'there is no such file in this build';
1338
+ return `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`;
1339
+ }
1340
+ }
1341
+
707
1342
  /** @param {string} text */
708
1343
  function shellQuote(text) {
709
1344
  return `'${text.split("'").join(`'\\''`)}'`;
@@ -739,6 +1374,15 @@ function sanitise(name) {
739
1374
  * @param {WatchedEvents} input.watched
740
1375
  * @param {import('./contract.js').RunContext} input.ctx
741
1376
  * @param {{dirs: string[], projectRoot?: string, ports?: number[]}} input.footprint
1377
+ * @param {string[]} [input.skipped] Folders this run did not watch, so it can say so.
1378
+ * @param {(text: string) => string} [input.quieten]
1379
+ * Applied to what the program printed, after our own footprint is rubbed out and before
1380
+ * anything is compared. It exists for one narrow case and has to stay narrow: a harness the
1381
+ * journey itself started - a test runner - that narrates its own stopwatch into the output.
1382
+ * That is the harness talking about the machine, not the product talking about itself, and
1383
+ * it is the same reason durations are never compared anywhere else in here. It is NOT for
1384
+ * the product's own volatile output; that is the noise-control layer's job, where the rules
1385
+ * live in the project's git and a person can see and argue with them.
742
1386
  * @returns {Promise<import('./contract.js').Observation[]>}
743
1387
  */
744
1388
  export async function describeRun(input) {
@@ -752,7 +1396,8 @@ export async function describeRun(input) {
752
1396
  ['to the screen', result.stdout, 'results', 'printed to the screen', 'printed nothing at all'],
753
1397
  ['as a complaint', result.stderr, 'complaints', 'complained about', 'complained about nothing'],
754
1398
  ])) {
755
- const text = undoOurFootprint(raw, footprint);
1399
+ const plain = undoOurFootprint(raw, footprint);
1400
+ const text = input.quieten ? input.quieten(plain) : plain;
756
1401
  const kept = trimForStorage(text);
757
1402
  let evidence;
758
1403
  if (kept.truncated) {
@@ -783,6 +1428,17 @@ export async function describeRun(input) {
783
1428
  }
784
1429
 
785
1430
  // ---- how it finished
1431
+ if (result.couldNotStart) {
1432
+ out.push(notCovered({
1433
+ channel: 'complaints',
1434
+ path: joinPath('cli', id, 'ran at all'),
1435
+ reason: 'crashed',
1436
+ says:
1437
+ `"${journey.describe}" never started: ${result.couldNotStart}. Nothing about the product was observed here, ` +
1438
+ `and a command that fails to start fails the same way on both builds — so without this line the comparison ` +
1439
+ `would have found no difference and called it clean.`,
1440
+ }));
1441
+ }
786
1442
  out.push(observation({
787
1443
  channel: 'complaints',
788
1444
  path: joinPath('cli', id, 'exit'),
@@ -808,6 +1464,54 @@ export async function describeRun(input) {
808
1464
  : `"${journey.describe}" ${change.what} ${change.file}. Only the contents are compared, so rewriting the same bytes is not a change.`,
809
1465
  }));
810
1466
  }
1467
+ // ---- and the three ways looking at files can come up short. All of them used to be
1468
+ // silent, and a silence here is indistinguishable from "nothing changed", which is the one
1469
+ // shape of wrong answer this whole tool exists to prevent.
1470
+ const bySize = [...input.after].filter(([, mark]) => mark.startsWith(BY_SIZE_ALONE)).map(([file]) => file).sort();
1471
+ if (bySize.length > 0) {
1472
+ out.push(observation({
1473
+ channel: 'effects',
1474
+ path: joinPath('file', id, 'compared by size alone'),
1475
+ value: bySize,
1476
+ says:
1477
+ `${bySize.length} file${bySize.length === 1 ? ' was' : 's were'} too big to read through, so ${bySize.length === 1 ? 'it was' : 'they were'} ` +
1478
+ `compared by size rather than by contents: ${bySize.join(', ')}. A rewrite of the same rough size would NOT be seen. ` +
1479
+ `This is a hole in what was checked, not a pass.`,
1480
+ covered: false,
1481
+ reason: 'too big',
1482
+ }));
1483
+ }
1484
+ const unreadable = [...new Map([...input.before, ...input.after])]
1485
+ .filter(([, mark]) => mark.startsWith(COULD_NOT_READ))
1486
+ .map(([file, mark]) => `${file} (${mark.slice(COULD_NOT_READ.length)})`)
1487
+ .sort();
1488
+ if (unreadable.length > 0) {
1489
+ out.push(observation({
1490
+ channel: 'effects',
1491
+ path: joinPath('file', id, 'could not be looked at'),
1492
+ value: unreadable,
1493
+ says:
1494
+ `${unreadable.length} place${unreadable.length === 1 ? '' : 's'} in the scratch copy could not be read, so anything written ` +
1495
+ `there — and anything underneath, for a folder — was not seen: ${unreadable.join(', ')}. This is a hole, not a pass.`,
1496
+ covered: false,
1497
+ reason: 'refused',
1498
+ }));
1499
+ }
1500
+ if (input.skipped && input.skipped.length > 0) {
1501
+ out.push(observation({
1502
+ channel: 'effects',
1503
+ path: joinPath('file', id, 'folders left unwatched'),
1504
+ value: input.skipped,
1505
+ says:
1506
+ `Files written into ${input.skipped.join(', ')} were not watched. ` +
1507
+ `node_modules is the one that costs something: a build step that patches a dependency, generates a client into it, ` +
1508
+ `or rebuilds a native module changes what ships and is not seen here. It is left out because fingerprinting it twice ` +
1509
+ `per run makes a check nobody waits for. Name it under "process.alsoWatch" in the settings to watch it anyway.`,
1510
+ covered: false,
1511
+ reason: 'too big',
1512
+ }));
1513
+ }
1514
+
811
1515
  out.push(observation({
812
1516
  channel: 'counters',
813
1517
  path: joinPath('count', id, 'files touched'),
@@ -843,6 +1547,18 @@ export async function describeRun(input) {
843
1547
  reason: 'irreversible',
844
1548
  }));
845
1549
  }
1550
+ if (watched.torn > 0) {
1551
+ out.push(observation({
1552
+ channel: 'effects',
1553
+ path: joinPath('proc', id, 'events that could not be read back'),
1554
+ value: watched.torn,
1555
+ says:
1556
+ `${watched.torn} line${watched.torn === 1 ? '' : 's'} of what the watcher wrote could not be read back, so ` +
1557
+ `that many programs started or connections attempted are missing from this run. This is a hole, not a pass.`,
1558
+ covered: false,
1559
+ reason: 'crashed',
1560
+ }));
1561
+ }
846
1562
  if (watched.settingsRead.length > 0) {
847
1563
  out.push(observation({
848
1564
  channel: 'effects',
@@ -890,8 +1606,12 @@ export async function describeRun(input) {
890
1606
  export function apiSurface(journey, result) {
891
1607
  /** @type {Record<string, string>} */
892
1608
  let surface;
1609
+ const at = result.stdout.lastIndexOf(EXPORTS_MARKER);
893
1610
  try {
894
- surface = JSON.parse(result.stdout);
1611
+ // Only what comes after the marker. Anything the module printed while importing sits in
1612
+ // front of it and is compared under "printed", where it belongs.
1613
+ surface = JSON.parse(at === -1 ? result.stdout : result.stdout.slice(at + EXPORTS_MARKER.length));
1614
+ if (surface === null || typeof surface !== 'object' || Array.isArray(surface)) throw new Error('not a list of names');
895
1615
  } catch {
896
1616
  return [notCovered({
897
1617
  channel: 'results',
@@ -917,14 +1637,6 @@ export function apiSurface(journey, result) {
917
1637
  return out;
918
1638
  }
919
1639
 
920
- /**
921
- * A scratch folder under the system temp directory, for callers that do not have one.
922
- * @param {string} [label]
923
- */
924
- export async function scratchFolder(label = 'staysfixed') {
925
- return fsp.mkdtemp(path.join(os.tmpdir(), `${label}-`));
926
- }
927
-
928
1640
  /** True when a path exists. Small enough to inline, useful enough to name. */
929
1641
  export function exists(/** @type {string} */ file) {
930
1642
  return fs.existsSync(file);