@ultimat3/cli 23.0.0 → 25.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -0,0 +1,51 @@
1
+ // The one reader of an app's `app.config.ts`: imported here and nowhere else in the tree
2
+ // (`bun run boundaries`, `X_CONFIG_IMPORT_OUTSIDE_LOADER`), its `config` export handed back through
3
+ // core's own `defineConfig` — so every boot fact the CLI reads is the validated, default-merged
4
+ // `AppConfig`, never a structural walk with a fallback of its own.
5
+
6
+ // why: Bun exposes no path-join primitive, and the config path is app-root-relative.
7
+ import { join } from 'node:path';
8
+ import type { AppConfig, AppConfigInput } from '@ultimat3/core';
9
+ import { ConfigInvalidError, defineConfig, describeValue } from '@ultimat3/core';
10
+ import { APP_CONFIG_FILE } from './app-root';
11
+
12
+ /** The export every app declares. Named, never default — the CLI and the runtime both import it. */
13
+ export const APP_CONFIG_EXPORT = 'config';
14
+
15
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
16
+ typeof value === 'object' && value !== null && !Array.isArray(value);
17
+
18
+ /**
19
+ * One named export of `root`'s `app.config.ts`, or `undefined` when the root has no such file or
20
+ * the module does not export `name`. A module that will not import throws as it did — the callers
21
+ * that report rather than crash (`x doctor`'s env check) turn that into a finding.
22
+ */
23
+ export async function appConfigExport(root: string, name: string): Promise<unknown> {
24
+ const path = join(root, APP_CONFIG_FILE);
25
+ if (!(await Bun.file(path).exists())) return undefined;
26
+ const module = (await import(path)) as Record<string, unknown>;
27
+ return Object.hasOwn(module, name) ? module[name] : undefined;
28
+ }
29
+
30
+ /**
31
+ * `root`'s `AppConfig`, or `undefined` when the root has no `app.config.ts` — not an app, and each
32
+ * caller's "nothing declared" answer is its own. A file that IS there is held to `defineConfig`'s
33
+ * one validator: a hand-built `config` object reaches the boot validated and defaulted exactly as
34
+ * one built by `defineConfig` does, which re-merges to itself. One resolved through a 24.x core is
35
+ * refused (its defaults wrote keys 25.0.0 removed): install every `@ultimat3/*` at one version. A file with no `config` object is refused rather than read as "every default".
36
+ */
37
+ export async function loadAppConfig(root: string): Promise<AppConfig | undefined> {
38
+ const path = join(root, APP_CONFIG_FILE);
39
+ if (!(await Bun.file(path).exists())) return undefined;
40
+ const exported = await appConfigExport(root, APP_CONFIG_EXPORT);
41
+ if (!isRecord(exported)) {
42
+ throw new ConfigInvalidError({
43
+ cause: `${path} exports no ${APP_CONFIG_EXPORT} object (${describeValue(exported)})`,
44
+ fix: "export const config = defineConfig({ name: 'my-app' });",
45
+ meta: { key: APP_CONFIG_EXPORT },
46
+ });
47
+ }
48
+ // A cast over a record whose structure `defineConfig` screens first (`shapeIssues`, per layer):
49
+ // the validator IS the parse, and a second hand-written schema here would be a second answer.
50
+ return defineConfig(exported as unknown as AppConfigInput);
51
+ }
package/src/app-env.ts CHANGED
@@ -2,18 +2,13 @@
2
2
  // `app.config.ts`, the `.env.example` projected from it, and the drift between the two. One
3
3
  // declaration, both files (axiom 2) — nothing here holds a second list of variable names.
4
4
 
5
- // why: Bun ships no synchronous exists; `existsSync` answers whether this root is an app.
6
- import { existsSync } from 'node:fs';
7
5
  // why: Bun exposes no path API — the two files this module reads are joined to the app root.
8
6
  import { join } from 'node:path';
9
7
  import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
10
- import {
11
- checkEnvExample,
12
- ENV_EXAMPLE_PATH,
13
- ERROR_DOCS_URL,
14
- renderEnvExample,
15
- } from '@ultimat3/core';
8
+ import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL, parseEnvKeys } from '@ultimat3/core';
9
+ import { appConfigExport } from './app-config-load';
16
10
  import { APP_CONFIG_FILE } from './app-root';
11
+ import { appEnvExample, FRAMEWORK_SECRETS } from './framework-env';
17
12
  import type { Finding } from './output';
