@ultimat3/cli 21.0.0 → 22.1.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 (236) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  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-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +23 -17
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +22 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/sync-url.ts +21 -1
  178. package/src/templates/action.ts +30 -16
  179. package/src/templates/entity.ts +12 -7
  180. package/src/templates/index.ts +1 -1
  181. package/src/templates/job.ts +10 -7
  182. package/src/templates/policy.ts +20 -2
  183. package/src/templates/resource-create.ts +127 -0
  184. package/src/templates/resource-form-island.ts +49 -12
  185. package/src/templates/resource.ts +10 -4
  186. package/src/templates/scaffold-app.ts +4 -1
  187. package/src/templates/scaffold-auth.ts +3 -1
  188. package/src/templates/scaffold-container-compose.ts +184 -0
  189. package/src/templates/scaffold-container.ts +24 -159
  190. package/src/templates/scaffold-dashboard-example.ts +2 -2
  191. package/src/templates/scaffold-db-package.ts +16 -3
  192. package/src/templates/scaffold-demo-org.ts +41 -0
  193. package/src/templates/scaffold-entries.ts +1 -1
  194. package/src/templates/scaffold-env.ts +6 -0
  195. package/src/templates/scaffold-helm-templates.ts +66 -7
  196. package/src/templates/scaffold-helm.ts +27 -5
  197. package/src/templates/scaffold-i18n.ts +15 -10
  198. package/src/templates/scaffold-repo.ts +13 -10
  199. package/src/templates/scaffold-roles.ts +38 -10
  200. package/src/templates/slice-foundation.ts +1 -1
  201. package/src/templates/wrap.ts +4 -1
  202. package/src/test-passes.ts +2 -1
  203. package/src/test-workers.ts +26 -0
  204. package/src/ts-scan.ts +3 -6
  205. package/src/tsconfig-references.ts +1 -2
  206. package/src/verify-checks.ts +41 -39
  207. package/src/verify-e2e.ts +10 -7
  208. package/src/verify-run.ts +11 -1
  209. package/src/verify-step.ts +3 -3
  210. package/src/verify-tests.ts +1 -26
  211. package/src/verify-typecheck.ts +28 -0
  212. package/src/web-binding.ts +2 -2
  213. package/src/workspace-graph.ts +10 -33
  214. package/src/cdp-browser.ts +0 -94
  215. package/src/cdp-connection.ts +0 -247
  216. package/src/cdp-e2e-page.ts +0 -180
  217. package/src/cdp-e2e-session.ts +0 -199
  218. package/src/cdp-errors.ts +0 -56
  219. package/src/cdp-launch.ts +0 -193
  220. package/src/cdp-offline-script.ts +0 -73
  221. package/src/cdp-pipe.ts +0 -77
  222. package/src/e2e-app.ts +0 -103
  223. package/src/e2e-browser-handle.ts +0 -55
  224. package/src/e2e-dom-fixture.ts +0 -117
  225. package/src/e2e-driver.ts +0 -117
  226. package/src/e2e-errors.ts +0 -117
  227. package/src/e2e-evaluate.ts +0 -156
  228. package/src/e2e-locator.ts +0 -86
  229. package/src/e2e-page.ts +0 -153
  230. package/src/e2e-preload.ts +0 -64
  231. package/src/e2e-probe.ts +0 -23
  232. package/src/e2e-selection.ts +0 -182
  233. package/src/e2e-spawn.ts +0 -169
  234. package/src/measurement-actor.ts +0 -26
  235. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  236. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
@@ -8,6 +8,8 @@
8
8
  // and the index are written, and only then does the missing-picture gate turn the reasons into a
9
9
  // non-zero exit. One island that will not mount must not cost a reader the other nineteen.
10
10
 
11
+ // why: Bun has no file delete that tolerates an absent file.
12
+ import { rm } from 'node:fs/promises';
11
13
  // why: no Bun native joins a path; `Bun.write` and `Bun.file` both take one already joined.
12
14
  import { join } from 'node:path';
13
15
  import { finiteCount } from '@ultimat3/core';
@@ -95,6 +97,9 @@ export async function runIslandSweep(options: IslandSweepRun): Promise<IslandSwe
95
97
  // for each one it cannot, and the missing-shot gate below is what turns those reasons into
96
98
  // a non-zero exit.
