@katerynakhar/i18n-keeper 0.16.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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +666 -0
  3. package/dist/apply.d.ts +66 -0
  4. package/dist/apply.js +254 -0
  5. package/dist/apply.js.map +1 -0
  6. package/dist/check.d.ts +5 -0
  7. package/dist/check.js +326 -0
  8. package/dist/check.js.map +1 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +525 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/formats/error.d.ts +12 -0
  13. package/dist/formats/error.js +19 -0
  14. package/dist/formats/error.js.map +1 -0
  15. package/dist/formats/flatten.d.ts +6 -0
  16. package/dist/formats/flatten.js +26 -0
  17. package/dist/formats/flatten.js.map +1 -0
  18. package/dist/formats/json-write.d.ts +7 -0
  19. package/dist/formats/json-write.js +55 -0
  20. package/dist/formats/json-write.js.map +1 -0
  21. package/dist/formats/json.d.ts +6 -0
  22. package/dist/formats/json.js +21 -0
  23. package/dist/formats/json.js.map +1 -0
  24. package/dist/formats/php-write.d.ts +16 -0
  25. package/dist/formats/php-write.js +130 -0
  26. package/dist/formats/php-write.js.map +1 -0
  27. package/dist/formats/php.d.ts +44 -0
  28. package/dist/formats/php.js +356 -0
  29. package/dist/formats/php.js.map +1 -0
  30. package/dist/formats/po-write.d.ts +3 -0
  31. package/dist/formats/po-write.js +200 -0
  32. package/dist/formats/po-write.js.map +1 -0
  33. package/dist/formats/po.d.ts +30 -0
  34. package/dist/formats/po.js +202 -0
  35. package/dist/formats/po.js.map +1 -0
  36. package/dist/formats/write.d.ts +29 -0
  37. package/dist/formats/write.js +19 -0
  38. package/dist/formats/write.js.map +1 -0
  39. package/dist/formats/yaml-write.d.ts +12 -0
  40. package/dist/formats/yaml-write.js +42 -0
  41. package/dist/formats/yaml-write.js.map +1 -0
  42. package/dist/formats/yaml.d.ts +18 -0
  43. package/dist/formats/yaml.js +51 -0
  44. package/dist/formats/yaml.js.map +1 -0
  45. package/dist/glossary.d.ts +38 -0
  46. package/dist/glossary.js +140 -0
  47. package/dist/glossary.js.map +1 -0
  48. package/dist/lengths.d.ts +37 -0
  49. package/dist/lengths.js +195 -0
  50. package/dist/lengths.js.map +1 -0
  51. package/dist/mcp.d.ts +2 -0
  52. package/dist/mcp.js +374 -0
  53. package/dist/mcp.js.map +1 -0
  54. package/dist/memory.d.ts +72 -0
  55. package/dist/memory.js +162 -0
  56. package/dist/memory.js.map +1 -0
  57. package/dist/placeholders.d.ts +15 -0
  58. package/dist/placeholders.js +103 -0
  59. package/dist/placeholders.js.map +1 -0
  60. package/dist/plurals.d.ts +59 -0
  61. package/dist/plurals.js +280 -0
  62. package/dist/plurals.js.map +1 -0
  63. package/dist/report.d.ts +2 -0
  64. package/dist/report.js +126 -0
  65. package/dist/report.js.map +1 -0
  66. package/dist/scan.d.ts +23 -0
  67. package/dist/scan.js +250 -0
  68. package/dist/scan.js.map +1 -0
  69. package/dist/translate.d.ts +93 -0
  70. package/dist/translate.js +369 -0
  71. package/dist/translate.js.map +1 -0
  72. package/dist/types.d.ts +88 -0
  73. package/dist/types.js +50 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/version.d.ts +1 -0
  76. package/dist/version.js +28 -0
  77. package/dist/version.js.map +1 -0
  78. package/package.json +77 -0
  79. package/src/apply.ts +334 -0
  80. package/src/check.ts +468 -0
  81. package/src/cli.ts +637 -0
  82. package/src/formats/error.ts +19 -0
  83. package/src/formats/flatten.ts +38 -0
  84. package/src/formats/json-write.ts +66 -0
  85. package/src/formats/json.ts +27 -0
  86. package/src/formats/php-write.ts +146 -0
  87. package/src/formats/php.ts +414 -0
  88. package/src/formats/po-write.ts +230 -0
  89. package/src/formats/po.ts +233 -0
  90. package/src/formats/write.ts +41 -0
  91. package/src/formats/yaml-write.ts +53 -0
  92. package/src/formats/yaml.ts +62 -0
  93. package/src/glossary.ts +191 -0
  94. package/src/lengths.ts +214 -0
  95. package/src/mcp.ts +451 -0
  96. package/src/memory.ts +227 -0
  97. package/src/placeholders.ts +123 -0
  98. package/src/plurals.ts +320 -0
  99. package/src/report.ts +162 -0
  100. package/src/scan.ts +280 -0
  101. package/src/translate.ts +486 -0
  102. package/src/types.ts +136 -0
  103. package/src/version.ts +27 -0
