@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
package/src/budgets.ts CHANGED
@@ -10,7 +10,13 @@ import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { ERROR_DOCS_URL } from '@ultimat3/core';
12
12
  import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
- import { formatBytes, parseByteBudget, themeScriptBody } from '@ultimat3/render';
13
+ import {
14
+ describeRoutes,
15
+ formatBytes,
16
+ parseByteBudget,
17
+ SPECULATION_RULES_TYPE,
18
+ themeScriptBody,
19
+ } from '@ultimat3/render';
14
20
  import type { Finding } from './output';
15
21
  import type { UnmeasuredRoute } from './static-report';
16
22
  import { SW_REGISTER_PATH } from './sw-artifacts';
@@ -69,6 +75,25 @@ const chainOf = (stats: RouteStats): string =>
69
75
 
70
76
  const jsBudgetOf = (route: RouteFact): number | null => parseByteBudget(route.budget?.js);
71
77
 
78
+ /** Which file declares a route, by URL — the loaded app's route table, read when the step runs. */
79
+ export type RouteFileOf = (url: string) => string | undefined;
80
+
81
+ const declaredFileOf: RouteFileOf = (url) =>
82
+ describeRoutes().find((route) => route.path === url && route.mount === undefined)?.file;
83
+
84
+ const KB = 1024;
85
+
86
+ /**
87
+ * The paste, not the advice: the file, the literal to set (the measured bytes rounded UP to a
88
+ * whole kb, so the raise is the smallest that clears) and the comment `bun run budget-raises`
89
+ * reads above it. The other way out stays beside it — a raise is axiom 9's to allow, not to force.
90
+ */
91
+ function exceededJsFix(url: string, file: string | undefined, bytes: number): string {
92
+ const where = file ?? `the file declaring ${url}`;
93
+ const kb = Math.ceil(bytes / KB);
94
+ return `edit ${where} — set budget: { js: '${String(kb)}kb' } with // measured: ${String(bytes)} B (x build --target static) — why: <the function it buys> directly above it; or x routes --json for the chain and move the heavy import behind hydrate: 'interaction'`;
95
+ }
96
+
72
97
  /** Which budgets the route declared, for a cause line that names what went unmeasured. */
