@ultimat3/cli 24.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 (242) 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 +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
package/src/jobs-json.ts CHANGED
@@ -13,7 +13,6 @@ import type {
13
13
  StepTrace,
14
14
  WorkerRecord,
15
15
  } from '@ultimat3/jobs';
16
- import type { DrainFailure, DrainSkip } from './jobs-drain';
17
16
  import type { JsonValue } from './output';
18
17
 
19
18
  function stepTraceToJson(step: StepTrace): JsonValue {
@@ -181,28 +180,3 @@ export function deadLetterToJson(entry: DeadLetterEntry): JsonValue {
181
180
  retryCommand: entry.retryCommand,
182
181
  };
183
182
  }
184
-
185
- export function drainFailureToJson(failure: DrainFailure): JsonValue {
186
- return {
187
- id: failure.id,
188
- name: failure.name,
189
- finding: {
190
- code: failure.finding.code,
191
- cause: failure.finding.cause,
192
- fix: failure.finding.fix,
193
- docs: failure.finding.docs ?? null,
194
- at: failure.finding.at ?? null,
195
- },
196
- };
197
- }
198
-
199
- /** A candidate the drain refused to touch, and why — the half of the outcome `moved` cannot show. */
200
- export function drainSkipToJson(skip: DrainSkip): JsonValue {
201
- return {
202
- id: skip.id,
203
- name: skip.name,
204
- queue: skip.queue,
205
- state: skip.state,
206
- reason: skip.reason,
207
- };
208
- }
@@ -1,7 +1,9 @@
1
1
  // Pure, driver-injected job operations behind `x jobs`: flag parsing, plus ls / show / retry. No
2
- // CLI parsing, no process I/O, no rendering — a test drives every path with `createMemoryDriver()`
3
- // alone. Drain is `jobs-drain.ts`, the `--json` shapes `jobs-json.ts`, the table `jobs-table.ts`.
2
+ // CLI parsing, no process I/O, no rendering — a test drives every path with `memoryJobDriver()`
3
+ // alone. The `--json` shapes are `jobs-json.ts`, the table `jobs-table.ts`.
4
4
 
