@ultimat3/cli 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -2,10 +2,9 @@
2
2
  // tag every process a step starts carries, and the kill that leaves none of them behind. A hung
3
3
  // step used to eat the whole CI job timeout and report nothing (#589); now it fails by name.
4
4
 
5
- import { ERROR_DOCS_URL } from '@ultimat3/core';
6
5
  import { TEST_TYPES } from '@ultimat3/testing';
7
6
  import type { ExecResult, Runner } from './exec';
8
- import type { Finding } from './output';
7
+ import { execOutput } from './exec';
9
8
  import type { VerifyStepName } from './verify-step';
10
9
 
11
10
  /** The `x.verify.json` key: `{ "stepTimeoutMs": { "unit": 900000 } }`. */
@@ -76,11 +75,47 @@ export const STEP_TAG_ENV = 'ULTIMATE_VERIFY_STEP';
76
75
  /** `bun test`-style "the command was stopped" exit, as `timeout(1)` reports it. */
77
76
  const EXPIRED_EXIT = 124;
78
77
 
78
+ /** A process the kill found, as it was the moment before it died. */
79
+ export interface KilledProcess {
80
+ readonly pid: number;
81
+ /** Its argv, clipped — a `bun test` coordinator's names every file of its batch. */
82
+ readonly command: string;
83
+ }
84
+
85
+ /** A child the step was still waiting on when it expired. */
86
+ export interface InFlightRun {
87
+ readonly command: readonly string[];
88
+ /** Everything it had printed before the kill; absent when it did not settle after it. */
89
+ readonly output?: string;
90
+ }
91
+
92
+ /** What a step was doing when its deadline passed — `verify-stalled.ts` turns it into the finding. */
93
+ export interface StepExpiry {
94
+ readonly killed: readonly KilledProcess[];
95
+ readonly inFlight: readonly InFlightRun[];
96
+ }
97
+
98
+ /** A killed child's pipes close with it; this is only the bound on a runner that is not `exec`. */
99
+ const SETTLE_MS = 2_000;
100
+
101
+ /**
102
+ * How a `bun test --parallel` worker is told from its coordinator: the flag bun starts each with
103
+ * (probed on 1.4.0). Killing the WORKERS first is what names the stuck file — the coordinator
104
+ * outlives them just long enough to print `✗ <file> (worker crashed: SIGKILL)` for whatever each
105
+ * was holding, in every reporter mode: plain, GitHub Actions' `::group::`, and the failures-only
106
+ * one an agent's shell switches on. A file's own header line cannot do it — bun prints that as
107
+ * results stream, so a file stuck in its third test has one.
108
+ */
109
+ const isTestWorker = (command: string): boolean => command.includes(' --test-worker');
110
+
111
+ /** How long a coordinator gets to say what its workers were holding before it is killed too. */
112
+ const CRASH_REPORT_MS = 1_500;
113
+
79
114
  export interface StepGuard {
80
115
  /** `runner`, with every child tagged — and refusing to start one once the step has expired. */
81
116
  readonly runner: Runner;
82
- /** Stop the step: no further child starts, and every tagged process is killed. Returns how many. */
83
- expire(): Promise<number>;
117
+ /** Stop the step: no further child starts, every tagged process is killed, and what was in flight is told. */
118
+ expire(): Promise<StepExpiry>;
84
119
  }
85
120
 
86
121
  /**
@@ -91,13 +126,23 @@ export interface StepGuard {
91
126
  *
92
127
  * A nested gate (a test that runs `x verify`) appends its tag to the one it inherited, so the
93
128
  * outer step's kill still finds the inner step's children.
129
+ *
130
+ * Windows reads no process's environment, so there the tag finds nothing: every child's pid is
131
+ * recorded at spawn (`ExecOptions.onSpawn`) and `expire` tree-kills each one (`taskkillTree`).
94
132
  */
