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