5
+ import type { Page } from '@ultimat3/core';
6
+ import { pageOf } from '@ultimat3/core';
5
7
  import type {
6
8
  BackfillProgress,
7
9
  DeadLetterEntry,
@@ -13,6 +15,7 @@ import type {
13
15
  QueueDepthReport,
14
16
  } from '@ultimat3/jobs';
15
17
  import {
18
+ DEFAULT_JOB_PAGE,
16
19
  inspectBackfills,
17
20
  inspectDeadLetters,
18
21
  inspectJob,
@@ -20,19 +23,12 @@ import {
20
23
  inspectQueues,
21
24
  isJobState,
22
25
  JOB_STATES,
26
+ jobCursor,
27
+ MAX_JOB_PAGE,
23
28
  retryFromStep,
24
29
  } from '@ultimat3/jobs';
25
30
  import { BadFlagError, JobUnknownError } from './errors';
26
31
 
27
- /**
28
- * The queue's own vocabulary, re-exported rather than restated — this file carried a copy of it,
29
- * and the copy was one member short. `cancelled` shipped in `@ultimat3/jobs` and never here, so
30
- * `x jobs cancel` created a state `x jobs ls --state cancelled` then refused to filter on: two
31
- * commands of one CLI disagreeing about what a job can be. Kept on this module's surface because
32
- * `index.ts` exports it from here.
33
- */
34
- export { JOB_STATES } from '@ultimat3/jobs';
35
-
36
32
  export function parseStateFlag(value: string | undefined): JobState | undefined {
37
33
  if (value === undefined) return undefined;
38
34
  if (isJobState(value)) return value;
@@ -76,12 +72,32 @@ export interface JobsListFilter {
76
72
  readonly after?: string | undefined;
77
73
  }
78
74
 
79
- export interface JobsListResult {
75
+ /** ONE page of rows — core's `Page`, so `nextCursor` exists exactly when another row does. */
76
+ export type JobsListResult = Page<JobRecord> & {
80
77
  readonly depth: QueueDepthReport;
81
- readonly rows: readonly JobRecord[];
82
78
  readonly deadLetters: readonly DeadLetterEntry[];
83
79
  /** The sweeps still in flight — see below for why finished ones are not this command's answer. */
84
80
  readonly backfills: readonly BackfillProgress[];
81
+ };
82
+
83
+ /**
84
+ * One page, and whether a row exists past it — asked of the queue, never guessed from a count.
85
+ * `next = rows.length === limit ? cursor : null` handed a FULL last page a cursor to an empty one.
86
+ * The page reads one row past its limit; at `MAX_JOB_PAGE` there is no room for that row (the
87
+ * queue refuses a bigger page), so a full maximal page asks for the one row after its last.
88
+ */
89
+ async function jobPage(driver: JobDriver, filter: JobFilter): Promise<Page<JobRecord>> {
90
+ const size = filter.limit ?? DEFAULT_JOB_PAGE;
91
+ const ask = size < MAX_JOB_PAGE ? size + 1 : size;
92
+ const fetched = await inspectJobList(driver, { ...filter, limit: ask });
93
+ const rows = fetched.slice(0, size);
94
+ const last = rows.at(-1);
95
+ if (last === undefined || fetched.length < size) return pageOf(rows, null);
96
+ if (fetched.length > size) return pageOf(rows, jobCursor(last));
97
+ if (ask > size) return pageOf(rows, null);
98
+ const after = jobCursor(last);
99
+ const beyond = await inspectJobList(driver, { ...filter, limit: 1, after });
100
+ return pageOf(rows, beyond.length > 0 ? after : null);
85
101
  }
86
102
 
87
103
  /**
@@ -107,13 +123,13 @@ export async function listJobs(
107
123
  ...(limit === undefined ? {} : { limit }),
108
124
  ...(filter.after === undefined ? {} : { after: filter.after }),
109
125
  };
110
- const [depth, rows, deadLetters, backfills] = await Promise.all([
126
+ const [depth, page, deadLetters, backfills] = await Promise.all([
111
127
  inspectQueues(driver),
112
- inspectJobList(driver, jobFilter),
128
+ jobPage(driver, jobFilter),
113
129
  inspectDeadLetters(driver),
114
130
  inspectBackfills(driver, { status: 'running' }),
115
131
  ]);
116
- return { depth, rows, deadLetters, backfills };
132
+ return { ...page, depth, deadLetters, backfills };
117
133
  }
118
134
 
119
135
  // ── show ──────────────────────────────────────────────────────────────────
@@ -0,0 +1,90 @@
1
+ // Running the app's own CLI in place of this one (`local-cli.ts` decides WHETHER). The parent only
2
+ // waits, so every stop signal it receives is the child's to act on: a supervisor or a closing
3
+ // terminal signals the pid it started, and that pid is this one. `bin.ts` exits with the answer.
4
+
5
+ /**
6
+ * The stop signals forwarded to the child at once. SIGINT is NOT one of them: Ctrl-C reaches the
7
+ * whole foreground process group, so the child already has it, and a second one landed inside its
8
+ * cleanup — `x secrets edit` died mid-shred with its decrypted buffer still on disk (audit M1).
9
+ */
10
+ export const HAND_OFF_SIGNALS = ['SIGTERM', 'SIGHUP'] as const;
11
+ export type HandOffSignal = (typeof HAND_OFF_SIGNALS)[number] | 'SIGINT';
12
+
13
+ /**
14
+ * How long a SIGINT is held before it is forwarded. A Ctrl-C the group delivered ends the child
15
+ * well inside it — a shred is milliseconds — so it is never sent twice; a SIGINT aimed at this pid
16
+ * alone (`kill -INT`, a runtime whose STOPSIGNAL is SIGINT) reaches the child a second later
17
+ * instead of never. A child still draining past it gets one more SIGINT, which every drain here
18
+ * already absorbs (`signal-shred.ts` ignores repeats; `x dev`'s stop is armed once).
19
+ */
20
+ export const INTERRUPT_GRACE_MS = 1000;
21
+
22
+ /** Arms `fire` after `ms`, answering its cancel. Injected by the test, so no verdict waits on a clock. */
23
+ export type Schedule = (fire: () => void, ms: number) => () => void;
24
+
25
+ /** The spawned CLI as the hand-off sees it — `Bun.spawn`'s answer, narrowed so a test can fake it. */
26
+ export interface HandOffChild {
27
+ readonly exited: Promise<number>;
28
+ kill(signal: HandOffSignal): void;
29
+ }
30
+
31
+ // Untimed by design, and counted under `cli`'s pin in `scripts/lib/spawn-timeout-pins.ts`: the
32
+ // child IS the command the user typed (`x dev` serves until Ctrl-C), so its lifetime is theirs, and
33
+ // the forwarded stop signals below are what end it.
34
+ const spawnInherited = (
35
+ command: readonly string[],
36
+ env: Readonly<Record<string, string | undefined>>,
37
+ ): HandOffChild =>
38
+ Bun.spawn([...command], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env });
39
+
40
+ const scheduleTimer: Schedule = (fire, ms) => {
41
+ const timer = setTimeout(fire, ms);
42
+ return () => clearTimeout(timer);
43
+ };
44
+
45
+ export interface HandOffOptions {
46
+ readonly spawn?: typeof spawnInherited;
47
+ readonly schedule?: Schedule;
48
+ }
49
+
50
+ /**
51
+ * Spawns `command`, forwards SIGTERM and SIGHUP to it for as long as it runs — SIGINT only once the
52
+ * grace passes with the child still running — and answers its exit code: `128 + n` when a signal
53
+ * ended it, as `Bun.spawn`'s `exited` reports. `bin.ts` exits with that code.
54
+ *
55
+ * Without the listeners the default action ran: the parent died on the signal and the child —
56
+ * an `x dev` holding a port and an embedded Postgres, an `x db migrate` mid-transaction — ran on
57
+ * as an orphan nothing would stop. A listener is also what keeps the parent alive to report the
58
+ * child's own exit code, rather than 143 before the child had drained.
59
+ */
60
+ export async function handOff(
61
+ command: readonly string[],
62
+ env: Readonly<Record<string, string | undefined>>,
63
+ options: HandOffOptions = {},
64
+ ): Promise<number> {
65
+ const child = (options.spawn ?? spawnInherited)(command, env);
66
+ const schedule = options.schedule ?? scheduleTimer;
67
+ const forwards = HAND_OFF_SIGNALS.map((signal) => {
68
+ const forward = (): void => child.kill(signal);
69
+ process.on(signal, forward);
70
+ return () => process.off(signal, forward);
71
+ });
72
+ // Whether the child already has this SIGINT is unknowable from here, so the grace decides: a
73
+ // repeat inside one window arms nothing new — one Ctrl-C never becomes two forwarded signals.
74
+ let cancelGrace: (() => void) | undefined;
75
+ const holdInterrupt = (): void => {
76
+ if (cancelGrace !== undefined) return;
77
+ cancelGrace = schedule(() => {
78
+ cancelGrace = undefined;
79
+ child.kill('SIGINT');
80
+ }, INTERRUPT_GRACE_MS);
81
+ };
82
+ process.on('SIGINT', holdInterrupt);
83
+ try {
84
+ return await child.exited;
85
+ } finally {
86
+ for (const release of forwards) release();
87
+ process.off('SIGINT', holdInterrupt);
88
+ cancelGrace?.();
89
+ }
90
+ }
package/src/mcp-errors.ts CHANGED
@@ -69,7 +69,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
69
69
  'x verify --json # the package-shape finding carries the dependency line to add',
70
70
  X_PACKAGE_DUPLICATED:
71
71
  'x i18n check --json # the finding names both copies and the package.json to pin',
72
- X_SHOT_BROWSER_MISSING: 'export CHROME_PATH=/usr/bin/google-chrome',
72
+ X_SHOT_BROWSER_MISSING:
73
+ 'export CHROME_PATH=/usr/bin/google-chrome # macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows PowerShell: $env:CHROME_PATH = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
73
74
  X_UI_SHOT_ROUTE_UNKNOWN:
74
75
  'x routes --json # then call the ui.* tool with one of its path values',
75
76
  X_UI_SHOT_ROUTE_UNBUDGETED:
@@ -293,6 +294,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
293
294
  X_TRUSTED_PROXY_HOPS_INVALID: 'TRUSTED_PROXY_HOPS=1 ROLE=web bun apps/web/server.ts',
294
295
  X_PERMISSION_BORROWED: 'x verify --only policy --json',
295
296
  X_ERROR_CODE_UNTHROWN_STALE: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
297
+ X_DEV_STATE_UNWRITABLE:
298
+ 'ls -ld .x # make it writable by this user, on a filesystem that supports hard links, then re-run x dev',
299
+ X_PACKAGE_TEST_ONLY_SHIPPED:
300
+ 'bun run x -- verify --only package-shape --json # then rename each listed module to <name>-fixture.ts and update its importers',
301
+ X_ISLAND_MODULE_DUPLICATED:
302
+ 'gh issue create --repo developerz-ai/ultimate --title "X_ISLAND_MODULE_DUPLICATED" # paste this error\'s --json as the body: the framework bundled one of its own modules twice',
296
303
  };
297
304
 
298
305
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/mcp-host.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  agentActor,
12
12
  isUltimateError,
13
13
  NotImplementedError,
14
+ renderFixShellArg,
14
15
  renderThrowable,
15
16
  UltimateError,
16
17
  } from '@ultimat3/core';
@@ -34,8 +35,8 @@ import type {
34
35
  VerifyResult,
35
36
  VerifyStep,
36
37
  } from '@ultimat3/mcp';
37
- import { createDevServer, DEV_SCOPES, devHost, frameworkIntrospection } from '@ultimat3/mcp';
38
- import { describeRoutes } from '@ultimat3/render';
38
+ import { DEV_SCOPES, devHost, devMcpServer, frameworkIntrospection } from '@ultimat3/mcp';
39
+ import { describePages } from '@ultimat3/render';
39
40
  import { loadApp } from './app-load';
40
41
  import { appManifest, policyFacts } from './app-manifest';
41
42
  import { runVerify, VERIFY_STEPS } from './cmd-verify';
@@ -293,7 +294,7 @@ function capabilities(
293
294
  fix:
294
295
  role === undefined
295
296
  ? `x dev > ${path} 2>&1`
296
- : `mkdir -p ${join(dir, 'logs')} && x dev --role ${role} > ${path} 2>&1`,
297
+ : `mkdir -p ${renderFixShellArg(join(dir, 'logs'), '<the logs dir of the path the cause names>')} && x dev --role ${role} > ${path} 2>&1`,
297
298
  });
