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/sync.js ADDED
@@ -0,0 +1,969 @@
1
+ /**
2
+ * Main sync orchestrator — ties together config, diff, hash, translate, and file I/O.
3
+ *
4
+ * This is the core "do the thing" module. It:
5
+ * 1. Prints version banner (e.g., "champollion v3.4.0")
6
+ * 2. Reads the source locale file (JSON, TOML, or YAML)
7
+ * 3. Logs detected format and framework (e.g., "Detected format: json (auto)", "Detected framework: Hugo")
8
+ * 4. Loads the hash manifest to detect changed English content
9
+ * 5. Iterates over all target pairs (v3 pair graph)
10
+ * 6. Diffs each one against the source (missing + fallback + changed + forced)
11
+ * 7. Delegates translation to lib/translate-pair.js (TM → API → quality gate)
12
+ * with an onProgress callback wired to output.progressBar()
13
+ * 8. Applies post-translation steps (terminology, script conversion)
14
+ * 9. Writes updated locale files
15
+ * 10. Saves updated hash manifest
16
+ * 11. Delegates Docusaurus sync to lib/docusaurus-sync.js
17
+ * 12. Delegates content sync to lib/content-sync.js
18
+ *
19
+ * Modes:
20
+ * - sync: one-shot, translate and write
21
+ * - dry: report only, no writes
22
+ * - audit: list all [EN]-prefixed values still needing real translation
23
+ *
24
+ * Related modules:
25
+ * - lib/translate-pair.js — shared TM→API→gate pipeline (used by both sync paths)
26
+ * - lib/docusaurus-sync.js — Docusaurus JSON + Markdown sync
27
+ * - lib/cost-report.js — pre-sync cost estimation display
28
+ * - lib/content-sync.js — Hugo/content Markdown sync
29
+ * - lib/watch.js — file watcher for auto-sync
30
+ * - lib/output.js — banner(), progressBar(), and all CLI output
31
+ */
32
+
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import { createRequire } from 'node:module';
36
+ import { flattenKeys, setNestedValue } from './flatten.js';
37
+ import { diffLocale, diffLabel } from './diff.js';
38
+ import { isUnsafeKey, getMethod } from './translate.js';
39
+ import { resolveConfig, autoDetectLanguages, DEFAULT_JSON_CONCURRENCY } from './config.js';
40
+ import { compileNoTranslate } from './no-translate.js';
41
+ import { buildHashManifest, detectChangedKeys, readManifest, writeManifest } from './hash.js';
42
+ import { detectFormatFromDir, getExtension, readLocaleFile, writeLocaleFile, detectYAMLStyle } from './format.js';
43
+ import { resolvePairs, filterPairGraph } from './pairs.js';
44
+ import { loadPlugins, resolvePluginForPair } from './plugins.js';
45
+ import { isPathContained } from './security.js';
46
+ import { loadApiKey } from './api-key.js';
47
+ import { runContentSync } from './content-sync.js';
48
+ import { auditProvenance } from './provenance.js';
49
+ import {
50
+ convertScript, getConverterInfo, applyScriptFallback,
51
+ converterKeyForLocale, formatScriptChoiceError,
52
+ } from './scripts.js';
53
+ import { getLanguageCard } from './registers.js';
54
+ import { loadTM, saveTM, tmSize, isTMDirty, lookupTM, tmMethodKey } from './tm.js';
55
+ import { verifyTerminology, logTermViolations } from './terminology.js';
56
+ import { output } from './output.js';
57
+ import { printCostEstimate, parseMaxCost, abortForMaxCost } from './cost-report.js';
58
+ import { runDocusaurusSync } from './docusaurus-sync.js';
59
+ import { translateAndValidate } from './translate-pair.js';
60
+ import { verifyLocales } from './verify.js';
61
+ import { pMap } from './concurrent.js';
62
+ import { resetTranslationError } from './methods/translation-error.js';
63
+
64
+
65
+ /**
66
+ * Resolve the translation runtime — API key, method detection, pair graph.
67
+ *
68
+ * Shared by runSync and runDocusaurusSync to avoid drift in the
69
+ * setup sequence (method detection, language resolution, plugin merging).
70
+ *
71
+ * @param {object} config - Resolved config (post-migration, post-defaults)
72
+ * @param {string} cwd - Working directory
73
+ * @param {object} cliArgs - CLI flags (method, fallback, etc.)
74
+ * @returns {{ apiKey: string|null, resolvedPairs: Map, pairEntries: Array }}
75
+ */
76
+ async function resolveRuntime(config, cwd, cliArgs = {}) {
77
+ const apiKey = loadApiKey(config, cwd);
78
+
79
+ // SAFETY: shallow copy so we don't mutate the caller's config object.
80
+ // Currently harmless (config is fresh from resolveConfig per invocation),
81
+ // but prevents subtle bugs if anyone adds code that reads config.defaultMethod
82
+ // or config.resolvedLanguages after resolveRuntime returns.
83
+ const runtimeConfig = { ...config };
84
+
85
+ // Smart method detection: if no LLM API key is available but
86
+ // Google Translate credentials are set, auto-switch the default method.
87
+ // This lets developers get started with just a Google Cloud API key.
88
+ if (!apiKey && !cliArgs.method && runtimeConfig.defaultMethod === 'llm') {
89
+ const googleKey = process.env.GOOGLE_TRANSLATE_API_KEY || process.env.GOOGLE_API_KEY;
90
+ if (googleKey) {
91
+ runtimeConfig.defaultMethod = 'google-translate';
92
+ output.info('No OPENROUTER_API_KEY found, but GOOGLE_TRANSLATE_API_KEY is set.');
93
+ output.info('Auto-switching default method to google-translate.');
94
+ }
95
+ }
96
+
97
+ // Resolve target languages — from config or auto-detect.
98
+ let languages = runtimeConfig.resolvedLanguages;
99
+ if (Object.keys(languages).length === 0) {
100
+ languages = autoDetectLanguages(runtimeConfig);
101
+ runtimeConfig.resolvedLanguages = languages;
102
+ }
103
+
104
+ // Build the pair graph — this is the v3 drivetrain.
105
+ // Each pair carries its method, model, register, and plugin context.
106
+ const pairs = resolvePairs(runtimeConfig);
107
+ const plugins = loadPlugins(cwd);
108
+
109
+ // Resolve plugin configs into each pair that references one.
110
+ let resolvedPairs = new Map();
111
+ for (const [pairKey, rawPairConfig] of pairs) {
112
+ resolvedPairs.set(pairKey, resolvePluginForPair(plugins, rawPairConfig));
113
+ }
114
+
115
+ // ── --pair filter ──────────────────────────────────────────────────
116
+ // `sync --pair en:fr` restricts THIS run to the named pair(s). The flag
117
+ // used to be parsed by the CLI but never read here, so sync silently
118
+ // translated every configured locale — a 5× spend for a user who asked
119
+ // for one pair. An unknown or malformed value fails loud inside
120
+ // filterPairGraph (same behavior class as an unknown flag), never a
121
+ // silent no-op. Applied BEFORE preflight so readiness is only checked
122
+ // for the pairs that will actually run.
123
+ if (cliArgs.pair) {
124
+ const configuredCount = resolvedPairs.size;
125
+ resolvedPairs = filterPairGraph(cliArgs.pair, resolvedPairs);
126
+ output.info(`Pair filter: ${[...resolvedPairs.keys()].join(', ')} (${resolvedPairs.size} of ${configuredCount} configured pair(s))`);
127
+ }
128
+
129
+ // Sort for deterministic output ordering
130
+ const pairEntries = [...resolvedPairs.entries()].sort(([a], [b]) => a.localeCompare(b));
131
+
132
+ // ── SCRIPT DECISION ────────────────────────────────────────────────
133
+ // For every pair whose locale has a registered script converter, say what
134
+ // will happen and why — once, up front, in dry runs too. Silence here is
135
+ // what let unconditional PUA conversion ship unrenderable text: the user's
136
+ // first sign was a blank page. Locales without a converter say nothing.
137
+ //
138
+ // A locale with more than one REAL orthography (crk: SRO/Syllabics,
139
+ // sr: Latin/Cyrillic) refuses to translate until the config chooses —
140
+ // that is a decision about a community's writing system, and it belongs
141
+ // to the project, never to a default.
142
+ const scriptChoiceErrors = [];
143
+ for (const [pairKey, pairConfig] of pairEntries) {
144
+ const res = pairConfig.scriptResolution;
145
+ if (!res) continue;
146
+ const registered = converterKeyForLocale(pairConfig.target, getLanguageCard(pairConfig.target));
147
+ if (!registered) continue;
148
+ const info = getConverterInfo(registered);
149
+
150
+ if (res.source === 'choice-required') {
151
+ scriptChoiceErrors.push(` ✗ ${pairKey}: ${formatScriptChoiceError(pairConfig.target, res)}`);
152
+ } else if (res.converterKey) {
153
+ const fontNote = info.fontNote ? ` — ${info.fontNote}; run \`champollion fonts\`` : '';
154
+ output.info(`[SCRIPT] ${pairKey} — converting ${info.from} → ${info.to} (script: ${res.script ?? res.converterKey}, from config)${fontNote}`);
155
+ } else {
156
+ const optIn = info.toScript ? `"script": "${info.toScript}"` : `"script": "${registered}"`;
157
+ output.info(
158
+ `[SCRIPT] ${pairKey} — writing ${info.from} (${res.source === 'config' ? 'from config' : 'default'}; no conversion). `
159
+ + `Set ${optIn} to emit ${info.to}.`
160
+ );
161
+ }
162
+ }
163
+ if (scriptChoiceErrors.length > 0) {
164
+ throw new Error([
165
+ '',
166
+ ' ┌─ ORTHOGRAPHY CHOICE REQUIRED ───────────────────────────────────┐',
167
+ ' │ These locales have more than one real writing system. │',
168
+ ' │ Champollion will not choose one for a community. │',
169
+ ' └─────────────────────────────────────────────────────────────────┘',
170
+ '',
171
+ ...scriptChoiceErrors,
172
+ '',
173
+ ].join('\n'));
174
+ }
175
+
176
+ // ── PREFLIGHT READINESS CHECK ──────────────────────────────────────
177
+ // Validate that every pair's translation method can actually execute
178
+ // BEFORE entering the translation loop. Without this, a missing API
179
+ // key was only discovered deep inside the loop — and for content sync,
180
+ // it was never discovered at all (silently wrote English fallbacks).
181
+ //
182
+ // No gas, no ignition. If a method can't run, we fail here with
183
+ // clear guidance instead of producing garbage 360 files later.
184
+ // Skip preflight for dry-run (reporting only) and audit (listing fallbacks).
185
+ // These are read-only operations that don't need an API key.
186
+ const skipPreflight = cliArgs.dryRun || cliArgs.audit;
187
+ if (!skipPreflight) {
188
+ const failures = [];
189
+ for (const [pairKey, pairConfig] of pairEntries) {
190
+ const method = getMethod(pairConfig.method || 'llm', pairConfig);
191
+ const readiness = await method.checkReadiness({ apiKey, cwd });
192
+ if (!readiness.ready) {
193
+ failures.push({ pairKey, pairConfig, reason: readiness.reason, method });
194
+ }
195
+ }
196
+
197
+ if (failures.length > 0) {
198
+ // Build a single, actionable error with all failures + setup help.
199
+ // Use the first failure's method for setup help (they're likely all
200
+ // the same method with the same missing key).
201
+ const lines = [
202
+ '',
203
+ ' ┌─ PREFLIGHT FAILED ──────────────────────────────────────────────┐',
204
+ ' │ Cannot start translation — method prerequisites not met. │',
205
+ ' └─────────────────────────────────────────────────────────────────┘',
206
+ '',
207
+ ];
208
+ for (const { pairKey, pairConfig, reason } of failures) {
209
+ lines.push(` ✗ ${pairKey} (method: ${pairConfig.method || 'llm'}): ${reason}`);
210
+ }
211
+ lines.push('');
212
+
213
+ // Append setup help from the first failing method (most actionable)
214
+ const helpLines = failures[0].method.getSetupHelp();
215
+ lines.push(...helpLines);
216
+
217
+ throw new Error(lines.join('\n'));
218
+ }
219
+ }
220
+
221
+ return { apiKey, resolvedPairs, pairEntries };
222
+ }
223
+
224
+ /**
225
+ * Build the end-of-sync summary line, deciding success vs failure framing.
226
+ *
227
+ * Pure and total so it can be unit-tested directly. The key invariant: a sync
228
+ * with ANY failed keys must NOT be reported as [OK] — a green marker on a
229
+ * partially-failed run misleads CI and agents.
230
+ *
231
+ * @param {boolean} dryRun
232
+ * @param {number} totalProcessed - Keys processed (or that WOULD be, in dry-run)
233
+ * @param {number} totalFailed - Keys that failed translation / quality gate
234
+ * @param {number} [totalCopied=0] - No-translate keys copied verbatim from the
235
+ * source. Reported separately because they cost nothing and cannot fail —
236
+ * folding them into totalProcessed would overstate the translation work done.
237
+ * @returns {{ ok: boolean, message: string }} ok=false → caller logs as a warning
238
+ */
239
+ function formatSyncSummary(dryRun, totalProcessed, totalFailed, totalCopied = 0) {
240
+ const verb = dryRun ? 'Would have processed' : 'Synced';
241
+ const copied = totalCopied > 0 ? ` (+${totalCopied} copied verbatim, no-translate)` : '';
242
+ if (totalFailed > 0) {
243
+ return { ok: false, message: `${verb} ${totalProcessed} key(s)${copied}; ${totalFailed} failed (see summary below).` };
244
+ }
245
+ return { ok: true, message: `${verb} ${totalProcessed} keys total${copied}.` };
246
+ }
247
+
248
+ /**
249
+ * Run the main sync operation.
250
+ *
251
+ * @param {object} options - { dryRun, audit, cwd, cliArgs }
252
+ */
253
+ async function runSync(options = {}) {
254
+ const { dryRun = false, audit = false, cwd = process.cwd(), cliArgs = {} } = options;
255
+ const config = resolveConfig(cliArgs, cwd);
256
+
257
+ // --max-cost: parse eagerly so a malformed cap fails loud up front,
258
+ // before anything (even read-only work) happens.
259
+ const maxCost = parseMaxCost(cliArgs['max-cost']);
260
+
261
+ // Clear any translation failure recorded by a prior in-process sync (watch
262
+ // mode) so getSetupHelp() reflects THIS run's failure, not a stale one.
263
+ resetTranslationError();
264
+
265
+ // Early dispatch: Docusaurus projects get their own sync path.
266
+ // This keeps the entire existing sync logic untouched. --max-cost is
267
+ // enforced INSIDE runDocusaurusSync (it needs the resolved pair graph +
268
+ // TM to estimate); an over-cap abort returns the same maxCostAborted
269
+ // result shape, which commands/sync.js maps to exit code 2.
270
+ if (config.format === 'docusaurus') {
271
+ return runDocusaurusSync(options, config, cwd, resolveRuntime);
272
+ }
273
+
274
+ // Verify locales directory exists
275
+ if (!fs.existsSync(config.localesDir)) {
276
+ throw new Error(`Locales directory not found: ${config.localesDir}. Create it or set "localesDir" in your config file.`);
277
+ }
278
+
279
+ // --- Version banner ---
280
+ const require = createRequire(import.meta.url);
281
+ const { version } = require('../package.json');
282
+ output.banner(version);
283
+
284
+ // Detect locale file format (JSON, TOML, or YAML)
285
+ // CLI flag takes priority, then config file, then auto-detect from directory
286
+ const isAutoFormat = config.format === 'auto';
287
+ const format = isAutoFormat
288
+ ? detectFormatFromDir(config.localesDir)
289
+ : config.format;
290
+ const ext = getExtension(format);
291
+ output.info(`Detected format: ${format} (${isAutoFormat ? 'auto' : 'config'})`);
292
+
293
+ // Framework detection — Hugo (contentDir) or Docusaurus (already dispatched above)
294
+ if (config.contentDir) {
295
+ output.info('Detected framework: Hugo');
296
+ output.info(`Content directory: ${config.contentDir}`);
297
+ }
298
+
299
+ const inputLocale = config.inputLocale;
300
+ const sourceFile = `${inputLocale}${ext}`;
301
+ const sourcePath = path.join(config.localesDir, sourceFile);
302
+ if (!fs.existsSync(sourcePath)) {
303
+ throw new Error(`Source locale not found: ${sourcePath}`);
304
+ }
305
+
306
+ // For JSON, read and flatten the nested structure.
307
+ // For TOML/YAML, readLocaleFile already returns a flat map.
308
+ const sourceRaw = readLocaleFile(sourcePath, format);
309
+ const sourceFlat = format === 'json' ? flattenKeys(sourceRaw) : sourceRaw;
310
+
311
+ // Detect YAML sub-format: Hugo (CLDR plural sub-keys only) vs standard nested.
312
+ // Read the raw file content to inspect sub-key names before they're flattened.
313
+ const yamlStyle = format === 'yaml'
314
+ ? detectYAMLStyle(fs.readFileSync(sourcePath, 'utf-8'))
315
+ : null;
316
+
317
+ // Defense-in-depth: remove any keys that could cause prototype pollution.
318
+ // Extremely unlikely in real locale files but important for a public package.
319
+ for (const key of Object.keys(sourceFlat)) {
320
+ if (isUnsafeKey(key)) {
321
+ delete sourceFlat[key];
322
+ }
323
+ }
324
+
325
+ const sourceKeyCount = Object.keys(sourceFlat).length;
326
+
327
+ // --force: re-queue EVERY string key — the whole-locale rebuild verb.
328
+ // Recovering from a bad version is exactly when someone needs this, and
329
+ // the only prior route was deleting the locale file by hand. Scope with
330
+ // --pair; combine with --no-tm when the cache itself is suspect (TM hits
331
+ // are still gate-checked and poisoned entries evicted, but --no-tm forces
332
+ // a fully fresh re-bill). Set BEFORE the cost estimator so the preview
333
+ // prices the full rebuild, and --max-cost can cap it.
334
+ if (cliArgs.force) {
335
+ config.forceKeys = Object.keys(sourceFlat).filter(k => typeof sourceFlat[k] === 'string');
336
+ output.info(`--force: re-queuing all ${config.forceKeys.length} source key(s)${cliArgs.pair ? ' for the selected pair(s)' : ''}`);
337
+ }
338
+
339
+ // Load the hash manifest and detect which English values changed
340
+ // since the last sync. On first run (no manifest), this returns []
341
+ // and everything flows through the normal missing-key detection.
342
+ const oldManifest = readManifest(cwd);
343
+ const changedKeys = detectChangedKeys(sourceFlat, oldManifest);
344
+ const currentManifest = buildHashManifest(sourceFlat);
345
+
346
+ // No-translate matcher — the ONE compiled instance for this run. The cost
347
+ // estimator and every locale's diff share it, so the keys excluded from the
348
+ // bill are exactly the keys excluded from translation, by construction.
349
+ const noTranslate = compileNoTranslate(config);
350
+ if (noTranslate.patterns.length > 0) {
351
+ output.info(`No-translate patterns: ${noTranslate.patterns.join(', ')}`);
352
+ }
353
+
354
+ // Resolve the pair graph via the shared helper.
355
+ // Thread dryRun/audit into cliArgs so the preflight check can skip
356
+ // for read-only operations that don't need an API key.
357
+ const { apiKey, resolvedPairs, pairEntries } = await resolveRuntime(config, cwd, { ...cliArgs, dryRun, audit });
358
+
359
+ // Provenance check — warn about uncleared licensing before sync starts.
360
+ // This is informational only (does not block execution).
361
+ const provenanceAudit = auditProvenance(resolvedPairs);
362
+ if (!provenanceAudit.allClear) {
363
+ for (const blockedKey of provenanceAudit.blockedPairs) {
364
+ const blockedPair = resolvedPairs.get(blockedKey);
365
+ output.warn(`${blockedKey}: Method "${blockedPair.method}" has unverified licensing. Run \`champollion provenance\` for details.`);
366
+ }
367
+ }
368
+
369
+ if (pairEntries.length === 0) {
370
+ output.info('No target languages configured. Run `champollion init` to set up.');
371
+ return;
372
+ }
373
+
374
+ // --- Audit mode ---
375
+ if (audit) {
376
+ output.info('Audit: scanning for untranslated values...');
377
+ let total = 0;
378
+ const auditLocales = [];
379
+ const missingLocales = [];
380
+ for (const [, pairConfig] of pairEntries) {
381
+ const code = pairConfig.target;
382
+ const filename = `${code}${ext}`;
383
+ const filePath = path.join(config.localesDir, filename);
384
+ if (!fs.existsSync(filePath)) {
385
+ // A configured locale with NO file is 100% untranslated, not "fully
386
+ // translated". Skipping it silently let an audit wired as a CI gate
387
+ // pass with zero translation done — every source key counts as
388
+ // untranslated and the run must exit non-zero.
389
+ if (sourceKeyCount > 0) {
390
+ missingLocales.push(filename);
391
+ auditLocales.push({
392
+ locale: code,
393
+ file: filename,
394
+ missing: true,
395
+ untranslatedCount: sourceKeyCount,
396
+ untranslatedKeys: Object.keys(sourceFlat),
397
+ });
398
+ output.error(`${filename}: locale file missing — all ${sourceKeyCount} key(s) untranslated. Run \`champollion sync\` to create it.`);
399
+ total += sourceKeyCount;
400
+ }
401
+ continue;
402
+ }
403
+ const dataRaw = readLocaleFile(filePath, format);
404
+ const flat = format === 'json' ? flattenKeys(dataRaw) : dataRaw;
405
+ const untranslated = Object.entries(flat)
406
+ .filter(([, val]) => typeof val === 'string' && val.startsWith(config.fallbackPrefix));
407
+ auditLocales.push({
408
+ locale: code,
409
+ file: filename,
410
+ untranslatedCount: untranslated.length,
411
+ untranslatedKeys: untranslated.map(([key]) => key),
412
+ });
413
+ if (untranslated.length > 0) {
414
+ output.raw(` ${filename}: ${untranslated.length} keys still need translation`);
415
+ for (const [key] of untranslated) {
416
+ output.raw(` - ${key}`);
417
+ }
418
+ total += untranslated.length;
419
+ }
420
+ }
421
+ if (missingLocales.length > 0) {
422
+ output.raw(`\n Total: ${total} keys need translation (${missingLocales.length} locale file(s) missing: ${missingLocales.join(', ')}).`);
423
+ } else {
424
+ output.raw(total === 0
425
+ ? '\n All locale files are fully translated.'
426
+ : `\n Total: ${total} keys need translation.`);
427
+ }
428
+ // Machine-readable end-of-command summary — in --json mode the raw lines
429
+ // above are suppressed, so the key list must ride the summary object.
430
+ output.summary({
431
+ command: 'audit',
432
+ untranslatedCount: total,
433
+ missingLocales,
434
+ locales: auditLocales,
435
+ });
436
+ return { untranslatedCount: total, missingLocaleCount: missingLocales.length };
437
+ }
438
+
439
+ // --- Sync mode ---
440
+ const methodSummary = pairEntries.map(([, p]) => `${p.target}:${p.method}`).join(', ');
441
+ output.info(`Source: ${sourceFile} (${sourceKeyCount} keys)`);
442
+ output.info(`Pairs: ${methodSummary}`);
443
+ if (changedKeys.length > 0) {
444
+ output.info(`Changed: ${changedKeys.length} key(s) have updated source content`);
445
+ }
446
+ if (dryRun) output.info('Dry-run mode — no files will be modified.');
447
+
448
+ // Load Translation Memory — provides same-project caching across syncs.
449
+ // Keys whose source text + locale + method haven't changed will be served
450
+ // from TM instead of hitting the API. This is the primary cost-saving
451
+ // mechanism: re-running sync after a single key change only translates
452
+ // that one key, not the entire file.
453
+ //
454
+ // Loaded BEFORE the cost estimate so the estimator partitions against the
455
+ // exact TM this run will use — TM hits are $0, not fresh API calls.
456
+ //
457
+ // --no-tm bypasses the cache entirely: all keys go to the API and nothing
458
+ // is stored. Useful when switching providers or debugging translation
459
+ // quality. The empty TM object makes the estimator price every key too.
460
+ const noTM = cliArgs['no-tm'] || false;
461
+ const tm = noTM ? { _meta: { version: 1 } } : loadTM(cwd);
462
+ const tmInitialSize = tmSize(tm);
463
+ if (noTM) {
464
+ output.info('Translation Memory disabled (--no-tm)');
465
+ } else if (tmInitialSize > 0) {
466
+ output.info(`Translation Memory: ${tmInitialSize} cached entries loaded`);
467
+ }
468
+
469
+ // --- Pre-sync cost estimation ---
470
+ // Runs for EVERY engine (each method implements estimateCost, or honestly
471
+ // reports "unknown"). Without --max-cost this stays non-blocking: failures
472
+ // log a warning and the sync continues. With --max-cost the estimate is a
473
+ // GATE: over-cap or unknowable estimates abort before any API call.
474
+ const costEstimate = await printCostEstimate(
475
+ pairEntries, sourceFlat, config, format, ext, changedKeys, { cwd, tm, noTranslate }
476
+ );
477
+
478
+ // Enforce the cap only for real runs: a dry-run makes zero API calls, and
479
+ // aborting it would block the exact preview a capped user needs to see.
480
+ if (maxCost !== null && !dryRun) {
481
+ if (!costEstimate) {
482
+ return abortForMaxCost(
483
+ maxCost, null,
484
+ 'Cost estimation failed, so --max-cost cannot be enforced (unknown is not free).'
485
+ );
486
+ }
487
+ if (costEstimate.hasUnknownCosts) {
488
+ return abortForMaxCost(
489
+ maxCost, null,
490
+ 'Some pairs have unknown pricing, so the total cost cannot be bounded (unknown is not free).'
491
+ );
492
+ }
493
+ if (costEstimate.totalEstimatedCost > maxCost) {
494
+ return abortForMaxCost(
495
+ maxCost, costEstimate.totalEstimatedCost,
496
+ 'Estimated translation cost exceeds the --max-cost cap.'
497
+ );
498
+ }
499
+ }
500
+
501
+ output.raw('');
502
+
503
+ let totalProcessed = 0;
504
+ let totalTMHits = 0;
505
+ let totalFailed = 0;
506
+ let totalCopied = 0;
507
+ let totalKeptWorkingScript = 0;
508
+ const failedPairs = [];
509
+
510
+ // ── Parallel locale processing ────────────────────────────────
511
+ // Each locale writes to its own file and its own TM keys (keyed by
512
+ // locale code), so there are zero data dependencies between locales.
513
+ // Node.js is single-threaded so storeTM() property assignments can't
514
+ // interleave between await points — fully safe under pMap concurrency.
515
+ const jsonConcurrency = config.jsonConcurrency ?? DEFAULT_JSON_CONCURRENCY;
516
+ output.info(`Translating ${pairEntries.length} locale(s) with concurrency ${jsonConcurrency}`);
517
+
518
+ const localeResults = await pMap(pairEntries, async ([pairKey, pairConfig]) => {
519
+ const code = pairConfig.target;
520
+ const filename = `${code}${ext}`;
521
+ const filePath = path.join(config.localesDir, filename);
522
+
523
+ // Security: verify the resolved write path is still within localesDir.
524
+ // Prevents path traversal via crafted language codes like "../../../etc/passwd".
525
+ // A refusal means NO file was written — count it as a failure so the run
526
+ // reports it and exits non-zero, instead of printing [OK] and exiting 0.
527
+ if (!isPathContained(filePath, config.localesDir)) {
528
+ output.error(`${filename} — refusing to write outside locales directory`);
529
+ // Nothing ran for this locale, so every changed key is unresolved here.
530
+ // Only changed keys matter for manifest retry-safety: missing keys
531
+ // re-fire via missing-key detection regardless of the manifest.
532
+ return { processed: 0, tmHits: 0, failed: 1, failedKeys: changedKeys, pairKey };
533
+ }
534
+
535
+ // If locale file doesn't exist yet, create it as empty
536
+ let data = {};
537
+ if (fs.existsSync(filePath)) {
538
+ data = readLocaleFile(filePath, format);
539
+ }
540
+
541
+ // For JSON, flatten the nested structure. TOML/YAML is already flat.
542
+ const targetFlat = format === 'json' ? flattenKeys(data) : { ...data };
543
+ // Source-echo requeue suppression: a target value equal to its source is
544
+ // only requeued when the TM does NOT confirm the echo came from the
545
+ // pipeline. lookupTM === sourceValue means a previous run translated this
546
+ // exact text to itself and the gate approved it — skip, don't re-bill.
547
+ // With --no-tm the TM is empty, so nothing is suppressed.
548
+ const tmKey = tmMethodKey(pairConfig);
549
+ const diff = diffLocale(
550
+ sourceFlat, targetFlat, config.fallbackPrefix, config.forceKeys, changedKeys,
551
+ (key, sourceValue) => lookupTM(tm, sourceValue, code, tmKey) === sourceValue,
552
+ noTranslate.active ? noTranslate.matches : null
553
+ );
554
+
555
+ if (diff.toProcess.length === 0 && diff.noTranslate.length === 0 && diff.extra.length === 0) {
556
+ output.ok(`${filename} — fully synced`);
557
+ return { processed: 0, tmHits: 0 };
558
+ }
559
+
560
+ let localeProcessed = 0;
561
+ let localeTMHits = 0;
562
+ let localeFailed = 0;
563
+ let localeCopied = 0;
564
+ let localeKeptWorkingScript = 0;
565
+ // Key NAMES that failed in this locale — threaded back to the aggregator
566
+ // so writeManifest can restore their OLD hashes. Persisting the NEW hash
567
+ // for a failed key would mark it resolved and it would never be retried.
568
+ const localeFailedKeys = [];
569
+
570
+ if (diff.toProcess.length > 0 || diff.noTranslate.length > 0) {
571
+ output.info(`${filename} — ${diffLabel(diff)}`);
572
+ }
573
+
574
+ // ── No-translate keys: copy the source value, verbatim ─────────────
575
+ // Runs BEFORE the translation block so a dead backend can't strand a
576
+ // corrupted URL for another cycle (see flushNoTranslateOnBail below).
577
+ // No API call, no quality gate, no cost — and byte-identical by
578
+ // construction, which is the only correct outcome for these values.
579
+ if (diff.noTranslate.length > 0) {
580
+ for (const key of diff.noTranslate) {
581
+ if (dryRun) continue;
582
+ if (format === 'json') setNestedValue(data, key, sourceFlat[key]);
583
+ else data[key] = sourceFlat[key];
584
+ }
585
+ localeCopied = diff.noTranslate.length;
586
+ const sample = diff.noTranslate.slice(0, 3)
587
+ .map(k => `${k} (${noTranslate.reason(k, sourceFlat[k])})`)
588
+ .join(', ');
589
+ const more = diff.noTranslate.length > 3 ? `, +${diff.noTranslate.length - 3} more` : '';
590
+ output.info(`${filename} — ${dryRun ? 'would copy' : 'copied'} ${localeCopied} no-translate key(s) verbatim: ${sample}${more}`);
591
+ }
592
+
593
+ // A whole-locale translation failure returns early and writes nothing, so
594
+ // the verbatim copies above would be lost with it. They do not depend on
595
+ // the backend, so flush them: a repair that is already computed and free
596
+ // must not wait on an unrelated outage.
597
+ const flushNoTranslateOnBail = () => {
598
+ if (dryRun || diff.noTranslate.length === 0) return 0;
599
+ try {
600
+ writeLocaleFile(filePath, data, format, format !== 'json' ? data : undefined, yamlStyle);
601
+ return localeCopied;
602
+ } catch (err) {
603
+ output.error(`${filename} — failed to write no-translate copies: ${err.message}`);
604
+ return 0;
605
+ }
606
+ };
607
+
608
+ if (diff.toProcess.length > 0) {
609
+ if (dryRun) {
610
+ // Dry-run does no API calls and writes nothing, but it must still
611
+ // report what it WOULD process — otherwise the summary always reads
612
+ // "Would have processed 0 keys total." even with pending work.
613
+ localeProcessed += diff.toProcess.length;
614
+
615
+ // --list-keys: NAME the queued keys, per reason. Counts alone made
616
+ // investigating a surprise queue impossible without re-implementing
617
+ // the diff by hand — integrity names damaged keys; a dry run must
618
+ // name queued ones. (The --json summary always carries these lists
619
+ // on dry runs; this is the human rendering.)
620
+ if (cliArgs['list-keys']) {
621
+ const sections = [
622
+ ['missing', diff.missing],
623
+ ['[EN] fallback', diff.needsTranslation],
624
+ ['untranslated (unstamped echo)', diff.untranslated],
625
+ ['changed', diff.changed],
626
+ ['forced', diff.forced],
627
+ ['copy verbatim (no-translate)', diff.noTranslate],
628
+ ];
629
+ for (const [label, keys] of sections) {
630
+ if (keys.length === 0) continue;
631
+ output.raw(` ${label}:`);
632
+ for (const k of keys) output.raw(` - ${k}`);
633
+ }
634
+ }
635
+ }
636
+
637
+ if (!dryRun) {
638
+ let translated = null;
639
+
640
+ const stringKeys = diff.toProcess.filter(k => typeof sourceFlat[k] === 'string');
641
+ if (stringKeys.length > 0) {
642
+ // Shared pipeline: TM partition → API call → TM store → quality gate
643
+ const result = await translateAndValidate(stringKeys, sourceFlat, pairConfig, pairKey, {
644
+ apiKey, tm, targetCode: code,
645
+ onProgress: (completed, total) => {
646
+ output.progressBar(completed, total);
647
+ },
648
+ });
649
+ translated = result.translated;
650
+ localeTMHits += result.tmHitCount;
651
+
652
+ // Terminology enforcement: check if dictionary terms were applied.
653
+ // This only runs when the pair has coaching data with a dictionary.
654
+ if (translated && pairConfig.coachingData?.dictionary) {
655
+ const { violations } = verifyTerminology(translated, sourceFlat, pairConfig.coachingData.dictionary);
656
+ if (violations.length > 0) {
657
+ logTermViolations(violations, pairKey);
658
+ }
659
+ }
660
+
661
+ if (translated) {
662
+ localeFailed += result.failures.length;
663
+ output.progress(result.failures.length > 0
664
+ ? ` [OK] (${result.failures.length} key(s) failed quality gate)`
665
+ : ' [OK]\n');
666
+ } else if (result.apiReturnedNull) {
667
+ // Method returned null — fail loud with actionable guidance.
668
+ output.progress(' [ERR]\n');
669
+ output.error(`${pairKey}: Translation method "${pairConfig.method}" returned no results.`);
670
+ const methodInstance = getMethod(pairConfig.method, pairConfig);
671
+ const helpLines = methodInstance.getSetupHelp();
672
+ for (const line of helpLines) {
673
+ output.error(line);
674
+ }
675
+ // Whole locale failed: every pending key must re-fire next sync.
676
+ return { processed: 0, tmHits: localeTMHits, copied: flushNoTranslateOnBail(), failed: diff.toProcess.length, failedKeys: diff.toProcess, pairKey };
677
+ } else if (result.failures.length > 0 && !translated) {
678
+ // All translations failed quality gate — fail loud
679
+ output.progress(' [ERR] all translations failed quality gate\n');
680
+ output.error(`${pairKey}: All translations were rejected by the quality gate.`);
681
+ output.error('Check your method configuration or review the gate failures above.');
682
+ return { processed: 0, tmHits: localeTMHits, copied: flushNoTranslateOnBail(), failed: diff.toProcess.length, failedKeys: diff.toProcess, pairKey };
683
+ }
684
+ }
685
+
686
+ // Post-translation script conversion — ONLY when this pair's script
687
+ // resolution asked for it (config `script:`). The old gate was a bare
688
+ // registry lookup, which converted every tlh/crk/… project into
689
+ // display scripts (PUA for the conlangs) whether or not their fonts
690
+ // could render them. See lib/scripts.js resolveTargetScript.
691
+ const scriptConverterKey = pairConfig.scriptResolution?.converterKey || null;
692
+ if (scriptConverterKey && translated && Object.keys(translated).length > 0) {
693
+ const info = getConverterInfo(scriptConverterKey);
694
+ output.info(`[SCRIPT] Converting ${info.from} → ${info.to} (${Object.keys(translated).length} keys)`);
695
+ }
696
+
697
+ for (const key of diff.toProcess) {
698
+ const sourceValue = sourceFlat[key];
699
+ let value;
700
+
701
+ if (translated && key in translated) {
702
+ value = translated[key];
703
+
704
+ if (scriptConverterKey && typeof value === 'string') {
705
+ // User-declared transliteration fallbacks first (validated at
706
+ // pair build), then the converter. If letters remain that the
707
+ // converter cannot map, the output would be an unreadable mix
708
+ // of both scripts — keep the WHOLE value in the working script
709
+ // instead, and say which letters and how to map them. Not a
710
+ // failure: unmappable proper nouns would fail identically on
711
+ // every retry, and a permanently red sync is the trap this
712
+ // release exists to close.
713
+ const prepared = applyScriptFallback(value, pairConfig.scriptFallback);
714
+ const { converted, unmapped } = convertScript(prepared, scriptConverterKey);
715
+ if (unmapped.length === 0) {
716
+ value = converted;
717
+ } else {
718
+ const hint = unmapped.map(l => `"${l}": "?"`).join(', ');
719
+ output.warn(
720
+ `${pairKey}: key "${key}" kept in ${getConverterInfo(scriptConverterKey).from} — `
721
+ + `letter(s) the converter cannot map: ${unmapped.join(', ')}. `
722
+ + `To transliterate them, add "scriptFallback": { ${hint} } for ${pairConfig.target}.`
723
+ );
724
+ localeKeptWorkingScript++;
725
+ }
726
+ }
727
+ } else if (typeof sourceValue === 'string') {
728
+ // Key not in translated result (gate rejection or partial API
729
+ // response) — skip it, don't write garbage. Record the key so its
730
+ // old manifest hash is restored and the retry actually happens.
731
+ output.warn(`${pairKey}: key "${key}" not translated — skipping (will retry next sync)`);
732
+ localeFailedKeys.push(key);
733
+ continue;
734
+ } else {
735
+ value = sourceValue;
736
+ }
737
+
738
+ if (format === 'json') {
739
+ setNestedValue(data, key, value);
740
+ } else {
741
+ data[key] = value;
742
+ }
743
+ }
744
+
745
+ localeProcessed += diff.toProcess.length;
746
+ }
747
+ }
748
+
749
+ if (diff.extra.length > 0) {
750
+ output.warn(`${filename} — ${diff.extra.length} extra key(s) not in source`);
751
+ }
752
+
753
+ // Write updated file.
754
+ // CRITICAL: isolate the write per-locale. If one locale file is unwritable
755
+ // (e.g. read-only permissions, a full disk, a locked file), a thrown error
756
+ // here used to reject the whole pMap and discard every SIBLING locale's
757
+ // already-paid translations. Instead, catch it, count this locale's keys as
758
+ // failed, and let the other locales write. The run reports the failure and
759
+ // exits non-zero rather than aborting everything.
760
+ if (!dryRun && (diff.toProcess.length > 0 || diff.noTranslate.length > 0)) {
761
+ try {
762
+ writeLocaleFile(filePath, data, format, format !== 'json' ? data : undefined, yamlStyle);
763
+ } catch (err) {
764
+ output.error(`${filename} — failed to write: ${err.message}`);
765
+ // Everything we attempted for this locale is unwritten → all failed.
766
+ // That includes the verbatim copies: they were staged in memory only.
767
+ return {
768
+ processed: 0,
769
+ tmHits: localeTMHits,
770
+ copied: 0,
771
+ failed: diff.toProcess.length,
772
+ failedKeys: diff.toProcess,
773
+ pairKey,
774
+ };
775
+ }
776
+ }
777
+
778
+ return {
779
+ processed: localeProcessed,
780
+ tmHits: localeTMHits,
781
+ copied: localeCopied,
782
+ keptWorkingScript: localeKeptWorkingScript,
783
+ failed: localeFailed,
784
+ failedKeys: localeFailedKeys,
785
+ pairKey,
786
+ // Dry runs carry the NAMES of queued keys per reason, so agents can
787
+ // read the plan from the --json summary instead of re-deriving the
788
+ // diff. Omitted on real runs — per-key outcomes are reported there.
789
+ ...(dryRun && {
790
+ queuedKeys: {
791
+ missing: diff.missing,
792
+ fallback: diff.needsTranslation,
793
+ untranslated: diff.untranslated,
794
+ changed: diff.changed,
795
+ forced: diff.forced,
796
+ noTranslate: diff.noTranslate,
797
+ },
798
+ }),
799
+ };
800
+ }, { concurrency: jsonConcurrency });
801
+
802
+ // Aggregate results across all locales
803
+ const failedKeySet = new Set();
804
+ for (const r of localeResults) {
805
+ totalProcessed += r.processed;
806
+ totalTMHits += r.tmHits;
807
+ totalFailed += (r.failed || 0);
808
+ totalCopied += (r.copied || 0);
809
+ totalKeptWorkingScript += (r.keptWorkingScript || 0);
810
+ if (r.failed > 0 && r.pairKey) {
811
+ failedPairs.push({ pair: r.pairKey, count: r.failed });
812
+ }
813
+ for (const key of r.failedKeys || []) failedKeySet.add(key);
814
+ }
815
+
816
+ // Summary. Never print [OK] when keys failed — a green success marker on a
817
+ // partially-failed sync misleads CI and agents into thinking all is well.
818
+ const summary = formatSyncSummary(dryRun, totalProcessed, totalFailed, totalCopied);
819
+ if (summary.ok) output.ok(summary.message);
820
+ else output.warn(summary.message);
821
+
822
+ // Keys kept in the working script (unmapped letters) are informational —
823
+ // valid translations, just not converted. Surface the count once so the
824
+ // per-key warnings above can't scroll away unnoticed.
825
+ if (totalKeptWorkingScript > 0) {
826
+ output.warn(
827
+ `${totalKeptWorkingScript} key(s) kept in the working script — the converter could not map some letters. `
828
+ + 'See the warnings above for a per-key "scriptFallback" suggestion.'
829
+ );
830
+ }
831
+
832
+ // ── Failure summary ──────────────────────────────────────────────
833
+ // Print a clear summary when any locale had partial failures.
834
+ // This gives the user a single glanceable block at the end instead
835
+ // of having to scroll back through per-locale output to find issues.
836
+ if (failedPairs.length > 0) {
837
+ output.raw('');
838
+ output.warn('Failure summary:');
839
+ for (const { pair, count } of failedPairs) {
840
+ output.warn(` ${pair}: ${count} key(s) failed translation or quality gate`);
841
+ }
842
+ output.warn(`Total: ${totalFailed} key(s) failed across ${failedPairs.length} locale(s).`);
843
+ output.warn('Failed keys keep their previous manifest hash and will be retried on the next sync.');
844
+ }
845
+
846
+ // Write the updated hash manifest so the next sync knows
847
+ // what state the translations are based on.
848
+ // Skip in dry-run mode — don't mark stale keys as resolved.
849
+ if (!dryRun) {
850
+ // ── Manifest retry-safety ─────────────────────────────────────
851
+ // A key that failed in ANY locale must keep its OLD hash: persisting the
852
+ // NEW hash would mark the changed source as resolved, and the failed key
853
+ // would never be re-detected as 'changed' (the locale file still has the
854
+ // stale translation, so missing-key detection can't catch it either).
855
+ //
856
+ // CONSERVATIVE by design: one restore is global, so the key re-fires for
857
+ // ALL locales next sync — but the re-fire is TM-served (zero API cost)
858
+ // for locales that already succeeded, so the only real work is the retry
859
+ // that actually failed. Keys with no prior hash are dropped from the
860
+ // manifest entirely; they were never written, so missing-key detection
861
+ // re-fires them regardless.
862
+ for (const key of failedKeySet) {
863
+ // Own-property check: `in` walks the prototype chain, so a key
864
+ // literally named "toString"/"valueOf" would mis-resolve.
865
+ if (Object.prototype.hasOwnProperty.call(oldManifest, key)) {
866
+ currentManifest[key] = oldManifest[key];
867
+ } else {
868
+ delete currentManifest[key];
869
+ }
870
+ }
871
+ writeManifest(cwd, currentManifest);
872
+
873
+ // Persist TM if it was mutated during this sync (stores OR evictions —
874
+ // a size check would miss eviction-only runs and same-key replacements).
875
+ // Skip when --no-tm is active — nothing was cached, nothing to save.
876
+ if (!noTM && isTMDirty(tm)) {
877
+ const tmFinalSize = tmSize(tm);
878
+ const delta = tmFinalSize - tmInitialSize;
879
+ saveTM(cwd, tm);
880
+ output.info(`[TM] Saved ${tmFinalSize} entries (${delta >= 0 ? '+' + delta : delta} this sync)`);
881
+ }
882
+ }
883
+
884
+ // Content sync — translate Hugo Markdown content files if configured.
885
+ // Uses the same resolved pair graph as key-value sync, ensuring method
886
+ // dispatch is consistent across both translation modes.
887
+ if (config.contentDir) {
888
+ await runContentSync({
889
+ contentDir: config.contentDir,
890
+ sourceLocale: inputLocale,
891
+ pairs: resolvedPairs,
892
+ translatableFields: config.translatableFields,
893
+ apiKey,
894
+ dryRun,
895
+ noTM,
896
+ cwd,
897
+ concurrency: config.contentConcurrency || 12,
898
+ });
899
+ }
900
+
901
+ // ── Post-sync verification ──────────────────────────────────────
902
+ // Re-read written locale files from disk and confirm translations
903
+ // are actually present and correct. Catches the gap between the CLI
904
+ // reporting "synced N keys" and keys being wrong in fact.
905
+ // Skipped for dry-run (nothing written) and audit (read-only).
906
+ //
907
+ // Verification errors feed the exit code: a sync that wrote files but
908
+ // left [EN] markers, missing keys, or wrong-script values is NOT a clean
909
+ // pass, and a CI gate must see that.
910
+ let verifyErrors = 0;
911
+ let verifyWarnings = 0;
912
+ if (!dryRun && !audit && !cliArgs['no-verify']) {
913
+ const v = await verifyLocales(config, cwd, { noTranslate });
914
+ verifyErrors = v.errors;
915
+ verifyWarnings = v.warnings;
916
+ }
917
+
918
+ // Per-locale structured results (zip the parallel pMap output back to its
919
+ // pair keys) — used by the --json summary so agents don't regex log lines.
920
+ const localeSummary = pairEntries.map(([pairKey, pairConfig], i) => {
921
+ const r = localeResults[i] || {};
922
+ return {
923
+ pair: pairKey,
924
+ target: pairConfig.target,
925
+ processed: r.processed || 0,
926
+ failed: r.failed || 0,
927
+ tmHits: r.tmHits || 0,
928
+ copied: r.copied || 0,
929
+ keptWorkingScript: r.keptWorkingScript || 0,
930
+ ...(r.queuedKeys && { queuedKeys: r.queuedKeys }),
931
+ };
932
+ });
933
+
934
+ // Machine-readable end-of-command summary. In --json mode output.summary
935
+ // emits a single {level:'summary', ...} object; in default/quiet mode it
936
+ // is a no-op (the human-readable lines above already cover it), so this is
937
+ // additive and never double-prints.
938
+ output.summary({
939
+ command: 'sync',
940
+ dryRun,
941
+ totalProcessed,
942
+ totalFailed,
943
+ tmHits: totalTMHits,
944
+ // No-translate keys copied verbatim: never sent to a backend, never
945
+ // gated, never billed. Counted apart from totalProcessed so an agent
946
+ // reading this can tell translation work from passthrough.
947
+ totalCopied,
948
+ // Valid translations left in the working script because the converter
949
+ // could not map some of their letters (see scriptFallback).
950
+ totalKeptWorkingScript,
951
+ noTranslate: { patterns: noTranslate.patterns, autoDetectUrls: noTranslate.urls },
952
+ verify: { errors: verifyErrors, warnings: verifyWarnings },
953
+ failedPairs,
954
+ locales: localeSummary,
955
+ // Pre-run cost estimate (null when estimation failed) — the human table
956
+ // is output.raw and therefore invisible in --json, so the structured
957
+ // estimate must ride the summary for agents/CI.
958
+ costEstimate,
959
+ });
960
+
961
+ // Return result for exit code determination.
962
+ // totalFailed > 0 means some keys couldn't be translated; verifyErrors > 0
963
+ // means written files didn't pass verification. The caller maps these to a
964
+ // non-zero exit code (see lib/commands/sync.js computeExitCode).
965
+ return { totalProcessed, totalFailed, totalCopied, totalKeptWorkingScript, failedPairs, verifyErrors, verifyWarnings };
966
+ }
967
+
968
+ export { runSync, runContentSync, resolveRuntime, loadApiKey, formatSyncSummary };
969
+