champollion 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
@@ -0,0 +1,372 @@
1
+ /**
2
+ * content-review.js — keep the edits a person makes to translated Markdown.
3
+ *
4
+ * THE BUG THIS CLOSES (persona finding, 2026-10-03): a reviewer corrects a
5
+ * sentence in newsletters/2026-10.crk.md; the author then edits a DIFFERENT
6
+ * paragraph of newsletters/2026-10.md; the next sync re-translates the file
7
+ * and the Translation Memory serves the OLD machine translation for every
8
+ * unchanged paragraph, so the reviewer's correction was overwritten with
9
+ * nothing said but "source updated, re-translating". The content lock held
10
+ * only the SOURCE hash, so nothing could tell a reviewed file from the file
11
+ * sync wrote.
12
+ *
13
+ * THE RECORD. Whenever the content lane writes (or adopts) a translated file
14
+ * it also records what it left on disk, in the same .champollion-content.lock,
15
+ * under "written:<relPath>:<locale>":
16
+ *
17
+ * {
18
+ * "file": 16 hex of SHA-256 over the whole file as sync left it,
19
+ * "blocks": "s:t s:t:r …" one entry per translatable body block, in
20
+ * order. s = 8 hex of the SOURCE block it translates, t = 8 hex
21
+ * of the block as written, ":r" = a person's text (kept on
22
+ * every later sync). null when the written body does not split
23
+ * into as many blocks as the source (no safe positional map).
24
+ * "fields": { "<name>": "s:t[:r]" } translatable front-matter fields.
25
+ * "owned": true the whole file is a person's (hand-translated, or
26
+ * brought up to date by hand) and has no block map.
27
+ * "held": 16 hex of the file when a sync last left it as is because
28
+ * its edits could not be merged with a source change.
29
+ * }
30
+ *
31
+ * The block unit is exactly the TM's (segment.js via translatableBlockSources:
32
+ * protect → split → restore, translatable segments only), and the record is
33
+ * computed by re-splitting the file AS WRITTEN — the same function the next
34
+ * sync runs on the file on disk — so "differs from the record" can only mean
35
+ * that a person changed it.
36
+ *
37
+ * WHAT A SYNC DOES WITH EDITS (lib/content-sync.js):
38
+ * - source unchanged → the file is not touched (as before);
39
+ * - source changed → edited paragraphs whose source paragraph is unchanged
40
+ * are kept word for word, everything else is updated; an edited paragraph
41
+ * whose source paragraph itself changed is re-translated and the run
42
+ * prints the edited wording so it can be re-applied;
43
+ * - edits that cannot be matched paragraph by paragraph (paragraphs added,
44
+ * removed or merged; 'page' segmentation) → the file is left as is and
45
+ * the run says so every time until the file is brought up to date by hand
46
+ * (the next sync then takes it as current) or re-translated by name.
47
+ */
48
+
49
+ import crypto from 'node:crypto';
50
+ import { parseContentFile } from './content.js';
51
+ import { translatableBlockSources } from './content-estimate.js';
52
+
53
+ const WRITTEN_PREFIX = 'written:';
54
+
55
+ function sha256(text) {
56
+ return crypto.createHash('sha256').update(text, 'utf-8').digest('hex');
57
+ }
58
+
59
+ /** 8-hex fingerprint of one block or field value (collisions only matter within one file). */
60
+ function blockHash(text) {
61
+ return sha256(text).slice(0, 8);
62
+ }
63
+
64
+ /** 16-hex fingerprint of a whole translated file. */
65
+ function fileHash(text) {
66
+ return sha256(text).slice(0, 16);
67
+ }
68
+
69
+ /** The lock key of the written-record for a content manifest key ("relPath:locale"). */
70
+ function writtenRecordKey(manifestKey) {
71
+ return `${WRITTEN_PREFIX}${manifestKey}`;
72
+ }
73
+
74
+ function encodePair(s, t, owned) {
75
+ return `${s}:${t}${owned ? ':r' : ''}`;
76
+ }
77
+
78
+ function decodePair(enc) {
79
+ if (typeof enc !== 'string') return null;
80
+ const [s, t, flag, extra] = enc.split(':');
81
+ if (!/^[0-9a-f]{8}$/.test(s || '') || !/^[0-9a-f]{8}$/.test(t || '')) return null;
82
+ if (extra !== undefined || (flag !== undefined && flag !== 'r')) return null;
83
+ return { s, t, owned: flag === 'r' };
84
+ }
85
+
86
+ /**
87
+ * Parse a stored written-record. Returns null when there is none; throws
88
+ * (with a plain message) when one is present but malformed — the caller
89
+ * must say so rather than silently treat the file as unedited.
90
+ *
91
+ * @param {*} value - The lock value under writtenRecordKey(...)
92
+ * @returns {{ file: string, blocks: Array<{s:string,t:string,owned:boolean}>|null,
93
+ * fields: Object<string,{s:string,t:string,owned:boolean}>, owned: boolean,
94
+ * held: string|null }|null}
95
+ */
96
+ function parseWrittenRecord(value) {
97
+ if (value === undefined || value === null) return null;
98
+ if (typeof value !== 'object' || Array.isArray(value) || typeof value.file !== 'string') {
99
+ throw new Error('not a written-record object');
100
+ }
101
+ let blocks = null;
102
+ if (typeof value.blocks === 'string') {
103
+ blocks = value.blocks === '' ? [] : value.blocks.split(' ').map(decodePair);
104
+ if (blocks.some(b => b === null)) throw new Error('malformed "blocks"');
105
+ } else if (value.blocks !== null && value.blocks !== undefined) {
106
+ throw new Error('malformed "blocks"');
107
+ }
108
+ const fields = {};
109
+ if (value.fields !== undefined) {
110
+ if (typeof value.fields !== 'object' || value.fields === null || Array.isArray(value.fields)) {
111
+ throw new Error('malformed "fields"');
112
+ }
113
+ for (const [name, enc] of Object.entries(value.fields)) {
114
+ const pair = decodePair(enc);
115
+ if (!pair) throw new Error(`malformed field "${name}"`);
116
+ fields[name] = pair;
117
+ }
118
+ }
119
+ return {
120
+ file: value.file,
121
+ blocks,
122
+ fields,
123
+ owned: value.owned === true,
124
+ held: typeof value.held === 'string' ? value.held : null,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Build the record of a file sync is leaving on disk.
130
+ *
131
+ * @param {object} args
132
+ * @param {string} args.sourceBody - Source Markdown body (front matter stripped)
133
+ * @param {Object<string,string>} args.sourceFields - Translatable source front-matter values
134
+ * @param {string} args.written - The complete target file as written
135
+ * @param {Set<number>} [args.ownedBlocks] - Source block positions holding a person's text
136
+ * @param {Set<string>} [args.ownedFields] - Field names holding a person's text
137
+ * @returns {object} JSON-ready record (see header)
138
+ */
139
+ function buildWrittenRecord({ sourceBody, sourceFields, written, ownedBlocks = new Set(), ownedFields = new Set() }) {
140
+ const target = parseContentFile(written);
141
+ const src = translatableBlockSources(sourceBody);
142
+ const out = translatableBlockSources(target.body);
143
+ const record = { file: fileHash(written), blocks: null };
144
+ if (src.length === out.length) {
145
+ record.blocks = src.map((s, j) => encodePair(blockHash(s), blockHash(out[j]), ownedBlocks.has(j))).join(' ');
146
+ } else if (ownedBlocks.size > 0) {
147
+ // A person's paragraphs with no block map: the whole file is theirs.
148
+ record.owned = true;
149
+ }
150
+ const fields = {};
151
+ for (const name of Object.keys(sourceFields).sort()) {
152
+ const value = sourceFields[name];
153
+ const tv = target.frontMatter[name];
154
+ if (typeof value === 'string' && typeof tv === 'string') {
155
+ fields[name] = encodePair(blockHash(value), blockHash(tv), ownedFields.has(name));
156
+ }
157
+ }
158
+ if (Object.keys(fields).length > 0) record.fields = fields;
159
+ return record;
160
+ }
161
+
162
+ /**
163
+ * Record a file a person wrote (hand-translated, or brought up to date by
164
+ * hand after a sync held it): every paragraph and field is theirs.
165
+ */
166
+ function buildAdoptedRecord({ sourceBody, sourceFields, written }) {
167
+ const blockCount = translatableBlockSources(sourceBody).length;
168
+ const ownedBlocks = new Set(Array.from({ length: blockCount }, (_, j) => j));
169
+ const record = buildWrittenRecord({
170
+ sourceBody, sourceFields, written, ownedBlocks, ownedFields: new Set(Object.keys(sourceFields)),
171
+ });
172
+ // A hand-written file with no translatable blocks is still a person's file.
173
+ if (record.blocks === null) record.owned = true;
174
+ return record;
175
+ }
176
+
177
+ /**
178
+ * First record of a file this version never recorded (written by an older
179
+ * version, source unchanged since). A block or field is marked as a person's
180
+ * when the TM holds a machine translation for its source and the file does
181
+ * NOT carry it — that is an edit made before the record existed. With no TM
182
+ * entry there is no evidence either way, and the text is taken as written.
183
+ *
184
+ * @param {object} args
185
+ * @param {(sourceText: string) => string[]} args.machineFor - Cached machine
186
+ * translations of a source text (exact entries; no side effects)
187
+ */
188
+ function buildBootstrapRecord({ sourceBody, sourceFields, written, machineFor }) {
189
+ const target = parseContentFile(written);
190
+ const src = translatableBlockSources(sourceBody);
191
+ const out = translatableBlockSources(target.body);
192
+ const differs = (sourceText, targetText) => {
193
+ const known = machineFor(sourceText);
194
+ return known.length > 0 && !known.includes(targetText);
195
+ };
196
+ const ownedBlocks = new Set();
197
+ if (src.length === out.length) {
198
+ src.forEach((s, j) => { if (differs(s, out[j])) ownedBlocks.add(j); });
199
+ }
200
+ const ownedFields = new Set();
201
+ for (const [name, value] of Object.entries(sourceFields)) {
202
+ const tv = target.frontMatter[name];
203
+ if (typeof value === 'string' && typeof tv === 'string' && differs(value, tv)) ownedFields.add(name);
204
+ }
205
+ return buildWrittenRecord({ sourceBody, sourceFields, written, ownedBlocks, ownedFields });
206
+ }
207
+
208
+ /**
209
+ * What a person changed in a translated file since sync last recorded it.
210
+ *
211
+ * @param {string} targetRaw - The translated file as it is on disk now
212
+ * @param {ReturnType<typeof parseWrittenRecord>} record
213
+ * @returns {{ state: 'clean' } |
214
+ * { state: 'unmappable', reason: string } |
215
+ * { state: 'edited', blocks: Array<{src:string,text:string,owned:boolean}>,
216
+ * fields: Object<string,{src:string,text:string,owned:boolean}>,
217
+ * ownedBlocks: number, ownedFields: number }}
218
+ */
219
+ function readReviewerEdits(targetRaw, record) {
220
+ const edited = fileHash(targetRaw) !== record.file;
221
+ const anyOwned = record.owned
222
+ || (record.blocks || []).some(b => b.owned)
223
+ || Object.values(record.fields).some(f => f.owned);
224
+ if (!edited && !anyOwned) return { state: 'clean' };
225
+
226
+ const target = parseContentFile(targetRaw);
227
+ const fields = {};
228
+ for (const [name, f] of Object.entries(record.fields)) {
229
+ const current = target.frontMatter[name];
230
+ if (typeof current !== 'string') continue; // removed from the file: nothing to keep
231
+ fields[name] = { src: f.s, text: current, owned: f.owned || blockHash(current) !== f.t };
232
+ }
233
+ const ownedFields = Object.values(fields).filter(f => f.owned).length;
234
+
235
+ if (record.blocks === null) {
236
+ if (!edited && !record.owned) {
237
+ // Only field flags: the body is as written.
238
+ return ownedFields > 0 ? { state: 'edited', blocks: null, fields, ownedBlocks: 0, ownedFields } : { state: 'clean' };
239
+ }
240
+ return {
241
+ state: 'unmappable',
242
+ reason: record.owned
243
+ ? 'it is maintained by hand'
244
+ : 'its paragraphs did not line up one to one with the source when sync wrote it',
245
+ };
246
+ }
247
+
248
+ const current = translatableBlockSources(target.body);
249
+ if (current.length !== record.blocks.length) {
250
+ return {
251
+ state: 'unmappable',
252
+ reason: `it now has ${current.length} paragraph(s) where sync wrote ${record.blocks.length} — paragraphs were added, removed or merged`,
253
+ };
254
+ }
255
+ const blocks = record.blocks.map((b, i) => ({
256
+ src: b.s, text: current[i], owned: b.owned || blockHash(current[i]) !== b.t,
257
+ }));
258
+ const ownedBlocks = blocks.filter(b => b.owned).length;
259
+ // Edits only to separators, passthrough blocks (code) or untranslated
260
+ // front matter: those parts are copied from the source by design.
261
+ if (ownedBlocks === 0 && ownedFields === 0) return { state: 'clean' };
262
+ return { state: 'edited', blocks, fields, ownedBlocks, ownedFields };
263
+ }
264
+
265
+ /**
266
+ * Match a person's paragraphs to the CURRENT source's blocks by the source
267
+ * text they translate. Every previously written block is queued under its
268
+ * source hash, so the k-th copy of a repeated paragraph maps to the k-th copy.
269
+ *
270
+ * @param {Array<{src:string,text:string,owned:boolean}>} editBlocks - From readReviewerEdits
271
+ * @param {string[]} sourceBlocks - Current source's translatable block texts, in order
272
+ * @returns {{ keep: Map<number,string>, superseded: Array<{ paragraph: number, text: string }> }}
273
+ * keep: source block position → the person's text. superseded: edited
274
+ * paragraphs (1-based, as they were in the file) whose source paragraph
275
+ * is gone or changed.
276
+ */
277
+ function matchEditedBlocks(editBlocks, sourceBlocks) {
278
+ const queues = new Map();
279
+ editBlocks.forEach((b, i) => {
280
+ if (!queues.has(b.src)) queues.set(b.src, []);
281
+ queues.get(b.src).push({ ...b, paragraph: i + 1 });
282
+ });
283
+ const keep = new Map();
284
+ sourceBlocks.forEach((text, j) => {
285
+ const entry = queues.get(blockHash(text))?.shift();
286
+ if (entry && entry.owned) keep.set(j, entry.text);
287
+ });
288
+ const superseded = [...queues.values()].flat()
289
+ .filter(e => e.owned)
290
+ .sort((a, b) => a.paragraph - b.paragraph)
291
+ .map(e => ({ paragraph: e.paragraph, text: e.text }));
292
+ return { keep, superseded };
293
+ }
294
+
295
+ /**
296
+ * Match a person's front-matter values to the current source fields.
297
+ *
298
+ * @returns {{ keep: Object<string,string>, superseded: Array<{ field: string, text: string }> }}
299
+ */
300
+ function matchEditedFields(editFields, sourceFields) {
301
+ const keep = {};
302
+ const superseded = [];
303
+ for (const [name, f] of Object.entries(editFields)) {
304
+ if (!f.owned) continue;
305
+ const source = sourceFields[name];
306
+ if (typeof source === 'string' && blockHash(source) === f.src) keep[name] = f.text;
307
+ else superseded.push({ field: name, text: f.text });
308
+ }
309
+ return { keep, superseded };
310
+ }
311
+
312
+ /**
313
+ * Decide what a sync does with an existing translated file before it
314
+ * translates anything. Pure (no I/O): runContentSync and the cost estimator
315
+ * (countPendingContentTranslations) both call it, so the estimate can never
316
+ * count a file the sync would leave as is.
317
+ *
318
+ * @param {object} args
319
+ * @param {string} args.targetRaw - The translated file on disk
320
+ * @param {ReturnType<typeof parseWrittenRecord>} args.record - null = no record (older version)
321
+ * @param {'block'|'page'} args.segMode - The pair's content segmentation
322
+ * @param {boolean} args.replaceEdits - The operator named this file for a redo
323
+ * (--retranslate, or --redo files: / --files with --force-content)
324
+ * @param {boolean} args.sourceCurrent - The lock says the source is unchanged
325
+ * (we are here only because of --force-content)
326
+ * @returns {{ action: 'proceed', edits: object|null, replaced?: object } |
327
+ * { action: 'keep', reason: string } | { action: 'accept' } |
328
+ * { action: 'hold', reason: string, heldHash: string }}
329
+ * proceed: translate; `edits` (state 'edited') are the person's paragraphs
330
+ * and fields to keep. keep: an up-to-date file with edits that cannot be
331
+ * merged — leave it. accept: a held file was edited since — take it as
332
+ * current. hold: leave the file as is and say so (lock not advanced).
333
+ */
334
+ function assessExistingTarget({ targetRaw, record, segMode, replaceEdits, sourceCurrent }) {
335
+ if (!record) return { action: 'proceed', edits: null };
336
+ const edits = readReviewerEdits(targetRaw, record);
337
+ if (edits.state === 'clean') return { action: 'proceed', edits: null };
338
+ if (replaceEdits) return { action: 'proceed', edits: null, replaced: edits };
339
+ const cannotMerge = edits.state === 'unmappable' || (segMode === 'page' && edits.ownedBlocks > 0);
340
+ if (!cannotMerge) return { action: 'proceed', edits };
341
+ const reason = edits.state === 'unmappable'
342
+ ? edits.reason
343
+ : "its pair uses 'page' segmentation, which translates the body as one piece";
344
+ if (sourceCurrent) return { action: 'keep', reason };
345
+ const current = fileHash(targetRaw);
346
+ if (record.held && record.held !== current) return { action: 'accept' };
347
+ return { action: 'hold', reason, heldHash: current };
348
+ }
349
+
350
+ /** "3 paragraph(s) and the title" — for kept/replaced messages. */
351
+ function describeEdits({ paragraphs = 0, fields = [] }) {
352
+ const parts = [];
353
+ if (paragraphs > 0) parts.push(`${paragraphs} paragraph(s)`);
354
+ if (fields.length > 0) parts.push(`front matter ${fields.map(f => `"${f}"`).join(', ')}`);
355
+ return parts.join(' and ') || 'the file';
356
+ }
357
+
358
+ export {
359
+ assessExistingTarget,
360
+ describeEdits,
361
+ WRITTEN_PREFIX,
362
+ blockHash,
363
+ fileHash,
364
+ writtenRecordKey,
365
+ parseWrittenRecord,
366
+ buildWrittenRecord,
367
+ buildAdoptedRecord,
368
+ buildBootstrapRecord,
369
+ readReviewerEdits,
370
+ matchEditedBlocks,
371
+ matchEditedFields,
372
+ };