champollion 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
package/lib/verify.js CHANGED
@@ -15,6 +15,16 @@
15
15
  * PHILOSOPHY: This is a trust-but-verify gate. Sync does the work,
16
16
  * verify confirms the work is correct. Every issue is logged LOUD.
17
17
  *
18
+ * DAMAGE LEAVES THE CACHE. A value verify finds damaged — ICU structure,
19
+ * placeholders, hollowed — that the Translation Memory proves the pipeline
20
+ * wrote is evicted from the TM (lib/tm-evict.js), so the next sync (or the
21
+ * `--force-keys` the report names) re-translates it instead of re-serving
22
+ * the damage for free. Hand-written values have no equal TM entry and are
23
+ * never touched; the files themselves are never modified here.
24
+ *
25
+ * SCOPE. `options.locales` limits verification to the locales a run touched
26
+ * — `sync --pair en:fr` verifies French only, not every locale on disk.
27
+ *
18
28
  * OUTPUT: The decorative section headers and per-check [OK] lines go through
19
29
  * output.raw(), which is suppressed in --json mode. The real findings go
20
30
  * through output.ok/warn/error, which json-encode themselves — so
@@ -24,16 +34,44 @@
24
34
 
25
35
  import fs from 'node:fs';
26
36
  import path from 'node:path';
27
- import { readLocaleFile, detectFormatFromDir, getExtension, extractDocusaurusMessages } from './format.js';
28
- import { flattenKeys } from './flatten.js';
29
- import { auditLocalePair } from './integrity.js';
30
- import { NON_LATIN_LOCALES, isAsciiOnly } from './validate.js';
37
+ import { extractDocusaurusMessages } from './format.js';
38
+ import {
39
+ discoverLocaleLayout, loadSourceUnits, expectedForTarget, readLocaleFlat, walkFiles, NS_SEPARATOR, lockKey,
40
+ } from './locale-layout.js';
41
+ import { readLock } from './hash.js';
42
+ import { LockState, localeHealth, decodeForm, decodeWritten, valueHash } from './locale-state.js';
43
+ import { getMethod } from './translate.js';
44
+ import { originKey } from './plurals.js';
45
+ import { auditLocalePair, auditARBDocument } from './integrity.js';
46
+ import { placeholderChanges, placeholderSentenceBreaks } from './placeholders.js';
47
+ import { NON_LATIN_LOCALES, isLatinOnly, hasForeignFullwidthLatin, isProtectedTermValue, SharedOutputIndex, sharedOutputItems, droppedTerminalMarks, describeDroppedMarks, contentGateFault } from './validate.js';
48
+ import { discoverContentFiles, getTargetContentPath, parseContentFile, DEFAULT_TRANSLATABLE_FIELDS } from './content.js';
49
+ import { translatableBlockSources } from './content-estimate.js';
31
50
  import { compileNoTranslate } from './no-translate.js';
32
- import { resolvePairs } from './pairs.js';
33
- import { loadTM, lookupTM, tmMethodKey } from './tm.js';
51
+ import { resolvePairs, filterPairGraph } from './pairs.js';
52
+ import { loadTM, saveTM, isTMDirty, adoptLegacyCoachingKeys } from './tm.js';
53
+ import { tmKeysForPair, tmHoldsValue } from './fallback.js';
54
+ import { tmTextFor, tmProofTextsFor, createTMEvictor } from './tm-evict.js';
34
55
  import { parsePairKey } from './pairs.js';
56
+ import { pluralGaps, pluralCategoryUse, describeCategories, parseMessage, hasBranching, CLDR_CATEGORIES } from './icu-structure.js';
57
+ import { pluralCategoriesFor, pluralExtraKeys } from './plurals.js';
58
+ import { poPluralFindings, poPluralSlots } from './po.js';
35
59
  import { output } from './output.js';
36
60
 
61
+ /**
62
+ * The target locales a `--pair` value names (e.g. "en:fr,en:de"), resolved
63
+ * against the project's pair graph — fails loud on an unknown pair, exactly
64
+ * like `sync --pair`.
65
+ *
66
+ * @param {object} config - Resolved config
67
+ * @param {string} pairFlag - The raw --pair value
68
+ * @param {{ cwd?: string }} [options] - The project directory (coaching files)
69
+ * @returns {string[]} Target locale codes
70
+ */
71
+ function localesForPairFlag(config, pairFlag, { cwd = process.cwd() } = {}) {
72
+ return [...filterPairGraph(pairFlag, resolvePairs(config, { cwd })).values()].map(p => p.target);
73
+ }
74
+
37
75
  /**
38
76
  * Target locales the CONFIG promises, independent of what's on disk.
39
77
  *
@@ -59,6 +97,56 @@ function configuredTargetLocales(config) {
59
97
  return targets;
60
98
  }
61
99
 
100
+ /**
101
+ * How verify names a placeholder finding, per syntax, in report order. The
102
+ * syntax comes from the check that found it: checkICUStructure tags its
103
+ * findings 'icu' or 'printf' (lib/icu-structure.js), and lib/placeholders.js
104
+ * placeholderChanges names a token's syntax by its form ("{{name}}" i18next,
105
+ * "<b>" a tag, "{name}" single-brace outside an ICU message).
106
+ */
107
+ const PLACEHOLDER_SYNTAX_LABELS = {
108
+ icu: 'ICU structure error(s)',
109
+ printf: 'printf/python-format placeholder mismatch(es)',
110
+ i18next: 'i18next {{…}} placeholder mismatch(es)',
111
+ brace: '{…} placeholder mismatch(es)',
112
+ markup: 'tag placeholder mismatch(es)',
113
+ 'sentence-break': 'sentence break(s) inserted beside a placeholder',
114
+ };
115
+
116
+ /**
117
+ * One locale file's placeholder and ICU-structure findings, each named by
118
+ * the syntax involved — one entry per (key, syntax), with what happened:
119
+ * { key: 'Hi %(name)s', syntax: 'printf', issues: ['printf placeholder %(name)s is missing'] }
120
+ * { key: 'greeting', syntax: 'i18next', issues: ['placeholder {{name}} was changed to {{nom}}'] }
121
+ *
122
+ * The token findings follow lib/placeholders.js placeholderChanges — the
123
+ * rule the sync quality gate refuses by, so sync never writes what verify
124
+ * flags (Round 12).
125
+ *
126
+ * @param {object} audit - auditLocalePair() result
127
+ * @returns {Array<{ key: string, syntax: 'icu'|'printf'|'i18next'|'brace'|'markup', issues: string[] }>}
128
+ * (auditTranslations adds 'sentence-break' findings: lib/placeholders.js placeholderSentenceBreaks)
129
+ */
130
+ function placeholderFindings(audit) {
131
+ const byKey = new Map();
132
+ const add = (syntax, key, issue) => {
133
+ const id = `${syntax}\u0000${key}`;
134
+ if (!byKey.has(id)) byKey.set(id, { key, syntax, issues: [] });
135
+ byKey.get(id).issues.push(issue);
136
+ };
137
+ for (const i of audit.icuIssues || []) {
138
+ i.issues.forEach((issue, n) => add(i.syntaxes?.[n] || 'icu', i.key, issue));
139
+ }
140
+ // The same rule the sync gate refuses by (lib/placeholders.js).
141
+ const markupKeys = new Set((audit.markupIssues || []).map(m => m.key));
142
+ for (const p of audit.placeholderIssues || []) {
143
+ for (const c of placeholderChanges(p.sourceVal, p.targetVal, { markupReported: markupKeys.has(p.key) })) {
144
+ add(c.syntax, p.key, c.issue);
145
+ }
146
+ }
147
+ return [...byKey.values()];
148
+ }
149
+
62
150
  /**
63
151
  * Run the per-locale correctness checks against a source/target flat map pair.
64
152
  *
@@ -74,9 +162,25 @@ function configuredTargetLocales(config) {
74
162
  * Compiled no-translate matcher. Exempt keys are excluded from the
75
163
  * source-echo warning (identical IS correct for them) and checked for
76
164
  * drift instead, which is an error.
77
- * @returns {{ errors: string[], warnings: string[], sourceKeyCount: number, targetKeyCount: number }}
165
+ * @param {Function} [isConfirmedEcho] - (key, sourceValue) => true when the TM
166
+ * settled an identical value as correct
167
+ * @param {{ fixFor?: (keys: string[]) => string }} [options] - fixFor: the
168
+ * command that re-translates these keys (the caller knows the pair and the
169
+ * namespace); default `champollion sync --redo keys:<keys>`
170
+ * @returns {{ errors: string[], warnings: string[], sourceKeyCount: number, targetKeyCount: number,
171
+ * damaged: Array<{ key: string, value: string, sourceValue: string }>,
172
+ * placeholders: Array<{ key: string, syntax: string, issues: string[] }> }} `damaged`: values
173
+ * the quality gate refuses today (ICU structure, placeholders, hollowed) — the
174
+ * caller evicts the TM entries that produced them. `placeholders`: the
175
+ * placeholder/ICU findings by syntax (placeholderFindings), for --json
78
176
  */
79
- function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate = null, isConfirmedEcho = null) {
177
+ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate = null, isConfirmedEcho = null,
178
+ { fixFor = (keys, opts) => redoCommand(keys, opts), pluralSlots = null, fillCommand = 'champollion sync' } = {}) {
179
+ // A damaged value already on disk reads as settled to sync: a plain sync
180
+ // keeps it. Every damage finding therefore names the command that repairs
181
+ // it (synthetic Django/i18next personas, 2026-10: verify reported a
182
+ // placeholder mismatch and stopped there).
183
+ const fix = (items) => ` — fix: \`${fixFor([...new Set(items.map(i => i.key))])}\``;
80
184
  const errors = [];
81
185
  const warnings = [];
82
186
  const sourceKeyCount = Object.keys(sourceFlat).length;
@@ -84,10 +188,18 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
84
188
 
85
189
  // 1. Key parity — are all source keys present in target?
86
190
  const missingKeys = Object.keys(sourceFlat).filter(k => !(k in targetFlat));
191
+ // The repair is named, as for every other finding (Round 9, i18next
192
+ // persona: a missing plural form `count_many` said only "missing key(s)").
193
+ // A few keys: the redo that asks for exactly them — it works even for a key
194
+ // refused before and held back. Many: a sync of the pair fills every
195
+ // missing key.
87
196
  if (missingKeys.length > 0) {
88
197
  const preview = missingKeys.slice(0, 5).join(', ');
89
198
  const suffix = missingKeys.length > 5 ? '...' : '';
90
- errors.push(`${missingKeys.length} missing key(s): ${preview}${suffix}`);
199
+ const repair = missingKeys.length <= 5
200
+ ? `\`${fixFor(missingKeys)}\``
201
+ : `\`${fillCommand}\` (a sync fills every missing key)`;
202
+ errors.push(`${missingKeys.length} missing key(s): ${preview}${suffix} — fix: ${repair}`);
91
203
  }
92
204
 
93
205
  // 2. [EN] fallback marker scan — any legacy [EN]-prefixed values?
@@ -111,8 +223,23 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
111
223
  errors.push(`${emptyKeys.length} empty translation(s): ${preview}${suffix}`);
112
224
  }
113
225
 
114
- // 4. Script compliance — non-Latin locales should have non-ASCII translations
226
+ // 4. Script compliance — non-Latin locales must not hold Latin-only text.
227
+ // Letters are classified by Unicode SCRIPT, not by byte: an ASCII test let
228
+ // fullwidth Latin ("Book") and accented Latin ("Bóók") through in a
229
+ // Russian catalog (Round 4, Django persona). And fullwidth Latin letters
230
+ // are wrong in any value outside CJK typography.
115
231
  const isNonLatin = NON_LATIN_LOCALES.has(locale) || NON_LATIN_LOCALES.has(locale.split('-')[0]);