298
299
  }
299
300
  return (await file.text()).trimEnd().split('\n').slice(-lines);
@@ -348,10 +349,10 @@ export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpSer
348
349
  const lazy = lazyServices(input);
349
350
  const ui = uiCapabilities({ root: input.root, env: input.env });
350
351
  const introspection = frameworkIntrospection({
351
- routes: () => describeRoutes(),
352
+ routes: () => describePages(),
352
353
  policies: () => policyFacts(),
353
354
  });
354
- const server = createDevServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
355
+ const server = devMcpServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
355
356
  const caller = localCaller();
356
357
  return {
357
358
  server,
@@ -11,9 +11,10 @@
11
11
  import { join } from 'node:path';
12
12
  import type { UiInspectInput, UiInspectResult, UiInspectSelector } from '@ultimat3/mcp';
13
13
  import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
14
+ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
14
15
  import type { AxNode, ShotDriver, ShotPage } from './browser-launcher-port';
15
16
  import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
16
- import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
17
+ import { runShot, SHOT_DIR, shotSlug } from './cmd-shot';
17
18
  import type { ShotServer } from './shot-server';
18
19
  import type { ShotVerdict } from './shot-verdict';
19
20
  import type { InspectProbe } from './ui-inspect-probe';
@@ -144,7 +145,7 @@ export async function inspectRoute(
144
145
  outDir,
145
146
  driver,
146
147
  boot: deps.boot,
147
- settleMs: DEFAULT_SETTLE_MS,
148
+ settleMs: IDLE_HYDRATE_TIMEOUT_MS,
148
149
  timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
149
150
  fullPage: true,
150
151
  colorScheme: input.colorScheme,
@@ -14,9 +14,10 @@ import { join } from 'node:path';
14
14
  import { isUltimateError, toUltimateError, UltimateError } from '@ultimat3/core';
15
15
  import type { UiInteractInput, UiInteractResult, UiInteractStepResult } from '@ultimat3/mcp';
16
16
  import { UI_INTERACT_LIMITS } from '@ultimat3/mcp';
17
+ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
17
18
  import type { ShotPage } from './browser-launcher-port';
18
19
  import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
19
- import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
20
+ import { runShot, SHOT_DIR, shotSlug } from './cmd-shot';
20
21
  import type { InspectDeps, Seen } from './mcp-ui-inspect';
21
22
  import { readInspect, selectorsOf } from './mcp-ui-inspect';
22
23
  import { SETTLE_POLL_MS } from './shot-settle';
@@ -265,7 +266,7 @@ export async function interactRoute(
265
266
  outDir,
266
267
  driver,
267
268
  boot: deps.boot,
268
- settleMs: DEFAULT_SETTLE_MS,
269
+ settleMs: IDLE_HYDRATE_TIMEOUT_MS,
269
270
  timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
270
271
  fullPage: input.fullPage,
271
272
  colorScheme: input.colorScheme,
package/src/mcp-ui.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  // why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
12
12
  import { join } from 'node:path';
13
- import { UltimateError } from '@ultimat3/core';
13
+ import { finiteCount, UltimateError } from '@ultimat3/core';
14
14
  import type {
15
15
  UiDiffInput,
16
16
  UiDiffResult,
@@ -23,11 +23,11 @@ import type {
23
23
  UiShotInput,
24
24
  UiShotResult,
25
25
  } from '@ultimat3/mcp';
26
- import { describeRoutes } from '@ultimat3/render';
26
+ import { describePages, IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
27
27
  import { appBrowser } from './browser-launcher';
28
28
  import type { ShotDriver } from './browser-launcher-port';
29
29
  import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
30
- import { DEFAULT_SETTLE_MS, readRoute, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
30
+ import { readRoute, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
31
31
  import { islandShot } from './cmd-shot-island';
32
32
  import { islandVerdictJson } from './island-verdict';
33
33
  import { diffShots } from './mcp-ui-diff';
@@ -95,7 +95,7 @@ export interface UiHostInput {
95
95
  readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ShotDriver>) | undefined;
96
96
  /** Injected by a test: the route table, in place of the registry. */
97
97
  readonly routes?: (() => readonly DeclaredRoute[]) | undefined;
98
- /** Injected by a test: the settle wait, in place of `DEFAULT_SETTLE_MS` (a real sleep per call). */
98
+ /** Injected by a test: the settle wait, in place of `IDLE_HYDRATE_TIMEOUT_MS` (a real sleep per call). */
99
99
  readonly settleMs?: number | undefined;
100
100
  }
101
101
 
@@ -127,7 +127,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
127
127
  // The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
128
128
  // provider's CDP URL, or the launcher's own discovery.
129
129
  const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
130
- const routes = input.routes ?? describeRoutes;
130
+ const routes = input.routes ?? describePages;
131
131
  // One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
132
132
  const driverFor = async (viewport: UiShotInput['viewport']): Promise<ShotDriver> => {
133
133
  if (input.driver !== undefined) return input.driver(viewport);
@@ -165,7 +165,8 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
165
165
  outDir,
166
166
  driver,
167
167
  boot,
168
- settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
168
+ // An agent's argument: a NaN or negative settle would wait forever or not at all.
169
+ settleMs: finiteCount('ui.shot', 'settleMs', input.settleMs ?? IDLE_HYDRATE_TIMEOUT_MS),
169
170
  timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
170
171
  fullPage: shot.fullPage,
171
172
  colorScheme: shot.colorScheme,
@@ -199,7 +200,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
199
200
  root,
200
201
  island: island.island,
201
202
  ...(island.state === undefined ? {} : { state: island.state }),
202
- settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
203
+ settleMs: finiteCount('ui.island', 'settleMs', input.settleMs ?? IDLE_HYDRATE_TIMEOUT_MS),
203
204
  timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
204
205
  ...(executablePath === undefined ? {} : { executablePath }),
205
206
  ...(cdpUrl === undefined ? {} : { cdpUrl }),
@@ -7,13 +7,8 @@
7
7
  // `withInProcessFetch`, because no server is listening during a build (plan 101 slice 11 m).
8
8
 
9
9
  import type { Actor, Ctx } from '@ultimat3/core';
10
- import { measurementActor, runWithContext, withInProcessFetch } from '@ultimat3/core';
11
- import {
12
- createPipeline,
13
- createRequestContext,
14
- createRouter,
15
- defineHttpConfig,
16
- } from '@ultimat3/http';
10
+ import { actorLabel, measurementActor, runWithContext, withInProcessFetch } from '@ultimat3/core';
11
+ import { defineHttpConfig, httpPipeline, httpRouter, requestContext } from '@ultimat3/http';
17
12
  import { apiRoutes } from './api-routes';
18
13
 
19
14
  /** One build's measurement scope: its actor, and a runner that renders inside it. */
@@ -35,15 +30,15 @@ export async function measureScope(input: {
35
30
  // No rate limit: every request here is this build's own render, and a limiter would need a
36
31
  // deployment scope a build does not have (`X_RATE_LIMIT_SCOPE_UNSET`).
37
32
  const config = defineHttpConfig({ buildId: input.buildId, rateLimit: { enabled: false } });
38
- const ctx = createRequestContext({
33
+ const ctx = requestContext({
39
34
  url: new URL(input.origin),
40
35
  method: 'GET',
41
36
  role: 'web',
42
37
  config,
43
38
  });
44
39
  ctx.actor = actor;
45
- const pipeline = createPipeline({
46
- table: createRouter(apiRoutes()),
40
+ const pipeline = httpPipeline({
41
+ table: httpRouter(apiRoutes()),
47
42
  config,
48
43
  hooks: { authenticate: () => actor },
49
44
  });
@@ -72,3 +67,13 @@ export async function withAppUrl<T>(origin: string, fn: () => Promise<T>): Promi
72
67
  else process.env['APP_URL'] = before;
73
68
  }
74
69
  }
70
+
71
+ /**
72
+ * The measurement actor as a refusal names it: core's `actorLabel` (kind, id, tenant) — the one
73
+ * actor format — plus the roles, which a role-gated page asks of it and `actorLabel` omits (#675).
74
+ * Never `facts`: this lands in a report and a gate finding.
75
+ */
76
+ export function measurementActorLabel(actor: Actor): string {
77
+ const roles = actor.roles.length === 0 ? 'none' : actor.roles.join(', ');
78
+ return `${actorLabel(actor)} (roles: ${roles})`;
79
+ }
package/src/messages.ts CHANGED
@@ -86,6 +86,9 @@ const CATALOG = {
86
86
  'cli.dev.mail.refused': 'mail=refused({detail})',
87
87
  'cli.dev.hmr': 'reloaded {file} in {ms}ms',
88
88
  'cli.dev.restart': 'restarting: {reason} — a module that defines a primitive holds the old code',
89
+ // The restart's wait, said while it waits: a save never cancels a running job (issue #677).
90
+ 'cli.dev.restartWaiting':
91
+ 'restart waits for {count} running job(s) to finish — no new job is claimed; Ctrl-C stops now (a job still running is cut off at the drain deadline and requeued)',
89
92
  // A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
90
93
  // reader knows why the boot paused, and never a finding.
91
94
  'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
@@ -143,9 +146,6 @@ const CATALOG = {
143
146
  'cli.jobs.deadLetters': '{count} dead letter(s):',
144
147
  'cli.jobs.depth':
145
148
  '{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
146
- 'cli.jobs.drained': 'drained {count} job(s) from {from} to {to}',
147
- 'cli.jobs.drainedPartial':
148
- 'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
149
149
  'cli.jobs.listed': '{count} job(s)',
150
150
  'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
151
151
  'cli.jobs.noError': 'no error recorded',
@@ -156,24 +156,30 @@ const CATALOG = {
156
156
  'cli.jobs.resumed': 'queue {queue} resumed',
157
157
  'cli.jobs.retried': 'job {id} re-queued — {state}',
158
158
  'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
159
- 'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
160
159
  'cli.manifest.blocked': 'manifest not written — {count} module(s) did not load',
161
160
  'cli.manifest.fresh': 'manifest is fresh',
162
161
  'cli.manifest.stale': 'manifest is stale',
163
162
  'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
164
163
  'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
165
164
  'cli.mcp.scopes': ' scopes {scopes}',
166
- // `bin/setup` and nothing else: the scaffold ships it, `README.md` and `bin/check` both name it,
167
- // and it is the only spelling that is right on a fresh clone — it installs, writes
168
- // `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no migration, so the
169
- // drift step is red until it has), migrates and seeds. The four-command line this replaced named
170
- // `x dev` off a tree where nothing had installed the CLI yet, and skipped the seed entirely.
171
- // `bin/dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
172
- // nowhere else, so the bare `x` this line printed is not on PATH in the shell it is pasted into
173
- // (proved with `env -i PATH=… command -v x`). The scaffold's own `bin/` wrappers are the form
174
- // that works from a fresh clone, and `bin/setup` already uses `bunx x` internally for this
175
- // reason.
176
- 'cli.new.done': 'created {name} — next: cd {name} && bin/setup && bin/dev',
165
+ // `bun run setup` and nothing else: the scaffold ships it (`bin/setup.ts`), `README.md` and
166
+ // `bun run check` both name it, and it is the only spelling that is right on a fresh clone — it
167
+ // installs, writes `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no
168
+ // migration, so the drift step is red until it has), migrates and seeds. The four-command line
169
+ // this replaced named `x dev` off a tree where nothing had installed the CLI yet, and skipped the
170
+ // seed entirely.
171
+ // `bun run dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
172
+ // nowhere else, so a bare `x` is not on PATH in the shell it is pasted into (proved with
173
+ // `env -i PATH=… command -v x`); `bun run` puts that directory on PATH for the script. And
174
+ // `bun run`, never a `bin/` path: Bun runs the line identically in PowerShell, cmd and bash.
175
+ // `{cd}` is the resolved target as the caller's shell reaches it — `--dir` included, relative to
176
+ // the cwd when under it, shell-quoted (`cmd-new.ts`'s `cdTarget`). `cd {name}` entered the wrong
177
+ // directory, or none, whenever `--dir` put the app somewhere else.
178
+ 'cli.new.done': 'created {name} — next: cd {cd} && bun run setup && bun run dev',
179
+ // `--dry-run`'s own summary: nothing landed, so "created" was a claim an agent branching on the
180
+ // summary would act on. `{dir}` is the resolved target, `--dir` included.
181
+ 'cli.new.dryRun':
182
+ 'would create {name} in {dir} — nothing written; after a real run: cd {cd} && bun run setup && bun run dev',
177
183
  // The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
178
184
  // one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
179
185
  // command is a broken one — the same split `Finding.fix` already makes.
@@ -235,6 +241,10 @@ const CATALOG = {
235
241
  'cli.shot.pageError': ' threw {message} {at}',
236
242
  'cli.shot.picture': ' picture {path}',
237
243
  'cli.shot.verdict': ' verdict {path}',
244
+ // `--viewport`: one route at several sizes. The summary is ONE size's verdict, so it says which
245
+ // size; each size line leads with its size and the per-size detail lines sit indented beneath.
246
+ 'cli.shot.viewport.summary': '{size}: {summary}',
247
+ 'cli.shot.viewport.line': '{size} {summary}',
238
248
  // `--island`. A component capture reports per STATE, so the summary counts pictures and the
239
249
  // lines name one state each; nothing here restates a fact `--json` does not carry.
240
250
  'cli.shot.island.ok':
@@ -304,7 +314,7 @@ const CATALOG = {
304
314
  // A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
305
315
  // ones are named, because "which suite has nothing to run here?" is the question a green gate
306
316
  // over a missing suite has to answer on its own line. Whole sentences per case rather than a
307
- // clause the caller glues on, the same shape `cli.jobs.drained`/`drainedPartial` already uses.
317
+ // clause the caller glues on: a catalog clause cannot know the sentence it lands in.
308
318
  'cli.verify.passSkipped':
309
319
  '{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
310
320
  'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
@@ -315,6 +325,7 @@ const CATALOG = {
315
325
  // tests ran. Each test's own name carries the reason it skipped, and `bun test` prints only the
316
326
  // counts — so the line says how many and `x test <type>` prints the names.
317
327
  'cli.verify.allSkipped': 'found {skipped} test(s) and every one skipped itself',
328
+ 'cli.verify.warnings': '{count} warning(s) — --verbose or --json lists them',
318
329
  // The other shape the same counts can take: a suite that reported no test at all, skipped ones
319
330
  // included. Its own line and not `allSkipped` with a zero, because the repair differs — there is
320
331
  // no skipped test to read a reason off, so the files the step selected hold no test.
@@ -18,6 +18,7 @@ import {
18
18
  UltimateError,
19
19
  } from '@ultimat3/core';
20
20
  import { neighbouringPort } from './flag-number';
21
+ import { quoteArg } from './shell-quote';
21
22
 
22
23
  /**
23
24
  * A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
@@ -48,7 +49,7 @@ export class MetricsPortInUseError extends UltimateError {
48
49
  super({
49
50
  code: 'X_PORT_IN_USE',
50
51
  cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
51
- fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
52
+ fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(input.port)))} x dev --json`,
52
53
  });
53
54
  }
54
55
  }
@@ -114,7 +115,7 @@ export function bindScrapePort<T>(
114
115
  logger.warn('ultimate metrics port taken', {
115
116
  port,
116
117
  cause: `the metrics port ${String(port)} is already bound — another x dev, or a Prometheus — so this process listens on a free one`,
117
- fix: `METRICS_PORT=${String(neighbouringPort(port))} x dev --json`,
118
+ fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(port)))} x dev --json`,
118
119
  });
119
120
  return bind(0);
120
121
  }
@@ -24,6 +24,6 @@ export function migrationExtensions(migrations: readonly Migration[]): readonly
24
24
  return [...names].sort();
25
25
  }
26
26
 
27
- /** The same list for an app on disk — what an embedded boot hands `createPgliteClient`. */
27
+ /** The same list for an app on disk — what an embedded boot hands `pgliteClient`. */
28
28
  export const appExtensions = async (root: string): Promise<readonly string[]> =>
29
29
  migrationExtensions(await readMigrations(root));
@@ -36,7 +36,7 @@ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
36
36
  * merge into process-global state, so stopping the timer and dropping the drain hooks left the
37
37
  * first boot's exporter configured: a second `serveApp` in the same process exported its spans
38
38
  * into a released exporter — queued against a collector nothing will flush to, on a timer nothing
39
- * clears. `cmd-dev.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
39
+ * clears. `dev-boot.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
40
40
  * never unconditionally: `x dev` configures a trace RECORDER before calling this, and a boot that
41
41
  * installed no exporter must not uninstall one it never owned.
42
42
  */
@@ -52,7 +52,7 @@ export function startOtlpExport(env: Env = process.env): () => void {
52
52
  const exporter = otlpSpanExporter({ endpoint: traces });
53
53
  configureTelemetry({ exporter });
54
54
  // Pushed first, so the reversed run below applies it LAST — after the drain hook is dropped,
55
- // the same order `cmd-dev.ts` releases the recorder in.
55
+ // the same order `dev-boot.ts` releases the recorder in.
56
56
  releases.push(() => configureTelemetry({ exporter: noopExporter }));
57
57
  releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
58
58
  logger.info('ultimate otlp traces', { endpoint: traces });
package/src/output.ts CHANGED
@@ -50,6 +50,12 @@ export interface StepResult {
50
50
  * coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
51
51
  */
52
52
  readonly output?: string;
53
+ /**
54
+ * Advice that does not fail the step — a file inside `filesize`'s band below the ceiling. Unlike
55
+ * `output`, carried by both renderers on a PASS too: advice a green run hides reaches nobody
56
+ * until it has become the failure it warns about. One line of text per warning.
57
+ */
58
+ readonly warnings?: readonly string[];
53
59
  /** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
54
60
  readonly workers?: number;
55
61
  /** Why the width is what it is — `4 workers (budget 4.0 GB)`. */
@@ -230,6 +236,14 @@ export function renderHuman(result: CommandResult, verbose = false): string {
230
236
  ` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}${why(step)}`,
231
237
  );
232
238
  for (const finding of step.findings) out.push(renderFinding(finding, ' '));
239
+ // Escaped like every other line text reaches fd 1 through: a warning names a file path. A
240
+ // green terminal run shows the count, not the list: `filesize`'s band alone is ~150 lines,
241
+ // and a wall of advice on every run is read by nobody. `--json` always carries the list.
242
+ const warnings = step.warnings ?? [];
243
+ if (verbose) for (const warning of warnings) out.push(` ! ${singleLine(warning)}`);
244
+ else if (warnings.length > 0) {
245
+ out.push(` ! ${msg('cli.verify.warnings', { count: warnings.length })}`);
246
+ }
233
247
  // NOT escaped, and that is the one exception: `output` is this process's own captured
234
248
  // subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
235
249
  // already split on its real newlines rather than carrying them inside one entry.
@@ -256,6 +270,10 @@ export function renderJson(result: CommandResult, verbose = false): string {
256
270
  durationMs: step.durationMs,
257
271
  skipped: step.skipped === true,
258
272
  findings: step.findings,
273
+ // Never gated on `ok` or `--verbose`, unlike `output` below: see `StepResult.warnings`.
274
+ ...(step.warnings === undefined || step.warnings.length === 0
275
+ ? {}
276
+ : { warnings: [...step.warnings] }),
259
277
  ...(step.workers === undefined ? {} : { workers: step.workers }),
260
278
  ...(step.widthReason === undefined ? {} : { widthReason: step.widthReason }),
261
279
  ...(step.shard === undefined