@ultimat3/cli 22.15.0 → 24.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  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/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
package/src/output.ts CHANGED
@@ -29,6 +29,12 @@ export interface Finding {
29
29
  * and must never be run as this CLI's own instruction.
30
30
  */
31
31
  readonly source?: 'ci-log';
32
+ /**
33
+ * Structured facts behind `cause`, for a `--json` reader that would otherwise parse the sentence:
34
+ * `X_VERIFY_STEP_TIMEOUT` carries the processes it killed and the files still in flight. Never
35
+ * the only home of a fact — the human render prints `cause` and `fix`, and both already say it.
36
+ */
37
+ readonly meta?: { readonly [key: string]: JsonValue };
32
38
  }
33
39
 
34
40
  export interface StepResult {
@@ -37,7 +43,12 @@ export interface StepResult {
37
43
  readonly durationMs: number;
38
44
  readonly skipped?: boolean;
39
45
  readonly findings: readonly Finding[];
40
- /** Captured stdout/stderr, shown on failure or with --verbose. */
46
+ /**
47
+ * Captured stdout/stderr, for a reader working out what a step DID — carried by both renderers
48
+ * on failure or with `--verbose`, and by neither on a quiet green run. Never the home of a fact:
49
+ * a number a passing step must report by default goes in `CommandResult.data` (the `unit` step's
50
+ * coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
51
+ */
41
52
  readonly output?: string;
42
53
  /** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
43
54
  readonly workers?: number;
@@ -208,6 +219,10 @@ const why = (step: StepResult): string => {
208
219
  return ` ${msg('cli.verify.allSkipped', { skipped: tests.skipped })}`;
209
220
  };
210
221
 
222
+ /** The one rule for a step's captured output, so the two renderers cannot answer differently. */
223
+ const showsOutput = (step: StepResult, verbose: boolean): boolean =>
224
+ step.output !== undefined && step.output.length > 0 && (verbose || !step.ok);
225
+
211
226
  export function renderHuman(result: CommandResult, verbose = false): string {
212
227
  const out: string[] = [];
213
228
  for (const step of result.steps ?? []) {
@@ -218,8 +233,8 @@ export function renderHuman(result: CommandResult, verbose = false): string {
218
233
  // NOT escaped, and that is the one exception: `output` is this process's own captured
219
234
  // subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
220
235
  // 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}`);
236
+ if (showsOutput(step, verbose)) {
237
+ for (const line of (step.output ?? '').trimEnd().split('\n')) out.push(` | ${line}`);
223
238
  }
224
239
  }
225
240
  // Every free-text line through the SAME `singleLine` the 3-line format runs, because this is
@@ -234,7 +249,7 @@ export function renderHuman(result: CommandResult, verbose = false): string {
234
249
  return out.join('\n');
235
250
  }
236
251
 
237
- export function renderJson(result: CommandResult): string {
252
+ export function renderJson(result: CommandResult, verbose = false): string {
238
253
  const steps = (result.steps ?? []).map((step) => ({
239
254
  name: step.name,
240
255
  ok: step.ok,
@@ -261,10 +276,10 @@ export function renderJson(result: CommandResult): string {
261
276
  // fix line — the failing test's name and its assertion diff existed and were thrown away, so
262
277
  // the only way to learn what broke was to re-run it somewhere else. CI output is a prompt:
263
278
  // 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 }),
279
+ // Success stays quiet so a green run is not a wall of text — unless `--verbose` asked, which
280
+ // is the same opt-in the terminal honours: a green step's output used to be reachable from the
281
+ // human render and from nowhere under `--json`.
282
+ ...(showsOutput(step, verbose) ? { output: step.output } : {}),
268
283
  }));
269
284
  const payload = {
270
285
  ok: result.ok,
@@ -278,7 +293,7 @@ export function renderJson(result: CommandResult): string {
278
293
  }
279
294
 
280
295
  export function render(result: CommandResult, json: boolean, verbose = false): string {
281
- return json ? renderJson(result) : renderHuman(result, verbose);
296
+ return json ? renderJson(result, verbose) : renderHuman(result, verbose);
282
297
  }
283
298
 
284
299
  export function exitCodeFor(result: CommandResult): number {
@@ -6,12 +6,9 @@
6
6
  // check a soft visit gets before `load`. No surface opted in: nothing is built, served or named — a
7
7
  // `site/` page that did not ask stays 0kb (axiom 6).
8
8
 
9
- // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
10
- // necessity `theme-boot.ts` records.
11
- import { join } from 'node:path';
12
9
  import { clientScopeOf } from '@ultimat3/auth';
13
10
  import type { Ctx, NavigationSurface } from '@ultimat3/core';
14
- import { CLIENT_NAVIGATION_SCOPE_HEADER, NAVIGATION_SURFACES } from '@ultimat3/core';
11
+ import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
15
12
  import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
16
13
  import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
17
14
  import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
@@ -22,8 +19,7 @@ import {
22
19
  routeEntries,
23
20
  } from '@ultimat3/render';
24
21
  import { ssrHeaders } from '@ultimat3/render/server';
25
- import { APP_CONFIG_EXPORT } from './app-auth';
26
- import { APP_CONFIG_FILE } from './app-root';
22
+ import { loadAppConfig } from './app-config-load';
27
23
  import { FrameworkScriptBuildFailedError } from './errors';
28
24
  import {
29
25
  buildNavigationScript,
@@ -56,26 +52,14 @@ export interface NavigationDeclaration {
56
52
  readonly surfaces: readonly NavigationSurface[];
57
53
  }
58
54
 
59
- const isRecord = (value: unknown): value is Record<string, unknown> =>
60
- typeof value === 'object' && value !== null;
61
-
62
- const isSurface = (value: unknown): value is NavigationSurface =>
63
- NAVIGATION_SURFACES.some((surface) => surface === value);
64
-
65
55
  /**
66
- * `navigation.client` and `name`, as `defineConfig` validated them — no surfaces when the app says
67
- * nothing or has no config file. Structural, never `instanceof`, for `loadThemeMode`'s reason.
56
+ * `navigation.client` and `name`, off the one loader (`app-config-load.ts`) — no surfaces, and the
57
+ * app called `app`, for a root with no config file.
68
58
  */
69
59
  export async function loadNavigation(root: string): Promise<NavigationDeclaration> {
70
- const configPath = join(root, APP_CONFIG_FILE);
71
- if (!(await Bun.file(configPath).exists())) return { app: 'app', surfaces: [] };
72
- const module = (await import(configPath)) as Record<string, unknown>;
73
- const config = module[APP_CONFIG_EXPORT];
74
- if (!isRecord(config)) return { app: 'app', surfaces: [] };
75
- const app = typeof config['name'] === 'string' ? config['name'] : 'app';
76
- const navigation = config['navigation'];
77
- const client = isRecord(navigation) ? navigation['client'] : undefined;
78
- return { app, surfaces: Array.isArray(client) ? client.filter(isSurface) : [] };
60
+ const config = await loadAppConfig(root);
61
+ if (config === undefined) return { app: 'app', surfaces: [] };
62
+ return { app: config.name, surfaces: config.navigation.client };
79
63
  }
80
64
 
81
65
  /**
@@ -5,11 +5,8 @@
5
5
  // the next page's document on pointer rest, so a full-page navigation between prerendered pages
6
6
  // paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
7
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
8
  import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
12
- import { DEFAULT_SPECULATION, localeSegment, resolveSpeculation } from '@ultimat3/core';
9
+ import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
13
10
  import { cspHashSource } from '@ultimat3/http';
14
11
  import { localeConfig, routedLocales } from '@ultimat3/i18n';
15
12
  import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
@@ -21,26 +18,14 @@ import {
21
18
  speculationRulesBody,
22
19
  speculationRulesTag,
23
20
  } 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;
21
+ import { loadAppConfig } from './app-config-load';
29
22
 
30
23
  /**
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.
24
+ * `navigation.speculation` off the one loader (`app-config-load.ts`), which holds it to core's
25
+ * `resolveSpeculation` — the default (on, `'moderate'`) for a root with no config file.
36
26
  */
37
27
  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);
28
+ return (await loadAppConfig(root))?.navigation.speculation ?? DEFAULT_SPECULATION;
44
29
  }
45
30
 
46
31
  /**
@@ -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
  }
@@ -1,25 +1,52 @@
1
1
  // The export directory is the build's own, and emptied before every build: `prerenderSite` only
2
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.
3
+ // Emptied only when it is provably the build's: absent, empty, or marked by an earlier build —
4
+ // `--out apps` deleted the app's pages and `--out .x` its embedded dev database.
4
5
 
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.
6
+ import { lstat, readdir, rm } from 'node:fs/promises'; // why: Bun has no recursive delete or readdir.
7
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
7
8
  import { UltimateError } from '@ultimat3/core';
8
9
 
10
+ /** Written into every export directory a build empties: the proof the next build may empty it. */
11
+ export const EXPORT_MARKER = '.x-export';
12
+
13
+ /**
14
+ * The export directory `x build --target static` chooses itself. Exempt from the marker: a build
15
+ * from before the marker existed left it full and unmarked, and the build owns this path by name.
16
+ */
17
+ const DEFAULT_EXPORT = join('.x', 'static');
18
+
19
+ const MARKER_TEXT =
20
+ 'Written by x build --target static. This directory is emptied before every static build.\n';
21
+
9
22
  /** True when `dir` is `root` itself or one of its ancestors. */
10
23
  const containsRoot = (dir: string, root: string): boolean => {
11
24
  const up = relative(resolve(dir), resolve(root));
12
25
  return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
13
26
  };
14
27
 
15
- /** Empties `out` for a fresh export, or refuses a directory that holds the app. */
28
+ /** Why `out` is not the build's to empty, or `undefined` when it is. */
29
+ async function unsafeReason(out: string, root: string): Promise<string | undefined> {
30
+ if (containsRoot(out, root)) return 'is the app root or holds it';
31
+ const stat = await lstat(out).catch(() => undefined);
32
+ if (stat === undefined) return undefined;
33
+ if (!stat.isDirectory()) return 'is not a directory';
34
+ const entries = await readdir(out);
35
+ if (entries.length === 0 || entries.includes(EXPORT_MARKER)) return undefined;
36
+ if (resolve(out) === resolve(root, DEFAULT_EXPORT)) return undefined;
37
+ return `already holds ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} and no ${EXPORT_MARKER} a previous build wrote`;
38
+ }
39
+
40
+ /** Empties `out` for a fresh export and marks it, or refuses a directory the build did not write. */
16
41
  export async function clearPrerenderOut(out: string, root: string): Promise<void> {
17
- if (containsRoot(out, root)) {
42
+ const reason = await unsafeReason(out, root);
43
+ if (reason !== undefined) {
18
44
  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`,
45
+ code: 'X_BUILD_OUT_UNSAFE',
46
+ cause: `the static export directory ${resolve(out)} ${reason}, and every build empties its export directory first`,
21
47
  fix: 'x build --target static --out .x/static --json',
22
48
  });
23
49
  }
24
50
  await rm(out, { recursive: true, force: true });
51
+ await Bun.write(join(out, EXPORT_MARKER), MARKER_TEXT);
25
52
  }
package/src/prerender.ts CHANGED
@@ -21,6 +21,7 @@ import { appManifest } from './app-manifest';
21
21
  import type { RouteStats } from './budgets';
22
22
  import { measureDocumentJs, writeBuildStats } from './budgets';
23
23
  import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
24
+ import { PrerenderLoadFailedError } from './errors';
24
25
  import { FAVICON_PATH, faviconBytes } from './favicon';
25
26
  import type { IslandBundle } from './island-bundle';
26
27
  import { buildIslands, writeIslands } from './island-bundle';
@@ -168,15 +169,17 @@ const NOT_FOUND_STATUS = 404;
168
169
  */
169
170
  const declaresBudget = (entry: RouteEntry): boolean => {
170
171
  const budget = entry.config.budget;
171
- return budget !== undefined && (budget.js !== undefined || budget.lcp !== undefined);
172
+ return budget?.js !== undefined;
172
173
  };
173
174
 
174
175
  export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
175
- // The same load `x dev` and `x manifest` perform: importing the app's modules IS what fills the
176
- // route registry, so there is no route table to prerender before this runs.
177
- // Emptied FIRST: the export only ever gained files, so a deleted route's HTML kept shipping.
176
+ // The load `x dev` and `x manifest` perform: importing the app's modules fills the route registry.
177
+ // A module that would not import registered nothing, so its page would vanish from the export
178
+ // under a green build — refused BEFORE the export is emptied, so the last good artifact survives.
179
+ const loaded = await loadApp(options.root);
180
+ if (loaded.findings.length > 0) throw new PrerenderLoadFailedError(loaded.findings);
181
+ // Emptied before anything is written: a deleted route's HTML kept shipping when it only grew.
178
182
  await clearPrerenderOut(options.out, options.root);
179
- await loadApp(options.root);
180
183
  const buildId = (await appManifest(options.root)).manifest.buildId;
181
184
  const declaredOrigin =
182
185
  options.origin ?? publicOrigin(process.env, await loadSiteSettings(options.root));
@@ -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
+ });
@@ -15,15 +15,10 @@
15
15
  // build behind them — `wiki/PWA-And-Offline.md` says so — and a bad `sw.js` is sticky in a way a
16
16
  // manifest is not, so the worker lands behind a real browser check rather than beside this.
17
17
 
18
- // why: Bun exposes no synchronous file-existence primitive, and this read is the same one
19
- // `app-auth.ts` and `runtime-cache.ts` each make before importing an app's config — a root with no
20
- // `app.config.ts` is an ordinary answer here (a scratch root, `x build` outside an app).
21
- import { existsSync } from 'node:fs';
22
- // why: Bun exposes no path-join primitive, and `APP_CONFIG_FILE` is app-root-relative — the same
23
- // necessity `favicon.ts` and `runtime-assets.ts` each record for their own root-relative constant.
18
+ // why: Bun exposes no path-join primitive, and the export writes each icon under an out directory.
24
19
  import { join } from 'node:path';
25
20
  import type { PwaColors, PwaOfflineConfig } from '@ultimat3/core';
26
- import { localeSegment } from '@ultimat3/core';
21
+ import { escapeHtml, localeSegment } from '@ultimat3/core';
27
22
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
28
23
  import { applyCacheHeaders } from '@ultimat3/http';
29
24
  import {
@@ -34,9 +29,7 @@ import {
34
29
  } from '@ultimat3/pwa';
35
30
  import type { AssetPath } from '@ultimat3/render';
36
31
  import { assetPathProblem } from '@ultimat3/render';
37
- import { escapeAttribute } from '@ultimat3/seo';
38
- import { APP_CONFIG_EXPORT } from './app-auth';
39
- import { APP_CONFIG_FILE } from './app-root';
32
+ import { loadAppConfig } from './app-config-load';
40
33
  import { hasSourceIcon, iconPlan, iconRenderer } from './icon-assets';
41
34
  import type { AppLocales } from './pwa-manifest-locales';
42
35
  import { appLocales, localeMembers, spellIn } from './pwa-manifest-locales';
@@ -80,47 +73,14 @@ export interface PwaArtifacts {
80
73
  /** The `<head>` for a document in `locale` — it links that locale's manifest. */
81
74
  headFor(locale: string): string;
82
75
  /**
83
- * The three `pwa` keys the SERVICE WORKER needs, carried here because this is the one module
84
- * that reads an app's config file — `sw-artifacts.ts` needs the route table and the island
85
- * bundle as well, and a second `await import` of `app.config.ts` would be a second answer to
86
- * "what did this app declare".
76
+ * The three `pwa` keys the SERVICE WORKER needs, carried here so `sw-artifacts.ts` — which needs
77
+ * the route table and the island bundle as well — reads them off the same resolved block.
87
78
  */
88
79
  readonly offline: PwaOfflineConfig;
89
80
  readonly backgroundSync: boolean;
90
81
  readonly push: boolean;
91
82
  }
92
83
 
93
- const isRecord = (value: unknown): value is Record<string, unknown> =>
94
- typeof value === 'object' && value !== null;
95
-
96
- const text = (value: unknown): string | undefined =>
97
- typeof value === 'string' && value.trim() !== '' ? value : undefined;
98
-
99
- /**
100
- * `colors` read structurally, for `loadSignInPath`'s reason: `defineConfig` returns a plain object
101
- * and a config resolved through an older core simply has no such key. `validate()` already refused
102
- * a blank one an `await import` above this line, so anything this rejects is a hand-written config
103
- * object — and the honest answer for one is no manifest at all, never a colour we invented.
104
- */
105
- function colorsOf(value: unknown): PwaColors | undefined {
106
- if (!isRecord(value)) return undefined;
107
- const light = isRecord(value['light']) ? value['light'] : undefined;
108
- const dark = isRecord(value['dark']) ? value['dark'] : undefined;
109
- if (light === undefined || dark === undefined) return undefined;
110
- const [lt, lb, dt, db] = [
111
- text(light['themeColor']),
112
- text(light['backgroundColor']),
113
- text(dark['themeColor']),
114
- text(dark['backgroundColor']),
115
- ];
116
- if (lt === undefined || lb === undefined || dt === undefined || db === undefined)
117
- return undefined;
118
- return {
119
- light: { themeColor: lt, backgroundColor: lb },
120
- dark: { themeColor: dt, backgroundColor: db },
121
- };
122
- }
123
-
124
84
  /** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
125
85
  interface InstallableApp {
126
86
  readonly name: string;
@@ -133,60 +93,30 @@ interface InstallableApp {
133
93
  readonly push: boolean;
134
94
  }
135
95
 
136
- async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
137
- const configPath = join(root, APP_CONFIG_FILE);
138
- if (!existsSync(configPath)) return undefined;
139
- const module = (await import(configPath)) as Record<string, unknown>;
140
- const config = module[APP_CONFIG_EXPORT];
141
- if (!isRecord(config)) return undefined;
142
- const pwa = config['pwa'];
143
- // `pwa.enabled`, read. Not a truthiness test: `enabled` is the key this whole file exists to
144
- // give a reader, and `=== true` is what makes a hand-written `enabled: 'yes'` produce no
145
- // manifest rather than one nobody asked for.
146
- if (!isRecord(pwa) || pwa['enabled'] !== true) return undefined;
147
- const name = text(pwa['name']);
148
- const colors = colorsOf(pwa['colors']);
149
- if (name === undefined || colors === undefined) return undefined;
150
- return {
151
- name,
152
- colors,
153
- block: pwa,
154
- locales: appLocales(config),
155
- offline: offlineOf(pwa['offline']),
156
- ...flags(pwa),
157
- };
158
- }
159
-
160
96
  /**
161
- * The offline block, read structurally for `colorsOf`'s reason: `defineConfig` refuses
162
- * `enabled: true` without an absolute `offline.fallback`, but a HAND-WRITTEN config object never
163
- * passed through it. A missing or relative fallback answers `null`, and `serviceWorkerArtifacts`
164
- * then emits no worker at all — never a path the framework invented, which offline would be a
165
- * cached 404 answering every navigation.
97
+ * `pwa.enabled`, read, off the one loader (`app-config-load.ts`). Core's validator refuses an
98
+ * enabled block without a name, both colour schemes or an absolute `offline.fallback`, so nothing
99
+ * here invents a colour or a fallback route; `colors` is optional only in the type.
166
100
  */
167
- function offlineOf(value: unknown): PwaOfflineConfig {
168
- const block = isRecord(value) ? value : {};
169
- const fallback = text(block['fallback']);
170
- const patterns = block['neverCache'];
101
+ async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
102
+ const config = await loadAppConfig(root);
103
+ const pwa = config?.pwa;
104
+ if (config === undefined || pwa === undefined || !pwa.enabled || pwa.colors === undefined) {
105
+ return undefined;
106
+ }
171
107
  return {
172
- fallback: fallback?.startsWith('/') === true ? fallback : null,
173
- image: text(block['image']) ?? null,
174
- font: text(block['font']) ?? null,
175
- neverCache: Array.isArray(patterns)
176
- ? patterns.filter((entry): entry is string => typeof entry === 'string')
177
- : [],
178
- personalPages: block['personalPages'] === 'last-member' ? 'last-member' : 'never',
108
+ name: pwa.name,
109
+ colors: pwa.colors,
110
+ block: { ...pwa },
111
+ locales: appLocales({ locales: config.locales, defaultLocale: config.defaultLocale }),
112
+ offline: pwa.offline,
113
+ backgroundSync: pwa.backgroundSync,
114
+ push: pwa.push,
179
115
  };
180
116
  }
181
117
 
182
- /** `=== true` for `enabled`'s reason: a hand-written `backgroundSync: 'yes'` wires nothing. */
183
- const flags = (pwa: Record<string, unknown>): { backgroundSync: boolean; push: boolean } => ({
184
- backgroundSync: pwa['backgroundSync'] === true,
185
- push: pwa['push'] === true,
186
- });
187
-
188
118
  /**
189
- * Resolved ONCE at boot, like `loadSignInPath` and `loadCacheTiers` and unlike `faviconBytes`:
119
+ * Resolved ONCE at boot, like `loadSignInPath` and unlike `faviconBytes`: the loader's
190
120
  * `await import` caches the module, so re-reading per request would answer the same object at a
191
121
  * per-request cost, and the head string has to be available synchronously while a document renders.
192
122
  */
@@ -219,7 +149,7 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
219
149
  path,
220
150
  body: serializeWebManifest(result.manifest),
221
151
  head:
222
- `<link rel="manifest" href="${escapeAttribute(path)}">` +
152
+ `<link rel="manifest" href="${escapeHtml(path)}">` +
223
153
  renderThemeColorMeta(result.themeColorMeta) +
224
154
  (icons === undefined ? '' : appleTouchLinks(icons)),
225
155
  };
@@ -244,7 +174,9 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
244
174
  * `asset()` does in a page. The site serves an asset ONLY at its hashed URL, and the manifest took
245
175
  * `src` verbatim, so an app had to hash its own screenshots to avoid shipping a 404 in the install
246
176
  * sheet (notificado.co's `pwa-install.ts`). A missing file is `X_ASSET_MISSING` at boot, never a
247
- * broken image in an install prompt; any other `src` (absolute, already hashed) passes untouched.
177
+ * broken image in an install prompt; any other `src` passes untouched — an absolute path outside
178
+ * `/assets/`, an already hashed one, or a shortcut icon's off-site URL (core screens screenshots and
179
+ * shortcut urls to absolute paths, not icon sources).
248
180
  */
249
181
  function withHashedAssets<M extends ReturnType<typeof localeMembers>>(members: M, root: string): M {
250
182
  const resolve = (src: string): string => {