@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
@@ -1,16 +1,19 @@
1
- // `x g job` / `x g task` / `x g resource` hand what they wrote to `apps/web/api/index.ts`. The scan
2
- // registers actions and queries by export name on its own and registers NO job: a job module
3
- // nothing lists keeps the positional `anonymous-job-2` that `job()` minted, on the queue row, in
1
+ // Every generator hands the primitives it wrote to `apps/web/api/index.ts`. The scan registers
2
+ // actions and queries by export name on its own and registers NO job: a job module nothing lists
3
+ // keeps the positional `anonymous-job-2` that `job()` minted, on the queue row, in
4
4
  // `x.manifest.json` and in every dead-letter trace — under a green gate (plan 101 slice 11 b).
5
+ // Actions and queries are listed for the TYPE: `Api` is what the browser's client is shaped from.
5
6
 
7
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
6
8
  import { API_INDEX } from './app-root';
7
9
  import { containedPath } from './generate-write';
10
+ import type { Finding } from './output';
8
11
  import { camel } from './templates/naming';
9
12
  import { wrapList } from './templates/wrap';
10
13
 
11
14
  /** One module to import as a namespace and list under a `defineApi` key. */
12
15
  export interface ApiEntry {
13
- readonly key: 'jobs' | 'tasks';
16
+ readonly key: 'actions' | 'queries' | 'jobs' | 'tasks';
14
17
  /** The namespace binding: the file's own name, camelCased — `reindexPost`. */
15
18
  readonly binding: string;
16
19
  /** From `apps/web/api/`: `../app/post/jobs/reindex-post`. */
@@ -18,17 +21,37 @@ export interface ApiEntry {
18
21
  }
19
22
 
20
23
  const PRIMITIVE_PATH =
21
- /^apps\/web\/(?<rest>[^/]+\/[^/]+\/(?<dir>jobs|tasks)\/(?<file>[a-z0-9-]+))\.ts$/;
24
+ /^apps\/web\/(?<rest>[^/]+\/[^/]+\/(?<dir>actions|queries|live|jobs|tasks)\/(?<file>[a-z0-9-]+))\.ts$/;
22
25
 
23
- /** The job and task modules among the paths a generator wrote. Tests are not modules to list. */
26
+ /** The order `x new` writes the lists in, which a list this adds keeps. */
27
+ const LIST_ORDER: readonly ApiEntry['key'][] = ['actions', 'queries', 'jobs', 'tasks'];
28
+
29
+ /**
30
+ * The `defineApi` list each generated directory belongs in. `actions/` holds mutators too, and a
31
+ * mutator IS an action — `defineApi` merges both lists into one registration — so one key serves.
32
+ */
33
+ const KEY_OF: Readonly<Record<string, ApiEntry['key']>> = {
34
+ actions: 'actions',
35
+ queries: 'queries',
36
+ live: 'queries',
37
+ jobs: 'jobs',
38
+ tasks: 'tasks',
39
+ };
40
+
41
+ /**
42
+ * The primitive modules among the paths a generator wrote. Tests are not modules to list.
43
+ *
44
+ * Actions and queries too, though the scan registers those at boot without being told: `Api` —
45
+ * the type the browser's typed client is shaped from — is `typeof defineApi({ … })`, so one the
46
+ * index does not list is an endpoint the page has no typed call for.
47
+ */
24
48
  export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] {
25
49
  return written.flatMap((path) => {
26
50
  const groups = PRIMITIVE_PATH.exec(path)?.groups;
27
51
  if (groups === undefined) return [];
28
52
  const { rest = '', dir, file = '' } = groups;
29
- return [
30
- { key: dir === 'tasks' ? 'tasks' : 'jobs', binding: camel(file), specifier: `../${rest}` },
31
- ];
53
+ const key = dir !== undefined && Object.hasOwn(KEY_OF, dir) ? KEY_OF[dir] : undefined;
54
+ return key === undefined ? [] : [{ key, binding: camel(file), specifier: `../${rest}` }];
32
55
  });
33
56
  }
