@adeu/core 1.17.2 → 1.18.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.17.2",
3
+ "version": "1.18.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -418,7 +418,7 @@ describe("Resolved Bugs Core Engine Verification", () => {
418
418
  expect(pTarget.parentNode).toBeNull();
419
419
  });
420
420
 
421
- it("BUG-EXPLORE-2: Nested redline validation error includes actionable hint", async () => {
421
+ it("BUG-EXPLORE-2: a strict edit inside a foreign author's insertion applies (nested change)", async () => {
422
422
  const doc = await createTestDocument();
423
423
  addParagraph(doc, "Original baseline.");
424
424
 
@@ -432,20 +432,18 @@ describe("Resolved Bugs Core Engine Verification", () => {
432
432
  },
433
433
  ]);
434
434
 
435
- // Author B tries to modify Author A's pending insertion
435
+ // Author B modifies Author A's pending insertion — this now applies: the
436
+ // enclosing <w:ins> is split and Author B's change nested inside it.
436
437
  const engineB = new RedlineEngine(doc, "Author B");
437
-
438
- expect(() => {
439
- engineB.process_batch([
440
- {
441
- type: "modify",
442
- target_text: "Inserted by A.",
443
- new_text: "Modified by B.",
444
- },
445
- ]);
446
- }).toThrowError(
447
- /Accept that change first or scope your edit outside of it/,
448
- );
438
+ const result = engineB.process_batch([
439
+ {
440
+ type: "modify",
441
+ target_text: "Inserted by A.",
442
+ new_text: "Modified by B.",
443
+ },
444
+ ]);
445
+ expect(result.edits_applied).toBe(1);
446
+ expect(result.edits_skipped).toBe(0);
449
447
  });
450
448
 
451
449
  it("BUG-CROSS-PARA-1: Cross-paragraph modify coalesces paragraphs and tracks para-mark deletion", async () => {
@@ -422,31 +422,36 @@ describe("BUG-23-4: multi-paragraph target_text must produce actionable feedback
422
422
  }
423
423
  });
424
424
 
425
- it("BUG-23-4-NN: rejects plain-paragraph N->N modifications spanning a paragraph boundary to prevent silent corruption", async () => {
425
+ it("BUG-23-4-NN: applies balanced plain-paragraph N->N modifications per paragraph, preserving the boundary", async () => {
426
426
  const doc = await createTestDocument();
427
427
  addParagraph(doc, "Clause 1 ends here.");
428
428
  addParagraph(doc, "Clause 2 begins here.");
429
429
 
430
430
  const engine = new RedlineEngine(doc, "Test Author");
431
431
 
432
- let raised: any = null;
433
- try {
434
- engine.process_batch([
435
- {
436
- type: "modify",
437
- target_text: "ends here.\n\nClause 2 begins",
438
- new_text: "ends here. MERGED\n\nClause 2 begins CHANGED",
439
- },
440
- ]);
441
- } catch (e: any) {
442
- raised = e;
443
- }
432
+ // Both target and replacement carry the same number of \n\n breaks, so the
433
+ // paragraph structure is preserved. The engine splits this into one sub-edit
434
+ // per paragraph and applies it as a single logical edit. (Unbalanced
435
+ // merges/splits are still rejected — see the regex case in engine.qa.test.)
436
+ const result = engine.process_batch([
437
+ {
438
+ type: "modify",
439
+ target_text: "ends here.\n\nClause 2 begins",
440
+ new_text: "ends here. MERGED\n\nClause 2 begins CHANGED",
441
+ },
442
+ ]);
444
443
 
445
- expect(raised).not.toBeNull();
446
- if (raised) {
447
- expect(raised.name).toBe("BatchValidationError");
448
- expect(raised.message.toLowerCase()).toContain("paragraph boundary");
449
- }
444
+ expect(result.edits_applied).toBe(1);
445
+ expect(result.edits_skipped).toBe(0);
446
+
447
+ const buf = await doc.save();
448
+ const text = await extractTextFromBuffer(buf);
449
+ // The paragraph boundary must survive — the two clauses are NOT merged.
450
+ expect(text).not.toContain("ends here.Clause 2 begins");
451
+ expect(text).not.toContain("ends here. Clause 2 begins");
452
+ // Both per-paragraph insertions land.
453
+ expect(text).toContain("MERGED");
454
+ expect(text).toContain("CHANGED");
450
455
  });
451
456
  });
452
457
 
package/src/engine.ts CHANGED
@@ -673,6 +673,106 @@ export class RedlineEngine {
673
673
  }
674
674
  }
