champollion 0.5.1 → 0.5.2

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.
@@ -51,6 +51,9 @@
51
51
  * paragraph written by hand — drops its record.
52
52
  */
53
53
 
54
+ import fs from 'node:fs';
55
+ import path from 'node:path';
56
+ import { output } from './output.js';
54
57
  import { tmMethodKey, lookupTM } from './tm.js';
55
58
  import { GATE_VERSION } from './validate.js';
56
59
  import { holdState, shortSourceHash } from './locale-state.js';
@@ -282,3 +285,54 @@ export function previewHeld({ tm, code, pairConfig, fields, body, segMode, holds
282
285
  }
283
286
  return { names, pageHeld: false };
284
287
  }
288
+
289
+ // ── What a run left in the source language ───────────────────────────
290
+
291
+ /** Where refused answers are logged (the per-machine cache folder, never committed). */
292
+ export const REFUSAL_LOG = path.join('.champollion', 'refused.jsonl');
293
+
294
+ /**
295
+ * Append one refused answer to `.champollion/refused.jsonl`: the source, what
296
+ * the model answered, and why the gate refused it. WHY: a refused answer was
297
+ * thrown away, so "why did it refuse that twice?" could only be answered by
298
+ * paying the model again (2026-10-06 — and the answer then showed the gate,
299
+ * not the model, was wrong). Never fails the run.
300
+ *
301
+ * @param {string} cwd
302
+ * @param {object} rec - { pair, method, file, locale, paragraph, reason, source, answer }
303
+ */
304
+ export function logRefusal(cwd, rec) {
305
+ try {
306
+ const file = path.join(cwd, REFUSAL_LOG);
307
+ fs.mkdirSync(path.dirname(file), { recursive: true });
308
+ fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...rec })}\n`, 'utf-8');
309
+ } catch { /* a diagnostic: never fails the run */ }
310
+ }
311
+
312
+ /**
313
+ * The run's closing report when anything was left in the source language —
314
+ * last, as an error, never under an [OK] (2026-10-06: a 1,082-page sync ended
315
+ * on "[OK] Created 1082 content file(s)" with seven untranslated blocks named
316
+ * only in warnings mid-stream).
317
+ *
318
+ * @param {Array<{ file: string, locale: string, pair: string, where: string, reason: string }>} items
319
+ */
320
+ export function reportLeftInSource(items) {
321
+ if (!items || items.length === 0) return;
322
+ const pages = new Map();
323
+ for (const it of items) {
324
+ const k = `${it.file}\u0000${it.pair}`;
325
+ if (!pages.has(k)) pages.set(k, { ...it, wheres: [] });
326
+ pages.get(k).wheres.push(`${it.where}: ${it.reason}`);
327
+ }
328
+ output.raw('');
329
+ output.error(`Not finished: ${items.length} part(s) of ${pages.size} page translation(s) are still in the source language — `
330
+ + 'the quality gate refused the model\'s answer, also when asked again with the reason:');
331
+ for (const p of pages.values()) {
332
+ output.raw(` ${p.file} → ${p.locale}: ${p.wheres.slice(0, 3).join('; ')}${p.wheres.length > 3 ? `; +${p.wheres.length - 3} more` : ''}`);
333
+ }
334
+ const first = [...pages.values()][0];
335
+ output.raw(` The rest of each page is written. What the model answered is in ${REFUSAL_LOG}. `
336
+ + `Ask again: \`${contentRedoCommand(first.file, { pair: first.pair })}\`${pages.size > 1 ? ' (one per page)' : ''}, `
337
+ + 'or add a "fallback" method to the pair.');
338
+ }
@@ -89,7 +89,7 @@ import {
89
89
  } from './fallback.js';
90
90
  import {
91
91
  readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
92
- describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
92
+ describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock, logRefusal, reportLeftInSource,
93
93
  } from './content-refusals.js';
94
94
  import { contentRedoCommand } from './verify.js';
95
95
 
@@ -115,6 +115,8 @@ import { contentRedoCommand } from './verify.js';
115
115
  * (null = no cap).
116
116
  */