34
57
 
@@ -87,8 +110,12 @@ export function insertApiEntries(
87
110
  const list = listOf(next, entry.key, call);
88
111
  if (list?.items.includes(entry.binding) === true) continue;
89
112
  if (list === undefined) {
90
- // After `actions: [...],` — the order `x new` writes: actions, queries, jobs, tasks.
91
- const after = next.indexOf('\n', actions.end);
113
+ // In the order `x new` writes — actions, queries, jobs, tasks: after the nearest list
114
+ // before it that the call already holds, which is `actions` at the least.
115
+ const earlier = LIST_ORDER.slice(0, LIST_ORDER.indexOf(entry.key))
116
+ .map((key) => listOf(next, key, call))
117
+ .findLast((found) => found !== undefined);
118
+ const after = next.indexOf('\n', (earlier ?? actions).end);
92
119
  const line = ` ${entry.key}: [${entry.binding}],`;
93
120
  next = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`;
94
121
  } else {
@@ -118,6 +145,93 @@ function withImport(source: string, importLine: string): string {
118
145
  return lines.join('\n');
119
146
  }
120
147
 
148
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
149
+
150
+ /** Every binding an import clause declares: `* as x`, `x`, `{ a, b as c, type d }`. */
151
+ const clauseBindings = (clause: string): readonly string[] =>
152
+ clause
153
+ .replace(/^type\s+/, '')
154
+ .replace(/[{}]/g, ',')
155
+ .split(',')
156
+ .map((part) =>
157
+ part
158
+ .trim()
159
+ .replace(/^type\s+/, '')
160
+ .replace(/^\*\s+as\s+/, ''),
161
+ )
162
+ .map(
163
+ (part) =>
164
+ part
165
+ .split(/\s+as\s+/)
166
+ .at(-1)
167
+ ?.trim() ?? '',
168
+ )
169
+ .filter((name) => IDENTIFIER.test(name));
170
+
171
+ /**
172
+ * Every top-level name the index declares, and what holds it: a namespace import by its
173
+ * specifier, anything else (`defineApi`, `api`, `Api`, a named import) by its own name. A second
174
+ * binding of any of them is a module that does not load — `import * as api` beside
175
+ * `export const api` is a ReferenceError in every app-loading command.
176
+ */
177
+ function declaredBindings(source: string): ReadonlyMap<string, string> {
178
+ const held = new Map<string, string>();
179
+ for (const found of source.matchAll(/^import\s+([^;]*?)\s+from\s+'([^']+)';/gm)) {
180
+ const [, clause = '', specifier = ''] = found;
181
+ const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(clause.trim())?.[1];
182
+ if (namespace !== undefined) held.set(namespace, specifier);
183
+ else for (const name of clauseBindings(clause)) held.set(name, `an import from ${specifier}`);
184
+ }
185
+ const declaration =
186
+ /^(?:export\s+)?(?:declare\s+)?(?:const|let|var|function\*?|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/gm;
187
+ for (const found of source.matchAll(declaration)) {
188
+ const [, name = ''] = found;
189
+ if (!held.has(name)) held.set(name, `the index's own ${name}`);
190
+ }
191
+ return held;
192
+ }
193
+
194
+ /**
195
+ * A written primitive whose namespace binding the index already holds — for another module, or as
196
+ * one of its own names — is a finding, decided BEFORE anything is written. Skipped silently, the
197
+ * job stayed unregistered under a green run; inserted, the index stopped loading.
198
+ */
199
+ export function apiBindingFindings(
200
+ source: string,
201
+ written: readonly string[],
202
+ fix: string,
203
+ ): readonly Finding[] {
204
+ const held = new Map(declaredBindings(source));
205
+ const findings: Finding[] = [];
206
+ for (const entry of apiEntriesFor(written)) {
207
+ const holder = held.get(entry.binding);
208
+ if (holder === entry.specifier) continue;
209
+ if (holder === undefined) {
210
+ held.set(entry.binding, entry.specifier);
211
+ continue;
212
+ }
213
+ findings.push({
214
+ code: 'X_GENERATE_CONFLICT',
215
+ cause: `${API_INDEX} binds every listed module under its file name, and "${entry.binding}" is already ${holder.startsWith('.') ? `the namespace of ${holder}` : holder} — ${entry.specifier} cannot be listed under it`,
216
+ fix,
217
+ docs: ERROR_DOCS_URL,
218
+ at: API_INDEX,
219
+ });
220
+ }
221
+ return findings;
222
+ }
223
+
224
+ /** `apiBindingFindings` against the app's own index; none when the app has no index. */
225
+ export async function indexBindingFindings(
226
+ root: string,
227
+ written: readonly string[],
228
+ fix: string,
229
+ ): Promise<readonly Finding[]> {
230
+ const file = containedPath(root, API_INDEX);
231
+ if (!(await Bun.file(file).exists())) return [];
232
+ return apiBindingFindings(await Bun.file(file).text(), written, fix);
233
+ }
234
+
121
235
  /** Performs `insertApiEntries` on the app's index. Answers the paths it rewrote. */
