@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
package/src/verify-run.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
15
15
 
16
16
  /**
17
- * Run every step in order, never bailing early: an agent fixing three things at once needs all
17
+ * Run every step, never bailing early: an agent fixing three things at once needs all
18
18
  * three findings from one run, not one per round-trip.
19
19
  *
20
20
  * `ctx.only` narrows the list to one step. The narrowing lives HERE rather than in `cmd-verify.ts`
@@ -26,61 +26,43 @@ export async function runVerify(
26
26
  ctx: VerifyContext,
27
27
  ): Promise<CommandResult> {
28
28
  const floor = await readVerifyFloor(ctx.root);
29
- const results: StepResult[] = [];
30
29
  const selected = ctx.only === undefined ? steps : steps.filter((step) => step.name === ctx.only);
30
+ const byName = new Map<string, StepResult>();
31
+ const began = performance.now();
32
+ // The static steps wait for the serial suites and then run BESIDE them — only when `live` is in
33
+ // the list, so a one-step run (`--only`) and a list with no serial suite keep today's order.
34
+ const overlapping = selected.some((step) => step.name === SERIAL_SUITES[0]);
35
+ const beside = overlapping ? selected.filter((step) => BESIDE_SERIAL_SUITES.has(step.name)) : [];
36
+ let pending: Promise<void> | undefined;
37
+ const join = async (): Promise<void> => {
38
+ await pending;
39
+ pending = undefined;
40
+ };
31
41
  for (const step of selected) {
32
- const applies = step.applies === undefined ? true : await step.applies(ctx);
33
- if (!applies) {
34
- // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
35
- // that claim, committed — so "nothing to check" now means the suite was deleted, and the
36
- // gate says so on the step's own line rather than counting one more thing not to worry
37
- // about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
38
- // the summary, `data.failed`, and the reference-app gate's own red list.
39
- const required = floorRequires(floor, step.name);
40
- results.push({
41
- name: step.name,
42
- ok: !required,
43
- durationMs: 0,
44
- skipped: !required,
45
- findings: required ? [vanishedSuiteFinding(step.name)] : [],
46
- });
47
- continue;
42
+ if (beside.includes(step)) continue;
43
+ if (step.name === SERIAL_SUITES[0]) {
44
+ pending = Promise.all(
45
+ beside.map(async (other) => {
46
+ byName.set(other.name, await runStep(other, ctx, floor));
47
+ }),
48
+ ).then(() => undefined);
49
+ } else if (!SERIAL_SUITES.includes(step.name)) {
50
+ await join();
48
51
  }
49
- const started = performance.now();
50
- const outcome = await step.run(ctx).catch(
51
- (error: unknown): StepOutcome => ({
52
- ok: false,
53
- findings: [findingOf(error, step.name)],
54
- }),
55
- );
56
- // A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
57
- // over an all-skipped file, so the counts are the only channel that can tell the two apart.
58
- // ONE definition of "nothing ran", read twice, because the floor decides which of the two
59
- // things it means — exactly as it already does for a step whose `applies` said no.
60
- const tests = outcome.tests;
61
- const nothingRan = tests !== undefined && tests.ran === 0;
62
- const required = floorRequires(floor, step.name);
63
- // A step the floor requires whose suite executed nothing is the same vanished suite as a step
64
- // with no files at all — the run just had to finish before it could be seen. Appended to the
65
- // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
66
- const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
67
- results.push({
68
- name: step.name,
69
- ok: outcome.ok && vanished.length === 0,
70
- durationMs: Math.round(performance.now() - started),
71
- // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
72
- // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
73
- // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
74
- skipped: nothingRan && !required,
75
- findings: [...outcome.findings, ...vanished],
76
- ...(outcome.output === undefined ? {} : { output: outcome.output }),
77
- ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
78
- ...(tests === undefined ? {} : { tests }),
79
- });
52
+ byName.set(step.name, await runStep(step, ctx, floor));
80
53
  }
54
+ await join();
55
+ // Reported in the declared order, whatever order the steps finished in: the table, `--json` and
56
+ // every gate parsing either read the same sequence they always did.
57
+ const results = selected.flatMap((step) => {
58
+ const result = byName.get(step.name);
59
+ return result === undefined ? [] : [result];
60
+ });
81
61
  const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
82
62
  const skippedSteps = results.filter((step) => step.skipped === true).map((step) => step.name);
83
- const totalMs = results.reduce((sum, step) => sum + step.durationMs, 0);
63
+ // WALL time, not the sum of step times: with steps overlapping, the sum overstates what a run
64
+ // costs, and the wall clock is the number a CI job waits on.
65
+ const totalMs = Math.round(performance.now() - began);
84
66
  const summary = verifySummary({
85
67
  results,
86
68
  failed: failedSteps,
@@ -139,6 +121,89 @@ function verifySummary(input: {
139
121
  return msg(clean ? 'cli.verify.fail' : 'cli.verify.failSkipped', params);
140
122
  }
141
123
 
124
+ /**
125
+ * `x verify`'s wall time was the SUM of 20 serial steps (#14, the DX ledger): measured locally, 395s,
126
+ * of which `lint`, `boundaries`, `filesize`, `package-shape` and `errors` were 127s spent while
127
+ * nothing else ran. They read the tree and write nothing a later step reads (`lint` is biome over
128
+ * files; the rest are in-process scans), so they run BESIDE the serial suites — `live` and `e2e`
129
+ * are one worker each, Postgres- and browser-bound, and mostly waiting. `typecheck` stays first
130
+ * and alone: `tsc -b` writes `.tsbuildinfo` and `dist/`, and `unit` saturates every core.
131
+ * `manifest` joined them 2026-09-23: it compares committed files against the code and writes
132
+ * nothing, and at the repo root its host checks are ~20 whole-tree reads that sat alone at the end.
133
+ */
134
+ export const BESIDE_SERIAL_SUITES: ReadonlySet<string> = new Set([
135
+ 'lint',
136
+ 'boundaries',
137
+ 'filesize',
138
+ 'package-shape',
139
+ 'errors',
140
+ 'manifest',
141
+ ]);
142
+
143
+ /** The consecutive run of steps the static group overlaps, in the order `VERIFY_STEP_NAMES` holds. */
144
+ export const SERIAL_SUITES: readonly string[] = ['live', 'job', 'e2e', 'eval'];
145
+
146
+ async function runStep(
147
+ step: VerifyStep,
148
+ ctx: VerifyContext,
149
+ floor: Awaited<ReturnType<typeof readVerifyFloor>>,
150
+ ): Promise<StepResult> {
151
+ // Inside the step's own failure, like a throwing `run`: an `applies` that threw escaped every
152
+ // catch and aborted the whole gate, which is the one outcome `runVerify` exists to prevent.
153
+ let applies: boolean;
154
+ try {
155
+ applies = step.applies === undefined ? true : await step.applies(ctx);
156
+ } catch (error) {
157
+ return { name: step.name, ok: false, durationMs: 0, findings: [findingOf(error, step.name)] };
158
+ }
159
+ if (!applies) {
160
+ // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
161
+ // that claim, committed — so "nothing to check" now means the suite was deleted, and the
162
+ // gate says so on the step's own line rather than counting one more thing not to worry
163
+ // about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
164
+ // the summary, `data.failed`, and the reference-app gate's own red list.
165
+ const required = floorRequires(floor, step.name);
166
+ return {
167
+ name: step.name,
168
+ ok: !required,
169
+ durationMs: 0,
170
+ skipped: !required,
171
+ findings: required ? [vanishedSuiteFinding(step.name)] : [],
172
+ };
173
+ }
174
+ const started = performance.now();
175
+ const outcome = await step.run(ctx).catch(
176
+ (error: unknown): StepOutcome => ({
177
+ ok: false,
178
+ findings: [findingOf(error, step.name)],
179
+ }),
180
+ );
181
+ // A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
182
+ // over an all-skipped file, so the counts are the only channel that can tell the two apart.
183
+ // ONE definition of "nothing ran", read twice, because the floor decides which of the two
184
+ // things it means — exactly as it already does for a step whose `applies` said no.
185
+ const tests = outcome.tests;
186
+ const nothingRan = tests !== undefined && tests.ran === 0;
187
+ const required = floorRequires(floor, step.name);
188
+ // A step the floor requires whose suite executed nothing is the same vanished suite as a step
189
+ // with no files at all — the run just had to finish before it could be seen. Appended to the
190
+ // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
191
+ const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
192
+ return {
193
+ name: step.name,
194
+ ok: outcome.ok && vanished.length === 0,
195
+ durationMs: Math.round(performance.now() - started),
196
+ // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
197
+ // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
198
+ // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
199
+ skipped: nothingRan && !required,
200
+ findings: [...outcome.findings, ...vanished],
201
+ ...(outcome.output === undefined ? {} : { output: outcome.output }),
202
+ ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
203
+ ...(tests === undefined ? {} : { tests }),
204
+ };
205
+ }
206
+
142
207
  function findingOf(error: unknown, step: string): Finding {
143
208
  // A step may throw anything, including an Error that fights being read: `instanceof` runs a
144
209
  // Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
@@ -79,9 +79,9 @@ export interface VerifyContext {
79
79
  */
80
80
  readonly workers?: number;
81
81
  /**
82
- * ONE step, by name — an iteration loop, and the one thing here that IS a narrowing. Every
83
- * iteration of the whole gate costs ~18s (14s of it `tsc -b`), which is the cost of asking a
84
- * question about one step. It does not weaken axiom 5, and the two rules that keep it honest are
82
+ * ONE step, by name — an iteration loop, and the one thing here that IS a narrowing. The
83
+ * whole gate costs minutes (3m19s at the framework root on 12 cores, `As of 2026-09-23`), which
84
+ * is the cost of asking a question about one step. It does not weaken axiom 5, and the two rules that keep it honest are
85
85
  * mechanical rather than remembered: a run with this set prints `NOT A GATE RUN` in the summary
86
86
  * AND carries `notAGateRun` in `--json` (`verify-run.ts`), so no reader of either can mistake it
87
87
  * for the gate; and nothing writes `x.verify.json`, so the suite floor cannot be lowered by a
@@ -18,7 +18,8 @@ import { countsOf } from './test-counts';
18
18
  import { testEnvOverrides } from './test-dotenv';
19
19
  import type { TestFile } from './test-select';
20
20
  import { discoverTests } from './test-select';
21
- import { defaultWorkers } from './test-workers';
21
+ import { defaultWorkers, SERIAL_TYPES } from './test-workers';
22
+ import { withE2eApp } from './verify-e2e';
22
23
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
23
24
  import { fromExec, fromFindings } from './verify-step';
24
25
  import { runParallel } from './verify-test-run';
@@ -110,31 +111,6 @@ export const ownerOf = (path: string): TestType =>
110
111
  const disownedBy = (type: TypedTest): readonly string[] =>
111
112
  type === 'e2e' ? [`**/e2e/**/*.{${OUTRANKING_E2E.join(',')}}.test.*`] : [];
112
113
 
113
- /**
114
- * Which types run across worker processes, and why the other two cannot.
115
- *
116
- * Parallel is safe when the only thing a test file shares with another file is the database, and
117
- * the database is per worker by construction (`ULTIMATE_TEST_WORKER` → one clone of the migrated
118
- * template, `@ultimat3/testing`'s `acquireWorkerDatabase`). Every other process-global in this
119
- * framework — the permission set, the roles, the entity/action/query registries, the error-code
120
- * titles, the fixture bag — is handled by `--isolate` giving each FILE its own module registry.
121
- *
122
- * | Type | Why |
123
- * |---|---|
124
- * | `live` | **serial.** A logical replication slot and a publication are named at the Postgres
125
- * CLUSTER level, not inside a database, and this repo's own feed tests hard-code
126
- * `x_live_slot` / `x_live_pub` against `TEST_REPLICATION_URL` — the one server, never a per-worker
127
- * clone. Two workers would race `pg_create_logical_replication_slot` and the loser's failure would
128
- * read as a flake. A per-worker database does not isolate a cluster-wide object |
129
- * | `e2e` | **serial.** It runs against the *built output*: one `dist/`, one service-worker
130
- * registration, one browser profile. There is nothing per-worker to hand it, and the type is
131
- * seconds at most, so a split would buy a race and no time |
132
- *
133
- * The two are named here rather than tested for, because "can this type be sharded?" is a design
134
- * fact about the type, not something a run can discover about itself.
135
- */
136
- export const SERIAL_TYPES: readonly TestType[] = ['live', 'e2e'];
137
-
138
114
  const isSerial = (type: TestType): boolean => SERIAL_TYPES.includes(type);
139
115
 
140
116
  /**
@@ -162,6 +138,12 @@ const ignoreFlags = (patterns: readonly string[]): readonly string[] =>
162
138
  export const typeFiltersOf = (type: Exclude<TestType, 'unit'>): readonly string[] =>
163
139
  OWNERSHIP.filter(([owner]) => owner === type).map(([, filter]) => filter);
164
140
 
141
+ /**
142
+ * An e2e test's own budget: a first navigation waits on `x dev` compiling the route and its island,
143
+ * which bun's default 5 s does not cover — a timeout there reads as an app bug that is not one.
144
+ */
145
+ export const E2E_TEST_TIMEOUT_MS = 60_000;
146
+
165
147
  /** Unit is everything the typed suites do not claim, so no test can fall between two steps. */
166
148
  export const testStepCommand = (type: TestType): readonly string[] =>
167
149
  type === 'unit'
@@ -173,6 +155,7 @@ export const testStepCommand = (type: TestType): readonly string[] =>
173
155
  : [
174
156
  'bun',
175
157
  'test',
158
+ ...(type === 'e2e' ? [`--timeout=${String(E2E_TEST_TIMEOUT_MS)}`] : []),
176
159
  ...ignoreFlags([...NEVER_A_TEST, ...disownedBy(type)]),
177
160
  ...typeFiltersOf(type),
178
161
  ];
@@ -203,10 +186,19 @@ export const resetTestDiscovery = (): void => discovered.clear();
203
186
  const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
204
187
  const command = testStepCommand(type);
205
188
  const envOverrides = testEnvOverrides(ctx.root, ctx.env ?? Bun.env);
206
- const result = await ctx.runner(command, {
207
- cwd: ctx.root,
208
- ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
209
- });
189
+ const exec = (e2e: { command: readonly string[]; env: typeof envOverrides }) =>
190
+ ctx.runner(e2e.command, {
191
+ cwd: ctx.root,
192
+ ...(Object.keys(e2e.env).length === 0 ? {} : { env: e2e.env }),
193
+ });
194
+ // The e2e step drives a real browser against the app it spawns, when this machine has one.
195
+ const result =
196
+ type === 'e2e'
197
+ ? await withE2eApp(
198
+ { root: ctx.root, isApp: isApp(ctx.root), command, env: envOverrides },
199
+ exec,
200
+ )
201
+ : await exec({ command, env: envOverrides });
210
202
  return {
211
203
  ...fromExec(result, {
212
204
  code: 'X_TEST_FAILED',
@@ -0,0 +1,28 @@
1
+ // Which `tsc` invocation the `typecheck` step runs. `tsc -b` decides "up to date" by mtime, and Bun
2
+ // installs a package as HARDLINKS out of its cache, carrying the cache's old mtimes — so upgrading
3
+ // a dependency to a version already in the cache left every input older than the buildinfo, `-b`
4
+ // skipped the program and reported green while a cold CI went red (#450). `-p` with `incremental`
5
+ // re-reads every input and compares content hashes, so it cannot be fooled by a date.
6
+
7
+ import { checkRootReferences } from './tsconfig-references';
8
+
9
+ /**
10
+ * A package dir no reference can name. `checkRootReferences` answers `[]` when the root has no
11
+ * `references` array at all and one finding for this probe when it has one, which is the one
12
+ * question asked here — without a second JSONC parser beside the one that file owns.
13
+ */
14
+ const PROBE = '\u0000x-verify-typecheck-probe';
15
+
16
+ /** True when the root tsconfig declares `references`: a build graph that only `-b` can walk. */
17
+ export const usesProjectReferences = async (root: string): Promise<boolean> =>
18
+ (await checkRootReferences(root, [PROBE])).length > 0;
19
+
20
+ /**
21
+ * The argv after `bunx`. A root with references keeps `-b` — the framework's 32 projects are only
22
+ * reachable that way, and `tsc -b` rebuilds a project whose own sources moved. A root without them
23
+ * (every scaffolded app) is ONE program, and `-p .` is the content-hashed check of it.
24
+ */
25
+ export async function typecheckArgs(root: string, bin: string): Promise<readonly string[]> {
26
+ const mode = (await usesProjectReferences(root)) ? ['-b'] : ['-p', '.'];
27
+ return [bin, ...mode, '--pretty', 'false'];
28
+ }
@@ -1,6 +1,6 @@
1
1
  // How a process binds its sockets, and what it admits about itself. A LEAF: it imports nothing,
2
- // so every role can read it without pulling `dev-roles` — which is what made this its own file.
3
- // `dev-sync` needs the default and `dev-roles` already imports `dev-sync`, so reading it from
2
+ // so every role can read it without pulling `role-start` — which is what made this its own file.
3
+ // `role-sync` needs the default and `role-start` already imports `role-sync`, so reading it from
4
4
  // there would be a runtime import cycle in the framework's own boot path.
5
5
 
6
6
  export interface WebBinding {
@@ -0,0 +1,192 @@
1
+ // The page's framework scripts (plan 101), each built as its own classic-script browser bundle,
2
+ // addressed by a hash of its source graph, served `immutable` by the same route in `x dev` and the
3
+ // container: the ONE sync worker (`@ultimat3/realtime/sync-worker`, slice 11) and the ONE page boot
4
+ // (`@ultimat3/realtime/boot` — the disk restore and the outbox, once per page instead of once per
5
+ // island). A new deploy is a new URL, so an old tab keeps what it started with.
6
+
7
+ // why: Bun ships no path API; the entry is resolved to a file and named in the cause.
8
+ import { dirname, join, relative } from 'node:path';
9
+ import type { Route, UltimateRequest } from '@ultimat3/http';
10
+ import { applyCacheHeaders, json } from '@ultimat3/http';
11
+ import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
12
+ import { describeBuildError, graphHash, stripDebugId } from './island-bundle';
13
+
14
+ /** Under the dev namespace `/_x` — where the socket it opens (`/_x/sync`) already lives. */
15
+ export const SYNC_WORKER_BASE_PATH = '/_x/sync-worker';
16
+
17
+ /** What an app resolves: realtime's worker entry, from the APP's install, never the CLI's own. */
18
+ export const SYNC_WORKER_SPECIFIER = '@ultimat3/realtime/sync-worker';
19
+
20
+ /** Under the dev namespace too; one per document that carries a principal scope. */
21
+ export const PAGE_BOOT_BASE_PATH = '/_x/page-boot';
22
+
23
+ /** Realtime's page boot, from the APP's install. */
24
+ export const PAGE_BOOT_SPECIFIER = '@ultimat3/realtime/boot';
25
+
26
+ /** One framework script: the sync worker or the page boot. */
27
+ export interface FrameworkScript {
28
+ /** `<base>/<hash>.js` — what the document names. */
29
+ readonly url: string;
30
+ readonly code: string;
31
+ readonly bytes: number;
32
+ }
33
+
34
+ /** The worker, by the name every caller already uses. */
35
+ export type SyncWorkerChunk = FrameworkScript;
36
+
37
+ export interface BuildSyncWorkerOptions {
38
+ /** An absolute entry path. Absent resolves `SYNC_WORKER_SPECIFIER` from `root`. */
39
+ readonly entry?: string;
40
+ }
41
+
42
+ /**
43
+ * `undefined` when the app cannot resolve realtime's worker: an app with no realtime has no
44
+ * socket to share, and the tab-side host falls back to an in-page engine by design (slice 11's
45
+ * transparent fallback) — absence is an answer, not a failure.
46
+ */
47
+ export function buildSyncWorker(
48
+ root: string,
49
+ options: BuildSyncWorkerOptions = {},
50
+ ): Promise<FrameworkScript | undefined> {
51
+ return buildFrameworkScript(
52
+ root,
53
+ 'sync worker',
54
+ SYNC_WORKER_SPECIFIER,
55
+ SYNC_WORKER_BASE_PATH,
56
+ options.entry,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * The page boot, or `undefined` for an app with no realtime (nothing to restore, no outbox). A
62
+ * document then carries no boot script, and `pageRealtime().booted` answers at once.
63
+ */
64
+ export function buildPageBoot(
65
+ root: string,
66
+ options: BuildSyncWorkerOptions = {},
67
+ ): Promise<FrameworkScript | undefined> {
68
+ return buildFrameworkScript(
69
+ root,
70
+ 'page boot',
71
+ PAGE_BOOT_SPECIFIER,
72
+ PAGE_BOOT_BASE_PATH,
73
+ options.entry,
74
+ );
75
+ }
76
+
77
+ async function buildFrameworkScript(
78
+ root: string,
79
+ what: FrameworkScriptKind,
80
+ specifier: string,
81
+ basePath: string,
82
+ explicit: string | undefined,
83
+ ): Promise<FrameworkScript | undefined> {
84
+ const entry = explicit ?? resolveEntry(root, specifier);
85
+ if (entry === undefined) return undefined;
86
+ const label = relative(root, entry);
87
+ let built: Awaited<ReturnType<typeof Bun.build>>;
88
+ try {
89
+ built = await Bun.build({
90
+ entrypoints: [entry],
91
+ target: 'browser',
92
+ // A CLASSIC script: `new SharedWorker(url, { name })` with no `type: 'module'` runs it, and a
93
+ // `<script defer>` runs it before any island module that follows it in the document.
94
+ format: 'iife',
95
+ splitting: false,
96
+ minify: true,
97
+ // `island-bundle.ts`'s reasons, verbatim: a chunk is only ever built to be shipped, and the
98
+ // map's `sourcesContent` is the one stable identity a minified bundle has.
99
+ define: { 'process.env.NODE_ENV': '"production"' },
100
+ sourcemap: 'external',
101
+ });
102
+ } catch (error) {
103
+ throw new FrameworkScriptBuildFailedError({
104
+ what,
105
+ entry: label,
106
+ logs: describeBuildError(error),
107
+ });
108
+ }
109
+ const output = built.outputs.find((artifact) => artifact.kind === 'entry-point');
110
+ const map = built.outputs.find((artifact) => artifact.kind === 'sourcemap');
111
+ if (!built.success || output === undefined || map === undefined) {
112
+ throw new FrameworkScriptBuildFailedError({
113
+ what,
114
+ entry: label,
115
+ logs: built.logs.map((log) => String(log)).join('; '),
116
+ });
117
+ }
118
+ const code = stripDebugId(await output.text());
119
+ return {
120
+ url: `${basePath}/${graphHash(specifier, await map.text())}.js`,
121
+ code,
122
+ bytes: new TextEncoder().encode(code).byteLength,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The app's realtime, from the root or — in a workspace app, where `@ultimat3/realtime` is a
128
+ * dependency of `apps/<app>` and not of the root (`examples/dummy`) — from the first app that has
129
+ * it: where its islands resolve it, which is the copy the scripts must match. Resolving from the
130
+ * root alone built no worker and no boot for such an app, silently.
131
+ */
132
+ function resolveEntry(root: string, specifier: string): string | undefined {
133
+ for (const dir of [root, ...appDirs(root)]) {
134
+ try {
135
+ return Bun.resolveSync(specifier, dir);
136
+ } catch {
137
+ // Not installed here, or a realtime that predates the export: try the next app.
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ function appDirs(root: string): readonly string[] {
144
+ const glob = new Bun.Glob('apps/*/package.json');
145
+ return [...glob.scanSync({ cwd: root, onlyFiles: true })]
146
+ .sort()
147
+ .map((file) => join(root, dirname(file)));
148
+ }
149
+
150
+ /** The script a build produced, read per request so `x dev` and the container share one route. */
151
+ export type SyncWorkerSource = () => FrameworkScript | undefined;
152
+
153
+ export function syncWorkerRoutes(source: SyncWorkerSource): readonly Route[] {
154
+ return [scriptRoute(SYNC_WORKER_BASE_PATH, 'assets.sync-worker', 'sync worker', source)];
155
+ }
156
+
157
+ export function pageBootRoutes(source: SyncWorkerSource): readonly Route[] {
158
+ return [scriptRoute(PAGE_BOOT_BASE_PATH, 'assets.page-boot', 'page boot', source)];
159
+ }
160
+
161
+ function scriptRoute(
162
+ basePath: string,
163
+ name: string,
164
+ what: string,
165
+ source: SyncWorkerSource,
166
+ ): Route {
167
+ return {
168
+ method: 'GET',
169
+ path: `${basePath}/:file`,
170
+ meta: { name, auth: 'public', tags: ['assets'] },
171
+ handler: (request: UltimateRequest): Response => {
172
+ const script = source();
173
+ if (script === undefined || script.url !== request.pathname) {
174
+ return json(
175
+ {
176
+ ok: false,
177
+ error: {
178
+ code: 'X_ROUTE_NOT_FOUND',
179
+ cause: `no ${what} is built at ${request.pathname} — the document that named it was rendered against another build`,
180
+ fix: `reload the page — this process serves only the ${what} it built`,
181
+ },
182
+ },
183
+ { status: 404 },
184
+ );
185
+ }
186
+ return applyCacheHeaders(
187
+ new Response(script.code, { headers: { 'content-type': 'text/javascript' } }),
188
+ { mode: 'immutable' },
189
+ );
190
+ },
191
+ };
192
+ }
@@ -5,9 +5,9 @@
5
5
 
6
6
  import { join } from 'node:path';
7
7
  import { ERROR_DOCS_URL } from '@ultimat3/core';
8
+ import { scanAllImports } from './import-scan';
8
9
  import type { Finding } from './output';
9
10
  import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
10
- import { maskLiterals } from './ts-scan';
11
11
 
12
12
  export interface WorkspaceNode {
13
13
  /** package.json "name". */
@@ -144,43 +144,20 @@ export function packageOfSpecifier(specifier: string): string | undefined {
144
144
  }
145
145
 
146
146
  /**
147
- * Every form that names a module: `… from '…'`, a bare `import '…'`, `import('…')`, `require('…')`.
148
- * `from` is matched only where a quote follows it directly, so `from<Row>('posts', …)` — the query
149
- * builder, which reads exactly like an import — cannot be one.
147
+ * Every package a source file imports, deduplicated, in first-appearance order — type-only imports
148
+ * included, because a type edge still needs its manifest line. The transpiler is the parser, so a
149
+ * generator's template literal (even one nested in a `${…}`, #493) is a string and never an import,
150
+ * and `from<Row>('posts')` is a call. A file it cannot parse throws rather than reading as empty.
150
151
  */
151
- const IMPORT_FORM =
152
- /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|(?:^|[;{])\s*import\s+)['"]([^'"]*)['"]/g;
153
-
154
- /**
155
- * Every package a source file imports, deduplicated, in first-appearance order.
156
- *
157
- * Read from the MASKED text, because a generator's template literal holds whole programs: every
158
- * `templates/*.ts` here emits `import … from '@ultimat3/ui'` as a string, and a scan that read
159
- * those would bill the CLI for the imports of the app it writes. The line a masked hit falls on is
160
- * then re-read from the real source — the specifier itself is what masking blanks.
161
- */
162
- export function importedPackages(source: string): readonly string[] {
163
- const masked = maskLiterals(source).split('\n');
164
- const lines = source.split('\n');
152
+ export function importedPackages(source: string, path = 'source.ts'): readonly string[] {
165
153
  const packages = new Set<string>();
166
- for (const [index, maskedLine] of masked.entries()) {
167
- const line = lines[index];
168
- // `matchAll` clones the regex, so the shared `lastIndex` is never carried between lines.
169
- if (line === undefined || [...maskedLine.matchAll(IMPORT_FORM)].length === 0) continue;
170
- for (const match of line.matchAll(IMPORT_FORM)) {
171
- const name = packageOfSpecifier(match[1] ?? '');
172
- if (name !== undefined) packages.add(name);
173
- }
154
+ for (const specifier of scanAllImports({ path, source })) {
155
+ const name = packageOfSpecifier(specifier);
156
+ if (name !== undefined) packages.add(name);
174
157
  }
175
158
  return [...packages];
176
159
  }
177
160
 
178
- /**
179
- * Borrowed, not twinned: `X_APP_PACKAGE_INVALID` already means "this package.json supplies no
180
- * usable name", and a workspace manifest is that same file one directory down. `bun pm pkg set`
181
- * is not the fix here — it parses the file it edits, so it fails on exactly the input this
182
- * reports.
183
- */
184
161
  export const unreadableWorkspaceFinding = (path: string): Finding => ({
185
162
  code: 'X_APP_PACKAGE_INVALID',
186
163
  cause: `${path} is claimed by the root "workspaces" globs and supplies no readable "name"`,
@@ -228,7 +205,7 @@ export async function checkWorkspaceDependencies(root: string): Promise<readonly
228
205
  if (isTest(path) || isGenerated(path)) continue;
229
206
  const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
230
207
  if (owner === undefined) continue;
231
- for (const name of importedPackages(await Bun.file(join(root, path)).text())) {
208
+ for (const name of importedPackages(await Bun.file(join(root, path)).text(), path)) {
232
209
  const target = byName.get(name);
233
210
  if (target === undefined || target.name === owner.name) continue;
234
211
  if (owner.dependencies.includes(target.name)) continue;