18
13
  import { findingFrom } from './output';
19
14
 
@@ -45,28 +40,26 @@ export const isEnvSchema = (value: unknown): value is EnvSchema =>
45
40
  isRecord(value) && Object.values(value).every(isDecl);
46
41
 
47
42
  /**
48
- * Import `app.config.ts` and hand back the declaration it exports. Importing is the only honest
49
- * way to read it — the alternative is a regex over the app's source, which is the pattern
50
- * `app-load.ts` exists to refuse. `undefined` means "this root declares no environment"; a config
51
- * that will not import throws, and the caller turns that into the finding.
43
+ * The declaration `app.config.ts` exports, through the one loader (`app-config-load.ts`).
44
+ * `undefined` means "this root declares no environment"; a config that will not import throws, and
45
+ * the caller turns that into the finding.
52
46
  */
53
47
  export async function loadEnvSchema(root: string): Promise<EnvSchema | undefined> {
54
- const configPath = join(root, APP_CONFIG_FILE);
55
- if (!existsSync(configPath)) return undefined;
56
- const module = (await import(configPath)) as Record<string, unknown>;
57
- const declared = module[ENV_SCHEMA_EXPORT];
58
- if (declared === undefined) return undefined;
48
+ const declared = await appConfigExport(root, ENV_SCHEMA_EXPORT);
59
49
  return isEnvSchema(declared) ? declared : undefined;
60
50
  }
61
51
 
62
- /** The bytes `.env.example` must hold. Deterministic, so a rewrite that changes nothing diffs to nothing. */
63
- export const envExampleFor = (schema: EnvSchema): string => renderEnvExample(schema);
52
+ /**
53
+ * The bytes `.env.example` must hold: the app's declaration, then the framework's deploy-required
54
+ * keys (`framework-env.ts`). Deterministic, so a rewrite that changes nothing diffs to nothing.
55
+ */
56
+ export const envExampleFor = (schema: EnvSchema): string => appEnvExample(schema);
64
57
 
65
58
  const driftFinding = (cause: string): Finding => ({
66
59
  code: 'X_ENV_EXAMPLE_DRIFT',
67
60
  cause,
68
- // The generator, not the assertion: `assertEnvExample`'s own fix is a `Bun.write(…)` call for
69
- // an app that has a schema object in scope, and a gate reader has a shell.
61
+ // The generator, never a `Bun.write(…)` call: that needs a schema object in scope, and a gate
62
+ // reader has a shell.
70
63
  fix: 'x env example',
71
64
  docs: ERROR_DOCS_URL,
72
65
  at: ENV_EXAMPLE_PATH,
@@ -97,12 +90,20 @@ export async function envExampleFindings(root: string): Promise<readonly Finding
97
90
  }
98
91
  const text = await file.text();
99
92
  if (text === expected) return [];
100
- const report = checkEnvExample(schema, text);
93
+ const present = new Set(parseEnvKeys(text));
94
+ const missing = Object.keys(schema).filter((key) => !present.has(key));
95
+ // The framework's keys are owed by every deployed app, whatever its schema says (#679), and an
96
+ // example written before they were part of the contract is missing exactly these.
97
+ const owed = FRAMEWORK_SECRETS.map((secret) => secret.key).filter(
98
+ (key) => !Object.hasOwn(schema, key) && !present.has(key),
99
+ );
101
100
  return [
102
101
  driftFinding(
103
- report.missing.length > 0
104
- ? `${ENV_EXAMPLE_PATH} does not declare ${report.missing.join(', ')}, declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`
105
- : `${ENV_EXAMPLE_PATH} is no longer the projection of ${ENV_SCHEMA_EXPORT} — a description, a default or the required flag has moved`,
102
+ missing.length > 0
103
+ ? `${ENV_EXAMPLE_PATH} does not declare ${missing.join(', ')}, declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`
104
+ : owed.length > 0
105
+ ? `${ENV_EXAMPLE_PATH} does not declare ${owed.join(', ')}, which the framework refuses to boot without outside development/test`
106
+ : `${ENV_EXAMPLE_PATH} is no longer the projection of ${ENV_SCHEMA_EXPORT} — a description, a default or the required flag has moved`,
106
107
  ),
107
108
  ];
108
109
  }
