@ultimat3/cli 22.14.0 → 23.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 (174) hide show
  1. package/CLAUDE.md +25 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +35 -3
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
@@ -1,21 +1,22 @@
1
- // The island chunks a container serves, built ONCE by `x build --target docker` into
2
- // `.x/islands/` and read back at boot. Without it every role's boot ran `Bun.build` — Babel, the
3
- // JSX transform, a minifier — for every island, in the image, on every start, and a chunk whose
4
- // URL is a hash of its sources was rebuilt from sources that could not have changed.
1
+ // The island chunks a container serves, built ONCE inside the image build by
2
+ // `x build --target prebuilt` and read back at boot. Without it every web pod's boot ran
3
+ // `Bun.build` — Babel, the JSX transform, a minifier — for every island, on every start, and a
4
+ // chunk whose URL is a hash of its sources was rebuilt from sources that could not have changed.
5
5
  //
6
6
  // Load-and-VERIFY, never load-and-trust: a store written by another framework or Bun version, one
7
7
  // that names a different set of islands than the app has, or a chunk whose bytes do not hash to
8
- // what the index recorded is refused, and the boot builds instead — with the reason logged.
8
+ // what the index recorded is refused, and the boot builds instead — and says so.
9
9
 
10
10
  import { join, relative, sep } from 'node:path'; // why: Bun ships no path-join primitive.
11
- import { frameworkVersion, logger } from '@ultimat3/core';
11
+ import { frameworkVersion } from '@ultimat3/core';
12
12
  import { contentHash, loadStylesheet } from '@ultimat3/render/server';
13
13
  import type { IslandBundle, IslandChunk, SharedChunk } from './island-bundle';
14
14
  import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
15
15
  import { islandStylesheets } from './island-styles';
16
+ import { PREBUILT_ISLANDS_DIR } from './serve-prebuilt-paths';
16
17
 
17
- /** App-root-relative: `COPY . .` carries it into the image with the rest of `.x/`. */
18
- export const ISLAND_STORE_DIR = '.x/islands';
18
+ /** App-root-relative, inside the image's prebuilt store (`serve-prebuilt-paths.ts` says why there). */
19
+ export const ISLAND_STORE_DIR = PREBUILT_ISLANDS_DIR;
19
20
  const INDEX = 'index.json';
20
21
 