97
99
  try {
100
+ // Deleted FIRST: the missing-shot gate reads the disk, and a picture an earlier run left
101
+ // there made a capture that failed today read as taken.
102
+ await rm(join(options.outDir, target.file), { force: true });
98
103
  shots.push(await captureIslandState(options, server, target, floor));
99
104
  } catch (error) {
100
105
  failures.push(error);
@@ -0,0 +1,131 @@
1
+ // The island chunks a container serves, built ONCE by `x build --target docker` into
2
+ // `.x/islands/` and read back at boot. Without it every role's boot ran `Bun.build` — Babel, the
3
+ // JSX transform, a minifier — for every island, in the image, on every start, and a chunk whose
4
+ // URL is a hash of its sources was rebuilt from sources that could not have changed.
5
+ //
6
+ // Load-and-VERIFY, never load-and-trust: a store written by another framework or Bun version, one
7
+ // that names a different set of islands than the app has, or a chunk whose bytes do not hash to
8
+ // what the index recorded is refused, and the boot builds instead — with the reason logged.
9
+
10
+ import { join } from 'node:path'; // why: Bun ships no path-join primitive.
11
+ import { frameworkVersion, logger } from '@ultimat3/core';
12
+ import { contentHash } from '@ultimat3/render/server';
13
+ import type { IslandBundle, IslandChunk } from './island-bundle';
14
+ import { buildIslands, discoverIslands, islandBundle } from './island-bundle';
15
+
16
+ /** App-root-relative: `COPY . .` carries it into the image with the rest of `.x/`. */
17
+ export const ISLAND_STORE_DIR = '.x/islands';
18
+ const INDEX = 'index.json';
19
+
20
+ interface StoredChunk {
21
+ readonly file: string;
22
+ readonly moduleId: string;
23
+ readonly url: string;
24
+ readonly identity: string;
25
+ }
26
+
27
+ interface StoreIndex {
28
+ readonly framework: string;
29
+ readonly bun: string;
30
+ readonly chunks: readonly StoredChunk[];
31
+ }
32
+
33
+ const chunkFile = (url: string): string => url.slice(url.lastIndexOf('/') + 1);
34
+
35
+ /** Writes every chunk and the index that verifies them. Answers the files written, app-relative. */
36
+ export async function writeIslandStore(
37
+ root: string,
38
+ bundle: IslandBundle,
39
+ ): Promise<readonly string[]> {
40
+ const dir = join(root, ISLAND_STORE_DIR);
41
+ const chunks: StoredChunk[] = [];
42
+ const written: string[] = [];
43
+ for (const chunk of bundle.chunks) {
44
+ await Bun.write(join(dir, chunkFile(chunk.url)), chunk.code);
45
+ written.push(`${ISLAND_STORE_DIR}/${chunkFile(chunk.url)}`);
46
+ chunks.push({
47
+ file: chunk.file,
48
+ moduleId: chunk.moduleId,
49
+ url: chunk.url,
50
+ identity: contentHash(chunk.code),
51
+ });
52
+ }
53
+ const index: StoreIndex = { framework: frameworkVersion(), bun: Bun.version, chunks };
54
+ await Bun.write(join(dir, INDEX), `${JSON.stringify(index, null, 2)}\n`);
55
+ return [...written, `${ISLAND_STORE_DIR}/${INDEX}`];
56
+ }
57
+
58
+ const isString = (value: unknown): value is string => typeof value === 'string';
59
+
60
+ /** The index, narrowed field by field — a file on disk is input, never a trusted shape. */
61
+ function parseIndex(value: unknown): StoreIndex | undefined {
62
+ if (typeof value !== 'object' || value === null) return undefined;
63
+ const record = value as Record<string, unknown>;
64
+ const chunks = record['chunks'];
65
+ if (!isString(record['framework']) || !isString(record['bun']) || !Array.isArray(chunks)) {
66
+ return undefined;
67
+ }
68
+ const parsed: StoredChunk[] = [];
69
+ for (const entry of chunks) {
70
+ if (typeof entry !== 'object' || entry === null) return undefined;
71
+ const chunk = entry as Record<string, unknown>;
72
+ const { file, moduleId, url, identity } = chunk;
73
+ if (!isString(file) || !isString(moduleId) || !isString(url) || !isString(identity))
74
+ return undefined;
75
+ parsed.push({ file, moduleId, url, identity });
76
+ }
77
+ return { framework: record['framework'], bun: record['bun'], chunks: parsed };
78
+ }
79
+
80
+ /** A verified store, or the one sentence saying why it cannot be served. */
81
+ export type StoreRead =
82
+ | { readonly bundle: IslandBundle; readonly stale?: undefined }
83
+ | { readonly bundle?: undefined; readonly stale: string };
84
+
85
+ export async function readIslandStore(root: string): Promise<StoreRead> {
86
+ const dir = join(root, ISLAND_STORE_DIR);
87
+ const file = Bun.file(join(dir, INDEX));
88
+ if (!(await file.exists())) return { stale: `no ${ISLAND_STORE_DIR}/${INDEX}` };
89
+ const index = parseIndex(await file.json().catch(() => undefined));
90
+ if (index === undefined) return { stale: `${ISLAND_STORE_DIR}/${INDEX} does not parse` };
91
+ if (index.framework !== frameworkVersion() || index.bun !== Bun.version) {
92
+ return {
93
+ stale: `built by framework ${index.framework} on Bun ${index.bun}, serving ${frameworkVersion()} on Bun ${Bun.version}`,
94
+ };
95
+ }
96
+ const stored = index.chunks.map((chunk) => chunk.file).sort();
97
+ const present = [...(await discoverIslands(root))];
98
+ if (stored.join('\n') !== present.join('\n')) {
99
+ return { stale: 'the stored islands are not the islands this app has' };
100
+ }
101
+ const chunks: IslandChunk[] = [];
102
+ for (const entry of index.chunks) {
103
+ const bytes = Bun.file(join(dir, chunkFile(entry.url)));
104
+ const code = (await bytes.exists()) ? await bytes.text() : undefined;
105
+ if (code === undefined || contentHash(code) !== entry.identity) {
106
+ return { stale: `${entry.url} is missing or does not match its recorded hash` };
107
+ }
108
+ chunks.push({
109
+ file: entry.file,
110
+ moduleId: entry.moduleId,
111
+ url: entry.url,
112
+ code,
113
+ bytes: new TextEncoder().encode(code).byteLength,
114
+ });
115
+ }
116
+ return { bundle: islandBundle(chunks) };
117
+ }
118
+
119
+ /**
120
+ * The container's islands: the verified store, or a build when there is none to trust. A build is
121
+ * correct and only slower, so a stale store is a warning with its reason, never a refusal.
122
+ */
123
+ export async function loadOrBuildIslands(root: string): Promise<IslandBundle> {
124
+ const read = await readIslandStore(root);
125
+ if (read.bundle !== undefined) return read.bundle;
126
+ logger.warn('islands built at boot', {
127
+ reason: read.stale,
128
+ fix: 'x build --target docker',
129
+ });
130
+ return buildIslands(root);
131
+ }
@@ -5,8 +5,8 @@
5
5
 
6
6
  import type { StandardSchemaV1 } from '@ultimat3/schema';
7
7
  import { t, validate } from '@ultimat3/schema';
8
- import type { ConsoleLine, PageError } from '@ultimat3/scraping';
9
8
  import type { IslandShotTarget } from '@ultimat3/testing';
9
+ import type { ConsoleLine, PageError } from './browser-launcher-port';
10
10
  import { msg } from './messages';
11
11
  import type { JsonValue } from './output';
12
12
 
@@ -0,0 +1,42 @@
1
+ // The `manifest` step's job half: a job or a task still carrying the positional name `job()` or
2
+ // `task()` minted (`anonymous-job-2`) was never handed to `defineApi`. That name is what the queue
3
+ // row, `x.manifest.json`, `x jobs show` and every dead-letter trace carry, and it moves whenever the
4
+ // import order does — so a retry scheduled under one deploy's name lands on another deploy's job.
5
+
6
+ import { registeredJobs, registeredTasks } from '@ultimat3/jobs';
7
+ import { loadApp } from './app-load';
8
+ import type { AppLoader } from './app-permissions';
9
+ import type { Finding } from './output';
10
+
11
+ /** What `job()` and `task()` name a declaration nobody named (`packages/jobs/src/job.ts`). */
12
+ const POSITIONAL = /^anonymous-(job|task)-\d+$/;
13
+
14
+ /** Every registered job and task still under its positional name. */
15
+ export function positionalNames(
16
+ names: readonly string[] = [...registeredJobs(), ...registeredTasks()].map((one) => one.name),
17
+ ): readonly string[] {
18
+ return names.filter((name) => POSITIONAL.test(name)).sort();
19
+ }
20
+
21
+ export function unregisteredJobFinding(name: string): Finding {
22
+ const kind = name.startsWith('anonymous-task-') ? 'tasks' : 'jobs';
23
+ return {
24
+ code: 'X_JOB_UNREGISTERED',
25
+ cause: `${name} is a ${kind === 'tasks' ? 'task' : 'job'} no defineApi() call names, so it carries the positional name ${kind === 'tasks' ? 'task()' : 'job()'} minted — on the queue row, in the manifest and in every dead-letter trace, and it moves when the import order does`,
26
+ fix: `add import * as <module> from its file and <module> to ${kind}: [...] in apps/web/api/index.ts, then x manifest`,
27
+ at: 'apps/web/api/index.ts',
28
+ };
29
+ }
30
+
31
+ /**
32
+ * The step's answer. Loads the app the way every other gate step does — the registries are only
33
+ * whole after the scan — and says nothing when the load failed: a short registry is the load's
34
+ * finding, reported by the step that loaded it.
35
+ */
36
+ export async function unregisteredJobFindings(
37
+ root: string,
38
+ load: AppLoader = loadApp,
39
+ ): Promise<readonly Finding[]> {
40
+ if ((await load(root)).findings.length > 0) return [];
41
+ return positionalNames().map(unregisteredJobFinding);
42
+ }
@@ -5,9 +5,9 @@
5
5
  import type { JobDriver } from '@ultimat3/jobs';
6
6
  import { jobDriver } from '@ultimat3/jobs';
7
7
  import type { CommandContext } from './command';
8
- import { startQueue } from './dev-queue';
9
- import { resolveServices } from './dev-services';
10
8
  import type { CommandResult } from './output';
9
+ import { resolveServices } from './runtime-bindings';
10
+ import { startQueue } from './runtime-queue';
11
11
 
12
12
  /**
13
13
  * `x jobs` needs the app's real driver. Reuse an already-running one first — inside `x dev` or
@@ -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,30 +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
- X_E2E_APP_FAILED: 'x dev --json',
106
- // The four raw-CDP codes. `x doctor` for the missing browser, because that is the command whose
107
- // whole job is reporting what this machine does not have; the other three are raised inside a
108
- // running suite, so the runnable half is the command that re-runs it.
109
- X_CDP_BROWSER_MISSING:
110
- 'x doctor --json # or set CHROME_PATH to a Chrome binary; unset, the browser-backed suite skips',
111
- X_CDP_LAUNCH_FAILED:
112
- 'x test e2e --json # the cause carries the last lines of the browser\u2019s own stderr',
113
- X_CDP_CALL_FAILED: 'x test e2e --json # the cause names the DevTools call the browser refused',
114
- X_CDP_TIMEOUT: 'x test e2e --json # the cause names the call that never answered',
115
97
  X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
116
98
  X_GH_NOT_AUTHENTICATED: 'gh auth login',
117
99
  X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
@@ -126,7 +108,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
126
108
  X_ERROR_CODE_UNTHROWN:
127
109
  'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
128
110
  X_CLI_UNEXPECTED: 'x doctor --json',
129
- 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',
130
113
  X_LINT_FAILED: 'bunx biome check --write .',
131
114
  X_TEST_FAILED: 'x test --json # the finding carries the exact bun test invocation that failed',
132
115
  // The same two edits `vanishedSuiteFinding` names, verbatim, so both surfaces of this code hand
@@ -182,6 +165,10 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
182
165
  X_PORT_INVALID: 'docker run -e PORT=3000 my-app:latest',
183
166
  X_RUNTIME_DRIVER_SPLIT: 'x dev --json # the boot names the driver the app installed twice',
184
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',
185
172
  X_PORT_IN_USE: 'x dev --port 3001 --json',
186
173
  X_DEV_ALREADY_RUNNING:
187
174
  'x dev --json # after stopping the x dev that already owns this checkout',
@@ -221,8 +208,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
221
208
  // consent and stays in the cause: it is the answer only for a container with a fixed argv.
222
209
  X_SEED_ENVIRONMENT:
223
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.
224
212
  X_BOUNDARY_SITE_TO_APP:
225
- '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',
226
214
  X_BOUNDARY_SHARED_LEAF:
227
215
  'x verify --json # then: x fix boundary <the file the finding names> --json',
228
216
  X_BOUNDARY_APP_TO_API:
@@ -231,6 +219,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
231
219
  'x verify --json # then: x fix boundary <the file the finding names> --json',
232
220
  X_BOUNDARY_SERVICE_TO_HTTP:
233
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/',
234
224
  // The app's own guards. All three are reported by the gate and by nothing else, so the runnable
235
225
  // half is the gate — the narrowing behind the `#` is the edit, because only the finding knows
236
226
  // which file in `guards/` is the one to open.
@@ -245,6 +235,17 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
245
235
  // Render's code, thrown here by the bundler half: the cause names the specifier and the file it
246
236
  // resolved to, and `x g island` is what puts that file where the page already says it is.
247
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',
248
249
  };
249
250
 
250
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,