@ultimat3/cli 20.2.1 → 22.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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -12,29 +12,35 @@ import { join, posix } from 'node:path';
12
12
  import { ERROR_DOCS_URL } from '@ultimat3/core';
13
13
  import type { RouteEntry } from '@ultimat3/render';
14
14
  import { ISLAND_EXTENSION, routeEntries } from '@ultimat3/render';
15
+ import { discoverIslands } from './island-bundle';
15
16
  import type { Finding } from './output';
16
17
 
17
18
  /**
18
- * The exports that only work with a registered `LiveClient`. Each one either subscribes, mutates
19
- * or reads the connection, so a module naming one is a module that needs a browser to have booted
20
- * it — `hasLiveClient` and `LiveClient` itself are deliberately absent: the first IS the guard, and
21
- * the second is what an island's `mount()` constructs.
19
+ * The exports that only work in a booted browser page: each one reads the page's store, its socket
20
+ * or its connection, so a module naming one needs an island to have run it. `hasPageSocket` is
21
+ * deliberately absent — it IS the guard — and so is `installRealtime`, which the island bundle
22
+ * writes for the author (plan 101, slice 14).
22
23
  */
23
24
  export const LIVE_HOOKS = [
24
- 'useLive',
25
- 'liveHookFor',
25
+ 'useQuery',
26
26
  'useConnection',
27
27
  'useMutation',
28
28
  'useMutationQueue',
29
+ 'useRecord',
30
+ 'useChannel',
29
31
  ] as const;
30
32
 
31
33
  /**
32
- * The one escape hatch, and it is a call an author writes on purpose: a module that ASKS whether
33
- * there is a client has already written what happens when there is none. `app/update-banner.tsx`
34
- * in the reference app is the shape — imported by the layout, so by every page, and correct on all
35
- * of them.
34
+ * NOT an escape hatch, `As of 2026-09-22`: `hasPageSocket()` answers false on the server, every
35
+ * time, so a module that guards on it and never runs in a browser renders nothing forever. It was
36
+ * exempted here — "a module that asks has handled the absence" — and that is exactly how
37
+ * `examples/dummy`'s update banner, in a layout no island imports, never showed "A new version is
38
+ * ready.". It is a browser-only read like any hook, so it is reported like one.
36
39
  */
37
- const GUARD = 'hasLiveClient';
40
+ const GUARD = 'hasPageSocket';
41
+
42
+ /** Everything that only means something in a booted browser page: the hooks, and the guard. */
43
+ const BROWSER_ONLY: readonly string[] = [GUARD, ...LIVE_HOOKS];
38
44
 
39
45
  /** Value imports only: `import type` is erased, so it boots nothing and needs nothing. */
