@mjasnikovs/pi-task 0.24.4 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,12 +40,15 @@ export interface FinalGateOutcome {
40
40
  */
41
41
  openDebts?: AcceptDebt[];
42
42
  /**
43
- * Set (with the UNOBSERVED note) when the gate observed NOTHING dynamic either
44
- * no command was discoverable at all, or every discovered one was skipped as an
45
- * environment gap. `ok` is still true (the statics did pass and there is nothing
46
- * to fix), but this is NOT a PASS: the caller must record it as UNOBSERVED, never
47
- * as "checked and fine". Absent at least one dynamic command actually ran.
48
- * See unobservedVerdict for the three-way verdict and the blocking decision.
43
+ * Set (with the UNOBSERVED note) when the gate could not OBSERVE something it was
44
+ * supposed to. Two independent triggers, either or both:
45
+ * - nothing dynamic ran at all no command was discoverable, or every discovered
46
+ * one skipped as an environment gap (unobservedVerdict);
47
+ * - a served app's boot command was discovered and SKIPPED, whatever else ran
48
+ * (bootSkipVerdict, mx5 run 18 test suites may not stand in for a launch).
49
+ * `ok` is still true (the statics did pass and there is nothing to fix), but this is
50
+ * NOT a PASS: the caller must record it as UNOBSERVED, never as "checked and fine".
51
+ * Absent ⇒ everything the gate meant to observe, it observed.
49
52
  */
50
53
  unobserved?: string;
51
54
  }
@@ -80,12 +83,57 @@ export declare function discoverIntegrationCommands(cwd: string): {
80
83
  };
81
84
  /** Every lockfile consistency check that applies to this tree (possibly none). */
82
85
  export declare function discoverLockfileChecks(cwd: string): HealthCommand[];
86
+ /**
87
+ * Why this script is NOT a launch of the shipped app, or null when it plausibly
88
+ * is one (mx5 run 18, validated).
89
+ *
90
+ * Run 18's boot command resolved to `bun run dev`, whose body is
91
+ * `docker compose -f docker-compose.dev.yml up -d && until docker compose … pg_isready
92
+ * … && concurrently "bun run dev:css" "bun run dev:js" "bun run --watch
93
+ * src/server/index.ts"`. The gate sandbox has no docker, so the chain died at 127 and
94
+ * the boot SKIPPED as an environment gap — while the shipped app had no HTTP listener
95
+ * at all. A script whose first act is `docker compose up` cannot distinguish "the app
96
+ * is broken" from "this box has no docker", so it is not evidence either way: better
97
+ * to discover NO boot command — reported as "nothing to boot" — and let the static
98
+ * serve-entry check (serve-entry.ts) carry the signal, than to spend the grace window
99
+ * producing an unfalsifiable skip.
100
+ *
101
+ * CONSERVATIVE AND LEXICAL BY CONSTRUCTION. Only two shapes are rejected, both
102
+ * decidable from the script text alone:
103
+ * 1. the chain OPENS with container orchestration (docker/podman/nerdctl … up|start|run);
104
+ * 2. the whole body is a multiplexer (concurrently/npm-run-all/run-p/run-s/turbo)
105
+ * whose every child is an ASSET watcher in watch mode (tailwind/tsc/esbuild/…),
106
+ * i.e. nothing in it can ever listen.
107
+ * Anything else — `vite`, `next dev`, `node dist/index.js`, `nodemon`, `bun --watch
108
+ * src/index.ts`, and any multiplexer with one non-asset child — is accepted
109
+ * unchanged. Deciding whether a watcher actually SERVES is not attempted here; that
110
+ * is exactly what the static serve-entry check is for.
111
+ */
112
+ export declare function nonLaunchScriptReason(body: string, scripts?: Record<string, string>): string | null;
83
113
  /**
84
114
  * The project's OWN launch command, if it declares one (package.json `start`,
85
115
  * else `dev`; Makefile `run`). null means the project has nothing to boot —
86
116
  * the boot check degrades to nothing-to-run.
117
+ *
118
+ * A script that is not a LAUNCH at all (nonLaunchScriptReason — mx5 run 18's
119
+ * `docker compose up` orchestrator) is rejected here and falls through to the
120
+ * next candidate, then to null. Discovering nothing is strictly better than
121
+ * discovering something unfalsifiable: an env-gap skip of an orchestration script
122
+ * says nothing about the app, and null is reported as "nothing to boot".
87
123
  */
88
124
  export declare function discoverBootCommand(cwd: string): HealthCommand | null;
125
+ /**
126
+ * The launch script that EXISTS but was rejected as not-a-launch, if any. Without
127
+ * this the rejection would trade run 18's unfalsifiable skip for pure silence: no
128
+ * boot command means bootSkipVerdict has no label to name, and a project whose test
129
+ * suite ran still reports `observed > 0`, so unobservedVerdict stays quiet too. A
130
+ * served app whose only declared launch script cannot start it was not observed to
131
+ * run, and must say so.
132
+ */
133
+ export declare function rejectedLaunchScript(cwd: string): {
134
+ name: string;
135
+ reason: string;
136
+ } | null;
89
137
  type BootOutcome = {
90
138
  outcome: 'skip' | 'pass';
91
139
  /** Set when the render check could not OBSERVE the served page (no browser,
@@ -347,6 +395,54 @@ export declare function unobservedVerdict(args: {
347
395
  /** Of those, how many actually RAN (a real pass or a real fail). */
348
396
  observed: number;
349
397
  }): string | null;