675
675
 
676
+ /**
677
+ * Revert every tracked change, returning the document to the state it had
678
+ * before any revision was proposed. The exact inverse of
679
+ * accept_all_revisions:
680
+ *
681
+ * - <w:ins> -> removed together with all of its content (the proposed
682
+ * insertion never existed); an inserted row (<w:ins> in
683
+ * <w:trPr>) drops the whole row.
684
+ * - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
685
+ * <w:t> again); a row-deletion mark in <w:trPr> is removed so
686
+ * the row survives.
687
+ * - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
688
+ *
689
+ * Comments are annotations, not revisions, so standalone comments are left in
690
+ * place; only anchors stranded inside a rejected insertion are cleaned up.
691
+ *
692
+ * Insertions are reverted before deletions are restored so a deletion nested
693
+ * inside a foreign author's insertion is removed wholesale with the insertion
694
+ * — the contingent text disappears rather than being promoted to committed
695
+ * body text.
696
+ *
697
+ * Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
698
+ * pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
699
+ * to the extent of dropping/keeping the mark; the original paragraph boundary
700
+ * is not reconstructed, because the merge protocol coalesces paragraphs
701
+ * destructively at edit time. Reverting run-level insertions/deletions (the
702
+ * common case) is exact. This limitation is shared with the Python engine.
703
+ */
704
+ public reject_all_revisions() {
705
+ const parts_to_process: Element[] = [this.doc.element];
706
+
707
+ for (const part of this.doc.pkg.parts) {
708
+ if (part === this.doc.part) continue;
709
+ if (
710
+ part.contentType.includes("wordprocessingml") &&
711
+ part.contentType.endsWith("+xml")
712
+ ) {
713
+ parts_to_process.push(part._element);
714
+ }
715
+ }
716
+
717
+ for (const root_element of parts_to_process) {
718
+ // 1. Reject insertions: drop the <w:ins> and everything inside it.
719
+ // Document order means an outer <w:ins> is handled before a nested
720
+ // one; removing the outer detaches the inner (guarded below).
721
+ const insNodes = findAllDescendants(root_element, "w:ins");
722
+ for (const ins of insNodes) {
723
+ const parent = ins.parentNode as Element | null;
724
+ if (!parent) continue;
725
+ this._clean_wrapping_comments(ins);
726
+ this._delete_comments_in_element(ins);
727
+ if (parent.tagName === "w:trPr") {
728
+ const row = parent.parentNode as Element | null;
729
+ if (row && row.parentNode) {
730
+ row.parentNode.removeChild(row);
731
+ }
732
+ } else {
733
+ parent.removeChild(ins);
734
+ }
735
+ }
736
+
737
+ // 2. Reject paragraph-mark deletions: keep the paragraph break.
738
+ const pNodes = findAllDescendants(root_element, "w:p");
739
+ for (const p of pNodes) {
740
+ const pPr = findChild(p, "w:pPr");
741
+ if (pPr) {
742
+ const rPr = findChild(pPr, "w:rPr");
743
+ const delMark = rPr ? findChild(rPr, "w:del") : null;
744
+ if (rPr && delMark) {
745
+ rPr.removeChild(delMark);
746
+ }
747
+ }
748
+ }
749
+
750
+ // 3. Reject deletions: restore the original text.
751
+ const delNodes = findAllDescendants(root_element, "w:del");
752
+ for (const d of delNodes) {
753
+ const parent = d.parentNode as Element | null;
754
+ if (!parent) continue;
755
+ this._clean_wrapping_comments(d);
756
+ if (parent.tagName === "w:trPr") {
757
+ parent.removeChild(d);
758
+ continue;
759
+ }
760
+ const delTexts = Array.from(d.getElementsByTagName("w:delText"));
761
+ for (const dt of delTexts) {
762
+ const t = d.ownerDocument!.createElement("w:t");
763
+ t.textContent = dt.textContent;
764
+ if (dt.hasAttribute("xml:space"))
765
+ t.setAttribute("xml:space", "preserve");
766
+ dt.parentNode?.replaceChild(t, dt);
767
+ }
768
+ while (d.firstChild) {
769
+ parent.insertBefore(d.firstChild, d);
770
+ }
771
+ parent.removeChild(d);
772
+ }
773
+ }
774
+ }
775
+
676
776
  private _getNextId(): string {
677
777
  this.current_id++;
678
778
  return this.current_id.toString();
@@ -1501,38 +1601,63 @@ export class RedlineEngine {
1501
1601
  (edit.new_text || "").length - sfx,
1502
1602
  );
1503
1603
  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
- );
1604
+ // A *balanced* multi-paragraph modification (target and replacement
1605
+ // carry the same number of paragraph breaks) is safe: it is split
1606
+ // into one sub-edit per paragraph segment and applied, leaving the
1607
+ // structural \n\n breaks untouched. Only reject when the paragraph
1608
+ // structure would actually change (a merge or split), which cannot be
1609
+ // expressed as a per-paragraph text replacement. See
1610
+ // _pre_resolve_heuristic_edit.
1611
+ const balanced =
1612
+ matched.split("\n\n").length ===
1613
+ (edit.new_text || "").split("\n\n").length;
1614
+ if (!balanced) {
1615
+ if (final_new.includes("\n\n")) {
1616
+ const parts = matched.split("\n\n");
1617
+ if (
1618
+ parts.length >= 2 &&
1619
+ parts[0].trim() !== "" &&
1620
+ parts[parts.length - 1].trim() !== ""
1621
+ ) {
1622
+ errors.push(
1623
+ `- 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.`,
1624
+ );
1625
+ }
1626
+ } else {
1627
+ const parts = final_target.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
+ }
1525
1637
  }
