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