@mjasnikovs/pi-task 0.38.9 → 0.38.11

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.
@@ -45,43 +45,24 @@
45
45
  * scripts/boot-skip-verdict-ab.ts two-armed deterministic A/B + invariants
46
46
  * scripts/boot-skip-fp-suite.ts zero-FP arms over every local repo
47
47
  */
48
- import { spawn, spawnSync } from 'node:child_process';
49
48
  import { existsSync, readFileSync } from 'node:fs';
50
- import * as net from 'node:net';
51
49
  import * as path from 'node:path';
52
50
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
53
51
  import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
54
52
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
55
- import { readLaunchManifest, inertLaunchContractNote } from './launch-manifest.js';
53
+ import { readLaunchManifest, inertLaunchContractNote, packageScripts, makeHasTarget } from './launch-manifest.js';
54
+ import { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners, recoverOrphanPort, defaultFindPortHolder } from './boot-probe.js';
56
55
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
57
56
  import { runRenderCheck } from './render-check.js';
58
- import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
59
- import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
60
- import { classifyCommandRun, spawnCommand, outputTail, INFRA_GAP_OUTPUT_RE } from './command-run.js';
57
+ import { runDeepRenderCheck } from './deep-render-check.js';
58
+ import { resolveRunner, runnerEnv } from './runner-resolve.js';
59
+ import { classifyCommandRun, spawnCommand, INFRA_GAP_OUTPUT_RE } from './command-run.js';
61
60
  import { findLaunchConfigGap, probeEnv, configGapUnobservedNote } from './launch-config-gap.js';
62
61
  import { taskThatIntroduced } from './task-provenance.js';
63
62
  import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
64
63
  import { findMissingEnvDeclarations, envGateFailureText, scanEnvTemplateClosure, inertClosure, trackedFiles } from './env-template-closure.js';
65
64
  import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
66
65
  import { makefileRecipe } from './command-shrink.js';