122
236
  export async function registerGeneratedPrimitives(
123
237
  root: string,
@@ -1,6 +1,6 @@
1
1
  // The two generated contracts an app commits — `x.manifest.json` and `openapi.json` — written by one
2
2
  // function, because two commands wrote them and only one wrote both: `x g` refreshed the manifest
3
- // and left `openapi.json` behind, so its own output failed the `contract-diff` step next.
3
+ // and left `openapi.json` behind, so its own output failed the gate's `manifest` step next.
4
4
 
5
5
  import { join } from 'node:path'; // why: Bun ships no path-join primitive.
6
6
  import type { Manifest } from '@ultimat3/manifest';
package/src/app-auth.ts CHANGED
@@ -1,32 +1,9 @@
1
1
  // The one fact `x dev` and `serve.ts` need out of `app.config.ts` before a listener binds: where
2
- // this app's sign-in page is. Sibling of `app-env.ts`'s `loadEnvSchema`, and imports the config
3
- // for the same reason — a regex over the app's source is the pattern `app-load.ts` refuses.
2
+ // this app's sign-in page is, off the one loader (`app-config-load.ts`).
4
3
 
5
- import { existsSync } from 'node:fs';
6
- import { join } from 'node:path';
7
- import { APP_CONFIG_FILE } from './app-root';
4
+ import { loadAppConfig } from './app-config-load';
8
5
 
9
- /** The export every app declares. Named, never default — the CLI and the runtime both import it. */
10
- export const APP_CONFIG_EXPORT = 'config';
11
-
12
- const isRecord = (value: unknown): value is Record<string, unknown> =>
13
- typeof value === 'object' && value !== null;
14
-
15
- /**
16
- * `auth.signInPath`, or `null` when the app declares none.
17
- *
18
- * Structural, not `instanceof`: `defineConfig` returns a plain object, and a config that resolved
19
- * through an older version of core simply has no `auth` section. `null` is the safe answer either
20
- * way — it is what turns the browser redirect off and leaves the problem document in place.
21
- */
6
+ /** `auth.signInPath`, or `null` when the app declares none or the root has no config file. */
22
7
  export async function loadSignInPath(root: string): Promise<string | null> {
23
- const configPath = join(root, APP_CONFIG_FILE);
24
- if (!existsSync(configPath)) return null;
25
- const module = (await import(configPath)) as Record<string, unknown>;
26
- const config = module[APP_CONFIG_EXPORT];
27
- if (!isRecord(config)) return null;
28
- const auth = config['auth'];
29
- if (!isRecord(auth)) return null;
30
- const path = auth['signInPath'];
31
- return typeof path === 'string' && path.startsWith('/') ? path : null;
8
+ return (await loadAppConfig(root))?.auth.signInPath ?? null;
32
9
  }
@@ -9,6 +9,10 @@
9
9
  // Runtime imports only: `Bun.Transpiler.scanImports` sees what survives type erasure, which is
10
10
  // exactly the distinction the `app/ -> api/` rule needs (`import type` is allowed, a value
11
11
  // import is not).
12
+ //
13
+ // The browser-transport rule rides the same read: `boundary-findings.ts`'s `appBoundaryFindings`
14
+ // hands the files read here to `app-transport.ts`, so a raw `fetch(` in an island is a finding of
15
+ // this step with no second walk of the app.
12
16
 
13
17
  // Bun ships no `Bun.*` path API: `joinPath` reaches a file on disk with the host's separator.
14
18
  import { join as joinPath } from 'node:path';
@@ -17,6 +21,7 @@ import { dirname, join, normalize, relative } from 'node:path/posix';
17
21
  import { ERROR_DOCS_URL } from '@ultimat3/core';
18
22
  import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
19
23
  import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
24
+ import { commentSafe } from './comment-safe';
20
25
  import { scanRuntimeImports, stripShebang } from './import-scan';
21
26
  import type { Finding } from './output';
22
27
  import { hasPathSegment } from './path-segments';
@@ -156,20 +161,6 @@ function subjectOf(path: string): string | undefined {
156
161
  return parent === undefined || SURFACE_NAMES.has(parent) ? undefined : parent;
157
162
  }
158
163
 
159
- /**
160
- * Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
161
- * and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
162
- * after it is a second command in a line whose whole purpose is to be pasted into a shell.
163
- * Escaped rather than deleted, because the comment still has to name the file the reader owns.
164
- */
165
- const commentSafe = (path: string): string =>
166
- [...path]
167
- .map((char) => {
168
- const code = char.codePointAt(0) ?? 0;
169
- return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
170
- })
171
- .join('');
172
-
173
164
  /**
174
165
  * `x g query posts` where the path names a resource, `x g query <name>` where it names a surface.
175
166
  * The placeholder form is the shape `MissingPositionalError` already hands out (`x g route
@@ -233,7 +224,7 @@ const APP_GLOBS = ['apps/*/{site,app,api,shared}/**/*.{ts,tsx}'];
233
224
  /**
234
225
  * Read every source file under an app's site, app, api and shared surfaces (`APP_GLOBS`). The
235
226
  * only I/O in this module, and the one definition of "which files are the app's sources" —
236
- * `checkAppBoundaries` reads through this, and so does `x fix boundary`, never a second glob.
227
+ * `appBoundaryFindings` reads through this, and so does `x fix boundary`, never a second glob.
237
228
  */
238
229
  export async function readAppSources(root: string): Promise<readonly SourceFile[]> {
239
230
  const files: SourceFile[] = [];
@@ -252,8 +243,3 @@ export async function readAppSources(root: string): Promise<readonly SourceFile[
252
243
  export function appImportGraph(files: readonly SourceFile[]): ImportGraph {
253
244
  return graphOf(scan(files));
254
245
  }
255
-
256
- /** Read an app's sources and check them. */
257
- export async function checkAppBoundaries(root: string): Promise<readonly Finding[]> {
258
- return checkImportRules(await readAppSources(root));
259
- }
@@ -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, or one resolved through an older core, reaches the
34
+ * boot validated and defaulted exactly as one built by `defineConfig` does, which re-merges to
35
+ * itself. 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,8 +2,6 @@
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';
@@ -13,6 +11,7 @@ import {
13
11
  ERROR_DOCS_URL,
14
12
  renderEnvExample,
15
13
  } from '@ultimat3/core';
14
+ import { appConfigExport } from './app-config-load';
16
15
  import { APP_CONFIG_FILE } from './app-root';
17
16
  import type { Finding } from './output';
18
17
  import { findingFrom } from './output';
@@ -45,17 +44,12 @@ export const isEnvSchema = (value: unknown): value is EnvSchema =>
45
44
  isRecord(value) && Object.values(value).every(isDecl);
46
45
 
47
46
  /**
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.
47
+ * The declaration `app.config.ts` exports, through the one loader (`app-config-load.ts`).
48
+ * `undefined` means "this root declares no environment"; a config that will not import throws, and
49
+ * the caller turns that into the finding.
52
50
  */
53
51
  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;
52
+ const declared = await appConfigExport(root, ENV_SCHEMA_EXPORT);
59
53
  return isEnvSchema(declared) ? declared : undefined;
60
54
  }
61
55
 
package/src/app-load.ts CHANGED
@@ -151,7 +151,31 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
151
151
  return found.includes(index) ? [index, ...found.filter((path) => path !== index)] : found;
152
152
  }
153
153
 
154
- export async function loadApp(root: string): Promise<LoadedApp> {
154
+ /** What one scan of the app's modules imported, and what would not. */
155
+ export interface AppScan {
156
+ /** App-root-relative POSIX paths of every module that imported, sorted. */
157
+ readonly files: readonly string[];
158
+ /** Modules that would not import, and primitives that would not register. */
159
+ readonly findings: readonly Finding[];
160
+ }
161
+
162
+ export interface ScanOptions {
163
+ /**
164
+ * Records each module's bytes and pins so a LATER scan can evict what changed: a tool's and
165
+ * `x dev`'s. A container scans once per process, so its boot passes `false` and neither reads a
166
+ * module before importing it nor hashes one (`role-load.ts`).
167
+ */
168
+ readonly track: boolean;
169
+ /** `false` leaves a module out: neither read nor imported. Absent: every module is imported. */
170
+ readonly include?: (absolute: string) => boolean | Promise<boolean>;
171
+ }
172
+
173
+ /**
174
+ * The scan itself: every app module `options.include` admits, imported in `appModulePaths` order
175
+ * and registered. `loadApp` is this plus the facts a tool reads; a container role calls it through
176
+ * `loadAppForRole`, which decides what the role imports.
177
+ */
178
+ export async function scanAppModules(root: string, options: ScanOptions): Promise<AppScan> {
155
179
  // Before any import: a stylesheet's surface is read below the app root, never off its absolute
156
180
  // path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
157
181
  // sheet, the site's included, classified as app CSS.
@@ -164,7 +188,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
164
188
  const findings: Finding[] = [];
165
189
  // A rescan: whatever changed on disk since the last one, and everything importing it, leaves
166
190
  // Bun's module registry before the loop below imports it again.
167
- if (registered.size > 0) await evictChanged(root);
191
+ if (options.track && registered.size > 0) await evictChanged(root);
168
192
 
169
193
  for (const absolute of await appModulePaths(root)) {
170
194
  // A SEGMENT, never a substring: an app checked out under
@@ -177,15 +201,19 @@ export async function loadApp(root: string): Promise<LoadedApp> {
177
201
  if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
178
202
  continue;
179
203
  }
204
+ if (options.include !== undefined && !(await options.include(absolute))) continue;
180
205
  // The source is read BEFORE the import, on the file's first pass and for every route module —
181
206
  // a rescan of any other registered module reads nothing here. What is recorded is bound to the
182
207
  // bytes the module was evaluated from, and a read AFTER the import cannot know which bytes
183
208
  // those were: a save landing between the two bound V1's component to V2's hash, so the next
184
209
  // scan saw nothing to do and served V1 until the save after. Read first, the worst case is one
185
- // re-import the next tick, of a file that did change.
210
+ // re-import the next tick, of a file that did change. An untracked scan reads nothing here:
211
+ // no save lands in an image, so a route module's text is read after its import, once.
186
212
  const first = !registered.has(absolute) && !failures.has(absolute);
187
213
  const snapshot =
188
- first || routeModules.has(absolute) ? await Bun.file(absolute).text() : undefined;
214
+ options.track && (first || routeModules.has(absolute))
215
+ ? await Bun.file(absolute).text()
216
+ : undefined;
189
217
  if (first && snapshot !== undefined) await trackModule(absolute, snapshot, root);
190
218
  let module: Record<string, unknown>;
191
219
  try {
@@ -195,16 +223,26 @@ export async function loadApp(root: string): Promise<LoadedApp> {
195
223
  continue;
196
224
  }
197
225
  files.push(file);
198
- const finding = await register(absolute, file, module, snapshot);
226
+ const finding = await register(absolute, file, module, snapshot, options.track);
199
227
  if (finding !== undefined) findings.push(finding);
200
228
  }
201
229
 
202
230
  files.sort();
231
+ return { files, findings };
232
+ }
233
+
234
+ export async function loadApp(root: string): Promise<LoadedApp> {
235
+ const scan = await scanAppModules(root, { track: true });
236
+ // After the scan, because the scan is what mounts an admin: a declaration the globs above cannot
237
+ // reach is a finding here, owned by `manifest` like every other load finding. Imported here, not
238
+ // at the top: a container boots through `scanAppModules` and never asks (`serve-graph.test.ts`).
239
+ const { unscannedAdminFindings } = await import('./unscanned-admin');
240
+ const findings = [...scan.findings, ...(await unscannedAdminFindings(root))];
203
241
  await trackStylesheets(root);
204
242
  // An app that imported NOTHING and reported nothing is a registry every later step reads as
205
243
  // empty-and-fine. With an `app.config.ts` beside it, that is never what the author meant.
206
244
  if (
207
- files.length === 0 &&
245
+ scan.files.length === 0 &&
208
246
  findings.length === 0 &&
209
247
  registersNothing() &&
210
248
  (await Bun.file(join(root, APP_CONFIG_FILE)).exists())
@@ -214,7 +252,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
214
252
  // Read after the loop, never before it: `configureLocales` runs on the app's own import.
215
253
  return {
216
254
  root,
217
- files,
255
+ files: scan.files,
218
256
  errorCodes: await appErrorCodes(root),
219
257
  defaultLocale: localeConfig().fallback,
220
258
  findings,
@@ -240,13 +278,15 @@ const emptyAppFinding = (root: string): Finding => ({
240
278
  /**
241
279
  * Registers a module once; every later call replays whatever the first one reported — except for
242
280
  * a route module, which a later call re-registers when the import handed back a new instance.
243
- * `snapshot` is the source read before this import: always for a first pass and a route module.
281
+ * `snapshot` is the source read before this import: always for a first pass and a route module of
282
+ * a TRACKED scan. An untracked one hands none, and a route module's text is read here instead.
244
283
  */
245
284
  async function register(
246
285
  absolute: string,
247
286
  file: string,
248
287
  module: Record<string, unknown>,
249
288
  snapshot: string | undefined,
289
+ track: boolean,
250
290
  ): Promise<Finding | undefined> {
251
291
  const previous = failures.get(absolute);
252
292
  if (previous !== undefined) return previous;
@@ -255,11 +295,15 @@ async function register(
255
295
  try {
256
296
  const config = module['config'];
257
297
  const route = isRouteConfig(config) ? config : undefined;
258
- if (route !== undefined && snapshot !== undefined) {
259
- registerRouteModule(file, module, route, snapshot);
298
+ if (route !== undefined) {
299
+ registerRouteModule(file, module, route, snapshot ?? (await Bun.file(absolute).text()));
260
300
  routeModules.set(absolute, module);
261
301
  }
262
- if (definesPrimitive(module, route !== undefined) || file === API_INDEX) pinModule(absolute);
302
+ // A pin is the reload graph's: it stops an eviction a process that never rescans never runs,
303
+ // and deciding it walks every export of every module against the entity registry.
304
+ if (track && (definesPrimitive(module, route !== undefined) || file === API_INDEX)) {
305
+ pinModule(absolute);
306
+ }
263
307
  registerActions(module);
264
308
  registerQueries(module);
265
309
  return undefined;
@@ -6,8 +6,10 @@
6
6
  // Bun ships no `Bun.*` path API: `join` builds the host-separator path to `x.manifest.json`.
7
7
  import { join } from 'node:path';
8
8
  import { describeActions } from '@ultimat3/action';
9
+ import { primitiveRegistrar } from '@ultimat3/core';
9
10
  import { registeredLocales } from '@ultimat3/i18n';
10
11
  import { registeredTasks } from '@ultimat3/jobs';
12
+ import { sendMailJob } from '@ultimat3/mail';
11
13
  import type { Manifest, PolicyFact, RouteFact, TaskFact } from '@ultimat3/manifest';
12
14
  import {
13
15
  buildManifest,
@@ -30,9 +32,21 @@ export interface AppManifest {
30
32
  readonly findings: readonly Finding[];
31
33
  }
32
34
 
35
+ /**
36
+ * The jobs every role process registers whatever the app imports: `runtime-services.ts` imports
37
+ * `@ultimat3/mail` in every role, and that import declares `mail.send` — so every app's worker
38
+ * runs it, and the manifest records it. Seated here rather than left to an import's side effect:
39
+ * `x manifest`'s module graph never reached the mail barrel and `x verify`'s did, so the same app
40
+ * got two job lists and the `fix:` of `X_MANIFEST_DRIFT` could never clear it.
41
+ */
42
+ const RUNTIME_JOBS: Readonly<Record<string, unknown>> = { sendMailJob };
43
+
33
44
  /** Load the app, then describe it. Every command that needs facts goes through here. */
34
45
  export async function appManifest(root: string): Promise<AppManifest> {
35
46
  const loaded = await loadApp(root);
47
+ // Through the registrar `defineApi` uses (`define-api.ts`): the declared name wins, and the
48
+ // same handle seated twice is one registration, so a second build of one tree is unchanged.
49
+ primitiveRegistrar('job')(RUNTIME_JOBS);
36
50
  const manifest = buildManifest(
37
51
  frameworkSources({
38
52
  app: await appIdentity(root),
@@ -103,14 +117,8 @@ const routeFacts = (): readonly RouteFact[] =>
103
117
  ...budgetOf(route),
104
118
  }));
105
119
 
106
- function budgetOf(route: RouteDescriptor): { budget?: { js?: string; lcp?: number } } {
107
- if (route.budgetJs === null && route.budgetLcp === null) return {};
108
- return {
109
- budget: {
110
- ...(route.budgetJs === null ? {} : { js: route.budgetJs }),
111
- ...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
112
- },
113
- };
120
+ function budgetOf(route: RouteDescriptor): { budget?: { js: string } } {
121
+ return route.budgetJs === null ? {} : { budget: { js: route.budgetJs } };
114
122
  }
115
123
 
116
124
  /**
package/src/app-mcp.ts CHANGED
@@ -9,7 +9,7 @@
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';
@@ -20,8 +20,7 @@ import {
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';
@@ -57,26 +56,9 @@ interface ExposeDeclaration {
57
56
  const isRecord = (value: unknown): value is Record<string, unknown> =>
58
57
  typeof value === 'object' && value !== null;
59
58
 
60
- /**
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.
65
- */
59
+ /** `config.ai.mcp` off the one loader (`app-config-load.ts`); nothing exposed with no config file. */
66
60
  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
- };
61
+ return (await loadAppConfig(root))?.ai.mcp;
80
62
  }
81
63
 
82
64
  const isAppMcp = (value: unknown): value is AppMcp =>
@@ -213,8 +195,13 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
213
195
  // The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
214
196
  // `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://`
215
197
  // on an internal host, and a `resource_metadata` naming that is a URL no client can reach.
198
+ // The client ADDRESS likewise: failed tokens are metered per address, and the descriptor
199
+ // has no other way to learn it.
216
200
  handler: (request, ctx) =>
217
- route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
201
+ route.handle(request.raw, {
202
+ origin: selfOrigin(ctx.url, ctx.https),
203
+ address: ctx.ip ?? undefined,
204
+ }),
218
205
  meta: { name, auth: 'public', enforcedBy: 'handler' },
219
206
  },
220
207
  ...metadata,