398
+ /**
399
+ * The SAME third verdict, at the door unobservedVerdict cannot reach: the boot
400
+ * check specifically (mx5 run 18, validated).
401
+ *
402
+ * Run 18 shipped an app with no HTTP server behind a converged final gate. Its
403
+ * `src/server/index.ts` ends at `export {app}` — no `Bun.serve`, no
404
+ * `export default app`, no `start` script — so `bun run src/server/index.ts` exits
405
+ * 0 immediately and the product cannot be started at all. The gate's boot command
406
+ * resolved to `bun run dev`, whose body begins `docker compose … up -d`; the gate
407
+ * sandbox had no docker, so the boot SKIPPED as an environment gap. Skips
408
+ * contribute nothing to `dynObserved`, and `bun run test`, `test:ct`, `build`,
409
+ * `lint`, `seed` and `migrate` all ran — so `dynObserved > 0`, the full-skip
410
+ * blindness guard (observabilityGapFailure) stayed correctly quiet, and the trail
411
+ * read `final-gate: autofix converged — statics + … passed` with 24/24 tasks green.
412
+ *
413
+ * The defect is that "the app was never observed to boot" and "the app booted
414
+ * fine" produced BYTE-IDENTICAL gate output. That is the class scripts/ab-verdict.ts
415
+ * exists to kill one layer up: absence of evidence rendered in the shape of
416
+ * evidence. So a discovered-but-skipped boot now names itself, and — unlike every
417
+ * other skip — it CANNOT be cancelled by observations from other commands.
418
+ * Component tests are the trap here, not the alibi: run 18 had 51 green Playwright
419
+ * CT tests, and CT mounts components in a browser without ever assembling or
420
+ * starting the server.
421
+ *
422
+ * DECIDED, do not silently re-open:
423
+ * - NOT a FAIL. A boot skip on a docker-less box is a genuine environment gap, and
424
+ * failing it re-creates run 16's unfalsifiable-FAIL mistake pointing the other
425
+ * way. UNOBSERVED blocks nothing while being loud and durable (the caller records
426
+ * it as final-gate debt the next run re-surfaces), and it keeps "boot never ran"
427
+ * out of the autofix child's seed — a child cannot fix a missing docker, so the
428
+ * highest-probability response would be to FABRICATE a bootable command, the
429
+ * class that refuted the `## verified tooling` harvest.
430
+ * - BOTH skip flavours count. Run 18's skip carried `spawnFailed: false` (127 inside
431
+ * the script chain, not an ENOENT on the runner), so keying off spawnFailed would
432
+ * have missed the actual defect.
433
+ * - SERVED APPS ONLY. `expectServer === false` (a CLI/library project) is fenced off
434
+ * deliberately: a CLI whose `dev` script needs an absent tool has no server to be
435
+ * unobserved, and widening the lever there buys warnings nobody can act on.
436
+ */
437
+ export declare function bootSkipVerdict(args: {
438
+ /** `bin args…` of the DISCOVERED boot command; null ⇒ nothing to boot, which is
439
+ * not the same thing as a boot that was not observed. */
440
+ label: string | null;
441
+ /** Did the boot check end in `skip` (either flavour)? */
442
+ skipped: boolean;
443
+ /** Does this project stand up an HTTP server (detectsServedApp)? */
444
+ expectServer: boolean;
445
+ }): string | null;
350
446
  export { taskThatIntroduced };
351
447
  /**
352
448
  * Run the final gate: static analysis first, then the lockfile consistency
@@ -37,6 +37,13 @@
37
37
  * database is genuinely reachable-but-mis-wired — which is exactly the class the
38
38
  * per-task gates kept excusing — and the caller puts a human on the decision
39
39
  * (accept / leave failed), so a genuine external gap can still be overridden.
40
+ *
41
+ * A skip is never silent, though. A DISCOVERED boot command that never ran is its
42
+ * own verdict (bootSkipVerdict, mx5 run 18) — nothing else the gate observed can
43
+ * cancel it. Validation harnesses for that lever:
44
+ * scripts/boot-skip-baserate.ts base rate, shipped gate, before the change
45
+ * scripts/boot-skip-verdict-ab.ts two-armed deterministic A/B + invariants
46
+ * scripts/boot-skip-fp-suite.ts zero-FP arms over every local repo
40
47
  */
41
48
  import { spawn, spawnSync } from 'node:child_process';
42
49
  import { existsSync, readFileSync } from 'node:fs';
@@ -51,6 +58,7 @@ import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-r
51
58
  import { resolveRunner, runnerEnv } from './runner-resolve.js';
52
59
  import { taskThatIntroduced } from './task-provenance.js';
53
60
  import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
