@adeu/core 1.17.2 → 1.18.1

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
@@ -322,8 +322,19 @@ export class RedlineEngine {
322
322
  this.comments_manager = new CommentsManager(this.doc);
323
323
  }
324
324
 
325
+ /**
326
+ * Return a hint when a short, single-token anchor contains punctuation that
327
+ * can split awkwardly, else null.
328
+ *
329
+ * Surface this ONLY for edits that actually failed to match/apply. On a
330
+ * successful edit the batch report already carries the redline preview, so
331
+ * emitting this would be a false positive: the punctuation (dates,
332
+ * `[_name_]` placeholders, `____` blanks) is frequently the literal target
333
+ * and the edit succeeds despite it. Mirrors the Python engine.
334
+ */
325
335
  private _check_punctuation_warning(target_text: string): string | null {
326
336
  if (!target_text) return null;
337
+ if (target_text.length > 20 || target_text.includes(" ")) return null;
327
338
  if (target_text.includes("_") || target_text.includes("-")) {
328
339
  return `Warning: target_text '${target_text}' contains tokenization-splitting punctuation ('_' or '-'). This can trigger mid-word splits in the diff engine. Consider using a longer plain-prose anchor.`;
329
340
  }
@@ -673,6 +684,106 @@ export class RedlineEngine {
673
684
  }
674
685
  }
675
686
 
