@ultimat3/cli 5.0.1 → 7.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 (60) hide show
  1. package/CLAUDE.md +75 -6
  2. package/README.md +2 -2
  3. package/package.json +28 -24
  4. package/src/affected.ts +320 -0
  5. package/src/browser-launcher.ts +109 -0
  6. package/src/ci-log.ts +0 -0
  7. package/src/ci-runs.ts +179 -0
  8. package/src/cmd-affected.ts +109 -0
  9. package/src/cmd-build.ts +36 -3
  10. package/src/cmd-ci.ts +273 -0
  11. package/src/cmd-dev.ts +35 -2
  12. package/src/cmd-generate.ts +16 -348
  13. package/src/cmd-i18n.ts +32 -16
  14. package/src/cmd-pr.ts +308 -0
  15. package/src/cmd-shot.ts +320 -0
  16. package/src/cmd-test.ts +96 -7
  17. package/src/cmd-verify.ts +10 -427
  18. package/src/compile-externals.ts +34 -0
  19. package/src/dev-lock.ts +275 -0
  20. package/src/dev-render.ts +7 -17
  21. package/src/error-codes.ts +18 -0
  22. package/src/generate-files.ts +127 -0
  23. package/src/generate-write.ts +229 -0
  24. package/src/gh-target.ts +118 -0
  25. package/src/gh.ts +204 -0
  26. package/src/i18n-audit.ts +39 -1
  27. package/src/i18n-registration.ts +130 -0
  28. package/src/index.ts +37 -0
  29. package/src/island-bundle.ts +68 -2
  30. package/src/island-solid-production.ts +129 -0
  31. package/src/island-styles.ts +41 -0
  32. package/src/mcp-errors.ts +11 -0
  33. package/src/messages.ts +67 -0
  34. package/src/pr-threads.ts +291 -0
  35. package/src/prerender.ts +52 -10
  36. package/src/registry.ts +8 -0
  37. package/src/shot-verdict.ts +337 -0
  38. package/src/solid-loader.ts +127 -0
  39. package/src/static-report.ts +219 -0
  40. package/src/templates/admin-page.ts +46 -5
  41. package/src/templates/index.ts +1 -0
  42. package/src/templates/island-fixture.ts +76 -0
  43. package/src/templates/island.ts +129 -18
  44. package/src/templates/resource-form-island.ts +279 -0
  45. package/src/templates/resource.ts +52 -43
  46. package/src/templates/route.ts +45 -6
  47. package/src/templates/scaffold-app.ts +70 -19
  48. package/src/templates/scaffold-container.ts +2 -2
  49. package/src/templates/scaffold-db-package.ts +88 -39
  50. package/src/templates/scaffold-docs.ts +18 -1
  51. package/src/templates/scaffold-i18n.ts +9 -2
  52. package/src/templates/scaffold-mcp-package.ts +35 -2
  53. package/src/templates/scaffold-package-shape.ts +7 -2
  54. package/src/templates/scaffold-repo.ts +2 -2
  55. package/src/test-shards.ts +19 -3
  56. package/src/verify-checks.ts +349 -0
  57. package/src/verify-run.ts +122 -0
  58. package/src/verify-step.ts +7 -0
  59. package/src/workspace-graph.ts +241 -0
  60. package/types/babel-modules.d.ts +31 -0
package/src/cmd-dev.ts CHANGED
@@ -23,10 +23,11 @@ import type { CliCommand, CommandContext } from './command';
23
23
  import { assetRoutes } from './dev-assets';
24
24
  import type { DevDashboardInput, DevStatus } from './dev-dashboard';
25
25
  import { devDashboardRoutes, devPanels } from './dev-dashboard';
26
+ import { clearLock, preflight, writeLock } from './dev-lock';
26
27
  import { createStatementLedger } from './dev-n-plus-one';
27
28
  import { appRoutes } from './dev-render';
28
29
  import type { RunningRoles } from './dev-roles';
