@burdenoff/fe-libs 2026.1021.8 → 2026.1021.9

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.
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * burdenoff-i18n — the fleet's shared i18n bundle gates.
4
+ *
5
+ * App shells each carried ~20 i18n scripts; the product-agnostic ones are
6
+ * here, configured by an `i18n.config.json` at the shell root. What stays in a
7
+ * shell is what reads that shell's own translation sources (the extractor and
8
+ * bundle builder), exposed to these commands through a small adapter module.
9
+ *
10
+ * Usage (from a shell root, after `bun install`):
11
+ * burdenoff-i18n <command> [--config=i18n.config.json] [options]
12
+ * or
13
+ * bun run node_modules/@burdenoff/fe-libs/scripts/i18n/cli.ts <command> …
14
+ *
15
+ * Commands:
16
+ * verify-shipped [--bundle=<src>] [--warn]
17
+ * Every key the installed, compiled packages request is in the bundle
18
+ * (default <src>: built), or held back on purpose by the builder.
19
+ * digest [<src>] [--order=utf8|utf16]
20
+ * Print key/row counts and set digests (default <src>: built) as JSON.
21
+ * diff <base> <proposed> [--allow-updates] [--allow-deletions] [--json=PATH] [--limit=N]
22
+ * The additive-seed contract: fail if seeding <proposed> over <base>
23
+ * would change or delete a served row.
24
+ * served-tree [<src>] [--baseline=<src>] [--reads-leaf-slot=true|false] [--limit=N]
25
+ * Model global-i18n-svc's nested export per locale and fail when a key
26
+ * would not be served as the bundle says (with --baseline: when a key
27
+ * resolved today would be hidden or shadowed).
28
+ *
29
+ * <src> is `built` (the adapter's buildBundle(), else bundleFile), `served`
30
+ * (servedUrl), a file path, or an https:// URL.
31
+ *
32
+ * Exit codes: 0 pass · 1 the check found a problem · 2 the check could not run.
33
+ */
34
+
35
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
36
+ import { dirname, resolve } from 'node:path';
37
+ import { parseArgs } from 'node:util';
38
+ import { fileURLToPath } from 'node:url';
39
+ import { BundleError } from './bundle';
40
+ import {
41
+ CommandError,
42
+ runDiff,
43
+ runDigest,
44
+ runServedTree,
45
+ runVerifyShipped,
46
+ type CommandContext,
47
+ } from './commands';
48
+ import { DEFAULT_CONFIG_FILE, I18nConfigError, resolveI18nConfig } from './config';
49
+ import type { DigestOrder, ResolvedI18nToolsConfig } from './types';
50
+
51
+ export const COMMANDS = ['verify-shipped', 'digest', 'diff', 'served-tree'] as const;
52
+ export type CommandName = (typeof COMMANDS)[number];
53
+
54
+ export interface ParsedCli {
55
+ command: CommandName;
56
+ configPath: string;
57
+ /** Whether --config was passed: then a missing file is an error. */
58
+ configExplicit: boolean;
59
+ positionals: string[];
60
+ warn: boolean;
61
+ bundle?: string;
62
+ order?: DigestOrder;
63
+ allowUpdates: boolean;
64
+ allowDeletions: boolean;
65
+ jsonPath?: string;
66
+ limit?: number;
67
+ baseline?: string;
68
+ readsLeafSlot?: boolean;
69
+ }
70
+
71
+ export class UsageError extends Error {
72
+ constructor(message: string) {
73
+ super(message);
74
+ this.name = 'UsageError';
75
+ }
76
+ }
77
+
78
+ const isCommand = (value: string | undefined): value is CommandName =>
79
+ value !== undefined && (COMMANDS as readonly string[]).includes(value);
80
+
81
+ const ARITY: Readonly<Record<CommandName, { min: number; max: number }>> = {
82
+ 'verify-shipped': { min: 0, max: 0 },
83
+ digest: { min: 0, max: 1 },
84
+ diff: { min: 2, max: 2 },
85
+ 'served-tree': { min: 0, max: 1 },
86
+ };
87
+
88
+ /** Parse argv (without the runtime and script) into a validated invocation. */
89
+ export function parseCli(argv: readonly string[]): ParsedCli {
90
+ const [command, ...rest] = argv;
91
+ if (!isCommand(command)) {
92
+ throw new UsageError(
93
+ `${command === undefined ? 'missing command' : `unknown command "${command}"`}; ` +
94
+ `expected one of: ${COMMANDS.join(', ')}`
95
+ );
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = parseArgs({
100
+ args: [...rest],
101
+ allowPositionals: true,
102
+ strict: true,
103
+ options: {
104
+ config: { type: 'string' },
105
+ warn: { type: 'boolean', default: false },
106
+ bundle: { type: 'string' },
107
+ order: { type: 'string' },
108
+ 'allow-updates': { type: 'boolean', default: false },
109
+ 'allow-deletions': { type: 'boolean', default: false },
110
+ json: { type: 'string' },
111
+ limit: { type: 'string' },
112
+ baseline: { type: 'string' },
113
+ 'reads-leaf-slot': { type: 'string' },
114
+ },
115
+ });
116
+ } catch (error) {
117
+ throw new UsageError((error as Error).message);
118
+ }
119
+ const { values, positionals } = parsed;
120
+ const arity = ARITY[command];
121
+ if (positionals.length < arity.min || positionals.length > arity.max) {
122
+ throw new UsageError(
123
+ `${command} takes ${arity.min === arity.max ? arity.min : `${arity.min}-${arity.max}`} ` +
124
+ `positional argument(s), got ${positionals.length}`
125
+ );
126
+ }
127
+
128
+ let order: DigestOrder | undefined;
129
+ if (values.order !== undefined) {
130
+ if (values.order !== 'utf8' && values.order !== 'utf16') {
131
+ throw new UsageError('--order must be utf8 or utf16');
132
+ }
133
+ order = values.order;
134
+ }
135
+ let limit: number | undefined;
136
+ if (values.limit !== undefined) {
137
+ limit = Number(values.limit);
138
+ if (!Number.isInteger(limit) || limit < 0)
139
+ throw new UsageError('--limit must be a whole number');
140
+ }
141
+ let readsLeafSlot: boolean | undefined;
142
+ const slot = values['reads-leaf-slot'];
143
+ if (slot !== undefined) {
144
+ if (slot !== 'true' && slot !== 'false') {
145
+ throw new UsageError('--reads-leaf-slot must be true or false');
146
+ }
147
+ readsLeafSlot = slot === 'true';
148
+ }
149
+
150
+ return {
151
+ command,
152
+ configPath: values.config ?? DEFAULT_CONFIG_FILE,
153
+ configExplicit: values.config !== undefined,
154
+ positionals,
155
+ warn: values.warn ?? false,
156
+ ...(values.bundle !== undefined ? { bundle: values.bundle } : {}),
157
+ ...(order !== undefined ? { order } : {}),
158
+ allowUpdates: values['allow-updates'] ?? false,
159
+ allowDeletions: values['allow-deletions'] ?? false,
160
+ ...(values.json !== undefined ? { jsonPath: values.json } : {}),
161
+ ...(limit !== undefined ? { limit } : {}),
162
+ ...(values.baseline !== undefined ? { baseline: values.baseline } : {}),
163
+ ...(readsLeafSlot !== undefined ? { readsLeafSlot } : {}),
164
+ };
165
+ }
166
+
167
+ /** Read the config when present; a missing default config is allowed for path-only commands. */
168
+ export function readConfig(cli: ParsedCli, cwd: string): ResolvedI18nToolsConfig | undefined {
169
+ const path = resolve(cwd, cli.configPath);
170
+ if (!existsSync(path)) {
171
+ if (cli.configExplicit || cli.command === 'verify-shipped') {
172
+ throw new I18nConfigError([
173
+ `config not found: ${path} (see @burdenoff/fe-libs/scripts/i18n/README.md)`,
174
+ ]);
175
+ }
176
+ return undefined;
177
+ }
178
+ let raw: unknown;
179
+ try {
180
+ raw = JSON.parse(readFileSync(path, 'utf8')) as unknown;
181
+ } catch (error) {
182
+ throw new I18nConfigError([`${path} is not valid JSON: ${(error as Error).message}`]);
183
+ }
184
+ return resolveI18nConfig(raw, dirname(path));
185
+ }
186
+
187
+ /** Run one invocation; returns the exit code. */
188
+ export async function runCli(
189
+ argv: readonly string[],
190
+ ctx: Omit<CommandContext, 'config'> = {}
191
+ ): Promise<number> {
192
+ const log = ctx.log ?? ((line: string) => console.error(line));
193
+ const cwd = ctx.cwd ?? process.cwd();
194
+ try {
195
+ const cli = parseCli(argv);
196
+ const config = readConfig(cli, cwd);
197
+ const context: CommandContext = { ...ctx, cwd, log, ...(config ? { config } : {}) };
198
+ switch (cli.command) {
199
+ case 'verify-shipped':
200
+ return await runVerifyShipped(
201
+ { warn: cli.warn, ...(cli.bundle !== undefined ? { bundle: cli.bundle } : {}) },
202
+ context
203
+ );
204
+ case 'digest':
205
+ return await runDigest(
206
+ { source: cli.positionals[0] ?? 'built', ...(cli.order ? { order: cli.order } : {}) },
207
+ context
208
+ );
209
+ case 'diff': {
210
+ const [base, proposed] = cli.positionals as [string, string];
211
+ return await runDiff(
212
+ {
213
+ base,
214
+ proposed,
215
+ allowUpdates: cli.allowUpdates,
216
+ allowDeletions: cli.allowDeletions,
217
+ ...(cli.limit !== undefined ? { limit: cli.limit } : {}),
218
+ ...(cli.jsonPath !== undefined
219
+ ? {
220
+ onDiff: (diff) => {
221
+ const out = resolve(cwd, cli.jsonPath as string);
222
+ mkdirSync(dirname(out), { recursive: true });
223
+ writeFileSync(out, `${JSON.stringify(diff, null, 2)}\n`, 'utf8');
224
+ log(`[burdenoff-i18n] diff written to ${out}`);
225
+ },
226
+ }
227
+ : {}),
228
+ },
229
+ context
230
+ );
231
+ }
232
+ case 'served-tree':
233
+ return await runServedTree(
234
+ {
235
+ proposed: cli.positionals[0] ?? 'built',
236
+ ...(cli.baseline !== undefined ? { baseline: cli.baseline } : {}),
237
+ ...(cli.readsLeafSlot !== undefined ? { readsLeafSlot: cli.readsLeafSlot } : {}),
238
+ ...(cli.limit !== undefined ? { limit: cli.limit } : {}),
239
+ },
240
+ context
241
+ );
242
+ }
243
+ } catch (error) {
244
+ if (
245
+ error instanceof UsageError ||
246
+ error instanceof I18nConfigError ||
247
+ error instanceof BundleError ||
248
+ error instanceof CommandError
249
+ ) {
250
+ log(`[burdenoff-i18n] ${error.message}`);
251
+ return 2;
252
+ }
253
+ log(`[burdenoff-i18n] unexpected failure: ${(error as Error).stack ?? String(error)}`);
254
+ return 2;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Whether this file is the process entry point. A package `bin` runs through a
260
+ * symlink in node_modules/.bin, so both sides are compared after realpath.
261
+ */
262
+ export function isEntryPoint(argv1: string | undefined, moduleUrl: string): boolean {
263
+ if (argv1 === undefined) return false;
264
+ try {
265
+ return realpathSync(resolve(argv1)) === realpathSync(fileURLToPath(moduleUrl));
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ const invokedDirectly = isEntryPoint(process.argv[1], import.meta.url);
272
+
273
+ if (invokedDirectly) {
274
+ runCli(process.argv.slice(2)).then(
275
+ (code) => process.exit(code),
276
+ (error: unknown) => {
277
+ console.error(`[burdenoff-i18n] unexpected failure: ${String(error)}`);
278
+ process.exit(2);
279
+ }
280
+ );
281
+ }
@@ -0,0 +1,370 @@
1
+ /**
2
+ * The `burdenoff-i18n` subcommands, as functions that return an exit code.
3
+ *
4
+ * Exit codes (every command):
5
+ * 0 the check passed / the report was produced
6
+ * 1 the check found a problem (missing keys, a non-additive seed, a lost key)
7
+ * 2 the check could not run (bad config, unreadable bundle, nothing to check)
8
+ *
9
+ * A check that could not look at anything is exit 2, never a pass.
10
+ */
11
+
12
+ import { join, resolve } from 'node:path';
13
+ import {
14
+ bundleLocales,
15
+ diffBundles,
16
+ digestBundle,
17
+ loadBundleSource,
18
+ localeRows,
19
+ type LoadOptions,
20
+ } from './bundle';
21
+ import { loadAdapter } from './config';
22
+ import { compareServed, resolvableKeys } from './servedTree';
23
+ import { missingFromBundle, shippedKeys } from './shipped';
24
+ import type {
25
+ BundleDiff,
26
+ DigestOrder,
27
+ I18nAdapter,
28
+ I18nBundle,
29
+ ResolvedI18nToolsConfig,
30
+ RowChange,
31
+ ShippedKey,
32
+ } from './types';
33
+
34
+ export type Log = (line: string) => void;
35
+
36
+ export interface CommandContext {
37
+ config?: ResolvedI18nToolsConfig;
38
+ /** Injected adapter (tests); otherwise loaded from `config.adapter`. */
39
+ adapter?: I18nAdapter;
40
+ load?: LoadOptions;
41
+ log?: Log;
42
+ cwd?: string;
43
+ }
44
+
45
+ export class CommandError extends Error {
46
+ constructor(message: string) {
47
+ super(message);
48
+ this.name = 'CommandError';
49
+ }
50
+ }
51
+
52
+ const defaultLog: Log = (line) => console.error(line);
53
+
54
+ async function adapterOf(ctx: CommandContext): Promise<I18nAdapter | undefined> {
55
+ if (ctx.adapter) return ctx.adapter;
56
+ if (ctx.config?.adapter) {
57
+ ctx.adapter = await loadAdapter(ctx.config.adapter);
58
+ return ctx.adapter;
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ export interface ResolvedSource {
64
+ label: string;
65
+ bundle: I18nBundle;
66
+ heldBack: Map<string, string>;
67
+ }
68
+
69
+ function toHeldBackMap(
70
+ value: ReadonlyMap<string, string> | Readonly<Record<string, string>> | undefined
71
+ ): Map<string, string> {
72
+ if (value === undefined) return new Map();
73
+ if (value instanceof Map) return new Map(value);
74
+ return new Map(Object.entries(value as Readonly<Record<string, string>>));
75
+ }
76
+
77
+ /**
78
+ * Resolve a bundle source spec:
79
+ * - `served` -> the config's `servedUrl`;
80
+ * - `built` -> the adapter's `buildBundle()`, else the config's `bundleFile`;
81
+ * - anything else -> a file path (relative to cwd) or an https:// URL.
82
+ */
83
+ export async function resolveSource(spec: string, ctx: CommandContext): Promise<ResolvedSource> {
84
+ const { config } = ctx;
85
+ if (spec === 'served') {
86
+ if (!config?.servedUrl)
87
+ throw new CommandError('"served" needs "servedUrl" in i18n.config.json');
88
+ return {
89
+ label: config.servedUrl,
90
+ bundle: await loadBundleSource(config.servedUrl, ctx.load),
91
+ heldBack: new Map(),
92
+ };
93
+ }
94
+ if (spec === 'built') {
95
+ const adapter = await adapterOf(ctx);
96
+ if (adapter?.buildBundle) {
97
+ const built = await adapter.buildBundle();
98
+ return {
99
+ label: 'built bundle',
100
+ bundle: built.bundle,
101
+ heldBack: toHeldBackMap(built.heldBack),
102
+ };
103
+ }
104
+ if (config?.bundleFile) {
105
+ return {
106
+ label: config.bundleFile,
107
+ bundle: await loadBundleSource(config.bundleFile, ctx.load),
108
+ heldBack: new Map(),
109
+ };
110
+ }
111
+ throw new CommandError(
112
+ '"built" needs an adapter with buildBundle() or "bundleFile" in i18n.config.json'
113
+ );
114
+ }
115
+ const path = /^[a-z]+:\/\//i.test(spec) ? spec : resolve(ctx.cwd ?? process.cwd(), spec);
116
+ return { label: spec, bundle: await loadBundleSource(path, ctx.load), heldBack: new Map() };
117
+ }
118
+
119
+ // ─── verify-shipped ────────────────────────────────────────────────────────
120
+
121
+ export interface VerifyShippedOptions {
122
+ warn?: boolean;
123
+ /** Bundle to check against; default `built`. */
124
+ bundle?: string;
125
+ /** Directory of the installed scope; default `<configDir>/node_modules/<scope>`. */
126
+ scopeDir?: string;
127
+ }
128
+
129
+ /**
130
+ * Every key the installed, compiled packages request must be in the bundle,
131
+ * or held back on purpose by the shell's builder.
132
+ */
133
+ export async function runVerifyShipped(
134
+ options: VerifyShippedOptions,
135
+ ctx: CommandContext
136
+ ): Promise<number> {
137
+ const log = ctx.log ?? defaultLog;
138
+ const config = ctx.config;
139
+ if (!config) throw new CommandError('verify-shipped needs an i18n.config.json');
140
+ const failCode = options.warn ? 0 : 1;
141
+ const scopeDir = options.scopeDir ?? join(config.configDir, 'node_modules', config.shipped.scope);
142
+
143
+ const shipped = shippedKeys(scopeDir, config.shipped.packages);
144
+ if (shipped.size === 0) {
145
+ log(
146
+ `❌ No compiled ${config.shipped.scope}/{${config.shipped.packages.join(',')}} ` +
147
+ `packages found under ${scopeDir} — run \`bun install\` first.`
148
+ );
149
+ return 2;
150
+ }
151
+
152
+ const adapter = await adapterOf(ctx);
153
+ if (adapter?.requestedKeys) {
154
+ const extra = await adapter.requestedKeys();
155
+ for (const [key, value] of Object.entries(extra)) {
156
+ if (!shipped.has(key)) shipped.set(key, value);
157
+ }
158
+ }
159
+
160
+ const source = await resolveSource(options.bundle ?? 'built', ctx);
161
+ const absent = missingFromBundle(
162
+ shipped,
163
+ source.bundle.keys.map((k) => k.key)
164
+ );
165
+ const held = absent.filter(([key]) => source.heldBack.has(key));
166
+ const missing = absent.filter(([key]) => !source.heldBack.has(key));
167
+
168
+ log(
169
+ `\n[${config.product}] Shipped packages request ${shipped.size} i18n keys; ${source.label} ` +
170
+ `defines ${shipped.size - absent.length}, and holds back ${held.length} on purpose.`
171
+ );
172
+ if (held.length > 0) {
173
+ const byReason = new Map<string, number>();
174
+ for (const [key] of held) {
175
+ const reason = source.heldBack.get(key) ?? 'held back';
176
+ byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
177
+ }
178
+ log('\nHeld back (not seeded; users read the call site fallback):');
179
+ for (const [reason, n] of [...byReason].sort((a, b) => b[1] - a[1])) {
180
+ log(` ${String(n).padStart(5)} ${reason}`);
181
+ }
182
+ }
183
+ if (missing.length === 0) {
184
+ log('✅ Every key the shipped code asks for is in the bundle, or held back on purpose.\n');
185
+ return 0;
186
+ }
187
+
188
+ const byPkg = new Map<string, number>();
189
+ for (const [, v] of missing) byPkg.set(v.pkg, (byPkg.get(v.pkg) ?? 0) + 1);
190
+ log(`\n❌ ${missing.length} key(s) requested by shipped code are MISSING:\n`);
191
+ for (const [pkg, n] of [...byPkg.entries()].sort((a, b) => b[1] - a[1])) {
192
+ log(` ${pkg.padEnd(32)} ${n}`);
193
+ }
194
+ log('');
195
+ for (const [key, v] of missing.slice(0, 25)) {
196
+ log(` ${key}\n (${v.pkg}) "${v.fallback.slice(0, 60)}"`);
197
+ }
198
+ if (missing.length > 25) log(` … and ${missing.length - 25} more`);
199
+ log(
200
+ '\nEvery locale reads the call site English for these. Re-run the shell extractor, ' +
201
+ 'or list pinned-only / dynamic keys in its dynamic-keys file.\n'
202
+ );
203
+ return failCode;
204
+ }
205
+
206
+ // ─── digest ────────────────────────────────────────────────────────────────
207
+
208
+ export interface DigestOptions {
209
+ source: string;
210
+ order?: DigestOrder;
211
+ /** Print to this sink instead of stdout (tests). */
212
+ out?: Log;
213
+ }
214
+
215
+ /** Print a bundle's counts and set digests as JSON. */
216
+ export async function runDigest(options: DigestOptions, ctx: CommandContext): Promise<number> {
217
+ const source = await resolveSource(options.source, ctx);
218
+ const order = options.order ?? ctx.config?.digestOrder ?? 'utf8';
219
+ const out = options.out ?? ((line: string) => console.log(line));
220
+ out(JSON.stringify({ source: source.label, ...digestBundle(source.bundle, order) }, null, 2));
221
+ return 0;
222
+ }
223
+
224
+ // ─── diff ──────────────────────────────────────────────────────────────────
225
+
226
+ export interface DiffOptions {
227
+ base: string;
228
+ proposed: string;
229
+ allowUpdates?: boolean;
230
+ allowDeletions?: boolean;
231
+ /** Rows of each kind to print. */
232
+ limit?: number;
233
+ /** Receives the full diff (the CLI writes it to --json). */
234
+ onDiff?: (diff: BundleDiff) => void;
235
+ }
236
+
237
+ function printRows(log: Log, title: string, rows: readonly RowChange[], limit: number): void {
238
+ if (rows.length === 0) return;
239
+ log(`\n${title} (${rows.length}):`);
240
+ for (const row of rows.slice(0, limit)) {
241
+ const before = row.before === null ? '∅' : JSON.stringify(row.before.slice(0, 60));
242
+ const after = row.after === null ? '∅' : JSON.stringify(row.after.slice(0, 60));
243
+ log(` ${row.key} [${row.locale}] ${before} → ${after}`);
244
+ }
245
+ if (rows.length > limit) log(` … and ${rows.length - limit} more`);
246
+ }
247
+
248
+ /**
249
+ * The additive-seed contract: seeding `proposed` over `base` may insert rows,
250
+ * but changes or removes none unless explicitly allowed.
251
+ */
252
+ export async function runDiff(options: DiffOptions, ctx: CommandContext): Promise<number> {
253
+ const log = ctx.log ?? defaultLog;
254
+ const base = await resolveSource(options.base, ctx);
255
+ const proposed = await resolveSource(options.proposed, ctx);
256
+ const diff = diffBundles(base.bundle, proposed.bundle);
257
+ options.onDiff?.(diff);
258
+ const limit = options.limit ?? 25;
259
+
260
+ log(
261
+ `\n${base.label} (${diff.base.keys} keys, ${diff.base.rows} rows) → ` +
262
+ `${proposed.label} (${diff.proposed.keys} keys, ${diff.proposed.rows} rows)`
263
+ );
264
+ log(
265
+ ` inserts ${diff.inserts.length}, updates ${diff.updates.length}, deletions ${diff.deletions.length}; ` +
266
+ `new keys ${diff.newKeys.length}, removed keys ${diff.removedKeys.length}`
267
+ );
268
+ const byLocale = Object.entries(diff.insertsByLocale)
269
+ .map(([l, n]) => `${l}=${n}`)
270
+ .join(' ');
271
+ if (byLocale) log(` inserts by locale: ${byLocale}`);
272
+ printRows(log, 'Updates', diff.updates, limit);
273
+ printRows(log, 'Deletions', diff.deletions, limit);
274
+
275
+ const blocking: string[] = [];
276
+ if (diff.updates.length > 0 && !options.allowUpdates) {
277
+ blocking.push(`${diff.updates.length} served value(s) would change`);
278
+ }
279
+ if (diff.deletions.length > 0 && !options.allowDeletions) {
280
+ blocking.push(`${diff.deletions.length} served row(s) would be deleted`);
281
+ }
282
+ if (blocking.length > 0) {
283
+ log(
284
+ `\n❌ Not additive: ${blocking.join('; ')}. Pass --allow-updates / --allow-deletions only ` +
285
+ 'for a reviewed, intended change.\n'
286
+ );
287
+ return 1;
288
+ }
289
+ log('\n✅ Additive: nothing served is changed or deleted.\n');
290
+ return 0;
291
+ }
292
+
293
+ // ─── served-tree ───────────────────────────────────────────────────────────
294
+
295
+ export interface ServedTreeOptions {
296
+ proposed: string;
297
+ /** Compare with this bundle (normally `served`); without it, report every unresolvable key. */
298
+ baseline?: string;
299
+ readsLeafSlot?: boolean;
300
+ limit?: number;
301
+ }
302
+
303
+ /**
304
+ * Model the nested export per locale. With a baseline, fail when a key the
305
+ * runtime resolves today would stop resolving or resolve to another value
306
+ * than the proposed bundle's own; without one, fail on any bundle value the
307
+ * runtime cannot read back.
308
+ */
309
+ export async function runServedTree(
310
+ options: ServedTreeOptions,
311
+ ctx: CommandContext
312
+ ): Promise<number> {
313
+ const log = ctx.log ?? defaultLog;
314
+ const readsLeafSlot = options.readsLeafSlot ?? ctx.config?.readsLeafSlot ?? true;
315
+ const limit = options.limit ?? 25;
316
+ const proposed = await resolveSource(options.proposed, ctx);
317
+ const baseline = options.baseline ? await resolveSource(options.baseline, ctx) : undefined;
318
+
319
+ const locales = bundleLocales(proposed.bundle);
320
+ if (locales.length === 0) {
321
+ log(`❌ ${proposed.label} carries no rows.`);
322
+ return 2;
323
+ }
324
+
325
+ let problems = 0;
326
+ for (const locale of locales) {
327
+ const rows = localeRows(proposed.bundle, locale);
328
+ const after = resolvableKeys(rows, readsLeafSlot);
329
+ if (baseline) {
330
+ const before = resolvableKeys(localeRows(baseline.bundle, locale), readsLeafSlot);
331
+ const cmp = compareServed(before, after);
332
+ // A changed value is fine when it IS the proposed value for that key
333
+ // (an intended update, which `diff` gates); it is a problem when the
334
+ // nested export serves something else at that key.
335
+ // A key the proposed bundle no longer carries at all is a deletion,
336
+ // which `diff` reports and gates; here only nesting effects count.
337
+ const proposedValue = new Map(rows);
338
+ const lost = cmp.lost.filter((key) => proposedValue.has(key));
339
+ const shadowed = cmp.changed.filter((key) => after.get(key) !== proposedValue.get(key));
340
+ const bad = [...lost, ...shadowed];
341
+ log(
342
+ `[${locale}] resolves ${cmp.before} → ${cmp.after} (+${cmp.gained}); ` +
343
+ `hidden ${lost.length}, shadowed ${shadowed.length}`
344
+ );
345
+ for (const key of bad.slice(0, limit)) log(` ${key}`);
346
+ if (bad.length > limit) log(` … and ${bad.length - limit} more`);
347
+ problems += bad.length;
348
+ } else {
349
+ const unreadable = rows
350
+ .filter(([key, value]) => after.get(key) !== value)
351
+ .map(([key]) => key);
352
+ log(`[${locale}] ${rows.length} rows; ${unreadable.length} not read back as themselves`);
353
+ for (const key of unreadable.slice(0, limit)) log(` ${key}`);
354
+ if (unreadable.length > limit) log(` … and ${unreadable.length - limit} more`);
355
+ problems += unreadable.length;
356
+ }
357
+ }
358
+
359
+ if (problems > 0) {
360
+ log(
361
+ `\n❌ ${problems} key/locale pair(s) would not be served as the bundle says ` +
362
+ `(readsLeafSlot=${readsLeafSlot}).\n`
363
+ );
364
+ return 1;
365
+ }
366
+ log(`\n✅ Every key resolves as the bundle says (readsLeafSlot=${readsLeafSlot}).\n`);
367
+ return 0;
368
+ }
369
+
370
+ export type { ShippedKey };