@ultimat3/cli 23.0.0 → 25.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -4,7 +4,7 @@
4
4
  // for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
5
5
 
6
6
  import { join } from 'node:path';
7
- import { ERROR_DOCS_URL } from '@ultimat3/core';
7
+ import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
8
8
  import { scanAllImports } from './import-scan';
9
9
  import type { Finding } from './output';
10
10
  import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
@@ -148,10 +148,11 @@ export function packageOfSpecifier(specifier: string): string | undefined {
148
148
  * included, because a type edge still needs its manifest line. The transpiler is the parser, so a
149
149
  * generator's template literal (even one nested in a `${…}`, #493) is a string and never an import,
150
150
  * and `from<Row>('posts')` is a call. A file it cannot parse throws rather than reading as empty.
151
+ * The mask is what reads a type-position `typeof import('x')`, which the transpiler erases whole.
151
152
  */
152
153
  export function importedPackages(source: string, path = 'source.ts'): readonly string[] {
153
154
  const packages = new Set<string>();
154
- for (const specifier of scanAllImports({ path, source })) {
155
+ for (const specifier of scanAllImports({ path, source }, maskLiterals(source))) {
155
156
  const name = packageOfSpecifier(specifier);
156
157
  if (name !== undefined) packages.add(name);
157
158
  }
package/src/write-line.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import { writeSync } from 'node:fs';
8
8
  // The one import beyond `node:fs`, and it costs nothing here: `create-ultimate` reaches this
9
9
  // module through `@ultimat3/cli`'s barrel, which has already evaluated core.
10
- import { stringField } from '@ultimat3/core';
10
+ import { backoffDelay, stringField } from '@ultimat3/core';
11
11
 
12
12
  /**
13
13
  * Write to stdout and be certain it arrived, even if the next statement exits the process.
@@ -22,22 +22,97 @@ import { stringField } from '@ultimat3/core';
22
22
  *
23
23
  * `EAGAIN` is "the pipe is full right now", not a failure. CI hands the process a NON-BLOCKING
24
24
  * stdout, where `writeSync` throws rather than blocking — so the loop that fixed the truncation
25
- * took the whole command down on a runner, emitting nothing at all. The reader drains in
26
- * microseconds; the retry is the correct response to "would block".
25
+ * took the whole command down on a runner, emitting nothing at all. A draining reader clears it in
26
+ * microseconds, so it is retried — but BOUNDED: a reader that never drains (a stopped `| less`, a
27
+ * wedged parent) turned the unbounded retry into a command that never exits, spinning a core.
28
+ * `EAGAIN_ATTEMPTS` consecutive stalls, each waiting longer (capped), then the line is dropped and
29
+ * counted (`droppedLineCount()`): one lost line beats a process that cannot finish. After a drop
30
+ * the fd is DEGRADED: each later line gets one attempt and no sleep, or a never-draining reader
31
+ * would cost ~1.5 s per line forever. Any byte arriving clears it.
27
32
  */
28
33
  function writeTo(fd: 1 | 2, line: string): void {
29
- const buffer = Buffer.from(`${line}\n`);
34
+ writeAll(fd, Buffer.from(`${line}\n`));
35
+ }
36
+
37
+ /** Consecutive stalls one write may absorb before its line is dropped. Progress resets it. */
38
+ export const EAGAIN_ATTEMPTS = 50;
39
+
40
+ /**
41
+ * Core's one curve, exponential and capped: 1, 2, 4 … 32 ms — under 1.5 s for a reader that never
42
+ * drains at all. No jitter: one process waiting on its own pipe has no herd to decorrelate.
43
+ */
44
+ const backoffMs = (stall: number): number => backoffDelay({ attempt: stall, base: 1, max: 32 });
45
+
46
+ /** `writeSync`'s shape, so a test can hand in a pipe that is full forever. */
47
+ export type WriteSync = (fd: number, buffer: Uint8Array, offset: number, length: number) => number;
48
+
49
+ let dropped = 0;
50
+
51
+ /**
52
+ * Per fd, because fd 1 and fd 2 are two readers. `degraded`: the last line was dropped, so the next
53
+ * gets one attempt. `torn`: it was dropped HALF-written, so the next line that arrives opens with
54
+ * the newline the torn one never got — otherwise the two glue into one unparseable line.
55
+ */
56
+ const pipes = new Map<number, { degraded: boolean; torn: boolean }>();
57
+
58
+ const pipeOf = (fd: number): { degraded: boolean; torn: boolean } => {
59
+ let pipe = pipes.get(fd);
60
+ if (pipe === undefined) {
61
+ pipe = { degraded: false, torn: false };
62
+ pipes.set(fd, pipe);
63
+ }
64
+ return pipe;
65
+ };
66
+
67
+ const NEWLINE = 0x0a;
68
+
69
+ /** Lines this process dropped to a reader that never drained. */
70
+ export const droppedLineCount = (): number => dropped;
71
+
72
+ /**
73
+ * Write every byte of `buffer` to `fd`, or drop it after `EAGAIN_ATTEMPTS` stalls in a row — one
74
+ * stall while the fd is degraded. Returns whether it all arrived. A write of zero bytes is a stall
75
+ * too, so no path spins.
76
+ */
77
+ export function writeAll(
78
+ fd: number,
79
+ buffer: Uint8Array,
80
+ write: WriteSync = writeSync,
81
+ sleep: (ms: number) => void = Bun.sleepSync,
82
+ ): boolean {
83
+ const pipe = pipeOf(fd);
84
+ const prefix = pipe.torn ? 1 : 0;
85
+ const bytes = prefix === 0 ? buffer : Buffer.concat([Uint8Array.of(NEWLINE), buffer]);
30
86
  let written = 0;
31
- while (written < buffer.length) {
87
+ let stalls = 0;
88
+ while (written < bytes.length) {
89
+ let progress = 0;
32
90
  try {
33
- written += writeSync(fd, buffer, written, buffer.length - written);
91
+ progress = write(fd, bytes, written, bytes.length - written);
34
92
  } catch (cause) {
35
93
  // `stringField`, never a cast plus a property read — the rule `metrics-endpoint.ts` states
36
94
  // and `caught-value-reads.test.ts` enforces. Here it is also the difference between
37
- // rethrowing and an infinite loop: a `code` that cannot be read must not read as `EAGAIN`.
95
+ // rethrowing and retrying: a `code` that cannot be read must not read as `EAGAIN`.
38
96
  if (stringField(cause, 'code') !== 'EAGAIN') throw cause;
39
97
  }
98
+ if (progress > 0) {
99
+ written += progress;
100
+ stalls = 0;
101
+ pipe.degraded = false; // The reader drained: it gets the full budget again.
102
+ continue;
103
+ }
104
+ stalls += 1;
105
+ if (stalls >= (pipe.degraded ? 1 : EAGAIN_ATTEMPTS)) {
106
+ dropped += 1;
107
+ pipe.degraded = true;
108
+ // Nothing written leaves a torn line torn; only the prefix written closes it cleanly.
109
+ if (written > 0) pipe.torn = written > prefix;
110
+ return false;
111
+ }
112
+ sleep(backoffMs(stalls));
40
113
  }
114
+ pipe.torn = false;
115
+ return true;
41
116
  }
42
117
 
43
118
  export function writeLine(line: string): void {
@@ -1,80 +0,0 @@
1
- // CSS selectors over an HTML string, on Bun's own `HTMLRewriter`: the `query()` the offline
2
- // `fakeShotDriver` answers from markup. The selector subset is lol-html's — type, `#id`, `.class`,
3
- // `[attr]`, `[attr=value]`, descendant and child combinators. No box and no hit-target on any
4
- // answer: this has no layout engine, and a fabricated box would make a covered button clickable.
5
- import type { ElementSnapshot } from './browser-launcher-port';
6
-
7
- /** Elements with no end tag: `onEndTag` never fires for them, so they close on open. */
8
- const VOID_TAGS = new Set([
9
- 'area',
10
- 'base',
11
- 'br',
12
- 'col',
13
- 'embed',
14
- 'hr',
15
- 'img',
16
- 'input',
17
- 'link',
18
- 'meta',
19
- 'source',
20
- 'track',
21
- 'wbr',
22
- ]);
23
-
24
- const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*(?:;|$)/i;
25
-
26
- const visible = (tag: string, attrs: ReadonlyMap<string, string>): boolean =>
27
- !attrs.has('hidden') &&
28
- !(tag === 'input' && attrs.get('type')?.toLowerCase() === 'hidden') &&
29
- !HIDDEN_STYLE.test(attrs.get('style') ?? '') &&
30
- attrs.get('aria-hidden') !== 'true';
31
-
32
- interface Open {
33
- readonly tag: string;
34
- readonly attrs: Map<string, string>;
35
- readonly parts: string[];
36
- }
37
-
38
- const finish = (open: Open): ElementSnapshot => ({
39
- tag: open.tag,
40
- attrs: Object.fromEntries(open.attrs),
41
- text: open.parts.join('').replaceAll(/\s+/g, ' ').trim(),
42
- value: open.attrs.get('value') ?? '',
43
- visible: visible(open.tag, open.attrs),
44
- enabled: !open.attrs.has('disabled') && open.attrs.get('aria-disabled') !== 'true',
45
- });
46
-
47
- /** Every element matching `selector`, as the markup states it. A selector lol-html refuses throws. */
48
- export async function queryHtml(
49
- html: string,
50
- selector: string,
51
- ): Promise<readonly ElementSnapshot[]> {
52
- const done: ElementSnapshot[] = [];
53
- const open: Open[] = [];
54
- const rewriter = new HTMLRewriter().on(selector, {
55
- element(element): void {
56
- const tag = element.tagName.toLowerCase();
57
- const attrs = new Map<string, string>();
58
- for (const [name, value] of element.attributes) attrs.set(name.toLowerCase(), value);
59
- const record: Open = { tag, attrs, parts: [] };
60
- if (VOID_TAGS.has(tag)) {
61
- done.push(finish(record));
62
- return;
63
- }
64
- open.push(record);
65
- element.onEndTag(() => {
66
- const index = open.lastIndexOf(record);
67
- if (index !== -1) open.splice(index, 1);
68
- done.push(finish(record));
69
- });
70
- },
71
- text(chunk): void {
72
- // Every OPEN match: `div, span` matches both, and both contain the text.
73
- for (const record of open) record.parts.push(chunk.text);
74
- },
75
- });
76
- await rewriter.transform(new Response(html)).text();
77
- // Never closed is malformed markup; the element still matched, so it is still reported.
78
- for (const record of open) done.push(finish(record));
79
- return done;
80
- }
@@ -1,168 +0,0 @@
1
- // `fakeShotDriver`: a `ShotDriver` over recorded pages, so every `x shot` and `ui.*` test runs with
2
- // no Chrome. It parses markup and runs none of it — `query()` is answered from the HTML,
3
- // `evaluate()` from a recorded table, and a click follows `data-goto` or an `<a href>`. What it
4
- // cannot know it refuses by name rather than inventing: no layout box, and no accessibility tree.
5
- import { hostDecision, notImplemented } from '@ultimat3/core';
6
- import { CdpCallFailedError } from '@ultimat3/testing';
7
- import { queryHtml } from './browser-launcher-fake-html';
8
- import type {
9
- CaptureClip,
10
- ElementSnapshot,
11
- NetworkEntry,
12
- ShotColorScheme,
13
- ShotDriver,
14
- ShotPage,
15
- ShotSession,
16
- ShotSessionInit,
17
- } from './browser-launcher-port';
18
- import { awaitReady } from './cdp-shot-element';
19
- import { ShotHostRefusedError } from './cdp-shot-errors';
20
- import { parseKeyChord } from './cdp-shot-keys';
21
-
22
- export const FAKE_SHOT_DRIVER = 'fake';
23
-
24
- /** One recorded page: its url, its markup, and `expression -> JSON text` for `evaluate()`. */
25
- export interface FakeShotPage {
26
- readonly url: string;
27
- readonly html: string;
28
- readonly evaluate?: Readonly<Record<string, string>> | undefined;
29
- /** The document's HTTP status. Absent is 200 — the page was recorded because it exists. */
30
- readonly status?: number | undefined;
31
- }
32
-
33
- /** A PNG signature, and nothing behind it: deterministic bytes, never a render. */
34
- export const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
35
-
36
- /**
37
- * Different deterministic bytes for every framing fact that was set — one per rectangle, one per
38
- * colour preference — so a caller that dropped the clip or the scheme fails a test here, which is
39
- * the only place a framing knob can be proved without a browser (issue #338).
40
- */
41
- export const framedPng = (clip: CaptureClip | undefined, scheme: ShotColorScheme): Uint8Array => {
42
- const notes =
43
- (clip === undefined
44
- ? ''
45
- : ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`) +
46
- (scheme === 'no-preference' ? '' : ` scheme ${scheme}`);
47
- if (notes === '') return FAKE_PNG;
48
- const suffix = new TextEncoder().encode(notes);
49
- const out = new Uint8Array(FAKE_PNG.length + suffix.length);
50
- out.set(FAKE_PNG);
51
- out.set(suffix, FAKE_PNG.length);
52
- return out;
53
- };
54
-
55
- const normalise = (url: string): string => {
56
- try {
57
- return new URL(url).toString();
58
- } catch {
59
- return url;
60
- }
61
- };
62
-
63
- function fakePage(byUrl: ReadonlyMap<string, FakeShotPage>, init: ShotSessionInit): ShotPage {
64
- let current: FakeShotPage | undefined;
65
- let scheme: ShotColorScheme = 'no-preference';
66
- const network: NetworkEntry[] = [];
67
- const url = (): string => current?.url ?? 'about:blank';
68
-
69
- const navigate = (to: string): void => {
70
- const page = byUrl.get(normalise(to));
71
- if (page === undefined) {
72
- throw new CdpCallFailedError({
73
- method: `Page.navigate to ${to}`,
74
- detail: 'fakeShotDriver() has no page recorded at that url',
75
- });
76
- }
77
- current = page;
78
- network.push({
79
- method: 'GET',
80
- url: page.url,
81
- // Data the page answers with, never a bound — a recorded page exists, so absent is a 200.
82
- status: page.status === undefined ? 200 : page.status,
83
- resourceType: 'document',
84
- at: init.clock.now().getTime(),
85
- });
86
- };
87
-
88
- const query = (selector: string): Promise<readonly ElementSnapshot[]> =>
89
- queryHtml(current?.html ?? '', selector);
90
-
91
- const ready = (selector: string, options?: Parameters<ShotPage['waitFor']>[1]) =>
92
- awaitReady({
93
- selector,
94
- state: options?.state ?? 'actionable',
95
- timeoutMs: options?.timeout ?? init.timeoutMs,
96
- clock: init.clock,
97
- url,
98
- snapshot: async () => (await query(selector))[0],
99
- });
100
-
101
- return {
102
- url,
103
- async goto(to) {
104
- if (!hostDecision(to, init.rules.allowHosts).allowed) {
105
- throw new ShotHostRefusedError({ url: to, allowHosts: init.rules.allowHosts });
106
- }
107
- navigate(to);
108
- },
109
- waitFor: (selector, options) => ready(selector, options),
110
- async click(selector, options) {
111
- const target = await ready(selector, options);
112
- // What the browser would do with the click, as far as markup can say: follow the link.
113
- const next =
114
- target.attrs['data-goto'] ?? (target.tag === 'a' ? target.attrs['href'] : undefined);
115
- if (next !== undefined) navigate(new URL(next, url()).toString());
116
- },
117
- async type(selector, _text, options) {
118
- await ready(selector, options);
119
- },
120
- async focus(selector, options) {
121
- await ready(selector, options);
122
- },
123
- async press(chord) {
124
- parseKeyChord(chord);
125
- },
126
- async accessibility() {
127
- return notImplemented(
128
- 'accessibility() on fakeShotDriver()',
129
- 'assert on the markup with page.query(selector) — a role read off the HTML would hide the element a screen reader cannot name, which is the finding this read exists for',
130
- );
131
- },
132
- query,
133
- async evaluate(expression) {
134
- const table = current?.evaluate ?? {};
135
- const recorded = Object.hasOwn(table, expression) ? table[expression] : undefined;
136
- if (recorded === undefined) {
137
- throw new CdpCallFailedError({
138
- method: 'Runtime.evaluate',
139
- detail: 'fakeShotDriver() has no answer recorded for that expression on this page',
140
- });
141
- }
142
- return JSON.parse(recorded) as unknown;
143
- },
144
- async screenshot(options) {
145
- return framedPng(options?.clip, scheme);
146
- },
147
- async colorScheme(next) {
148
- scheme = next;
149
- },
150
- async prepare() {},
151
- console: () => [],
152
- pageErrors: () => [],
153
- pageErrorsDropped: () => 0,
154
- network: () => [...network],
155
- networkDropped: () => 0,
156
- };
157
- }
158
-
159
- /** A driver whose every session reads from `pages`. An unrecorded url is refused, never fetched. */
160
- export function fakeShotDriver(pages: readonly FakeShotPage[]): ShotDriver {
161
- const byUrl = new Map(pages.map((page) => [normalise(page.url), page]));
162
- return {
163
- name: FAKE_SHOT_DRIVER,
164
- async open(init): Promise<ShotSession> {
165
- return { page: fakePage(byUrl, init), close: async () => undefined };
166
- },
167
- };
168
- }
@@ -1,54 +0,0 @@
1
- // The CLI's build and bundle refusals: an entry an app does not have, an island that will not
2
- // compile, a framework browser script that will not bundle. Split from `errors.ts` at its 500-line
3
- // ceiling; `errors.ts` re-exports every name, so no import site moves.
4
-
5
- import { UltimateError } from '@ultimat3/core';
6
- import { quoteArg } from './shell-quote';
7
-
8
- /**
9
- * A build target names an entry file the app does not have. `x build` refuses before it spawns the
10
- * builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
11
- * supposed to own, and `docker build`'s says nothing about which target wanted it.
12
- */
13
- export class BuildEntryMissingError extends UltimateError {
14
- constructor(input: { target: string; entry: string }) {
15
- super({
16
- code: 'X_BUILD_ENTRY_MISSING',
17
- cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
18
- fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
19
- });
20
- }
21
- }
22
-
23
- /**
24
- * A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
25
- * entry point like any other, and the target's own logs are what says which line. The fix builds
26
- * exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
27
- */
28
- export class IslandBuildFailedError extends UltimateError {
29
- constructor(input: { file: string; logs: string }) {
30
- super({
31
- code: 'X_BUILD_FAILED',
32
- cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
33
- fix: `bun build --target browser ${input.file}`,
34
- });
35
- }
36
- }
37
-
38
- /** Which framework script a page ships: the sync worker, the page boot, or the client router. */
39
- export type FrameworkScriptKind = 'sync worker' | 'page boot' | 'client router';
40
-
41
- /**
42
- * One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
43
- * the framework builds did not build" is one condition, and the entry here is framework code, not
44
- * the app's — the cause names WHICH script, so the reader knows what the page is now missing.
45
- */
46
- export class FrameworkScriptBuildFailedError extends UltimateError {
47
- constructor(input: { what: FrameworkScriptKind; entry: string; logs: string }) {
48
- super({
49
- code: 'X_BUILD_FAILED',
50
- cause: `the ${input.what} (${input.entry}) would not bundle for the browser: ${input.logs}`,
51
- fix: `bun build --target browser --format iife ${quoteArg(input.entry)}`,
52
- });
53
- }
54
- }
package/src/jobs-drain.ts DELETED
@@ -1,163 +0,0 @@
1
- // `x jobs drain`: moving in-flight work from one queue driver onto another. Its own file because
2
- // it is the only command here that WRITES to two drivers at once, and the ordering rule that makes
3
- // that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
4
-
5
- import { uuid } from '@ultimat3/core';
6
- import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
7
- import { claimOf, inspectJobList } from '@ultimat3/jobs';
8
- import type { Finding } from './output';
9
- import { findingFrom } from './output';
10
-
11
- /** `running` is deliberately excluded: a job a worker is mid-execution on is not "pending". */
12
- const PENDING_STATES: readonly JobState[] = ['ready', 'delayed', 'suspended'];
13
-
14
- /**
15
- * The lease has to outlive the WHOLE transfer loop, not one record: the batch is claimed up
16
- * front, and a lease expiring mid-drain would hand a half-transferred job back to a source
17
- * worker. The snapshot is bounded by the drivers' own 100-row-per-state list cap, so this is a
18
- * ceiling over a few hundred sequential enqueues rather than a guess about one.
19
- */
20
- const DRAIN_LEASE_MS = 300_000;
21
-
22
- const NO_LEASE = 'no lease could be taken — not yet due, or a worker is already running it';
23
-
24
- export interface DrainFailure {
25
- readonly id: string;
26
- readonly name: string;
27
- readonly finding: Finding;
28
- }
29
-
30
- /** A candidate the drain deliberately left alone, because it could not prove it owned the job. */
31
- export interface DrainSkip {
32
- readonly id: string;
33
- readonly name: string;
34
- readonly queue: string;
35
- readonly state: JobState;
36
- readonly reason: string;
37
- }
38
-
39
- export interface DrainOutcome {
40
- readonly from: string;
41
- readonly to: string;
42
- readonly dryRun: boolean;
43
- readonly candidates: readonly JobRecord[];
44
- readonly moved: readonly JobRecord[];
45
- readonly skipped: readonly DrainSkip[];
46
- readonly failures: readonly DrainFailure[];
47
- }
48
-
49
- /**
50
- * The lease IS the proof of ownership. `ack()` takes only a job id, so a drain that ack'd rows
51
- * off a snapshot could acknowledge a job a source worker claimed and is executing right now —
52
- * the target would then run a duplicate of a job still in flight. One batch claim over the
53
- * candidates' own queues; whatever comes back is the drain's to move, and everything else is
54
- * left alone. `queues` is always explicit because the pg driver reads an empty list as
55
- * `['default']` rather than as "every queue".
56
- */
57
- function leaseCandidates(
58
- source: JobDriver,
59
- candidates: readonly JobRecord[],
60
- ): Promise<readonly ClaimedJob[]> {
61
- if (candidates.length === 0) return Promise.resolve([]);
62
- return source.claim({
63
- queues: [...new Set(candidates.map((record) => record.queue))],
64
- limit: candidates.length,
65
- visibilityTimeoutMs: DRAIN_LEASE_MS,
66
- workerId: `x-jobs-drain:${uuid()}`,
67
- });
68
- }
69
-
70
- /** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
71
- async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
72
- for (const record of await source.steps.list(runId)) await target.steps.put(record);
73
- }
74
-
75
- /**
76
- * Hand a leased job back exactly as the drain found it. `countsAsAttempt: false` is the point:
77
- * a transfer that failed is not a failed attempt, and burning one per `x jobs drain` retry would
78
- * dead-letter a job nobody ever ran.
79
- *
80
- * It returns to `ready`, which is what "as the drain found it" means — the drain leased a ready
81
- * job and could not move it. It used to land in `suspended`, not by intent but because
82
- * `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
83
- * `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
84
- */
85
- async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
86
- try {
87
- // Fenced on the drain's own claim, like every settle: a lease that lapsed is not handed back.
88
- await source.nack(leased.id, {
89
- ...claimOf(leased),
90
- delayMs: 0,
91
- countsAsAttempt: false,
92
- });
93
- } catch {
94
- // The lease expires on its own. Masking the transfer's real error with this one helps nobody.
95
- }
96
- }
97
-
98
- const toSkip = (record: JobRecord): DrainSkip => ({
99
- id: record.id,
100
- name: record.name,
101
- queue: record.queue,
102
- state: record.state,
103
- reason: NO_LEASE,
104
- });
105
-
106
- /**
107
- * Move every pending job the drain can take a lease on from `source` onto `target`. `--dry-run`
108
- * reports the candidate list, takes no lease and enqueues nothing. Per-record try/catch, not a
109
- * batch operation: one record that cannot enqueue on the target (a redis/nats stub, a transient
110
- * error) must not stop the rest from moving, and the caller reports each failure on its own.
111
- */
112
- export async function drainJobs(
113
- source: JobDriver,
114
- target: JobDriver,
115
- dryRun: boolean,
116
- ): Promise<DrainOutcome> {
117
- const lists = await Promise.all(PENDING_STATES.map((state) => inspectJobList(source, { state })));
118
- const candidates = lists.flat();
119
- const base = { from: source.name, to: target.name, candidates };
120
- if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
121
-
122
- const leased = await leaseCandidates(source, candidates);
123
- const held = new Set(leased.map((record) => record.id));
124
- const found = new Map(candidates.map((record) => [record.id, record]));
125
- const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
126
-
127
- const moved: JobRecord[] = [];
128
- const failures: DrainFailure[] = [];
129
- for (const record of leased) {
130
- let enqueued = false;
131
- try {
132
- // Steps BEFORE the job: a worker on the target must never be able to claim a run whose
133
- // checkpoint has not landed, or it repeats completed steps or loses them outright.
134
- await copySteps(source, target, record.runId);
135
- await target.enqueue({
136
- name: record.name,
137
- queue: record.queue,
138
- input: record.input,
139
- idempotencyKey: record.idempotencyKey,
140
- runId: record.runId,
141
- maxAttempts: record.maxAttempts,
142
- runAt: record.runAt,
143
- ...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
144
- });
145
- enqueued = true;
146
- // Only now is the ack the drain's to make: the lease proves no source worker holds this
147
- // job, and the target already has both the row and its steps. A crash between the two
148
- // leaves the job live on both drivers, where `idempotencyKey` dedupes it.
149
- // `counted: false`: the row is finished HERE because it left, and a job that was moved is
150
- // not a job that completed — it must add nothing to the source's `done` history.
151
- await source.ack(record.id, { ...claimOf(record), counted: false });
152
- // Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
153
- // attempt higher), a state nothing on either driver is in once this returns.
154
- moved.push(found.get(record.id) ?? record);
155
- } catch (error) {
156
- // A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
157
- // not: the job is already live there, and releasing it would race the target's worker.
158
- if (!enqueued) await releaseLease(source, record);
159
- failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
160
- }
161
- }
162
- return { ...base, dryRun: false, moved, skipped, failures };
163
- }