@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
@@ -0,0 +1,95 @@
1
+ // A generator name whose type spelling the emitted code ALSO uses as a global or a local alias:
2
+ // `x g resource promise` imports `type { Promise }` beside `Promise<Row>`, and `row` writes
3
+ // `type Row = Row`. Decided on the planned files themselves, so a template that starts using
4
+ // another global is covered the day it does — and the names that clash nowhere stay allowed.
5
+ // The same for a VALUE: `x g job job` imports `{ job }` from `@ultimat3/jobs` and declares
6
+ // `export const job = job(…)` beside it, which is TS2440 in the module the run just wrote.
7
+
8
+ import { BadFlagError } from './errors';
9
+ import type { Generator } from './generate-kinds';
10
+ import type { GeneratedFile } from './templates';
11
+ import { camel, kebab, names } from './templates/naming';
12
+
13
+ /** The emitted file that imports `type` from the slice AND uses the same name as its own. */
14
+ function shadowingFile(files: readonly GeneratedFile[], type: string): string | undefined {
15
+ // A pascal spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
16
+ const imported = new RegExp(
17
+ `import\\s+(?:type\\s+)?\\{[^}]*\\b${type}\\b[^}]*\\}\\s+from\\s+'\\.`,
18
+ );
19
+ const reused = new RegExp(
20
+ `\\b${type}<|^\\s*(?:export\\s+)?(?:type\\s+${type}\\s*=|interface\\s+${type}\\b)`,
21
+ 'm',
22
+ );
23
+ return files.find(
24
+ (file) =>
25
+ typeof file.contents === 'string' &&
26
+ /\.tsx?$/.test(file.path) &&
27
+ imported.test(file.contents) &&
28
+ reused.test(file.contents),
29
+ )?.path;
30
+ }
31
+
32
+ /** Refuses `name` when its type spelling would shadow a name the planned files rely on. */
33
+ export function refuseShadowedTypes(
34
+ files: readonly GeneratedFile[],
35
+ kind: Generator,
36
+ name: string,
37
+ ): void {
38
+ const type = names(name).pascal;
39
+ const file = shadowingFile(files, type);
40
+ if (file === undefined) return;
41
+ const noun = kind.split(':').at(-1) ?? kind;
42
+ throw new BadFlagError({
43
+ flag: 'name',
44
+ command: `g ${kind}`,
45
+ reason: `"${name}" becomes the type ${type}, and ${file} imports it beside its own use of ${type} — the global or local type the template means is shadowed and the file does not compile`,
46
+ fix: ['x g', kind, `${kebab(name)}-${noun}`].join(' '),
47
+ });
48
+ }
49
+
50
+ /** A `{ … }` value import's local names — `type` specifiers and `import type` lines excluded. */
51
+ const VALUE_IMPORT = /^import\s+\{([^}]*)\}\s+from\s+'[^']+';/gm;
52
+
53
+ const importedValues = (source: string): readonly string[] =>
54
+ [...source.matchAll(VALUE_IMPORT)].flatMap((match) =>
55
+ (match[1] ?? '')
56
+ .split(',')
57
+ .map((specifier) => specifier.trim())
58
+ .filter((specifier) => specifier !== '' && !specifier.startsWith('type '))
59
+ .map((specifier) => specifier.split(/\s+as\s+/).at(-1) ?? specifier),
60
+ );
61
+
62
+ /** The emitted file that imports `value` AND declares a binding of the same name. */
63
+ function redeclaringFile(files: readonly GeneratedFile[], value: string): string | undefined {
64
+ // A camel spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
65
+ const declared = new RegExp(
66
+ `^\\s*(?:export\\s+)?(?:const|let|var|function|class)\\s+${value}\\b`,
67
+ 'm',
68
+ );
69
+ return files.find(
70
+ (file) =>
71
+ typeof file.contents === 'string' &&
72
+ /\.tsx?$/.test(file.path) &&
73
+ declared.test(file.contents) &&
74
+ importedValues(file.contents).includes(value),
75
+ )?.path;
76
+ }
77
+
78
+ /** Refuses `name` when its camel spelling is a value a planned file both imports and declares. */
79
+ export function refuseShadowedValues(
80
+ files: readonly GeneratedFile[],
81
+ kind: Generator,
82
+ name: string,
83
+ ): void {
84
+ const value = camel(name);
85
+ if (value === '') return;
86
+ const file = redeclaringFile(files, value);
87
+ if (file === undefined) return;
88
+ const noun = kind.split(':').at(-1) ?? kind;
89
+ throw new BadFlagError({
90
+ flag: 'name',
91
+ command: `g ${kind}`,
92
+ reason: `"${name}" becomes the binding ${value}, and ${file} imports ${value} and declares its own — a module that declares a name it imports does not compile (TS2440)`,
93
+ fix: ['x g', kind, `${kebab(name)}-${noun}`].join(' '),
94
+ });
95
+ }
@@ -102,7 +102,7 @@ export function containedPath(root: string, path: string): string {
102
102
  dir: base,
103
103
  // Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
104
104
  // rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
105
- fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
105
+ fix: `x g resource post --dry-run # name every file relative to the app root, no ".." segment`,
106
106
  });
