@ultimat3/cli 21.0.0 → 22.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +23 -17
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +22 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/sync-url.ts +21 -1
  178. package/src/templates/action.ts +30 -16
  179. package/src/templates/entity.ts +12 -7
  180. package/src/templates/index.ts +1 -1
  181. package/src/templates/job.ts +10 -7
  182. package/src/templates/policy.ts +20 -2
  183. package/src/templates/resource-create.ts +127 -0
  184. package/src/templates/resource-form-island.ts +49 -12
  185. package/src/templates/resource.ts +10 -4
  186. package/src/templates/scaffold-app.ts +4 -1
  187. package/src/templates/scaffold-auth.ts +3 -1
  188. package/src/templates/scaffold-container-compose.ts +184 -0
  189. package/src/templates/scaffold-container.ts +24 -159
  190. package/src/templates/scaffold-dashboard-example.ts +2 -2
  191. package/src/templates/scaffold-db-package.ts +16 -3
  192. package/src/templates/scaffold-demo-org.ts +41 -0
  193. package/src/templates/scaffold-entries.ts +1 -1
  194. package/src/templates/scaffold-env.ts +6 -0
  195. package/src/templates/scaffold-helm-templates.ts +66 -7
  196. package/src/templates/scaffold-helm.ts +27 -5
  197. package/src/templates/scaffold-i18n.ts +15 -10
  198. package/src/templates/scaffold-repo.ts +13 -10
  199. package/src/templates/scaffold-roles.ts +38 -10
  200. package/src/templates/slice-foundation.ts +1 -1
  201. package/src/templates/wrap.ts +4 -1
  202. package/src/test-passes.ts +2 -1
  203. package/src/test-workers.ts +26 -0
  204. package/src/ts-scan.ts +3 -6
  205. package/src/tsconfig-references.ts +1 -2
  206. package/src/verify-checks.ts +41 -39
  207. package/src/verify-e2e.ts +10 -7
  208. package/src/verify-run.ts +11 -1
  209. package/src/verify-step.ts +3 -3
  210. package/src/verify-tests.ts +1 -26
  211. package/src/verify-typecheck.ts +28 -0
  212. package/src/web-binding.ts +2 -2
  213. package/src/workspace-graph.ts +10 -33
  214. package/src/cdp-browser.ts +0 -94
  215. package/src/cdp-connection.ts +0 -247
  216. package/src/cdp-e2e-page.ts +0 -180
  217. package/src/cdp-e2e-session.ts +0 -199
  218. package/src/cdp-errors.ts +0 -56
  219. package/src/cdp-launch.ts +0 -193
  220. package/src/cdp-offline-script.ts +0 -73
  221. package/src/cdp-pipe.ts +0 -77
  222. package/src/e2e-app.ts +0 -103
  223. package/src/e2e-browser-handle.ts +0 -55
  224. package/src/e2e-dom-fixture.ts +0 -117
  225. package/src/e2e-driver.ts +0 -117
  226. package/src/e2e-errors.ts +0 -117
  227. package/src/e2e-evaluate.ts +0 -156
  228. package/src/e2e-locator.ts +0 -86
  229. package/src/e2e-page.ts +0 -153
  230. package/src/e2e-preload.ts +0 -64
  231. package/src/e2e-probe.ts +0 -23
  232. package/src/e2e-selection.ts +0 -182
  233. package/src/e2e-spawn.ts +0 -169
  234. package/src/measurement-actor.ts +0 -26
  235. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  236. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
package/src/mcp-ui.ts CHANGED
@@ -24,17 +24,17 @@ import type {
24
24
  UiShotResult,
25
25
  } from '@ultimat3/mcp';
26
26
  import { describeRoutes } from '@ultimat3/render';
27
- import type { ScrapeDriver } from '@ultimat3/scraping';
28
- import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
29
27
  import { appBrowser } from './browser-launcher';