117
117
  async function runContentSync(options) {
118
+ // Parts this run left in the source language — reported at the end, as an error.
119
+ const leftInSource = [];
118
120
  const {
119
121
  contentDir,
120
122
  sourceLocale,
@@ -700,6 +702,11 @@ async function runContentSync(options) {
700
702
  // field keeps its source text; the page is still written.
701
703
  fieldsLeftInSource = true;
702
704
  for (const h of fm.hollowed) {
705
+ leftInSource.push({ file: relPath, locale: code, pair: pairKey, where: `front matter "${h.field}"`, reason: h.reason });
706
+ logRefusal(cwd, {
707
+ pair: pairKey, method: tmMethodKey(pairConfig), file: relPath, locale: code,
708
+ field: h.field, reason: h.reason, source: fieldsToTranslate[h.field], answer: h.value ?? null,
709
+ });
703
710
  const advice = h.sharedOutput
704
711
  ? ` ${pairConfig.method} answers it only with a sentence it gave for other strings — `
705
712
  + `${pairConfig.fallback ? 'and the fallback did not translate it either' : 'add a "fallback" method to the pair'}, or write it in ${targetRel} by hand (kept).`
@@ -990,6 +997,13 @@ async function runContentSync(options) {
990
997
  // Blocks the quality gate refused — the key-value gate's checks
991
998
  // (length inflation, echo, truncation, script), per block.
992
999
  const refusedByGate = batchOutcome.refused || [];
1000
+ for (const r of refusedByGate) {
1001
+ leftInSource.push({ file: relPath, locale: code, pair: pairKey, where: `paragraph ${r.block}`, reason: r.reason });
1002
+ logRefusal(cwd, {
1003
+ pair: pairKey, method: tmMethodKey(pairConfig), file: relPath, locale: code,
1004
+ paragraph: r.block, reason: r.reason, source: missed[r.i].source, answer: r.answer,
1005
+ });
1006
+ }
993
1007
  if (refusedByGate.length > 0) {
994
1008
  output.warn(
995
1009
  `Content sync body for ${code}: ${refusedByGate.length} block(s) of ${relPath} refused by the quality gate, also when asked again with the reason — `
@@ -1210,9 +1224,11 @@ async function runContentSync(options) {
1210
1224
  const heldBackNote = heldBackSeen > 0 ? `; ${heldBackSeen} block(s)/field(s) refused before would be held back (not sent)` : '';
1211
1225
  output.info(`Would have created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}${heldBackNote}.`);
1212
1226
  } else {
1213
- output.ok(`Created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}.`);
1227
+ const unfinished = leftInSource.length > 0 ? ` — ${leftInSource.length} part(s) left in the source language (below)` : '';
1228
+ (unfinished ? output.warn : output.ok).call(output, `Created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}${unfinished}.`);
1214
1229
  }
1215
1230
  }
1231
+ reportLeftInSource(leftInSource);
1216
1232
  if (heldBack > 0) {
1217
1233
  // Not sent, not billed — but not translated either (exit 2, as for a
1218
1234
  // held-back key). Each page was named above with its own repair.
@@ -74,7 +74,7 @@ import {
74
74
  import { walkFiles } from './locale-layout.js';
75
75
  import {
76
76
  readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
77
- describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
77
+ describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock, logRefusal, reportLeftInSource,
78
78
  } from './content-refusals.js';
79
79
  import { applyNamedKeyRule, reportUnmatchedKeys, unmatchedKeysSummary, reportNamedFromCache } from './named-keys.js';
80
80
 
@@ -1209,6 +1209,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1209
1209
  let contentHeldBack = 0;
1210
1210
  const contentHeldBackItems = [];
1211
1211
  let contentRefused = 0;
1212
+ // Parts this run left in the source language — said last, as an error.
1213
+ const leftInSource = [];
1212
1214
 
1213
1215
  if (contentSources.length === 0) {
1214
1216
  output.info('No docs/ or blog/ directories found — skipping content sync.');
@@ -1443,6 +1445,11 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1443
1445
  // field keeps its source text; the page is still written.
1444
1446
  fieldsLeftInSource = true;
1445
1447
  for (const h of fm.hollowed) {
1448
+ leftInSource.push({ file: label, locale: code, pair: pairKey, where: `front matter "${h.field}"`, reason: h.reason });
1449
+ logRefusal(cwd, {
1450
+ pair: pairKey, method: tmMethodKey(pairConfig), file: label, locale: code,
1451
+ field: h.field, reason: h.reason, source: fieldsToTranslate[h.field], answer: h.value ?? null,
1452
+ });
1446
1453
  output.warn(
1447
1454
  `${label} → ${code}: front matter "${h.field}" kept in the source language — ${h.reason}`
1448
1455
  + `${h.fallbackReason ? `; the fallback (${pairConfig.fallback.method}): ${h.fallbackReason}` : ''}.`
@@ -1666,6 +1673,13 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1666
1673
  'the model gave for other, different source strings (a memorized sentence, not a translation) — refused.'
1667
1674
  );
1668
1675
  }
1676
+ for (const r of outcome.refused || []) {
1677
+ leftInSource.push({ file: `${dirName}/${relPath}`, locale: code, pair: pairKey, where: `paragraph ${r.block}`, reason: r.reason });
1678
+ logRefusal(cwd, {
1679
+ pair: pairKey, method: tmMethodKey(pairConfig), file: `${dirName}/${relPath}`, locale: code,
1680
+ paragraph: r.block, reason: r.reason, source: missed[r.i].source, answer: r.answer,
1681
+ });
1682
+ }
1669
1683
  if ((outcome.refused || []).length > 0) {
1670
1684
  output.warn(
1671
1685
  `Docusaurus body for ${code}: ${outcome.refused.length} block(s) of ${dirName}/${relPath} refused by the quality gate, also when asked again with the reason — `
@@ -1845,7 +1859,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1845
1859
  if (totalContent > 0 || totalContentSkipped > 0 || totalContentRetranslated > 0) {
1846
1860
  const action = dryRun ? 'Would create' : 'Created';
1847
1861
  const retranslateNote = totalContentRetranslated > 0 ? ` (${totalContentRetranslated} re-translated)` : '';
1848
- output.ok(`${action} ${totalContent} content file(s)${retranslateNote}, ${totalContentSkipped} unchanged`);
1862
+ const unfinished = leftInSource.length > 0 ? ` — ${leftInSource.length} part(s) left in the source language (below)` : '';
1863
+ (unfinished ? output.warn : output.ok).call(output, `${action} ${totalContent} content file(s)${retranslateNote}, ${totalContentSkipped} unchanged${unfinished}`);
1849
1864
  }
1850
1865
 
1851
1866
  contentTranslated = totalContent;
@@ -1891,6 +1906,10 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
1891
1906
  saveTM(cwd, tm);
1892
1907
  output.info(`[TM] Saved ${describeTMChanges(tm)} this sync`);
1893
1908
  }
1909
+ {
1910
+ // Last, so it is the run's closing word: what is still untranslated.
1911
+ reportLeftInSource(leftInSource);
1912
+ }
1894
1913
 
1895
1914
  // Failures are reported, not thrown: a partly failed run still did real
1896
1915
  // work, and the exit code says so (2 = partial, 1 = nothing succeeded).
package/lib/fallback.js CHANGED
@@ -572,7 +572,8 @@ export async function translateFieldsWithFallback({
572
572
  if (typeof value !== 'string') continue;
573
573
  const f = failed.get(field);
574
574
  const fault = contentGateFault(fields[field], value, pairConfig);
575
- const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason) && value === f.value;
575
+ const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason)
576
+ && [f.value, fields[field]].some(v => sameAnswer(v, value));
576
577
  if (!fault || insisted) againPassing.push([field, value]);
577
578
  else f.reason = `${f.reason}; asked again with that reason: ${fault}`;
578
579
  }
@@ -698,6 +699,17 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
698
699
  return contentGateFault(restoredSource, restoredOut, pairConfig);
699
700
  }
700
701
 
702
+ /**
703
+ * The same answer, give or take spacing and closing punctuation: a citation
704
+ * kept as written once with its final "." and once without was refused as a
705
+ * changed answer (dogfood 2026-10-06, a Thai reference) — the model had said
706
+ * "keep it" twice.
707
+ */
708
+ function sameAnswer(a, b) {
709
+ const norm = (v) => String(v ?? '').replace(/\s+/g, ' ').trim().replace(/[.,;:!?。、]+$/u, '');
710
+ return norm(a) === norm(b);
711
+ }
712
+
701
713
  /**
702
714
  * Ask the pair's method once more for the blocks the gate refused, telling it
703
715
  * why. WHY: a refusal used to be final for the run, and the block was left in
@@ -724,7 +736,7 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
724
736
  * @param {object|null} [p.budget]
725
737
  * @returns {Promise<Map<number,string>>} index → accepted (restored) answer
726
738
  */
727
- async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, budget = null }) {
739
+ async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, answers = null, budget = null }) {
728
740
  const accepted = new Map();
729
741
  if (idx.length === 0) return accepted;
730
742
  // An endpoint that declares it follows no instructions (a trained NMT
@@ -751,9 +763,13 @@ async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runB
751
763
  if (!res || fell.has(j) || typeof res.blocks[j] !== 'string') return;
752
764
  const restored = restoreBlocks(res.blocks[j], blocks);
753
765
  const fault = blockFault(texts[i], res.blocks[j], restored, missed[i].source, pairConfig);
754
- const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i)) && restored === firstOut.get(i);
766
+ const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i))
767
+ && [firstOut.get(i), missed[i].source].some(v => sameAnswer(v, restored));
755
768
  if (!fault || insisted) accepted.set(i, restored);
756
- else reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
769
+ else {
770
+ reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
771
+ if (answers) answers.set(i, restored);
772
+ }
757
773
  });