29
- import { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
30
+ import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
30
31
  import type { RunningServices } from './dev-runtime';
31
32
  import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
32
33
  import type { DevServices } from './dev-services';
@@ -295,6 +296,20 @@ export const devCommand: CliCommand = {
295
296
  DEFAULT_PORT,
296
297
  );
297
298
  const roles = selectRoles(flagString(ctx.args, 'role'));
299
+ // BEFORE anything boots. Both failures this catches were reachable and both reported the wrong
300
+ // thing: a taken port surfaced as X_CLI_UNEXPECTED wrapping "Is port 3000 in use?" with a `fix:`
301
+ // naming `x doctor`, and a second `x dev` on one checkout died later on X_DB_UNAVAILABLE whose
302
+ // `fix:` named `x dev`. Neither is discoverable from the message; both are trivial once the
303
+ // preflight has the state directory and the port in front of it.
304
+ const services = resolveServices(root, ctx.env);
305
+ const { clearedStale } = await preflight({
306
+ stateDir: services.stateDir,
307
+ port,
308
+ // The address the web role will actually bind, never a wider one: probing `0.0.0.0` would
309
+ // refuse a boot that a neighbour on one LAN interface does not actually block.
310
+ hostname: DEV_BINDING.hostname,
311
+ embeddedDb: services.db.mode === 'embedded',
312
+ });
298
313
  const server = await startDev({
299
314
  root,
300
315
  port,
@@ -344,13 +359,23 @@ export const devCommand: CliCommand = {
344
359
  panels: [...server.panels],
345
360
  },
346
361
  lines: [
362
+ // A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
363
+ // finding. First, because it happened before anything else this run reports.
364
+ ...(clearedStale ? [msg('cli.dev.staleLock')] : []),
347
365
  msg('cli.dev.roles', { roles: server.roles.join(', ') }),
348
366
  msg('cli.dev.panels', { panels: server.panels.join(', ') }),
349
367
  msg('cli.dev.manifest', { path: join(root, MANIFEST_FILENAME) }),
350
368
  msg('cli.dev.introspect', { url: `${server.url}/_x` }),
351
369
  ],
352
370
  };
371
+ await writeLock(services.stateDir, {
372
+ pid: process.pid,
373
+ port,
374
+ url: server.url,
375
+ startedAt: new Date().toISOString(),
376
+ });
353
377
  if (ctx.args.flags.get('once') === true) {
378
+ clearLock(services.stateDir);
354
379
  await server.stop();
355
380
  return result;
356
381
  }
@@ -358,6 +383,14 @@ export const devCommand: CliCommand = {
358
383
  // `/_x` stays reachable. Ctrl-C drains the web role through core's phases first and releases
359
384
  // the embedded Postgres, the worker and the watcher after — a hard kill leaves the PGlite
360
385
  // directory locked by a process that no longer exists.
361
- return { ...result, hold: holdUntilShutdown('dev', () => server.stop()) };
386
+ return {
387
+ ...result,
388
+ hold: holdUntilShutdown('dev', async () => {
389
+ // The lock first: a stop() that throws must not leave a file claiming this pid still owns
390
+ // the directory, because the next boot would then refuse for a process that is gone.
391
+ clearLock(services.stateDir);
392
+ await server.stop();
393
+ }),
394
+ };
362
395
  },
363
396
  };
@@ -2,365 +2,29 @@
2
2
  // emits a TODO has moved the work, not done it; every file this writes typechecks, and every
3
3
  // primitive arrives with the test that pins its distant invariants (policy, idempotency, budget).
4
4
 
5
- // `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed inside the
6
- // app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
7
5
  import { existsSync } from 'node:fs';
8
- import { resolve, sep } from 'node:path';
9
6
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
10
7
  import { appManifest, writeAppManifest } from './app-manifest';
11
8
  import { requireAppRoot } from './app-root';
12
9
  import type { CliCommand, CommandContext } from './command';
13
- import {
14
- CliNotImplementedError,
15
- GenerateJsonInvalidError,
16
- ScaffoldPathEscapeError,
17
- } from './errors';
18
- // Re-exported below: `GENERATORS` and `Generator` are imported from this module by the tests, the
19
- // scaffold fixture and `src/index.ts`, and moving where they are declared must not move where they
20
- // are read from.
21
- import type { Generator } from './generate-kinds';
22
- import {
23
- assertSurfaceSupported,
24
- GENERATORS,
25
- readKind,
26
- readName,
27
- readSurface,
28
- } from './generate-kinds';
29
- import { mergeJsonDeep } from './json-merge';
10
+ import { generate } from './generate-files';
11
+ import { GENERATORS, readKind, readName, readSurface } from './generate-kinds';
12
+ import { containedPath, writeFiles } from './generate-write';
13
+ import { resolveCatalogModule } from './i18n-audit';
30
14
  import { msg } from './messages';
31
15
  import type { CommandResult, Finding } from './output';
32
16
  import { flagBool, flagList, flagString } from './parse';
33
- import type { GeneratedFile, Surface } from './templates';
34
- import {
35
- actionFiles,
36
- adminPageFiles,
37
- backfillFiles,
38
- CATALOG_ROOT,
39
- entityFiles,
40
- guardFiles,
41
- i18nIndex,
42
- islandFiles,
43
- jobFiles,
44
- kebab,
45
- policyFiles,
46
- queryFiles,
47
- resolveLocales,
48
- resourceFiles,
49
- routeFiles,
50
- taskFiles,
51
- } from './templates';
17
+ import { CATALOG_ROOT, i18nIndex, resolveLocales } from './templates';
52
18
 
19
+ // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
20
+ // fixture reach the kinds, the pure file list and the writer through this module, and a second path
21
+ // to any of them would be the ambiguity axiom 1 forbids.
22
+ export type { GenerateOptions } from './generate-files';
23
+ export { generate } from './generate-files';
53
24
  export type { Generator } from './generate-kinds';
54
25
  export { GENERATORS } from './generate-kinds';
55
-
56
- export interface GenerateOptions {
57
- readonly kind: Generator;
58
- readonly name: string;
59
- readonly feature?: string;
60
- readonly surface?: Surface;
61
- readonly live?: boolean;
62
- /** `resource` only: also emit the per-entity admin override. */
63
- readonly admin?: boolean;
64
- /** Every locale a generated i18n catalog entry ships for. Defaults to `['en']`. */
65
- readonly locales?: readonly string[];
66
- /**
67
- * `island` and `admin:page`: the directory the generated files land in. Named rather than
68
- * derived, because neither destination is derivable — `X_ISLAND_INVALID`'s cause already holds
69
- * the path a page's `src` resolved to, and an app's admin is wherever its `defineAdmin` is.
70
- */
71
- readonly at?: string;
72
- /** `admin:page` only: the permission the page's own work needs, on top of `admin:read`. */
73
- readonly permission?: string;
74
- }
75
-
76
- const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
77
- site: 'apps/web/site',
78
- app: 'apps/web/app',
79
- };
80
-
81
- /** `undefined` when `text` does not parse as a JSON object — the one shape every catalog, whether
82
- * generated or hand-edited on disk, must hold. */
83
- function parseJsonObject(text: string): Record<string, unknown> | undefined {
84
- let parsed: unknown;
85
- try {
86
- parsed = JSON.parse(text);
87
- } catch {
88
- return undefined;
89
- }
90
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
91
- ? (parsed as Record<string, unknown>)
92
- : undefined;
93
- }
94
-
95
- /** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
96
- * the keys a run actually changed, never a reordering. */
97
- function prettyJson(value: Record<string, unknown>): string {
98
- const sorted = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
99
- return `${JSON.stringify(sorted, null, 2)}\n`;
100
- }
101
-
102
- /**
103
- * Two generators can legitimately produce the same shared file. A plain file (errors.ts) keeps
104
- * first-write-wins; a `merge: 'json'` catalog instead merges every contributor's keys into one
105
- * file — `resourceFiles` and `routeFiles` both target the same locale's catalog, and a plain
106
- * overwrite would drop whichever generator ran first. Later entries fill keys the earlier one
107
- * lacks; the first occurrence wins a clash, the same rule `writeFiles` applies against the copy
108
- * already on disk. Exported so `x new` (`cmd-new.ts`) and the scaffold fixture resolve a shared
109
- * catalog the identical way — one merge rule, not three hand-copied ones.
110
- *
111
- * A `merge: 'json'` file's `contents` are the generator's own output, not user data — one that
112
- * fails to parse as a JSON object is a bug in the template that produced it, so it throws here
113
- * rather than being silently treated as `{}` and merged into (or written as) a catalog with
114
- * attribution to nobody. `writeFiles`/`mergeJsonFile` never see a malformed *generated* payload in
115
- * practice: every production caller (`generate()` below, `cmd-new.ts`'s `planNewApp()`, the
116
- * scaffold fixture) runs its file list through this function first.
117
- */
118
- export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[] {
119
- const seen = new Map<string, GeneratedFile>();
120
- for (const file of files) {
121
- if (file.merge === 'json' && parseJsonObject(file.contents) === undefined) {
122
- throw new GenerateJsonInvalidError({ path: file.path });
123
- }
124
- const prior = seen.get(file.path);
125
- if (prior === undefined) {
126
- seen.set(file.path, file);
127
- } else if (prior.merge === 'json' && file.merge === 'json') {
128
- // Both sides already proved parseable above — the fallback only guards a future change to
129
- // that invariant, it never fires today. Deep: two generators contributing to one nested
130
- // catalog share top-level keys (`app`, `admin`), and a shallow spread drops one of them.
131
- const later = parseJsonObject(file.contents) ?? {};
132
- const earlier = parseJsonObject(prior.contents) ?? {};
133
- const { merged } = mergeJsonDeep(earlier, later);
134
- seen.set(file.path, { ...prior, contents: prettyJson(merged) });
135
- }
136
- // else: not mergeable — first write wins, exactly as it always has.
137
- }
138
- return [...seen.values()];
139
- }
140
-
141
- /**
142
- * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
143
- * on them, and nothing has to run a filesystem to review what a generator produces.
144
- */
145
- export function generate(options: GenerateOptions): readonly GeneratedFile[] {
146
- const surface: Surface = options.surface ?? 'app';
147
- assertSurfaceSupported(options.kind, surface, options.name);
148
- const surfaceDir = DEFAULT_SURFACE_DIR[surface];
149
- const feature = options.feature ?? options.name;
150
- const target = { surfaceDir, feature };
151
- switch (options.kind) {
152
- case 'resource':
153
- return dedupe(
154
- resourceFiles(options.name, {
155
- ...target,
156
- admin: options.admin === true,
157
- ...(options.locales === undefined ? {} : { locales: options.locales }),
158
- }),
159
- );
160
- case 'action':
161
- return dedupe(actionFiles(options.name, target));
162
- case 'mutator':
163
- return dedupe(actionFiles(options.name, { ...target, mutator: true }));
164
- case 'backfill':
165
- return dedupe(backfillFiles(options.name, target));
166
- case 'entity':
167
- return dedupe(entityFiles(options.name, target));
168
- case 'policy':
169
- return dedupe(policyFiles(options.name, target));
170
- case 'query':
171
- return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
172
- case 'job':
173
- return dedupe(jobFiles(options.name, target));
174
- case 'task':
175
- return dedupe(taskFiles(options.name, target));
176
- case 'island':
177
- return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
178
- // No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
179
- // guard that lived anywhere else would need an app-side registration to be found.
180
- case 'guard':
181
- return dedupe(guardFiles(options.name));
182
- case 'admin:page':
183
- // A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
184
- return dedupe(
185
- adminPageFiles(options.name, {
186
- permission: options.permission ?? `${kebab(options.name)}:read`,
187
- // The same `--at` `island` takes: an app's admin is wherever its `defineAdmin` is.
188
- ...(options.at === undefined ? {} : { dir: options.at }),
189
- ...(options.locales === undefined ? {} : { locales: options.locales }),
190
- }),
191
- );
192
- case 'route':
193
- // `--locales` reaches the route generator too: its catalog entry is the route's title and
194
- // description, and a locale asked for on the command line is a locale that gets a file.
195
- return dedupe(
196
- routeFiles(options.name, {
197
- surface,
198
- ...(options.locales === undefined ? {} : { locales: options.locales }),
199
- }),
200
- );
201
- default:
202
- throw new CliNotImplementedError({
203
- feature: `generator "${String(options.kind)}"`,
204
- fix: `x g ${GENERATORS.join('|')}`,
205
- });
206
- }
207
- }
208
-
209
- export interface WriteReport {
210
- readonly written: readonly string[];
211
- readonly conflicts: readonly Finding[];
212
- }
213
-
214
- /**
215
- * `GeneratedFile.path` is documented as relative-POSIX, not enforced as it: `join` would happily
216
- * walk out of the app on a `..` segment or ignore the root entirely on an absolute path. Proven
217
- * before the write, once per file, because after the write there is nothing left to prove.
218
- */
219
- export function containedPath(root: string, path: string): string {
220
- const base = resolve(root);
221
- const target = resolve(base, path);
222
- if (target !== base && !target.startsWith(`${base}${sep}`))
223
- // The default `fix` names the scaffold gate's own test, which repairs nothing for someone
224
- // running `x g`: the fix here is the generate command, re-run as a dry run.
225
- throw new ScaffoldPathEscapeError({
226
- path,
227
- dir: base,
228
- // Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
229
- // rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
230
- fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
231
- });
232
- return target;
233
- }
234
-
235
- /**
236
- * A `merge: 'json'` catalog is never a conflict on existence and never subject to `--force`: an
237
- * existing key on disk always wins, because it may hold a human translation, and only genuinely
238
- * new keys are added — so a second, third… generator run keeps growing the same file instead of
239
- * fighting over it. A file that exists but does not parse as a JSON object cannot be merged into
240
- * without risking silent data loss, so that alone is reported rather than clobbered or thrown past.
241
- *
242
- * Typed to the `merge: 'json'` variant alone, not the general `GeneratedFile` union: a
243
- * byte-carrying file has no `contents: string` to merge, and this is what stops one from ever
244
- * reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
245
- * its one call site already applies.
246
- */
247
- async function planJsonMerge(
248
- file: Extract<GeneratedFile, { merge: 'json' }>,
249
- absolute: string,
250
- ): Promise<WritePlan> {
251
- const generated = parseJsonObject(file.contents) ?? {};
252
- if (!existsSync(absolute))
253
- return { kind: 'write', file, absolute, contents: prettyJson(generated) };
254
- const existing = parseJsonObject(await Bun.file(absolute).text());
255
- if (existing === undefined) {
256
- return {
257
- kind: 'conflict',
258
- finding: {
259
- code: 'X_GENERATE_CONFLICT',
260
- cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
261
- fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
262
- docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
263
- at: file.path,
264
- },
265
- };
266
- }
267
- // An existing key wins because it may hold a human translation; only the new keys are added.
268
- // Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
269
- const { merged, gained } = mergeJsonDeep(existing, generated);
270
- // Every key the generator wants is already there — leave the file untouched and unclaimed.
271
- if (!gained) return { kind: 'skip' };
272
- return { kind: 'write', file, absolute, contents: prettyJson(merged) };
273
- }
274
-
275
- /**
276
- * What one generated file would do, decided without doing it. The merge case computes its own
277
- * bytes here rather than at the write, so the two passes below cannot disagree about a file.
278
- */
279
- type WritePlan =
280
- | {
281
- readonly kind: 'write';
282
- readonly file: GeneratedFile;
283
- readonly absolute: string;
284
- readonly contents: string | Uint8Array;
285
- }
286
- | { readonly kind: 'skip' }
287
- | { readonly kind: 'conflict'; readonly finding: Finding };
288
-
289
- function planFile(
290
- file: GeneratedFile,
291
- absolute: string,
292
- force: boolean,
293
- invocation: string,
294
- ): WritePlan {
295
- // A foundation file belongs to the slice, not to the generator that needs it: several generators
296
- // emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
297
- // overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
298
- // `policy.ts` to regenerate one action would delete every rule they wrote. Regenerating a slice
299
- // module is `x g entity|policy`.
300
- if (file.merge === 'if-absent') {
301
- return existsSync(absolute)
302
- ? { kind: 'skip' }
303
- : { kind: 'write', file, absolute, contents: file.contents };
304
- }
305
- if (!force && existsSync(absolute)) {
306
- return {
307
- kind: 'conflict',
308
- finding: {
309
- code: 'X_GENERATE_CONFLICT',
310
- cause: `${file.path} already exists`,
311
- // The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
312
- // when run, and a `fix:` is copied and pasted verbatim. Same construction as
313
- // `generate-kinds.ts`'s `assertSurfaceSupported`.
314
- fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
315
- docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
316
- at: file.path,
317
- },
318
- };
319
- }
320
- return { kind: 'write', file, absolute, contents: file.contents };
321
- }
322
-
323
- /**
324
- * Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
325
- * twice; a generator that lands four of seven files and then reports a conflict is worse, because
326
- * the next run conflicts on the files the failed one wrote.
327
- *
328
- * Two passes, and the split is the point: the first decides — containment, existence, whether a
329
- * catalog can be merged into — and touches nothing, the second writes only when the first found
330
- * no conflict at all. Containment was already proven up front and the rest was not.
331
- */
332
- export async function writeFiles(
333
- root: string,
334
- files: readonly GeneratedFile[],
335
- force: boolean,
336
- /**
337
- * The command line that produced these files, so a conflict's `fix:` can hand it back with
338
- * `--force` on the end. Optional for a caller assembling files itself; the fallback is the
339
- * shape, not a runnable line, and every generator path supplies the real one.
340
- */
341
- invocation = 'x g <kind> <name>',
342
- ): Promise<WriteReport> {
343
- const plans: WritePlan[] = [];
344
- for (const file of files) {
345
- const absolute = containedPath(root, file.path);
346
- plans.push(
347
- file.merge === 'json'
348
- ? await planJsonMerge(file, absolute)
349
- : planFile(file, absolute, force, invocation),
350
- );
351
- }
352
- const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
353
- if (conflicts.length > 0) return { written: [], conflicts };
354
-
355
- const written: string[] = [];
356
- for (const plan of plans) {
357
- if (plan.kind !== 'write') continue;
358
- // Bun.write creates missing parent directories, so a generator never needs an mkdir step.
359
- await Bun.write(plan.absolute, plan.contents);
360
- written.push(plan.file.path);
361
- }
362
- return { written, conflicts: [] };
363
- }
26
+ export type { WriteReport } from './generate-write';
27
+ export { dedupe, writeFiles } from './generate-write';
364
28
 
