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
@@ -28,6 +28,8 @@
28
28
  * }
29
29
  */
30
30
 
31
+ import { output } from './output.js';
32
+
31
33
  /**
32
34
  * Check whether required dictionary terms were used in translations.
33
35
  *
@@ -99,13 +101,20 @@ function verifyTerminology(translations, sourceFlat, dictionary) {
99
101
  */
100
102
  function logTermViolations(violations, pairKey) {
101
103
  if (violations.length === 0) return;
104
+ if (output.getMode() === 'json') {
105
+ output.warn(`${pairKey}: ${violations.length} dictionary term(s) may not have been applied`, {
106
+ pairKey, termViolations: violations,
107
+ });
108
+ return;
109
+ }
102
110
 
103
- console.error(`\n [TERM] ${pairKey}: ${violations.length} dictionary term(s) may not have been applied:`);
111
+ const lines = ['', ` [TERM] ${pairKey}: ${violations.length} dictionary term(s) may not have been applied:`];
104
112
  for (const { key, term, expected, got } of violations) {
105
- console.error(` ⚠ "${key}": expected "${expected}" for term "${term}"`);
106
- console.error(` → got "${got}"`);
113
+ lines.push(` ⚠ "${key}": expected "${expected}" for term "${term}"`);
114
+ lines.push(` → got "${got}"`);
107
115
  }
108
- console.error('');
116
+ lines.push('');
117
+ output.block(lines);
109
118
  }
110
119
 
111
120
  export { verifyTerminology, logTermViolations };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Translation Memory eviction for DAMAGED values, and the TM text of a key.