73
98
  function declaredBudgets(js: number | null, lcp: number | undefined): string {
74
99
  const labels: string[] = [];
@@ -164,11 +189,15 @@ function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Fi
164
189
  * each failure's code when it had one. Optional because the report is written beside the stats
165
190
  * and can be absent for the same reason; with it, a route whose measurement failed on a code in
166
191
  * `REPORTED_BY_OWN_CODE` is reported under that code, with the build's own cause and fix.
192
+ *
193
+ * `fileOf` names the file an `X_BUDGET_EXCEEDED` fix edits: the step runs after the app loaded,
194
+ * so render's route table already holds it. A test hands its own.
167
195
  */
168
196
  export function checkBudgets(
169
197
  manifest: Manifest,
170
198
  stats: BuildStats | undefined,
171
199
  unmeasured: readonly UnmeasuredRoute[] = [],
200
+ fileOf: RouteFileOf = declaredFileOf,
172
201
  ): readonly Finding[] {
173
202
  const byPath = new Map((stats?.routes ?? []).map((route) => [route.path, route]));
174
203
  // Routes no build can weigh by construction (`UnmeasuredRoute.weighable`): printed by the step,
@@ -200,7 +229,7 @@ export function checkBudgets(
200
229
  findings.push({
201
230
  code: 'X_BUDGET_EXCEEDED',
202
231
  cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
203
- fix: `x routes --json to see the chain, then move the heavy import behind hydrate: 'interaction'`,
232
+ fix: exceededJsFix(route.url, fileOf(route.url), measured.jsBytes),
204
233
  docs: ERROR_DOCS_URL,
205
234
  at: route.url,
206
235
  });
@@ -242,7 +271,10 @@ const carriesJson = (attrs: string): boolean => {
242
271
  // `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
243
272
  // test alone charged an SEO structured-data block as executable JavaScript all over again.
244
273
  const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
245
- return type.trim().toLowerCase().endsWith('json');
274
+ const declared = type.trim().toLowerCase();
275
+ // Speculation rules are JSON under a type that does not say so: read by the browser's
276
+ // prefetcher, never executed — the one script a 0kb page may carry for free.
277
+ return declared.endsWith('json') || declared === SPECULATION_RULES_TYPE;
246
278
  };
247
279
 
248
280
  /**
@@ -5,12 +5,19 @@ import type { CommandSpec } from './parse';
5
5
 
6
6
  export const buildSpec: CommandSpec = {
7
7
  name: 'build',
8
- summary: 'build a container image, a single binary, or a prerendered static site',
8
+ summary:
9
+ 'build a container image, a single binary, a prerendered static site, or the store an image boots from',
9
10
  usage:
10
- 'x build --target docker|binary|static [--tag name] [--out path] [--no-preflight] [--json]',
11
+ 'x build --target docker|binary|static|prebuilt [--tag name] [--out path] [--no-preflight] [--json]',
11
12
  requiresApp: true,
12
13
  flags: [
13
- { name: 'target', type: 'string', summary: 'docker | binary | static', default: 'docker' },
14
+ {
15
+ name: 'target',
16
+ type: 'string',
17
+ summary:
18
+ 'docker | binary | static | prebuilt — prebuilt is the line a Dockerfile runs inside the image build',
19
+ default: 'docker',
20
+ },
14
21
  { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
15
22
  { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
16
23
  {
package/src/cmd-build.ts CHANGED
@@ -1,5 +1,6 @@
1
- // `x build --target docker|binary|static` — three targets, no platform primitives. Deploy anywhere
2
- // means "anywhere that runs a container or a binary"; nothing here knows the name of a cloud.
1
+ // `x build --target docker|binary|static|prebuilt` — no platform primitives. Deploy anywhere means
2
+ // "anywhere that runs a container or a binary"; nothing here knows the name of a cloud. `prebuilt`
3
+ // is the docker target's other half: the line the Dockerfile runs INSIDE the image build.
3
4
 
4
5
  import { existsSync } from 'node:fs';
5
6
  import { join, resolve } from 'node:path';
@@ -9,13 +10,13 @@ import { buildSpec } from './cmd-build-spec';
9
10
  import { runVerify } from './cmd-verify';
10
11
  import type { CliCommand, CommandContext } from './command';
11
12
  import { externalArgs } from './compile-externals';
12
- import { BuildEntryMissingError, UnknownCommandError } from './errors';
13
+ import { BadFlagError, BuildEntryMissingError, UnknownCommandError } from './errors';
13
14
  import type { ExecResult } from './exec';
14
15
  import { execOutput } from './exec';
15
- import { prepareImage } from './image-prepare';
16
16
  import { msg } from './messages';
17
17
  import type { CommandResult } from './output';
18
18
  import { flagString } from './parse';
19
+ import { PREBUILT_DIR } from './serve-prebuilt-paths';
19
20
  import type { StaticReport } from './static-report';
20
21
  import {
21
22
  readStaticReport,
@@ -24,10 +25,13 @@ import {
24
25
  staticReportData,
25
26
  } from './static-report';
26
27
 
27
- export const BUILD_TARGETS = ['docker', 'binary', 'static'] as const;
28
+ export const BUILD_TARGETS = ['docker', 'binary', 'static', 'prebuilt'] as const;
28
29
 
29
30
  export type BuildTarget = (typeof BUILD_TARGETS)[number];
30
31
 
32
+ /** The targets whose builder is a subprocess. `prebuilt` is this process, inside `docker build`. */
33
+ export type SpawnedTarget = Exclude<BuildTarget, 'prebuilt'>;
34
+
31
35
  export function readTarget(raw: string | undefined): BuildTarget {
32
36
  const targets: readonly string[] = BUILD_TARGETS;
33
37
  if (raw === undefined) return 'docker';
@@ -48,6 +52,8 @@ export const BUILD_ENTRY: Readonly<Record<BuildTarget, string>> = {
48
52
  docker: 'docker/Dockerfile',
49
53
  binary: 'apps/web/server.ts',
50
54
  static: 'apps/web/prerender.ts',
55
+ // What boots from the store: an image with no server entry has nothing to prebuild for.
56
+ prebuilt: 'apps/web/server.ts',
51
57
  };
52
58
 
53
59
  /** Absolute path of the target's entry, or the error that names the file and what writes it. */
@@ -109,7 +115,7 @@ export function staticArgs(root: string, out: string): readonly string[] {
109
115
  }
110
116
 
111
117
  export function argsFor(
112
- target: BuildTarget,
118
+ target: SpawnedTarget,
113
119
  paths: {
114
120
  readonly root: string;
115
121
  readonly tag: string;
@@ -184,6 +190,45 @@ export function buildResult(input: {
184
190
  };
185
191
  }
186
192
 
193
+ /**
194
+ * `x build --target prebuilt`: the island chunks and compiled stylesheets, written by the process
195
+ * `docker build` runs after the source is copied in. No gate and no subprocess — the image holds
196
+ * no devDependencies to typecheck with, and `x verify` ran before `docker build` was called.
197
+ *
198
+ * A module that would not import fails the image build rather than warning into a build log
199
+ * nobody reads: its stylesheets are missing from the store, and every pod would compile them.
200
+ */
201
+ async function buildPrebuilt(ctx: CommandContext, root: string): Promise<CommandResult> {
202
+ // The boot reads ONE place; a flag that seemed to move it would write a store no pod opens.
203
+ for (const flag of ['tag', 'out']) {
204
+ if (flagString(ctx.args, flag) === undefined) continue;
205
+ throw new BadFlagError({
206
+ flag,
207
+ command: 'build',
208
+ reason: `the prebuilt target writes ${PREBUILT_DIR}, the one place a container boots from, and takes no --${flag}`,
209
+ fix: 'x build --target prebuilt',
210
+ });
211
+ }
212
+ const started = Bun.nanoseconds();
213
+ const { prebuildImage } = await import('./image-prepare');
214
+ const built = await prebuildImage(root);
215
+ const ok = built.findings.length === 0;
216
+ return {
217
+ ok,
218
+ command: 'build',
219
+ summary: msg(ok ? 'cli.build.done' : 'cli.build.failed', { target: 'prebuilt' }),
220
+ findings: built.findings,
221
+ data: {
222
+ target: 'prebuilt',
223
+ artifact: built.dir,
224
+ durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
225
+ islands: built.islands,
226
+ stylesheets: built.stylesheets,
227
+ },
228
+ lines: [],
229
+ };
230
+ }
231
+
187
232
  /** The static steps `x build` runs before it builds, and `--no-preflight` leaves to the gate. */
188
233
  export const PREFLIGHT_STEPS: readonly string[] = [
189
234
  'typecheck',
@@ -203,6 +248,7 @@ export const buildCommand: CliCommand = {
203
248
  // typecheck, and eight seconds of `tsc` ahead of "that file does not exist" is eight seconds
204
249
  // an agent spends on the wrong question.
205
250
  requireEntry(root, target);
251
+ if (target === 'prebuilt') return buildPrebuilt(ctx, root);
206
252
 
207
253
  // Run static verify steps before building — unless the caller is a gate that runs the same
208
254
  // six steps itself right after (`bin/check`: `x build --no-preflight && x verify`), where the
@@ -227,9 +273,10 @@ export const buildCommand: CliCommand = {
227
273
  ? join(root, '.x', target === 'static' ? 'static' : 'app')
228
274
  : resolve(ctx.cwd, outFlag);
229
275
  const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
230
- // The docker target stamps the manifest's build id into the image, and writes the island
231
- // chunks the image serves, so a container boot neither re-derives the one nor rebuilds the other.
232
- const buildId = target === 'docker' ? await prepareImage(root) : undefined;
276
+ // The docker target stamps the manifest's build id into the image, so no role derives one at
277
+ // boot. The island chunks and stylesheets are the image build's own (`--target prebuilt`).
278
+ const buildId =
279
+ target === 'docker' ? await (await import('./image-prepare')).imageBuildId(root) : undefined;
233
280
  const command = argsFor(target, {
234
281
  root,
235
282
  tag,
package/src/cmd-db.ts CHANGED
@@ -22,6 +22,13 @@ import { plannedSubcommand } from './cmd-planned';
22
22
  import type { CliCommand, CommandContext } from './command';
23
23
  import { stepFinding } from './db-finding';
24
24
  import { generateAppMigration, unrenderedJson, unrenderedLines } from './db-generate';
25
+ import { liveObjectDrift } from './db-object-drift';
26
+ import {
27
+ afterWrittenMigration,
28
+ refreshSchemaDump,
29
+ type SchemaDumpRefresh,
30
+ schemaDumpJson,
31
+ } from './db-schema-refresh';
25
32
  import type { SeedPassRow } from './db-seed';
26
33
  import {
27
34
  discoverSeeds,
@@ -109,34 +116,52 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
109
116
  // `x i18n add fr` failure, repeated. The count is the verdict; the gate's own `drift` step is
110
117
  // where a red belongs, and it reads the same list to decide that `x db gen` is not the fix.
111
118
  const lost = unrenderedLines(generated.unrendered);
119
+ // After the migration, from the migrations alone: the dump is what replaying them produces, so
120
+ // it is rewritten on all three outcomes — `unchanged` included, which is what makes this
121
+ // command the `fix:` on `X_SCHEMA_DUMP_DRIFT` when a framework table or a hand edit moved it.
122
+ const dump = await refreshSchemaDump(root, ctx.env);
123
+ const dumpFindings = dump.finding === undefined ? [] : [dump.finding];
112
124
  const migration = generated.migration;
113
125
  if (migration === undefined) {
114
126
  return {
115
- ok: generated.findings.length === 0,
127
+ ok: generated.findings.length === 0 && dumpFindings.length === 0,
116
128
  command: 'db',
117
129
  // The sidecar path, never a bare id: `hash-recorded` writes exactly one, and it is the file
118
130
  // the `drift` step reads back.
131
+ // `blocked` FIRST: the app did not load, so nothing was diffed, and every sentence below
132
+ // it is a claim about a comparison that never ran. It read "entities and migrations agree"
133
+ // over an exit 1.
119
134
  summary:
120
- generated.outcome === 'hash-recorded'
121
- ? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
122
- : msg('cli.db.gen.unchanged'),
123
- findings: generated.findings,
135
+ generated.outcome === 'blocked'
136
+ ? msg('cli.db.gen.blocked', { count: generated.findings.length })
137
+ : generated.outcome === 'hash-recorded'
138
+ ? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
139
+ : dump.status === 'written'
140
+ ? msg('cli.db.gen.dumped', { count: dump.written.length + dump.removed.length })
141
+ : msg('cli.db.gen.unchanged'),
142
+ findings: [...generated.findings, ...dumpFindings],
124
143
  // `files` is what this command WROTE, so the empty array here was a false claim.
125
- lines: [...generated.files.map((file) => ` ${file}`), ...lost],
144
+ lines: [...generated.files.map((file) => ` ${file}`), ...dumpLines(dump), ...lost],
126
145
  data: {
127
146
  outcome: generated.outcome,
128
147
  migration: null,
129
148
  files: [...generated.files],
130
149
  schemaHash: generated.schemaHash ?? null,
131
150
  unrendered: unrenderedJson(generated.unrendered),
151
+ schemaDump: schemaDumpJson(dump),
132
152
  },
133
153
  };
134
154
  }
135
155
  return {
136
- ok: true,
156
+ ok: dumpFindings.length === 0,
137
157
  command: 'db',
138
- summary: msg('cli.db.gen.written', { id: migration.id }),
139
- lines: [...generated.files.map((file) => ` ${file}`), ...lost],
158
+ summary:
159
+ dumpFindings.length === 0
160
+ ? msg('cli.db.gen.written', { id: migration.id })
161
+ : msg('cli.db.gen.writtenDumpFailed', { id: migration.id }),
162
+ // Exit 1 with a migration on disk: the finding has to say so, or the next move is a duplicate.
163
+ findings: dumpFindings.map((finding) => afterWrittenMigration(finding, migration.id)),
164
+ lines: [...generated.files.map((file) => ` ${file}`), ...dumpLines(dump), ...lost],
140
165
  data: {
141
166
  outcome: generated.outcome,
142
167
  migration: migration.id,
@@ -144,10 +169,20 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
144
169
  files: [...generated.files],
145
170
  schemaHash: generated.schemaHash ?? null,
146
171
  unrendered: unrenderedJson(generated.unrendered),
172
+ schemaDump: schemaDumpJson(dump),
147
173
  },
148
174
  };
149
175
  }
150
176
 
177
+ /**
178
+ * What the dump refresh did, one path per line — the same list `data.schemaDump` carries, so the
179
+ * human render and `--json` report one fact. A refresh that changed nothing prints nothing.
180
+ */
181
+ const dumpLines = (dump: SchemaDumpRefresh): readonly string[] => [
182
+ ...dump.written.map((file) => ` ${file}`),
183
+ ...dump.removed.map((file) => ` - ${file}`),
184
+ ];
185
+
151
186
  /**
152
187
  * The post-migrate report, rendered. Through `driftError` rather than a second literal: the
153
188
  * three-line `X_DB_DRIFT` output is pinned by the framework contract, and this command must not be
@@ -175,16 +210,30 @@ async function runMigrate(
175
210
  try {
176
211
  const migrated = await runMigrations({ root, env: ctx.env });
177
212
  const report = migrated.report;
213
+ // A hand-written or pulled migration reaches the dump here, with no `x db gen` in between.
214
+ const dump = await refreshSchemaDump(root, ctx.env);
215
+ // The half a snapshot cannot hold: a trigger, function, view, type or sequence this database
216
+ // carries and no migration creates. Asked only when the replay produced a catalog to ask
217
+ // against — without one there is nothing that says what the migrations create.
218
+ const objects =
219
+ dump.catalog === undefined ? [] : await liveObjectDrift(root, ctx.env, dump.catalog);
220
+ const findings = [
221
+ ...driftFindings(migrated.drift),
222
+ ...objects.map((difference) => findingFrom(driftError(difference))),
223
+ ...(dump.finding === undefined ? [] : [dump.finding]),
224
+ ];
178
225
  return {
179
- ok: migrated.drift.ok,
226
+ ok: findings.length === 0,
180
227
  command: 'db',
181
228
  summary,
182
- findings: driftFindings(migrated.drift),
229
+ findings,
230
+ lines: dumpLines(dump),
183
231
  data: {
184
232
  applied: report.applied.map((entry) => entry.id),
185
233
  skipped: report.skipped.length,
186
234
  appVersion: report.appVersion,
187
235
  durationMs: report.durationMs,
236
+ schemaDump: schemaDumpJson(dump),
188
237
  },
189
238
  };
190
239
  } catch (error) {
package/src/cmd-dev.ts CHANGED
@@ -242,7 +242,7 @@ async function bootDev(
242
242
  // `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
243
243
  // its whole ttl after every save, whatever the modules behind it now said.
244
244
  const isr = createIsrController({ buildId });
245
- const { routes, theme, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
245
+ const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
246
246
  isr,
247
247
  root: options.root,
248
248
  env: options.env,
@@ -278,7 +278,7 @@ async function bootDev(
278
278
  // The `/_x` shell, the harness's frame, and the app's own error pages — the inline bodies this
279
279
  // process serves; the app's surfaces are content-hashed files `'self'` admits.
280
280
  inlineStyles: [await devShellStyle(), FRAME_STYLE, ...errorStyles],
281
- inlineScripts: [theme.cspSource],
281
+ inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
282
282
  // The overlay renders this request's own loops under the error it is already showing.
283
283
  // `serve.ts` boots through the same `startRoles` and passes nothing (axiom 6).
284
284
  devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
package/src/cmd-doctor.ts CHANGED
@@ -22,8 +22,12 @@ import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
22
22
  import { DEFAULT_DOCTOR_PORT, doctorSpec } from './cmd-doctor-spec';
23
23
  import type { CliCommand, CommandContext } from './command';
24
24
  import { checkMigrationSnapshots } from './db-snapshot';
25
+ import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
26
+ import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
25
27
  import type { OfflineFallbackFact } from './doctor-offline';
26
28
  import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
29
+ import type { SealedKeysFact } from './doctor-sealed';
30
+ import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
27
31
  import { intFlagOr, PORT_RANGE, portPairAfter } from './flag-number';
28
32
  import { ICON_SOURCE } from './icon-assets';
29
33
  import { msg } from './messages';
@@ -91,6 +95,12 @@ export interface DoctorProbe {
91
95
  * reaching the disk — the same split `drift()` makes one question over.
92
96
  */
93
97
  offlineFallback(): Promise<OfflineFallbackFact>;
98
+ /**
99
+ * The app's `.sealed()` columns and the master keys this environment declares. A FACT, for the
100
+ * reason `offlineFallback` is one: loading the app and opening the secrets file is IO, and what
101
+ * a retired key beside a sealed column means is `sealedKeyFindings`' pure rule.
102
+ */
103
+ sealedKeys(): Promise<SealedKeysFact>;
94
104
  }
95
105
 
96
106
  /** What `x doctor` reads about the embedded database, without opening it. */
@@ -281,6 +291,8 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
281
291
  // author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
282
292
  // condition this diagnostic could not see at all, so the fix line ran clean over a broken app.
283
293
  findings.push(...(await probe.snapshots()));
294
+ // Sealed columns against the key ring: no key at all, or a rotation whose re-seal is pending.
295
+ findings.push(...sealedKeyFindings(await probe.sealedKeys()));
284
296
  return findings;
285
297
  }
286
298
 
@@ -394,6 +406,8 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
394
406
  // `runDoctor` returns on `X_NOT_IN_APP` before this can be asked.
395
407
  offlineFallback: async () =>
396
408
  root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
409
+ sealedKeys: async () =>
410
+ root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
397
411
  };
398
412
  }
399
413
 
@@ -401,16 +415,26 @@ export const doctorCommand: CliCommand = {
401
415
  spec: doctorSpec,
402
416
  async run(ctx: CommandContext): Promise<CommandResult> {
403
417
  const port = doctorPort(ctx.args);
404
- const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
405
- return {
406
- ok: findings.length === 0,
407
- command: 'doctor',
408
- summary:
409
- findings.length === 0
410
- ? msg('cli.doctor.clean')
411
- : msg('cli.doctor.findings', { count: findings.length }),
412
- findings,
413
- data: { count: findings.length, codes: findings.map((entry) => entry.code) },
414
- };
418
+ const probe = probeFor(ctx.cwd, ctx.bunVersion, port);
419
+ const findings = await runDoctor(probe);
420
+ const listed = withGuardListing(
421
+ {
422
+ ok: findings.length === 0,
423
+ command: 'doctor',
424
+ summary:
425
+ findings.length === 0
426
+ ? msg('cli.doctor.clean')
427
+ : msg('cli.doctor.findings', { count: findings.length }),
428
+ findings,
429
+ data: { count: findings.length, codes: findings.map((entry) => entry.code) },
430
+ },
431
+ // A listing beside the verdict, never part of it (`doctor-guards.ts` says why).
432
+ probe.root === undefined ? [] : await shippedGuardsProbe(probe.root),
433
+ );
434
+ // The second listing: what the coverage floor does not see (`doctor-coverage.ts`).
435
+ return withCoverageExcludes(
436
+ listed,
437
+ probe.root === undefined ? [] : await coverageExcludesProbe(probe.root),
438
+ );
415
439
  },
416
440
  };