365
29
  const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
366
30
 
@@ -425,6 +89,9 @@ export const generateCommand: CliCommand = {
425
89
  const locales = resolveLocales(flagList(ctx.args, 'locales'));
426
90
  const at = flagString(ctx.args, 'at');
427
91
  const permission = flagString(ctx.args, 'permission');
92
+ // Read before a file is planned, like the flags above: which module a generated component
93
+ // imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
94
+ const catalogModule = await resolveCatalogModule(root);
428
95
  const files = generate({
429
96
  kind,
430
97
  name,
@@ -435,6 +102,7 @@ export const generateCommand: CliCommand = {
435
102
  live: flagBool(ctx.args, 'live'),
436
103
  admin: flagBool(ctx.args, 'admin'),
437
104
  locales,
105
+ ...(catalogModule === undefined ? {} : { catalogModule }),
438
106
  });
439
107
  if (flagBool(ctx.args, 'dry-run')) {
440
108
  return {
package/src/cmd-i18n.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // would, and `node:path` because Bun exposes no path API to build what either of them takes.
9
9
  import { type FileHandle, mkdir, open } from 'node:fs/promises';
10
10
  import { dirname, join } from 'node:path';
11
- import { catalogKeys, catalogMissingKeys } from '@ultimat3/i18n';
11
+ import { catalogKeys } from '@ultimat3/i18n';
12
12
  import { loadApp } from './app-load';
13
13
  import { requireAppRoot } from './app-root';
14
14
  import type { CliCommand, CommandContext } from './command';
@@ -21,9 +21,9 @@ import {
21
21
  serializeCatalog,
22
22
  syncCatalog,
23
23
  } from './i18n-audit';
24
+ import { checkRegistration, missingKeyFindings } from './i18n-registration';
24
25
  import { msg } from './messages';
25
26
  import type { CommandResult, Finding, JsonValue } from './output';
26
- import { findingFrom } from './output';
27
27
  import { renderTable } from './table';
28
28
  import { catalogPath, resolveLocales } from './templates/locales';
29
29
 
@@ -102,21 +102,24 @@ function resolveOneLocale(ctx: CommandContext, sub: string): string {
102
102
  }
103
103
 
104
104
  async function runCheck(root: string): Promise<CommandResult> {
105
- const { report, catalogs } = await auditApp(root);
106
-
107
- const findings: Finding[] = report.locales
108
- .filter((audit) => audit.missing.length > 0)
109
- .map((audit) => ({
110
- ...findingFrom(catalogMissingKeys(audit.locale, audit.missing)),
111
- at: catalogPath(audit.locale),
112
- }));
113
-
114
- const header = ['locale', 'keys', 'missing', 'unused'];
105
+ const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
106
+ // The runtime question, asked after the file question and never instead of it: a catalog can be
107
+ // complete on disk, used everywhere in source, and reach no registry at all (issue #249).
108
+ const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
109
+
110
+ const findings: Finding[] = [...missingKeyFindings(report), ...registration.findings];
111
+
112
+ // Whether the LOCALE has a catalog is the wrong question — the framework's own `en` is always
113
+ // registered, so a locale-presence column reads `yes` for an app whose every key is a loud miss.
114
+ // The column answers the key-level one: does the registry hold what this file defines?
115
+ const unregistered = new Set(registration.unregisteredLocales);
116
+ const header = ['locale', 'keys', 'missing', 'unused', 'registered'];
115
117
  const rows = report.locales.map((audit) => [
116
118
  audit.locale,
117
119
  String(catalogKeys(catalogs[audit.locale] ?? {}).length),
118
120
  String(audit.missing.length),
119
121
  String(audit.unused.length),
122
+ unregistered.has(audit.locale) ? 'no' : 'yes',
120
123
  ]);
121
124
  const lines = renderTable(header, rows).map((line) => ` ${line}`);
122
125
 
@@ -135,13 +138,26 @@ async function runCheck(root: string): Promise<CommandResult> {
135
138
  }
136
139
  }
137
140
 
138
- const gapLocales = report.locales.filter((audit) => audit.missing.length > 0).length;
139
- const missingTotal = report.locales.reduce((sum, audit) => sum + audit.missing.length, 0);
140
- const summary = report.ok
141
+ // A key that renders a loud miss counts once, whichever half of the check found it: a missing
142
+ // catalog entry and an entry no registry holds are the same `⟦key⟧` on the same page.
143
+ const gapLocales =
144
+ report.locales.filter((audit) => audit.missing.length > 0).length + registration.locales;
145
+ const missingTotal =
146
+ report.locales.reduce((sum, audit) => sum + audit.missing.length, 0) +
147
+ registration.unregistered;
148
+ const ok = report.ok && registration.ok;
149
+ const summary = ok
141
150
  ? msg('cli.i18n.ok', { locales: report.locales.length, keys: report.used.length })
142
151
  : msg('cli.i18n.gaps', { missing: missingTotal, locales: gapLocales });
143
152
 
144
- return { ok: report.ok, command: 'i18n', summary, lines, findings, data: asJson(report) };
153
+ return {
154
+ ok,
155
+ command: 'i18n',
156
+ summary,
157
+ lines,
158
+ findings,
159
+ data: { ...asJson(report), ok, registered: [...registration.registered] },
160
+ };
145
161
  }
146
162
 
147
163
  async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult> {