95
133
  export function guardStep(
96
134
  runner: Runner,
97
135
  tag: string,
98
136
  env: Readonly<Record<string, string | undefined>> = Bun.env,
137
+ platform: PlatformKill = {},
99
138
  ): StepGuard {
100
139
  let expired = false;
140
+ // Keyed by the promise itself: two batches of one step may run the very same argv.
141
+ const inFlight = new Map<Promise<ExecResult>, readonly string[]>();
142
+ // Every child's pid, recorded the moment it exists and dropped when its run settles (a settled
143
+ // pid may be reused). Windows reads no process's environment, so this is the step's only handle.
144
+ const spawned = new Map<number, readonly string[]>();
145
+ const windows = (platform.platform ?? process.platform) === 'win32';
101
146
  const inherited = env[STEP_TAG_ENV];
102
147
  const value = inherited === undefined || inherited === '' ? tag : `${inherited} ${tag}`;
103
148
  return {
@@ -114,15 +159,108 @@ export function guardStep(
114
159
  durationMs: 0,
115
160
  };
116
161
  }
117
- return runner(command, { ...options, env: { ...options.env, [STEP_TAG_ENV]: value } });
162
+ let pid: number | undefined;
163
+ const run = runner(command, {
164
+ ...options,
165
+ env: { ...options.env, [STEP_TAG_ENV]: value },
166
+ onSpawn: (spawnedPid) => {
167
+ pid = spawnedPid;
168
+ spawned.set(spawnedPid, command);
169
+ options.onSpawn?.(spawnedPid);
170
+ },
171
+ });
172
+ inFlight.set(run, command);
173
+ try {
174
+ return await run;
175
+ } finally {
176
+ inFlight.delete(run);
177
+ if (pid !== undefined) spawned.delete(pid);
178
+ }
118
179
  },
119
- async expire(): Promise<number> {
180
+ async expire(): Promise<StepExpiry> {
120
181
  expired = true;
121
- return killTagged(tag);
182
+ // Before the kill: a killed child settles its runner call, which takes it off the map.
183
+ const waiting = [...inFlight];
184
+ if (windows) {
185
+ const killed = await killTrees(spawned, platform.killTree ?? taskkillTree);
186
+ return { killed, inFlight: await settleAll(waiting) };
187
+ }
188
+ const workers = await killTagged(tag, undefined, isTestWorker);
189
+ if (workers.length > 0) {
190
+ await raceDeadline(Promise.allSettled(waiting.map(([run]) => run)), CRASH_REPORT_MS);
191
+ }
192
+ const rest = await killTagged(tag);
193
+ const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
194
+ // After it: the kill closed the child's pipes, so its call now resolves with everything it
195
+ // had printed — for `bun test`, the crash line of each file a worker was holding.
196
+ return { killed, inFlight: await settleAll(waiting) };
122
197
  },
123
198
  };
124
199
  }
125
200
 
