staysfixed 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,988 @@
1
+ /**
2
+ * Journeys harvested from the project's own test suite.
3
+ *
4
+ * This is the highest-value source in the tool, and the reason is arithmetic: Terminal Deck
5
+ * has 693 test files that somebody already wrote, that already walk the real paths through
6
+ * the real code, and that are sitting there being used for exactly one thing — saying pass
7
+ * or fail. Run them once with the instrumentation on and you have hundreds of journeys
8
+ * nobody had to write, each one already known to reach real code.
9
+ *
10
+ * THE ASSERTIONS ARE NOT THE POINT. A test that fails still exercised the product, and its
11
+ * journey is still worth keeping — what is harvested is what the test TOUCHED, not what it
12
+ * concluded. A suite that is red today is still a map of the product.
13
+ *
14
+ * HOW "WHAT IT TOUCHED" IS MEASURED. Node writes V8 coverage for a process and its children
15
+ * when `NODE_V8_COVERAGE` is set: every script that executed, and every function inside it
16
+ * that ran. That is exactly the question, answered by the runtime itself, with nothing
17
+ * patched and nothing about the tests changed. Vitest is the exception — its tests run in
18
+ * worker threads whose coverage never reaches that folder, verified here rather than
19
+ * assumed — so vitest is asked for its own V8 coverage report instead, and when the package
20
+ * that produces one is not installed the tool says so and hands over the exact command,
21
+ * rather than quietly harvesting journeys that know nothing about what they touch.
22
+ *
23
+ * WHAT THIS DELIBERATELY DOES NOT DO. It does not patch `fs`, `child_process` or `fetch` to
24
+ * watch effects. Effects are the adapter's job when the journey is WALKED; a harvester that
25
+ * rewrote the runtime under somebody's test suite would be changing the product in order to
26
+ * measure it, and the first strange failure would cost a day of somebody's life.
27
+ *
28
+ * SEQUENTIAL, ALWAYS. One test file at a time, twice each. Two runs of the same suite at
29
+ * once fight over ports, fixtures and temporary folders, and every difference that comes out
30
+ * of that fight is a difference this tool would then report as real.
31
+ */
32
+
33
+ import fs from 'node:fs';
34
+ import fsp from 'node:fs/promises';
35
+ import os from 'node:os';
36
+ import path from 'node:path';
37
+ import { spawn } from 'node:child_process';
38
+
39
+ /** @typedef {import('../types.js').Journey} Journey */
40
+ /** @typedef {import('../types.js').JourneyStep} JourneyStep */
41
+ /** @typedef {import('../types.js').Surface} Surface */
42
+ /** @typedef {import('../adapters/contract.js').Missing} Missing */
43
+
44
+ /**
45
+ * A journey harvested from a test file, carrying what that test touched.
46
+ *
47
+ * `touched` is not compared and is not part of the journey's identity. It is what makes the
48
+ * coverage ledger possible: match these files and function names against the doors the code
49
+ * reader found, and "how deep is this really" stops being a claim and becomes a number.
50
+ *
51
+ * `reproducible` is the receipt for the rule that matters most here: a journey that does not
52
+ * do the same thing twice on the same build is rejected at birth rather than admitted and
53
+ * condemned later. Harvesting runs every file twice, so a harvested journey arrives with
54
+ * that check already done, and `index.js` does not pay for it again.
55
+ *
56
+ * @typedef {Journey & {touched?: Touched, reproducible?: {how: string, at: string}}} SuiteJourney
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} Touched
61
+ * @property {string[]} files Project files that executed, relative to the root.
62
+ * @property {string[]} functions Named functions that ran, as `file:name`.
63
+ * @property {boolean} measured False when nothing could be measured — see `why`.
64
+ * @property {string} why Plain English, always filled in.
65
+ * @property {number} [ranButNotListed]
66
+ * Functions that ran and were cut from `functions` to keep
67
+ * the list readable. It has to be a number rather than a
68
+ * silent slice, because the coverage ledger reads this list
69
+ * to decide which doors were opened — and a truncated list
70
+ * read as a complete one reports work as still to do when
71
+ * it is already done.
72
+ */
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Which runner
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * @typedef {'vitest'|'node:test'|'none'} Runner
80
+ */
81
+
82
+ /**
83
+ * @typedef {object} RunnerDetection
84
+ * @property {Runner} runner
85
+ * @property {number} confidence 0..1.
86
+ * @property {string} why Plain English, always filled in, including for 'none'.
87
+ * @property {string} [binary] The program that runs one test file.
88
+ * @property {string} [configFile] Relative path to the runner's config, when there is one.
89
+ * @property {Missing[]} missing What would unlock more, with the command to get it.
90
+ * @property {string[]} notes
91
+ */
92
+
93
+ /** Folders never worth walking into looking for tests. */
94
+ const SKIP_DIRS = new Set([
95
+ 'node_modules', '.git', 'dist', 'build', 'out', 'release', 'coverage', '.next', '.turbo',
96
+ '.staysfixed', '.cache', 'vendor', '__snapshots__', '.venv', 'venv', 'ios', 'android',
97
+ ]);
98
+
99
+ /**
100
+ * What a test file is called, in every convention either runner accepts.
101
+ *
102
+ * Deliberately not "anything inside a folder called test". That rule sweeps up the shared
103
+ * helper every suite has — `test/support.mjs` in this very repository — and a helper is not
104
+ * a journey: running it on its own reports nothing, and it would be rejected two minutes
105
+ * later having cost a process launch to find out.
106
+ */
107
+ const TEST_FILE = /(^|[./-])(test|spec)\.[cm]?[jt]sx?$|(^|\/)__tests__\/[^/]+\.[cm]?[jt]sx?$/;
108
+
109
+ /**
110
+ * Work out which test runner this project uses, and say how sure that is.
111
+ *
112
+ * The `test` script wins over the dependency list, because a project can have vitest
113
+ * installed for one workspace and run `node --test` in another, and the script is what the
114
+ * person actually runs.
115
+ *
116
+ * @param {string} root
117
+ * @returns {Promise<RunnerDetection>}
118
+ */
119
+ export async function detectRunner(root) {
120
+ /** @type {Record<string, any>} */
121
+ let pkg = {};
122
+ try {
123
+ pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
124
+ } catch {
125
+ return {
126
+ runner: 'none',
127
+ confidence: 1,
128
+ why: 'There is no package.json here, so there is no test suite this tool knows how to run.',
129
+ missing: [],
130
+ notes: [],
131
+ };
132
+ }
133
+
134
+ const scripts = /** @type {Record<string, string>} */ (pkg.scripts ?? {});
135
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
136
+ const testScript = String(scripts.test ?? '');
137
+ const configFile = await firstExisting(root, [
138
+ 'vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs', 'vitest.config.mts',
139
+ 'vite.config.ts', 'vite.config.js', 'vite.config.mjs',
140
+ ]);
141
+
142
+ /** @type {Missing[]} */
143
+ const missing = [];
144
+ /** @type {string[]} */
145
+ const notes = [];
146
+
147
+ const saysVitest = /\bvitest\b/.test(testScript);
148
+ const saysNodeTest = /node\s+--test|--test\b/.test(testScript);
149
+ const hasVitest = 'vitest' in deps;
150
+
151
+ if (saysVitest || (hasVitest && !saysNodeTest)) {
152
+ const binary = await firstExisting(root, [path.join('node_modules', '.bin', 'vitest')]);
153
+ if (!binary) {
154
+ missing.push({
155
+ what: 'vitest, installed in this project',
156
+ unlocks: 'running the suite one file at a time so each one becomes a journey',
157
+ howToGet: 'npm install',
158
+ blocking: true,
159
+ });
160
+ }
161
+ if (!('@vitest/coverage-v8' in deps)) {
162
+ // Verified on this machine: with vitest the tests run in worker threads, and Node's
163
+ // own NODE_V8_COVERAGE folder comes back with the runner's own files in it and none
164
+ // of the project's. Vitest's coverage package is the way to see what a test touched.
165
+ missing.push({
166
+ what: '@vitest/coverage-v8',
167
+ unlocks: 'seeing which source files and functions each test file actually exercised, which is what turns a list of tests into a coverage ledger',
168
+ howToGet: 'npm install --save-dev @vitest/coverage-v8',
169
+ });
170
+ notes.push('Without the coverage package the journeys are still harvested; they just do not know what they touched.');
171
+ }
172
+ return {
173
+ runner: 'vitest',
174
+ confidence: saysVitest ? 1 : 0.8,
175
+ why: saysVitest
176
+ ? `The test script runs vitest (${testScript}).`
177
+ : 'vitest is installed as a dependency and nothing else claims the test script.',
178
+ binary: binary ?? undefined,
179
+ configFile: configFile ?? undefined,
180
+ missing,
181
+ notes,
182
+ };
183
+ }
184
+
185
+ if (saysNodeTest || (await hasNodeTestFiles(root))) {
186
+ return {
187
+ runner: 'node:test',
188
+ confidence: saysNodeTest ? 1 : 0.7,
189
+ why: saysNodeTest
190
+ ? `The test script uses Node's own test runner (${testScript}).`
191
+ : "Test files import node:test, so Node's own runner will run them.",
192
+ binary: process.execPath,
193
+ missing,
194
+ notes,
195
+ };
196
+ }
197
+
198
+ return {
199
+ runner: 'none',
200
+ confidence: 0.9,
201
+ why: testScript
202
+ ? `The test script is "${testScript}", which is not a runner this tool knows how to instrument yet. Only vitest and Node's own test runner are supported.`
203
+ : 'This project has no test script, so there is no suite to harvest journeys from.',
204
+ missing: [
205
+ {
206
+ what: 'a test suite run by vitest or by Node\'s own test runner',
207
+ unlocks: 'journeys nobody has to write — every test file becomes a journey through real code',
208
+ },
209
+ ],
210
+ notes,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * @param {string} root
216
+ * @param {string[]} candidates
217
+ * @returns {Promise<string|null>}
218
+ */
219
+ async function firstExisting(root, candidates) {
220
+ for (const candidate of candidates) {
221
+ if (fs.existsSync(path.join(root, candidate))) return candidate;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * @param {string} root
228
+ * @returns {Promise<boolean>}
229
+ */
230
+ async function hasNodeTestFiles(root) {
231
+ const files = await listTestFiles(root, { limit: 20 });
232
+ for (const rel of files.files) {
233
+ try {
234
+ const text = await fsp.readFile(path.join(root, rel), 'utf8');
235
+ if (/from\s+['"]node:test['"]|require\(['"]node:test['"]\)/.test(text)) return true;
236
+ } catch { /* an unreadable file answers nothing */ }
237
+ }
238
+ return false;
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Which files
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /**
246
+ * Every test file in the project, in a fixed order.
247
+ *
248
+ * Ordered, because the order journeys come out in ends up as the order they are walked in,
249
+ * and an order that changes between runs is one more thing that looks like a difference.
250
+ *
251
+ * @param {string} root
252
+ * @param {{limit?: number, only?: string[], skipDirs?: Set<string>}} [opts]
253
+ * @returns {Promise<{files: string[], scanned: number, cappedAt?: number}>}
254
+ */
255
+ export async function listTestFiles(root, opts = {}) {
256
+ const skip = opts.skipDirs ?? SKIP_DIRS;
257
+ /** @type {string[]} */
258
+ const found = [];
259
+ let scanned = 0;
260
+ /** @type {string[]} */
261
+ const stack = [root];
262
+ while (stack.length > 0) {
263
+ const dir = /** @type {string} */ (stack.pop());
264
+ /** @type {import('node:fs').Dirent[]} */
265
+ let entries;
266
+ try {
267
+ entries = await fsp.readdir(dir, { withFileTypes: true });
268
+ } catch {
269
+ continue;
270
+ }
271
+ for (const entry of entries) {
272
+ const full = path.join(dir, entry.name);
273
+ if (entry.isDirectory()) {
274
+ if (!skip.has(entry.name) && !entry.name.startsWith('.')) stack.push(full);
275
+ continue;
276
+ }
277
+ if (!entry.isFile()) continue;
278
+ scanned++;
279
+ const rel = path.relative(root, full).split(path.sep).join('/');
280
+ if (!TEST_FILE.test(rel)) continue;
281
+ if (opts.only && !opts.only.some((pattern) => rel.includes(pattern))) continue;
282
+ found.push(rel);
283
+ }
284
+ }
285
+ found.sort();
286
+ if (opts.limit !== undefined && found.length > opts.limit) {
287
+ return { files: found.slice(0, opts.limit), scanned, cappedAt: opts.limit };
288
+ }
289
+ return { files: found, scanned };
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Running one file, watched
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /**
297
+ * @typedef {object} OneRun
298
+ * @property {string[]} tests Names of the checks that reported, in a fixed order.
299
+ * @property {number} passed
300
+ * @property {number} failed
301
+ * @property {number} exitCode
302
+ * @property {boolean} ran False means the file never got as far as reporting anything.
303
+ * @property {Touched} touched
304
+ * @property {number} durationMs
305
+ * @property {string} [trouble] One plain sentence when something went wrong.
306
+ */
307
+
308
+ /**
309
+ * The command that runs exactly one test file. Kept in one place because the harvested
310
+ * journey carries it, and whatever walks the journey later has to run the same thing —
311
+ * a journey that is walked differently from the way it was harvested is not the same journey.
312
+ *
313
+ * @param {Runner} runner
314
+ * @param {string} file Relative to the root.
315
+ * @param {{binary?: string, root: string, coverageDir?: string, resultFile?: string}} opts
316
+ * @returns {{command: string, argv: string[]}}
317
+ */
318
+ export function runnerCommand(runner, file, opts) {
319
+ if (runner === 'vitest') {
320
+ const command = opts.binary ? path.resolve(opts.root, opts.binary) : 'npx';
321
+ const argv = opts.binary ? [] : ['vitest'];
322
+ argv.push('run', '--pool=forks', '--no-file-parallelism', '--reporter=json');
323
+ if (opts.resultFile) argv.push(`--outputFile=${opts.resultFile}`);
324
+ if (opts.coverageDir) {
325
+ argv.push(
326
+ '--coverage',
327
+ '--coverage.provider=v8',
328
+ '--coverage.reporter=json',
329
+ '--coverage.all=false',
330
+ `--coverage.reportsDirectory=${opts.coverageDir}`,
331
+ );
332
+ }
333
+ argv.push(file);
334
+ return { command, argv };
335
+ }
336
+ return {
337
+ command: opts.binary ?? process.execPath,
338
+ argv: ['--test', '--test-reporter=tap', file],
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Run one test file and watch what it touches.
344
+ *
345
+ * Never throws. A test file that hangs, crashes or refuses to start comes back as a run
346
+ * that did not happen, with the reason in plain English, because one bad file must not cost
347
+ * the other six hundred.
348
+ *
349
+ * @param {object} opts
350
+ * @param {string} opts.root
351
+ * @param {string} opts.file Relative to the root.
352
+ * @param {Runner} opts.runner
353
+ * @param {string} opts.scratchDir Somewhere to write coverage and reports. Not the project.
354
+ * @param {string} [opts.binary]
355
+ * @param {boolean} [opts.coverage] Default true.
356
+ * @param {number} [opts.timeoutMs] Default two minutes.
357
+ * @param {AbortSignal} [opts.signal]
358
+ * @returns {Promise<OneRun>}
359
+ */
360
+ export async function runOneFile(opts) {
361
+ const started = Date.now();
362
+ const stamp = `${path.basename(opts.file).replace(/[^a-zA-Z0-9]+/g, '-')}-${started}-${Math.random().toString(36).slice(2, 8)}`;
363
+ const coverageDir = path.join(opts.scratchDir, `cov-${stamp}`);
364
+ const resultFile = path.join(opts.scratchDir, `result-${stamp}.json`);
365
+ await fsp.mkdir(coverageDir, { recursive: true });
366
+
367
+ const wantCoverage = opts.coverage !== false;
368
+ const { command, argv } = runnerCommand(opts.runner, opts.file, {
369
+ binary: opts.binary,
370
+ root: opts.root,
371
+ coverageDir: wantCoverage ? coverageDir : undefined,
372
+ resultFile: opts.runner === 'vitest' ? resultFile : undefined,
373
+ });
374
+
375
+ /** @type {NodeJS.ProcessEnv} */
376
+ const env = { ...process.env, STAYSFIXED_HARVEST: '1' };
377
+ // Node's own runner writes coverage for the process and every child it spawns, which is
378
+ // exactly how a test file's own run gets measured. Vitest is asked for its own instead.
379
+ if (wantCoverage && opts.runner === 'node:test') env.NODE_V8_COVERAGE = coverageDir;
380
+ else delete env.NODE_V8_COVERAGE;
381
+
382
+ const result = await runToEnd(command, argv, {
383
+ cwd: opts.root,
384
+ env,
385
+ timeoutMs: opts.timeoutMs ?? 120_000,
386
+ signal: opts.signal,
387
+ });
388
+
389
+ /** @type {OneRun} */
390
+ const run = {
391
+ tests: [],
392
+ passed: 0,
393
+ failed: 0,
394
+ exitCode: result.code,
395
+ ran: false,
396
+ touched: { files: [], functions: [], measured: false, why: 'Nothing was measured.' },
397
+ durationMs: Date.now() - started,
398
+ };
399
+ if (result.trouble) run.trouble = result.trouble;
400
+
401
+ if (opts.runner === 'vitest') {
402
+ const parsed = await readVitestResults(resultFile);
403
+ run.tests = parsed.tests;
404
+ run.passed = parsed.passed;
405
+ run.failed = parsed.failed;
406
+ run.ran = parsed.tests.length > 0;
407
+ run.touched = wantCoverage
408
+ ? await readVitestCoverage(path.join(coverageDir, 'coverage-final.json'), opts.root)
409
+ : { files: [], functions: [], measured: false, why: 'Coverage was switched off for this run.' };
410
+ } else {
411
+ const parsed = parseTap(result.stdout);
412
+ run.tests = parsed.tests;
413
+ run.passed = parsed.passed;
414
+ run.failed = parsed.failed;
415
+ run.ran = parsed.tests.length > 0;
416
+ run.touched = wantCoverage
417
+ ? await readNodeCoverage(coverageDir, opts.root)
418
+ : { files: [], functions: [], measured: false, why: 'Coverage was switched off for this run.' };
419
+ }
420
+
421
+ if (!run.ran && !run.trouble) {
422
+ run.trouble =
423
+ result.code === 0
424
+ ? 'It finished without reporting a single check, so there is nothing here to walk.'
425
+ : `It stopped with exit code ${result.code} before reporting anything.`;
426
+ }
427
+
428
+ await fsp.rm(coverageDir, { recursive: true, force: true }).catch(() => {});
429
+ await fsp.rm(resultFile, { force: true }).catch(() => {});
430
+ return run;
431
+ }
432
+
433
+ /**
434
+ * Run a command to the end and keep what it said. Kills the whole process group on a
435
+ * timeout, because a test runner that hangs usually has children hanging with it.
436
+ *
437
+ * @param {string} command
438
+ * @param {string[]} argv
439
+ * @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number, signal?: AbortSignal}} opts
440
+ * @returns {Promise<{code: number, stdout: string, stderr: string, trouble?: string}>}
441
+ */
442
+ function runToEnd(command, argv, opts) {
443
+ return new Promise((resolve) => {
444
+ /** @type {import('node:child_process').ChildProcess} */
445
+ let child;
446
+ try {
447
+ child = spawn(command, argv, {
448
+ cwd: opts.cwd,
449
+ env: opts.env,
450
+ stdio: ['ignore', 'pipe', 'pipe'],
451
+ detached: process.platform !== 'win32',
452
+ });
453
+ } catch (error) {
454
+ resolve({ code: -1, stdout: '', stderr: '', trouble: `It could not be started: ${String(error)}` });
455
+ return;
456
+ }
457
+
458
+ let stdout = '';
459
+ let stderr = '';
460
+ let settled = false;
461
+ /** @type {string|undefined} */
462
+ let trouble;
463
+
464
+ const stop = () => {
465
+ if (!child.pid) return;
466
+ try {
467
+ if (process.platform === 'win32') child.kill('SIGKILL');
468
+ else process.kill(-child.pid, 'SIGKILL');
469
+ } catch { /* it had already gone */ }
470
+ };
471
+
472
+ const timer = setTimeout(() => {
473
+ trouble = `It was still running after ${Math.round(opts.timeoutMs / 1000)} seconds, so it was stopped.`;
474
+ stop();
475
+ }, opts.timeoutMs);
476
+
477
+ const onAbort = () => {
478
+ trouble = 'The harvest was stopped before this file finished.';
479
+ stop();
480
+ };
481
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
482
+
483
+ child.stdout?.on('data', (chunk) => { if (stdout.length < 4_000_000) stdout += chunk; });
484
+ child.stderr?.on('data', (chunk) => { if (stderr.length < 1_000_000) stderr += chunk; });
485
+
486
+ /** @param {number} code */
487
+ const done = (code) => {
488
+ if (settled) return;
489
+ settled = true;
490
+ clearTimeout(timer);
491
+ opts.signal?.removeEventListener('abort', onAbort);
492
+ resolve({ code, stdout, stderr, trouble });
493
+ };
494
+
495
+ child.on('error', (error) => {
496
+ trouble = `It could not be started: ${error.message}`;
497
+ done(-1);
498
+ });
499
+ child.on('close', (code) => done(code ?? -1));
500
+ });
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Reading what came back
505
+ // ---------------------------------------------------------------------------
506
+
507
+ /**
508
+ * Pull the check names out of TAP.
509
+ *
510
+ * Names rather than counts, because two runs producing thirteen checks each is not the same
511
+ * evidence as two runs producing the same thirteen checks.
512
+ *
513
+ * @param {string} output
514
+ * @returns {{tests: string[], passed: number, failed: number}}
515
+ */
516
+ export function parseTap(output) {
517
+ /** @type {string[]} */
518
+ const tests = [];
519
+ let passed = 0;
520
+ let failed = 0;
521
+ for (const line of output.split('\n')) {
522
+ const match = /^\s*(not )?ok\s+\d+\s*-?\s*(.*)$/.exec(line);
523
+ if (!match) continue;
524
+ const name = match[2].replace(/\s*#\s*(SKIP|TODO).*$/i, '').trim();
525
+ if (name === '') continue;
526
+ tests.push(name);
527
+ if (match[1]) failed++;
528
+ else passed++;
529
+ }
530
+ tests.sort();
531
+ return { tests, passed, failed };
532
+ }
533
+
534
+ /**
535
+ * @param {string} file
536
+ * @returns {Promise<{tests: string[], passed: number, failed: number}>}
537
+ */
538
+ async function readVitestResults(file) {
539
+ try {
540
+ const report = JSON.parse(await fsp.readFile(file, 'utf8'));
541
+ /** @type {string[]} */
542
+ const tests = [];
543
+ let passed = 0;
544
+ let failed = 0;
545
+ for (const suite of report.testResults ?? []) {
546
+ for (const assertion of suite.assertionResults ?? []) {
547
+ const name = [assertion.ancestorTitles?.join(' > '), assertion.title].filter(Boolean).join(' > ');
548
+ tests.push(name);
549
+ if (assertion.status === 'passed') passed++;
550
+ else if (assertion.status === 'failed') failed++;
551
+ }
552
+ }
553
+ tests.sort();
554
+ return { tests, passed, failed };
555
+ } catch {
556
+ return { tests: [], passed: 0, failed: 0 };
557
+ }
558
+ }
559
+
560
+ /**
561
+ * How many functions from one file are worth writing down. A journey that lists nine
562
+ * hundred function names is not evidence, it is a wall, and nobody reads a wall.
563
+ */
564
+ export const TOUCHED_FUNCTION_LIMIT = 400;
565
+
566
+ /**
567
+ * What a run of Node's own test runner touched, out of the raw V8 coverage it wrote.
568
+ *
569
+ * @param {string} dir Folder NODE_V8_COVERAGE was pointed at.
570
+ * @param {string} root Project root, so paths come back relative and readable.
571
+ * @returns {Promise<Touched>}
572
+ */
573
+ export async function readNodeCoverage(dir, root) {
574
+ /** @type {Set<string>} */
575
+ const files = new Set();
576
+ /** @type {Set<string>} */
577
+ const functions = new Set();
578
+ /** @type {string[]} */
579
+ let entries = [];
580
+ try {
581
+ entries = await fsp.readdir(dir);
582
+ } catch {
583
+ return { files: [], functions: [], measured: false, why: 'Node wrote no coverage folder, so what the tests touched is not known.' };
584
+ }
585
+ for (const entry of entries) {
586
+ if (!entry.endsWith('.json')) continue;
587
+ /** @type {any} */
588
+ let report;
589
+ try {
590
+ report = JSON.parse(await fsp.readFile(path.join(dir, entry), 'utf8'));
591
+ } catch {
592
+ continue;
593
+ }
594
+ for (const script of report.result ?? []) {
595
+ const rel = relativeIfInside(script.url, root);
596
+ if (!rel) continue;
597
+ files.add(rel);
598
+ for (const fn of script.functions ?? []) {
599
+ if (!fn.functionName) continue;
600
+ const ran = (fn.ranges ?? []).some((/** @type {any} */ range) => range.count > 0);
601
+ if (ran) functions.add(`${rel}:${fn.functionName}`);
602
+ }
603
+ }
604
+ }
605
+ if (files.size === 0) {
606
+ return {
607
+ files: [],
608
+ functions: [],
609
+ measured: false,
610
+ why: 'Coverage came back with none of the project\'s own files in it, so what the tests touched is not known.',
611
+ };
612
+ }
613
+ return keptFunctions(files, functions, `Node reported ${files.size} of the project's own files executing.`);
614
+ }
615
+
616
+ /**
617
+ * The function list, cut to something readable, saying out loud how much was cut.
618
+ *
619
+ * The cut itself is old: a journey listing nine hundred function names is a wall and nobody
620
+ * reads a wall. Announcing it is not. The coverage ledger matches this list against the
621
+ * doors the code reader found, so a list quietly missing three hundred entries makes the
622
+ * ledger ask for work that has already been done — which is the same failure as a green run
623
+ * that means less than it looks like, pointed the other way.
624
+ *
625
+ * @param {Set<string>} files
626
+ * @param {Set<string>} functions
627
+ * @param {string} why
628
+ * @returns {Touched}
629
+ */
630
+ function keptFunctions(files, functions, why) {
631
+ const all = [...functions].sort();
632
+ const kept = all.slice(0, TOUCHED_FUNCTION_LIMIT);
633
+ /** @type {Touched} */
634
+ const touched = { files: [...files].sort(), functions: kept, measured: true, why };
635
+ if (all.length > kept.length) {
636
+ touched.ranButNotListed = all.length - kept.length;
637
+ touched.why = `${why} ${all.length} named functions ran and the ${kept.length} listed here are the first of them in alphabetical order, so ${touched.ranButNotListed} that really did run are not named. Anything reading this list to work out what was covered will undercount by that much.`;
638
+ }
639
+ return touched;
640
+ }
641
+
642
+ /**
643
+ * The same question, answered by vitest's own coverage report.
644
+ *
645
+ * @param {string} file coverage-final.json
646
+ * @param {string} root
647
+ * @returns {Promise<Touched>}
648
+ */
649
+ export async function readVitestCoverage(file, root) {
650
+ /** @type {any} */
651
+ let report;
652
+ try {
653
+ report = JSON.parse(await fsp.readFile(file, 'utf8'));
654
+ } catch {
655
+ return {
656
+ files: [],
657
+ functions: [],
658
+ measured: false,
659
+ why: 'Vitest wrote no coverage report. Its tests run in worker threads, so this needs @vitest/coverage-v8 installed — without it the journey is still good, it just does not know what it touched.',
660
+ };
661
+ }
662
+ /** @type {Set<string>} */
663
+ const files = new Set();
664
+ /** @type {Set<string>} */
665
+ const functions = new Set();
666
+ for (const [absolute, entry] of Object.entries(/** @type {Record<string, any>} */ (report))) {
667
+ const rel = relativeIfInside(absolute, root);
668
+ if (!rel) continue;
669
+ files.add(rel);
670
+ for (const [id, meta] of Object.entries(/** @type {Record<string, any>} */ (entry.fnMap ?? {}))) {
671
+ const hits = entry.f?.[id] ?? 0;
672
+ if (hits > 0 && meta?.name) functions.add(`${rel}:${meta.name}`);
673
+ }
674
+ }
675
+ if (files.size === 0) {
676
+ return { files: [], functions: [], measured: false, why: 'The coverage report named none of the project\'s own files.' };
677
+ }
678
+ return keptFunctions(files, functions, `Vitest reported ${files.size} of the project's own files executing.`);
679
+ }
680
+
681
+ /**
682
+ * A path inside the project, relative and readable — or null for anything outside it, which
683
+ * is node's own internals and everybody's dependencies.
684
+ *
685
+ * @param {string} url A file URL or an absolute path.
686
+ * @param {string} root
687
+ * @returns {string|null}
688
+ */
689
+ export function relativeIfInside(url, root) {
690
+ let absolute = String(url);
691
+ if (absolute.startsWith('file://')) {
692
+ try {
693
+ absolute = new URL(absolute).pathname;
694
+ } catch {
695
+ return null;
696
+ }
697
+ }
698
+ if (!path.isAbsolute(absolute)) return null;
699
+ const rel = path.relative(root, absolute).split(path.sep).join('/');
700
+ if (rel === '' || rel.startsWith('..')) return null;
701
+ if (rel.includes('node_modules/')) return null;
702
+ // Node reports a script it was handed on the command line as `[eval1]`, resolved against
703
+ // the working folder, so it arrives looking exactly like a file in the project. It is not
704
+ // one, and a file that does not exist cannot be a door anything walked through.
705
+ if (!/\.[cm]?[jt]sx?$/.test(rel)) return null;
706
+ return rel;
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // The harvest
711
+ // ---------------------------------------------------------------------------
712
+
713
+ /**
714
+ * @typedef {object} HarvestOptions
715
+ * @property {string} root
716
+ * @property {Runner} [runner] Detected when not given.
717
+ * @property {string} [binary]
718
+ * @property {Surface} [surface] Default 'library'.
719
+ * @property {string[]} [files] Exact files, relative to the root. Overrides listing.
720
+ * @property {string[]} [only] Substrings a test file's path must contain.
721
+ * @property {number} [limit] Stop after this many files. Coverage says so out loud.
722
+ * @property {1|2} [repeat] Runs per file. Two is the default and it is the point:
723
+ * a journey that does not reproduce twice on the same
724
+ * build is rejected at birth rather than admitted and
725
+ * condemned later.
726
+ * @property {boolean} [coverage] Measure what each file touched. Default true.
727
+ * @property {number} [timeoutMs]
728
+ * @property {string} [scratchDir] Somewhere to write. A temp folder by default. NEVER
729
+ * the project.
730
+ * @property {(message: string) => void} [log]
731
+ * @property {AbortSignal} [signal]
732
+ * @property {boolean} [dryRun] List what would be run and run nothing.
733
+ */
734
+
735
+ /**
736
+ * @typedef {object} HarvestReport
737
+ * @property {Runner} runner
738
+ * @property {string} why Why that runner, in plain English.
739
+ * @property {number} testFilesFound
740
+ * @property {number} testFilesRun
741
+ * @property {number} journeys
742
+ * @property {number} checks Individual checks the harvested files contain.
743
+ * @property {number} touchedFiles Distinct project files the suite reached.
744
+ * @property {boolean} touchedMeasured False when nothing could see what was touched.
745
+ * @property {{file: string, why: string}[]} rejected
746
+ * Files that produced no journey, and why. This is
747
+ * missing coverage, not a pass.
748
+ * @property {{file: string, failed: number}[]} failing
749
+ * Files whose checks did not all pass. Kept anyway —
750
+ * what a test exercises is useful even when it is red.
751
+ * @property {Missing[]} missing
752
+ * @property {string[]} notes
753
+ * @property {number} durationMs
754
+ */
755
+
756
+ /**
757
+ * Harvest journeys out of a project's own test suite.
758
+ *
759
+ * @param {HarvestOptions} opts
760
+ * @returns {Promise<{journeys: SuiteJourney[], report: HarvestReport}>}
761
+ */
762
+ export async function harvestJourneys(opts) {
763
+ const started = Date.now();
764
+ const root = path.resolve(opts.root);
765
+ const log = opts.log ?? (() => {});
766
+ const detection = opts.runner
767
+ ? { runner: opts.runner, why: 'The runner was named by the caller.', binary: opts.binary, missing: [], notes: [] }
768
+ : await detectRunner(root);
769
+ const runner = /** @type {Runner} */ (detection.runner);
770
+
771
+ /** @type {HarvestReport} */
772
+ const report = {
773
+ runner,
774
+ why: detection.why,
775
+ testFilesFound: 0,
776
+ testFilesRun: 0,
777
+ journeys: 0,
778
+ checks: 0,
779
+ touchedFiles: 0,
780
+ touchedMeasured: false,
781
+ rejected: [],
782
+ failing: [],
783
+ missing: [...(detection.missing ?? [])],
784
+ notes: [...(detection.notes ?? [])],
785
+ durationMs: 0,
786
+ };
787
+
788
+ if (runner === 'none') {
789
+ report.durationMs = Date.now() - started;
790
+ return { journeys: [], report };
791
+ }
792
+
793
+ const listed = opts.files
794
+ ? { files: opts.files, scanned: opts.files.length, cappedAt: undefined }
795
+ : await listTestFiles(root, { limit: opts.limit, only: opts.only });
796
+ report.testFilesFound = listed.files.length;
797
+ if (listed.cappedAt !== undefined) {
798
+ report.notes.push(
799
+ `Only the first ${listed.cappedAt} test files were harvested. The rest of the suite is not covered by these journeys.`,
800
+ );
801
+ }
802
+
803
+ if (opts.dryRun) {
804
+ report.notes.push('This was a dry run: the files were listed and none of them were run.');
805
+ report.durationMs = Date.now() - started;
806
+ return { journeys: [], report };
807
+ }
808
+
809
+ const scratchDir = opts.scratchDir ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-harvest-')));
810
+ await fsp.mkdir(scratchDir, { recursive: true });
811
+ const repeat = opts.repeat ?? 2;
812
+
813
+ /** @type {SuiteJourney[]} */
814
+ const journeys = [];
815
+ /** @type {Set<string>} */
816
+ const touchedEverything = new Set();
817
+
818
+ for (const file of listed.files) {
819
+ if (opts.signal?.aborted) {
820
+ report.rejected.push({ file, why: 'The harvest was stopped before this file was reached.' });
821
+ continue;
822
+ }
823
+ log(`Running ${file}${repeat > 1 ? ' (twice, to see whether it repeats)' : ''}.`);
824
+
825
+ /** @type {OneRun[]} */
826
+ const runs = [];
827
+ for (let i = 0; i < repeat; i++) {
828
+ // Sequential on purpose. Two runs of the same test file at the same time share
829
+ // ports, fixtures and temporary folders, and every difference that comes out of
830
+ // that fight is one this tool would go on to report as real.
831
+ runs.push(
832
+ await runOneFile({
833
+ root,
834
+ file,
835
+ runner,
836
+ scratchDir,
837
+ binary: detection.binary,
838
+ coverage: opts.coverage,
839
+ timeoutMs: opts.timeoutMs,
840
+ signal: opts.signal,
841
+ }),
842
+ );
843
+ }
844
+ report.testFilesRun++;
845
+
846
+ const first = runs[0];
847
+ if (!first.ran) {
848
+ report.rejected.push({ file, why: first.trouble ?? 'It reported nothing at all.' });
849
+ continue;
850
+ }
851
+ const disagreement = repeat > 1 ? disagree(runs[0], runs[1]) : null;
852
+ if (disagreement) {
853
+ report.rejected.push({ file, why: disagreement });
854
+ continue;
855
+ }
856
+
857
+ const touched = first.touched.measured ? first.touched : runs[runs.length - 1].touched;
858
+ for (const touchedFile of touched.files) touchedEverything.add(touchedFile);
859
+ if (first.failed > 0) report.failing.push({ file, failed: first.failed });
860
+ report.checks += first.tests.length;
861
+
862
+ journeys.push(journeyForTestFile({
863
+ root,
864
+ file,
865
+ runner,
866
+ surface: opts.surface ?? 'library',
867
+ binary: detection.binary,
868
+ run: first,
869
+ repeated: repeat > 1,
870
+ timeoutMs: opts.timeoutMs,
871
+ }));
872
+ }
873
+
874
+ if (!opts.scratchDir) await fsp.rm(scratchDir, { recursive: true, force: true }).catch(() => {});
875
+
876
+ report.journeys = journeys.length;
877
+ report.touchedFiles = touchedEverything.size;
878
+ report.touchedMeasured = journeys.some((j) => j.touched?.measured === true);
879
+ if (!report.touchedMeasured && journeys.length > 0) {
880
+ report.notes.push(
881
+ 'These journeys do not know which files they touch, so they cannot say which of the doors in the code they open.',
882
+ );
883
+ }
884
+ report.durationMs = Date.now() - started;
885
+ return { journeys, report };
886
+ }
887
+
888
+ /**
889
+ * Did the same test file do the same thing twice?
890
+ *
891
+ * The comparison is on WHAT WAS EXERCISED — the checks that reported and the files that
892
+ * executed — and never on how long anything took or how many times a line ran. A test that
893
+ * reports different checks, or reaches different code, on two runs of identical bytes is
894
+ * not a journey: whatever it later says about a change, it was already saying about nothing.
895
+ *
896
+ * @param {OneRun} a
897
+ * @param {OneRun} b
898
+ * @returns {string|null} the reason it is rejected, or null when it repeats
899
+ */
900
+ export function disagree(a, b) {
901
+ if (!b.ran) return 'It reported checks the first time and nothing the second, so it does not repeat.';
902
+ const missingNames = onlyIn(a.tests, b.tests);
903
+ const extraNames = onlyIn(b.tests, a.tests);
904
+ if (missingNames.length > 0 || extraNames.length > 0) {
905
+ const example = missingNames[0] ?? extraNames[0];
906
+ return `Two runs of the same code reported different checks (for instance "${example}"), so this file does not repeat.`;
907
+ }
908
+ if (a.touched.measured && b.touched.measured) {
909
+ const missingFiles = onlyIn(a.touched.files, b.touched.files);
910
+ const extraFiles = onlyIn(b.touched.files, a.touched.files);
911
+ if (missingFiles.length > 0 || extraFiles.length > 0) {
912
+ const example = missingFiles[0] ?? extraFiles[0];
913
+ return `Two runs of the same code went through different files (for instance ${example}), so this file does not repeat.`;
914
+ }
915
+ }
916
+ return null;
917
+ }
918
+
919
+ /**
920
+ * @param {string[]} a
921
+ * @param {string[]} b
922
+ * @returns {string[]}
923
+ */
924
+ function onlyIn(a, b) {
925
+ const other = new Set(b);
926
+ return a.filter((item) => !other.has(item));
927
+ }
928
+
929
+ /**
930
+ * One test file, as a journey.
931
+ *
932
+ * @param {object} spec
933
+ * @param {string} spec.root
934
+ * @param {string} spec.file
935
+ * @param {Runner} spec.runner
936
+ * @param {Surface} spec.surface
937
+ * @param {string} [spec.binary]
938
+ * @param {OneRun} spec.run
939
+ * @param {boolean} spec.repeated
940
+ * @param {number} [spec.timeoutMs]
941
+ * @returns {SuiteJourney}
942
+ */
943
+ export function journeyForTestFile(spec) {
944
+ const { command, argv } = runnerCommand(spec.runner, spec.file, { binary: spec.binary, root: spec.root });
945
+ const count = spec.run.tests.length;
946
+ /** @type {JourneyStep} */
947
+ const step = {
948
+ act: 'run-tests',
949
+ runner: spec.runner,
950
+ file: spec.file,
951
+ command,
952
+ argv,
953
+ tests: spec.run.tests,
954
+ note: 'Run this exactly as it was harvested. A test file run a different way is a different journey.',
955
+ };
956
+ /** @type {SuiteJourney} */
957
+ const journey = {
958
+ name: `suite-${slugPath(spec.file)}`,
959
+ describe: `run the ${count} ${count === 1 ? 'check' : 'checks'} in ${spec.file} and watch what they touch`,
960
+ source: 'suite',
961
+ surface: spec.surface,
962
+ from: spec.file,
963
+ channels: ['results', 'complaints', 'counters'],
964
+ steps: [step],
965
+ timeoutMs: spec.timeoutMs,
966
+ touched: spec.run.touched,
967
+ };
968
+ if (spec.repeated) {
969
+ journey.reproducible = {
970
+ how: 'It was run twice while it was harvested, and both runs reported the same checks and went through the same files.',
971
+ at: new Date().toISOString(),
972
+ };
973
+ }
974
+ return journey;
975
+ }
976
+
977
+ /**
978
+ * A file path as a journey name: readable, file-safe, and unique to that path.
979
+ * @param {string} file
980
+ * @returns {string}
981
+ */
982
+ export function slugPath(file) {
983
+ return String(file)
984
+ .replace(/\.[cm]?[jt]sx?$/, '')
985
+ .toLowerCase()
986
+ .replace(/[^a-z0-9]+/g, '-')
987
+ .replace(/^-+|-+$/g, '');
988
+ }