21
22
  interface StoredChunk {
@@ -200,16 +201,23 @@ export async function readIslandStore(root: string): Promise<StoreRead> {
200
201
  return { bundle: islandBundle(chunks, shared) };
201
202
  }
202
203
 
204
+ /** The container's islands, and — when this boot had to build them — why and how many. */
205
+ export interface LoadedIslands {
206
+ readonly bundle: IslandBundle;
207
+ readonly built?: { readonly chunks: number; readonly reason: string };
208
+ }
209
+
203
210
  /**
204
211
  * The container's islands: the verified store, or a build when there is none to trust. A build is
205
- * correct and only slower, so a stale store is a warning with its reason, never a refusal.
212
+ * correct and only slower, so a stale store is never a refusal — the caller is handed the reason,
213
+ * and `serve-boot.ts` logs it once with everything else the boot built (`X_IMAGE_NOT_PREBUILT`).
206
214
  */
207
- export async function loadOrBuildIslands(root: string): Promise<IslandBundle> {
215
+ export async function loadOrBuildIslands(root: string): Promise<LoadedIslands> {
208
216
  const read = await readIslandStore(root);
209
- if (read.bundle !== undefined) return read.bundle;
210
- logger.warn('islands built at boot', {
211
- reason: read.stale,
212
- fix: 'x build --target docker',
213
- });
214
- return buildIslands(root);
217
+ if (read.bundle !== undefined) return { bundle: read.bundle };
218
+ const bundle = await buildIslands(root);
219
+ return {
220
+ bundle,
221
+ built: { chunks: bundle.chunks.length + bundle.shared.length, reason: read.stale },
222
+ };
215
223
  }
package/src/jobs-drain.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  // that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
4
4
 
5
5
  import { uuid } from '@ultimat3/core';
6
- import type { JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
7
- import { inspectJobList } from '@ultimat3/jobs';
6
+ import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
7
+ import { claimOf, inspectJobList } from '@ultimat3/jobs';
8
8
  import type { Finding } from './output';
9
9
  import { findingFrom } from './output';
10
10
 
@@ -57,7 +57,7 @@ export interface DrainOutcome {
57
57
  function leaseCandidates(
58
58
  source: JobDriver,
59
59
  candidates: readonly JobRecord[],
60
- ): Promise<readonly JobRecord[]> {
60
+ ): Promise<readonly ClaimedJob[]> {
61
61
  if (candidates.length === 0) return Promise.resolve([]);
62
62
  return source.claim({
63
63
  queues: [...new Set(candidates.map((record) => record.queue))],
@@ -82,9 +82,14 @@ async function copySteps(source: JobDriver, target: JobDriver, runId: string): P
82
82
  * `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
83
83
  * `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
84
84
  */
85
- async function releaseLease(source: JobDriver, id: string): Promise<void> {
85
+ async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
86
86
  try {
87
- await source.nack(id, { delayMs: 0, countsAsAttempt: false });
87
+ // Fenced on the drain's own claim, like every settle: a lease that lapsed is not handed back.
88
+ await source.nack(leased.id, {
89
+ ...claimOf(leased),
90
+ delayMs: 0,
91
+ countsAsAttempt: false,
92
+ });
88
93
  } catch {
89
94
  // The lease expires on its own. Masking the transfer's real error with this one helps nobody.
90
95
  }
@@ -141,14 +146,16 @@ export async function drainJobs(
141
146
  // Only now is the ack the drain's to make: the lease proves no source worker holds this
142
147
  // job, and the target already has both the row and its steps. A crash between the two
143
148
  // leaves the job live on both drivers, where `idempotencyKey` dedupes it.
144
- await source.ack(record.id);
149
+ // `counted: false`: the row is finished HERE because it left, and a job that was moved is
150
+ // not a job that completed — it must add nothing to the source's `done` history.
151
+ await source.ack(record.id, { ...claimOf(record), counted: false });
145
152
  // Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
146
153
  // attempt higher), a state nothing on either driver is in once this returns.
147
154
  moved.push(found.get(record.id) ?? record);
148
155
  } catch (error) {
149
156
  // A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
150
157
  // not: the job is already live there, and releasing it would race the target's worker.
151
- if (!enqueued) await releaseLease(source, record.id);
158
+ if (!enqueued) await releaseLease(source, record);
152
159
  failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
153
160
  }
154
161
  }
package/src/jobs-json.ts CHANGED
@@ -7,9 +7,11 @@ import type {
7
7
  DeadLetterEntry,
8
8
  JobRecord,
9
9
  JobTrace,
10
+ PausedName,
10
11
  QueueDepthReport,
11
12
  QueueStats,
12
13
  StepTrace,
14
+ WorkerRecord,
13
15
  } from '@ultimat3/jobs';
14
16
  import type { DrainFailure, DrainSkip } from './jobs-drain';
15
17
  import type { JsonValue } from './output';
@@ -59,15 +61,61 @@ export function jobTraceToJson(trace: JobTrace): JsonValue {
59
61
  runId: trace.runId,
60
62
  runAt: trace.runAt,
61
63
  lastError: trace.lastError,
64
+ // The failure's stack, bounded by the queue. `null` when the thrown value carried none.
65
+ stack: trace.stack,
66
+ // The payload with every key the app declared secret already replaced (`redactInput`), so it
67
+ // is the queue's promise that this is safe to print — never this file's.
68
+ input: toJson(trace.input),
69
+ progress:
70
+ trace.progress === null
71
+ ? null
72
+ : {
73
+ done: trace.progress.done,
74
+ total: trace.progress.total,
75
+ note: trace.progress.note ?? null,
76
+ at: trace.progress.at,
77
+ },
62
78
  tenantId: trace.tenantId,
63
79
  steps: trace.steps.map(stepTraceToJson),
64
80
  retryDelaysMs: trace.retryDelaysMs.map((ms) => ms),
81
+ // `concurrency.key(input)` for a keyed job, `null` for every other: which lease this run waits on.
82
+ concurrencyKey: trace.concurrencyKey,
65
83
  // `null` for every job that is not a `backfill()` pass. Dropped, `x jobs show <id> --json`
66
84
  // would answer "how far has it got" with silence for the one job kind that can say.
67
85
  backfill: trace.backfill === null ? null : backfillToJson(trace.backfill),
68
86
  };
69
87
  }
70
88
 
89
+ /**
90
+ * An app payload as plain JSON. A round trip through the serialiser is the proof: whatever a
91
+ * payload held that JSON cannot carry — a bigint, a function, a cycle — degrades to `null` rather
92
+ * than failing the command that was asked to show it.
93
+ */
94
+ function toJson(value: unknown): JsonValue {
95
+ try {
96
+ const text: string | undefined = JSON.stringify(value);
97
+ return text === undefined ? null : (JSON.parse(text) as JsonValue);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export function pausedToJson(paused: PausedName): JsonValue {
104
+ return { name: paused.name, pausedAt: paused.pausedAt };
105
+ }
106
+
107
+ export function workerToJson(worker: WorkerRecord): JsonValue {
108
+ return {
109
+ id: worker.id,
110
+ host: worker.host,
111
+ startedAt: worker.startedAt,
112
+ heartbeatAt: worker.heartbeatAt,
113
+ queues: [...worker.queues],
114
+ concurrency: worker.concurrency,
115
+ inFlight: [...worker.inFlight],
116
+ };
117
+ }
118
+
71
119
  /**
72
120
  * `input` is deliberately excluded: it is `unknown` at the driver boundary (an app-defined
73
121
  * payload, not something this package can prove is JSON-safe), and `JobTrace` already sets the
@@ -100,6 +148,7 @@ function queueStatsToJson(stats: QueueStats): JsonValue {
100
148
  delayed: stats.delayed,
101
149
  running: stats.running,
102
150
  suspended: stats.suspended,
151
+ failed: stats.failed,
103
152
  dead: stats.dead,
104
153
  oldestReadyMs: stats.oldestReadyMs,
105
154
  };
@@ -114,6 +163,7 @@ export function depthToJson(depth: QueueDepthReport): JsonValue {
114
163
  delayed: depth.totals.delayed,
115
164
  running: depth.totals.running,
116
165
  suspended: depth.totals.suspended,
166
+ failed: depth.totals.failed,
117
167
  dead: depth.totals.dead,
118
168
  },
119
169
  oldestReadyMs: depth.oldestReadyMs,
@@ -72,6 +72,8 @@ export interface JobsListFilter {
72
72
  readonly state?: string | undefined;
73
73
  readonly name?: string | undefined;
74
74
  readonly limit?: string | undefined;
75
+ /** `jobCursor(lastRow)` of the page before — the keyset the next page seeks from. */
76
+ readonly after?: string | undefined;
75
77
  }
76
78
 
77
79
  export interface JobsListResult {
@@ -103,6 +105,7 @@ export async function listJobs(
103
105
  ...(filter.name === undefined ? {} : { name: filter.name }),
104
106
  ...(state === undefined ? {} : { state }),
105
107
  ...(limit === undefined ? {} : { limit }),
108
+ ...(filter.after === undefined ? {} : { after: filter.after }),
106
109
  };
107
110
  const [depth, rows, deadLetters, backfills] = await Promise.all([
108
111
  inspectQueues(driver),
package/src/json-merge.ts CHANGED
@@ -38,3 +38,49 @@ export function mergeJsonDeep(
38
38
  }
39
39
  return { merged, gained };
40
40
  }
41
+
42
+ /** Code units, never `localeCompare`: the bytes must not move with the machine's ICU. */
43
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
44
+
45
+ const isSorted = (keys: readonly string[]): boolean =>
46
+ keys.every((key, index) => index === 0 || byCodeUnit(keys[index - 1] ?? '', key) <= 0);
47
+
48
+ /** Every key of every level, sorted — the order a generator's own fresh output is written in. */
49
+ export function sortJsonDeep(value: unknown): unknown {
50
+ if (Array.isArray(value)) return value.map(sortJsonDeep);
51
+ if (!isObject(value)) return value;
52
+ return Object.fromEntries(
53
+ Object.keys(value)
54
+ .sort(byCodeUnit)
55
+ .map((key) => [key, sortJsonDeep(value[key])]),
56
+ );
57
+ }
58
+
59
+ /**
60
+ * `mergeJsonDeep`, for a file that is ALREADY ON DISK: the same union, written back in the order
61
+ * the file is in. A catalog a team keeps grouped by screen was re-sorted wholesale by a merge
62
+ * that sorted every level — 424 changed lines for 22 added keys — which buries the keys a run
63
+ * added and turns every generator run into a merge conflict.
64
+ *
65
+ * Per level: a held key stays where it is. A new key takes its SORTED place when that level is
66
+ * sorted already — which is every level a generator wrote, so two runs in either order still write
67
+ * the same bytes — and goes to the END, in sorted order among the new ones, when it is not. A
68
+ * wholly new subtree is the generator's own and arrives sorted.
69
+ */
70
+ export function mergeJsonInPlace(held: JsonObject, incoming: JsonObject): JsonObject {
71
+ const heldKeys = Object.keys(held);
72
+ const added = Object.keys(incoming)
73
+ .filter((key) => !Object.hasOwn(held, key))
74
+ .sort(byCodeUnit);
75
+ const order = isSorted(heldKeys)
76
+ ? [...heldKeys, ...added].sort(byCodeUnit)
77
+ : [...heldKeys, ...added];
78
+ return Object.fromEntries(
79
+ order.map((key) => {
80
+ if (!Object.hasOwn(held, key)) return [key, sortJsonDeep(incoming[key])];
81
+ const current = held[key];
82
+ const next = incoming[key];
83
+ return [key, isObject(current) && isObject(next) ? mergeJsonInPlace(current, next) : current];
84
+ }),
85
+ );
86
+ }
package/src/mcp-errors.ts CHANGED
@@ -43,6 +43,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
43
43
  X_VERIFY_MERGE_INCOMPLETE:
44
44
  'x verify merge parts/*.json --json # after every CI job uploaded its part',
45
45
  X_VERIFY_MERGE_INPUT: 'x verify --only unit --json > part.json # one document per part',
46
+ X_VERIFY_STEP_TIMEOUT:
47
+ 'x verify --only unit --json # reproduce the step alone; one that needs longer states it under "stepTimeoutMs" in x.verify.json',
48
+ X_COVERAGE_BELOW_FLOOR:
49
+ 'x verify --only unit --json # names the ten files losing the most lines; cover the worst with a test beside it',
50
+ X_COVERAGE_FLOOR_UNSTATED:
51
+ 'x verify --only unit --json # the finding carries the measured numbers and the "coverage" line to add to x.verify.json',
52
+ X_COVERAGE_FLOOR_STALE:
53
+ 'x verify --only unit --json # the finding carries the measured numbers; raise "coverage" in x.verify.json to them',
46
54
  X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
47
55
  X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
48
56
  X_GENERATE_JSON_INVALID:
@@ -233,18 +241,30 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
233
241
  X_GUARD_FAILED: 'x verify --json # the cause carries the throw the guard raised, verbatim',
234
242
  X_GUARD_FINDING_INVALID:
235
243
  'x verify --json # then give the finding an X_ code, a cause and a fix naming a command',
244
+ // The finding's own `fix:` is this command plus the edit — the line, and what replaces the call.
245
+ X_BROWSER_TRANSPORT_BYPASS:
246
+ 'x verify --only boundaries --json # each finding names the line and the transport to use instead',
247
+ X_BROWSER_SERVER_BARREL:
248
+ 'x verify --only boundaries --json # each finding names the import and the browser entry to import instead',
236
249
  // `EDITOR=` inline rather than `export`: the variable is only needed for the one invocation, and
237
250
  // an agent copying this line gets a working command instead of a shell it has to keep.
238
251
  X_SECRETS_EDITOR_MISSING: 'EDITOR=nano x secrets edit',
239
252
  X_SECRETS_EDIT_FAILED: 'x secrets show --json # then re-open the buffer: x secrets edit',
253
+ X_SEAL_RESEAL_PENDING:
254
+ 'x doctor --json # names the sealed columns and the retired key; re-seal with a backfill(), then: x secrets rotate --drop <keyId>',
240
255
  // Render's code, thrown here by the bundler half: the cause names the specifier and the file it
241
256
  // resolved to, and `x g island` is what puts that file where the page already says it is.
242
257
  X_ISLAND_INVALID: 'x routes --json # the cause names the src; then: x g island <name>',
243
258
  // The four slice-11 codes: generated code that passed the gate and failed at runtime.
259
+ // The role map's file is the app's: the finding's `at` names it, so this line never guesses one.
244
260
  X_PERMISSION_UNGRANTED:
245
- 'x verify --only policy --json # then add the permission to a role in apps/web/shared/roles.ts',
261
+ 'x verify --only policy --json # then add the permission to a role in the file the finding names (at)',
246
262
  X_JOB_UNREGISTERED:
247
263
  'x manifest --json # after listing the module under jobs: [...] in apps/web/api/index.ts',
264
+ // The boot's twin of the line above: the worker or scheduler loaded only what the API index
265
+ // imports, and the manifest names a job or task that load did not register.
266
+ X_ROLE_LOAD_INCOMPLETE:
267
+ 'x manifest --json # after importing the module the cause names from apps/web/api/index.ts',
248
268
  X_FEATURE_UNKNOWN:
249
269
  'x entities --json # then x g resource <feature> for the one the cause names',
250
270
  X_BUDGET_PARAMS_UNDECLARED:
@@ -256,6 +276,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
256
276
  X_DEV_RESTART_REQUIRED:
257
277
  'x dev --json # a fresh process; the supervised x dev restarts itself on such a save',
258
278
  X_DEV_ROOT_GONE: 'x dev --json # from the app root, once it exists again',
279
+ X_DB_HANDLE_UNREGISTERED:
280
+ 'x g entity widget --dry-run --json # lists the files; the finding names the two lines to add to packages/db/src/client.ts',
281
+ X_ADMIN_RESOURCE_UNWIRED:
282
+ 'x g resource widget --admin --dry-run --json # lists the files; the finding names the two lines to add to apps/admin/app/admin/admin.ts',
283
+ X_IMAGE_NOT_PREBUILT:
284
+ 'edit docker/Dockerfile: add RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt after COPY . . in the runtime stage, then rebuild the image',
285
+ X_ADMIN_UNSCANNED:
286
+ 'git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts # then repoint its relative imports and x verify --only manifest --json',
259
287
  };
260
288
 
261
289
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/messages.ts CHANGED
@@ -50,12 +50,21 @@ const CATALOG = {
50
50
  'cli.db.branch.unknown': '-',
51
51
  'cli.db.gen.failed': 'migration not generated',
52
52
  'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
53
+ 'cli.db.gen.blocked':
54
+ 'nothing was compared — the app did not load ({count} finding(s)); fix them, then run x db gen again',
53
55
  // A THIRD outcome, and it is neither of the other two: nothing to generate, but the sidecar the
54
56
  // `drift` step reads did move — an edit under `packages/db/src` that implies no DDL. Rendering it
55
57
  // as `written` would name a migration nobody can apply; as `unchanged`, it would hide a file this
56
58
  // command wrote. `GeneratedFiles.outcome` is what `--json` carries the same distinction on.
57
59
  'cli.db.gen.recorded': 'no migration needed — schema hash re-recorded in {file}',
60
+ // The fourth: no DDL and no sidecar moved, but `packages/db/schema/` did — a hand edit repaired,
61
+ // or a framework table that changed under an upgrade. `unchanged` would deny a write the lines
62
+ // below it list, and this command is `X_SCHEMA_DUMP_DRIFT`'s whole `fix:`.
63
+ 'cli.db.gen.dumped': 'no migration needed — schema dump rewritten ({count} file(s))',
58
64
  'cli.db.gen.written': 'migration {id} generated',
65
+ // Exit 1 and a migration on disk at once: the summary carries the half the exit code hides.
66
+ 'cli.db.gen.writtenDumpFailed':
67
+ 'migration {id} generated and written — the schema dump was not; do not generate it again',
59
68
  'cli.db.migrate.applied': 'migrations applied',
60
69
  'cli.db.migrate.failed': 'migration failed',
61
70
  'cli.db.reset.done': 'database reset and migrated',
@@ -88,6 +97,11 @@ const CATALOG = {
88
97
  'cli.deploy.plan': 'containers only: {images} image, roles {roles}',
89
98
  'cli.doctor.clean': 'no findings — environment is shippable',
90
99
  'cli.doctor.findings': '{count} finding(s)',
100
+ 'cli.doctor.guards':
101
+ '{count} shipped guard(s) this app does not hold — each line adds one, then x verify reports what it finds:',
102
+ // `x doctor`'s other listing: what the coverage floor in x.verify.json does not see, and why.
103
+ 'cli.doctor.coverageExclude':
104
+ '{count} path(s) x.verify.json excludes from the coverage floor — each with the reason it gives:',
91
105
  'cli.docs.code': '{code} is an error code — x errors explain answers it',
92
106
  'cli.docs.exports': 'exports: {list}',
93
107
  'cli.docs.installed': 'installed: {list}',
@@ -110,6 +124,8 @@ const CATALOG = {
110
124
  'cli.fix.plan':
111
125
  '{count} boundary violation(s) involve {file} — {edits} edit(s) to make, nothing written',
112
126
  'cli.generate.wrote': 'wrote {count} file(s) for {kind} {name}',
127
+ // The run that declared a table: `{next}` is commands, joined with `&&`, that run as printed.
128
+ 'cli.generate.wroteNext': 'wrote {count} file(s) for {kind} {name} — next: {next}',
113
129
  // A distinct key, not the same sentence with a flag beside it: `--dry-run` reported "wrote 4
114
130
  // file(s)" while `data.dryRun` said nothing had landed, so an agent branching on `summary`
115
131
  // believed the files were on disk.
@@ -131,7 +147,13 @@ const CATALOG = {
131
147
  'cli.jobs.drainedPartial':
132
148
  'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
133
149
  'cli.jobs.listed': '{count} job(s)',
150
+ 'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
134
151
  'cli.jobs.noError': 'no error recorded',
152
+ 'cli.jobs.paused': 'queue {queue} paused — no worker claims from it; enqueues still land',
153
+ 'cli.jobs.pausedQueues': 'paused queue(s): {queues}',
154
+ 'cli.jobs.promoted': 'job {id} promoted — due now',
155
+ 'cli.jobs.removed': 'job {id} removed — it was {state}',
156
+ 'cli.jobs.resumed': 'queue {queue} resumed',
135
157
  'cli.jobs.retried': 'job {id} re-queued — {state}',
136
158
  'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
137
159
  'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
@@ -296,6 +318,12 @@ const CATALOG = {
296
318
  // no skipped test to read a reason off, so the files the step selected hold no test.
297
319
  'cli.verify.ranNothing': 'found no test to run in the file(s) it selected',
298
320
  'cli.verify.merged': 'merged {parts} part(s) — {summary}',
321
+ // The `unit` step's own measurement, in `output`: what the suite covered of the app's whole
322
+ // source tree, beside the floor it was held to. A shard says it measured a slice instead.
323
+ 'cli.verify.coverage':
324
+ 'coverage: {lines}% of lines, {funcs}% of functions over {files} source file(s) — floor {floor}',
325
+ 'cli.verify.coverageDeferred':
326
+ 'coverage: this shard measured its own slice — x verify merge folds every shard and judges the floor',
299
327
  'cli.verify.serial': 'serial',
300
328
  'cli.verify.workers': '{workers} workers',
301
329
  'cli.env.checked': '{count} declared variable(s), all present and valid',
@@ -314,6 +342,11 @@ const CATALOG = {
314
342
  'cli.secrets.set': 'sealed {name} into {path} — {count} secret(s)',
315
343
  'cli.secrets.rotated':
316
344
  'rotated {path} from master key {from} to {to} — {count} secret(s) resealed',
345
+ 'cli.secrets.retired':
346
+ ' master key {kid} is retired, not gone: values it sealed still open — once they are re-sealed, x secrets rotate --drop {kid}',
347
+ 'cli.secrets.dropped':
348
+ 'dropped retired master key {kid} from {path} — {count} retired key(s) still declared',
349
+ 'cli.secrets.ring': 'retired master key(s) that still open sealed values: {kids}',
317
350
  } as const;
318
351
 
319
352
  export type MessageKey = keyof typeof CATALOG;
@@ -1,6 +1,7 @@
1
1
  // Single responsibility: the scrape listener every role opens. `@ultimat3/core` declares the
2
2
  // series and renders the body; this is the one place a process answers `METRICS_PATH` with it, so
3
- // `docker/helm`'s HPAs read a number instead of `<unknown>`.
3
+ // `docker/helm`'s HPAs read a number instead of `<unknown>`. Opening it starts the process's own
4
+ // series (`process_*`): a role that can be scraped reports what it costs.
4
5
 
5
6
  import {
6
7
  finiteCount,
@@ -9,6 +10,7 @@ import {
9
10
  METRICS_PATH,
10
11
  markListening,
11
12
  metricsText,
13
+ startProcessMetrics,
12
14
  stringField,
13
15
  UltimateError,
14
16
  } from '@ultimat3/core';
@@ -64,6 +66,55 @@ export interface MetricsEndpointOptions {
64
66
  readonly port?: number;
65
67
  /** A container must bind every interface; a laptop must not. Same decision as the web role. */
66
68
  readonly hostname?: string;
69
+ /**
70
+ * The `process_info{role}` label: `ROLE` in a container, the roles `x dev` runs joined by `+`.
71
+ * A host that opens the listener itself and names none is labelled `unknown`.
72
+ */
73
+ readonly role?: string;
74
+ /**
75
+ * What a taken port means. `refuse` (the default, and every container's): `X_PORT_IN_USE` —
76
+ * Prometheus is configured against the declared port, so listening anywhere else is a scrape
77
+ * that silently finds nothing. `free`: bind whichever port the kernel hands out and say which —
78
+ * `x dev` with no `METRICS_PORT`, where two apps on one laptop both default to 9090.
79
+ */
80
+ readonly whenTaken?: 'refuse' | 'free';
81
+ }
82
+
83
+ /**
84
+ * Which answer a boot gives. A laptop that named no port takes a free one when 9090 is another
85
+ * app's; a declared `METRICS_PORT` — and every container — is the port a scraper was told, so it
86
+ * refuses rather than listen where nothing will look.
87
+ */
88
+ export const whenMetricsPortTaken = (
89
+ dev: boolean,
90
+ env: Readonly<Record<string, string | undefined>>,
91
+ ): 'refuse' | 'free' => {
92
+ const declared = env['METRICS_PORT'];
93
+ return dev && (declared === undefined || declared.trim().length === 0) ? 'free' : 'refuse';
94
+ };
95
+
96
+ /**
97
+ * The bind and its one retry. `bind` is `Bun.serve` behind a seam: whether a kernel refuses a
98
+ * second bind is the OS's answer, and the rule here is testable without racing a socket for it.
99
+ * Port 0 is never retried — the kernel chose it, so a refusal there is not a collision.
100
+ */
101
+ export function bindScrapePort<T>(
102
+ port: number,
103
+ whenTaken: 'refuse' | 'free',
104
+ bind: (port: number) => T,
105
+ ): T {
106
+ try {
107
+ return bind(port);
108
+ } catch (error) {
109
+ if (!isAddressInUse(error)) throw error;
110
+ if (whenTaken === 'refuse' || port === 0) throw new MetricsPortInUseError({ port });
111
+ logger.warn('ultimate metrics port taken', {
112
+ port,
113
+ cause: `the metrics port ${String(port)} is already bound — another x dev, or a Prometheus — so this process listens on a free one`,
114
+ fix: `METRICS_PORT=${String(neighbouringPort(port))} x dev --json`,
115
+ });
116
+ return bind(0);
117
+ }
67
118
  }
68
119
 
69
120
  export interface MetricsEndpoint {
@@ -86,29 +137,24 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
86
137
  const port = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
87
138
  // `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
88
139
  // second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
89
- // The return type is inferred, keeping `Bun.serve`'s own shape stated once.
90
- function listen() {
91
- try {
92
- return Bun.serve({
93
- port,
94
- hostname: options.hostname ?? 'localhost',
95
- fetch(request: Request): Response {
96
- if (new URL(request.url).pathname !== METRICS_PATH) {
97
- return new Response('not found', { status: 404 });
98
- }
99
- // `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
100
- // steal each other's samples — but a cache would hand the second one a stale window.
101
- return new Response(metricsText(), {
102
- headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
103
- });
104
- },
105
- });
106
- } catch (error) {
107
- if (!isAddressInUse(error)) throw error;
108
- throw new MetricsPortInUseError({ port });
109
- }
110
- }
111
- const server = listen();
140
+ const server = bindScrapePort(port, options.whenTaken ?? 'refuse', (candidate) =>
141
+ Bun.serve({
142
+ port: candidate,
143
+ hostname: options.hostname ?? 'localhost',
144
+ fetch(request: Request): Response {
145
+ if (new URL(request.url).pathname !== METRICS_PATH) {
146
+ return new Response('not found', { status: 404 });
147
+ }
148
+ // `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
149
+ // steal each other's samples — but a cache would hand the second one a stale window.
150
+ return new Response(metricsText(), {
151
+ headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
152
+ });
153
+ },
154
+ }),
155
+ );
156
+ // After the bind: a port that was taken starts no sampler nobody would stop.
157
+ const stopProcessMetrics = startProcessMetrics({ role: options.role ?? 'unknown' });
112
158
  // Same rule as every other socket the framework opens: announce it, so a request back to it is
113
159
  // recognisably this process calling itself rather than egress the test seal must refuse.
114
160
  const stopListening = markListening(server.url.origin);
@@ -118,6 +164,7 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
118
164
  stop(): void {
119
165
  server.stop(true);
120
166
  stopListening();
167
+ stopProcessMetrics();
121
168
  },
122
169
  };
123
170
  }
@@ -0,0 +1,29 @@
1
+ // Which Postgres extensions an app's migrations create. One reader for the two embedded boots
2
+ // that must agree — `x dev`'s database (`runtime-queue.ts`) and the schema dump's scratch replay
3
+ // (`db-schema-dump.ts`): PGlite links an extension only when it is named at boot, and an app that
4
+ // replays in the gate and fails in `x dev` was the result of only one of them asking.
5
+
6
+ import type { Migration } from '@ultimat3/db';
7
+ import { readMigrations } from './migrations';
8
+
9
+ const CREATE_EXTENSION = /\bcreate\s+extension\s+(?:if\s+not\s+exists\s+)?"?([a-z][a-z0-9_-]*)"?/gi;
10
+
11
+ /**
12
+ * Every extension the migrations create, sorted. Matched on the raw text, comments included, on
13
+ * purpose: over-reading costs one bundle that is never created, and a name nothing ships is
14
+ * refused later by `create extension` itself. Blanking quoted identifiers first would lose
15
+ * `"uuid-ossp"`, the one spelling that needs its quotes.
16
+ */
17
+ export function migrationExtensions(migrations: readonly Migration[]): readonly string[] {
18
+ const names = new Set<string>();
19
+ for (const migration of migrations) {
20
+ for (const match of migration.up.matchAll(CREATE_EXTENSION)) {
21
+ if (match[1] !== undefined) names.add(match[1].toLowerCase());
22
+ }
23
+ }
24
+ return [...names].sort();
25
+ }
26
+
27
+ /** The same list for an app on disk — what an embedded boot hands `createPgliteClient`. */
28
+ export const appExtensions = async (root: string): Promise<readonly string[]> =>
29
+ migrationExtensions(await readMigrations(root));
@@ -0,0 +1,47 @@
1
+ // The files one module's source imports, resolved the way Bun will resolve them. One function for
2
+ // the two graphs built over it: the reload graph (`app-reload-graph.ts`), which follows every file
3
+ // a module NAMES, and the document graph (`document-graph.ts`), which follows only what
4
+ // EVALUATING the module evaluates. Each caller keeps its own rule for which targets it walks.
5
+
6
+ // why: Bun ships no path API; an import specifier resolves against the importing file's directory.
7
+ import { dirname } from 'node:path';
8
+
9
+ /**
10
+ * `referenced`: every file the module names, an `await import()` target included — a save to one
11
+ * must reload its importer. `evaluated`: what importing the module evaluates, so the lazy targets
12
+ * are left out. Each keeps the transpiler pass its graph was built on (`scanImports`, the fast
13
+ * one, and `scan`); measured on Bun 1.4.0 the two agree on every static shape — both drop
14
+ * `import type` and an inline `type` specifier, both keep a value import used only as a type.
15
+ */
16
+ export type ImportEdges = 'referenced' | 'evaluated';
17
+
18
+ const transpilers = {
19
+ ts: new Bun.Transpiler({ loader: 'ts' }),
20
+ tsx: new Bun.Transpiler({ loader: 'tsx' }),
21
+ };
22
+
23
+ /**
24
+ * Absolute paths, in source order. A file that will not parse imports nothing yet, and a
25
+ * specifier that will not resolve contributes no edge: the module's own import reports both.
26
+ */
27
+ export function resolvedImports(path: string, source: string, edges: ImportEdges): string[] {
28
+ const transpiler = path.endsWith('x') ? transpilers.tsx : transpilers.ts;
29
+ let specifiers: readonly { readonly path: string; readonly kind: string }[];
30
+ try {
31
+ specifiers =
32
+ edges === 'referenced'
33
+ ? transpiler.scanImports(source)
34
+ : transpiler.scan(source).imports.filter((one) => one.kind !== 'dynamic-import');
35
+ } catch {
36
+ return [];
37
+ }
38
+ const found: string[] = [];
39
+ for (const { path: specifier } of specifiers) {
40
+ try {
41
+ found.push(Bun.resolveSync(specifier, dirname(path)));
42
+ } catch {
43
+ // Unresolvable: no edge.
44
+ }
45
+ }
46
+ return found;
47
+ }