staysfixed 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,920 @@
1
+ /**
2
+ * CLI tools and libraries — the first platform, and the one the whole design is tested on.
3
+ *
4
+ * A command is the easiest thing in the world to observe honestly. You run it, and it tells
5
+ * you what happened: what it printed, what it complained about, what number it exited with,
6
+ * what it left behind on disk, what it tried to phone. No accessibility tree, no browser, no
7
+ * simulator. That is exactly why this adapter is first — if subtracting the measured wobble
8
+ * does not make the differences quiet HERE, it will not work anywhere, and it is better to
9
+ * find that out in days than in months.
10
+ *
11
+ * WHAT IT WATCHES, per journey:
12
+ * printed stdout and stderr, with only our own footprint rubbed out
13
+ * finished the exit code, and whether it was killed
14
+ * files every file created, changed or deleted, by content, not by timestamp
15
+ * ran every other program it started
16
+ * reached out to every outbound connection it tried — and every one of them refused
17
+ * settings it read which environment variables it looked at
18
+ * how long in coarse buckets, never in milliseconds
19
+ *
20
+ * TWO THINGS TO KNOW ABOUT HOW IT WATCHES.
21
+ *
22
+ * First, every run happens in a scratch copy of the project. Never the real one. Somebody
23
+ * has the real one open in an editor, and a command that writes a file is a command that
24
+ * would have written it into their working tree.
25
+ *
26
+ * Second, the watching itself is done from INSIDE the child, by a small script Node loads
27
+ * before the program starts. That is what makes "it tried to call this URL" observable at
28
+ * all, and it is the safety boundary: an outbound connection to anywhere but this machine
29
+ * is recorded and then refused, so a command that charges a card gets watched asking and
30
+ * never gets to ask. The catch, said plainly rather than hidden: this only works when the
31
+ * thing being run is Node. Run a Go binary and the file-and-output channels still work
32
+ * perfectly, but nobody is watching its network, and the report says so with `covered:
33
+ * false` rather than quietly passing.
34
+ */
35
+
36
+ import fs from 'node:fs';
37
+ import fsp from 'node:fs/promises';
38
+ import path from 'node:path';
39
+ import os from 'node:os';
40
+ import crypto from 'node:crypto';
41
+ import { spawn } from 'node:child_process';
42
+ import {
43
+ defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
44
+ trimForStorage, undoOurFootprint,
45
+ } from './contract.js';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // The environment every run gets
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * The settings that are pinned for every run, so two runs of the same build differ because
53
+ * of the build and not because of the weather.
54
+ *
55
+ * `NO_COLOR` and friends matter more than they look: a program that colours its output when
56
+ * it thinks it is talking to a terminal and does not when it thinks it is talking to a pipe
57
+ * produces two completely different strings for the same work. Pinning the answer is better
58
+ * than stripping the colours afterwards, because stripping also hides a real change in what
59
+ * the program chose to colour.
60
+ *
61
+ * @param {object} opts
62
+ * @param {string} opts.clock ISO time the run should believe it is.
63
+ * @param {number} opts.seed
64
+ * @param {string} opts.home A scratch home directory, so nothing reads the real one.
65
+ * @param {string} opts.tmp
66
+ * @param {Record<string,string>} [opts.extra]
67
+ * @returns {Record<string,string>}
68
+ */
69
+ export function frozenEnvironment(opts) {
70
+ return {
71
+ PATH: process.env.PATH ?? '/usr/bin:/bin:/usr/sbin:/sbin',
72
+ SHELL: '/bin/sh',
73
+ HOME: opts.home,
74
+ TMPDIR: opts.tmp,
75
+ TEMP: opts.tmp,
76
+ TMP: opts.tmp,
77
+ TZ: 'UTC',
78
+ LANG: 'en_US.UTF-8',
79
+ LC_ALL: 'en_US.UTF-8',
80
+ TERM: 'dumb',
81
+ NO_COLOR: '1',
82
+ FORCE_COLOR: '0',
83
+ CI: '',
84
+ COLUMNS: '80',
85
+ LINES: '24',
86
+ // Anything that seeds itself from here gets the same seed twice, which is the point.
87
+ STAYSFIXED: '1',
88
+ STAYSFIXED_SEED: String(opts.seed),
89
+ STAYSFIXED_CLOCK: opts.clock,
90
+ ...opts.extra,
91
+ };
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // The watcher that rides inside the child
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Build the script Node loads before the program under test starts.
100
+ *
101
+ * Written as text rather than as a file in this package because it has to be given a path
102
+ * inside the scratch folder anyway, and because generating it here keeps what it does
103
+ * readable next to why it does it.
104
+ *
105
+ * The network patch goes on `net.Socket.prototype.connect` rather than on `fetch` or on
106
+ * `http.request`. That is deliberate and it is the difference between a boundary and a
107
+ * suggestion: every HTTP client, every database driver, every SDK anybody has ever written
108
+ * ends up on that one prototype, however it was imported. Patching `fetch` catches the
109
+ * polite callers and misses the payment library.
110
+ *
111
+ * @param {object} opts
112
+ * @param {string} opts.reportFile Where the child writes what it saw.
113
+ * @param {boolean} opts.allowLoopback Let it talk to this machine (its own server).
114
+ * @returns {string} JavaScript, ready to be written to disk and passed to --import
115
+ */
116
+ export function watcherScript(opts) {
117
+ const settings = JSON.stringify({ reportFile: opts.reportFile, allowLoopback: opts.allowLoopback });
118
+ return [
119
+ "// Written by Stays Fixed. Watches one run from the inside, and refuses anything that",
120
+ "// would reach off this machine. Deleted with the scratch folder when the run ends.",
121
+ "import { createRequire } from 'node:module';",
122
+ "const require = createRequire(import.meta.url);",
123
+ "const fs = require('node:fs');",
124
+ "const settings = " + settings + ";",
125
+ "",
126
+ "const write = (kind, what) => {",
127
+ " try { fs.appendFileSync(settings.reportFile, JSON.stringify({ kind, what }) + '\\n'); }",
128
+ " catch { /* a run we cannot report on is still a run */ }",
129
+ "};",
130
+ "",
131
+ "// The first line goes out before anything else runs. Its presence is the proof that",
132
+ "// something was watching at all — without it, a run that simply did nothing quiet is",
133
+ "// indistinguishable from a run nobody was watching, and those two must never be confused.",
134
+ "write('watching', { pid: process.pid });",
135
+ "",
136
+ "// --- what else it started ------------------------------------------------",
137
+ "// Patched on the module's own exports object, which every `require` and every default",
138
+ "// import shares. A caller who wrote `import { spawn } from ...` bound the function",
139
+ "// directly and will not be seen; the report says so rather than pretending otherwise.",
140
+ "try {",
141
+ " const cp = require('node:child_process');",
142
+ " for (const name of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) {",
143
+ " const original = cp[name];",
144
+ " if (typeof original !== 'function') continue;",
145
+ " cp[name] = function (...args) {",
146
+ " write('ran', { how: name, command: String(args[0] ?? '') });",
147
+ " return original.apply(this, args);",
148
+ " };",
149
+ " }",
150
+ "} catch { /* no child_process, nothing to watch */ }",
151
+ "",
152
+ "// --- what it tried to reach ----------------------------------------------",
153
+ "const loopback = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0', '']);",
154
+ "try {",
155
+ " const net = require('node:net');",
156
+ " const connect = net.Socket.prototype.connect;",
157
+ " 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);",
162
+ " if (local && settings.allowLoopback) return connect.apply(this, args);",
163
+ " write('reached out', { host: host || 'somewhere it did not name', port: port ?? null });",
164
+ " // Refused, not allowed through. Whatever this was going to do out there, it does not",
165
+ " // 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;",
170
+ " };",
171
+ "} catch { /* no net module, nothing to refuse */ }",
172
+ "",
173
+ "// --- what settings it read -----------------------------------------------",
174
+ "// Buffered rather than written as it happens: a program can read the environment",
175
+ "// thousands of times and appending to a file each time would change what we are trying",
176
+ "// to measure.",
177
+ "const settingsRead = new Set();",
178
+ "try {",
179
+ " const real = process.env;",
180
+ " 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; },",
183
+ " });",
184
+ " Object.defineProperty(process, 'env', { value: watched, configurable: true, writable: true });",
185
+ "} catch { /* some hosts freeze this; the other channels still work */ }",
186
+ "",
187
+ "const finish = () => {",
188
+ " if (settingsRead.size > 0) { write('settings read', [...settingsRead].sort()); settingsRead.clear(); }",
189
+ "};",
190
+ "process.on('exit', finish);",
191
+ ].join('\n');
192
+ }
193
+
194
+ /**
195
+ * @typedef {object} WatchedEvents
196
+ * @property {boolean} inForce False when nothing was watching from the inside.
197
+ * @property {Map<string, number>} ran Command as written, and how many times.
198
+ * @property {Array<{host: string, port: number|null}>} reachedOut
199
+ * @property {string[]} settingsRead
200
+ */
201
+
202
+ /**
203
+ * Read back what the watcher saw. An empty or missing file is not an error: it is the
204
+ * answer to "was anything watching", and the caller reports coverage accordingly.
205
+ * @param {string} reportFile
206
+ * @returns {Promise<WatchedEvents>}
207
+ */
208
+ export async function readWatcher(reportFile) {
209
+ /** @type {WatchedEvents} */
210
+ const seen = { inForce: false, ran: new Map(), reachedOut: [], settingsRead: [] };
211
+ let text;
212
+ try {
213
+ text = await fsp.readFile(reportFile, 'utf8');
214
+ } catch {
215
+ return seen;
216
+ }
217
+ seen.inForce = true;
218
+ for (const line of text.split('\n')) {
219
+ if (line.trim() === '') continue;
220
+ let event;
221
+ try { event = JSON.parse(line); } catch { continue; }
222
+ if (event.kind === 'ran') {
223
+ const command = String(event.what?.command ?? '');
224
+ seen.ran.set(command, (seen.ran.get(command) ?? 0) + 1);
225
+ } else if (event.kind === 'reached out') {
226
+ seen.reachedOut.push({ host: String(event.what?.host ?? ''), port: event.what?.port ?? null });
227
+ } else if (event.kind === 'settings read') {
228
+ // The variables we set ourselves are our footprint, not the program's dependencies.
229
+ // Everything else stays, including the ones Node itself reads — those vary between
230
+ // runs, and subtracting the measured wobble is exactly how that is meant to be dealt
231
+ // with, rather than by a growing list of names somebody has to keep up to date.
232
+ const ours = /^(STAYSFIXED|NODE_OPTIONS$)/;
233
+ seen.settingsRead.push(...(Array.isArray(event.what) ? event.what.map(String).filter((/** @type {string} */ k) => !ours.test(k)) : []));
234
+ }
235
+ }
236
+ seen.settingsRead = [...new Set(seen.settingsRead)].sort();
237
+ return seen;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // What the disk looked like before and after
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /** @typedef {Map<string, string>} TreeSnapshot relative path -> fingerprint of its contents */
245
+
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']);
248
+
249
+ /**
250
+ * Fingerprint every file under a folder.
251
+ *
252
+ * By CONTENTS, never by timestamp or size. A run that rewrites a file with the same bytes
253
+ * 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.
256
+ *
257
+ * @param {string} root
258
+ * @param {object} [opts]
259
+ * @param {number} [opts.maxBytes] Hash files up to this size. Default 8MB.
260
+ * @param {Set<string>} [opts.skip]
261
+ * @returns {Promise<TreeSnapshot>}
262
+ */
263
+ export async function snapshotTree(root, opts = {}) {
264
+ const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024;
265
+ const skip = opts.skip ?? SNAPSHOT_SKIP;
266
+ /** @type {TreeSnapshot} */
267
+ const snapshot = new Map();
268
+
269
+ /** @param {string} dir */
270
+ const walk = async (dir) => {
271
+ /** @type {import('node:fs').Dirent[]} */
272
+ let entries;
273
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
274
+ for (const entry of entries) {
275
+ const full = path.join(dir, entry.name);
276
+ if (entry.isDirectory()) {
277
+ if (!skip.has(entry.name)) await walk(full);
278
+ continue;
279
+ }
280
+ const relative = path.relative(root, full);
281
+ if (entry.isSymbolicLink()) {
282
+ try { snapshot.set(relative, `points at ${await fsp.readlink(full)}`); } catch { /* gone already */ }
283
+ continue;
284
+ }
285
+ if (!entry.isFile()) continue;
286
+ try {
287
+ 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 */ }
294
+ }
295
+ };
296
+
297
+ await walk(root);
298
+ return snapshot;
299
+ }
300
+
301
+ /**
302
+ * @typedef {object} FileChange
303
+ * @property {string} file
304
+ * @property {'created'|'changed'|'deleted'} what
305
+ * @property {string} [now] Fingerprint afterwards.
306
+ */
307
+
308
+ /**
309
+ * @param {TreeSnapshot} before
310
+ * @param {TreeSnapshot} after
311
+ * @returns {FileChange[]}
312
+ */
313
+ export function compareTrees(before, after) {
314
+ /** @type {FileChange[]} */
315
+ const changes = [];
316
+ for (const [file, now] of after) {
317
+ const then = before.get(file);
318
+ if (then === undefined) changes.push({ file, what: 'created', now });
319
+ else if (then !== now) changes.push({ file, what: 'changed', now });
320
+ }
321
+ for (const file of before.keys()) {
322
+ if (!after.has(file)) changes.push({ file, what: 'deleted' });
323
+ }
324
+ changes.sort((a, b) => a.file.localeCompare(b.file));
325
+ return changes;
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Running one command
330
+ // ---------------------------------------------------------------------------
331
+
332
+ /**
333
+ * @typedef {object} CommandResult
334
+ * @property {string} stdout
335
+ * @property {string} stderr
336
+ * @property {number|null} code
337
+ * @property {string|null} signal Set when it was killed rather than finishing.
338
+ * @property {boolean} timedOut
339
+ * @property {number} ms
340
+ */
341
+
342
+ /**
343
+ * Run a command and wait for it, with a hard limit.
344
+ *
345
+ * Killed with SIGTERM first and SIGKILL after a grace period, because a program that traps
346
+ * SIGTERM and hangs would otherwise hold the whole run open — and killed is reported as
347
+ * killed, never quietly as an exit code.
348
+ *
349
+ * @param {string} command Run through the shell, so a project can write what it means.
350
+ * @param {object} opts
351
+ * @param {string} opts.cwd
352
+ * @param {Record<string,string>} opts.env
353
+ * @param {number} [opts.timeoutMs]
354
+ * @param {string} [opts.stdin]
355
+ * @param {AbortSignal} [opts.signal]
356
+ * @returns {Promise<CommandResult>}
357
+ */
358
+ export function runCommand(command, opts) {
359
+ const timeoutMs = opts.timeoutMs ?? 120000;
360
+ return new Promise((resolve) => {
361
+ const started = Date.now();
362
+ const child = spawn(command, {
363
+ shell: true,
364
+ cwd: opts.cwd,
365
+ env: opts.env,
366
+ stdio: ['pipe', 'pipe', 'pipe'],
367
+ });
368
+ /** @type {Buffer[]} */
369
+ const out = [];
370
+ /** @type {Buffer[]} */
371
+ const err = [];
372
+ let timedOut = false;
373
+ let settled = false;
374
+
375
+ child.stdout?.on('data', (chunk) => out.push(chunk));
376
+ child.stderr?.on('data', (chunk) => err.push(chunk));
377
+ if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
378
+ else child.stdin?.end();
379
+
380
+ const finish = (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
381
+ if (settled) return;
382
+ settled = true;
383
+ clearTimeout(alarm);
384
+ clearTimeout(hardStop);
385
+ opts.signal?.removeEventListener('abort', onAbort);
386
+ resolve({
387
+ stdout: Buffer.concat(out).toString('utf8'),
388
+ stderr: Buffer.concat(err).toString('utf8'),
389
+ code,
390
+ signal,
391
+ timedOut,
392
+ ms: Date.now() - started,
393
+ });
394
+ };
395
+
396
+ /** @type {NodeJS.Timeout} */
397
+ let hardStop;
398
+ const alarm = setTimeout(() => {
399
+ timedOut = true;
400
+ child.kill('SIGTERM');
401
+ hardStop = setTimeout(() => child.kill('SIGKILL'), 5000);
402
+ }, timeoutMs);
403
+
404
+ const onAbort = () => { child.kill('SIGTERM'); };
405
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
406
+
407
+ child.on('error', (error) => {
408
+ err.push(Buffer.from(`${error.message}\n`));
409
+ finish(null, null);
410
+ });
411
+ child.on('close', (code, signal) => finish(code, signal));
412
+ });
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Making the scratch copy
417
+ // ---------------------------------------------------------------------------
418
+
419
+ /**
420
+ * Copy a project into a scratch folder so a run can write whatever it likes.
421
+ *
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.
427
+ *
428
+ * @param {string} from
429
+ * @param {string} to
430
+ * @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}>}
434
+ */
435
+ export async function copyForScratch(from, to, opts = {}) {
436
+ const skip = new Set(opts.skip ?? ['.git', '.staysfixed']);
437
+ await fsp.mkdir(to, { recursive: true });
438
+ 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.` };
451
+ } catch (error) {
452
+ return { copied: false, why: `The project could not be copied into a scratch folder: ${error instanceof Error ? error.message : String(error)}` };
453
+ }
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // The adapter
458
+ // ---------------------------------------------------------------------------
459
+
460
+ /**
461
+ * @typedef {object} CommandJourneyDetail
462
+ * @property {string} run The command line, as a person would type it.
463
+ * @property {string} [cwd] Relative to the scratch copy. Default: its root.
464
+ * @property {string} [stdin]
465
+ * @property {number} [timeoutMs]
466
+ * @property {Record<string,string>} [env]
467
+ */
468
+
469
+ /**
470
+ * @typedef {object} ImportJourneyDetail
471
+ * @property {string} module A path inside the project, or a package entry name.
472
+ */
473
+
474
+ /** Everything a prepared build needs to remember between journeys. */
475
+ const prepared = new Map();
476
+
477
+ /**
478
+ * The CLI-and-library adapter.
479
+ */
480
+ export const processAdapter = defineAdapter({
481
+ name: 'process',
482
+ title: 'CLI tools and libraries',
483
+ 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.',
485
+ channels: ['results', 'complaints', 'effects', 'counters'],
486
+
487
+ /** @param {import('./contract.js').AdapterProject} project */
488
+ async detect(project) {
489
+ /** @type {import('./contract.js').Missing[]} */
490
+ const missing = [];
491
+ const configured = project.config?.commands ?? [];
492
+ const imports = project.config?.imports ?? [];
493
+
494
+ let pkg = null;
495
+ try { pkg = JSON.parse(await fsp.readFile(path.join(project.root, 'package.json'), 'utf8')); } catch { /* fine */ }
496
+ const bins = pkg?.bin ? (typeof pkg.bin === 'string' ? 1 : Object.keys(pkg.bin).length) : 0;
497
+
498
+ if (configured.length === 0 && imports.length === 0) {
499
+ missing.push({
500
+ what: 'a list of commands worth running',
501
+ unlocks: 'everything this adapter does — it needs to know what to run, and it will not guess, because guessing means running something that deletes files',
502
+ howToGet: bins > 0
503
+ ? `This project installs ${bins} command${bins === 1 ? '' : 's'}. Put the ones worth checking under "process.commands" in the config, each as {"name": "...", "run": "..."}.`
504
+ : 'Put them under "process.commands" in the config, each as {"name": "...", "run": "..."}.',
505
+ blocking: true,
506
+ });
507
+ }
508
+
509
+ const applies = configured.length > 0 || imports.length > 0 || bins > 0 || pkg !== null;
510
+ return {
511
+ applies,
512
+ confidence: configured.length + imports.length > 0 ? 1 : (bins > 0 ? 0.6 : 0.2),
513
+ why: configured.length + imports.length > 0
514
+ ? `There ${configured.length + imports.length === 1 ? 'is 1 journey' : `are ${configured.length + imports.length} journeys`} to walk. Each runs in its own scratch copy of the project.`
515
+ : applies
516
+ ? 'This looks like a package, but nothing has said which commands are worth running yet.'
517
+ : 'There is no package here and no commands were listed, so there is nothing to run.',
518
+ missing,
519
+ notes: [
520
+ 'Every run happens in a scratch copy. The real project is never written to.',
521
+ 'Outbound connections are recorded and then refused, so nothing that costs money or sends a message can happen during a check.',
522
+ ],
523
+ };
524
+ },
525
+
526
+ /** @param {import('./contract.js').AdapterProject} project */
527
+ async journeys(project) {
528
+ /** @type {import('./contract.js').Journey[]} */
529
+ const journeys = [];
530
+ for (const entry of project.config?.commands ?? []) {
531
+ journeys.push({
532
+ name: String(entry.name ?? entry.run),
533
+ describe: String(entry.describe ?? entry.why ?? `run ${entry.run}`),
534
+ source: 'code',
535
+ surface: 'cli',
536
+ from: 'the project config',
537
+ channels: ['results', 'complaints', 'effects', 'counters'],
538
+ steps: [{ act: 'run', run: String(entry.run), cwd: entry.cwd, stdin: entry.stdin, env: entry.env }],
539
+ irreversible: entry.irreversible === true,
540
+ timeoutMs: entry.timeoutMs,
541
+ });
542
+ }
543
+ for (const entry of project.config?.imports ?? []) {
544
+ journeys.push({
545
+ name: String(entry.name ?? entry.module),
546
+ describe: String(entry.describe ?? `import ${entry.module} and look at what it exports`),
547
+ source: 'code',
548
+ surface: 'library',
549
+ from: 'the project config',
550
+ channels: ['results', 'complaints', 'effects', 'counters'],
551
+ steps: [{ act: 'import', module: String(entry.module) }],
552
+ timeoutMs: entry.timeoutMs,
553
+ });
554
+ }
555
+ return journeys;
556
+ },
557
+
558
+ /**
559
+ * @param {import('./contract.js').Build} build
560
+ * @param {import('./contract.js').RunContext} ctx
561
+ */
562
+ async prepare(build, ctx) {
563
+ const base = path.join(ctx.scratchDir, `build-${build.id.slice(0, 12)}`);
564
+ const work = path.join(base, 'work');
565
+ const home = path.join(base, 'home');
566
+ const tmp = path.join(base, 'tmp');
567
+ await fsp.mkdir(home, { recursive: true });
568
+ await fsp.mkdir(tmp, { recursive: true });
569
+
570
+ const copy = await copyForScratch(build.root, work);
571
+ if (!copy.copied) {
572
+ return {
573
+ build, root: work, ready: false, why: copy.why,
574
+ dispose: async () => { await fsp.rm(base, { recursive: true, force: true }); },
575
+ };
576
+ }
577
+
578
+ const watcher = path.join(base, 'watcher.mjs');
579
+ prepared.set(build.id, { base, work, home, tmp, watcher });
580
+
581
+ return {
582
+ build,
583
+ root: work,
584
+ ready: true,
585
+ why: `${copy.why} It reads a scratch home folder too, so nothing picks up a real config file by accident.`,
586
+ facts: { work, home, tmp },
587
+ dispose: async () => {
588
+ prepared.delete(build.id);
589
+ await fsp.rm(base, { recursive: true, force: true });
590
+ },
591
+ };
592
+ },
593
+
594
+ /**
595
+ * @param {import('./contract.js').Journey} journey
596
+ * @param {import('./contract.js').PreparedBuild} build
597
+ * @param {import('./contract.js').RunContext} ctx
598
+ * @returns {Promise<import('./contract.js').Observation[]>}
599
+ */
600
+ async run(journey, build, ctx) {
601
+ const places = prepared.get(build.build.id);
602
+ if (!build.ready || !places) {
603
+ return [notCovered({
604
+ channel: 'results',
605
+ path: joinPath('cli', journey.name, 'ran at all'),
606
+ reason: 'refused',
607
+ says: `"${journey.describe}" was not run: ${build.why}`,
608
+ })];
609
+ }
610
+
611
+ // Something irreversible is watched at the call boundary and refused at the wire — but
612
+ // a journey that says up front that it is irreversible is not started at all, because
613
+ // the refusal happens inside the child and there is no child until we start one.
614
+ if (journey.irreversible && ctx.allowIrreversible !== true) {
615
+ return [notCovered({
616
+ channel: 'effects',
617
+ path: joinPath('cli', journey.name, 'ran at all'),
618
+ reason: 'irreversible',
619
+ says: `"${journey.describe}" was left alone because it spends money, sends a message or destroys data. This is a hole in what was checked, not a pass.`,
620
+ })];
621
+ }
622
+
623
+ const reportFile = path.join(places.base, `watch-${sanitise(journey.name)}.jsonl`);
624
+ await fsp.rm(reportFile, { force: true });
625
+ await fsp.writeFile(places.watcher, watcherScript({ reportFile, allowLoopback: true }), 'utf8');
626
+
627
+ // The engine's Journey carries its steps as an open-ended list; a CLI journey has
628
+ // exactly one, and this is where it is unpacked.
629
+ const step = /** @type {any} */ (journey.steps?.[0] ?? {});
630
+ const cwd = step.cwd ? path.resolve(places.work, step.cwd) : places.work;
631
+ const env = frozenEnvironment({
632
+ clock: ctx.clock,
633
+ seed: ctx.seed,
634
+ home: places.home,
635
+ tmp: places.tmp,
636
+ extra: {
637
+ ...step.env,
638
+ // `--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(),
641
+ },
642
+ });
643
+
644
+ // A step that names nothing to run must say so. Handing `undefined` to a shell runs a
645
+ // command called "undefined", which fails identically on both builds and therefore
646
+ // reports NO difference - a silent nothing that looks exactly like a clean check.
647
+ const nothingToRun = step.act === 'import' ? !step.module : !step.run;
648
+ if (nothingToRun) {
649
+ return [notCovered({
650
+ channel: 'results',
651
+ path: joinPath('cli', journey.name, 'ran at all'),
652
+ reason: 'refused',
653
+ says:
654
+ `"${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.`,
656
+ })];
657
+ }
658
+
659
+ const before = await snapshotTree(places.work);
660
+ const result = step.act === 'import'
661
+ ? 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);
664
+ const watched = await readWatcher(reportFile);
665
+
666
+ const observations = await describeRun({
667
+ journey, result, before, after, watched, ctx,
668
+ footprint: { dirs: [places.base, places.tmp, places.home], projectRoot: build.build.root },
669
+ });
670
+ if (step.act === 'import') observations.push(...apiSurface(journey, result));
671
+ return observations;
672
+ },
673
+
674
+ async teardown() {
675
+ prepared.clear();
676
+ },
677
+ });
678
+
679
+ /**
680
+ * The command that imports a module and prints its shape.
681
+ *
682
+ * Run in its own process rather than imported here, for two reasons that are both about
683
+ * honesty: a module that throws on import must not take the tool down with it, and a module
684
+ * that starts a server or reads a file on import must do that under the same watcher as
685
+ * everything else. Importing it into this process would let it out of the boundary.
686
+ *
687
+ * @param {string} moduleId
688
+ */
689
+ export function importProbeCommand(moduleId) {
690
+ 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]);",
692
+ "const out = {};",
693
+ "for (const key of Object.keys(m).sort()) {",
694
+ " const v = m[key];",
695
+ " const t = typeof v;",
696
+ " out[key] = t === 'function' ? ('a function taking ' + v.length + (v.length === 1 ? ' argument' : ' arguments'))",
697
+ " : v === null ? 'nothing'",
698
+ " : Array.isArray(v) ? ('a list of ' + v.length)",
699
+ " : t === 'object' ? ('an object with ' + Object.keys(v).sort().join(', '))",
700
+ " : t === 'string' ? 'some text' : t;",
701
+ "}",
702
+ "process.stdout.write(JSON.stringify(out, null, 2));",
703
+ ].join('\n');
704
+ return `node --input-type=module -e ${shellQuote(probe)} ${shellQuote(moduleId)}`;
705
+ }
706
+
707
+ /** @param {string} text */
708
+ function shellQuote(text) {
709
+ return `'${text.split("'").join(`'\\''`)}'`;
710
+ }
711
+
712
+ /** @param {string} file */
713
+ function pathToUrl(file) {
714
+ return `file://${file.split(path.sep).join('/')}`;
715
+ }
716
+
717
+ /** @param {string} name */
718
+ function sanitise(name) {
719
+ return name.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80) || 'journey';
720
+ }
721
+
722
+ // ---------------------------------------------------------------------------
723
+ // Turning a run into observations
724
+ // ---------------------------------------------------------------------------
725
+
726
+ /**
727
+ * Everything a run saw, as observations.
728
+ *
729
+ * Split fine on purpose. One observation holding "here is everything that happened" means
730
+ * any change anywhere reports as one enormous difference, and the agent reading it has to
731
+ * work out which part moved. One observation per file, per program started, per host
732
+ * reached for, means the difference names itself.
733
+ *
734
+ * @param {object} input
735
+ * @param {import('./contract.js').Journey} input.journey
736
+ * @param {CommandResult} input.result
737
+ * @param {TreeSnapshot} input.before
738
+ * @param {TreeSnapshot} input.after
739
+ * @param {WatchedEvents} input.watched
740
+ * @param {import('./contract.js').RunContext} input.ctx
741
+ * @param {{dirs: string[], projectRoot?: string, ports?: number[]}} input.footprint
742
+ * @returns {Promise<import('./contract.js').Observation[]>}
743
+ */
744
+ export async function describeRun(input) {
745
+ const { journey, result, watched, ctx, footprint } = input;
746
+ /** @type {import('./contract.js').Observation[]} */
747
+ const out = [];
748
+ const id = journey.name;
749
+
750
+ // ---- what it printed
751
+ for (const [where, raw, channel, sentence, nothing] of /** @type {const} */ ([
752
+ ['to the screen', result.stdout, 'results', 'printed to the screen', 'printed nothing at all'],
753
+ ['as a complaint', result.stderr, 'complaints', 'complained about', 'complained about nothing'],
754
+ ])) {
755
+ const text = undoOurFootprint(raw, footprint);
756
+ const kept = trimForStorage(text);
757
+ let evidence;
758
+ if (kept.truncated) {
759
+ evidence = path.join(ctx.evidenceDir, `${sanitise(id)}-${sanitise(where)}.txt`);
760
+ try {
761
+ await fsp.mkdir(ctx.evidenceDir, { recursive: true });
762
+ await fsp.writeFile(evidence, text, 'utf8');
763
+ } catch { evidence = undefined; }
764
+ }
765
+ out.push(observation({
766
+ channel,
767
+ path: joinPath('cli', id, where === 'to the screen' ? 'stdout' : 'stderr'),
768
+ value: kept.text,
769
+ says: text === ''
770
+ ? `"${journey.describe}" ${nothing}.`
771
+ : `What "${journey.describe}" ${sentence}${kept.truncated ? `, with the middle left out — the whole of it is ${sizeBucket(kept.bytes)}` : ''}.`,
772
+ evidence,
773
+ journey: id,
774
+ }));
775
+ }
776
+
777
+ // ---- how it finished
778
+ out.push(observation({
779
+ channel: 'complaints',
780
+ path: joinPath('cli', id, 'exit'),
781
+ value: result.timedOut ? 'killed for taking too long' : (result.signal ? `killed by ${result.signal}` : result.code),
782
+ says: result.timedOut
783
+ ? `"${journey.describe}" was still running when its time ran out, so it was stopped.`
784
+ : result.signal
785
+ ? `"${journey.describe}" was killed by ${result.signal} rather than finishing on its own.`
786
+ : result.code === 0
787
+ ? `"${journey.describe}" finished cleanly.`
788
+ : `"${journey.describe}" finished with exit code ${result.code}, which means it thinks something went wrong.`,
789
+ }));
790
+
791
+ // ---- what it left behind
792
+ const changes = compareTrees(input.before, input.after);
793
+ for (const change of changes) {
794
+ out.push(observation({
795
+ channel: 'effects',
796
+ path: joinPath('file', id, change.file),
797
+ value: change.what === 'deleted' ? 'deleted' : { what: change.what, contents: change.now ?? '' },
798
+ says: change.what === 'deleted'
799
+ ? `"${journey.describe}" deleted ${change.file}.`
800
+ : `"${journey.describe}" ${change.what} ${change.file}. Only the contents are compared, so rewriting the same bytes is not a change.`,
801
+ }));
802
+ }
803
+ out.push(observation({
804
+ channel: 'counters',
805
+ path: joinPath('count', id, 'files touched'),
806
+ value: changes.length,
807
+ says: changes.length === 0
808
+ ? `"${journey.describe}" left the folder exactly as it found it.`
809
+ : `"${journey.describe}" created, changed or deleted ${changes.length} file${changes.length === 1 ? '' : 's'}.`,
810
+ }));
811
+
812
+ // ---- what else it started, and what it tried to reach
813
+ if (watched.inForce) {
814
+ for (const [command, times] of [...watched.ran].sort()) {
815
+ out.push(observation({
816
+ channel: 'effects',
817
+ path: joinPath('proc', id, undoOurFootprint(command, footprint)),
818
+ value: times,
819
+ says: `"${journey.describe}" started ${command}${times > 1 ? ` ${times} times` : ''}.`,
820
+ }));
821
+ }
822
+ /** @type {Map<string, number>} */
823
+ const hosts = new Map();
824
+ for (const attempt of watched.reachedOut) {
825
+ const key = attempt.port ? `${attempt.host}:${attempt.port}` : attempt.host;
826
+ hosts.set(key, (hosts.get(key) ?? 0) + 1);
827
+ }
828
+ for (const [host, times] of [...hosts].sort()) {
829
+ out.push(observation({
830
+ channel: 'effects',
831
+ path: joinPath('net', id, host),
832
+ value: `tried ${times} time${times === 1 ? '' : 's'}, refused every time`,
833
+ says: `"${journey.describe}" tried to connect to ${host} and was refused. What it asked for is compared; whether it would have worked is not, because it was never allowed to happen.`,
834
+ covered: false,
835
+ reason: 'irreversible',
836
+ }));
837
+ }
838
+ if (watched.settingsRead.length > 0) {
839
+ out.push(observation({
840
+ channel: 'effects',
841
+ path: joinPath('proc', id, 'environment read'),
842
+ value: watched.settingsRead,
843
+ says: `The environment variables "${journey.describe}" looked at. A new one appearing here means it started depending on something it did not depend on before.`,
844
+ }));
845
+ }
846
+ } else {
847
+ out.push(notCovered({
848
+ channel: 'effects',
849
+ path: joinPath('net', id, 'watched at all'),
850
+ reason: 'not supported here',
851
+ says: `Nothing was watching "${journey.describe}" from the inside — it is not a Node program, or it replaced the environment it was started with. Its files and its output were still checked exactly; its outbound calls and the programs it started were not.`,
852
+ }));
853
+ }
854
+
855
+ // ---- how long, coarsely
856
+ out.push(observation({
857
+ channel: 'counters',
858
+ path: joinPath('count', id, 'duration'),
859
+ value: timeBucket(result.ms),
860
+ says: `"${journey.describe}" took ${timeBucket(result.ms)}. Deliberately rough: exact timings differ on every run and would drown everything else.`,
861
+ }));
862
+
863
+ return out;
864
+ }
865
+
866
+ /**
867
+ * The exported API surface of a library, one observation per name.
868
+ *
869
+ * The whole surface as one blob would report a single enormous difference the moment
870
+ * anything moved, and an agent reading it would have to diff the blob itself. One
871
+ * observation per export means a removed function names itself, and a function that quietly
872
+ * grew a required argument names itself too — which is the break nobody notices until a
873
+ * caller in another repo falls over.
874
+ *
875
+ * @param {import('./contract.js').Journey} journey
876
+ * @param {CommandResult} result
877
+ * @returns {import('./contract.js').Observation[]}
878
+ */
879
+ export function apiSurface(journey, result) {
880
+ /** @type {Record<string, string>} */
881
+ let surface;
882
+ try {
883
+ surface = JSON.parse(result.stdout);
884
+ } catch {
885
+ return [notCovered({
886
+ channel: 'results',
887
+ path: joinPath('export', journey.name, 'readable at all'),
888
+ reason: 'crashed',
889
+ says: `"${journey.describe}" could not be imported, so nothing is known about what it exports. Whatever it printed instead is under "printed".`,
890
+ })];
891
+ }
892
+ const names = Object.keys(surface).sort();
893
+ /** @type {import('./contract.js').Observation[]} */
894
+ const out = names.map((name) => observation({
895
+ channel: 'results',
896
+ path: joinPath('export', journey.name, name),
897
+ value: surface[name],
898
+ says: `The module exports "${name}", which is ${surface[name]}. If this changes, anything calling it from outside has to change too.`,
899
+ }));
900
+ out.push(observation({
901
+ channel: 'counters',
902
+ path: joinPath('count', journey.name, 'exports'),
903
+ value: names.length,
904
+ says: `"${journey.describe}" exports ${names.length} name${names.length === 1 ? '' : 's'}.`,
905
+ }));
906
+ return out;
907
+ }
908
+
909
+ /**
910
+ * A scratch folder under the system temp directory, for callers that do not have one.
911
+ * @param {string} [label]
912
+ */
913
+ export async function scratchFolder(label = 'staysfixed') {
914
+ return fsp.mkdtemp(path.join(os.tmpdir(), `${label}-`));
915
+ }
916
+
917
+ /** True when a path exists. Small enough to inline, useful enough to name. */
918
+ export function exists(/** @type {string} */ file) {
919
+ return fs.existsSync(file);
920
+ }