champollion 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
@@ -0,0 +1,689 @@
1
+ /**
2
+ * Integrity linter — catches translation defects that silently break UI.
3
+ *
4
+ * THREE CLASSES OF DEFECT:
5
+ *
6
+ * 1. FORMAT LEAKS: ICU placeholders ({name}, {count, plural, ...}) that
7
+ * the translator mangled or dropped. Detected by comparing placeholder
8
+ * tokens between source and target.
9
+ *
10
+ * 2. ENCODING ISSUES: BOM markers, non-UTF-8 sequences, invisible
11
+ * directional marks (LRM/RLM/ZWJ/ZWNJ) where they shouldn't be.
12
+ *
13
+ * 3. STRUCTURAL PARITY: Target file has extra keys the source doesn't,
14
+ * or values that are clearly just the source value copy-pasted (same
15
+ * string in a different locale file = untranslated).
16
+ *
17
+ * Zero external dependencies. All string-based analysis.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { isICUString, parseICU, getRequiredPluralCategories } from './icu.js';
23
+ import { checkContentPreservation } from './validate.js';
24
+
25
+ // -----------------------------------------------------------------
26
+ // Placeholder extraction
27
+ // -----------------------------------------------------------------
28
+
29
+ /**
30
+ * Extract ICU-style placeholders from a string.
31
+ *
32
+ * Handles:
33
+ * - Simple: {name}, {count}
34
+ * - Nested ICU: {count, plural, one {# item} other {# items}}
35
+ * - React-intl: <bold>text</bold>
36
+ *
37
+ * @param {string} text - Translation string
38
+ * @returns {string[]} Sorted array of placeholder tokens
39
+ */
40
+ function extractPlaceholders(text) {
41
+ if (typeof text !== 'string') return [];
42
+
43
+ const placeholders = new Set();
44
+
45
+ // Simple ICU placeholders: {name}, {count}
46
+ // Match top-level braces only (not nested plurals)
47
+ const simplePattern = /\{(\w+)(?:[,}])/g;
48
+ let match;
49
+ while ((match = simplePattern.exec(text)) !== null) {
50
+ placeholders.add(match[1]);
51
+ }
52
+
53
+ // React-intl XML tags: <bold>, </bold>, <link>, </link>
54
+ const xmlPattern = /<\/?(\w+)>/g;
55
+ while ((match = xmlPattern.exec(text)) !== null) {
56
+ placeholders.add(`<${match[1]}>`);
57
+ }
58
+
59
+ return [...placeholders].sort();
60
+ }
61
+
62
+ /**
63
+ * Compare placeholders between source and target strings.
64
+ *
65
+ * @param {string} sourceValue - Source locale value
66
+ * @param {string} targetValue - Target locale value
67
+ * @returns {{ missing: string[], extra: string[] }} Placeholder differences
68
+ */
69
+ function comparePlaceholders(sourceValue, targetValue) {
70
+ const sourcePH = extractPlaceholders(sourceValue);
71
+ const targetPH = extractPlaceholders(targetValue);
72
+
73
+ const missing = sourcePH.filter(p => !targetPH.includes(p));
74
+ const extra = targetPH.filter(p => !sourcePH.includes(p));
75
+
76
+ return { missing, extra };
77
+ }
78
+
79
+ // -----------------------------------------------------------------
80
+ // Encoding checks
81
+ // -----------------------------------------------------------------
82
+
83
+ /**
84
+ * Invisible Unicode characters that cause silent UI bugs.
85
+ *
86
+ * Each entry has:
87
+ * - name: Human-readable name
88
+ * - regex: Detection pattern
89
+ * - severity: 'error' (likely bug) or 'warning' (suspicious but maybe intentional)
90
+ */
91
+ const INVISIBLE_CHARS = [
92
+ { name: 'BOM (Byte Order Mark)', regex: /\uFEFF/, severity: 'error' },
93
+ { name: 'Zero-Width Space (ZWSP)', regex: /\u200B/, severity: 'warning' },
94
+ { name: 'Zero-Width Non-Joiner (ZWNJ)', regex: /\u200C/, severity: 'warning' },
95
+ { name: 'Zero-Width Joiner (ZWJ)', regex: /\u200D/, severity: 'warning' },
96
+ { name: 'Left-to-Right Mark (LRM)', regex: /\u200E/, severity: 'warning' },
97
+ { name: 'Right-to-Left Mark (RLM)', regex: /\u200F/, severity: 'warning' },
98
+ { name: 'Left-to-Right Override', regex: /\u202D/, severity: 'error' },
99
+ { name: 'Right-to-Left Override', regex: /\u202E/, severity: 'error' },
100
+ { name: 'Pop Directional Formatting', regex: /\u202C/, severity: 'warning' },
101
+ { name: 'Object Replacement Character', regex: /\uFFFC/, severity: 'error' },
102
+ { name: 'Replacement Character (encoding error)', regex: /\uFFFD/, severity: 'error' },
103
+ ];
104
+
105
+ /**
106
+ * Characters to escape when PRINTING a value in a report.
107
+ *
108
+ * Broader than INVISIBLE_CHARS above, which decides what counts as a
109
+ * DEFECT; this set only decides what is unreadable on a terminal. A report
110
+ * that prints a raw ZWSP shows two identical-looking lines and reads as a
111
+ * broken tool.
112
+ */
113
+ const INVISIBLE_FOR_DISPLAY = new RegExp(
114
+ // Invisible format characters, plus the BMP Private Use Area — PUA renders
115
+ // as blank or tofu in a terminal, so a report that prints it raw is
116
+ // unreadable in exactly the cases (pIqaD/Tengwar/Kryptonian damage) where
117
+ // reading it matters most.
118
+ '[\\u00AD\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u206A-\\u206F\\uE000-\\uF8FF\\uFFFC\\uFFFD]',
119
+ 'g',
120
+ );
121
+
122
+ /**
123
+ * Check a string value for invisible/problematic Unicode characters.
124
+ *
125
+ * @param {string} value - String to check
126
+ * @returns {{ name: string, severity: string }[]} Array of detected issues
127
+ */
128
+ function checkEncoding(value) {
129
+ if (typeof value !== 'string') return [];
130
+
131
+ const issues = [];
132
+ for (const check of INVISIBLE_CHARS) {
133
+ if (check.regex.test(value)) {
134
+ issues.push({ name: check.name, severity: check.severity });
135
+ }
136
+ }
137
+ return issues;
138
+ }
139
+
140
+ /**
141
+ * Check if a file has a UTF-8 BOM at the start.
142
+ *
143
+ * @param {string} filePath - Path to the file
144
+ * @returns {boolean} True if BOM is present
145
+ */
146
+ function hasBOM(filePath) {
147
+ const buffer = fs.readFileSync(filePath);
148
+ return buffer.length >= 3 &&
149
+ buffer[0] === 0xEF &&
150
+ buffer[1] === 0xBB &&
151
+ buffer[2] === 0xBF;
152
+ }
153
+
154
+ // -----------------------------------------------------------------
155
+ // Cross-locale parity
156
+ // -----------------------------------------------------------------
157
+
158
+ /**
159
+ * Check for untranslated values (target value === source value).
160
+ *
161
+ * Returns keys where the target is an exact copy of the source,
162
+ * excluding keys that look like they SHOULD be the same (brand names,
163
+ * URLs, format strings, numbers).
164
+ *
165
+ * @param {object} sourceFlat - Flattened source locale
166
+ * @param {object} targetFlat - Flattened target locale
167
+ * @param {string} targetLang - Target language code (for RTL direction checks)
168
+ * @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] -
169
+ * Compiled no-translate matcher. Keys it claims are SUPPOSED to be
170
+ * identical — flagging them as untranslated would turn a correct sync into
171
+ * a failing `champollion integrity` and back the user into the same corner
172
+ * the quality gate did.
173
+ * @param {((key: string, sourceValue: string) => boolean)|null} [isConfirmedEcho] -
174
+ * The SAME predicate the sync diff uses (lib/diff.js): true when the
175
+ * Translation Memory records that the pipeline itself produced this exact
176
+ * source-equal value and the gate approved it. Without it, integrity
177
+ * flagged thousands of settled echoes on a project sync reports as fully
178
+ * synced — two tools reading the same file and disagreeing about it. What
179
+ * remains flagged here is exactly what sync would requeue.
180
+ * @returns {string[]} Keys with identical source/target values
181
+ */
182
+ function findUntranslatedCopies(sourceFlat, targetFlat, targetLang, noTranslate = null, isConfirmedEcho = null) {
183
+ const copies = [];
184
+
185
+ for (const [key, sourceVal] of Object.entries(sourceFlat)) {
186
+ const targetVal = targetFlat[key];
187
+ if (targetVal === undefined) continue;
188
+ if (typeof sourceVal !== 'string' || typeof targetVal !== 'string') continue;
189
+
190
+ // Skip if values are different — it's translated
191
+ if (sourceVal !== targetVal) continue;
192
+
193
+ // Skip values that are EXPECTED to be the same across locales
194
+ if (isLocaleInvariant(sourceVal)) continue;
195
+
196
+ // Skip keys the project declared no-translate — identical is correct.
197
+ if (noTranslate && noTranslate.matches(key, sourceVal)) continue;
198
+
199
+ // Skip echoes the TM confirms as pipeline-produced and gate-approved.
200
+ if (isConfirmedEcho && isConfirmedEcho(key, sourceVal)) continue;
201
+
202
+ copies.push(key);
203
+ }
204
+
205
+ return copies;
206
+ }
207
+
208
+ /**
209
+ * Find no-translate keys whose target has DRIFTED from the source.
210
+ *
211
+ * The inverse of findUntranslatedCopies, and the check that would have caught
212
+ * the production incident this feature exists for: 48 URL values across 13
213
+ * locales that a model bent just enough to clear the source-echo gate —
214
+ * fabricated fragments, stray trailing characters, an invisible U+200E in
215
+ * Arabic and U+200B in Hindi that broke the links outright.
216
+ *
217
+ * For a declared no-translate key there is exactly one correct target value,
218
+ * so any difference is a defect. `champollion sync` repairs it by copying the
219
+ * source verbatim.
220
+ *
221
+ * @param {object} sourceFlat - Flattened source locale
222
+ * @param {object} targetFlat - Flattened target locale
223
+ * @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] - Compiled matcher
224
+ * @returns {Array<{ key: string, expected: string, actual: string, reason: string }>}
225
+ */
226
+ function findNoTranslateDrift(sourceFlat, targetFlat, noTranslate = null) {
227
+ if (!noTranslate || !noTranslate.active) return [];
228
+
229
+ const drift = [];
230
+ for (const [key, sourceVal] of Object.entries(sourceFlat)) {
231
+ if (typeof sourceVal !== 'string') continue;
232
+ const targetVal = targetFlat[key];
233
+ // A key absent from the target is a missing-key finding, reported by the
234
+ // callers' own parity checks — not drift.
235
+ if (targetVal === undefined) continue;
236
+ if (targetVal === sourceVal) continue;
237
+ if (!noTranslate.matches(key, sourceVal)) continue;
238
+
239
+ drift.push({
240
+ key,
241
+ expected: sourceVal,
242
+ actual: typeof targetVal === 'string' ? targetVal : String(targetVal),
243
+ reason: noTranslate.reason(key, sourceVal),
244
+ });
245
+ }
246
+ return drift;
247
+ }
248
+
249
+ /**
250
+ * Check if a value is expected to be the same across all locales.
251
+ *
252
+ * Brand names, URLs, format patterns, single-word identifiers,
253
+ * numeric values, etc.
254
+ *
255
+ * @param {string} value - The value to check
256
+ * @returns {boolean} True if the value should NOT be flagged as untranslated
257
+ */
258
+ function isLocaleInvariant(value) {
259
+ const v = value.trim();
260
+ if (v.length === 0) return true;
261
+
262
+ // URLs
263
+ if (/^https?:\/\//.test(v)) return true;
264
+ // Email addresses
265
+ if (/^\S+@\S+\.\S+$/.test(v)) return true;
266
+ // Pure numbers (with optional formatting)
267
+ if (/^[\d.,\-+%$€£¥]+$/.test(v)) return true;
268
+ // Single word under 4 characters (likely a code or abbreviation)
269
+ if (/^\w{1,3}$/.test(v)) return true;
270
+ // Pure placeholder string: {name}
271
+ if (/^\{[\w,.\s]+\}$/.test(v)) return true;
272
+ // Format patterns: YYYY-MM-DD, HH:mm:ss
273
+ if (/^[YMDHhmsSzZ\-/:.\s]+$/.test(v)) return true;
274
+ // Known brand names that shouldn't be translated
275
+ // (Keeping this minimal — better to flag false positives than miss real copies)
276
+ if (/^(GitHub|Google|Facebook|Twitter|LinkedIn|YouTube|Instagram|WhatsApp|Stripe|PayPal|Apple|Microsoft)$/.test(v)) return true;
277
+
278
+ return false;
279
+ }
280
+
281
+ /**
282
+ * Find values ON DISK that are their source hollowed of its letters.
283
+ *
284
+ * The translation-time gate (validate.js check 5) stops NEW hollowing, but
285
+ * values written by an older pipeline never re-enter the gate: their manifest
286
+ * hashes match the current source, so sync considers them settled forever.
287
+ * Upgraders discovered this the hard way — `" · · êhiêi"`-class damage
288
+ * survived the fix that would have refused to write it.
289
+ *
290
+ * Same two-signal rule as the gate (see checkContentPreservation for why a
291
+ * bare density threshold is unshippable — legitimate CJK sits at the same
292
+ * retention as the bug): low letter retention AND the value is the source
293
+ * with characters deleted. Values identical to their source are the echo
294
+ * checks' business; no-translate keys are the drift check's.
295
+ *
296
+ * The fix is re-translation, which no offline tool can do — so the finding
297
+ * names the sync invocation that does.
298
+ *
299
+ * @param {object} sourceFlat - Flattened source locale
300
+ * @param {object} targetFlat - Flattened target locale
301
+ * @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] - Compiled matcher
302
+ * @returns {Array<{ key: string, actual: string, reason: string }>}
303
+ */
304
+ function findHollowedValues(sourceFlat, targetFlat, noTranslate = null) {
305
+ const findings = [];
306
+ for (const [key, sourceVal] of Object.entries(sourceFlat)) {
307
+ const targetVal = targetFlat[key];
308
+ if (typeof sourceVal !== 'string' || typeof targetVal !== 'string') continue;
309
+ if (sourceVal === targetVal) continue;
310
+ if (noTranslate && noTranslate.matches(key, sourceVal)) continue;
311
+ const hollowed = checkContentPreservation(sourceVal, targetVal);
312
+ if (hollowed) {
313
+ findings.push({ key, actual: targetVal, reason: hollowed.reason });
314
+ }
315
+ }
316
+ return findings;
317
+ }
318
+
319
+ /**
320
+ * Find Private Use Area codepoints where script conversion is switched OFF.
321
+ *
322
+ * PUA renders as nothing without a purpose-built font, and Champollion only
323
+ * ever writes it deliberately — through an opted-in script converter (pIqaD,
324
+ * Tengwar, Kryptonian). When the locale's script resolution says conversion
325
+ * is off, any PUA in the file is damage: either output from the pre-0.3.0
326
+ * unconditional converter, or corruption from elsewhere. Both mean blank
327
+ * strings on the page. `champollion repair-script` restores the romanization.
328
+ *
329
+ * Only runs when the caller passes a resolution for a PUA-capable converter
330
+ * locale — for every other locale PUA is out of scope here (checkEncoding
331
+ * covers the general invisible-character classes).
332
+ *
333
+ * @param {object} targetFlat - Flattened target locale
334
+ * @param {{converts: boolean, locale: string}|null} scriptExpectation -
335
+ * Caller-computed: does this locale's resolution apply a PUA converter?
336
+ * @returns {Array<{ key: string, actual: string, reason: string }>}
337
+ */
338
+ function findUnexpectedPua(targetFlat, scriptExpectation = null) {
339
+ if (!scriptExpectation || scriptExpectation.converts) return [];
340
+
341
+ const PUA = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u;
342
+ const findings = [];
343
+ for (const [key, value] of Object.entries(targetFlat)) {
344
+ if (typeof value !== 'string' || !PUA.test(value)) continue;
345
+ findings.push({
346
+ key,
347
+ actual: value,
348
+ reason:
349
+ `Private Use Area codepoints, but script conversion is not enabled for ${scriptExpectation.locale} `
350
+ + '— this text renders blank without a PUA font. Run `champollion repair-script` to restore the romanization.',
351
+ });
352
+ }
353
+ return findings;
354
+ }
355
+
356
+ /**
357
+ * Find keys present in target but NOT in source (orphaned keys).
358
+ *
359
+ * @param {object} sourceFlat - Flattened source locale
360
+ * @param {object} targetFlat - Flattened target locale
361
+ * @returns {string[]} Keys only in target
362
+ */
363
+ function findOrphanedKeys(sourceFlat, targetFlat) {
364
+ return Object.keys(targetFlat).filter(k => !(k in sourceFlat));
365
+ }
366
+
367
+ // -----------------------------------------------------------------
368
+ // ICU plural category validation
369
+ // -----------------------------------------------------------------
370
+
371
+ /**
372
+ * Check whether ICU plural strings have the correct categories for the target locale.
373
+ *
374
+ * WHY: Arabic requires {zero, one, two, few, many, other} but if the LLM only
375
+ * produces {one, other}, the runtime silently falls back to 'other' for all
376
+ * other quantities — producing wrong text for 0, 2, 3-10, 11-99, etc.
377
+ *
378
+ * This function:
379
+ * 1. Scans source values for ICU plural patterns
380
+ * 2. Parses the corresponding target values
381
+ * 3. Compares their plural categories against what CLDR requires
382
+ * 4. Reports missing categories as warnings
383
+ *
384
+ * Only triggers on keys where the SOURCE value is an ICU plural string.
385
+ * If the source doesn't use ICU plurals, we don't check (the target shouldn't
386
+ * be expected to add plural forms the source doesn't have).
387
+ *
388
+ * @param {object} sourceFlat - Flattened source locale
389
+ * @param {object} targetFlat - Flattened target locale
390
+ * @param {string} targetLang - Target locale code
391
+ * @returns {Array<{ key: string, missing: string[], extra: string[] }>}
392
+ */
393
+ function checkPluralCategories(sourceFlat, targetFlat, targetLang) {
394
+ const issues = [];
395
+ const requiredCategories = getRequiredPluralCategories(targetLang);
396
+
397
+ for (const [key, sourceVal] of Object.entries(sourceFlat)) {
398
+ if (typeof sourceVal !== 'string') continue;
399
+
400
+ // Only check if the source value contains an ICU plural pattern
401
+ if (!isICUString(sourceVal)) continue;
402
+
403
+ const sourceAST = parseICU(sourceVal);
404
+ const hasPluralNode = sourceAST.some(n => n.type === 'plural');
405
+ if (!hasPluralNode) continue;
406
+
407
+ // Now check the target's plural categories
408
+ const targetVal = targetFlat[key];
409
+ if (typeof targetVal !== 'string' || !isICUString(targetVal)) continue;
410
+
411
+ const targetAST = parseICU(targetVal);
412
+ const targetPluralNode = targetAST.find(n => n.type === 'plural');
413
+ if (!targetPluralNode || !targetPluralNode.options) continue;
414
+
415
+ const targetCategories = Object.keys(targetPluralNode.options);
416
+
417
+ // Find missing required categories (ignoring exact-match like =0, =1)
418
+ const missing = requiredCategories.filter(cat =>
419
+ !targetCategories.includes(cat) &&
420
+ !cat.startsWith('=')
421
+ );
422
+
423
+ // Find unexpected categories (not in CLDR for this locale)
424
+ // Exclude exact-match categories (=0, =1) which are always valid
425
+ const extra = targetCategories.filter(cat =>
426
+ !requiredCategories.includes(cat) &&
427
+ !cat.startsWith('=')
428
+ );
429
+
430
+ if (missing.length > 0 || extra.length > 0) {
431
+ issues.push({ key, missing, extra });
432
+ }
433
+ }
434
+
435
+ return issues;
436
+ }
437
+
438
+ // -----------------------------------------------------------------
439
+ // Full integrity audit
440
+ // -----------------------------------------------------------------
441
+
442
+ /**
443
+ * Run a full integrity audit on a locale pair.
444
+ *
445
+ * @param {object} sourceFlat - Flattened source locale
446
+ * @param {object} targetFlat - Flattened target locale
447
+ * @param {string} targetLang - Target language code
448
+ * @param {object} [options]
449
+ * @param {string} [options.sourceFile] - Path to the source file (for BOM reporting)
450
+ * @param {string} [options.targetFile] - Path to the target file (for BOM reporting)
451
+ * @param {import('./no-translate.js').NoTranslateMatcher} [options.noTranslate] -
452
+ * Compiled no-translate matcher. Its keys are exempted from the
453
+ * untranslated-copies check and checked for drift instead.
454
+ * @returns {{ placeholderIssues: object[], encodingIssues: object[], copies: string[], orphans: string[], noTranslateDrift: object[], bomFiles: string[] }}
455
+ */
456
+ function auditLocalePair(sourceFlat, targetFlat, targetLang, options = {}) {
457
+ const placeholderIssues = [];
458
+ const encodingIssues = [];
459
+
460
+ for (const [key, sourceVal] of Object.entries(sourceFlat)) {
461
+ const targetVal = targetFlat[key];
462
+ if (targetVal === undefined) continue;
463
+
464
+ // Check placeholder preservation
465
+ if (typeof sourceVal === 'string' && typeof targetVal === 'string') {
466
+ const { missing, extra } = comparePlaceholders(sourceVal, targetVal);
467
+ if (missing.length > 0 || extra.length > 0) {
468
+ placeholderIssues.push({ key, missing, extra, sourceVal, targetVal });
469
+ }
470
+ }
471
+
472
+ // Check encoding in target values
473
+ if (typeof targetVal === 'string') {
474
+ const issues = checkEncoding(targetVal);
475
+ if (issues.length > 0) {
476
+ encodingIssues.push({ key, value: targetVal, issues });
477
+ }
478
+ }
479
+ }
480
+
481
+ const noTranslate = options.noTranslate || null;
482
+ const copies = findUntranslatedCopies(
483
+ sourceFlat, targetFlat, targetLang, noTranslate, options.isConfirmedEcho || null,
484
+ );
485
+ const noTranslateDrift = findNoTranslateDrift(sourceFlat, targetFlat, noTranslate);
486
+ const unexpectedPua = findUnexpectedPua(targetFlat, options.scriptExpectation || null);
487
+ const hollowedValues = findHollowedValues(sourceFlat, targetFlat, noTranslate);
488
+ const orphans = findOrphanedKeys(sourceFlat, targetFlat);
489
+ const pluralIssues = checkPluralCategories(sourceFlat, targetFlat, targetLang);
490
+
491
+ // File-level BOM check. When the caller passes file paths, REPORT a UTF-8
492
+ // BOM as an issue rather than letting it silently corrupt the first key /
493
+ // crash a downstream JSON.parse. Guarded so a vanished file can't throw.
494
+ const bomFiles = [];
495
+ for (const file of [options.sourceFile, options.targetFile]) {
496
+ try {
497
+ if (file && fs.existsSync(file) && hasBOM(file)) bomFiles.push(file);
498
+ } catch { /* unreadable file — not an integrity finding */ }
499
+ }
500
+
501
+ return { placeholderIssues, encodingIssues, copies, orphans, noTranslateDrift, unexpectedPua, hollowedValues, pluralIssues, bomFiles };
502
+ }
503
+
504
+ /**
505
+ * Render a value for a terminal report with invisible characters made visible.
506
+ *
507
+ * Printing a raw ZWSP or LRM produces two lines that look byte-identical and
508
+ * a reader who concludes the tool is broken. The whole class of corruption
509
+ * this reports is invisible by nature, so it has to be escaped to be read.
510
+ *
511
+ * @param {string} value - Value to display
512
+ * @returns {string} JSON-quoted value with invisible characters as \uXXXX
513
+ */
514
+ function visualize(value) {
515
+ const escaped = String(value).replace(
516
+ INVISIBLE_FOR_DISPLAY,
517
+ ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
518
+ );
519
+ return JSON.stringify(escaped);
520
+ }
521
+
522
+ /**
523
+ * Format an integrity audit result as a console report.
524
+ *
525
+ * @param {string} targetLang - Target language code
526
+ * @param {object} audit - Result from auditLocalePair
527
+ * @returns {string} Formatted report
528
+ */
529
+ function formatIntegrityReport(targetLang, audit) {
530
+ const lines = [];
531
+ const { placeholderIssues, encodingIssues, copies, orphans } = audit;
532
+
533
+ const bomFiles = audit.bomFiles || [];
534
+ const drift = audit.noTranslateDrift || [];
535
+ const unexpectedPua = audit.unexpectedPua || [];
536
+ const hollowedValues = audit.hollowedValues || [];
537
+ const totalIssues = placeholderIssues.length + encodingIssues.length +
538
+ copies.length + orphans.length + (audit.pluralIssues?.length || 0) +
539
+ bomFiles.length + drift.length + unexpectedPua.length + hollowedValues.length;
540
+
541
+ lines.push(`\n Integrity Audit: ${targetLang}`);
542
+ lines.push(` ${'─'.repeat(40)}`);
543
+
544
+ if (totalIssues === 0) {
545
+ lines.push(' [OK] All checks passed — no issues found');
546
+ lines.push('');
547
+ return lines.join('\n');
548
+ }
549
+
550
+ // Placeholder issues
551
+ if (placeholderIssues.length > 0) {
552
+ lines.push(`\n PLACEHOLDER ISSUES (${placeholderIssues.length})`);
553
+ for (const issue of placeholderIssues.slice(0, 10)) {
554
+ lines.push(` ├── ${issue.key}`);
555
+ if (issue.missing.length > 0) {
556
+ lines.push(` │ Missing: ${issue.missing.join(', ')}`);
557
+ }
558
+ if (issue.extra.length > 0) {
559
+ lines.push(` │ Extra: ${issue.extra.join(', ')}`);
560
+ }
561
+ }
562
+ if (placeholderIssues.length > 10) {
563
+ lines.push(` └── ... and ${placeholderIssues.length - 10} more`);
564
+ }
565
+ }
566
+
567
+ // Encoding issues
568
+ if (encodingIssues.length > 0) {
569
+ lines.push(`\n [WARN] ENCODING ISSUES (${encodingIssues.length})`);
570
+ for (const issue of encodingIssues.slice(0, 10)) {
571
+ const names = issue.issues.map(i => i.name).join(', ');
572
+ lines.push(` ├── ${issue.key}: ${names}`);
573
+ }
574
+ if (encodingIssues.length > 10) {
575
+ lines.push(` └── ... and ${encodingIssues.length - 10} more`);
576
+ }
577
+ }
578
+
579
+ // Untranslated copies
580
+ if (copies.length > 0) {
581
+ lines.push(`\n [WARN] UNTRANSLATED COPIES (${copies.length})`);
582
+ lines.push(' └── These keys have identical source/target values:');
583
+ for (const key of copies.slice(0, 10)) {
584
+ lines.push(` ${key}`);
585
+ }
586
+ if (copies.length > 10) {
587
+ lines.push(` ... and ${copies.length - 10} more`);
588
+ }
589
+ }
590
+
591
+ // No-translate drift — a declared-verbatim key that isn't verbatim
592
+ if (drift.length > 0) {
593
+ lines.push(`\n NO-TRANSLATE DRIFT (${drift.length})`);
594
+ lines.push(' └── These keys must match the source byte-for-byte. Run `champollion sync` to repair:');
595
+ for (const d of drift.slice(0, 10)) {
596
+ lines.push(` ${d.key} [${d.reason}]`);
597
+ lines.push(` expected: ${visualize(d.expected)}`);
598
+ lines.push(` actual: ${visualize(d.actual)}`);
599
+ }
600
+ if (drift.length > 10) {
601
+ lines.push(` ... and ${drift.length - 10} more`);
602
+ }
603
+ }
604
+
605
+ // Unexpected PUA — unrenderable script conversion where none was asked for
606
+ if (unexpectedPua.length > 0) {
607
+ lines.push(`\n UNEXPECTED PUA (${unexpectedPua.length})`);
608
+ lines.push(' └── Private Use Area codepoints with script conversion OFF — renders blank');
609
+ lines.push(' without a PUA font. Run `champollion repair-script` to restore romanization:');
610
+ for (const p of unexpectedPua.slice(0, 10)) {
611
+ lines.push(` ${p.key}: ${visualize(p.actual)}`);
612
+ }
613
+ if (unexpectedPua.length > 10) {
614
+ lines.push(` ... and ${unexpectedPua.length - 10} more`);
615
+ }
616
+ }
617
+
618
+ // Hollowed values — old damage the translation-time gate never saw
619
+ if (hollowedValues.length > 0) {
620
+ lines.push(`\n HOLLOWED VALUES (${hollowedValues.length})`)
621
+ lines.push(' └── The source with its letters deleted — damage from an older pipeline.');
622
+ lines.push(' Re-translate: `champollion sync --force-keys <key>` (or `--pair <pair> --force`):');
623
+ for (const h of hollowedValues.slice(0, 10)) {
624
+ lines.push(` ${h.key}: ${visualize(h.actual)}`);
625
+ }
626
+ if (hollowedValues.length > 10) {
627
+ lines.push(` ... and ${hollowedValues.length - 10} more`);
628
+ }
629
+ }
630
+
631
+ // Orphaned keys
632
+ if (orphans.length > 0) {
633
+ lines.push(`\n [WARN] ORPHANED KEYS (${orphans.length})`);
634
+ lines.push(' └── Keys in target not present in source:');
635
+ for (const key of orphans.slice(0, 10)) {
636
+ lines.push(` ${key}`);
637
+ }
638
+ if (orphans.length > 10) {
639
+ lines.push(` ... and ${orphans.length - 10} more`);
640
+ }
641
+ }
642
+
643
+ // BOM (file encoding) issues
644
+ if (bomFiles.length > 0) {
645
+ lines.push(`\n [WARN] BYTE ORDER MARK (${bomFiles.length})`);
646
+ lines.push(' └── These files start with a UTF-8 BOM (strip it):');
647
+ for (const file of bomFiles) {
648
+ lines.push(` ${file}`);
649
+ }
650
+ }
651
+
652
+ // Plural category issues
653
+ if (audit.pluralIssues && audit.pluralIssues.length > 0) {
654
+ lines.push(`\n [WARN] PLURAL CATEGORY ISSUES (${audit.pluralIssues.length})`);
655
+ for (const issue of audit.pluralIssues.slice(0, 10)) {
656
+ lines.push(` ├── ${issue.key}`);
657
+ if (issue.missing.length > 0) {
658
+ lines.push(` │ Missing categories: ${issue.missing.join(', ')}`);
659
+ }
660
+ if (issue.extra.length > 0) {
661
+ lines.push(` │ Unexpected categories: ${issue.extra.join(', ')}`);
662
+ }
663
+ }
664
+ if (audit.pluralIssues.length > 10) {
665
+ lines.push(` └── ... and ${audit.pluralIssues.length - 10} more`);
666
+ }
667
+ }
668
+
669
+ lines.push('');
670
+ return lines.join('\n');
671
+ }
672
+
673
+ export {
674
+ extractPlaceholders,
675
+ comparePlaceholders,
676
+ checkEncoding,
677
+ hasBOM,
678
+ findUntranslatedCopies,
679
+ findNoTranslateDrift,
680
+ findUnexpectedPua,
681
+ findHollowedValues,
682
+ isLocaleInvariant,
683
+ findOrphanedKeys,
684
+ checkPluralCategories,
685
+ auditLocalePair,
686
+ formatIntegrityReport,
687
+ visualize,
688
+ INVISIBLE_CHARS,
689
+ };