232
+ const fullwidthKeys = Object.keys(targetFlat).filter(k => {
233
+ const val = targetFlat[k];
234
+ if (typeof val !== 'string' || val === sourceFlat[k]) return false;
235
+ if (noTranslate && noTranslate.matches(k, sourceFlat[k])) return false;
236
+ if (isProtectedTermValue(val, config.protectedTerms)) return false;
237
+ return hasForeignFullwidthLatin(val, locale);
238
+ });
239
+ if (fullwidthKeys.length > 0) {
240
+ errors.push(`${fullwidthKeys.length} wrong script (fullwidth Latin letters — English in disguise): `
241
+ + `${fullwidthKeys.slice(0, 3).join(', ')}${fullwidthKeys.length > 3 ? '...' : ''}${fix(fullwidthKeys.map(key => ({ key })))}`);
242
+ }
116
243
  if (isNonLatin) {
117
244
  const asciiOnlyKeys = Object.keys(targetFlat).filter(k => {
118
245
  const val = targetFlat[k];
@@ -123,22 +250,55 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
123
250
  // Arabic locale is CORRECT, and flagging it as wrong-script would make
124
251
  // a clean sync fail its own post-sync verification.
125
252
  if (noTranslate && noTranslate.matches(k, sourceFlat[k])) return false;
126
- return isAsciiOnly(val);
253
+ // Names kept as written: declared ones (config.protectedTerms), and
254
+ // ones the quality gate settled as names (a TM-stamped echo — the
255
+ // model kept it in Latin script after being asked to translate it).
256
+ // The gate already accepted these; verify must agree, or a clean sync
257
+ // fails its own verification (curtisforbes.com had to run every sync
258
+ // with --no-verify for this).
259
+ if (isProtectedTermValue(val, config.protectedTerms)) return false;
260
+ if (isConfirmedEcho && val === sourceFlat[k] && isConfirmedEcho(k, sourceFlat[k])) return false;
261
+ if (fullwidthKeys.includes(k)) return false; // reported above
262
+ return isLatinOnly(val, locale);
127
263
  });
128
264
  if (asciiOnlyKeys.length > 0) {
129
265
  const preview = asciiOnlyKeys.slice(0, 3).join(', ');
130
266
  const suffix = asciiOnlyKeys.length > 3 ? '...' : '';
131
- errors.push(`${asciiOnlyKeys.length} wrong script (ASCII-only): ${preview}${suffix}`);
267
+ errors.push(`${asciiOnlyKeys.length} wrong script (Latin letters only, expected ${locale}'s script): ${preview}${suffix}`);
132
268
  }
133
269
  }
134
270
 
135
- // 5. Placeholder preservation — run the integrity audit for placeholder + encoding checks
136
- const audit = auditLocalePair(sourceFlat, targetFlat, locale, { noTranslate, isConfirmedEcho });
137
-
138
- if (audit.placeholderIssues.length > 0) {
139
- const preview = audit.placeholderIssues.slice(0, 3).map(i => i.key).join(', ');
140
- const suffix = audit.placeholderIssues.length > 3 ? '...' : '';
141
- errors.push(`${audit.placeholderIssues.length} placeholder mismatch(es): ${preview}${suffix}`);
271
+ // 5. Placeholders and ICU structure, named by the syntax involved — run the
272
+ // integrity audit for placeholder + encoding checks. ICU structure damage
273
+ // (a translated variable, keyword or selector, a lost #) and lost printf
274
+ // conversions come from the gate's own check (lib/icu-structure.js); the
275
+ // quality gate refuses these now, so this reports the ones written before
276
+ // it did (they read as settled to sync). One line per syntax: a gettext
277
+ // catalog's lost %(name)s used to be called an "ICU structure error" in a
278
+ // catalog with no ICU in it (Round 12, Django persona), and an i18next
279
+ // {{name}} loss a bare "placeholder mismatch".
280
+ const audit = auditLocalePair(sourceFlat, targetFlat, locale, { noTranslate, isConfirmedEcho, pluralSlots });
281
+ const placeholders = placeholderFindings(audit);
282
+ // A sentence break inserted right beside a placeholder where the source
283
+ // has none ("… sina. {time}." for "Take this medicine at {time}.") — the
284
+ // rule the sync gate refuses by (lib/placeholders.js), so a value written
285
+ // before the gate checked it is flagged here (Round 14, hospital persona).
286
+ // Keys the gate never sees are left out: no-translate keys and declared
287
+ // names (protectedTerms).
288
+ for (const [k, src] of Object.entries(sourceFlat)) {
289
+ const tgt = targetFlat[k];
290
+ if (typeof src !== 'string' || typeof tgt !== 'string') continue;
291
+ if (noTranslate && noTranslate.matches(k, src)) continue;
292
+ if (isProtectedTermValue(tgt, config.protectedTerms)) continue;
293
+ const breaks = placeholderSentenceBreaks(src, tgt);
294
+ if (breaks.length > 0) placeholders.push({ key: k, syntax: 'sentence-break', issues: breaks.map(b => b.issue) });
295
+ }
296
+ for (const syntax of Object.keys(PLACEHOLDER_SYNTAX_LABELS)) {
297
+ const found = placeholders.filter(p => p.syntax === syntax);
298
+ if (found.length === 0) continue;
299
+ const preview = found.slice(0, 3).map(p => `${p.key} (${p.issues[0]})`).join(', ');
300
+ const suffix = found.length > 3 ? '...' : '';
301
+ errors.push(`${found.length} ${PLACEHOLDER_SYNTAX_LABELS[syntax]}: ${preview}${suffix}${fix(found)}`);
142
302
  }
143
303
 
144
304
  // 6. Encoding issues (warning)
@@ -149,10 +309,12 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
149
309
  }
150
310
 
151
311
  // 7. Source echo — untranslated copies (warning, not error — some are legitimate)
152
- if (audit.copies.length > 0) {
153
- const preview = audit.copies.slice(0, 5).join(', ');
154
- const suffix = audit.copies.length > 5 ? '...' : '';
155
- warnings.push(`${audit.copies.length} source echo(es): ${preview}${suffix}`);
312
+ // A declared name (config.protectedTerms) is supposed to equal the source.
313
+ const copies = audit.copies.filter(k => !isProtectedTermValue(targetFlat[k], config.protectedTerms));
314
+ if (copies.length > 0) {
315
+ const preview = copies.slice(0, 5).join(', ');
316
+ const suffix = copies.length > 5 ? '...' : '';
317
+ warnings.push(`${copies.length} source echo(es): ${preview}${suffix}`);
156
318
  }
157
319
 
158
320
  // 8. Hollowed values — the source with its letters deleted, written by a
@@ -164,7 +326,7 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
164
326
  const suffix = audit.hollowedValues.length > 3 ? '...' : '';
165
327
  errors.push(
166
328
  `${audit.hollowedValues.length} hollowed value(s) (source with letters deleted): ${preview}${suffix}`
167
- + ' — re-translate with `champollion sync --force-keys <key>` or `--pair <pair> --force`',
329
+ + fix(audit.hollowedValues),
168
330
  );
169
331
  }
170
332
 
@@ -181,7 +343,143 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
181
343
  );
182
344
  }
183
345
 
