@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
@@ -12,8 +12,18 @@ const helpers = (
12
12
  {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
13
13
  {{- end -}}
14
14
 
15
+ {{/*
16
+ helm create's rule: a release whose name already contains the chart's is the full name on its own.
17
+ x deploy --method helm names the release after the app, which IS the chart name, so without it
18
+ every object would be ${app.kebab}-${app.kebab}-web.
19
+ */}}
15
20
  {{- define "${app.kebab}.fullname" -}}
16
- {{- printf "%s-%s" .Release.Name (include "${app.kebab}.name" .) | trunc 63 | trimSuffix "-" -}}
21
+ {{- $name := include "${app.kebab}.name" . -}}
22
+ {{- if contains $name .Release.Name -}}
23
+ {{- .Release.Name | trunc 63 | trimSuffix "-" -}}
24
+ {{- else -}}
25
+ {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
26
+ {{- end -}}
17
27
  {{- end -}}
18
28
 
19
29
  {{/*
@@ -92,15 +102,38 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
92
102
  HTTP and get both. worker, scheduler and replicator open no HTTP socket at all — the scrape
93
103
  listener is their only port — so they take liveness on it and NO readiness: nothing routes to
94
104
  them, and a readiness flap would drop the pod out of the Service and so out of the scrape.
105
+
106
+ Every one of them takes a startupProbe too, because no listener opens early: the server builds
107
+ the app's islands before any role binds a port, and a liveness probe counting from container
108
+ start restarts a pod that is merely booting. 30 x 5s = 150s of boot, then the ordinary checks.
95
109
  */}}
96
110
  {{- if $cfg.port }}
111
+ startupProbe:
112
+ httpGet: { path: /healthz, port: http }
113
+ periodSeconds: 5
114
+ failureThreshold: 30
97
115
  readinessProbe:
98
116
  httpGet: { path: /readyz, port: http }
99
117
  periodSeconds: 5
100
118
  livenessProbe:
101
119
  httpGet: { path: /healthz, port: http }
102
120
  periodSeconds: 15
121
+ {{- /*
122
+ Holds SIGTERM back while the pod is already out of its Service's endpoints, so a proxy that has
123
+ not caught up still reaches a listener that answers. lifecycle.preStop.sleep exists from
124
+ Kubernetes 1.30 and this chart's floor is 1.27, so it renders only where the API server knows it;
125
+ below that the framework's own readiness grace (/readyz at 503, listener still open) covers it.
126
+ */}}
127
+ {{- if semverCompare ">=1.30-0" $root.Capabilities.KubeVersion.Version }}
128
+ lifecycle:
129
+ preStop:
130
+ sleep: { seconds: {{ $root.Values.drain.preStopSleepSeconds | int }} }
131
+ {{- end }}
103
132
  {{- else if $scraped }}
133
+ startupProbe:
134
+ httpGet: { path: /metrics, port: metrics }
135
+ periodSeconds: 5
136
+ failureThreshold: 30
104
137
  livenessProbe:
105
138
  httpGet: { path: /metrics, port: metrics }
106
139
  periodSeconds: 15
@@ -114,8 +147,9 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
114
147
  `;
115
148
 
116
149
  const deployments = (app: NameSet): string => `{{/*
117
- One Deployment per enabled role, from one image. terminationGracePeriodSeconds matches the
118
- framework's SIGTERM drain: in-flight requests, open websockets and running job steps finish.
150
+ One Deployment per enabled role, from one image. terminationGracePeriodSeconds covers the preStop
151
+ sleep, the framework's readiness grace and its SIGTERM drain: in-flight requests, open websockets
152
+ and running job steps finish.
119
153
  */}}
120
154
  {{- range $role, $cfg := .Values.roles }}
121
155
  {{- if $cfg.enabled }}
@@ -151,9 +185,11 @@ spec:
151
185
  code that reaches the filesystem, which for a web role is one path traversal. */}}
152
186
  automountServiceAccountToken: false
153
187
  securityContext: {{- toYaml $.Values.podSecurityContext | nindent 8 }}
154
- {{/* At least the framework's drain deadline (25s by default), which is what
155
- X_SHUTDOWN_TIMEOUT's fix line tells an operator. Raise this WITH configureLifecycle({
156
- deadlineMs }), never instead of it: the drain abandons its hooks at its own deadline. */}}
188
+ {{/* Spent in order: the preStop sleep (drain.preStopSleepSeconds, 1.30+), the framework's
189
+ readiness grace (5s outside local environments) and its drain deadline (25s, which is what
190
+ X_SHUTDOWN_TIMEOUT's fix line names) — 35s, so 45 leaves 10s before SIGKILL. Raise this WITH
191
+ configureLifecycle({ deadlineMs, readinessGraceMs }), never instead of it: the drain
192
+ abandons its hooks at its own deadline. */}}
157
193
  terminationGracePeriodSeconds: 45
158
194
  containers:
159
195
  {{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
@@ -259,12 +295,16 @@ spec:
259
295
  paths:
260
296
  {{- /* The path the sync node actually serves. Routing /_sync instead sends every
261
297
  websocket to the web role, which answers no upgrade. */}}
298
+ {{- /* Only when the sync role runs: a rule naming a Service the chart did not render is an
299
+ ingress that 503s every websocket instead of letting it reach nothing at all. */}}
300
+ {{- if .Values.roles.sync.enabled }}
262
301
  - path: /_x/sync
263
302
  pathType: Prefix
264
303
  backend:
265
304
  service:
266
305
  name: {{ include "${app.kebab}.fullname" . }}-sync
267
306
  port: { name: http }
307
+ {{- end }}
268
308
  - path: /
269
309
  pathType: Prefix
270
310
  backend:
@@ -277,8 +317,13 @@ spec:
277
317
  const hpa = (app: NameSet): string => `{{/*
278
318
  Per-role autoscalers, off until you turn one on. Each role scales on the signal that predicts ITS
279
319
  saturation — CPU is a lagging proxy for all three and scales the wrong thing at the wrong time.
280
- A Pods metric needs a metrics adapter in the cluster; without one the HPA reads <unknown> and
320
+ Either metric type needs a metrics adapter in the cluster; without one the HPA reads <unknown> and
281
321
  holds at minReplicas, which is why these are opt-in rather than a default nobody wired.
322
+
323
+ autoscaling.type says whose number it is. Pods (the default) is a per-pod series — rps, open
324
+ sockets — averaged across pods. External is ONE series for the whole role, divided by the replica
325
+ count: queue_depth is that shape, because every worker publishes the same global backlog, and read
326
+ as Pods it asked for N times the workers the queue needed. Any other type fails the render.
282
327
  */}}
283
328
  {{- range $role, $cfg := .Values.roles }}
284
329
  {{- if and $cfg.enabled $cfg.autoscaling $cfg.autoscaling.enabled }}
@@ -297,7 +342,20 @@ spec:
297
342
  name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
298
343
  minReplicas: {{ $cfg.autoscaling.minReplicas }}
299
344
  maxReplicas: {{ $cfg.autoscaling.maxReplicas }}
345
+ {{- $type := default "Pods" $cfg.autoscaling.type }}
346
+ {{- if not (has $type (list "Pods" "External")) }}
347
+ {{- fail (printf "roles.%s.autoscaling.type is %q — set it to Pods (a per-pod series) or External (one series for the whole role, e.g. queue_depth)" $role $type) }}
348
+ {{- end }}
300
349
  metrics:
350
+ {{- if eq $type "External" }}
351
+ - type: External
352
+ external:
353
+ metric:
354
+ name: {{ $cfg.autoscaling.metric }}
355
+ target:
356
+ type: AverageValue
357
+ averageValue: {{ $cfg.autoscaling.targetAverageValue | quote }}
358
+ {{- else }}
301
359
  - type: Pods
302
360
  pods:
303
361
  metric:
@@ -305,6 +363,7 @@ spec:
305
363
  target:
306
364
  type: AverageValue
307
365
  averageValue: {{ $cfg.autoscaling.targetAverageValue | quote }}
366
+ {{- end }}
308
367
  behavior:
309
368
  scaleUp:
310
369
  stabilizationWindowSeconds: 30
@@ -34,7 +34,7 @@ image:
34
34
  env:
35
35
  NODE_ENV: production
36
36
 
37
- existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET
37
+ existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET, ULTIMATE_CURSOR_SECRET
38
38
 
39
39
  # The scrape listener every serving role opens, on its own port and never the app's: the ingress
40
40
  # routes / to web, so /metrics beside /healthz on 3000 is /metrics on the internet. This is
@@ -51,6 +51,12 @@ securityContext:
51
51
  readOnlyRootFilesystem: true
52
52
  capabilities: { drop: [ALL] }
53
53
 
54
+ # The stop sequence: out of the Service, then this preStop sleep (Kubernetes 1.30+ only), then
55
+ # SIGTERM, then the framework's readiness grace and drain. terminationGracePeriodSeconds in
56
+ # templates/deployments.yaml is sized to all three, so move them together.
57
+ drain:
58
+ preStopSleepSeconds: 5
59
+
54
60
  # The release phase. Runs to completion before any serving role starts.
55
61
  migrate:
56
62
  enabled: true
@@ -63,8 +69,10 @@ migrate:
63
69
  # PORT: they differ for sync, which binds PORT + 1, and the chart derives the env from this number
64
70
  # so there is only ever one to move.
65
71
  #
66
- # Autoscaling is off until you wire a metrics adapter — each \`metric\` below is a Pods metric the
67
- # role exports, and an HPA with no adapter behind it reads <unknown> and holds at minReplicas.
72
+ # Autoscaling is off until you wire a metrics adapter — each \`metric\` below is a series the role
73
+ # exports, and an HPA with no adapter behind it reads <unknown> and holds at minReplicas.
74
+ # \`type\` is Pods (the default: a per-pod series averaged across pods) or External (one series for
75
+ # the whole role, divided by the replica count).
68
76
  roles:
69
77
  web:
70
78
  enabled: true
@@ -81,7 +89,14 @@ roles:
81
89
  targetAverageValue: "50"
82
90
 
83
91
  sync:
84
- enabled: true
92
+ # OFF by default. A sync pod on a real database hears committed changes only from a replicator
93
+ # it can reach, and a fresh deploy has none: booted anyway it is refused (X_REALTIME_TOPOLOGY).
94
+ # To turn live queries and channels on: declare realtime: { enabled: true, transport: 'nats',
95
+ # urlEnv: 'NATS_URL' } in app.config.ts (a NATS_URL under transport 'memory' is refused, and
96
+ # 'nats' without it is too); run NATS and set the SAME NATS_URL for web, sync and the
97
+ # replicator; enable exactly one replicator (below) against a Postgres started with
98
+ # wal_level=logical (the replicator creates its publication at boot) and a role with REPLICATION on a cluster dedicated to this app; then set this to true.
99
+ enabled: false
85
100
  replicas: 2
86
101
  port: 3001
87
102
  resources:
@@ -104,6 +119,10 @@ roles:
104
119
  enabled: false
105
120
  minReplicas: 1
106
121
  maxReplicas: 50
122
+ # External: every worker publishes the SAME global backlog, so averaged as a Pods metric it
123
+ # asked for N times the workers the queue needed. Expose it from your adapter deduplicated —
124
+ # max(queue_depth), never sum, which would multiply it by the pod count again.
125
+ type: External
107
126
  metric: queue_depth # jobs waiting, exported by the worker role
108
127
  targetAverageValue: "100"
109
128
 
@@ -112,9 +131,12 @@ roles:
112
131
  # Fixed 1, and a second replica is safe but pointless: leadership is an expiring row in
113
132
  # x_scheduler_leader, so the extra pod stands by.
114
133
  replicas: 1
134
+ # 512Mi, not 256Mi: a scaffolded app measured ~325 MiB at boot as ROLE=scheduler (2026-09-23),
135
+ # when every role still built the app's islands first; at 256Mi the pod was OOMKilled on every
136
+ # boot. Only web builds islands now, so the peak is lower but unmeasured, and the limit stays.
115
137
  resources:
116
138
  requests: { cpu: 50m, memory: 128Mi }
117
- limits: { memory: 256Mi }
139
+ limits: { memory: 512Mi }
118
140
 
119
141
  replicator:
120
142
  enabled: false # exactly one per database; enable when the change feed is in use
@@ -28,27 +28,31 @@ const i18nPackage = (app: NameSet, version: string): string => `{
28
28
  `;
29
29
 
30
30
  /**
31
- * `en` first, then every other locale alphabetically — a stable order so a diff shows only the
32
- * locale a run actually added, never a reshuffle. `en` is always included: `default: 'en'` below
33
- * requires it to be a registered locale, and every real catalog set already has one from `x new`
34
- * scaffold time.
31
+ * The default locale first, then every other one alphabetically — a stable order so a diff shows
32
+ * only the locale a run actually added. The default is `en` when the set holds it (and when the set
33
+ * is empty, which is `x new`), else the first tag: importing an `en.json` the app does not have is
34
+ * a file that does not compile.
35
35
  */
36
+ export const defaultLocaleOf = (locales: readonly string[]): string =>
37
+ locales.length === 0 || locales.includes('en') ? 'en' : ([...locales].sort()[0] ?? 'en');
38
+
36
39
  const orderedLocales = (locales: readonly string[]): readonly string[] => {
40
+ const first = defaultLocaleOf(locales);
37
41
  const rest = new Set(locales);
38
- rest.delete('en');
39
- return ['en', ...[...rest].sort()];
42
+ rest.delete(first);
43
+ return [first, ...[...rest].sort()];
40
44
  };
41
45
 
42
46
  /** A locale tag is not always a valid JS binding (`zh-hant`) — `camel()` is the one identifier
43
47
  * derivation every generated file already uses for names, so the import agrees with the rest of
44
48
  * the app's own naming instead of inventing a second casing rule. */
45
- const localeImport = (locale: string): string =>
49
+ export const localeImport = (locale: string): string =>
46
50
  `import ${camel(locale)} from '../catalogs/${locale}.json';`;
47
51
 
48
52
  /** The object-literal entry for one locale: shorthand when the binding IS the tag (`en`, `es`, …),
49
53
  * `'tag': binding` when `camel()` had to reshape it (`zh-hant` → `zhHant`) — `defineCatalogs` reads
50
54
  * the locale from the key, never the identifier, so the quoted form is what keeps it addressable. */
51
- const localeEntry = (locale: string): string => {
55
+ export const localeEntry = (locale: string): string => {
52
56
  const binding = camel(locale);
53
57
  return binding === locale ? binding : `'${locale}': ${binding}`;
54
58
  };
@@ -61,6 +65,7 @@ const localeEntry = (locale: string): string => {
61
65
  */
62
66
  export function i18nIndex(locales: readonly string[]): string {
63
67
  const ordered = orderedLocales(locales);
68
+ const fallback = defaultLocaleOf(locales);
64
69
  const imports = ordered.map(localeImport).join('\n');
65
70
  const entries = ordered.map(localeEntry).join(', ');
66
71
  return `// The app's catalog, registered once and typed against English. Every surface resolves strings
@@ -75,13 +80,13 @@ import {
75
80
  } from '@ultimat3/i18n';
76
81
  ${imports}
77
82
 
78
- export const catalogs = defineCatalogs({ default: 'en', locales: { ${entries} } });
83
+ export const catalogs = defineCatalogs({ default: '${fallback}', locales: { ${entries} } });
79
84
 
80
85
  /**
81
86
  * English is the source of truth for the key space — a second locale must match it exactly, or
82
87
  * \`x verify\` fails.
83
88
  */
84
- export type AppCatalog = typeof en;
89
+ export type AppCatalog = typeof ${camel(fallback)};
85
90
 
86
91
  /** Every key this app's catalog defines — dot-paths, plus the stem of each plural family. */
87
92
  export type TranslationKey = KeyOf<AppCatalog>;
@@ -64,9 +64,9 @@ const rootPackage = (app: NameSet, version: string): string => `{
64
64
  "dev": "x dev",
65
65
  "check": "bin/check",
66
66
  "verify": "x verify",
67
- "typecheck": "tsc -b --pretty",
67
+ "typecheck": "tsc -p . --pretty",
68
68
  "lint": "biome check .",
69
- "test": "bun test",
69
+ "test": "bun test --isolate",
70
70
  "db:migrate": "x db migrate",
71
71
  "db:seed": "x db seed"
72
72
  },
@@ -89,6 +89,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
89
89
  "@ultimat3/i18n": "^${version}",
90
90
  "@ultimat3/jobs": "^${version}",
91
91
  "@ultimat3/mcp": "^${version}",
92
+ "@ultimat3/money": "^${version}",
92
93
  "@ultimat3/policy": "^${version}",
93
94
  "@ultimat3/pwa": "^${version}",
94
95
  "@ultimat3/query": "^${version}",
@@ -106,14 +107,16 @@ const rootPackage = (app: NameSet, version: string): string => `{
106
107
 
107
108
  /**
108
109
  * `"incremental": true` is ONE line and it is the difference between a 4.9s typecheck and a 92s
109
- * one. `x verify`'s first step is `tsc -b`, and `-b` decides "up to date?" by comparing emitted
110
- * OUTPUTS against inputs — with `noEmit` and no `composite`/`references`, the output it looks for
111
- * is an `app.config.js` that will never exist (`Project 'tsconfig.json' is out of date because
112
- * output file 'app.config.js' does not exist`), so every run rebuilt the whole program from
113
- * scratch, forever. Measured on a 166-file scaffold with no source change between runs: 92s wall
114
- * / 43s user CPU without it, 4.9s / 8.8s warm with it, and the whole gate at 12s rather than
115
- * 24-71s. This was the only tree in the framework without incremental typechecking — the repo
116
- * root has 32 `references` and `examples/dummy/tsconfig.json` sets `composite`.
110
+ * one. Measured on a 166-file scaffold with no source change between runs: 92s wall / 43s user CPU
111
+ * without it, 4.9s / 8.8s warm with it.
112
+ *
113
+ * The typecheck is `tsc -p .`, never `-b`, `As of 2026-09-23` (#450) — here, in the scaffold's
114
+ * `typecheck` script, and in `x verify`'s step (`verify-typecheck.ts`, for any root without
115
+ * `references`). `-b` decides "up to date?" by MTIME, and Bun installs a dependency as hardlinks
116
+ * carrying its cache's old mtimes: upgrading to a version already in the cache left every input
117
+ * older than the buildinfo, so `-b` skipped the program and reported green while a cold CI was
118
+ * red. `-p` with `incremental` compares content hashes. `-b` also wanted an emitted
119
+ * `app.config.js` that `noEmit` never writes, which is why this line was first needed.
117
120
  *
118
121
  * The note lives HERE and not in the emitted file, for the reason `biome.json` below gives: an
119
122
  * app author has no use for eight lines of framework archaeology in their own tsconfig, and
@@ -4,9 +4,23 @@
4
4
  // many answers as the app has folders, and the framework's two tracked apps already disagree.
5
5
 
6
6
  import type { GeneratedFile } from './naming';
7
+ import { wrapList } from './wrap';
7
8
 
8
- const rolesSource =
9
- (): string => `// Who holds which permission, for the whole app. Roles are sugar: every one expands to a flat
9
+ /**
10
+ * A role's grants as Biome prints them. The example slice's \`post:read\`/\`post:write\` ride along
11
+ * with it: \`x g resource post\` would have granted them, and the scaffold writes that result.
12
+ */
13
+ const grants = (base: string, example: string | undefined): string =>
14
+ wrapList(
15
+ ' ',
16
+ 'grants: [',
17
+ [base, ...(example === undefined ? [] : [example])].map((grant) => `'${grant}'`),
18
+ '],',
19
+ );
20
+
21
+ const rolesSource = (
22
+ example: boolean,
23
+ ): string => `// Who holds which permission, for the whole app. Roles are sugar: every one expands to a flat
10
24
  // permission set before any policy runs, so a rule never reasons about the hierarchy.
11
25
  //
12
26
  // ONE file, and it lives in shared/ — the leaf both site/ and app/ already import, and the one the
@@ -16,8 +30,9 @@ const rolesSource =
16
30
  // role HERE; calling defineRoles() again from a feature folder works and is the drift this file
17
31
  // exists to prevent.
18
32
  //
19
- // \`x g policy <feature>\` declares \`<feature>:read\` and \`<feature>:write\`. Granting them is this
20
- // file's job — a permission no role holds is one no actor can ever exercise.
33
+ // \`x g policy <feature>\` declares \`<feature>:read\` and \`<feature>:write\` and grants them below —
34
+ // read to member, write to admin. A permission no role holds is one no actor can ever exercise,
35
+ // and \`x verify\`'s policy step refuses it (X_PERMISSION_UNGRANTED).
21
36
 
22
37
  import { definePermissions, defineRoles } from '@ultimat3/policy';
23
38
 
@@ -32,11 +47,11 @@ export const appPermissions = definePermissions(['admin:read', 'dashboard:read']
32
47
  export const roles = defineRoles({
33
48
  member: {
34
49
  description: 'Signed in. Reads the app surface.',
35
- grants: ['dashboard:read'],
50
+ ${grants('dashboard:read', example ? 'post:read' : undefined)}
36
51
  },
37
52
  admin: {
38
53
  description: 'Runs the app: the /admin surface, plus everything a member may do.',
39
- grants: ['admin:read'],
54
+ ${grants('admin:read', example ? 'post:write' : undefined)}
40
55
  inherits: ['member'],
41
56
  },
42
57
  });
@@ -49,12 +64,25 @@ import { expandRoles, isKnownPermission, rolesGranting } from '@ultimat3/policy'
49
64
  import { expect, unitTest } from '@ultimat3/testing';
50
65
  import { appPermissions, roles } from './roles';
51
66
 
67
+ // Every feature's policy.ts, imported the way the boot scan imports it: \`x g policy\` grants the
68
+ // permissions a policy.ts DECLARES, so the registry below is only whole once they have run.
69
+ const features = new Bun.Glob('{app,site}/*/policy.ts');
70
+ for await (const file of features.scan({ cwd: \`\${import.meta.dir}/..\` })) {
71
+ await import(\`\${import.meta.dir}/../\${file}\`);
72
+ }
73
+
52
74
  // The map is passed explicitly rather than read off the module-global one: a test that depended on
53
75
  // which module imported first would pass alone and fail inside a suite.
54
76
 
77
+ // Subsets, never exact lists: every \`x g policy\` adds a grant here, and a pinned list would make
78
+ // the generator's correct edit a red test.
55
79
  unitTest('admin inherits every member grant and adds its own', () => {
56
- expect(expandRoles(['member'], roles)).toEqual(['dashboard:read']);
57
- expect(expandRoles(['admin'], roles)).toEqual(['admin:read', 'dashboard:read']);
80
+ const member = expandRoles(['member'], roles);
81
+ const admin = expandRoles(['admin'], roles);
82
+ expect(member).toContain('dashboard:read');
83
+ expect(member).not.toContain('admin:read');
84
+ expect(admin).toContain('admin:read');
85
+ for (const permission of member) expect(admin).toContain(permission);
58
86
  });
59
87
 
60
88
  unitTest('a role nobody declared grants nothing', () => {
@@ -88,9 +116,9 @@ unitTest('the routes this app ships require permissions this app declares', () =
88
116
  `;
89
117
 
90
118
  /** `apps/web/shared/roles.ts` and its test. Written by `x new`, with or without the example slice. */
91
- export function rolesFiles(): readonly GeneratedFile[] {
119
+ export function rolesFiles(example = false): readonly GeneratedFile[] {
92
120
  return [
93
- { path: 'apps/web/shared/roles.ts', contents: rolesSource() },
121
+ { path: 'apps/web/shared/roles.ts', contents: rolesSource(example) },
94
122
  { path: 'apps/web/shared/roles.test.ts', contents: rolesTest() },
95
123
  ];
96
124
  }
@@ -3,7 +3,7 @@
3
3
  // `policyFiles`; the five generators that write *into* a slice imported the same files and wrote
4
4
  // none of them, so each emitted TS2307 in any slice a resource had not been run in first.
5
5
 
6
- import { stripComments } from '../ts-scan';
6
+ import { stripComments } from '@ultimat3/core';
7
7
  import type { FeatureTarget } from './entity';
8
8
  import { entityFiles } from './entity';
9
9
  import type { GeneratedFile, NameSet } from './naming';
@@ -80,7 +80,10 @@ const compareSpecifiers = (left: string, right: string): number => {
80
80
  * than spelled in each template, because the correct spelling is not knowable at authoring time.
81
81
  */
82
82
  export const sortSpecifiers = (names: readonly string[]): readonly string[] =>
83
- [...names].sort(compareSpecifiers);
83
+ [...names].sort((left, right) => compareSpecifiers(bare(left), bare(right)));
84
+
85
+ /** An inline \`type X\` sorts as \`X\`: Biome orders by the imported name, never by the modifier. */
86
+ const bare = (name: string): string => (name.startsWith('type ') ? name.slice(5) : name);
84
87
 
85
88
  /**
86
89
  * A named import, sorted and wrapped. Its own function because the braces are spaced on one line
@@ -3,8 +3,9 @@
3
3
  // this file decides how many, and `test-shards.ts` spends them.
4
4
 
5
5
  import type { TestFile } from './test-select';
6
+ import { SERIAL_TYPES } from './test-workers';
6
7
  import type { TestType } from './verify-tests';
7
- import { ownerOf, SERIAL_TYPES } from './verify-tests';
8
+ import { ownerOf } from './verify-tests';
8
9
 
9
10
  /** One `bun test` invocation: which files, how wide, and the type its reproduce line names. */
10
11
  export interface TestPass {
@@ -5,6 +5,7 @@
5
5
 
6
6
  // Bun ships no CPU-count primitive; `cpus()` is the fallback when navigator cannot answer.
7
7
  import { cpus } from 'node:os';
8
+ import type { TestType } from '@ultimat3/testing';
8
9
 
9
10
  /** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
10
11
  export function availableCpus(): number {
@@ -48,3 +49,28 @@ export const WORKER_FLOOR = 2;
48
49
 
49
50
  export const defaultWorkers = (available: number = availableCpus()): number =>
50
51
  Math.max(WORKER_FLOOR, Math.min(WORKER_CEILING, Math.round(available * WORKER_OVERSUBSCRIBE)));
52
+
53
+ /**
54
+ * Which types run across worker processes, and why the other two cannot.
55
+ *
56
+ * Parallel is safe when the only thing a test file shares with another file is the database, and
57
+ * the database is per worker by construction (`ULTIMATE_TEST_WORKER` → one clone of the migrated
58
+ * template, `@ultimat3/testing`'s `acquireWorkerDatabase`). Every other process-global in this
59
+ * framework — the permission set, the roles, the entity/action/query registries, the error-code
60
+ * titles, the fixture bag — is handled by `--isolate` giving each FILE its own module registry.
61
+ *
62
+ * | Type | Why |
63
+ * |---|---|
64
+ * | `live` | **serial.** A logical replication slot and a publication are named at the Postgres
65
+ * CLUSTER level, not inside a database, and this repo's own feed tests hard-code
66
+ * `x_live_slot` / `x_live_pub` against `TEST_REPLICATION_URL` — the one server, never a per-worker
67
+ * clone. Two workers would race `pg_create_logical_replication_slot` and the loser's failure would
68
+ * read as a flake. A per-worker database does not isolate a cluster-wide object |
69
+ * | `e2e` | **serial.** It runs against the *built output*: one `dist/`, one service-worker
70
+ * registration, one browser profile. There is nothing per-worker to hand it, and the type is
71
+ * seconds at most, so a split would buy a race and no time |
72
+ *
73
+ * The two are named here rather than tested for, because "can this type be sharded?" is a design
74
+ * fact about the type, not something a run can discover about itself.
75
+ */
76
+ export const SERIAL_TYPES: readonly TestType[] = ['live', 'e2e'];
package/src/ts-scan.ts CHANGED
@@ -30,14 +30,11 @@ export interface CodeScan {
30
30
  readonly unresolved: readonly UnresolvedCodeSite[];
31
31
  }
32
32
 
33
- // The masking itself — `QUOTES`, `endOfLiteral`, `stripComments`, `maskLiterals` — lives in
34
- // `@ultimat3/core`'s `source-mask.ts` since 2026-09-05, because `@ultimat3/i18n`'s key extractor
35
- // (tier 1) needed it and could not reach a tier-5 package. Re-exported here so every scanner in
36
- // this package, and this package's public API, keep the names they had.
33
+ // The masking itself — `QUOTES`, `endOfLiteral`, `stripComments`, `maskLiterals` — is
34
+ // `@ultimat3/core`'s (`source-mask.ts`), imported from there by every scanner. It was re-exported
35
+ // here and from this package's barrel: two import paths for one function (22.0.0 removed both).
37
36
  import { endOfLiteral, maskLiterals, QUOTES, stripComments } from '@ultimat3/core';
38
37
 
39
- export { endOfLiteral, maskLiterals, QUOTES, stripComments };
40
-
41
38
  // `ReadonlySet`, so a consumer cannot mutate what every scan in this package reads.
42
39
  export const OPENERS: ReadonlySet<string> = new Set(['(', '[', '{']);
43
40
  export const CLOSERS: ReadonlySet<string> = new Set([')', ']', '}']);
@@ -7,9 +7,8 @@
7
7
  // root that has no `tsconfig.json` at all, so an `existsSync` ahead of it was a second question
8
8
  // with one answer.
9
9
  import { join } from 'node:path';
10
- import { ERROR_DOCS_URL } from '@ultimat3/core';
10
+ import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
11
11
  import type { Finding } from './output';
12
- import { maskLiterals, stripComments } from './ts-scan';
13
12
 
14
13
  const ROOT_TSCONFIG = 'tsconfig.json';
15
14