@adeu/core 1.27.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,
@@ -1613,7 +1635,7 @@ export class RedlineEngine {
1613
1635
  // Inherit pPr from the anchor paragraph (preserves list numbering).
1614
1636
  const existing_pPr = findChild(current_p, "w:pPr");
1615
1637
  if (existing_pPr) {
1616
- new_p.appendChild(existing_pPr.cloneNode(true));
1638
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
1617
1639
  }
1618
1640
  }
1619
1641
 
@@ -2134,9 +2156,13 @@ export class RedlineEngine {
2134
2156
  }
2135
2157
  }
2136
2158
 
2137
- let matches = this.mapper.find_all_match_indices(
2138
- edit.target_text,
2139
- 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),
2140
2166
  );
2141
2167
  let activeText = this.mapper.full_text;
2142
2168
  let target_mapper = this.mapper;
@@ -2144,9 +2170,8 @@ export class RedlineEngine {
2144
2170
  if (matches.length === 0) {
2145
2171
  if (!this.clean_mapper)
2146
2172
  this.clean_mapper = new DocumentMapper(this.doc, true);
2147
- matches = this.clean_mapper.find_all_match_indices(
2148
- edit.target_text,
2149
- is_regex,
2173
+ matches = this.clean_mapper.drop_virtual_only_matches(
2174
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
2150
2175
  );
2151
2176
  if (matches.length > 0) {
2152
2177
  activeText = this.clean_mapper.full_text;
@@ -2164,9 +2189,9 @@ export class RedlineEngine {
2164
2189
  const realSpans = this.mapper.spans.filter(
2165
2190
  (s) => s.run !== null && s.end > start && s.start < start + length,
2166
2191
  );
2167
- if (realSpans.length === 0) return true; // virtual-only; keep
2168
- // Keep only if at least one overlapping real span is live (not
2169
- // 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;
2170
2195
  return realSpans.some((s) => !s.del_id);
2171
2196
  });
2172
2197
  matches = liveMatches;
@@ -2179,9 +2204,11 @@ export class RedlineEngine {
2179
2204
  if (!this.original_mapper) {
2180
2205
  this.original_mapper = new DocumentMapper(this.doc, false, true);
2181
2206
  }
2182
- const orig_matches = this.original_mapper.find_all_match_indices(
2183
- edit.target_text,
2184
- 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
+ ),
2185
2212
  );
2186
2213
  if (orig_matches.length > 0) {
2187
2214
  is_deleted_text = true;
@@ -2535,10 +2562,15 @@ export class RedlineEngine {
2535
2562
  length: number,
2536
2563
  ): number | null {
2537
2564
  for (const s of mapper.spans) {
2538
- if (s.run === null || s.end <= start || s.start >= start + length) {
2565
+ if (s.end <= start || s.start >= start + length) {
2539
2566
  continue;
2540
2567
  }
2541
- 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
+ }
2542
2574
  while (curr) {
2543
2575
  if (curr.nodeType === 1 && (curr as Element).tagName === "w:tr") {
2544
2576
  return findAllDescendants(curr as Element, "w:tc").filter(
@@ -2743,9 +2775,15 @@ export class RedlineEngine {
2743
2775
  // _insert_and_split_ins).
2744
2776
 
2745
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).
2746
2781
  if (!dry_run_mode) {
2747
- const action_errors =
2782
+ let action_errors =
2748
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
+ }
2749
2787
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
2750
2788
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
2751
2789
  const all_errors = [...action_errors, ...edit_errors];
@@ -2754,7 +2792,10 @@ export class RedlineEngine {
2754
2792
  }
2755
2793
  } else {
2756
2794
  if (actions.length > 0) {
2757
- 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
+ }
2758
2799
  if (action_errors.length > 0) {
2759
2800
  throw new BatchValidationError(action_errors);
2760
2801
  }
@@ -2763,10 +2804,12 @@ export class RedlineEngine {
2763
2804
 
2764
2805
  let applied_actions = 0;
2765
2806
  let skipped_actions = 0;
2807
+ let already_resolved_actions = 0;
2766
2808
  if (actions.length > 0) {
2767
2809
  const res = this.apply_review_actions(actions);
2768
2810
  applied_actions = res[0];
2769
2811
  skipped_actions = res[1];
2812
+ already_resolved_actions = res[2];
2770
2813
  if (skipped_actions > 0) {
2771
2814
  throw new BatchValidationError(this.skipped_details);
2772
2815
  }
@@ -3004,6 +3047,11 @@ export class RedlineEngine {
3004
3047
  return {
3005
3048
  actions_applied: applied_actions,
3006
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,
3007
3055
  edits_applied: applied_edits,
3008
3056
  edits_skipped: skipped_edits,
3009
3057
  // edits_applied counts change OBJECTS; this is the total number of
@@ -3059,13 +3107,17 @@ export class RedlineEngine {
3059
3107
  edit._resolved_start_idx = edit._match_start_index;
3060
3108
  resolved_edits.push([edit, edit.new_text || null]);
3061
3109
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
3062
- 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
+ );
3063
3113
  let resolved_mapper = this.mapper;
3064
3114
  if (matches.length === 0) {
3065
3115
  if (!this.clean_mapper) {
3066
3116
  this.clean_mapper = new DocumentMapper(this.doc, true);
3067
3117
  }
3068
- 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
+ );
3069
3121
  resolved_mapper = this.clean_mapper;
3070
3122
  }
3071
3123
 
@@ -3289,11 +3341,165 @@ export class RedlineEngine {
3289
3341
  return [applied_logical, skipped_logical];
3290
3342
  }
3291
3343
 
3292
- 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] {
3293
3496
  let applied = 0;
3294
3497
  let skipped = 0;
3498
+ let already_resolved = 0;
3499
+ const resolved_history = new Map<string, string>(); // id -> resolving action type
3295
3500
 
3296
- for (const action of actions) {
3501
+ for (let pos = 0; pos < actions.length; pos++) {
3502
+ const action = actions[pos];
3297
3503
  const type = action.type;
3298
3504
  if (type === "reply") {
3299
3505
  const cid = action.target_id.replace("Com:", "");
@@ -3308,6 +3514,32 @@ export class RedlineEngine {
3308
3514
  }
3309
3515
 
3310
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
+
3311
3543
  const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
3312
3544
  (n) => n.getAttribute("w:id") === target_id,
3313
3545
  );
@@ -3347,7 +3579,25 @@ export class RedlineEngine {
3347
3579
  continue;
3348
3580
  }
3349
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);
3350
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) {
3351
3601
  const is_ins = node.tagName === "w:ins";
3352
3602
  const parent_tag = node.parentNode
3353
3603
  ? (node.parentNode as Element).tagName
@@ -3402,9 +3652,12 @@ export class RedlineEngine {
3402
3652
  }
3403
3653
  }
3404
3654
  }
3655
+ for (const rid of resolved_now) {
3656
+ resolved_history.set(rid, type);
3657
+ }
3405
3658
  applied++;
3406
3659
  }
3407
- return [applied, skipped];
3660
+ return [applied, skipped, already_resolved];
3408
3661
  }
3409
3662
 
3410
3663
  private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
@@ -3486,18 +3739,16 @@ export class RedlineEngine {
3486
3739
  const is_regex = edit.regex || false;
3487
3740
  const match_mode = edit.match_mode || "strict";
3488
3741
 
3489
- let matches = this.mapper.find_all_match_indices(
3490
- edit.target_text,
3491
- is_regex,
3742
+ let matches = this.mapper.drop_virtual_only_matches(
3743
+ this.mapper.find_all_match_indices(edit.target_text, is_regex),
3492
3744
  );
3493
3745
  let use_clean_map = false;
3494
3746
 
3495
3747
  if (matches.length === 0) {
3496
3748
  if (!this.clean_mapper)
3497
3749
  this.clean_mapper = new DocumentMapper(this.doc, true);
3498
- matches = this.clean_mapper.find_all_match_indices(
3499
- edit.target_text,
3500
- is_regex,
3750
+ matches = this.clean_mapper.drop_virtual_only_matches(
3751
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
3501
3752
  );
3502
3753
  if (matches.length > 0) use_clean_map = true;
3503
3754
  else return null;
@@ -3511,6 +3762,8 @@ export class RedlineEngine {
3511
3762
  (span) =>
3512
3763
  span.run !== null && span.end > s && span.start < s + match_len,
3513
3764
  );
3765
+ // Virtual-only matches were already dropped above; here we only skip
3766
+ // matches buried entirely inside tracked deletions.
3514
3767
  if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
3515
3768
  live_matches.push([s, match_len]);
3516
3769
  }
@@ -4190,7 +4443,9 @@ export class RedlineEngine {
4190
4443
  this._set_paragraph_style(new_p, style_name);
4191
4444
  } else {
4192
4445
  const existing_pPr = findChild(_bug233_target_para, "w:pPr");
4193
- 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
+ }
4194
4449
  }
4195
4450
  let pPr = findChild(new_p, "w:pPr");
4196
4451
  if (!pPr) {
@@ -4534,7 +4789,41 @@ export class RedlineEngine {
4534
4789
  }
4535
4790
 
4536
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
+
4537
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
+ }
4538
4827
  if (!pPr) {
4539
4828
  pPr = p1_element.ownerDocument!.createElement("w:pPr") as Element;
4540
4829
  p1_element.insertBefore(
@@ -4547,8 +4836,10 @@ export class RedlineEngine {
4547
4836
  rPr = p1_element.ownerDocument!.createElement("w:rPr") as Element;
4548
4837
  pPr!.appendChild(rPr);
4549
4838
  }
4550
- const del_mark = this._create_track_change_tag("w:del");
4551
- 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
+ }
4552
4843
 
4553
4844
  const children = Array.from(p2_element.childNodes);
4554
4845
  for (const child of children) {
@@ -4618,27 +4909,7 @@ export class RedlineEngine {
4618
4909
 
4619
4910
  // PHASE 2: Check for orphaned paragraphs with zero visible content remaining
4620
4911
  for (const p_elem of affected_ps) {
4621
- let has_visible = false;
4622
- for (const tag of ["w:t", "w:tab", "w:br"]) {
4623
- const nodes = findAllDescendants(p_elem, tag);
4624
- for (const node of nodes) {
4625
- let is_deleted = false;
4626
- let curr = node.parentNode as Element | null;
4627
- while (curr && curr !== p_elem.parentNode) {
4628
- if (curr.tagName === "w:del") {
4629
- is_deleted = true;
4630
- break;
4631
- }
4632
- curr = curr.parentNode as Element | null;
4633
- }
4634
- if (!is_deleted) {
4635
- if (tag === "w:t" && !node.textContent) continue;
4636
- has_visible = true;
4637
- break;
4638
- }
4639
- }
4640
- if (has_visible) break;
4641
- }
4912
+ const has_visible = this._paragraph_has_visible_content(p_elem);
4642
4913
 
4643
4914
  if (!has_visible) {
4644
4915
  let pPr = findChild(p_elem, "w:pPr");
@@ -23,7 +23,9 @@ describe('Ingestion Engine (Node.js Port)', () => {
23
23
  expect(markdown.length).toBeGreaterThan(0);
24
24
 
25
25
  // Assert exact parity with the Python engine's CriticMarkup generation
26
- expect(markdown).toBe('This is the {--initial --}{++golden ++}{>>[Chg:3 delete] Mikko Korpela\n[Chg:4 insert] Mikko Korpela\n[Com:0] Mikko Korpela @ 2026-01-23T07:25:00Z: Start of comment thread\n[Com:1] Mikko Korpela @ 2026-01-23T07:25:00Z: Second comment\n[Com:2] Mikko Korpela @ 2026-01-23T07:26:00Z: Third comment in the thread<<}document');
26
+ // The del+ins pair of one modification is annotated as a resolution
27
+ // group (QA 2026-07-19 ADEU-QA-004).
28
+ expect(markdown).toBe('This is the {--initial --}{++golden ++}{>>[Chg:3 delete] Mikko Korpela (pairs with Chg:4)\n[Chg:4 insert] Mikko Korpela (pairs with Chg:3)\n[Com:0] Mikko Korpela @ 2026-01-23T07:25:00Z: Start of comment thread\n[Com:1] Mikko Korpela @ 2026-01-23T07:25:00Z: Second comment\n[Com:2] Mikko Korpela @ 2026-01-23T07:26:00Z: Third comment in the thread<<}document');
27
29
  });
28
30
 
29
31
  it('should execute in cleanView mode without failing', async () => {
package/src/ingest.ts CHANGED
@@ -2,6 +2,7 @@ import { DocumentObject } from "./docx/bridge.js";
2
2
  import { Paragraph, Table, Run, DocxEvent } from "./docx/primitives.js";
3
3
  import {
4
4
  _get_style_cache,
5
+ compute_change_pair_map,
5
6
  get_paragraph_prefix,
6
7
  is_heading_paragraph,
7
8
  is_native_heading,
@@ -258,7 +259,8 @@ export function extract_table(
258
259
  | undefined;
259
260
  const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
260
261
  if (paraId) {
261
- const anchor = `{#cell:${paraId}}`;
262
+ const space_pad = cell_content ? " " : "";
263
+ const anchor = `${space_pad}{#cell:${paraId}}`;
262
264
  cell_content = cell_content + anchor;
263
265
  }
264
266
  }
@@ -603,13 +605,22 @@ function _build_merged_meta_block(
603
605
  const comment_lines: string[] = [];
604
606
  const seen_sigs = new Set<string>();
605
607
 
608
+ // Ids of one resolution group (a replacement's contiguous same-author
609
+ // del+ins pair) must not read as independently resolvable — either side
610
+ // resolves the whole group (QA 2026-07-19 ADEU-QA-004).
611
+ const pair_map = compute_change_pair_map(states_list);
612
+ const pairSuffix = (uid: string): string =>
613
+ pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
614
+
606
615
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
607
616
  for (const [uid, meta] of Object.entries(
608
617
  ins_map as Record<string, DocxEvent>,
609
618
  )) {
610
619
  const sig = `Chg:${uid}`;
611
620
  if (!seen_sigs.has(sig)) {
612
- change_lines.push(`[${sig} insert] ${meta.author || "Unknown"}`);
621
+ change_lines.push(
622
+ `[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
623
+ );
613
624
  seen_sigs.add(sig);
614
625
  }
615
626
  }
@@ -618,7 +629,9 @@ function _build_merged_meta_block(
618
629
  )) {
619
630
  const sig = `Chg:${uid}`;
620
631
  if (!seen_sigs.has(sig)) {
621
- change_lines.push(`[${sig} delete] ${meta.author || "Unknown"}`);
632
+ change_lines.push(
633
+ `[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
634
+ );
622
635
  seen_sigs.add(sig);
623
636
  }
624
637
  }