184
- return { errors, warnings, sourceKeyCount, targetKeyCount };
346
+ // 10. (ICU structure damage is reported with the placeholders, by syntax — 5.)
347
+
348
+ // 10b. Markup damage — a tag opened, closed or nested differently from the
349
+ // source (a lost `</strong>` used to pass: tag names were compared as a set).
350
+ if (audit.markupIssues && audit.markupIssues.length > 0) {
351
+ const preview = audit.markupIssues.slice(0, 3).map(i => `${i.key} (${i.issues[0]})`).join(', ');
352
+ const suffix = audit.markupIssues.length > 3 ? '...' : '';
353
+ errors.push(`${audit.markupIssues.length} markup error(s): ${preview}${suffix}${fix(audit.markupIssues)}`);
354
+ }
355
+
356
+ // 11. Plural messages without a form the target language uses for
357
+ // ordinary counts (Russian few/many): the runtime shows the "other" form
358
+ // for 2, 3, 4 … Not damage — the message is valid ICU — but wrong
359
+ // grammar nobody asked for (Round 3, Django persona). A gettext catalog
360
+ // reads back with every msgstr[] filled; its repeated forms are found
361
+ // from the catalog's own marker (poPluralFindings, in verifyLocales).
362
+ // --fresh in the fix: the cache holds the same incomplete answer.
363
+ const gapKeys = [];
364
+ for (const [k, src] of Object.entries(sourceFlat)) {
365
+ const tgt = targetFlat[k];
366
+ if (typeof src !== 'string' || typeof tgt !== 'string' || !src.includes('{') || tgt === src) continue;
367
+ const gaps = pluralGaps(src, tgt, locale, pluralSlots).filter(g => g.everyday.length > 0);
368
+ if (gaps.length > 0) gapKeys.push({ key: k, missing: [...new Set(gaps.flatMap(g => g.everyday))] });
369
+ }
370
+ if (gapKeys.length > 0) {
371
+ const preview = gapKeys.slice(0, 3).map(g => `${g.key} (${g.missing.join(', ')})`).join(', ');
372
+ const suffix = gapKeys.length > 3 ? '...' : '';
373
+ warnings.push(
374
+ `${gapKeys.length} plural message(s) without a form ${locale} uses for ordinary counts: ${preview}${suffix}`
375
+ + ` — the "other" form is shown for those counts; write the missing branches, or ask the model again — fix: \`${fixFor([...new Set(gapKeys.map(i => i.key))], { fresh: true })}\``,
376
+ );
377
+ }
378
+
379
+ // 12. A question or exclamation that lost its closing "?" / "!" (Round 6,
380
+ // hospital persona: "Where does it hurt?" written as a statement). A
381
+ // warning: some languages mark a question with a particle instead.
382
+ const dropped = droppedTerminalMarks(Object.keys(targetFlat)
383
+ .filter(k => typeof sourceFlat[k] === 'string' && typeof targetFlat[k] === 'string')
384
+ .filter(k => !(noTranslate && noTranslate.matches(k, sourceFlat[k])) && !isProtectedTermValue(targetFlat[k], config.protectedTerms))
385
+ .map(k => [k, sourceFlat[k], targetFlat[k]]));
386
+ if (dropped.length > 0) warnings.push(describeDroppedMarks(dropped, fixFor(dropped.map(f => f.key), { fresh: true })));
387
+
388
+ // Only what was reported: a token difference placeholderFindings set aside
389
+ // (a one-word ICU plural branch "{Brak}" read as a placeholder) is not
390
+ // damage — evicting its cache entry would throw away a good translation.
391
+ const reportedPlaceholderKeys = new Set(placeholders.filter(p => p.syntax !== 'sentence-break').map(p => p.key));
392
+ const damaged = [
393
+ ...audit.icuIssues.map(i => ({ key: i.key, value: i.actual })),
394
+ ...(audit.markupIssues || []).map(i => ({ key: i.key, value: i.actual })),
395
+ ...audit.placeholderIssues.filter(i => reportedPlaceholderKeys.has(i.key)).map(i => ({ key: i.key, value: i.targetVal })),
396
+ ...audit.hollowedValues.map(h => ({ key: h.key, value: h.actual })),
397
+ // The gate refuses these now: the cache entry that produced one is evicted like the rest.
398
+ ...placeholders.filter(p => p.syntax === 'sentence-break').map(p => ({ key: p.key, value: targetFlat[p.key] })),
399
+ ].map(d => ({ ...d, sourceValue: sourceFlat[d.key] }));
400
+
401
+ return { errors, warnings, sourceKeyCount, targetKeyCount, damaged, placeholders };
402
+ }
403
+
404
+ /**
405
+ * Make keys printable: a gettext key with a context is `msgctxt\u0004msgid`,
406
+ * and U+0004 is invisible on a terminal — print it as "␄" (U+2404), which
407
+ * `--force-keys` accepts back (lib/locale-layout.js keysForNamespace).
408
+ */
409
+ function showKeys(text) {
410
+ return String(text).replace(/\u0004/g, '\u2404');
411
+ }
412
+
413
+ /** One shell word, quoted only when it has to be (a msgid has spaces, `\,`…). */
414
+ function shellWord(word) {
415
+ return /^[A-Za-z0-9_.:\/@%+=,-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
416
+ }
417
+
418
+ /**
419
+ * The command that re-translates exactly these keys — and nothing a human
420
+ * wrote elsewhere: `--redo keys:` (lib/redo.js), scoped to the pair.
421
+ * - a namespaced layout names `<ns>::<key>` (one file's key; a bare key
422
+ * would re-queue it in every file that has it)
423
+ * - a comma inside a key is written `\,` (gettext msgids are sentences)
424
+ * - a gettext context separator is written ␄ — the way reports and the
425
+ * docs show it — and, when there is one, a shell comment says it can be
426
+ * typed as `\x04` (--redo accepts both; Round 7, Django persona: the
427
+ * docs said ␄, the suggested command \x04, and neither said the other
428
+ * works). The comment is part of the line, so the whole line still
429
+ * pastes into a shell.
430
+ *
431
+ * @param {string[]} keys
432
+ * @param {{ pair?: string|null, ns?: string }} [scope]
433
+ * @returns {string}
434
+ */
435
+ function redoCommand(keys, { pair = null, ns = '', fresh = false } = {}) {
436
+ // A gettext context separator (U+0004) is written ␄, as key lists and the
437
+ // docs show it; the comment names the typeable `\x04`. --redo takes both.
438
+ const names = keys.map(k => String(ns ? `${ns}${NS_SEPARATOR}${k}` : k).replace(/\u0004|\\x04/g, '\u2404').replace(/,/g, '\\,'));
439
+ const typed = names.some(n => n.includes('\u2404')) ? ' # type ␄ as \\x04 if you cannot (both work)' : '';
440
+ return `champollion sync${pair ? ` --pair ${shellWord(pair)}` : ''} --redo ${shellWord(`keys:${names.join(',')}`)}${fresh ? ' --fresh' : ''}${typed}`;
441
+ }
442
+
443
+ /**
444
+ * Persist evictions, and say once whether the repair commands need --fresh.
445
+ * They do not: every cached copy of a damaged value (under any model key)
446
+ * has just been evicted from this project's cache, so `--redo keys:` either
447
+ * re-translates the text or serves the cache's own, different translation
448
+ * of it — never the damaged value again.
449
+ */
450
+ function finishEviction(cwd, tm, evicted, result, damagedCount = 0) {
451
+ if (evicted > 0 && tm && isTMDirty(tm)) {
452
+ saveTM(cwd, tm);
453
+ output.warn(
454
+ `[TM] Evicted ${evicted} cached translation(s) that produced damaged values. A plain `
455
+ + '`champollion sync` keeps values already on disk: run the `--redo keys:` command shown with each '
456
+ + 'finding — no `--fresh` needed, the cache can no longer serve the damaged text.');
457
+ } else if (damagedCount > 0) {
458
+ output.warn(
459
+ '[VERIFY] A plain `champollion sync` keeps values already on disk: run the `--redo keys:` command '
460
+ + 'shown with each damage finding. No `--fresh` needed — none of the damaged values is in this '
461
+ + 'project\'s translation cache.');
462
+ }
463
+ return evicted > 0 ? { ...result, tmEvicted: evicted } : result;
464
+ }
465
+
466
+ /**
467
+ * The result of a verify that could not check anything: one error line that
468
+ * names what it looked for and the setting that points there — and exit 1
469
+ * through the caller (an error count), never a quiet pass.
470
+ *
471
+ * @returns {Promise<{ errors: number, warnings: number, nothingChecked: true }>}
472
+ */
473
+ async function nothingVerified(cwd, config, reason) {
474
+ let hint = '';
475
+ try {
476
+ // What init would configure from the files on disk (sync's own hint).
477
+ const { describeLocaleSetupHint } = await import('./commands/init.js');
478
+ const found = describeLocaleSetupHint(cwd, config.inputLocale);
479
+ if (found) hint = ` ${found}`;
480
+ } catch { /* the hint is a courtesy */ }
481
+ output.error(`[VERIFY] Nothing verified: ${reason}${hint}`, { command: 'verify', errors: 1, warnings: 0, nothingChecked: true });
482
+ return { errors: 1, warnings: 0, nothingChecked: true };
185
483
  }
186
484
 
187
485
  /**
@@ -191,17 +489,63 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
191
489
  * @param {number} totalWarnings
192
490
  * @returns {{ errors: number, warnings: number }}
193
491
  */
194
- function printSummary(totalErrors, totalWarnings) {
492
+ function printSummary(totalErrors, totalWarnings, { strict = false, incomplete = null, scope = null } = {}) {
493
+ // --json: the closing line keeps its level and message; it also carries
494
+ // the counts (the per-locale records came before it as `verify` events),
495
+ // and which locales were checked when not all of them were.
496
+ const counts = {
497
+ command: 'verify', errors: totalErrors, warnings: totalWarnings,
498
+ ...(scope && { checked: scope.locales, scope: scope.afterSync ? 'synced-pairs' : 'pair-flag' }),
499
+ };
500
+ // A scoped check names what it looked at, and that the rest was not
501
+ // looked at: after `sync --pair en:fr` the closing line said "intact in
502
+ // every locale" over French alone while Spanish had a warning (Round 14,
503
+ // i18next persona).
504
+ const forWhere = scope ? ` for ${scope.locales.join(', ')}` : '';
505
+ const which = scope ? ` (${describeScope(scope)})` : '';
506
+ // Right after a sync that did not finish (keys not translated or held back,
507
+ // a plural message without an everyday form): never an [OK] line as the
508
+ // run's last word over an exit 2 (Round 9, Django persona: "[OK]
509
+ // Verification passed with 1 warning(s)" closed a run that exited 2).
510
+ if (incomplete && totalErrors === 0 && !(strict && totalWarnings > 0)) {
511
+ output.warn(`Verification: ${scope ? `the files of ${scope.locales.join(', ')} are` : 'the files are'} structurally intact`
512
+ + `${totalWarnings > 0 ? ` (${totalWarnings} warning(s), listed above)` : ''}${which}, `
513
+ + `but this sync is incomplete — ${incomplete} (see the summary above).`, counts);
514
+ return { errors: totalErrors, warnings: totalWarnings };
515
+ }
516
+ // What a pass means, in one line: verify checks structure, never meaning.
517
+ // "All locales look good" read like a sign-off on a phrasebook whose
518
+ // "Where does it hurt?" was an unrelated sentence (Round 3, hospital persona).
195
519
  if (totalErrors === 0 && totalWarnings === 0) {
196
- output.ok('Verification passed — all locales look good.');
520
+ output.ok(`Verification passed${forWhere}: keys, placeholders, plurals, markup and script are intact${scope ? which : ' in every locale'} — `
521
+ + 'the meaning is not checked; have a speaker review before relying on it.', counts);
522
+ } else if (totalErrors === 0 && strict) {
523
+ // Under --strict a warning fails the run (exit 1): never an [OK] line
524
+ // over it (Round 8, Django persona: "[OK] … passed with 1 warning(s)",
525
+ // then exit 1).
526
+ output.error(`[FAIL] ${totalWarnings} warning(s)${forWhere} — --strict treats warnings as failures (listed above${scope ? `; ${describeScope(scope)}` : ''}).`, counts);
197
527
  } else if (totalErrors === 0) {
198
- output.ok(`Verification passed with ${totalWarnings} warning(s).`);
528
+ output.ok(`Verification passed with ${totalWarnings} warning(s)${forWhere} — structure only; the meaning is not checked${which}.`, counts);
199
529
  } else {
200
- output.error(`Verification: ${totalErrors} error(s), ${totalWarnings} warning(s).`);
530
+ output.error(`Verification: ${totalErrors} error(s), ${totalWarnings} warning(s)${forWhere}${which}.`, counts);
201
531
  }
202
532
  return { errors: totalErrors, warnings: totalWarnings };
203
533
  }
204
534
 
535
+ /**
536
+ * A scoped check in words: what was synced (or asked for), so only those
537
+ * locales were checked, and the command that checks all of them.
538
+ *
539
+ * @param {{ locales: string[], pairs: string[], afterSync: boolean }} scope
540
+ * @returns {string}
541
+ */
542
+ function describeScope(scope) {
543
+ const pairs = scope.pairs.join(', ');
544
+ return scope.afterSync
545
+ ? `only ${pairs} ${scope.pairs.length > 1 ? 'were' : 'was'} synced; \`champollion verify\` checks every locale`
546
+ : `only ${pairs}, as --pair asked; \`champollion verify\` without --pair checks every locale`;
547
+ }
548
+
205
549
  /**
206
550
  * Verify all target locale files against the source.
207
551
  *
@@ -215,131 +559,950 @@ function printSummary(totalErrors, totalWarnings) {
215
559
  * Compiled matcher. Pass the SAME instance the sync used so verification
216
560
  * judges the files by the rules that wrote them. Derived from config when
217
561
  * omitted (the standalone `verify` command).
218
- * @returns {Promise<{ errors: number, warnings: number }>}
562
+ * @param {string[]|null} [options.locales] - Verify only these target
563
+ * locales (the pairs a `sync --pair` run touched). null = every locale.
564
+ * @param {object|null} [options.tm] - The TM object to consult and evict
565
+ * from (sync passes its own); loaded from disk when omitted
566
+ * @param {boolean} [options.afterSync] - Called by sync right after it wrote
567
+ * the files: the heading says "Post-Sync Verification" (else "Verification")
568
+ * @param {string|null} [options.incomplete] - Called by a sync that did not
569
+ * finish (what it left undone, in words): the closing line is a warning
570
+ * naming it, never an [OK] over a run that exits 2
571
+ * @param {boolean} [options.noTM] - The run did not READ the TM (--fresh /
572
+ * --no-tm). Its results were still cached, so damage is evicted as usual
573
+ * @returns {Promise<{ errors: number, warnings: number, tmEvicted?: number }>} tmEvicted is
574
+ * present when cache entries were evicted
219
575
  */
220
576
  async function verifyLocales(config, cwd, options = {}) {
221
577
  const noTranslate = options.noTranslate || compileNoTranslate(config);
578
+ const scope = Array.isArray(options.locales) ? new Set(options.locales) : null;
579
+ const inScope = (locale) => !scope || scope.has(locale);
222
580
 
223
581
  // TM-confirmed echoes are settled facts, not findings — the same
224
582
  // suppression the sync diff and `integrity` apply, so the three tools
225
- // cannot disagree about a healthy file. Read-only TM load.
226
- const tm = loadTM(cwd);
583
+ // cannot disagree about a healthy file.
584
+ const tm = options.tm || loadTM(cwd);
585
+ // Per locale: the pair's own TM key, then its fallback's — a value the
586
+ // fallback produced is cached under the fallback (lib/fallback.js).
227
587
  const tmKeys = new Map();
228
588
  try {
229
- for (const [, pc] of resolvePairs(config)) tmKeys.set(pc.target, tmMethodKey(pc));
589
+ const graph = resolvePairs(config, { cwd });
590
+ // A cache from before coaching was keyed reads as sync reads it
591
+ // (lib/tm.js adoptLegacyCoachingKeys — once per cache).
592
+ adoptLegacyCoachingKeys(tm, graph.values());
593
+ for (const [, pc] of graph) tmKeys.set(pc.target, tmKeysForPair(pc));
230
594
  } catch { /* invalid pair config — sync reports it; verify still runs */ }
231
- const echoPredicateFor = (locale) => {
232
- const tmKey = tmKeys.get(locale);
233
- return tmKey
234
- ? (key, sourceValue) => lookupTM(tm, sourceValue, locale, tmKey) === sourceValue
595
+ // A borrowed i18next plural form is cached under its own text (tmTextFor).
596
+ const echoPredicateFor = (locale, expansion = null) => {
597
+ const keys = tmKeys.get(locale);
598
+ return keys
599
+ ? (key, sourceValue) => tmHoldsValue(tm, tmTextFor(key, sourceValue, expansion), locale, keys, sourceValue)
235
600
  : null;
236
601
  };
237
602
 
603
+ // The pair each target locale belongs to, for the repair command
604
+ // (`sync --pair en:fr --redo keys:…`).
605
+ const pairOf = new Map();
606
+ try {
607
+ for (const [pairKey, pc] of resolvePairs(config, { cwd })) if (!pairOf.has(pc.target)) pairOf.set(pc.target, pairKey);
608
+ } catch { /* invalid pair config — sync reports it */ }
609
+ const fixFor = (locale, ns = '') => (keys, opts = {}) => redoCommand(keys, {
610
+ pair: pairOf.get(locale) || `${config.inputLocale}:${locale}`, ns, ...opts,
611
+ });
612
+
613
+ // Damaged values the TM proves the pipeline wrote → evict those entries.
614
+ const evictor = createTMEvictor(tm);
615
+ let tmEvicted = 0;
616
+ let damagedCount = 0;
617
+ const evictDamaged = (locale, damaged, expansion = null) => {
618
+ damagedCount += damaged.length;
619
+ if (!evictor) return;
620
+ const keys = tmKeys.get(locale) || [];
621
+ for (const d of damaged) {
622
+ if (typeof d.sourceValue !== 'string' || typeof d.value !== 'string') continue;
623
+ for (const text of tmProofTextsFor(d.key, d.sourceValue, expansion)) {
624
+ tmEvicted += evictor.evictProducing(text, locale, d.value, keys);
625
+ }
626
+ }
627
+ };
628
+
238
629
  // Docusaurus uses a directory-per-locale layout (i18n/<locale>/code.json,
239
630
  // …/<plugin>/*.json) — NOT a flat i18n/<locale>.json file. The flat path
240
631
  // below would look for i18n/en.json, never find it, and exit 0 — a
241
632
  // false-green gate. Route Docusaurus projects to their own verifier.
242
633
  if (config.format === 'docusaurus') {
243
- return verifyDocusaurusLocales(config, cwd, noTranslate, echoPredicateFor);
634
+ const result = await verifyDocusaurusLocales(config, cwd, noTranslate, echoPredicateFor, {
635
+ inScope, evictDamaged, fixFor, afterSync: !!options.afterSync, strict: !!options.strict, incomplete: options.incomplete || null,
636
+ describeChecked: scope ? (locales) => checkedScope(locales, pairOf, config, !!options.afterSync) : null,
637
+ });
638
+ return finishEviction(cwd, tm, tmEvicted, result, damagedCount);
244
639
  }
245
640
 
246
- const format = config.format !== 'auto'
247
- ? config.format
248
- : detectFormatFromDir(config.localesDir);
249
- const ext = getExtension(format);
250
- const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
251
-
252
- if (!fs.existsSync(sourcePath)) {
253
- // No source file — can't verify. This shouldn't happen after a sync,
254
- // but don't crash the user's workflow over it.
255
- output.warn('[VERIFY] Source locale file not found — skipping verification.');
256
- return { errors: 0, warnings: 0 };
641
+ // Every locale's files come from the ONE layout module (flat, folder per
642
+ // locale, or localesPattern) — the same files sync wrote.
643
+ const rel = (p) => path.relative(cwd, p).split(path.sep).join('/') || '.';
644
+ const setting = config.localesPattern ? '"localesPattern"' : '"localesDir"';
645
+ if (!config.localesPattern && !fs.existsSync(config.localesDir)) {
646
+ return nothingVerified(cwd, config,
647
+ `the locales folder ${rel(config.localesDir)} does not exist (${setting} in champollion.config.json, default ./locales).`);
648
+ }
649
+ const layout = discoverLocaleLayout(config, { cwd });
650
+ const sourceMissing = layout.namespaced
651
+ ? layout.sourceFiles.length === 0
652
+ : !fs.existsSync(layout.sourceFiles[0].path);
653
+ if (sourceMissing) {
654
+ // A verify that checked nothing is not a pass. It used to warn and exit
655
+ // 0, so a CI gate with a mistyped localesDir stayed green while checking
656
+ // nothing (Round 3, i18next persona).
657
+ const where = layout.namespaced
658
+ ? `${rel(layout.baseDir)}/${config.inputLocale}/ (no source files there)`
659
+ : rel(layout.sourceFiles[0].path);
660
+ return nothingVerified(cwd, config,
661
+ `the source locale was not found — looked for ${where} (${setting} and "inputLocale": "${config.inputLocale}" in champollion.config.json).`);
257
662
  }
258
663
 
259
- const sourceRaw = readLocaleFile(sourcePath, format);
260
- const sourceFlat = format === 'json' ? flattenKeys(sourceRaw) : sourceRaw;
261
- const sourceKeyCount = Object.keys(sourceFlat).length;
664
+ const units = loadSourceUnits(layout);
665
+ const sourceKeyCount = units.reduce((n, u) => n + Object.keys(u.flat).length, 0);
262
666
 
263
- // Detect target locales from directory listing
264
- const files = fs.readdirSync(config.localesDir);
265
- const targetLocales = files
266
- .filter(f => f.endsWith(ext) && !f.startsWith(config.inputLocale))
267
- .map(f => f.replace(ext, ''));
667
+ // Target locales present on disk — EXACT code match. (The old listing
668
+ // dropped every file that merely STARTED with the source code, so with
669
+ // source "en" an en-GB.json was never verified.)
670
+ const targetLocales = layout.listLocales().filter(inScope);
268
671
 
269
- // Configured locales whose file doesn't exist at all. These are invisible
270
- // to the directory listing above, so without this check a CI `verify` gate
271
- // passed with zero translation done. Only enforced when the source has
272
- // keys to translate — an empty source promises nothing.
672
+ // Configured locales with no file at all. These are invisible to the
673
+ // listing above, so without this check a CI `verify` gate passed with zero
674
+ // translation done. Only enforced when the source has keys to translate —
675
+ // an empty source promises nothing.
273
676
  const missingTargets = sourceKeyCount === 0 ? [] :
274
677
  [...configuredTargetLocales(config)]
275
- .filter(l => !fs.existsSync(path.join(config.localesDir, `${l}${ext}`)))
678
+ .filter(inScope)
679
+ .filter(l => !layout.filesFor(l).some(f => fs.existsSync(f.path)))
276
680
  .sort();
277
681
 
278
682
  if (targetLocales.length === 0 && missingTargets.length === 0) {
279
- return { errors: 0, warnings: 0 };
683
+ // An empty source promises nothing; anything else with no target at all
684
+ // (none on disk, none configured) is a gate that checked nothing.
685
+ if (sourceKeyCount === 0) {
686
+ output.info('[VERIFY] The source has no keys — nothing to verify.', { command: 'verify', errors: 0, warnings: 0 });
687
+ return { errors: 0, warnings: 0 };
688
+ }
689
+ return nothingVerified(cwd, config, scope
690
+ ? `no target locale file for ${[...scope].join(', ')} next to the source (${layout.display}).`
691
+ : `no target locale files next to the source (${layout.display}) and no "languages" in champollion.config.json.`);
280
692
  }
281
693
 
282
- output.raw('\n ── Post-Sync Verification ───────────────────────────────\n');
694
+ // "Post-Sync" only right after a sync: a standalone `verify` ran no sync
695
+ // (Round 7, Django persona).
696
+ output.raw(options.afterSync
697
+ ? '\n ── Post-Sync Verification ───────────────────────────────\n'
698
+ : '\n ── Verification ─────────────────────────────────────────\n');
283
699
 
284
700
  let totalErrors = 0;
285
701
  let totalWarnings = 0;
286
702
 
287
703
  for (const locale of missingTargets) {
288
- output.error(`[VERIFY] ${locale}: locale file missing (${locale}${ext}) — configured target has no translations. Run \`champollion sync\` to create it.`);
704
+ const expectedFiles = layout.filesFor(locale).map(f => f.rel);
705
+ const where = layout.namespaced
706
+ ? `expected ${expectedFiles.slice(0, 3).join(', ')}${expectedFiles.length > 3 ? `, +${expectedFiles.length - 3} more` : ''}`
707
+ : expectedFiles[0];
708
+ const message = `${locale}: locale file missing (${where}) — configured target has no translations. Run \`champollion sync\` to create it.`;
709
+ output.error(`[VERIFY] ${message}`);
289
710
  totalErrors++;
711
+ output.event('verify', {
712
+ locale, pair: pairOf.get(locale) || `${config.inputLocale}:${locale}`, ok: false, fileMissing: true,
713
+ expectedFiles: layout.filesFor(locale).map(f => f.rel), errors: [message], warnings: [], infos: [],
714
+ });
290
715
  }
291
716
 
717
+ // Every locale's values, for the cross-locale check after the loop.
718
+ const valuesByLocale = new Map(targetLocales.map(l => [l, new Map()]));
719
+ const sourceByKey = new Map();
720
+
721
+ // The lock's per-locale record: which translations were made from an
722
+ // older source text (lib/locale-state.js). Unreadable → sync says so loud;
723
+ // verify skips this one check rather than fail on it.
724
+ let lock = null;
725
+ try { lock = readLock(cwd); } catch { lock = null; }
726
+ const lockState = lock ? new LockState(lock.locales) : null;
727
+ const pairConfigs = new Map();
728
+ try {
729
+ for (const [, pc] of resolvePairs(config, { cwd })) if (!pairConfigs.has(pc.target)) pairConfigs.set(pc.target, pc);
730
+ } catch { /* invalid pair config — sync reports it */ }
731
+
292
732
  for (const locale of targetLocales) {
293
- const targetPath = path.join(config.localesDir, `${locale}${ext}`);
294
- if (!fs.existsSync(targetPath)) continue;
733
+ const localeErrors = [];
734
+ const localeWarnings = [];
735
+ // Said, never counted: not a warning (`verify --strict` does not fail on it).
736
+ const localeInfos = [];
737
+ let targetKeyTotal = 0;
738
+ let expectedKeyTotal = 0;
739
+ // Keys the locale has that it should not (an `es` plural `count_two`),
740
+ // and expected keys it lacks: either one means the count line is not OK.
741
+ const extraKeyNames = [];
742
+ let missingKeyTotal = 0;
743
+ let anyFile = false;
744
+ // For --json and the plural line: findings by placeholder syntax, and
745
+ // the plural forms the locale's files are expected to have.
746
+ const localePlaceholders = [];
747
+ const pluralCoverage = new Map();
748
+
749
+ for (const unit of units) {
750
+ const file = layout.fileFor(locale, unit.ns);
751
+ // Single-file layouts skip a vanished file (the listing raced a
752
+ // delete). In a namespaced locale a missing namespace file is a real
753
+ // gap — every key in it is untranslated — so it is audited as empty.
754
+ if (!fs.existsSync(file.path) && !layout.namespaced) continue;
755
+ anyFile = true;
756
+ const targetFlat = fs.existsSync(file.path) ? readLocaleFlat(file) : {};
757
+ // The keys THIS locale should have (i18next plurals → its own CLDR
758
+ // categories), the same expectation sync translated against.
759
+ const { flat: expected, expansion } = expectedForTarget(unit, config.inputLocale, locale);
760
+ // A gettext catalog holds only the plural forms its header has slots for.
761
+ const poText = file.format === 'po' && fs.existsSync(file.path) ? fs.readFileSync(file.path, 'utf-8') : null;
762
+ const pluralSlots = poText !== null ? poPluralSlots(poText, locale) : null;
763
+ const { errors, warnings, targetKeyCount, damaged, placeholders } = auditTranslations(
764
+ expected, targetFlat, locale, config, noTranslate, echoPredicateFor(locale, expansion),
765
+ { fixFor: fixFor(locale, layout.namespaced ? unit.ns : ''), pluralSlots,
766
+ fillCommand: `champollion sync --pair ${shellWord(pairOf.get(locale) || `${config.inputLocale}:${locale}`)}` });
767
+ evictDamaged(locale, damaged, expansion);
768
+ const keyName = (k) => (layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k);
769
+ for (const p of placeholders) localePlaceholders.push({ ...p, key: keyName(p.key) });
770
+ // gettext forms sync marked as repeating `other` (the line the plural
771
+ // warning below reads too); an unparsable catalog is readLocaleFlat's
772
+ // error, reported above — nothing is marked in it.
773
+ let marked = [];
774
+ if (poText !== null) {
775
+ try { marked = poPluralFindings(poText, { locale, filePath: file.rel }).copied; } catch { marked = []; }
776
+ }
777
+ addPluralCoverage(pluralCoverage, {
778
+ unit, expected, expansion, targetFlat, locale, gettext: file.format === 'po', slots: pluralSlots, marked, keyName,
779
+ });
780
+ targetKeyTotal += targetKeyCount;
781
+ expectedKeyTotal += Object.keys(expected).length;
782
+ for (const k of Object.keys(targetFlat)) {
783
+ if (!Object.prototype.hasOwnProperty.call(expected, k)) extraKeyNames.push(layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k);
784
+ }
785
+ missingKeyTotal += Object.keys(expected).filter(k => !Object.prototype.hasOwnProperty.call(targetFlat, k)).length;
786
+ const prefix = layout.namespaced ? `${unit.ns}: ` : '';
787
+ for (const e of errors) localeErrors.push(prefix + e);
788
+ for (const w of warnings) localeWarnings.push(prefix + w);
789
+ const plural = pluralFileWarnings(unit, file, targetFlat, locale, config, fixFor(locale, layout.namespaced ? unit.ns : ''), {
790
+ expansion, localeState: lockState ? lockState.peek(locale) : null, lockKeyOf: (k) => lockKey(layout, unit.ns, k),
791
+ pruneCommand: () => `champollion sync --pair ${shellWord(pairOf.get(locale) || `${config.inputLocale}:${locale}`)} --prune plural-extras`,
792
+ });
793
+ for (const w of plural.warnings) localeWarnings.push(prefix + w);
794
+ for (const i of plural.infos) localeInfos.push(prefix + i);
795
+ for (const [k, v] of Object.entries(targetFlat)) {
796
+ if (typeof v === 'string') valuesByLocale.get(locale).set(layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k, v);
797
+ }
798
+ for (const [k, v] of Object.entries(expected)) {
799
+ if (typeof v === 'string') sourceByKey.set(layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k, v);
800
+ }
801
+ // ARB: the document around the messages (@@locale, placeholder types).
802
+ if (file.format === 'arb' && fs.existsSync(file.path)) {
803
+ const docIssues = auditARBDocument(unit.file.path, file.path, locale);
804
+ if (docIssues.length > 0) {
805
+ localeErrors.push(`${prefix}${docIssues.length} ARB file error(s): ${docIssues.slice(0, 3).map(d => d.reason).join('; ')}`
806
+ + ` — any sync that rewrites ${file.rel} repairs it (e.g. \`champollion sync --pair ${config.inputLocale}:${locale} --force\`, served from the cache)`);
807
+ }
808
+ }
809
+ }
810
+ if (!anyFile) continue;
811
+
812
+ // Out of date: a value made from an older source text — structurally
813
+ // intact (so a warning here; `audit` is the completeness gate that fails
814
+ // on it, and `verify --strict` fails on any warning). A source edit whose
815
+ // re-translation failed used to leave the old text behind with verify
816
+ // and audit both green (Round 4, i18next persona).
817
+ if (lockState) {
818
+ try {
819
+ const health = localeHealth({
820
+ layout, units, inputLocale: config.inputLocale, code: locale, localeState: lockState.peek(locale),
821
+ manifest: lock.source, tm, pairConfig: pairConfigs.get(locale) || null,
822
+ helpers: { expectedForTarget, readLocaleFlat, lockKey, originKey, fallbackPrefix: config.fallbackPrefix || '[EN] ' },
823
+ });
824
+ if (health.stale.length > 0) {
825
+ const pair = pairOf.get(locale) || `${config.inputLocale}:${locale}`;
826
+ localeWarnings.push(`${health.stale.length} translation(s) out of date — made from an older source text: `
827
+ + `${health.stale.slice(0, 5).join(', ')}${health.stale.length > 5 ? '...' : ''} — re-translate: \`champollion sync --pair ${shellWord(pair)}\``
828
+ + (health.stale.some(k => health.held.includes(k))
829
+ ? ` (refused before, held back until named: \`${redoCommand(health.stale.filter(k => health.held.includes(k)).slice(0, 8), { pair })}\`)`
830
+ : ''));
831
+ }
832
+ } catch { /* a check that cannot run is not a finding */ }
833
+ }
295
834
 
296
- const targetRaw = readLocaleFile(targetPath, format);
297
- const targetFlat = format === 'json' ? flattenKeys(targetRaw) : targetRaw;
835
+ // One text written for several different source strings — key values,
836
+ // ICU branches and the locale's Markdown pages counted together: a model
837
+ // repeating a memorized sentence (Round 4 and 5 personas). Sync's gate
838
+ // refuses it; on disk it is an error, said inside this locale's block,
839
+ // never after a "[OK]" line.
840
+ const contentItems = contentItemsFor(config, cwd, locale);
841
+ for (const e of sharedOutputErrors(locale, valuesByLocale.get(locale), sourceByKey, config, noTranslate, pairOf,
842
+ contentItems, tm?._meta?.memorized?.[locale] || [])) {
843
+ localeErrors.push(e);
844
+ }
845
+ // Markdown blocks and front-matter fields the quality gate refuses today
846
+ // (a heading turned into a sentence): sync refuses them since Round 7;
847
+ // what is on disk from before is named here, with the one repair.
848
+ for (const w of contentGateWarnings(locale, contentItems, pairConfigs.get(locale) || { target: locale },
849
+ pairOf.get(locale) || `${config.inputLocale}:${locale}`, config.fallbackPrefix || '[EN] ')) {
850
+ localeWarnings.push(w);
851
+ }
298
852
 
299
853
  output.raw(` ── ${locale} ──────────────────────────────────────`);
300
854
 
301
- const { errors, warnings, targetKeyCount } = auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate, echoPredicateFor(locale));
302
-
303
- if (!errors.some(e => e.includes('missing key'))) {
304
- output.raw(` [OK] ${targetKeyCount}/${sourceKeyCount} keys present`);
855
+ // [OK] only for an exact match: "[OK] 9/8 keys present" was printed for a
856
+ // locale holding an extra plural form, right above the warning about it
857
+ // (Round 8, i18next persona).
858
+ if (missingKeyTotal === 0 && extraKeyNames.length === 0) {
859
+ output.raw(` [OK] ${targetKeyTotal}/${expectedKeyTotal} keys present`);
860
+ } else {
861
+ const shown = extraKeyNames.slice(0, 5).map(k => k.replace(/\u0004/g, '\u2404')).join(', ') + (extraKeyNames.length > 5 ? ', …' : '');
862
+ const parts = [
863
+ missingKeyTotal > 0 && `${missingKeyTotal} missing`,
864
+ extraKeyNames.length > 0 && `${extraKeyNames.length} extra: ${shown}`,
865
+ ].filter(Boolean).join('; ');
866
+ output.raw(` ${expectedKeyTotal} expected, ${targetKeyTotal} present (${parts})`);
305
867
  }
868
+ // The plural forms this locale is expected to have, and whether its
869
+ // files have them — one line per kind (i18next keys, ICU messages,
870
+ // gettext entries); none when the files carry no plurals.
871
+ for (const line of pluralCoverageLines(locale, pluralCoverage)) output.raw(line);
306
872
 
307
- for (const err of errors) {
308
- output.error(`[VERIFY] ${locale}: ${err}`);
873
+ for (const err of localeErrors) {
874
+ output.error(`[VERIFY] ${locale}: ${showKeys(err)}`);
309
875
  totalErrors++;
310
876
  }
311
- for (const warn of warnings) {
312
- output.warn(`[VERIFY] ${locale}: ${warn}`);
877
+ for (const warn of localeWarnings) {
878
+ output.warn(`[VERIFY] ${locale}: ${showKeys(warn)}`);
313
879
  totalWarnings++;
314
880
  }
881
+ for (const info of localeInfos) output.info(`[VERIFY] ${locale}: ${showKeys(info)}`);
315
882
 
316
- if (errors.length === 0 && warnings.length === 0) {
317
- output.raw(' [OK] All checks passed');
883
+ if (localeErrors.length === 0 && localeWarnings.length === 0) {
884
+ output.raw(STRUCTURE_ONLY_OK);
318
885
  }
319
886
  output.raw('');
887
+ // --json: this locale's results as one record on stdout (the findings
888
+ // are also the [VERIFY] lines on stderr). Keys in structured fields are
889
+ // exact (a gettext context key keeps its U+0004); messages show it as ␄.
890
+ output.event('verify', {
891
+ locale,
892
+ pair: pairOf.get(locale) || `${config.inputLocale}:${locale}`,
893
+ ok: localeErrors.length === 0 && localeWarnings.length === 0,
894
+ keys: { expected: expectedKeyTotal, present: targetKeyTotal, missing: missingKeyTotal, extra: extraKeyNames },
895
+ errors: localeErrors.map(showKeys),
896
+ warnings: localeWarnings.map(showKeys),
897
+ infos: localeInfos.map(showKeys),
898
+ placeholders: localePlaceholders,
899
+ plurals: pluralCoverageData(pluralCoverage),
900
+ });
901
+ }
902
+
903
+ // Two locales holding the same text: one of them is probably in the
904
+ // other's language (or a model ignored the target language).
905
+ for (const w of identicalLocaleWarnings(valuesByLocale, sourceByKey, config, noTranslate, pairOf)) {
906
+ output.warn(`[VERIFY] ${showKeys(w)}`);
907
+ totalWarnings++;
320
908
  }
321
909
 
322
- return printSummary(totalErrors, totalWarnings);
910
+ // Scoped, and some locale left out: the closing line names what was checked.
911
+ // (A --pair that names every locale is a full check, said as one.)
912
+ const leftOut = scope && [...new Set([...layout.listLocales(), ...configuredTargetLocales(config)])]
913
+ .some(l => l !== config.inputLocale && !inScope(l));
914
+ return finishEviction(cwd, tm, tmEvicted, printSummary(totalErrors, totalWarnings, {
915
+ strict: !!options.strict, incomplete: options.incomplete || null,
916
+ scope: leftOut ? checkedScope([...missingTargets, ...targetLocales], pairOf, config, !!options.afterSync) : null,
917
+ }), damagedCount);
323
918
  }
324
919
 
325
920
  /**
326
- * Recursively collect all .json files under a directory.
921
+ * What a scoped verify checked: its locales (sorted) and their pairs.
922
+ *
923
+ * @param {string[]} locales - The locales checked
924
+ * @param {Map<string, string>} pairOf - locale → pair key
925
+ * @param {object} config
926
+ * @param {boolean} afterSync
927
+ * @returns {{ locales: string[], pairs: string[], afterSync: boolean }}
928
+ */
929
+ function checkedScope(locales, pairOf, config, afterSync) {
930
+ const sorted = [...new Set(locales)].sort();
931
+ return { locales: sorted, pairs: sorted.map(l => pairOf.get(l) || `${config.inputLocale}:${l}`), afterSync };
932
+ }
933
+
934
+ /**
935
+ * The method named by a lock `by` key ("deepl|||") when it cannot be told
936
+ * which plural form to write (it reads no per-key instructions), else null.
937
+ */
938
+ function untoldMethod(methodKey) {
939
+ const method = String(methodKey).split('|')[0] || 'llm';
940
+ try { return getMethod(method, { method }).acceptsKeyInstructions === true ? null : method; } catch { return null; }
941
+ }
942
+
943
+ /** The per-locale pass line: what was checked, and that meaning was not. */
944
+ const STRUCTURE_ONLY_OK = ' [OK] Structural checks passed (meaning is not checked)';
945
+
946
+ /**
947
+ * Plural findings that need the FILE, not just its flat map:
948
+ * - i18next: a plural key whose CLDR category the locale does not have
949
+ * (French `count_two`) — i18next never selects it. `_zero` is never
950
+ * flagged: i18next uses it for 0 in every language.
951
+ * - i18next: a borrowed form holding the text of the form it is translated
952
+ * from, with no record of the model writing it as that form (an info
953
+ * line — decided from the committed lock, never from the cache).
954
+ * - gettext: forms marked as repeating `other` that still do (sync wrote
955
+ * them because the translation had no such form), and msgstr[n] beyond
956
+ * the catalog's nplurals.
957
+ *
958
+ * @returns {{ warnings: string[], infos: string[] }}
959
+ */
960
+ function pluralFileWarnings(unit, file, targetFlat, locale, config, fixFor, { expansion = null, localeState = null, lockKeyOf = (k) => k, pruneCommand = null } = {}) {
961
+ const out = [];
962
+ const infos = [];
963
+ // i18next: a borrowed form (French `count_many`, translated from the
964
+ // English `count_other` text) holding exactly the text of the form it
965
+ // borrows from. Some languages write the two alike; what tells "the model
966
+ // wrote it this way" from "filled from the other form" is the lock's
967
+ // forms-record (lib/locale-state.js), committed with the files — so every
968
+ // clone of a commit gets the same answer. This used to consult each
969
+ // machine's own cache: one commit passed `verify --strict` on the machine
970
+ // that translated it and failed in CI, where it also suggested a redo that
971
+ // would re-bill (Round 7, i18next persona).
972
+ if (expansion?.borrowed) {
973
+ const unproven = [];
974
+ for (const [b, form] of Object.entries(expansion.borrowed)) {
975
+ const s = expansion.origin?.[b];
976
+ if (!s || expansion.origin[s] !== s || expansion.borrowed[s]) continue;
977
+ const vb = targetFlat[b];
978
+ if (typeof vb !== 'string' || vb.trim() === '' || vb !== targetFlat[s]) continue;
979
+ const lk = lockKeyOf(b);
980
+ const asked = decodeForm(localeState?.forms?.[lk]);
981
+ if (asked && asked.value === valueHash(vb)) continue; // asked for this form; it answered so
982
+ const written = decodeWritten(localeState?.written?.[lk]);
983
+ const bySync = !!written && written.value === valueHash(vb);
984
+ const by = bySync ? (localeState?.by?.[lk] || null) : null;
985
+ unproven.push({ key: b, sibling: s, form, value: vb, bySync, untold: by ? untoldMethod(by) : null });
986
+ }
987
+ const preview = (list) => list.slice(0, 3).map(x => `${x.key} = ${x.sibling} (${JSON.stringify(x.value.length > 40 ? `${x.value.slice(0, 37)}...` : x.value)})`).join(', ')
988
+ + (list.length > 3 ? '...' : '');
989
+ const formsOf = (list) => describeCategories(locale, [...new Set(list.map(x => x.form.replace(/^ordinal-/, '')))],
990
+ list[0].form.startsWith('ordinal-') ? 'ordinal' : 'cardinal');
991
+ const redo = (list) => `\`${fixFor(list.map(x => x.key))}\` (sends ${list.length} key(s) to the model)`;
992
+ const untold = unproven.filter(x => x.untold);
993
+ const predates = unproven.filter(x => x.bySync && !x.untold);
994
+ const noRecord = unproven.filter(x => !x.bySync);
995
+ if (untold.length > 0) {
996
+ infos.push(`${untold.length} plural form(s) hold the same text as the form they are translated from: ${preview(untold)}`
997
+ + ` — written by ${untold[0].untold}, which cannot be told which plural form to write (${locale} uses ${formsOf(untold)}). `
998
+ + 'Review them, or use an LLM method for this pair.');
999
+ }
1000
+ if (predates.length > 0) {
1001
+ infos.push(`${predates.length} plural form(s) hold the same text as the form they are translated from: ${preview(predates)}`
1002
+ + ` — written before each form had its own cache entry, when a cached answer for one form could be written into both `
1003
+ + `(${locale} uses ${formsOf(predates)}). If they should differ, ask again: ${redo(predates)}`);
1004
+ }
1005
+ if (noRecord.length > 0) {
1006
+ infos.push(`${noRecord.length} plural form(s) hold the same text as the form they are translated from: ${preview(noRecord)}`
1007
+ + ` — Champollion has no record of the model writing them as that form (written by hand, by another tool, or by a `
1008
+ + `version before 0.4.0; ${locale} uses ${formsOf(noRecord)}). If they should differ: ${redo(noRecord)}`);
1009
+ }
1010
+ }
1011
+ // i18next keys for a form the language does not have — with the command
1012
+ // that removes exactly them (lib/plurals.js pluralExtraKeys, the same rule).
1013
+ const extra = pluralExtraKeys(unit, targetFlat, locale);
1014
+ if (extra.length > 0) {
1015
+ out.push(`${extra.length} plural key(s) for a form ${locale} does not have: ${extra.slice(0, 5).map(e => e.key).join(', ')}`
1016
+ + `${extra.length > 5 ? '...' : ''} (${locale} plural forms: ${extra[0].cats.join(', ')}) — i18next never uses them. `
1017
+ + `Remove them: \`${pruneCommand ? pruneCommand() : 'champollion sync --prune plural-extras'}\` (deletes only plural keys for forms ${locale} does not have, `
1018
+ + 'lists each one, sends nothing), or delete them by hand.');
1019
+ }
1020
+ if (file.format === 'po' && fs.existsSync(file.path)) {
1021
+ let findings = null;
1022
+ try { findings = poPluralFindings(fs.readFileSync(file.path, 'utf-8'), { locale, filePath: file.rel }); } catch { findings = null; }
1023
+ if (findings) {
1024
+ const everydayOf = (cats) => cats.filter(c => (pluralCategoryUseFor(locale) || []).includes(c));
1025
+ const copied = findings.copied.map(f => ({ key: f.key, cats: everydayOf(f.categories) })).filter(f => f.cats.length > 0);
1026
+ if (copied.length > 0) {
1027
+ const preview = copied.slice(0, 3).map(f => `${f.key} (${f.cats.join(', ')})`).join(', ');
1028
+ out.push(`${copied.length} plural entr(y/ies) repeat the "other" form where ${locale} has its own: ${preview}`
1029
+ + `${copied.length > 3 ? '...' : ''} — the translation did not supply those forms (marked "# champollion:" in ${file.rel}). `
1030
+ + `Write them and delete that line, or ask again: \`${fixFor(copied.map(f => f.key), { fresh: true })}\``);
1031
+ }
1032
+ if (findings.extraForms.length > 0) {
1033
+ const f0 = findings.extraForms[0];
1034
+ out.push(`${findings.extraForms.length} plural entr(y/ies) with more forms than the catalog's nplurals=${f0.nplurals} `
1035
+ + `(${locale}: ${f0.categories.join(', ')}): ${findings.extraForms.slice(0, 3).map(f => `${f.key} (msgstr[0..${f.forms - 1}])`).join(', ')}`
1036
+ + ' — msgfmt rejects them; delete the extra msgstr[] lines (sync then treats the entry as translated again).');
1037
+ }
1038
+ }
1039
+ }
1040
+ return { warnings: out, infos };
1041
+ }
1042
+
1043
+ /**
1044
+ * Plural messages in one target file that lack a form the language uses for
1045
+ * ORDINARY counts (Russian few/many) — what `verify --strict` fails on, so
1046
+ * `audit` (the completeness gate) counts the same entries as incomplete
1047
+ * (Round 7, Django persona: verify warned, audit said "fully translated",
1048
+ * and wrong Russian plurals shipped with a green build).
1049
+ * - ICU plural values (next-intl, ARB, i18next ICU): a missing branch;
1050
+ * - gettext: msgstr[] forms sync marked "# champollion:" as repeating
1051
+ * `other` (the translation had no such form).
1052
+ *
1053
+ * @returns {Array<{ key: string, missing: string[], marked: boolean }>}
1054
+ */
1055
+ function pluralGapsInFile({ file, expected, targetFlat, locale }) {
1056
+ const out = [];
1057
+ const isPo = file.format === 'po' && fs.existsSync(file.path);
1058
+ const text = isPo ? fs.readFileSync(file.path, 'utf-8') : null;
1059
+ const pluralSlots = isPo ? poPluralSlots(text, locale) : null;
1060
+ for (const [k, src] of Object.entries(expected)) {
1061
+ const tgt = targetFlat[k];
1062
+ if (typeof src !== 'string' || typeof tgt !== 'string' || !src.includes('{') || tgt === src) continue;
1063
+ const gaps = pluralGaps(src, tgt, locale, pluralSlots).filter(g => g.everyday.length > 0);
1064
+ if (gaps.length > 0) out.push({ key: k, missing: [...new Set(gaps.flatMap(g => g.everyday))], marked: false });
1065
+ }
1066
+ if (isPo) {
1067
+ let findings = null;
1068
+ try { findings = poPluralFindings(text, { locale, filePath: file.rel }); } catch { findings = null; }
1069
+ for (const f of findings?.copied || []) {
1070
+ const cats = f.categories.filter(c => (pluralCategoryUseFor(locale) || []).includes(c));
1071
+ if (cats.length > 0 && !out.some(o => o.key === f.key)) out.push({ key: f.key, missing: cats, marked: true });
1072
+ }
1073
+ }
1074
+ return out;
1075
+ }
1076
+
1077
+ /** Everyday plural categories of a locale (lib/icu-structure.js), memoized. */
1078
+ const everydayCache = new Map();
1079
+ function pluralCategoryUseFor(locale) {
1080
+ if (!everydayCache.has(locale)) everydayCache.set(locale, pluralCategoryUse(locale)?.everyday || null);
1081
+ return everydayCache.get(locale);
1082
+ }
1083
+
1084
+ // -----------------------------------------------------------------
1085
+ // Plural coverage — the forms each locale is expected to have, at a glance
1086
+ // -----------------------------------------------------------------
1087
+
1088
+ /**
1089
+ * The plural / selectordinal arguments of an ICU message, nested ones too
1090
+ * (a gettext msgid_plural entry reads as one: lib/po.js). `inflects` is
1091
+ * false for an argument with nothing but `other` — count-agnostic by design,
1092
+ * and not judged (the rule lib/icu-structure.js pluralGaps applies).
1093
+ * Parsed under the apostrophe readings pluralGaps tries, in its order.
1094
+ *
1095
+ * @param {string} text
1096
+ * @returns {Array<{ name: string, type: 'cardinal'|'ordinal', inflects: boolean }>}
1097
+ */
1098
+ function pluralArguments(text) {
1099
+ if (typeof text !== 'string' || !text.includes('{')) return [];
1100
+ for (const apostrophes of ['literal', 'icu']) {
1101
+ const parsed = parseMessage(text, { apostrophes });
1102
+ if (!parsed.ok || !hasBranching(parsed.nodes)) continue;
1103
+ const out = [];
1104
+ const visit = (nodes) => {
1105
+ for (const node of nodes) {
1106
+ if (node.type !== 'branching') continue;
1107
+ if (node.keyword === 'plural' || node.keyword === 'selectordinal') {
1108
+ out.push({
1109
+ name: node.name,
1110
+ type: node.keyword === 'selectordinal' ? 'ordinal' : 'cardinal',
1111
+ inflects: node.options.some(o => o.selector !== 'other'),
1112
+ });
1113
+ }
1114
+ for (const opt of node.options) visit(opt.nodes);
1115
+ }
1116
+ };
1117
+ visit(parsed.nodes);
1118
+ return out;
1119
+ }
1120
+ return [];
1121
+ }
1122
+
1123
+ /** A value that holds text (a missing or empty form is not a form). */
1124
+ const holdsText = (v) => typeof v === 'string' && v.trim() !== '';
1125
+
1126
+ /**
1127
+ * Add one target file's plurals to its locale's coverage (`acc`, a Map the
1128
+ * caller keeps per locale). Two kinds, each judged by the expectation sync
1129
+ * translates against — never a hardcoded list:
1130
+ * - i18next suffixed keys (`count_one`, `count_many`, …): the keys the
1131
+ * locale's expansion expects (lib/plurals.js — CLDR's categories for the
1132
+ * locale, `_zero` when the source has it; the source's own forms when
1133
+ * CLDR has no rules for the locale). A group is complete when every
1134
+ * expected key holds text.
1135
+ * - ICU plural messages (next-intl, ARB, i18next ICU) and gettext
1136
+ * msgid_plural entries: CLDR's categories (lib/icu-structure.js
1137
+ * pluralCategoryUse), in a gettext catalog only those its Plural-Forms
1138
+ * has a slot for. A message is complete when it keeps its plural and
1139
+ * supplies every form ordinary counts use (no pluralGaps everyday gap,
1140
+ * no msgstr[] marked as repeating `other`). A form only numbers above
1141
+ * 1000 or fractions use (French `many`) is counted apart: "other"
1142
+ * stands in for it, which is not a finding.
1143
+ * Missing forms are already findings (a missing key, a plural warning);
1144
+ * this is the summary that confirms the plurals (Round 12, i18next persona:
1145
+ * confirming the forms needed a separate check).
1146
+ *
1147
+ * @param {Map} acc - Per-locale accumulator: `${kind}|${type}` → bucket
1148
+ * @param {object} args
1149
+ * @param {object} args.unit - Source unit (lib/locale-layout.js)
1150
+ * @param {object} args.expected - The keys this locale should have (expectedForTarget)
1151
+ * @param {object|null} args.expansion - Its plural expansion
1152
+ * @param {object} args.targetFlat - The locale file's values
1153
+ * @param {string} args.locale
1154
+ * @param {boolean} [args.gettext] - The file is a gettext catalog
1155
+ * @param {string[]|null} [args.slots] - Its plural slots (poPluralSlots; null = CLDR's)
1156
+ * @param {Array<{ key: string, categories: string[] }>} args.marked - gettext forms marked as repeating `other`
1157
+ * @param {(key: string) => string} args.keyName - Display key (namespaced)
1158
+ */
1159
+ function addPluralCoverage(acc, { unit, expected, expansion, targetFlat, locale, gettext = false, slots = null, marked = [], keyName = (k) => k }) {
1160
+ const bucket = (kind, type, basis) => {
1161
+ const id = `${kind}|${type}`;
1162
+ if (!acc.has(id)) {
1163
+ acc.set(id, { kind, type, basis, categories: new Set(), rare: new Set(), total: 0, complete: 0, incomplete: [], leftToOther: new Map() });
1164
+ }
1165
+ return acc.get(id);
1166
+ };
1167
+ const has = (k) => Object.prototype.hasOwnProperty.call(expected, k);
1168
+
1169
+ // i18next suffixed keys.
1170
+ if (expansion && unit.pluralGroups && unit.pluralGroups.size > 0) {
1171
+ for (const group of unit.pluralGroups.values()) {
1172
+ const type = group.ordinal ? 'ordinal' : 'cardinal';
1173
+ const prefix = `${group.base}${group.ordinal ? '_ordinal' : ''}_`;
1174
+ const cats = CLDR_CATEGORIES.filter(c => has(`${prefix}${c}`));
1175
+ if (cats.length === 0) continue;
1176
+ const b = bucket('i18next-keys', type, expansion.unknownLocale ? 'source' : 'cldr');
1177
+ for (const c of cats) b.categories.add(c);
1178
+ b.total++;
1179
+ const missing = cats.filter(c => !holdsText(targetFlat[`${prefix}${c}`]));
1180
+ if (missing.length === 0) b.complete++;
1181
+ else b.incomplete.push({ key: keyName(group.base), missing, keys: missing.map(c => keyName(`${prefix}${c}`)) });
1182
+ }
1183
+ }
1184
+
1185
+ // ICU plural messages / gettext plural entries.
1186
+ const kind = gettext ? 'gettext-entries' : 'icu-messages';
1187
+ const markedOf = new Map(marked.map(f => [f.key, f.categories]));
1188
+ for (const [k, src] of Object.entries(expected)) {
1189
+ const srcArgs = pluralArguments(src).filter(a => a.inflects);
1190
+ if (srcArgs.length === 0) continue;
1191
+ const tgt = targetFlat[k];
1192
+ const tgtArgs = holdsText(tgt) ? pluralArguments(tgt) : [];
1193
+ const gaps = holdsText(tgt) ? pluralGaps(src, tgt, locale, slots) : [];
1194
+ for (const type of new Set(srcArgs.map(a => a.type))) {
1195
+ const use = pluralCategoryUse(locale, type);
1196
+ const holds = (c) => !slots || type !== 'cardinal' || slots.includes(c);
1197
+ const b = bucket(kind, type, !use ? 'none' : (slots && type === 'cardinal' ? 'gettext' : 'cldr'));
1198
+ b.total++;
1199
+ if (!use) continue; // CLDR has no rules for the locale: nothing to judge against
1200
+ const cats = use.categories.filter(holds);
1201
+ for (const c of cats) b.categories.add(c);
1202
+ for (const c of use.rare.filter(holds)) b.rare.add(c);
1203
+ const lost = srcArgs.some(a => a.type === type && !tgtArgs.some(t => t.name === a.name && t.type === type));
1204
+ let missing;
1205
+ const rareLeft = new Set();
1206
+ if (lost) {
1207
+ missing = cats; // the value has no such plural at all (or no text)
1208
+ } else {
1209
+ const ofType = gaps.filter(g => g.type === type);
1210
+ missing = [...new Set(ofType.flatMap(g => g.everyday))];
1211
+ for (const g of ofType) for (const c of g.rare) rareLeft.add(c);
1212
+ if (type === 'cardinal') {
1213
+ for (const c of markedOf.get(k) || []) {
1214
+ if (use.everyday.includes(c)) { if (!missing.includes(c)) missing.push(c); } else if (use.rare.includes(c)) rareLeft.add(c);
1215
+ }
1216
+ }
1217
+ }
1218
+ for (const c of rareLeft) b.leftToOther.set(c, (b.leftToOther.get(c) || 0) + 1);
1219
+ if (missing.length === 0) b.complete++;
1220
+ else b.incomplete.push({ key: keyName(k), missing: CLDR_CATEGORIES.filter(c => missing.includes(c)) });
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ /** A locale's plural coverage as --json data (one entry per kind and type). */
1226
+ function pluralCoverageData(acc) {
1227
+ return [...acc.values()].map(b => ({
1228
+ kind: b.kind,
1229
+ type: b.type,
1230
+ // cldr: CLDR's categories for the locale; gettext: those the catalog's
1231
+ // Plural-Forms holds; source: CLDR has no rules — the source's forms;
1232
+ // none: CLDR has no rules, nothing judged.
1233
+ basis: b.basis,
1234
+ categories: CLDR_CATEGORIES.filter(c => b.categories.has(c)),
1235
+ total: b.total,
1236
+ complete: b.basis === 'none' ? null : b.complete,
1237
+ ok: b.basis === 'none' ? null : b.incomplete.length === 0,
1238
+ incomplete: b.incomplete,
1239
+ ...(b.rare.size > 0 && { rare: CLDR_CATEGORIES.filter(c => b.rare.has(c)) }),
1240
+ ...(b.leftToOther.size > 0 && { leftToOther: Object.fromEntries(b.leftToOther) }),
1241
+ }));
1242
+ }
1243
+
1244
+ /**
1245
+ * The human lines for a locale's plural coverage, e.g.
1246
+ * Plural forms (CLDR fr): one, many, other ✓ — 2 i18next plural key group(s), every form present
1247
+ *
1248
+ * @param {string} locale
1249
+ * @param {Map} acc
1250
+ * @returns {string[]}
1251
+ */
1252
+ function pluralCoverageLines(locale, acc) {
1253
+ const nouns = {
1254
+ 'i18next-keys': 'i18next plural key group(s)',
1255
+ 'icu-messages': 'ICU plural message(s)',
1256
+ 'gettext-entries': 'gettext plural entr(y/ies)',
1257
+ };
1258
+ const lines = [];
1259
+ for (const b of pluralCoverageData(acc)) {
1260
+ const forms = b.type === 'ordinal' ? 'Ordinal forms' : 'Plural forms';
1261
+ const noun = nouns[b.kind];
1262
+ if (b.basis === 'none') {
1263
+ lines.push(` ${forms} (CLDR has no plural rules for ${locale}): ${b.total} ${noun}, not checked`);
1264
+ continue;
1265
+ }
1266
+ const basis = {
1267
+ cldr: `CLDR ${locale}`,
1268
+ gettext: `CLDR ${locale}, the forms the catalog's Plural-Forms has`,
1269
+ source: `the source's — CLDR has no plural rules for ${locale}`,
1270
+ }[b.basis];
1271
+ const shown = b.incomplete.slice(0, 5).map(i => (i.keys ? i.keys.join(', ') : `${i.key} (${i.missing.join(', ')})`)).join(', ')
1272
+ + (b.incomplete.length > 5 ? ', …' : '');
1273
+ const left = Object.entries(b.leftToOther || {})
1274
+ .map(([c, n]) => `; ${c} is used only above 1000 or for fractions — "other" stands in for it in ${n}`).join('');
1275
+ const detail = b.ok
1276
+ ? `${b.total} ${noun}, every form ${left ? 'ordinary counts use ' : ''}present`
1277
+ : `${b.incomplete.length} of ${b.total} ${noun} lack a form: ${shown}`;
1278
+ // i18next looks `key_zero` up for 0 in every language: a source `_zero`
1279
+ // is expected even where CLDR has no zero category (lib/plurals.js).
1280
+ const cldr = pluralCategoriesFor(locale, b.type) || [];
1281
+ const zero = b.kind === 'i18next-keys' && b.basis === 'cldr' && b.categories.includes('zero') && !cldr.includes('zero')
1282
+ ? ' (zero: the source has _zero, which i18next uses for 0)' : '';
1283
+ lines.push(` ${forms} (${basis}): ${b.categories.join(', ')}${zero} ${b.ok ? '✓' : '✗'} — ${detail}${left}`);
1284
+ }
1285
+ return lines;
1286
+ }
1287
+
1288
+ /** A value with words in it — not a number, a placeholder, a URL or punctuation. */
1289
+ function hasWords(value) {
1290
+ const text = String(value)
1291
+ .replace(/https?:\/\/\S+/g, '')
1292
+ .replace(/\{[^{}]*\}/g, '')
1293
+ .replace(/%(\([^)]*\))?[-#0 +]*\d*(\.\d+)?[sdifuxXoeEgGc]/g, '')
1294
+ .replace(/[\d\s\p{P}\p{S}]/gu, '');
1295
+ return text.length >= 2;
1296
+ }
1297
+
1298
+ /**
1299
+ * A locale's Markdown pages as shared-output items: each front-matter field
1300
+ * and each block, beside its source (contentDir projects; a page whose block
1301
+ * count differs from its source's is compared field by field only). Sync
1302
+ * seeds its gate with the pages a run leaves alone from the same reading
1303
+ * (lib/shared-output-seed.js), so the two judge the same items.
1304
+ *
1305
+ * @param {object} config
1306
+ * @param {string} cwd
1307
+ * @param {string} locale
1308
+ * @param {{ only?: (sourcePath: string) => boolean }} [opts] - Read only these source pages
1309
+ * @returns {Array<{ key: string, source: string, value: string }>}
1310
+ */
1311
+ function contentItemsFor(config, cwd, locale, { only = null } = {}) {
1312
+ if (!config.contentDir || config.format === 'docusaurus') return [];
1313
+ const contentDir = path.resolve(cwd, config.contentDir);
1314
+ if (!fs.existsSync(contentDir)) return [];
1315
+ const fields = config.translatableFields || DEFAULT_TRANSLATABLE_FIELDS;
1316
+ const items = [];
1317
+ let sources = [];
1318
+ try { sources = discoverContentFiles(contentDir, config.inputLocale); } catch { return []; }
1319
+ for (const sourcePath of sources) {
1320
+ if (only && !only(sourcePath)) continue;
1321
+ const targetPath = getTargetContentPath(sourcePath, locale, config.inputLocale);
1322
+ if (!fs.existsSync(targetPath)) continue;
1323
+ const rel = path.relative(contentDir, sourcePath).split(path.sep).join('/');
1324
+ let src;
1325
+ let tgt;
1326
+ try {
1327
+ src = parseContentFile(fs.readFileSync(sourcePath, 'utf-8'));
1328
+ tgt = parseContentFile(fs.readFileSync(targetPath, 'utf-8'));
1329
+ } catch { continue; }
1330
+ for (const field of fields) {
1331
+ const a = src.frontMatter?.[field];
1332
+ const b = tgt.frontMatter?.[field];
1333
+ if (typeof a === 'string' && typeof b === 'string') items.push({ key: `content:${rel} front matter:${field}`, source: a, value: b });
1334
+ }
1335
+ const srcBlocks = translatableBlockSources(src.body || '');
1336
+ const tgtBlocks = translatableBlockSources(tgt.body || '');
1337
+ if (srcBlocks.length !== tgtBlocks.length) continue;
1338
+ srcBlocks.forEach((block, i) => items.push({ key: `content:${rel}#${i + 1}`, source: block, value: tgtBlocks[i] }));
1339
+ }
1340
+ return items;
1341
+ }
1342
+
1343
+ /**
1344
+ * The command that re-translates one content file — the ONE repair sync and
1345
+ * verify both print for a content finding. `--redo files:` matches the path
1346
+ * relative to the contentDir (as sync names files) or to the project root
1347
+ * (lib/file-scope.js); the memorized sentence a finding names has been
1348
+ * evicted from the cache and is refused if the model answers with it again,
1349
+ * so no --fresh is needed (Round 6).
1350
+ *
1351
+ * @param {string} file - The content file as sync names it (relative to the contentDir)
1352
+ * @param {{ pair?: string|null }} [opts]
1353
+ * @returns {string}
1354
+ */
1355
+ function contentRedoCommand(file, { pair = null } = {}) {
1356
+ return `champollion sync${pair ? ` --pair ${shellWord(pair)}` : ''} --redo files:${shellWord(file)}`;
1357
+ }
1358
+
1359
+ /**
1360
+ * Per locale: Markdown blocks and front-matter fields on disk that the
1361
+ * quality gate refuses today (lib/validate.js contentGateFault — the
1362
+ * key-value gate's checks: length inflation, echo, truncation, script). A
1363
+ * warning: sync refuses such output, so on disk it predates the check, was
1364
+ * written around it, or by hand. '[EN] ' fallbacks are their own finding.
1365
+ *
1366
+ * @returns {string[]} warnings
1367
+ */
1368
+ function contentGateWarnings(locale, items, pairConfig, pair, fallbackPrefix) {
1369
+ const byFile = new Map();
1370
+ for (const it of items || []) {
1371
+ if (typeof it.value !== 'string' || it.value.startsWith(fallbackPrefix)) continue;
1372
+ const reason = contentGateFault(it.source, it.value, pairConfig);
1373
+ if (!reason) continue;
1374
+ const rest = it.key.slice('content:'.length);
1375
+ const m = /^(.*?)(?:#(\d+)| front matter:(.*))$/.exec(rest);
1376
+ const file = m ? m[1] : rest;
1377
+ const where = m && m[2] ? `paragraph ${m[2]}` : m && m[3] !== undefined ? `front matter "${m[3]}"` : rest;
1378
+ if (!byFile.has(file)) byFile.set(file, []);
1379
+ byFile.get(file).push({ where, reason });
1380
+ }
1381
+ const out = [];
1382
+ for (const [file, list] of byFile) {
1383
+ out.push(`${file}: ${list.length} Markdown block(s)/front-matter field(s) the quality gate refuses — `
1384
+ + list.slice(0, 3).map(x => `${x.where}: ${x.reason}`).join('; ') + (list.length > 3 ? '; …' : '')
1385
+ + ` — re-translate: \`${contentRedoCommand(file, { pair })}\``);
1386
+ }
1387
+ return out;
1388
+ }
1389
+
1390
+ /**
1391
+ * Per locale: one value written for several different source strings
1392
+ * (lib/validate.js SharedOutputIndex — the gate's own rule, so synonyms
1393
+ * collapsing to one short word are not flagged). ICU messages count branch
1394
+ * by branch, and the locale's Markdown pages share the index with its keys.
1395
+ *
1396
+ * An ERROR, not a warning: sync's gate refuses the same group, so on disk
1397
+ * it is either older than the gate, written around it, or hand-made — and a
1398
+ * memorized sentence in place of three different clinical prompts passed a
1399
+ * warning-only verify (Round 5, hospital persona).
1400
+ *
1401
+ * @returns {string[]} errors
1402
+ */
1403
+ function sharedOutputErrors(locale, values, sourceByKey, config, noTranslate, pairOf, contentItems = [], memorized = []) {
1404
+ const index = new SharedOutputIndex({ protectedTerms: config.protectedTerms || [] });
1405
+ // A sentence an earlier sync caught the model repeating (TM _meta.memorized):
1406
+ // on disk even once, it is that sentence, not a translation (Round 6).
1407
+ index.markMemorized(memorized);
1408
+ const items = [];
1409
+ for (const [key, value] of values || []) {
1410
+ const source = sourceByKey.get(key);
1411
+ if (typeof source !== 'string' || (noTranslate && noTranslate.matches(key, source))) continue;
1412
+ items.push(...sharedOutputItems(key, source, value));
1413
+ }
1414
+ items.push(...contentItems);
1415
+ const suspects = index.suspects(items);
1416
+ if (suspects.size === 0) return [];
1417
+ const groups = new Map();
1418
+ const remembered = new Set();
1419
+ for (const [key, g] of suspects) {
1420
+ if (!groups.has(g.value)) groups.set(g.value, []);
1421
+ groups.get(g.value).push(key);
1422
+ if (g.memorized) remembered.add(g.value);
1423
+ }
1424
+ const pair = pairOf.get(locale) || `${config.inputLocale}:${locale}`;
1425
+ const out = [];
1426
+ for (const [value, members] of groups) {
1427
+ const shown = value.length > 60 ? `${value.slice(0, 57)}...` : value;
1428
+ const keys = members.filter(k => !k.startsWith('content:'));
1429
+ const files = [...new Set(members.filter(k => k.startsWith('content:')).map(k => k.slice(8).replace(/(#\d+| front matter:.*)$/, '')))];
1430
+ // The content repair is the one sync prints for the same finding, word
1431
+ // for word (Round 7, school persona: the two named different commands).
1432
+ const redo = [
1433
+ keys.length > 0 && `\`${redoCommand(keys.slice(0, 8), { pair, fresh: true })}\``,
1434
+ ...files.slice(0, 3).map(f => `\`${contentRedoCommand(f, { pair })}\``),
1435
+ ].filter(Boolean).join(', ');
1436
+ out.push(remembered.has(value)
1437
+ ? `${members.length} value(s) hold the sentence an earlier sync caught the model repeating for different source strings — ${JSON.stringify(shown)} for `
1438
+ + `${members.map(k => k.replace(/^content:/, '')).slice(0, 5).join(', ')}${members.length > 5 ? '...' : ''} — a memorized sentence, `
1439
+ + `not a translation. Re-translate (a "fallback" method on the pair takes what the model can only answer this way): ${redo}`
1440
+ : `${members.length} value(s) hold the same text for different source strings — ${JSON.stringify(shown)} for `
1441
+ + `${members.map(k => k.replace(/^content:/, '')).slice(0, 5).join(', ')}${members.length > 5 ? '...' : ''} — a model repeating one memorized sentence, `
1442
+ + `not translations of each. Re-translate: ${redo}`);
1443
+ }
1444
+ return out;
1445
+ }
1446
+
1447
+ /**
1448
+ * Two target locales whose values are the same text for most keys: one is
1449
+ * almost certainly in the other's language — the output of a model that
1450
+ * ignored the target language passes every per-locale check (Round 3,
1451
+ * Next.js persona: fr.json and de.json byte-identical, "All checks passed").
1452
+ *
1453
+ * Compared: keys both locales have whose values contain words, differ from
1454
+ * the source (an echo is its own finding), and are not no-translate or
1455
+ * declared names. Locales of one language (pt-BR / pt-PT, fr / fr-CA) are
1456
+ * not compared — they legitimately share most of their text. Flagged at
1457
+ * 80 % or more identical, over at least 4 compared keys.
1458
+ *
1459
+ * @returns {string[]} warnings
1460
+ */
1461
+ function identicalLocaleWarnings(valuesByLocale, sourceByKey, config, noTranslate, pairOf) {
1462
+ const out = [];
1463
+ const locales = [...valuesByLocale.keys()];
1464
+ const lang = (l) => l.toLowerCase().split(/[-_]/)[0];
1465
+ for (let i = 0; i < locales.length; i++) {
1466
+ for (let j = i + 1; j < locales.length; j++) {
1467
+ const a = locales[i];
1468
+ const b = locales[j];
1469
+ if (lang(a) === lang(b)) continue;
1470
+ const va = valuesByLocale.get(a);
1471
+ const vb = valuesByLocale.get(b);
1472
+ let compared = 0;
1473
+ let identical = 0;
1474
+ for (const [key, x] of va) {
1475
+ const y = vb.get(key);
1476
+ const src = sourceByKey.get(key);
1477
+ if (typeof y !== 'string' || typeof src !== 'string') continue;
1478
+ if (x === src || y === src || !hasWords(x)) continue;
1479
+ if (noTranslate && noTranslate.matches(key, src)) continue;
1480
+ if (isProtectedTermValue(x, config.protectedTerms)) continue;
1481
+ compared++;
1482
+ if (x === y) identical++;
1483
+ }
1484
+ if (compared >= 4 && identical / compared >= 0.8) {
1485
+ const pa = pairOf.get(a) || `${config.inputLocale}:${a}`;
1486
+ const pb = pairOf.get(b) || `${config.inputLocale}:${b}`;
1487
+ out.push(`${a} and ${b}: ${identical} of ${compared} translated values are identical — one of them is probably `
1488
+ + `in the other's language (or the model ignored the target language). Check both files; re-translate the wrong one: `
1489
+ + `\`champollion sync --pair ${pa} --redo all --fresh\` (or --pair ${pb}).`);
1490
+ }
1491
+ }
1492
+ }
1493
+ return out;
1494
+ }
1495
+
1496
+ /**
1497
+ * Recursively collect all .json files under a directory — the shared
1498
+ * folder walk (lib/locale-layout.js), with Docusaurus's historical reach
1499
+ * (hidden entries included), the same walk the Docusaurus sync uses.
327
1500
  *
328
1501
  * @param {string} dir - Directory to walk
329
1502
  * @returns {string[]} Absolute paths to .json files, sorted
330
1503
  */
331
1504
  function walkJSONFiles(dir) {
332
- const files = [];
333
- function walk(d) {
334
- if (!fs.existsSync(d)) return;
335
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
336
- const full = path.join(d, entry.name);
337
- if (entry.isDirectory()) walk(full);
338
- else if (entry.isFile() && entry.name.endsWith('.json')) files.push(full);
339
- }
340
- }
341
- walk(dir);
342
- return files.sort();
1505
+ return walkFiles(dir, name => name.endsWith('.json'), { skipHidden: false });
343
1506
  }
344
1507
 
345
1508
  /**
@@ -354,46 +1517,57 @@ function walkJSONFiles(dir) {
354
1517
  * @param {object} config - Resolved config (format === 'docusaurus')
355
1518
  * @param {string} cwd - Working directory
356
1519
  * @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] - Compiled matcher
1520
+ * @param {Function} [echoPredicateFor]
1521
+ * @param {{ inScope?: (locale: string) => boolean, evictDamaged?: Function }} [hooks]
357
1522
  * @returns {Promise<{ errors: number, warnings: number }>}
358
1523
  */
359
- async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPredicateFor = () => null) {
1524
+ async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPredicateFor = () => null,
1525
+ { inScope = () => true, evictDamaged = () => {}, fixFor = () => (keys, opts) => redoCommand(keys, opts), afterSync = false, strict = false, incomplete = null,
1526
+ describeChecked = null } = {}) {
360
1527
  const sourceLocaleDir = path.join(config.localesDir, config.inputLocale);
361
1528
 
1529
+ const rel = (p) => path.relative(cwd, p).split(path.sep).join('/') || '.';
362
1530
  if (!fs.existsSync(sourceLocaleDir)) {
363
- output.warn(`[VERIFY] Docusaurus source locale dir not found (${sourceLocaleDir}) — skipping verification.`);
364
- return { errors: 0, warnings: 0 };
1531
+ return nothingVerified(cwd, config, `the Docusaurus source folder ${rel(sourceLocaleDir)}/ does not exist `
1532
+ + `("localesDir" and "inputLocale": "${config.inputLocale}" in champollion.config.json) — `
1533
+ + `\`npx docusaurus write-translations --locale ${config.inputLocale}\` creates it.`);
365
1534
  }
366
1535
 
367
1536
  const sourceFiles = walkJSONFiles(sourceLocaleDir);
368
1537
  if (sourceFiles.length === 0) {
369
- output.warn('[VERIFY] No source JSON strings found — skipping verification.');
370
- return { errors: 0, warnings: 0 };
1538
+ return nothingVerified(cwd, config, `no source JSON strings under ${rel(sourceLocaleDir)}/.`);
371
1539
  }
372
1540
 
373
1541
  // Target locales = subdirectories of i18n/ other than the source locale.
374
1542
  const targetLocales = fs.readdirSync(config.localesDir, { withFileTypes: true })
375
1543
  .filter(e => e.isDirectory() && e.name !== config.inputLocale && !e.name.startsWith('.'))
376
1544
  .map(e => e.name)
1545
+ .filter(inScope)
377
1546
  .sort();
378
1547
 
379
1548
  // Same false-green hole as the flat path: a configured locale with no
380
1549
  // i18n/<locale>/ directory is invisible to the listing above. Fail loud.
381
1550
  const missingTargets = [...configuredTargetLocales(config)]
1551
+ .filter(inScope)
382
1552
  .filter(l => !fs.existsSync(path.join(config.localesDir, l)))
383
1553
  .sort();
384
1554
 
385
1555
  if (targetLocales.length === 0 && missingTargets.length === 0) {
386
- return { errors: 0, warnings: 0 };
1556
+ return nothingVerified(cwd, config, `no target locale folders under ${rel(config.localesDir)}/ and no "languages" in champollion.config.json.`);
387
1557
  }
388
1558
 
389
- output.raw('\n ── Post-Sync Verification (Docusaurus) ──────────────────\n');
1559
+ output.raw(afterSync
1560
+ ? '\n ── Post-Sync Verification (Docusaurus) ──────────────────\n'
1561
+ : '\n ── Verification (Docusaurus) ────────────────────────────\n');
390
1562
 
391
1563
  let totalErrors = 0;
392
1564
  let totalWarnings = 0;
393
1565
 
394
1566
  for (const locale of missingTargets) {
395
- output.error(`[VERIFY] ${locale}: locale directory missing (${path.join(path.basename(config.localesDir), locale)}/) — configured target has no translations. Run \`champollion sync\` to create it.`);
1567
+ const message = `${locale}: locale directory missing (${path.join(path.basename(config.localesDir), locale)}/) — configured target has no translations. Run \`champollion sync\` to create it.`;
1568
+ output.error(`[VERIFY] ${message}`);
396
1569
  totalErrors++;
1570
+ output.event('verify', { locale, pair: `${config.inputLocale}:${locale}`, ok: false, fileMissing: true, errors: [message], warnings: [], infos: [] });
397
1571
  }
398
1572
 
399
1573
  for (const locale of targetLocales) {
@@ -401,6 +1575,7 @@ async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPred
401
1575
 
402
1576
  const localeErrors = [];
403
1577
  const localeWarnings = [];
1578
+ const localePlaceholders = [];
404
1579
 
405
1580
  for (const sourceFile of sourceFiles) {
406
1581
  const relPath = path.relative(sourceLocaleDir, sourceFile);
@@ -426,9 +1601,12 @@ async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPred
426
1601
  }
427
1602
  }
428
1603
 
429
- const { errors, warnings } = auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate, echoPredicateFor(locale));
1604
+ const { errors, warnings, damaged, placeholders } = auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate,
1605
+ echoPredicateFor(locale), { fixFor: fixFor(locale) });
1606
+ evictDamaged(locale, damaged);
430
1607
  for (const e of errors) localeErrors.push(`${relPath}: ${e}`);
431
1608
  for (const w of warnings) localeWarnings.push(`${relPath}: ${w}`);
1609
+ for (const p of placeholders) localePlaceholders.push({ ...p, file: relPath.split(path.sep).join('/') });
432
1610
  }
433
1611
 
434
1612
  for (const err of localeErrors) {
@@ -440,12 +1618,26 @@ async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPred
440
1618
  totalWarnings++;
441
1619
  }
442
1620
  if (localeErrors.length === 0 && localeWarnings.length === 0) {
443
- output.raw(' [OK] All checks passed');
1621
+ output.raw(STRUCTURE_ONLY_OK);
444
1622
  }
445
1623
  output.raw('');
1624
+ output.event('verify', {
1625
+ locale, pair: `${config.inputLocale}:${locale}`,
1626
+ ok: localeErrors.length === 0 && localeWarnings.length === 0,
1627
+ errors: localeErrors, warnings: localeWarnings, infos: [], placeholders: localePlaceholders,
1628
+ });
446
1629
  }
447
1630
 
448
- return printSummary(totalErrors, totalWarnings);
1631
+ // Scoped, and some locale left out: the closing line names what was checked.
1632
+ const everyTarget = new Set([
1633
+ ...fs.readdirSync(config.localesDir, { withFileTypes: true })
1634
+ .filter(e => e.isDirectory() && e.name !== config.inputLocale && !e.name.startsWith('.')).map(e => e.name),
1635
+ ...configuredTargetLocales(config),
1636
+ ]);
1637
+ const leftOut = [...everyTarget].some(l => !inScope(l));
1638
+ return printSummary(totalErrors, totalWarnings, {
1639
+ strict, incomplete, scope: describeChecked && leftOut ? describeChecked([...missingTargets, ...targetLocales]) : null,
1640
+ });
449
1641
  }
450
1642
 
451
- export { verifyLocales, auditTranslations };
1643
+ export { verifyLocales, auditTranslations, localesForPairFlag, redoCommand, contentRedoCommand, shellWord, pluralGapsInFile, contentItemsFor };