67
- function packageScripts(cwd) {
68
- try {
69
- const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
70
- return j.scripts ?? {};
71
- }
72
- catch {
73
- return {};
74
- }
75
- }
76
- function makeHasTarget(cwd, target) {
77
- try {
78
- const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
79
- return new RegExp(`^${target}:`, 'm').test(mk);
80
- }
81
- catch {
82
- return false;
83
- }
84
- }
85
66
  /**
86
67
  * The project's OWN whole-repo integration commands (test, then build — test
87
68
  * first because it is the richer signal and the more common script). First
@@ -198,720 +179,6 @@ export function discoverLockfileChecks(cwd) {
198
179
  }
199
180
  return cmds;
200
181
  }
201
- /** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
202
- function commandTokens(member) {
203
- const t = member.trim().split(/\s+/).filter(Boolean);
204
- while (t.length > 0
205
- && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]) || /^(?:sudo|exec|env)$/.test(t[0]))) {
206
- t.shift();
207
- }
208
- return t;
209
- }
210
- /** The chain members of a shell script body, in order (`&&`, `||`, `;`, `|`). */
211
- function chainMembers(body) {
212
- return body
213
- .split(/&&|\|\||;|\|/)
214
- .map(s => s.trim())
215
- .filter(s => s.length > 0);
216
- }
217
- /** Container/infra orchestration: `docker compose … up`, `docker-compose … up -d`,
218
- * `podman-compose … up`, `docker run …`. The verb must be a bare token, so a
219
- * filename like `docker-compose.dev.yml` never counts as one. */
220
- function isContainerOrchestration(member) {
221
- const t = commandTokens(member);
222
- if (t.length === 0)
223
- return false;
224
- const bin = path.posix.basename(t[0]);
225
- if (!/^(?:docker|podman|nerdctl)(?:-compose)?$/.test(bin))
226
- return false;
227
- const verbs = new Set(['up', 'start', 'run']);
228
- return t.slice(1).some(tok => verbs.has(tok));
229
- }
230
- const MULTIPLEXER_RE = /^(?:concurrently|npm-run-all|run-p|run-s|turbo)$/;
231
- /** A watcher that recompiles ASSETS and never listens: the tool is a
232
- * bundler/compiler/preprocessor AND it is in watch mode. `bun run --watch x.ts`
233
- * is deliberately NOT here — that re-executes an entrypoint, which may serve. */
234
- const ASSET_TOOL_RE = /(?:^|[\s/@])(?:tailwindcss|postcss|sass|node-sass|less|stylus|esbuild|rollup|webpack|parcel|swc|babel|tsc|tsup|chokidar)(?:$|[\s"'])/;
235
- const WATCH_FLAG_RE = /(?:^|\s)(?:--watch|-w|--watch=[^\s]*)(?:\s|$)/;
236
- /** The quoted commands a multiplexer runs, or its bare script-name arguments
237
- * resolved through the manifest (`run-p dev:css dev:js`). One level only. */
238
- function multiplexerChildren(member, scripts) {
239
- const quoted = [...member.matchAll(/"([^"]+)"|'([^']+)'/g)].map(m => m[1] ?? m[2]);
240
- if (quoted.length > 0)
241
- return quoted;
242
- const t = commandTokens(member)
243
- .slice(1)
244
- .filter(a => !a.startsWith('-'));
245
- return t.flatMap(name => (scripts[name] !== undefined ? [scripts[name]] : []));
246
- }
247
- /** Every member of the chain that could plausibly stay up and serve. Members that
248
- * are one-shot setup (`mkdir`, `sleep`, an `until … done` wait loop) are not
249
- * themselves launches, but they are not disqualifying either — only the two
250
- * shapes below are. */
251
- function isWatcherOnlyMultiplexer(member, scripts) {
252
- const t = commandTokens(member);
253
- if (t.length === 0)
254
- return false;
255
- const bin = path.posix.basename(t[0]);
256
- const runner = /^(?:npx|bunx|pnpm|yarn|npm)$/.test(bin);
257
- const head = runner ?
258
- (t.slice(1).find(a => !a.startsWith('-') && a !== 'exec' && a !== 'dlx' && a !== 'run')
259
- ?? '')
260
- : bin;
261
- if (!MULTIPLEXER_RE.test(path.posix.basename(head)))
262
- return false;
263
- const children = multiplexerChildren(member, scripts);
264
- if (children.length === 0)
265
- return false;
266
- // Every child is an ASSET watcher ⇒ nothing in here ever listens.
267
- return children.every(c => ASSET_TOOL_RE.test(c) && WATCH_FLAG_RE.test(c));
268
- }
269
- /**
270
- * Why this script is NOT a launch of the shipped app, or null when it plausibly
271
- * is one (mx5 run 18, validated).
272
- *
273
- * Run 18's boot command resolved to `bun run dev`, whose body is
274
- * `docker compose -f docker-compose.dev.yml up -d && until docker compose … pg_isready
275
- * … && concurrently "bun run dev:css" "bun run dev:js" "bun run --watch
276
- * src/server/index.ts"`. The gate sandbox has no docker, so the chain died at 127 and
277
- * the boot SKIPPED as an environment gap — while the shipped app had no HTTP listener
278
- * at all. A script whose first act is `docker compose up` cannot distinguish "the app
279
- * is broken" from "this box has no docker", so it is not evidence either way: better
280
- * to discover NO boot command — reported as "nothing to boot" — and let the static
281
- * serve-entry check (serve-entry.ts) carry the signal, than to spend the grace window
282
- * producing an unfalsifiable skip.
283
- *
284
- * CONSERVATIVE AND LEXICAL BY CONSTRUCTION. Only two shapes are rejected, both
285
- * decidable from the script text alone:
286
- * 1. the chain OPENS with container orchestration (docker/podman/nerdctl … up|start|run);
287
- * 2. the whole body is a multiplexer (concurrently/npm-run-all/run-p/run-s/turbo)
288
- * whose every child is an ASSET watcher in watch mode (tailwind/tsc/esbuild/…),
289
- * i.e. nothing in it can ever listen.
290
- * Anything else — `vite`, `next dev`, `node dist/index.js`, `nodemon`, `bun --watch
291
- * src/index.ts`, and any multiplexer with one non-asset child — is accepted
292
- * unchanged. Deciding whether a watcher actually SERVES is not attempted here; that
293
- * is exactly what the static serve-entry check is for.
294
- */
295
- export function nonLaunchScriptReason(body, scripts = {}) {
296
- const members = chainMembers(body);
297
- if (members.length === 0)
298
- return null;
299
- if (isContainerOrchestration(members[0])) {
300
- return 'it opens with container orchestration, which starts infrastructure rather than the app';
301
- }
302
- if (members.every(m => isWatcherOnlyMultiplexer(m, scripts))) {
303
- return 'its only long-running member multiplexes asset watchers, none of which serves';
304
- }
305
- return null;
306
- }
307
- /**
308
- * The project's OWN launch command, if it declares one (package.json `start`,
309
- * else `dev`; Makefile `run`). null means the project has nothing to boot —
310
- * the boot check degrades to nothing-to-run.
311
- *
312
- * A script that is not a LAUNCH at all (nonLaunchScriptReason — mx5 run 18's
313
- * `docker compose up` orchestrator) is rejected here and falls through to the
314
- * next candidate, then to null. Discovering nothing is strictly better than
315
- * discovering something unfalsifiable: an env-gap skip of an orchestration script
316
- * says nothing about the app, and null is reported as "nothing to boot".
317
- */
318
- export function discoverBootCommand(cwd) {
319
- if (existsSync(path.join(cwd, 'package.json'))) {
320
- const s = packageScripts(cwd);
321
- for (const name of ['start', 'dev']) {
322
- if (s[name] && nonLaunchScriptReason(s[name], s) === null)
323
- return ['bun', ['run', name]];
324
- }
325
- return null;
326
- }
327
- if (existsSync(path.join(cwd, 'Makefile')) && makeHasTarget(cwd, 'run')) {
328
- return ['make', ['run']];
329
- }
330
- return null;
331
- }
332
- /**
333
- * The launch script that EXISTS but was rejected as not-a-launch, if any. Without
334
- * this the rejection would trade run 18's unfalsifiable skip for pure silence: no
335
- * boot command means bootSkipVerdict has no label to name, and a project whose test
336
- * suite ran still reports `observed > 0`, so unobservedVerdict stays quiet too. A
337
- * served app whose only declared launch script cannot start it was not observed to
338
- * run, and must say so.
339
- */
340
- export function rejectedLaunchScript(cwd) {
341
- if (!existsSync(path.join(cwd, 'package.json')))
342
- return null;
343
- const s = packageScripts(cwd);
344
- for (const name of ['start', 'dev']) {
345
- if (!s[name])
346
- continue;
347
- const reason = nonLaunchScriptReason(s[name], s);
348
- if (reason === null)
349
- return null; // this one IS a launch — it was chosen
350
- return { name, reason };
351
- }
352
- return null;
353
- }
354
- /** Recognise an "address already in use" bind failure across runtimes (Node
355
- * EADDRINUSE, Bun "Is port N in use?", Go "address already in use", generic). */
356
- function isAddressInUse(text) {
357
- return /EADDRINUSE|address already in use|address in use|port \d+ (?:is |already )?in use/i.test(text);
358
- }
359
- /** Best-effort port number from a bind-failure message, for the diagnosis line. The
360
- * digit run ends on any non-digit (a `(?!\d)` lookahead, NOT `\b`): runtimes often
361
- * print ":3000" flush against the next token with no separating space/newline
362
- * ("…:3000error: script exited"), where a trailing `\b` would never match. */
363
- function extractPort(text) {
364
- const m = /(?:port|:)\s*(\d{2,5})(?!\d)/i.exec(text) ?? /\baddress[^0-9]*(\d{2,5})(?!\d)/i.exec(text);
365
- if (!m)
366
- return null;
367
- const n = Number(m[1]);
368
- return n > 0 && n < 65536 ? n : null;
369
- }
370
- /** Stamped on a PASS the boot check could not actually observe, so the trail says
371
- * so out loud instead of implying the listener requirement was met. */
372
- const UNOBSERVED_LISTENER_NOTE = 'listener check UNOBSERVED: no socket-enumeration tool (ss/netstat/lsof) in this '
373
- + 'environment and the app never answered on the port it was given — passed on the '
374
- + 'survival rule (the process stayed up), NOT on observed serving';
375
- /** Package deps that mean "this project stands up an HTTP server" — the deterministic
376
- * proxy for "the plan/spec promised a served app". Bare framework names plus the
377
- * scoped families whose presence implies a listener at runtime. */
378
- function isServerFrameworkDep(name) {
379
- return (/^(?:hono|express|fastify|koa|polka|restify|next|nuxt|http-server|serve|ws|socket\.io)$/.test(name) || /^@(?:hono|fastify|koa|nestjs|sveltejs|remix-run)\//.test(name));
380
- }
381
- /** Spec/plan phrasings that promise a listening server, for the text signal. */
382
- const SERVE_TEXT_RE = /\b(?:https?\s+server|web\s+server|serves?\b|listen(?:s|ing)?\b|Bun\.serve|app\.listen|createServer|serve\s+(?:static|the)|\/api\/|endpoints?\b)/i;
383
- /**
384
- * Does the finished run stand up a listening HTTP server? Deterministic, from the
385
- * built manifest (a server-framework dependency is the plan's own artifact) OR, when
386
- * available, the plan/spec text. Used to decide whether the boot check must observe a
387
- * LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
388
- */
389
- export function detectsServedApp(cwd, planText) {
390
- try {
391
- const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
392
- const all = { ...(j.dependencies ?? {}), ...(j.devDependencies ?? {}) };
393
- if (Object.keys(all).some(isServerFrameworkDep))
394
- return true;
395
- }
396
- catch {
397
- // no/unreadable manifest → fall through to the text signal
398
- }
399
- return planText !== undefined && SERVE_TEXT_RE.test(planText);
400
- }
401
- /** `ss -tlnpH` rows → {pid, port}. Column 4 (0-based 3) is the local address; the
402
- * port is its last `:`-suffixed number ("0.0.0.0:3000", "[::]:3000"). */
403
- export function parseSsListeners(stdout) {
404
- const out = [];
405
- for (const line of stdout.split('\n')) {
406
- const pm = /pid=(\d+)/.exec(line);
407
- if (!pm)
408
- continue;
409
- const local = line.trim().split(/\s+/)[3] ?? '';
410
- const portm = /:(\d+)$/.exec(local);
411
- if (!portm)
412
- continue;
413
- out.push({ pid: Number(pm[1]), port: Number(portm[1]) });
414
- }
415
- return out;
416
- }
417
- /**
418
- * `netstat -tlnp` rows → {pid, port} (mx5 run 14, validated: the agent-sandbox
419
- * image ships NEITHER ss NOR lsof — only ps and netstat — so the served-app boot
420
- * check could never observe a listener and failed unfalsifiably). The pid rides
421
- * in the trailing "PID/Program name" column ("1234/bun"); rows the kernel will
422
- * not attribute to us print "-" there and are skipped.
423
- */
424
- export function parseNetstatListeners(stdout) {
425
- const out = [];
426
- for (const line of stdout.split('\n')) {
427
- if (!/^\s*tcp/i.test(line))
428
- continue;
429
- const cols = line.trim().split(/\s+/);
430
- const local = cols[3] ?? '';
431
- const portm = /:(\d+)$/.exec(local);
432
- if (!portm)
433
- continue;
434
- const pidm = /^(\d+)\//.exec(cols[cols.length - 1] ?? '');
435
- if (!pidm)
436
- continue;
437
- out.push({ pid: Number(pidm[1]), port: Number(portm[1]) });
438
- }
439
- return out;
440
- }
441
- /** `lsof -iTCP -sTCP:LISTEN -n -P` rows → {pid, port}. */
442
- export function parseLsofListeners(stdout) {
443
- const out = [];
444
- for (const line of stdout.split('\n').slice(1)) {
445
- const cols = line.trim().split(/\s+/);
446
- const pid = Number(cols[1]);
447
- const name = cols.find(c => /:\d+$/.test(c)) ?? '';
448
- const portm = /:(\d+)$/.exec(name);
449
- if (Number.isInteger(pid) && pid > 0 && portm) {
450
- out.push({ pid, port: Number(portm[1]) });
451
- }
452
- }
453
- return out;
454
- }
455
- /** The socket-enumeration tools we can attribute listeners with, in preference
456
- * order: ss (richest), netstat (present where ss is not), lsof (BSD/macOS). */
457
- const LISTENER_TOOLS = [
458
- { bin: 'ss', args: ['-tlnpH'], parse: parseSsListeners },
459
- { bin: 'netstat', args: ['-tlnp'], parse: parseNetstatListeners },
460
- { bin: 'lsof', args: ['-iTCP', '-sTCP:LISTEN', '-n', '-P'], parse: parseLsofListeners }
461
- ];
462
- /** Listening TCP sockets as {pid, port} pairs (best-effort; ss, then netstat, then
463
- * lsof). Empty on any failure — the caller then cannot attribute a listener to our
464
- * group and the served-app check degrades to survival (never a false FAIL). */
465
- function listeningSockets() {
466
- for (const { bin, args, parse } of LISTENER_TOOLS) {
467
- try {
468
- const t = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
469
- if (t.error || !t.stdout)
470
- continue;
471
- const rows = parse(t.stdout);
472
- if (rows.length > 0)
473
- return rows;
474
- }
475
- catch {
476
- // tool missing/unusable — try the next one
477
- }
478
- }
479
- return [];
480
- }
481
- /**
482
- * Can ANY socket-enumeration tool run here at all? (mx5 run 14: the sandbox had
483
- * none, so `groupHasListener` returned false forever and the boot check emitted
484
- * "never opened a listening socket" no matter what the app did — an unfalsifiable
485
- * FAIL that failed a run whose app demonstrably served.) This is a CAPABILITY
486
- * question, deliberately separate from "did we see a listener": a tool that ran
487
- * and found nothing is an observation; no tool at all is blindness, and blindness
488
- * must degrade to the survival rule exactly like win32 — never a false FAIL on a
489
- * platform we cannot probe.
490
- *
491
- * "Ran" = spawned without ENOENT and either exited 0 or printed something (lsof
492
- * exits 1 on an empty match set; a netstat that rejects `-p` prints nothing).
493
- * Memoised: the answer is a property of the box, not of the run.
494
- */
495
- let listenerToolCapability = null;
496
- export function canEnumerateListeners() {
497
- if (listenerToolCapability !== null)
498
- return listenerToolCapability;
499
- listenerToolCapability = LISTENER_TOOLS.some(({ bin, args }) => {
500
- try {
501
- const r = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
502
- if (r.error)
503
- return false;
504
- return r.status === 0 || (r.stdout ?? '').trim().length > 0;
505
- }
506
- catch {
507
- return false;
508
- }
509
- });
510
- return listenerToolCapability;
511
- }
512
- /** Test seam: forget the memoised capability answer. */
513
- export function resetListenerToolCapability() {
514
- listenerToolCapability = null;
515
- }
516
- /**
517
- * A free TCP port on the loopback interface, or null if one cannot be reserved.
518
- * The boot check hands this to the child as PORT so that a successful HTTP
519
- * request to it is OWNERSHIP evidence: nobody else knows the number (mx5 runs
520
- * 8/10/11 — orphaned servers from earlier checks answered curl on the
521
- * conventional :3000 and passed checks the app had not earned).
522
- */
523
- export function pickFreePort() {
524
- return new Promise(resolve => {
525
- try {
526
- const srv = net.createServer();
527
- srv.once('error', () => resolve(null));
528
- srv.listen(0, '127.0.0.1', () => {
529
- const a = srv.address();
530
- const port = typeof a === 'object' && a !== null ? a.port : null;
531
- srv.close(() => resolve(port));
532
- });
533
- }
534
- catch {
535
- resolve(null);
536
- }
537
- });
538
- }
539
- /** Can we bind 127.0.0.1:`port` right now? (Free ⇒ the boot child can have it.) */
540
- export function isPortFree(port) {
541
- return new Promise(resolve => {
542
- try {
543
- const srv = net.createServer();
544
- srv.once('error', () => resolve(false));
545
- srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
546
- }
547
- catch {
548
- resolve(false);
549
- }
550
- });
551
- }
552
- /**
553
- * The project's own declared local port, but only if nothing is holding it — the
554
- * default `preferredPort` for the gate. A declared port that is BUSY falls back to
555
- * a reserved one rather than colliding: a stranger's server on :3000 must never be
556
- * mistaken for the app we just booted.
557
- */
558
- export async function preferredDeclaredPort(cwd) {
559
- const port = pinnedLocalPort(collectProjectEnv(cwd));
560
- if (port === null)
561
- return null;
562
- return (await isPortFree(port)) ? port : null;
563
- }
564
- /**
565
- * Does anything answer HTTP on 127.0.0.1:`port`? Any response at all (404, 500 —
566
- * a status is a listener) counts; only a connection error or timeout is a no.
567
- * Runs in a throwaway child of our own runtime so it needs no curl on PATH and
568
- * stays synchronous inside the boot poll.
569
- */
570
- function defaultHttpProbe(port) {
571
- const script = `fetch('http://127.0.0.1:${port}/').then(()=>process.exit(0),()=>process.exit(1));`
572
- + `setTimeout(()=>process.exit(1),2000)`;
573
- try {
574
- const r = spawnSync(process.execPath, ['-e', script], {
575
- encoding: 'utf8',
576
- timeout: 5000
577
- });
578
- return !r.error && r.status === 0;
579
- }
580
- catch {
581
- return false;
582
- }
583
- }
584
- /** Process-group id of `pid`, or null if it cannot be read. */
585
- function pgidOf(pid) {
586
- try {
587
- const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], {
588
- encoding: 'utf8',
589
- timeout: 4000
590
- });
591
- const n = Number((r.stdout ?? '').trim());
592
- return Number.isInteger(n) && n > 0 ? n : null;
593
- }
594
- catch {
595
- return null;
596
- }
597
- }
598
- /** Default listener probe: any LISTENing socket owned by a pid in process group
599
- * `pgid` (the detached boot child IS its own group leader, so pgid === child.pid). */
600
- function defaultGroupHasListener(pgid) {
601
- for (const { pid } of listeningSockets()) {
602
- if (pgidOf(pid) === pgid)
603
- return true;
604
- }
605
- return false;
606
- }
607
- /** Default port lookup for the render check: the LOWEST port among the group's
608
- * listeners (a dev toolchain may open an HMR socket too; the app's own server
609
- * conventionally sits on the lower, configured port). Null when undeterminable. */
610
- function defaultGroupListeningPort(pgid) {
611
- const ports = listeningSockets()
612
- .filter(({ pid }) => pgidOf(pid) === pgid)
613
- .map(({ port }) => port);
614
- return ports.length > 0 ? Math.min(...ports) : null;
615
- }
616
- /** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
617
- * failure (the diagnosis then omits the pid — never blocks). */
618
- function defaultFindPortHolder(port) {
619
- try {
620
- const t = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t', '-P', '-n'], {
621
- encoding: 'utf8',
622
- timeout: 4000
623
- });
624
- const pid = Number((t.stdout ?? '').split('\n')[0]?.trim());
625
- if (!Number.isInteger(pid) || pid <= 0)
626
- return null;
627
- const ps = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], {
628
- encoding: 'utf8',
629
- timeout: 4000
630
- });
631
- return { pid, command: (ps.stdout ?? '').trim() || `pid ${pid}` };
632
- }
633
- catch {
634
- return null;
635
- }
636
- }
637
- function defaultReap(pid) {
638
- try {
639
- process.kill(pid, 'SIGTERM');
640
- setTimeout(() => {
641
- try {
642
- process.kill(pid, 'SIGKILL');
643
- }
644
- catch {
645
- // already gone
646
- }
647
- }, 1_000).unref();
648
- return true;
649
- }
650
- catch {
651
- return false;
652
- }
653
- }
654
- /** Does the port holder look like one of OUR gate children (a `dev`/`start` run of
655
- * the discovered boot command)? Only then do we reap it — never a foreign process
656
- * the user happens to be running. */
657
- function holderIsOurs(command, boot) {
658
- const script = boot[1][boot[1].length - 1] ?? ''; // 'start' | 'dev' | 'run'
659
- const c = command.toLowerCase();
660
- return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
661
- && (c.includes(` ${script}`) || c.endsWith(script)));
662
- }
663
- /**
664
- * Exercise the start command ONCE. For a CLI project (`expectServer` false) the
665
- * command's own fate within the grace window decides:
666
- *
667
- * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
668
- * - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
669
- * - still alive when the window closes → PASS, then the whole process group is
670
- * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
671
- *
672
- * For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
673
- * survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
674
- * forever without ever listening, and a type-only entrypoint exits 0 in <1s having
675
- * served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
676
- * PASSes only once a LISTENing socket owned by our process group is observed; if the
677
- * command exits, or the grace window closes, with no listener ever seen → FAIL naming
678
- * that a listening server was expected.
679
- *
680
- * OBSERVABILITY is a precondition of that FAIL (mx5 run 14, validated). The listener
681
- * requirement needs pgid-attributed socket enumeration; win32 has none, and neither
682
- * does a Linux image shipping no ss/netstat/lsof — run 14's sandbox was exactly that,
683
- * so the check emitted "never opened a listening socket" against an app that
684
- * demonstrably served, three autofix passes could not falsify it, and the run was
685
- * recorded failed. Two defences, in order:
686
- *
687
- * - the child is spawned with a freshly reserved, otherwise-unused PORT, and an
688
- * HTTP answer on THAT port proves a listener regardless of tooling. The private
689
- * port is what makes the HTTP probe trustworthy: an orphaned server from an
690
- * earlier check answers on :3000, but nobody else knows this number.
691
- * - if nothing can enumerate listeners AND the assigned port never answered, the
692
- * served-app requirement is unobservable here, so `expectServer` collapses to
693
- * the survival rule and the PASS is stamped UNOBSERVED. An app that ignores PORT
694
- * is indistinguishable from one that never listened — an observer limitation,
695
- * not an app defect, and it may not be reported as one.
696
- *
697
- * A child that EXITS non-zero still FAILs in every environment: "the process died"
698
- * needs no socket probe, so run 14's original true positive (a `--hot` runtime
699
- * pinning a crashed app) stays reportable wherever the tooling exists.
700
- *
701
- * Env-gap contract as everywhere: spawn error (ENOENT) or a command-not-found
702
- * inside the chain (exit 127, or the runner's own wording where the platform
703
- * reports it that way — see isCommandNotFound) → skip.
704
- */
705
- export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
706
- const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
707
- const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
708
- const httpProbe = opts.deps?.httpProbe ?? defaultHttpProbe;
709
- const canEnumerate = expectServer ? (opts.deps?.enumerationCapable ?? canEnumerateListeners)() : true;
710
- // Only served apps get an assigned port: a CLI project has nothing to bind, and
711
- // an unexpected PORT in its env is noise.
712
- // The app's OWN declared local port wins when it is free (see pinnedLocalPort):
713
- // a client whose base URL was baked in at build time calls that origin and no
714
- // other, so serving it anywhere else makes the whole authenticated half
715
- // unobservable. Anything else — no declaration, a port already held — falls back
716
- // to the freshly reserved private port that run 14's ownership evidence needs.
717
- const noPreference = () => Promise.resolve(null);
718
- const preferred = expectServer ? await (opts.deps?.preferredPort ?? noPreference)() : null;
719
- const assignedPort = preferred ?? (expectServer ? await (opts.deps?.pickPort ?? pickFreePort)() : null);
720
- // Runner resolution (mx5 run 16): same contract as runGateCommand — resolve
721
- // the runner and carry its directory on PATH so the boot script's own chain
722
- // can re-invoke it.
723
- const runner = resolveRunner(bin);
724
- return new Promise(resolve => {
725
- const child = spawn(runner.bin, args, {
726
- cwd,
727
- detached: true,
728
- stdio: ['ignore', 'pipe', 'pipe'],
729
- env: {
730
- ...runnerEnv(runner),
731
- ...(assignedPort !== null ? { PORT: String(assignedPort) } : {})
732
- }
733
- });
734
- // Best-effort cleanup only: killGroup below can silently fail to reap the
735
- // process (platform/sandbox-specific — observed on a GH Actions Linux
736
- // runner where the group-kill did not take, hanging the whole `bun test
737
- // --isolate` run on the leaked child's piped stdio). unref() so a child
738
- // we already tried to kill can never itself keep this process alive.
739
- child.unref();
740
- let out = '';
741
- let err = '';
742
- let listenerSeen = false;
743
- const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
744
- child.stdout?.on('data', (d) => (out = cap(out + String(d))));
745
- child.stderr?.on('data', (d) => (err = cap(err + String(d))));
746
- let settled = false;
747
- const settle = (r) => {
748
- if (settled)
749
- return;
750
- settled = true;
751
- clearTimeout(timer);
752
- if (poll)
753
- clearInterval(poll);
754
- resolve(r);
755
- };
756
- const killGroup = (sig) => {
757
- try {
758
- if (!child.pid)
759
- return;
760
- if (process.platform === 'win32') {
761
- // Windows has no process groups / negative-pid kill. taskkill
762
- // /T tears down the whole tree (the detached child plus any
763
- // grandchildren it spawned); /F forces it, so the SIGTERM→
764
- // SIGKILL escalation collapses to one idempotent call.
765
- spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
766
- }
767
- else {
768
- process.kill(-child.pid, sig);
769
- }
770
- }
771
- catch {
772
- // group already gone
773
- }
774
- };
775
- const passAndKill = (renderNote) => {
776
- settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
777
- killGroup('SIGTERM');
778
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
779
- };
780
- const failAndKill = (detail) => {
781
- settle({ outcome: 'fail', detail });
782
- killGroup('SIGTERM');
783
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
784
- };
785
- // Served apps only: poll for a listening socket owned by our process group.
786
- // As soon as one appears the boot has demonstrably served → run the render
787
- // check against the LIVE listener (mx5 runs 8/11: a listener that serves a
788
- // permanently blank page passed every curl-shaped check), then PASS/FAIL.
789
- // The probe is spawnSync, so the interval cannot re-enter mid-check.
790
- // The deep probe is asynchronous (it drives a browser session), so the
791
- // interval body must not re-enter while one is in flight — a second session
792
- // would race the first for the same still-booting child.
793
- let probing = false;
794
- const poll = expectServer ?
795
- setInterval(() => {
796
- if (settled || probing || !child.pid)
797
- return;
798
- // pgid attribution first (precise, cheap). If it saw nothing — or
799
- // cannot see anything here — fall back to the private assigned
800
- // port: an HTTP answer on a number only this child was told is
801
- // proof of OUR listener, not of some orphan on :3000.
802
- const byGroup = canEnumerate && groupHasListener(child.pid);
803
- const byPort = !byGroup && assignedPort !== null && httpProbe(assignedPort);
804
- if (!byGroup && !byPort)
805
- return;
806
- listenerSeen = true;
807
- const probe = opts.deps?.renderProbe;
808
- if (!probe)
809
- return passAndKill();
810
- const port = byGroup ?
811
- (opts.deps?.groupListeningPort ?? defaultGroupListeningPort)(child.pid)
812
- : assignedPort;
813
- if (port === null) {
814
- return passAndKill('render check UNOBSERVED: a listener was seen but its port could not be determined');
815
- }
816
- const url = `http://127.0.0.1:${port}/`;
817
- const rr = probe(url);
818
- if (rr.outcome === 'fail') {
819
- return failAndKill(`listens on :${port} but ${rr.detail}`);
820
- }
821
- const deep = opts.deps?.deepRenderProbe;
822
- if (rr.outcome !== 'pass' || !deep) {
823
- return passAndKill(rr.outcome === 'skip' ?
824
- `render check UNOBSERVED: ${rr.note}`
825
- : undefined);
826
- }
827
- // The page renders. Now sign in and prove the AUTHENTICATED half
828
- // is alive (mx5 run 17): the server accepted the login and the
829
- // client never used it. Async, so the interval is held off by
830
- // `probing` until this settles.
831
- probing = true;
832
- void Promise.resolve(deep(url)).then(dr => {
833
- if (settled)
834
- return;
835
- if (dr.outcome === 'fail') {
836
- return failAndKill(`listens on :${port} but ${dr.detail}`);
837
- }
838
- passAndKill(dr.outcome === 'skip' ?
839
- `authenticated render check UNOBSERVED: ${dr.note}`
840
- : undefined);
841
- }, () => {
842
- // The deep probe may never fail the gate on its own fault.
843
- if (!settled)
844
- passAndKill();
845
- });
846
- }, 500)
847
- : null;
848
- const onGrace = () => {
849
- // A browser session in flight outlives the grace window by design (it
850
- // signs in and waits for the app's data calls). Settling here would kill
851
- // the server under it and discard its verdict, so the window re-arms
852
- // until the probe resolves — which it always does, on its own hard
853
- // timeout (DEEP_RENDER_TIMEOUT_MS).
854
- if (probing) {
855
- timer = setTimeout(onGrace, 500);
856
- return;
857
- }
858
- if (expectServer && !listenerSeen) {
859
- // Blind here (no enumeration tool, and the assigned port never
860
- // answered) ⇒ we cannot tell "never listened" from "ignores PORT".
861
- // Survival rule, stamped UNOBSERVED — an observer limitation is not
862
- // an app defect (mx5 run 14).
863
- if (!canEnumerate)
864
- return passAndKill(UNOBSERVED_LISTENER_NOTE);
865
- settle({
866
- outcome: 'fail',
867
- detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
868
- });
869
- killGroup('SIGTERM');
870
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
871
- return;
872
- }
873
- passAndKill();
874
- };
875
- let timer = setTimeout(onGrace, graceMs);
876
- child.on('error', () => settle({ outcome: 'skip', spawnFailed: true }));
877
- child.on('exit', (status, signal) => {
878
- if (status === 0) {
879
- if (expectServer && !listenerSeen) {
880
- if (!canEnumerate) {
881
- return settle({ outcome: 'pass', renderNote: UNOBSERVED_LISTENER_NOTE });
882
- }
883
- return settle({
884
- outcome: 'fail',
885
- detail: 'exited 0 without ever opening a listening socket — the spec/dependencies '
886
- + 'promise an HTTP server, so a boot that serves nothing is not a launch'
887
- });
888
- }
889
- return settle({ outcome: 'pass' });
890
- }
891
- // Command-not-found inside the boot chain — 127 on a posix shell, or
892
- // the runner's own wording where it isn't (Windows bun exits 1). Either
893
- // way the boot never RAN, so it is an environment gap, not an app fault.
894
- if (isCommandNotFound(status, `${out}\n${err}`)
895
- || (status === null && signal === null)) {
896
- return settle({ outcome: 'skip' });
897
- }
898
- const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
899
- const tail = outputTail(out, err);
900
- // A bind collision is an environment condition, not an app defect — hand
901
- // it back distinctly so the gate can reap our own orphan and retry rather
902
- // than reporting the app "crashed" (mx5 run 9 item 3).
903
- if (isAddressInUse(`${out}\n${err}`)) {
904
- settle({
905
- outcome: 'orphan-port',
906
- port: extractPort(`${out}\n${err}`),
907
- detail: `${what}${tail ? ` — ${tail}` : ''}`
908
- });
909
- return;
910
- }
911
- settle({ outcome: 'fail', detail: `${what}${tail ? ` — ${tail}` : ''}` });
912
- });
913
- });
914
- }
915
182
  /**
916
183
  * Labels (`bin args…`) of every command the gate CAN currently discover — the
917
184
  * static half (repo-health) plus the integration half. Pure discovery, nothing
@@ -1126,77 +393,15 @@ export function unobservedVerdict(args) {
1126
393
  return (`UNOBSERVED — NOT a pass: ${why}; statics passed, but this run produced NO evidence `
1127
394
  + 'that the assembled product builds, boots or works.');
1128
395
  }
1129
- /**
1130
- * The SAME third verdict, at the door unobservedVerdict cannot reach: the boot
1131
- * check specifically (mx5 run 18, validated).
1132
- *
1133
- * Run 18 shipped an app with no HTTP server behind a converged final gate. Its
1134
- * `src/server/index.ts` ends at `export {app}` — no `Bun.serve`, no
1135
- * `export default app`, no `start` script — so `bun run src/server/index.ts` exits
1136
- * 0 immediately and the product cannot be started at all. The gate's boot command
1137
- * resolved to `bun run dev`, whose body begins `docker compose … up -d`; the gate
1138
- * sandbox had no docker, so the boot SKIPPED as an environment gap. Skips
1139
- * contribute nothing to `dynObserved`, and `bun run test`, `test:ct`, `build`,
1140
- * `lint`, `seed` and `migrate` all ran — so `dynObserved > 0`, the full-skip
1141
- * blindness guard (observabilityGapFailure) stayed correctly quiet, and the trail
1142
- * read `final-gate: autofix converged — statics + … passed` with 24/24 tasks green.
1143
- *
1144
- * The defect is that "the app was never observed to boot" and "the app booted
1145
- * fine" produced BYTE-IDENTICAL gate output. That is the class scripts/ab-verdict.ts
1146
- * exists to kill one layer up: absence of evidence rendered in the shape of
1147
- * evidence. So a discovered-but-skipped boot now names itself, and — unlike every
1148
- * other skip — it CANNOT be cancelled by observations from other commands.
1149
- * Component tests are the trap here, not the alibi: run 18 had 51 green Playwright
1150
- * CT tests, and CT mounts components in a browser without ever assembling or
1151
- * starting the server.
1152
- *
1153
- * DECIDED, do not silently re-open:
1154
- * - NOT a FAIL. A boot skip on a docker-less box is a genuine environment gap, and
1155
- * failing it re-creates run 16's unfalsifiable-FAIL mistake pointing the other
1156
- * way. UNOBSERVED blocks nothing while being loud and durable (the caller records
1157
- * it as final-gate debt the next run re-surfaces), and it keeps "boot never ran"
1158
- * out of the autofix child's seed — a child cannot fix a missing docker, so the
1159
- * highest-probability response would be to FABRICATE a bootable command, the
1160
- * class that refuted the `## verified tooling` harvest.
1161
- * - BOTH skip flavours count. Run 18's skip carried `spawnFailed: false` (127 inside
1162
- * the script chain, not an ENOENT on the runner), so keying off spawnFailed would
1163
- * have missed the actual defect.
1164
- * - SERVED APPS ONLY. `expectServer === false` (a CLI/library project) is fenced off
1165
- * deliberately: a CLI whose `dev` script needs an absent tool has no server to be
1166
- * unobserved, and widening the lever there buys warnings nobody can act on.
1167
- */
1168
- export function bootSkipVerdict(args) {
1169
- if (args.label === null || !args.skipped || !args.expectServer)
1170
- return null;
1171
- // Deliberately short: the run-level trail slices the reason at 300 chars and this
1172
- // note leads it, so the command name always survives.
1173
- return (`boot check: \`${args.label}\` NEVER RAN (environment gap) — the app was not observed `
1174
- + 'to start, and no test suite substitutes for that.');
1175
- }
1176
- /**
1177
- * Boot check hit an address-in-use bind failure. If the port is held by one of OUR
1178
- * own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
1179
- * so the app gets a fair launch; otherwise leave the (foreign) holder alone and let
1180
- * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
1181
- * to ourselves.
1182
- */
1183
- async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServer) {
1184
- if (first.port === null)
1185
- return first;
1186
- const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
1187
- if (!holder || !holderIsOurs(holder.command, boot))
1188
- return first;
1189
- const reaped = (deps.reap ?? defaultReap)(holder.pid);
1190
- if (!reaped)
1191
- return first;
1192
- // Give the OS a moment to release the socket, then re-run the boot once.
1193
- await new Promise(r => setTimeout(r, 1_500));
1194
- return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
1195
- }
1196
396
  // File → introducing-task provenance moved to task-provenance.ts (mx5 run-12
