@ultimat3/cli 28.0.0 → 29.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 CHANGED
@@ -98,7 +98,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
98
98
  | `app-config-load.ts` | the ONE reader of an app's `app.config.ts`: `loadAppConfig(root)` — the `config` export through core's `defineConfig`, so validated and default-merged; `undefined` with no file; no `config` object is `X_CONFIG_INVALID`. Every boot fact (`app-auth.ts`, `theme-boot.ts`, `site-config.ts`, `pwa-artifacts.ts`, …) reads a section off it, and `startServices` loads it once. An `import(` of the config module anywhere else is `X_CONFIG_IMPORT_OUTSIDE_LOADER` (`scripts/lib/config-import.ts`) |
99
99
  | `theme-brand.ts` | the ONE reader of an app's theme: `export const brand` in `apps/web/shared/theme.ts` (ui's `defineTheme`), inlined after the surface stylesheet with its hash in `style-src`; none → nothing emitted |
100
100
  | `runtime-jobs.ts` / `runtime-realtime.ts` / `runtime-notify-retention.ts` / `runtime-cache.ts` / `runtime-purge.ts` / `runtime-replica.ts` | `app.config.ts` sections the boot obeys (`*Of(config)` over the one loaded config), and what each wires |
101
- | `runtime-jobs.ts` (queues) | union of `jobs.queues` + registered queues; `WORKER_QUEUES` set = the EXACT set, `jobs.worker.queue-unserved` names the rest. `jobs.concurrency` passes as written |
101
+ | `runtime-jobs.ts` (queues) | `jobs.queues` ∪ registered queues; `WORKER_QUEUES` = the EXACT set, each entry in that union or `X_WORKER_QUEUE_UNKNOWN`; `queue-unserved` warns the rest |
102
102
  | `serve-retire.ts` / `dev-restart-retire.ts` | the worker retire — `stop()`: claim nothing, abort nothing — on SIGUSR2 under `runRole` (armed before boot, then drain + exit 0; none on win32; the chart's `roles.worker.retireSeconds` preStop sends it), and before a supervised `x dev` restart's drain (#677) |
103
103
  | `role-start.ts` / `role-start-types.ts` / `role-realtime.ts` | `--role` selection and start/stop for `web`, `sync`, `worker`, `scheduler`; `realtime.enabled: false` drops `sync` and `replicator` |
104
104
  | `role-wake.ts` | the worker's LISTEN session (`@ultimat3/jobs`' `startQueueWake`) — worker role only, only over a client that `canListen` — and `queue_wake_live` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "28.0.0",
3
+ "version": "29.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "LICENSE"
31
31
  ],
32
32
  "engines": {
33
- "bun": ">=1.4.3"
33
+ "bun": ">=1.4.4"
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -38,34 +38,34 @@
38
38
  "dev": "bun run src/bin.ts dev"
39
39
  },
40
40
  "dependencies": {
41
- "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "28.0.0",
43
- "@ultimat3/admin": "28.0.0",
44
- "@ultimat3/ai": "28.0.0",
45
- "@ultimat3/auth": "28.0.0",
46
- "@ultimat3/cache": "28.0.0",
47
- "@ultimat3/core": "28.0.0",
48
- "@ultimat3/db": "28.0.0",
49
- "@ultimat3/entity": "28.0.0",
50
- "@ultimat3/flags": "28.0.0",
51
- "@ultimat3/http": "28.0.0",
52
- "@ultimat3/i18n": "28.0.0",
53
- "@ultimat3/jobs": "28.0.0",
54
- "@ultimat3/mail": "28.0.0",
55
- "@ultimat3/manifest": "28.0.0",
56
- "@ultimat3/mcp": "28.0.0",
57
- "@ultimat3/money": "28.0.0",
58
- "@ultimat3/notify": "28.0.0",
59
- "@ultimat3/policy": "28.0.0",
60
- "@ultimat3/pwa": "28.0.0",
61
- "@ultimat3/query": "28.0.0",
62
- "@ultimat3/realtime": "28.0.0",
63
- "@ultimat3/render": "28.0.0",
64
- "@ultimat3/schema": "28.0.0",
65
- "@ultimat3/seo": "28.0.0",
66
- "@ultimat3/storage": "28.0.0",
67
- "@ultimat3/testing": "28.0.0",
68
- "@ultimat3/time": "28.0.0",
69
- "babel-preset-solid": "^1.9.15"
41
+ "@babel/core": "^8.0.7",
42
+ "@ultimat3/action": "29.0.0",
43
+ "@ultimat3/admin": "29.0.0",
44
+ "@ultimat3/ai": "29.0.0",
45
+ "@ultimat3/auth": "29.0.0",
46
+ "@ultimat3/cache": "29.0.0",
47
+ "@ultimat3/core": "29.0.0",
48
+ "@ultimat3/db": "29.0.0",
49
+ "@ultimat3/entity": "29.0.0",
50
+ "@ultimat3/flags": "29.0.0",
51
+ "@ultimat3/http": "29.0.0",
52
+ "@ultimat3/i18n": "29.0.0",
53
+ "@ultimat3/jobs": "29.0.0",
54
+ "@ultimat3/mail": "29.0.0",
55
+ "@ultimat3/manifest": "29.0.0",
56
+ "@ultimat3/mcp": "29.0.0",
57
+ "@ultimat3/money": "29.0.0",
58
+ "@ultimat3/notify": "29.0.0",
59
+ "@ultimat3/policy": "29.0.0",
60
+ "@ultimat3/pwa": "29.0.0",
61
+ "@ultimat3/query": "29.0.0",
62
+ "@ultimat3/realtime": "29.0.0",
63
+ "@ultimat3/render": "29.0.0",
64
+ "@ultimat3/schema": "29.0.0",
65
+ "@ultimat3/seo": "29.0.0",
66
+ "@ultimat3/storage": "29.0.0",
67
+ "@ultimat3/testing": "29.0.0",
68
+ "@ultimat3/time": "29.0.0",
69
+ "babel-preset-solid": "^1.9.16"
70
70
  }