3
+ *
4
+ * THE FINDING (next-intl synthetic user, 2026-10): an ICU plural came back
5
+ * with mangled keywords, passed the gate of its day, was written AND stored
6
+ * in the TM. `verify` caught it — and `sync --force-keys Home.items` then
7
+ * re-served the same broken text from the TM for $0. Only --no-tm fixed it.
8
+ *
9
+ * WHICH ENTRY. Since model carry-over (lib/tm.js), a lookup that misses the
10
+ * current model falls back to the same text under ANOTHER model. Evicting
11
+ * the current method key alone therefore leaves the entry that actually
12
+ * served; the next lookup finds it again. So eviction removes every entry
13
+ * for (source text, locale) — under any method key — whose cached text IS
14
+ * the damaged value.
15
+ *
16
+ * NEVER A HAND-WRITTEN VALUE. Equality with a cached translation is the
17
+ * proof that the pipeline produced the value on disk (the same proof the
18
+ * i18next plural cleanup uses). A value someone wrote by hand has no TM
19
+ * entry equal to it, so nothing is evicted for it — and the file itself is
20
+ * never touched here.
21
+ */
22
+
23
+ import { cacheKey, evictTM, storeTM } from './tm.js';
24
+
25
+ /** gettext's msgctxt/msgid separator (lib/po.js). */
26
+ const CONTEXT_SEPARATOR = '\u0004';
27
+
28
+ /** Separates a borrowed plural form from the text it is cached with (never in UI text). */
29
+ const PLURAL_FORM_MARK = '\u0005';
30
+
31
+ /**
32
+ * The text a key's translation is cached under. Normally the source text;
33
+ * for a gettext entry with a context (key `msgctxt\u0004msgid`) the context
34
+ * is folded in — "Open" the verb and "Open" the adjective must not share a
35
+ * cache entry, or the second is served the first's translation.
36
+ *
37
+ * A BORROWED plural form (an i18next key translated from another
38
+ * category's source text — French `count_many` from English `count_other`,
39
+ * lib/plurals.js `borrowed`) folds its form in the same way: the two keys
40
+ * send the same source text, but the model is asked for different forms
41
+ * ("2 recettes" / "1 000 000 de recettes"). Sharing one entry, the second
42
+ * answer replaced the first, and a redo wrote one form into both keys
43
+ * (Round 6, i18next persona). The form it borrows FROM keeps the plain
44
+ * entry, so caches written before this stay valid for it. gettext
45
+ * (msgid_plural) and ARB/ICU plurals are ONE message per key — nothing is
46
+ * borrowed there.
47
+ *
48
+ * @param {string} key
49
+ * @param {string} sourceValue
50
+ * @param {string|null} [pluralForm] - The borrowed form ("many"), or null
51
+ * @returns {string}
52
+ */
53
+ function tmSourceText(key, sourceValue, pluralForm = null) {
54
+ if (typeof key !== 'string' || typeof sourceValue !== 'string') return sourceValue;
55
+ const i = key.indexOf(CONTEXT_SEPARATOR);
56
+ const base = i < 0 ? sourceValue : `${key.slice(0, i)}${CONTEXT_SEPARATOR}${sourceValue}`;
57
+ return pluralForm ? `${PLURAL_FORM_MARK}${pluralForm}${PLURAL_FORM_MARK}${base}` : base;
58
+ }
59
+
60
+ /**
61
+ * tmSourceText for a TARGET key of an expanded file (lib/plurals.js
62
+ * expandPluralsForLocale): a borrowed plural form gets its own identity.
63
+ *
64
+ * @param {string} key - Target key
65
+ * @param {string} sourceValue - The source text it is translated from
66
+ * @param {object|null} expansion - expectedForTarget(...).expansion
67
+ * @returns {string}
68
+ */
69
+ function tmTextFor(key, sourceValue, expansion) {
70
+ return tmSourceText(key, sourceValue, expansion?.borrowed?.[key] || null);
71
+ }
72
+
73
+ /**
74
+ * The texts whose cache entries PROVE the pipeline produced a value for this
75
+ * key: tmTextFor, and — for a borrowed plural form — the plain text too,
76
+ * which is where every version before this one cached it (shared with the
77
+ * form it borrows from). For proofs only ("is this value Champollion's?"),
78
+ * never for serving: the plain entry is the other form's translation.
79
+ *
80
+ * @returns {string[]}
81
+ */
82
+ function tmProofTextsFor(key, sourceValue, expansion) {
83
+ const own = tmTextFor(key, sourceValue, expansion);
84
+ const plain = tmSourceText(key, sourceValue);
85
+ return own === plain ? [own] : [own, plain];
86
+ }
87
+
88
+ /**
89
+ * An evictor over one TM object. The per-locale method-key index is built
90
+ * once (a TM can hold 70k entries; a verify run may evict many values).
91
+ *
92
+ * @param {object} tm - TM object (mutated)
93
+ * @returns {{ evictProducing: (sourceText: string, locale: string, value: string,
94
+ * extraMethodKeys?: string[]) => number }}
95
+ */
96
+ function createTMEvictor(tm) {
97
+ let index = null;
98
+ const methodKeysFor = (locale) => {
99
+ if (!index) {
100
+ index = new Map();
101
+ for (const [k, entry] of Object.entries(tm)) {
102
+ if (k === '_meta' || !entry || typeof entry.l !== 'string' || typeof entry.m !== 'string') continue;
103
+ if (!index.has(entry.l)) index.set(entry.l, new Set());
104
+ index.get(entry.l).add(entry.m);
105
+ }
106
+ }
107
+ return index.get(locale) || new Set();
108
+ };
109
+
110
+ return {
111
+ /**
112
+ * Evict every entry for (sourceText, locale) whose cached text is `value`.
113
+ *
114
+ * @returns {number} Entries removed
115
+ */
116
+ evictProducing(sourceText, locale, value, extraMethodKeys = []) {
117
+ if (!tm || typeof sourceText !== 'string' || typeof value !== 'string') return 0;
118
+ let removed = 0;
119
+ for (const m of new Set([...methodKeysFor(locale), ...extraMethodKeys])) {
120
+ const entry = tm[cacheKey(sourceText, locale, m)];
121
+ if (entry && entry.t === value && evictTM(tm, sourceText, locale, m)) removed++;
122
+ }
123
+ return removed;
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Repair a cache written before borrowed plural forms had their own entry.
130
+ *
131
+ * Then, French `count_many` and `count_other` (both translated from the
132
+ * English `count_other` text) shared ONE entry, holding whichever answer was
133
+ * stored last — the `_many` text when the model returned the keys in that
134
+ * order. Served to `count_other` by a redo, it wrote "2 de recettes". The
135
+ * files tell which it holds: when the entry's text is what the borrowed key
136
+ * holds on disk, and the other key holds something else, the entry is the
137
+ * borrowed form's translation. It moves to the borrowed form's own entry
138
+ * (that answer was paid for), and the shared entry is removed, so the form
139
+ * it borrows from is translated again the next time it is queued instead of
140
+ * being served the wrong form. An entry that matches the other key's value
141
+ * (or neither) is left as it is: it stays valid for that key, and the
142
+ * borrowed form has no entry until it is translated once more.
143
+ *
144
+ * @param {object} tm - TM object (mutated)
145
+ * @param {{ expansion: object|null, targetFlat: object, locale: string }} p
146
+ * @returns {string[]} Borrowed keys whose translation was moved
147
+ */
148
+ function splitSharedPluralEntries(tm, { expansion, targetFlat, locale }) {
149
+ const moved = new Set();
150
+ if (!tm || !expansion?.borrowed || !targetFlat) return [];
151
+ let methodKeys = null;
152
+ for (const [b, form] of Object.entries(expansion.borrowed)) {
153
+ const s = expansion.origin?.[b];
154
+ // The target must have the form it borrows from, un-borrowed.
155
+ if (!s || expansion.origin[s] !== s || expansion.borrowed[s]) continue;
156
+ const src = expansion.flat?.[b];
157
+ const vb = targetFlat[b];
158
+ const vs = targetFlat[s];
159
+ if (typeof src !== 'string' || typeof vb !== 'string' || typeof vs !== 'string' || vb === vs) continue;
160
+ if (!methodKeys) {
161
+ methodKeys = new Set();
162
+ for (const [k, entry] of Object.entries(tm)) {
163
+ if (k !== '_meta' && entry && entry.l === locale && typeof entry.m === 'string') methodKeys.add(entry.m);
164
+ }
165
+ }
166
+ const plain = tmSourceText(b, src);
167
+ const own = tmSourceText(b, src, form);
168
+ for (const mk of methodKeys) {
169
+ const entry = tm[cacheKey(plain, locale, mk)];
170
+ if (!entry || entry.t !== vb) continue;
171
+ if (!tm[cacheKey(own, locale, mk)]) storeTM(tm, own, locale, mk, entry.t);
172
+ evictTM(tm, plain, locale, mk);
173
+ moved.add(b);
174
+ }
175
+ }
176
+ return [...moved];
177
+ }
178
+
179
+ export { tmSourceText, tmTextFor, tmProofTextsFor, splitSharedPluralEntries, createTMEvictor, CONTEXT_SEPARATOR, PLURAL_FORM_MARK };
package/lib/tm-seed.js CHANGED
@@ -39,7 +39,7 @@
39
39
  import fs from 'node:fs';
40
40
  import path from 'node:path';
41
41
  import crypto from 'node:crypto';
42
- import { loadTM, saveTM, lookupTM, storeTM, isTMDirty, tmSize, tmMethodKey } from './tm.js';
42
+ import { loadTM, saveTM, lookupTM, storeTM, isTMDirty, tmSize, tmMethodKey, setModelCarryover } from './tm.js';
43
43
  import { readContentManifest } from './content-sync.js';
44
44
  import { splitBlocks } from './segment.js';
45
45
  import { isPathContained } from './security.js';
@@ -85,7 +85,7 @@ function translatableBlockTexts(body) {
85
85
  * @param {string[]|null} [options.translatableFields] - Hugo-lane front matter
86
86
  * fields (null → DEFAULT_TRANSLATABLE_FIELDS). The Docusaurus lane always
87
87
  * uses the defaults, mirroring runDocusaurusSync.
88
- * @param {string|null} [options.contentDir] - Hugo content directory (null = lane off)
88
+ * @param {string|null} [options.contentDir] - Content directory, any folder of Markdown (null = lane off)
89
89
  * @param {{ localesDir: string, docsDir: string, blogDir: string }|null} [options.docusaurus]
90
90
  * Docusaurus lane directories (null = lane off)
91
91
  * @param {boolean} [options.dryRun] - Report what would be seeded, write nothing
@@ -114,6 +114,9 @@ function seedTMFromExisting(options) {
114
114
 
115
115
  const manifest = readContentManifest(cwd);
116
116
  const tm = loadTM(cwd);
117
+ // Seeding RE-KEYS translations under the current method key; a carried
118
+ // (other-model) hit must not count as 'already cached' or nothing is re-keyed.
119
+ setModelCarryover(tm, false);
117
120
  const tmSizeBefore = tmSize(tm);
118
121
 
119
122
  const pairEntries = [...pairs.entries()]