1526
1638
  }
1527
1639
  }
1528
1640
  }
1529
1641
 
1530
1642
  for (const [start, length] of matches) {
1531
- const spans = this.mapper.spans.filter(
1643
+ // Filter spans from the SAME mapper the match indices came from
1644
+ // (target_mapper may be the clean mapper); using this.mapper.spans here
1645
+ // would read a different coordinate space and miss the foreign <w:ins>
1646
+ // overlap for clean-mapper-resolved targets — silently letting a
1647
+ // partial straddle through. (Python filters target_mapper.spans too.)
1648
+ const spans = target_mapper.spans.filter(
1532
1649
  (s) => s.end > start && s.start < start + length,
1533
1650
  );
1534
- const nestedAuthors = new Set<string>();
1651
+ const insAuthors = new Set<string>();
1652
+ const commentAuthors = new Set<string>();
1653
+ // Does any real (run-backed) text in the target lie OUTSIDE a foreign
1654
+ // insertion? If so the target only partially overlaps the insertion and
1655
+ // replacing it as one span would straddle the <w:ins> boundary — that
1656
+ // case must still be refused.
1657
+ let hasNonForeignRealText = false;
1535
1658
  for (const s of spans) {
1659
+ if (s.run === null) continue;
1660
+ let isForeignIns = false;
1536
1661
  if (s.ins_id) {
1537
1662
  const insNodes = findAllDescendants(
1538
1663
  this.doc.element,
@@ -1541,24 +1666,45 @@ export class RedlineEngine {
1541
1666
  if (insNodes.length > 0) {
1542
1667
  const auth = insNodes[0].getAttribute("w:author");
1543
1668
  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
- }
1669
+ insAuthors.add(auth);
1670
+ isForeignIns = true;
1549
1671
  }
1550
1672
  }
1551
1673
  }
1674
+ if (!isForeignIns) hasNonForeignRealText = true;
1675
+ }
1676
+ for (const s of spans) {
1552
1677
  if (s.comment_ids) {
1553
1678
  for (const cid of s.comment_ids) {
1554
1679
  const c_data = this.mapper.comments_map[cid];
1555
1680
  if (c_data && c_data.author && c_data.author !== this.author) {
1556
- nestedAuthors.add(c_data.author);
1681
+ commentAuthors.add(c_data.author);
1557
1682
  }
1558
1683
  }
1559
1684
  }
1560
1685
  }
1561
- if (nestedAuthors.size > 0) {
1686
+ if (insAuthors.size > 0 || commentAuthors.size > 0) {
1687
+ // A single (strict/first) modification whose target lies ENTIRELY
1688
+ // inside foreign-authored insertion(s), with no foreign comment
1689
+ // overlap, is allowed: track_delete_run splits the enclosing <w:ins>
1690
+ // and nests the change, producing valid tracked-change XML. Refuse the
1691
+ // remaining cases — match_mode "all" fan-outs, partial overlaps that
1692
+ // straddle the insertion boundary, and edits touching another author's
1693
+ // comment range.
1694
+ const fullyWithinForeignIns =
1695
+ insAuthors.size > 0 &&
1696
+ !hasNonForeignRealText &&
1697
+ commentAuthors.size === 0;
1698
+ if (
1699
+ (match_mode === "strict" || match_mode === "first") &&
1700
+ fullyWithinForeignIns
1701
+ ) {
1702
+ continue;
1703
+ }
1704
+ const nestedAuthors = new Set<string>([
1705
+ ...insAuthors,
1706
+ ...commentAuthors,
1707
+ ]);
1562
1708
  errors.push(
1563
1709
  `- 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
1710
  );
@@ -1686,60 +1832,17 @@ export class RedlineEngine {
1686
1832
  !["accept", "reject", "reply"].includes(c.type),
1687
1833
  );
1688
1834
 
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
- }
1835
+ // NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
1836
+ // foreign author's <w:ins> when a strict/first edit only partially straddled
1837
+ // its boundary turning that author's tracked-inserted text into untracked
1838
+ // committed body text before the edit applied, destroying their provenance.
1839
+ // That is the same provenance-laundering failure mode the canonical engine
1840
+ // refuses, so it has been removed: a partial straddle now surfaces the
1841
+ // standard validation error ("Modification targets an active insertion from
1842
+ // another author …") via validate_edits, matching the Python engine. An edit
1843
+ // fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
1844
+ // nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
1845
+ // _insert_and_split_ins).
1743
1846
 
1744
1847
  // BUG-7: Unified single-pass validation in wet-run / standard mode.
1745
1848
  if (!dry_run_mode) {
@@ -2018,6 +2121,10 @@ export class RedlineEngine {
2018
2121
  (b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
2019
2122
  );
2020
2123
  const occupied_ranges: [number, number][] = [];
2124
+ // Sub-edits split from one balanced multi-paragraph modification share a
2125
+ // _split_group_id; count the group as a single applied edit (and a single
2126
+ // occurrence), even though it touches several paragraphs.
2127
+ const counted_split_groups = new Set<number>();
2021
2128
 
2022
2129
  for (const [edit, orig_new] of resolved_edits) {
2023
2130
  const start = edit._resolved_start_idx || 0;
@@ -2050,14 +2157,27 @@ export class RedlineEngine {
2050
2157
  }
2051
2158
 
2052
2159
  if (success) {
2053
- applied++;
2160
+ // A balanced multi-paragraph split fans one logical edit into several
2161
+ // paragraph sub-edits sharing a _split_group_id; count it once. Edits
2162
+ // with no group id (the common case) always count.
2163
+ const group_id = edit._split_group_id;
2164
+ const first_in_group =
2165
+ group_id === undefined ||
2166
+ group_id === null ||
2167
+ !counted_split_groups.has(group_id);
2168
+ if (first_in_group && group_id !== undefined && group_id !== null) {
2169
+ counted_split_groups.add(group_id);
2170
+ }
2171
+ if (first_in_group) applied++;
2054
2172
  occupied_ranges.push([start, end]);
2055
2173
  edit._applied_status = true;
2056
2174
  const parent = edit._parent_edit_ref;
2057
2175
  if (parent) {
2058
2176
  parent._applied_status = true;
2059
- parent._occurrences_modified =
2060
- (parent._occurrences_modified || 0) + 1;
2177
+ if (first_in_group) {
2178
+ parent._occurrences_modified =
2179
+ (parent._occurrences_modified || 0) + 1;
2180
+ }
2061
2181
  const [path, page] = this._get_heading_path_and_page(
2062
2182
  start,
2063
2183
  this.mapper.full_text,
@@ -2068,7 +2188,9 @@ export class RedlineEngine {
2068
2188
  parent._pages = pages;
2069
2189
  parent._heading_path = path;
2070
2190
  } else {
2071
- edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
2191
+ if (first_in_group) {
2192
+ edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
2193
+ }
2072
2194
  const [path, page] = this._get_heading_path_and_page(
2073
2195
  start,
2074
2196
  this.mapper.full_text,
@@ -2419,6 +2541,65 @@ export class RedlineEngine {
2419
2541
  final_new = current_effective_new_text.substring(prefix_len, n_end);
2420
2542
  effective_start_idx = start_idx + prefix_len;
2421
2543
 
2544
+ // Balanced multi-paragraph modification: the matched span crosses one or
2545
+ // more paragraph breaks and the replacement preserves the same number of
2546
+ // breaks. Apply it as one independent sub-edit per paragraph segment so
2547
+ // the structural \n\n breaks are left intact. Each sub-edit shares a
2548
+ // _split_group_id (the occurrence's start index) so the batch report
2549
+ // counts it as a single applied edit. Unbalanced cases (a genuine
2550
+ // paragraph merge or split) fall through to the single-span path and are
2551
+ // rejected by validate_edits.
2552
+ const target_segs = actual_doc_text.split("\n\n");
2553
+ const new_segs = current_effective_new_text.split("\n\n");
2554
+ if (
2555
+ actual_doc_text.includes("\n\n") &&
2556
+ target_segs.length === new_segs.length
2557
+ ) {
2558
+ const split_sub_edits: any[] = [];
2559
+ let seg_offset = start_idx;
2560
+ let comment_assigned = false;
2561
+ for (let k = 0; k < target_segs.length; k++) {
2562
+ const t_seg = target_segs[k];
2563
+ const n_seg = new_segs[k];
2564
+ if (t_seg !== n_seg) {
2565
+ const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
2566
+ const seg_target = t_seg.substring(
2567
+ seg_prefix,
2568
+ t_seg.length - seg_suffix,
2569
+ );
2570
+ const seg_new = n_seg.substring(
2571
+ seg_prefix,
2572
+ n_seg.length - seg_suffix,
2573
+ );
2574
+ const seg_start = seg_offset + seg_prefix;
2575
+ let seg_op: string;
2576
+ if (!seg_target && seg_new) seg_op = "INSERTION";
2577
+ else if (seg_target && !seg_new) seg_op = "DELETION";
2578
+ else if (seg_target && seg_new) seg_op = "MODIFICATION";
2579
+ else seg_op = "COMMENT_ONLY";
2580
+ const seg_comment =
2581
+ edit.comment && !comment_assigned ? edit.comment : null;
2582
+ if (seg_comment) comment_assigned = true;
2583
+ split_sub_edits.push({
2584
+ type: "modify",
2585
+ target_text: seg_target,
2586
+ new_text: seg_new,
2587
+ comment: seg_comment,
2588
+ _match_start_index: seg_start,
2589
+ _internal_op: seg_op,
2590
+ _active_mapper_ref: active_mapper,
2591
+ _split_group_id: start_idx,
2592
+ });
2593
+ }
2594
+ // Advance past this segment plus its "\n\n" separator span.
2595
+ seg_offset += t_seg.length + 2;
2596
+ }
2597
+ if (split_sub_edits.length > 0) {
2598
+ for (const sub of split_sub_edits) all_sub_edits.push(sub);
2599
+ continue;
2600
+ }
2601
+ }
2602
+
2422
2603
  if (!final_target && final_new) effective_op = "INSERTION";
2423
2604
  else if (final_target && !final_new) effective_op = "DELETION";
2424
2605
  else if (final_target && final_new) effective_op = "MODIFICATION";
@@ -2441,6 +2622,41 @@ export class RedlineEngine {
2441
2622
  return all_sub_edits[0];
2442
2623
  }
2443
2624
 
2625
+ /**
2626
+ * Split a <w:ins> so that everything up to and INCLUDING split_after stays in
2627
+ * a left <w:ins>, new_elem is placed between, and the remainder moves to a
2628
+ * right <w:ins> — all at the grandparent level. Used when revising another
2629
+ * author's pending insertion: the <w:del> stays nested in their <w:ins> while
2630
+ * our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
2631
+ * <w:ins>.
2632
+ */
2633
+ private _insert_and_split_ins(
2634
+ parent_ins: Element,
2635
+ split_after: Element,
2636
+ new_elem: Element,
2637
+ ) {
2638
+ const grandparent = parent_ins.parentNode as Element | null;
2639
+ if (!grandparent) return;
2640
+ // cloneNode(false) copies the attributes (author/id/date) onto both halves.
2641
+ const left = parent_ins.cloneNode(false) as Element;
2642
+ const right = parent_ins.cloneNode(false) as Element;
2643
+ let toRight = false;
2644
+ for (const kid of Array.from(parent_ins.childNodes)) {
2645
+ parent_ins.removeChild(kid);
2646
+ if (!toRight) {
2647
+ left.appendChild(kid);
2648
+ if (kid === split_after) toRight = true;
2649
+ } else {
2650
+ right.appendChild(kid);
2651
+ }
2652
+ }
2653
+ if (left.childNodes.length > 0) grandparent.insertBefore(left, parent_ins);
2654
+ grandparent.insertBefore(new_elem, parent_ins);
2655
+ if (right.childNodes.length > 0)
2656
+ grandparent.insertBefore(right, parent_ins);
2657
+ grandparent.removeChild(parent_ins);
2658
+ }
2659
+
2444
2660
  private _apply_single_edit_indexed(
2445
2661
  edit: any,
2446
2662
  orig_new: string | null,
@@ -2699,7 +2915,20 @@ export class RedlineEngine {
2699
2915
  const is_inline_first = result.first_node.tagName === "w:ins";
2700
2916
  if (is_inline_first) {
2701
2917
  if (anchor_run) {
2702
- insertAfter(result.first_node, anchor_run._element);
2918
+ const anchor_parent = anchor_run._element.parentNode as Element | null;
2919
+ if (anchor_parent && anchor_parent.tagName === "w:ins") {
2920
+ // Inserting inside another author's pending <w:ins>: split it so our
2921
+ // new <w:ins> lands as a sibling right after the anchor run, never
2922
+ // <w:ins> nested in <w:ins> (mirrors the MODIFICATION path and the
2923
+ // Python engine).
2924
+ this._insert_and_split_ins(
2925
+ anchor_parent,
2926
+ anchor_run._element,
2927
+ result.first_node,
2928
+ );
2929
+ } else {
2930
+ insertAfter(result.first_node, anchor_run._element);
2931
+ }
2703
2932
  } else if (anchor_para) {
2704
2933
  anchor_para._element.appendChild(result.first_node);
2705
2934
  }
@@ -2841,8 +3070,17 @@ export class RedlineEngine {
2841
3070
  if (result.first_node) {
2842
3071
  const is_inline_first = result.first_node.tagName === "w:ins";
2843
3072
  if (is_inline_first) {
2844
- // Inline: place the first <w:ins> immediately after last_del.
2845
- insertAfter(result.first_node, last_del);
3073
+ const del_parent = last_del!.parentNode as Element | null;
3074
+ if (del_parent && del_parent.tagName === "w:ins") {
3075
+ // Revising another author's pending insertion: keep the <w:del>
3076
+ // nested in their <w:ins> and splice our new <w:ins> in right after
3077
+ // it by splitting their <w:ins>, so we never nest <w:ins> in
3078
+ // <w:ins>.
3079
+ this._insert_and_split_ins(del_parent, last_del!, result.first_node);
3080
+ } else {
3081
+ // Inline: place the first <w:ins> immediately after last_del.
3082
+ insertAfter(result.first_node, last_del!);
3083
+ }
2846
3084
  ins_elem = result.first_node;
2847
3085
  } else {
2848
3086
  // Block-mode first paragraph was already inserted after the anchor