@mjasnikovs/pi-task 0.38.15 → 0.38.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -0,0 +1,113 @@
1
+ /**
2
+ * autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
3
+ * that record makes.
4
+ *
5
+ * The same deepening `GateTally` already proved one altitude down. That replaced
6
+ * twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
7
+ * by closure; this replaces six threaded through ~235 lines of
8
+ * `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
9
+ * stranded sub-fixes, the previous failure signature, the demoted set, and the
10
+ * rejected-edits flag.
11
+ *
12
+ * The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
13
+ * each called from exactly ONE site inside that loop — extracted for testability
14
+ * while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
15
+ * is precisely the shape `isNonProgress`'s own comment indicts:
16
+ *
17
+ * > The fix is NOT a better string pattern — the bug is that the decision was
18
+ * > made downstream from the evidence.
19
+ *
20
+ * mx5 run 21 shipped a product whose every page was blank as a `completed` run
21
+ * through that gap. Here the evidence and the decision sit in one module, and the
22
+ * loop body reads as picker → apply → record.
23
+ *
24
+ * What is NOT here: anything that talks to the user, writes a ledger file, or
25
+ * commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
26
+ * that performs effects cannot be driven by a test that only wants the verdict.
27
+ */
28
+ /** The gate-outcome fields this record reads. Structural, so a caller can hand it
29
+ * a `FinalGateOutcome` without this module importing the gate. */
30
+ export interface AutofixOutcomeView {
31
+ reason: string;
32
+ failures?: string[];
33
+ observedFailures?: string[];
34
+ }
35
+ /** What the loop should do about this attempt's ranked-first failure. */
36
+ export interface AttemptVerdict {
37
+ /** The ranked-first failure, trimmed; null when the outcome names none. */
38
+ detail: string | null;
39
+ /**
40
+ * True ⇒ this check is unfalsifiable here: the attempt edited the tree, the
41
+ * gate re-ran, and returned the same first failure as the previous one — and
42
+ * no probe OBSERVED it. Already recorded as demoted when true.
43
+ */
44
+ demoted: boolean;
45
+ /** The debt reason to carry, present exactly when `demoted`. */
46
+ debtReason?: string;
47
+ }
48
+ export declare class AutofixLedger {
49
+ private readonly _budget;
50
+ /** Autofix attempts made in this loop, including the one in flight. */
51
+ private _attempts;
52
+ /**
53
+ * Gitignored paths the fix passes have written so far. ACCUMULATED across
54
+ * attempts: a `.env` written by a failed attempt is still on disk for the next
55
+ * one, and that attempt's own before/after diff cannot see it (mx5 run 19).
56
+ */
57
+ private _ignoredWritten;
58
+ /** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
59
+ private _stranded;
60
+ /** The previous attempt's normalized ranked-first failure. */
61
+ private _prevFailSig;
62
+ /** Signatures already carried as debt; a re-run reporting them does not re-fail. */
63
+ private readonly _demoted;
64
+ /**
65
+ * A write-guard rejected an attempt whose edits could NOT be discarded, so
66
+ * REJECTED edits are sitting in the tree and the terminal paths must not commit
67
+ * what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
68
+ * to ease committing).
69
+ */
70
+ private _rejectedEditsInTree;
71
+ constructor(_budget: number);
72
+ /** One autofix attempt is starting. */
73
+ attempt(): number;
74
+ /** Are there attempts left? Drives whether the picker offers the card at all. */
75
+ canAutofix(): boolean;
76
+ attempts(): number;
77
+ /** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
78
+ wroteIgnored(paths: readonly string[] | undefined): void;
79
+ ignoredWrites(): readonly string[];
80
+ /** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
81
+ setStranded(paths: readonly string[]): void;
82
+ stranded(): readonly string[];
83
+ /** A guard rejected an attempt and its edits are still in the tree. */
84
+ rejectedEditsRemain(): void;
85
+ /**
86
+ * May a terminal path commit what is in the working tree?
87
+ *
88
+ * False once a guard has rejected an attempt without discarding its edits.
89
+ * This is the ONE question the flag exists to answer, and it is asked at three
90
+ * terminal sites — as a method rather than a bare boolean read at each.
91
+ */
92
+ mayCommitTree(): boolean;
93
+ /**
94
+ * Judge this attempt's gate outcome, and record what the judgement implies.
95
+ *
96
+ * `edited` is the caller's fact — did this attempt change the tree and survive
97
+ * the guards. The observed/non-progress rule and the signature carry-forward
98
+ * are this module's, so they cannot be applied in the wrong order or skipped:
99
+ * a demoted signature is entered into the set and the previous-signature chain
100
+ * is broken in the SAME call that decides to demote.
101
+ */
102
+ judge(outcome: AutofixOutcomeView | undefined, edited: boolean): AttemptVerdict;
103
+ demotedCount(): number;
104
+ /**
105
+ * What still has to pass for the gate to converge: this outcome's failures with
106
+ * every already-demoted signature dropped. An EMPTY array means converged
107
+ * carrying the demotions as debt; `undefined` means the outcome named no list
108
+ * and there is nothing to reason about.
109
+ */
110
+ remaining(outcome: AutofixOutcomeView | undefined): string[] | undefined;
111
+ /** Has anything been demoted? Only then can a run converge on the remainder. */
112
+ hasDemotions(): boolean;
113
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
3
+ * that record makes.
4
+ *
5
+ * The same deepening `GateTally` already proved one altitude down. That replaced
6
+ * twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
7
+ * by closure; this replaces six threaded through ~235 lines of
8
+ * `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
9
+ * stranded sub-fixes, the previous failure signature, the demoted set, and the
10
+ * rejected-edits flag.
11
+ *
12
+ * The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
13
+ * each called from exactly ONE site inside that loop — extracted for testability
14
+ * while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
15
+ * is precisely the shape `isNonProgress`'s own comment indicts:
16
+ *
17
+ * > The fix is NOT a better string pattern — the bug is that the decision was
18
+ * > made downstream from the evidence.
19
+ *
20
+ * mx5 run 21 shipped a product whose every page was blank as a `completed` run
21
+ * through that gap. Here the evidence and the decision sit in one module, and the
22
+ * loop body reads as picker → apply → record.
23
+ *
24
+ * What is NOT here: anything that talks to the user, writes a ledger file, or
25
+ * commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
26
+ * that performs effects cannot be driven by a test that only wants the verdict.
27
+ */
28
+ import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
29
+ export class AutofixLedger {
30
+ _budget;
31
+ /** Autofix attempts made in this loop, including the one in flight. */
32
+ _attempts = 0;
33
+ /**
34
+ * Gitignored paths the fix passes have written so far. ACCUMULATED across
35
+ * attempts: a `.env` written by a failed attempt is still on disk for the next
36
+ * one, and that attempt's own before/after diff cannot see it (mx5 run 19).
37
+ */
38
+ _ignoredWritten = [];
39
+ /** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
40
+ _stranded = [];
41
+ /** The previous attempt's normalized ranked-first failure. */
42
+ _prevFailSig = null;
43
+ /** Signatures already carried as debt; a re-run reporting them does not re-fail. */
44
+ _demoted = new Set();
45
+ /**
46
+ * A write-guard rejected an attempt whose edits could NOT be discarded, so
47
+ * REJECTED edits are sitting in the tree and the terminal paths must not commit
48
+ * what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
49
+ * to ease committing).
50
+ */
51
+ _rejectedEditsInTree = false;
52
+ constructor(_budget) {
53
+ this._budget = _budget;
54
+ }
55
+ // ─── Record ──────────────────────────────────────────────────────────────
56
+ /** One autofix attempt is starting. */
57
+ attempt() {
58
+ return ++this._attempts;
59
+ }
60
+ /** Are there attempts left? Drives whether the picker offers the card at all. */
61
+ canAutofix() {
62
+ return this._attempts < this._budget;
63
+ }
64
+ attempts() {
65
+ return this._attempts;
66
+ }
67
+ /** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
68
+ wroteIgnored(paths) {
69
+ if (!paths || paths.length === 0)
70
+ return;
71
+ for (const p of paths)
72
+ if (!this._ignoredWritten.includes(p))
73
+ this._ignoredWritten.push(p);
74
+ }
75
+ ignoredWrites() {
76
+ return this._ignoredWritten;
77
+ }
78
+ /** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
79
+ setStranded(paths) {
80
+ this._stranded = [...paths];
81
+ }
82
+ stranded() {
83
+ return this._stranded;
84
+ }
85
+ /** A guard rejected an attempt and its edits are still in the tree. */
86
+ rejectedEditsRemain() {
87
+ this._rejectedEditsInTree = true;
88
+ }
89
+ /**
90
+ * May a terminal path commit what is in the working tree?
91
+ *
92
+ * False once a guard has rejected an attempt without discarding its edits.
93
+ * This is the ONE question the flag exists to answer, and it is asked at three
94
+ * terminal sites — as a method rather than a bare boolean read at each.
95
+ */
96
+ mayCommitTree() {
97
+ return !this._rejectedEditsInTree;
98
+ }
99
+ // ─── Decide ──────────────────────────────────────────────────────────────
100
+ /**
101
+ * Judge this attempt's gate outcome, and record what the judgement implies.
102
+ *
103
+ * `edited` is the caller's fact — did this attempt change the tree and survive
104
+ * the guards. The observed/non-progress rule and the signature carry-forward
105
+ * are this module's, so they cannot be applied in the wrong order or skipped:
106
+ * a demoted signature is entered into the set and the previous-signature chain
107
+ * is broken in the SAME call that decides to demote.
108
+ */
109
+ judge(outcome, edited) {
110
+ const detail = rankedFirstFailure({
111
+ ...(outcome ? { reason: outcome.reason } : {}),
112
+ ...(outcome?.failures ? { failures: outcome.failures } : {})
113
+ });
114
+ // Whether a PROBE OBSERVED this failure, read off the SAME outcome the
115
+ // failure came from — exact text identity, never a second string pattern.
116
+ const observed = outcome?.observedFailures?.includes(detail ?? '') === true;
117
+ if (detail !== null
118
+ && isNonProgress({
119
+ previousSignature: this._prevFailSig,
120
+ currentDetail: detail,
121
+ edited,
122
+ observed
123
+ })) {
124
+ this._demoted.add(normalizeFailureDetail(detail));
125
+ // A demotion ends the chain: the next attempt has no previous signature
126
+ // to match, so one demotion cannot cascade into a second.
127
+ this._prevFailSig = null;
128
+ return { detail, demoted: true, debtReason: unobservedDebtReason(detail) };
129
+ }
130
+ this._prevFailSig = detail !== null ? normalizeFailureDetail(detail) : null;
131
+ return { detail, demoted: false };
132
+ }
133
+ demotedCount() {
134
+ return this._demoted.size;
135
+ }
136
+ /**
137
+ * What still has to pass for the gate to converge: this outcome's failures with
138
+ * every already-demoted signature dropped. An EMPTY array means converged
139
+ * carrying the demotions as debt; `undefined` means the outcome named no list
140
+ * and there is nothing to reason about.
141
+ */
142
+ remaining(outcome) {
143
+ const failures = outcome?.failures ?? (outcome ? [outcome.reason] : undefined);
144
+ if (failures === undefined)
145
+ return undefined;
146
+ return applyDemotions(failures, this._demoted);
147
+ }
148
+ /** Has anything been demoted? Only then can a run converge on the remainder. */
149
+ hasDemotions() {
150
+ return this._demoted.size > 0;
151
+ }
152
+ }
@@ -135,6 +135,52 @@ export interface BootDeps {
135
135
  preferredPort?: () => Promise<number | null>;
136
136
  /** Does anything answer HTTP on 127.0.0.1:`port`? Injected for tests. */
137
137
  httpProbe?: (port: number) => boolean;
138
+ /**
139
+ * Spawn the boot child. THE SUBJECT of this check, and the one thing `BootDeps`
140
+ * did not seam.
141
+ *
142
+ * Nine fields above inject something the check LOOKS AT; `spawn` was imported
143
+ * directly, so the ~220-line state machine below — seven locals threaded by
144
+ * closure, four `BootOutcome` kinds, five exit arms — was reachable only through
145
+ * a real process on a real clock. Measured: 52 tests / 13.6s, with 300–5000ms
146
+ * grace windows scripted as real `process.execPath -e` children.
147
+ *
148
+ * `BootChild` is defined from what this function CALLS, not from Node's
149
+ * `ChildProcess` — the same way `driveSession(cdp: CdpLike, …)` was defined from
150
+ * the two `Cdp` methods it uses. A scripted fake is a dozen lines.
151
+ */
152
+ spawnBoot?: (bin: string, args: string[], opts: BootSpawnOptions) => BootChild;
153
+ /**
154
+ * Tear down the child's whole process group. Injected with `spawnBoot`, because
155
+ * a fake child has no group to kill and a real `process.kill(-pid)` against a
156
+ * fake pid would signal something else entirely.
157
+ */
158
+ killGroup?: (pid: number, signal: NodeJS.Signals) => void;
159
+ }
160
+ /** What `runBootCheck` passes to its spawn. */
161
+ export interface BootSpawnOptions {
162
+ cwd: string;
163
+ detached: true;
164
+ stdio: ['ignore', 'pipe', 'pipe'];
165
+ env: Record<string, string | undefined>;
166
+ }
167
+ /** A stream the boot check reads output from. */
168
+ export interface BootStream {
169
+ on: (event: 'data', cb: (chunk: Buffer | string) => void) => void;
170
+ }
171
+ /**
172
+ * The boot child, as the check actually uses it: a pid, two output streams, an
173
+ * `error` event and an `exit` event carrying (status, signal).
174
+ */
175
+ export interface BootChild {
176
+ pid?: number | undefined;
177
+ unref?: () => void;
178
+ stdout?: BootStream | null;
179
+ stderr?: BootStream | null;
180
+ on: {
181
+ (event: 'error', cb: (err: Error) => void): void;
182
+ (event: 'exit', cb: (status: number | null, signal: NodeJS.Signals | null) => void): void;
183
+ };
138
184
  }
139
185
  /**
140
186
  * Does the finished run stand up a listening HTTP server? Deterministic, from the
@@ -294,5 +340,67 @@ export declare function recoverOrphanPort(cwd: string, boot: HealthCommand, firs
294
340
  outcome: 'orphan-port';
295
341
  detail: string;
296
342
  port: number | null;
297
- }, bootGraceMs: number, deps: BootDeps, expectServer: boolean): Promise<BootOutcome>;
343
+ },
344
+ /** One options object, not four trailing positionals — `bootGraceMs` and a
345
+ * boolean sat adjacent and swapped without a type error. */
346
+ opts: {
347
+ graceMs?: number;
348
+ deps: BootDeps;
349
+ expectServer: boolean;
350
+ }): Promise<BootOutcome>;
351
+ /**
352
+ * Everything the run-end gate records about "did the assembled product start?".
353
+ *
354
+ * This module owned the mechanics but not the CONCEPT: answering that question
355
+ * meant reading ~110 lines of `final-gate.ts` as well, where the four-armed
356
+ * {@link BootOutcome} union was destructured, the render/deep-render/port probe
357
+ * defaults were bound, `recoverOrphanPort` was re-invoked with six positional
358
+ * arguments, and the port-holder diagnosis reached back into `BootDeps` a SECOND
359
+ * time to build its own sentence. CONTEXT.md records the earlier extraction as
360
+ * "a file move, not a re-shaping"; this is the re-shaping it left open.
361
+ *
362
+ * The gate's boot branch is now: call this, write the fields into the tally.
363
+ * `runBootCheck` stays exported unchanged — seven harnesses under `scripts/`
364
+ * drive it directly.
365
+ */
366
+ export interface BootSectionVerdict {
367
+ /** The bin the gate counts as ATTEMPTED. Absent ⇒ there was nothing to boot. */
368
+ attempted?: string;
369
+ /** A probe LOOKED. False for a skip, and for a project with no launch surface. */
370
+ observed: boolean;
371
+ /** The runner binary never spawned — feeds the full-blindness guard. */
372
+ spawnFailedBin?: string;
373
+ /** The UNOBSERVED note, offered on both branches (a rejected launch script has one). */
374
+ unobservedNote?: string;
375
+ /**
376
+ * The one failure this section can produce, with the rank the gate gives it (0 —
377
+ * boot failures lead the aggregate) and whether a probe OBSERVED it. A harness
378
+ * condition (a port we could not clear) is a failure that nothing observed about
379
+ * the APP, which is why `observed` is a field and not implied by `failure`.
380
+ */
381
+ failure?: {
382
+ detail: string;
383
+ rank: number;
384
+ observed: boolean;
385
+ };
386
+ /** The label recorded as having RUN, on a clean boot. */
387
+ ranLabel?: string;
388
+ /** UNOBSERVED warnings — a listener that served but whose page could not be judged. */
389
+ warnings: string[];
390
+ }
391
+ export interface BootSectionOptions {
392
+ /** The plan text, for served-app detection. */
393
+ planText?: string;
394
+ /** Grace period for the boot check. */
395
+ graceMs?: number;
396
+ /**
397
+ * Probe overrides. The render, deep-render and preferred-port defaults are
398
+ * bound HERE now: they were the gate's, so a caller that wanted the real boot
399
+ * behaviour had to know to supply three functions it should never have had to
400
+ * name — `findPortHolder` was already defaulted inside this module, and the
401
+ * other three now match it.
402
+ */
403
+ deps?: BootDeps;
404
+ }
405
+ export declare function runBootSection(cwd: string, opts?: BootSectionOptions): Promise<BootSectionVerdict>;
298
406
  export {};
@@ -23,10 +23,11 @@ import { spawn, spawnSync } from 'node:child_process';
23
23
  import { existsSync, readFileSync } from 'node:fs';
24
24
  import * as net from 'node:net';
25
25
  import * as path from 'node:path';
26
+ import { runRenderCheck } from './render-check.js';
26
27
  import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
27
28
  import { outputTail } from './command-run.js';
28
29
  import { packageScripts, makeHasTarget } from './launch-manifest.js';
29
- import { collectProjectEnv, pinnedLocalPort } from './deep-render-check.js';
30
+ import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
30
31
  /** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
31
32
  function commandTokens(member) {
32
33
  const t = member.trim().split(/\s+/).filter(Boolean);
@@ -485,6 +486,30 @@ function holderIsOurs(command, boot) {
485
486
  return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
486
487
  && (c.includes(` ${script}`) || c.endsWith(script)));
487
488
  }
489
+ /** The real spawn. Kept beside the seam so the default is one line to read. */
490
+ function defaultSpawnBoot(bin, args, o) {
491
+ return spawn(bin, args, o);
492
+ }
493
+ /**
494
+ * The real group teardown, best-effort. A group already gone is not an error.
495
+ *
496
+ * Windows has no process groups / negative-pid kill: `taskkill /T` tears down the
497
+ * whole tree (the detached child plus any grandchildren it spawned) and `/F`
498
+ * forces it, so the SIGTERM→SIGKILL escalation collapses to one idempotent call.
499
+ */
500
+ function defaultKillGroup(pid, sig) {
501
+ try {
502
+ if (process.platform === 'win32') {
503
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
504
+ }
505
+ else {
506
+ process.kill(-pid, sig);
507
+ }
508
+ }
509
+ catch {
510
+ // group already gone
511
+ }
512
+ }
488
513
  /**
489
514
  * Exercise the start command ONCE. For a CLI project (`expectServer` false) the
490
515
  * command's own fate within the grace window decides:
@@ -546,8 +571,9 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
546
571
  // the runner and carry its directory on PATH so the boot script's own chain
547
572
  // can re-invoke it.
548
573
  const runner = resolveRunner(bin);
574
+ const spawnBoot = opts.deps?.spawnBoot ?? defaultSpawnBoot;
549
575
  return new Promise(resolve => {
550
- const child = spawn(runner.bin, args, {
576
+ const child = spawnBoot(runner.bin, args, {
551
577
  cwd,
552
578
  detached: true,
553
579
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -561,13 +587,17 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
561
587
  // runner where the group-kill did not take, hanging the whole `bun test
562
588
  // --isolate` run on the leaked child's piped stdio). unref() so a child
563
589
  // we already tried to kill can never itself keep this process alive.
564
- child.unref();
590
+ child.unref?.();
565
591
  let out = '';
566
592
  let err = '';
567
593
  let listenerSeen = false;
568
594
  const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
569
- child.stdout?.on('data', (d) => (out = cap(out + String(d))));
570
- child.stderr?.on('data', (d) => (err = cap(err + String(d))));
595
+ child.stdout?.on('data', d => {
596
+ out = cap(out + String(d));
597
+ });
598
+ child.stderr?.on('data', d => {
599
+ err = cap(err + String(d));
600
+ });
571
601
  let settled = false;
572
602
  const settle = (r) => {
573
603
  if (settled)
@@ -578,24 +608,15 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
578
608
  clearInterval(poll);
579
609
  resolve(r);
580
610
  };
611
+ const reapGroup = opts.deps?.killGroup ?? defaultKillGroup;
581
612
  const killGroup = (sig) => {
582
- try {
583
- if (!child.pid)
584
- return;
585
- if (process.platform === 'win32') {
586
- // Windows has no process groups / negative-pid kill. taskkill
587
- // /T tears down the whole tree (the detached child plus any
588
- // grandchildren it spawned); /F forces it, so the SIGTERM→
589
- // SIGKILL escalation collapses to one idempotent call.
590
- spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
591
- }
592
- else {
593
- process.kill(-child.pid, sig);
594
- }
595
- }
596
- catch {
597
- // group already gone
598
- }
613
+ // Truthiness, deliberately: `process.kill(-0, sig)` signals the
614
+ // CALLER's own process group, so a pid of 0 turns a best-effort
615
+ // teardown into self-termination. Node's spawn never yields 0, but
616
+ // `spawnBoot` is a seam now and a fake or future child could.
617
+ if (!child.pid)
618
+ return;
619
+ reapGroup(child.pid, sig);
599
620
  };
600
621
  const passAndKill = (renderNote) => {
601
622
  settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
@@ -791,7 +812,11 @@ export function bootSkipVerdict(args) {
791
812
  * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
792
813
  * to ourselves.
793
814
  */
794
- export async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServer) {
815
+ export async function recoverOrphanPort(cwd, boot, first,
816
+ /** One options object, not four trailing positionals — `bootGraceMs` and a
817
+ * boolean sat adjacent and swapped without a type error. */
818
+ opts) {
819
+ const { graceMs: bootGraceMs, deps, expectServer } = opts;
795
820
  if (first.port === null)
796
821
  return first;
797
822
  const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
@@ -804,3 +829,94 @@ export async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, exp
804
829
  await new Promise(r => setTimeout(r, 1_500));
805
830
  return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
806
831
  }
832
+ export async function runBootSection(cwd, opts = {}) {
833
+ const boot = discoverBootCommand(cwd);
834
+ const expectServer = detectsServedApp(cwd, opts.planText);
835
+ const warnings = [];
836
+ if (!boot) {
837
+ // Nothing to boot — but if the reason is that the project's only launch
838
+ // script was REJECTED as not-a-launch (2A), that is not the same thing as a
839
+ // project with no launch surface, and it must not degrade into silence.
840
+ const rejected = rejectedLaunchScript(cwd);
841
+ if (rejected && expectServer) {
842
+ return {
843
+ observed: false,
844
+ warnings,
845
+ unobservedNote: `boot check: this project's only launch script (\`${rejected.name}\`) is not a `
846
+ + `launch — ${rejected.reason} — so nothing was started and the app was never `
847
+ + 'observed to run.'
848
+ };
849
+ }
850
+ return { observed: false, warnings };
851
+ }
852
+ const label = `${boot[0]} ${boot[1].join(' ')}`;
853
+ // Render check (mx5 runs 8/11): for a served app, load the live page in a
854
+ // headless browser and judge the RENDERED DOM — curl can't run JS, so a
855
+ // blank-mount app passed every prior "renders" check. runRenderCheck
856
+ // env-gap-SKIPs when no browser exists, so a box without one never gets a
857
+ // false FAIL.
858
+ //
859
+ // Authenticated deep-render check (mx5 run 17): the page above renders, so now
860
+ // sign in with the account the project's own dotenv declares (the same
861
+ // ADMIN_PHONE/ADMIN_PASSWORD the launch contract's seed step consumes) and
862
+ // require the session to actually work. WEB-ONLY by construction — it hangs off
863
+ // the served-app branch and never runs for C++, Godot, CLI or library projects.
864
+ // It may only FAIL when the SERVER authenticated us; no browser, no
865
+ // credentials, an undrivable form or rejected credentials all skip as env gaps.
866
+ const deps = {
867
+ ...opts.deps,
868
+ renderProbe: opts.deps?.renderProbe ?? runRenderCheck,
869
+ deepRenderProbe: opts.deps?.deepRenderProbe ?? (url => runDeepRenderCheck(url, cwd)),
870
+ preferredPort: opts.deps?.preferredPort ?? (() => preferredDeclaredPort(cwd))
871
+ };
872
+ let b = await runBootCheck(cwd, boot, opts.graceMs, { expectServer, deps });
873
+ if (b.outcome === 'orphan-port') {
874
+ b = await recoverOrphanPort(cwd, boot, b, {
875
+ ...(opts.graceMs === undefined ? {} : { graceMs: opts.graceMs }),
876
+ deps,
877
+ expectServer
878
+ });
879
+ }
880
+ const verdict = {
881
+ attempted: boot[0],
882
+ observed: b.outcome !== 'skip',
883
+ warnings,
884
+ ...(b.outcome === 'skip' && b.spawnFailed ? { spawnFailedBin: boot[0] } : {})
885
+ };
886
+ const unobserved = bootSkipVerdict({ label, skipped: b.outcome === 'skip', expectServer });
887
+ if (unobserved !== null)
888
+ verdict.unobservedNote = unobserved;
889
+ if (b.outcome === 'fail') {
890
+ // OBSERVED (nexttask 19A). Every path that produces `fail` here is a probe
891
+ // that looked: the render judge saw an empty body, the deep session saw the
892
+ // authenticated half dead, the enumerator saw no listener, or the launch
893
+ // command itself exited non-zero. The one condition that means "we could not
894
+ // look" — no ss/netstat/lsof, mx5 run 14 — returns PASS stamped UNOBSERVED
895
+ // and never reaches here.
896
+ verdict.failure = { detail: `boot check: \`${label}\` ${b.detail}`, rank: 0, observed: true };
897
+ }
898
+ else if (b.outcome === 'orphan-port') {
899
+ // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
900
+ // FAIL: name the port and (when known) the process squatting on it. The
901
+ // holder lookup reads the SAME deps the boot ran under — it used to be a
902
+ // second reach into `BootDeps` from the gate, one layer away from the run.
903
+ const holder = b.port !== null ? (deps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
904
+ const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
905
+ : b.port !== null ? ` — port ${b.port} is held by another process`
906
+ : '';
907
+ verdict.failure = {
908
+ detail: `boot check: \`${label}\` could not bind: orphaned process / port already in `
909
+ + `use${who} (harness condition, not an app fault)`,
910
+ rank: 0,
911
+ observed: false
912
+ };
913
+ }
914
+ else if (b.outcome === 'pass') {
915
+ verdict.ranLabel = label;
916
+ // A listener that served, but whose page could not be OBSERVED to render
917
+ // (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
918
+ if (b.renderNote)
919
+ warnings.push(b.renderNote);
920
+ }
921
+ return verdict;
922
+ }