1197
397
  // PROMPT 2 extracted it for the cross-task deletion guards); re-exported so
1198
398
  // existing importers keep working.
1199
399
  export { taskThatIntroduced };
400
+ // The boot probe moved to boot-probe.ts (its own concern, 0 other importers inside
401
+ // src/). Re-exported so the seven validation harnesses under scripts/ — which have
402
+ // always imported exactly this surface and nothing else from the gate — keep
403
+ // working unchanged. Same pattern as taskThatIntroduced above.
404
+ export { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
1200
405
  /**
1201
406
  * ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
1202
407
  * the user accepted despite a verify-FAIL and re-check each against the CURRENT
@@ -1393,23 +598,15 @@ function runClosureScans(stage, input, fail, scans = CLOSURE_SCANS) {
1393
598
  }
1394
599
  }
1395
600
  export { CLOSURE_SCANS, runClosureScans };
1396
- /**
1397
- * Run the final gate: static analysis first, then the lockfile consistency
1398
- * checks, then the discovered integration commands, then one boot exercise of
1399
- * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
1400
- *
1401
- * EVERY section runs and failures AGGREGATE (mx5 run 13): the gate used to
1402
- * early-return on the first failing section, and the boot + render probe — built
1403
- * after run 11 exactly for "app serves blank/nothing" — was ordered last, so any
1404
- * earlier failure shadowed the most load-bearing signal. Run 13's user accepted
1405
- * the FAIL having seen only 1 failing CT test while the app 404'd on every
1406
- * non-API GET; boot/render never executed in any attempt. Now the outcome
1407
- * carries the full ranked failure list (boot/render first — "the app does not
1408
- * serve/render" outranks any single test), the ACCEPT decision is made on all of
1409
- * it, and autofix converges only when the whole list is empty. Per-section
1410
- * env-gap/INFRA_GAP skip semantics and orphan-port recovery are unchanged.
1411
- */
1412
- export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText) {
601
+ export async function runFinalIntegrationGate(cwd, opts = {}) {
602
+ const { timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText, run: runCmd = spawnCommand, envClosure = (c) => {
603
+ try {
604
+ return scanEnvTemplateClosure(c);
605
+ }
606
+ catch {
607
+ return inertClosure();
608
+ }
609
+ }, trackedFiles: trackedFilesFn = trackedFiles } = opts;
1413
610
  const stat = runRepoHealthCheck(cwd);
1414
611
  const { openDebts, debtNote } = await deriveOpenDebts(cwd, stat.ok);
1415
612
  // The debt note rides in its OWN field: `reason` stays the mechanical failure
@@ -1508,7 +705,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1508
705
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
1509
706
  dynAttempted += 1;
1510
707
  dynBins.add(cmd[0]);
1511
- const r = runGateCommand(cwd, cmd, timeoutMs);
708
+ const r = runGateCommand(cwd, cmd, timeoutMs, undefined, undefined, runCmd);
1512
709
  if (r.outcome === 'skip') {
1513
710
  if (r.spawnFailed)
1514
711
  dynSpawnFailures += 1;
@@ -1555,15 +752,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1555
752
  // union of every tracked env template's declared variables. Both empty on a
1556
753
  // non-git tree or a tree with no template, which makes the whole check inert
1557
754
  // — a project with no template gains no excuse. See launch-config-gap.ts.
1558
- const closure = (() => {
1559
- try {
1560
- return scanEnvTemplateClosure(cwd);
1561
- }
1562
- catch {
1563
- return inertClosure();
1564
- }
1565
- })();
1566
- const trackedForGap = closure.templates.length > 0 ? (trackedFiles(cwd) ?? []) : [];
755
+ const closure = envClosure(cwd);
756
+ const trackedForGap = closure.templates.length > 0 ? (trackedFilesFn(cwd) ?? []) : [];
1567
757
  const launchTimeout = Math.min(timeoutMs, 180_000);
1568
758
  for (const name of runnableDeclaredScripts(declared, covered)) {
1569
759
  if (!present.has(name.toLowerCase()))
@@ -1572,7 +762,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1572
762
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
1573
763
  dynAttempted += 1;
1574
764
  dynBins.add(cmd[0]);
1575
- const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE);
765
+ const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, undefined, runCmd);
1576
766
  if (r.outcome === 'skip') {
1577
767
  if (r.spawnFailed)
1578
768
  dynSpawnFailures += 1;
@@ -1601,7 +791,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1601
791
  env: process.env
1602
792
  });
1603
793
  if (gap) {
1604
- const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap));
794
+ const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap), runCmd);
1605
795
  if (probe.outcome === 'pass') {
1606
796
  // Nothing about this script was OBSERVED: the real run could
1607
797
  // not reach it and the probe run is a diagnostic, never an