champollion 0.3.3 → 0.4.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 (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
package/lib/plurals.js ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * i18next plural keys — give every target locale exactly ITS plural forms.
3
+ *
4
+ * THE PROBLEM: i18next (v21+, "JSON v4") stores plurals as sibling keys with
5
+ * a CLDR category suffix:
6
+ *
7
+ * en.json { "item_one": "{{count}} item", "item_other": "{{count}} items" }
8
+ *
9
+ * English has two categories (one, other). French and Spanish have three
10
+ * (one, many, other); Japanese has one (other); Arabic has six. A sync that
11
+ * mirrors the SOURCE's keys writes `item_one`/`item_other` into every
12
+ * locale: French silently loses its `many` form (i18next falls back to the
13
+ * key for 1 000 000), and Japanese carries a `_one` it never selects.
14
+ *
15
+ * THE RULE (applied per target locale, json key-value files only):
16
+ * - The target's categories come from CLDR via
17
+ * Intl.PluralRules(target).resolvedOptions().pluralCategories — no
18
+ * hardcoded language table.
19
+ * - Each target category is translated from the source's `_other` form,
20
+ * except `_one` (from the source's `_one`) and `_zero` (from the
21
+ * source's `_zero` when it has one — i18next looks `key_zero` up for
22
+ * count 0 in EVERY language, so a source that defines it keeps it).
23
+ * - Source categories the target does not use are not created. If an
24
+ * earlier sync wrote one, sync removes it only when the Translation
25
+ * Memory proves the pipeline produced that value; a hand-written value
26
+ * is kept (and reported as an extra key).
27
+ * - Ordinal groups (`key_ordinal_one`, …) use Intl.PluralRules with
28
+ * type 'ordinal'.
29
+ * - A locale CLDR has no plural rules for (Intl falls back to another
30
+ * locale) keeps the source's categories, and the caller says so —
31
+ * guessing a category set for a language CLDR does not describe would
32
+ * be inventing grammar.
33
+ *
34
+ * DETECTION is deliberately strict, because a false positive invents keys:
35
+ * a group needs an `_other` key plus at least one other category, OR — for
36
+ * a source language whose only category is `other` (ja, zh, ko) — an
37
+ * `_other` value that interpolates {{count}}. A lone `gender_other: "Other"`
38
+ * is an ordinary key.
39
+ *
40
+ * Everything here is pure: callers (sync, the cost estimate, verify,
41
+ * integrity) pass the source map in and get the per-target expected map out,
42
+ * so all of them judge a target by the same keys.
43
+ */
44
+
45
+ const CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'];
46
+ const SUFFIX_RE = /^(.+?)(_ordinal)?_(zero|one|two|few|many|other)$/;
47
+
48
+ /** Normalize a locale code for Intl (pt_BR → pt-BR). */
49
+ function intlTag(code) {
50
+ return String(code).replace(/_/g, '-');
51
+ }
52
+
53
+ /**
54
+ * CLDR plural categories for a locale, or null when CLDR (as shipped in
55
+ * this runtime's Intl) has no rules for it.
56
+ *
57
+ * @param {string} code - Locale code
58
+ * @param {'cardinal'|'ordinal'} [type='cardinal']
59
+ * @returns {string[]|null}
60
+ */
61
+ function pluralCategoriesFor(code, type = 'cardinal') {
62
+ const tag = intlTag(code);
63
+ try {
64
+ if (Intl.PluralRules.supportedLocalesOf([tag]).length === 0) return null;
65
+ return new Intl.PluralRules(tag, { type }).resolvedOptions().pluralCategories;
66
+ } catch {
67
+ // RangeError: not a well-formed BCP 47 tag — no CLDR rules either.
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Find i18next plural groups in a flat source map.
74
+ *
75
+ * @param {object} sourceFlat - Flat key → value map of ONE source file
76
+ * @param {string} sourceLocale - Source locale code
77
+ * @returns {Map<string, { base: string, ordinal: boolean, keys: Object<string, string> }>}
78
+ * groupId → group; `keys` maps category → source key
79
+ */
80
+ function findPluralGroups(sourceFlat, sourceLocale) {
81
+ const candidates = new Map();
82
+ for (const [key, value] of Object.entries(sourceFlat)) {
83
+ if (typeof value !== 'string') continue;
84
+ const m = SUFFIX_RE.exec(key);
85
+ if (!m) continue;
86
+ const ordinal = !!m[2];
87
+ const id = `${m[1]}\u0000${ordinal ? 'ordinal' : 'cardinal'}`;
88
+ if (!candidates.has(id)) candidates.set(id, { base: m[1], ordinal, keys: {} });
89
+ candidates.get(id).keys[m[3]] = key;
90
+ }
91
+
92
+ const groups = new Map();
93
+ for (const [id, group] of candidates) {
94
+ const cats = Object.keys(group.keys);
95
+ if (!cats.includes('other')) continue;
96
+ if (cats.length >= 2) { groups.set(id, group); continue; }
97
+ // Single-category source languages (ja/zh/ko…) write only `_other`.
98
+ const sourceCats = pluralCategoriesFor(sourceLocale, group.ordinal ? 'ordinal' : 'cardinal');
99
+ const otherValue = sourceFlat[group.keys.other];
100
+ if (sourceCats && sourceCats.length === 1 && sourceCats[0] === 'other'
101
+ && /\{\{\s*count\b/.test(otherValue)) {
102
+ groups.set(id, group);
103
+ }
104
+ }
105
+ return groups;
106
+ }
107
+
108
+ /**
109
+ * A few integers that select `category` in `code` — prompt context so the
110
+ * model writes, e.g., French `many` as the form for 1 000 000.
111
+ */
112
+ function sampleCounts(code, category, type) {
113
+ let rules;
114
+ try { rules = new Intl.PluralRules(intlTag(code), { type }); } catch { return []; }
115
+ const out = [];
116
+ for (const n of [0, 1, 2, 3, 4, 5, 6, 7, 11, 12, 21, 22, 100, 101, 1000000]) {
117
+ if (rules.select(n) === category) out.push(n);
118
+ if (out.length === 3) break;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Build the map a target locale is expected to contain for one source file.
125
+ *
126
+ * @param {object} sourceFlat - Flat source map (one file)
127
+ * @param {string} sourceLocale
128
+ * @param {string} targetLocale
129
+ * @param {Map} [groups] - Precomputed findPluralGroups() result
130
+ * @returns {null | {
131
+ * flat: object, // expected target keys → SOURCE text to translate
132
+ * origin: Object<string, string>, // target key → source key it is translated from
133
+ * descriptions: Object<string, string>, // prompt context for generated categories
134
+ * unused: string[], // source plural keys this target does not use
135
+ * borrowed: Object<string, string>, // target key → its plural form, for a key
136
+ * // translated from ANOTHER category's source
137
+ * // (French `_many` from `_other`): "many",
138
+ * // "ordinal-few" — its own cache identity
139
+ * // (lib/tm-evict.js tmTextFor)
140
+ * groups: number,
141
+ * unknownLocale: boolean, // CLDR has no rules → source categories kept
142
+ * }} null when the file has no plural groups (callers keep the source map as is)
143
+ */
144
+ function expandPluralsForLocale(sourceFlat, sourceLocale, targetLocale, groups = null) {
145
+ const found = groups || findPluralGroups(sourceFlat, sourceLocale);
146
+ if (found.size === 0) return null;
147
+
148
+ const groupOfKey = new Map();
149
+ for (const [id, group] of found) {
150
+ for (const key of Object.values(group.keys)) groupOfKey.set(key, id);
151
+ }
152
+
153
+ const flat = {};
154
+ const origin = {};
155
+ const descriptions = {};
156
+ const unused = [];
157
+ const borrowed = {};
158
+ const emitted = new Set();
159
+ let unknownLocale = false;
160
+
161
+ for (const [key, value] of Object.entries(sourceFlat)) {
162
+ const id = groupOfKey.get(key);
163
+ if (id === undefined) { flat[key] = value; continue; }
164
+ if (emitted.has(id)) continue;
165
+ emitted.add(id);
166
+
167
+ const group = found.get(id);
168
+ const type = group.ordinal ? 'ordinal' : 'cardinal';
169
+ const sourceCats = Object.keys(group.keys);
170
+ let targetCats = pluralCategoriesFor(targetLocale, type);
171
+ // No CLDR rules for this locale: mirror the source's forms one-to-one
172
+ // (the pre-plural-aware behaviour), and let the caller report it.
173
+ const mirror = !targetCats;
174
+ if (mirror) {
175
+ unknownLocale = true;
176
+ targetCats = CATEGORIES.filter(c => sourceCats.includes(c));
177
+ } else if (group.keys.zero && !targetCats.includes('zero')) {
178
+ // i18next resolves key_zero for count 0 in every language.
179
+ targetCats = ['zero', ...targetCats];
180
+ }
181
+
182
+ const prefix = `${group.base}${group.ordinal ? '_ordinal' : ''}_`;
183
+ for (const cat of CATEGORIES.filter(c => targetCats.includes(c))) {
184
+ const fromCat = mirror || ((cat === 'one' || cat === 'zero') && group.keys[cat]) ? cat : 'other';
185
+ const fromKey = group.keys[fromCat];
186
+ const targetKey = `${prefix}${cat}`;
187
+ flat[targetKey] = sourceFlat[fromKey];
188
+ origin[targetKey] = fromKey;
189
+ if (fromCat !== cat) {
190
+ // Same source text as the key it borrows from, a different form:
191
+ // cached on its own (Round 6, i18next persona — `_many` and `_other`
192
+ // shared one entry, and a redo wrote the `_many` text into both).
193
+ borrowed[targetKey] = group.ordinal ? `ordinal-${cat}` : cat;
194
+ const samples = sampleCounts(targetLocale, cat, type);
195
+ descriptions[targetKey] = `${group.ordinal ? 'Ordinal' : 'Plural'} form "${cat}" (CLDR) of this message`
196
+ + (samples.length > 0 ? `, used for counts such as ${samples.join(', ')}` : '')
197
+ + '. Translate the plural text into that grammatical form.';
198
+ }
199
+ }
200
+ for (const cat of sourceCats) {
201
+ if (!targetCats.includes(cat)) unused.push(group.keys[cat]);
202
+ }
203
+ }
204
+
205
+ return { flat, origin, descriptions, unused, borrowed, groups: found.size, unknownLocale };
206
+ }
207
+
208
+ /**
209
+ * Map source-key lists (changed keys, forced keys) into the target key
210
+ * space: a generated key is changed/forced when the source key it is
211
+ * translated from is.
212
+ *
213
+ * @param {string[]} sourceKeys
214
+ * @param {ReturnType<typeof expandPluralsForLocale>} expansion
215
+ * @returns {string[]}
216
+ */
217
+ function mapSourceKeysToTarget(sourceKeys, expansion) {
218
+ if (!expansion || !sourceKeys || sourceKeys.length === 0) return sourceKeys || [];
219
+ const set = new Set(sourceKeys);
220
+ const out = new Set(sourceKeys.filter(k => Object.prototype.hasOwnProperty.call(expansion.flat, k)));
221
+ for (const [targetKey, fromKey] of Object.entries(expansion.origin)) {
222
+ if (set.has(fromKey) || set.has(targetKey)) out.add(targetKey);
223
+ }
224
+ return [...out];
225
+ }
226
+
227
+ /**
228
+ * The source key a target key's value was translated from (identity for
229
+ * ordinary keys). Used to restore manifest hashes for failed keys.
230
+ *
231
+ * @param {string} targetKey
232
+ * @param {ReturnType<typeof expandPluralsForLocale>} expansion
233
+ * @returns {string}
234
+ */
235
+ function originKey(targetKey, expansion) {
236
+ return (expansion && expansion.origin[targetKey]) || targetKey;
237
+ }
238
+
239
+ /**
240
+ * What each target's CLDR plural forms change from the source's, in words,
241
+ * for the run's own targets: "es, fr add _many; ja keeps only _other; de: as
242
+ * en". Read from Intl.PluralRules (CLDR) at run time — the line used to say
243
+ * "e.g. French adds _many" whatever the project's languages, and the docs
244
+ * named French where Spanish gains the same form (Round 13, i18next persona).
245
+ *
246
+ * @param {string} sourceLocale
247
+ * @param {string[]} targets
248
+ * @param {'cardinal'|'ordinal'} [type='cardinal']
249
+ * @returns {string} '' when nothing can be said (no CLDR rules for the source)
250
+ */
251
+ function describePluralFormChanges(sourceLocale, targets, type = 'cardinal') {
252
+ const src = pluralCategoriesFor(sourceLocale, type);
253
+ if (!src) return '';
254
+ const order = (cats) => CATEGORIES.filter(c => cats.includes(c)).map(c => `_${c}`).join(', ');
255
+ // One phrase per kind of change, the targets that share it named together.
256
+ const groups = new Map();
257
+ for (const code of [...new Set(targets)].sort()) {
258
+ const cats = pluralCategoriesFor(code, type);
259
+ let kind;
260
+ if (!cats) kind = { id: 'none' };
261
+ else {
262
+ const added = cats.filter(c => !src.includes(c));
263
+ const dropped = src.filter(c => !cats.includes(c));
264
+ if (added.length === 0 && dropped.length === 0) kind = { id: 'same' };
265
+ else if (cats.length === 1) kind = { id: `only ${cats[0]}`, only: cats[0] };
266
+ else kind = { id: `+${order(added)} -${order(dropped)}`, added: order(added), dropped: order(dropped) };
267
+ }
268
+ if (!groups.has(kind.id)) groups.set(kind.id, { kind, codes: [] });
269
+ groups.get(kind.id).codes.push(code);
270
+ }
271
+ return [...groups.values()].map(({ kind, codes }) => {
272
+ const one = codes.length === 1;
273
+ const who = codes.join(', ');
274
+ if (kind.id === 'none') return `${who}: no CLDR rules (the source's forms are kept)`;
275
+ if (kind.id === 'same') return `${who}: the same forms as ${sourceLocale}`;
276
+ if (kind.only) return `${who} ${one ? 'keeps' : 'keep'} only _${kind.only}`;
277
+ return `${who} ${[
278
+ kind.added && `${one ? 'adds' : 'add'} ${kind.added}`,
279
+ kind.dropped && `${one ? 'drops' : 'drop'} ${kind.dropped}`,
280
+ ].filter(Boolean).join(' and ')}`;
281
+ }).join('; ');
282
+ }
283
+
284
+ /**
285
+ * Keys a target file holds for an i18next plural form its language does not
286
+ * have (Spanish `count_two`, from a source with `count_one`/`count_other`):
287
+ * i18next never selects them. `_zero` is never one (i18next uses it for 0 in
288
+ * every language), and a key the source file itself defines is left to the
289
+ * rule above (removed when the cache proves sync wrote it). ONE rule for
290
+ * what `verify` warns about and what `sync --prune plural-extras` removes —
291
+ * nothing else is ever removed by it (Round 13, i18next persona: verify said
292
+ * "delete them" and offered no way to but by hand).
293
+ *
294
+ * @param {{ flat: object, pluralGroups?: Map }} unit - A source unit (lib/locale-layout.js)
295
+ * @param {object} targetFlat - The target file's flat map
296
+ * @param {string} locale
297
+ * @returns {Array<{ key: string, category: string, cats: string[] }>}
298
+ */
299
+ function pluralExtraKeys(unit, targetFlat, locale) {
300
+ const out = [];
301
+ if (!unit?.pluralGroups || unit.pluralGroups.size === 0 || !targetFlat) return out;
302
+ const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k);
303
+ for (const group of unit.pluralGroups.values()) {
304
+ const cats = pluralCategoriesFor(locale, group.ordinal ? 'ordinal' : 'cardinal');
305
+ if (!cats) continue;
306
+ for (const c of CATEGORIES) {
307
+ if (c === 'zero' || cats.includes(c)) continue;
308
+ const key = `${group.base}${group.ordinal ? '_ordinal' : ''}_${c}`;
309
+ if (has(targetFlat, key) && !has(unit.flat, key)) out.push({ key, category: c, cats });
310
+ }
311
+ }
312
+ return out;
313
+ }
314
+
315
+ export {
316
+ pluralCategoriesFor,
317
+ findPluralGroups,
318
+ expandPluralsForLocale,
319
+ mapSourceKeysToTarget,
320
+ originKey,
321
+ describePluralFormChanges,
322
+ pluralExtraKeys,
323
+ };