@ultimat3/cli 22.14.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CLAUDE.md +25 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +35 -3
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
package/src/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),
Binary file
@@ -20,9 +20,10 @@
20
20
  // the old instance of what changed (a slice's service under its query, an admin page's view under
21
21
  // `defineAdmin`), so only a new process serves the save. `x dev` restarts on it (`dev-supervisor.ts`).
22
22
 
23
- // why: Bun ships no path API; import specifiers resolve against the importing file's directory.
24
- import { dirname, resolve } from 'node:path';
23
+ // why: Bun ships no path API; the app root is compared as an absolute path.
24
+ import { resolve } from 'node:path';
25
25
  import { registeredStylesheets } from '@ultimat3/render/server';
26
+ import { resolvedImports } from './module-imports';
26
27
  import { hasPathSegment } from './path-segments';
27
28
 
28
29
  type Kind = 'script' | 'style' | 'data';
@@ -91,11 +92,6 @@ const STYLE = /\.(?:s[ac]ss|css)$/;
91
92
  const kindOf = (path: string): Kind =>
92
93
  SCRIPT.test(path) ? 'script' : STYLE.test(path) ? 'style' : 'data';
93
94
 
94
- const transpilers = {
95
- ts: new Bun.Transpiler({ loader: 'ts' }),
96
- tsx: new Bun.Transpiler({ loader: 'tsx' }),
97
- };
98
-
99
95
  const readText = async (path: string): Promise<string | null> => {
100
96
  try {
101
97
  return await Bun.file(path).text();
@@ -108,29 +104,10 @@ const hashOf = (text: string | null): bigint | null =>
108
104
  text === null ? null : BigInt(Bun.hash.wyhash(text));
109
105
 
110
106
  /** The in-app files `source` imports — a package under `node_modules` is not the app's to reload. */
111
- function importsOf(path: string, source: string, root: string): string[] {
112
- let specifiers: readonly { readonly path: string }[];
113
- try {
114
- const transpiler = path.endsWith('x') ? transpilers.tsx : transpilers.ts;
115
- specifiers = transpiler.scanImports(source);
116
- } catch {
117
- // A file that will not parse imports nothing yet; its own hash still changes on the fix.
118
- return [];
119
- }
120
- const found: string[] = [];
121
- for (const { path: specifier } of specifiers) {
122
- let target: string;
123
- try {
124
- target = Bun.resolveSync(specifier, dirname(path));
125
- } catch {
126
- continue;
127
- }
128
- if (target.startsWith(`${root}/`) && !hasPathSegment(target, 'node_modules')) {
129
- found.push(target);
130
- }
131
- }
132
- return found;
133
- }
107
+ const importsOf = (path: string, source: string, root: string): string[] =>
108
+ resolvedImports(path, source, 'referenced').filter(
109
+ (target) => target.startsWith(`${root}/`) && !hasPathSegment(target, 'node_modules'),
110
+ );
134
111
 
135
112
  function link(from: string, to: string): void {
136
113
  let set = importers.get(to);
@@ -0,0 +1,219 @@
1
+ // The browser-transport rule (`browser-transport.ts`) over ONE APP: what `x verify`'s `boundaries`
2
+ // step reports for a raw `fetch(` in an island. Built in rather than a file in `guards/` — a rule
3
+ // an app can delete is a rule the next agent deletes.
4
+ //
5
+ // It reads nothing `readAppSources` already read. The islands and the surface files arrive in
6
+ // memory; the only new I/O is the app's `tsconfig.json`, its workspace manifests, the installed
7
+ // `@ultimat3/*` manifests (which name the browser entries), and — lazily — the files of the app's
8
+ // own `packages/*` an island actually imports. An app with no island pays for none of it.
9
+ //
10
+ // The walk STOPS at an installed package: `@ultimat3/core`'s `clientTransport`, realtime's socket
11
+ // and storage's upload client are the three seams, they live in `node_modules`, and they are
12
+ // trusted rather than re-read — in an app, no file may open its own connection.
13
+
14
+ // why: `ClosureHost.read` is synchronous (the walk is a plain loop) and `Bun.file().text()` is not.
15
+ import { readFileSync } from 'node:fs';
16
+ // why: Bun ships no path API; this reaches a file on disk with the host's separator.
17
+ import { join } from 'node:path';
18
+ import { isJsonObject } from '@ultimat3/core';
19
+ import type { SourceFile } from './app-boundaries';
20
+ import type { FindingContext } from './browser-transport';
21
+ import {
22
+ barrelFinding,
23
+ browserEntries,
24
+ bypassFinding,
25
+ checkBrowserTransport,
26
+ } from './browser-transport';
27
+ import type { ClosureHost } from './import-closure';
28
+ import type { Finding } from './output';
29
+ import { hasPathSegment } from './path-segments';
30
+ import { serverBarrels } from './server-barrels';
31
+ import type { TransportShape } from './transport-calls';
32
+
33
+ /** What an app is told may hold each shape: the framework's function, never a file of its own. */
34
+ const HOLDER: ReadonlyMap<TransportShape, string> = new Map([
35
+ ['fetch', "@ultimat3/core's clientTransport"],
36
+ ['websocket', "@ultimat3/realtime's page socket"],
37
+ ['xhr', "@ultimat3/storage's uploadFile"],
38
+ ]);
39
+
40
+ const CONTEXT: FindingContext = {
41
+ holder: (shape) => HOLDER.get(shape),
42
+ rerun: 'x verify --only boundaries --json',
43
+ };
44
+
45
+ /** One `paths` row or one `exports` row, as a specifier pattern: at most one `*` on each side. */
46
+ interface Pattern {
47
+ readonly head: string;
48
+ readonly tail: string;
49
+ readonly wild: boolean;
50
+ /** Root-relative, `*` still in place. */
51
+ readonly target: string;
52
+ }
53
+
54
+ const patternOf = (key: string, target: string): Pattern => {
55
+ const star = key.indexOf('*');
56
+ return star < 0
57
+ ? { head: key, tail: '', wild: false, target }
58
+ : { head: key.slice(0, star), tail: key.slice(star + 1), wild: true, target };
59
+ };
60
+
61
+ /** The path `spec` names under `pattern`, or `undefined` when the pattern is not about it. */
62
+ function applied(pattern: Pattern, spec: string): string | undefined {
63
+ if (!pattern.wild) return spec === pattern.head ? pattern.target : undefined;
64
+ if (!spec.startsWith(pattern.head) || !spec.endsWith(pattern.tail)) return undefined;
65
+ if (spec.length < pattern.head.length + pattern.tail.length) return undefined;
66
+ const middle = spec.slice(pattern.head.length, spec.length - pattern.tail.length);
67
+ return pattern.target.replace('*', middle);
68
+ }
69
+
70
+ const posix = (path: string): string => path.split('\\').join('/').replace(/^\.\//, '');
71
+
72
+ /** A manifest or a tsconfig, or `undefined`: absent and unreadable both mean "says nothing". */
73
+ async function readObject(path: string): Promise<Readonly<Record<string, unknown>> | undefined> {
74
+ const file = Bun.file(path);
75
+ if (!(await file.exists())) return undefined;
76
+ try {
77
+ // JSONC: a tsconfig may carry comments and trailing commas, and a strict parse would turn one
78
+ // into "this app has no aliases" — every island's imports unresolved, the rule silently blind.
79
+ const parsed: unknown = Bun.JSONC.parse(await file.text());
80
+ return isJsonObject(parsed) ? parsed : undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /** `compilerOptions.paths` of the app's root tsconfig — how its own packages are spelled. */
87
+ async function tsconfigPatterns(root: string): Promise<readonly Pattern[]> {
88
+ const options = (await readObject(join(root, 'tsconfig.json')))?.['compilerOptions'];
89
+ const paths = isJsonObject(options) ? options['paths'] : undefined;
90
+ if (!isJsonObject(paths)) return [];
91
+ return Object.entries(paths).flatMap(([key, targets]) => {
92
+ const first: unknown = Array.isArray(targets) ? targets[0] : undefined;
93
+ return typeof first === 'string' ? [patternOf(key, posix(first))] : [];
94
+ });
95
+ }
96
+
97
+ interface Workspaces {
98
+ /** Every specifier a workspace package publishes, as a pattern onto its file. */
99
+ readonly patterns: readonly Pattern[];
100
+ /** Package name → its directory, for "is this file inside the package that barrel names". */
101
+ readonly dirs: ReadonlyMap<string, string>;
102
+ }
103
+
104
+ /** The app's own `apps/*` and `packages/*`, by the `name` and `exports` each manifest declares. */
105
+ async function workspaces(root: string): Promise<Workspaces> {
106
+ const patterns: Pattern[] = [];
107
+ const dirs = new Map<string, string>();
108
+ for await (const manifest of new Bun.Glob('{apps,packages}/*/package.json').scan({ cwd: root })) {
109
+ const dir = posix(manifest).split('/').slice(0, 2).join('/');
110
+ const parsed = await readObject(join(root, manifest));
111
+ const name = parsed?.['name'];
112
+ if (typeof name !== 'string') continue;
113
+ dirs.set(name, dir);
114
+ const exports = isJsonObject(parsed?.['exports']) ? parsed['exports'] : {};
115
+ for (const [sub, target] of Object.entries(exports)) {
116
+ if (typeof target !== 'string') continue;
117
+ const spec = sub === '.' ? name : `${name}${sub.slice(1)}`;
118
+ patterns.push(patternOf(spec, `${dir}/${posix(target)}`));
119
+ }
120
+ }
121
+ return { patterns, dirs };
122
+ }
123
+
124
+ const INSTALLED = [
125
+ 'node_modules/@ultimat3/*/package.json',
126
+ // A workspace install may leave a package beside the workspace that asked for it.
127
+ '{apps,packages}/*/node_modules/@ultimat3/*/package.json',
128
+ ];
129
+
130
+ /** Every specifier the installed framework publishes — what `serverBarrels` derives its map from. */
131
+ async function installedSpecifiers(root: string): Promise<ReadonlySet<string>> {
132
+ const specifiers = new Set<string>();
133
+ for (const pattern of INSTALLED) {
134
+ const found = new Bun.Glob(pattern).scan({ cwd: root, followSymlinks: true });
135
+ for await (const manifest of found) {
136
+ const parsed = await readObject(join(root, manifest));
137
+ const name = parsed?.['name'];
138
+ if (typeof name !== 'string') continue;
139
+ const exports = isJsonObject(parsed?.['exports']) ? parsed['exports'] : {};
140
+ for (const sub of Object.keys(exports)) {
141
+ if (!sub.includes('*')) specifiers.add(sub === '.' ? name : `${name}${sub.slice(1)}`);
142
+ }
143
+ }
144
+ }
145
+ return specifiers;
146
+ }
147
+
148
+ const SUFFIXES = ['', '.ts', '.tsx', '/index.ts', '/index.tsx'];
149
+
150
+ /**
151
+ * The closure's view of the app: the surface files from memory, a file of the app's own
152
+ * `packages/*` from disk the first time an island reaches it, and nothing under `node_modules` —
153
+ * a relative import that climbs into an installed package is still past the boundary.
154
+ */
155
+ function hostOf(
156
+ root: string,
157
+ sources: ReadonlyMap<string, string>,
158
+ patterns: readonly Pattern[],
159
+ ): ClosureHost {
160
+ const disk = new Map<string, string | undefined>();
161
+ const read = (path: string): string | undefined => {
162
+ const held = sources.get(path);
163
+ if (held !== undefined) return held;
164
+ if (!/\.tsx?$/.test(path) || hasPathSegment(path, 'node_modules')) return undefined;
165
+ if (!disk.has(path)) {
166
+ try {
167
+ disk.set(path, readFileSync(join(root, path), 'utf8'));
168
+ } catch {
169
+ disk.set(path, undefined);
170
+ }
171
+ }
172
+ return disk.get(path);
173
+ };
174
+ const alias = (spec: string): string | undefined => {
175
+ for (const pattern of patterns) {
176
+ const base = applied(pattern, spec);
177
+ if (base === undefined) continue;
178
+ for (const suffix of SUFFIXES) {
179
+ if (read(`${base}${suffix}`) !== undefined) return `${base}${suffix}`;
180
+ }
181
+ }
182
+ return undefined;
183
+ };
184
+ return { read, alias };
185
+ }
186
+
187
+ /**
188
+ * `X_BROWSER_TRANSPORT_BYPASS` and `X_BROWSER_SERVER_BARREL` for the app at `root`, over the
189
+ * sources `readAppSources` already holds.
190
+ */
191
+ export async function appTransportFindings(
192
+ root: string,
193
+ files: readonly SourceFile[],
194
+ ): Promise<readonly Finding[]> {
195
+ const sources = new Map(files.map((file) => [file.path, file.source]));
196
+ const entries = browserEntries(sources);
197
+ if (entries.length === 0) return [];
198
+ const [paths, own, installed] = await Promise.all([
199
+ tsconfigPatterns(root),
200
+ workspaces(root),
201
+ installedSpecifiers(root),
202
+ ]);
203
+ const published = [...installed, ...own.patterns.filter((p) => !p.wild).map((p) => p.head)];
204
+ const { bypasses, barrels } = checkBrowserTransport({
205
+ entries,
206
+ // The tsconfig first: it is what the app's own typecheck resolves through.
207
+ host: hostOf(root, sources, [...paths, ...own.patterns]),
208
+ seams: new Map(),
209
+ barrels: serverBarrels(published),
210
+ ownsBarrel: (file, barrel) => {
211
+ const dir = own.dirs.get(barrel);
212
+ return dir !== undefined && file.startsWith(`${dir}/`);
213
+ },
214
+ });
215
+ return [
216
+ ...bypasses.map((bypass) => bypassFinding(bypass, CONTEXT)),
217
+ ...barrels.map((barrel) => barrelFinding(barrel, CONTEXT)),
218
+ ];
219
+ }
@@ -3,7 +3,9 @@
3
3
  // was another command to run for the real one (plan 101 slice 11 j). The planner is the same one
4
4
  // `x fix boundary` prints from, so the gate and the command cannot disagree about the cut.
5
5
 
6
+ import type { SourceFile } from './app-boundaries';
6
7
  import { appImportGraph, checkImportRules, readAppSources } from './app-boundaries';
8
+ import { appTransportFindings } from './app-transport';
7
9
  import { planBoundaryCuts } from './boundary-cuts';
8
10
  import type { Finding } from './output';
9
11
 
@@ -29,8 +31,18 @@ export function withCutEdits(
29
31
  });
30
32
  }
31
33
 
32
- /** Read the app's sources once, check them, and hand back findings whose fix is the edit. */
33
- export async function appBoundaryFindings(root: string): Promise<readonly Finding[]> {
34
- const files = await readAppSources(root);
35
- return withCutEdits(checkImportRules(files), appImportGraph(files));
34
+ /**
35
+ * Read the app's sources once, check them, and hand back findings whose fix is the edit. The
36
+ * browser-transport findings come from the same read and already carry their own edit.
37
+ */
38
+ export async function appBoundaryFindings(
39
+ root: string,
40
+ /** The app's sources when the caller has already read them — the gate step reads them once. */
41
+ read?: readonly SourceFile[],
42
+ ): Promise<readonly Finding[]> {
43
+ const files = read ?? (await readAppSources(root));
44
+ return [
45
+ ...withCutEdits(checkImportRules(files), appImportGraph(files)),
46
+ ...(await appTransportFindings(root, files)),
47
+ ];
36
48
  }
@@ -0,0 +1,160 @@
1
+ // The browser has ONE HTTP function and ONE socket, as a rule over a file tree: a raw `fetch(`,
2
+ // `new WebSocket(`, `new XMLHttpRequest(` or `new EventSource(` in browser-reachable code skips
3
+ // `@ultimat3/core`'s `clientTransport` — credentials, the idempotency header, the record envelope,
4
+ // the principal fence — so its rows never reach the page's store and it keeps running for the
5
+ // previous principal after a sign-out. A package's SERVER BARREL in that closure is refused too.
6
+ //
7
+ // BROWSER-REACHABLE is derived, never listed: every `*.island.tsx`, every module that names the
8
+ // client seam itself, and the import closure of each, followed name by name through barrels
9
+ // (`import-closure.ts`). Pure over the tree its caller read — `app-transport.ts` reads an app's,
10
+ // `scripts/browser-transport.ts` this repository's.
11
+ //
12
+ // WHAT IT CANNOT SEE: a browser module that neither is an island, nor names the seam, nor is
13
+ // imported by either; a request made inside an installed package (the walk ends at the package
14
+ // boundary); a fetch reached through a computed member (`globalThis['fetch']`); and a bare
15
+ // `fetch(` in a file that also binds a local `fetch` (`bindsFetch`). A floor, not a proof.
16
+
17
+ import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
18
+ import { commentSafe } from './comment-safe';
19
+ import type { ClosureHost } from './import-closure';
20
+ import { importClosure } from './import-closure';
21
+ import type { Finding } from './output';
22
+ import type { BarrelImport } from './server-barrels';
23
+ import { barrelImports } from './server-barrels';
24
+ import { isTest } from './source-files';
25
+ import type { TransportCall, TransportShape } from './transport-calls';
26
+ import { transportCalls } from './transport-calls';
27
+
28
+ export interface Bypass extends TransportCall {
29
+ readonly file: string;
30
+ }
31
+
32
+ export interface ServerBarrel extends BarrelImport {
33
+ readonly file: string;
34
+ }
35
+
36
+ export const isIsland = (path: string): boolean => path.endsWith('.island.tsx');
37
+
38
+ /** A module naming the page's client seam is browser code by its own statement. */
39
+ const NAMES_SEAM = /\b(?:clientTransport|pageClient)\s*[(<]/;
40
+
41
+ /**
42
+ * Where the closure starts: every island, plus every module `namesSeam` admits that calls the seam
43
+ * itself. Masked before the test: a template that EMITS `clientTransport(` is not browser code.
44
+ */
45
+ export function browserEntries(
46
+ files: ReadonlyMap<string, string>,
47
+ namesSeam: (path: string) => boolean = () => true,
48
+ ): readonly string[] {
49
+ return [...files.entries()]
50
+ .filter(([path, source]) => {
51
+ if (isTest(path)) return false;
52
+ if (isIsland(path)) return true;
53
+ // The raw text first: masking is a pass over every character, and almost no file names it.
54
+ return namesSeam(path) && NAMES_SEAM.test(source) && NAMES_SEAM.test(maskLiterals(source));
55
+ })
56
+ .map(([path]) => path)
57
+ .sort();
58
+ }
59
+
60
+ export interface TransportInput {
61
+ readonly entries: readonly string[];
62
+ /** Reads a file and resolves a bare specifier; `undefined` from either ends the walk there. */
63
+ readonly host: ClosureHost;
64
+ /**
65
+ * Shape → the ONE file in this tree allowed to hold it. Empty for an app: the seams are the
66
+ * framework's and live in `node_modules`, past the boundary the walk stops at.
67
+ */
68
+ readonly seams: ReadonlyMap<TransportShape, string>;
69
+ /** Files whose realm is not a page — a service worker has no store and no principal fence. */
70
+ readonly exempt?: ReadonlySet<string>;
71
+ /** Server barrel → the browser entry that replaces it (`serverBarrels`). */
72
+ readonly barrels: ReadonlyMap<string, string>;
73
+ /** A package's own modules may import its own barrel; only a FOREIGN import pays for it. */
74
+ readonly ownsBarrel?: (file: string, barrel: string) => boolean;
75
+ }
76
+
77
+ /** The whole rule over a tree, pure. */
78
+ export function checkBrowserTransport(input: TransportInput): {
79
+ readonly reachable: readonly string[];
80
+ readonly bypasses: readonly Bypass[];
81
+ readonly barrels: readonly ServerBarrel[];
82
+ } {
83
+ const reachable = importClosure(input.host, input.entries);
84
+ const bypasses: Bypass[] = [];
85
+ const barrels: ServerBarrel[] = [];
86
+ for (const file of reachable) {
87
+ if (input.exempt?.has(file) === true) continue;
88
+ const source = input.host.read(file) ?? '';
89
+ for (const call of transportCalls(source)) {
90
+ if (input.seams.get(call.shape) === file) continue;
91
+ bypasses.push({ ...call, file });
92
+ }
93
+ for (const found of barrelImports(source, input.barrels)) {
94
+ if (input.ownsBarrel?.(file, found.barrel) === true) continue;
95
+ barrels.push({ ...found, file });
96
+ }
97
+ }
98
+ return { reachable, bypasses, barrels };
99
+ }
100
+
101
+ /**
102
+ * What to write instead, per shape — each a line that runs once its placeholder is named. A raw
103
+ * request does not say which of these it is, so `fetch` lists the four it can be.
104
+ */
105
+ const REPLACEMENT: ReadonlyMap<TransportShape, string> = new Map([
106
+ [
107
+ 'fetch',
108
+ "an action: `await browserClient.<action>(input)` (rpc<Api['actions']> in apps/web/shared/browser-client.ts); a read: `useQuery(<QUERY_REF>, input)` from '@ultimat3/realtime'; a file: `await uploadFile({ file, grant })` from '@ultimat3/storage'; anything else: `await clientTransport({ method: 'GET', url })` from '@ultimat3/core/page'",
109
+ ],
110
+ [
111
+ 'websocket',
112
+ "`useQuery(<QUERY_REF>, input)` for rows or `useChannel(<CHANNEL_REF>, params, { onEvent })` for events, both from '@ultimat3/realtime' — the page has one socket and it is the framework's",
113
+ ],
114
+ [
115
+ 'xhr',
116
+ "an upload with progress: `await uploadFile({ file, grant, onProgress })` from '@ultimat3/storage'; anything else: `await clientTransport({ method: 'GET', url })` from '@ultimat3/core/page'",
117
+ ],
118
+ [
119
+ 'eventsource',
120
+ "`useQuery(<QUERY_REF>, input)` or `useChannel(<CHANNEL_REF>, params, { onEvent })` from '@ultimat3/realtime' — pushed frames arrive on the page's one socket",
121
+ ],
122
+ ]);
123
+
124
+ export interface FindingContext {
125
+ /** Who may hold `shape` instead — a file in a repo, the framework's function in an app. */
126
+ readonly holder: (shape: TransportShape) => string | undefined;
127
+ /** The command that re-reads the tree: the gate's step in an app, the script in this repo. */
128
+ readonly rerun: string;
129
+ }
130
+
131
+ /** `file:line` for a message: the path is the scanned tree's, so it is escaped before it is quoted. */
132
+ const siteOf = (site: { readonly file: string; readonly line: number }): string =>
133
+ `${commentSafe(site.file)}:${site.line}`;
134
+
135
+ /**
136
+ * One runnable line, the edit behind a `#` — the shape `app-boundaries.ts` gives every fix. The
137
+ * command re-reads the tree; the comment is the edit, because only the finding knows the line.
138
+ */
139
+ export function bypassFinding(bypass: Bypass, context: FindingContext): Finding {
140
+ const holder = context.holder(bypass.shape);
141
+ const site = siteOf(bypass);
142
+ return {
143
+ code: 'X_BROWSER_TRANSPORT_BYPASS',
144
+ at: `${bypass.file}:${bypass.line}`,
145
+ cause: `${site} calls ${bypass.spelled} in browser-reachable code; ${holder === undefined ? 'no module may' : `only ${holder} may`}, so this request skips the page's record store, idempotency header and principal fence`,
146
+ fix: `${context.rerun} # after replacing ${bypass.spelled} at ${site} with ${REPLACEMENT.get(bypass.shape) ?? ''}`,
147
+ docs: ERROR_DOCS_URL,
148
+ };
149
+ }
150
+
151
+ export function barrelFinding(site: ServerBarrel, context: FindingContext): Finding {
152
+ const at = siteOf(site);
153
+ return {
154
+ code: 'X_BROWSER_SERVER_BARREL',
155
+ at: `${site.file}:${site.line}`,
156
+ cause: `${at} imports ${site.barrel} in browser-reachable code; that barrel is the package's server half, and ${site.entry} is the entry it publishes for a browser`,
157
+ fix: `${context.rerun} # after changing the import at ${at} from '${site.barrel}' to '${site.entry}'`,
158
+ docs: ERROR_DOCS_URL,
159
+ };
160
+ }