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
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Command: tm
3
+ *
4
+ * Manages the Translation Memory cache (.champollion/tm.json).
5
+ *
6
+ * Subcommands:
7
+ * stats Show entry count, file size, locale breakdown, and timestamps.
8
+ * clear Delete the TM cache (with --yes to skip confirmation, --locale to target one locale).
9
+ * seed Back-fill the TM from EXISTING translated content files so a
10
+ * lost/clobbered .champollion-content.lock doesn't re-bill files
11
+ * whose translations already sit on disk (--dry-run, --locale).
12
+ * prune Remove dead weight: legacy entries missing l/m metadata, entries
13
+ * whose cached translation matches --matching <regex> (policy
14
+ * eviction for banned/renamed wording), and (with --older-than
15
+ * <days>) entries older than N days. Dry report by default; --yes
16
+ * actually deletes.
17
+ *
18
+ * stats, seed, and prune support --json (single JSON document on stdout).
19
+ *
20
+ * WHY THIS EXISTS:
21
+ * TM is the primary cost-saving mechanism in champollion — it prevents
22
+ * re-translating keys whose source text hasn't changed. But users
23
+ * need visibility into the cache (how big is it? what's cached?)
24
+ * and a way to reset it (switching providers, fixing a bad batch).
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import readline from 'node:readline';
30
+ import { loadTM, saveTM, tmSize, pruneTM, TM_DIR, TM_FILENAME } from '../tm.js';
31
+ import { resolveConfig } from '../config.js';
32
+ import { resolveRuntime } from '../sync.js';
33
+ import { seedTMFromExisting } from '../tm-seed.js';
34
+ import { output } from '../output.js';
35
+
36
+ /**
37
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
38
+ * @param {string} cwd - Working directory
39
+ * @returns {Promise<number>} Exit code (0 = success, 1 = error)
40
+ */
41
+ async function run(args, cwd) {
42
+ const sub = args._[1];
43
+
44
+ if (!sub || sub === 'help') {
45
+ printUsage();
46
+ return 0;
47
+ }
48
+
49
+ if (sub === 'stats') {
50
+ return runStats(args, cwd);
51
+ }
52
+
53
+ if (sub === 'clear') {
54
+ return runClear(args, cwd);
55
+ }
56
+
57
+ if (sub === 'seed') {
58
+ return runSeed(args, cwd);
59
+ }
60
+
61
+ if (sub === 'prune') {
62
+ return runPrune(args, cwd);
63
+ }
64
+
65
+ output.error(`Unknown subcommand: "${sub}". Run "champollion tm --help" for usage.`);
66
+ return 1;
67
+ }
68
+
69
+ // -----------------------------------------------------------------
70
+ // tm seed
71
+ // -----------------------------------------------------------------
72
+
73
+ /**
74
+ * Seed the TM from existing translated content files.
75
+ *
76
+ * The block-level TM only fills as files are re-translated, so a project
77
+ * that upgraded with translations already on disk is one lost lock file
78
+ * away from re-billing everything. This walks the existing translations
79
+ * (gated on an up-to-date lock entry) and back-fills field, block, and
80
+ * whole-body entries — see lib/tm-seed.js for the safety rules.
81
+ *
82
+ * Uses the SAME config + pair resolution as sync (resolveRuntime), so
83
+ * entries land under the exact tmMethodKey a future sync will look up.
84
+ * No API key is needed: preflight is skipped, nothing is translated.
85
+ */
86
+ async function runSeed(args, cwd) {
87
+ const json = !!args.json;
88
+ // In --json mode stdout carries exactly one JSON document. Quiet mode
89
+ // suppresses the human raws/infos; warnings/errors still reach stderr.
90
+ if (json) output.setMode('quiet');
91
+ const dryRun = !!args.dry;
92
+ const localeFilter = args.locale || null;
93
+
94
+ let config;
95
+ try {
96
+ config = resolveConfig(args, cwd);
97
+ } catch (err) {
98
+ output.error(`tm seed: ${err.message}`);
99
+ return 1;
100
+ }
101
+
102
+ // dryRun: true skips the preflight readiness check — seeding never
103
+ // calls a translation engine, so it must not demand an API key.
104
+ const { resolvedPairs } = await resolveRuntime(config, cwd, { ...args, dryRun: true });
105
+ if (resolvedPairs.size === 0) {
106
+ if (json) {
107
+ console.log(JSON.stringify({ command: 'tm', action: 'seed', dryRun, seeded: 0, skipped: 0, files: [], note: 'No target languages configured.' }, null, 2));
108
+ return 0;
109
+ }
110
+ output.info('No target languages configured. Add pairs to champollion.config.json.');
111
+ return 0;
112
+ }
113
+
114
+ const docusaurus = config.format === 'docusaurus'
115
+ ? {
116
+ localesDir: config.localesDir,
117
+ docsDir: path.join(cwd, 'docs'),
118
+ blogDir: path.join(cwd, 'blog'),
119
+ }
120
+ : null;
121
+
122
+ if (!config.contentDir && !docusaurus) {
123
+ if (json) {
124
+ console.log(JSON.stringify({ command: 'tm', action: 'seed', dryRun, seeded: 0, skipped: 0, files: [], note: 'No content lane configured — nothing to seed.' }, null, 2));
125
+ return 0;
126
+ }
127
+ output.info('No content lane configured (no contentDir, not a Docusaurus project).');
128
+ output.info('Key-value sync diffs against the locale files themselves — nothing to seed.');
129
+ return 0;
130
+ }
131
+
132
+ const result = seedTMFromExisting({
133
+ cwd,
134
+ pairs: resolvedPairs,
135
+ sourceLocale: config.inputLocale,
136
+ translatableFields: config.translatableFields,
137
+ contentDir: config.contentDir,
138
+ docusaurus,
139
+ dryRun,
140
+ localeFilter,
141
+ });
142
+
143
+ if (json) {
144
+ console.log(JSON.stringify({
145
+ command: 'tm',
146
+ action: 'seed',
147
+ dryRun,
148
+ seeded: result.seededFiles,
149
+ skipped: result.skippedFiles,
150
+ entriesAdded: result.entriesAdded,
151
+ entriesExisting: result.entriesExisting,
152
+ saved: !!result.saved,
153
+ files: result.files,
154
+ }, null, 2));
155
+ return 0;
156
+ }
157
+
158
+ const verb = dryRun ? 'Would seed' : 'Seeded';
159
+ output.raw(`\n Translation Memory seed${dryRun ? ' — DRY RUN' : ''}\n`);
160
+
161
+ for (const f of result.files) {
162
+ if (f.status === 'seeded') {
163
+ const parts = [];
164
+ if (f.fields > 0) parts.push(`${f.fields} field(s)`);
165
+ if (f.blocks > 0) parts.push(`${f.blocks} block(s)`);
166
+ if (f.body) parts.push('body');
167
+ const detail = parts.length > 0 ? parts.join(' + ') : 'nothing new';
168
+ const cached = f.existing > 0 ? `, ${f.existing} already cached` : '';
169
+ output.raw(` [SEED] ${f.label} → ${f.code} ${detail} (${f.added} new${cached})`);
170
+ } else {
171
+ output.raw(` [SKIP] ${f.label} → ${f.code} ${f.reason}`);
172
+ }
173
+ }
174
+
175
+ output.raw('');
176
+ output.raw(` ${verb} ${result.seededFiles} file(s), skipped ${result.skippedFiles}.`);
177
+ output.raw(` Entries: ${result.entriesAdded} new, ${result.entriesExisting} already cached.`);
178
+ if (result.saved) {
179
+ output.raw(` TM: ${result.tmSizeBefore} → ${result.tmSizeAfter} entries (${path.join(TM_DIR, TM_FILENAME)})`);
180
+ } else if (dryRun && result.entriesAdded > 0) {
181
+ output.raw(' Nothing written (dry run). Re-run without --dry-run to seed.');
182
+ }
183
+ if (result.seededFiles === 0 && result.skippedFiles > 0) {
184
+ output.warn('Nothing was seeded. Seeding requires up-to-date lock entries — run it right after a clean sync, while .champollion-content.lock is intact.');
185
+ }
186
+ output.raw('');
187
+ return 0;
188
+ }
189
+
190
+ // -----------------------------------------------------------------
191
+ // tm stats
192
+ // -----------------------------------------------------------------
193
+
194
+ /**
195
+ * Display TM statistics: entry count, file size, timestamps,
196
+ * and a per-locale breakdown. --json emits a single JSON document.
197
+ */
198
+ function runStats(args, cwd) {
199
+ const json = !!args.json;
200
+ if (json) output.setMode('quiet');
201
+ const tmPath = path.join(cwd, TM_DIR, TM_FILENAME);
202
+
203
+ if (!fs.existsSync(tmPath)) {
204
+ if (json) {
205
+ console.log(JSON.stringify({ command: 'tm', action: 'stats', file: tmPath, exists: false, total: 0 }, null, 2));
206
+ return 0;
207
+ }
208
+ output.raw('\n Translation Memory — no cache file found');
209
+ output.raw(` Expected: ${tmPath}`);
210
+ output.raw(' Run "champollion sync" to populate the cache.\n');
211
+ return 0;
212
+ }
213
+
214
+ const tm = loadTM(cwd);
215
+ const entryCount = tmSize(tm);
216
+
217
+ // File size
218
+ const stat = fs.statSync(tmPath);
219
+ const sizeStr = formatBytes(stat.size);
220
+
221
+ // Timestamps from metadata
222
+ const created = tm._meta?.created || 'unknown';
223
+ const createdDate = created !== 'unknown' ? created.split('T')[0] : 'unknown';
224
+
225
+ // Find newest entry timestamp
226
+ let newest = null;
227
+ let oldest = null;
228
+ for (const [key, entry] of Object.entries(tm)) {
229
+ if (key === '_meta') continue;
230
+ if (entry.ts) {
231
+ if (!newest || entry.ts > newest) newest = entry.ts;
232
+ if (!oldest || entry.ts < oldest) oldest = entry.ts;
233
+ }
234
+ }
235
+ const newestDate = newest ? newest.split('T')[0] : 'unknown';
236
+
237
+ // Per-locale breakdown — only possible for entries that have the l/m fields
238
+ // (added in the TM format enhancement). Old entries show as "unknown".
239
+ const localeStats = {};
240
+ let unknownCount = 0;
241
+
242
+ for (const [key, entry] of Object.entries(tm)) {
243
+ if (key === '_meta') continue;
244
+
245
+ const locale = entry.l || null;
246
+ const method = entry.m || null;
247
+
248
+ if (!locale) {
249
+ unknownCount++;
250
+ continue;
251
+ }
252
+
253
+ if (!localeStats[locale]) {
254
+ localeStats[locale] = { total: 0, methods: {} };
255
+ }
256
+ localeStats[locale].total++;
257
+
258
+ const methodKey = method || 'unknown';
259
+ localeStats[locale].methods[methodKey] = (localeStats[locale].methods[methodKey] || 0) + 1;
260
+ }
261
+
262
+ // Sort locales by entry count (descending)
263
+ const sortedLocales = Object.entries(localeStats)
264
+ .sort((a, b) => b[1].total - a[1].total);
265
+
266
+ if (json) {
267
+ const byLocale = {};
268
+ for (const [locale, stats] of sortedLocales) {
269
+ byLocale[locale] = { total: stats.total, byMethod: stats.methods };
270
+ }
271
+ console.log(JSON.stringify({
272
+ command: 'tm',
273
+ action: 'stats',
274
+ file: tmPath,
275
+ exists: true,
276
+ total: entryCount,
277
+ sizeBytes: stat.size,
278
+ created: createdDate,
279
+ lastEntry: newestDate,
280
+ byLocale,
281
+ unknownLocale: unknownCount,
282
+ }, null, 2));
283
+ return 0;
284
+ }
285
+
286
+ output.raw('\n Translation Memory — .champollion/tm.json\n');
287
+ output.raw(` Entries: ${entryCount.toLocaleString()}`);
288
+ output.raw(` File size: ${sizeStr}`);
289
+ output.raw(` Created: ${createdDate}`);
290
+ output.raw(` Last entry: ${newestDate}`);
291
+
292
+ if (sortedLocales.length > 0 || unknownCount > 0) {
293
+ output.raw('\n By locale:');
294
+
295
+ for (const [locale, stats] of sortedLocales) {
296
+ const methodParts = Object.entries(stats.methods)
297
+ .sort((a, b) => b[1] - a[1])
298
+ .map(([m, count]) => `${m}: ${count}`);
299
+ const methodStr = methodParts.length > 0 ? ` (${methodParts.join(', ')})` : '';
300
+ output.raw(` ${locale.padEnd(6)} ${String(stats.total).padStart(5)} entries${methodStr}`);
301
+ }
302
+
303
+ if (unknownCount > 0) {
304
+ output.raw(` ${'(old)'.padEnd(6)} ${String(unknownCount).padStart(5)} entries (pre-v3.4, no locale metadata)`);
305
+ }
306
+ }
307
+
308
+ output.raw('');
309
+ return 0;
310
+ }
311
+
312
+ // -----------------------------------------------------------------
313
+ // tm clear
314
+ // -----------------------------------------------------------------
315
+
316
+ /**
317
+ * Clear the TM cache. Supports full clear or per-locale clear.
318
+ *
319
+ * --yes skips the confirmation prompt.
320
+ * --locale <code> removes only entries for that locale (requires l field).
321
+ */
322
+ async function runClear(args, cwd) {
323
+ const tmPath = path.join(cwd, TM_DIR, TM_FILENAME);
324
+ const locale = args.locale || null;
325
+ const skipConfirm = args.yes || false;
326
+
327
+ if (!fs.existsSync(tmPath)) {
328
+ output.raw('\n No TM cache file found — nothing to clear.\n');
329
+ return 0;
330
+ }
331
+
332
+ if (locale) {
333
+ // Per-locale clear — remove entries matching locale, keep the rest
334
+ return clearLocale(cwd, tmPath, locale, skipConfirm);
335
+ }
336
+
337
+ // Full clear — delete the file
338
+ if (!skipConfirm) {
339
+ const tm = loadTM(cwd);
340
+ const count = tmSize(tm);
341
+ const confirmed = await confirm(
342
+ ` Delete TM cache? (${count.toLocaleString()} entries will be lost) [y/N] `
343
+ );
344
+ if (!confirmed) {
345
+ output.raw(' Aborted.\n');
346
+ return 0;
347
+ }
348
+ }
349
+
350
+ fs.unlinkSync(tmPath);
351
+ output.raw('\n ✓ TM cache cleared (.champollion/tm.json deleted)');
352
+ output.raw(' Next sync will re-translate all keys.\n');
353
+ return 0;
354
+ }
355
+
356
+ /**
357
+ * Remove TM entries matching a specific locale.
358
+ * Entries without the `l` field (old format) are preserved.
359
+ */
360
+ async function clearLocale(cwd, tmPath, locale, skipConfirm) {
361
+ const tm = loadTM(cwd);
362
+ let removeCount = 0;
363
+
364
+ // Count entries to remove
365
+ for (const [key, entry] of Object.entries(tm)) {
366
+ if (key === '_meta') continue;
367
+ if (entry.l === locale) removeCount++;
368
+ }
369
+
370
+ if (removeCount === 0) {
371
+ output.raw(`\n No TM entries found for locale "${locale}".`);
372
+ output.raw(' (Old entries without locale metadata cannot be filtered.)\n');
373
+ return 0;
374
+ }
375
+
376
+ if (!skipConfirm) {
377
+ const confirmed = await confirm(
378
+ ` Remove ${removeCount} TM entries for locale "${locale}"? [y/N] `
379
+ );
380
+ if (!confirmed) {
381
+ output.raw(' Aborted.\n');
382
+ return 0;
383
+ }
384
+ }
385
+
386
+ // Remove matching entries
387
+ for (const key of Object.keys(tm)) {
388
+ if (key === '_meta') continue;
389
+ if (tm[key].l === locale) delete tm[key];
390
+ }
391
+
392
+ saveTM(cwd, tm);
393
+ const remaining = tmSize(tm);
394
+ output.raw(`\n ✓ Removed ${removeCount} entries for locale "${locale}" (${remaining} remaining)\n`);
395
+ return 0;
396
+ }
397
+
398
+ // -----------------------------------------------------------------
399
+ // tm prune
400
+ // -----------------------------------------------------------------
401
+
402
+ /**
403
+ * Prune dead-weight TM entries.
404
+ *
405
+ * Removes (a) legacy entries missing the l/m metadata fields (pre-v3.4 —
406
+ * unfilterable per-locale, keyed under the pre-tmMethodKey scheme),
407
+ * (b) with --matching <regex>, entries whose cached translation matches the
408
+ * pattern — the policy-eviction lane for wording that was banned or renamed
409
+ * after it was cached (stale entries would otherwise resurrect the old
410
+ * wording if the old source text ever reappears), and
411
+ * (c) with --older-than <days>, entries whose ts is older than N days.
412
+ *
413
+ * Default is a DRY REPORT (counts by category, nothing deleted);
414
+ * --yes actually deletes. --json emits a single JSON document.
415
+ */
416
+ async function runPrune(args, cwd) {
417
+ const json = !!args.json;
418
+ if (json) output.setMode('quiet');
419
+ const tmPath = path.join(cwd, TM_DIR, TM_FILENAME);
420
+ const doDelete = !!args.yes;
421
+
422
+ let olderThanDays = null;
423
+ const olderRaw = args['older-than'];
424
+ if (olderRaw !== undefined && olderRaw !== null && olderRaw !== false) {
425
+ olderThanDays = Number.parseFloat(String(olderRaw));
426
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
427
+ output.error(`tm prune: --older-than must be a non-negative number of days (got "${olderRaw}").`);
428
+ return 1;
429
+ }
430
+ }
431
+
432
+ let matching = null;
433
+ const matchingRaw = args.matching;
434
+ if (matchingRaw !== undefined && matchingRaw !== null && matchingRaw !== false) {
435
+ if (typeof matchingRaw !== 'string' || matchingRaw.length === 0) {
436
+ output.error('tm prune: --matching requires a non-empty regular expression.');
437
+ return 1;
438
+ }
439
+ try {
440
+ // Case-sensitive, no flags: banned-term patterns are usually
441
+ // case-shaped ("OCAP" vs a word that merely contains "ocap").
442
+ // Encode any case-insensitivity in the pattern itself ([Cc]…).
443
+ matching = new RegExp(matchingRaw);
444
+ } catch (err) {
445
+ output.error(`tm prune: --matching is not a valid regular expression: ${err.message}`);
446
+ return 1;
447
+ }
448
+ }
449
+
450
+ if (!fs.existsSync(tmPath)) {
451
+ if (json) {
452
+ console.log(JSON.stringify({
453
+ command: 'tm', action: 'prune', file: tmPath, exists: false,
454
+ dryRun: !doDelete, removed: 0, kept: 0,
455
+ byReason: { legacy: 0, matching: 0, stale: 0 }, deleted: false,
456
+ }, null, 2));
457
+ return 0;
458
+ }
459
+ output.raw('\n No TM cache file found — nothing to prune.\n');
460
+ return 0;
461
+ }
462
+
463
+ const tm = loadTM(cwd);
464
+ const before = tmSize(tm);
465
+ // pruneTM mutates the loaded object either way; the dry report simply
466
+ // never persists the mutation (only --yes reaches saveTM).
467
+ const report = pruneTM(tm, { legacy: true, matching, olderThanDays });
468
+ const deleted = doDelete && report.removed > 0;
469
+ if (deleted) {
470
+ saveTM(cwd, tm);
471
+ }
472
+
473
+ if (json) {
474
+ console.log(JSON.stringify({
475
+ command: 'tm', action: 'prune', file: tmPath, exists: true,
476
+ dryRun: !doDelete, olderThanDays, matching: matching !== null ? matchingRaw : null,
477
+ total: before, removed: report.removed, kept: report.kept,
478
+ byReason: report.byReason, deleted,
479
+ }, null, 2));
480
+ return 0;
481
+ }
482
+
483
+ const verb = doDelete ? 'Removed' : 'Would remove';
484
+ output.raw(`\n Translation Memory prune${doDelete ? '' : ' — DRY REPORT'}\n`);
485
+ output.raw(` Entries scanned: ${before}`);
486
+ output.raw(` ${verb}: ${report.removed}`);
487
+ output.raw(` legacy (no locale/method metadata): ${report.byReason.legacy}`);
488
+ if (matching !== null) {
489
+ output.raw(` matching ${matching}: ${report.byReason.matching}`);
490
+ }
491
+ if (olderThanDays !== null) {
492
+ output.raw(` stale (older than ${olderThanDays} day(s)): ${report.byReason.stale}`);
493
+ }
494
+ output.raw(` Kept: ${report.kept}`);
495
+ if (!doDelete && report.removed > 0) {
496
+ output.raw('\n Nothing deleted (dry report). Re-run with --yes to prune.');
497
+ } else if (deleted) {
498
+ output.raw(`\n TM: ${before} → ${report.kept} entries (${path.join(TM_DIR, TM_FILENAME)})`);
499
+ }
500
+ output.raw('');
501
+ return 0;
502
+ }
503
+
504
+ // -----------------------------------------------------------------
505
+ // Helpers
506
+ // -----------------------------------------------------------------
507
+
508
+ /**
509
+ * Format bytes into a human-readable string.
510
+ * @param {number} bytes
511
+ * @returns {string} e.g., "1.2 MB"
512
+ */
513
+ function formatBytes(bytes) {
514
+ if (bytes < 1024) return `${bytes} B`;
515
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
516
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
517
+ }
518
+
519
+ /**
520
+ * Prompt for confirmation. Returns true if the user types 'y' or 'yes'.
521
+ * Returns false for any other input (including empty / Enter).
522
+ */
523
+ function confirm(prompt) {
524
+ return new Promise((resolve) => {
525
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
526
+ rl.question(prompt, (answer) => {
527
+ rl.close();
528
+ resolve(/^y(es)?$/i.test(answer.trim()));
529
+ });
530
+ });
531
+ }
532
+
533
+ function printUsage() {
534
+ output.raw(`
535
+ champollion tm — Translation Memory cache management
536
+
537
+ SUBCOMMANDS
538
+ stats Show entry count, file size, and locale breakdown
539
+ clear Delete the TM cache (--yes to skip confirmation)
540
+ seed Back-fill the TM from existing translated content files
541
+ (field + block + whole-body entries; only files whose lock
542
+ entry is up to date and whose block counts align — never
543
+ guessed). Protects against a lost .champollion-content.lock.
544
+ prune Remove legacy entries missing locale/method metadata, entries
545
+ whose cached translation matches --matching <regex> (evict
546
+ banned/renamed wording so it can never be re-served), and
547
+ optionally entries older than --older-than <days>.
548
+ Dry report by default; --yes actually deletes.
549
+
550
+ OPTIONS
551
+ --locale <code> Clear/seed only entries for a specific locale
552
+ --yes, -y Skip confirmation prompt (clear); actually delete (prune)
553
+ --dry-run Show what seed would store without writing (seed)
554
+ --matching <regex> Also prune entries whose translation matches (prune;
555
+ case-sensitive — encode [Cc]ase in the pattern)
556
+ --older-than <days> Also prune entries older than N days (prune)
557
+ --json Single JSON document output (stats, seed, prune)
558
+
559
+ EXAMPLES
560
+ champollion tm stats # Show cache statistics
561
+ champollion tm clear # Clear entire cache (with confirmation)
562
+ champollion tm clear --yes # Clear without confirmation
563
+ champollion tm clear --locale fr # Clear only French entries
564
+ champollion tm seed --dry-run # Preview what would be seeded
565
+ champollion tm seed # Seed TM from existing translations
566
+ champollion tm prune # Dry report of prunable entries
567
+ champollion tm prune --yes # Delete legacy entries
568
+ champollion tm prune --matching 'Old Brand Name' --yes # Evict banned wording
569
+ champollion tm prune --older-than 90 --yes # Also delete entries >90 days old
570
+ `);
571
+ }
572
+
573
+ export { run };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Command: verify
3
+ *
4
+ * Re-reads all locale files from disk and confirms translations are
5
+ * actually present and correct. Catches the gap between sync reporting
6
+ * success and keys being wrong in fact.
7
+ *
8
+ * Exit code 1 if errors found (CI-gate compatible).
9
+ * --warn-only exits 0 regardless.
10
+ *
11
+ * This runs the same checks that sync's post-sync verification uses,
12
+ * but can be invoked independently for CI pipelines or manual auditing.
13
+ */
14
+
15
+ import { resolveConfig } from '../config.js';
16
+ import { verifyLocales } from '../verify.js';
17
+ import { output } from '../output.js';
18
+
19
+ /**
20
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
21
+ * @param {string} cwd - Working directory
22
+ * @returns {Promise<number>} Exit code (0 = success, 1 = errors found)
23
+ */
24
+ async function run(args, cwd) {
25
+ // Honor --json/--quiet so `champollion verify --json | jq` is parseable,
26
+ // matching `sync`. Without this the verifier printed human text in json mode.
27
+ if (args.json) output.setMode('json');
28
+ else if (args.quiet) output.setMode('quiet');
29
+
30
+ const config = resolveConfig(args, cwd);
31
+ const { errors } = await verifyLocales(config, cwd);
32
+
33
+ if (errors > 0 && !args['warn-only']) {
34
+ return 1;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ export { run };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Command: watch
3
+ *
4
+ * Starts a file watcher that auto-syncs when the source locale changes.
5
+ * Delegates to lib/watch.startWatch.
6
+ */
7
+
8
+ import { startWatch } from '../watch.js';
9
+
10
+ /**
11
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
12
+ * @param {string} cwd - Working directory
13
+ * @returns {Promise<number>} Exit code (0 = success, 1 = error)
14
+ */
15
+ async function run(args, cwd) {
16
+ await startWatch({ cwd, cliArgs: args });
17
+ // Watch runs indefinitely — no exit code
18
+ }
19
+
20
+ export { run };