687
+ /**
688
+ * Revert every tracked change, returning the document to the state it had
689
+ * before any revision was proposed. The exact inverse of
690
+ * accept_all_revisions:
691
+ *
692
+ * - <w:ins> -> removed together with all of its content (the proposed
693
+ * insertion never existed); an inserted row (<w:ins> in
694
+ * <w:trPr>) drops the whole row.
695
+ * - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
696
+ * <w:t> again); a row-deletion mark in <w:trPr> is removed so
697
+ * the row survives.
698
+ * - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
699
+ *
700
+ * Comments are annotations, not revisions, so standalone comments are left in
701
+ * place; only anchors stranded inside a rejected insertion are cleaned up.
702
+ *
703
+ * Insertions are reverted before deletions are restored so a deletion nested
704
+ * inside a foreign author's insertion is removed wholesale with the insertion
705
+ * — the contingent text disappears rather than being promoted to committed
706
+ * body text.
707
+ *
708
+ * Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
709
+ * pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
710
+ * to the extent of dropping/keeping the mark; the original paragraph boundary
711
+ * is not reconstructed, because the merge protocol coalesces paragraphs
712
+ * destructively at edit time. Reverting run-level insertions/deletions (the
713
+ * common case) is exact. This limitation is shared with the Python engine.
714
+ */
715
+ public reject_all_revisions() {
716
+ const parts_to_process: Element[] = [this.doc.element];
717
+
718
+ for (const part of this.doc.pkg.parts) {
719
+ if (part === this.doc.part) continue;
720
+ if (
721
+ part.contentType.includes("wordprocessingml") &&
722
+ part.contentType.endsWith("+xml")
723
+ ) {
724
+ parts_to_process.push(part._element);
725
+ }
726
+ }
727
+
728
+ for (const root_element of parts_to_process) {
729
+ // 1. Reject insertions: drop the <w:ins> and everything inside it.
730
+ // Document order means an outer <w:ins> is handled before a nested
731
+ // one; removing the outer detaches the inner (guarded below).
732
+ const insNodes = findAllDescendants(root_element, "w:ins");
733
+ for (const ins of insNodes) {
734
+ const parent = ins.parentNode as Element | null;
735
+ if (!parent) continue;
736
+ this._clean_wrapping_comments(ins);
737
+ this._delete_comments_in_element(ins);
738
+ if (parent.tagName === "w:trPr") {
739
+ const row = parent.parentNode as Element | null;
740
+ if (row && row.parentNode) {
741
+ row.parentNode.removeChild(row);
742
+ }
743
+ } else {
744
+ parent.removeChild(ins);
745
+ }
746
+ }
747
+
748
+ // 2. Reject paragraph-mark deletions: keep the paragraph break.
749
+ const pNodes = findAllDescendants(root_element, "w:p");
750
+ for (const p of pNodes) {
751
+ const pPr = findChild(p, "w:pPr");
752
+ if (pPr) {
753
+ const rPr = findChild(pPr, "w:rPr");
754
+ const delMark = rPr ? findChild(rPr, "w:del") : null;
755
+ if (rPr && delMark) {
756
+ rPr.removeChild(delMark);
757
+ }
758
+ }
759
+ }
760
+
761
+ // 3. Reject deletions: restore the original text.
762
+ const delNodes = findAllDescendants(root_element, "w:del");
763
+ for (const d of delNodes) {
764
+ const parent = d.parentNode as Element | null;
765
+ if (!parent) continue;
766
+ this._clean_wrapping_comments(d);
767
+ if (parent.tagName === "w:trPr") {
768
+ parent.removeChild(d);
769
+ continue;
770
+ }
771
+ const delTexts = Array.from(d.getElementsByTagName("w:delText"));
772
+ for (const dt of delTexts) {
773
+ const t = d.ownerDocument!.createElement("w:t");
774
+ t.textContent = dt.textContent;
775
+ if (dt.hasAttribute("xml:space"))
776
+ t.setAttribute("xml:space", "preserve");
777
+ dt.parentNode?.replaceChild(t, dt);
778
+ }
779
+ while (d.firstChild) {
780
+ parent.insertBefore(d.firstChild, d);
781
+ }
782
+ parent.removeChild(d);
783
+ }
784
+ }
785
+ }
786
+
676
787
  private _getNextId(): string {
677
788
  this.current_id++;
678
789
  return this.current_id.toString();
@@ -1501,38 +1612,63 @@ export class RedlineEngine {
1501
1612
  (edit.new_text || "").length - sfx,
1502
1613
  );
1503
1614
  if (final_target.includes("\n\n")) {
1504
- if (final_new.includes("\n\n")) {
1505
- const parts = matched.split("\n\n");
1506
- if (
1507
- parts.length >= 2 &&
1508
- parts[0].trim() !== "" &&
1509
- parts[parts.length - 1].trim() !== ""
1510
- ) {
1511
- errors.push(
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.`,
1513
- );
1514
- }
1515
- } else {
1516
- const parts = final_target.split("\n\n");
1517
- if (
1518
- parts.length >= 2 &&
1519
- parts[0].trim() !== "" &&
1520
- parts[parts.length - 1].trim() !== ""
1521
- ) {
1522
- errors.push(
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.`,
1524
- );
1615
+ // A *balanced* multi-paragraph modification (target and replacement
1616
+ // carry the same number of paragraph breaks) is safe: it is split
1617
+ // into one sub-edit per paragraph segment and applied, leaving the
1618
+ // structural \n\n breaks untouched. Only reject when the paragraph
1619
+ // structure would actually change (a merge or split), which cannot be
1620
+ // expressed as a per-paragraph text replacement. See
1621
+ // _pre_resolve_heuristic_edit.
1622
+ const balanced =
1623
+ matched.split("\n\n").length ===
1624
+ (edit.new_text || "").split("\n\n").length;
1625
+ if (!balanced) {
1626
+ if (final_new.includes("\n\n")) {
1627
+ const parts = matched.split("\n\n");
1628
+ if (
1629
+ parts.length >= 2 &&
1630
+ parts[0].trim() !== "" &&
1631
+ parts[parts.length - 1].trim() !== ""
1632
+ ) {
1633
+ errors.push(
1634
+ `- 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.`,
1635
+ );
1636
+ }
1637
+ } else {
1638
+ const parts = final_target.split("\n\n");
1639
+ if (
1640
+ parts.length >= 2 &&
1641
+ parts[0].trim() !== "" &&
1642
+ parts[parts.length - 1].trim() !== ""
1643
+ ) {
1644
+ errors.push(
1645
+ `- 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.`,
1646
+ );
1647
+ }
1525
1648
  }
1526
1649
  }
1527
1650
  }
1528
1651
  }
1529
1652
 
1530
1653
  for (const [start, length] of matches) {
1531
- const spans = this.mapper.spans.filter(
1654
+ // Filter spans from the SAME mapper the match indices came from
1655
+ // (target_mapper may be the clean mapper); using this.mapper.spans here
1656
+ // would read a different coordinate space and miss the foreign <w:ins>
1657
+ // overlap for clean-mapper-resolved targets — silently letting a
1658
+ // partial straddle through. (Python filters target_mapper.spans too.)
1659
+ const spans = target_mapper.spans.filter(
1532
1660
  (s) => s.end > start && s.start < start + length,
1533
1661
  );
1534
- const nestedAuthors = new Set<string>();
1662
+ const insAuthors = new Set<string>();
1663
+ const commentAuthors = new Set<string>();
1664
+ // Does any real (run-backed) text in the target lie OUTSIDE a foreign
1665
+ // insertion? If so the target only partially overlaps the insertion and
1666
+ // replacing it as one span would straddle the <w:ins> boundary — that
1667
+ // case must still be refused.
1668
+ let hasNonForeignRealText = false;
1535
1669
  for (const s of spans) {
1670
+ if (s.run === null) continue;
1671
+ let isForeignIns = false;
1536
1672
  if (s.ins_id) {
1537
1673
  const insNodes = findAllDescendants(
1538
1674
  this.doc.element,
@@ -1541,24 +1677,45 @@ export class RedlineEngine {
1541
1677
  if (insNodes.length > 0) {
1542
1678
  const auth = insNodes[0].getAttribute("w:author");
1543
1679
  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
- }
1680
+ insAuthors.add(auth);
1681
+ isForeignIns = true;
1549
1682
  }
1550
1683
  }
1551
1684
  }
1685
+ if (!isForeignIns) hasNonForeignRealText = true;
1686
+ }
1687
+ for (const s of spans) {
1552
1688
  if (s.comment_ids) {
1553
1689
  for (const cid of s.comment_ids) {
1554
1690
  const c_data = this.mapper.comments_map[cid];
1555
1691
  if (c_data && c_data.author && c_data.author !== this.author) {
1556
- nestedAuthors.add(c_data.author);
1692
+ commentAuthors.add(c_data.author);
1557
1693
  }
1558
1694
  }
1559
1695
  }
1560
1696
  }
1561
- if (nestedAuthors.size > 0) {
1697
+ if (insAuthors.size > 0 || commentAuthors.size > 0) {
1698
+ // A single (strict/first) modification whose target lies ENTIRELY
1699
+ // inside foreign-authored insertion(s), with no foreign comment
1700
+ // overlap, is allowed: track_delete_run splits the enclosing <w:ins>
1701
+ // and nests the change, producing valid tracked-change XML. Refuse the
1702
+ // remaining cases — match_mode "all" fan-outs, partial overlaps that
1703
+ // straddle the insertion boundary, and edits touching another author's
1704
+ // comment range.
1705
+ const fullyWithinForeignIns =
1706
+ insAuthors.size > 0 &&
1707
+ !hasNonForeignRealText &&
1708
+ commentAuthors.size === 0;
1709
+ if (
1710
+ (match_mode === "strict" || match_mode === "first") &&
1711
+ fullyWithinForeignIns
1712
+ ) {
1713
+ continue;
1714
+ }
1715
+ const nestedAuthors = new Set<string>([
1716
+ ...insAuthors,
1717
+ ...commentAuthors,
1718
+ ]);
1562
1719
  errors.push(
1563
1720
  `- 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.`,
1564
1721
  );
@@ -1686,60 +1843,17 @@ export class RedlineEngine {
1686
1843
  !["accept", "reject", "reply"].includes(c.type),
1687
1844
  );
1688
1845
 
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!;
1702
- }
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
- }
1737
- }
1738
- }
1739
- if (mapper_dirty) {
1740
- this.mapper = new DocumentMapper(this.doc);
1741
- this.clean_mapper = null;
1742
- }
1846
+ // NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
1847
+ // foreign author's <w:ins> when a strict/first edit only partially straddled
1848
+ // its boundary turning that author's tracked-inserted text into untracked
1849
+ // committed body text before the edit applied, destroying their provenance.
1850
+ // That is the same provenance-laundering failure mode the canonical engine
1851
+ // refuses, so it has been removed: a partial straddle now surfaces the
1852
+ // standard validation error ("Modification targets an active insertion from
1853
+ // another author …") via validate_edits, matching the Python engine. An edit
1854
+ // fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
1855
+ // nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
1856
+ // _insert_and_split_ins).
1743
1857
 
1744
1858
  // BUG-7: Unified single-pass validation in wet-run / standard mode.
1745
1859
  if (!dry_run_mode) {
@@ -1791,11 +1905,16 @@ export class RedlineEngine {
1791
1905
  for (let i = 0; i < edits.length; i++) {
1792
1906
  const edit = edits[i];
1793
1907
  const single_errors = this.validate_edits([edit], i);
1794
- const warning = this._check_punctuation_warning(
1795
- (edit as any).target_text || "",
1796
- );
1797
1908
  if (single_errors.length > 0) {
1798
1909
  skipped_edits++;
1910
+ // Only surface the punctuation-anchor warning when the edit actually
1911
+ // failed. A clean apply already returns the redline preview, so the
1912
+ // warning is pure noise on success — and it misleads agents into
1913
+ // hunting for a "cleaner" anchor that was never needed (e.g. on
1914
+ // placeholders/dates where the punctuation IS the literal target).
1915
+ const warning = this._check_punctuation_warning(
1916
+ (edit as any).target_text || "",
1917
+ );
1799
1918
  edits_reports.push({
1800
1919
  status: "failed",
1801
1920
  target_text: (edit as any).target_text || "",
@@ -1815,7 +1934,7 @@ export class RedlineEngine {
1815
1934
  status: "applied",
1816
1935
  target_text: (edit as any).target_text || "",
1817
1936
  new_text: (edit as any).new_text || "",
1818
- warning: warning,
1937
+ warning: null,
1819
1938
  error: null,
1820
1939
  critic_markup: previews[0],
1821
1940
  clean_text: previews[1],
@@ -1832,6 +1951,9 @@ export class RedlineEngine {
1832
1951
  this.skipped_details.length > 0
1833
1952
  ? this.skipped_details[this.skipped_details.length - 1]
1834
1953
  : "Failed to apply edit";
1954
+ const warning = this._check_punctuation_warning(
1955
+ (edit as any).target_text || "",
1956
+ );
1835
1957
  edits_reports.push({
1836
1958
  status: "failed",
1837
1959
  target_text: (edit as any).target_text || "",
@@ -1880,15 +2002,19 @@ export class RedlineEngine {
1880
2002
  for (const edit of edits) {
1881
2003
  const success = (edit as any)._applied_status || false;
1882
2004
  const error_msg = (edit as any)._error_msg || null;
1883
- const warning = this._check_punctuation_warning(
1884
- (edit as any).target_text || "",
1885
- );
1886
2005
  let critic_markup = null;
1887
2006
  let clean_text = null;
2007
+ // Punctuation-anchor warning is failure-context only: on success the
2008
+ // redline preview below already reports the change cleanly.
2009
+ let warning: string | null = null;
1888
2010
  if (success) {
1889
2011
  const previews = this._build_edit_context_previews(edit);
1890
2012
  critic_markup = previews[0];
1891
2013
  clean_text = previews[1];
2014
+ } else {
2015
+ warning = this._check_punctuation_warning(
2016
+ (edit as any).target_text || "",
2017
+ );
1892
2018
  }
1893
2019
  edits_reports.push({
1894
2020
  status: success ? "applied" : "failed",
@@ -2018,6 +2144,10 @@ export class RedlineEngine {
2018
2144
  (b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
2019
2145
  );
2020
2146
  const occupied_ranges: [number, number][] = [];
2147
+ // Sub-edits split from one balanced multi-paragraph modification share a
2148
+ // _split_group_id; count the group as a single applied edit (and a single
2149
+ // occurrence), even though it touches several paragraphs.
2150
+ const counted_split_groups = new Set<number>();
2021
2151
 
2022
2152
  for (const [edit, orig_new] of resolved_edits) {
2023
2153
  const start = edit._resolved_start_idx || 0;
@@ -2050,14 +2180,27 @@ export class RedlineEngine {
2050
2180
  }
2051
2181
 
2052
2182
  if (success) {
2053
- applied++;
2183
+ // A balanced multi-paragraph split fans one logical edit into several
2184
+ // paragraph sub-edits sharing a _split_group_id; count it once. Edits
2185
+ // with no group id (the common case) always count.
2186
+ const group_id = edit._split_group_id;
2187
+ const first_in_group =
2188
+ group_id === undefined ||
2189
+ group_id === null ||
2190
+ !counted_split_groups.has(group_id);
2191
+ if (first_in_group && group_id !== undefined && group_id !== null) {
2192
+ counted_split_groups.add(group_id);
2193
+ }
2194
+ if (first_in_group) applied++;
2054
2195
  occupied_ranges.push([start, end]);
2055
2196
  edit._applied_status = true;
2056
2197
  const parent = edit._parent_edit_ref;
2057
2198
  if (parent) {
2058
2199
  parent._applied_status = true;
2059
- parent._occurrences_modified =
2060
- (parent._occurrences_modified || 0) + 1;
2200
+ if (first_in_group) {
2201
+ parent._occurrences_modified =
2202
+ (parent._occurrences_modified || 0) + 1;
2203
+ }
2061
2204
  const [path, page] = this._get_heading_path_and_page(
2062
2205
  start,
2063
2206
  this.mapper.full_text,
@@ -2068,7 +2211,9 @@ export class RedlineEngine {
2068
2211
  parent._pages = pages;
2069
2212
  parent._heading_path = path;
2070
2213
  } else {
2071
- edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
2214
+ if (first_in_group) {
2215
+ edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
2216
+ }
2072
2217
  const [path, page] = this._get_heading_path_and_page(
2073
2218
  start,
2074
2219
  this.mapper.full_text,
@@ -2419,6 +2564,65 @@ export class RedlineEngine {
2419
2564
  final_new = current_effective_new_text.substring(prefix_len, n_end);
2420
2565
  effective_start_idx = start_idx + prefix_len;
2421
2566
 
2567
+ // Balanced multi-paragraph modification: the matched span crosses one or
2568
+ // more paragraph breaks and the replacement preserves the same number of
2569
+ // breaks. Apply it as one independent sub-edit per paragraph segment so
2570
+ // the structural \n\n breaks are left intact. Each sub-edit shares a
2571
+ // _split_group_id (the occurrence's start index) so the batch report
2572
+ // counts it as a single applied edit. Unbalanced cases (a genuine
2573
+ // paragraph merge or split) fall through to the single-span path and are
2574
+ // rejected by validate_edits.
2575
+ const target_segs = actual_doc_text.split("\n\n");
2576
+ const new_segs = current_effective_new_text.split("\n\n");
2577
+ if (
2578
+ actual_doc_text.includes("\n\n") &&
2579
+ target_segs.length === new_segs.length
2580
+ ) {
2581
+ const split_sub_edits: any[] = [];
2582
+ let seg_offset = start_idx;
2583
+ let comment_assigned = false;
2584
+ for (let k = 0; k < target_segs.length; k++) {
2585
+ const t_seg = target_segs[k];
2586
+ const n_seg = new_segs[k];
2587
+ if (t_seg !== n_seg) {
2588
+ const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
2589
+ const seg_target = t_seg.substring(
2590
+ seg_prefix,
2591
+ t_seg.length - seg_suffix,
2592
+ );
2593
+ const seg_new = n_seg.substring(
2594
+ seg_prefix,
2595
+ n_seg.length - seg_suffix,
2596
+ );
2597
+ const seg_start = seg_offset + seg_prefix;
2598
+ let seg_op: string;
2599
+ if (!seg_target && seg_new) seg_op = "INSERTION";
2600
+ else if (seg_target && !seg_new) seg_op = "DELETION";
2601
+ else if (seg_target && seg_new) seg_op = "MODIFICATION";
2602
+ else seg_op = "COMMENT_ONLY";
2603
+ const seg_comment =
2604
+ edit.comment && !comment_assigned ? edit.comment : null;
2605
+ if (seg_comment) comment_assigned = true;
2606
+ split_sub_edits.push({
2607
+ type: "modify",
2608
+ target_text: seg_target,
2609
+ new_text: seg_new,
2610
+ comment: seg_comment,
2611
+ _match_start_index: seg_start,
2612
+ _internal_op: seg_op,
2613
+ _active_mapper_ref: active_mapper,
2614
+ _split_group_id: start_idx,
2615
+ });
2616
+ }
2617
+ // Advance past this segment plus its "\n\n" separator span.
2618
+ seg_offset += t_seg.length + 2;
2619
+ }
2620
+ if (split_sub_edits.length > 0) {
2621
+ for (const sub of split_sub_edits) all_sub_edits.push(sub);
2622
+ continue;
2623
+ }
2624
+ }
2625
+
2422
2626
  if (!final_target && final_new) effective_op = "INSERTION";
2423
2627
  else if (final_target && !final_new) effective_op = "DELETION";
2424
2628
  else if (final_target && final_new) effective_op = "MODIFICATION";
@@ -2441,6 +2645,41 @@ export class RedlineEngine {
2441
2645
  return all_sub_edits[0];
2442
2646
  }
2443
2647
 
2648
+ /**
2649
+ * Split a <w:ins> so that everything up to and INCLUDING split_after stays in
2650
+ * a left <w:ins>, new_elem is placed between, and the remainder moves to a
2651
+ * right <w:ins> — all at the grandparent level. Used when revising another
2652
+ * author's pending insertion: the <w:del> stays nested in their <w:ins> while
2653
+ * our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
2654
+ * <w:ins>.
2655
+ */
2656
+ private _insert_and_split_ins(
2657
+ parent_ins: Element,
2658
+ split_after: Element,
2659
+ new_elem: Element,
2660
+ ) {
2661
+ const grandparent = parent_ins.parentNode as Element | null;
2662
+ if (!grandparent) return;
2663
+ // cloneNode(false) copies the attributes (author/id/date) onto both halves.
2664
+ const left = parent_ins.cloneNode(false) as Element;
2665
+ const right = parent_ins.cloneNode(false) as Element;
2666
+ let toRight = false;
2667
+ for (const kid of Array.from(parent_ins.childNodes)) {
2668
+ parent_ins.removeChild(kid);
2669
+ if (!toRight) {
2670
+ left.appendChild(kid);
2671
+ if (kid === split_after) toRight = true;
2672
+ } else {
2673
+ right.appendChild(kid);
2674
+ }
2675
+ }
2676
+ if (left.childNodes.length > 0) grandparent.insertBefore(left, parent_ins);
2677
+ grandparent.insertBefore(new_elem, parent_ins);
2678
+ if (right.childNodes.length > 0)
2679
+ grandparent.insertBefore(right, parent_ins);
2680
+ grandparent.removeChild(parent_ins);
2681
+ }
2682
+
2444
2683
  private _apply_single_edit_indexed(
2445
2684
  edit: any,
2446
2685
  orig_new: string | null,
@@ -2699,7 +2938,20 @@ export class RedlineEngine {
2699
2938
  const is_inline_first = result.first_node.tagName === "w:ins";
2700
2939
  if (is_inline_first) {
2701
2940
  if (anchor_run) {
2702
- insertAfter(result.first_node, anchor_run._element);
2941
+ const anchor_parent = anchor_run._element.parentNode as Element | null;
2942
+ if (anchor_parent && anchor_parent.tagName === "w:ins") {
2943
+ // Inserting inside another author's pending <w:ins>: split it so our
2944
+ // new <w:ins> lands as a sibling right after the anchor run, never
2945
+ // <w:ins> nested in <w:ins> (mirrors the MODIFICATION path and the
2946
+ // Python engine).
2947
+ this._insert_and_split_ins(
2948
+ anchor_parent,
2949
+ anchor_run._element,
2950
+ result.first_node,
2951
+ );
2952
+ } else {
2953
+ insertAfter(result.first_node, anchor_run._element);
2954
+ }
2703
2955
  } else if (anchor_para) {
2704
2956
  anchor_para._element.appendChild(result.first_node);
2705
2957
  }
@@ -2841,8 +3093,17 @@ export class RedlineEngine {
2841
3093
  if (result.first_node) {
2842
3094
  const is_inline_first = result.first_node.tagName === "w:ins";
2843
3095
  if (is_inline_first) {
2844
- // Inline: place the first <w:ins> immediately after last_del.
2845
- insertAfter(result.first_node, last_del);
3096
+ const del_parent = last_del!.parentNode as Element | null;
3097
+ if (del_parent && del_parent.tagName === "w:ins") {
3098
+ // Revising another author's pending insertion: keep the <w:del>
3099
+ // nested in their <w:ins> and splice our new <w:ins> in right after
3100
+ // it by splitting their <w:ins>, so we never nest <w:ins> in
3101
+ // <w:ins>.
3102
+ this._insert_and_split_ins(del_parent, last_del!, result.first_node);
3103
+ } else {
3104
+ // Inline: place the first <w:ins> immediately after last_del.
3105
+ insertAfter(result.first_node, last_del!);
3106
+ }
2846
3107
  ins_elem = result.first_node;
2847
3108
  } else {
2848
3109
  // Block-mode first paragraph was already inserted after the anchor