package/src/app-load.ts CHANGED
@@ -3,12 +3,12 @@
3
3
  // `registerQueries` / `registerRoute` name the rest — so `x manifest`, `x routes` and `x verify`
4
4
  // read exactly the tables the running server reads.
5
5
 
6
- // Bun ships no `Bun.*` path API: `relative`/`sep` turn an absolute scan hit into the app-root-
7
- // relative POSIX path every finding and every manifest fact is keyed by.
8
- import { join, relative, resolve, sep } from 'node:path';
6
+ // Bun ships no `Bun.*` path API: `join`/`resolve` reach the files a scan imports.
7
+ import { join, resolve } from 'node:path';
9
8
  import { isAction, isMutator, listActions, registerActions } from '@ultimat3/action';
10
9
  import { describeEntities, registeredEntities } from '@ultimat3/entity';
11
- import { localeConfig } from '@ultimat3/i18n';
10
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
11
+ import { appLocaleSet, UNDECLARED_LOCALES } from '@ultimat3/i18n/app-catalogs';
12
12
  import { isJobHandle, isTaskHandle, registeredJobs, registeredTasks } from '@ultimat3/jobs';
13
13
  import type { ErrorCodeFact } from '@ultimat3/manifest';
14
14
  import { isQuery, listQueries, registerQueries } from '@ultimat3/query';
@@ -40,6 +40,7 @@ import { collectDeclaredCodes } from './error-contract';
40
40
  import type { Finding } from './output';
41
41
  import { findingFrom } from './output';
42
42
  import { hasPathSegment } from './path-segments';
43
+ import { posixRelative, toPosix } from './posix-path';
43
44
  import { siteAssetTable } from './site-assets';
44
45
  import { isTest } from './source-files';
45
46
 
@@ -83,13 +84,13 @@ export interface LoadedApp {
83
84
  /** Every `X_*` code the app's source declares, by code — the one fact no registry holds. */
84
85
  readonly errorCodes: readonly ErrorCodeFact[];
85
86
  /**
86
- * The locale the app falls back to. `packages/i18n/src/index.ts` is inside the import loop, and
87
- * `defineCatalogs()` configures `@ultimat3/i18n` on its way through — so this is the framework's
88
- * own answer, read back from `localeConfig()`, and never a regex over the app's source, which
89
- * only ever matched the one `defineCatalogs({ default: '…' })` spelling it anticipated. An app
90
- * whose i18n module would not import leaves the framework default (`en`) and a finding saying so.
87
+ * The app's locales, default first — `@ultimat3/i18n`'s `appLocaleSet`, the ONE reader of
88
+ * `defineCatalogs()`, asked once here so every step after the load reads one answer. Never
89
+ * `localeConfig()` read blind (it answers an earlier import's declaration for an app that made
90
+ * none) and never a regex over the source. An app with no catalogs, or whose catalog module would
91
+ * not import (the scan's finding already says so), is `UNDECLARED_LOCALES`.
91
92
  */
92
- readonly defaultLocale: string;
93
+ readonly locales: AppLocaleSet;
93
94
  /** Modules that would not import, and primitives that would not register. */
94
95
  readonly findings: readonly Finding[];
95
96
  }
@@ -121,6 +122,17 @@ export function resetAppLoad(): void {
121
122
  resetReloadGraph();
122
123
  }
123
124
 
