champollion 0.3.3

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 (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/tm.js ADDED
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Translation Memory (TM) — lightweight same-project cache.
3
+ *
4
+ * WHY THIS EXISTS:
5
+ * Without TM, re-running `champollion sync` after changing ONE English key
6
+ * re-translates every key that was modified, including keys that already
7
+ * have perfectly good translations from a previous run with the same
8
+ * source text. This wastes API tokens and adds latency.
9
+ *
10
+ * Common scenarios this helps:
11
+ * 1. Source key reverted to a previous value → TM provides instant hit
12
+ * 2. Same phrase appears in multiple locale files → first translation cached
13
+ * 3. Dry-run followed by real sync → second run reuses TM from first
14
+ * 4. Developer iterating on a single file → only truly new keys hit the API
15
+ *
16
+ * HOW IT WORKS:
17
+ * TM is a JSON file at .champollion/tm.json in the project root.
18
+ *
19
+ * Cache key: SHA-256(sourceValue + '\x00' + targetLocale + '\x00' + method)
20
+ * - Including the method ensures translations from Google Translate aren't
21
+ * served when the user switches to DeepL or a coached LLM model.
22
+ * - The null byte separator prevents "ab" + "c" colliding with "a" + "bc".
23
+ *
24
+ * Cache value: { translation, timestamp }
25
+ * - timestamp is ISO-8601, used for informational/debugging purposes only.
26
+ * - No TTL — translations don't expire. Users can delete .champollion/tm.json
27
+ * to clear the cache entirely.
28
+ *
29
+ * STORAGE FORMAT:
30
+ * {
31
+ * "_meta": { "version": 1, "created": "2026-05-24T05:30:00Z" },
32
+ * "abc123...": { "t": "Bonjour", "ts": "2026-05-24T05:30:00Z" }
33
+ * }
34
+ *
35
+ * Keys are abbreviated ('t' for translation, 'ts' for timestamp) to keep
36
+ * the file compact. At 50 languages × 500 keys = 25,000 entries, the file
37
+ * should be ~2-3 MB — comfortably manageable.
38
+ *
39
+ * USAGE:
40
+ * import { loadTM, saveTM, lookupTM, storeTM } from './tm.js';
41
+ *
42
+ * const tm = loadTM(cwd);
43
+ * const cached = lookupTM(tm, sourceValue, 'fr', 'llm');
44
+ * if (cached) { use cached; }
45
+ * else { translate, then storeTM(tm, sourceValue, 'fr', 'llm', translated); }
46
+ * saveTM(cwd, tm);
47
+ */
48
+
49
+ import fs from 'node:fs';
50
+ import path from 'node:path';
51
+ import crypto from 'node:crypto';
52
+
53
+ /**
54
+ * Current TM format version. If the format changes in a backward-incompatible
55
+ * way, bump this to invalidate old caches.
56
+ */
57
+ const TM_VERSION = 1;
58
+
59
+ /**
60
+ * Default path relative to project root.
61
+ */
62
+ const TM_DIR = '.champollion';
63
+ const TM_FILENAME = 'tm.json';
64
+
65
+ // -----------------------------------------------------------------
66
+ // Cache key generation
67
+ // -----------------------------------------------------------------
68
+
69
+ /**
70
+ * Generate a cache key for a source value + locale + method triple.
71
+ *
72
+ * Uses SHA-256 truncated to 16 hex characters (64 bits). Collision probability
73
+ * at 100k entries: ~3×10⁻¹⁰ — negligible. If a collision occurs, the worst
74
+ * case is serving one wrong cached translation that would be overwritten on
75
+ * the next sync anyway.
76
+ *
77
+ * @param {string} sourceValue - Source language value
78
+ * @param {string} locale - Target locale code
79
+ * @param {string} method - Translation method name
80
+ * @returns {string} 16-char hex hash
81
+ */
82
+ function cacheKey(sourceValue, locale, method) {
83
+ const input = `${sourceValue}\x00${locale}\x00${method}`;
84
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
85
+ }
86
+
87
+ /**
88
+ * Short stable hash used inside tmMethodKey parts (register text, coaching).
89
+ * 8 hex chars (32 bits) is plenty: it only needs to distinguish the handful
90
+ * of register/coaching variants a single project cycles through, and a
91
+ * collision merely re-serves a cached translation from another variant of
92
+ * the SAME pair — the quality gate still stands between the TM and the file.
93
+ *
94
+ * @param {string} text - Text to fingerprint
95
+ * @returns {string} 8-char hex hash
96
+ */
97
+ function _shortHash(text) {
98
+ return crypto.createHash('sha256').update(text).digest('hex').slice(0, 8);
99
+ }
100
+
101
+ /**
102
+ * Build the TM "method" key for a pair — the third component of cacheKey().
103
+ *
104
+ * WHY: the bare method name ("llm") is NOT enough to identify what shaped a
105
+ * translation. Two runs with method "llm" but different models, registers,
106
+ * or coaching prompts produce systematically different output. Keying the TM
107
+ * on the bare method silently re-served old-style translations after the
108
+ * user switched model (gemini-flash → gpt-4o), changed the register
109
+ * (formal → casual-tu), or edited their coaching file — the exact situations
110
+ * where a fresh translation is the whole point of the change.
111
+ *
112
+ * The key folds in everything on the pair config that shapes output:
113
+ * - method: translation strategy (llm, llm-coached, google-translate, …)
114
+ * - model: the specific model, when the method uses one ('' otherwise)
115
+ * - register: the preset key when known (human-readable), else a short
116
+ * hash of the custom register text ('' when unset)
117
+ * - coaching: for llm-coached pairs, a short hash of the resolved coaching
118
+ * prompt text (config.js reads coachingFile into
119
+ * coachingPrompt) plus any structured plugin coachingData.
120
+ * When only a coachingFile path is available (not yet
121
+ * resolved), the path is fingerprinted — a moved/renamed file
122
+ * still invalidates, though an in-place edit that bypassed
123
+ * config resolution would not. '' for non-coached methods.
124
+ *
125
+ * Changing any component makes old entries unreachable (a cache miss, so
126
+ * the API is consulted) WITHOUT nuking valid entries for other pairs —
127
+ * which is why callers must use this instead of bumping TM_VERSION.
128
+ *
129
+ * @param {object} pairConfig - Pair config (method, model, register, registerPreset, coaching*)
130
+ * @returns {string} Stable method-key string, e.g. "llm|google/gemini-3.5-flash|formal|"
131
+ */
132
+ function tmMethodKey(pairConfig) {
133
+ const method = pairConfig.method || 'llm';
134
+ const model = pairConfig.model || '';
135
+
136
+ const register = pairConfig.registerPreset
137
+ || (typeof pairConfig.register === 'string' && pairConfig.register.length > 0
138
+ ? _shortHash(pairConfig.register)
139
+ : '');
140
+
141
+ let coaching = '';
142
+ if (method === 'llm-coached') {
143
+ const parts = [];
144
+ if (typeof pairConfig.coachingPrompt === 'string' && pairConfig.coachingPrompt.trim().length > 0) {
145
+ parts.push(pairConfig.coachingPrompt);
146
+ } else if (typeof pairConfig.coachingFile === 'string' && pairConfig.coachingFile.trim().length > 0) {
147
+ parts.push(`file:${pairConfig.coachingFile}`);
148
+ }
149
+ if (pairConfig.coachingData) {
150
+ parts.push(JSON.stringify(pairConfig.coachingData));
151
+ }
152
+ if (parts.length > 0) {
153
+ coaching = _shortHash(parts.join('\x00'));
154
+ }
155
+ }
156
+
157
+ return `${method}|${model}|${register}|${coaching}`;
158
+ }
159
+
160
+ // -----------------------------------------------------------------
161
+ // TM lifecycle
162
+ // -----------------------------------------------------------------
163
+
164
+ /**
165
+ * Load the translation memory from disk.
166
+ *
167
+ * Returns an empty TM object if the file doesn't exist or is corrupt.
168
+ * Logs a warning on corruption but never throws — a missing TM
169
+ * just means no cache hits (cold start).
170
+ *
171
+ * @param {string} cwd - Project root directory
172
+ * @returns {object} TM object (mutable — callers add entries, then save)
173
+ */
174
+ function loadTM(cwd) {
175
+ const tmPath = path.join(cwd, TM_DIR, TM_FILENAME);
176
+
177
+ if (!fs.existsSync(tmPath)) {
178
+ return _createEmptyTM();
179
+ }
180
+
181
+ try {
182
+ const raw = fs.readFileSync(tmPath, 'utf-8');
183
+ const data = JSON.parse(raw);
184
+
185
+ // Version check — if format changed, start fresh
186
+ if (!data._meta || data._meta.version !== TM_VERSION) {
187
+ console.error(` [TM] Cache version mismatch (expected ${TM_VERSION}). Starting fresh.`);
188
+ return _createEmptyTM();
189
+ }
190
+
191
+ return data;
192
+ } catch (err) {
193
+ // FAIL LOUD. "Starting fresh" on an unreadable TM silently discards every
194
+ // cached translation, so the next sync re-translates the entire project at
195
+ // full API cost — a warning line is not adequate notice for a bill.
196
+ //
197
+ // A version mismatch above stays a warning: that path is expected on
198
+ // upgrade, its cause is known, and there is genuinely nothing to preserve.
199
+ // This path is different — the file is corrupt for an unknown reason, and
200
+ // the cheap, correct move is usually to restore it, not to rebuild it.
201
+ if (process.env.CHAMPOLLION_ALLOW_CACHE_RESET === '1') {
202
+ console.error(
203
+ ` [TM] ${tmPath} is unreadable (${err.message}). `
204
+ + `CHAMPOLLION_ALLOW_CACHE_RESET=1 — starting fresh; `
205
+ + `every key will be re-translated at full cost.`,
206
+ );
207
+ return _createEmptyTM();
208
+ }
209
+ const e = new Error(
210
+ `Translation memory is unreadable: ${err.message}\n\n`
211
+ + ` ${tmPath}\n\n`
212
+ + `Refusing to continue: starting fresh would discard every cached `
213
+ + `translation and re-translate the whole project at full API cost.\n\n`
214
+ + ` • Restore the file from version control if you can, or\n`
215
+ + ` • delete it and re-run to accept the re-translation cost, or\n`
216
+ + ` • re-run with CHAMPOLLION_ALLOW_CACHE_RESET=1 to do that in place.`,
217
+ );
218
+ e.code = 'CHAMPOLLION_TM_UNREADABLE';
219
+ throw e;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Save the translation memory to disk.
225
+ *
226
+ * Creates the .champollion/ directory if it doesn't exist.
227
+ * Writes atomically (write to .tmp, rename) to prevent corruption
228
+ * if the process is killed mid-write.
229
+ *
230
+ * @param {string} cwd - Project root directory
231
+ * @param {object} tm - TM object to save
232
+ */
233
+ function saveTM(cwd, tm) {
234
+ const dirPath = path.join(cwd, TM_DIR);
235
+ const tmPath = path.join(dirPath, TM_FILENAME);
236
+ const tmpPath = tmPath + '.tmp';
237
+
238
+ // Ensure .champollion/ directory exists
239
+ if (!fs.existsSync(dirPath)) {
240
+ fs.mkdirSync(dirPath, { recursive: true });
241
+ }
242
+
243
+ const json = JSON.stringify(tm, null, 0); // compact — no pretty-printing
244
+ fs.writeFileSync(tmpPath, json, 'utf-8');
245
+ fs.renameSync(tmpPath, tmPath);
246
+ tm[_DIRTY] = false;
247
+ }
248
+
249
+ /**
250
+ * Look up a cached translation.
251
+ *
252
+ * @param {object} tm - TM object (from loadTM)
253
+ * @param {string} sourceValue - Source language value
254
+ * @param {string} locale - Target locale code
255
+ * @param {string} method - Translation method name
256
+ * @returns {string|null} Cached translation, or null for cache miss
257
+ */
258
+ function lookupTM(tm, sourceValue, locale, method) {
259
+ const key = cacheKey(sourceValue, locale, method);
260
+ const entry = tm[key];
261
+ if (entry && typeof entry.t === 'string') {
262
+ return entry.t;
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Look up a cached translation and validate it before serving.
269
+ *
270
+ * WHY: a cache is a time machine — an entry stored before a quality gate
271
+ * existed re-serves output that gate would reject today. The content lanes
272
+ * hit this in production: front-matter and body values hollowed by a
273
+ * pre-0.2.0 pipeline sat in the TM and were served back verbatim, forever,
274
+ * because TM hits skipped the gates that had since been built. Validating at
275
+ * READ time means every gate improvement retroactively cleans the cache —
276
+ * no version bookkeeping, no migration.
277
+ *
278
+ * A hit that fails validation is EVICTED (so the next lookup is an honest
279
+ * miss and the API is consulted) and reported as a miss to the caller.
280
+ *
281
+ * The validator is a callback so this module stays dependency-free: callers
282
+ * bring whatever check fits their lane.
283
+ *
284
+ * @param {object} tm - TM object (mutated on eviction)
285
+ * @param {string} sourceValue - Source language value
286
+ * @param {string} locale - Target locale code
287
+ * @param {string} method - Translation method name
288
+ * @param {(source: string, cached: string) => boolean} isValid - True to serve
289
+ * @returns {string|null} Validated cached translation, or null
290
+ */
291
+ function lookupTMValidated(tm, sourceValue, locale, method, isValid) {
292
+ const cached = lookupTM(tm, sourceValue, locale, method);
293
+ if (cached === null) return null;
294
+ if (isValid(sourceValue, cached)) return cached;
295
+ evictTM(tm, sourceValue, locale, method);
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Store a translation in the TM.
301
+ *
302
+ * @param {object} tm - TM object (mutated in place)
303
+ * @param {string} sourceValue - Source language value
304
+ * @param {string} locale - Target locale code
305
+ * @param {string} method - Translation method name
306
+ * @param {string} translation - Translated value to cache
307
+ */
308
+ function storeTM(tm, sourceValue, locale, method, translation) {
309
+ const key = cacheKey(sourceValue, locale, method);
310
+ tm[key] = {
311
+ t: translation,
312
+ ts: new Date().toISOString(),
313
+ l: locale, // locale code — enables per-locale stats and filtering
314
+ m: method, // method name — enables per-method stats
315
+ };
316
+ _markDirty(tm);
317
+ }
318
+
319
+ /**
320
+ * Evict a cached translation from the TM.
321
+ *
322
+ * Used by the quality gate: when a TM-served translation fails validation,
323
+ * the entry must be removed — otherwise it is re-served (and re-fails) on
324
+ * every future sync, and the API is never consulted again for that string.
325
+ *
326
+ * @param {object} tm - TM object (mutated in place)
327
+ * @param {string} sourceValue - Source language value
328
+ * @param {string} locale - Target locale code
329
+ * @param {string} method - Translation method name
330
+ * @returns {boolean} True if an entry existed and was removed
331
+ */
332
+ function evictTM(tm, sourceValue, locale, method) {
333
+ const key = cacheKey(sourceValue, locale, method);
334
+ if (key in tm) {
335
+ delete tm[key];
336
+ _markDirty(tm);
337
+ return true;
338
+ }
339
+ return false;
340
+ }
341
+
342
+ /**
343
+ * Has this TM been mutated (store or evict) since load/save?
344
+ *
345
+ * Callers use this to decide whether saveTM is needed. A size comparison is
346
+ * NOT equivalent: evicting a poisoned entry and re-storing its replacement
347
+ * under the same cache key leaves the size unchanged, and an eviction-only
348
+ * run shrinks it — both must still be persisted.
349
+ *
350
+ * @param {object} tm - TM object
351
+ * @returns {boolean} True if the TM has unsaved changes
352
+ */
353
+ function isTMDirty(tm) {
354
+ return tm[_DIRTY] === true;
355
+ }
356
+
357
+ // Non-enumerable dirty marker — invisible to JSON.stringify and Object.keys,
358
+ // so it never leaks into the saved file or entry counts.
359
+ const _DIRTY = Symbol('tm-dirty');
360
+
361
+ function _markDirty(tm) {
362
+ tm[_DIRTY] = true;
363
+ }
364
+
365
+ /**
366
+ * Prune TM entries by category, mutating the loaded object in place.
367
+ *
368
+ * Categories:
369
+ * - legacy: entries missing the `l`/`m` metadata fields (pre-v3.4 format).
370
+ * These can never be filtered per-locale or per-method, and predate the
371
+ * tmMethodKey cache-key scheme, so they are dead weight.
372
+ * - matching: entries whose cached translation matches `matching` (RegExp).
373
+ * This is the policy-eviction lane: when wording is banned AFTER entries
374
+ * were cached (house-term passes, renames), the source edit orphans the
375
+ * entries but does not remove them — and if the old source string ever
376
+ * reappears, the cache re-serves the banned translation. Same rationale
377
+ * as evictTM's quality-gate eviction, applied in bulk by content.
378
+ * - stale: entries whose `ts` timestamp is older than `olderThanDays`.
379
+ *
380
+ * An entry matching several categories is counted once, under the first in
381
+ * the order above. `_meta` is never touched. Callers decide persistence: for
382
+ * a dry report just discard the mutated object; to actually prune, follow
383
+ * with saveTM (the object is marked dirty here whenever anything was removed).
384
+ *
385
+ * @param {object} tm - TM object (from loadTM; mutated in place)
386
+ * @param {object} [options]
387
+ * @param {boolean} [options.legacy=true] - Remove entries missing l/m metadata
388
+ * @param {RegExp|null} [options.matching=null] - Remove entries whose translation text matches
389
+ * @param {number|null} [options.olderThanDays=null] - Remove entries older than N days (by ts)
390
+ * @returns {{ removed: number, kept: number, byReason: { legacy: number, matching: number, stale: number } }}
391
+ */
392
+ function pruneTM(tm, { legacy = true, matching = null, olderThanDays = null } = {}) {
393
+ const byReason = { legacy: 0, matching: 0, stale: 0 };
394
+ let kept = 0;
395
+
396
+ const cutoff = olderThanDays !== null
397
+ ? new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000).toISOString()
398
+ : null;
399
+
400
+ for (const [key, entry] of Object.entries(tm)) {
401
+ if (key === '_meta') continue;
402
+
403
+ let reason = null;
404
+ if (legacy && (!entry.l || !entry.m)) {
405
+ reason = 'legacy';
406
+ } else if (matching !== null && typeof entry.t === 'string' && _testFresh(matching, entry.t)) {
407
+ reason = 'matching';
408
+ } else if (cutoff !== null && typeof entry.ts === 'string' && entry.ts < cutoff) {
409
+ reason = 'stale';
410
+ }
411
+
412
+ if (reason) {
413
+ delete tm[key];
414
+ byReason[reason]++;
415
+ } else {
416
+ kept++;
417
+ }
418
+ }
419
+
420
+ const removed = byReason.legacy + byReason.matching + byReason.stale;
421
+ if (removed > 0) _markDirty(tm);
422
+ return { removed, kept, byReason };
423
+ }
424
+
425
+ /**
426
+ * RegExp.test without sticky/global lastIndex carry-over between entries.
427
+ * A caller-supplied /g or /y regex would otherwise skip matches on
428
+ * subsequent entries and make pruning nondeterministic.
429
+ */
430
+ function _testFresh(re, text) {
431
+ if (re.global || re.sticky) re.lastIndex = 0;
432
+ return re.test(text);
433
+ }
434
+
435
+ /**
436
+ * Partition a set of keys into TM hits and TM misses.
437
+ *
438
+ * This is the main entry point for the sync pipeline:
439
+ * 1. Load source values for the keys that need translation
440
+ * 2. Check each against TM
441
+ * 3. Return hits (reuse immediately) and misses (send to API)
442
+ *
443
+ * @param {object} tm - TM object
444
+ * @param {object} sourceFlat - Full source key→value map
445
+ * @param {string[]} keysToTranslate - Keys that need translation
446
+ * @param {string} locale - Target locale code
447
+ * @param {string} method - Translation method name
448
+ * @returns {{ hits: object, misses: string[] }} hits is key→cached translation, misses is keys to translate
449
+ */
450
+ function partitionByTM(tm, sourceFlat, keysToTranslate, locale, method) {
451
+ const hits = {};
452
+ const misses = [];
453
+
454
+ for (const key of keysToTranslate) {
455
+ const sourceValue = sourceFlat[key];
456
+ if (typeof sourceValue !== 'string') {
457
+ misses.push(key);
458
+ continue;
459
+ }
460
+
461
+ const cached = lookupTM(tm, sourceValue, locale, method);
462
+ if (cached !== null) {
463
+ hits[key] = cached;
464
+ } else {
465
+ misses.push(key);
466
+ }
467
+ }
468
+
469
+ return { hits, misses };
470
+ }
471
+
472
+ /**
473
+ * Get the number of cached entries in a TM (excluding metadata).
474
+ *
475
+ * @param {object} tm - TM object
476
+ * @returns {number} Entry count
477
+ */
478
+ function tmSize(tm) {
479
+ return Object.keys(tm).filter(k => k !== '_meta').length;
480
+ }
481
+
482
+ // -----------------------------------------------------------------
483
+ // Internal helpers
484
+ // -----------------------------------------------------------------
485
+
486
+ function _createEmptyTM() {
487
+ return {
488
+ _meta: {
489
+ version: TM_VERSION,
490
+ created: new Date().toISOString(),
491
+ },
492
+ };
493
+ }
494
+
495
+ // -----------------------------------------------------------------
496
+ // Exports
497
+ // -----------------------------------------------------------------
498
+
499
+ export {
500
+ loadTM,
501
+ saveTM,
502
+ lookupTM,
503
+ lookupTMValidated,
504
+ storeTM,
505
+ evictTM,
506
+ isTMDirty,
507
+ pruneTM,
508
+ partitionByTM,
509
+ tmSize,
510
+ cacheKey,
511
+ tmMethodKey,
512
+ TM_VERSION,
513
+ TM_DIR,
514
+ TM_FILENAME,
515
+ };