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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|