125
+ /**
126
+ * One glob's hits in code-unit order of their POSIX spelling — never the host's. Sorted raw, `\`
127
+ * (0x5C) sorts after a digit or a capital where `/` (0x2F) sorts before one, so Windows imported
128
+ * `app/a1/page.tsx` before `app/a/page.tsx` and Linux after: one app, two cascades.
129
+ */
130
+ export function sortModulePaths(paths: readonly string[]): string[] {
131
+ const keyed = paths.map((path) => ({ path, key: toPosix(path) }));
132
+ keyed.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
133
+ return keyed.map((one) => one.path);
134
+ }
135
+
124
136
  /**
125
137
  * Every module path the app globs match, SORTED — with the API index first. `Bun.Glob` answers in
126
138
  * directory order, which is the filesystem's — ext4 hashes names with a per-filesystem seed — so
@@ -144,7 +156,7 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
144
156
  if (!seen.has(absolute)) matched.push(absolute);
145
157
  seen.add(absolute);
146
158
  }
147
- found.push(...matched.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
159
+ found.push(...sortModulePaths(matched));
148
160
  }
149
161
  // Resolved, like the glob's own answers: `loadApp('.')` is a legal call.
150
162
  const index = resolve(root, API_INDEX);
@@ -196,7 +208,7 @@ export async function scanAppModules(root: string, options: ScanOptions): Promis
196
208
  // file it holds, so this loop imported none of them and the app registered nothing.
197
209
  // The ROOT-RELATIVE path is tested, never the absolute one: an app checked out under
198
210
  // `~/work/my.test.app` matched `.test.` on every file and loaded none of them.
199
- const file = relative(root, absolute).split(sep).join('/');
211
+ const file = posixRelative(root, absolute);
200
212
  if (hasPathSegment(absolute, 'node_modules') || isTest(file)) continue;
201
213
  if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
202
214
  continue;
@@ -249,16 +261,28 @@ export async function loadApp(root: string): Promise<LoadedApp> {
249
261
  ) {
250
262
  findings.push(emptyAppFinding(root));
251
263
  }
252
- // Read after the loop, never before it: `configureLocales` runs on the app's own import.
253
264
  return {
254
265
  root,
255
266
  files: scan.files,
256
267
  errorCodes: await appErrorCodes(root),
257
- defaultLocale: localeConfig().fallback,
268
+ locales: await loadedLocales(root),
258
269
  findings,
259
270
  };
260
271
  }
261
272
 
273
+ /**
274
+ * After the scan, never before it: the scan imported the catalog module, so the set it exports is
275
+ * what `loadAppCatalogs` recognises. A module that would not import is the scan's finding, not a
276
+ * second one here.
277
+ */
278
+ async function loadedLocales(root: string): Promise<AppLocaleSet> {
279
+ try {
280
+ return await appLocaleSet(root);
281
+ } catch {
282
+ return UNDECLARED_LOCALES;
283
+ }
284
+ }
285
+
262
286
  /** No primitive in any registry this scan fills — a process that registered some itself is not empty. */
263
287
  const registersNothing = (): boolean =>
264
288
  listActions().length === 0 &&
