@adeu/core 1.18.0 → 1.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.18.0",
3
+ "version": "1.18.2",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -0,0 +1,174 @@
1
+ // FILE: node/packages/core/src/engine.comment-preservation.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import { zipSync, strToU8 } from "fflate";
4
+ import { DocumentObject } from "./docx/bridge.js";
5
+ import { extract_comments_data } from "./comments.js";
6
+ import { RedlineEngine } from "./engine.js";
7
+
8
+ /**
9
+ * Regression test for author-aware comment preservation on accept/reject.
10
+ *
11
+ * Bug: accepting a tracked change whose run is wrapped by a comment caused
12
+ * `_clean_wrapping_comments` to delete that comment unconditionally — even when
13
+ * it belonged to another author. In the playbook-commenting scenario this
14
+ * silently erased the counterparty's ("Supplier's Counsel") annotation the
15
+ * instant their insertion (Chg:2) was accepted, destroying provenance and
16
+ * failing the "keep the counterparty comment" requirement.
17
+ *
18
+ * Fix: a wrapping comment authored by someone else has its range markers
19
+ * detached (so the accept proceeds with no orphaned anchor) but its BODY is
20
+ * kept in the comments part. Own-authored wrapping comments are still deleted.
21
+ *
22
+ * The triggering document is built in-memory as a minimal valid .docx so the
23
+ * test doesn't depend on the contents of any golden fixture: a paragraph with a
24
+ * foreign <w:ins id="2"> whose run is wrapped by comment id="1" from that same
25
+ * foreign author.
26
+ */
27
+
28
+ const WORD_XMLNS =
29
+ 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' +
30
+ 'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ' +
31
+ 'xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"';
32
+
33
+ function xmlDecl(body: string): string {
34
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + body;
35
+ }
36
+
37
+ /**
38
+ * Build a minimal, valid DOCX buffer containing a wrapped foreign insertion:
39
+ *
40
+ * commentRangeStart(1) → <w:ins id=2 author=insAuthor>run("INSERTED")</w:ins>
41
+ * → commentRangeEnd(1) → <w:r><w:commentReference id=1/></w:r>
42
+ *
43
+ * plus a comments part with one comment id=1 authored by commentAuthor, body
44
+ * "robust protection". Includes [Content_Types].xml (with the comments
45
+ * Override) and word/_rels/document.xml.rels so `load()` classifies the parts.
46
+ */
47
+ async function buildWrappedInsertionDoc(
48
+ insAuthor: string,
49
+ commentAuthor: string,
50
+ ): Promise<DocumentObject> {
51
+ const contentTypes = xmlDecl(
52
+ `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
53
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
54
+ <Default Extension="xml" ContentType="application/xml"/>
55
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
56
+ <Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
57
+ </Types>`,
58
+ );
59
+
60
+ const rootRels = xmlDecl(
61
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
62
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
63
+ </Relationships>`,
64
+ );
65
+
66
+ const documentRels = xmlDecl(
67
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
68
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>
69
+ </Relationships>`,
70
+ );
71
+
72
+ const documentXml = xmlDecl(
73
+ `<w:document ${WORD_XMLNS}>
74
+ <w:body>
75
+ <w:p w14:paraId="00000001">
76
+ <w:r><w:t xml:space="preserve">Prefix text. </w:t></w:r>
77
+ <w:commentRangeStart w:id="1"/>
78
+ <w:ins w:id="2" w:author="${insAuthor}" w:date="2026-01-01T00:00:00Z"><w:r><w:t>INSERTED</w:t></w:r></w:ins>
79
+ <w:commentRangeEnd w:id="1"/>
80
+ <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
81
+ <w:r><w:t xml:space="preserve"> suffix text.</w:t></w:r>
82
+ </w:p>
83
+ </w:body>
84
+ </w:document>`,
85
+ );
86
+
87
+ const commentsXml = xmlDecl(
88
+ `<w:comments ${WORD_XMLNS}>
89
+ <w:comment w:id="1" w:author="${commentAuthor}" w:date="2026-01-01T00:00:00Z" w:initials="SC"><w:p><w:r><w:t>robust protection</w:t></w:r></w:p></w:comment>
90
+ </w:comments>`,
91
+ );
92
+
93
+ const zip: Record<string, Uint8Array> = {
94
+ "[Content_Types].xml": strToU8(contentTypes),
95
+ "_rels/.rels": strToU8(rootRels),
96
+ "word/document.xml": strToU8(documentXml),
97
+ "word/comments.xml": strToU8(commentsXml),
98
+ "word/_rels/document.xml.rels": strToU8(documentRels),
99
+ };
100
+
101
+ const buf = Buffer.from(zipSync(zip));
102
+ return DocumentObject.load(buf);
103
+ }
104
+
105
+ describe("author-aware wrapping-comment preservation", () => {
106
+ it("keeps a foreign author's wrapping comment when their change is accepted", async () => {
107
+ const doc = await buildWrappedInsertionDoc(
108
+ "Supplier's Counsel",
109
+ "Supplier's Counsel",
110
+ );
111
+
112
+ // Sanity: comment present before we touch anything.
113
+ const before = extract_comments_data(doc.pkg);
114
+ expect(Object.keys(before).length).toBe(1);
115
+ expect(before["1"].text).toContain("robust protection");
116
+
117
+ // We ("Authority Counsel") accept the counterparty's insertion Chg:2.
118
+ const engine = new RedlineEngine(doc, "Authority Counsel");
119
+ engine.process_batch(
120
+ [{ type: "accept", target_id: "Chg:2" } as any],
121
+ false,
122
+ );
123
+
124
+ // The counterparty's comment body must survive the accept.
125
+ const after = extract_comments_data(doc.pkg);
126
+ expect(after["1"]?.text).toContain("robust protection");
127
+
128
+ // The accept must actually have taken effect: the <w:ins> is unwrapped.
129
+ const savedBuf = await doc.save();
130
+ const reloaded = await DocumentObject.load(savedBuf);
131
+ expect(reloaded.element.getElementsByTagName("w:ins").length).toBe(0);
132
+
133
+ // Comment survives the roundtrip too.
134
+ const afterRoundtrip = extract_comments_data(reloaded.pkg);
135
+ expect(afterRoundtrip["1"]?.text).toContain("robust protection");
136
+ });
137
+
138
+ it("keeps a foreign author's wrapping comment when their change is rejected", async () => {
139
+ const doc = await buildWrappedInsertionDoc(
140
+ "Supplier's Counsel",
141
+ "Supplier's Counsel",
142
+ );
143
+
144
+ const engine = new RedlineEngine(doc, "Authority Counsel");
145
+ engine.process_batch(
146
+ [{ type: "reject", target_id: "Chg:2" } as any],
147
+ false,
148
+ );
149
+
150
+ // Rejecting removes the inserted TEXT, but the wrapping annotation (a
151
+ // separate comment) is preserved rather than collaterally deleted.
152
+ const after = extract_comments_data(doc.pkg);
153
+ expect(after["1"]?.text).toContain("robust protection");
154
+ });
155
+
156
+ it("still deletes our OWN wrapping comment when we accept our own change", async () => {
157
+ // Control: when the wrapping comment is ours, cleanup is unchanged — it is
158
+ // removed, matching prior behavior (keeping our own now-resolved note is not
159
+ // required).
160
+ const doc = await buildWrappedInsertionDoc(
161
+ "Authority Counsel",
162
+ "Authority Counsel",
163
+ );
164
+
165
+ const engine = new RedlineEngine(doc, "Authority Counsel");
166
+ engine.process_batch(
167
+ [{ type: "accept", target_id: "Chg:2" } as any],
168
+ false,
169
+ );
170
+
171
+ const after = extract_comments_data(doc.pkg);
172
+ expect(after["1"]).toBeUndefined();
173
+ });
174
+ });
@@ -31,7 +31,11 @@ describe("Feedback Layer & Dry Run Verification", () => {
31
31
  expect(stats.version).toBeDefined();
32
32
  });