61
+ import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
54
62
  function packageScripts(cwd) {
55
63
  try {
56
64
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -185,16 +193,128 @@ export function discoverLockfileChecks(cwd) {
185
193
  }
186
194
  return cmds;
187
195
  }
196
+ /** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
197
+ function commandTokens(member) {
198
+ const t = member.trim().split(/\s+/).filter(Boolean);
199
+ while (t.length > 0
200
+ && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]) || /^(?:sudo|exec|env)$/.test(t[0]))) {
201
+ t.shift();
202
+ }
203
+ return t;
204
+ }
205
+ /** The chain members of a shell script body, in order (`&&`, `||`, `;`, `|`). */
206
+ function chainMembers(body) {
207
+ return body
208
+ .split(/&&|\|\||;|\|/)
209
+ .map(s => s.trim())
210
+ .filter(s => s.length > 0);
211
+ }
212
+ /** Container/infra orchestration: `docker compose … up`, `docker-compose … up -d`,
213
+ * `podman-compose … up`, `docker run …`. The verb must be a bare token, so a
214
+ * filename like `docker-compose.dev.yml` never counts as one. */
215
+ function isContainerOrchestration(member) {
216
+ const t = commandTokens(member);
217
+ if (t.length === 0)
218
+ return false;
219
+ const bin = path.posix.basename(t[0]);
220
+ if (!/^(?:docker|podman|nerdctl)(?:-compose)?$/.test(bin))
221
+ return false;
222
+ const verbs = new Set(['up', 'start', 'run']);
223
+ return t.slice(1).some(tok => verbs.has(tok));
224
+ }
225
+ const MULTIPLEXER_RE = /^(?:concurrently|npm-run-all|run-p|run-s|turbo)$/;
226
+ /** A watcher that recompiles ASSETS and never listens: the tool is a
227
+ * bundler/compiler/preprocessor AND it is in watch mode. `bun run --watch x.ts`
228
+ * is deliberately NOT here — that re-executes an entrypoint, which may serve. */
229
+ const ASSET_TOOL_RE = /(?:^|[\s/@])(?:tailwindcss|postcss|sass|node-sass|less|stylus|esbuild|rollup|webpack|parcel|swc|babel|tsc|tsup|chokidar)(?:$|[\s"'])/;
230
+ const WATCH_FLAG_RE = /(?:^|\s)(?:--watch|-w|--watch=[^\s]*)(?:\s|$)/;
231
+ /** The quoted commands a multiplexer runs, or its bare script-name arguments
232
+ * resolved through the manifest (`run-p dev:css dev:js`). One level only. */
233
+ function multiplexerChildren(member, scripts) {
234
+ const quoted = [...member.matchAll(/"([^"]+)"|'([^']+)'/g)].map(m => m[1] ?? m[2]);
235
+ if (quoted.length > 0)
236
+ return quoted;
237
+ const t = commandTokens(member)
238
+ .slice(1)
239
+ .filter(a => !a.startsWith('-'));
240
+ return t.flatMap(name => (scripts[name] !== undefined ? [scripts[name]] : []));
241
+ }
242
+ /** Every member of the chain that could plausibly stay up and serve. Members that
243
+ * are one-shot setup (`mkdir`, `sleep`, an `until … done` wait loop) are not
244
+ * themselves launches, but they are not disqualifying either — only the two
245
+ * shapes below are. */
246
+ function isWatcherOnlyMultiplexer(member, scripts) {
247
+ const t = commandTokens(member);
248
+ if (t.length === 0)
249
+ return false;
250
+ const bin = path.posix.basename(t[0]);
251
+ const runner = /^(?:npx|bunx|pnpm|yarn|npm)$/.test(bin);
252
+ const head = runner ?
253
+ (t.slice(1).find(a => !a.startsWith('-') && a !== 'exec' && a !== 'dlx' && a !== 'run')
254
+ ?? '')
255
+ : bin;
256
+ if (!MULTIPLEXER_RE.test(path.posix.basename(head)))
257
+ return false;
258
+ const children = multiplexerChildren(member, scripts);
259
+ if (children.length === 0)
260
+ return false;
261
+ // Every child is an ASSET watcher ⇒ nothing in here ever listens.
262
+ return children.every(c => ASSET_TOOL_RE.test(c) && WATCH_FLAG_RE.test(c));
263
+ }
264
+ /**
265
+ * Why this script is NOT a launch of the shipped app, or null when it plausibly
266
+ * is one (mx5 run 18, validated).
267
+ *
268
+ * Run 18's boot command resolved to `bun run dev`, whose body is
269
+ * `docker compose -f docker-compose.dev.yml up -d && until docker compose … pg_isready
270
+ * … && concurrently "bun run dev:css" "bun run dev:js" "bun run --watch
271
+ * src/server/index.ts"`. The gate sandbox has no docker, so the chain died at 127 and
272
+ * the boot SKIPPED as an environment gap — while the shipped app had no HTTP listener
273
+ * at all. A script whose first act is `docker compose up` cannot distinguish "the app
274
+ * is broken" from "this box has no docker", so it is not evidence either way: better
275
+ * to discover NO boot command — reported as "nothing to boot" — and let the static
276
+ * serve-entry check (serve-entry.ts) carry the signal, than to spend the grace window
277
+ * producing an unfalsifiable skip.
278
+ *
279
+ * CONSERVATIVE AND LEXICAL BY CONSTRUCTION. Only two shapes are rejected, both
280
+ * decidable from the script text alone:
281
+ * 1. the chain OPENS with container orchestration (docker/podman/nerdctl … up|start|run);
282
+ * 2. the whole body is a multiplexer (concurrently/npm-run-all/run-p/run-s/turbo)
283
+ * whose every child is an ASSET watcher in watch mode (tailwind/tsc/esbuild/…),
284
+ * i.e. nothing in it can ever listen.
285
+ * Anything else — `vite`, `next dev`, `node dist/index.js`, `nodemon`, `bun --watch
286
+ * src/index.ts`, and any multiplexer with one non-asset child — is accepted
287
+ * unchanged. Deciding whether a watcher actually SERVES is not attempted here; that
288
+ * is exactly what the static serve-entry check is for.
289
+ */
290
+ export function nonLaunchScriptReason(body, scripts = {}) {
291
+ const members = chainMembers(body);
292
+ if (members.length === 0)
293
+ return null;
294
+ if (isContainerOrchestration(members[0])) {
295
+ return 'it opens with container orchestration, which starts infrastructure rather than the app';
296
+ }
297
+ if (members.every(m => isWatcherOnlyMultiplexer(m, scripts))) {
298
+ return 'its only long-running member multiplexes asset watchers, none of which serves';
299
+ }
300
+ return null;
301
+ }
188
302
  /**
189
303
  * The project's OWN launch command, if it declares one (package.json `start`,
190
304
  * else `dev`; Makefile `run`). null means the project has nothing to boot —
191
305
  * the boot check degrades to nothing-to-run.
306
+ *
307
+ * A script that is not a LAUNCH at all (nonLaunchScriptReason — mx5 run 18's
308
+ * `docker compose up` orchestrator) is rejected here and falls through to the
309
+ * next candidate, then to null. Discovering nothing is strictly better than
310
+ * discovering something unfalsifiable: an env-gap skip of an orchestration script
311
+ * says nothing about the app, and null is reported as "nothing to boot".
192
312
  */
193
313
  export function discoverBootCommand(cwd) {
194
314
  if (existsSync(path.join(cwd, 'package.json'))) {
195
315
  const s = packageScripts(cwd);
196
316
  for (const name of ['start', 'dev']) {
197
- if (s[name])
317
+ if (s[name] && nonLaunchScriptReason(s[name], s) === null)
198
318
  return ['bun', ['run', name]];
199
319
  }
200
320
  return null;
@@ -204,6 +324,28 @@ export function discoverBootCommand(cwd) {
204
324
  }
205
325
  return null;
206
326
  }
327
+ /**
328
+ * The launch script that EXISTS but was rejected as not-a-launch, if any. Without
329
+ * this the rejection would trade run 18's unfalsifiable skip for pure silence: no
330
+ * boot command means bootSkipVerdict has no label to name, and a project whose test
331
+ * suite ran still reports `observed > 0`, so unobservedVerdict stays quiet too. A
332
+ * served app whose only declared launch script cannot start it was not observed to
333
+ * run, and must say so.
334
+ */
335
+ export function rejectedLaunchScript(cwd) {
336
+ if (!existsSync(path.join(cwd, 'package.json')))
337
+ return null;
338
+ const s = packageScripts(cwd);
339
+ for (const name of ['start', 'dev']) {
340
+ if (!s[name])
341
+ continue;
342
+ const reason = nonLaunchScriptReason(s[name], s);
343
+ if (reason === null)
344
+ return null; // this one IS a launch — it was chosen
345
+ return { name, reason };
346
+ }
347
+ return null;
348
+ }
207
349
  /** Recognise an "address already in use" bind failure across runtimes (Node
208
350
  * EADDRINUSE, Bun "Is port N in use?", Go "address already in use", generic). */
209
351
  function isAddressInUse(text) {
@@ -911,6 +1053,53 @@ export function unobservedVerdict(args) {
911
1053
  return (`UNOBSERVED — NOT a pass: ${why}; statics passed, but this run produced NO evidence `
912
1054
  + 'that the assembled product builds, boots or works.');
913
1055
  }
1056
+ /**
1057
+ * The SAME third verdict, at the door unobservedVerdict cannot reach: the boot
1058
+ * check specifically (mx5 run 18, validated).
1059
+ *
1060
+ * Run 18 shipped an app with no HTTP server behind a converged final gate. Its
1061
+ * `src/server/index.ts` ends at `export {app}` — no `Bun.serve`, no
1062
+ * `export default app`, no `start` script — so `bun run src/server/index.ts` exits
1063
+ * 0 immediately and the product cannot be started at all. The gate's boot command
1064
+ * resolved to `bun run dev`, whose body begins `docker compose … up -d`; the gate
1065
+ * sandbox had no docker, so the boot SKIPPED as an environment gap. Skips
1066
+ * contribute nothing to `dynObserved`, and `bun run test`, `test:ct`, `build`,
1067
+ * `lint`, `seed` and `migrate` all ran — so `dynObserved > 0`, the full-skip
1068
+ * blindness guard (observabilityGapFailure) stayed correctly quiet, and the trail
1069
+ * read `final-gate: autofix converged — statics + … passed` with 24/24 tasks green.
1070
+ *
1071
+ * The defect is that "the app was never observed to boot" and "the app booted
1072
+ * fine" produced BYTE-IDENTICAL gate output. That is the class scripts/ab-verdict.ts
1073
+ * exists to kill one layer up: absence of evidence rendered in the shape of
1074
+ * evidence. So a discovered-but-skipped boot now names itself, and — unlike every
1075
+ * other skip — it CANNOT be cancelled by observations from other commands.
1076
+ * Component tests are the trap here, not the alibi: run 18 had 51 green Playwright
1077
+ * CT tests, and CT mounts components in a browser without ever assembling or
1078
+ * starting the server.
1079
+ *
1080
+ * DECIDED, do not silently re-open:
1081
+ * - NOT a FAIL. A boot skip on a docker-less box is a genuine environment gap, and
1082
+ * failing it re-creates run 16's unfalsifiable-FAIL mistake pointing the other
1083
+ * way. UNOBSERVED blocks nothing while being loud and durable (the caller records
1084
+ * it as final-gate debt the next run re-surfaces), and it keeps "boot never ran"
1085
+ * out of the autofix child's seed — a child cannot fix a missing docker, so the
1086
+ * highest-probability response would be to FABRICATE a bootable command, the
1087
+ * class that refuted the `## verified tooling` harvest.
1088
+ * - BOTH skip flavours count. Run 18's skip carried `spawnFailed: false` (127 inside
1089
+ * the script chain, not an ENOENT on the runner), so keying off spawnFailed would
1090
+ * have missed the actual defect.
1091
+ * - SERVED APPS ONLY. `expectServer === false` (a CLI/library project) is fenced off
1092
+ * deliberately: a CLI whose `dev` script needs an absent tool has no server to be
1093
+ * unobserved, and widening the lever there buys warnings nobody can act on.
1094
+ */
1095
+ export function bootSkipVerdict(args) {
1096
+ if (args.label === null || !args.skipped || !args.expectServer)
1097
+ return null;
1098
+ // Deliberately short: the run-level trail slices the reason at 300 chars and this
1099
+ // note leads it, so the command name always survives.
1100
+ return (`boot check: \`${args.label}\` NEVER RAN (environment gap) — the app was not observed `
1101
+ + 'to start, and no test suite substitutes for that.');
1102
+ }
914
1103
  /**
915
1104
  * Boot check hit an address-in-use bind failure. If the port is held by one of OUR
916
1105
  * own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
@@ -1004,6 +1193,23 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1004
1193
  fail(`launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
1005
1194
  }
1006
1195
  }
1196
+ // Serve-entry closure (mx5 run 18, nexttask 2B): the tree builds a server app,
1197
+ // expects to serve (SPA fallback / static read / a design clause), and NOTHING
1198
+ // anywhere starts a listener — `src/server/index.ts` ended at `export {app}`, so
1199
+ // the product could not be started at all while every dynamic probe went blind on
1200
+ // a docker-less box. Static, deterministic, milliseconds, and — unlike the boot
1201
+ // check — decidable in exactly the environment where the boot skipped. Rank 0:
1202
+ // "the app cannot be started" is the same load-bearing class as boot/render.
1203
+ // Placed BEFORE the zero-discovery early return on purpose: a project with no
1204
+ // runnable command at all must still fail this, not report UNOBSERVED.
1205
+ try {
1206
+ const noServeEntry = findMissingServeEntry(cwd, planText);
1207
+ if (noServeEntry)
1208
+ fail(serveEntryGateFailureText(noServeEntry), 0);
1209
+ }
1210
+ catch {
1211
+ // best-effort scan — a scanner fault must never break the gate
1212
+ }
1007
1213
  const lockCmds = discoverLockfileChecks(cwd);
1008
1214
  const { cmds } = discoverIntegrationCommands(cwd);
1009
1215
  const boot = discoverBootCommand(cwd);
@@ -1107,6 +1313,10 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1107
1313
  // Boot + render ALWAYS runs (mx5 run 13): it is independent of test results by
1108
1314
  // construction, and it carries the run's most load-bearing signal — earlier
1109
1315
  // failures no longer shadow it. Its failures rank FIRST in the aggregate.
1316
+ // A boot that never RAN is its own verdict (mx5 run 18 — see bootSkipVerdict);
1317
+ // it lives outside the dynObserved counters on purpose, so the test/build
1318
+ // commands that did run cannot cancel it.
1319
+ let bootUnobserved = null;
1110
1320
  if (boot) {
1111
1321
  const label = `${boot[0]} ${boot[1].join(' ')}`;
1112
1322
  dynAttempted += 1;
@@ -1142,6 +1352,11 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1142
1352
  dynObserved += 1;
1143
1353
  else if (b.spawnFailed)
1144
1354
  dynSpawnFailures += 1;
1355
+ bootUnobserved = bootSkipVerdict({
1356
+ label,
1357
+ skipped: b.outcome === 'skip',
1358
+ expectServer
1359
+ });
1145
1360
  if (b.outcome === 'fail') {
1146
1361
  fail(`boot check: \`${label}\` ${b.detail}`, 0);
1147
1362
  }
@@ -1162,6 +1377,18 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1162
1377
  warnings.push(b.renderNote);
1163
1378
  }
1164
1379
  }
1380
+ else {
1381
+ // Nothing to boot — but if the reason is that the project's only launch
1382
+ // script was REJECTED as not-a-launch (2A), that is not the same thing as a
1383
+ // project with no launch surface, and it must not degrade into silence.
1384
+ const rejected = rejectedLaunchScript(cwd);
1385
+ if (rejected && detectsServedApp(cwd, planText)) {
1386
+ bootUnobserved =
1387
+ `boot check: this project's only launch script (\`${rejected.name}\`) is not a `
1388
+ + `launch — ${rejected.reason} — so nothing was started and the app was never `
1389
+ + 'observed to run.';
1390
+ }
1391
+ }
1165
1392
  // Full-skip blindness guard (mx5 run 16): commands were discovered but every
1166
1393
  // one skipped → rank-0 failure, never a static-only PASS. Runner resolvability
1167
1394
  // is checked through resolveRunner so the failure text can name the missing
@@ -1213,7 +1440,15 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1213
1440
  // (integration commands not runnable here)`, which is the identical "we never checked"
1214
1441
  // silence wearing different words. Unchanged when anything at all was observed, so a
1215
1442
  // project with runnable commands is byte-for-byte unaffected.
1216
- const unobserved = unobservedVerdict({ discovered: dynAttempted, observed: dynObserved });
1443
+ // Two independent UNOBSERVED notes, either or both of which may apply: the boot
1444
+ // never ran (run 18), and/or NOTHING dynamic ran at all. The boot note leads
1445
+ // because it names a concrete command and the trail line is sliced at 300 chars.
1446
+ const unobserved = [
1447
+ bootUnobserved,
1448
+ unobservedVerdict({ discovered: dynAttempted, observed: dynObserved })
1449
+ ]
1450
+ .filter(n => n !== null)
1451
+ .join(' ');
1217
1452
  return withDebts({
1218
1453
  ok: true,
1219
1454
  ...(unobserved ? { unobserved } : {}),
@@ -0,0 +1,60 @@
1
+ export interface ServeEntryFinding {
2
+ /** Repo-relative module that constructs the app and is the natural home for the bind. */
3
+ file: string;
4
+ /** The construct that matched there (`new Hono()`, `express()`, …). */
5
+ construct: string;
6
+ /** Why this project is expected to serve, in words. */
7
+ expectation: string;
8
+ /** Where that expectation was found (repo-relative file, or 'the design/spec'). */
9
+ expectationSource: string;
10
+ /** Every construction file found, for the report. */
11
+ appFiles: string[];
12
+ }
13
+ /** Constructions in one source file, plus the variable each was assigned to. */
14
+ export declare function findAppConstructions(raw: string): Array<{
15
+ construct: string;
16
+ name: string | null;
17
+ }>;
18
+ /** Bind evidence in one source file, or null. */
19
+ export declare function findBindEvidence(src: string, appNames?: Set<string>): string | null;
20
+ /** Why this file makes the project a SERVING one, or null. */
21
+ export declare function findServeExpectation(src: string): string | null;
22
+ /** A design/spec clause that names a served path — the plan-side half of the same
23
+ * expectation (mx5's `DESIGN/PROJECT.md:285`: "serves `/api` + static `dist/`"). */
24
+ export declare function planExpectsServing(planText: string | undefined): string | null;
25
+ /** The platform that would bind on this project's behalf, or null. */
26
+ export declare function opaqueLauncher(cwd: string): string | null;
27
+ /** What a whole tree looks like to this check — the base-rate row, and the
28
+ * intermediate the finding is derived from. */
29
+ export interface ServeEntryScan {
30
+ /** Files that construct a server app, with the construct that matched. */
31
+ apps: Array<{
32
+ file: string;
33
+ construct: string;
34
+ name: string | null;
35
+ }>;
36
+ /** The first bind found anywhere, with its file. */
37
+ bind: {
38
+ file: string;
39
+ what: string;
40
+ } | null;
41
+ /** Why the project is expected to serve, with its source. */
42
+ expectation: {
43
+ source: string;
44
+ what: string;
45
+ } | null;
46
+ /** Non-null ⇒ somebody else's launcher owns the listener; the check stands down. */
47
+ launcher: string | null;
48
+ filesScanned: number;
49
+ }
50
+ /** Read the tree once and answer all three questions. Read-only, deterministic. */
51
+ export declare function scanServeEntry(cwd: string, planText?: string): ServeEntryScan;
52
+ /**
53
+ * FINAL-GATE seam: does this project build a server app it expects to serve, with
54
+ * nothing anywhere to start it? Returns at most ONE finding — the defect is a
55
+ * property of the whole tree, not of each file. Best-effort and read-only.
56
+ */
57
+ export declare function findMissingServeEntry(cwd: string, planText?: string): ServeEntryFinding | null;
58
+ /** Ranked-failure text for the final gate. Names the module that must bind and the
59
+ * reason the project is a serving one, so the autofix child has both halves. */
60
+ export declare function serveEntryGateFailureText(f: ServeEntryFinding): string;
@@ -0,0 +1,343 @@
1
+ /**
2
+ * serve-entry — the project builds a server app, expects to SERVE, and nothing in
3
+ * the tree ever starts a listener (nexttask 2 part B).
4
+ *
5
+ * THE FAILURE THIS CLOSES (mx5 run 18, measured). The shipped `src/server/index.ts`
6
+ * is 28 lines that construct a Hono app, mount five `/api` routers, add an SPA
7
+ * fallback reading `Bun.file('dist/index.html')` — and end at `export {app}`. There
8
+ * is no `Bun.serve`, no `export default app`, no `serve()` from an adapter, no
9
+ * `start` script. Running the entry directly exits 0 in milliseconds:
10
+ *
11
+ * $ DATABASE_URL=… timeout 15 bun run src/server/index.ts → EXIT=0
12
+ *
13
+ * The product could not be started at all, and the run shipped green: the gate's
14
+ * boot command resolved to a `docker compose up` orchestrator, docker was absent in
15
+ * the sandbox, and the boot SKIPPED. This is the SECOND run to lose this exact
16
+ * clause — mx5 run 16 shipped a server that never served the client bundle
17
+ * (scripts/live-owned-requirement-compose-ab.ts) — so a dynamic-only gate has now
18
+ * failed to catch it twice.
19
+ *
20
+ * WHY STATIC. The check needs no runtime, no browser, no docker, no database and no
21
+ * model: the tree either contains a bind or it does not. It runs in milliseconds and
22
+ * is decidable in exactly the environment where every dynamic probe went blind.
23
+ *
24
+ * FP DISCIPLINE (the standing rule — inconclusive is NEVER evidence). Three
25
+ * independent conditions must ALL hold before anything is reported, and each one
26
+ * steps aside on doubt:
27
+ * 1. a module CONSTRUCTS a server app (a known framework construct, literal);
28
+ * 2. the project is expected to SERVE (a catch-all route, a static/dist read, a
29
+ * static-file middleware, or a design clause naming a served path);
30
+ * 3. NOTHING anywhere in the scanned tree binds — `Bun.serve(`/`Deno.serve(`,
31
+ * any `.listen(`, an adapter `serve(` import, `export default <the app>` or
32
+ * `export default {fetch…}` (the Workers/Bun default-export protocol),
33
+ * `app.fire()`, or a platform handler export.
34
+ * And a project whose LAUNCHER is somebody else's (next/nuxt/astro/remix/sveltekit/
35
+ * nest/wrangler/vercel/netlify/serverless/…, by dependency or by script) steps aside
36
+ * whole: those frameworks bind inside their own CLI, so their app modules correctly
37
+ * contain no listener and the question is not decidable from this tree.
38
+ *
39
+ * Ground truth is the file tree only. No model, no network.
40
+ */
41
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
42
+ import * as path from 'node:path';
43
+ /** A server-app construction: framework, and the regex that recognises it. */
44
+ const CONSTRUCT_PATTERNS = [
45
+ { re: /\bnew\s+Hono\s*[<(]/, construct: 'new Hono()' },
46
+ { re: /\bnew\s+Elysia\s*[<(]/, construct: 'new Elysia()' },
47
+ { re: /\bnew\s+Koa\s*\(/, construct: 'new Koa()' },
48
+ { re: /\bnew\s+Application\s*\(\s*\)/, construct: 'new Application()' },
49
+ { re: /(?:^|[^.\w])express\s*\(\s*\)/, construct: 'express()' },
50
+ { re: /(?:^|[^.\w])[Ff]astify\s*\(/, construct: 'fastify()' },
51
+ { re: /(?:^|[^.\w])polka\s*\(/, construct: 'polka()' }
52
+ // `connect()` (the middleware framework) is deliberately absent: gofer's
53
+ // src/store/db.ts calls `connect()` on a DATABASE, and a construct signal that
54
+ // cannot tell a server from a db handle is not a construct signal.
55
+ ];
56
+ /**
57
+ * Anything that starts (or hands off) a listener. Deliberately GENEROUS: every
58
+ * pattern here SUPPRESSES a finding, so a loose match costs a missed defect while a
59
+ * tight one costs a false accusation — and the standing direction is that a false
60
+ * accusation is the worse failure.
61
+ */
62
+ const BIND_PATTERNS = [
63
+ { re: /\bBun\s*\.\s*serve\s*\(/, what: 'Bun.serve(' },
64
+ { re: /\bDeno\s*\.\s*serve\s*\(/, what: 'Deno.serve(' },
65
+ { re: /\.listen\s*\(/, what: '.listen(' },
66
+ { re: /\bcreateServer\s*\([^)]*\)\s*\.\s*listen/, what: 'createServer().listen(' },
67
+ { re: /\.\s*fire\s*\(\s*\)/, what: 'app.fire()' },
68
+ { re: /\bserveHandler\s*\(|\bstartServer\s*\(/, what: 'a server-start helper' }
69
+ ];
70
+ /** `export default` forms that ARE a bind: the runtime (Bun, Workers, Deno Deploy,
71
+ * Vercel) starts whatever is exported. A default-exported config object
72
+ * (`export default defineConfig({…})`) is not one of them, and neither is a React
73
+ * component — hence the identifier must be a name the tree BOUND to a server-app
74
+ * construction (aiz-client's `export default App` is a component, not a listener). */
75
+ function defaultExportBind(src, appNames) {
76
+ for (const m of src.matchAll(/export\s+default\s+([A-Za-z_$][\w$]*)/g)) {
77
+ if (appNames.has(m[1]))
78
+ return `export default ${m[1]}`;
79
+ }
80
+ // `export default {fetch: app.fetch, port}` / `export default {async fetch(…)}` —
81
+ // the Workers/Bun protocol. The `fetch` key must be in the same object literal.
82
+ for (const m of src.matchAll(/export\s+default\s*\{([\s\S]{0,400}?)\}/g)) {
83
+ if (/(?:^|[\s,{])(?:async\s+)?fetch\s*[:(]/.test(m[1]))
84
+ return 'export default {fetch…}';
85
+ }
86
+ if (/export\s+default\s+(?:handle|serve|createHandler)\s*\(/.test(src)) {
87
+ return 'export default handle(app)';
88
+ }
89
+ return null;
90
+ }
91
+ /** An adapter whose `serve(app)` binds for you (`@hono/node-server`, `srvx`, …). */
92
+ function adapterServeBind(src) {
93
+ const importsAdapter = /from\s+['"](?:@hono\/node-server|srvx|@fastify\/[\w-]+|h3|listhen)['"]/.test(src);
94
+ return importsAdapter && /(?:^|[^.\w])(?:serve|listen)\s*\(/.test(src) ?
95
+ 'serve() from a server adapter'
96
+ : null;
97
+ }
98
+ /** Blank out same-line string literals. Real code never constructs an app inside a
99
+ * quote, but a module that NAMES the constructs — a detector, a doc, this file's
100
+ * own `construct: 'new Hono()'` labels — otherwise reads as seven server apps.
101
+ * Applied to construction matching only: the serve-expectation patterns are ABOUT
102
+ * string literals (`app.get('*')`, `Bun.file('dist/…')`) and must keep them. */
103
+ function stripStringLiterals(src) {
104
+ return src.replace(/'[^'\n]*'|"[^"\n]*"/g, "''");
105
+ }
106
+ /** Constructions in one source file, plus the variable each was assigned to. */
107
+ export function findAppConstructions(raw) {
108
+ const src = stripStringLiterals(raw);
109
+ const out = [];
110
+ for (const { re, construct } of CONSTRUCT_PATTERNS) {
111
+ if (!re.test(src))
112
+ continue;
113
+ // `const app = new Hono()` → remember `app`, so `export default app` counts.
114
+ const assign = new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]{0,80})?=\\s*[^=]{0,40}?${re.source}`).exec(src);
115
+ out.push({ construct, name: assign ? assign[1] : null });
116
+ }
117
+ return out;
118
+ }
119
+ /** Bind evidence in one source file, or null. */
120
+ export function findBindEvidence(src, appNames = new Set()) {
121
+ for (const { re, what } of BIND_PATTERNS) {
122
+ if (re.test(src))
123
+ return what;
124
+ }
125
+ return defaultExportBind(src, appNames) ?? adapterServeBind(src);
126
+ }
127
+ const SERVE_EXPECTATIONS = [
128
+ {
129
+ re: /\.\s*(?:get|all|use|route)\s*\(\s*['"`]\/?\*['"`]/,
130
+ what: 'a catch-all route (the SPA fallback)'
131
+ },
132
+ {
133
+ re: /(?:Bun\s*\.\s*file|readFileSync|readFile|createReadStream|sendFile)\s*\(\s*['"`]\.?\/?(?:dist|build|out|public|static|client|assets)\//,
134
+ what: 'a read of a built asset under dist/ (the client bundle)'
135
+ },
136
+ { re: /\bserveStatic\s*\(/, what: 'static-file middleware (serveStatic)' },
137
+ { re: /\bexpress\s*\.\s*static\s*\(/, what: 'static-file middleware (express.static)' },
138
+ { re: /\bfastifyStatic\b|@fastify\/static/, what: 'static-file middleware (@fastify/static)' }
139
+ ];
140
+ /** Why this file makes the project a SERVING one, or null. */
141
+ export function findServeExpectation(src) {
142
+ for (const { re, what } of SERVE_EXPECTATIONS) {
143
+ if (re.test(src))
144
+ return what;
145
+ }
146
+ return null;
147
+ }
148
+ /** A design/spec clause that names a served path — the plan-side half of the same
149
+ * expectation (mx5's `DESIGN/PROJECT.md:285`: "serves `/api` + static `dist/`"). */
150
+ export function planExpectsServing(planText) {
151
+ if (!planText)
152
+ return null;
153
+ const re = /\bserves?\b[^\n]{0,120}?\b(?:static|dist\/?|client|bundle|index\.html|spa|frontend)\b/i;
154
+ const m = re.exec(planText);
155
+ return m ? `the design declares a served path ("${m[0].trim().slice(0, 100)}")` : null;
156
+ }
157
+ /**
158
+ * Frameworks and platforms that own the listener themselves. When one of these is a
159
+ * dependency or drives a script, an app module with no bind is CORRECT, so the whole
160
+ * check steps aside — it is not decidable from this tree.
161
+ */
162
+ const LAUNCHER_DEPS = /^(?:next|nuxt|nuxt3|astro|@remix-run\/|@sveltejs\/kit|@nestjs\/core|@angular\/|@adonisjs\/core|redwoodjs|blitz|wrangler|@cloudflare\/|vercel|@vercel\/|netlify-cli|@netlify\/|serverless|serverless-http|firebase-functions|aws-lambda|@aws-sdk\/client-lambda|sst|nitropack|encore\.dev|@medusajs\/|keystone|payload|gatsby|@builder\.io\/qwik-city)/;
163
+ const LAUNCHER_SCRIPT_RE = /\b(?:next|nuxt|astro|remix-serve|nest|wrangler|vercel|netlify|sst|gatsby|blitz|redwood|encore|payload|medusa)\s+(?:dev|start|serve|build\s+&&|run\b)/;
164
+ /** The platform that would bind on this project's behalf, or null. */
165
+ export function opaqueLauncher(cwd) {
166
+ let pkg;
167
+ try {
168
+ pkg = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
174
+ const dep = deps.find(d => LAUNCHER_DEPS.test(d));
175
+ if (dep)
176
+ return `the \`${dep}\` framework starts its own server`;
177
+ for (const [name, body] of Object.entries(pkg.scripts ?? {})) {
178
+ if (LAUNCHER_SCRIPT_RE.test(body))
179
+ return `\`${name}\` runs a framework launcher (${body.slice(0, 60)})`;
180
+ }
181
+ return null;
182
+ }
183
+ // Directories never scanned: VCS/dep trees, build output (bundled copies of the
184
+ // same sources), and test/fixture/example/doc trees — a test that stands up a
185
+ // throwaway listener is not the app's launch, and a doc snippet is not code.
186
+ const SKIP_DIR_RE = /^(?:\.git|node_modules|\.pi-tasks|dist|build|out|coverage|target|vendor|__pycache__|\.venv|venv|tmp|test|tests|__tests__|__mocks__|__fixtures__|fixtures|e2e|examples|example|docs|doc|bench|benchmarks)$/;
187
+ const SKIP_FILE_RE = /\.(?:test|spec|stories|bench)\.[a-z]+$|\.d\.[mc]?ts$/i;
188
+ const SCAN_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
189
+ const MAX_SCAN_FILES = 3000;
190
+ const MAX_FILE_BYTES = 400_000;
191
+ /** Authored sources, bounded and in deterministic order. */
192
+ function scanCandidates(cwd) {
193
+ const out = [];
194
+ const walk = (rel) => {
195
+ if (out.length >= MAX_SCAN_FILES)
196
+ return;
197
+ let entries;
198
+ try {
199
+ entries = readdirSync(path.join(cwd, rel)).sort();
200
+ }
201
+ catch {
202
+ return;
203
+ }
204
+ for (const name of entries) {
205
+ if (out.length >= MAX_SCAN_FILES)
206
+ return;
207
+ const relPath = rel === '' ? name : `${rel}/${name}`;
208
+ let st;
209
+ try {
210
+ st = statSync(path.join(cwd, relPath));
211
+ }
212
+ catch {
213
+ continue;
214
+ }
215
+ if (st.isDirectory()) {
216
+ if (name.startsWith('.') || SKIP_DIR_RE.test(name))
217
+ continue;
218
+ walk(relPath);
219
+ }
220
+ else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
221
+ if (SKIP_FILE_RE.test(name))
222
+ continue;
223
+ if (SCAN_RE.test(name))
224
+ out.push(relPath);
225
+ }
226
+ }
227
+ };
228
+ walk('');
229
+ return out;
230
+ }
231
+ /** Strip comment-only lines — a `Bun.serve` quoted in a comment is not a bind, and
232
+ * a commented-out catch-all is not a route. Inline comments are left alone. */
233
+ function stripCommentLines(src) {
234
+ return src
235
+ .split('\n')
236
+ .filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
237
+ .join('\n');
238
+ }
239
+ /** A `/…/flags` literal whose body carries a regex METACHARACTER — `[`, `\`, `+`,
240
+ * `?`, `|`, a group. A path string like `'/api/admin'` has none, so it survives. */
241
+ const REGEX_LITERAL_RE = /\/(?![*/])((?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+)\/[gimsuyd]*/g;
242
+ const METACHAR_RE = /[\\[\]+*?^$|(){}]/;
243
+ /**
244
+ * Blank out regex literals. A project that MATCHES on `new Hono` — a linter, a
245
+ * codemod, this very module — does not construct one, and a source scanner that
246
+ * cannot tell the two apart reports every detector as an app (pi-task scanned
247
+ * itself and found "9 server apps", all of them pattern tables). Only literals
248
+ * carrying a metacharacter are stripped, so `'/api/v1'` is untouched, and because
249
+ * stripping can only REMOVE matches it is safe in the FP direction by construction.
250
+ */
251
+ function stripRegexLiterals(src) {
252
+ return src.replace(REGEX_LITERAL_RE, (whole, body) => METACHAR_RE.test(body) ? ' ' : whole);
253
+ }
254
+ /** Read the tree once and answer all three questions. Read-only, deterministic. */
255
+ export function scanServeEntry(cwd, planText) {
256
+ const scan = {
257
+ apps: [],
258
+ bind: null,
259
+ expectation: null,
260
+ launcher: opaqueLauncher(cwd),
261
+ filesScanned: 0
262
+ };
263
+ const files = scanCandidates(cwd);
264
+ scan.filesScanned = files.length;
265
+ // Two passes: constructions first, so `export default app` can be resolved
266
+ // against the app variable names the tree actually uses.
267
+ const sources = new Map();
268
+ const appNames = new Set();
269
+ for (const rel of files) {
270
+ let src;
271
+ try {
272
+ src = stripRegexLiterals(stripCommentLines(readFileSync(path.join(cwd, rel), 'utf8')));
273
+ }
274
+ catch {
275
+ continue;
276
+ }
277
+ sources.set(rel, src);
278
+ for (const c of findAppConstructions(src)) {
279
+ scan.apps.push({ file: rel, construct: c.construct, name: c.name });
280
+ if (c.name)
281
+ appNames.add(c.name);
282
+ }
283
+ }
284
+ for (const [rel, src] of sources) {
285
+ if (scan.bind === null) {
286
+ const what = findBindEvidence(src, appNames);
287
+ if (what)
288
+ scan.bind = { file: rel, what };
289
+ }
290
+ if (scan.expectation === null) {
291
+ const what = findServeExpectation(src);
292
+ if (what)
293
+ scan.expectation = { source: rel, what };
294
+ }
295
+ }
296
+ if (scan.expectation === null) {
297
+ const fromPlan = planExpectsServing(planText);
298
+ if (fromPlan)
299
+ scan.expectation = { source: 'the design/spec', what: fromPlan };
300
+ }
301
+ return scan;
302
+ }
303
+ /** The construction file that should carry the bind: the one holding the serve
304
+ * expectation, else the most entry-like name, else the first. */
305
+ function entryFile(scan) {
306
+ const withExpectation = scan.apps.find(a => a.file === scan.expectation?.source);
307
+ if (withExpectation)
308
+ return withExpectation;
309
+ const entryish = scan.apps.find(a => /(?:^|\/)(?:index|main|server|app)\.[a-z]+$/i.test(a.file));
310
+ return entryish ?? scan.apps[0];
311
+ }
312
+ /**
313
+ * FINAL-GATE seam: does this project build a server app it expects to serve, with
314
+ * nothing anywhere to start it? Returns at most ONE finding — the defect is a
315
+ * property of the whole tree, not of each file. Best-effort and read-only.
316
+ */
317
+ export function findMissingServeEntry(cwd, planText) {
318
+ const scan = scanServeEntry(cwd, planText);
319
+ if (scan.launcher !== null)
320
+ return null;
321
+ if (scan.apps.length === 0)
322
+ return null;
323
+ if (scan.bind !== null)
324
+ return null;
325
+ if (scan.expectation === null)
326
+ return null;
327
+ const { file, construct } = entryFile(scan);
328
+ return {
329
+ file,
330
+ construct,
331
+ expectation: scan.expectation.what,
332
+ expectationSource: scan.expectation.source,
333
+ appFiles: [...new Set(scan.apps.map(a => a.file))]
334
+ };
335
+ }
336
+ /** Ranked-failure text for the final gate. Names the module that must bind and the
337
+ * reason the project is a serving one, so the autofix child has both halves. */
338
+ export function serveEntryGateFailureText(f) {
339
+ return (`serve entry missing: \`${f.file}\` builds a server app (${f.construct}) and the project `
340
+ + `is expected to serve — ${f.expectation} in ${f.expectationSource === 'the design/spec' ? 'the design/spec' : `\`${f.expectationSource}\``} — `
341
+ + 'but NOTHING in the tree ever starts a listener (no `Bun.serve(`, `export default app`, '
342
+ + 'adapter `serve()`, or `.listen(`), so the app cannot be started at all');
343
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.24.4",
3
+ "version": "0.25.0",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",