@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.
- package/CLAUDE.md +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- 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 {
|
|
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
|
};
|
package/src/cmd-generate.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
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 {
|
|
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> {
|