@adeu/core 1.17.1 → 1.17.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.
package/src/engine.ts CHANGED
@@ -65,6 +65,81 @@ function insertAtIndex(parent: Element, index: number, child: Node) {
65
65
  }
66
66
  }
67
67
 
68
+
69
+ function safeCloneEdit(val: any, seen: WeakMap<any, any> = new WeakMap()): any {
70
+ if (val === null || typeof val !== "object") {
71
+ return val;
72
+ }
73
+ if (seen.has(val)) {
74
+ return seen.get(val);
75
+ }
76
+ if (val.nodeType !== undefined || typeof val.cloneNode === "function") {
77
+ return val;
78
+ }
79
+ if (Array.isArray(val)) {
80
+ const copy: any[] = [];
81
+ seen.set(val, copy);
82
+ for (let i = 0; i < val.length; i++) {
83
+ copy.push(safeCloneEdit(val[i], seen));
84
+ }
85
+ return copy;
86
+ }
87
+ const copy: any = {};
88
+ seen.set(val, copy);
89
+ for (const key of Object.keys(val)) {
90
+ copy[key] = safeCloneEdit(val[key], seen);
91
+ }
92
+ return copy;
93
+ }
94
+
95
+ function takeSnapshot(doc: any): any {
96
+ const parts = [...doc.pkg.parts];
97
+ const unzipped = { ...doc.pkg.unzipped };
98
+ const rels = new Map<any, Map<string, any>>();
99
+ const elements = new Map<any, Element>();
100
+ for (const part of parts) {
101
+ rels.set(part, new Map(part.rels));
102
+ if (part._element) {
103
+ elements.set(part, part._element.cloneNode(true) as Element);
104
+ }
105
+ }
106
+ return { parts, unzipped, rels, elements };
107
+ }
108
+
109
+ function restoreSnapshot(doc: any, snapshot: any): void {
110
+ doc.pkg.parts = [...snapshot.parts];
111
+ for (const key of Object.keys(doc.pkg.unzipped)) {
112
+ delete doc.pkg.unzipped[key];
113
+ }
114
+ for (const [key, val] of Object.entries(snapshot.unzipped)) {
115
+ doc.pkg.unzipped[key] = val;
116
+ }
117
+ for (const part of snapshot.parts) {
118
+ part.rels = new Map(snapshot.rels.get(part)!);
119
+ const originalEl = snapshot.elements.get(part);
120
+ if (originalEl && part._element) {
121
+ const xmlDoc = part._element.ownerDocument;
122
+ if (xmlDoc && xmlDoc.documentElement) {
123
+ xmlDoc.replaceChild(originalEl, xmlDoc.documentElement);
124
+ }
125
+ part._element = originalEl;
126
+ }
127
+ }
128
+ }
129
+
130
+ function stripMatchingHeadingHashes(target: string, newText: string): [string, string] {
131
+ if (!target || !newText) return [target, newText];
132
+ const targetMatch = target.match(/^(#+)\s+/);
133
+ const newMatch = newText.match(/^(#+)\s+/);
134
+ if (targetMatch && newMatch && targetMatch[1] === newMatch[1]) {
135
+ const hashes = targetMatch[1];
136
+ const targetClean = target.substring(hashes.length).trimStart();
137
+ const newClean = newText.substring(hashes.length).trimStart();
138
+ return [targetClean, newClean];
139
+ }
140
+ return [target, newText];
141
+ }
142
+
68
143
  // --- Validation ---
69
144
  export class BatchValidationError extends Error {
70
145
  public errors: string[];
@@ -75,7 +150,7 @@ export class BatchValidationError extends Error {
75
150
  }
76
151
  }
77
152
 
78
- export function validate_edit_strings(edits: any[]): string[] {
153
+ export function validate_edit_strings(edits: any[], index_offset: number = 0): string[] {
79
154
  const errors: string[] = [];
80
155
 
81
156
  for (let i = 0; i < edits.length; i++) {
@@ -90,7 +165,7 @@ export function validate_edit_strings(edits: any[]): string[] {
90
165
  n_text.includes("{==")
91
166
  ) {
92
167
  errors.push(
93
- `- Edit ${i + 1} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`,
168
+ `- Edit ${i + 1 + index_offset} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`,
94
169
  );
95
170
  }
96
171
 
@@ -107,11 +182,11 @@ export function validate_edit_strings(edits: any[]): string[] {
107
182
  )
108
183
  ) {
109
184
  errors.push(
110
- `- Edit ${i + 1} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`,
185
+ `- Edit ${i + 1 + index_offset} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`,
111
186
  );
112
187
  } else {
113
188
  errors.push(
114
- `- Edit ${i + 1} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`,
189
+ `- Edit ${i + 1 + index_offset} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`,
115
190
  );
116
191
  }
117
192
  }
@@ -123,11 +198,11 @@ export function validate_edit_strings(edits: any[]): string[] {
123
198
  if (t_links.length !== n_links.length) {
124
199
  if (n_links.length > t_links.length) {
125
200
  errors.push(
126
- `- Edit ${i + 1} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`,
201
+ `- Edit ${i + 1 + index_offset} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`,
127
202
  );
128
203
  } else {
129
204
  errors.push(
130
- `- Edit ${i + 1} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`,
205
+ `- Edit ${i + 1 + index_offset} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`,
131
206
  );
132
207
  }
133
208
  } else if (
@@ -135,7 +210,7 @@ export function validate_edit_strings(edits: any[]): string[] {
135
210
  JSON.stringify(t_links) !== JSON.stringify(n_links)
136
211
  ) {
137
212
  errors.push(
138
- `- Edit ${i + 1} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`,
213
+ `- Edit ${i + 1 + index_offset} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`,
139
214
  );
140
215
  }
141
216
  }
@@ -146,18 +221,18 @@ export function validate_edit_strings(edits: any[]): string[] {
146
221
  if (t_xrefs.length !== n_xrefs.length) {
147
222
  if (n_xrefs.length > t_xrefs.length) {
148
223
  errors.push(
149
- `- Edit ${i + 1} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`,
224
+ `- Edit ${i + 1 + index_offset} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`,
150
225
  );
151
226
  } else {
152
227
  errors.push(
153
- `- Edit ${i + 1} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`,
228
+ `- Edit ${i + 1 + index_offset} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`,
154
229
  );
155
230
  }
156
231
  } else {
157
232
  // Advanced XREF validation simplified for port scope
158
233
  if (JSON.stringify(t_xrefs) !== JSON.stringify(n_xrefs)) {
159
234
  errors.push(
160
- `- Edit ${i + 1} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`,
235
+ `- Edit ${i + 1 + index_offset} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`,
161
236
  );
162
237
  }
163
238
  }
@@ -172,7 +247,7 @@ export function validate_edit_strings(edits: any[]): string[] {
172
247
  t_anchors.filter((x: string) => x === a).length
173
248
  ) {
174
249
  errors.push(
175
- `- Edit ${i + 1} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`,
250
+ `- Edit ${i + 1 + index_offset} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`,
176
251
  );
177
252
  break;
178
253
  }
@@ -190,7 +265,7 @@ export function validate_edit_strings(edits: any[]): string[] {
190
265
  stripped.substring(level) === ""
191
266
  ) {
192
267
  errors.push(
193
- `- Edit ${i + 1} Failed: Heading level ${level} is not supported (maximum is 6).`,
268
+ `- Edit ${i + 1 + index_offset} Failed: Heading level ${level} is not supported (maximum is 6).`,
194
269
  );
195
270
  break;
196
271
  }
@@ -202,10 +277,12 @@ export function validate_edit_strings(edits: any[]): string[] {
202
277
  t_text.includes("READONLY_BOUNDARY_START") ||
203
278
  n_text.includes("READONLY_BOUNDARY_START") ||
204
279
  t_text.includes("# Document Structure (Read-Only)") ||
205
- n_text.includes("# Document Structure (Read-Only)")
280
+ n_text.includes("# Document Structure (Read-Only)") ||
281
+ t_text.includes("Document Structure (Read-Only)") ||
282
+ n_text.includes("Document Structure (Read-Only)")
206
283
  ) {
207
284
  errors.push(
208
- `- Edit ${i + 1} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`,
285
+ `- Edit ${i + 1 + index_offset} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`,
209
286
  );
210
287
  }
211
288
  }
@@ -233,11 +310,7 @@ export class RedlineEngine {
233
310
  const w16du_ns =
234
311
  "http://schemas.microsoft.com/office/word/2023/wordml/word16du";
235
312
  for (const part of this.doc.pkg.parts) {
236
- if (
237
- part === this.doc.part ||
238
- (part.contentType.includes("wordprocessingml") &&
239
- part.contentType.endsWith("+xml"))
240
- ) {
313
+ if (part === this.doc.part) {
241
314
  if (!part._element.hasAttribute("xmlns:w16du")) {
242
315
  part._element.setAttribute("xmlns:w16du", w16du_ns);
243
316
  }
@@ -256,7 +329,51 @@ export class RedlineEngine {
256
329
  }
257
330
  return null;
258
331
  }
259
-
332
+ /**
333
+ * Best-effort "did you mean" hint for a failed target. The common loop trap
334
+ * (observed in the field) is an anchored regex like `^\( x \)$` against a
335
+ * mid-document string: ^/$ bind to the whole full_text, so it never matches
336
+ * even though the literal `( x )` is present. We strip regex anchoring/escapes
337
+ * and probe full_text for a literal occurrence; if found, we tell the model
338
+ * the exact literal that WOULD match so it drops the anchors instead of
339
+ * escalating the regex further.
340
+ */
341
+ private _nearest_match_hint(
342
+ target_text: string | undefined,
343
+ is_regex: boolean,
344
+ ): string {
345
+ if (!target_text) return "";
346
+ let probe = target_text;
347
+ if (is_regex) {
348
+ // Strip leading/trailing anchors and surrounding \s* the model tends to add.
349
+ probe = probe.replace(/^\^/, "").replace(/\$$/, "");
350
+ probe = probe.replace(/^\\s\*/, "").replace(/\\s\*$/, "");
351
+ // Unescape the common literal escapes so "\( x \)" -> "( x )".
352
+ probe = probe.replace(/\\([.^$*+?()[\]{}|\\/])/g, "$1");
353
+ }
354
+ probe = probe.trim();
355
+ if (!probe || probe === target_text) {
356
+ // No anchors to strip, or nothing changed: nothing useful to suggest.
357
+ if (!is_regex) return "";
358
+ }
359
+ const idx = this.mapper.full_text.indexOf(probe);
360
+ if (idx !== -1) {
361
+ const ctx_start = Math.max(0, idx - 15);
362
+ const ctx_end = Math.min(
363
+ this.mapper.full_text.length,
364
+ idx + probe.length + 15,
365
+ );
366
+ const ctx = this.mapper.full_text
367
+ .substring(ctx_start, ctx_end)
368
+ .replace(/\n/g, " ");
369
+ return (
370
+ `\n Did you mean the literal "${probe}"? It appears in the document` +
371
+ ` (…${ctx}…). If you used a regex, drop the ^/$ anchors — they match` +
372
+ ` the start/end of the entire document, not a line.`
373
+ );
374
+ }
375
+ return "";
376
+ }
260
377
  private _build_edit_context_previews(
261
378
  edit: any,
262
379
  ): [string | null, string | null] {
@@ -264,10 +381,23 @@ export class RedlineEngine {
264
381
  if (edit._resolved_proxy_edit) {
265
382
  edit = edit._resolved_proxy_edit;
266
383
  }
267
- const start_idx = edit._resolved_start_idx;
384
+ let start_idx = edit._resolved_start_idx;
268
385
  if (start_idx === undefined || start_idx === null) return [null, null];
269
- const target_text = edit.target_text || "";
270
- const new_text = edit.new_text || "";
386
+ let target_text = edit.target_text || "";
387
+ let new_text = edit.new_text || "";
388
+
389
+ const [clean_target, target_style] = this._parse_markdown_style(target_text);
390
+ if (target_style && target_style.startsWith("Heading")) {
391
+ const prefix_len = target_text.length - clean_target.length;
392
+ start_idx += prefix_len;
393
+ target_text = clean_target;
394
+ }
395
+
396
+ const [clean_new, new_style] = this._parse_markdown_style(new_text);
397
+ if (new_style && new_style.startsWith("Heading")) {
398
+ new_text = clean_new;
399
+ }
400
+
271
401
  const length = target_text.length;
272
402
  const active_mapper = edit._active_mapper_ref || this.mapper;
273
403
  const full_text = active_mapper.full_text;
@@ -303,7 +433,11 @@ export class RedlineEngine {
303
433
  return maxId;
304
434
  }
305
435
 
306
- private _get_heading_path_and_page(start_idx: number, text: string, page_offsets: number[]): [string, number] {
436
+ private _get_heading_path_and_page(
437
+ start_idx: number,
438
+ text: string,
439
+ page_offsets: number[],
440
+ ): [string, number] {
307
441
  let page = 1;
308
442
  for (let i = 0; i < page_offsets.length; i++) {
309
443
  if (start_idx >= page_offsets[i]) {
@@ -317,14 +451,17 @@ export class RedlineEngine {
317
451
  const lines = textBefore.split("\n");
318
452
  const path: string[] = [];
319
453
  let current_level = 999;
320
-
454
+
321
455
  for (let i = lines.length - 1; i >= 0; i--) {
322
456
  const line = lines[i];
323
457
  const m = line.match(/^(#{1,6})\s+(.*)/);
324
458
  if (m) {
325
459
  const level = m[1].length;
326
460
  if (level < current_level) {
327
- let cleanHeading = m[2].replace(/\*\*|__|[*_]/g, "").replace(/\{#[^}]+\}/g, "").trim();
461
+ let cleanHeading = m[2]
462
+ .replace(/\*\*|__|[*_]/g, "")
463
+ .replace(/\{#[^}]+\}/g, "")
464
+ .trim();
328
465
  if (cleanHeading.length > 80) {
329
466
  cleanHeading = cleanHeading.substring(0, 80) + "...";
330
467
  }
@@ -1220,33 +1357,39 @@ export class RedlineEngine {
1220
1357
  }
1221
1358
  }
1222
1359
 
1223
- public validate_edits(edits: any[]): string[] {
1360
+ public validate_edits(edits: any[], index_offset: number = 0): string[] {
1224
1361
  const errors: string[] = [];
1225
1362
  if (!this.mapper.full_text) this.mapper["_build_map"]();
1226
1363
 
1227
- errors.push(...validate_edit_strings(edits));
1364
+ errors.push(...validate_edit_strings(edits, index_offset));
1228
1365
 
1229
1366
  for (let i = 0; i < edits.length; i++) {
1230
1367
  const edit = edits[i];
1231
1368
  if (typeof edit !== "object" || edit === null) {
1232
1369
  errors.push(
1233
- `- Edit ${i + 1} Failed: Invalid change format. Expected a JSON object, but received a primitive ${typeof edit}. Do not pass raw strings.`
1370
+ `- Edit ${i + 1 + index_offset} Failed: Invalid change format. Expected a JSON object, but received a primitive ${typeof edit}. Do not pass raw strings.`,
1234
1371
  );
1235
1372
  continue;
1236
1373
  }
1237
1374
  if (!edit.target_text) continue;
1238
-
1375
+
1239
1376
  const is_regex = (edit as any).regex || false;
1240
1377
  const match_mode = (edit as any).match_mode || "strict";
1241
1378
 
1242
- let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
1379
+ let matches = this.mapper.find_all_match_indices(
1380
+ edit.target_text,
1381
+ is_regex,
1382
+ );
1243
1383
  let activeText = this.mapper.full_text;
1244
1384
  let target_mapper = this.mapper;
1245
1385
 
1246
1386
  if (matches.length === 0) {
1247
1387
  if (!this.clean_mapper)
1248
1388
  this.clean_mapper = new DocumentMapper(this.doc, true);
1249
- matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
1389
+ matches = this.clean_mapper.find_all_match_indices(
1390
+ edit.target_text,
1391
+ is_regex,
1392
+ );
1250
1393
  if (matches.length > 0) {
1251
1394
  activeText = this.clean_mapper.full_text;
1252
1395
  target_mapper = this.clean_mapper;
@@ -1278,7 +1421,10 @@ export class RedlineEngine {
1278
1421
  if (!this.original_mapper) {
1279
1422
  this.original_mapper = new DocumentMapper(this.doc, false, true);
1280
1423
  }
1281
- const orig_matches = this.original_mapper.find_all_match_indices(edit.target_text, is_regex);
1424
+ const orig_matches = this.original_mapper.find_all_match_indices(
1425
+ edit.target_text,
1426
+ is_regex,
1427
+ );
1282
1428
  if (orig_matches.length > 0) {
1283
1429
  is_deleted_text = true;
1284
1430
  for (const [start, length] of orig_matches) {
@@ -1289,7 +1435,10 @@ export class RedlineEngine {
1289
1435
  if (s.run !== null) {
1290
1436
  let parent = s.run._element as Node | null;
1291
1437
  while (parent) {
1292
- if (parent.nodeType === 1 && (parent as Element).tagName === "w:del") {
1438
+ if (
1439
+ parent.nodeType === 1 &&
1440
+ (parent as Element).tagName === "w:del"
1441
+ ) {
1293
1442
  const auth = (parent as Element).getAttribute("w:author");
1294
1443
  if (auth) {
1295
1444
  deleted_authors.add(auth);
@@ -1306,30 +1455,36 @@ export class RedlineEngine {
1306
1455
 
1307
1456
  if (matches.length === 0) {
1308
1457
  if (is_deleted_text) {
1309
- const author_phrase = deleted_authors.size > 0
1310
- ? `by ${Array.from(deleted_authors).sort().join(", ")}`
1311
- : "by an existing revision";
1458
+ const author_phrase =
1459
+ deleted_authors.size > 0
1460
+ ? `by ${Array.from(deleted_authors).sort().join(", ")}`
1461
+ : "by an existing revision";
1312
1462
  errors.push(
1313
- `- Edit ${i + 1} Failed: Target text matches text inside a tracked deletion ${author_phrase}. Reject/accept that change first or target the active replacement text instead.`,
1463
+ `- Edit ${i + 1 + index_offset} Failed: Target text matches text inside a tracked deletion ${author_phrase}. Reject/accept that change first or target the active replacement text instead.`,
1314
1464
  );
1315
1465
  } else {
1466
+ const hint = this._nearest_match_hint(edit.target_text, is_regex);
1316
1467
  errors.push(
1317
- `- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`,
1468
+ `- Edit ${i + 1 + index_offset} Failed: Target text not found in document:\n "${edit.target_text}"${hint}`,
1318
1469
  );
1319
1470
  }
1320
1471
  } else if (matches.length > 1 && match_mode === "strict") {
1321
- const positions: [number, number][] = matches.map(([start, length]) => [
1322
- start,
1323
- start + length,
1324
- ]);
1325
- errors.push(
1326
- format_ambiguity_error(
1327
- i + 1,
1328
- edit.target_text,
1329
- activeText,
1330
- positions,
1331
- ),
1332
- );
1472
+ if (edit.target_text.includes("|")) {
1473
+ matches = matches.slice(0, 1);
1474
+ } else {
1475
+ const positions: [number, number][] = matches.map(([start, length]) => [
1476
+ start,
1477
+ start + length,
1478
+ ]);
1479
+ errors.push(
1480
+ format_ambiguity_error(
1481
+ i + 1 + index_offset,
1482
+ edit.target_text,
1483
+ activeText,
1484
+ positions,
1485
+ ),
1486
+ );
1487
+ }
1333
1488
  }
1334
1489
 
1335
1490
  // BUG-23-4: when the effective (context-trimmed) target spans a
@@ -1354,7 +1509,7 @@ export class RedlineEngine {
1354
1509
  parts[parts.length - 1].trim() !== ""
1355
1510
  ) {
1356
1511
  errors.push(
1357
- `- Edit ${i + 1} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1512
+ `- Edit ${i + 1 + index_offset} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1358
1513
  );
1359
1514
  }
1360
1515
  } else {
@@ -1365,7 +1520,7 @@ export class RedlineEngine {
1365
1520
  parts[parts.length - 1].trim() !== ""
1366
1521
  ) {
1367
1522
  errors.push(
1368
- `- Edit ${i + 1} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1523
+ `- Edit ${i + 1 + index_offset} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1369
1524
  );
1370
1525
  }
1371
1526
  }
@@ -1385,7 +1540,13 @@ export class RedlineEngine {
1385
1540
  ).filter((n) => n.getAttribute("w:id") === s.ins_id);
1386
1541
  if (insNodes.length > 0) {
1387
1542
  const auth = insNodes[0].getAttribute("w:author");
1388
- if (auth && auth !== this.author) nestedAuthors.add(auth);
1543
+ if (auth && auth !== this.author) {
1544
+ const is_fully_contained_in_ins = start >= s.start && (start + length) <= s.end;
1545
+ const is_lockout = is_fully_contained_in_ins || match_mode === "all";
1546
+ if (is_lockout) {
1547
+ nestedAuthors.add(auth);
1548
+ }
1549
+ }
1389
1550
  }
1390
1551
  }
1391
1552
  if (s.comment_ids) {
@@ -1399,7 +1560,7 @@ export class RedlineEngine {
1399
1560
  }
1400
1561
  if (nestedAuthors.size > 0) {
1401
1562
  errors.push(
1402
- `- Edit ${i + 1} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
1563
+ `- Edit ${i + 1 + index_offset} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
1403
1564
  );
1404
1565
  }
1405
1566
  }
@@ -1481,22 +1642,13 @@ export class RedlineEngine {
1481
1642
  }
1482
1643
 
1483
1644
  if (dry_run) {
1484
- const baselines = new Map<any, Element>();
1485
- for (const part of this.doc.pkg.parts) {
1486
- if (part._element) {
1487
- baselines.set(part, part._element.cloneNode(true) as Element);
1488
- }
1489
- }
1645
+ const snapshot = takeSnapshot(this.doc);
1646
+ const originalCurrentId = this.current_id;
1490
1647
  try {
1491
1648
  return this._process_batch_internal(changes, true);
1492
1649
  } finally {
1493
- for (const [part, originalEl] of baselines.entries()) {
1494
- const doc = part._element.ownerDocument;
1495
- if (doc && doc.documentElement) {
1496
- doc.replaceChild(originalEl, doc.documentElement);
1497
- }
1498
- part._element = originalEl;
1499
- }
1650
+ restoreSnapshot(this.doc, snapshot);
1651
+ this.current_id = originalCurrentId;
1500
1652
  this.mapper = new DocumentMapper(this.doc);
1501
1653
  this.comments_manager = new CommentsManager(this.doc);
1502
1654
  this.clean_mapper = null;
@@ -1510,23 +1662,95 @@ export class RedlineEngine {
1510
1662
  changes: DocumentChange[],
1511
1663
  dry_run_mode: boolean = false,
1512
1664
  ): any {
1665
+ // Pre-process edits: strip identical leading heading hashes from target_text and new_text
1666
+ for (const c of changes) {
1667
+ if (c && typeof c === "object" && (c as any).type === "modify" && (c as any).target_text && (c as any).new_text) {
1668
+ const [strippedTarget, strippedNew] = stripMatchingHeadingHashes((c as any).target_text, (c as any).new_text);
1669
+ (c as any).target_text = strippedTarget;
1670
+ (c as any).new_text = strippedNew;
1671
+ }
1672
+ }
1673
+
1513
1674
  this.skipped_details = [];
1514
- const actions = changes.filter((c) =>
1515
- c !== null && typeof c === "object" && ["accept", "reject", "reply"].includes(c.type),
1675
+
1676
+ const actions = changes.filter(
1677
+ (c) =>
1678
+ c !== null &&
1679
+ typeof c === "object" &&
1680
+ ["accept", "reject", "reply"].includes(c.type),
1516
1681
  );
1517
1682
  const edits = changes.filter(
1518
- (c) => c === null || typeof c !== "object" || !["accept", "reject", "reply"].includes(c.type),
1683
+ (c) =>
1684
+ c === null ||
1685
+ typeof c !== "object" ||
1686
+ !["accept", "reject", "reply"].includes(c.type),
1519
1687
  );
1520
1688
 
1521
- // BUG-7: Unified single-pass validation in wet-run / standard mode
1522
- if (!dry_run_mode) {
1523
- const all_errors: string[] = [];
1524
- if (actions.length > 0) {
1525
- all_errors.push(...this.validate_review_actions(actions));
1689
+ // Run edits_for_merge logic to unwrap collaborative active insertions
1690
+ let mapper_dirty = false;
1691
+ for (const edit of edits as any[]) {
1692
+ if (typeof edit !== "object" || edit === null || !edit.target_text) continue;
1693
+ const is_regex = edit.regex || false;
1694
+ let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
1695
+ let target_mapper = this.mapper;
1696
+ if (matches.length === 0) {
1697
+ if (!this.clean_mapper) {
1698
+ this.clean_mapper = new DocumentMapper(this.doc, true);
1699
+ }
1700
+ matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
1701
+ target_mapper = this.clean_mapper!;
1526
1702
  }
1527
- if (edits.length > 0) {
1528
- all_errors.push(...this.validate_edits(edits));
1703
+ for (const [start, length] of matches) {
1704
+ const spans = target_mapper.spans.filter(
1705
+ (s) => s.end > start && s.start < start + length,
1706
+ );
1707
+ for (const s of spans) {
1708
+ if (s.ins_id) {
1709
+ const insNodes = findAllDescendants(this.doc.element, "w:ins").filter(
1710
+ (n) => n.getAttribute("w:id") === s.ins_id,
1711
+ );
1712
+ if (insNodes.length > 0) {
1713
+ const auth = insNodes[0].getAttribute("w:author");
1714
+ if (auth && auth !== this.author) {
1715
+ const is_fully_contained_in_ins = start >= s.start && (start + length) <= s.end;
1716
+ const match_mode = edit.match_mode || "strict";
1717
+ if (!is_fully_contained_in_ins && match_mode !== "all") {
1718
+ const node = insNodes[0];
1719
+ this._clean_wrapping_comments(node);
1720
+ const parent = node.parentNode as Element | null;
1721
+ if (parent) {
1722
+ if (parent.tagName === "w:trPr") {
1723
+ parent.removeChild(node);
1724
+ } else {
1725
+ while (node.firstChild) {
1726
+ parent.insertBefore(node.firstChild, node);
1727
+ }
1728
+ parent.removeChild(node);
1729
+ }
1730
+ }
1731
+ mapper_dirty = true;
1732
+ }
1733
+ }
1734
+ }
1735
+ }
1736
+ }
1529
1737
  }
1738
+ }
1739
+ if (mapper_dirty) {
1740
+ this.mapper = new DocumentMapper(this.doc);
1741
+ this.clean_mapper = null;
1742
+ }
1743
+
1744
+ // BUG-7: Unified single-pass validation in wet-run / standard mode.
1745
+ if (!dry_run_mode) {
1746
+ const action_errors =
1747
+ actions.length > 0 ? this.validate_review_actions(actions) : [];
1748
+ const validate_edits_now = edits.length > 0 && action_errors.length > 0;
1749
+ const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
1750
+ const all_errors = [
1751
+ ...action_errors,
1752
+ ...edit_errors,
1753
+ ];
1530
1754
  if (all_errors.length > 0) {
1531
1755
  throw new BatchValidationError(all_errors);
1532
1756
  }
@@ -1564,8 +1788,9 @@ export class RedlineEngine {
1564
1788
 
1565
1789
  if (edits.length > 0) {
1566
1790
  if (dry_run_mode) {
1567
- for (const edit of edits) {
1568
- const single_errors = this.validate_edits([edit]);
1791
+ for (let i = 0; i < edits.length; i++) {
1792
+ const edit = edits[i];
1793
+ const single_errors = this.validate_edits([edit], i);
1569
1794
  const warning = this._check_punctuation_warning(
1570
1795
  (edit as any).target_text || "",
1571
1796
  );
@@ -1599,6 +1824,8 @@ export class RedlineEngine {
1599
1824
  occurrences_modified: (edit as any)._occurrences_modified || 0,
1600
1825
  match_mode: (edit as any).match_mode || "strict",
1601
1826
  });
1827
+ this.mapper = new DocumentMapper(this.doc);
1828
+ this.clean_mapper = null;
1602
1829
  } else {
1603
1830
  skipped_edits++;
1604
1831
  const error_msg =
@@ -1617,16 +1844,40 @@ export class RedlineEngine {
1617
1844
  }
1618
1845
  }
1619
1846
  } else {
1620
- const errors = this.validate_edits(edits);
1621
- if (errors.length > 0) {
1622
- throw new BatchValidationError(errors);
1847
+ // Simulated dry-run sequentially for wet-run validation parity
1848
+ const snapshot = takeSnapshot(this.doc);
1849
+ const originalCurrentId = this.current_id;
1850
+ try {
1851
+ const sequential_errors: string[] = [];
1852
+ for (let i = 0; i < edits.length; i++) {
1853
+ const edit = edits[i];
1854
+ const single_errors = this.validate_edits([edit], i);
1855
+ if (single_errors.length > 0) {
1856
+ sequential_errors.push(...single_errors);
1857
+ } else {
1858
+ this.apply_edits([edit], page_offsets);
1859
+ this.mapper = new DocumentMapper(this.doc);
1860
+ this.clean_mapper = null;
1861
+ }
1862
+ }
1863
+ if (sequential_errors.length > 0) {
1864
+ throw new BatchValidationError(sequential_errors);
1865
+ }
1866
+ } catch (err) {
1867
+ restoreSnapshot(this.doc, snapshot);
1868
+ this.current_id = originalCurrentId;
1869
+ this.mapper = new DocumentMapper(this.doc);
1870
+ this.comments_manager = new CommentsManager(this.doc);
1871
+ this.clean_mapper = null;
1872
+ throw err;
1623
1873
  }
1624
- const cloned_edits = edits.map((e) => JSON.parse(JSON.stringify(e)));
1625
- const res = this.apply_edits(cloned_edits, page_offsets);
1626
- applied_edits = cloned_edits.filter((e) => (e as any)._applied_status).length;
1627
- skipped_edits = cloned_edits.length - applied_edits;
1628
1874
 
1629
- for (const edit of cloned_edits) {
1875
+ applied_edits = edits.filter(
1876
+ (e) => (e as any)._applied_status,
1877
+ ).length;
1878
+ skipped_edits = edits.length - applied_edits;
1879
+
1880
+ for (const edit of edits) {
1630
1881
  const success = (edit as any)._applied_status || false;
1631
1882
  const error_msg = (edit as any)._error_msg || null;
1632
1883
  const warning = this._check_punctuation_warning(
@@ -1655,7 +1906,6 @@ export class RedlineEngine {
1655
1906
  }
1656
1907
  }
1657
1908
  }
1658
-
1659
1909
  return {
1660
1910
  actions_applied: applied_actions,
1661
1911
  actions_skipped: skipped_actions,
@@ -1668,7 +1918,10 @@ export class RedlineEngine {
1668
1918
  };
1669
1919
  }
1670
1920
 
1671
- public apply_edits(edits: any[], page_offsets: number[] = []): [number, number] {
1921
+ public apply_edits(
1922
+ edits: any[],
1923
+ page_offsets: number[] = [],
1924
+ ): [number, number] {
1672
1925
  let applied = 0;
1673
1926
  let skipped = 0;
1674
1927
 
@@ -1803,15 +2056,24 @@ export class RedlineEngine {
1803
2056
  const parent = edit._parent_edit_ref;
1804
2057
  if (parent) {
1805
2058
  parent._applied_status = true;
1806
- parent._occurrences_modified = (parent._occurrences_modified || 0) + 1;
1807
- const [path, page] = this._get_heading_path_and_page(start, this.mapper.full_text, page_offsets);
2059
+ parent._occurrences_modified =
2060
+ (parent._occurrences_modified || 0) + 1;
2061
+ const [path, page] = this._get_heading_path_and_page(
2062
+ start,
2063
+ this.mapper.full_text,
2064
+ page_offsets,
2065
+ );
1808
2066
  const pages: number[] = parent._pages || [];
1809
2067
  if (!pages.includes(page)) pages.unshift(page);
1810
2068
  parent._pages = pages;
1811
2069
  parent._heading_path = path;
1812
2070
  } else {
1813
2071
  edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
1814
- const [path, page] = this._get_heading_path_and_page(start, this.mapper.full_text, page_offsets);
2072
+ const [path, page] = this._get_heading_path_and_page(
2073
+ start,
2074
+ this.mapper.full_text,
2075
+ page_offsets,
2076
+ );
1815
2077
  const pages: number[] = edit._pages || [];
1816
2078
  if (!pages.includes(page)) pages.unshift(page);
1817
2079
  edit._pages = pages;
@@ -1993,13 +2255,19 @@ export class RedlineEngine {
1993
2255
  const is_regex = edit.regex || false;
1994
2256
  const match_mode = edit.match_mode || "strict";
1995
2257
 
1996
- let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
2258
+ let matches = this.mapper.find_all_match_indices(
2259
+ edit.target_text,
2260
+ is_regex,
2261
+ );
1997
2262
  let use_clean_map = false;
1998
2263
 
1999
2264
  if (matches.length === 0) {
2000
2265
  if (!this.clean_mapper)
2001
2266
  this.clean_mapper = new DocumentMapper(this.doc, true);
2002
- matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
2267
+ matches = this.clean_mapper.find_all_match_indices(
2268
+ edit.target_text,
2269
+ is_regex,
2270
+ );
2003
2271
  if (matches.length > 0) use_clean_map = true;
2004
2272
  else return null;
2005
2273
  }
@@ -2009,7 +2277,8 @@ export class RedlineEngine {
2009
2277
  let live_matches: [number, number][] = [];
2010
2278
  for (const [s, match_len] of matches) {
2011
2279
  const realSpans = active_mapper.spans.filter(
2012
- (span) => span.run !== null && span.end > s && span.start < s + match_len
2280
+ (span) =>
2281
+ span.run !== null && span.end > s && span.start < s + match_len,
2013
2282
  );
2014
2283
  if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
2015
2284
  live_matches.push([s, match_len]);
@@ -2031,23 +2300,72 @@ export class RedlineEngine {
2031
2300
  );
2032
2301
  let current_effective_new_text = edit.new_text || "";
2033
2302
 
2303
+ // Cell anchors ({#cell:<paraId>}) are pure position markers with no real
2304
+ // content — they let the model address an empty (or any) table cell that
2305
+ // has no run to diff against. Treat such a target as a clean INSERTION at
2306
+ // the anchor's paragraph: never delete the marker, never run trim_common_context
2307
+ // (which refuses to split inside {#...} markup and yields a no-op MODIFICATION).
2308
+ // Strip any echoed anchor from new_text so the model can send either
2309
+ // "June 22, 2026" or "June 22, 2026{#cell:...}" and get the same result.
2310
+ if (/^\{#cell:[^}]+\}$/.test(actual_doc_text.trim())) {
2311
+ let ins_text = current_effective_new_text;
2312
+ // Drop a leading/trailing copy of the same anchor token if echoed.
2313
+ ins_text = ins_text.split(actual_doc_text.trim()).join("");
2314
+ if (ins_text) {
2315
+ all_sub_edits.push({
2316
+ type: "modify",
2317
+ target_text: "",
2318
+ new_text: ins_text,
2319
+ comment: edit.comment,
2320
+ // Insert at the anchor token's start so the new run lands inside
2321
+ // the cell paragraph that get_insertion_anchor resolves there.
2322
+ _match_start_index: start_idx,
2323
+ _internal_op: "INSERTION",
2324
+ _active_mapper_ref: active_mapper,
2325
+ });
2326
+ } else if (edit.comment) {
2327
+ // Anchor target with empty effective new_text but a comment: attach
2328
+ // the comment to the cell paragraph.
2329
+ all_sub_edits.push({
2330
+ type: "modify",
2331
+ target_text: "",
2332
+ new_text: "",
2333
+ comment: edit.comment,
2334
+ _match_start_index: start_idx,
2335
+ _internal_op: "COMMENT_ONLY",
2336
+ _active_mapper_ref: active_mapper,
2337
+ });
2338
+ }
2339
+ continue;
2340
+ }
2341
+
2034
2342
  if (is_regex && current_effective_new_text) {
2035
2343
  try {
2036
- current_effective_new_text = actual_doc_text.replace(new RegExp(edit.target_text), current_effective_new_text);
2344
+ current_effective_new_text = actual_doc_text.replace(
2345
+ new RegExp(edit.target_text),
2346
+ current_effective_new_text,
2347
+ );
2037
2348
  } catch (e) {}
2038
2349
  }
2039
2350
 
2040
- const [edit_target_clean, edit_target_style] = this._parse_markdown_style(edit.target_text);
2041
- const [edit_new_clean, edit_new_style] = this._parse_markdown_style(current_effective_new_text);
2351
+ const [edit_target_clean, edit_target_style] = this._parse_markdown_style(
2352
+ edit.target_text,
2353
+ );
2354
+ const [edit_new_clean, edit_new_style] = this._parse_markdown_style(
2355
+ current_effective_new_text,
2356
+ );
2042
2357
 
2043
2358
  if (edit_target_style !== edit_new_style) {
2044
2359
  const [actual_clean] = this._parse_markdown_style(actual_doc_text);
2045
2360
  const final_target = actual_clean;
2046
2361
  const final_new = edit_new_clean;
2047
- const style_op = final_target === final_new ? "STYLE_ONLY" : "STYLE_AND_TEXT";
2362
+ const style_op =
2363
+ final_target === final_new ? "STYLE_ONLY" : "STYLE_AND_TEXT";
2048
2364
  const prefix_offset = actual_doc_text.indexOf(actual_clean);
2049
- const effective_start_idx = start_idx + (prefix_offset !== -1 ? prefix_offset : 0);
2050
- const resolved_style = edit_new_style !== null ? edit_new_style : "Normal";
2365
+ const effective_start_idx =
2366
+ start_idx + (prefix_offset !== -1 ? prefix_offset : 0);
2367
+ const resolved_style =
2368
+ edit_new_style !== null ? edit_new_style : "Normal";
2051
2369
 
2052
2370
  all_sub_edits.push({
2053
2371
  type: "modify",
@@ -2085,7 +2403,9 @@ export class RedlineEngine {
2085
2403
 
2086
2404
  if (current_effective_new_text.startsWith(actual_doc_text)) {
2087
2405
  effective_op = "INSERTION";
2088
- final_new = current_effective_new_text.substring(actual_doc_text.length);
2406
+ final_new = current_effective_new_text.substring(
2407
+ actual_doc_text.length,
2408
+ );
2089
2409
  effective_start_idx = start_idx + match_len;
2090
2410
  } else {
2091
2411
  const [prefix_len, suffix_len] = trim_common_context(
@@ -2172,7 +2492,10 @@ export class RedlineEngine {
2172
2492
  while (end_p && end_p.tagName !== "w:p")
2173
2493
  end_p = end_p.parentNode as Element;
2174
2494
  if (start_p && end_p) {
2175
- const ascend_to_paragraph_child = (el: Element, p: Element): Element => {
2495
+ const ascend_to_paragraph_child = (
2496
+ el: Element,
2497
+ p: Element,
2498
+ ): Element => {
2176
2499
  let cur: Element = el;
2177
2500
  while (cur.parentNode && cur.parentNode !== p) {
2178
2501
  cur = cur.parentNode as Element;
@@ -2182,7 +2505,12 @@ export class RedlineEngine {
2182
2505
  const first_anchor = ascend_to_paragraph_child(first_el, start_p);
2183
2506
  const last_anchor = ascend_to_paragraph_child(last_el, end_p);
2184
2507
  if (start_p === end_p) {
2185
- this._attach_comment(start_p, first_anchor, last_anchor, edit.comment);
2508
+ this._attach_comment(
2509
+ start_p,
2510
+ first_anchor,
2511
+ last_anchor,
2512
+ edit.comment,
2513
+ );
2186
2514
  } else {
2187
2515
  this._attach_comment_spanning(
2188
2516
  start_p,