71
71
  }
package/src/app-root.ts CHANGED
@@ -36,7 +36,7 @@ export const MANIFEST_FILE = 'x.manifest.json';
36
36
  * declared supported would hang on graceful shutdown the moment its database went away. The full
37
37
  * measurement is in `.github/actions/setup/action.yml`; read it before lowering this.
38
38
  */
39
- export const REQUIRED_BUN = '1.4.3';
39
+ export const REQUIRED_BUN = '1.4.4';
40
40
 
41
41
  export interface AppRoot {
42
42
  readonly dir: string;
@@ -84,7 +84,16 @@ export function composeStrategies(text: string | undefined): ReadonlyMap<string,
84
84
  }
85
85
  const services = isRecord(parsed) ? parsed['services'] : undefined;
86
86
  if (!isRecord(services)) return new Map();
87
- return new Map(SERVING_ROLES.map((role) => [role, strategyOf(services[role])]));
87
+ const strategies = new Map<string, ComposeStrategy>(
88
+ SERVING_ROLES.map((role) => [role, strategyOf(services[role])]),
89
+ );
90
+ if (Object.hasOwn(services, 'replicator')) {
91
+ strategies.set('replicator', {
92
+ kind: 'stop-first',
93
+ why: 'the replicator is a singleton: the old process must stop before its replacement starts',
94
+ });
95
+ }
96
+ return strategies;
88
97
  }
89
98
 
90
99
  /** The compose file at `path`, or `undefined` when there is none to read. */
@@ -1,6 +1,7 @@
1
1
  // One compose role rolled start-first: the new containers come up beside the old ones, are waited
2
- // on until healthy, and only then are the old ones stopped and removed. Converges on the declared
3
- // replica count, so a rerun after a half-finished deploy finishes it rather than adding replicas.
2
+ // on until healthy, and only then are the old ones stopped and removed, converging on the declared
3
+ // replica count. A replica is the requested version only when compose's config hash AND the image
4
+ // it runs BOTH match: a tag republished under the same name leaves that hash untouched.
4
5
 
5
6
  import type { ExecOptions, Runner } from './exec';
6
7
 
@@ -13,7 +14,13 @@ export const COMPOSE_WAIT_TIMEOUT_SECONDS = 300;
13
14
 
14
15
  /** The label compose stamps on each container: the hash of the service definition it was made from. */
15
16
  const CONFIG_HASH = 'com.docker.compose.config-hash';
16
- const INSPECT_FORMAT = `{{.Id}} {{.Created}} {{index .Config.Labels "${CONFIG_HASH}"}}`;
17
+
18
+ /**
19
+ * One `docker inspect` reads BOTH facts a replica carries: the definition it was made from (the
20
+ * config hash) and the build it runs (the image id). One call, one format string — two calls could
21
+ * describe two different moments, and the pair would be a fiction.
22
+ */
23
+ const INSPECT_FORMAT = `{{.Id}} {{.Created}} {{index .Config.Labels "${CONFIG_HASH}"}} {{.Image}}`;
17
24
 
18
25
  /** The `up` that starts the new replicas beside the running ones; `--no-recreate` keeps the old. */