107
107
  return target;
108
108
  }
@@ -198,14 +198,56 @@ function planFile(
198
198
  return { kind: 'write', file, absolute, contents: file.contents };
199
199
  }
200
200
 
201
+ /** Every file's plan, decided without touching the disk — the first of `writeFiles`' two passes. */
202
+ async function planAll(
203
+ root: string,
204
+ files: readonly GeneratedFile[],
205
+ force: boolean,
206
+ invocation: string,
207
+ ): Promise<readonly WritePlan[]> {
208
+ const plans: WritePlan[] = [];
209
+ for (const file of files) {
210
+ const absolute = containedPath(root, file.path);
211
+ plans.push(
212
+ file.merge === 'json'
213
+ ? await planJsonMerge(file, absolute)
214
+ : planFile(file, absolute, force, invocation),
215
+ );
216
+ }
217
+ return plans;
218
+ }
219
+
220
+ const conflictsOf = (plans: readonly WritePlan[]): readonly Finding[] =>
221
+ plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
222
+
223
+ /**
224
+ * What `writeFiles` WOULD do, and nothing else: the paths it would write and the conflicts that
225
+ * would stop it. `x g --dry-run` answers with this — the bare file list said "would write 33"
226
+ * where the real run was `X_GENERATE_CONFLICT`, and listed slice modules the run skips as writes.
227
+ */
228
+ export async function planWrites(
229
+ root: string,
230
+ files: readonly GeneratedFile[],
231
+ force: boolean,
232
+ invocation = 'x g <kind> <name>',
233
+ ): Promise<WriteReport> {
234
+ const plans = await planAll(root, files, force, invocation);
235
+ const conflicts = conflictsOf(plans);
236
+ if (conflicts.length > 0) return { written: [], conflicts };
237
+ return {
238
+ written: plans.flatMap((plan) => (plan.kind === 'write' ? [plan.file.path] : [])),
239
+ conflicts: [],
240
+ };
241
+ }
242
+
201
243
  /**
202
244
  * Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
203
245
  * twice; a generator that lands four of seven files and then reports a conflict is worse, because
204
246
  * the next run conflicts on the files the failed one wrote.
205
247
  *
206
248
  * Two passes, and the split is the point: the first decides — containment, existence, whether a
207
- * catalog can be merged into — and touches nothing, the second writes only when the first found
208
- * no conflict at all. Containment was already proven up front and the rest was not.
249
+ * catalog can be merged into — and touches nothing (`planWrites` is that pass alone), the second
250
+ * writes only when the first found no conflict at all.
209
251
  */
