@ultimat3/cli 24.0.0 → 25.1.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 (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
@@ -0,0 +1,49 @@
1
+ // How many proxies sit in front of this process, read from `TRUSTED_PROXY_HOPS`. Its own module
2
+ // so both the web role (`role-start.ts`) and the sync node (`role-sync.ts`) read it without one
3
+ // importing the other — `role-start` imports `role-sync`, and the reverse edge was a cycle.
4
+
5
+ import { MAX_PROXY_HOPS } from '@ultimat3/http';
6
+ import type { Env } from './runtime-bindings';
7
+ import { TrustedProxyHopsInvalidError } from './trusted-hops-error';
8
+
9
+ /**
10
+ * How many proxies is too many, and the number is **`@ultimat3/http`'s**, not this file's.
11
+ *
12
+ * `defineHttpConfig` screens the same setting — `assertFiniteCount('trustedProxyHops', …,
13
+ * MAX_PROXY_HOPS)` — and this screen used to say 16 where that one says 64, so one setting had two
14
+ * ceilings and a deployment behind 20 hops was accepted by the library and refused by the boot.
15
+ * Widened rather than narrowed: tightening the library would break a shipped public API, while
16
+ * this end only ever refused topologies http already supports.
17
+ *
18
+ * It was a duplicated literal until 2026-08-26, because `@ultimat3/http` did not export the
19
+ * constant. It does now, so this file IMPORTS it — a downward edge, tier 5 to tier 2 — and there is
20
+ * no second number left to drift. `runtime-overrides.test.ts` still probes both screens for the
21
+ * highest count each accepts, and it is kept rather than deleted: it compares BEHAVIOUR, so it also
22
+ * catches the two disagreeing for a reason a shared constant cannot fix, such as one side gaining a
23
+ * range check the other does not have.
24
+ */
25
+
26
+ /**
27
+ * How many proxies append to `x-forwarded-for` between the client and this process, or `null`
28
+ * when nothing in front of it is trusted.
29
+ *
30
+ * Read from the environment and not from `app.config.ts`, for the reason `PORT` and `ROLE` are: it
31
+ * is a fact about the DEPLOYMENT — one image runs behind an ingress in one cluster and behind
32
+ * nothing on a laptop — and an app that hardcoded it would be wrong in one of the two. `x dev`
33
+ * sets neither and gets `trustProxy: false`, which is correct: there is no proxy.
34
+ *
35
+ * Without this seam a container behind an ingress reads `ctx.ip` as the ingress's own socket
36
+ * address on every request, so the rate limiter keys the entire fleet's anonymous traffic into ONE
37
+ * bucket and a single scanner 429s every real signup.
38
+ */
39
+ export function trustedHopsFromEnv(env: Env): number | null {
40
+ const raw = env['TRUSTED_PROXY_HOPS']?.trim();
41
+ if (raw === undefined || raw === '') return null;
42
+ const hops = Number(raw);
43
+ // A malformed count is refused rather than defaulted: reading the header at the wrong index is
44
+ // trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
45
+ if (!/^\d+$/.test(raw) || !Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
46
+ throw new TrustedProxyHopsInvalidError({ value: raw });
47
+ }
48
+ return hops;
49
+ }
@@ -4,7 +4,7 @@
4
4
  // silently, which is how the reference app shipped an admin nobody could open.
5
5
 
6
6
  import { renderFixShellArg, stripComments } from '@ultimat3/core';
7
- import { describeRoutes } from '@ultimat3/render';
7
+ import { describePages } from '@ultimat3/render';
8
8
  import { ADMIN_FILE } from './admin-registration';
9
9
  import type { Finding } from './output';
10
10
  import { hasPathSegment } from './path-segments';
@@ -17,7 +17,7 @@ const DECLARES = /\bdefineAdmin\(/;
17
17
 
18
18
  /** Any admin this process mounted — then every declaration was reached, wherever it sits. */
19
19
  export const adminMounted = (): boolean =>
20
- describeRoutes().some((route) => route.mount?.by === 'defineAdmin');
20
+ describePages().some((route) => route.mount?.by === 'defineAdmin');
21
21
 
22
22
  /** Where the file belongs: its own name under the admin's home, `index.ts` as the declaration. */
23
23
  const homeOf = (file: string): string => {
@@ -46,7 +46,7 @@ import type { VerifyStep } from './verify-step';
46
46
  import { fromExec, fromFindings, hostFindings } from './verify-step';
47
47
  import { TEST_STEPS } from './verify-tests';
48
48
  import { typecheckArgs } from './verify-typecheck';
49
- import { checkFileSizes, checkPackageShape, hasWorkspacePackages } from './workspace-checks';
49
+ import { checkPackageShape, fileSizeReport, hasWorkspacePackages } from './workspace-checks';
50
50
  import { checkWorkspaceDependencies } from './workspace-graph';
51
51
 
52
52
  /** The one file that makes the `roadmap` step answerable, and therefore what `applies` reads. */
@@ -120,7 +120,17 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
120
120
  {
121
121
  name: 'filesize',
122
122
  summary: 'one file, one job',
123
- run: async (ctx) => fromFindings(await checkFileSizes(ctx.root)),
123
+ // The band below the ceiling rides as warnings: listed in `--json` on a pass, never a failure.
124
+ run: async (ctx) => {
125
+ const { findings, warnings } = await fileSizeReport(ctx.root);
126
+ return {
127
+ ...fromFindings(findings),
128
+ warnings: warnings.map(
129
+ (warning) =>
130
+ `${warning.path}: ${warning.lines} lines, ${warning.ceiling - warning.lines} under the ${warning.ceiling} line ceiling — plan the split`,
131
+ ),
132
+ };
133
+ },
124
134
  },
125
135
  {
126
136
  name: 'package-shape',
@@ -14,6 +14,7 @@
14
14
  import { rm } from 'node:fs/promises';
15
15
  // why: Bun ships no path-join primitive.
16
16
  import { dirname, join } from 'node:path';
17
+ import { ISOLATED_ENV } from '@ultimat3/testing';
17
18
  import type { CoverageMap } from './coverage-lcov';
18
19
  import { fileCoverageOf, mergeCoverage, parseLcov } from './coverage-lcov';
19
20
  import type { ExecResult, Runner } from './exec';
@@ -21,7 +22,7 @@ import { execOutput } from './exec';
21
22
  import { msg } from './messages';
22
23
  import { countsOf } from './test-counts';
23
24
  import { testEnvOverrides } from './test-dotenv';
24
- import { failureOf, ISOLATED_TEST_ENV } from './test-shards';
25
+ import { failureOf } from './test-shards';
25
26
  import { SLOT_HELD_ENV } from './test-slots';
26
27
  import type { StepOutcome } from './verify-step';
27
28
 
@@ -113,7 +114,7 @@ export async function runCovered(options: CoveredRunOptions): Promise<CoveredRun
113
114
  const isolate = options.isolate === true;
114
115
  const overrides = {
115
116
  ...testEnvOverrides(options.root, options.env ?? Bun.env),
116
- ...(isolate ? { [ISOLATED_TEST_ENV]: '1' } : {}),
117
+ ...(isolate ? { [ISOLATED_ENV]: '1' } : {}),
117
118
  ...(lease === undefined ? {} : { [SLOT_HELD_ENV]: '1' }),
118
119
  };
119
120
  const results = new Array<ExecResult>(slices.length);
@@ -126,15 +126,23 @@ export interface StepGuard {
126
126
  *
127
127
  * A nested gate (a test that runs `x verify`) appends its tag to the one it inherited, so the
128
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`).
129
132
  */
130
133
  export function guardStep(
131
134
  runner: Runner,
132
135
  tag: string,
133
136
  env: Readonly<Record<string, string | undefined>> = Bun.env,
137
+ platform: PlatformKill = {},
134
138
  ): StepGuard {
135
139
  let expired = false;
136
140
  // Keyed by the promise itself: two batches of one step may run the very same argv.
137
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';
138
146
  const inherited = env[STEP_TAG_ENV];
139
147
  const value = inherited === undefined || inherited === '' ? tag : `${inherited} ${tag}`;
140
148
  return {
@@ -151,18 +159,32 @@ export function guardStep(
151
159
  durationMs: 0,
152
160
  };
153
161
  }
154
- const run = 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
+ });
155
172
  inFlight.set(run, command);
156
173
  try {
157
174
  return await run;
158
175
  } finally {
159
176
  inFlight.delete(run);
177
+ if (pid !== undefined) spawned.delete(pid);
160
178
  }
161
179
  },
162
180
  async expire(): Promise<StepExpiry> {
163
181
  expired = true;
164
182
  // Before the kill: a killed child settles its runner call, which takes it off the map.
165
183
  const waiting = [...inFlight];
184
+ if (windows) {
185
+ const killed = await killTrees(spawned, platform.killTree ?? taskkillTree);
186
+ return { killed, inFlight: await settleAll(waiting) };
187
+ }
166
188
  const workers = await killTagged(tag, undefined, isTestWorker);
167
189
  if (workers.length > 0) {
168
190
  await raceDeadline(Promise.allSettled(waiting.map(([run]) => run)), CRASH_REPORT_MS);
@@ -171,19 +193,74 @@ export function guardStep(
171
193
  const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
172
194
  // After it: the kill closed the child's pipes, so its call now resolves with everything it
173
195
  // had printed — for `bun test`, the crash line of each file a worker was holding.
174
- const settled = await Promise.all(
175
- waiting.map(async ([run, command]): Promise<InFlightRun> => {
176
- const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
177
- return raced === undefined || raced.timedOut
178
- ? { command }
179
- : { command, output: raced.value };
180
- }),
181
- );
182
- return { killed, inFlight: settled };
196
+ return { killed, inFlight: await settleAll(waiting) };
183
197
  },
184
198
  };
185
199
  }
186
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
+
187
264
  /** One process and the environment it was started with, as the platform reports it. */
188
265
  export interface ProcessEnviron {
189
266
  readonly pid: number;
@@ -132,15 +132,41 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
132
132
  /** A gap finding, with a fix spelled for the entry that raised it. */
133
133
  type Gap = (cause: string, at?: string) => Finding;
134
134
 
135
- const gapFor =
136
- (command: string): Gap =>
137
- (cause, at) => ({
138
- code: 'X_VERIFY_MERGE_INCOMPLETE',
139
- cause,
140
- fix: `${command} merge parts/*.json --json # after every CI job uploaded its part`,
141
- docs: ERROR_DOCS_URL,
142
- ...(at === undefined ? {} : { at }),
143
- });
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
+ };
144
170
 
145
171
  /** The step every part's coverage facts belong to. */
146
172
  const COVERED_STEP = 'unit';
@@ -220,15 +246,16 @@ function mergeStep(
220
246
  name: string,
221
247
  entries: readonly { readonly part: string; readonly step: PartStep }[],
222
248
  floor: VerifyFloor | undefined,
223
- gap: Gap,
249
+ kinds: Gaps,
224
250
  rider: MergeRiders[string] | undefined,
225
251
  ): StepResult {
252
+ const gap = kinds.present;
226
253
  if (entries.length === 0) {
227
254
  return {
228
255
  name,
229
256
  ok: false,
230
257
  durationMs: 0,
231
- 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)],
232
259
  };
233
260
  }
234
261
  const sharded = entries.filter((entry) => entry.step.shard !== undefined);
@@ -268,8 +295,8 @@ function mergeStep(
268
295
  const doubled = [...seen].filter(([, count]) => count > 1).map(([index]) => index);
269
296
  if (missing.length > 0) {
270
297
  gaps.push(
271
- gap(
272
- `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}`,
273
300
  name,
274
301
  ),
275
302
  );
@@ -295,6 +322,8 @@ function mergeStep(
295
322
  .map((step) => step.output as string),
296
323
  ...(rider?.output === undefined ? [] : [rider.output]),
297
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 ?? []))];
298
327
  const skipped =
299
328
  sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
300
329
  return {
@@ -309,6 +338,7 @@ function mergeStep(
309
338
  ...(skipped ? { skipped: true } : {}),
310
339
  findings,
311
340
  ...(output.length === 0 ? {} : { output }),
341
+ ...(warnings.length === 0 ? {} : { warnings }),
312
342
  ...(tests === undefined ? {} : { tests }),
313
343
  };
314
344
  }
@@ -320,11 +350,13 @@ export function mergeParts(
320
350
  declared: readonly string[] = VERIFY_STEP_NAMES,
321
351
  options: MergeOptions = {},
322
352
  ): CommandResult {
323
- const gap = gapFor(options.command ?? GATE_COMMAND);
353
+ const kinds = gapsFor(options.command ?? GATE_COMMAND);
324
354
  const unknown = parts.flatMap((part) =>
325
355
  part.steps
326
356
  .filter((step) => !declared.includes(step.name))
327
- .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
+ ),
328
360
  );
329
361
  const steps = declared.map((name) =>
330
362
  mergeStep(
@@ -333,7 +365,7 @@ export function mergeParts(
333
365
  part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
334
366
  ),
335
367
  floor,
336
- gap,
368
+ kinds,
337
369
  options.riders !== undefined && Object.hasOwn(options.riders, name)
338
370
  ? options.riders[name]
339
371
  : undefined,
@@ -14,6 +14,7 @@ export interface PartStep {
14
14
  readonly workers?: number;
15
15
  readonly tests?: TestCounts;
16
16
  readonly output?: string;
17
+ readonly warnings?: readonly string[];
17
18
  readonly shard?: {
18
19
  readonly index: number;
19
20
  readonly total: number;
@@ -29,7 +30,7 @@ const isCount = (value: unknown): value is number =>
29
30
  typeof value === 'number' && Number.isInteger(value) && value >= 0;
30
31
 
31
32
  /** What every finding is: code, cause and fix as strings. Optional fields ride along as written. */
32
- const isFinding = (value: unknown): value is Finding =>
33
+ export const isFinding = (value: unknown): value is Finding =>
33
34
  isRecord(value) &&
34
35
  typeof value['code'] === 'string' &&
35
36
  typeof value['cause'] === 'string' &&
@@ -41,6 +42,9 @@ const isTests = (value: unknown): value is TestCounts =>
41
42
  isCount(value['skipped']) &&
42
43
  (value['errors'] === undefined || isCount(value['errors']));
43
44
 
45
+ const isTextList = (value: unknown): value is readonly string[] =>
46
+ Array.isArray(value) && value.every((line) => typeof line === 'string');
47
+
44
48
  const isShard = (value: unknown): value is NonNullable<PartStep['shard']> =>
45
49
  isRecord(value) &&
46
50
  isCount(value['index']) &&
@@ -63,7 +67,7 @@ export function readPartStep(raw: unknown): PartStep | string {
63
67
  if (!Array.isArray(findings) || !findings.every(isFinding)) {
64
68
  return `holds step "${name}" whose findings are not each a code, a cause and a fix`;
65
69
  }
66
- const { tests, shard, output, workers, skipped } = raw;
70
+ const { tests, shard, output, workers, skipped, warnings } = raw;
67
71
  if (tests !== undefined && !isTests(tests)) {
68
72
  return `holds step "${name}" whose tests are not counts`;
69
73
  }
@@ -73,6 +77,9 @@ export function readPartStep(raw: unknown): PartStep | string {
73
77
  if (output !== undefined && typeof output !== 'string') {
74
78
  return `holds step "${name}" whose output is not text`;
75
79
  }
80
+ if (warnings !== undefined && !isTextList(warnings)) {
81
+ return `holds step "${name}" whose warnings are not a list of text`;
82
+ }
76
83
  if (workers !== undefined && !isCount(workers)) {
77
84
  return `holds step "${name}" whose workers is not a count`;
78
85
  }
@@ -85,6 +92,7 @@ export function readPartStep(raw: unknown): PartStep | string {
85
92
  ...(workers === undefined ? {} : { workers }),
86
93
  ...(tests === undefined ? {} : { tests }),
87
94
  ...(output === undefined ? {} : { output }),
95
+ ...(warnings === undefined ? {} : { warnings }),
88
96
  ...(shard === undefined ? {} : { shard }),
89
97
  };
90
98
  }
@@ -3,11 +3,22 @@
3
3
  // those registered that the worker's process did not hold. Run by hand, it is the same answer.
4
4
 
5
5
  import { scanAppModules } from './app-load';
6
+ import type { RelativeHost } from './posix-path';
7
+ import { posixRelative } from './posix-path';
6
8
  import { scanBackground } from './role-load';
7
9
  import type { MissingRegistration } from './verify-role-load';
8
10
  import { PROBE_MARKER, registeredSince, registrations } from './verify-role-load';
9
11
  import { writeLine } from './write-line';
10
12
 
13
+ /**
14
+ * The app-root-relative POSIX key a scanned module is held under — the form `scanBackground`'s
15
+ * `files` already are. A `${root}/` prefix strip matched nothing on Windows (`D:\…\app\apps\…`),
16
+ * so every module the worker DID import was blamed as missing. `host` is for a test only.
17
+ */
18
+ export function moduleKey(root: string, absolute: string, host?: RelativeHost): string {
19
+ return posixRelative(root, absolute, host);
20
+ }
21
+
11
22
  /**
12
23
  * What importing each module the worker load left out registered, by module — `[]` when the whole
13
24
  * scan reported a module that would not import. Exported so a test calls it in its own process;
@@ -31,12 +42,11 @@ export async function probeRoleLoad(root: string): Promise<readonly MissingRegis
31
42
 
32
43
  // The whole app, in the scan's own order. `include` is asked before each import, so the look it
33
44
  // takes is at what the PREVIOUS module added; a module the worker already imported adds nothing.
34
- const prefix = root.endsWith('/') ? root : `${root}/`;
35
45
  const whole = await scanAppModules(root, {
36
46
  track: false,
37
47
  include: (absolute) => {
38
48
  attribute();
39
- const file = absolute.startsWith(prefix) ? absolute.slice(prefix.length) : absolute;
49
+ const file = moduleKey(root, absolute);
40
50
  current = imported.has(file) ? undefined : file;
41
51
  return true;
42
52
  },
@@ -5,7 +5,7 @@
5
5
  // manifest; this is the build error for everything else a job reads by name.
6
6
 
7
7
  // why: Bun ships no path API; the fix names the import relative to the API index.
8
- import { dirname, join, relative } from 'node:path';
8
+ import { dirname, join } from 'node:path';
9
9
  import { ERROR_DOCS_URL, registeredServiceNames, singleLine } from '@ultimat3/core';
10
10
  import { entityNames } from '@ultimat3/entity';
11
11
  import { catalogDeclarationCount } from '@ultimat3/i18n';
@@ -16,6 +16,8 @@ import { definedStorage } from '@ultimat3/storage';
16
16
  import { API_INDEX } from './app-root';
17
17
  import type { Runner } from './exec';
18
18
  import type { Finding } from './output';
19
+ import type { RelativeHost } from './posix-path';
20
+ import { posixRelative } from './posix-path';
19
21
  import { quoteArg } from './shell-quote';
20
22
 
21
23
  /** One thing a process registered, as a job would ask for it: by kind and by name. */
@@ -79,9 +81,12 @@ export function registeredSince(
79
81
  return after.filter((one) => !had.has(keyOf(one)));
80
82
  }
81
83
 
82
- /** The import the API index is missing, as it would be written there. */
83
- function specifierFor(module: string): string {
84
- const from = relative(dirname(API_INDEX), module).replace(/\.[jt]sx?$/, '');
84
+ /**
85
+ * The import the API index is missing, as it would be written there — POSIX on every host: a `\`
86
+ * pasted from a Windows run is an import no Linux teammate resolves. `host` is for a test only.
87
+ */
88
+ export function specifierFor(module: string, host?: RelativeHost): string {
89
+ const from = posixRelative(dirname(API_INDEX), module, host).replace(/\.[jt]sx?$/, '');
85
90
  return from.startsWith('.') ? from : `./${from}`;
86
91
  }
87
92
 
package/src/verify-run.ts CHANGED
@@ -277,6 +277,9 @@ async function runStep(
277
277
  skipped: nothingRan && !required,
278
278
  findings: [...outcome.findings, ...vanished],
279
279
  ...(outcome.output === undefined ? {} : { output: outcome.output }),
280
+ ...(outcome.warnings === undefined || outcome.warnings.length === 0
281
+ ? {}
282
+ : { warnings: outcome.warnings }),
280
283
  ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
281
284
  ...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
282
285
  ...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
@@ -135,6 +135,8 @@ export interface StepOutcome {
135
135
  readonly ok: boolean;
136
136
  readonly findings: readonly Finding[];
137
137
  readonly output?: string;
138
+ /** Advice that does not fail the step; carried on a pass too (`StepResult.warnings`). */
139
+ readonly warnings?: readonly string[];
138
140
  /**
139
141
  * Processes this step actually used. `1` is a step that ran serially, and a reader has to be
140
142
  * able to tell "serial because nothing could isolate it" from "parallel and fast" without
@@ -4,13 +4,14 @@
4
4
 
5
5
  // why: Bun ships no path-join primitive; the timings cache lives under the run's root.
6
6
  import { join } from 'node:path';
7
+ import { ISOLATED_ENV } from '@ultimat3/testing';
7
8
  import type { Runner } from './exec';
8
9
  import type { Finding } from './output';
9
10
  import { runBatches, testBatches } from './test-batches';
10
11
  import { countsOf } from './test-counts';
11
12
  import { testEnvOverrides } from './test-dotenv';
12
13
  import type { TestFile } from './test-select';
13
- import { failureOf, ISOLATED_TEST_ENV, TEST_TIMINGS_FILE, testArgs } from './test-shards';
14
+ import { failureOf, TEST_TIMINGS_FILE, testArgs } from './test-shards';
14
15
  import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
15
16
  import type { StepOutcome } from './verify-step';
16
17
  // Type-only, so nothing here evaluates verify-tests.ts and the two files cannot form a cycle.
@@ -53,7 +54,7 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
53
54
  const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
54
55
  const envOverrides = {
55
56
  ...testEnvOverrides(options.root, options.env ?? Bun.env),
56
- ...(options.isolate === true ? { [ISOLATED_TEST_ENV]: '1' } : {}),
57
+ ...(options.isolate === true ? { [ISOLATED_ENV]: '1' } : {}),
57
58
  };
58
59
  // Batched exactly as `x test` batches the same selection at the same width, so the gate's
59
60
  // `fix:` line — `x test <type> --workers N` — reruns the very processes that failed.
@@ -40,7 +40,6 @@ import { runParallel } from './verify-test-run';
40
40
  * and `@ultimat3/testing` is already a runtime dependency of this package.
41
41
  */
42
42
  export type { TestType } from '@ultimat3/testing';
43
- export { TEST_TYPES } from '@ultimat3/testing';
44
43
 
45
44
  type TypedTest = Exclude<TestType, 'unit'>;
46
45
 
@@ -7,7 +7,7 @@
7
7
  // why: Bun ships no path API; the entry is resolved to a file and named in the cause.
8
8
  import { dirname, join, relative } from 'node:path';
9
9
  import type { Route, UltimateRequest } from '@ultimat3/http';
10
- import { applyCacheHeaders, json } from '@ultimat3/http';
10
+ import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
11
11
  import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
12
12
  import { describeBuildError, graphHash, stripDebugId } from './island-identity';
13
13
 
@@ -198,7 +198,7 @@ function scriptRoute(
198
198
  handler: (request: UltimateRequest): Response => {
199
199
  const script = source();
200
200
  if (script === undefined || script.url !== request.pathname) {
201
- return json(
201
+ return jsonResponse(
202
202
  {
203
203
  ok: false,
204
204
  error: {