201
+ /** What each child had printed, once the kill closed its pipes — or just its argv past `SETTLE_MS`. */
202
+ function settleAll(
203
+ waiting: readonly (readonly [Promise<ExecResult>, readonly string[]])[],
204
+ ): Promise<readonly InFlightRun[]> {
205
+ return Promise.all(
206
+ waiting.map(async ([run, command]): Promise<InFlightRun> => {
207
+ const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
208
+ return raced === undefined || raced.timedOut ? { command } : { command, output: raced.value };
209
+ }),
210
+ );
211
+ }
212
+
213
+ /** Kills `pid` and every process under it; answers whether it did. */
214
+ export type KillTree = (pid: number) => Promise<boolean>;
215
+
216
+ /** The platform a guard kills on, injectable so the Windows path runs on any host's tests. */
217
+ export interface PlatformKill {
218
+ /** `process.platform` unless a test names one. */
219
+ readonly platform?: string;
220
+ /** Windows' tree kill. Absent is `taskkill /T /F /PID`. */
221
+ readonly killTree?: KillTree;
222
+ }
223
+
224
+ /**
225
+ * Windows: `taskkill /T /F` — the tree under the pid, forced. POSIX's tag sweep cannot run there
226
+ * (no process exposes its environment) and `process.kill` ends one process, leaving a
227
+ * `bun test --parallel` coordinator's workers behind. Bounded like `ps`, for the same reason.
228
+ */
229
+ export async function taskkillTree(pid: number): Promise<boolean> {
230
+ try {
231
+ const proc = Bun.spawn(['taskkill', '/T', '/F', '/PID', String(pid)], {
232
+ stdout: 'ignore',
233
+ stderr: 'ignore',
234
+ timeout: PS_TIMEOUT_MS,
235
+ killSignal: 'SIGKILL',
236
+ });
237
+ return (await proc.exited) === 0;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Every recorded child, each with its tree, named by the argv it was started with. A child that
245
+ * already exited is not recorded — its run settled — so what is left is what was still running.
246
+ * A grandchild whose parent already exited is out of reach here: Windows keeps no tag to find it.
247
+ */
248
+ export async function killTrees(
249
+ spawned: ReadonlyMap<number, readonly string[]>,
250
+ killTree: KillTree,
251
+ ): Promise<readonly KilledProcess[]> {
252
+ const killed: KilledProcess[] = [];
253
+ for (const [pid, command] of spawned) {
254
+ if (pid === process.pid || !(await killTree(pid))) continue;
255
+ const line = command.join(' ');
256
+ killed.push({
257
+ pid,
258
+ command: line.length > COMMAND_CHARS ? `${line.slice(0, COMMAND_CHARS)}…` : line,
259
+ });
260
+ }
261
+ return killed;
262
+ }
263
+
126
264
  /** One process and the environment it was started with, as the platform reports it. */
127
265
  export interface ProcessEnviron {
128
266
  readonly pid: number;
@@ -162,19 +300,33 @@ async function procfsProcesses(): Promise<readonly ProcessEnviron[] | undefined>
162
300
  return out;
163
301
  }
164
302
 
303
+ /** One `ps` listing; past this the sweep answers what it has rather than outliving the step. */
304
+ const PS_TIMEOUT_MS = 10_000;
305
+
306
+ const PS_LISTING: readonly string[] = ['ps', 'axeww', '-o', 'pid=,command='];
307
+
165
308
  /**
166
309
  * Elsewhere (macOS, BSD): `ps` in its BSD spelling — `e` prints each process's environment after
167
310
  * its command, `ww` never truncates it. The same spelling answers on Linux, which is where it is
168
311
  * tested; procfs is preferred there because it needs no process and no parsing.
169
312
  */
170
- export async function psProcesses(): Promise<readonly ProcessEnviron[]> {
313
+ export async function psProcesses(
314
+ argv: readonly string[] = PS_LISTING,
315
+ timeoutMs: number = PS_TIMEOUT_MS,
316
+ ): Promise<readonly ProcessEnviron[]> {
171
317
  try {
172
- const ps = Bun.spawn(['ps', 'axeww', '-o', 'pid=,command='], {
318
+ const ps = Bun.spawn([...argv], {
173
319
  stdout: 'pipe',
174
320
  stderr: 'ignore',
321
+ // The deadline's own sweep: a `ps` that hangs would hang the step it exists to end. SIGKILL,
322
+ // never the default SIGTERM, which a wedged child may ignore and so outlive the bound.
323
+ timeout: timeoutMs,
324
+ killSignal: 'SIGKILL',
175
325
  });
176
- const text = await new Response(ps.stdout).text();
177
- await ps.exited;
326
+ const [text, code] = await Promise.all([new Response(ps.stdout).text(), ps.exited]);
327
+ // A listing cut short by the timeout is NOT a smaller table: read as one, a tagged worker past
328
+ // the cut is "nothing left to kill". Unreadable, exactly like a `ps` that never ran.
329
+ if (code !== 0) return [];
178
330
  return text.split('\n').flatMap((line) => {
179
331
  const match = /^\s*(\d+)\s+(.*)$/.exec(line);
180
332
  return match === null ? [] : [{ pid: Number(match[1]), environ: match[2] ?? '' }];
@@ -186,26 +338,65 @@ export async function psProcesses(): Promise<readonly ProcessEnviron[]> {
186
338
 
187
339
  const SWEEPS = 5;
188
340
 
341
+ const COMMAND_CHARS = 240;
342
+
343
+ /**
344
+ * A process's argv, read BEFORE it is killed — afterwards there is nothing to read. procfs where
345
+ * there is one, `ps` otherwise; a process that is already gone is named by its pid alone.
346
+ */
347
+ async function commandOf(pid: number): Promise<string> {
348
+ let text = await Bun.file(`/proc/${String(pid)}/cmdline`)
349
+ .text()
350
+ .catch(() => '');
351
+ if (text === '') {
352
+ try {
353
+ const ps = Bun.spawn(['ps', '-ww', '-o', 'command=', '-p', String(pid)], {
354
+ stdout: 'pipe',
355
+ stderr: 'ignore',
356
+ timeout: PS_TIMEOUT_MS,
357
+ killSignal: 'SIGKILL',
358
+ });
359
+ text = await new Response(ps.stdout).text();
360
+ await ps.exited;
361
+ } catch {
362
+ text = '';
363
+ }
364
+ }
365
+ const line = text.split('\0').join(' ').trim();
366
+ return line.length > COMMAND_CHARS ? `${line.slice(0, COMMAND_CHARS)}…` : line;
367
+ }
368
+
189
369
  /**
190
370
  * SIGKILL every process carrying `tag`, and sweep again until a pass finds none: a worker can fork
191
371
  * between the listing and its own death. Never this process — a nested gate carries the outer tag.
372
+ * Returns the processes it killed, each with the command line it was running.
192
373
  */
193
374
  export async function killTagged(
194
375
  tag: string,
195
376
  /** The process table. Absent is this platform's: procfs where there is one, `ps` otherwise. */
196
377
  list: () => Promise<readonly ProcessEnviron[]> = async () =>
197
378
  (await procfsProcesses()) ?? (await psProcesses()),
198
- ): Promise<number> {
199
- const killed = new Set<number>();
379
+ /** Kill only the processes whose command line this accepts. Absent kills every tagged one. */
380
+ only?: (command: string) => boolean,
381
+ ): Promise<readonly KilledProcess[]> {
382
+ const killed = new Map<number, KilledProcess>();
383
+ // Read once per pid and BEFORE the first signal: a worker exits on its own the moment its
384
+ // coordinator dies, and one read between two kills would find it already gone.
385
+ const commands = new Map<number, string>();
200
386
  for (let sweep = 0; sweep < SWEEPS; sweep += 1) {
201
387
  const processes = await list();
202
- const found = taggedPids(processes, tag).filter((pid) => pid !== process.pid);
388
+ const tagged = taggedPids(processes, tag).filter((pid) => pid !== process.pid);
389
+ for (const pid of tagged) {
390
+ if (!commands.has(pid)) commands.set(pid, await commandOf(pid));
391
+ }
392
+ const found =
393
+ only === undefined ? tagged : tagged.filter((pid) => only(commands.get(pid) ?? ''));
203
394
  const fresh = found.filter((pid) => !killed.has(pid));
204
395
  if (found.length === 0) break;
205
396
  for (const pid of found) {
206
397
  try {
207
398
  process.kill(pid, 'SIGKILL');
208
- killed.add(pid);
399
+ if (!killed.has(pid)) killed.set(pid, { pid, command: commands.get(pid) ?? '' });
209
400
  } catch {
210
401
  // Already gone between the listing and the signal — which is the outcome wanted.
211
402
  }
@@ -214,26 +405,9 @@ export async function killTagged(
214
405
  if (fresh.length === 0) break;
215
406
  await Bun.sleep(20);
216
407
  }
217
- return killed.size;
408
+ return [...killed.values()];
218
409
  }
219
410
 
220
- /**
221
- * A step that ran past its deadline, on that step's own line. `command` is the gate as it was
222
- * invoked here (`x verify`, or `bun run verify` at the framework root), so the fix runs where the
223
- * finding was raised.
224
- */
225
- export const stepTimeoutFinding = (
226
- step: VerifyStepName,
227
- ms: number,
228
- killed: number,
229
- command: string,
230
- ): Finding => ({
231
- code: 'X_VERIFY_STEP_TIMEOUT',
232
- cause: `step "${step}" did not finish within its ${String(ms)} ms deadline, so it was stopped and ${String(killed)} process(es) it had started were killed`,
233
- fix: `${command} --only ${step} --json # reproduce it alone; if it legitimately needs longer, edit x.verify.json — add "${STEP_TIMEOUT_FIELD}": { "${step}": <milliseconds> }`,
234
- docs: ERROR_DOCS_URL,
235
- });
236
-
237
411
  /** `work`, or `{ timedOut: true }` once `ms` passes. The abandoned promise is never unhandled. */
238
412
  export async function raceDeadline<T>(
239
413
  work: Promise<T>,
@@ -16,7 +16,7 @@
16
16
  import { existsSync } from 'node:fs';
17
17
  // why: Bun ships no path-join primitive.
18
18
  import { join } from 'node:path';
19
- import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
19
+ import { ERROR_DOCS_URL, renderThrowable, UltimateError } from '@ultimat3/core';
20
20
  import { APP_CONFIG_FILE } from './app-root';
21
21
  import { judgeCoverage } from './coverage-floor';
22
22
  import type { CoverageMap } from './coverage-lcov';
@@ -24,27 +24,29 @@ import { decodeCoverage, mergeCoverage } from './coverage-lcov';
24
24
  import { msg } from './messages';
25
25
  import type { CommandResult, Finding, StepResult } from './output';
26
26
  import type { TestCounts } from './test-counts';
27
- import { VerifyMergeInputError } from './verify-errors';
28
27
  import type { VerifyFloor } from './verify-floor';
29
28
  import { floorRequires, skippedSuiteFinding } from './verify-floor';
29
+ import type { PartStep } from './verify-part-step';
30
+ import { readPartStep } from './verify-part-step';
30
31
  import { verifySummary } from './verify-run';
32
+ import { corpusHash } from './verify-shard';
31
33
  import { GATE_COMMAND, VERIFY_STEP_NAMES } from './verify-step';
32
34
 
33
- interface PartStep {
34
- readonly name: string;
35
- readonly ok: boolean;
36
- readonly durationMs: number;
37
- readonly skipped: boolean;
38
- readonly findings: readonly Finding[];
39
- readonly workers?: number;
40
- readonly tests?: TestCounts;
41
- readonly output?: string;
42
- readonly shard?: {
43
- readonly index: number;
44
- readonly total: number;
45
- readonly corpusHash: string;
46
- readonly files: readonly string[];
47
- };
35
+ /**
36
+ * A part handed to `x verify merge` that is not an `x verify --json` document. `command` is the
37
+ * entry that raised it — `bun run verify` at the framework root, where `x verify` answers
38
+ * `X_NOT_IN_APP` — so the `fix:` runs where it was raised.
39
+ */
40
+ export class VerifyMergeInputError extends UltimateError {
41
+ constructor(input: { file: string; reason: string; sourceError?: unknown; command?: string }) {
42
+ const command = input.command ?? GATE_COMMAND;
43
+ super({
44
+ code: 'X_VERIFY_MERGE_INPUT',
45
+ cause: `${command} merge: ${input.file} ${input.reason}`,
46
+ fix: `${command} --only unit --json > part.json # one document per part, the last line of stdout`,
47
+ ...(input.sourceError === undefined ? {} : { sourceError: input.sourceError }),
48
+ });
49
+ }
48
50
  }
49
51
 
50
52
  export interface VerifyPart {
@@ -70,8 +72,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
70
72
  typeof value === 'object' && value !== null && !Array.isArray(value);
71
73
 
72
74
  /**
73
- * The LAST non-empty line that parses: `bin/check --json` prints the build's document and then
74
- * the gate's, and a reader takes the last one.
75
+ * The LAST non-empty line, and only it: `bin/check --json` prints the build's document and then
76
+ * the gate's. Never "the last line that parses" — a part whose tail is not the gate's document is
77
+ * a truncated or appended upload, and falling back to an earlier line would believe it.
75
78
  */
76
79
  export function parsePart(file: string, text: string, command: string = GATE_COMMAND): VerifyPart {
77
80
  const lines = text
@@ -100,14 +103,9 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
100
103
  const data = isRecord(doc['data']) ? doc['data'] : {};
101
104
  const steps: PartStep[] = [];
102
105
  for (const raw of doc['steps'] as unknown[]) {
103
- if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['ok'] !== 'boolean') {
104
- throw new VerifyMergeInputError({
105
- file,
106
- reason: 'holds a step without a name and ok',
107
- command,
108
- });
109
- }
110
- steps.push(raw as unknown as PartStep);
106
+ const step = readPartStep(raw);
107
+ if (typeof step === 'string') throw new VerifyMergeInputError({ file, reason: step, command });
108
+ steps.push(step);
111
109
  }
112
110
  const coverage = new Map<string, CoverageMap>();
113
111
  for (const [step, entry] of Object.entries(isRecord(data['coverage']) ? data['coverage'] : {})) {
@@ -134,15 +132,41 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
134
132
  /** A gap finding, with a fix spelled for the entry that raised it. */
135
133
  type Gap = (cause: string, at?: string) => Finding;
136
134
 
137
- const gapFor =
138
- (command: string): Gap =>
139
- (cause, at) => ({
140
- code: 'X_VERIFY_MERGE_INCOMPLETE',
141
- cause,
142
- fix: `${command} merge parts/*.json --json # after every CI job uploaded its part`,
143
- docs: ERROR_DOCS_URL,
144
- ...(at === undefined ? {} : { at }),
145
- });
135
+ /**
136
+ * The gap kinds a merge can find. `present` is a document that arrived and disagrees with the
137
+ * others; `absent` is one that never arrived at all — GitHub failing to acquire a hosted runner
138
+ * leaves the job `cancelled` with no steps and no upload, and "missing shard 2/3" alone read as a
139
+ * shard that ran and died. The two are fixed differently: the first is debugged, the second re-run.
140
+ */
141
+ interface Gaps {
142
+ readonly present: Gap;
143
+ readonly absent: Gap;
144
+ }
145
+
146
+ /** Said of every absent gap, so it can never be mistaken for a failure the part reported. */
147
+ const NEVER_ARRIVED =
148
+ 'its result never arrived — its CI job never ran or never uploaded (cancelled before a step, or no runner acquired); this is not a failure it reported';
149
+
150
+ const gapsFor = (command: string): Gaps => {
151
+ const finding =
152
+ (fix: string): Gap =>
153
+ (cause, at) => ({
154
+ code: 'X_VERIFY_MERGE_INCOMPLETE',
155
+ cause,
156
+ fix,
157
+ docs: ERROR_DOCS_URL,
158
+ ...(at === undefined ? {} : { at }),
159
+ });
160
+ return {
161
+ present: finding(
162
+ `${command} merge parts/*.json --json # after every CI job uploaded its part`,
163
+ ),
164
+ // The merge is still what proves the gate whole; what it waits on is a re-run, not a fix.
165
+ absent: finding(
166
+ `${command} merge parts/*.json --json # after re-running the job that never reported: gh run rerun <run-id> --failed`,
167
+ ),
168
+ };
169
+ };
146
170
 
147
171
  /** The step every part's coverage facts belong to. */
148
172
  const COVERED_STEP = 'unit';
@@ -184,20 +208,54 @@ const sumCounts = (steps: readonly PartStep[]): TestCounts | undefined => {
184
208
  };
185
209
  };
186
210
 
211
+ /**
212
+ * The shards' `files` must PARTITION the corpus they all hashed: each file in exactly one shard,
213
+ * and the union hashing to `corpusHash`. Agreeing on the hash is not agreeing on the split — shard
214
+ * 1/2 round-robin beside 2/2 with `--timings` ran one file twice and another nowhere, green.
215
+ */
216
+ function partitionGaps(
217
+ name: string,
218
+ sharded: readonly { readonly step: PartStep }[],
219
+ gap: Gap,
220
+ ): readonly Finding[] {
221
+ const counts = new Map<string, number>();
222
+ for (const entry of sharded) {
223
+ for (const file of entry.step.shard?.files ?? []) counts.set(file, (counts.get(file) ?? 0) + 1);
224
+ }
225
+ const twice = [...counts].filter(([, count]) => count > 1).map(([file]) => file);
226
+ const found: Finding[] = [];
227
+ if (twice.length > 0) {
228
+ found.push(gap(`step "${name}" ran ${twice.join(', ')} in more than one shard`, name));
229
+ }
230
+ // A list's fingerprint, not a credential: what the shards ran against what they all counted.
231
+ const ranCorpus = corpusHash([...counts.keys()]);
232
+ const countedCorpus = sharded[0]?.step.shard?.corpusHash;
233
+ if (ranCorpus !== countedCorpus) {
234
+ found.push(
235
+ gap(
236
+ `step "${name}"'s shards ran ${String(counts.size)} distinct file(s), which is not the corpus they hashed — some file ran in no shard; every shard must split one list the same way (all with --timings, or none)`,
237
+ name,
238
+ ),
239
+ );
240
+ }
241
+ return found;
242
+ }
243
+
187
244
  /** One step's entries from every part, folded into one step result and its gaps. */
188
245
  function mergeStep(
189
246
  name: string,
190
247
  entries: readonly { readonly part: string; readonly step: PartStep }[],
191
248
  floor: VerifyFloor | undefined,
192
- gap: Gap,
249
+ kinds: Gaps,
193
250
  rider: MergeRiders[string] | undefined,
194
251
  ): StepResult {
252
+ const gap = kinds.present;
195
253
  if (entries.length === 0) {
196
254
  return {
197
255
  name,
198
256
  ok: false,
199
257
  durationMs: 0,
200
- findings: [gap(`step "${name}" is in no part — no CI job ran it`, name)],
258
+ findings: [kinds.absent(`step "${name}" is in no part: ${NEVER_ARRIVED}`, name)],
201
259
  };
202
260
  }
203
261
  const sharded = entries.filter((entry) => entry.step.shard !== undefined);
@@ -237,8 +295,8 @@ function mergeStep(
237
295
  const doubled = [...seen].filter(([, count]) => count > 1).map(([index]) => index);
238
296
  if (missing.length > 0) {
239
297
  gaps.push(
240
- gap(
241
- `step "${name}" is missing shard(s) ${missing.map((i) => `${String(i)}/${String(total)}`).join(', ')}`,
298
+ kinds.absent(
299
+ `step "${name}" is missing shard(s) ${missing.map((i) => `${String(i)}/${String(total)}`).join(', ')}: ${NEVER_ARRIVED}`,
242
300
  name,
243
301
  ),
244
302
  );
@@ -246,6 +304,8 @@ function mergeStep(
246
304
  if (doubled.length > 0) {
247
305
  gaps.push(gap(`step "${name}" has shard(s) ${doubled.join(', ')} more than once`, name));
248
306
  }
307
+ // Only over a complete, single-corpus set — any gap above already explains a partial union.
308
+ if (gaps.length === 0) gaps.push(...partitionGaps(name, sharded, gap));
249
309
  }
250
310
  const steps = entries.map((entry) => entry.step);
251
311
  const tests = sumCounts(steps);
@@ -262,6 +322,8 @@ function mergeStep(
262
322
  .map((step) => step.output as string),
263
323
  ...(rider?.output === undefined ? [] : [rider.output]),
264
324
  ].join('\n');
325
+ // Every part's advice, once each and in the order the parts reported it.
326
+ const warnings = [...new Set(steps.flatMap((step) => step.warnings ?? []))];
265
327
  const skipped =
266
328
  sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
267
329
  return {
@@ -276,6 +338,7 @@ function mergeStep(
276
338
  ...(skipped ? { skipped: true } : {}),
277
339
  findings,
278
340
  ...(output.length === 0 ? {} : { output }),
341
+ ...(warnings.length === 0 ? {} : { warnings }),
279
342
  ...(tests === undefined ? {} : { tests }),
280
343
  };
281
344
  }
@@ -287,11 +350,13 @@ export function mergeParts(
287
350
  declared: readonly string[] = VERIFY_STEP_NAMES,
288
351
  options: MergeOptions = {},
289
352
  ): CommandResult {
290
- const gap = gapFor(options.command ?? GATE_COMMAND);
353
+ const kinds = gapsFor(options.command ?? GATE_COMMAND);
291
354
  const unknown = parts.flatMap((part) =>
292
355
  part.steps
293
356
  .filter((step) => !declared.includes(step.name))
294
- .map((step) => gap(`${part.file} reports "${step.name}", which x verify does not run`)),
357
+ .map((step) =>
358
+ kinds.present(`${part.file} reports "${step.name}", which x verify does not run`),
359
+ ),
295
360
  );
296
361
  const steps = declared.map((name) =>
297
362
  mergeStep(
@@ -300,7 +365,7 @@ export function mergeParts(
300
365
  part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
301
366
  ),
302
367
  floor,
303
- gap,
368
+ kinds,
304
369
  options.riders !== undefined && Object.hasOwn(options.riders, name)
305
370
  ? options.riders[name]
306
371
  : undefined,