@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KATERYNA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,666 @@
1
+ # i18n-keeper
2
+
3
+ [![CI](https://github.com/katerynaKhar/i18n-keeper/actions/workflows/ci.yml/badge.svg)](https://github.com/katerynaKhar/i18n-keeper/actions/workflows/ci.yml)
4
+
5
+ Deterministic linter for JSON, Laravel PHP, gettext and YAML locale files, as a
6
+ CLI and an MCP server. No LLM, no network, no API key — every finding is mechanically
7
+ verifiable, which is the point: you can trust the report in languages you do not
8
+ read.
9
+
10
+ ```bash
11
+ npx @katerynakhar/i18n-keeper check
12
+ ```
13
+
14
+ ```
15
+ i18n check · source: en · 16 keys · locales
16
+
17
+ locale coverage missing orphan stale errors warnings
18
+ de 93.8% 0 0 2 1 2
19
+ es 100.0% 0 1 2 0 5
20
+ fr 93.8% 1 0 2 2 3
21
+ pl 68.8% 4 0 1 8 2
22
+
23
+ errors
24
+ de nav.home structure_mismatch value in source, object in target
25
+ fr cart.total placeholder_missing {{amount}} lost
26
+ pl order.thanks placeholder_extra {{imie}} not in source
27
+ ...
28
+ ```
29
+
30
+ ## Rules
31
+
32
+ | Rule | Default | What it catches |
33
+ |---|---|---|
34
+ | `missing_key` | error | Not translated: absent from a target, or present and empty |
35
+ | `structure_mismatch` | error | Value on one side, object on the other |
36
+ | `placeholder_missing` | error | `{{name}}`, `%s`, `<0>` dropped in translation |
37
+ | `placeholder_extra` | error | Placeholder that does not exist in the source |
38
+ | `orphan_key` | warning | Key in a target locale, gone from the source |
39
+ | `identical_to_source` | off | Probably untranslated — but usually a proper noun, so opt in |
40
+ | `stale` | warning | Source changed after the translation was recorded |
41
+ | `untracked` | off | Translated but absent from the memory |
42
+ | `icu_syntax_error` | error | Malformed ICU message — throws at format time |
43
+ | `plural_missing_category` | warning | Plural lacks a form the target language requires |
44
+ | `plural_extra_category` | warning | Plural branch the target language never selects |
45
+ | `plural_selector_lost` | warning | Laravel `a|b` selection flattened to one form |
46
+ | `dnt_violation` | warning | A do-not-translate token did not survive |
47
+ | `glossary_violation` | warning | A glossary term rendered with an unapproved word |
48
+ | `inconsistent_translation` | off | One source string translated two different ways |
49
+ | `length_over_max` | warning | Wider than the limit configured for that key |
50
+ | `length_overflow` | off | Grew more than translation expansion normally allows |
51
+
52
+ Errors break at runtime. Warnings only look bad — an outdated translation still
53
+ renders, so `stale` is a warning even though it is the most interesting rule
54
+ here. Naming a rule with `--rule` also enables it, so `--rule untracked` works
55
+ without extra configuration.
56
+
57
+ ## Translation memory
58
+
59
+ Everything above compares locales against each other, which any script can do.
60
+ The memory is what makes the difference: it remembers **which source string a
61
+ translation was made from**, so a later edit to the source surfaces every
62
+ translation that silently went out of date.
63
+
64
+ ```bash
65
+ i18n-keeper sync # record what is already translated
66
+ # ... someone edits an English string ...
67
+ i18n-keeper check # every locale still holding the old translation is stale
68
+ ```
69
+
70
+ The memory lives at `.i18n/memory.json`, sorted for readable diffs, and is meant
71
+ to be committed — it turns translation state into something reviewable in git.
72
+
73
+ ```json
74
+ {
75
+ "version": 1,
76
+ "sourceLocale": "en",
77
+ "entries": {
78
+ "fr": {
79
+ "cart.checkout": {
80
+ "sourceHash": "9d0277a31e87",
81
+ "value": "Passer à la caisse",
82
+ "origin": "human",
83
+ "reviewed": true,
84
+ "updatedAt": "2026-08-30T15:03:09.972Z"
85
+ }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ Two safeguards matter more than they look:
92
+
93
+ **`sync` never silently clears a stale flag.** An entry whose translation is
94
+ unchanged keeps its old source hash, because nothing about the translation was
95
+ actually redone. Only `sync --force` accepts the current state wholesale, and
96
+ the command says how many entries it deliberately left stale.
97
+
98
+ **A hand-edited translation is not called stale.** If the target no longer
99
+ matches what the memory recorded, someone already touched it and we cannot claim
100
+ it is outdated — so the rule stays quiet rather than guessing.
101
+
102
+ ## String length
103
+
104
+ German runs about a third longer than English, so a button that fits in the
105
+ source overflows its container once translated — silently, because nothing
106
+ throws.
107
+
108
+ ### Measured in display columns, not characters
109
+
110
+ ```
111
+ "Subscribe" .length 9 columns 9
112
+ "Newsletter abonnieren" .length 21 columns 21
113
+ "ニュースレターを購読する" .length 12 columns 24
114
+ "설정" .length 2 columns 4
115
+ ```
116
+
117
+ That Japanese string is twelve characters and would pass a limit of sixteen.
118
+ It occupies twenty-four columns and does not fit. CJK and fullwidth characters
119
+ count as two, combining marks and variation selectors as zero.
120
+
121
+ Strings that are never displayed whole are not measured whole: an ICU plural
122
+ holds every branch at once but shows one, so it is skipped entirely, and
123
+ Laravel's `a|b` is measured at its widest segment.
124
+
125
+ ### Explicit limits
126
+
127
+ `.i18n/limits.json`, checked against every locale including the source:
128
+
129
+ ```json
130
+ {
131
+ "version": 1,
132
+ "keys": { "cta.subscribe": 16 },
133
+ "patterns": [{ "match": "nav.*.button", "max": 12 }]
134
+ }
135
+ ```
136
+
137
+ An exact key beats a pattern, the first matching pattern beats `default`, and
138
+ anything unmatched is not checked. `*` matches any run of characters.
139
+
140
+ ```
141
+ de cta.subscribe length_over_max 21 columns, limit 16
142
+ de nav.settings.button length_over_max 13 columns, limit 12
143
+ ja cta.subscribe length_over_max 24 columns, limit 16
144
+ ```
145
+
146
+ ### Expansion without configuration
147
+
148
+ `length_overflow` needs no limits file: it compares each translation to its
149
+ source and complains when it grew more than translation normally does. A single
150
+ ratio would be useless — short strings expand far more in relative terms — so
151
+ the allowance shrinks as strings grow:
152
+
153
+ | Source width | Allowed |
154
+ |---|---|
155
+ | ≤ 10 columns | 300% |
156
+ | ≤ 20 | 200% |
157
+ | ≤ 30 | 180% |
158
+ | ≤ 50 | 160% |
159
+ | ≤ 70 | 140% |
160
+ | longer | 130% |
161
+
162
+ These are the conventional expansion rules of thumb, not a standard, and the
163
+ rule is approximate by nature — so it is off until asked for with `--rule
164
+ length_overflow`.
165
+
166
+ ```
167
+ de settings.delete length_overflow 36 columns vs 14 in source — 257%, allowance 200%
168
+ de body.welcome length_overflow 130 columns vs 93 in source — 140%, allowance 130%
169
+ ```
170
+
171
+ ## Glossary and do-not-translate
172
+
173
+ Translating one string well is easy. Keeping one word rendered the same way
174
+ across three thousand keys, several translators and two years is the part that
175
+ drifts — and it is checkable without knowing the language.
176
+
177
+ `.i18n/glossary.json`, committed alongside the memory:
178
+
179
+ ```json
180
+ {
181
+ "version": 1,
182
+ "doNotTranslate": ["Acme", "GitHub", "OAuth"],
183
+ "terms": [
184
+ {
185
+ "source": "cart",
186
+ "targets": {
187
+ "fr": ["panier"],
188
+ "pl": ["koszyk"],
189
+ "ru": ["корзин"],
190
+ "ja": ["カート"]
191
+ }
192
+ }
193
+ ]
194
+ }
195
+ ```
196
+
197
+ A term is only checked in strings whose **source** actually contains it, and
198
+ only for locales the entry lists. Anything you have not defined is not judged.
199
+
200
+ ```
201
+ pl cart.empty glossary_violation "cart" should be "koszyk"
202
+ pl auth.signin dnt_violation GitHub must stay verbatim
203
+ ja cart.add glossary_violation "cart" should be "カート"
204
+ ```
205
+
206
+ ### Matching is built for inflected languages
207
+
208
+ Demanding a literal substring would fire on every correctly translated Slavic
209
+ string, so matching is **prefix by default**: a term written `koszyk` accepts
210
+ `koszyka`, and `корзин` accepts `корзина`, `корзину` and `корзине`. Write the
211
+ stem, not the dictionary form. Per entry, `"match"` can be `"exact"` or
212
+ `"substring"` instead, and `"caseSensitive"` can be turned on.
213
+
214
+ A term still has to start a word, so `cart` does not match `Uncartlike`. That
215
+ check is skipped for scripts written without spaces — Japanese, Chinese, Thai,
216
+ Khmer, Lao, Burmese — where a term is normally surrounded by other letters and a
217
+ boundary test would never match at all.
218
+
219
+ Do-not-translate tokens are compared **case-sensitively**, because that is the
220
+ whole point of a brand name: `Github` is reported where `GitHub` was expected.
221
+
222
+ ### Consistency without a glossary
223
+
224
+ `inconsistent_translation` needs no configuration: it reports one source string
225
+ that received two different translations within a locale. Reusing a wording is
226
+ often deliberate, so it is off until asked for with `--rule
227
+ inconsistent_translation`.
228
+
229
+ ## Plural forms
230
+
231
+ English has two plural forms, Polish has four, Arabic has six, Japanese has one.
232
+ A translation copied from the English shape is therefore not merely stylistically
233
+ off — it renders the wrong grammar for whole ranges of numbers, silently.
234
+
235
+ Categories come from `Intl.PluralRules`, i.e. the ICU data already in the
236
+ runtime, rather than a table in this repository that would drift out of date.
237
+
238
+ ```
239
+ pl cart.removed plural_missing_category pl needs one/few/many/other, has one/other
240
+ ja cart.removed plural_extra_category one is not a plural category in ja
241
+ ar file_* plural_missing_category ar needs zero/one/two/few/many/other, has one/other
242
+ ```
243
+
244
+ Three plural conventions are understood: ICU messages
245
+ (`{count, plural, one {# item} other {# items}}`), i18next suffix keys
246
+ (`item_one`, `item_few`), and Rails or Symfony nesting (`items.one`,
247
+ `items.few`). Findings name the group the way the project writes it —
248
+ `item_*` or `items.*`.
249
+
250
+ A single sibling is not treated as a plural group, so a key literally named
251
+ `numbers.one` is never asked to grow a `few` form.
252
+
253
+ Getting this right also **removes** findings that a locale-diffing tool would
254
+ otherwise invent:
255
+
256
+ - `item_few` exists in Polish and not in English. That is correct, not an orphan.
257
+ - `item_one` is absent from Japanese, which has no such form. That is correct,
258
+ not a missing key — and it is left out of the coverage denominator, so a
259
+ complete Japanese locale reads as 100%.
260
+
261
+ When a locale tag is not recognised, nothing is asserted. `Intl.PluralRules`
262
+ quietly falls back to the system locale for unknown tags — asking about `zz` on
263
+ a Russian machine reports four categories — so a resolved language subtag that
264
+ does not match the request is treated as unknown rather than as an answer.
265
+
266
+ Laravel's `a|b` and `{0} none|[1,*] many` selection is its own mechanism, not
267
+ CLDR, so it is not judged against CLDR categories. The one unambiguous failure —
268
+ a source that selects between forms translated as a single form — is reported as
269
+ `plural_selector_lost`.
270
+
271
+ ## Placeholder syntaxes
272
+
273
+ Detected by default: `{{name}}` (mustache/i18next), `{name}` and
274
+ `{count, plural, ...}` (ICU), `%{name}` (Ruby), `%s` / `%1$s` (printf),
275
+ `<0>…</0>` (react-i18next `<Trans>`).
276
+
277
+ Patterns are applied most-specific first and each match is masked out, so
278
+ `{{name}}` is never also counted as `{name}`.
279
+
280
+ Laravel's `:name` is off in a plain JSON project, where it false-positives on
281
+ prose like `Warning:Important`. It turns on for a PHP project — one with PHP
282
+ language files, a `composer.json`, or a `lang/` directory — because Laravel
283
+ keeps string-keyed translations in `lang/xx.json`, placeholders and all, so the
284
+ file extension alone would miss them. `--syntax` overrides the choice.
285
+
286
+ ## Formats and layouts
287
+
288
+ JSON, Laravel PHP, gettext and YAML, in either layout, auto-detected:
289
+
290
+ ```
291
+ locales/en.json locales/en/common.json -> common.cart.total
292
+ lang/en.php lang/en/validation.php -> validation.max.string
293
+ config/locales/en.yml locale/en/LC_MESSAGES/app.po -> app.<msgid>
294
+ ```
295
+
296
+ Formats can coexist: a locale directory holding `messages.php` next to a
297
+ `lang/en.json` is read as one keyspace. When PHP files are present, Laravel's
298
+ `:name` interpolation is enabled automatically, and `:name`, `:Name` and
299
+ `:NAME` are treated as one placeholder because Laravel renders them from the
300
+ same replacement.
301
+
302
+ ### gettext
303
+
304
+ The msgid *is* the source text, so a `.pot` — or any catalogue with empty
305
+ `msgstr` — works as the source locale without a parallel English file.
306
+
307
+ The format also already tracks what the translation memory was built for: an
308
+ entry flagged `#, fuzzy` is reported as `stale` with no memory involved.
309
+
310
+ ```
311
+ fr messages.Add to cart stale marked fuzzy in the catalogue
312
+ fr messages.Welcome, %s! placeholder_missing %s lost
313
+ pl messages.adjective|Open missing_key not translated
314
+ pl messages.%d file plural_missing_category header declares nplurals=3, entry has 2
315
+ ```
316
+
317
+ `msgctxt` disambiguates, and shows in keys as `context|msgid`. Entries
318
+ commented out with `#~` are already removed from the catalogue and are not
319
+ reported as orphans. `LC_MESSAGES` is dropped from key paths, since it is
320
+ directory layout rather than namespace. The last check above needs no CLDR at
321
+ all: the catalogue header states its own form count.
322
+
323
+ ### YAML
324
+
325
+ Rails nests a whole file under its locale code, which is stripped — otherwise
326
+ every key in `en.yml` would differ from every key in `fr.yml`. Rails also
327
+ writes plurals as nested `one:` / `other:` keys, which are recognised
328
+ alongside i18next's `item_one` suffixes.
329
+
330
+ This is the one format with a dependency (`yaml`). The PHP parser is hand
331
+ written because the alternative there was executing untrusted code; YAML poses
332
+ no such hazard, and its spec is deep enough — anchors, block scalars, implicit
333
+ typing — that a hand-rolled subset would quietly misread real files. Notably,
334
+ under YAML 1.1 a `no:` key becomes `false`, which would silently corrupt a
335
+ Norwegian entry; the library's 1.2 default keeps it a string.
336
+
337
+ ### PHP files are parsed, never executed
338
+
339
+ Locale files come from the repository being linted. Running them would mean
340
+ executing untrusted code, and would force PHP onto every machine and CI runner
341
+ using the linter. So `i18n-keeper` ships its own parser for the
342
+ `<?php return [...];` subset — literal arrays, both quote styles with full
343
+ escape handling, `array()`, integer and string keys, and all three comment
344
+ styles.
345
+
346
+ Anything outside that subset — variables, interpolation, concatenation,
347
+ function calls, heredocs, statements after the return — is a clear error naming
348
+ the line, not a silent guess:
349
+
350
+ ```
351
+ Cannot parse lang/fr.php
352
+ Constants and function calls are not supported (line 4)
353
+ ```
354
+
355
+ The parser is verified differentially against PHP itself: `npm run test:php`
356
+ reads every fixture with the real interpreter and compares the two results
357
+ structurally. PHP is a development dependency for that test only.
358
+
359
+ ## Usage
360
+
361
+ ```
362
+ i18n-keeper check [path] lint locale files
363
+ i18n-keeper scan [path] show what would be checked
364
+ i18n-keeper sync [path] record current translations in the memory
365
+ i18n-keeper translate [path] fill the missing and stale set with Claude
366
+ i18n-keeper apply <file> [path] write proposals saved by translate
367
+ i18n-keeper review [path] sign off on machine translations
368
+
369
+ --locales <dir> locales directory (default: auto-detect)
370
+ --source <locale> source locale (default: en, else the first found)
371
+ --locale <locale> limit to this locale (repeatable)
372
+ --memory <file> translation memory (default: .i18n/memory.json)
373
+ --no-memory ignore the memory; disables stale detection
374
+ --glossary <file> glossary (default: .i18n/glossary.json)
375
+ --no-glossary ignore the glossary
376
+ --limits <file> width limits (default: .i18n/limits.json)
377
+ --no-limits ignore the width limits
378
+
379
+ check
380
+ --rule <rule> only report this rule, enabling it if off (repeatable)
381
+ --ignore-identical <a,b> values allowed to equal the source
382
+ --syntax <a,b> override placeholder syntaxes
383
+ --limit <n> max findings printed (default: 40)
384
+ --json machine-readable output
385
+
386
+ sync
387
+ --origin <human|machine> who produced these translations (default: human)
388
+ --force re-record unchanged translations, clearing stale
389
+
390
+ translate
391
+ --write apply accepted translations (default: write nothing)
392
+ --cap <n> most strings per run (default: 50)
393
+ --batch <n> strings per request (default: 20)
394
+ --model <id> default: claude-opus-5
395
+ --effort <level> low|medium|high|xhigh|max (default: medium)
396
+ --only <kind> fill | repair | refresh (repeatable; default: all)
397
+ --save <file> keep the proposals for a later apply
398
+
399
+ apply
400
+ --dry-run re-check the saved proposals and report, writing nothing
401
+
402
+ review
403
+ --key <k> only this key (repeatable)
404
+ --all every unreviewed translation
405
+ --dry-run list what would be signed off, changing nothing
406
+ ```
407
+
408
+ Exit codes: `0` clean, `1` at least one error, `2` the tool itself failed.
409
+ Suitable for CI and pre-commit as-is.
410
+
411
+ ## MCP server
412
+
413
+ The same core is exposed over MCP, so an agent can audit locales itself.
414
+
415
+ ```bash
416
+ claude mcp add i18n-keeper -- node /path/to/i18n-keeper/dist/mcp.js
417
+ ```
418
+
419
+ Or per project, in `.mcp.json`:
420
+
421
+ ```json
422
+ {
423
+ "mcpServers": {
424
+ "i18n-keeper": {
425
+ "command": "node",
426
+ "args": ["/path/to/i18n-keeper/dist/mcp.js"]
427
+ }
428
+ }
429
+ }
430
+ ```
431
+
432
+ | Tool | Returns |
433
+ |---|---|
434
+ | `i18n_scan` | Locale directory, layout, locales and whether a memory exists |
435
+ | `i18n_status` | Per-locale coverage and counts, no individual findings |
436
+ | `i18n_check` | Findings, filterable by `locale` / `rule` / `severity`, paged via `offset` |
437
+ | `i18n_sync` | Records translations in the memory — the only tool that writes |
438
+
439
+ Findings are paged (25 per call by default) because tool output costs the agent
440
+ context; `i18n_status` exists so an agent can get the shape of the problem for a
441
+ few dozen tokens before asking for detail. `i18n_status`, `i18n_check` and
442
+ `i18n_sync` also return `structuredContent`, so the numbers can be consumed
443
+ without parsing the table.
444
+
445
+ ## Machine translation
446
+
447
+ Everything above is deterministic and offline. This one command is neither: it
448
+ calls Claude, and its output cannot be verified by reading it in a language you
449
+ do not speak.
450
+
451
+ So it is not trusted. **Every proposal goes back through the same checks the
452
+ linter applies, and anything that fails is rejected rather than written.**
453
+
454
+ ```bash
455
+ i18n-keeper translate # propose, validate, print — writes nothing
456
+ i18n-keeper translate --write # also apply the accepted ones
457
+ i18n-keeper translate --save review.json # keep the proposals for later
458
+ i18n-keeper apply review.json # write them, without translating again
459
+ ```
460
+
461
+ The work list comes from the report, so the linter decides what needs doing —
462
+ in three kinds:
463
+
464
+ | Kind | From | Meaning |
465
+ |---|---|---|
466
+ | `fill` | `missing_key` | No usable translation exists — absent, or present and empty |
467
+ | `repair` | see below | One exists and the linter proved it wrong |
468
+ | `refresh` | `stale` | One exists and its source has moved |
469
+
470
+ Narrow it with `--only fill`, `--only repair`, `--only refresh` (repeatable).
471
+
472
+ ### What counts as repairable
473
+
474
+ A defect is only handed back to the model if the local check can **confirm the
475
+ repair afterwards**. A fix nobody can verify is a fix nobody should trust, so
476
+ those findings are left for a human. That single rule picks the set:
477
+
478
+ `placeholder_missing`, `placeholder_extra`, `icu_syntax_error`,
479
+ `plural_missing_category`, `glossary_violation`, `dnt_violation`,
480
+ `length_over_max`.
481
+
482
+ It leaves out `identical_to_source` — often correct, since "Email" really is
483
+ "Email" in French, and forcing a change would make it worse — along with
484
+ `plural_extra_category` and `plural_selector_lost`, which the single-string
485
+ validator does not check and therefore could not confirm.
486
+
487
+ A repair is sent with the wording someone already chose and the exact findings
488
+ against it, and asked to change only what those require:
489
+
490
+ ```json
491
+ {
492
+ "key": "order.thanks",
493
+ "source": "Thanks, {{name}}!",
494
+ "placeholders_that_must_survive": ["{{name}}"],
495
+ "current_translation": "Dziękujemy, {{imie}}!",
496
+ "problems_to_fix": [
497
+ "placeholder_extra: {{imie}} not in source",
498
+ "placeholder_missing: {{name}} lost"
499
+ ]
500
+ }
501
+ ```
502
+
503
+ One key broken several ways carries every reason at once, and a repair that does
504
+ not actually repair is rejected like any other proposal.
505
+
506
+ ### The constraints go in, not just on afterwards
507
+
508
+ Each string is sent with everything the checks will later demand of it: the
509
+ placeholders that must survive, the plural categories the target language
510
+ requires, the glossary forms and do-not-translate tokens that apply to *that*
511
+ string, and the width limit for that key.
512
+
513
+ Then the result is checked anyway. When a proposal fails, it goes back once with
514
+ the specific rule it broke:
515
+
516
+ ```
517
+ request: fr, 4 strings
518
+ request: fr, 3 strings (retry)
519
+
520
+ accept cart.empty Votre panier est vide
521
+ accept cart.total Total : {{amount}}
522
+ REJECT nav.subscribe S'abonner à la lettre d'information
523
+ ! 35 columns, limit 12
524
+ ```
525
+
526
+ The first attempt had dropped `{{amount}}` and translated *cart* as *chariot*
527
+ against the glossary; the retry fixed both. The third string was too wide twice
528
+ and was never written.
529
+
530
+ ### Look first, apply later, pay once
531
+
532
+ `--save` keeps the proposals in a file that `apply` can write afterwards, so
533
+ reviewing before applying does not mean paying for the translation twice. The
534
+ file is written even when a run stops early, so partial work survives.
535
+
536
+ **`apply` puts every proposal through the checks again.** A saved file can be
537
+ days old and is editable by hand, so nothing is written on the strength of a
538
+ check made earlier against files that may since have moved:
539
+
540
+ ```
541
+ dropped
542
+ fr cart.total ! placeholders lost: {{amount}}
543
+ fr gone ! the key is no longer in the source locale
544
+ fr moved ! the source string changed after the proposal was made
545
+ fr cart.empty ! was rejected when proposed
546
+ ```
547
+
548
+ The first of those is a translation someone edited inside the saved file after
549
+ it had passed. It does not get written.
550
+
551
+ `apply --dry-run` reports without writing.
552
+
553
+ ### Nothing is written by accident
554
+
555
+ Without `--write` the command only prints. With it, accepted translations are
556
+ applied and recorded in the memory as `origin: "machine"`, `reviewed: false` —
557
+ so a human can find every unreviewed machine string later, and `stale` keeps
558
+ working from there.
559
+
560
+ ### Writing back without losing the file
561
+
562
+ Reading discards everything that is not a key or a value: comments, quote
563
+ styles, blank lines, anchors, translator notes. Writing must not. So no writer
564
+ re-serialises a parsed tree — each edits the text in place and leaves every byte
565
+ it had no reason to touch.
566
+
567
+ - **JSON** is the exception, and the easy one: no comments, no styles, nothing
568
+ to lose, so it is re-serialised.
569
+ - **PHP** replaces the exact span of one value. A comment above the entry, a
570
+ `'C:\\Users\\shared'` escape, a `"caf\u{e9}"` written with a unicode escape —
571
+ all come out byte for byte as they went in. New keys are inserted into their
572
+ array with the indentation the neighbours use, and missing levels are created.
573
+ - **YAML** goes through the document model, which keeps comments and anchors
574
+ across a round trip. An existing scalar is mutated rather than replaced, so a
575
+ block scalar stays a block scalar. **An alias is refused**: writing through
576
+ `*shared` would silently change every key that shares the anchor.
577
+ - **gettext** is edited by line, leaving the header, obsolete `#~` entries and
578
+ multi-line msgids alone. A new entry is appended with its `msgctxt`.
579
+
580
+ A gettext entry written this way is marked `#, fuzzy`. That flag is gettext's
581
+ own word for "no person has reviewed this", which is exactly what the memory
582
+ records as `reviewed: false`; leaving it off would claim an approval nobody
583
+ gave. `check` then reports those entries as stale, which is correct.
584
+
585
+ A locale file that does not exist yet is created for JSON and PHP, whose empty
586
+ form is unambiguous. It is not invented for YAML, whose shape depends on whether
587
+ the project nests under a locale root, nor for gettext, whose header declares
588
+ the language's own plural rules. Those are reported as not written.
589
+
590
+ Exit code is 1 whenever anything was rejected, so a pipeline notices.
591
+
592
+ A refusal applies to one batch and the run carries on. Anything else — no
593
+ credentials, a rate limit, a dropped connection — will hit every remaining
594
+ batch identically, so the run stops and says how many strings were never
595
+ attempted. Those are not reported as rejections: the checks never saw them.
596
+
597
+ ### Signing off
598
+
599
+ Recording machine output as unreviewed is only useful if there is a way out of
600
+ that state, and nothing else in the tool provides one — not `sync`, not
601
+ `--force`. Only a person can say a translation is good:
602
+
603
+ ```bash
604
+ i18n-keeper review # what is waiting, changing nothing
605
+ i18n-keeper review --locale pl # sign off on one language
606
+ i18n-keeper review --key cart.empty # or one string
607
+ i18n-keeper review --all # everything outstanding
608
+ ```
609
+
610
+ With no selection it lists the queue with the source beside each translation,
611
+ so the review can happen in the terminal rather than by hunting through files:
612
+
613
+ ```
614
+ pl cart.empty (machine)
615
+ en Your cart is empty
616
+ pl Twój koszyk jest pusty
617
+
618
+ 3 waiting. Sign off with --all, or narrow with --locale / --key.
619
+ ```
620
+
621
+ A sign-off records `reviewedAt` and survives later edits to the *source*; only
622
+ rewriting the translation itself clears it. `--dry-run` shows what would be
623
+ marked without touching the file.
624
+
625
+ ### Cost and credentials
626
+
627
+ Needs `ANTHROPIC_API_KEY`, or a profile from `ant auth login`. Defaults to
628
+ `claude-opus-5` at `--effort medium`, batches of 20 strings, and at most 50
629
+ strings per run — raise with `--cap`. The source strings, their keys and their
630
+ constraints are what gets sent.
631
+
632
+ ## Known limits
633
+
634
+ - A gettext key joins `msgctxt` and `msgid` with `|`. A contextless msgid that
635
+ contains a pipe is indistinguishable from a contextual one, which only matters
636
+ when appending an entry the catalogue has never carried.
637
+ - New locale files are created for JSON and PHP only; see above.
638
+
639
+ ## Development
640
+
641
+ ```bash
642
+ npm install
643
+ npm run build
644
+ npm run demo # CLI against the JSON demo fixture
645
+ npm run demo:laravel # CLI against the Laravel fixture
646
+ npm run walkthrough # the whole memory/stale lifecycle, step by step
647
+ npm run smoke # drives the MCP server as a real client would
648
+ npm run test:php # our PHP parser vs the real interpreter (needs php)
649
+ npm run test:laravel # placeholder casing, flat PHP layout, parse errors
650
+ npm run test:plurals # ICU scanner and CLDR category resolution
651
+ npm run test:glossary # term matching across scripts, and glossary errors
652
+ npm run test:lengths # display width, limit resolution, limits-file errors
653
+ npm run test:formats # gettext parsing, YAML typing traps, parse errors
654
+ npm run test:translate # the translation gate and repairs, against a stub client
655
+ npm run test:apply # save/apply, and every way a saved proposal goes stale
656
+ npm run test:writers # writing into PHP, YAML and gettext without losing anything
657
+ npm run test:review # the review queue and its only exit
658
+ npm run test:detection # what a real third-party project taught the scanner
659
+ npm run demo:plurals # five locales with one, two, four and six plural forms
660
+ npm run demo:glossary # inflection, Cyrillic stems, CJK and brand names
661
+ npm run demo:lengths # German expansion and double-width Japanese
662
+ npm run demo:gettext # fuzzy entries, msgctxt, nplurals
663
+ npm run demo:rails # locale roots and nested plural keys
664
+ ```
665
+
666
+ MIT.