40
46
  const REALTIME_IMPORT = /import\s+([^;]*?)from\s*['"]@ultimat3\/realtime(?:\/[\w-]+)?['"]/g;
@@ -45,18 +51,14 @@ const bindingsOf = (clause: string): readonly string[] =>
45
51
  .map((entry) => entry.split(/\bas\b/)[0]?.trim() ?? '')
46
52
  .filter((name) => name.length > 0 && !name.startsWith('type '));
47
53
 
48
- /**
49
- * Which live hooks one module imports, or `[]` — including for a module that guards, which is a
50
- * per-FILE verdict on purpose: the guard is written next to the read it protects.
51
- */
54
+ /** Which browser-only reads one module imports — the hooks and `hasPageSocket` — or `[]`. */
52
55
  export function liveHooksIn(source: string): readonly string[] {
53
56
  const hooks: string[] = [];
54
57
  for (const match of source.matchAll(REALTIME_IMPORT)) {
55
58
  const clause = match[1] ?? '';
56
59
  if (clause.trimStart().startsWith('type ')) continue;
57
60
  const names = bindingsOf(clause);
58
- if (names.includes(GUARD)) return [];
59
- for (const hook of LIVE_HOOKS) if (names.includes(hook)) hooks.push(hook);
61
+ for (const hook of BROWSER_ONLY) if (names.includes(hook)) hooks.push(hook);
60
62
  }
61
63
  return hooks;
62
64
  }
@@ -88,14 +90,18 @@ export interface LiveReach {
88
90
  }
89
91
 
90
92
  /**
91
- * Walk the route module's own import graph and answer the first live hook in it.
93
+ * Walk a module's own import graph and answer the first thing `probe` finds in it.
92
94
  *
93
95
  * Relative specifiers only. A bare one resolves through `node_modules` or a workspace name, and
94
96
  * following either would mean guessing which package a name came from — the limit `fix-imports.ts`
95
97
  * records for the same walk. So this UNDER-reports rather than over-reports: a finding here is
96
98
  * always a real one, which is what lets the rule ship with no pin table.
97
99
  */
98
- export async function liveReachOf(root: string, file: string): Promise<LiveReach | undefined> {
100
+ export async function firstInGraph<T>(
101
+ root: string,
102
+ file: string,
103
+ probe: (source: string, path: string) => T | undefined,
104
+ ): Promise<T | undefined> {
99
105
  const seen = new Set<string>();
100
106
  const queue = [file];
101
107
  while (queue.length > 0) {
@@ -104,8 +110,8 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
104
110
  seen.add(next);
105
111
  const module = await readModule(root, next);
106
112
  if (module === undefined) continue;
107
- const hook = liveHooksIn(module.source)[0];
108
- if (hook !== undefined) return { at: module.path, hook };
113
+ const found = probe(module.source, module.path);
114
+ if (found !== undefined) return found;
109
115
  const loader = module.path.endsWith('x') ? 'tsx' : 'ts';
110
116
  // Bun's transpiler is the parser, exactly as in `scripts/boundaries.ts`: it erases type-only
111
117
  // imports and finds the dynamic ones, which no regex over this source could do.
@@ -117,6 +123,36 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
117
123
  return undefined;
118
124
  }
119
125
 
126
+ /** Every module in a file's relative import graph, app-root-relative — one walk, no probe. */
127
+ export async function graphModules(root: string, file: string): Promise<ReadonlySet<string>> {
128
+ const seen = new Set<string>();
129
+ await firstInGraph(root, file, (_source, path) => {
130
+ seen.add(path);
131
+ return undefined;
132
+ });
133
+ return seen;
134
+ }
135
+
136
+ /** Every module a browser can run: the union of every island's graph in the app. */
137
+ async function islandModules(root: string): Promise<ReadonlySet<string>> {
138
+ const modules = new Set<string>();
139
+ for (const island of await discoverIslands(root)) {
140
+ for (const path of await graphModules(root, island)) modules.add(path);
141
+ }
142
+ return modules;
143
+ }
144
+
145
+ /** Each module in the route's graph that reads a browser-only name, with the first name it reads. */
146
+ async function browserOnlyReads(root: string, file: string): Promise<readonly LiveReach[]> {
147
+ const reads: LiveReach[] = [];
148
+ await firstInGraph(root, file, (source, path) => {
149
+ const hook = liveHooksIn(source)[0];
150
+ if (hook !== undefined) reads.push({ at: path, hook });
151
+ return undefined;
152
+ });
153
+ return reads;
154
+ }
155
+
120
156
  export interface LiveRouteGap extends LiveReach {
121
157
  readonly route: string;
122
158
  readonly file: string;
@@ -132,28 +168,34 @@ const generatorFor = (file: string): string => {
132
168
  };
133
169
 
134
170
  /**
135
- * Every route that reads live rows with nothing to receive them. Two shapes, one condition — no
136
- * island at all, and an island the route declares `hydrate: 'never'` for. `X_ISLAND_NOT_HYDRATED`
137
- * covers the second only at render time, and only for a render that reaches the island, so a route
138
- * can hold the contradiction and never be asked.
171
+ * Every browser-only read a route's SERVER graph holds that no browser will ever run. A page's
172
+ * imports are server-rendered — an island is reached by a `src` string, never an import — so a
173
+ * module there runs in a browser only if some island's own graph imports it too. Anything else
174
+ * renders its server state (nothing, or `loading`) forever, at 200. `hydrate: 'never'` boots no
175
+ * island at all, so every read on such a route is reported. One finding per MODULE: a layout every
176
+ * page imports is one mistake, not one per route.
139
177
  */
140
178
  export async function liveRouteGaps(
141
179
  root: string,
142
180
  entries: readonly RouteEntry[],
143
181
  ): Promise<readonly LiveRouteGap[]> {
182
+ const inBrowser = await islandModules(root);
183
+ const reported = new Set<string>();
144
184
  const gaps: LiveRouteGap[] = [];
145
185
  for (const entry of entries) {
146
186
  if (entry.surface === 'api') continue;
147
- if (entry.islands.length > 0 && entry.config.hydrate !== 'never') continue;
148
- const reach = await liveReachOf(root, entry.file);
149
- if (reach === undefined) continue;
150
- gaps.push({
151
- ...reach,
152
- route: entry.path,
153
- file: entry.file,
154
- hydrate: entry.config.hydrate,
155
- islands: entry.islands,
156
- });
187
+ const never = entry.config.hydrate === 'never';
188
+ for (const read of await browserOnlyReads(root, entry.file)) {
189
+ if (reported.has(read.at) || (!never && inBrowser.has(read.at))) continue;
190
+ reported.add(read.at);
191
+ gaps.push({
192
+ ...read,
193
+ route: entry.path,
194
+ file: entry.file,
195
+ hydrate: entry.config.hydrate,
196
+ islands: entry.islands,
197
+ });
198
+ }
157
199
  }
158
200
  return gaps;
159
201
  }
@@ -161,14 +203,12 @@ export async function liveRouteGaps(
161
203
  export const liveRouteFindingFor = (gap: LiveRouteGap): Finding => ({
162
204
  code: 'X_LIVE_ROUTE_NO_ISLAND',
163
205
  cause:
164
- `${gap.route} reads ${gap.hook}() (${gap.at}) and ` +
165
- (gap.islands.length === 0
166
- ? 'declares no island'
167
- : `declares hydrate: 'never' beside ${gap.islands.join(', ')}`) +
168
- ', so no module of this route ever runs in a browser: its rows have nowhere to arrive and the page renders its loading branch forever, at 200',
206
+ `${gap.route} reads ${gap.hook}() in ${gap.at}, which ` +
207
+ (gap.hydrate === 'never' ? `sits on a route declaring hydrate: 'never'` : 'no island imports') +
208
+ ' — so it only ever runs on the server, where it answers its server state (nothing, or loading) forever, at 200',
169
209
  fix:
170
- `${generatorFor(gap.file)}, declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) above defineRoute in ${gap.file}, ` +
171
- `and move the ${gap.hook}() read into its mount() — which is where setLiveClient() can be called`,
210
+ `move it into an island: ${generatorFor(gap.file)}, import ${gap.at} from that island's mount(), and declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) in ${gap.file}` +
211
+ (gap.hydrate === 'never' ? `, with a hydrate other than 'never'` : ''),
172
212
  docs: ERROR_DOCS_URL,
173
213
  at: gap.at,
174
214
  });
@@ -0,0 +1,51 @@
1
+ // One cause, one step. A module that will not import was reported by up to four steps at once —
2
+ // `contract-diff`, `budgets`, `policy` and `manifest` all load the app, and each carried the load's
3
+ // findings — so one syntax error read as four red steps and four copies of one line (plan 101
4
+ // slice 11 i). The `manifest` step owns them: it runs in every repo, and "does the projection
5
+ // describe the code?" is the question a module that will not import answers first.
6
+
7
+ import { loadApp } from './app-load';
8
+ import type { Finding } from './output';
9
+ import type { StepOutcome, VerifyStepName } from './verify-step';
10
+
11
+ /** The step that reports the app's load failures. Every other step points at it. */
12
+ export const LOAD_OWNER: VerifyStepName = 'manifest';
13
+
14
+ /** What the app load reports, as the step sees it. */
15
+ export type LoadProbe = (root: string) => Promise<{ readonly findings: readonly Finding[] }>;
16
+
17
+ const keyOf = (finding: Finding): string =>
18
+ [finding.code, finding.at ?? '', finding.cause].join('\u0000');
19
+
20
+ /**
21
+ * `findings` without the ones the app load raised, and an `output` line saying where they went.
22
+ * The step's own verdict stands on what is left: a budget the broken module left unmeasured is
23
+ * still this step's finding, only its cause moved.
24
+ */
25
+ export async function withoutLoadFindings(
26
+ root: string,
27
+ findings: readonly Finding[],
28
+ load: LoadProbe = loadApp,
29
+ ): Promise<StepOutcome> {
30
+ const loaded = new Set((await load(root)).findings.map(keyOf));
31
+ const kept = findings.filter((finding) => !loaded.has(keyOf(finding)));
32
+ const moved = findings.length - kept.length;
33
+ return {
34
+ ok: kept.length === 0,
35
+ findings: kept,
36
+ ...(moved === 0
37
+ ? {}
38
+ : { output: `skipped: ${moved} module-load finding(s) — see ${LOAD_OWNER}` }),
39
+ };
40
+ }
41
+
42
+ /** The owner's half: every load finding exactly once, beside what the step found itself. */
43
+ export async function withLoadFindings(
44
+ root: string,
45
+ findings: readonly Finding[],
46
+ load: LoadProbe = loadApp,
47
+ ): Promise<readonly Finding[]> {
48
+ const own = (await load(root)).findings;
49
+ const seen = new Set(own.map(keyOf));
50
+ return [...own, ...findings.filter((finding) => !seen.has(keyOf(finding)))];
51
+ }
@@ -10,7 +10,7 @@ import { tryResolveEnvironment } from '@ultimat3/core';
10
10
  import { pgliteDataDir } from '@ultimat3/db';
11
11
  import type { DatabaseTarget } from '@ultimat3/mcp';
12
12
  import { branchNameOf, pgliteBranchName } from './db-branch';
13
- import type { DevServices, Env } from './dev-services';
13
+ import type { DevServices, Env } from './runtime-bindings';
14
14
  import { safeUrlLabel } from './safe-url-label';
15
15
 
16
16
  /**
package/src/mcp-errors.ts CHANGED
@@ -54,7 +54,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
54
54
  'x verify --json # the package-shape finding carries the dependency line to add',
55
55
  X_PACKAGE_DUPLICATED:
56
56
  'x i18n check --json # the finding names both copies and the package.json to pin',
57
- X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
57
+ X_SHOT_BROWSER_MISSING: 'export CHROME_PATH=/usr/bin/google-chrome',
58
58
  X_UI_SHOT_ROUTE_UNKNOWN:
59
59
  'x routes --json # then call the ui.* tool with one of its path values',
60
60
  X_UI_SHOT_ROUTE_UNBUDGETED:
@@ -69,6 +69,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
69
69
  'x routes --json # then resend ui.interact with steps that stay on one of its paths',
70
70
  X_UI_INTERACT_STEP_FAILED:
71
71
  'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
72
+ X_SHOT_HOST_REFUSED: 'x shot /route --allow-hosts cdn.example.com --json',
73
+ X_SHOT_ELEMENT_MISSING:
74
+ 'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
75
+ X_SHOT_ELEMENT_UNREADY:
76
+ 'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
77
+ X_SHOT_KEY_INVALID: 'x help shot --json # a chord is modifiers then one key: Meta+K',
72
78
  // The three `ui.diff` codes. Every capture it can compare was written by `x shot` or a `ui.*`
73
79
  // tool under `.x/shot/`, so the runnable half is the command that writes one there.
74
80
  X_UI_DIFF_PATH_OUTSIDE:
@@ -88,29 +94,6 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
88
94
  'x help shot --json # the cause lists every request the state must answer under routes',
89
95
  X_SHOT_ISLAND_MISSING:
90
96
  'x help shot --json # every absent picture carries its own named refusal in the run above',
91
- // The six e2e-driver codes. Every one of them is raised inside a running suite, so the runnable
92
- // half is the command that re-runs that suite — the cause already names the closure, the locator
93
- // call or the budget, and no `x` command can edit a test for its author.
94
- X_E2E_EVALUATE_UNSUPPORTED:
95
- 'x test e2e --json # the cause quotes the closure; page.evaluate takes a zero-parameter arrow',
96
- X_E2E_EVALUATE_CAPTURED:
97
- 'x test e2e --json # the fix line names the binding to inline into the closure',
98
- X_E2E_EVALUATE_THREW:
99
- 'x dev --json # then run the expression the cause quotes in the browser console; the throw is the page\u2019s',
100
- X_E2E_LOCATOR_EMPTY:
101
- 'x test e2e --json # the fix line carries the toBeVisible() assertion to await first',
102
- X_E2E_LOCATOR_AMBIGUOUS:
103
- 'x test e2e --json # the fix line carries the same call with .first() on it',
104
- X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
105
- // The four raw-CDP codes. `x doctor` for the missing browser, because that is the command whose
106
- // whole job is reporting what this machine does not have; the other three are raised inside a
107
- // running suite, so the runnable half is the command that re-runs it.
108
- X_CDP_BROWSER_MISSING:
109
- 'x doctor --json # or set CHROME_PATH to a Chrome binary; unset, the browser-backed suite skips',
110
- X_CDP_LAUNCH_FAILED:
111
- 'x test e2e --json # the cause carries the last lines of the browser\u2019s own stderr',
112
- X_CDP_CALL_FAILED: 'x test e2e --json # the cause names the DevTools call the browser refused',
113
- X_CDP_TIMEOUT: 'x test e2e --json # the cause names the call that never answered',
114
97
  X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
115
98
  X_GH_NOT_AUTHENTICATED: 'gh auth login',
116
99
  X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
@@ -122,8 +105,11 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
122
105
  'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
123
106
  X_ERROR_CODE_UNRESOLVED:
124
107
  'x verify --json # the finding names the file, the line and the name it could not resolve',
108
+ X_ERROR_CODE_UNTHROWN:
109
+ 'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
125
110
  X_CLI_UNEXPECTED: 'x doctor --json',
126
- X_TYPECHECK_FAILED: 'bunx tsc -b --pretty false',
111
+ // `-p .`: an app root has no `references`, so the gate's own step runs this form (#450).
112
+ X_TYPECHECK_FAILED: 'bunx tsc -p . --pretty false',
127
113
  X_LINT_FAILED: 'bunx biome check --write .',
128
114
  X_TEST_FAILED: 'x test --json # the finding carries the exact bun test invocation that failed',
129
115
  // The same two edits `vanishedSuiteFinding` names, verbatim, so both surfaces of this code hand
@@ -179,6 +165,10 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
179
165
  X_PORT_INVALID: 'docker run -e PORT=3000 my-app:latest',
180
166
  X_RUNTIME_DRIVER_SPLIT: 'x dev --json # the boot names the driver the app installed twice',
181
167
  X_GENERATE_CONFLICT: 'x g route posts --force --json',
168
+ X_APP_NAME_EMPTY:
169
+ 'x new my-app --json # a name with letters or digits; it becomes the directory',
170
+ X_APP_EMPTY:
171
+ 'x doctor --json # then check the app root: no module under apps/ or packages/ loaded',
182
172
  X_PORT_IN_USE: 'x dev --port 3001 --json',
183
173
  X_DEV_ALREADY_RUNNING:
184
174
  'x dev --json # after stopping the x dev that already owns this checkout',
@@ -218,8 +208,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
218
208
  // consent and stays in the cause: it is the answer only for a container with a fixed argv.
219
209
  X_SEED_ENVIRONMENT:
220
210
  'x db seed --dry-run --json # then name the tier: x db seed <name> --tier dev --json',
211
+ // The finding's own `fix:` is the concrete edit (`boundary-findings.ts`): the file and the import.
221
212
  X_BOUNDARY_SITE_TO_APP:
222
- 'x verify --json # then: x fix boundary <the file the finding names> --json',
213
+ 'x verify --only boundaries --json # each finding names the import to delete, and where',
223
214
  X_BOUNDARY_SHARED_LEAF:
224
215
  'x verify --json # then: x fix boundary <the file the finding names> --json',
225
216
  X_BOUNDARY_APP_TO_API:
@@ -228,6 +219,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
228
219
  'x verify --json # then: x fix boundary <the file the finding names> --json',
229
220
  X_BOUNDARY_SERVICE_TO_HTTP:
230
221
  'x verify --json # then: x fix boundary <the file the finding names> --json',
222
+ X_BOUNDARY_SURFACE_IMPORT:
223
+ 'x verify --only boundaries --json # then move the imported code into shared/',
231
224
  // The app's own guards. All three are reported by the gate and by nothing else, so the runnable
232
225
  // half is the gate — the narrowing behind the `#` is the edit, because only the finding knows
233
226
  // which file in `guards/` is the one to open.
@@ -242,6 +235,17 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
242
235
  // Render's code, thrown here by the bundler half: the cause names the specifier and the file it
243
236
  // resolved to, and `x g island` is what puts that file where the page already says it is.
244
237
  X_ISLAND_INVALID: 'x routes --json # the cause names the src; then: x g island <name>',
238
+ // The four slice-11 codes: generated code that passed the gate and failed at runtime.
239
+ X_PERMISSION_UNGRANTED:
240
+ 'x verify --only policy --json # then add the permission to a role in apps/web/shared/roles.ts',
241
+ X_JOB_UNREGISTERED:
242
+ 'x manifest --json # after listing the module under jobs: [...] in apps/web/api/index.ts',
243
+ X_FEATURE_UNKNOWN:
244
+ 'x entities --json # then x g resource <feature> for the one the cause names',
245
+ X_BUDGET_PARAMS_UNDECLARED:
246
+ 'x build --target static --json # after adding prerender() with one real path to the route',
247
+ X_ROUTE_ASYNC_PAGE:
248
+ 'x verify --only budgets --json # after moving each await in Page into export const load',
245
249
  };
246
250
 
247
251
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/mcp-host.ts CHANGED
@@ -34,12 +34,8 @@ import { loadApp } from './app-load';
34
34
  import { appManifest, policyFacts } from './app-manifest';
35
35
  import { runVerify, VERIFY_STEPS } from './cmd-verify';
36
36
  import { declareDevEnvironment } from './dev-environment';
37
- import type { RunningServices } from './dev-runtime';
38
- import { startServices } from './dev-runtime';
39
- import type { DevServices, Env } from './dev-services';
40
- import { resolveServices } from './dev-services';
41
37
  import { loadCodeFixes } from './error-fixes';
42
- import { CliNotImplementedError } from './errors';
38
+ import { BadFlagError, CliNotImplementedError } from './errors';
43
39
  import type { Runner } from './exec';
44
40
  import { execOutput } from './exec';
45
41
  import { databaseTarget } from './mcp-db-target';
@@ -48,6 +44,10 @@ import { parseBunTest } from './mcp-test-output';
48
44
  import { type UiCapabilities, uiCapabilities } from './mcp-ui';
49
45
  import { readMigrations } from './migrations';
50
46
  import { retryMemo } from './retry-memo';
47
+ import type { DevServices, Env } from './runtime-bindings';
48
+ import { resolveServices } from './runtime-bindings';
49
+ import type { RunningServices } from './runtime-services';
50
+ import { startServices } from './runtime-services';
51
51
  import { testEnvOverrides } from './test-dotenv';
52
52
 
53
53
  export interface DevHostInput {
@@ -180,6 +180,24 @@ export async function readOnlyRows(
180
180
  return { columns, rows: kept.map((row) => columns.map((column) => row[column])), guards };
181
181
  }
182
182
 
183
+ /**
184
+ * `bun test`'s argv for a `test.run` filter. A filter is a PATH, and one starting with `-` is a
185
+ * `bun test` flag — `--preload ./x.ts` loads a module into the child — so it is refused rather
186
+ * than forwarded: a filter is a path, and no path this suite has starts with `-`.
187
+ */
188
+ export function testRunArgv(filter: string | undefined): readonly string[] {
189
+ if (filter === undefined) return ['bun', 'test'];
190
+ if (filter.trimStart().startsWith('-')) {
191
+ throw new BadFlagError({
192
+ flag: 'filter',
193
+ command: 'mcp serve',
194
+ reason: `test.run's filter "${filter}" starts with "-", which bun test would read as its own flag`,
195
+ fix: 'call test.run with a path filter such as packages/cli/src, or with no filter',
196
+ });
197
+ }
198
+ return ['bun', 'test', filter];
199
+ }
200
+
183
201
  function capabilities(
184
202
  input: DevHostInput,
185
203
  lazy: LazyServices,
@@ -249,13 +267,10 @@ function capabilities(
249
267
  // Same leak the CLI's own `x test`/`x verify` had: `.env.development` auto-loaded into
250
268
  // THIS process must not ride along into the `bun test` child the dev MCP server spawns.
251
269
  const envOverrides = testEnvOverrides(root, env);
252
- const result = await runner(
253
- filter === undefined ? ['bun', 'test'] : ['bun', 'test', filter],
254
- {
255
- cwd: root,
256
- ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
257
- },
258
- );
270
+ const result = await runner(testRunArgv(filter), {
271
+ cwd: root,
272
+ ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
273
+ });
259
274
  return parseBunTest(execOutput(result), result.durationMs);
260
275
  },
261
276
 
@@ -81,6 +81,32 @@ async function readCapture(root: string, relative: string, field: string): Promi
81
81
  return file.bytes();
82
82
  }
83
83
 
84
+ /**
85
+ * The symlink half for the one path this tool WRITES: `realpath` of the nearest directory that
86
+ * exists above `out`, held under the shot directory's real location — `readCapture`'s rule. The
87
+ * lexical check alone let `.x/shot/<link-to-elsewhere>/diff.png` write wherever the link pointed.
88
+ */
89
+ async function assertWritable(root: string, path: string): Promise<void> {
90
+ let dir = dirname(path);
91
+ while (!(await exists(dir)) && dirname(dir) !== dir) dir = dirname(dir);
92
+ const real = await realpath(dir);
93
+ const shotDir = await realpath(resolve(root, SHOT_DIR));
94
+ if (!under(real, shotDir)) {
95
+ throw new UltimateError({
96
+ code: 'X_UI_DIFF_PATH_OUTSIDE',
97
+ cause: `out is under a link to ${real}, which is not inside ${shotDir}`,
98
+ fix: SHOT_FIX,
99
+ meta: { field: 'out', path, real, shotDir },
100
+ });
101
+ }
102
+ }
103
+
104
+ const exists = (dir: string): Promise<boolean> =>
105
+ realpath(dir).then(
106
+ () => true,
107
+ () => false,
108
+ );
109
+
84
110
  /**
85
111
  * The seam reads 8-bit RGBA and nothing else. A PNG in any other shape — Chrome's RGB when the page
86
112
  * is opaque, a palette from an optimiser — goes once through Bun's codecs, which always write the
@@ -126,6 +152,7 @@ export async function diffShots(deps: DiffDeps, input: UiDiffInput): Promise<UiD
126
152
  }
127
153
  const { width, height } = after;
128
154
  const result = diffPixels(before.pixels, after.pixels, width, height, input.threshold);
155
+ await assertWritable(root, diff);
129
156
  await Bun.write(diff, encodeImage({ width, height, pixels: result.diffRgba }));
130
157
  return {
131
158
  ok: true,
@@ -11,8 +11,8 @@
11
11
  import { join } from 'node:path';
12
12
  import type { UiInspectInput, UiInspectResult, UiInspectSelector } from '@ultimat3/mcp';
13
13
  import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
14
- import type { AxNode, ScrapeDriver, ScrapePage } from '@ultimat3/scraping';
15
- import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
14
+ import type { AxNode, ShotDriver, ShotPage } from './browser-launcher-port';
15
+ import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
16
16
  import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
17
17
  import type { ShotServer } from './shot-server';
18
18
  import type { ShotVerdict } from './shot-verdict';
@@ -23,7 +23,7 @@ export interface InspectDeps {
23
23
  readonly root: string;
24
24
  /** The memoised scratch server (or the running `x dev`), as `uiCapabilities` hands it out. */
25
25
  readonly boot: () => Promise<ShotServer>;
26
- readonly driver: (viewport: UiInspectInput['viewport']) => Promise<ScrapeDriver>;
26
+ readonly driver: (viewport: UiInspectInput['viewport']) => Promise<ShotDriver>;
27
27
  }
28
28
 
29
29
  /** What the page answered before the parser had a say — `null` selectors when it answered nothing. */
@@ -45,7 +45,7 @@ export type InspectSpecInput = Pick<
45
45
  * a page that refuses evaluation is a page with no facts, and the verdict — not a throw here — is
46
46
  * what says why. Shared by `ui.inspect` and `ui.interact`, so both read the same facts.
47
47
  */
48
- export async function readInspect(page: ScrapePage, spec: InspectSpecInput): Promise<Seen> {
48
+ export async function readInspect(page: ShotPage, spec: InspectSpecInput): Promise<Seen> {
49
49
  const expression = inspectExpression({
50
50
  selectors: spec.selectors,
51
51
  styles: spec.styles,
@@ -14,8 +14,8 @@ import { join } from 'node:path';
14
14
  import { isUltimateError, toUltimateError, UltimateError } from '@ultimat3/core';
15
15
  import type { UiInteractInput, UiInteractResult, UiInteractStepResult } from '@ultimat3/mcp';
16
16
  import { UI_INTERACT_LIMITS } from '@ultimat3/mcp';
17
- import type { ScrapePage } from '@ultimat3/scraping';
18
- import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
17
+ import type { ShotPage } from './browser-launcher-port';
18
+ import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
19
19
  import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
20
20
  import type { InspectDeps, Seen } from './mcp-ui-inspect';
21
21
  import { readInspect, selectorsOf } from './mcp-ui-inspect';
@@ -130,7 +130,7 @@ const describe = (step: InteractStep): string => {
130
130
  };
131
131
 
132
132
  async function perform(
133
- page: ScrapePage,
133
+ page: ShotPage,
134
134
  step: InteractStep,
135
135
  sleep: (ms: number) => Promise<void>,
136
136
  ): Promise<void> {
@@ -153,19 +153,25 @@ async function perform(
153
153
  * Before any keystroke: a password field's value would land in the PNG, the verdict's console and
154
154
  * an agent's transcript. The read is the driver's own `query`, so the fake answers it offline.
155
155
  */
156
- async function refuseSecretField(
157
- page: ScrapePage,
158
- step: InteractStep,
159
- index: number,
160
- ): Promise<void> {
161
- if (step.kind !== 'type') return;
162
- const [first] = await page.query(step.selector);
163
- if (first?.attrs['type'] !== 'password') return;
156
+ /** The `type` of the focused element — what a `press` step's key lands in. */
157
+ export const ACTIVE_FIELD_TYPE =
158
+ '(function(){var a=document.activeElement;return a&&a.getAttribute?a.getAttribute("type"):null})()';
159
+
160
+ async function refuseSecretField(page: ShotPage, step: InteractStep, index: number): Promise<void> {
161
+ if (step.kind !== 'type' && step.kind !== 'press') return;
162
+ // A `press` goes to whatever has focus: `focus` a password field, then eleven presses, typed
163
+ // the password the `type` guard refused — so the focused element is asked before each key.
164
+ const secret =
165
+ step.kind === 'type'
166
+ ? (await page.query(step.selector))[0]?.attrs['type'] === 'password'
167
+ : (await page.evaluate(ACTIVE_FIELD_TYPE)) === 'password';
168
+ if (!secret) return;
169
+ const target = step.kind === 'type' ? JSON.stringify(step.selector) : 'the focused element';
164
170
  throw new UltimateError({
165
171
  code: 'X_UI_INTERACT_SECRET_FIELD',
166
- cause: `step ${index} would type into ${JSON.stringify(step.selector)}, an <input type="password">`,
172
+ cause: `step ${index} would type into ${target}, an <input type="password">`,
167
173
  fix: FIX.secret,
168
- meta: { step: index, selector: step.selector },
174
+ meta: { step: index, ...(step.kind === 'type' ? { selector: step.selector } : {}) },
169
175
  });
170
176
  }
171
177
 
@@ -177,11 +183,11 @@ export interface StepsRun {
177
183
 
178
184
  /**
179
185
  * The steps in order, each followed by a settle and one poll interval, each checked against the
180
- * origin. A scraping error is wrapped, never passed through: the agent needs to know WHICH step,
186
+ * origin. A driver error is wrapped, never passed through: the agent needs to know WHICH step,
181
187
  * and the fix is a selector `ui.inspect` reports rather than whatever the driver's fix names.
182
188
  */
183
189
  export async function runSteps(
184
- page: ScrapePage,
190
+ page: ShotPage,
185
191
  steps: readonly InteractStep[],
186
192
  settle: () => Promise<IslandCount | null>,
187
193
  origin: string,