@@ -21,7 +21,7 @@ import {
21
21
  import { knownPermissions } from '@ultimat3/policy';
22
22
  import { describeQueries } from '@ultimat3/query';
23
23
  import type { RouteDescriptor } from '@ultimat3/render';
24
- import { describeRoutes } from '@ultimat3/render';
24
+ import { describePages } from '@ultimat3/render';
25
25
  import { loadApp } from './app-load';
26
26
  import { AppPackageInvalidError } from './errors';
27
27
  import type { Finding } from './output';
@@ -105,7 +105,7 @@ type UrlRoute = RouteDescriptor & { readonly surface: 'site' | 'app' | 'api' };
105
105
  const hasUrl = (route: RouteDescriptor): route is UrlRoute => route.surface !== 'shared';
106
106
 
107
107
  const routeFacts = (): readonly RouteFact[] =>
108
- describeRoutes()
108
+ describePages()
109
109
  .filter(hasUrl)
110
110
  .map((route) => ({
111
111
  url: route.path,
@@ -117,14 +117,8 @@ const routeFacts = (): readonly RouteFact[] =>
117
117
  ...budgetOf(route),
118
118
  }));
119
119
 
120
- function budgetOf(route: RouteDescriptor): { budget?: { js?: string; lcp?: number } } {
121
- if (route.budgetJs === null && route.budgetLcp === null) return {};
122
- return {
123
- budget: {
124
- ...(route.budgetJs === null ? {} : { js: route.budgetJs }),
125
- ...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
126
- },
127
- };
120
+ function budgetOf(route: RouteDescriptor): { budget?: { js: string } } {
121
+ return route.budgetJs === null ? {} : { budget: { js: route.budgetJs } };
128
122
  }
129
123
 
130
124
  /**
package/src/app-mcp.ts CHANGED
@@ -1,27 +1,26 @@
1
1
  // The app's own MCP endpoint, mounted by the web role. `defineAppMcp` built `mcp.route` — a
2
2
  // `POST` handler with token auth and per-class rate limits — and `app.config.ts` declared
3
- // `ai: { mcp: { expose: true, path: '/mcp' } }` by DEFAULT, and nothing between the two served it:
3
+ // `ai: { mcp: { expose: true } }` by DEFAULT, and nothing between the two served it:
4
4
  // neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
5
5
  // every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
6
6
  // exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
7
- // affiliates) — this module finds it, and both boots mount what it carries: endpoint #0 at
8
- // `ai.mcp.path`, every other at its own `defineAppMcp({ path })`.
7
+ // affiliates) — this module finds it, and both boots mount what it carries: EVERY endpoint at its
8
+ // own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name.
9
9
 
10
10
  // why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
11
11
  import { existsSync } from 'node:fs';
12
- // why: Bun exposes no path-join primitive; the config file and each candidate are joined to root.
12
+ // why: Bun exposes no path-join primitive; each candidate is joined to root.
13
13
  import { join } from 'node:path';
14
14
  import { logger } from '@ultimat3/core';
15
15
  import type { Route } from '@ultimat3/http';
16
- import { json, selfOrigin } from '@ultimat3/http';
16
+ import { jsonResponse, selfOrigin } from '@ultimat3/http';
17
17
  import {
18
18
  type AppMcp,
19
19
  McpAppUnmountedError,
20
20
  McpPathDuplicateError,
21
21
  PROTECTED_RESOURCE_WELL_KNOWN,
22
22
  } from '@ultimat3/mcp';
23
- import { APP_CONFIG_EXPORT } from './app-auth';
24
- import { APP_CONFIG_FILE } from './app-root';
23
+ import { loadAppConfig } from './app-config-load';
25
24
 
26
25
  /** The one file an app writes, per app directory. */
27
26
  export const APP_MCP_GLOB = 'apps/*/mcp.ts';
@@ -37,7 +36,7 @@ export const APP_MCP_ROUTE_NAME = 'mcp';
37
36
  export interface AppMcpMount {
38
37
  /** `[]` when `expose` is false, when nothing exports `mcp`, or when no endpoint has a route. */
39
38
  readonly routes: readonly Route[];
40
- /** Endpoint #0's `POST <path>` when mounted, else `null` — the default, at `ai.mcp.path`. */
39
+ /** Endpoint #0's `POST <path>` when mounted, else `null` — the default endpoint. */
41
40
  readonly path: string | null;
42
41
  /** Every mounted endpoint's `POST <path>`, in export order — the boot line prints one each. */
43
42
  readonly paths: readonly string[];
@@ -49,34 +48,19 @@ export interface AppMcpMount {
49
48
  readonly warning: McpAppUnmountedError | undefined;
50
49
  }
51
50
 
52
- interface ExposeDeclaration {
53
- readonly expose: boolean;
54
- readonly path: string;
55
- }
56
-
57
51
  const isRecord = (value: unknown): value is Record<string, unknown> =>
58
52
  typeof value === 'object' && value !== null;
59
53
 
60
54
  /**
61
- * `config.ai.mcp`, off the app's own resolved config — the exported `config` is what
62
- * `defineConfig` returned, so both keys are present and defaulted. Read the same way
63
- * `loadSignInPath` reads `auth.signInPath`: the config file is imported, never re-parsed.
64
- * An app with no config file has nothing exposed and nothing to warn about.
55
+ * `defineAppMcp`'s own default — `mcp/src/transport-http.ts`, `input.path ?? '/mcp'` — named here
56
+ * ONLY in a warning about an endpoint that has no route and so no resolved path. It never decides
57
+ * where anything mounts: a mounted endpoint's path is `route.path`.
65
58
  */
66
- async function exposeDeclaration(root: string): Promise<ExposeDeclaration | undefined> {
67
- const configPath = join(root, APP_CONFIG_FILE);
68
- if (!(await Bun.file(configPath).exists())) return undefined;
69
- const module = (await import(configPath)) as Record<string, unknown>;
70
- const config = module[APP_CONFIG_EXPORT];
71
- if (!isRecord(config) || !isRecord(config['ai']) || !isRecord(config['ai']['mcp'])) {
72
- return undefined;
73
- }
74
- const mcp = config['ai']['mcp'];
75
- const path = mcp['path'];
76
- return {
77
- expose: mcp['expose'] === true,
78
- path: typeof path === 'string' && path.startsWith('/') ? path : '/mcp',
79
- };
59
+ const UNROUTED_MCP_PATH = '/mcp';
60
+
61
+ /** `config.ai.mcp.expose` off the one loader (`app-config-load.ts`); false with no config file. */
62
+ async function exposed(root: string): Promise<boolean> {
63
+ return (await loadAppConfig(root))?.ai.mcp.expose ?? false;
80
64
  }
81
65
 
82
66
  const isAppMcp = (value: unknown): value is AppMcp =>
@@ -112,21 +96,20 @@ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undef
112
96
  * does not have and answer 401 before the token was ever read.
113
97
  */
114
98
  export async function appMcpMount(root: string): Promise<AppMcpMount> {
115
- const declared = await exposeDeclaration(root);
116
- if (declared === undefined || !declared.expose) return NOTHING;
99
+ if (!(await exposed(root))) return NOTHING;
117
100
  const files = await candidates(root);
118
101
  const fallbackFile = 'apps/web/mcp.ts';
119
102
  for (const file of files) {
120
103
  const module = (await import(join(root, file))) as Record<string, unknown>;
121
104
  const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
122
105
  if (endpoints === undefined) continue;
123
- return mountEndpoints(endpoints, declared.path, file);
106
+ return mountEndpoints(endpoints, file);
124
107
  }
125
108
  return {
126
109
  ...NOTHING,
127
110
  warning: new McpAppUnmountedError({
128
111
  reason: 'missing',
129
- path: declared.path,
112
+ path: UNROUTED_MCP_PATH,
130
113
  file: files[0] ?? fallbackFile,
131
114
  }),
132
115
  };
@@ -137,11 +120,7 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
137
120
  * route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
138
121
  * the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
139
122
  */
140
- function mountEndpoints(
141
- endpoints: readonly AppMcp[],
142
- defaultPath: string,
143
- file: string,
144
- ): AppMcpMount {
123
+ function mountEndpoints(endpoints: readonly AppMcp[], file: string): AppMcpMount {
145
124
  const routes: Route[] = [];
146
125
  const paths: string[] = [];
147
126
  const claimed = new Map<string, number>();
@@ -151,13 +130,15 @@ function mountEndpoints(
151
130
  if (route === undefined) {
152
131
  warning ??= new McpAppUnmountedError({
153
132
  reason: 'no-route',
154
- path: defaultPath,
133
+ path: UNROUTED_MCP_PATH,
155
134
  file,
156
135
  ...(index === 0 ? {} : { endpoint: index }),
157
136
  });
158
137
  return;
159
138
  }
160
- const path = index === 0 ? defaultPath : route.path;
139
+ // The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
140
+ // over. A mount path from anywhere else is a resource advertised where nothing answers.
141
+ const path = route.path;
161
142
  for (const mounted of endpointRoutes(route, path, index)) {
162
143
  const key = `${mounted.method} ${mounted.path}`;
163
144
  const first = claimed.get(key);
@@ -174,8 +155,7 @@ function mountEndpoints(
174
155
  }
175
156
  paths.push(path);
176
157
  });
177
- const path = endpoints[0]?.route === undefined ? null : defaultPath;
178
- return { routes, path, paths, warning };
158
+ return { routes, path: endpoints[0]?.route?.path ?? null, paths, warning };
179
159
  }
180
160
 
181
161
  type Descriptor = NonNullable<AppMcp['route']>;
@@ -197,7 +177,7 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
197
177
  method: 'GET',
198
178
  path: metadataPath,
199
179
  handler: (_request, ctx) => {
200
- const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
180
+ const response = jsonResponse(resource.document(selfOrigin(ctx.url, ctx.https)));
201
181
  // A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
202
182
  response.headers.set('access-control-allow-origin', '*');
203
183
  response.headers.set('cache-control', 'public, max-age=3600');
@@ -213,8 +193,13 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
213
193
  // The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
214
194
  // `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://`
215
195
  // on an internal host, and a `resource_metadata` naming that is a URL no client can reach.
196
+ // The client ADDRESS likewise: failed tokens are metered per address, and the descriptor
197
+ // has no other way to learn it.
216
198
  handler: (request, ctx) =>
217
- route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
199
+ route.handle(request.raw, {
200
+ origin: selfOrigin(ctx.url, ctx.https),
201
+ address: ctx.ip ?? undefined,
202
+ }),
218
203
  meta: { name, auth: 'public', enforcedBy: 'handler' },
219
204
  },
220
205
  ...metadata,
@@ -0,0 +1,52 @@
1
+ // `x new <name>`'s last refusal before a byte lands: the slug must be a name core's `defineConfig`
2
+ // accepts, because it is written verbatim as `app.config.ts`'s `name:` and spelled into every
3
+ // identifier the scaffold derives (`<app>Seed`). The rule is asked of core, never restated here.
4
+
5
+ import { defineConfig, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * Why core's own config validator refuses `name`, in core's words, or `undefined` when it accepts
9
+ * it — asked by building the one config that name would scaffold, every other field defaulted. A
10
+ * copy of core's pattern here would be a second answer to "what is an app name", and the day one
11
+ * of them moved `x new` would again write an app whose `app.config.ts` throws at its own import.
12
+ */
13
+ export function appNameIssue(name: string): string | undefined {
14
+ try {
15
+ defineConfig({ name });
16
+ return undefined;
17
+ } catch (error) {
18
+ return isUltimateError(error) ? error.cause : renderThrowable(error);
19
+ }
20
+ }
21
+
22
+ /** The bound the suggestion trims an over-long slug to — re-proved by `appNameIssue`, not trusted. */
23
+ const MAX_LENGTH = 64;
24
+
25
+ /**
26
+ * A slug core accepts, as close to `slug` as one edit gets: a leading digit gains an `app-` prefix
27
+ * (an identifier cannot start with one either), a one-letter name an `-app` suffix, and an
28
+ * over-long one is cut at the bound. `undefined` when none of those land on a valid name.
29
+ */
30
+ export function suggestAppName(slug: string): string | undefined {
31
+ let candidate = /^[0-9]/.test(slug) ? `app-${slug}` : slug;
32
+ if (candidate.length < 2) candidate = `${candidate}-app`;
33
+ candidate = candidate.slice(0, MAX_LENGTH).replace(/-+$/, '');
34
+ return appNameIssue(candidate) === undefined ? candidate : undefined;
35
+ }
36
+
37
+ /** `x new 9lives`, `x new a`: a slug core's `defineConfig` would refuse at the app's first boot. */
38
+ export class AppNameInvalidError extends UltimateError {
39
+ constructor(input: {
40
+ name: string;
41
+ slug: string;
42
+ issue: string;
43
+ invocation: string;
44
+ flags: readonly string[];
45
+ }) {
46
+ super({
47
+ code: 'X_CLI_BAD_FLAG',
48
+ cause: `"${input.name}" scaffolds the app name "${input.slug}", which app.config.ts's defineConfig refuses: ${input.issue}`,
49
+ fix: [input.invocation, suggestAppName(input.slug) ?? '<name>', ...input.flags].join(' '),
50
+ });
51
+ }
52
+ }
@@ -0,0 +1,123 @@
1
+ // The `policy` step's provenance question: every permission the app's OWN actions, queries and
2
+ // routes require is one the app ITSELF declares. `defineAdmin()` declares `<entity>:read|write|
3
+ // delete` for every mounted entity, so an app rule naming one passed in the web process and threw
4
+ // X_PERMISSION_UNKNOWN in any process that never mounted the admin — with nothing saying so.
5
+
6
+ import { listActions } from '@ultimat3/action';
7
+ import { type Policy, permissionDeclarationSites, policyPermissions } from '@ultimat3/policy';
8
+ import { listQueries } from '@ultimat3/query';
9
+ import { routeEntries } from '@ultimat3/render';
10
+ import { frameFile, rootRelative, siteFile } from './app-permissions-site';
11
+ import type { Finding } from './output';
12
+
13
+ /** One permission one app declaration requires. */
14
+ export interface AppRule {
15
+ readonly permission: string;
16
+ /** `action createPost`, `query postList`, or the route file. */
17
+ readonly by: string;
18
+ }
19
+
20
+ /** An app rule whose permission no app module declares, and the file that did declare it. */
21
+ export interface BorrowedPermission extends AppRule {
22
+ /**
23
+ * Root-relative paths of EVERY declaring module (`node_modules/…` or `../…`), distinct and
24
+ * sorted: naming only the first told the reader one package declared it when two did.
25
+ */
26
+ readonly from: readonly string[];
27
+ }
28
+
29
+ /**
30
+ * EVERY permission in each tree, composites included — unlike the grant question, which judges
31
+ * only a bare `can()`: an `or()` branch is still a `can()` that asserted its name, so it needs the
32
+ * app's own declaration as much as a bare one does. A screen the admin MOUNTS is not here: those
33
+ * are the package's rules, and declaring their names is the package's job.
34
+ */
35
+ export function appRules(): readonly AppRule[] {
36
+ const named = (by: string, policy: Policy<unknown, unknown>): readonly AppRule[] =>
37
+ policyPermissions(policy).map((permission) => ({ permission, by }));
38
+ return [
39
+ ...listActions().flatMap((target) => named(`action ${target.name}`, target.policy)),
40
+ ...listQueries().flatMap((target) => named(`query ${target.name}`, target.policy)),
41
+ ...routeEntries().flatMap((entry) => {
42
+ const permission = entry.config.policy?.permission;
43
+ return permission === undefined ? [] : [{ permission, by: entry.file }];
44
+ }),
45
+ ];
46
+ }
47
+
48
+ /** An app module's root-relative path, or `undefined` for a dependency's or an unreadable frame. */
49
+ const appFile = (root: string, site: string): string | undefined => {
50
+ const file = siteFile(root, site);
51
+ return file === undefined || file.split('/').includes('node_modules') ? undefined : file;
52
+ };
53
+
54
+ /**
55
+ * Provenance by the declaring module's PATH, which is where the call ran — never by the name's
56
+ * shape: `posts:read` is the same string whether `defineAdmin()` or the app declared it. A name
57
+ * with any unreadable site is one whose origin nobody can state, so it is not judged — and that
58
+ * covers an UNKNOWN name too, which has no site at all: it is X_PERMISSION_UNKNOWN's, asked first,
59
+ * and a second finding would be a second instruction for one fault.
60
+ */
61
+ export function borrowedPermissions(
62
+ root: string,
63
+ rules: readonly AppRule[] = appRules(),
64
+ declaredAt: Readonly<Record<string, readonly string[]>> = permissionDeclarationSites(),
65
+ ): readonly BorrowedPermission[] {
66
+ const found = new Map<string, BorrowedPermission>();
67
+ for (const rule of rules) {
68
+ const sites = Object.hasOwn(declaredAt, rule.permission) ? declaredAt[rule.permission] : [];
69
+ const files = (sites ?? []).map(frameFile);
70
+ if (files.length === 0 || files.some((file) => file === undefined)) continue;
71
+ if ((sites ?? []).some((site) => appFile(root, site) !== undefined)) continue;
72
+ const from = [
73
+ ...new Set(files.flatMap((file) => (file === undefined ? [] : [rootRelative(root, file)]))),
74
+ ].sort();
75
+ found.set(`${rule.permission}\u0000${rule.by}`, { ...rule, from });
76
+ }
77
+ return [...found.values()].sort((a, b) =>
78
+ a.permission === b.permission
79
+ ? a.by.localeCompare(b.by)
80
+ : a.permission.localeCompare(b.permission),
81
+ );
82
+ }
83
+
84
+ /**
85
+ * The app file its permissions are declared in — where the missing name is added. Read off the
86
+ * recorded sites, never a scaffold path; none under the root is `undefined`, never a guess.
87
+ */
88
+ export function permissionsFile(root: string): string | undefined {
89
+ for (const sites of Object.values(permissionDeclarationSites())) {
90
+ for (const site of sites) {
91
+ const file = appFile(root, site);
92
+ if (file !== undefined) return file;
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /** A TS string literal for the name — quoted the house way unless the name itself needs escaping. */
99
+ const literal = (name: string): string =>
100
+ /^[\w:.*-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
101
+
102
+ export function borrowedFinding(
103
+ entry: BorrowedPermission,
104
+ declaredIn: string | undefined,
105
+ ): Finding {
106
+ const [only, ...more] = entry.from;
107
+ const declarers =
108
+ more.length === 0 ? `only ${only} declares it` : `only ${entry.from.join(', ')} declare it`;
109
+ const where =
110
+ declaredIn === undefined
111
+ ? 'paste this call into an app module'
112
+ : `paste this call into ${declaredIn} (definePermissions() merges, so a second call is fine)`;
113
+ const finding: Finding = {
114
+ code: 'X_PERMISSION_BORROWED',
115
+ cause:
116
+ `${entry.by} requires ${literal(entry.permission)}, and ${declarers} — no app module does, ` +
117
+ "so the rule holds only in a process that ran that package's declaration (defineAdmin()) " +
118
+ `first; ${where}, then x verify --only policy`,
119
+ // A call to paste, never prose: `add …` read as a command answered `add: command not found`.
120
+ fix: `definePermissions([${literal(entry.permission)}])`,
121
+ };
122
+ return declaredIn === undefined ? finding : { ...finding, at: declaredIn };
123
+ }