@mjasnikovs/pi-task 0.38.10 → 0.38.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +93 -116
- package/dist/task/boot-probe.d.ts +298 -0
- package/dist/task/boot-probe.js +806 -0
- package/dist/task/child-runner.d.ts +56 -25
- package/dist/task/child-runner.js +65 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +38 -432
- package/dist/task/final-gate.js +105 -1213
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/launch-manifest.d.ts +5 -0
- package/dist/task/launch-manifest.js +21 -0
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +70 -297
- package/dist/task/phases.d.ts +18 -0
- package/dist/task/phases.js +4 -3
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/task-gates.d.ts +69 -0
- package/dist/task/task-gates.js +114 -90
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/docs-core.d.ts +0 -4
- package/dist/workers/docs-core.js +10 -34
- package/dist/workers/docs-project.js +3 -3
- package/dist/workers/docs-resolve.d.ts +18 -0
- package/dist/workers/docs-resolve.js +39 -0
- package/dist/workers/docs-retrieve.d.ts +13 -0
- package/dist/workers/docs-retrieve.js +17 -2
- package/dist/workers/fetch-core.d.ts +0 -4
- package/dist/workers/fetch-core.js +2 -5
- package/dist/workers/phantom-imports.d.ts +3 -3
- package/dist/workers/phantom-imports.js +16 -29
- package/dist/workers/pi-worker-docs.d.ts +49 -0
- package/dist/workers/pi-worker-docs.js +33 -9
- package/dist/workers/pi-worker-fetch.d.ts +18 -0
- package/dist/workers/pi-worker-fetch.js +19 -4
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
package/dist/task/final-gate.js
CHANGED
|
@@ -45,43 +45,25 @@
|
|
|
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
|
-
import {
|
|
51
|
+
import { deriveOpenDebts, rerunDebtVerifyCommand } 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 {
|
|
59
|
-
import { resolveRunner, runnerEnv
|
|
60
|
-
import { classifyCommandRun, spawnCommand,
|
|
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
|
-
|
|
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
|
-
}
|
|
66
|
+
import { GateTally, observabilityGapFailure, unobservedVerdict } from './gate-tally.js';
|
|
85
67
|
/**
|
|
86
68
|
* The project's OWN whole-repo integration commands (test, then build — test
|
|
87
69
|
* first because it is the richer signal and the more common script). First
|
|
@@ -198,720 +180,6 @@ export function discoverLockfileChecks(cwd) {
|
|
|
198
180
|
}
|
|
199
181
|
return cmds;
|
|
200
182
|
}
|
|
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
183
|
/**
|
|
916
184
|
* Labels (`bin args…`) of every command the gate CAN currently discover — the
|
|
917
185
|
* static half (repo-health) plus the integration half. Pure discovery, nothing
|
|
@@ -1005,302 +273,26 @@ run = spawnCommand) {
|
|
|
1005
273
|
}
|
|
1006
274
|
return verdict;
|
|
1007
275
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
return null;
|
|
1016
|
-
}
|
|
1017
|
-
/**
|
|
1018
|
-
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
1019
|
-
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
1020
|
-
* command itself rather than by a judgement about it.
|
|
1021
|
-
*
|
|
1022
|
-
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
1023
|
-
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
1024
|
-
* are all ordinary there. The leading command word is still resolved through
|
|
1025
|
-
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
1026
|
-
* gap (mx5 run 16's blindness, one level down).
|
|
1027
|
-
*
|
|
1028
|
-
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
1029
|
-
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
1030
|
-
* debt exactly as open as it was.
|
|
1031
|
-
*/
|
|
1032
|
-
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
|
|
1033
|
-
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
1034
|
-
run = spawnCommand) {
|
|
1035
|
-
const bin = leadingBin(line);
|
|
1036
|
-
const runner = bin === null ? null : resolveRunner(bin);
|
|
1037
|
-
// A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
|
|
1038
|
-
// redirects are all ordinary there — so the runner spawns `sh -c`.
|
|
1039
|
-
const verdict = classifyCommandRun(run({
|
|
1040
|
-
cwd,
|
|
1041
|
-
bin: 'sh',
|
|
1042
|
-
args: ['-c', line],
|
|
1043
|
-
timeoutMs,
|
|
1044
|
-
env: runner ? runnerEnv(runner) : { ...process.env }
|
|
1045
|
-
}),
|
|
1046
|
-
// Infrastructure counts as a gap on EVERY debt re-run, not only on
|
|
1047
|
-
// request: an unreachable database cannot tell us whether the code is
|
|
1048
|
-
// fixed, and the asymmetry below means an inconclusive re-run simply
|
|
1049
|
-
// leaves the debt as open as it was.
|
|
1050
|
-
extraGapRe ? [INFRA_GAP_OUTPUT_RE, extraGapRe] : [INFRA_GAP_OUTPUT_RE]);
|
|
1051
|
-
if (verdict.outcome === 'gap')
|
|
1052
|
-
return { outcome: 'gap', detail: verdict.detail };
|
|
1053
|
-
return verdict;
|
|
1054
|
-
}
|
|
1055
|
-
/**
|
|
1056
|
-
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
1057
|
-
* DISCOVERED but every single one skipped as an environment gap, so the gate
|
|
1058
|
-
* decided on statics alone and stamped a permanently blank app green. Per-command
|
|
1059
|
-
* env-gap skips stay legitimate (a missing browser must not fail a suite); what
|
|
1060
|
-
* may never happen again is ALL of them skipping while the gate still reports
|
|
1061
|
-
* PASS — a gate that observed nothing dynamic has no basis to vouch for the
|
|
1062
|
-
* assembled app. Pure so the semantics are unit-tested; the caller feeds it the
|
|
1063
|
-
* attempt/observation counters and runner resolvability.
|
|
1064
|
-
*/
|
|
1065
|
-
export function observabilityGapFailure(args) {
|
|
1066
|
-
if (args.attempted === 0 || args.observed > 0)
|
|
1067
|
-
return null;
|
|
1068
|
-
if (args.spawnFailures < args.attempted)
|
|
1069
|
-
return null;
|
|
1070
|
-
const unresolvable = args.runnerBins.filter(b => !args.runnerResolvable(b));
|
|
1071
|
-
const runnerNote = unresolvable.length > 0 ?
|
|
1072
|
-
` — the project's own runner ${unresolvable
|
|
1073
|
-
.map(b => `\`${b}\``)
|
|
1074
|
-
.join(', ')} is not spawnable here (not on PATH nor any known install location)`
|
|
1075
|
-
: '';
|
|
1076
|
-
return (`observability gap: ${args.attempted} integration/boot command(s) exist but NONE `
|
|
1077
|
-
+ `could even spawn in this environment${runnerNote}; `
|
|
1078
|
-
+ `the gate observed nothing dynamic and cannot vouch for the assembled app`);
|
|
1079
|
-
}
|
|
1080
|
-
/**
|
|
1081
|
-
* The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
|
|
1082
|
-
* but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
|
|
1083
|
-
* `attempted === 0`, and until now that silence fell straight through to
|
|
1084
|
-
* `PASS — no integration command found (statics passed)`: the run-16 blindness class
|
|
1085
|
-
* entering through a different door, where "we never checked" reads exactly like "we
|
|
1086
|
-
* checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
|
|
1087
|
-
* shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
|
|
1088
|
-
* godot-engine (package.json whose only script is `verify`) reproduces it live today.
|
|
1089
|
-
*
|
|
1090
|
-
* So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
|
|
1091
|
-
* existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
|
|
1092
|
-
*
|
|
1093
|
-
* WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
|
|
1094
|
-
* the resolution):
|
|
1095
|
-
* - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
|
|
1096
|
-
* the runs with no dynamic evidence were exactly the runs already known to be
|
|
1097
|
-
* carrying defects.
|
|
1098
|
-
* - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
|
|
1099
|
-
* gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
|
|
1100
|
-
* integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
|
|
1101
|
-
* missing signal was never the debt, it was the word PASS endorsing the run, and
|
|
1102
|
-
* that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
|
|
1103
|
-
* seed is `reason`; "no integration command is discoverable" is not fixable by
|
|
1104
|
-
* editing code, so the highest-probability child response is to FABRICATE a
|
|
1105
|
-
* runnable command to satisfy the gate — the same fabrication class that refuted
|
|
1106
|
-
* the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
|
|
1107
|
-
* run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
|
|
1108
|
-
* means IAR1 and godot-engine can NEVER discover a command, so blocking would end
|
|
1109
|
-
* every non-npm run in `failed` permanently, with no remedy — the task's own I3
|
|
1110
|
-
* ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
|
|
1111
|
-
* its stated consequence is to downgrade to a warning and say so. This is that.
|
|
1112
|
-
* The teeth are elsewhere and are real: the verdict word changes, the gate trail says
|
|
1113
|
-
* UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
|
|
1114
|
-
* gate re-surfaces (it can never auto-close — it is not static-class).
|
|
1115
|
-
*/
|
|
1116
|
-
export function unobservedVerdict(args) {
|
|
1117
|
-
if (args.observed > 0)
|
|
1118
|
-
return null;
|
|
1119
|
-
// Kept short ON PURPOSE: the run-level trail line slices the reason at 300 chars,
|
|
1120
|
-
// and the whole point of this verdict is that the durable record carries it.
|
|
1121
|
-
const why = args.discovered === 0 ?
|
|
1122
|
-
'no integration, lockfile or boot command was discoverable here, so the gate ran '
|
|
1123
|
-
+ 'nothing at all'
|
|
1124
|
-
: `all ${args.discovered} discovered command(s) skipped as environment gaps, so the `
|
|
1125
|
-
+ 'gate ran nothing observable';
|
|
1126
|
-
return (`UNOBSERVED — NOT a pass: ${why}; statics passed, but this run produced NO evidence `
|
|
1127
|
-
+ 'that the assembled product builds, boots or works.');
|
|
1128
|
-
}
|
|
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
|
-
}
|
|
276
|
+
// `runVerifyCommandLine` and its outcome type live in command-run.ts with the
|
|
277
|
+
// other command drivers; re-exported so existing importers keep working.
|
|
278
|
+
export { runVerifyCommandLine } from './command-run.js';
|
|
279
|
+
// The two verdict predicates — the run-16 full-blindness FAIL and the third,
|
|
280
|
+
// non-blocking UNOBSERVED verdict — live with the counters they read, in
|
|
281
|
+
// gate-tally.ts (GateTally). Re-exported so every existing importer keeps working.
|
|
282
|
+
export { observabilityGapFailure, unobservedVerdict };
|
|
1196
283
|
// File → introducing-task provenance moved to task-provenance.ts (mx5 run-12
|
|
1197
284
|
// PROMPT 2 extracted it for the cross-task deletion guards); re-exported so
|
|
1198
285
|
// existing importers keep working.
|
|
1199
286
|
export { taskThatIntroduced };
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
*
|
|
1210
|
-
* FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
|
|
1211
|
-
* runnable at a SECOND moment — after a converged final-gate autofix, where the
|
|
1212
|
-
* orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
|
|
1213
|
-
* `openDebts` entirely. The report a run ends on has to be derived from the tree
|
|
1214
|
-
* the run ends with, not from the tree as it was before the fix pass.
|
|
1215
|
-
*
|
|
1216
|
-
* `staticOk` is the caller's claim about the CURRENT statics, and it is the only
|
|
1217
|
-
* thing that can auto-close a static-class debt — so a caller that does not know
|
|
1218
|
-
* must pass `false` (unprovable ⇒ stays open), never a guess.
|
|
1219
|
-
*/
|
|
1220
|
-
export async function deriveOpenDebts(cwd, staticOk) {
|
|
1221
|
-
const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
|
|
1222
|
-
staticOk,
|
|
1223
|
-
// Cross-task-deletion debts auto-close iff the deleted file is back in the
|
|
1224
|
-
// tree — a deterministic existence check, corroborating the per-file
|
|
1225
|
-
// provenance the record already carries.
|
|
1226
|
-
fileExists: rel => existsSync(path.join(cwd, rel)),
|
|
1227
|
-
// VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
|
|
1228
|
-
// by running that command, under the gate's own env-gap contract and behind
|
|
1229
|
-
// the no-write guard below.
|
|
1230
|
-
rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
|
|
1231
|
-
});
|
|
1232
|
-
if (resolved.length > 0)
|
|
1233
|
-
await writeAcceptDebts(cwd, openRaw);
|
|
1234
|
-
// Conflicting-claim annotation (mx5 run 11): an existence-as-failure debt whose
|
|
1235
|
-
// named file is another task's committed deliverable is a plan defect — surface
|
|
1236
|
-
// the contradiction with the debt so nobody (human or child) treats the claim as
|
|
1237
|
-
// a deletion instruction. Pure git-history lookup; degrades to no annotation.
|
|
1238
|
-
const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
|
|
1239
|
-
const debtNote = buildAcceptDebtNote(openDebts);
|
|
1240
|
-
return { openDebts, ...(debtNote ? { debtNote } : {}), ...(trail.length > 0 ? { trail } : {}) };
|
|
1241
|
-
}
|
|
1242
|
-
/** Per-command ceiling for a debt re-run (`inv-bounded`). */
|
|
1243
|
-
const DEBT_RERUN_TIMEOUT_MS = 300_000;
|
|
1244
|
-
/**
|
|
1245
|
-
* Extra infrastructure-gap shapes recognised ONLY when re-running a debt's command,
|
|
1246
|
-
* never in the gate's own verdicts. A driver that reports its connection simply
|
|
1247
|
-
* closed (`ERR_POSTGRES_CONNECTION_CLOSED` — what bun's SQL client says when the
|
|
1248
|
-
* database is not there at all, as on this box with the mx5 container stopped) is an
|
|
1249
|
-
* absent dependency, and calling that "the defect is still present" would be a
|
|
1250
|
-
* finding the environment invented. Kept out of INFRA_GAP_OUTPUT_RE on purpose: in a
|
|
1251
|
-
* gate verdict the same wording can be a real fault the suite must own, and only the
|
|
1252
|
-
* debt re-check needs the conservative reading — where it costs nothing, because gap
|
|
1253
|
-
* and fail both leave the debt open.
|
|
1254
|
-
*/
|
|
1255
|
-
const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|ECONNRESET/i;
|
|
1256
|
-
/**
|
|
1257
|
-
* Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
|
|
1258
|
-
* (`inv-no-write`) wrapped around it.
|
|
1259
|
-
*
|
|
1260
|
-
* A VERIFY command is the project's own command and may legitimately write (a build
|
|
1261
|
-
* emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
|
|
1262
|
-
* a passing tree and have that count as the debt being fixed — the run would then be
|
|
1263
|
-
* certifying its own side effect. So tracked state is captured before and after, and
|
|
1264
|
-
* a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
|
|
1265
|
-
* change named. Untracked output is left alone: it is what a build legitimately
|
|
1266
|
-
* produces, and `git status --porcelain` in a repo with the usual ignores does not
|
|
1267
|
-
* see it.
|
|
1268
|
-
*
|
|
1269
|
-
* A repository the guard cannot read (no git, git absent) is not a licence to skip
|
|
1270
|
-
* the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
|
|
1271
|
-
* assumption rather than an observation.
|
|
1272
|
-
*/
|
|
1273
|
-
export function rerunDebtVerifyCommand(cwd, command,
|
|
1274
|
-
/** The spawner, for BOTH the command and the tracked-state reads. Injected so
|
|
1275
|
-
* the guard's four outcomes are testable without a repo or a real command. */
|
|
1276
|
-
run = spawnCommand) {
|
|
1277
|
-
const tracked = () => {
|
|
1278
|
-
const r = run({
|
|
1279
|
-
cwd,
|
|
1280
|
-
bin: 'git',
|
|
1281
|
-
args: ['status', '--porcelain', '--untracked-files=no'],
|
|
1282
|
-
timeoutMs: 60_000
|
|
1283
|
-
});
|
|
1284
|
-
return r.failedToStart || r.status !== 0 ? null : r.stdout;
|
|
1285
|
-
};
|
|
1286
|
-
const before = tracked();
|
|
1287
|
-
const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
|
|
1288
|
-
if (r.outcome === 'fail')
|
|
1289
|
-
return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
|
|
1290
|
-
if (r.outcome === 'gap')
|
|
1291
|
-
return { outcome: 'gap', detail: r.detail };
|
|
1292
|
-
const after = tracked();
|
|
1293
|
-
if (before === null || after === null) {
|
|
1294
|
-
return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
|
|
1295
|
-
}
|
|
1296
|
-
if (before !== after) {
|
|
1297
|
-
return {
|
|
1298
|
-
outcome: 'gap',
|
|
1299
|
-
detail: 'the re-run itself CHANGED tracked files — a command that edits the tree into a pass proves nothing'
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
return { outcome: 'pass' };
|
|
1303
|
-
}
|
|
287
|
+
// The boot probe moved to boot-probe.ts (its own concern, 0 other importers inside
|
|
288
|
+
// src/). Re-exported so the seven validation harnesses under scripts/ — which have
|
|
289
|
+
// always imported exactly this surface and nothing else from the gate — keep
|
|
290
|
+
// working unchanged. Same pattern as taskThatIntroduced above.
|
|
291
|
+
export { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
|
|
292
|
+
// The ACCEPT-debt re-check (`deriveOpenDebts`, `rerunDebtVerifyCommand`) lives in
|
|
293
|
+
// accept-debt.ts with the ledger it reads and writes; re-exported so the
|
|
294
|
+
// orchestrator and the harnesses under scripts/ keep working unchanged.
|
|
295
|
+
export { deriveOpenDebts, rerunDebtVerifyCommand };
|
|
1304
296
|
/**
|
|
1305
297
|
* The run-level closure scans, in emission order within their stage.
|
|
1306
298
|
*
|
|
@@ -1393,51 +385,27 @@ function runClosureScans(stage, input, fail, scans = CLOSURE_SCANS) {
|
|
|
1393
385
|
}
|
|
1394
386
|
}
|
|
1395
387
|
export { CLOSURE_SCANS, runClosureScans };
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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) {
|
|
388
|
+
export async function runFinalIntegrationGate(cwd, opts = {}) {
|
|
389
|
+
const { timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText, run: runCmd = spawnCommand, envClosure = (c) => {
|
|
390
|
+
try {
|
|
391
|
+
return scanEnvTemplateClosure(c);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return inertClosure();
|
|
395
|
+
}
|
|
396
|
+
}, trackedFiles: trackedFilesFn = trackedFiles } = opts;
|
|
1413
397
|
const stat = runRepoHealthCheck(cwd);
|
|
1414
|
-
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
// Aggregated failures across ALL sections (mx5 run 13 — see the function doc).
|
|
1424
|
-
// rank 0 = boot/render ("does not serve/render" is the most load-bearing
|
|
1425
|
-
// signal); rank 1 = everything else, kept in execution order by stable sort.
|
|
1426
|
-
const failures = [];
|
|
1427
|
-
const fail = (text, rank = 1) => {
|
|
1428
|
-
failures.push({ rank, text });
|
|
1429
|
-
};
|
|
1430
|
-
/**
|
|
1431
|
-
* A failure a PROBE returned after observing (nexttask 19A — see
|
|
1432
|
-
* FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
|
|
1433
|
-
* section, whose `fail` outcome can only arise from a probe that looked. Every
|
|
1434
|
-
* other `fail()` keeps today's class, so nothing else changes.
|
|
1435
|
-
*/
|
|
1436
|
-
const failObserved = (text, rank = 1) => {
|
|
1437
|
-
failures.push({ rank, text, observed: true });
|
|
1438
|
-
};
|
|
398
|
+
// Debts are derived once, before any section runs, and ride on every verdict
|
|
399
|
+
// shape (GateTally.verdict): `reason` stays the mechanical failure because it
|
|
400
|
+
// seeds the autofix child's prompt — run 11's fix child executed a recorded
|
|
401
|
+
// claim as an instruction.
|
|
402
|
+
const debts = await deriveOpenDebts(cwd, stat.ok);
|
|
403
|
+
// Every section below RECORDS into the tally (failures ranked, the four
|
|
404
|
+
// dynamic counters, the notes) and the verdict is assembled ONCE at the end —
|
|
405
|
+
// see gate-tally.ts for what each method means.
|
|
406
|
+
const tally = new GateTally();
|
|
1439
407
|
if (!stat.ok)
|
|
1440
|
-
fail(`static checks: ${stat.reason}`);
|
|
408
|
+
tally.fail(`static checks: ${stat.reason}`);
|
|
1441
409
|
// Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
|
|
1442
410
|
// scripts that fell through decompose and shipped missing, unchecked. Diff the
|
|
1443
411
|
// plan-time-extracted declared scripts against the manifest; a missing one is a
|
|
@@ -1453,73 +421,43 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1453
421
|
// readLaunchManifest resolves package.json, else a Makefile's targets, else
|
|
1454
422
|
// nothing — and nothing means no failure plus a note, never a silent pass.
|
|
1455
423
|
const declared = await readDeclaredScripts(cwd);
|
|
1456
|
-
const contractNotes = [];
|
|
1457
424
|
if (declared.length > 0) {
|
|
1458
425
|
const manifest = readLaunchManifest(cwd);
|
|
1459
426
|
if (manifest.kind === 'none') {
|
|
1460
|
-
|
|
427
|
+
tally.contractNote(inertLaunchContractNote(declared, manifest));
|
|
1461
428
|
}
|
|
1462
429
|
else {
|
|
1463
430
|
const missing = missingDeclaredScripts(declared, manifest.names);
|
|
1464
431
|
if (missing.length > 0) {
|
|
1465
|
-
fail(`launch contract: the design declares script(s) the shipped ${manifest.file} does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
|
|
432
|
+
tally.fail(`launch contract: the design declares script(s) the shipped ${manifest.file} does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
|
|
1466
433
|
}
|
|
1467
434
|
}
|
|
1468
435
|
}
|
|
1469
436
|
// Run-level closure scans that must be decided BEFORE the zero-discovery early
|
|
1470
437
|
// return below — a static check needs no runner (CLOSURE_SCANS: 'pre-discovery').
|
|
1471
|
-
runClosureScans('pre-discovery', { cwd, planText }, fail);
|
|
438
|
+
runClosureScans('pre-discovery', { cwd, planText }, (t, r) => tally.fail(t, r));
|
|
1472
439
|
const lockCmds = discoverLockfileChecks(cwd);
|
|
1473
440
|
const { cmds } = discoverIntegrationCommands(cwd);
|
|
1474
441
|
const boot = discoverBootCommand(cwd);
|
|
1475
|
-
// ZERO DISCOVERY IS UNOBSERVED, NEVER A PASS (see unobservedVerdict). Nothing was
|
|
1476
|
-
// discovered, so nothing ran, so observabilityGapFailure (attempted === 0 → null) does
|
|
1477
|
-
// not fire — and this outcome used to be reported as `PASS — no integration command
|
|
1478
|
-
// found (statics passed)`, i.e. "we never checked" reading identically to "we checked
|
|
1479
|
-
// and it was fine". IAR1 shipped that verdict TWICE while carrying open verify-FAIL
|
|
1480
|
-
// debt (its .pi-tasks/TASK_AUTO_0001.md:31 and TASK_AUTO_0002.md:37). The outcome stays
|
|
1481
|
-
// `ok: true` (non-blocking, justified at unobservedVerdict) but is now labelled, trailed
|
|
1482
|
-
// and carried as debt by the caller. It needs no new command source, so unlike the
|
|
1483
|
-
// harvest lever refuted at discoverIntegrationCommands it cannot inject a fabricated
|
|
1484
|
-
// failure.
|
|
1485
|
-
if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
|
|
1486
|
-
// The inert-contract note rides here too: a non-npm project carrying a launch
|
|
1487
|
-
// contract usually discovers no command either, and that is exactly the run
|
|
1488
|
-
// whose silence must not read as "the contract was checked and was fine".
|
|
1489
|
-
const note = [unobservedVerdict({ discovered: 0, observed: 0 }) ?? '', ...contractNotes]
|
|
1490
|
-
.filter(n => n !== '')
|
|
1491
|
-
.join(' ');
|
|
1492
|
-
return withDebts({ ok: true, unobserved: note, reason: note });
|
|
1493
|
-
}
|
|
1494
|
-
const ran = [];
|
|
1495
|
-
// Full-skip blindness counters (mx5 run 16): every dynamic spawn counts an
|
|
1496
|
-
// attempt; a real pass OR a real fail counts an observation; skips observe
|
|
1497
|
-
// nothing. If everything discovered ends up skipped, observabilityGapFailure
|
|
1498
|
-
// turns the silence into a rank-0 failure instead of a static-only PASS.
|
|
1499
|
-
let dynAttempted = 0;
|
|
1500
|
-
let dynObserved = 0;
|
|
1501
|
-
let dynSpawnFailures = 0;
|
|
1502
|
-
const dynBins = new Set();
|
|
1503
442
|
for (const { prefix, list } of [
|
|
1504
443
|
{ prefix: 'lockfile check: ', list: lockCmds },
|
|
1505
444
|
{ prefix: '', list: cmds }
|
|
1506
445
|
]) {
|
|
1507
446
|
for (const cmd of list) {
|
|
1508
447
|
const label = `${cmd[0]} ${cmd[1].join(' ')}`;
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const r = runGateCommand(cwd, cmd, timeoutMs);
|
|
448
|
+
tally.attempted(cmd[0]);
|
|
449
|
+
const r = runGateCommand(cwd, cmd, timeoutMs, undefined, undefined, runCmd);
|
|
1512
450
|
if (r.outcome === 'skip') {
|
|
1513
451
|
if (r.spawnFailed)
|
|
1514
|
-
|
|
452
|
+
tally.spawnFailure(cmd[0]);
|
|
1515
453
|
continue;
|
|
1516
454
|
}
|
|
1517
|
-
|
|
455
|
+
tally.observed();
|
|
1518
456
|
if (r.outcome === 'fail') {
|
|
1519
|
-
fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
457
|
+
tally.fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
1520
458
|
continue;
|
|
1521
459
|
}
|
|
1522
|
-
ran
|
|
460
|
+
tally.ran(label);
|
|
1523
461
|
}
|
|
1524
462
|
}
|
|
1525
463
|
// EXECUTE the launch contract (mx5 run 11): every declared script that is
|
|
@@ -1532,12 +470,6 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1532
470
|
// note (F7) is surfaced as an UNOBSERVED warning — the note may be covering a
|
|
1533
471
|
// real defect the gate could not reach here (run 11's "pre-existing .rows
|
|
1534
472
|
// bug" note excused the exact scripts that shipped broken).
|
|
1535
|
-
const warnings = [];
|
|
1536
|
-
/** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
|
|
1537
|
-
* They ride in `unobserved`, not `warnings`, so the caller's existing
|
|
1538
|
-
* `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
|
|
1539
|
-
* never a PASS. */
|
|
1540
|
-
const configGapNotes = [];
|
|
1541
473
|
if (declared.length > 0) {
|
|
1542
474
|
const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
|
|
1543
475
|
const skippedLaunch = [];
|
|
@@ -1555,31 +487,23 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1555
487
|
// union of every tracked env template's declared variables. Both empty on a
|
|
1556
488
|
// non-git tree or a tree with no template, which makes the whole check inert
|
|
1557
489
|
// — a project with no template gains no excuse. See launch-config-gap.ts.
|
|
1558
|
-
const closure = (
|
|
1559
|
-
|
|
1560
|
-
return scanEnvTemplateClosure(cwd);
|
|
1561
|
-
}
|
|
1562
|
-
catch {
|
|
1563
|
-
return inertClosure();
|
|
1564
|
-
}
|
|
1565
|
-
})();
|
|
1566
|
-
const trackedForGap = closure.templates.length > 0 ? (trackedFiles(cwd) ?? []) : [];
|
|
490
|
+
const closure = envClosure(cwd);
|
|
491
|
+
const trackedForGap = closure.templates.length > 0 ? (trackedFilesFn(cwd) ?? []) : [];
|
|
1567
492
|
const launchTimeout = Math.min(timeoutMs, 180_000);
|
|
1568
493
|
for (const name of runnableDeclaredScripts(declared, covered)) {
|
|
1569
494
|
if (!present.has(name.toLowerCase()))
|
|
1570
495
|
continue;
|
|
1571
496
|
const cmd = ['bun', ['run', name]];
|
|
1572
497
|
const label = `${cmd[0]} ${cmd[1].join(' ')}`;
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE);
|
|
498
|
+
tally.attempted(cmd[0]);
|
|
499
|
+
const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, undefined, runCmd);
|
|
1576
500
|
if (r.outcome === 'skip') {
|
|
1577
501
|
if (r.spawnFailed)
|
|
1578
|
-
|
|
502
|
+
tally.spawnFailure(cmd[0]);
|
|
1579
503
|
skippedLaunch.push(name);
|
|
1580
504
|
continue;
|
|
1581
505
|
}
|
|
1582
|
-
|
|
506
|
+
tally.observed();
|
|
1583
507
|
if (r.outcome === 'fail') {
|
|
1584
508
|
// A CONFIG GAP IS NOT A CODE FAULT (mx5 run 20). The run died on
|
|
1585
509
|
// `bun run seed` exiting 1 because ADMIN_PHONE — which the project's
|
|
@@ -1601,21 +525,21 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1601
525
|
env: process.env
|
|
1602
526
|
});
|
|
1603
527
|
if (gap) {
|
|
1604
|
-
const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap));
|
|
528
|
+
const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap), runCmd);
|
|
1605
529
|
if (probe.outcome === 'pass') {
|
|
1606
530
|
// Nothing about this script was OBSERVED: the real run could
|
|
1607
531
|
// not reach it and the probe run is a diagnostic, never an
|
|
1608
532
|
// observation. So it un-counts, exactly like a skip.
|
|
1609
|
-
|
|
533
|
+
tally.unobserve();
|
|
1610
534
|
skippedLaunch.push(name);
|
|
1611
|
-
|
|
535
|
+
tally.configGap(configGapUnobservedNote(gap));
|
|
1612
536
|
continue;
|
|
1613
537
|
}
|
|
1614
538
|
}
|
|
1615
|
-
fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
539
|
+
tally.fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
1616
540
|
continue;
|
|
1617
541
|
}
|
|
1618
|
-
ran
|
|
542
|
+
tally.ran(label);
|
|
1619
543
|
}
|
|
1620
544
|
if (skippedLaunch.length > 0) {
|
|
1621
545
|
const notes = parseEnvNotes(await readEnvNotes(cwd)).filter(n => isExcuseNote(n.fact));
|
|
@@ -1623,7 +547,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1623
547
|
const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
|
1624
548
|
const excuse = notes.find(n => re.test(n.fact));
|
|
1625
549
|
if (excuse) {
|
|
1626
|
-
|
|
550
|
+
tally.warn(`launch script \`${name}\` could not run here (environment gap) and a `
|
|
1627
551
|
+ `standing excuse note covers it ("${excuse.fact.slice(0, 160)}") — `
|
|
1628
552
|
+ `UNOBSERVED: verify it by hand before trusting the launch surface`);
|
|
1629
553
|
}
|
|
@@ -1634,13 +558,36 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1634
558
|
// construction, and it carries the run's most load-bearing signal — earlier
|
|
1635
559
|
// failures no longer shadow it. Its failures rank FIRST in the aggregate.
|
|
1636
560
|
// A boot that never RAN is its own verdict (mx5 run 18 — see bootSkipVerdict);
|
|
1637
|
-
// it lives outside the
|
|
561
|
+
// it lives outside the tally's dynamic counters on purpose, so the test/build
|
|
1638
562
|
// commands that did run cannot cancel it.
|
|
1639
|
-
|
|
563
|
+
//
|
|
564
|
+
// ZERO DISCOVERY IS UNOBSERVED, NEVER A PASS (see unobservedVerdict, and the
|
|
565
|
+
// zero-attempts branch of GateTally.verdict). Nothing was discovered, so nothing
|
|
566
|
+
// ran, so the blindness guard below (attempted === 0 → null) does not fire — and
|
|
567
|
+
// this outcome used to be reported as `PASS — no integration command found
|
|
568
|
+
// (statics passed)`, i.e. "we never checked" reading identically to "we checked
|
|
569
|
+
// and it was fine". IAR1 shipped that verdict TWICE while carrying open
|
|
570
|
+
// verify-FAIL debt (its .pi-tasks/TASK_AUTO_0001.md:31 and TASK_AUTO_0002.md:37).
|
|
571
|
+
// The outcome stays `ok: true` (non-blocking, justified at unobservedVerdict) but
|
|
572
|
+
// is labelled, trailed and carried as debt by the caller. It needs no new command
|
|
573
|
+
// source, so unlike the harvest lever refuted at discoverIntegrationCommands it
|
|
574
|
+
// cannot inject a fabricated failure. The inert-contract note rides on it too: a
|
|
575
|
+
// non-npm project carrying a launch contract usually discovers no command either,
|
|
576
|
+
// and that is exactly the run whose silence must not read as "the contract was
|
|
577
|
+
// checked and was fine".
|
|
578
|
+
//
|
|
579
|
+
// This return sits AFTER the launch-script loop, not before it: it used to fire
|
|
580
|
+
// first, so a DECLARED launch script never ran on a tree with no discoverable
|
|
581
|
+
// integration command (found and left unfixed in f5d7110). "Nothing to observe"
|
|
582
|
+
// is a fact about the tally — no attempt, no failure — not about discovery, and
|
|
583
|
+
// asking the tally makes the two paths see the same state. It still returns
|
|
584
|
+
// before the boot `else` branch and the post-boot closure scans, whose stage is a
|
|
585
|
+
// statement about when they are meaningful.
|
|
586
|
+
if (!boot && tally.silent())
|
|
587
|
+
return tally.verdict(debts);
|
|
1640
588
|
if (boot) {
|
|
1641
589
|
const label = `${boot[0]} ${boot[1].join(' ')}`;
|
|
1642
|
-
|
|
1643
|
-
dynBins.add(boot[0]);
|
|
590
|
+
tally.attempted(boot[0]);
|
|
1644
591
|
const expectServer = detectsServedApp(cwd, planText);
|
|
1645
592
|
// Render check (mx5 runs 8/11): for a served app, load the live page in a
|
|
1646
593
|
// headless browser and judge the RENDERED DOM — curl can't run JS, so a
|
|
@@ -1669,14 +616,14 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1669
616
|
b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
|
|
1670
617
|
}
|
|
1671
618
|
if (b.outcome !== 'skip')
|
|
1672
|
-
|
|
619
|
+
tally.observed();
|
|
1673
620
|
else if (b.spawnFailed)
|
|
1674
|
-
|
|
1675
|
-
bootUnobserved
|
|
621
|
+
tally.spawnFailure(boot[0]);
|
|
622
|
+
tally.bootUnobserved(bootSkipVerdict({
|
|
1676
623
|
label,
|
|
1677
624
|
skipped: b.outcome === 'skip',
|
|
1678
625
|
expectServer
|
|
1679
|
-
});
|
|
626
|
+
}));
|
|
1680
627
|
if (b.outcome === 'fail') {
|
|
1681
628
|
// OBSERVED (nexttask 19A). Every path that produces `fail` here is a
|
|
1682
629
|
// probe that looked: the render judge saw an empty body, the deep
|
|
@@ -1685,7 +632,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1685
632
|
// condition that means "we could not look" — no ss/netstat/lsof, mx5
|
|
1686
633
|
// run 14 — returns PASS stamped UNOBSERVED and never reaches here
|
|
1687
634
|
// (`b0f90a7`, final-gate.ts `if (!canEnumerate) return passAndKill(…)`).
|
|
1688
|
-
failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
|
|
635
|
+
tally.failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
|
|
1689
636
|
}
|
|
1690
637
|
else if (b.outcome === 'orphan-port') {
|
|
1691
638
|
// Could not clear the port. Distinct HARNESS diagnosis, never a bare app
|
|
@@ -1694,14 +641,14 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1694
641
|
const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
|
|
1695
642
|
: b.port !== null ? ` — port ${b.port} is held by another process`
|
|
1696
643
|
: '';
|
|
1697
|
-
fail(`boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`, 0);
|
|
644
|
+
tally.fail(`boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`, 0);
|
|
1698
645
|
}
|
|
1699
646
|
else if (b.outcome === 'pass') {
|
|
1700
|
-
ran
|
|
647
|
+
tally.ran(label);
|
|
1701
648
|
// A listener that served, but whose page could not be OBSERVED to render
|
|
1702
649
|
// (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
|
|
1703
650
|
if (b.renderNote)
|
|
1704
|
-
|
|
651
|
+
tally.warn(b.renderNote);
|
|
1705
652
|
}
|
|
1706
653
|
}
|
|
1707
654
|
else {
|
|
@@ -1710,77 +657,22 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1710
657
|
// project with no launch surface, and it must not degrade into silence.
|
|
1711
658
|
const rejected = rejectedLaunchScript(cwd);
|
|
1712
659
|
if (rejected && detectsServedApp(cwd, planText)) {
|
|
1713
|
-
bootUnobserved
|
|
1714
|
-
`
|
|
1715
|
-
|
|
1716
|
-
+ 'observed to run.';
|
|
660
|
+
tally.bootUnobserved(`boot check: this project's only launch script (\`${rejected.name}\`) is not a `
|
|
661
|
+
+ `launch — ${rejected.reason} — so nothing was started and the app was never `
|
|
662
|
+
+ 'observed to run.');
|
|
1717
663
|
}
|
|
1718
664
|
}
|
|
1719
665
|
// Full-skip blindness guard (mx5 run 16): commands were discovered but every
|
|
1720
666
|
// one skipped → rank-0 failure, never a static-only PASS. Runner resolvability
|
|
1721
667
|
// is checked through resolveRunner so the failure text can name the missing
|
|
1722
668
|
// runner when that is the cause (the run-16 shape: login-shell PATH lost bun).
|
|
1723
|
-
const gap =
|
|
1724
|
-
attempted: dynAttempted,
|
|
1725
|
-
observed: dynObserved,
|
|
1726
|
-
spawnFailures: dynSpawnFailures,
|
|
1727
|
-
runnerBins: [...dynBins],
|
|
1728
|
-
runnerResolvable: b => resolveRunner(b).ok
|
|
1729
|
-
});
|
|
669
|
+
const gap = tally.blindness(b => resolveRunner(b).ok);
|
|
1730
670
|
if (gap)
|
|
1731
|
-
fail(gap, 0);
|
|
671
|
+
tally.fail(gap, 0);
|
|
1732
672
|
// The remaining run-level closure scans — "the shipped tree references or
|
|
1733
673
|
// requires something it does not contain" — after every dynamic section, so
|
|
1734
674
|
// their failures keep their historical place in the aggregate (CLOSURE_SCANS:
|
|
1735
675
|
// 'post-boot').
|
|
1736
|
-
runClosureScans('post-boot', { cwd, planText }, fail);
|
|
1737
|
-
|
|
1738
|
-
// Stable sort: boot/render (rank 0) leads, everything else keeps execution
|
|
1739
|
-
// order. One failure keeps the exact single-failure wording; several become
|
|
1740
|
-
// a numbered list so the trail, the ACCEPT picker, and the autofix seed all
|
|
1741
|
-
// carry the complete ranked picture.
|
|
1742
|
-
const ranked = [...failures].sort((a, b) => a.rank - b.rank);
|
|
1743
|
-
const texts = ranked.map(f => f.text);
|
|
1744
|
-
// The observed subset rides along by exact text identity (19A) — the demote
|
|
1745
|
-
// decision downstream reads THIS, instead of re-deriving observability from
|
|
1746
|
-
// the failure string.
|
|
1747
|
-
const observed = ranked.filter(f => f.observed === true).map(f => f.text);
|
|
1748
|
-
return withDebts({
|
|
1749
|
-
ok: false,
|
|
1750
|
-
reason: texts.length === 1 ?
|
|
1751
|
-
texts[0]
|
|
1752
|
-
: `${texts.length} failures (ranked, most load-bearing first):\n${texts
|
|
1753
|
-
.map((t, i) => `${i + 1}. ${t}`)
|
|
1754
|
-
.join('\n')}`,
|
|
1755
|
-
failures: texts,
|
|
1756
|
-
...(observed.length > 0 ? { observedFailures: observed } : {})
|
|
1757
|
-
});
|
|
1758
|
-
}
|
|
1759
|
-
const warningNote = warnings.length > 0 ? ` — WARNING: ${warnings.join('; WARNING: ')}` : '';
|
|
1760
|
-
// The same three-way verdict at the other zero-observation door: commands WERE
|
|
1761
|
-
// discovered, none spawn-failed (so the run-16 guard correctly stayed silent — every
|
|
1762
|
-
// skip was a tool-level env gap), and yet nothing ran. That was `statics passed
|
|
1763
|
-
// (integration commands not runnable here)`, which is the identical "we never checked"
|
|
1764
|
-
// silence wearing different words. Unchanged when anything at all was observed, so a
|
|
1765
|
-
// project with runnable commands is byte-for-byte unaffected.
|
|
1766
|
-
// Two independent UNOBSERVED notes, either or both of which may apply: the boot
|
|
1767
|
-
// never ran (run 18), and/or NOTHING dynamic ran at all. The boot note leads
|
|
1768
|
-
// because it names a concrete command and the trail line is sliced at 300 chars.
|
|
1769
|
-
const unobserved = [
|
|
1770
|
-
bootUnobserved,
|
|
1771
|
-
unobservedVerdict({ discovered: dynAttempted, observed: dynObserved }),
|
|
1772
|
-
...configGapNotes,
|
|
1773
|
-
...contractNotes
|
|
1774
|
-
]
|
|
1775
|
-
.filter(n => n !== null)
|
|
1776
|
-
.join(' ');
|
|
1777
|
-
return withDebts({
|
|
1778
|
-
ok: true,
|
|
1779
|
-
...(unobserved ? { unobserved } : {}),
|
|
1780
|
-
reason: (unobserved ? `${unobserved} — ` : '')
|
|
1781
|
-
+ (ran.length > 0 ?
|
|
1782
|
-
`statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
|
|
1783
|
-
: 'statics passed (integration commands not runnable here)')
|
|
1784
|
-
+ warningNote
|
|
1785
|
-
});
|
|
676
|
+
runClosureScans('post-boot', { cwd, planText }, (t, r) => tally.fail(t, r));
|
|
677
|
+
return tally.verdict(debts);
|
|
1786
678
|
}
|