staysfixed 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/README.md +191 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  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 +549 -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/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +285 -45
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. 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,16 +153,30 @@ 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.",
@@ -177,9 +194,27 @@ export function watcherScript(opts) {
177
194
  "const settingsRead = new Set();",
178
195
  "try {",
179
196
  " const real = process.env;",
197
+ " const note = (key) => { if (typeof key === 'string') settingsRead.add(key); };",
198
+ " // EVERY trap forwards, and every trap that changes something forwards with the TARGET",
199
+ " // as the receiver. A proxy carrying only the traps we happen to care about is not a",
200
+ " // window, it is a wall with a window in it. With no `set` trap, `process.env.X = 'y'`",
201
+ " // takes the default, which reflects onto the PROXY, which lands on defineProperty",
202
+ " // against Node's own env object and quietly does nothing at all. npm sets",
203
+ " // npm_lifecycle_event and its npm_config_ family that way, reads them back, finds",
204
+ " // nothing and exits 1 without printing one word - so every product whose start command",
205
+ " // went through npm died here, and the report said, in good faith, that the product",
206
+ " // would not boot. `staysfixed init` writes `npm run start` by default, so this was the",
207
+ " // default path. Found by installing the published copy and pointing it at an ordinary",
208
+ " // Express app.",
180
209
  " 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; },",
210
+ " get(target, key) { note(key); return Reflect.get(target, key); },",
211
+ " has(target, key) { note(key); return Reflect.has(target, key); },",
212
+ " set(target, key, value) { return Reflect.set(target, key, value); },",
213
+ " deleteProperty(target, key) { return Reflect.deleteProperty(target, key); },",
214
+ " ownKeys(target) { return Reflect.ownKeys(target); },",
215
+ " getOwnPropertyDescriptor(target, key) { return Reflect.getOwnPropertyDescriptor(target, key); },",
216
+ " defineProperty(target, key, descriptor) { return Reflect.defineProperty(target, key, descriptor); },",
217
+ " getPrototypeOf(target) { return Reflect.getPrototypeOf(target); },",
183
218
  " });",
184
219
  " Object.defineProperty(process, 'env', { value: watched, configurable: true, writable: true });",
185
220
  "} catch { /* some hosts freeze this; the other channels still work */ }",
@@ -197,6 +232,7 @@ export function watcherScript(opts) {
197
232
  * @property {Map<string, number>} ran Command as written, and how many times.
198
233
  * @property {Array<{host: string, port: number|null}>} reachedOut
199
234
  * @property {string[]} settingsRead
235
+ * @property {number} torn Lines of the report that could not be read back.
200
236
  */
201
237
 
202
238
  /**
@@ -207,7 +243,7 @@ export function watcherScript(opts) {
207
243
  */
208
244
  export async function readWatcher(reportFile) {
209
245
  /** @type {WatchedEvents} */
210
- const seen = { inForce: false, ran: new Map(), reachedOut: [], settingsRead: [] };
246
+ const seen = { inForce: false, ran: new Map(), reachedOut: [], settingsRead: [], torn: 0 };
211
247
  let text;
212
248
  try {
213
249
  text = await fsp.readFile(reportFile, 'utf8');
@@ -218,7 +254,9 @@ export async function readWatcher(reportFile) {
218
254
  for (const line of text.split('\n')) {
219
255
  if (line.trim() === '') continue;
220
256
  let event;
221
- try { event = JSON.parse(line); } catch { continue; }
257
+ // A half-written line is a program that started or a host that was reached and is now
258
+ // reported as neither. Counted, so the run can say it saw less than it saw.
259
+ try { event = JSON.parse(line); } catch { seen.torn += 1; continue; }
222
260
  if (event.kind === 'ran') {
223
261
  const command = String(event.what?.command ?? '');
224
262
  seen.ran.set(command, (seen.ran.get(command) ?? 0) + 1);
@@ -243,25 +281,111 @@ export async function readWatcher(reportFile) {
243
281
 
244
282
  /** @typedef {Map<string, string>} TreeSnapshot relative path -> fingerprint of its contents */
245
283
 
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']);
284
+ /**
285
+ * Folders left out of a snapshot: enormous, and not what anybody means by "it wrote a file".
286
+ *
287
+ * `node_modules` is the one that had to be argued out rather than assumed. A build step that
288
+ * writes in there — a patch, a generated client, a native rebuild — is a real change to what
289
+ * ships, and leaving it out means that change is invisible. It stays out anyway, because
290
+ * fingerprinting thirty thousand files twice per journey per build turns a check that takes
291
+ * seconds into one that takes minutes, and a check nobody runs catches nothing.
292
+ *
293
+ * What is NOT acceptable is skipping it quietly. Every run says which folders it did not
294
+ * watch, as missing coverage rather than as a clean result, and `process.alsoWatch` takes any
295
+ * of these back off the list for a project that needs it. See `snapshotSkip`.
296
+ */
297
+ export const SNAPSHOT_SKIP = new Set(['node_modules', '.git', '.staysfixed']);
298
+
299
+ /**
300
+ * The folders this run will not watch, after the project has had its say.
301
+ *
302
+ * @param {Record<string, unknown>|undefined} config The `process` section of the settings.
303
+ * @returns {Set<string>}
304
+ */
305
+ export function snapshotSkip(config) {
306
+ const skip = new Set(SNAPSHOT_SKIP);
307
+ const alsoWatch = Array.isArray(config?.alsoWatch) ? config.alsoWatch : [];
308
+ for (const name of alsoWatch) skip.delete(String(name));
309
+ return skip;
310
+ }
311
+
312
+ /** A file recorded by its size because hashing it would have meant reading past the ceiling. */
313
+ export const BY_SIZE_ALONE = 'compared by size alone, ';
314
+
315
+ /** A file or folder nothing could read. Kept in the snapshot so it is never a silence. */
316
+ export const COULD_NOT_READ = 'could not be looked at: ';
317
+
318
+ /**
319
+ * Why a path would not open, in words rather than in an errno.
320
+ * @param {unknown} error
321
+ * @returns {string}
322
+ */
323
+ function whyItWouldNotOpen(error) {
324
+ const code = String(/** @type {{code?: unknown}} */ (error)?.code ?? '');
325
+ if (code === 'EACCES' || code === 'EPERM') return 'no permission to read it';
326
+ if (code === 'EIO') return 'the disk would not answer';
327
+ if (code === 'ELOOP') return 'the symlinks point at each other';
328
+ if (code === 'EMFILE' || code === 'ENFILE') return 'this machine ran out of open files';
329
+ if (code === 'ENAMETOOLONG') return 'the name is longer than this machine allows';
330
+ if (code !== '') return code;
331
+ return error instanceof Error ? error.message : String(error);
332
+ }
333
+
334
+ /** A path that is simply not there any more was not there to begin with. */
335
+ const isGone = (/** @type {unknown} */ error) => {
336
+ const code = String(/** @type {{code?: unknown}} */ (error)?.code ?? '');
337
+ return code === 'ENOENT' || code === 'ENOTDIR';
338
+ };
339
+
340
+ /**
341
+ * Fingerprint one file that is bigger than the read-it-all-at-once limit.
342
+ *
343
+ * Streamed rather than read into memory, so the size of the file is the machine's problem and
344
+ * not this process's. There is still a ceiling, because a file measured in tens of gigabytes
345
+ * would be read twice per journey per build and nobody would wait for it — and above that
346
+ * ceiling the answer is the size bucket, with the marker that says so, so the run can report
347
+ * it as a hole instead of as a match.
348
+ *
349
+ * @param {string} file
350
+ * @param {number} size
351
+ * @param {number} ceilingBytes
352
+ * @returns {Promise<string>}
353
+ */
354
+ async function fingerprintBigFile(file, size, ceilingBytes) {
355
+ if (size > ceilingBytes) return `${BY_SIZE_ALONE}${sizeBucket(size)}`;
356
+ const hash = crypto.createHash('sha256');
357
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
358
+ return hash.digest('hex').slice(0, 16);
359
+ }
248
360
 
249
361
  /**
250
362
  * Fingerprint every file under a folder.
251
363
  *
252
364
  * By CONTENTS, never by timestamp or size. A run that rewrites a file with the same bytes
253
365
  * 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.
366
+ * ignore it.
367
+ *
368
+ * A big file is streamed rather than bucketed. It used to be recorded as "too big to
369
+ * fingerprint, tens of megabytes", which meant a build that wrote a COMPLETELY DIFFERENT
370
+ * forty-megabyte bundle compared equal to the old one as long as the size landed in the same
371
+ * bucket — the exact file a bundler rewrites, silently passing. Reading a large file is cheap
372
+ * next to running the whole product twice, so it is read.
373
+ *
374
+ * Anything that cannot be read at all — a folder with no permission on it, a disk that will
375
+ * not answer — goes into the snapshot as its own entry rather than being dropped. Dropping a
376
+ * folder takes everything under it with it, and a file nobody looked at cannot be seen
377
+ * changing; the run reports those as holes.
256
378
  *
257
379
  * @param {string} root
258
380
  * @param {object} [opts]
259
- * @param {number} [opts.maxBytes] Hash files up to this size. Default 8MB.
381
+ * @param {number} [opts.maxBytes] Read files up to this size in one go. Default 8MB.
382
+ * @param {number} [opts.ceilingBytes] Above this, record the size instead of hashing. Default 4GB.
260
383
  * @param {Set<string>} [opts.skip]
261
384
  * @returns {Promise<TreeSnapshot>}
262
385
  */
263
386
  export async function snapshotTree(root, opts = {}) {
264
387
  const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024;
388
+ const ceilingBytes = opts.ceilingBytes ?? 4 * 1024 * 1024 * 1024;
265
389
  const skip = opts.skip ?? SNAPSHOT_SKIP;
266
390
  /** @type {TreeSnapshot} */
267
391
  const snapshot = new Map();
@@ -270,7 +394,19 @@ export async function snapshotTree(root, opts = {}) {
270
394
  const walk = async (dir) => {
271
395
  /** @type {import('node:fs').Dirent[]} */
272
396
  let entries;
273
- try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
397
+ try {
398
+ entries = await fsp.readdir(dir, { withFileTypes: true });
399
+ } catch (error) {
400
+ // A folder that will not open used to be dropped here without a word, and everything
401
+ // under it with it — so a file inside it could be created, rewritten or deleted and the
402
+ // run would report the folder as unchanged. It goes in the snapshot instead, and
403
+ // `describeRun` reports it as a hole.
404
+ if (!isGone(error)) {
405
+ const at = path.relative(root, dir);
406
+ snapshot.set(at === '' ? '.' : at, `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`);
407
+ }
408
+ return;
409
+ }
274
410
  for (const entry of entries) {
275
411
  const full = path.join(dir, entry.name);
276
412
  if (entry.isDirectory()) {
@@ -285,12 +421,15 @@ export async function snapshotTree(root, opts = {}) {
285
421
  if (!entry.isFile()) continue;
286
422
  try {
287
423
  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 */ }
424
+ snapshot.set(relative, stat.size > maxBytes
425
+ ? await fingerprintBigFile(full, stat.size, ceilingBytes)
426
+ : crypto.createHash('sha256').update(await fsp.readFile(full)).digest('hex').slice(0, 16));
427
+ } catch (error) {
428
+ // A file that vanished between the listing and the read was not there to begin with.
429
+ // Anything else is a file nobody looked at, and staying quiet about it reads exactly
430
+ // like "it did not change".
431
+ if (!isGone(error)) snapshot.set(relative, `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`);
432
+ }
294
433
  }
295
434
  };
296
435
 
@@ -337,6 +476,10 @@ export function compareTrees(before, after) {
337
476
  * @property {string|null} signal Set when it was killed rather than finishing.
338
477
  * @property {boolean} timedOut
339
478
  * @property {number} ms
479
+ * @property {string} [couldNotStart] Why the command never ran at all. A missing exit code
480
+ * means two different things without this — killed, or
481
+ * never started — and the second one compares equal on
482
+ * both builds, which reads exactly like a clean run.
340
483
  */
341
484
 
342
485
  /**
@@ -377,6 +520,9 @@ export function runCommand(command, opts) {
377
520
  if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
378
521
  else child.stdin?.end();
379
522
 
523
+ /** @type {string|undefined} */
524
+ let couldNotStart;
525
+
380
526
  const finish = (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
381
527
  if (settled) return;
382
528
  settled = true;
@@ -390,6 +536,7 @@ export function runCommand(command, opts) {
390
536
  signal,
391
537
  timedOut,
392
538
  ms: Date.now() - started,
539
+ ...(couldNotStart ? { couldNotStart } : {}),
393
540
  });
394
541
  };
395
542
 
@@ -405,6 +552,10 @@ export function runCommand(command, opts) {
405
552
  opts.signal?.addEventListener('abort', onAbort, { once: true });
406
553
 
407
554
  child.on('error', (error) => {
555
+ // Nothing ran. Said out loud rather than folded into "exit code null", which is what a
556
+ // killed run also looks like — and which is identical on both builds, so the comparison
557
+ // saw no difference and the run passed for the worst possible reason.
558
+ couldNotStart = error.message;
408
559
  err.push(Buffer.from(`${error.message}\n`));
409
560
  finish(null, null);
410
561
  });
@@ -416,41 +567,161 @@ export function runCommand(command, opts) {
416
567
  // Making the scratch copy
417
568
  // ---------------------------------------------------------------------------
418
569
 
570
+ /**
571
+ * Folders not worth copying into a scratch build.
572
+ *
573
+ * The bar for this list is deliberately high: anything skipped that turns out to matter
574
+ * produces a run that passes for the wrong reason, and a false pass is the one failure
575
+ * this whole tool exists to prevent. So it holds only things that are *regenerated on
576
+ * demand and read by nothing* — caches and coverage reports — plus the two that are ours
577
+ * and git's. Build output, `node_modules`, lockfiles, fixtures and configuration are all
578
+ * copied, because a check that runs against a different set of files than the real
579
+ * product is not checking the real product.
580
+ */
581
+ export const SKIP_BY_DEFAULT = [
582
+ '.git',
583
+ '.staysfixed',
584
+ '.turbo',
585
+ '.nyc_output',
586
+ 'coverage',
587
+ '.pytest_cache',
588
+ '__pycache__',
589
+ '.DS_Store',
590
+ ];
591
+
419
592
  /**
420
593
  * Copy a project into a scratch folder so a run can write whatever it likes.
421
594
  *
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.
595
+ * ## Why this is a clone and not a copy
596
+ *
597
+ * The real projects this gets pointed at are enormous the one it was built against is
598
+ * twelve gigabytes, most of it an iOS build folder. Copying that byte by byte before every
599
+ * single run would take minutes and fill a disk, and a check nobody can afford to run is
600
+ * a check nobody runs.
601
+ *
602
+ * So it asks the filesystem to *clone* instead: on macOS that is `cp -c`, one APFS call
603
+ * per file that copies no bytes at all and shares the blocks until something writes to
604
+ * them; on Linux it is `cp --reflink=auto`, which does the same where the filesystem
605
+ * supports it and a real copy where it does not. Measured on the twelve-gigabyte project:
606
+ * the six-hundred-megabyte `node_modules` alone went from a long wait to under three
607
+ * seconds, and used no extra disk.
608
+ *
609
+ * Never a symlink and never a hardlink. Both of those point back at the real project,
610
+ * which is the one thing this function exists to protect — the first thing a broken build
611
+ * does is write to a file, and with a link that write lands in his actual working tree.
612
+ *
613
+ * It falls back to a plain recursive copy whenever the clone is unavailable or fails, so
614
+ * a filesystem without reflinks is slower here and never wrong.
427
615
  *
428
616
  * @param {string} from
429
617
  * @param {string} to
430
618
  * @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}>}
619
+ * @param {string[]} [opts.skip] Names not to copy. See `SKIP_BY_DEFAULT`.
620
+ * @param {string[]} [opts.also] Extra names to skip, on top of the defaults.
621
+ * @param {AbortSignal} [opts.signal]
622
+ * @returns {Promise<{copied: boolean, why: string, cloned: boolean, tookMs: number, skipped: string[]}>}
434
623
  */
435
624
  export async function copyForScratch(from, to, opts = {}) {
436
- const skip = new Set(opts.skip ?? ['.git', '.staysfixed']);
625
+ const began = Date.now();
626
+ const skip = new Set([...(opts.skip ?? SKIP_BY_DEFAULT), ...(opts.also ?? [])]);
437
627
  await fsp.mkdir(to, { recursive: true });
628
+
629
+ /** @type {import('node:fs').Dirent[]} */
630
+ let entries;
438
631
  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.` };
632
+ entries = await fsp.readdir(from, { withFileTypes: true });
451
633
  } catch (error) {
452
- return { copied: false, why: `The project could not be copied into a scratch folder: ${error instanceof Error ? error.message : String(error)}` };
634
+ return {
635
+ copied: false, cloned: false, tookMs: Date.now() - began, skipped: [],
636
+ why: `The project could not be read: ${error instanceof Error ? error.message : String(error)}`,
637
+ };
453
638
  }
639
+
640
+ const skipped = entries.filter((e) => skip.has(e.name)).map((e) => e.name);
641
+ const wanted = entries.filter((e) => !skip.has(e.name));
642
+
643
+ let cloned = 0;
644
+ let copied = 0;
645
+ for (const entry of wanted) {
646
+ const source = path.join(from, entry.name);
647
+ const target = path.join(to, entry.name);
648
+ if (await cloneOne(source, target, opts.signal)) {
649
+ cloned += 1;
650
+ continue;
651
+ }
652
+ try {
653
+ await fsp.cp(source, target, {
654
+ recursive: true,
655
+ force: true,
656
+ dereference: false,
657
+ preserveTimestamps: true,
658
+ filter: (p) => !skip.has(path.basename(p)),
659
+ });
660
+ copied += 1;
661
+ } catch (error) {
662
+ return {
663
+ copied: false, cloned: cloned > 0, tookMs: Date.now() - began, skipped,
664
+ why: `The project could not be copied into a scratch folder: ${error instanceof Error ? error.message : String(error)}`,
665
+ };
666
+ }
667
+ }
668
+
669
+ const tookMs = Date.now() - began;
670
+ const how = cloned > 0 && copied === 0
671
+ ? 'The project was cloned into a scratch folder — the filesystem shared the blocks, so no bytes moved'
672
+ : cloned > 0
673
+ ? 'The project was cloned into a scratch folder where the filesystem allowed it and copied where it did not'
674
+ : 'The project was copied into a scratch folder';
675
+ const left = skipped.length ? ` Left behind: ${skipped.join(', ')}.` : '';
676
+ return {
677
+ copied: true,
678
+ cloned: cloned > 0,
679
+ tookMs,
680
+ skipped,
681
+ why: `${how} (${(tookMs / 1000).toFixed(1)}s), so the run can write anywhere it likes without touching the real one.${left}`,
682
+ };
683
+ }
684
+
685
+ /**
686
+ * Ask the filesystem to clone one entry. False means "it would not", not "it broke".
687
+ *
688
+ * @param {string} source
689
+ * @param {string} target
690
+ * @param {AbortSignal} [signal]
691
+ * @returns {Promise<boolean>}
692
+ */
693
+ function cloneOne(source, target, signal) {
694
+ // Windows has no reflink through `cp`, and there is no `cp`. Straight to the fallback.
695
+ if (process.platform === 'win32') return Promise.resolve(false);
696
+ const args = process.platform === 'darwin'
697
+ ? ['-Rc', source, target]
698
+ : ['-a', '--reflink=auto', source, target];
699
+ return new Promise((resolve) => {
700
+ let settled = false;
701
+ /** @param {boolean} ok */
702
+ const done = (ok) => {
703
+ if (settled) return;
704
+ settled = true;
705
+ resolve(ok);
706
+ };
707
+ let child;
708
+ try {
709
+ child = spawn('cp', args, { stdio: 'ignore', signal });
710
+ } catch {
711
+ done(false);
712
+ return;
713
+ }
714
+ child.on('error', () => done(false));
715
+ child.on('close', (code) => {
716
+ if (code === 0) {
717
+ done(true);
718
+ return;
719
+ }
720
+ // A half-written target from a failed clone would make the fallback copy merge into
721
+ // it. Clear it out so the fallback starts from nothing.
722
+ fsp.rm(target, { recursive: true, force: true }).then(() => done(false), () => done(false));
723
+ });
724
+ });
454
725
  }
455
726
 
456
727
  // ---------------------------------------------------------------------------
@@ -471,6 +742,73 @@ export async function copyForScratch(from, to, opts = {}) {
471
742
  * @property {string} module A path inside the project, or a package entry name.
472
743
  */
473
744
 
745
+ /**
746
+ * The command a run line actually opens, named the way the code reader names it.
747
+ *
748
+ * These two lists only ever meet here. A command door is read out of package.json and comes
749
+ * out as either `staysfixed` (something the package installs) or `npm run build` (a script),
750
+ * while a journey is named by whoever wrote the settings — "build the app". So a journey
751
+ * carrying nothing but its own name matched no door at all.
752
+ *
753
+ * `pnpm run build` opens the same door as `npm run build`, because the door is the script in
754
+ * package.json and the package manager standing in front of it is not a second door.
755
+ *
756
+ * A line this cannot read plainly — a pipeline, a shell one-liner, anything with an operator
757
+ * in it — gets null instead of a guess. Missing a walked command leaves a job on the queue;
758
+ * naming the wrong one marks a door walked that nobody touched, and that is the single
759
+ * direction the coverage ledger is never allowed to be wrong in.
760
+ *
761
+ * @param {string} run The command line, exactly as the settings wrote it.
762
+ * @returns {string|null}
763
+ */
764
+ export function commandDoorName(run) {
765
+ const line = String(run ?? '').trim();
766
+ if (line === '') return null;
767
+ // An operator means the line runs more than one thing, and this cannot say which of them
768
+ // the door is.
769
+ if (/[|;&<>`$]/.test(line)) return null;
770
+ const words = line.split(/\s+/);
771
+ // FOO=bar in front of a command is the environment it runs in, not the command.
772
+ while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
773
+ // A runner in front fetches or finds the real command and then runs it; the door is what
774
+ // comes after it.
775
+ while (words.length > 1 && (/^(npx|bunx)$/.test(words[0]) || (/^(pnpm|yarn|bun|npm)$/.test(words[0]) && /^(exec|dlx)$/.test(words[1])))) {
776
+ words.splice(0, words[0] === 'npx' || words[0] === 'bunx' ? 1 : 2);
777
+ }
778
+ // Flags to the runner itself — `npx --yes staysfixed` — belong to the runner.
779
+ while (words.length > 1 && words[0].startsWith('-')) words.shift();
780
+ if (words.length === 0) return null;
781
+ const program = path.basename(words[0]);
782
+ const rest = words.slice(1).filter((w) => !w.startsWith('-'));
783
+ if (/^(npm|pnpm|yarn|bun)$/.test(program)) {
784
+ if (words[1] === 'run' && rest[1]) return `npm run ${rest[1]}`;
785
+ // npm's own shorthands for four scripts. `yarn <anything>` is deliberately not read this
786
+ // way: with yarn a bare word may be a script or a command, and this cannot tell.
787
+ if (program !== 'yarn' && rest[0] && /^(test|start|stop|restart)$/.test(rest[0])) return `npm run ${rest[0]}`;
788
+ return null;
789
+ }
790
+ // An interpreter with something after it is running that something, and the door is whatever
791
+ // that file installs as — which this cannot know. Null, rather than reporting a door called
792
+ // "node" that the code reader never found.
793
+ if (words.length > 1 && /^(node|deno|sh|bash|zsh|dash|env|python|python3|ruby|perl)$/.test(program)) return null;
794
+ return program;
795
+ }
796
+
797
+ /**
798
+ * The door fields a command journey's step carries, or nothing when the command line is not
799
+ * plain enough to name one honestly. `door` in the settings overrides the reading, which is
800
+ * the way out for a project whose command line this cannot make sense of.
801
+ *
802
+ * @param {Record<string, unknown>} entry
803
+ * @returns {{door: string, kind: 'command'}|{}}
804
+ */
805
+ function doorFields(entry) {
806
+ const named = typeof entry.door === 'string' && entry.door.trim() !== ''
807
+ ? entry.door.trim()
808
+ : commandDoorName(String(entry.run ?? ''));
809
+ return named ? { door: named, kind: /** @type {const} */ ('command') } : {};
810
+ }
811
+
474
812
  /** Everything a prepared build needs to remember between journeys. */
475
813
  const prepared = new Map();
476
814
 
@@ -481,7 +819,7 @@ export const processAdapter = defineAdapter({
481
819
  name: 'process',
482
820
  title: 'CLI tools and libraries',
483
821
  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.',
822
+ '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
823
  channels: ['results', 'complaints', 'effects', 'counters'],
486
824
 
487
825
  /** @param {import('./contract.js').AdapterProject} project */
@@ -535,7 +873,17 @@ export const processAdapter = defineAdapter({
535
873
  surface: 'cli',
536
874
  from: 'the project config',
537
875
  channels: ['results', 'complaints', 'effects', 'counters'],
538
- steps: [{ act: 'run', run: String(entry.run), cwd: entry.cwd, stdin: entry.stdin, env: entry.env }],
876
+ // `door` and `kind` are how the coverage ledger learns this journey ran that command.
877
+ // Without them a command counted as walked only if an observation landed at its own
878
+ // address, and this adapter writes everything under `cli.<journey name>` — so
879
+ // {"name": "build the app", "run": "npm run build"} produced `cli.build the app.*`
880
+ // against a door addressed `cli.npm run build`, and every command in the project read
881
+ // as never walked on a run that had just walked all of them. The address rule cannot
882
+ // rescue this one: it is switched off for commands on purpose.
883
+ steps: [{
884
+ act: 'run', run: String(entry.run), cwd: entry.cwd, stdin: entry.stdin, env: entry.env,
885
+ ...doorFields(entry),
886
+ }],
539
887
  irreversible: entry.irreversible === true,
540
888
  timeoutMs: entry.timeoutMs,
541
889
  });
@@ -548,6 +896,11 @@ export const processAdapter = defineAdapter({
548
896
  surface: 'library',
549
897
  from: 'the project config',
550
898
  channels: ['results', 'complaints', 'effects', 'counters'],
899
+ // No `door` here on purpose, and it was checked rather than assumed: `apiSurface`
900
+ // writes every exported name at `export.<journey name>.<name>`, which is exactly the
901
+ // branch the ledger already reads exports through, so these doors open on their own.
902
+ // Naming the module as the door instead would claim a door of that name that the code
903
+ // reader never found.
551
904
  steps: [{ act: 'import', module: String(entry.module) }],
552
905
  timeoutMs: entry.timeoutMs,
553
906
  });
@@ -567,7 +920,17 @@ export const processAdapter = defineAdapter({
567
920
  await fsp.mkdir(home, { recursive: true });
568
921
  await fsp.mkdir(tmp, { recursive: true });
569
922
 
570
- const copy = await copyForScratch(build.root, work);
923
+ // A project may name more to leave behind — a giant build folder no command reads,
924
+ // say. It can only ADD to the defaults: a setting that could switch off `.git` being
925
+ // skipped would only ever make runs slower.
926
+ const alsoSkip = Array.isArray(ctx.config?.skip) ? ctx.config.skip.map(String) : [];
927
+ const copy = await copyForScratch(build.root, work, { also: alsoSkip, signal: ctx.signal });
928
+ if (copy.copied && copy.tookMs > 20_000) {
929
+ ctx.log?.(
930
+ `Making a scratch copy of this project took ${Math.round(copy.tookMs / 1000)} seconds. ` +
931
+ `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.`,
932
+ );
933
+ }
571
934
  if (!copy.copied) {
572
935
  return {
573
936
  build, root: work, ready: false, why: copy.why,
@@ -636,15 +999,23 @@ export const processAdapter = defineAdapter({
636
999
  extra: {
637
1000
  ...step.env,
638
1001
  // `--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(),
1002
+ // rather than assigned so a project that needs its own options keeps them — and the
1003
+ // journey's own NODE_OPTIONS is the one that has to survive, which it did not: this
1004
+ // line spread `step.env` and then overwrote it with the OUTER machine's value, so a
1005
+ // journey that asked for `--max-old-space-size` got the comment's promise and none of
1006
+ // its behaviour.
1007
+ NODE_OPTIONS: `${step.env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? ''} --import ${pathToUrl(places.watcher)}`.trim(),
641
1008
  },
642
1009
  });
643
1010
 
644
1011
  // A step that names nothing to run must say so. Handing `undefined` to a shell runs a
645
1012
  // command called "undefined", which fails identically on both builds and therefore
646
1013
  // reports NO difference - a silent nothing that looks exactly like a clean check.
647
- const nothingToRun = step.act === 'import' ? !step.module : !step.run;
1014
+ const nothingToRun = step.act === 'import'
1015
+ ? !step.module
1016
+ : step.act === 'run-tests'
1017
+ ? !step.file || !(step.command || step.run)
1018
+ : !step.run;
648
1019
  if (nothingToRun) {
649
1020
  return [notCovered({
650
1021
  channel: 'results',
@@ -652,22 +1023,53 @@ export const processAdapter = defineAdapter({
652
1023
  reason: 'refused',
653
1024
  says:
654
1025
  `"${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.`,
1026
+ `an import journey needs a "module", and a test-file journey needs the "file" it walks and the command ` +
1027
+ `that runs it. Nothing was run, and that is a hole, not a pass.`,
656
1028
  })];
657
1029
  }
658
1030
 
659
- const before = await snapshotTree(places.work);
1031
+ const runner = /** @type {import('../journeys/from-suite.js').Runner} */ (step.runner ?? 'node:test');
1032
+ // The same list for both snapshots, and handed on to the report: a folder that is not
1033
+ // watched has to be named in the run that did not watch it, not left to be discovered.
1034
+ const skip = snapshotSkip(ctx.config);
1035
+ const before = await snapshotTree(places.work, { skip });
660
1036
  const result = step.act === 'import'
661
1037
  ? 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);
1038
+ : step.act === 'run-tests'
1039
+ ? await runCommand(testFileCommand(step), { cwd, env, timeoutMs: journey.timeoutMs ?? 120000, signal: ctx.signal })
1040
+ : await runCommand(String(step.run), { cwd, env, timeoutMs: journey.timeoutMs ?? 120000, stdin: step.stdin, signal: ctx.signal });
1041
+ const after = await snapshotTree(places.work, { skip });
664
1042
  const watched = await readWatcher(reportFile);
665
1043
 
666
1044
  const observations = await describeRun({
667
- journey, result, before, after, watched, ctx,
1045
+ journey, result, before, after, watched, ctx, skipped: [...skip].sort(),
668
1046
  footprint: { dirs: [places.base, places.tmp, places.home], projectRoot: build.build.root },
1047
+ // A test runner narrates its own stopwatch and nothing else moves between two runs of
1048
+ // identical bytes, so taking the stopwatch out is what makes the whole of what it
1049
+ // printed worth comparing. See `withoutRunnerTiming`.
1050
+ quieten: step.act === 'run-tests' ? (text) => quietenRunnerOutput(runner, text) : undefined,
669
1051
  });
670
1052
  if (step.act === 'import') observations.push(...apiSurface(journey, result));
1053
+ if (step.act === 'run-tests') {
1054
+ observations.push(...(await suiteObservations({ journey, step, runner, result, root: places.work })));
1055
+ }
1056
+ // Only the first step is walked, and until now the rest were dropped without a word — a
1057
+ // journey of three steps reported on one of them and read as a clean, complete walk.
1058
+ // Nothing here builds a multi-step CLI journey today; a recording or an agent easily
1059
+ // could, and a silent drop is how that arrives as a false pass.
1060
+ const rest = (journey.steps ?? []).slice(1);
1061
+ if (rest.length > 0) {
1062
+ observations.push(notCovered({
1063
+ channel: 'results',
1064
+ path: joinPath('cli', journey.name, 'the rest of its steps'),
1065
+ reason: 'not supported here',
1066
+ says:
1067
+ `"${journey.describe}" has ${rest.length + 1} steps and this adapter walks one command per journey, so ` +
1068
+ `${rest.length} of them ${rest.length === 1 ? 'was' : 'were'} not walked: ` +
1069
+ `${rest.map((/** @type {any} */ s) => s.run ?? s.module ?? s.file ?? s.act).join(', ')}. ` +
1070
+ `Split them into a journey each. This is a hole, not a pass.`,
1071
+ }));
1072
+ }
671
1073
  return observations;
672
1074
  },
673
1075
 
@@ -699,11 +1101,194 @@ export function importProbeCommand(moduleId) {
699
1101
  " : t === 'object' ? ('an object with ' + Object.keys(v).sort().join(', '))",
700
1102
  " : t === 'string' ? 'some text' : t;",
701
1103
  "}",
702
- "process.stdout.write(JSON.stringify(out, null, 2));",
1104
+ "process.stdout.write('\\n' + " + JSON.stringify(EXPORTS_MARKER) + " + '\\n' + JSON.stringify(out, null, 2));",
703
1105
  ].join('\n');
704
1106
  return `node --input-type=module -e ${shellQuote(probe)} ${shellQuote(moduleId)}`;
705
1107
  }
706
1108
 
1109
+ /**
1110
+ * How the probe's answer is told apart from anything the module printed on the way in.
1111
+ *
1112
+ * Without it the whole of stdout was handed to `JSON.parse`, so ONE line printed at import
1113
+ * time — a dotenv banner, a deprecation warning, anything — made the parse fail, and the run
1114
+ * then said "could not be imported, so nothing is known about what it exports". Both halves
1115
+ * false: it imported perfectly, and its whole exported surface was sitting in the same
1116
+ * string. Every export on that module read as never walked, which is the coverage ledger
1117
+ * lying, and the API comparison that is the entire point of an import journey was off.
1118
+ */
1119
+ const EXPORTS_MARKER = '<<< staysfixed: what it exports >>>';
1120
+
1121
+ // ---------------------------------------------------------------------------
1122
+ // Walking a test file the harvest found
1123
+ // ---------------------------------------------------------------------------
1124
+
1125
+ /**
1126
+ * The command line for a test-file journey, exactly as it was harvested.
1127
+ *
1128
+ * The harvest wrote the program and its arguments down separately, and they are put back
1129
+ * together with every part quoted, because a project with a space in its path is not a
1130
+ * project this tool gets to be wrong about.
1131
+ *
1132
+ * ONE SUBSTITUTION, and only one. The program the harvest recorded is an absolute path to the
1133
+ * Node binary on the machine that did the harvesting. A journey saved in a repository and
1134
+ * walked on somebody else's laptop names a file that is not there, and the shell then fails
1135
+ * the same way on BOTH builds - which produces no difference at all and reads exactly like a
1136
+ * clean check. So a missing absolute Node is replaced with the Node running this, and
1137
+ * anything else is left alone for the shell to find on the path.
1138
+ *
1139
+ * @param {{command?: string, argv?: string[], run?: string, file?: string}} step
1140
+ * @returns {string}
1141
+ */
1142
+ export function testFileCommand(step) {
1143
+ if (!step.command) return String(step.run ?? '');
1144
+ let program = String(step.command);
1145
+ if (path.isAbsolute(program) && !exists(program) && /^node(\.exe)?$/.test(path.basename(program))) {
1146
+ program = process.execPath;
1147
+ }
1148
+ return [program, ...(step.argv ?? []).map(String)].map(shellQuote).join(' ');
1149
+ }
1150
+
1151
+ /**
1152
+ * What a walked test file says, beyond what any command says.
1153
+ *
1154
+ * Four things, and each one answers a question an exit code cannot.
1155
+ *
1156
+ * EACH CHECK, BY NAME, passed or failed. A suite that was already red stays red on both
1157
+ * builds and reports nothing, which is right: it was already failing and you did not break
1158
+ * it. A check that goes green-to-red on the new build alone is the finding, and it names
1159
+ * itself instead of arriving as "the exit code changed".
1160
+ *
1161
+ * WHY EACH FAILING CHECK FAILED. "Still failing" and "failing for a completely different
1162
+ * reason" are different facts, and a flag cannot hold both.
1163
+ *
1164
+ * THE CHECKS THE FILE CONTAINS, as a list. Add, rename or delete a test and this moves.
1165
+ *
1166
+ * THE TEST FILE ITSELF, as a fingerprint of its contents. This is the one that stops the
1167
+ * whole feature crying wolf. If you edited the test, then every difference underneath it is
1168
+ * a difference you made on purpose, and the reader has to be told so in the same breath as
1169
+ * the difference rather than left to work it out. It is compared rather than merely noted,
1170
+ * so it appears exactly when it is true and never otherwise.
1171
+ *
1172
+ * FLAKES ARE NOT DEALT WITH HERE, on purpose. A check that flips between two runs of the same
1173
+ * build lands in the wobble measurement like anything else that cannot answer twice, and is
1174
+ * subtracted there. A second mechanism for the same problem is how two mechanisms end up
1175
+ * disagreeing with each other.
1176
+ *
1177
+ * @param {object} input
1178
+ * @param {import('./contract.js').Journey} input.journey
1179
+ * @param {{file?: string, tests?: string[]}} input.step
1180
+ * @param {import('../journeys/from-suite.js').Runner} input.runner
1181
+ * @param {CommandResult} input.result
1182
+ * @param {string} input.root The scratch copy this build was walked in.
1183
+ * @returns {Promise<import('./contract.js').Observation[]>}
1184
+ */
1185
+ export async function suiteObservations(input) {
1186
+ const { journey, step, runner, result, root } = input;
1187
+ const id = journey.name;
1188
+ const file = String(step.file ?? '');
1189
+ /** @type {import('./contract.js').Observation[]} */
1190
+ const out = [];
1191
+
1192
+ out.push(observation({
1193
+ channel: 'results',
1194
+ path: joinPath('test', id, 'the test file itself'),
1195
+ value: await fingerprintOf(path.join(root, file)),
1196
+ says:
1197
+ `${file} as it stands in this build. If this is one of the things that changed, then whatever moved below ` +
1198
+ `moved because you edited the test, and it cannot tell you whether the product still works - run the check ` +
1199
+ `again once the test is the way you want it.`,
1200
+ where: { file },
1201
+ }));
1202
+
1203
+ const read = readChecks(runner, result.stdout);
1204
+ if (!read.read) {
1205
+ out.push(notCovered({
1206
+ channel: 'results',
1207
+ path: joinPath('test', id, 'the checks it reported'),
1208
+ reason: 'crashed',
1209
+ says:
1210
+ `Nothing could be read back from ${file}: ${read.why} It was run, and what it printed and how it finished ` +
1211
+ `are still compared exactly - but which of its checks passed is not known, and that is a hole, not a pass.`,
1212
+ where: { file },
1213
+ }));
1214
+ return out;
1215
+ }
1216
+
1217
+ out.push(observation({
1218
+ channel: 'results',
1219
+ path: joinPath('test', id, 'the checks it contains'),
1220
+ value: read.checks.map((c) => c.name).sort(),
1221
+ says:
1222
+ `The ${read.checks.length} ${read.checks.length === 1 ? 'check' : 'checks'} ${file} reported. A name appearing ` +
1223
+ `or disappearing here means the test file itself was added to or cut down.`,
1224
+ where: { file },
1225
+ }));
1226
+
1227
+ for (const check of read.checks) {
1228
+ out.push(observation({
1229
+ channel: 'results',
1230
+ path: joinPath('test', id, check.name),
1231
+ value: check.ok ? 'passed' : 'failed',
1232
+ says: check.ok
1233
+ ? `"${check.name}" in ${file} passed.`
1234
+ : `"${check.name}" in ${file} failed. If it failed on the build you were happy with too, nothing is ` +
1235
+ `reported - it was already broken, and you did not break it.`,
1236
+ where: { file },
1237
+ }));
1238
+ if (check.detail) {
1239
+ // A long failure message gets its middle cut out, and that used to be stored with
1240
+ // nothing saying so — two different failures whose ends match would then compare equal
1241
+ // and report "still failing for the same reason" when the reason had changed.
1242
+ const kept = trimForStorage(check.detail);
1243
+ out.push(observation({
1244
+ channel: 'complaints',
1245
+ path: joinPath('test', id, check.name, 'why it failed'),
1246
+ value: kept.text,
1247
+ says:
1248
+ `What ${file} said when "${check.name}" failed. A check that was already failing and is now failing for a ` +
1249
+ `different reason is a change, and this is where it shows.` +
1250
+ (kept.truncated
1251
+ ? ` It is ${sizeBucket(kept.bytes)}, so only the two ends are compared: a different failure with the same ` +
1252
+ `ends and the same length would not be seen.`
1253
+ : ''),
1254
+ where: { file },
1255
+ covered: kept.truncated ? false : undefined,
1256
+ reason: kept.truncated ? 'too big' : undefined,
1257
+ }));
1258
+ }
1259
+ }
1260
+
1261
+ out.push(observation({
1262
+ channel: 'counters',
1263
+ path: joinPath('count', id, 'checks that failed'),
1264
+ value: read.checks.filter((c) => !c.ok).length,
1265
+ says:
1266
+ `How many of ${file}'s checks did not pass. Compared against the build you were happy with, so a suite that ` +
1267
+ `was already red is not news.`,
1268
+ where: { file },
1269
+ }));
1270
+
1271
+ return out;
1272
+ }
1273
+
1274
+ /**
1275
+ * A file's contents in one short string, or a plain sentence saying it is not there.
1276
+ *
1277
+ * @param {string} file
1278
+ * @returns {Promise<string>}
1279
+ */
1280
+ async function fingerprintOf(file) {
1281
+ try {
1282
+ const bytes = await fsp.readFile(file);
1283
+ return `${crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 16)} (${bytes.length} bytes)`;
1284
+ } catch (error) {
1285
+ // "It is not here" and "it is here and would not open" are different facts, and one
1286
+ // sentence for both means a permissions problem reads as a deleted test file.
1287
+ if (isGone(error)) return 'there is no such file in this build';
1288
+ return `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`;
1289
+ }
1290
+ }
1291
+
707
1292
  /** @param {string} text */
708
1293
  function shellQuote(text) {
709
1294
  return `'${text.split("'").join(`'\\''`)}'`;
@@ -739,6 +1324,15 @@ function sanitise(name) {
739
1324
  * @param {WatchedEvents} input.watched
740
1325
  * @param {import('./contract.js').RunContext} input.ctx
741
1326
  * @param {{dirs: string[], projectRoot?: string, ports?: number[]}} input.footprint
1327
+ * @param {string[]} [input.skipped] Folders this run did not watch, so it can say so.
1328
+ * @param {(text: string) => string} [input.quieten]
1329
+ * Applied to what the program printed, after our own footprint is rubbed out and before
1330
+ * anything is compared. It exists for one narrow case and has to stay narrow: a harness the
1331
+ * journey itself started - a test runner - that narrates its own stopwatch into the output.
1332
+ * That is the harness talking about the machine, not the product talking about itself, and
1333
+ * it is the same reason durations are never compared anywhere else in here. It is NOT for
1334
+ * the product's own volatile output; that is the noise-control layer's job, where the rules
1335
+ * live in the project's git and a person can see and argue with them.
742
1336
  * @returns {Promise<import('./contract.js').Observation[]>}
743
1337
  */
744
1338
  export async function describeRun(input) {
@@ -752,7 +1346,8 @@ export async function describeRun(input) {
752
1346
  ['to the screen', result.stdout, 'results', 'printed to the screen', 'printed nothing at all'],
753
1347
  ['as a complaint', result.stderr, 'complaints', 'complained about', 'complained about nothing'],
754
1348
  ])) {
755
- const text = undoOurFootprint(raw, footprint);
1349
+ const plain = undoOurFootprint(raw, footprint);
1350
+ const text = input.quieten ? input.quieten(plain) : plain;
756
1351
  const kept = trimForStorage(text);
757
1352
  let evidence;
758
1353
  if (kept.truncated) {
@@ -783,6 +1378,17 @@ export async function describeRun(input) {
783
1378
  }
784
1379
 
785
1380
  // ---- how it finished
1381
+ if (result.couldNotStart) {
1382
+ out.push(notCovered({
1383
+ channel: 'complaints',
1384
+ path: joinPath('cli', id, 'ran at all'),
1385
+ reason: 'crashed',
1386
+ says:
1387
+ `"${journey.describe}" never started: ${result.couldNotStart}. Nothing about the product was observed here, ` +
1388
+ `and a command that fails to start fails the same way on both builds — so without this line the comparison ` +
1389
+ `would have found no difference and called it clean.`,
1390
+ }));
1391
+ }
786
1392
  out.push(observation({
787
1393
  channel: 'complaints',
788
1394
  path: joinPath('cli', id, 'exit'),
@@ -808,6 +1414,54 @@ export async function describeRun(input) {
808
1414
  : `"${journey.describe}" ${change.what} ${change.file}. Only the contents are compared, so rewriting the same bytes is not a change.`,
809
1415
  }));
810
1416
  }
1417
+ // ---- and the three ways looking at files can come up short. All of them used to be
1418
+ // silent, and a silence here is indistinguishable from "nothing changed", which is the one
1419
+ // shape of wrong answer this whole tool exists to prevent.
1420
+ const bySize = [...input.after].filter(([, mark]) => mark.startsWith(BY_SIZE_ALONE)).map(([file]) => file).sort();
1421
+ if (bySize.length > 0) {
1422
+ out.push(observation({
1423
+ channel: 'effects',
1424
+ path: joinPath('file', id, 'compared by size alone'),
1425
+ value: bySize,
1426
+ says:
1427
+ `${bySize.length} file${bySize.length === 1 ? ' was' : 's were'} too big to read through, so ${bySize.length === 1 ? 'it was' : 'they were'} ` +
1428
+ `compared by size rather than by contents: ${bySize.join(', ')}. A rewrite of the same rough size would NOT be seen. ` +
1429
+ `This is a hole in what was checked, not a pass.`,
1430
+ covered: false,
1431
+ reason: 'too big',
1432
+ }));
1433
+ }
1434
+ const unreadable = [...new Map([...input.before, ...input.after])]
1435
+ .filter(([, mark]) => mark.startsWith(COULD_NOT_READ))
1436
+ .map(([file, mark]) => `${file} (${mark.slice(COULD_NOT_READ.length)})`)
1437
+ .sort();
1438
+ if (unreadable.length > 0) {
1439
+ out.push(observation({
1440
+ channel: 'effects',
1441
+ path: joinPath('file', id, 'could not be looked at'),
1442
+ value: unreadable,
1443
+ says:
1444
+ `${unreadable.length} place${unreadable.length === 1 ? '' : 's'} in the scratch copy could not be read, so anything written ` +
1445
+ `there — and anything underneath, for a folder — was not seen: ${unreadable.join(', ')}. This is a hole, not a pass.`,
1446
+ covered: false,
1447
+ reason: 'refused',
1448
+ }));
1449
+ }
1450
+ if (input.skipped && input.skipped.length > 0) {
1451
+ out.push(observation({
1452
+ channel: 'effects',
1453
+ path: joinPath('file', id, 'folders left unwatched'),
1454
+ value: input.skipped,
1455
+ says:
1456
+ `Files written into ${input.skipped.join(', ')} were not watched. ` +
1457
+ `node_modules is the one that costs something: a build step that patches a dependency, generates a client into it, ` +
1458
+ `or rebuilds a native module changes what ships and is not seen here. It is left out because fingerprinting it twice ` +
1459
+ `per run makes a check nobody waits for. Name it under "process.alsoWatch" in the settings to watch it anyway.`,
1460
+ covered: false,
1461
+ reason: 'too big',
1462
+ }));
1463
+ }
1464
+
811
1465
  out.push(observation({
812
1466
  channel: 'counters',
813
1467
  path: joinPath('count', id, 'files touched'),
@@ -843,6 +1497,18 @@ export async function describeRun(input) {
843
1497
  reason: 'irreversible',
844
1498
  }));
845
1499
  }
1500
+ if (watched.torn > 0) {
1501
+ out.push(observation({
1502
+ channel: 'effects',
1503
+ path: joinPath('proc', id, 'events that could not be read back'),
1504
+ value: watched.torn,
1505
+ says:
1506
+ `${watched.torn} line${watched.torn === 1 ? '' : 's'} of what the watcher wrote could not be read back, so ` +
1507
+ `that many programs started or connections attempted are missing from this run. This is a hole, not a pass.`,
1508
+ covered: false,
1509
+ reason: 'crashed',
1510
+ }));
1511
+ }
846
1512
  if (watched.settingsRead.length > 0) {
847
1513
  out.push(observation({
848
1514
  channel: 'effects',
@@ -890,8 +1556,12 @@ export async function describeRun(input) {
890
1556
  export function apiSurface(journey, result) {
891
1557
  /** @type {Record<string, string>} */
892
1558
  let surface;
1559
+ const at = result.stdout.lastIndexOf(EXPORTS_MARKER);
893
1560
  try {
894
- surface = JSON.parse(result.stdout);
1561
+ // Only what comes after the marker. Anything the module printed while importing sits in
1562
+ // front of it and is compared under "printed", where it belongs.
1563
+ surface = JSON.parse(at === -1 ? result.stdout : result.stdout.slice(at + EXPORTS_MARKER.length));
1564
+ if (surface === null || typeof surface !== 'object' || Array.isArray(surface)) throw new Error('not a list of names');
895
1565
  } catch {
896
1566
  return [notCovered({
897
1567
  channel: 'results',
@@ -917,14 +1587,6 @@ export function apiSurface(journey, result) {
917
1587
  return out;
918
1588
  }
919
1589
 
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
1590
  /** True when a path exists. Small enough to inline, useful enough to name. */
929
1591
  export function exists(/** @type {string} */ file) {
930
1592
  return fs.existsSync(file);