758
774
  return accepted;
759
775
  }
@@ -828,9 +844,13 @@ export async function translateBlocksWithFallback({
828
844
  };
829
845
  // Why the gate refused a block (index → reason), for the lane's warning.
830
846
  const refusedReason = new Map();
847
+ // The last answer the gate refused, per block — kept for the refusal log
848
+ // (.champollion/refused.jsonl), so "why was this refused?" can be read off
849
+ // what the model actually said instead of asked again.
850
+ const refusedAnswer = new Map();
831
851
  /** Blocks left as their source text because the gate refused them. */
832
852
  const refusedList = (fellBack) => fellBack.filter(i => refusedReason.has(i))
833
- .map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i) }));
853
+ .map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i), answer: refusedAnswer.get(i) ?? null }));
834
854
 
835
855
  if (!fb) {
836
856
  const { blocks: translatedBlocks, fellBack } = await runBatch(texts, pairConfig);
@@ -841,14 +861,14 @@ export async function translateBlocksWithFallback({
841
861
  for (let i = 0; i < outs.length; i++) {
842
862
  if (fellSet.has(i)) continue;
843
863
  const fault = contentGateFault(missed[i].source, outs[i], pairConfig);
844
- if (fault) refusedReason.set(i, fault);
864
+ if (fault) { refusedReason.set(i, fault); refusedAnswer.set(i, outs[i]); }
845
865
  }
846
866
  const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
847
867
  for (const i of shared) refusedReason.set(i, 'the same text the model gave for other, different source strings');
848
868
  // Refused: asked once more, with the reason.
849
869
  const again = await retryRefusedBlocks({
850
870
  idx: [...refusedReason.keys()], texts, missed, blocks, pairConfig, runBatch,
851
- firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, budget,
871
+ firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, answers: refusedAnswer, budget,
852
872
  });
853
873
  const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
854
874
  for (const [i, out] of again) {
@@ -914,6 +934,7 @@ export async function translateBlocksWithFallback({
914
934
  const fault = blockFault(texts[i], primary.blocks[i], restored, missed[i].source, pairConfig);
915
935
  if (fault) {
916
936
  refusedReason.set(i, fault);
937
+ refusedAnswer.set(i, restored);
917
938
  primaryRefused.add(i);
918
939
  failed.push(i);
919
940
  continue;
@@ -934,7 +955,7 @@ export async function translateBlocksWithFallback({
934
955
  if (refusedReason.has(i)) firstOut.set(i, restoreBlocks(primary.blocks[i], blocks));
935
956
  }
936
957
  const again = await retryRefusedBlocks({
937
- idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, budget,
958
+ idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, answers: refusedAnswer, budget,
938
959
  });
939
960
  const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
940
961
  const recovered = new Set();
@@ -1015,6 +1036,7 @@ export async function translateBlocksWithFallback({
1015
1036
  if (!fbFault) fbGood.push({ i, out: restored });
1016
1037
  else {
1017
1038
  refusedReason.set(i, refusedReason.has(i) ? `${refusedReason.get(i)}; the fallback's answer: ${fbFault}` : `the fallback's answer: ${fbFault}`);
1039
+ refusedAnswer.set(i, restored);
1018
1040
  fallbackRefused.add(i);
1019
1041
  }
1020
1042
  }
package/lib/validate.js CHANGED
@@ -451,8 +451,11 @@ function isProtectedTermValue(value, protectedTerms = []) {
451
451
  * until someone names it for a redo. Bump it whenever a check is loosened.
452
452
  * 2 — 2026-10-05: names with citations/anchors, reference entries, tables,
453
453
  * short titles and source-shown fullwidth letters stopped being refused.
454
+ * 3 — 2026-10-06: inline code is not prose (an ICU sample in backticks no
455
+ * longer makes a translated paragraph "ASCII-only"); a phrase said
456
+ * twice is not a loop; "keep it" twice is accepted give or take a stop.
454
457
  */
455
- const GATE_VERSION = 2;
458
+ const GATE_VERSION = 3;
456
459
 
457
460
  /**
458
461
  * Refusals that may be the model keeping text that is correct as written —
@@ -519,7 +522,7 @@ function isBibliographicEntry(text) {
519
522
  const lines = t.split('\n').map(l => l.trim()).filter(Boolean);
520
523
  return lines.length > 1 && lines.every(l => !l.includes('\n') && isBibliographicEntry(l));
521
524
  }
522
- return /^(?:\[?[A-Z]?\d{1,3}\]?[.)]?|[-*+])\s+\S/.test(t)
525
+ return /^(?:\[?[A-Z]?\d{1,3}[a-z]?\]?[.)]?|[-*+])\s+\S/.test(t)
523
526
  && (/\(\d{4}[a-z]?\)/.test(t) || /\]\(https?:\/\//.test(t))
524
527
  && (/"[^"]{8,}"|“[^”]{8,}”/.test(t) || /\*[^*]{8,}\*/.test(t));
525
528
  }
@@ -570,6 +573,43 @@ function contentGateFault(source, value, pairConfig = {}) {
570
573
  return failures.length > 0 ? failures[0].reason : null;
571
574
  }
572
575
 
576
+ /**
577
+ * Does `translated` show the shape of a degeneration loop, beyond a high
578
+ * repeated-n-gram rate? A phrase a language says twice where English
579
+ * elides it is not a loop: "From most to least trustworthy:" is correctly
580
+ * "Từ đáng tin cậy nhất đến ít đáng tin cậy nhất:" in Vietnamese, and was
581
+ * refused as a repetition hallucination (dogfood 2026-10-06). A loop does
582
+ * one of three things: repeats some span three times or more, repeats a whole
583
+ * line, or hands the source back verbatim beside a translation of it (the
584
+ * doubled Arabic headings, "### METEOR (…)\n### METEOR (…)").
585
+ *
586
+ * @param {string} translated
587
+ * @param {string} source
588
+ * @returns {boolean}
589
+ */
590
+ function hasLoopEvidence(translated, source) {
591
+ const t = String(translated);
592
+ const grams = new Map();
593
+ for (let i = 0; i + REPETITION_LONG_N <= t.length; i++) {
594
+ const g = t.slice(i, i + REPETITION_LONG_N);
595
+ const n = (grams.get(g) || 0) + 1;
596
+ if (n >= 3 && g.trim().length >= 4) return true;
597
+ grams.set(g, n);
598
+ }
599
+ // A word said three times or more ("Qo' Qo' Qo' Qo'" — too short for an
600
+ // 8-character span to recur three times).
601
+ const words = new Map();
602
+ for (const w of t.toLowerCase().split(/\s+/u).filter(x => /\p{L}/u.test(x))) {
603
+ const n = (words.get(w) || 0) + 1;
604
+ if (n >= 3) return true;
605
+ words.set(w, n);
606
+ }
607
+ const lines = t.split('\n').map(l => l.trim()).filter(l => l.length >= 8);
608
+ if (new Set(lines).size < lines.length) return true;
609
+ const src = String(source).trim();
610
+ return src.length >= 8 && t.trim() !== src && t.includes(src);
611
+ }
612
+
573
613
  /**
574
614
  * Validate a batch of translations and return only passing keys.
575
615
  *
@@ -775,7 +815,7 @@ function validateTranslations(translations, sourceFlat, pairConfig, options = {}
775
815
  longGramRate: measureRepetition(seg, REPETITION_LONG_N),
776
816
  }))
777
817
  .find(m => m.trigramRate > trigramCap && m.longGramRate > longGramCap);
778
- if (degenerateSegment) {
818
+ if (degenerateSegment && hasLoopEvidence(translated, source)) {
779
819
  failures.push({
780
820
  key,
781
821
  reason: `repetition hallucination (${(degenerateSegment.trigramRate * 100).toFixed(0)}% repeated trigrams, ${(degenerateSegment.longGramRate * 100).toFixed(0)}% repeated ${REPETITION_LONG_N}-grams)`,
@@ -1300,12 +1340,30 @@ const FULLWIDTH_LATIN = /[A-Za-z]/u;
1300
1340
  * URL value is a wrong-script answer unless the key is declared
1301
1341
  * no-translate — which never reaches this check.)
1302
1342
  */
1343
+ /** `text` with every balanced {…} group removed (nested groups included). */
1344
+ function withoutBraceGroups(text) {
1345
+ let out = '';
1346
+ let depth = 0;
1347
+ for (const ch of text) {
1348
+ if (ch === '{') { depth++; continue; }
1349
+ if (ch === '}' && depth > 0) { depth--; continue; }
1350
+ if (depth === 0) out += ch;
1351
+ }
1352
+ return out;
1353
+ }
1354
+
1303
1355
  function proseOf(text) {
1356
+ // Inline code is never prose: a Markdown paragraph quoting an ICU message
1357
+ // in backticks (`{n, plural, one {One file} other {…}}`) was read as that
1358
+ // message, its English branches were taken for the whole value, and 470
1359
+ // characters of correct Japanese were refused as "ASCII-only" — twice, in
1360
+ // five languages (dogfood 2026-10-06, docs/getting-started/configuration.md).
1361
+ const str = String(text).replace(/(`+)[^`]*?\1/g, ' ');
1304
1362
  // A plural/select message: its prose is the text of its branches (the
1305
- // keywords and selectors — "plural", "one", "other" — are code).
1306
- const str = String(text);
1363
+ // keywords and selectors — "plural", "one", "other" — are code) PLUS the
1364
+ // text around it ("You have {count, plural, …}" — "You have" is prose).
1307
1365
  const branches = str.includes('{') ? pluralBranchTexts(str) : null;
1308
- return (branches ? branches.join(' ') : str)
1366
+ return (branches ? `${branches.join(' ')} ${withoutBraceGroups(str)}` : str)
1309
1367
  // Simple placeholders only: {name}, {count, number}. Branch text such as
1310
1368
  // "{Один файл}" is prose, never stripped.
1311
1369
  .replace(/\{\s*[\w.$-]+\s*(?:,[^{}]*)?\}/g, ' ')
@@ -1467,6 +1525,7 @@ export {
1467
1525
  nameCore,
1468
1526
  GATE_VERSION,
1469
1527
  isKeepAsWrittenFault,
1528
+ hasLoopEvidence,
1470
1529
  isBibliographicEntry,
1471
1530
  tableProse,
1472
1531
  SHORT_SOURCE_LENGTH_SLACK,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "champollion",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Research-grade translation engine for i18n projects. Pluggable methods, per-pair quality tiers, and deterministic script converters. Supports JSON (next-intl, i18next), TOML, and YAML (Hugo).",
5
5
  "type": "module",
6
6
  "bin": {