@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/output.ts CHANGED
@@ -37,7 +37,12 @@ export interface StepResult {
37
37
  readonly durationMs: number;
38
38
  readonly skipped?: boolean;
39
39
  readonly findings: readonly Finding[];
40
- /** Captured stdout/stderr, shown on failure or with --verbose. */
40
+ /**
41
+ * Captured stdout/stderr, for a reader working out what a step DID — carried by both renderers
42
+ * on failure or with `--verbose`, and by neither on a quiet green run. Never the home of a fact:
43
+ * a number a passing step must report by default goes in `CommandResult.data` (the `unit` step's
44
+ * coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
45
+ */
41
46
  readonly output?: string;
42
47
  /** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
43
48
  readonly workers?: number;
@@ -208,6 +213,10 @@ const why = (step: StepResult): string => {
208
213
  return ` ${msg('cli.verify.allSkipped', { skipped: tests.skipped })}`;
209
214
  };
210
215
 
216
+ /** The one rule for a step's captured output, so the two renderers cannot answer differently. */
217
+ const showsOutput = (step: StepResult, verbose: boolean): boolean =>
218
+ step.output !== undefined && step.output.length > 0 && (verbose || !step.ok);
219
+
211
220
  export function renderHuman(result: CommandResult, verbose = false): string {
212
221
  const out: string[] = [];
213
222
  for (const step of result.steps ?? []) {
@@ -218,8 +227,8 @@ export function renderHuman(result: CommandResult, verbose = false): string {
218
227
  // NOT escaped, and that is the one exception: `output` is this process's own captured
219
228
  // subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
220
229
  // already split on its real newlines rather than carrying them inside one entry.
221
- if (step.output !== undefined && step.output.length > 0 && (verbose || !step.ok)) {
222
- for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
230
+ if (showsOutput(step, verbose)) {
231
+ for (const line of (step.output ?? '').trimEnd().split('\n')) out.push(` | ${line}`);
223
232
  }
224
233
  }
225
234
  // Every free-text line through the SAME `singleLine` the 3-line format runs, because this is
@@ -234,7 +243,7 @@ export function renderHuman(result: CommandResult, verbose = false): string {
234
243
  return out.join('\n');
235
244
  }
236
245
 
237
- export function renderJson(result: CommandResult): string {
246
+ export function renderJson(result: CommandResult, verbose = false): string {
238
247
  const steps = (result.steps ?? []).map((step) => ({
239
248
  name: step.name,
240
249
  ok: step.ok,
@@ -261,10 +270,10 @@ export function renderJson(result: CommandResult): string {
261
270
  // fix line — the failing test's name and its assertion diff existed and were thrown away, so
262
271
  // the only way to learn what broke was to re-run it somewhere else. CI output is a prompt:
263
272
  // whoever reads it next, agent or human, must be able to act without reproducing first.
264
- // Success stays quiet (`--verbose` is the human's opt-in) so a green run is not a wall of text.
265
- ...(step.ok || step.output === undefined || step.output.length === 0
266
- ? {}
267
- : { output: step.output }),
273
+ // Success stays quiet so a green run is not a wall of text — unless `--verbose` asked, which
274
+ // is the same opt-in the terminal honours: a green step's output used to be reachable from the
275
+ // human render and from nowhere under `--json`.
276
+ ...(showsOutput(step, verbose) ? { output: step.output } : {}),
268
277
  }));
269
278
  const payload = {
270
279
  ok: result.ok,
@@ -278,7 +287,7 @@ export function renderJson(result: CommandResult): string {
278
287
  }
279
288
 
280
289
  export function render(result: CommandResult, json: boolean, verbose = false): string {
281
- return json ? renderJson(result) : renderHuman(result, verbose);
290
+ return json ? renderJson(result, verbose) : renderHuman(result, verbose);
282
291
  }
283
292
 
284
293
  export function exitCodeFor(result: CommandResult): number {
@@ -0,0 +1,124 @@
1
+ // Speculation Rules for the documents that carry NO client router, composed ONCE for `x dev`, the
2
+ // container and the static export: `navigation.speculation` from `app.config.ts`, the pages the
3
+ // route table knows to be a pure read, the one `<script type="speculationrules">` every such
4
+ // document carries, and the `script-src` source that admits exactly that body. A browser fetches
5
+ // the next page's document on pointer rest, so a full-page navigation between prerendered pages
6
+ // paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
7
+
8
+ // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
9
+ // necessity `theme-boot.ts` records.
10
+ import { join } from 'node:path';
11
+ import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
12
+ import { DEFAULT_SPECULATION, localeSegment, resolveSpeculation } from '@ultimat3/core';
13
+ import { cspHashSource } from '@ultimat3/http';
14
+ import { localeConfig, routedLocales } from '@ultimat3/i18n';
15
+ import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
16
+ import {
17
+ describeRoutes,
18
+ renderHead,
19
+ routeEntries,
20
+ speculationPattern,
21
+ speculationRulesBody,
22
+ speculationRulesTag,
23
+ } from '@ultimat3/render';
24
+ import { APP_CONFIG_EXPORT } from './app-auth';
25
+ import { APP_CONFIG_FILE } from './app-root';
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ typeof value === 'object' && value !== null;
29
+
30
+ /**
31
+ * `navigation.speculation` — the default (on, `'moderate'`) when the app says nothing or has no
32
+ * config file. Structural, for `loadThemeMode`'s reason, and so possibly NOT what `defineConfig`
33
+ * validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
34
+ * REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
35
+ * quietly served as `'moderate'` is a rule the app did not write.
36
+ */
37
+ export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
38
+ const configPath = join(root, APP_CONFIG_FILE);
39
+ if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
40
+ const module = (await import(configPath)) as Record<string, unknown>;
41
+ const config = module[APP_CONFIG_EXPORT];
42
+ const navigation = isRecord(config) ? config['navigation'] : undefined;
43
+ return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
44
+ }
45
+
46
+ /**
47
+ * Whether a browser may fetch this page before anyone asked for it. An ALLOW-list, never "every
48
+ * link but these": a prefetch is a real GET with the visitor's cookies, so the only safe candidates
49
+ * are pages the table itself says are a pure read.
50
+ *
51
+ * - On a surface WITHOUT the client router: a prerendered, shareable document — `static` or `isr`,
52
+ * no policy, no `no-store`. Such a page ran its `load` at build time, for nobody. An `ssr` page
53
+ * there has no way to declare that its GET records something (`navigation` is refused on a
54
+ * surface that did not opt in), so it is never a candidate.
55
+ * - On a client-routed surface: only a page that declared `navigation: 'prefetch'` — the app's own
56
+ * statement that its GET is a pure read, the same one the router's prefetch obeys.
57
+ * - Never an `api/` route, a `navigation: 'document'` page, or an `offline: 'network-only'` one.
58
+ */
59
+ export function speculationCandidate(
60
+ entry: Pick<RouteEntry, 'surface' | 'config'>,
61
+ described: Pick<RouteDescriptor, 'mode' | 'offline' | 'personal'>,
62
+ client: ReadonlySet<string>,
63
+ ): boolean {
64
+ if (entry.surface !== 'site' && entry.surface !== 'app') return false;
65
+ if (entry.config.navigation === 'document' || described.offline === 'network-only') return false;
66
+ if (client.has(entry.surface)) return entry.config.navigation === 'prefetch';
67
+ return (described.mode === 'static' || described.mode === 'isr') && !described.personal;
68
+ }
69
+
70
+ export interface PageSpeculation {
71
+ /** The rendered `<script type="speculationrules">`, for `DocumentOptions.speculationHead`. */
72
+ readonly head: string;
73
+ /** The text between its tags — what a browser hashes. */
74
+ readonly body: string;
75
+ /** The `script-src` source that admits exactly that body. */
76
+ readonly cspSource: string;
77
+ }
78
+
79
+ export interface PageSpeculationInput {
80
+ readonly config: SpeculationConfig;
81
+ /** `navigation.client`: the surfaces whose documents carry the router instead. */
82
+ readonly client: readonly NavigationSurface[];
83
+ readonly entries?: readonly RouteEntry[];
84
+ readonly described?: readonly RouteDescriptor[];
85
+ /** The NON-default routed locales' URL segments. Read from `@ultimat3/i18n` when absent. */
86
+ readonly localeSegments?: readonly string[];
87
+ }
88
+
89
+ const otherLocaleSegments = (): readonly string[] => {
90
+ const fallback = localeSegment(localeConfig().fallback);
91
+ return routedLocales()
92
+ .map(localeSegment)
93
+ .filter((segment) => segment !== fallback);
94
+ };
95
+
96
+ /**
97
+ * Tag and hash from ONE body, as `themeBoot` does. `undefined` when the app turned it off or no
98
+ * page is a candidate — then no document carries a tag and the policy admits nothing new. Called
99
+ * after the app is loaded: the route table and the routed locales are what it reads.
100
+ */
101
+ export function pageSpeculation(input: PageSpeculationInput): PageSpeculation | undefined {
102
+ if (input.config.prefetch === false) return undefined;
103
+ const entries = input.entries ?? routeEntries();
104
+ const described = new Map((input.described ?? describeRoutes()).map((one) => [one.file, one]));
105
+ const client = new Set<string>(input.client);
106
+ const segments = input.localeSegments ?? otherLocaleSegments();
107
+ const include = entries
108
+ .filter((entry) => {
109
+ const facts = described.get(entry.file);
110
+ return facts !== undefined && speculationCandidate(entry, facts, client);
111
+ })
112
+ .map((entry) => speculationPattern(entry.path, segments));
113
+ const body = speculationRulesBody({
114
+ eagerness: input.config.prefetch,
115
+ include,
116
+ exclude: input.config.exclude,
117
+ });
118
+ if (body === undefined) return undefined;
119
+ return {
120
+ head: renderHead([speculationRulesTag(body)]),
121
+ body,
122
+ cspSource: cspHashSource(body),
123
+ };
124
+ }
@@ -1,5 +1,5 @@
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
1
+ // The `policy` step's second question: every permission an action, a query, a route or a MOUNTED
2
+ // screen (the admin's) REQUIRES is one some role GRANTS. `x g resource customer` declared `customer:read`/`customer:write`, the
3
3
  // actions required them, no role held them, and `x verify` was green over an app whose every
4
4
  // generated endpoint answered 403 to the dev actor (plan 101 slice 11 a).
5
5
 
@@ -7,14 +7,14 @@ import { listActions } from '@ultimat3/action';
7
7
  import type { Policy } from '@ultimat3/policy';
8
8
  import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
9
9
  import { listQueries } from '@ultimat3/query';
10
- import { routeEntries } from '@ultimat3/render';
10
+ import { describeRoutes, routeEntries } from '@ultimat3/render';
11
11
  import type { Finding } from './output';
12
12
  import { quoteArg } from './shell-quote';
13
13
 
14
14
  /** One rule that cannot pass for any actor the role map mints. */
15
15
  export interface UngrantedRequirement {
16
16
  readonly permission: string;
17
- /** `action createCustomer`, `query customerList`, or the route file. */
17
+ /** `action createCustomer`, `query customerList`, the route file, or a mounted route's URL. */
18
18
  readonly by: string;
19
19
  }
20
20
 
@@ -43,6 +43,28 @@ export function requirements(): readonly UngrantedRequirement[] {
43
43
  const permission = entry.config.policy?.permission;
44
44
  if (permission !== undefined) rules.push({ permission, by: entry.file });
45
45
  }
46
+ // A route a package MOUNTED — the admin's — is in no file and so in no `routeEntries()` row. Its
47
+ // screen decides on every permission of the mount, not only the coarse gate its route config
48
+ // carries, so each one is a requirement: a hand-written entity nobody granted `<table>:read`
49
+ // for was a green gate and a 403 on its first open. `describeRoutes()` is memoized by the
50
+ // registry, so this loop reads a list every other step already built.
51
+ // One requirement per permission per mount, naming the first route that asks it: `posts:write`
52
+ // gates the create form and the edit form, and one missing grant is one finding.
53
+ const asked = new Map<string, { permission: string; by: string; first: string; more: number }>();
54
+ for (const route of describeRoutes()) {
55
+ if (route.mount === undefined) continue;
56
+ for (const permission of route.mount.permissions) {
57
+ const key = `${route.mount.by} ${permission}`;
58
+ const held = asked.get(key);
59
+ if (held === undefined) {
60
+ asked.set(key, { permission, by: route.mount.by, first: route.path, more: 0 });
61
+ } else held.more += 1;
62
+ }
63
+ }
64
+ for (const { permission, by, first, more } of asked.values()) {
65
+ const rest = more === 0 ? '' : `, and ${String(more)} more of its routes`;
66
+ rules.push({ permission, by: `${first} (mounted by ${by}${rest})` });
67
+ }
46
68
  return rules;
47
69
  }
48
70
 
@@ -73,14 +95,24 @@ export function ungrantedRequirements(
73
95
  );
74
96
  }
75
97
 
76
- /** Where the scaffold keeps the role map — the file `x g policy` and `x g resource` edit. */
98
+ /**
99
+ * Where the SCAFFOLD keeps the role map — the file `x g policy` and `x g resource` edit. Never what
100
+ * a finding names: an app may declare its roles anywhere (the reference app's are in
101
+ * `shared/policies.ts`), so `ungrantedFinding` takes the file the role map was declared in.
102
+ */
77
103
  export const ROLES_FILE = 'apps/web/shared/roles.ts';
78
104
 
79
- export function ungrantedFinding(rule: UngrantedRequirement): Finding {
80
- return {
105
+ /**
106
+ * `rolesAt` is the app-relative file `defineRoles` ran in (`app-permissions.ts`'s `roleMapFile`),
107
+ * or `undefined` when no site under the app root can be read — then the fix names the call, not a
108
+ * path this function would have to guess.
109
+ */
110
+ export function ungrantedFinding(rule: UngrantedRequirement, rolesAt: string | undefined): Finding {
111
+ const where = rolesAt ?? 'the module that calls defineRoles({ ... })';
112
+ const finding: Finding = {
81
113
  code: 'X_PERMISSION_UNGRANTED',
82
114
  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,
115
+ fix: `add '${rule.permission}' to the grants of a role in ${where}, then x verify --only policy`,
85
116
  };
117
+ return rolesAt === undefined ? finding : { ...finding, at: rolesAt };
86
118
  }
package/src/prerender.ts CHANGED
@@ -28,6 +28,7 @@ import { measureDatabase } from './measure-database';
28
28
  import { measurePaths } from './measure-paths';
29
29
  import { measureScope, withAppUrl } from './measure-scope';
30
30
  import { loadNavigation, pageNavigation } from './page-navigation';
31
+ import { loadSpeculation, pageSpeculation } from './page-speculation';
31
32
  import { localizedArtifacts } from './prerender-locales';
32
33
  import { clearPrerenderOut } from './prerender-out';
33
34
  import { loadPwaArtifacts, writePwaIcons } from './pwa-artifacts';
@@ -237,11 +238,13 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
237
238
  // The client router, for the surfaces that opted in: written BEFORE any document names it, for
238
239
  // the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
239
240
  // the route (the interactivity the app asked for), so it must be there to be weighed.
240
- const navigation = await pageNavigation(
241
- options.root,
242
- await loadNavigation(options.root),
243
- buildId,
244
- );
241
+ const declared = await loadNavigation(options.root);
242
+ const navigation = await pageNavigation(options.root, declared, buildId);
243
+ // The same rules the served process writes, so the exported file and the served document agree.
244
+ const speculation = pageSpeculation({
245
+ config: await loadSpeculation(options.root),
246
+ client: declared.surfaces,
247
+ });
245
248
  if (navigation.script !== undefined) {
246
249
  await Bun.write(join(options.out, navigation.script.url.slice(1)), navigation.script.code);
247
250
  }
@@ -305,6 +308,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
305
308
  routeDocument(entry, data, {
306
309
  resolveIsland: (file: string) => islands.resolverFor(file),
307
310
  themeHead: theme.head,
311
+ ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
308
312
  origin,
309
313
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
310
314
  ...(pwa === undefined
@@ -0,0 +1,101 @@
1
+ // `package-shape`'s other half of "a fixture does not ship": what a published package's ENTRY
2
+ // POINTS can load, held against the files its tarball leaves out. `files` negates
3
+ // `src/**/*-fixture.ts`, so a module an entry reaches under that suffix is a published package
4
+ // that fails on import — and the defect is the name, not the exclusion.
5
+
6
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
7
+ import type { ClosureHost } from './import-closure';
8
+ import { importClosure } from './import-closure';
9
+ import type { Finding } from './output';
10
+
11
+ /** The suffix `FIXTURE_EXCLUSION` removes from a tarball. */
12
+ export const FIXTURE_SUFFIX = '-fixture.ts';
13
+
14
+ /** A package directory, read by package-relative POSIX path. */
15
+ export type PackageReader = Pick<ClosureHost, 'read'>;
16
+
17
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
18
+ typeof value === 'object' && value !== null;
19
+
20
+ /** Every string under `exports` / `bin`, whatever the nesting: a condition map is still a file. */
21
+ const targetsOf = (value: unknown): readonly string[] => {
22
+ if (typeof value === 'string') return [value];
23
+ return isRecord(value) ? Object.values(value).flatMap(targetsOf) : [];
24
+ };
25
+
26
+ /**
27
+ * The modules a consumer can import or run: `exports` and `bin`, package-relative. A subpath
28
+ * pattern (`./icons/*`) names no one file and a stylesheet is not a module, so neither is an
29
+ * entry to walk from.
30
+ */
31
+ export function entryFilesOf(manifest: unknown): readonly string[] {
32
+ if (!isRecord(manifest)) return [];
33
+ const targets = [...targetsOf(manifest['exports']), ...targetsOf(manifest['bin'])];
34
+ const files = targets
35
+ .map((target) => target.replace(/^\.\//, ''))
36
+ .filter((target) => /\.tsx?$/.test(target) && !target.includes('*'));
37
+ return [...new Set(files)].sort();
38
+ }
39
+
40
+ /**
41
+ * Every `*-fixture.ts` whose body an entry can run. Bare specifiers end the walk: another package
42
+ * is that package's tarball, held by its own run of this rule.
43
+ */
44
+ export function fixturesReachedFrom(
45
+ reader: PackageReader,
46
+ entries: readonly string[],
47
+ ): readonly string[] {
48
+ const host: ClosureHost = { read: reader.read, alias: () => undefined };
49
+ return importClosure(host, entries).filter((path) => path.endsWith(FIXTURE_SUFFIX));
50
+ }
51
+
52
+ /** `src/a/driver-fixture.ts` → `driver-fixture`; an `index.ts` is named by its directory. */
53
+ const stemOf = (path: string): string => {
54
+ const parts = path.replace(/\.tsx?$/, '').split('/');
55
+ const last = parts.at(-1) ?? '';
56
+ return last === 'index' ? (parts.at(-2) ?? last) : last;
57
+ };
58
+
59
+ const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+
61
+ /**
62
+ * The cheap question, asked before the walk: COULD an entry reach a fixture? Backwards from each
63
+ * fixture, by the last segment of a relative specifier, over text alone — so it over-answers (a
64
+ * type-only import, two files of one name) and never under-answers. `false` is final; `true`
65
+ * earns the real closure. Measured 2026-10: the closure alone was ~650 ms across the 12 packages
66
+ * that hold a fixture, and all but one of them are answered here.
67
+ */
68
+ export function mayReachFixture(
69
+ sources: ReadonlyMap<string, string>,
70
+ entries: readonly string[],
71
+ ): boolean {
72
+ const entrySet = new Set(entries);
73
+ const seen = new Set<string>();
74
+ let frontier = [...sources.keys()].filter((path) => path.endsWith(FIXTURE_SUFFIX));
75
+ while (frontier.length > 0) {
76
+ const names = new RegExp(
77
+ `/(?:${frontier.map(stemOf).map(escaped).join('|')})(?:\\.[jt]sx?)?['"]`,
78
+ );
79
+ const next: string[] = [];
80
+ for (const [path, text] of sources) {
81
+ if (seen.has(path) || path.endsWith(FIXTURE_SUFFIX) || !names.test(text)) continue;
82
+ if (entrySet.has(path)) return true;
83
+ seen.add(path);
84
+ next.push(path);
85
+ }
86
+ frontier = next;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ export const fixtureReachFinding = (
92
+ dir: string,
93
+ fixture: string,
94
+ entries: readonly string[],
95
+ ): Finding => ({
96
+ code: 'X_PACKAGE_SHAPE',
97
+ cause: `packages/${dir}/${fixture} is loaded from the package's entry points (${entries.join(', ')}), and "files" leaves every *${FIXTURE_SUFFIX} out of the tarball — the published package would fail on import`,
98
+ fix: `rename packages/${dir}/${fixture} so it does not end in ${FIXTURE_SUFFIX} — it is shipped source, not test code — and update the imports that name it`,
99
+ docs: ERROR_DOCS_URL,
100
+ at: `packages/${dir}/${fixture}`,
101
+ });
@@ -0,0 +1,143 @@
1
+ // What a container role imports of the app. `web`, `sync` and `replicator` answer for registries a
2
+ // request, a subscription or the WAL names — routes, queries, entities — so they import every
3
+ // module. `worker` and `scheduler` run jobs and tasks: they import the API index and every module
4
+ // that brings no document (a component, a stylesheet) with it, and nothing else.
5
+
6
+ // why: Bun ships no path API; the API index is named app-root-relative and imported absolute.
7
+ import { join, resolve } from 'node:path';
8
+ import type { Role } from '@ultimat3/core';
9
+ import { isJsonObject, logger, UltimateError } from '@ultimat3/core';
10
+ import { registeredJobs, registeredTasks } from '@ultimat3/jobs';
11
+ import type { AppScan } from './app-load';
12
+ import { scanAppModules } from './app-load';
13
+ import { API_INDEX } from './app-root';
14
+ import { createDocumentGraph } from './document-graph';
15
+
16
+ /** `everything`: the scan every tool runs. `background`: no document a job does not import. */
17
+ export type RoleLoad = 'everything' | 'background';
18
+
19
+ /**
20
+ * Typed over `Role`, so a seventh role does not compile until somebody decides what it imports.
21
+ * `migrate` never reaches a load — `runMigrations` reads SQL files — and says `everything` so a
22
+ * caller that boots it by hand is given the whole app rather than a guess.
23
+ */
24
+ const DECLARED: Readonly<Record<Role, RoleLoad>> = {
25
+ web: 'everything',
26
+ // A subscription names a live query, and a page module may export one.
27
+ sync: 'everything',
28
+ // The slot decodes every table an entity declares, wherever the entity is declared.
29
+ replicator: 'everything',
30
+ worker: 'background',
31
+ scheduler: 'background',
32
+ migrate: 'everything',
33
+ };
34
+
35
+ /** A `Map`, so the read below is an own-key lookup — never a member of `Object.prototype`. */
36
+ export const ROLE_LOADS: ReadonlyMap<Role, RoleLoad> = new Map(
37
+ Object.entries(DECLARED) as [Role, RoleLoad][],
38
+ );
39
+
40
+ /** What `role` imports. A value that is no role — an untyped caller's — is given everything. */
41
+ export const roleLoadFor = (role: Role): RoleLoad => ROLE_LOADS.get(role) ?? 'everything';
42
+
43
+ /** The file the gate holds fresh (`manifest` step) and every image carries beside the source. */
44
+ const MANIFEST_FILE = 'x.manifest.json';
45
+
46
+ /**
47
+ * A background load that left out a job or task the committed manifest names. Logged, never
48
+ * thrown: the boot answers it by importing the rest of the app, so the role runs everything it
49
+ * ran before — it only pays for the documents this load exists to leave out.
50
+ */
51
+ export class RoleLoadIncompleteError extends UltimateError {
52
+ constructor(input: { role: Role; missing: readonly string[] }) {
53
+ super({
54
+ code: 'X_ROLE_LOAD_INCOMPLETE',
55
+ cause: `the ${input.role} role imported ${API_INDEX} and every module that reaches no component or stylesheet, and ${MANIFEST_FILE} names ${String(input.missing.length)} job(s) or task(s) that load did not register (${input.missing.join(', ')}), so the whole app was imported instead`,
56
+ fix: `add import * as <module> from the file that declares it and <module> to jobs: [...] or tasks: [...] in ${API_INDEX}, then x manifest`,
57
+ meta: { role: input.role, missing: input.missing },
58
+ });
59
+ }
60
+ }
61
+
62
+ /** `name` of every entry of one manifest list — `[]` for a list that is absent or malformed. */
63
+ function namesIn(manifest: Record<string, unknown>, key: 'jobs' | 'tasks'): readonly string[] {
64
+ const list = manifest[key];
65
+ if (!Array.isArray(list)) return [];
66
+ return list.flatMap((entry: unknown) =>
67
+ isJsonObject(entry) && typeof entry['name'] === 'string' ? [entry['name']] : [],
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Every job and task the committed manifest names that this process has not registered —
73
+ * `undefined` when there is no manifest to check against, which is not the same answer as "none".
74
+ */
75
+ export async function unregisteredFromManifest(
76
+ root: string,
77
+ ): Promise<readonly string[] | undefined> {
78
+ const parsed: unknown = await Bun.file(join(root, MANIFEST_FILE))
79
+ .json()
80
+ .catch(() => undefined);
81
+ if (!isJsonObject(parsed)) return undefined;
82
+ const jobs = new Set(registeredJobs().map((job) => job.name));
83
+ const tasks = new Set(registeredTasks().map((task) => task.name));
84
+ return [
85
+ ...namesIn(parsed, 'jobs').filter((name) => !jobs.has(name)),
86
+ ...namesIn(parsed, 'tasks').filter((name) => !tasks.has(name)),
87
+ ].sort();
88
+ }
89
+
90
+ /**
91
+ * The background scan alone, with no proof and no fallback: the API index and every module that
92
+ * brings no document. What a worker imports when nothing is wrong — and what the gate imports in a
93
+ * child to compare against the whole app (`verify-role-load.ts`).
94
+ */
95
+ export async function scanBackground(root: string): Promise<AppScan> {
96
+ const index = resolve(root, API_INDEX);
97
+ // why `require.cache`: Bun 1.4 lists every evaluated ES module there, by absolute path — the
98
+ // same table `app-reload-graph.ts` evicts from.
99
+ const loaded = (absolute: string): boolean => absolute in require.cache;
100
+ const graph = createDocumentGraph(loaded);
101
+ return scanAppModules(root, {
102
+ track: false,
103
+ // The index first (`appModulePaths` leads with it): what it imports is what the app runs.
104
+ // Then every module already here, and every one that brings no document with it — a service,
105
+ // a catalog, a storage declaration registers on import and no job names it.
106
+ include: async (absolute) =>
107
+ absolute === index || loaded(absolute) || !(await graph.reachesDocument(absolute)),
108
+ });
109
+ }
110
+
111
+ /**
112
+ * The background load, then the proof it was enough. A job runs by NAME — the queue row carries
113
+ * it — so the one thing this load must not do is leave a named job unregistered. The gate already
114
+ * requires every job and task to be named from a `defineApi()` call (`X_JOB_UNREGISTERED`); the
115
+ * manifest is the list that call produced, and a name it holds that this load did not register is
116
+ * answered by importing everything, loudly.
117
+ */
118
+ async function loadBackground(root: string, role: Role): Promise<AppScan> {
119
+ const scan = await scanBackground(root);
120
+ const missing = await unregisteredFromManifest(root);
121
+ if (missing !== undefined && missing.length === 0) return scan;
122
+ if (missing === undefined) {
123
+ logger.warn('ultimate role load unverified', {
124
+ role,
125
+ cause: `${MANIFEST_FILE} is absent or unreadable, so nothing names the jobs this role must run and the whole app was imported`,
126
+ fix: 'x manifest',
127
+ });
128
+ } else {
129
+ const error = new RoleLoadIncompleteError({ role, missing });
130
+ logger.error(error.format(), { code: error.code, role, missing });
131
+ }
132
+ return scanAppModules(root, { track: false });
133
+ }
134
+
135
+ /**
136
+ * What `serve-boot.ts` calls instead of `loadApp`: the scan a role needs and none of what a tool
137
+ * reads from it afterwards — no reload graph, no error-code walk of the app's source.
138
+ */
139
+ export async function loadAppForRole(root: string, role: Role): Promise<AppScan> {
140
+ return roleLoadFor(role) === 'background'
141
+ ? loadBackground(root, role)
142
+ : scanAppModules(root, { track: false });
143
+ }
package/src/role-start.ts CHANGED
@@ -29,12 +29,17 @@ import {
29
29
  import type { SyncWs } from '@ultimat3/realtime/server';
30
30
  import { errorPageHook } from './error-pages';
31
31
  import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
32
- import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
32
+ import {
33
+ DEFAULT_METRICS_PORT,
34
+ startMetricsEndpoint,
35
+ whenMetricsPortTaken,
36
+ } from './metrics-endpoint';
33
37
  import { rolesUnderRealtime } from './role-realtime';
34
38
  import { startReplicator } from './role-replicator';
35
39
  import type { RunningRoles, StartRolesOptions } from './role-start-types';
36
40
  import { DEV_ROLES } from './role-start-types';
37
41
  import { prepareSync, type RunningSync } from './role-sync';
42
+ import { startWorkerWake } from './role-wake';
38
43
  import type { Env } from './runtime-bindings';
39
44
  import { devHooks } from './runtime-hooks';
40
45
  import { workerOptionsFor } from './runtime-jobs';
@@ -304,6 +309,9 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
304
309
  startMetricsEndpoint({
305
310
  port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
306
311
  hostname: binding.hostname,
312
+ // `x dev` is one process running several roles: its `process_info` says which.
313
+ role: selected.join('+'),
314
+ whenTaken: whenMetricsPortTaken(binding.dev, options.env),
307
315
  });
308
316
  started.push(async () => metrics.stop());
309
317
 
@@ -350,6 +358,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
350
358
  // one hands the failure rollback the same window a dropped `await` gives the teardown below.
351
359
  if (relay !== null) started.push(() => relay.stop());
352
360
 
361
+ // The cross-process wake: one LISTEN session, so a job another pod committed starts now
362
+ // instead of at the next poll. On `worker` alone — it is the role whose two loops (the claim
363
+ // and the relay above) hear it; a web or scheduler pod holding a session would pin a
364
+ // connection per replica to wake nothing (`role-wake.ts`).
365
+ const wake = selected.includes('worker') ? startWorkerWake(options.runtime.db) : null;
366
+ if (wake !== null) started.push(() => wake.stop());
367
+
353
368
  // `state` and `leader`, not the defaults. `createMemorySchedulerState` forgets every watermark
354
369
  // on restart, so a rolling deploy re-fires or skips whatever was due across it, and
355
370
  // `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
@@ -413,6 +428,8 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
413
428
  // and AWAITED, because a pass is a `driver.enqueue` followed by a `markPublished`. Dropped,
414
429
  // this returns between the two and the lines below close the pool under the row it was
415
430
  // about to mark: re-published next boot at best, a rejection against a closed pool at worst.
431
+ // The session first: a notification arriving after this wakes a loop that is about to stop.
432
+ await wake?.stop();
416
433
  await relay?.stop();
417
434
  await worker?.stop('x dev stopped');
418
435
  await sync?.stop();
@@ -0,0 +1,45 @@
1
+ // The worker role's cross-process wake, and the one series that says whether it is PROVEN. The
2
+ // mechanism is `@ultimat3/jobs`' (`startQueueWake`); this decides who holds the LISTEN session —
3
+ // the worker, and only over a client that can listen — and publishes `queue_wake_live`, because a
4
+ // transaction-pooling proxy disables the wake in silence and the poll hides that it did.
5
+
6
+ import { gauge } from '@ultimat3/core';
7
+ import type { DbClient } from '@ultimat3/db';
8
+ import { canListen } from '@ultimat3/db';
9
+ import type { QueueWake } from '@ultimat3/jobs';
10
+ import { startQueueWake } from '@ultimat3/jobs';
11
+ import { pgExecutorFor } from './runtime-queue';
12
+
13
+ // The wake the gauge reads. One observer for the life of the process: a gauge redeclared with a
14
+ // different `observe` is refused, and `x dev` starts the roles again on every restart-in-place.
15
+ let current: QueueWake | undefined;
16
+ let declared = false;
17
+
18
+ const wakeLive = (): number => (current?.live() === true ? 1 : 0);
19
+
20
+ /**
21
+ * Starts the wake for a worker, or answers `null` when the client cannot hold a session (a
22
+ * hand-built runtime): the claim loop and the relay then keep the poll they always had.
23
+ */
24
+ export function startWorkerWake(db: DbClient): QueueWake | null {
25
+ if (!canListen(db)) return null;
26
+ if (!declared) {
27
+ declared = true;
28
+ gauge('queue_wake_live', {
29
+ unit: '1',
30
+ description:
31
+ '1 while a notification has crossed the worker LISTEN session, 0 while jobs start on the poll alone',
32
+ observe: wakeLive,
33
+ });
34
+ }
35
+ const wake = startQueueWake({ listener: db, executor: pgExecutorFor(db) });
36
+ current = wake;
37
+ return {
38
+ live: () => wake.live(),
39
+ async stop(): Promise<void> {
40
+ // Only the wake still published: a stop of one that was already replaced silences nothing.
41
+ if (current === wake) current = undefined;
42
+ await wake.stop();
43
+ },
44
+ };
45
+ }