30
- import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
28
+ import type { ShotDriver } from './browser-launcher-port';
29
+ import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
30
+ import { DEFAULT_SETTLE_MS, readRoute, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
31
31
  import { islandShot } from './cmd-shot-island';
32
- import type { Env } from './dev-services';
33
32
  import { islandVerdictJson } from './island-verdict';
34
33
  import { diffShots } from './mcp-ui-diff';
35
34
  import { inspectRoute } from './mcp-ui-inspect';
36
35
  import { interactRoute } from './mcp-ui-interact';
37
36
  import { retryMemo } from './retry-memo';
37
+ import type { Env } from './runtime-bindings';
38
38
  import { shotBrowserChoice } from './shot-browser';
39
39
  import { devServerFor, type ShotServer } from './shot-server';
40
40
  import { verdictJson } from './shot-verdict';
@@ -54,7 +54,11 @@ export interface DeclaredRoute {
54
54
  readonly budgetJs: string | null;
55
55
  }
56
56
 
57
- export function assertBudgetedRoute(route: string, declared: readonly DeclaredRoute[]): void {
57
+ export function assertBudgetedRoute(raw: string, declared: readonly DeclaredRoute[]): string {
58
+ // `readRoute` FIRST, for every `ui.*` caller: `/\\localhost:9200` matches `/:slug` and a
59
+ // browser reads it as `//localhost:9200`, another service on the box. `x shot` ran it; the three
60
+ // MCP tools skipped it, and this is the one function all four pass through.
61
+ const route = readRoute(raw);
58
62
  const path = route.split('?')[0] ?? route;
59
63
  const hit = declared.find((entry) => matches(entry.path, path));
60
64
  if (hit === undefined) {
@@ -71,6 +75,7 @@ export function assertBudgetedRoute(route: string, declared: readonly DeclaredRo
71
75
  fix: `declare budget: { js: '<n>kb' } in ${hit.file}, then: x build --target static --json && x verify --only budgets --json`,
72
76
  });
73
77
  }
78
+ return route;
74
79
  }
75
80
 
76
81
  /** `/links/:slug` matches `/links/abc123`; one segment per `:param`, no globbing. */
@@ -87,9 +92,11 @@ export interface UiHostInput {
87
92
  /** Injected by a test: the server a picture is of, in place of `devServerFor`. */
88
93
  readonly boot?: (() => Promise<ShotServer>) | undefined;
89
94
  /** Injected by a test: the browser, in place of `appBrowser` (which needs Chrome). */
90
- readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ScrapeDriver>) | undefined;
95
+ readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ShotDriver>) | undefined;
91
96
  /** Injected by a test: the route table, in place of the registry. */
92
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). */
99
+ readonly settleMs?: number | undefined;
93
100
  }
94
101
 
