@ultimat3/cli 24.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 (242) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
package/src/jobs-drain.ts DELETED
@@ -1,230 +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 { logger, uuid } from '@ultimat3/core';
6
- import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
7
- import { claimOf, inspectJobList, jobCursor, MAX_JOB_PAGE } 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 transfer of the whole BATCH it covers: a batch is claimed up front,
16
- * and a lease expiring mid-batch would hand a half-transferred job back to a source worker. A
17
- * batch is at most `MAX_JOB_PAGE` rows, so this is a ceiling over a couple of hundred sequential
18
- * enqueues rather than a guess about however many the queue holds.
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 leaseBatch(
58
- source: JobDriver,
59
- candidates: readonly JobRecord[],
60
- limit: number,
61
- ): Promise<readonly ClaimedJob[]> {
62
- if (candidates.length === 0 || limit <= 0) return Promise.resolve([]);
63
- return source.claim({
64
- queues: [...new Set(candidates.map((record) => record.queue))],
65
- limit,
66
- visibilityTimeoutMs: DRAIN_LEASE_MS,
67
- workerId: `x-jobs-drain:${uuid()}`,
68
- // A claim BURIES a row whose lease lapsed on its final attempt, whoever makes it — this one
69
- // too. The drain is an operator's tool with no worker context, so it runs no `onSettled`
70
- // (at most once across a crash, as documented) and names no `dropExhausted`: said out loud
71
- // here, because an unlistened burial is a row that went `dead` with nothing anywhere saying so.
72
- onExhausted: (dead) => {
73
- for (const row of dead) {
74
- logger.error('jobs.claim.exhausted', {
75
- workerId: 'x-jobs-drain',
76
- job: row.name,
77
- jobId: row.id,
78
- attempt: row.attempt,
79
- maxAttempts: row.maxAttempts,
80
- error: row.lastError ?? '',
81
- });
82
- }
83
- },
84
- });
85
- }
86
-
87
- /**
88
- * Every row in `state`, page by page on the keyset cursor until a short page: one page was the
89
- * drivers' default 100, so a drain of 350 ready jobs moved 100 and left 250 on the source.
90
- */
91
- async function everyRow(source: JobDriver, state: JobState): Promise<readonly JobRecord[]> {
92
- const rows: JobRecord[] = [];
93
- let after: string | undefined;
94
- for (;;) {
95
- const page = await inspectJobList(source, {
96
- state,
97
- limit: MAX_JOB_PAGE,
98
- ...(after === undefined ? {} : { after }),
99
- });
100
- rows.push(...page);
101
- const last = page.at(-1);
102
- if (page.length < MAX_JOB_PAGE || last === undefined) return rows;
103
- after = jobCursor(last);
104
- }
105
- }
106
-
107
- /** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
108
- async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
109
- for (const record of await source.steps.list(runId)) await target.steps.put(record);
110
- }
111
-
112
- /**
113
- * Hand a leased job back exactly as the drain found it. `countsAsAttempt: false` is the point:
114
- * a transfer that failed is not a failed attempt, and burning one per `x jobs drain` retry would
115
- * dead-letter a job nobody ever ran.
116
- *
117
- * It returns to `ready`, which is what "as the drain found it" means — the drain leased a ready
118
- * job and could not move it. It used to land in `suspended`, not by intent but because
119
- * `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
120
- * `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
121
- */
122
- async function releaseLease(source: JobDriver, leased: ClaimedJob): Promise<void> {
123
- try {
124
- // Fenced on the drain's own claim, like every settle: a lease that lapsed is not handed back.
125
- await source.nack(leased.id, {
126
- ...claimOf(leased),
127
- delayMs: 0,
128
- countsAsAttempt: false,
129
- });
130
- } catch {
131
- // The lease expires on its own. Masking the transfer's real error with this one helps nobody.
132
- }
133
- }
134
-
135
- const toSkip = (record: JobRecord): DrainSkip => ({
136
- id: record.id,
137
- name: record.name,
138
- queue: record.queue,
139
- state: record.state,
140
- reason: NO_LEASE,
141
- });
142
-
143
- /**
144
- * Move every pending job the drain can take a lease on from `source` onto `target`. `--dry-run`
145
- * reports the candidate list, takes no lease and enqueues nothing. Per-record try/catch, not a
146
- * batch operation: one record that cannot enqueue on the target (a redis/nats stub, a transient
147
- * error) must not stop the rest from moving, and the caller reports each failure on its own.
148
- */
149
- export async function drainJobs(
150
- source: JobDriver,
151
- target: JobDriver,
152
- dryRun: boolean,
153
- ): Promise<DrainOutcome> {
154
- const lists = await Promise.all(PENDING_STATES.map((state) => everyRow(source, state)));
155
- const candidates = lists.flat();
156
- const base = { from: source.name, to: target.name, candidates };
157
- if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
158
-
159
- const found = new Map(candidates.map((record) => [record.id, record]));
160
- const held = new Set<string>();
161
- const moved: JobRecord[] = [];
162
- const failures: DrainFailure[] = [];
163
- // Batch by batch, each under its own lease, until the claims stop or the snapshot's count is
164
- // spent — a claim may lease a row enqueued after the snapshot, and that row spends the budget
165
- // too, so a queue that keeps filling cannot keep the drain running.
166
- let budget = candidates.length;
167
- while (budget > 0) {
168
- const leased = await leaseBatch(source, candidates, Math.min(budget, MAX_JOB_PAGE));
169
- if (leased.length === 0) break;
170
- // A row the target already refused is tried once per drain: handed straight back, spending
171
- // no budget and adding no second failure for the same id.
172
- const refused = new Set(failures.map((failure) => failure.id));
173
- const fresh = leased.filter((record) => !refused.has(record.id));
174
- for (const record of leased) if (refused.has(record.id)) await releaseLease(source, record);
175
- if (fresh.length === 0) break;
176
- budget -= fresh.length;
177
- for (const record of fresh) held.add(record.id);
178
- const before = moved.length;
179
- await transfer(source, target, fresh, found, moved, failures);
180
- // A batch that moved nothing handed its leases back, and the next claim would take the same
181
- // rows to the same refusing target: stop, and let the failures already counted say why.
182
- if (moved.length === before) break;
183
- }
184
- const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
185
- return { ...base, dryRun: false, moved, skipped, failures };
186
- }
187
-
188
- /** One leased batch onto the target: steps, then the job, then the ack — per record. */
189
- async function transfer(
190
- source: JobDriver,
191
- target: JobDriver,
192
- leased: readonly ClaimedJob[],
193
- found: ReadonlyMap<string, JobRecord>,
194
- moved: JobRecord[],
195
- failures: DrainFailure[],
196
- ): Promise<void> {
197
- for (const record of leased) {
198
- let enqueued = false;
199
- try {
200
- // Steps BEFORE the job: a worker on the target must never be able to claim a run whose
201
- // checkpoint has not landed, or it repeats completed steps or loses them outright.
202
- await copySteps(source, target, record.runId);
203
- await target.enqueue({
204
- name: record.name,
205
- queue: record.queue,
206
- input: record.input,
207
- idempotencyKey: record.idempotencyKey,
208
- runId: record.runId,
209
- maxAttempts: record.maxAttempts,
210
- runAt: record.runAt,
211
- ...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
212
- });
213
- enqueued = true;
214
- // Only now is the ack the drain's to make: the lease proves no source worker holds this
215
- // job, and the target already has both the row and its steps. A crash between the two
216
- // leaves the job live on both drivers, where `idempotencyKey` dedupes it.
217
- // `counted: false`: the row is finished HERE because it left, and a job that was moved is
218
- // not a job that completed — it must add nothing to the source's `done` history.
219
- await source.ack(record.id, { ...claimOf(record), counted: false });
220
- // Report the row as the drain FOUND it: `claim()` returns it mid-lease (`running`, one
221
- // attempt higher), a state nothing on either driver is in once this returns.
222
- moved.push(found.get(record.id) ?? record);
223
- } catch (error) {
224
- // A failed enqueue left nothing on the target, so the lease goes back. A failed ack did
225
- // not: the job is already live there, and releasing it would race the target's worker.
226
- if (!enqueued) await releaseLease(source, record);
227
- failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
228
- }
229
- }
230
- }
@@ -1,227 +0,0 @@
1
- // Typechecks what `x new` and `x g` write, the way the user's own `tsc` will: real files on disk,
2
- // the real workspace packages, the real compiler. A template that merely parses has not been
3
- // checked — it has moved its failure from this gate to the first command the user runs.
4
-
5
- // `node:` and not Bun: Bun has no API for a temporary directory (`mkdtempSync` + `tmpdir`), for a
6
- // symlink (`symlinkSync`, how the sandbox borrows the workspace's node_modules), or for a
7
- // recursive delete (`rmSync`). `node:path` comes with them — `Bun.write` takes the joined path,
8
- // but only `resolve`/`sep` can prove that path stayed inside the sandbox.
9
- import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
10
- import { tmpdir } from 'node:os';
11
- import { join, resolve, sep } from 'node:path';
12
- import { ScaffoldPathEscapeError } from './errors';
13
- import type { Runner } from './exec';
14
- import { exec } from './exec';
15
- import { FIXTURE_APP, scaffoldFixture } from './scaffold-fixture';
16
- import type { GeneratedFile } from './templates';
17
-
18
- /** Derived from this file, never from cwd, so the harness works from any working directory. */
19
- export const workspaceRoot = (): string => resolve(import.meta.dir, '..', '..', '..');
20
-
21
- export interface TypeDiagnostic {
22
- /** Sandbox-relative path, or '' for a diagnostic the compiler raised about the project itself. */
23
- readonly file: string;
24
- readonly line: number;
25
- readonly code: string;
26
- readonly message: string;
27
- }
28
-
29
- const AT_FILE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
30
- const PROJECT_WIDE = /^error (TS\d+): (.*)$/;
31
-
32
- /**
33
- * Config errors carry no file, so both shapes are collected: a silent harness is a green lie.
34
- * Split on `\r?\n`, because a trailing `\r` lands inside the message capture and no `KNOWN_GAPS`
35
- * entry would match it — a CRLF `tsc` would turn every pinned gap into an unexplained failure.
36
- */
37
- export function parseDiagnostics(output: string): readonly TypeDiagnostic[] {
38
- const found: TypeDiagnostic[] = [];
39
- for (const line of output.split(/\r?\n/)) {
40
- const at = AT_FILE.exec(line);
41
- if (at !== null) {
42
- found.push({
43
- file: at[1] ?? '',
44
- line: Number.parseInt(at[2] ?? '0', 10),
45
- code: at[4] ?? '',
46
- message: at[5] ?? '',
47
- });
48
- continue;
49
- }
50
- const wide = PROJECT_WIDE.exec(line.trim());
51
- if (wide !== null)
52
- found.push({ file: '', line: 0, code: wide[1] ?? '', message: wide[2] ?? '' });
53
- }
54
- return found;
55
- }
56
-
57
- export interface KnownGap {
58
- /** `ScaffoldVariant.name`. A pin bought by one invocation may not excuse another. */
59
- readonly variant: string;
60
- readonly code: string;
61
- /** Exact sandbox-relative path. A pattern would absolve the same bug in a file nobody pinned. */
62
- readonly file: string;
63
- /** Exact `tsc` message. Matched literally so a near-miss regression is not absorbed. */
64
- readonly message: string;
65
- /** Who fixes it, and why the template cannot. */
66
- readonly owner: string;
67
- }
68
-
69
- /**
70
- * Diagnostics a template cannot fix, pinned one occurrence at a time. Pinned, never ignored:
71
- * `unexpectedIn` fails on anything not listed here — including a second copy of a listed
72
- * diagnostic, because each entry is consumed by exactly one match — and `staleGapsIn` fails when
73
- * a listed entry stops reproducing, so an entry cannot outlive the bug it describes.
74
- *
75
- * Empty, and that is the point: `invariants:` is one callback over the whole list, so `c` is typed
76
- * from the entity's own columns and the six `TS18048 'c.title' is possibly 'undefined'` pins this
77
- * list used to carry are gone. A new entry here is a template shipping a diagnostic, which needs
78
- * an owner who can remove it — not a permanent exemption.
79
- */
80
- export const KNOWN_GAPS: readonly KnownGap[] = [];
81
-
82
- /**
83
- * The pins one invocation may spend. Another variant's pins are not its to spend. `gaps` is a
84
- * parameter for the same reason it is on `unexpectedIn` and `staleGapsIn`: with `KNOWN_GAPS`
85
- * legitimately empty, the bookkeeping is only testable against a list a test supplies.
86
- */
87
- export const gapsFor = (
88
- variant: string,
89
- gaps: readonly KnownGap[] = KNOWN_GAPS,
90
- ): readonly KnownGap[] => gaps.filter((gap) => gap.variant === variant);
91
-
92
- const matches = (diagnostic: TypeDiagnostic, gap: KnownGap): boolean =>
93
- diagnostic.code === gap.code &&
94
- diagnostic.file === gap.file &&
95
- diagnostic.message === gap.message;
96
-
97
- export interface GapPartition {
98
- /** Diagnostics no unconsumed `KNOWN_GAPS` entry accounts for. */
99
- readonly unexpected: readonly TypeDiagnostic[];
100
- /** Entries that found no match — the bug is fixed and the pin has to go. */
101
- readonly stale: readonly KnownGap[];
102
- }
103
-
104
- /**
105
- * One pass, first-fit, each pin spent once. Counting matters: two occurrences of a diagnostic
106
- * pinned once means a new regression is hiding behind an old bug, so the surplus is unexpected.
107
- */
108
- export function partitionDiagnostics(
109
- diagnostics: readonly TypeDiagnostic[],
110
- gaps: readonly KnownGap[] = KNOWN_GAPS,
111
- ): GapPartition {
112
- const budget = gaps.map((gap) => ({ gap, spent: false }));
113
- const unexpected: TypeDiagnostic[] = [];
114
- for (const entry of diagnostics) {
115
- const slot = budget.find((pin) => !pin.spent && matches(entry, pin.gap));
116
- if (slot === undefined) unexpected.push(entry);
117
- else slot.spent = true;
118
- }
119
- return { unexpected, stale: budget.filter((pin) => !pin.spent).map((pin) => pin.gap) };
120
- }
121
-
122
- /** Everything the gate refuses: a diagnostic no unconsumed `KNOWN_GAPS` entry accounts for. */
123
- export const unexpectedIn = (
124
- diagnostics: readonly TypeDiagnostic[],
125
- gaps: readonly KnownGap[] = KNOWN_GAPS,
126
- ): readonly TypeDiagnostic[] => partitionDiagnostics(diagnostics, gaps).unexpected;
127
-
128
- /** Entries that no longer reproduce — the bug is fixed and the pin has to go. */
129
- export const staleGapsIn = (
130
- diagnostics: readonly TypeDiagnostic[],
131
- gaps: readonly KnownGap[] = KNOWN_GAPS,
132
- ): readonly KnownGap[] => partitionDiagnostics(diagnostics, gaps).stale;
133
-
134
- /** Written beside the app's own tsconfig so the gate inherits every flag the app ships with. */
135
- const OVERLAY = 'tsconfig.scaffold-check.json';
136
-
137
- /**
138
- * The one thing the sandbox may change about the generated project: where its imports resolve.
139
- * `@ultimat3/*` points at workspace source instead of a published tarball, and the app's own
140
- * workspace packages resolve without a `bun install` that a sealed test could never run.
141
- */
142
- const overlay = (root: string, app: string): string =>
143
- `${JSON.stringify(
144
- {
145
- extends: './tsconfig.json',
146
- compilerOptions: {
147
- noEmit: true,
148
- paths: {
149
- // The subpath exports a generated app reaches for, spelled the way the packages' own
150
- // `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a package
151
- // name, and `cli/serve` as `packages/cli/serve`. Longest prefix wins, so these are
152
- // consulted first. `@ultimat3/cli/serve` is what `x new`'s `apps/web/server.ts` imports.
153
- '@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
154
- '@ultimat3/query/client': [`${root}/packages/query/src/client`],
155
- '@ultimat3/render/server': [`${root}/packages/render/src/server`],
156
- '@ultimat3/cli/serve': [`${root}/packages/cli/src/serve-entry`],
157
- '@ultimat3/testing/test-types': [`${root}/packages/testing/src/test-types`],
158
- '@ultimat3/*': [`${root}/packages/*/src`],
159
- [`@${app}/web/*`]: ['./apps/web/*'],
160
- [`@${app}/admin/*`]: ['./apps/admin/*'],
161
- [`@${app}/*`]: ['./packages/*/src'],
162
- },
163
- },
164
- },
165
- null,
166
- 2,
167
- )}\n`;
168
-
169
- export interface TypecheckOptions {
170
- readonly files?: readonly GeneratedFile[];
171
- readonly app?: string;
172
- readonly runner?: Runner;
173
- /** Leave the sandbox on disk. For debugging a red gate by hand, never for the gate itself. */
174
- readonly keep?: boolean;
175
- }
176
-
177
- export interface TypecheckReport {
178
- readonly dir: string;
179
- readonly fileCount: number;
180
- readonly diagnostics: readonly TypeDiagnostic[];
181
- readonly output: string;
182
- }
183
-
184
- /**
185
- * `GeneratedFile.path` is documented as relative-POSIX, not enforced as it. A `..` segment would
186
- * put template output on the developer's real disk, so the sandbox proves containment before it
187
- * writes rather than after.
188
- */
189
- export function sandboxPath(dir: string, path: string): string {
190
- const target = resolve(dir, path);
191
- if (target !== dir && !target.startsWith(`${dir}${sep}`))
192
- throw new ScaffoldPathEscapeError({ path, dir });
193
- return target;
194
- }
195
-
196
- export async function typecheckScaffold(options: TypecheckOptions = {}): Promise<TypecheckReport> {
197
- const root = workspaceRoot();
198
- const app = options.app ?? FIXTURE_APP;
199
- const files = options.files ?? scaffoldFixture();
200
- const dir = mkdtempSync(join(tmpdir(), 'x-scaffold-'));
201
- try {
202
- for (const file of files) await Bun.write(sandboxPath(dir, file.path), file.contents);
203
- // The sandbox borrows the workspace's installed dependencies. The gate is about the
204
- // templates; whether a registry install succeeds is a different question, and a sealed
205
- // test cannot ask it.
206
- symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'dir');
207
- await Bun.write(join(dir, OVERLAY), overlay(root, app));
208
- const result = await (options.runner ?? exec)(
209
- [join(root, 'node_modules', '.bin', 'tsc'), '--noEmit', '--pretty', 'false', '-p', OVERLAY],
210
- { cwd: dir },
211
- );
212
- const output = [result.stdout, result.stderr].filter((part) => part.length > 0).join('\n');
213
- return { dir, fileCount: files.length, diagnostics: parseDiagnostics(output), output };
214
- } finally {
215
- if (options.keep !== true) rmSync(dir, { recursive: true, force: true });
216
- }
217
- }
218
-
219
- /** One diagnostic per line, in the shape `tsc` prints — a failed gate is a runnable bug report. */
220
- export const formatDiagnostics = (diagnostics: readonly TypeDiagnostic[]): string =>
221
- diagnostics
222
- .map((entry) =>
223
- entry.file === ''
224
- ? `error ${entry.code}: ${entry.message}`
225
- : `${entry.file}:${entry.line} ${entry.code}: ${entry.message}`,
226
- )
227
- .join('\n');
package/src/thrown-by.ts DELETED
@@ -1,24 +0,0 @@
1
- // One `thrownBy` for every test in this package. What a caller acts on is the thrown *shape* —
2
- // the code, the cause and the fix — never the class, so a test that asserts on the class would
3
- // pass while the fix line rots. Shared rather than copied: a second copy asserts less, silently.
4
-
5
- import { expect } from 'bun:test';
6
-
7
- /** The three fields every `UltimateError` carries, as a test reads them off the thrown value. */
8
- export interface ThrownShape {
9
- readonly code?: string;
10
- readonly cause?: string;
11
- readonly fix?: string;
12
- }
13
-
14
- export function thrownBy(call: () => unknown): ThrownShape {
15
- try {
16
- call();
17
- } catch (error) {
18
- return error as ThrownShape;
19
- }
20
- // Not `throw new Error(...)`: a bare throw carries no code and no fix, and this is the one
21
- // failure a test author most needs named. `expect.unreachable` fails through the runner, which
22
- // prints the assertion the caller wrote instead of a stack from inside this helper.
23
- return expect.unreachable('expected a throw');
24
- }