@adeu/core 1.26.0 → 1.28.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/src/engine.ts CHANGED
@@ -1409,6 +1409,28 @@ export class RedlineEngine {
1409
1409
  *
1410
1410
  * Does NOT attach comments; callers handle that.
1411
1411
  */
1412
+ private _clone_pPr_scrubbing_headings(existing_pPr: Element): Element {
1413
+ const pPr_clone = existing_pPr.cloneNode(true) as Element;
1414
+ const pStyle_el = findChild(pPr_clone, "w:pStyle");
1415
+ if (pStyle_el) {
1416
+ const style_val = pStyle_el.getAttribute("w:val");
1417
+ if (style_val) {
1418
+ const is_heading =
1419
+ style_val.startsWith("Heading") ||
1420
+ style_val === "Title" ||
1421
+ style_val.replace(/\s+/g, "").startsWith("Heading");
1422
+ if (is_heading) {
1423
+ pPr_clone.removeChild(pStyle_el);
1424
+ }
1425
+ }
1426
+ }
1427
+ const outlineLvl_el = findChild(pPr_clone, "w:outlineLvl");
1428
+ if (outlineLvl_el) {
1429
+ pPr_clone.removeChild(outlineLvl_el);
1430
+ }
1431
+ return pPr_clone;
1432
+ }
1433
+
1412
1434
  private _track_insert_multiline(
1413
1435
  text: string,
1414
1436
  anchor_run: Run | null,
@@ -1423,6 +1445,12 @@ export class RedlineEngine {
1423
1445
  // authoritative: strip inherited bold/italic from the anchor style
1424
1446
  // (QA 2026-07-19 F-02).
1425
1447
  suppress_emphasis: boolean = false,
1448
+ // True when the caller will attach the insertion BEFORE the anchor
1449
+ // (paragraph-start insertions): the anchor itself then belongs to the
1450
+ // relocating suffix (hunt-profile counterexample, 2026-07-19 —
1451
+ // "00." + insert "0.\n\n0 " must read "0.\n\n0 00.", never
1452
+ // "0.00.\n\n0 "). Mirrors the Python engine.
1453
+ insert_before: boolean = false,
1426
1454
  ): {
1427
1455
  first_node: Element | null;
1428
1456
  last_p: Element | null;
@@ -1459,12 +1487,21 @@ export class RedlineEngine {
1459
1487
  // DOM, and the insertion lands immediately after it, so its following
1460
1488
  // child-of-paragraph siblings are exactly the suffix.
1461
1489
  const suffix_nodes: Element[] = [];
1462
- const pos_source =
1490
+ const relocatable = new Set(["w:r", "w:ins", "w:del"]);
1491
+ // insert_before with a RUN anchor: the insertion precedes the anchor, so
1492
+ // the anchor run itself is part of the suffix. (The explicit
1493
+ // positional_anchor_el is only passed by flows that insert AFTER it.)
1494
+ const pos_from_positional =
1463
1495
  positional_anchor_el && positional_anchor_el.parentNode
1464
1496
  ? positional_anchor_el
1465
- : anchor_run !== null && anchor_run._element.parentNode
1466
- ? anchor_run._element
1467
- : null;
1497
+ : null;
1498
+ const pos_from_anchor_run =
1499
+ anchor_run !== null && anchor_run._element.parentNode
1500
+ ? anchor_run._element
1501
+ : null;
1502
+ const pos_source = pos_from_positional ?? pos_from_anchor_run;
1503
+ const suffix_includes_anchor =
1504
+ insert_before && pos_from_positional === null && pos_from_anchor_run !== null;
1468
1505
  if (current_p !== null && pos_source !== null) {
1469
1506
  let pos_anchor: Element | null = pos_source;
1470
1507
  while (pos_anchor && pos_anchor.parentNode !== current_p) {
@@ -1475,8 +1512,9 @@ export class RedlineEngine {
1475
1512
  }
1476
1513
  }
1477
1514
  if (pos_anchor) {
1478
- const relocatable = new Set(["w:r", "w:ins", "w:del"]);
1479
- let nxt = pos_anchor.nextSibling;
1515
+ let nxt: Node | null = suffix_includes_anchor
1516
+ ? pos_anchor
1517
+ : pos_anchor.nextSibling;
1480
1518
  while (nxt) {
1481
1519
  if (nxt.nodeType === 1 && relocatable.has((nxt as Element).tagName)) {
1482
1520
  suffix_nodes.push(nxt as Element);
@@ -1484,6 +1522,20 @@ export class RedlineEngine {
1484
1522
  nxt = nxt.nextSibling;
1485
1523
  }
1486
1524
  }
1525
+ } else if (current_p !== null && insert_before) {
1526
+ // No attached anchor run at all (paragraph-anchored insertion at
1527
+ // paragraph START): everything in the host paragraph follows the
1528
+ // insertion point, so it all relocates (mirrors the Python engine).
1529
+ let child = current_p.firstChild;
1530
+ while (child) {
1531
+ if (
1532
+ child.nodeType === 1 &&
1533
+ relocatable.has((child as Element).tagName)
1534
+ ) {
1535
+ suffix_nodes.push(child as Element);
1536
+ }
1537
+ child = child.nextSibling;
1538
+ }
1487
1539
  }
1488
1540
 
1489
1541
  // Drop the trailing empty line ONLY when there is no suffix to relocate.
@@ -1583,7 +1635,7 @@ export class RedlineEngine {
1583
1635
  // Inherit pPr from the anchor paragraph (preserves list numbering).
1584
1636
  const existing_pPr = findChild(current_p, "w:pPr");
1585
1637
  if (existing_pPr) {
1586
- new_p.appendChild(existing_pPr.cloneNode(true));
1638
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
1587
1639
  }
1588
1640
  }
1589
1641
 
@@ -2104,9 +2156,13 @@ export class RedlineEngine {
2104
2156
  }
2105
2157
  }
2106
2158
 
2107
- let matches = this.mapper.find_all_match_indices(
2108
- edit.target_text,
2109
- is_regex,
2159
+ // Matches covering ONLY virtual projection text (meta bubbles,
2160
+ // timestamps, style markers) are phantoms: they can neither be edited
2161
+ // nor legitimately ambiguate a real match — a target of "4" was
2162
+ // rejected as "appears 8 times" because comment-bubble timestamps
2163
+ // matched (QA 2026-07-19 ADEU-QA-002 C).
2164
+ let matches = this.mapper.drop_virtual_only_matches(
2165
+ this.mapper.find_all_match_indices(edit.target_text, is_regex),
2110
2166
  );
2111
2167
  let activeText = this.mapper.full_text;
2112
2168
  let target_mapper = this.mapper;
@@ -2114,9 +2170,8 @@ export class RedlineEngine {
2114
2170
  if (matches.length === 0) {
2115
2171
  if (!this.clean_mapper)
2116
2172
  this.clean_mapper = new DocumentMapper(this.doc, true);
2117
- matches = this.clean_mapper.find_all_match_indices(
2118
- edit.target_text,
2119
- is_regex,
2173
+ matches = this.clean_mapper.drop_virtual_only_matches(
2174
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
2120
2175
  );
2121
2176
  if (matches.length > 0) {
2122
2177
  activeText = this.clean_mapper.full_text;
@@ -2134,9 +2189,9 @@ export class RedlineEngine {
2134
2189
  const realSpans = this.mapper.spans.filter(
2135
2190
  (s) => s.run !== null && s.end > start && s.start < start + length,
2136
2191
  );
2137
- if (realSpans.length === 0) return true; // virtual-only; keep
2138
- // Keep only if at least one overlapping real span is live (not
2139
- // part of a tracked deletion).
2192
+ // Virtual-only matches were already dropped above; here we only
2193
+ // skip matches buried entirely inside tracked deletions.
2194
+ if (realSpans.length === 0) return true;
2140
2195
  return realSpans.some((s) => !s.del_id);
2141
2196
  });
2142
2197
  matches = liveMatches;
@@ -2149,9 +2204,11 @@ export class RedlineEngine {
2149
2204
  if (!this.original_mapper) {
2150
2205
  this.original_mapper = new DocumentMapper(this.doc, false, true);
2151
2206
  }
2152
- const orig_matches = this.original_mapper.find_all_match_indices(
2153
- edit.target_text,
2154
- is_regex,
2207
+ const orig_matches = this.original_mapper.drop_virtual_only_matches(
2208
+ this.original_mapper.find_all_match_indices(
2209
+ edit.target_text,
2210
+ is_regex,
2211
+ ),
2155
2212
  );
2156
2213
  if (orig_matches.length > 0) {
2157
2214
  is_deleted_text = true;
@@ -2505,10 +2562,15 @@ export class RedlineEngine {
2505
2562
  length: number,
2506
2563
  ): number | null {
2507
2564
  for (const s of mapper.spans) {
2508
- if (s.run === null || s.end <= start || s.start >= start + length) {
2565
+ if (s.end <= start || s.start >= start + length) {
2509
2566
  continue;
2510
2567
  }
2511
- let curr: Node | null = s.run._element;
2568
+ let curr: Node | null = null;
2569
+ if (s.run !== null) {
2570
+ curr = s.run._element;
2571
+ } else if (s.paragraph !== null) {
2572
+ curr = s.paragraph._element;
2573
+ }
2512
2574
  while (curr) {
2513
2575
  if (curr.nodeType === 1 && (curr as Element).tagName === "w:tr") {
2514
2576
  return findAllDescendants(curr as Element, "w:tc").filter(
@@ -2523,6 +2585,60 @@ export class RedlineEngine {
2523
2585
 
2524
2586
  public validate_review_actions(actions: any[]): string[] {
2525
2587
  const errors: string[] = [];
2588
+
2589
+ // Document-context-free shape checks (QA 2026-07-19 v8 F-07), mirroring
2590
+ // Python's validate_review_action_batch: blank replies render as empty
2591
+ // Word comment bubbles; a duplicated or conflicting accept/reject on one
2592
+ // target_id either double-counts as "applied" or contradicts itself.
2593
+ // Distinct IDs one action resolves as a group (a modification's del+ins
2594
+ // pair) stay legitimate, as do DIFFERENT replies to the same comment.
2595
+ const seen_resolutions = new Map<string, [number, string]>();
2596
+ const seen_replies = new Set<string>();
2597
+ for (let i = 0; i < actions.length; i++) {
2598
+ const action = actions[i];
2599
+ const type = action.type;
2600
+ const target_id = action.target_id ?? "";
2601
+ if (type === "reply") {
2602
+ if (!String(action.text ?? "").trim()) {
2603
+ errors.push(
2604
+ `- Action ${i + 1} Failed: reply text for ${target_id} is empty or ` +
2605
+ `whitespace-only. Word would show a blank comment bubble — provide the ` +
2606
+ `reply content in 'text'.`,
2607
+ );
2608
+ continue;
2609
+ }
2610
+ const reply_key = `${target_id}${String(action.text).trim()}`;
2611
+ if (seen_replies.has(reply_key)) {
2612
+ errors.push(
2613
+ `- Action ${i + 1} Failed: duplicate reply — this batch already replies to ` +
2614
+ `${target_id} with the same text. Remove the duplicate action.`,
2615
+ );
2616
+ }
2617
+ seen_replies.add(reply_key);
2618
+ } else if (type === "accept" || type === "reject") {
2619
+ const prior = seen_resolutions.get(target_id);
2620
+ if (prior !== undefined) {
2621
+ const [first_idx, first_type] = prior;
2622
+ if (first_type === type) {
2623
+ errors.push(
2624
+ `- Action ${i + 1} Failed: duplicate action — Action ${first_idx + 1} in this ` +
2625
+ `batch already applies '${type}' to ${target_id}. A change can only be ` +
2626
+ `resolved once; remove the duplicate action.`,
2627
+ );
2628
+ } else {
2629
+ errors.push(
2630
+ `- Action ${i + 1} Failed: conflicting actions — Action ${first_idx + 1} in ` +
2631
+ `this batch applies '${first_type}' to ${target_id}, but this action applies ` +
2632
+ `'${type}'. Decide the outcome and keep exactly one of them.`,
2633
+ );
2634
+ }
2635
+ } else {
2636
+ seen_resolutions.set(target_id, [i, type]);
2637
+ }
2638
+ }
2639
+ }
2640
+ if (errors.length > 0) return errors;
2641
+
2526
2642
  for (let i = 0; i < actions.length; i++) {
2527
2643
  const action = actions[i];
2528
2644
  const type = action.type;
@@ -2659,9 +2775,15 @@ export class RedlineEngine {
2659
2775
  // _insert_and_split_ins).
2660
2776
 
2661
2777
  // BUG-7: Unified single-pass validation in wet-run / standard mode.
2778
+ // The document-aware pairing check runs BEFORE any action mutates the
2779
+ // DOM: accept + reject across one replacement's del+ins pair is a
2780
+ // contradiction, not two independent operations (ADEU-QA-004).
2662
2781
  if (!dry_run_mode) {
2663
- const action_errors =
2782
+ let action_errors =
2664
2783
  actions.length > 0 ? this.validate_review_actions(actions) : [];
2784
+ if (actions.length > 0 && action_errors.length === 0) {
2785
+ action_errors = this.validate_action_pairing(actions);
2786
+ }
2665
2787
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
2666
2788
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
2667
2789
  const all_errors = [...action_errors, ...edit_errors];
@@ -2670,7 +2792,10 @@ export class RedlineEngine {
2670
2792
  }
2671
2793
  } else {
2672
2794
  if (actions.length > 0) {
2673
- const action_errors = this.validate_review_actions(actions);
2795
+ let action_errors = this.validate_review_actions(actions);
2796
+ if (action_errors.length === 0) {
2797
+ action_errors = this.validate_action_pairing(actions);
2798
+ }
2674
2799
  if (action_errors.length > 0) {
2675
2800
  throw new BatchValidationError(action_errors);
2676
2801
  }
@@ -2679,10 +2804,12 @@ export class RedlineEngine {
2679
2804
 
2680
2805
  let applied_actions = 0;
2681
2806
  let skipped_actions = 0;
2807
+ let already_resolved_actions = 0;
2682
2808
  if (actions.length > 0) {
2683
2809
  const res = this.apply_review_actions(actions);
2684
2810
  applied_actions = res[0];
2685
2811
  skipped_actions = res[1];
2812
+ already_resolved_actions = res[2];
2686
2813
  if (skipped_actions > 0) {
2687
2814
  throw new BatchValidationError(this.skipped_details);
2688
2815
  }
@@ -2920,6 +3047,11 @@ export class RedlineEngine {
2920
3047
  return {
2921
3048
  actions_applied: applied_actions,
2922
3049
  actions_skipped: skipped_actions,
3050
+ // Actions whose target was already resolved by an earlier action of
3051
+ // this batch (via its replacement pair): consistent no-ops, never
3052
+ // counted as applied — every reported "applied" action causes an
3053
+ // observable state transition (ADEU-QA-004).
3054
+ actions_already_resolved: already_resolved_actions,
2923
3055
  edits_applied: applied_edits,
2924
3056
  edits_skipped: skipped_edits,
2925
3057
  // edits_applied counts change OBJECTS; this is the total number of
@@ -2975,13 +3107,17 @@ export class RedlineEngine {
2975
3107
  edit._resolved_start_idx = edit._match_start_index;
2976
3108
  resolved_edits.push([edit, edit.new_text || null]);
2977
3109
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
2978
- let matches = this.mapper.find_all_match_indices(edit.target_text);
3110
+ let matches = this.mapper.drop_virtual_only_matches(
3111
+ this.mapper.find_all_match_indices(edit.target_text),
3112
+ );
2979
3113
  let resolved_mapper = this.mapper;
2980
3114
  if (matches.length === 0) {
2981
3115
  if (!this.clean_mapper) {
2982
3116
  this.clean_mapper = new DocumentMapper(this.doc, true);
2983
3117
  }
2984
- matches = this.clean_mapper.find_all_match_indices(edit.target_text);
3118
+ matches = this.clean_mapper.drop_virtual_only_matches(
3119
+ this.clean_mapper.find_all_match_indices(edit.target_text),
3120
+ );
2985
3121
  resolved_mapper = this.clean_mapper;
2986
3122
  }
2987
3123
 
@@ -3205,11 +3341,165 @@ export class RedlineEngine {
3205
3341
  return [applied_logical, skipped_logical];
3206
3342
  }
3207
3343
 
3208
- public apply_review_actions(actions: any[]): [number, number] {
3344
+ /**
3345
+ * True when the paragraph still carries visible content (w:t text, w:tab,
3346
+ * w:br) that is NOT wrapped in a tracked deletion — i.e. the paragraph
3347
+ * would render non-empty in the accepted document.
3348
+ */
3349
+ private _paragraph_has_visible_content(p_elem: Element): boolean {
3350
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
3351
+ const nodes = findAllDescendants(p_elem, tag);
3352
+ for (const node of nodes) {
3353
+ let is_deleted = false;
3354
+ let curr = node.parentNode as Element | null;
3355
+ while (curr && curr !== p_elem.parentNode) {
3356
+ if (curr.tagName === "w:del") {
3357
+ is_deleted = true;
3358
+ break;
3359
+ }
3360
+ curr = curr.parentNode as Element | null;
3361
+ }
3362
+ if (!is_deleted) {
3363
+ if (tag === "w:t" && !node.textContent) continue;
3364
+ return true;
3365
+ }
3366
+ }
3367
+ }
3368
+ return false;
3369
+ }
3370
+
3371
+ /**
3372
+ * All contiguous same-author w:ins/w:del siblings that form one logical
3373
+ * modification block with `node` (a replacement's del+ins pair). Mirrors
3374
+ * the Python engine's _get_paired_nodes: comment range markers and
3375
+ * rPr/pPr are transparent; a different author or any other element breaks
3376
+ * the group.
3377
+ */
3378
+ private _get_paired_nodes(node: Element): Element[] {
3379
+ const pairs: Element[] = [];
3380
+ const author = node.getAttribute("w:author");
3381
+ const transparent = new Set([
3382
+ "w:commentRangeStart",
3383
+ "w:commentRangeEnd",
3384
+ "w:commentReference",
3385
+ "w:rPr",
3386
+ "w:pPr",
3387
+ ]);
3388
+
3389
+ const walk = (start: Element, dir: "next" | "prev") => {
3390
+ let cur: Node | null =
3391
+ dir === "next" ? start.nextSibling : start.previousSibling;
3392
+ while (cur) {
3393
+ if (cur.nodeType !== 1) {
3394
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
3395
+ continue;
3396
+ }
3397
+ const el = cur as Element;
3398
+ if (transparent.has(el.tagName)) {
3399
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
3400
+ continue;
3401
+ }
3402
+ if (
3403
+ (el.tagName === "w:ins" || el.tagName === "w:del") &&
3404
+ el.getAttribute("w:author") === author
3405
+ ) {
3406
+ pairs.push(el);
3407
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
3408
+ continue;
3409
+ }
3410
+ break;
3411
+ }
3412
+ };
3413
+
3414
+ walk(node, "next");
3415
+ walk(node, "prev");
3416
+ return pairs;
3417
+ }
3418
+
3419
+ /**
3420
+ * All revision ids that resolve as ONE unit with `target_id`: the ids of
3421
+ * every contiguous same-author w:ins/w:del sibling of its elements (a
3422
+ * replacement's del+ins pair), plus the id itself.
3423
+ */
3424
+ private _resolution_group_ids(target_id: string): Set<string> {
3425
+ const nodes = [
3426
+ ...findAllDescendants(this.doc.element, "w:ins"),
3427
+ ...findAllDescendants(this.doc.element, "w:del"),
3428
+ ].filter((n) => n.getAttribute("w:id") === target_id);
3429
+ const group = new Set<string>();
3430
+ if (nodes.length === 0) return group;
3431
+ group.add(target_id);
3432
+ for (const node of nodes) {
3433
+ for (const paired of this._get_paired_nodes(node)) {
3434
+ const pid = paired.getAttribute("w:id");
3435
+ if (pid) group.add(pid);
3436
+ }
3437
+ }
3438
+ return group;
3439
+ }
3440
+
3441
+ /**
3442
+ * Document-aware validation (QA 2026-07-19 ADEU-QA-004): a replacement's
3443
+ * del+ins pair carries two distinct ids but resolves as one unit, so a
3444
+ * batch that accepts one side and rejects the other is contradictory.
3445
+ * Rejecting it up front — before any action mutates the document — keeps
3446
+ * the batch transactional.
3447
+ */
3448
+ public validate_action_pairing(actions: any[]): string[] {
3449
+ const errors: string[] = [];
3450
+ const group_first = new Map<string, [number, string, string]>();
3451
+ for (let pos = 0; pos < actions.length; pos++) {
3452
+ const act = actions[pos];
3453
+ if (act.type !== "accept" && act.type !== "reject") continue;
3454
+ const raw_id = String(act.target_id ?? "");
3455
+ if (raw_id.startsWith("Com:")) continue;
3456
+ const target_id = raw_id.startsWith("Chg:") ? raw_id.slice(4) : raw_id;
3457
+ const group = this._resolution_group_ids(target_id);
3458
+ if (group.size === 0) continue; // unknown ids fail with their own not-found error
3459
+ let conflict: [number, string, string] | null = null;
3460
+ for (const gid of group) {
3461
+ const prior = group_first.get(gid);
3462
+ if (prior !== undefined && prior[1] !== act.type) {
3463
+ conflict = prior;
3464
+ break;
3465
+ }
3466
+ }
3467
+ if (conflict !== null) {
3468
+ const [first_pos, first_type, first_id] = conflict;
3469
+ errors.push(
3470
+ `- Action ${pos + 1} Failed: conflicting actions on one replacement — Action ` +
3471
+ `${first_pos + 1} applies '${first_type}' to Chg:${first_id}, and Chg:${target_id} is ` +
3472
+ `part of the same change (a replacement's contiguous del+ins pair resolves as one ` +
3473
+ `unit, so '${first_type}' already decides both sides). Accepting one side and ` +
3474
+ `rejecting the other is contradictory — decide the outcome and submit exactly one ` +
3475
+ `action for the pair.`,
3476
+ );
3477
+ continue;
3478
+ }
3479
+ for (const gid of group) {
3480
+ if (!group_first.has(gid)) {
3481
+ group_first.set(gid, [pos, act.type, target_id]);
3482
+ }
3483
+ }
3484
+ }
3485
+ return errors;
3486
+ }
3487
+
3488
+ /**
3489
+ * Returns [applied, skipped, already_resolved]. `applied` counts actions
3490
+ * that caused an observable state transition; an action naming an id an
3491
+ * earlier action of this batch already resolved (via its replacement pair)
3492
+ * is counted in `already_resolved` instead — never as applied
3493
+ * (QA 2026-07-19 ADEU-QA-004).
3494
+ */
3495
+ public apply_review_actions(actions: any[]): [number, number, number] {
3209
3496
  let applied = 0;
3210
3497
  let skipped = 0;
3498
+ let already_resolved = 0;
3499
+ const resolved_history = new Map<string, string>(); // id -> resolving action type
3211
3500
 
3212
- for (const action of actions) {
3501
+ for (let pos = 0; pos < actions.length; pos++) {
3502
+ const action = actions[pos];
3213
3503
  const type = action.type;
3214
3504
  if (type === "reply") {
3215
3505
  const cid = action.target_id.replace("Com:", "");
@@ -3224,6 +3514,32 @@ export class RedlineEngine {
3224
3514
  }
3225
3515
 
3226
3516
  const target_id = action.target_id.replace("Chg:", "");
3517
+
3518
+ const prior_type = resolved_history.get(target_id);
3519
+ if (prior_type !== undefined) {
3520
+ if (prior_type === type) {
3521
+ // Consistent follow-up on the pair: legitimate agent workflow
3522
+ // ("accept both ids of the replacement"), but no state transition
3523
+ // happens — report it accurately (ADEU-QA-004).
3524
+ already_resolved++;
3525
+ this.skipped_details.push(
3526
+ `- Note: Action ${pos + 1} ('${type}' on ${action.target_id}) had no additional effect — ` +
3527
+ `the change was already resolved together with its replacement pair by an earlier ` +
3528
+ `action in this batch. Counted as already_resolved, not applied.`,
3529
+ );
3530
+ continue;
3531
+ }
3532
+ // Contradiction. validate_action_pairing rejects this shape before
3533
+ // anything mutates; this guard covers direct callers.
3534
+ this.skipped_details.push(
3535
+ `- Action ${pos + 1} Failed: contradictory action — '${type}' on ${action.target_id}, but ` +
3536
+ `the change was already resolved as '${prior_type}' together with its replacement ` +
3537
+ `pair by an earlier action in this batch.`,
3538
+ );
3539
+ skipped++;
3540
+ continue;
3541
+ }
3542
+
3227
3543
  const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
3228
3544
  (n) => n.getAttribute("w:id") === target_id,
3229
3545
  );
@@ -3263,7 +3579,25 @@ export class RedlineEngine {
3263
3579
  continue;
3264
3580
  }
3265
3581
 
3582
+ // A modification is one logical unit stored as a contiguous
3583
+ // same-author del+ins pair: resolving either side resolves BOTH —
3584
+ // Word's atomic replacement handling, and the Python engine's
3585
+ // long-standing behavior. Without this, accepting the deletion side
3586
+ // left the paired insertion pending (engine divergence,
3587
+ // QA 2026-07-19 ADEU-QA-004).
3588
+ const group_nodes = new Set<Element>(all_nodes);
3266
3589
  for (const node of all_nodes) {
3590
+ for (const paired of this._get_paired_nodes(node)) {
3591
+ group_nodes.add(paired);
3592
+ }
3593
+ }
3594
+ const resolved_now = new Set<string>();
3595
+ for (const node of group_nodes) {
3596
+ const rid = node.getAttribute("w:id");
3597
+ if (rid) resolved_now.add(rid);
3598
+ }
3599
+
3600
+ for (const node of group_nodes) {
3267
3601
  const is_ins = node.tagName === "w:ins";
3268
3602
  const parent_tag = node.parentNode
3269
3603
  ? (node.parentNode as Element).tagName
@@ -3318,9 +3652,12 @@ export class RedlineEngine {
3318
3652
  }
3319
3653
  }
3320
3654
  }
3655
+ for (const rid of resolved_now) {
3656
+ resolved_history.set(rid, type);
3657
+ }
3321
3658
  applied++;
3322
3659
  }
3323
- return [applied, skipped];
3660
+ return [applied, skipped, already_resolved];
3324
3661
  }
3325
3662
 
3326
3663
  private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
@@ -3402,18 +3739,16 @@ export class RedlineEngine {
3402
3739
  const is_regex = edit.regex || false;
3403
3740
  const match_mode = edit.match_mode || "strict";
3404
3741
 
3405
- let matches = this.mapper.find_all_match_indices(
3406
- edit.target_text,
3407
- is_regex,
3742
+ let matches = this.mapper.drop_virtual_only_matches(
3743
+ this.mapper.find_all_match_indices(edit.target_text, is_regex),
3408
3744
  );
3409
3745
  let use_clean_map = false;
3410
3746
 
3411
3747
  if (matches.length === 0) {
3412
3748
  if (!this.clean_mapper)
3413
3749
  this.clean_mapper = new DocumentMapper(this.doc, true);
3414
- matches = this.clean_mapper.find_all_match_indices(
3415
- edit.target_text,
3416
- is_regex,
3750
+ matches = this.clean_mapper.drop_virtual_only_matches(
3751
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
3417
3752
  );
3418
3753
  if (matches.length > 0) use_clean_map = true;
3419
3754
  else return null;
@@ -3427,6 +3762,8 @@ export class RedlineEngine {
3427
3762
  (span) =>
3428
3763
  span.run !== null && span.end > s && span.start < s + match_len,
3429
3764
  );
3765
+ // Virtual-only matches were already dropped above; here we only skip
3766
+ // matches buried entirely inside tracked deletions.
3430
3767
  if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
3431
3768
  live_matches.push([s, match_len]);
3432
3769
  }
@@ -4106,7 +4443,9 @@ export class RedlineEngine {
4106
4443
  this._set_paragraph_style(new_p, style_name);
4107
4444
  } else {
4108
4445
  const existing_pPr = findChild(_bug233_target_para, "w:pPr");
4109
- if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
4446
+ if (existing_pPr) {
4447
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
4448
+ }
4110
4449
  }
4111
4450
  let pPr = findChild(new_p, "w:pPr");
4112
4451
  if (!pPr) {
@@ -4161,6 +4500,9 @@ export class RedlineEngine {
4161
4500
  ins_id!,
4162
4501
  null,
4163
4502
  suppress_emphasis,
4503
+ // Paragraph-start insertions attach BEFORE the anchor (see
4504
+ // before_anchor below): the suffix relocation must know.
4505
+ start_idx === 0,
4164
4506
  );
4165
4507
 
4166
4508
  if (!result.first_node) return false;
@@ -4447,7 +4789,41 @@ export class RedlineEngine {
4447
4789
  }
4448
4790
 
4449
4791
  if (p2_element && p2_element.tagName === "w:p") {
4792
+ // Decide the merged container's properties BEFORE p2's children
4793
+ // move in: when p1 keeps no visible content (a FULL paragraph
4794
+ // deletion), the only surviving text is p2's — the merged
4795
+ // paragraph must carry p2's properties (style, numbering).
4796
+ // Keeping p1's restyled the following paragraph: deleting a
4797
+ // heading turned the next body paragraph into a heading,
4798
+ // deleting a plain paragraph before a list item stripped the
4799
+ // item's numbering (QA 2026-07-19 ADEU-QA-002 B).
4800
+ const p1_fully_deleted =
4801
+ !this._paragraph_has_visible_content(p1_element);
4802
+
4450
4803
  let pPr = findChild(p1_element, "w:pPr");
4804
+ if (p1_fully_deleted) {
4805
+ const p2_pPr = findChild(p2_element, "w:pPr");
4806
+ const adopted = (
4807
+ p2_pPr
4808
+ ? p2_pPr.cloneNode(true)
4809
+ : p1_element.ownerDocument!.createElement("w:pPr")
4810
+ ) as Element;
4811
+ // Section properties belong to p1's position in the document
4812
+ // flow, never to p2's styling — carry them over so a section
4813
+ // boundary is not destroyed.
4814
+ if (pPr) {
4815
+ const sect = findChild(pPr, "w:sectPr");
4816
+ if (sect && !findChild(adopted, "w:sectPr")) {
4817
+ adopted.appendChild(sect.cloneNode(true));
4818
+ }
4819
+ p1_element.removeChild(pPr);
4820
+ }
4821
+ p1_element.insertBefore(
4822
+ adopted,
4823
+ p1_element.firstChild as Node | null,
4824
+ );
4825
+ pPr = adopted;
4826
+ }
4451
4827
  if (!pPr) {
4452
4828
  pPr = p1_element.ownerDocument!.createElement("w:pPr") as Element;
4453
4829
  p1_element.insertBefore(
@@ -4460,8 +4836,10 @@ export class RedlineEngine {
4460
4836
  rPr = p1_element.ownerDocument!.createElement("w:rPr") as Element;
4461
4837
  pPr!.appendChild(rPr);
4462
4838
  }
4463
- const del_mark = this._create_track_change_tag("w:del");
4464
- rPr!.appendChild(del_mark);
4839
+ if (!findChild(rPr!, "w:del")) {
4840
+ const del_mark = this._create_track_change_tag("w:del");
4841
+ rPr!.appendChild(del_mark);
4842
+ }
4465
4843
 
4466
4844
  const children = Array.from(p2_element.childNodes);
4467
4845
  for (const child of children) {
@@ -4531,27 +4909,7 @@ export class RedlineEngine {
4531
4909
 
4532
4910
  // PHASE 2: Check for orphaned paragraphs with zero visible content remaining
4533
4911
  for (const p_elem of affected_ps) {
4534
- let has_visible = false;
4535
- for (const tag of ["w:t", "w:tab", "w:br"]) {
4536
- const nodes = findAllDescendants(p_elem, tag);
4537
- for (const node of nodes) {
4538
- let is_deleted = false;
4539
- let curr = node.parentNode as Element | null;
4540
- while (curr && curr !== p_elem.parentNode) {
4541
- if (curr.tagName === "w:del") {
4542
- is_deleted = true;
4543
- break;
4544
- }
4545
- curr = curr.parentNode as Element | null;
4546
- }
4547
- if (!is_deleted) {
4548
- if (tag === "w:t" && !node.textContent) continue;
4549
- has_visible = true;
4550
- break;
4551
- }
4552
- }
4553
- if (has_visible) break;
4554
- }
4912
+ const has_visible = this._paragraph_has_visible_content(p_elem);
4555
4913
 
4556
4914
  if (!has_visible) {
4557
4915
  let pPr = findChild(p_elem, "w:pPr");