95
102
  export interface UiCapabilities {
@@ -122,11 +129,10 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
122
129
  const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
123
130
  const routes = input.routes ?? describeRoutes;
124
131
  // One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
125
- const driverFor = async (viewport: UiShotInput['viewport']): Promise<ScrapeDriver> => {
132
+ const driverFor = async (viewport: UiShotInput['viewport']): Promise<ShotDriver> => {
126
133
  if (input.driver !== undefined) return input.driver(viewport);
127
134
  const { cdpUrl, executablePath } = browser();
128
135
  return appBrowser({
129
- root,
130
136
  viewport,
131
137
  ...(executablePath === undefined ? {} : { executablePath }),
132
138
  ...(cdpUrl === undefined ? {} : { cdpUrl }),
@@ -144,22 +150,22 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
144
150
  },
145
151
 
146
152
  async shotRoute(shot) {
147
- assertBudgetedRoute(shot.route, routes());
153
+ const route = assertBudgetedRoute(shot.route, routes());
148
154
  const driver = await driverFor(shot.viewport);
149
155
  // One directory per (route, viewport, scheme), so two pictures of one route at two widths
150
156
  // never overwrite each other and an agent can hold both.
151
157
  const outDir = join(
152
158
  root,
153
159
  SHOT_DIR,
154
- shotSlug(shot.route),
160
+ shotSlug(route),
155
161
  `${shot.viewport.width}x${shot.viewport.height}-${shot.colorScheme}`,
156
162
  );
157
163
  const artifacts = await runShot({
158
- route: shot.route,
164
+ route,
159
165
  outDir,
160
166
  driver,
161
167
  boot,
162
- settleMs: DEFAULT_SETTLE_MS,
168
+ settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
163
169
  timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
164
170
  fullPage: shot.fullPage,
165
171
  colorScheme: shot.colorScheme,
@@ -175,13 +181,13 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
175
181
  async inspectRoute(inspect) {
176
182
  // The same gate as `ui.shot`, for the same reason: facts about a draft are facts about the
177
183
  // wrong thing.
178
- assertBudgetedRoute(inspect.route, routes());
179
- return inspectRoute({ root, boot, driver: driverFor }, inspect);
184
+ const route = assertBudgetedRoute(inspect.route, routes());
185
+ return inspectRoute({ root, boot, driver: driverFor }, { ...inspect, route });
180
186
  },
181
187
 
182
188
  async interactRoute(interact) {
183
- assertBudgetedRoute(interact.route, routes());
184
- return interactRoute({ root, boot, driver: driverFor }, interact);
189
+ const route = assertBudgetedRoute(interact.route, routes());
190
+ return interactRoute({ root, boot, driver: driverFor }, { ...interact, route });
185
191
  },
186
192
  // No route gate and no boot: the captures were gated when they were taken, and a diff of two
187
193
  // files needs neither a server nor a browser.
@@ -193,7 +199,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
193
199
  root,
194
200
  island: island.island,
195
201
  ...(island.state === undefined ? {} : { state: island.state }),
196
- settleMs: DEFAULT_SETTLE_MS,
202
+ settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
197
203
  timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
198
204
  ...(executablePath === undefined ? {} : { executablePath }),
199
205
  ...(cdpUrl === undefined ? {} : { cdpUrl }),
@@ -0,0 +1,73 @@
1
+ // The database a measurement render reads, when the build has none. A page's `load` reads rows,
2
+ // and a build runs with no `DATABASE_URL`, so every data-reading route was `X_DB_UNAVAILABLE` and
3
+ // never weighed. The answer is `x dev`'s own embedded PGlite on a THROWAWAY state directory, with
4
+ // the app's migrations applied and its `dev`-tier seeds run: `defineSeed` rows are keyed by
5
+ // `seedId(label)`, so the database is the same on every machine — and a dynamic route whose
6
+ // `prerender()` lists paths from its own rows has paths to be weighed at (plan 101 slice 11 m).
7
+
8
+ // why: Bun has no mkdtemp and no recursive remove; the state directory must not outlive the build.
9
+ import { mkdtemp, rm } from 'node:fs/promises';
10
+ // why: Bun exposes no tmpdir(); the throwaway directory lives in the platform temp root.
11
+ import { tmpdir } from 'node:os';
12
+ // why: Bun ships no path-join primitive.
13
+ import { join } from 'node:path';
14
+ import { migrate, withTransaction } from '@ultimat3/db';
15
+ import { postgresDriver } from '@ultimat3/entity';
16
+ import { discoverSeeds, runSeeds, selectSeeds } from './db-seed';
17
+ import { readMigrations } from './migrations';
18
+ import { resolveServices } from './runtime-bindings';
19
+ import { startQueue } from './runtime-queue';
20
+
21
+ type Env = Readonly<Record<string, string | undefined>>;
22
+
23
+ /** One build's database: started on first use, released once. */
24
+ export interface MeasureDatabase {
25
+ ready(): Promise<void>;
26
+ close(): Promise<void>;
27
+ }
28
+
29
+ /**
30
+ * `x dev`'s embedded database, empty and migrated, for an env with no `DATABASE_URL`. An env that
31
+ * names one keeps it: `db()` already reaches that server, and a build pointed at a database was
32
+ * pointed there on purpose.
33
+ */
34
+ export function measureDatabase(root: string, env: Env = process.env): MeasureDatabase {
35
+ let started: Promise<() => Promise<void>> | undefined;
36
+ const start = async (): Promise<() => Promise<void>> => {
37
+ if ((env['DATABASE_URL'] ?? '') !== '') return async () => undefined;
38
+ // An app with no migration has no table to read, so it pays for no database: a PGlite boot
39
+ // is seconds, and every build of such an app (and every prerender test) paid it for nothing.
40
+ const migrations = await readMigrations(root);
41
+ if (migrations.length === 0) return async () => undefined;
42
+ const state = await mkdtemp(join(tmpdir(), 'x-measure-'));
43
+ const scoped = { ...env, ULTIMATE_STATE_DIR: state };
44
+ const queue = await startQueue(resolveServices(root, scoped), undefined, scoped);
45
+ await migrate({ migrations });
46
+ // The seeds `x db seed` runs in development. A failed seed is a row in its own result and
47
+ // never a thrown build: a route it starves is reported by the measurement that needed it.
48
+ const discovered = (await discoverSeeds(root)).seeds;
49
+ await runSeeds({
50
+ seeds: selectSeeds({ discovered, environment: 'development' }),
51
+ driver: postgresDriver(),
52
+ dryRun: false,
53
+ env: scoped,
54
+ transaction: (work) => withTransaction(() => work()),
55
+ });
56
+ return async () => {
57
+ await queue.stop();
58
+ await rm(state, { recursive: true, force: true });
59
+ };
60
+ };
61
+ return {
62
+ ready: async () => {
63
+ started ??= start();
64
+ await started;
65
+ },
66
+ close: async () => {
67
+ if (started === undefined) return;
68
+ const stop = await started;
69
+ started = undefined;
70
+ await stop();
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,74 @@
1
+ // Which URLs a route rendered only to WEIGH is rendered at. A route with no params is its own
2
+ // path. A route with params is rendered at the paths its own `prerender()` lists — the same list a
3
+ // static route writes files for — and one that lists none is not rendered at all: rendering
4
+ // `/blog/:slug` with an empty `slug` failed the page's own input schema (`X_INPUT_INVALID`), and
5
+ // that was filed as "could not be weighed", a failure of the app dressed as a gap in the build.
6
+
7
+ import type { RouteEntry } from '@ultimat3/render';
8
+ import { enumeratePrerender } from '@ultimat3/render/server';
9
+ import { quoteArg } from './shell-quote';
10
+ import type { UnmeasuredRoute } from './static-report';
11
+
12
+ /** One render the measuring pass performs: the URL path and the params it carries. */
13
+ export interface MeasurePath {
14
+ readonly path: string;
15
+ readonly params: Readonly<Record<string, string>>;
16
+ }
17
+
18
+ /**
19
+ * `pattern` with each `:name` / `*name` segment filled from `params`. Values are URI-encoded per
20
+ * segment, except a catch-all's, whose slashes are its own.
21
+ */
22
+ export function fillPath(pattern: string, params: Readonly<Record<string, string>>): string {
23
+ const segments = pattern.split('/').map((segment) => {
24
+ const name = segment.slice(1);
25
+ const value = Object.hasOwn(params, name) ? (params[name] ?? '') : '';
26
+ if (segment.startsWith(':')) return encodeURIComponent(value);
27
+ if (segment.startsWith('*')) return value.split('/').map(encodeURIComponent).join('/');
28
+ return segment;
29
+ });
30
+ return segments.join('/') || '/';
31
+ }
32
+
33
+ /** The distinct finding for a dynamic route that gives the build nothing to render it at. */
34
+ export function paramsUndeclared(entry: RouteEntry, listed: boolean): UnmeasuredRoute {
35
+ const keys = entry.pattern.keys;
36
+ const example = keys.map((key) => `${key}: 'a-real-${key}'`).join(', ');
37
+ const cause = listed
38
+ ? `${entry.path} has dynamic params and its prerender() listed no path, so the build had nothing to render it at and weighed nothing`
39
+ : `${entry.path} has dynamic params and declares no prerender(), so the build has no path to render it at and weighed nothing`;
40
+ return {
41
+ path: entry.path,
42
+ reason: cause,
43
+ code: 'X_BUDGET_PARAMS_UNDECLARED',
44
+ cause,
45
+ fix: `add prerender: () => [{ ${example} }] to defineRoute in ${quoteArg(entry.file)}, then x build --target static`,
46
+ };
47
+ }
48
+
49
+ /** The paths to render `entry` at, or the finding that says why there are none. */
50
+ export type MeasurePlan =
51
+ | { readonly paths: readonly MeasurePath[] }
52
+ | { readonly unmeasured: UnmeasuredRoute };
53
+
54
+ /**
55
+ * A `render: 'ssr'` route with params: it may not declare `prerender()` (`X_ROUTE_MODE_INVALID`),
56
+ * so no build can hold a value to render it at. Reported as unweighable, never as a finding — there
57
+ * is no edit to the app that a build could then measure.
58
+ */
59
+ export const perRequestParams = (entry: RouteEntry): UnmeasuredRoute => ({
60
+ path: entry.path,
61
+ reason: `${entry.path} renders per request with params (${entry.pattern.keys.join(', ')}) and render: 'ssr' may not declare prerender(), so no build has a value to render it at — its budget is not enforced by x verify`,
62
+ weighable: false,
63
+ });
64
+
65
+ export async function measurePaths(entry: RouteEntry): Promise<MeasurePlan> {
66
+ if (entry.pattern.keys.length === 0) return { paths: [{ path: entry.path, params: {} }] };
67
+ if (entry.config.render === 'ssr') return { unmeasured: perRequestParams(entry) };
68
+ if (entry.config.prerender === undefined) return { unmeasured: paramsUndeclared(entry, false) };
69
+ const sets = await enumeratePrerender(entry);
70
+ if (sets.length === 0) return { unmeasured: paramsUndeclared(entry, true) };
71
+ return {
72
+ paths: sets.map((params) => ({ path: fillPath(entry.pattern.source, params), params })),
73
+ };
74
+ }
@@ -0,0 +1,74 @@
1
+ // The request a measurement render runs inside. `x build` renders every app/ route it cannot
2
+ // prerender only to WEIGH it, and that render reads its data the way a real one does: a `load`
3
+ // calls the app's typed client, over `APP_URL`, as the request's own member. So it needs a request
4
+ // context (`useRequestHeader('cookie')` throws X_NO_REQUEST outside one), an actor the app chose
5
+ // (`defineMeasurementActor()` in `app.config.ts`, core's `measurementActor()` otherwise), and a
6
+ // server to answer — the app's own API pipeline, dispatched IN PROCESS through core's
7
+ // `withInProcessFetch`, because no server is listening during a build (plan 101 slice 11 m).
8
+
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';
17
+ import { apiRoutes } from './api-routes';
18
+
19
+ /** One build's measurement scope: its actor, and a runner that renders inside it. */
20
+ export interface MeasureScope {
21
+ readonly actor: Actor;
22
+ run<T>(fn: () => T): T;
23
+ }
24
+
25
+ /**
26
+ * The scope for `origin`. The pipeline is the app's API table and nothing else: a page's data
27
+ * comes from its actions and queries, and a route the render reaches that is not one of them
28
+ * answers 404 — which the render reports, and the route is filed unmeasured with that reason.
29
+ */
30
+ export async function measureScope(input: {
31
+ readonly origin: string;
32
+ readonly buildId: string;
33
+ }): Promise<MeasureScope> {
34
+ const actor = await measurementActor();
35
+ // No rate limit: every request here is this build's own render, and a limiter would need a
36
+ // deployment scope a build does not have (`X_RATE_LIMIT_SCOPE_UNSET`).
37
+ const config = defineHttpConfig({ buildId: input.buildId, rateLimit: { enabled: false } });
38
+ const ctx = createRequestContext({
39
+ url: new URL(input.origin),
40
+ method: 'GET',
41
+ role: 'web',
42
+ config,
43
+ });
44
+ ctx.actor = actor;
45
+ const pipeline = createPipeline({
46
+ table: createRouter(apiRoutes()),
47
+ config,
48
+ hooks: { authenticate: () => actor },
49
+ });
50
+ const dispatch = (url: string, init: RequestInit): Promise<Response> =>
51
+ pipeline.handle(new Request(url, init), { role: 'web' });
52
+ const request: Ctx = ctx;
53
+ return {
54
+ actor,
55
+ run: <T>(fn: () => T): T => withInProcessFetch(dispatch, () => runWithContext(request, fn)),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * `APP_URL` for the length of `fn`, when the process has none: an app's typed client reads it to
61
+ * name the origin it posts to, and `withInProcessFetch` answers that origin in process. A value
62
+ * the process already carries is left alone — it is the app's.
63
+ */
64
+ export async function withAppUrl<T>(origin: string, fn: () => Promise<T>): Promise<T> {
65
+ const before = process.env['APP_URL'];
66
+ if (before !== undefined && before !== '') return fn();
67
+ process.env['APP_URL'] = origin;
68
+ try {
69
+ return await fn();
70
+ } finally {
71
+ if (before === undefined) delete process.env['APP_URL'];
72
+ else process.env['APP_URL'] = before;
73
+ }
74
+ }
package/src/messages.ts CHANGED
@@ -69,7 +69,7 @@ const CATALOG = {
69
69
  'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}',
70
70
  // The mail and CDN halves of that boot line. Rendered text, so it lives here — while
71
71
  // `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json`
72
- // carries, and `dev-runtime.test.ts` pins the two together so neither can drift alone.
72
+ // carries, and `runtime-services.test.ts` pins the two together so neither can drift alone.
73
73
  'cli.dev.cdn.external': 'cdn=external({driver} via {detail})',
74
74
  'cli.dev.cdn.none': 'cdn=none',
75
75
  'cli.dev.mail.embedded': 'mail=embedded',
@@ -204,8 +204,6 @@ const CATALOG = {
204
204
  'cli.shot.pageError': ' threw {message} {at}',
205
205
  'cli.shot.picture': ' picture {path}',
206
206
  'cli.shot.verdict': ' verdict {path}',
207
- 'cli.shot.blind.status':
208
- 'HTTP response status is not observed — the port records requests, never responses',
209
207
  // `--island`. A component capture reports per STATE, so the summary counts pictures and the
210
208
  // lines name one state each; nothing here restates a fact `--json` does not carry.
211
209
  'cli.shot.island.ok':
@@ -81,7 +81,7 @@ export interface MetricsEndpoint {
81
81
  export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
82
82
  // Screened here rather than left to `Bun.serve`, which refuses a `NaN` with a bare `RangeError`
83
83
  // — no code, no `fix:` — at exactly the boot path the refusal below exists to stop reporting
84
- // that way. Floor 0, because 0 asks the kernel for a free port and `dev-roles.ts` passes it for
84
+ // that way. Floor 0, because 0 asks the kernel for a free port and `role-start.ts` passes it for
85
85
  // an ephemeral boot; the ceiling stays Bun's, which names the range it refuses.
86
86
  const port = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
87
87
  // `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
@@ -15,7 +15,7 @@ import {
15
15
  startMetricExport,
16
16
  tryOtlpEndpoint,
17
17
  } from '@ultimat3/core';
18
- import type { Env } from './dev-services';
18
+ import type { Env } from './runtime-bindings';
19
19
 
20
20
  /** How often counters are pushed. Core's own default; named here because the boot chose it. */
21
21
  export const METRIC_EXPORT_INTERVAL_MS = 60_000;
package/src/output.ts CHANGED
@@ -13,6 +13,12 @@ export interface Finding {
13
13
  readonly docs?: string;
14
14
  /** Optional locator: a file, route, or table the finding is about. */
15
15
  readonly at?: string;
16
+ /**
17
+ * Where the finding's TEXT came from, when not from this process. `'ci-log'` is `x ci` reading
18
+ * a finding back out of a CI log that anyone who can push a branch writes: its `fix` is fenced
19
+ * and must never be run as this CLI's own instruction.
20
+ */
21
+ readonly source?: 'ci-log';
16
22
  }
17
23
 
18
24
  export interface StepResult {
@@ -0,0 +1,86 @@
1
+ // The `policy` step's second question: every permission an action, a query or a route REQUIRES is
2
+ // one some role GRANTS. `x g resource customer` declared `customer:read`/`customer:write`, the
3
+ // actions required them, no role held them, and `x verify` was green over an app whose every
4
+ // generated endpoint answered 403 to the dev actor (plan 101 slice 11 a).
5
+
6
+ import { listActions } from '@ultimat3/action';
7
+ import type { Policy } from '@ultimat3/policy';
8
+ import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
9
+ import { listQueries } from '@ultimat3/query';
10
+ import { routeEntries } from '@ultimat3/render';
11
+ import type { Finding } from './output';
12
+ import { quoteArg } from './shell-quote';
13
+
14
+ /** One rule that cannot pass for any actor the role map mints. */
15
+ export interface UngrantedRequirement {
16
+ readonly permission: string;
17
+ /** `action createCustomer`, `query customerList`, or the route file. */
18
+ readonly by: string;
19
+ }
20
+
21
+ /**
22
+ * A rule that REQUIRES exactly one permission: a bare `can()`. A composite is not judged — in an
23
+ * `or(...)` one branch may be granted and the other deliberately not, and a `not(...)` names a
24
+ * permission the actor must LACK — so a finding there would be an argument, not a fact.
25
+ */
26
+ const required = (policy: Policy<unknown, unknown>): string | undefined =>
27
+ policy.kind === 'permission' && policy.permissions.length === 1
28
+ ? policy.permissions[0]
29
+ : undefined;
30
+
31
+ /** Every single-permission rule the app's actions, queries and routes declare. */
32
+ export function requirements(): readonly UngrantedRequirement[] {
33
+ const rules: UngrantedRequirement[] = [];
34
+ for (const target of listActions()) {
35
+ const permission = required(target.policy);
36
+ if (permission !== undefined) rules.push({ permission, by: `action ${target.name}` });
37
+ }
38
+ for (const target of listQueries()) {
39
+ const permission = required(target.policy);
40
+ if (permission !== undefined) rules.push({ permission, by: `query ${target.name}` });
41
+ }
42
+ for (const entry of routeEntries()) {
43
+ const permission = entry.config.policy?.permission;
44
+ if (permission !== undefined) rules.push({ permission, by: entry.file });
45
+ }
46
+ return rules;
47
+ }
48
+
49
+ /**
50
+ * The requirements no role grants. `rolesGranting` is `@ultimat3/policy`'s own matcher, wildcards
51
+ * included, so the gate and `can()` agree on what a grant covers. An app that declares no role at
52
+ * all is not judged: it grants through `Actor.permissions` directly (an API key, a service actor),
53
+ * and there is no role map for a grant to be missing from.
54
+ */
55
+ export function ungrantedRequirements(
56
+ rules: readonly UngrantedRequirement[] = requirements(),
57
+ ): readonly UngrantedRequirement[] {
58
+ const roles = roleDefinitions();
59
+ if (Object.keys(roles).length === 0) return [];
60
+ const seen = new Set<string>();
61
+ return rules
62
+ .filter((rule) => rolesGranting(rule.permission, roles).length === 0)
63
+ .filter((rule) => {
64
+ const key = `${rule.permission} ${rule.by}`;
65
+ if (seen.has(key)) return false;
66
+ seen.add(key);
67
+ return true;
68
+ })
69
+ .sort((a, b) =>
70
+ a.permission === b.permission
71
+ ? a.by.localeCompare(b.by)
72
+ : a.permission.localeCompare(b.permission),
73
+ );
74
+ }
75
+
76
+ /** Where the scaffold keeps the role map — the file `x g policy` and `x g resource` edit. */
77
+ export const ROLES_FILE = 'apps/web/shared/roles.ts';
78
+
79
+ export function ungrantedFinding(rule: UngrantedRequirement): Finding {
80
+ return {
81
+ code: 'X_PERMISSION_UNGRANTED',
82
+ cause: `${rule.by} requires ${quoteArg(rule.permission)}, and no role in the app's role map grants it — no actor a role mints can ever pass`,
83
+ fix: `add '${rule.permission}' to the grants of a role in ${ROLES_FILE}, then x verify --only policy`,
84
+ at: ROLES_FILE,
85
+ };
86
+ }
@@ -0,0 +1,25 @@
1
+ // The export directory is the build's own, and emptied before every build: `prerenderSite` only
2
+ // ever ADDED files, so a route deleted from the app kept shipping its last HTML from `.x/static`.
3
+ // Refused when `out` is the app root or above it — an `rm -rf` there deletes the app.
4
+
5
+ import { rm } from 'node:fs/promises'; // why: Bun has no recursive directory delete.
6
+ import { isAbsolute, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
7
+ import { UltimateError } from '@ultimat3/core';
8
+
9
+ /** True when `dir` is `root` itself or one of its ancestors. */
10
+ const containsRoot = (dir: string, root: string): boolean => {
11
+ const up = relative(resolve(dir), resolve(root));
12
+ return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
13
+ };
14
+
15
+ /** Empties `out` for a fresh export, or refuses a directory that holds the app. */
16
+ export async function clearPrerenderOut(out: string, root: string): Promise<void> {
17
+ if (containsRoot(out, root)) {
18
+ throw new UltimateError({
19
+ code: 'X_CLI_BAD_FLAG',
20
+ cause: `the static export directory ${resolve(out)} is the app root or holds it, and every build empties its export directory first`,
21
+ fix: 'x build --target static --out .x/static --json',
22
+ });
23
+ }
24
+ await rm(out, { recursive: true, force: true });
25
+ }