19
26
  export function startFirstUp(
@@ -63,42 +70,68 @@ const linesOf = (stdout: string): readonly string[] =>
63
70
  const hashOf = (stdout: string): string | undefined => linesOf(stdout)[0]?.split(/\s+/)[1];
64
71
 
65
72
  /**
66
- * The running containers split by the definition they were made from: `current` already runs the
67
- * one being deployed (newest first), `stale` anything else. A container `inspect` did not describe
68
- * is stale — stopping one too many is a restart, keeping one too many is the old build serving.
73
+ * The running containers split by the TWO facts a replica carries: `current` already runs the one
74
+ * being deployed (newest first), `stale` anything else. A container `inspect` did not describe is
75
+ * stale — stopping one too many is a restart, keeping one too many is the old build serving.
76
+ *
77
+ * BOTH facts must match: compose's config hash (the definition) AND the image id (the build). The
78
+ * hash alone is not enough — a tag republished under the same name leaves the compose file, and
79
+ * with it that hash, untouched, so the previous build would hash-match and the deploy would report
80
+ * a rollout it never did.
81
+ *
82
+ * A requested image whose id could not be resolved is UNKNOWN, and unknown is never read as
83
+ * "unchanged": every replica is then stale — the safe direction, the role is rolled and compose
84
+ * fetches the image during the `up`.
69
85
  */
70
86
  function partition(
71
87
  running: readonly string[],
72
88
  inspected: string,
73
89
  want: string,
90
+ wantImage: string | undefined,
74
91
  ): { readonly current: readonly string[]; readonly stale: readonly string[] } {
75
92
  const described = new Map(
76
93
  linesOf(inspected).map((line) => {
77
- const [id = '', created = '', hash = ''] = line.split(/\s+/);
78
- return [id, { created, hash }] as const;
94
+ const [id = '', created = '', hash = '', image = ''] = line.split(/\s+/);
95
+ return [id, { created, hash, image }] as const;
79
96
  }),
80
97
  );
81
98
  const facts = running.map((id) => {
82
99
  const match = [...described].find(([full]) => full === id || full.startsWith(id));
83
- return { id, created: match?.[1].created ?? '', hash: match?.[1].hash };
100
+ return {
101
+ id,
102
+ created: match?.[1].created ?? '',
103
+ hash: match?.[1].hash,
104
+ image: match?.[1].image,
105
+ };
84
106
  });
85
107
  return {
86
108
  current: facts
87
- .filter((fact) => fact.hash === want)
109
+ .filter((fact) => wantImage !== undefined && fact.hash === want && fact.image === wantImage)
88
110
  .sort((a, b) => (a.created < b.created ? 1 : -1))
89
111
  .map((fact) => fact.id),
90
- stale: facts.filter((fact) => fact.hash !== want).map((fact) => fact.id),
112
+ stale: facts
113
+ .filter((fact) => wantImage === undefined || fact.hash !== want || fact.image !== wantImage)
114
+ .map((fact) => fact.id),
91
115
  };
92
116
  }
93
117
 
94
118
  /**
95
- * Start-first for one role. The containers running now are listed and split by compose's config
96
- * hash against the definition being deployed: those already on it are kept (up to `replicas`,
97
- * newest first), and only the shortfall is created BESIDE the rest (`--scale running + shortfall
98
- * --no-recreate`, so compose builds the extra containers from the new definition and leaves the
99
- * others alone). Everything is waited on until its healthcheck — `/readyz` for web and sync, the
100
- * scrape port for the others — answers. Only then are the stale containers and any surplus stopped
101
- * (SIGTERM, then the `stop_grace_period` compose stamped on each as its stop timeout) and removed.
119
+ * Start-first for one role. The containers running now are listed and split against the version
120
+ * being deployed: those already on it — compose's config hash AND the image id they run — are kept
121
+ * (up to `replicas`, newest first), and only the shortfall is created BESIDE the rest (`--scale
122
+ * running + shortfall --no-recreate`, so compose builds the extra containers from the new
123
+ * definition and leaves the others alone). Everything is waited on until its healthcheck — `/readyz`
124
+ * for web and sync, the scrape port for the others — answers. Only then are the stale containers
125
+ * and any surplus stopped (SIGTERM, then the `stop_grace_period` compose stamped on each as its
126
+ * stop timeout) and removed.
127
+ *
128
+ * The image id is half of what "the version being deployed" means, and the hash cannot carry it:
129
+ * a tag republished under the same name leaves the compose file, and with it the config hash,
130
+ * untouched — the hash alone would call the previous build the requested one, the shortfall would
131
+ * be zero, and the deploy would report a rollout it never did. The requested id is the one the
132
+ * deploy was ASKED for (the `--image` reference) resolves to on the daemon; a reference that
133
+ * resolves to nothing is UNKNOWN, and unknown is never read as "unchanged": every container is
134
+ * then stale and the role is rolled — compose fetches the image during the `up`.
102
135
  *
103
136
  * Counting the shortfall rather than `running + replicas` is what makes a rerun safe: after a
104
137
  * deploy that brought the new containers up and then failed to stop the old, running it again
@@ -113,10 +146,12 @@ export async function runStartFirst(step: {
113
146
  readonly role: string;
114
147
  readonly base: readonly string[];
115
148
  readonly replicas: number;
149
+ /** The reference the deploy was asked for — the image the plan reports and compose runs. */
150
+ readonly image: string;
116
151
  readonly runner: Runner;
117
152
  readonly options: ExecOptions;
118
153
  }): Promise<StartFirstOutcome> {
119
- const { role, base, replicas, runner, options } = step;
154
+ const { role, base, replicas, image, runner, options } = step;
120
155
  const run = async (command: readonly string[]) => {
121
156
  const result = await runner(command, options);
122
157
  return { result, failed: { ok: false as const, code: result.code, command } };
@@ -132,10 +167,19 @@ export async function runStartFirst(step: {
132
167
  if (!config.result.ok) return config.failed;
133
168
  const inspect = await run(['docker', 'inspect', '--format', INSPECT_FORMAT, ...running]);
134
169
  if (!inspect.result.ok) return inspect.failed;
170
+ // The id the REQUESTED reference resolves to on the daemon. Unresolved is UNKNOWN and not a
171
+ // failure: compose fetches the image during the `up`, so a tag that is not on the daemon yet is
172
+ // the ordinary case — and unknown must never read as "unchanged" (see `partition`).
173
+ const resolved = await runner(
174
+ ['docker', 'image', 'inspect', '--format', '{{.Id}}', image],
175
+ options,
176
+ );
177
+ const wantImage = resolved.ok ? linesOf(resolved.stdout)[0] : undefined;
135
178
  const { current, stale } = partition(
136
179
  running,
137
180
  inspect.result.stdout,
138
181
  hashOf(config.result.stdout) ?? '',
182
+ wantImage,
139
183
  );
140
184
  const shortfall = Math.max(0, replicas - current.length);
141
185
  const up = await run(startFirstUp(base, role, running.length + shortfall));
package/src/cmd-deploy.ts CHANGED
@@ -236,7 +236,12 @@ export function planDeploy(
236
236
  // header documents `IMAGE=… docker compose …` as the way to run it by hand; this is that line,
237
237
  // performed.
238
238
  env: { IMAGE: image },
239
- steps: DEPLOY_ROLES.map((role) => composeStep(composeBase(root), role, strategies.get(role))),
239
+ steps: DEPLOY_ROLES.flatMap((role) => {
240
+ const step = composeStep(composeBase(root), role, strategies.get(role));
241
+ return role === 'backfill' && strategies.has('replicator')
242
+ ? [composeStep(composeBase(root), 'replicator', strategies.get('replicator')), step]
243
+ : [step];
244
+ }),
240
245
  };
241
246
  }
242
247
 
@@ -413,6 +418,10 @@ export const deployCommand: CliCommand = {
413
418
  role: step.role,
414
419
  base: composeBase(root),
415
420
  replicas: step.strategy.replicas,
421
+ // The reference the deploy was asked for, named here rather than read off
422
+ // `options.env`: the image the plan reports and the image compose runs must be one
423
+ // fact, and the roll decides on the id this reference resolves to.
424
+ image: plan.image,
416
425
  runner: ctx.runner,
417
426
  options,
418
427
  })
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 { stringField } from '@ultimat3/core';
11
+ import { DEFAULT_LOCALE, stringField } from '@ultimat3/core';
12
12
  import type { Catalog } from '@ultimat3/i18n';
13
13
  import { auditCatalogs, catalogKeys } from '@ultimat3/i18n';
14
14
  import { loadApp } from './app-load';
@@ -26,6 +26,7 @@ import {
26
26
  syncCatalog,
27
27
  } from './i18n-audit';
28
28
  import { syncI18nIndex } from './i18n-index';
29
+ import { localeParityFindings } from './i18n-locale-parity';
29
30
  import {
30
31
  checkRegistration,
31
32
  loudMiss,
@@ -126,8 +127,13 @@ async function runCheck(root: string): Promise<CommandResult> {
126
127
  // The runtime question, asked after the file question and never instead of it: a catalog can be
127
128
  // complete on disk, used everywhere in source, and reach no registry at all (issue #249).
128
129
  const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
130
+ // The two rules only a comparison with the reference locale can see: a slot one side does not
131
+ // have, and a string nobody translated. The same call `x verify`'s `i18n` step makes, so the
132
+ // command and the gate answer about an app with one voice.
133
+ const parity = localeParityFindings(catalogs);
134
+ const parityLocales = new Set(parity.map((finding) => finding.meta?.['locale'])).size;
129
135
 
130
- const findings: Finding[] = [...missingKeyFindings(report), ...registration.findings];
136
+ const findings: Finding[] = [...missingKeyFindings(report), ...registration.findings, ...parity];
131
137
 
132
138
  // Whether the LOCALE has a catalog is the wrong question — the framework's own `en` is always
133
139
  // registered, so a locale-presence column reads `yes` for an app whose every key is a loud miss.
@@ -165,10 +171,26 @@ async function runCheck(root: string): Promise<CommandResult> {
165
171
  const missingTotal =
166
172
  report.locales.reduce((sum, audit) => sum + audit.missing.length, 0) +
167
173
  registration.unregistered;
168
- const ok = report.ok && registration.ok;
169
- const summary = ok
170
- ? msg('cli.i18n.ok', { locales: report.locales.length, keys: report.used.length })
171
- : msg('cli.i18n.gaps', { missing: missingTotal, locales: gapLocales });
174
+ const ok = report.ok && registration.ok && parity.length === 0;
175
+ // The summary names the parity findings when there are any: `cli.i18n.gaps` counts MISSING KEYS,
176
+ // so a locale whose slots drifted and whose string was never translated answered "0 missing
177
+ // key(s) across 0 locale(s)" — a green-looking line over a red command. The missing count keeps
178
+ // a line of its own, so neither fact is lost.
179
+ let summary: string;
180
+ if (parity.length > 0) {
181
+ summary = msg('cli.i18n.parity', {
182
+ count: parity.length,
183
+ locales: parityLocales,
184
+ reference: DEFAULT_LOCALE,
185
+ });
186
+ if (missingTotal > 0) {
187
+ lines.push(` ${msg('cli.i18n.gaps', { missing: missingTotal, locales: gapLocales })}`);
188
+ }
189
+ } else {
190
+ summary = ok
191
+ ? msg('cli.i18n.ok', { locales: report.locales.length, keys: report.used.length })
192
+ : msg('cli.i18n.gaps', { missing: missingTotal, locales: gapLocales });
193
+ }
172
194
 
173
195
  return {
174
196
  ok,
@@ -92,6 +92,10 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
92
92
  // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
93
93
  const site = await loadSiteSettings(input.root);
94
94
  const origin = publicOrigin(input.env, site);
95
+ // The app's own favicon is NOT resolved here: the document resolves it, per render, from the
96
+ // app root below. The route table is built once and the watcher rebuilds only the manifest and
97
+ // the islands, so a head minted at boot would name the icon's bytes as they were and go stale
98
+ // on the first save.
95
99
  // The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
96
100
  const sync = await pageSync(input.root, input.env, input.buildId, input.realtime);
97
101
  // The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
@@ -186,6 +190,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
186
190
  }),
187
191
  ...appRoutes({
188
192
  buildId: input.buildId,
193
+ // The app root: the document resolves the app's own favicon from it, per render.
194
+ root: input.root,
189
195
  ...(input.isr === undefined ? {} : { isr: input.isr }),
190
196
  resolveIsland: (file) => input.islands().resolverFor(file),
191
197
  realtimeIslands: () => input.islands().realtime,
@@ -75,4 +75,14 @@ export interface DocumentOptions {
75
75
  * carries none, because the router prefetches by its own rules.
76
76
  */
77
77
  readonly speculationHead?: string;
78
+ /**
79
+ * `<link rel="icon" type="image/svg+xml" href="…">` — the app's own favicon, resolved once at
80
+ * boot by `runtime-render.ts`'s `appFaviconHead` into the content-hashed site-asset URL
81
+ * `/assets/*` serves.
82
+ *
83
+ * Document-level for `pwaHead`'s reason: it is the same one link on every page of the app, and
84
+ * it is decided by `app.config.ts`, which the boot read and the renderer cannot. Absent when the
85
+ * app declares no favicon — the browser's own `/favicon.ico` request is then the only one.
86
+ */
87
+ readonly faviconHead?: string;
78
88
  }
@@ -234,6 +234,9 @@ export const CLI_OWNED_ERROR_CODES = [
234
234
  'X_BACKFILL_SLICE_ENTITY',
235
235
  'X_LAYOUT_SIBLING_MODULE',
236
236
  'X_SLICE_POLICY_INCOMPLETE',
237
+ 'X_CATALOG_SLOT_MISMATCH',
238
+ 'X_CATALOG_UNTRANSLATED',
239
+ 'X_WORKER_QUEUE_UNKNOWN',
237
240
  ] as const;
238
241
 
239
242
  /**
@@ -424,6 +427,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
424
427
  X_BACKFILL_SLICE_ENTITY: 'the slice holds no entity a generated backfill can sweep',
425
428
  X_LAYOUT_SIBLING_MODULE: 'a primitive module beside its directory',
426
429
  X_SLICE_POLICY_INCOMPLETE: 'a generator needs an export the slice policy.ts does not declare',
430
+ X_CATALOG_SLOT_MISMATCH: 'two locales define one key with different interpolation slots',
431
+ X_CATALOG_UNTRANSLATED: "a locale still carries the reference locale's value",
432
+ X_WORKER_QUEUE_UNKNOWN: 'WORKER_QUEUES names a queue no job and no jobs.queues entry declares',
427
433
  };
428
434
 
429
435
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -0,0 +1,134 @@
1
+ // The two rules a locale's catalog must satisfy against the reference locale's: the same
2
+ // interpolation slots, and no untranslated copy of the reference's string. Both already hold at the
3
+ // APP level (examples/dummy's catalog test asserts them over its en/es catalogs); this module is
4
+ // the same rule as a finding, so a broken locale fails the gate instead of shipping.
5
+
6
+ import { DEFAULT_LOCALE, ERROR_DOCS_URL } from '@ultimat3/core';
7
+ import type { Catalog, Locale } from '@ultimat3/i18n';
8
+ import { isMiss, placeholdersOf } from '@ultimat3/i18n';
9
+ import type { Finding } from './output';
10
+ import { catalogPath } from './templates/locales';
11
+
12
+ /**
13
+ * The length above which two identical values are a copy rather than a name. `Postly`, `Free` and
14
+ * `Blog` read the same in every language; a sentence of 25 characters does not, and shipping it
15
+ * untranslated is the defect this rule exists to name.
16
+ */
17
+ const COPY_LENGTH = 24;
18
+
19
+ /** The slots, as `{name}` — the way a cause and a fix name them. */
20
+ const shownSlots = (slots: readonly string[]): string =>
21
+ slots.map((slot) => `{${slot}}`).join(', ');
22
+
23
+ /** The slots as the loud miss `interpolate` renders for each of them. */
24
+ const shownMisses = (slots: readonly string[]): string =>
25
+ slots.map((slot) => `⟦${slot}⟧`).join(', ');
26
+
27
+ /** A value, capped: a cause is one line, and a 300-character string is not a cause. */
28
+ const shownValue = (value: string): string =>
29
+ value.length > 60 ? `${value.slice(0, 60)}…` : value;
30
+
31
+ /**
32
+ * Every defect one locale's catalog holds against the reference's, as findings.
33
+ *
34
+ * Pure: catalogs in, findings out. A slot is read with `placeholdersOf` — @ultimat3/i18n's one
35
+ * definition, the same function `interpolate` substitutes by — because a second regex here could
36
+ * only ever disagree with the runtime about what a slot is.
37
+ *
38
+ * The reference is `en` — core's `DEFAULT_LOCALE`, never a second `'en'` spelled here. An app
39
+ * that ships no `en` catalog is not compared at all: there is no reference to compare against, and
40
+ * a rule with nothing to compare is noise on a healthy app.
41
+ */
42
+ export function localeParityFindings(
43
+ catalogs: Readonly<Record<Locale, Catalog>>,
44
+ ): readonly Finding[] {
45
+ const reference = catalogs[DEFAULT_LOCALE];
46
+ if (reference === undefined) return [];
47
+ const findings: Finding[] = [];
48
+ for (const locale of Object.keys(catalogs).sort()) {
49
+ if (locale === DEFAULT_LOCALE) continue;
50
+ const catalog = catalogs[locale];
51
+ if (catalog === undefined) continue;
52
+ for (const key of Object.keys(reference).sort()) {
53
+ const value = reference[key];
54
+ const translated = catalog[key];
55
+ // Only a key BOTH define: a key one catalog holds alone is a MISSING key, and that is
56
+ // `X_CATALOG_MISSING_KEYS`'s finding. Reporting it here too would be one defect under two
57
+ // codes, and the second code's repair is not the first one's.
58
+ if (value === undefined || translated === undefined) continue;
59
+ findings.push(...keyFindings(locale, key, value, translated));
60
+ }
61
+ }
62
+ return findings;
63
+ }
64
+
65
+ /**
66
+ * The findings for one key, in one order: the slots the locale adds, the slots it drops, and the
67
+ * copy. A slot mismatch is TWO findings, because the two repairs differ — one is a rename, the
68
+ * other an addition — and a single cause could only name one of them.
69
+ *
70
+ * Every `fix:` opens with the command and names the edit behind a `#`: a fix is pasted from its
71
+ * first character, so a sentence the reader has to translate first is what the `fix-prose`
72
+ * ratchet refuses.
73
+ */
74
+ function keyFindings(
75
+ locale: Locale,
76
+ key: string,
77
+ reference: string,
78
+ translated: string,
79
+ ): readonly Finding[] {
80
+ const path = catalogPath(locale);
81
+ // A loud miss (`⟦key⟧`) is a placeholder, not a translation, and it carries no slot of its own:
82
+ // a slot comparison across it blames the marker — `x i18n sync` seeds one into every key
83
+ // nobody has translated — and prints a fix ("add {name}") that edits the placeholder. The hole
84
+ // is `X_CATALOG_MISSING_KEYS`'s finding, already reported: a second code for one defect, with a
85
+ // repair that would destroy the marker. So the comparison waits for a real string on both sides.
86
+ const comparable = !isMiss(reference) && !isMiss(translated);
87
+ const referenceSlots = placeholdersOf(reference);
88
+ const translatedSlots = placeholdersOf(translated);
89
+ const extra = translatedSlots.filter((slot) => !referenceSlots.includes(slot));
90
+ const missing = referenceSlots.filter((slot) => !translatedSlots.includes(slot));
91
+ const findings: Finding[] = [];
92
+
93
+ if (comparable && extra.length > 0) {
94
+ findings.push({
95
+ code: 'X_CATALOG_SLOT_MISMATCH',
96
+ cause: `${path} defines "${key}" with ${shownSlots(extra)}, which ${catalogPath(DEFAULT_LOCALE)} does not — interpolate substitutes by name, so ${locale} renders ${shownMisses(extra)}`,
97
+ fix:
98
+ referenceSlots.length === 0
99
+ ? `x i18n check --json # remove ${shownSlots(extra)} from "${key}" in ${path}`
100
+ : `x i18n check --json # use ${shownSlots(referenceSlots)} instead of ${shownSlots(extra)} in "${key}" in ${path}`,
101
+ docs: ERROR_DOCS_URL,
102
+ at: path,
103
+ meta: { locale, key },
104
+ });
105
+ }
106
+
107
+ if (comparable && missing.length > 0) {
108
+ findings.push({
109
+ code: 'X_CATALOG_SLOT_MISMATCH',
110
+ cause: `${catalogPath(DEFAULT_LOCALE)} defines "${key}" with ${shownSlots(missing)}, which ${path} does not — the value it carries never renders in ${locale}`,
111
+ fix: `x i18n check --json # add ${shownSlots(missing)} to "${key}" in ${path}`,
112
+ docs: ERROR_DOCS_URL,
113
+ at: path,
114
+ meta: { locale, key },
115
+ });
116
+ }
117
+
118
+ // A value longer than the bar that is byte-identical to the reference's is a copy nobody
119
+ // translated. A marker is NOT a copy: `⟦key⟧` is the loud miss `X_CATALOG_MISSING_KEYS` already
120
+ // reports, and a placeholder seeded into two locales would otherwise be one hole under two
121
+ // codes. `isMiss` is the framework's own predicate for that marker.
122
+ if (translated === reference && reference.length > COPY_LENGTH && !isMiss(reference)) {
123
+ findings.push({
124
+ code: 'X_CATALOG_UNTRANSLATED',
125
+ cause: `${path} defines "${key}" exactly as ${catalogPath(DEFAULT_LOCALE)} does ("${shownValue(reference)}") — ${reference.length} characters, so ${locale} reads the reference's sentence instead of its own`,
126
+ fix: `x i18n check --json # translate "${key}" in ${path}`,
127
+ docs: ERROR_DOCS_URL,
128
+ at: path,
129
+ meta: { locale, key },
130
+ });
131
+ }
132
+
133
+ return findings;
134
+ }
@@ -25,6 +25,7 @@ import { loadApp } from './app-load';
25
25
  import type { DuplicateProbe } from './duplicate-packages';
26
26
  import { duplicateCause, duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
27
27
  import { auditApp } from './i18n-audit';
28
+ import { localeParityFindings } from './i18n-locale-parity';
28
29
  import type { Finding } from './output';
29
30
  import { findingFrom } from './output';
30
31
  import { CATALOG_ROOT, catalogPath } from './templates/locales';
@@ -287,6 +288,10 @@ export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
287
288
  * Both halves of one question — does every string this app renders resolve? — for a caller that
288
289
  * wants the verdict and not the table. `x verify`'s `i18n` step is that caller; `cmd-i18n.ts`
289
290
  * composes the same two pieces itself because it also renders per-locale rows.
291
+ *
292
+ * The locale parity joins them: a catalog can hold every key source uses and still be wrong in
293
+ * the two ways only a comparison with the reference locale can see — a slot one side does not
294
+ * have, and a string nobody translated.
290
295
  */
291
296
  export async function catalogFindings(root: string): Promise<readonly Finding[]> {
292
297
  const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
@@ -294,5 +299,6 @@ export async function catalogFindings(root: string): Promise<readonly Finding[]>
294
299
  return [
295
300
  ...missingKeyFindings(withPlaceholdersMissing(report, catalogs)),
296
301
  ...registration.findings,
302
+ ...localeParityFindings(catalogs),
297
303
  ];
298
304
  }
package/src/mcp-errors.ts CHANGED
@@ -306,6 +306,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
306
306
  'x verify --only boundaries --json # then move each declaration in X.ts to X/<name>.ts, one primitive per file',
307
307
  X_SLICE_POLICY_INCOMPLETE:
308
308
  'x policy list --json # names the slice policy module; add the export lines the thrown fix spells out to it, then run the generator again',
309
+ X_CATALOG_SLOT_MISMATCH:
310
+ 'x i18n check --json # the finding names the locale, the key and the slots that differ',
311
+ X_CATALOG_UNTRANSLATED:
312
+ 'x i18n check --json # the finding names the locale and the key still carrying the reference value',
313
+ X_WORKER_QUEUE_UNKNOWN:
314
+ 'x jobs list --json # set WORKER_QUEUES to queues it lists, or add the queue to jobs.queues in app.config.ts',
309
315
  };
310
316
 
311
317
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/messages.ts CHANGED
@@ -139,6 +139,10 @@ const CATALOG = {
139
139
  'cli.i18n.dynamic': '{count} dynamic t() call(s) the extractor cannot verify:',
140
140
  'cli.i18n.gaps': '{missing} missing key(s) across {locales} locale(s)',
141
141
  'cli.i18n.ok': '{locales} locale(s), {keys} key(s) used — no gaps',
142
+ // The summary a locale-parity finding needs: `cli.i18n.gaps` counts MISSING KEYS, so it cannot
143
+ // say "this locale's slots drifted and this string was never translated".
144
+ 'cli.i18n.parity':
145
+ '{count} locale-parity finding(s) across {locales} locale(s) — a slot or an untranslated copy against {reference}',
142
146
  'cli.i18n.synced': 'synced {locale} from {from} — {added} key(s) added, {total} total',
143
147
  'cli.i18n.unused': '{count} key(s) defined in {locale} and never used:',
144
148
  'cli.jobs.backfillNoCursor': 'no cursor yet',
package/src/prerender.ts CHANGED
@@ -195,6 +195,8 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
195
195
  const theme = themeBoot(await loadThemeMode(options.root));
196
196
  // The brand the served process carries, so an exported page and a served one paint alike.
197
197
  const brand = await loadThemeBrand(options.root);
198
+ // The app's own favicon is resolved by the DOCUMENT, per render, from the app root below — the
199
+ // link the served surfaces emit, so an exported document and a served one carry the same icon.
198
200
  // The client router, for the surfaces that opted in: written BEFORE any document names it, for
199
201
  // the register's reason below — `measureDocumentJs` weighs it off disk, and it is charged to
200
202
  // the route (the interactivity the app asked for), so it must be there to be weighed.
@@ -266,6 +268,9 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
266
268
  // Started on the first route that needs weighing, and released however the loop ends.
267
269
  const database = measureDatabase(options.root);
268
270
  const documentOptions = {
271
+ // The app root: the document resolves the app's own favicon from it, per render. `writeSiteAssets`
272
+ // above copied the icon into the artifact under the same hashed URL this resolves.
273
+ root: options.root,
269
274
  resolveIsland: (file: string) => islands.resolverFor(file),
270
275
  realtimeIslands: () => islands.realtime,
271
276
  themeHead: theme.head,