210
252
  export async function writeFiles(
211
253
  root: string,
@@ -218,16 +260,8 @@ export async function writeFiles(
218
260
  */
219
261
  invocation = 'x g <kind> <name>',
220
262
  ): Promise<WriteReport> {
221
- const plans: WritePlan[] = [];
222
- for (const file of files) {
223
- const absolute = containedPath(root, file.path);
224
- plans.push(
225
- file.merge === 'json'
226
- ? await planJsonMerge(file, absolute)
227
- : planFile(file, absolute, force, invocation),
228
- );
229
- }
230
- const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
263
+ const plans = await planAll(root, files, force, invocation);
264
+ const conflicts = conflictsOf(plans);
231
265
  if (conflicts.length > 0) return { written: [], conflicts };
232
266
 
233
267
  const written: string[] = [];
package/src/gh-target.ts CHANGED
@@ -76,18 +76,23 @@ const PR_VIEW = t.object({ number: t.number });
76
76
  * The pull request for the current branch. gh exits non-zero when there is none, with a message
77
77
  * that names the branch — so the refusal keeps gh's own sentence and adds the remedy gh has no
78
78
  * opinion about: name the number, or open the PR.
79
+ *
80
+ * No `--repo`: gh refuses `pr view --repo <slug>` without a number, url or branch before any
81
+ * network call, so that argv could never succeed. The checkout-scoped form resolves the branch's
82
+ * tracking remote itself — the same resolution `gh repo view` gave `resolveRepo`. `repo` names the
83
+ * pull request in the refusal only.
79
84
  */
80
85
  export async function resolvePrNumber(host: GhHost, repo: GhRepo): Promise<number> {
81
86
  try {
82
- const viewed = await ghJson(
83
- host,
84
- ['pr', 'view', '--repo', repo.slug, '--json', 'number'],
85
- PR_VIEW,
86
- { label: 'gh pr view', fix: 'x pr review --pr 241 --json' },
87
- );
87
+ const viewed = await ghJson(host, ['pr', 'view', '--json', 'number'], PR_VIEW, {
88
+ label: 'gh pr view',
89
+ fix: 'x pr review --pr 241 --json',
90
+ });
88
91
  return viewed.number;
89
92
  } catch (error) {
90
- if (error instanceof GhFailedError) throw new PrNotFoundError({ detail: error.cause });
93
+ if (error instanceof GhFailedError) {
94
+ throw new PrNotFoundError({ detail: `${repo.slug}: ${error.cause}` });
95
+ }
91
96
  throw error;
92
97
  }
93
98
  }
package/src/hold.ts CHANGED
@@ -48,6 +48,18 @@ export function holdUntilShutdown(
48
48
  release: () => Promise<void>,
49
49
  options: HoldOptions = {},
50
50
  ): () => Promise<void> {
51
+ return armHold(name, release, options).hold;
52
+ }
53
+
54
+ /** A hold whose handlers are already listening, and the way to give them back unheld. */
55
+ interface ArmedHold {
56
+ readonly hold: () => Promise<void>;
57
+ /** Uninstall the handlers and the hook without waiting for any shutdown — a boot that failed. */
58
+ readonly disarm: () => void;
59
+ }
60
+
61
+ /** The signal handlers and the `accept` hook, installed NOW; the release waits for `hold()`. */
62
+ function armHold(name: string, release: () => Promise<void>, options: HoldOptions): ArmedHold {
51
63
  const uninstall = installSignalHandlers({ exit: false });
52
64
  let unregister = (): void => {};
53
65
  // The hook's own `reason`, not a stopwatch of ours: `deadlineAt` is the instant core computed
@@ -64,7 +76,7 @@ export function holdUntilShutdown(
64
76
  });
65
77
 
66
78
  let held: Promise<void> | undefined;
67
- return () => {
79
+ const hold = (): Promise<void> => {
68
80
  // Memoised: awaiting a hold twice must not release twice, and `dispatch` is not the only
69
81
  // caller a test can be.
70
82
  held ??= (async () => {
@@ -79,6 +91,49 @@ export function holdUntilShutdown(
79
91
  })();
80
92
  return held;
81
93
  };
94
+ return {
95
+ hold,
96
+ disarm: () => {
97
+ unregister();
98
+ uninstall();
99
+ },
100
+ };
101
+ }
102
+
103
+ /** What a boot answers once it is up: the app, and the hold that keeps the process on it. */
104
+ export interface BootedHold<T> {
105
+ readonly app: T;
106
+ readonly hold: () => Promise<void>;
107
+ }
108
+
109
+ /**
110
+ * Boot under the drain's handlers, never ahead of them. `runRole` used to install them after the
111
+ * boot returned, while `startRoles` starts the worker claiming in the middle of it — so a SIGTERM
112
+ * landing between the two (a rollout replacing a pod still booting) met Bun's default and ended a
113
+ * process holding claimed jobs, each one stranded for a whole visibility timeout. Armed first, the
114
+ * same signal is a drain: whatever the boot has started hears core's phases (the worker stops
115
+ * claiming and hands back at its cut-off), and the release — the app's own `stop()` — runs once
116
+ * the boot has finished, so nothing it acquired after the signal is left running.
117
+ *
118
+ * A boot that throws gives the handlers back and rethrows its own error: the refusal is the
119
+ * finding, and handlers left installed would turn the next signal into a drain of nothing.
120
+ */
121
+ export async function holdWhileBooting<T extends { stop(): Promise<void> }>(
122
+ name: string,
123
+ boot: () => Promise<T>,
124
+ options: HoldOptions = {},
125
+ ): Promise<BootedHold<T>> {
126
+ let booted: T | undefined;
127
+ const armed = armHold(name, async () => await booted?.stop(), options);
128
+ let app: T;
129
+ try {
130
+ app = await boot();
131
+ } catch (error) {
132
+ armed.disarm();
133
+ throw error;
134
+ }
135
+ booted = app;
136
+ return { app, hold: armed.hold };
82
137
  }
83
138
 
84
139
  /**
package/src/i18n-audit.ts CHANGED
@@ -4,22 +4,23 @@
4
4
 
5
5
  // `node:` and not Bun: Bun exposes no existence check (`existsSync`, which is how a missing
6
6
  // `catalogs/` directory reads as "nothing shipped" instead of a throw) and no path API at all —
7
- // `join` builds the absolute path `Bun.file` reads, and `relative`/`sep` turn it back into the
7
+ // `join` builds the absolute path `Bun.file` reads; `posixRelative` turns it back into the
8
8
  // root-relative POSIX shape every CLI-reported path is keyed by.
9
9
  import { existsSync } from 'node:fs';
10
- import { join, relative, sep } from 'node:path';
11
- import { renderThrowable } from '@ultimat3/core';
10
+ import { join } from 'node:path';
11
+ import { DEFAULT_LOCALE, renderThrowable } from '@ultimat3/core';
12
12
  import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
13
13
  import {
14
14
  auditCatalogs,
15
15
  catalogInvalid,
16
16
  catalogKeys,
17
- DEFAULT_LOCALE,
18
17
  extractFromFiles,
19
18
  loadCatalog,
20
19
  missingFrom,
21
20
  nestCatalog,
22
21
  } from '@ultimat3/i18n';
22
+ import { isLoudMiss, loudMiss } from './i18n-registration';
23
+ import { posixRelative } from './posix-path';
23
24
  import { eachSourceFile, isTest } from './source-files';
24
25
  import { CATALOG_ROOT, catalogPath } from './templates/locales';
25
26
 
@@ -28,16 +29,19 @@ import { CATALOG_ROOT, catalogPath } from './templates/locales';
28
29
  * glob set every text-scanning gate step already shares (`errors`, `filesize`) — a second glob
29
30
  * here would mean this command and `x verify` disagree on what "the app's source" is. Test files
30
31
  * are excluded: a fixture's `t('fixture.key')` is not a gap the shipped catalogs owe an answer to.
32
+ * So are `guards/`: gate tooling renders nothing, and its fix lines quote `t('…')` as an example.
31
33
  * `extractFromFiles` reads by absolute path, so its `file` label is rewritten back to the same
32
34
  * root-relative POSIX shape `app-load.ts` uses for every other CLI-reported path.
33
35
  */
36
+ const isGuard = (file: string): boolean => file.startsWith('guards/');
37
+
34
38
  export async function scanSource(root: string): Promise<Extraction> {
35
39
  const files: string[] = [];
36
40
  for await (const file of eachSourceFile(root)) {
37
- if (!isTest(file)) files.push(file);
41
+ if (!isTest(file) && !isGuard(file)) files.push(file);
38
42
  }
39
43
  const extraction = await extractFromFiles(files.map((file) => join(root, file)));
40
- const toRelative = (file: string): string => relative(root, file).split(sep).join('/');
44
+ const toRelative = (file: string): string => posixRelative(root, file);
41
45
  return {
42
46
  usages: extraction.usages.map((usage) => ({ ...usage, file: toRelative(usage.file) })),
43
47
  dynamic: extraction.dynamic.map((entry) => ({ ...entry, file: toRelative(entry.file) })),
@@ -182,11 +186,23 @@ function sortCatalog(catalog: Catalog): Catalog {
182
186
  }
183
187
 
184
188
  /**
185
- * `x i18n add`'s seed: every key the default locale defines, values copied verbatim — an
186
- * untranslated string that renders is strictly better than a missing key that renders `⟦key⟧`.
189
+ * `x i18n add`'s seed, and `x i18n sync`'s source on a real merge: every key the default locale
190
+ * defines, each value copied INSIDE the CLI's
191
+ * one placeholder marker — `⟦Hello {name}⟧`. A bare copy rendered the default locale's string to
192
+ * every `fr` reader under a green gate, an untranslated catalog no audit could tell from a
193
+ * finished one. Marked, the source text still sits where the translation goes, the page shows the
194
+ * brackets, and `withPlaceholdersMissing` counts every key as missing (`X_CATALOG_MISSING_KEYS`)
195
+ * until a human replaces it. `loudMiss` is the marker's one spelling — a second one would be a
196
+ * placeholder this command writes and the gate cannot see.
187
197
  */
188
198
  export function seedCatalog(source: Catalog): Catalog {
189
- return sortCatalog(source);
199
+ const marked: Record<string, string> = {};
200
+ for (const key of catalogKeys(source)) {
201
+ const value = source[key];
202
+ // A value already a placeholder stays one, as written — never `⟦⟦key⟧⟧`.
203
+ if (value !== undefined) marked[key] = isLoudMiss(value) ? value : loudMiss(value);
204
+ }
205
+ return marked;
190
206
  }
191
207
 
192
208
  export interface SyncResult {
package/src/i18n-index.ts CHANGED
@@ -9,12 +9,11 @@
9
9
  // why: Bun has no synchronous existence check — `Bun.file(p).exists()` is async, and this decides
10
10
  // whether to write at all, before any await the caller could interleave with.
11
11
  import { existsSync } from 'node:fs';
12
+ import { APP_CATALOGS_PATH } from '@ultimat3/i18n/app-catalogs';
12
13
  import { containedPath } from './generate-write';
13
14
  import type { Finding } from './output';
14
15
  import { CATALOG_ROOT, i18nIndex, localeEntry, localeImport } from './templates';
15
16
 
16
- export const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
17
-
18
17
  /** Every locale with a catalog on disk, sorted — the file names are the tags. */
19
18
  export async function catalogLocales(root: string): Promise<readonly string[]> {
20
19
  const catalogDir = containedPath(root, CATALOG_ROOT);
@@ -50,7 +49,7 @@ const importedLocales = (source: string): readonly string[] =>
50
49
  * overwritten: it was, on every `x g`, with a template hard-coding `default: 'en'`.
51
50
  */
52
51
  export async function syncI18nIndex(root: string): Promise<IndexSync> {
53
- const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
52
+ const indexAbsolute = containedPath(root, APP_CATALOGS_PATH);
54
53
  if (!existsSync(indexAbsolute)) return { registered: false, findings: [] };
55
54
  const current = await Bun.file(indexAbsolute).text();
56
55
  const onDisk = await catalogLocales(root);
@@ -83,7 +82,7 @@ function withLocales(source: string, locales: readonly string[]): string | undef
83
82
 
84
83
  const refusal = (locales: readonly string[]): Finding => ({
85
84
  code: 'X_CATALOG_UNREGISTERED',
86
- cause: `${I18N_INDEX_PATH} is hand-written in a shape this writer cannot add ${locales.join(', ')} to, so those catalogs are on disk and not selectable`,
87
- fix: `edit ${I18N_INDEX_PATH} — add ${locales.map(localeImport).join(' ')} and ${locales.map(localeEntry).join(', ')} to the locales passed to defineCatalogs`,
88
- at: I18N_INDEX_PATH,
85
+ cause: `${APP_CATALOGS_PATH} is hand-written in a shape this writer cannot add ${locales.join(', ')} to, so those catalogs are on disk and not selectable`,
86
+ fix: `edit ${APP_CATALOGS_PATH} — add ${locales.map(localeImport).join(' ')} and ${locales.map(localeEntry).join(', ')} to the locales passed to defineCatalogs`,
87
+ at: APP_CATALOGS_PATH,
89
88
  });
@@ -18,12 +18,13 @@ import {
18
18
  pluralVariantsOf,
19
19
  registeredLocales,
20
20
  } from '@ultimat3/i18n';
21
+ import { APP_CATALOGS_PATH } from '@ultimat3/i18n/app-catalogs';
21
22
  import { mountedAdminKeys } from './admin-catalog-keys';
23
+ import type { LoadedApp } from './app-load';
22
24
  import { loadApp } from './app-load';
23
25
  import type { DuplicateProbe } from './duplicate-packages';
24
26
  import { duplicateCause, duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
25
27
  import { auditApp } from './i18n-audit';
26
- import { I18N_INDEX_PATH } from './i18n-index';
27
28
  import type { Finding } from './output';
28
29
  import { findingFrom } from './output';
29
30
  import { CATALOG_ROOT, catalogPath } from './templates/locales';
@@ -37,10 +38,7 @@ const I18N_PKG = '@ultimat3/i18n';
37
38
  * can resolve `@ultimat3/*`. `loadApp` is the production value, and it is the same call
38
39
  * `serveApp` makes: asking a different loader than the server uses would prove nothing.
39
40
  */
40
- export type AppLoader = (root: string) => Promise<{
41
- readonly findings: readonly Finding[];
42
- readonly defaultLocale: string;
43
- }>;
41
+ export type AppLoader = (root: string) => Promise<Pick<LoadedApp, 'findings' | 'locales'>>;
44
42
 
45
43
  export interface RegistrationInput {
46
44
  readonly root: string;
@@ -121,9 +119,9 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
121
119
  let locales = gaps.length;
122
120
 
123
121
  if (Object.keys(input.catalogs).length === 0) {
124
- const unresolved = unresolvedUsedKeys(input, app.defaultLocale);
122
+ const unresolved = unresolvedUsedKeys(input, app.locales.defaultLocale);
125
123
  if (unresolved.length > 0) {
126
- findings.push(findingFrom(catalogsNeverRegistered(app.defaultLocale, unresolved)));
124
+ findings.push(findingFrom(catalogsNeverRegistered(app.locales.defaultLocale, unresolved)));
127
125
  unregistered += unresolved.length;
128
126
  locales += 1;
129
127
  }
@@ -182,7 +180,7 @@ function adminKeysFinding(locale: Locale, missing: readonly string[]): Finding {
182
180
  * the half that knows what an app's directories are.
183
181
  */
184
182
  async function indexSource(root: string): Promise<string | undefined> {
185
- const file = Bun.file(join(root, I18N_INDEX_PATH));
183
+ const file = Bun.file(join(root, APP_CATALOGS_PATH));
186
184
  return (await file.exists()) ? file.text() : undefined;
187
185
  }
188
186
 
@@ -208,7 +206,7 @@ export function unregisteredFix(
208
206
  // report a registered locale as unregistered, which is the direction that costs trust.
209
207
  if (index.includes(`${CATALOG_ROOT.split('/').pop() ?? 'catalogs'}/${locale}.json`)) return {};
210
208
  return {
211
- fix: `x i18n sync ${locale} # re-derives ${I18N_INDEX_PATH} from the catalogs on disk`,
209
+ fix: `x i18n sync ${locale} # re-derives ${APP_CATALOGS_PATH} from the catalogs on disk`,
212
210
  };
213
211
  }
214
212
 
@@ -42,12 +42,36 @@ export function scanRuntimeImports(file: ScannedFile): readonly string[] {
42
42
  .map((entry) => entry.path);
43
43
  }
44
44
 
45
+ /**
46
+ * A type-position `import('…')` — `typeof import('@ultimat3/cli')`, `let x: import('a').B` — is no
47
+ * import statement, so the transpiler erases it whole and neither pass above sees it (K16: the tier
48
+ * rule passed one). Read off `masked`, the source with comments and string CONTENTS blanked and
49
+ * delimiters kept (`@ultimat3/core`'s `maskLiterals`): a match there is code, and the specifier is
50
+ * read back from `source` at the same offsets. Taken as an argument because this file is a leaf.
51
+ * A dynamic `import(x)` is reported too, which the runtime pass already holds — callers dedupe.
52
+ */
53
+ const IMPORT_CALL = /(?<![\w$.])import\s*\(\s*(['"])/g;
54
+
55
+ export function typePositionImports(source: string, masked: string): readonly string[] {
56
+ const found: string[] = [];
57
+ for (const match of masked.matchAll(IMPORT_CALL)) {
58
+ const quote = match[1] as string;
59
+ const open = match.index + match[0].length;
60
+ const close = masked.indexOf(quote, open);
61
+ if (close === -1) continue;
62
+ const specifier = source.slice(open, close);
63
+ if (specifier !== '' && !specifier.includes('\n')) found.push(specifier);
64
+ }
65
+ return found;
66
+ }
67
+
45
68
  /**
46
69
  * Every specifier the file names, type-only ones included. Throws when the file does not parse —
47
70
  * a scan that answered `[]` there would read as a file importing nothing. The rewrite pass alone
48
- * is forgiving: a rewrite the parser refuses falls back to the runtime scan.
71
+ * is forgiving: a rewrite the parser refuses falls back to the runtime scan. Hand it `masked`
72
+ * (core's `maskLiterals(file.source)`) and a type-position `import('…')` is read as well.
49
73
  */
50
- export function scanAllImports(file: ScannedFile): readonly string[] {
74
+ export function scanAllImports(file: ScannedFile, masked?: string): readonly string[] {
51
75
  const runtime = scanRuntimeImports(file);
52
76
  let typed: readonly string[] = [];
53
77
  try {
@@ -58,6 +82,7 @@ export function scanAllImports(file: ScannedFile): readonly string[] {
58
82
  } catch {
59
83
  typed = [];
60
84
  }
85
+ const inTypes = masked === undefined ? [] : typePositionImports(file.source, masked);
61
86
  // The rewritten pass first: it holds every import in SOURCE order, which callers report in.
62
- return [...new Set([...typed, ...runtime])];
87
+ return [...new Set([...typed, ...inTypes, ...runtime])];
63
88
  }
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export { appManifest, writeAppManifest } from './app-manifest';
21
21
  export { requireAppRoot } from './app-root';
22
22
  export type { RouteStats } from './budgets';
23
23
  export { checkBudgets, FRAMEWORK_INLINE_SCRIPTS, FRAMEWORK_SCRIPTS } from './budgets';
24
- export { BUILD_ENTRY, BUILD_TARGETS, readTarget } from './cmd-build';
24
+ export { BUILD_ENTRY, BUILD_TARGETS, BuildEntryMissingError, readTarget } from './cmd-build';
25
25
  export { dbCommand } from './cmd-db';
26
26
  export { planDeploy } from './cmd-deploy';
27
27
  export { startDev } from './cmd-dev';
@@ -69,10 +69,8 @@ export {
69
69
  export { checkErrorCodesThrown } from './error-unthrown';
70
70
  export {
71
71
  BadFlagError,
72
- BuildEntryMissingError,
73
72
  BunVersionError,
74
73
  CatalogExistsError,
75
- CliNotImplementedError,
76
74
  DeclarationUnknownError,
77
75
  ErrorCodeUnknownError,
78
76
  FixTargetUnknownError,
@@ -117,7 +115,6 @@ export { ICON_BASE_PATH, ICON_SOURCE } from './icon-assets';
117
115
  // `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
118
116
  export type { IslandChunk, SharedChunk } from './island-bundle';
119
117
  export { buildIslands } from './island-bundle';
120
- export { JOB_STATES } from './jobs-report';
121
118
  export { renderJobTable } from './jobs-table';
122
119
  export { msg } from './messages';
123
120
  export { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
@@ -125,7 +122,7 @@ export { parseMigrationSql, readMigrations } from './migrations';
125
122
  export type { CommandResult, Finding, JsonValue } from './output';
126
123
  export { exitCodeFor, findingFrom, render, renderFinding, renderHuman } from './output';
127
124
  export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
128
- export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
125
+ export { flagBool, flagList, flagString, GLOBAL_FLAGS, parseArgs } from './parse';
129
126
  export type { PrerenderedPage, PrerenderReport } from './prerender';
130
127
  export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
131
128
  export type { PwaArtifacts } from './pwa-artifacts';
@@ -171,7 +168,6 @@ export { readVerifyFloor, skippedSuiteFinding, VERIFY_FLOOR_FILE } from './verif
171
168
  export type { HostCheck, StepOutcome, VerifyStep, VerifyStepName } from './verify-step';
172
169
  export { VERIFY_STEP_NAMES } from './verify-step';
173
170
  export type { TestType } from './verify-tests';
174
- export { TEST_TYPES } from './verify-tests';
175
171
  export {
176
172
  checkFileSizes,
177
173
  checkLockstep,