33
33
 
34
- it("punctuation anchor triggers warning", async () => {
34
+ it("punctuation anchor: no warning on clean apply", async () => {
35
+ // A punctuated anchor that matches and applies cleanly must NOT raise the
36
+ // tokenization-splitting warning. The redline preview already reports the
37
+ // change; a warning here is a false positive that pushes agents into
38
+ // needless "cleaner anchor" retries.
35
39
  const doc = await createTestDocument();
36
40
  addParagraph(doc, "Refer to sample_term_name in Section 4.");
37
41
  const engine = new RedlineEngine(doc, "Reviewer TS");
@@ -41,9 +45,37 @@ describe("Feedback Layer & Dry Run Verification", () => {
41
45
  ]);
42
46
 
43
47
  const report = stats.edits[0];
48
+ expect(report.status).toBe("applied");
49
+ expect(report.warning).toBeNull();
50
+
51
+ // Same expectation under dry_run.
52
+ const doc2 = await createTestDocument();
53
+ addParagraph(doc2, "Refer to sample_term_name in Section 4.");
54
+ const engine2 = new RedlineEngine(doc2, "Reviewer TS");
55
+ const dryStats = (engine2 as any).process_batch([
56
+ { type: "modify", target_text: "sample_term_name", new_text: "validated_term_name" }
57
+ ], true);
58
+ const dryReport = dryStats.edits[0];
59
+ expect(dryReport.status).toBe("applied");
60
+ expect(dryReport.warning).toBeNull();
61
+ });
62
+
63
+ it("punctuation anchor: warns only when match fails", async () => {
64
+ // When a punctuated anchor fails to match, the warning IS surfaced as
65
+ // recovery context (the punctuation may be why the match missed).
66
+ const doc = await createTestDocument();
67
+ addParagraph(doc, "Refer to sample_term_name in Section 4.");
68
+ const engine = new RedlineEngine(doc, "Reviewer TS");
69
+
70
+ const stats = (engine as any).process_batch([
71
+ { type: "modify", target_text: "phantom_term-x", new_text: "anything" }
72
+ ], true);
73
+
74
+ const report = stats.edits[0];
75
+ expect(report.status).toBe("failed");
44
76
  expect(report.warning).not.toBeNull();
45
77
  expect(report.warning.toLowerCase()).toContain("punctuation");
46
- expect(report.warning).toContain("sample_term_name");
78
+ expect(report.warning).toContain("phantom_term-x");
47
79
  });
48
80
 
49
81
  it("dry_run does not mutate and reports safely", async () => {
package/src/engine.ts CHANGED
@@ -65,7 +65,6 @@ function insertAtIndex(parent: Element, index: number, child: Node) {
65
65
  }
66
66
  }
67
67
 
68
-
69
68
  function safeCloneEdit(val: any, seen: WeakMap<any, any> = new WeakMap()): any {
70
69
  if (val === null || typeof val !== "object") {
71
70
  return val;
@@ -127,7 +126,10 @@ function restoreSnapshot(doc: any, snapshot: any): void {
127
126
  }
128
127
  }
129
128
 
130
- function stripMatchingHeadingHashes(target: string, newText: string): [string, string] {
129
+ function stripMatchingHeadingHashes(
130
+ target: string,
131
+ newText: string,
132
+ ): [string, string] {
131
133
  if (!target || !newText) return [target, newText];
132
134
  const targetMatch = target.match(/^(#+)\s+/);
133
135
  const newMatch = newText.match(/^(#+)\s+/);
@@ -150,7 +152,10 @@ export class BatchValidationError extends Error {
150
152
  }
151
153
  }
152
154
 
153
- export function validate_edit_strings(edits: any[], index_offset: number = 0): string[] {
155
+ export function validate_edit_strings(
156
+ edits: any[],
157
+ index_offset: number = 0,
158
+ ): string[] {
154
159
  const errors: string[] = [];
155
160
 
156
161
  for (let i = 0; i < edits.length; i++) {
@@ -322,8 +327,19 @@ export class RedlineEngine {
322
327
  this.comments_manager = new CommentsManager(this.doc);
323
328
  }
324
329
 
330
+ /**
331
+ * Return a hint when a short, single-token anchor contains punctuation that
332
+ * can split awkwardly, else null.
333
+ *
334
+ * Surface this ONLY for edits that actually failed to match/apply. On a
335
+ * successful edit the batch report already carries the redline preview, so
336
+ * emitting this would be a false positive: the punctuation (dates,
337
+ * `[_name_]` placeholders, `____` blanks) is frequently the literal target
338
+ * and the edit succeeds despite it. Mirrors the Python engine.
339
+ */
325
340
  private _check_punctuation_warning(target_text: string): string | null {
326
341
  if (!target_text) return null;
342
+ if (target_text.length > 20 || target_text.includes(" ")) return null;
327
343
  if (target_text.includes("_") || target_text.includes("-")) {
328
344
  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
345
  }
@@ -386,7 +402,8 @@ export class RedlineEngine {
386
402
  let target_text = edit.target_text || "";
387
403
  let new_text = edit.new_text || "";
388
404
 
389
- const [clean_target, target_style] = this._parse_markdown_style(target_text);
405
+ const [clean_target, target_style] =
406
+ this._parse_markdown_style(target_text);
390
407
  if (target_style && target_style.startsWith("Heading")) {
391
408
  const prefix_len = target_text.length - clean_target.length;
392
409
  start_idx += prefix_len;
@@ -1347,7 +1364,29 @@ export class RedlineEngine {
1347
1364
 
1348
1365
  insertAfter(ref_run, new_end);
1349
1366
  }
1350
-
1367
+ /**
1368
+ * Read a comment's author directly from the comments part. Used by
1369
+ * _clean_wrapping_comments to decide whether a wrapping comment belongs to
1370
+ * another author (and must be preserved when its anchored change is
1371
+ * accepted/rejected) or to us (safe to delete). Reads the part rather than
1372
+ * the mapper because the mapper's comments_map is not rebuilt between review
1373
+ * actions, so it can be stale mid-batch. Returns null if not found.
1374
+ */
1375
+ private _get_comment_author(c_id: string): string | null {
1376
+ const part = this.doc.pkg.parts.find(
1377
+ (p) =>
1378
+ p.contentType ===
1379
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
1380
+ );
1381
+ if (!part) return null;
1382
+ const comments = findAllDescendants(part._element, "w:comment");
1383
+ for (const c of comments) {
1384
+ if (c.getAttribute("w:id") === c_id) {
1385
+ return c.getAttribute("w:author");
1386
+ }
1387
+ }
1388
+ return null;
1389
+ }
1351
1390
  private _clean_wrapping_comments(element: Element) {
1352
1391
  let first_node: Element = element;
1353
1392
  while (true) {
@@ -1420,7 +1459,21 @@ export class RedlineEngine {
1420
1459
  for (const s of starts_to_remove) {
1421
1460
  const c_id = s.getAttribute("w:id");
1422
1461
  if (c_id && end_ids.has(c_id)) {
1423
- this.comments_manager.deleteComment(c_id);
1462
+ // Author-aware preservation. A comment that merely WRAPS the change
1463
+ // being accepted/rejected is a separate annotation — often the
1464
+ // counterparty's note explaining the very edit we are resolving.
1465
+ // Deleting it silently destroys their provenance (and, e.g., fails a
1466
+ // "keep the counterparty's comment" review requirement). So: always
1467
+ // detach the range markers (leaving no orphaned anchor, which lets the
1468
+ // accept/reject proceed cleanly), but only delete the comment BODY when
1469
+ // it is ours. Foreign-authored comment bodies are kept in the comments
1470
+ // part. When the author can't be read we default to preserving, since
1471
+ // deletion is the irreversible, higher-cost mistake.
1472
+ const author = this._get_comment_author(c_id);
1473
+ const is_own = author !== null && author === this.author;
1474
+ if (is_own) {
1475
+ this.comments_manager.deleteComment(c_id);
1476
+ }
1424
1477
  if (s.parentNode) s.parentNode.removeChild(s);
1425
1478
  for (const e of ends_to_remove) {
1426
1479
  let e_id: string | null = null;
@@ -1572,10 +1625,9 @@ export class RedlineEngine {
1572
1625
  if (edit.target_text.includes("|")) {
1573
1626
  matches = matches.slice(0, 1);
1574
1627
  } else {
1575
- const positions: [number, number][] = matches.map(([start, length]) => [
1576
- start,
1577
- start + length,
1578
- ]);
1628
+ const positions: [number, number][] = matches.map(
1629
+ ([start, length]) => [start, start + length],
1630
+ );
1579
1631
  errors.push(
1580
1632
  format_ambiguity_error(
1581
1633
  i + 1 + index_offset,
@@ -1810,8 +1862,17 @@ export class RedlineEngine {
1810
1862
  ): any {
1811
1863
  // Pre-process edits: strip identical leading heading hashes from target_text and new_text
1812
1864
  for (const c of changes) {
1813
- if (c && typeof c === "object" && (c as any).type === "modify" && (c as any).target_text && (c as any).new_text) {
1814
- const [strippedTarget, strippedNew] = stripMatchingHeadingHashes((c as any).target_text, (c as any).new_text);
1865
+ if (
1866
+ c &&
1867
+ typeof c === "object" &&
1868
+ (c as any).type === "modify" &&
1869
+ (c as any).target_text &&
1870
+ (c as any).new_text
1871
+ ) {
1872
+ const [strippedTarget, strippedNew] = stripMatchingHeadingHashes(
1873
+ (c as any).target_text,
1874
+ (c as any).new_text,
1875
+ );
1815
1876
  (c as any).target_text = strippedTarget;
1816
1877
  (c as any).new_text = strippedNew;
1817
1878
  }
@@ -1850,10 +1911,7 @@ export class RedlineEngine {
1850
1911
  actions.length > 0 ? this.validate_review_actions(actions) : [];
1851
1912
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
1852
1913
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
1853
- const all_errors = [
1854
- ...action_errors,
1855
- ...edit_errors,
1856
- ];
1914
+ const all_errors = [...action_errors, ...edit_errors];
1857
1915
  if (all_errors.length > 0) {
1858
1916
  throw new BatchValidationError(all_errors);
1859
1917
  }
@@ -1894,11 +1952,16 @@ export class RedlineEngine {
1894
1952
  for (let i = 0; i < edits.length; i++) {
1895
1953
  const edit = edits[i];
1896
1954
  const single_errors = this.validate_edits([edit], i);
1897
- const warning = this._check_punctuation_warning(
1898
- (edit as any).target_text || "",
1899
- );
1900
1955
  if (single_errors.length > 0) {
1901
1956
  skipped_edits++;
1957
+ // Only surface the punctuation-anchor warning when the edit actually
1958
+ // failed. A clean apply already returns the redline preview, so the
1959
+ // warning is pure noise on success — and it misleads agents into
1960
+ // hunting for a "cleaner" anchor that was never needed (e.g. on
1961
+ // placeholders/dates where the punctuation IS the literal target).
1962
+ const warning = this._check_punctuation_warning(
1963
+ (edit as any).target_text || "",
1964
+ );
1902
1965
  edits_reports.push({
1903
1966
  status: "failed",
1904
1967
  target_text: (edit as any).target_text || "",
@@ -1918,7 +1981,7 @@ export class RedlineEngine {
1918
1981
  status: "applied",
1919
1982
  target_text: (edit as any).target_text || "",
1920
1983
  new_text: (edit as any).new_text || "",
1921
- warning: warning,
1984
+ warning: null,
1922
1985
  error: null,
1923
1986
  critic_markup: previews[0],
1924
1987
  clean_text: previews[1],
@@ -1935,6 +1998,9 @@ export class RedlineEngine {
1935
1998
  this.skipped_details.length > 0
1936
1999
  ? this.skipped_details[this.skipped_details.length - 1]
1937
2000
  : "Failed to apply edit";
2001
+ const warning = this._check_punctuation_warning(
2002
+ (edit as any).target_text || "",
2003
+ );
1938
2004
  edits_reports.push({
1939
2005
  status: "failed",
1940
2006
  target_text: (edit as any).target_text || "",
@@ -1975,23 +2041,25 @@ export class RedlineEngine {
1975
2041
  throw err;
1976
2042
  }
1977
2043
 
1978
- applied_edits = edits.filter(
1979
- (e) => (e as any)._applied_status,
1980
- ).length;
2044
+ applied_edits = edits.filter((e) => (e as any)._applied_status).length;
1981
2045
  skipped_edits = edits.length - applied_edits;
1982
2046
 
1983
2047
  for (const edit of edits) {
1984
2048
  const success = (edit as any)._applied_status || false;
1985
2049
  const error_msg = (edit as any)._error_msg || null;
1986
- const warning = this._check_punctuation_warning(
1987
- (edit as any).target_text || "",
1988
- );
1989
2050
  let critic_markup = null;
1990
2051
  let clean_text = null;
2052
+ // Punctuation-anchor warning is failure-context only: on success the
2053
+ // redline preview below already reports the change cleanly.
2054
+ let warning: string | null = null;
1991
2055
  if (success) {
1992
2056
  const previews = this._build_edit_context_previews(edit);
1993
2057
  critic_markup = previews[0];
1994
2058
  clean_text = previews[1];
2059
+ } else {
2060
+ warning = this._check_punctuation_warning(
2061
+ (edit as any).target_text || "",
2062
+ );
1995
2063
  }
1996
2064
  edits_reports.push({
1997
2065
  status: success ? "applied" : "failed",
@@ -2017,7 +2085,7 @@ export class RedlineEngine {
2017
2085
  skipped_details: this.skipped_details,
2018
2086
  edits: edits_reports,
2019
2087
  engine: "node",
2020
- version: "1.10.0",
2088
+ version: "1.18.2",
2021
2089
  };
2022
2090
  }
2023
2091
 
@@ -2562,7 +2630,10 @@ export class RedlineEngine {
2562
2630
  const t_seg = target_segs[k];
2563
2631
  const n_seg = new_segs[k];
2564
2632
  if (t_seg !== n_seg) {
2565
- const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
2633
+ const [seg_prefix, seg_suffix] = trim_common_context(
2634
+ t_seg,
2635
+ n_seg,
2636
+ );
2566
2637
  const seg_target = t_seg.substring(
2567
2638
  seg_prefix,
2568
2639
  t_seg.length - seg_suffix,
@@ -2915,7 +2986,8 @@ export class RedlineEngine {
2915
2986
  const is_inline_first = result.first_node.tagName === "w:ins";
2916
2987
  if (is_inline_first) {
2917
2988
  if (anchor_run) {
2918
- const anchor_parent = anchor_run._element.parentNode as Element | null;
2989
+ const anchor_parent = anchor_run._element
2990
+ .parentNode as Element | null;
2919
2991
  if (anchor_parent && anchor_parent.tagName === "w:ins") {
2920
2992
  // Inserting inside another author's pending <w:ins>: split it so our
2921
2993
  // new <w:ins> lands as a sibling right after the anchor run, never
@@ -3076,7 +3148,11 @@ export class RedlineEngine {
3076
3148
  // nested in their <w:ins> and splice our new <w:ins> in right after
3077
3149
  // it by splitting their <w:ins>, so we never nest <w:ins> in
3078
3150
  // <w:ins>.
3079
- this._insert_and_split_ins(del_parent, last_del!, result.first_node);
3151
+ this._insert_and_split_ins(
3152
+ del_parent,
3153
+ last_del!,
3154
+ result.first_node,
3155
+ );
3080
3156
  } else {
3081
3157
  // Inline: place the first <w:ins> immediately after last_del.
3082
3158
  insertAfter(result.first_node, last_del!);