package/src/cli.ts ADDED
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { relative } from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+ import {
6
+ applyProposals,
7
+ loadRun,
8
+ recheck,
9
+ recordMachine,
10
+ saveRun,
11
+ ApplyError,
12
+ SAVE_VERSION,
13
+ } from './apply.js';
14
+ import { check } from './check.js';
15
+ import { FormatError, describeFormatError } from './formats/error.js';
16
+ import {
17
+ GlossaryError,
18
+ glossaryPath,
19
+ loadGlossary,
20
+ type Glossary,
21
+ } from './glossary.js';
22
+ import { LimitsError, limitsPath, loadLimits, type Limits } from './lengths.js';
23
+ import {
24
+ MemoryError,
25
+ emptyMemory,
26
+ hashValue,
27
+ loadMemory,
28
+ markReviewed,
29
+ unreviewed,
30
+ memoryPath,
31
+ saveMemory,
32
+ syncMemory,
33
+ type Memory,
34
+ } from './memory.js';
35
+ import { renderReport } from './report.js';
36
+ import {
37
+ collectJobs,
38
+ runTranslation,
39
+ DEFAULT_BATCH,
40
+ DEFAULT_CAP,
41
+ DEFAULT_MODEL,
42
+ JOB_KINDS,
43
+ type JobKind,
44
+ } from './translate.js';
45
+ import { ALL_SYNTAXES } from './placeholders.js';
46
+ import { ScanError, detectProject, listLocales, loadBundle, sourceIsMsgid } from './scan.js';
47
+ import { RULE_IDS, type Config, type RuleId } from './types.js';
48
+ import { VERSION } from './version.js';
49
+
50
+ const HELP = `i18n-keeper ${VERSION}
51
+
52
+ i18n-keeper check [path] lint locale files
53
+ i18n-keeper scan [path] show what would be checked
54
+ i18n-keeper sync [path] record current translations in the memory
55
+ i18n-keeper translate [path] fill the missing and stale set with Claude
56
+ i18n-keeper apply <file> [path] write proposals saved by translate
57
+ i18n-keeper review [path] sign off on machine translations
58
+
59
+ Options
60
+ --locales <dir> locales directory (default: auto-detect)
61
+ --source <locale> source locale (default: en, else the first found)
62
+ --locale <locale> limit to this locale (repeatable)
63
+ --memory <file> translation memory (default: .i18n/memory.json)
64
+ --no-memory ignore the memory; disables stale detection
65
+ --glossary <file> glossary (default: .i18n/glossary.json)
66
+ --no-glossary ignore the glossary
67
+ --limits <file> width limits (default: .i18n/limits.json)
68
+ --no-limits ignore the width limits
69
+
70
+ check
71
+ --rule <rule> only report this rule (repeatable)
72
+ --ignore-identical <a,b> values allowed to equal the source, e.g. OK,Email
73
+ --syntax <a,b> placeholder syntaxes: ${ALL_SYNTAXES.join(', ')}
74
+ --limit <n> max findings to print (default: 40)
75
+ --json machine-readable output
76
+
77
+ sync
78
+ --origin <human|machine> who produced these translations (default: human)
79
+ --force also re-record unchanged translations, clearing stale
80
+
81
+ translate
82
+ --write apply accepted translations (otherwise nothing is written)
83
+ --save <file> save the proposals so apply can write them later,
84
+ without paying for the translation twice
85
+ --cap <n> most strings to translate in one run (default: ${DEFAULT_CAP})
86
+ --batch <n> strings per request (default: ${DEFAULT_BATCH})
87
+ --model <id> default: ${DEFAULT_MODEL}
88
+ --effort <level> low|medium|high|xhigh|max (default: medium)
89
+ --only <kind> fill | repair | refresh (repeatable; default: all three)
90
+ fill strings with no translation yet
91
+ repair translations the linter proved wrong
92
+ refresh translations whose source has moved
93
+
94
+ Sends the source strings, their keys and their constraints to the Anthropic
95
+ API. Every proposal is re-checked locally and rejected if it breaks a rule.
96
+
97
+ apply
98
+ --dry-run re-check the saved proposals and report, writing nothing
99
+
100
+ review
101
+ --locale <l> only this locale (repeatable)
102
+ --key <k> only this key (repeatable)
103
+ --all every unreviewed translation
104
+ --dry-run list what would be signed off, changing nothing
105
+
106
+ With no selection it lists what is waiting, and changes nothing. Nothing
107
+ else in the tool can mark a translation reviewed; that is the point of
108
+ recording machine output as unreviewed in the first place.
109
+
110
+ Every proposal is checked again before it is written: a saved file may be
111
+ days old, and it is editable by hand.
112
+
113
+ Exits 1 when there is at least one error.`;
114
+
115
+ function fail(message: string): never {
116
+ process.stderr.write(`${message}\n`);
117
+ process.exit(2);
118
+ }
119
+
120
+ const { values, positionals } = (() => {
121
+ try {
122
+ return parseArgs({
123
+ allowPositionals: true,
124
+ options: {
125
+ locales: { type: 'string' },
126
+ source: { type: 'string' },
127
+ locale: { type: 'string', multiple: true },
128
+ rule: { type: 'string', multiple: true },
129
+ memory: { type: 'string' },
130
+ 'no-memory': { type: 'boolean' },
131
+ glossary: { type: 'string' },
132
+ 'no-glossary': { type: 'boolean' },
133
+ limits: { type: 'string' },
134
+ 'no-limits': { type: 'boolean' },
135
+ 'ignore-identical': { type: 'string' },
136
+ syntax: { type: 'string' },
137
+ origin: { type: 'string' },
138
+ force: { type: 'boolean' },
139
+ write: { type: 'boolean' },
140
+ model: { type: 'string' },
141
+ effort: { type: 'string' },
142
+ batch: { type: 'string' },
143
+ cap: { type: 'string' },
144
+ only: { type: 'string', multiple: true },
145
+ save: { type: 'string' },
146
+ 'dry-run': { type: 'boolean' },
147
+ key: { type: 'string', multiple: true },
148
+ all: { type: 'boolean' },
149
+ limit: { type: 'string' },
150
+ json: { type: 'boolean' },
151
+ help: { type: 'boolean', short: 'h' },
152
+ version: { type: 'boolean' },
153
+ },
154
+ });
155
+ } catch (err) {
156
+ fail(err instanceof Error ? err.message : String(err));
157
+ }
158
+ })();
159
+
160
+ if (values.version) {
161
+ process.stdout.write(`${VERSION}\n`);
162
+ process.exit(0);
163
+ }
164
+
165
+ const command = positionals[0] ?? 'check';
166
+ if (values.help || command === 'help') {
167
+ process.stdout.write(`${HELP}\n`);
168
+ process.exit(0);
169
+ }
170
+ if (
171
+ command !== 'check' &&
172
+ command !== 'scan' &&
173
+ command !== 'sync' &&
174
+ command !== 'translate' &&
175
+ command !== 'apply' &&
176
+ command !== 'review'
177
+ ) {
178
+ fail(`Unknown command: ${command}\n\n${HELP}`);
179
+ }
180
+
181
+ // `apply` takes the proposals file first, then the optional project path.
182
+ const proposalsFile = command === 'apply' ? positionals[1] : undefined;
183
+ if (command === 'apply' && !proposalsFile) {
184
+ fail('apply needs a proposals file:\n i18n-keeper apply <file> [path]');
185
+ }
186
+ const root = (command === 'apply' ? positionals[2] : positionals[1]) ?? process.cwd();
187
+
188
+ const limit = values.limit === undefined ? 40 : Number.parseInt(values.limit, 10);
189
+ if (!Number.isFinite(limit) || limit < 0) fail('--limit expects a non-negative number');
190
+
191
+ for (const rule of values.rule ?? []) {
192
+ if (!(RULE_IDS as readonly string[]).includes(rule)) {
193
+ fail(`Unknown rule: ${rule}\nAvailable: ${RULE_IDS.join(', ')}`);
194
+ }
195
+ }
196
+
197
+ const syntaxes = values.syntax?.split(',').map((s) => s.trim()).filter(Boolean);
198
+ for (const syntax of syntaxes ?? []) {
199
+ if (!ALL_SYNTAXES.includes(syntax)) {
200
+ fail(`Unknown placeholder syntax: ${syntax}\nAvailable: ${ALL_SYNTAXES.join(', ')}`);
201
+ }
202
+ }
203
+
204
+ const origin = values.origin ?? 'human';
205
+ if (origin !== 'human' && origin !== 'machine') {
206
+ fail('--origin expects "human" or "machine"');
207
+ }
208
+
209
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
210
+ const effort = values.effort ?? 'medium';
211
+ if (!(EFFORTS as readonly string[]).includes(effort)) {
212
+ fail(`--effort expects one of: ${EFFORTS.join(', ')}`);
213
+ }
214
+
215
+ function positive(flag: string, raw: string | undefined, fallback: number): number {
216
+ if (raw === undefined) return fallback;
217
+ const parsed = Number.parseInt(raw, 10);
218
+ if (!Number.isFinite(parsed) || parsed < 1) fail(`${flag} expects a positive number`);
219
+ return parsed;
220
+ }
221
+
222
+ for (const kind of values.only ?? []) {
223
+ if (!(JOB_KINDS as readonly string[]).includes(kind)) {
224
+ fail(`--only expects one of: ${JOB_KINDS.join(', ')}`);
225
+ }
226
+ }
227
+
228
+ const cap = positive('--cap', values.cap, DEFAULT_CAP);
229
+ const batchSize = positive('--batch', values.batch, DEFAULT_BATCH);
230
+
231
+ /** An explicit --limits must exist; the default path is simply optional. */
232
+ function openLimits(config: Config): { limits: Limits | null; file: string } {
233
+ const file = limitsPath(config.root, values.limits);
234
+ if (values['no-limits']) return { limits: null, file };
235
+ if (values.limits && !existsSync(file)) fail(`Limits file not found: ${file}`);
236
+ return { limits: loadLimits(file), file };
237
+ }
238
+
239
+ /** An explicit --glossary must exist; the default path is simply optional. */
240
+ function openGlossary(config: Config): { glossary: Glossary | null; file: string } {
241
+ const file = glossaryPath(config.root, values.glossary);
242
+ if (values['no-glossary']) return { glossary: null, file };
243
+ if (values.glossary && !existsSync(file)) fail(`Glossary file not found: ${file}`);
244
+ return { glossary: loadGlossary(file), file };
245
+ }
246
+
247
+ /** An explicit --memory must exist; the default path is simply optional. */
248
+ function openMemory(config: Config): { memory: Memory | null; file: string } {
249
+ const file = memoryPath(config.root, values.memory);
250
+ if (values['no-memory']) return { memory: null, file };
251
+ if (values.memory && !existsSync(file)) fail(`Memory file not found: ${file}`);
252
+ return { memory: loadMemory(file), file };
253
+ }
254
+
255
+ /**
256
+ * Returns an exit code rather than calling process.exit: the HTTP connection
257
+ * pool is still closing when this returns, and exiting underneath it trips a
258
+ * libuv assertion on Windows.
259
+ */
260
+ async function translateCommand(config: Config): Promise<number> {
261
+ const { memory: existing } = openMemory(config);
262
+ const { glossary } = openGlossary(config);
263
+ const { limits } = openLimits(config);
264
+
265
+ const report = check(config, existing, glossary, limits);
266
+ const source = loadBundle(config, config.sourceLocale);
267
+ let jobs = collectJobs(
268
+ config,
269
+ report.findings,
270
+ source,
271
+ glossary,
272
+ limits,
273
+ values.locale,
274
+ values.only as JobKind[] | undefined,
275
+ );
276
+
277
+ const total = jobs.length;
278
+ if (total === 0) {
279
+ process.stdout.write('Nothing to do: no strings to fill, repair or refresh.\n');
280
+ return 0;
281
+ }
282
+ if (total > cap) jobs = jobs.slice(0, cap);
283
+
284
+ const locales = [...new Set(jobs.map((job) => job.locale))];
285
+ const breakdown = JOB_KINDS.map((kind) => ({
286
+ kind,
287
+ count: jobs.filter((job) => job.kind === kind).length,
288
+ }))
289
+ .filter((entry) => entry.count > 0)
290
+ .map((entry) => `${entry.count} to ${entry.kind}`)
291
+ .join(', ');
292
+
293
+ process.stdout.write(
294
+ [
295
+ `Sending ${jobs.length} string${jobs.length === 1 ? '' : 's'} ` +
296
+ `(${locales.join(', ')}) to ${values.model ?? DEFAULT_MODEL} at effort ${effort}: ` +
297
+ `${breakdown}.`,
298
+ total > cap ? `${total - cap} more are waiting; raise --cap to include them.` : '',
299
+ ]
300
+ .filter(Boolean)
301
+ .join('\n') + '\n',
302
+ );
303
+
304
+ const run = await runTranslation(config, jobs, glossary, {
305
+ model: values.model ?? DEFAULT_MODEL,
306
+ effort: effort as 'low' | 'medium' | 'high' | 'xhigh' | 'max',
307
+ batchSize,
308
+ onBatch: (locale, size, attempt) => {
309
+ process.stderr.write(
310
+ ` ${locale}: ${size} string${size === 1 ? '' : 's'}${attempt > 1 ? ' (retry)' : ''}\n`,
311
+ );
312
+ },
313
+ });
314
+
315
+ const proposals = run.proposals;
316
+
317
+ if (values.save) {
318
+ saveRun(values.save, {
319
+ version: SAVE_VERSION,
320
+ model: values.model ?? DEFAULT_MODEL,
321
+ sourceLocale: config.sourceLocale,
322
+ createdAt: new Date().toISOString(),
323
+ aborted: run.aborted,
324
+ proposals,
325
+ });
326
+ }
327
+
328
+ if (values.json) {
329
+ process.stdout.write(`${JSON.stringify(run, null, 2)}\n`);
330
+ return run.aborted || proposals.some((p) => !p.accepted) ? 1 : 0;
331
+ }
332
+
333
+ const accepted = proposals.filter((p) => p.accepted);
334
+ const rejected = proposals.filter((p) => !p.accepted);
335
+
336
+ for (const proposal of accepted) {
337
+ process.stdout.write(
338
+ ` ${proposal.locale} ${proposal.kind.padEnd(7)} ${proposal.key}\n ${proposal.value}\n`,
339
+ );
340
+ }
341
+ if (rejected.length > 0) {
342
+ process.stdout.write('\nrejected\n');
343
+ for (const proposal of rejected) {
344
+ process.stdout.write(
345
+ ` ${proposal.locale} ${proposal.kind.padEnd(7)} ${proposal.key}\n` +
346
+ ` ${proposal.value || '(nothing returned)'}\n` +
347
+ proposal.rejections.map((r) => ` ! ${r}\n`).join(''),
348
+ );
349
+ }
350
+ }
351
+
352
+ process.stdout.write(`\n${accepted.length} accepted, ${rejected.length} rejected\n`);
353
+
354
+ // An error that is not a content refusal stopped the run before the checks
355
+ // could say anything, so it must not be reported as a rejection.
356
+ if (run.aborted) {
357
+ const hint = /authentication|api[ _-]?key|credential|unauthor/i.test(run.aborted)
358
+ ? '\nSet ANTHROPIC_API_KEY, or sign in with `ant auth login`.'
359
+ : '';
360
+ process.stderr.write(
361
+ `Translation stopped: ${run.aborted}\n` +
362
+ `${jobs.length - proposals.length} of ${jobs.length} strings were never attempted.${hint}\n`,
363
+ );
364
+ return 2;
365
+ }
366
+
367
+ if (!values.write) {
368
+ process.stdout.write(
369
+ values.save
370
+ ? `Nothing written. Saved to ${values.save}; apply with: i18n-keeper apply ${values.save}\n`
371
+ : 'Nothing written. Pass --write to apply, or --save <file> to apply later.\n',
372
+ );
373
+ return rejected.length > 0 ? 1 : 0;
374
+ }
375
+
376
+ const applied = applyProposals(config, source, proposals);
377
+ const memoryFile = memoryPath(config.root, values.memory);
378
+ const memory = existing ?? emptyMemory(config.sourceLocale);
379
+ recordMachine(memory, accepted, new Date().toISOString());
380
+ saveMemory(memoryFile, memory);
381
+
382
+ process.stdout.write(
383
+ `Wrote ${applied.written} string${applied.written === 1 ? '' : 's'} to ` +
384
+ `${applied.files.length} file${applied.files.length === 1 ? '' : 's'}, ` +
385
+ `recorded as unreviewed machine output in ${relative(config.root, memoryFile)}.\n`,
386
+ );
387
+ for (const skip of applied.skipped) {
388
+ process.stdout.write(` not written: ${skip.locale} ${skip.key} — ${skip.reason}\n`);
389
+ }
390
+
391
+ return rejected.length > 0 ? 1 : 0;
392
+ }
393
+
394
+ try {
395
+ const config = detectProject(root, { localesDir: values.locales, sourceLocale: values.source });
396
+ if (syntaxes && syntaxes.length > 0) config.placeholderSyntaxes = syntaxes;
397
+ if (values['ignore-identical']) {
398
+ config.ignoreIdentical = values['ignore-identical']
399
+ .split(',')
400
+ .map((s) => s.trim())
401
+ .filter(Boolean);
402
+ }
403
+
404
+ if (command === 'scan') {
405
+ const { layout, locales } = listLocales(config.localesDir);
406
+ const { memory, file } = openMemory(config);
407
+ const glossaryInfo = openGlossary(config);
408
+ const limitsInfo = openLimits(config);
409
+ // Files that were read but carry nothing translatable are worth naming:
410
+ // silently ignoring part of a project is how a linter comes to be trusted
411
+ // for coverage it never had.
412
+ const sourceBundle = loadBundle(config, config.sourceLocale);
413
+ const passedOver = sourceBundle.skipped;
414
+ const summary = {
415
+ localesDir: config.localesDir,
416
+ layout,
417
+ sourceLocale: config.sourceLocale,
418
+ locales,
419
+ placeholderSyntaxes: config.placeholderSyntaxes,
420
+ memory: memory ? file : null,
421
+ glossary: glossaryInfo.glossary ? glossaryInfo.file : null,
422
+ limits: limitsInfo.limits ? limitsInfo.file : null,
423
+ };
424
+ if (values.json) {
425
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
426
+ } else {
427
+ process.stdout.write(
428
+ [
429
+ `locales dir ${summary.localesDir}`,
430
+ `layout ${summary.layout}`,
431
+ `source ${summary.sourceLocale}${
432
+ sourceIsMsgid(sourceBundle)
433
+ ? ' — gettext, so the source text comes from each msgid, not from this locale'
434
+ : ''
435
+ }`,
436
+ `locales ${summary.locales.join(', ')}`,
437
+ `placeholders ${summary.placeholderSyntaxes.join(', ')}`,
438
+ `memory ${memory ? relative(config.root, file) : 'none'}`,
439
+ ...(passedOver.length > 0
440
+ ? [
441
+ '',
442
+ 'passed over:',
443
+ ...passedOver.map((s) => ` ${relative(config.root, s.file)} — ${s.reason}`),
444
+ ]
445
+ : []),
446
+ `glossary ${
447
+ glossaryInfo.glossary
448
+ ? `${relative(config.root, glossaryInfo.file)} (${glossaryInfo.glossary.terms.length} terms, ${glossaryInfo.glossary.doNotTranslate.length} verbatim)`
449
+ : 'none'
450
+ }`,
451
+ `limits ${
452
+ limitsInfo.limits
453
+ ? `${relative(config.root, limitsInfo.file)} (${Object.keys(limitsInfo.limits.keys).length} keys, ${limitsInfo.limits.patterns.length} patterns)`
454
+ : 'none'
455
+ }`,
456
+ '',
457
+ ].join('\n'),
458
+ );
459
+ }
460
+ process.exit(0);
461
+ }
462
+
463
+ if (command === 'review') {
464
+ const memoryFile = memoryPath(config.root, values.memory);
465
+ const memory = loadMemory(memoryFile);
466
+ if (!memory) {
467
+ process.stdout.write(`No memory at ${relative(config.root, memoryFile)}; nothing to review.\n`);
468
+ process.exit(0);
469
+ }
470
+
471
+ const source = loadBundle(config, config.sourceLocale);
472
+ const selected = values.all === true;
473
+ const narrowed = (values.locale?.length ?? 0) > 0 || (values.key?.length ?? 0) > 0;
474
+ const waiting = unreviewed(memory, values.locale, values.key);
475
+
476
+ if (waiting.length === 0) {
477
+ process.stdout.write('Nothing waiting: every recorded translation has been reviewed.\n');
478
+ process.exit(0);
479
+ }
480
+
481
+ for (const item of waiting) {
482
+ const current = source.leaves.get(item.key)?.value;
483
+ process.stdout.write(` ${item.locale} ${item.key} (${item.entry.origin})\n`);
484
+ if (current !== undefined) {
485
+ process.stdout.write(` ${config.sourceLocale} ${current}\n`);
486
+ }
487
+ process.stdout.write(` ${item.locale} ${item.entry.value}\n`);
488
+ }
489
+
490
+ if (!selected && !narrowed) {
491
+ process.stdout.write(
492
+ `\n${waiting.length} waiting. Sign off with --all, or narrow with --locale / --key.\n`,
493
+ );
494
+ process.exit(0);
495
+ }
496
+
497
+ if (values['dry-run']) {
498
+ process.stdout.write(`\n${waiting.length} would be marked reviewed. Nothing written.\n`);
499
+ process.exit(0);
500
+ }
501
+
502
+ const marked = markReviewed(waiting, new Date().toISOString());
503
+ saveMemory(memoryFile, memory);
504
+ process.stdout.write(
505
+ `\nMarked ${marked} translation${marked === 1 ? '' : 's'} reviewed in ` +
506
+ `${relative(config.root, memoryFile)}.\n`,
507
+ );
508
+ process.exit(0);
509
+ }
510
+
511
+ if (command === 'apply') {
512
+ const saved = loadRun(proposalsFile!);
513
+ const { glossary } = openGlossary(config);
514
+ const { limits } = openLimits(config);
515
+ const source = loadBundle(config, config.sourceLocale);
516
+ const { ready, dropped } = recheck(config, source, glossary, limits, saved.proposals);
517
+
518
+ process.stdout.write(
519
+ `${saved.proposals.length} proposal${saved.proposals.length === 1 ? '' : 's'} from ` +
520
+ `${saved.model}${saved.createdAt ? `, saved ${saved.createdAt}` : ''}\n`,
521
+ );
522
+ for (const proposal of ready) {
523
+ process.stdout.write(
524
+ ` ${proposal.locale} ${proposal.kind.padEnd(7)} ${proposal.key}\n ${proposal.value}\n`,
525
+ );
526
+ }
527
+ if (dropped.length > 0) {
528
+ process.stdout.write('\ndropped\n');
529
+ for (const entry of dropped) {
530
+ process.stdout.write(
531
+ ` ${entry.proposal.locale} ${entry.proposal.key}\n ! ${entry.reason}\n`,
532
+ );
533
+ }
534
+ }
535
+ process.stdout.write(`\n${ready.length} to apply, ${dropped.length} dropped\n`);
536
+
537
+ if (values['dry-run']) {
538
+ process.stdout.write('Dry run: nothing written.\n');
539
+ process.exit(dropped.length > 0 ? 1 : 0);
540
+ }
541
+
542
+ const applied = applyProposals(config, source, ready);
543
+ const memoryFile = memoryPath(config.root, values.memory);
544
+ const memory = loadMemory(memoryFile) ?? emptyMemory(config.sourceLocale);
545
+ recordMachine(memory, ready, new Date().toISOString());
546
+ saveMemory(memoryFile, memory);
547
+
548
+ process.stdout.write(
549
+ `Wrote ${applied.written} string${applied.written === 1 ? '' : 's'} to ` +
550
+ `${applied.files.length} file${applied.files.length === 1 ? '' : 's'}, ` +
551
+ `recorded as unreviewed machine output in ${relative(config.root, memoryFile)}.\n`,
552
+ );
553
+ for (const skip of applied.skipped) {
554
+ process.stdout.write(` not written: ${skip.locale} ${skip.key} — ${skip.reason}\n`);
555
+ }
556
+
557
+ process.exit(dropped.length > 0 ? 1 : 0);
558
+ }
559
+
560
+ if (command === 'sync') {
561
+ const file = memoryPath(config.root, values.memory);
562
+ const memory = (values['no-memory'] ? null : loadMemory(file)) ?? emptyMemory(config.sourceLocale);
563
+ const result = syncMemory(config, memory, {
564
+ origin,
565
+ reviewed: origin === 'human',
566
+ locales: values.locale,
567
+ force: values.force === true,
568
+ });
569
+ saveMemory(file, memory);
570
+
571
+ if (values.json) {
572
+ process.stdout.write(`${JSON.stringify({ memory: file, ...result }, null, 2)}\n`);
573
+ } else {
574
+ process.stdout.write(
575
+ [
576
+ `memory ${relative(config.root, file)}`,
577
+ `created ${result.created} updated ${result.updated} ` +
578
+ `kept-stale ${result.keptStale} unchanged ${result.unchanged} removed ${result.removed}`,
579
+ result.keptStale > 0 && !values.force
580
+ ? `\n${result.keptStale} translation(s) still stale — retranslate them, or run sync --force to accept as-is.`
581
+ : '',
582
+ ]
583
+ .filter(Boolean)
584
+ .join('\n') + '\n',
585
+ );
586
+ }
587
+ process.exit(0);
588
+ }
589
+
590
+ if (command === 'translate') {
591
+ process.exitCode = await translateCommand(config);
592
+ } else {
593
+ const localeFilter = new Set(values.locale ?? []);
594
+ const ruleFilter = new Set((values.rule ?? []) as RuleId[]);
595
+
596
+ // Naming a rule explicitly turns it on; otherwise --rule untracked, which is
597
+ // off by default, would print nothing and look broken.
598
+ for (const rule of ruleFilter) {
599
+ if (config.rules[rule] === 'off') config.rules[rule] = 'warning';
600
+ }
601
+
602
+ const { memory } = openMemory(config);
603
+ const { glossary } = openGlossary(config);
604
+ const { limits } = openLimits(config);
605
+ const report = check(config, memory, glossary, limits);
606
+ if (localeFilter.size > 0 || ruleFilter.size > 0) {
607
+ report.findings = report.findings.filter(
608
+ (f) =>
609
+ (localeFilter.size === 0 || localeFilter.has(f.locale)) &&
610
+ (ruleFilter.size === 0 || ruleFilter.has(f.rule)),
611
+ );
612
+ if (localeFilter.size > 0) {
613
+ report.stats = report.stats.filter((s) => localeFilter.has(s.locale));
614
+ }
615
+ }
616
+
617
+ if (values.json) {
618
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
619
+ } else {
620
+ process.stdout.write(`${renderReport(report, config.root, limit)}\n`);
621
+ if (ruleFilter.size > 0) {
622
+ // The table stays project-wide on purpose; only the list below it is filtered.
623
+ process.stdout.write(`(list filtered to: ${[...ruleFilter].join(', ')})\n`);
624
+ }
625
+ }
626
+
627
+ process.exit(report.findings.some((f) => f.severity === 'error') ? 1 : 0);
628
+ }
629
+ } catch (err) {
630
+ if (err instanceof ScanError) fail(err.message);
631
+ if (err instanceof MemoryError) fail(err.message);
632
+ if (err instanceof GlossaryError) fail(err.message);
633
+ if (err instanceof LimitsError) fail(err.message);
634
+ if (err instanceof ApplyError) fail(err.message);
635
+ if (err instanceof FormatError) fail(describeFormatError(err));
636
+ throw err;
637
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Base for every format reader's failure.
3
+ *
4
+ * The CLI and the MCP server catch this one type, so adding a format cannot
5
+ * leave a new error class escaping as an uncaught stack trace.
6
+ */
7
+ export class FormatError extends Error {
8
+ constructor(
9
+ public readonly file: string,
10
+ message: string,
11
+ public readonly line?: number,
12
+ ) {
13
+ super(line === undefined ? message : `${message} (line ${line})`);
14
+ }
15
+ }
16
+
17
+ export function describeFormatError(err: FormatError): string {
18
+ return `Cannot read ${err.file}\n ${err.message}`;
19
+ }
@@ -0,0 +1,38 @@
1
+ import type { Leaf, LeafKind } from '../types.js';
2
+
3
+ export type PlainValue =
4
+ | string
5
+ | number
6
+ | boolean
7
+ | null
8
+ | PlainValue[]
9
+ | { [key: string]: PlainValue };
10
+
11
+ function kindOf(value: string | number | boolean | null): LeafKind {
12
+ if (value === null) return 'null';
13
+ if (typeof value === 'number') return 'number';
14
+ if (typeof value === 'boolean') return 'boolean';
15
+ return 'string';
16
+ }
17
+
18
+ /** Flattens a parsed locale value into dot-notation keys, shared by every format. */
19
+ export function flattenValue(
20
+ node: unknown,
21
+ prefix: string,
22
+ file: string,
23
+ leaves: Map<string, Leaf>,
24
+ containers: Set<string>,
25
+ ): void {
26
+ if (node !== null && typeof node === 'object') {
27
+ if (prefix) containers.add(prefix);
28
+ const entries = Array.isArray(node)
29
+ ? node.map((v, i) => [String(i), v] as const)
30
+ : Object.entries(node as Record<string, unknown>);
31
+ for (const [k, v] of entries) {
32
+ flattenValue(v, prefix ? `${prefix}.${k}` : k, file, leaves, containers);
33
+ }
34
+ return;
35
+ }
36
+ const value = node as string | number | boolean | null;
37
+ leaves.set(prefix, { value: value === null ? '' : String(value), kind: kindOf(value), file });
38
+ }