@adeu/core 1.27.0 → 1.29.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
@@ -905,10 +905,30 @@ export class RedlineEngine {
905
905
  }
906
906
  }
907
907
 
908
+ // Pre-count revisions before mutating. Unit is REVISION ELEMENTS, matching
909
+ // the Python engine and sanitize's count_tracked_changes so no two surfaces
910
+ // report different totals for one document.
911
+ let accepted_insertions = 0;
912
+ let accepted_deletions = 0;
913
+ let accepted_formatting = 0;
914
+ for (const root_element of parts_to_process) {
915
+ accepted_insertions += findAllDescendants(root_element, "w:ins").length;
916
+ accepted_deletions += findAllDescendants(root_element, "w:del").length;
917
+ for (const tag of ["w:rPrChange", "w:pPrChange", "w:sectPrChange"]) {
918
+ accepted_formatting += findAllDescendants(root_element, tag).length;
919
+ }
920
+ }
921
+
922
+ // Counted as it happens below, not pre-read from the comments part: this
923
+ // method only deletes the bodies of OUR OWN comments wrapping a resolved
924
+ // revision (foreign ones keep their body by design), so the document's
925
+ // comment total would claim removals that never happened.
926
+ let removed_comments = 0;
927
+
908
928
  for (const root_element of parts_to_process) {
909
929
  const insNodes = findAllDescendants(root_element, "w:ins");
910
930
  for (const ins of insNodes) {
911
- this._clean_wrapping_comments(ins);
931
+ removed_comments += this._clean_wrapping_comments(ins);
912
932
  const parent = ins.parentNode as Element | null;
913
933
  if (!parent) continue;
914
934
 
@@ -956,8 +976,8 @@ export class RedlineEngine {
956
976
  if (has_content) {
957
977
  rPr.removeChild(delMark);
958
978
  } else {
959
- this._clean_wrapping_comments(p);
960
- this._delete_comments_in_element(p);
979
+ removed_comments += this._clean_wrapping_comments(p);
980
+ removed_comments += this._delete_comments_in_element(p);
961
981
  if (p.parentNode) {
962
982
  p.parentNode.removeChild(p);
963
983
  }
@@ -968,8 +988,8 @@ export class RedlineEngine {
968
988
 
969
989
  const delNodes = findAllDescendants(root_element, "w:del");
970
990
  for (const d of delNodes) {
971
- this._clean_wrapping_comments(d);
972
- this._delete_comments_in_element(d);
991
+ removed_comments += this._clean_wrapping_comments(d);
992
+ removed_comments += this._delete_comments_in_element(d);
973
993
  const parent = d.parentNode as Element | null;
974
994
  if (parent) {
975
995
  if (parent.tagName === "w:trPr") {
@@ -1089,6 +1109,13 @@ export class RedlineEngine {
1089
1109
  }
1090
1110
  }
1091
1111
  }
1112
+
1113
+ return {
1114
+ accepted_insertions,
1115
+ accepted_deletions,
1116
+ accepted_formatting,
1117
+ removed_comments,
1118
+ };
1092
1119
  }
1093
1120
 
1094
1121
  /**
@@ -1409,6 +1436,28 @@ export class RedlineEngine {
1409
1436
  *
1410
1437
  * Does NOT attach comments; callers handle that.
1411
1438
  */
1439
+ private _clone_pPr_scrubbing_headings(existing_pPr: Element): Element {
1440
+ const pPr_clone = existing_pPr.cloneNode(true) as Element;
1441
+ const pStyle_el = findChild(pPr_clone, "w:pStyle");
1442
+ if (pStyle_el) {
1443
+ const style_val = pStyle_el.getAttribute("w:val");
1444
+ if (style_val) {
1445
+ const is_heading =
1446
+ style_val.startsWith("Heading") ||
1447
+ style_val === "Title" ||
1448
+ style_val.replace(/\s+/g, "").startsWith("Heading");
1449
+ if (is_heading) {
1450
+ pPr_clone.removeChild(pStyle_el);
1451
+ }
1452
+ }
1453
+ }
1454
+ const outlineLvl_el = findChild(pPr_clone, "w:outlineLvl");
1455
+ if (outlineLvl_el) {
1456
+ pPr_clone.removeChild(outlineLvl_el);
1457
+ }
1458
+ return pPr_clone;
1459
+ }
1460
+
1412
1461
  private _track_insert_multiline(
1413
1462
  text: string,
1414
1463
  anchor_run: Run | null,
@@ -1613,7 +1662,7 @@ export class RedlineEngine {
1613
1662
  // Inherit pPr from the anchor paragraph (preserves list numbering).
1614
1663
  const existing_pPr = findChild(current_p, "w:pPr");
1615
1664
  if (existing_pPr) {
1616
- new_p.appendChild(existing_pPr.cloneNode(true));
1665
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
1617
1666
  }
1618
1667
  }
1619
1668
 
@@ -1963,7 +2012,10 @@ export class RedlineEngine {
1963
2012
  }
1964
2013
  return null;
1965
2014
  }
1966
- private _clean_wrapping_comments(element: Element) {
2015
+ /** Returns how many comment BODIES were actually deleted (see below: only
2016
+ * our own are; foreign ones keep their body and lose only the anchor). */
2017
+ private _clean_wrapping_comments(element: Element): number {
2018
+ let deleted = 0;
1967
2019
  let first_node: Element = element;
1968
2020
  while (true) {
1969
2021
  const prev = getPreviousElement(first_node);
@@ -2049,6 +2101,7 @@ export class RedlineEngine {
2049
2101
  const is_own = author !== null && author === this.author;
2050
2102
  if (is_own) {
2051
2103
  this.comments_manager.deleteComment(c_id);
2104
+ deleted++;
2052
2105
  }
2053
2106
  if (s.parentNode) s.parentNode.removeChild(s);
2054
2107
  for (const e of ends_to_remove) {
@@ -2066,14 +2119,18 @@ export class RedlineEngine {
2066
2119
  }
2067
2120
  }
2068
2121
  }
2122
+ return deleted;
2069
2123
  }
2070
2124
 
2071
- private _delete_comments_in_element(element: Element) {
2125
+ /** Returns how many comment bodies were deleted. */
2126
+ private _delete_comments_in_element(element: Element): number {
2127
+ let deleted = 0;
2072
2128
  const refs = findAllDescendants(element, "w:commentReference");
2073
2129
  for (const ref of refs) {
2074
2130
  const c_id = ref.getAttribute("w:id");
2075
2131
  if (c_id) {
2076
2132
  this.comments_manager.deleteComment(c_id);
2133
+ deleted++;
2077
2134
  for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
2078
2135
  const nodes = findAllDescendants(this.doc.element, tag);
2079
2136
  for (const node of nodes) {
@@ -2084,6 +2141,7 @@ export class RedlineEngine {
2084
2141
  }
2085
2142
  }
2086
2143
  }
2144
+ return deleted;
2087
2145
  }
2088
2146
 
2089
2147
  public validate_edits(edits: any[], index_offset: number = 0): string[] {
@@ -2134,9 +2192,13 @@ export class RedlineEngine {
2134
2192
  }
2135
2193
  }
2136
2194
 
2137
- let matches = this.mapper.find_all_match_indices(
2138
- edit.target_text,
2139
- is_regex,
2195
+ // Matches covering ONLY virtual projection text (meta bubbles,
2196
+ // timestamps, style markers) are phantoms: they can neither be edited
2197
+ // nor legitimately ambiguate a real match — a target of "4" was
2198
+ // rejected as "appears 8 times" because comment-bubble timestamps
2199
+ // matched (QA 2026-07-19 ADEU-QA-002 C).
2200
+ let matches = this.mapper.drop_virtual_only_matches(
2201
+ this.mapper.find_all_match_indices(edit.target_text, is_regex),
2140
2202
  );
2141
2203
  let activeText = this.mapper.full_text;
2142
2204
  let target_mapper = this.mapper;
@@ -2144,9 +2206,8 @@ export class RedlineEngine {
2144
2206
  if (matches.length === 0) {
2145
2207
  if (!this.clean_mapper)
2146
2208
  this.clean_mapper = new DocumentMapper(this.doc, true);
2147
- matches = this.clean_mapper.find_all_match_indices(
2148
- edit.target_text,
2149
- is_regex,
2209
+ matches = this.clean_mapper.drop_virtual_only_matches(
2210
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
2150
2211
  );
2151
2212
  if (matches.length > 0) {
2152
2213
  activeText = this.clean_mapper.full_text;
@@ -2164,9 +2225,9 @@ export class RedlineEngine {
2164
2225
  const realSpans = this.mapper.spans.filter(
2165
2226
  (s) => s.run !== null && s.end > start && s.start < start + length,
2166
2227
  );
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).
2228
+ // Virtual-only matches were already dropped above; here we only
2229
+ // skip matches buried entirely inside tracked deletions.
2230
+ if (realSpans.length === 0) return true;
2170
2231
  return realSpans.some((s) => !s.del_id);
2171
2232
  });
2172
2233
  matches = liveMatches;
@@ -2179,9 +2240,11 @@ export class RedlineEngine {
2179
2240
  if (!this.original_mapper) {
2180
2241
  this.original_mapper = new DocumentMapper(this.doc, false, true);
2181
2242
  }
2182
- const orig_matches = this.original_mapper.find_all_match_indices(
2183
- edit.target_text,
2184
- is_regex,
2243
+ const orig_matches = this.original_mapper.drop_virtual_only_matches(
2244
+ this.original_mapper.find_all_match_indices(
2245
+ edit.target_text,
2246
+ is_regex,
2247
+ ),
2185
2248
  );
2186
2249
  if (orig_matches.length > 0) {
2187
2250
  is_deleted_text = true;
@@ -2535,10 +2598,15 @@ export class RedlineEngine {
2535
2598
  length: number,
2536
2599
  ): number | null {
2537
2600
  for (const s of mapper.spans) {
2538
- if (s.run === null || s.end <= start || s.start >= start + length) {
2601
+ if (s.end <= start || s.start >= start + length) {
2539
2602
  continue;
2540
2603
  }
2541
- let curr: Node | null = s.run._element;
2604
+ let curr: Node | null = null;
2605
+ if (s.run !== null) {
2606
+ curr = s.run._element;
2607
+ } else if (s.paragraph !== null) {
2608
+ curr = s.paragraph._element;
2609
+ }
2542
2610
  while (curr) {
2543
2611
  if (curr.nodeType === 1 && (curr as Element).tagName === "w:tr") {
2544
2612
  return findAllDescendants(curr as Element, "w:tc").filter(
@@ -2743,9 +2811,15 @@ export class RedlineEngine {
2743
2811
  // _insert_and_split_ins).
2744
2812
 
2745
2813
  // BUG-7: Unified single-pass validation in wet-run / standard mode.
2814
+ // The document-aware pairing check runs BEFORE any action mutates the
2815
+ // DOM: accept + reject across one replacement's del+ins pair is a
2816
+ // contradiction, not two independent operations (ADEU-QA-004).
2746
2817
  if (!dry_run_mode) {
2747
- const action_errors =
2818
+ let action_errors =
2748
2819
  actions.length > 0 ? this.validate_review_actions(actions) : [];
2820
+ if (actions.length > 0 && action_errors.length === 0) {
2821
+ action_errors = this.validate_action_pairing(actions);
2822
+ }
2749
2823
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
2750
2824
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
2751
2825
  const all_errors = [...action_errors, ...edit_errors];
@@ -2754,7 +2828,10 @@ export class RedlineEngine {
2754
2828
  }
2755
2829
  } else {
2756
2830
  if (actions.length > 0) {
2757
- const action_errors = this.validate_review_actions(actions);
2831
+ let action_errors = this.validate_review_actions(actions);
2832
+ if (action_errors.length === 0) {
2833
+ action_errors = this.validate_action_pairing(actions);
2834
+ }
2758
2835
  if (action_errors.length > 0) {
2759
2836
  throw new BatchValidationError(action_errors);
2760
2837
  }
@@ -2763,10 +2840,12 @@ export class RedlineEngine {
2763
2840
 
2764
2841
  let applied_actions = 0;
2765
2842
  let skipped_actions = 0;
2843
+ let already_resolved_actions = 0;
2766
2844
  if (actions.length > 0) {
2767
2845
  const res = this.apply_review_actions(actions);
2768
2846
  applied_actions = res[0];
2769
2847
  skipped_actions = res[1];
2848
+ already_resolved_actions = res[2];
2770
2849
  if (skipped_actions > 0) {
2771
2850
  throw new BatchValidationError(this.skipped_details);
2772
2851
  }
@@ -2850,6 +2929,7 @@ export class RedlineEngine {
2850
2929
  );
2851
2930
  reports_by_input[i] = {
2852
2931
  status: "failed",
2932
+ type: (edit as any).type || "modify",
2853
2933
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
2854
2934
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
2855
2935
  warning: warning,
@@ -2865,6 +2945,7 @@ export class RedlineEngine {
2865
2945
  const previews = this._build_edit_context_previews(edit);
2866
2946
  reports_by_input[i] = {
2867
2947
  status: "applied",
2948
+ type: (edit as any).type || "modify",
2868
2949
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
2869
2950
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
2870
2951
  warning: null,
@@ -2889,6 +2970,7 @@ export class RedlineEngine {
2889
2970
  );
2890
2971
  reports_by_input[i] = {
2891
2972
  status: "failed",
2973
+ type: (edit as any).type || "modify",
2892
2974
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
2893
2975
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
2894
2976
  warning: warning,
@@ -2911,6 +2993,7 @@ export class RedlineEngine {
2911
2993
  if (report.status === "applied") {
2912
2994
  reports_by_input[i] = {
2913
2995
  status: "failed",
2996
+ type: report.type || "modify",
2914
2997
  target_text: report.target_text,
2915
2998
  new_text: report.new_text,
2916
2999
  warning: null,
@@ -2987,6 +3070,7 @@ export class RedlineEngine {
2987
3070
  }
2988
3071
  edits_reports.push({
2989
3072
  status: success ? "applied" : "failed",
3073
+ type: (edit as any).type || "modify",
2990
3074
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
2991
3075
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
2992
3076
  warning: warning,
@@ -3004,6 +3088,11 @@ export class RedlineEngine {
3004
3088
  return {
3005
3089
  actions_applied: applied_actions,
3006
3090
  actions_skipped: skipped_actions,
3091
+ // Actions whose target was already resolved by an earlier action of
3092
+ // this batch (via its replacement pair): consistent no-ops, never
3093
+ // counted as applied — every reported "applied" action causes an
3094
+ // observable state transition (ADEU-QA-004).
3095
+ actions_already_resolved: already_resolved_actions,
3007
3096
  edits_applied: applied_edits,
3008
3097
  edits_skipped: skipped_edits,
3009
3098
  // edits_applied counts change OBJECTS; this is the total number of
@@ -3059,24 +3148,79 @@ export class RedlineEngine {
3059
3148
  edit._resolved_start_idx = edit._match_start_index;
3060
3149
  resolved_edits.push([edit, edit.new_text || null]);
3061
3150
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
3062
- let matches = this.mapper.find_all_match_indices(edit.target_text);
3151
+ let matches = this.mapper.drop_virtual_only_matches(
3152
+ this.mapper.find_all_match_indices(edit.target_text),
3153
+ );
3063
3154
  let resolved_mapper = this.mapper;
3064
3155
  if (matches.length === 0) {
3065
3156
  if (!this.clean_mapper) {
3066
3157
  this.clean_mapper = new DocumentMapper(this.doc, true);
3067
3158
  }
3068
- matches = this.clean_mapper.find_all_match_indices(edit.target_text);
3159
+ matches = this.clean_mapper.drop_virtual_only_matches(
3160
+ this.clean_mapper.find_all_match_indices(edit.target_text),
3161
+ );
3069
3162
  resolved_mapper = this.clean_mapper;
3070
3163
  }
3071
3164
 
3072
3165
  if (matches.length > 0) {
3073
- // Record WHICH mapper produced the offset: a clean-view index
3074
- // resolved against the raw mapper lands rows at the wrong position
3075
- // once earlier edits in the batch put tracked changes in the
3076
- // anchor row.
3077
- edit._resolved_start_idx = matches[0][0];
3078
- edit._active_mapper_ref = resolved_mapper;
3079
- resolved_edits.push([edit, null]);
3166
+ const match_mode = edit.match_mode || "strict";
3167
+
3168
+ // We need to resolve matches to unique w:tr elements to deduplicate them.
3169
+ const unique_matches: [number, number][] = [];
3170
+ const seen_trs = new Set<any>();
3171
+
3172
+ for (const match of matches) {
3173
+ const start_idx = match[0];
3174
+ const [anchor_run, anchor_para] = resolved_mapper.get_insertion_anchor(start_idx, false);
3175
+ let target_element: Element | null = null;
3176
+ if (anchor_run) target_element = anchor_run._element;
3177
+ else if (anchor_para) target_element = anchor_para._element;
3178
+
3179
+ let tr: Element | null = target_element;
3180
+ while (tr && tr.tagName !== "w:tr") tr = tr.parentNode as Element;
3181
+
3182
+ if (tr) {
3183
+ if (!seen_trs.has(tr)) {
3184
+ seen_trs.add(tr);
3185
+ unique_matches.push(match);
3186
+ }
3187
+ }
3188
+ }
3189
+
3190
+ if (unique_matches.length > 0) {
3191
+ let matches_to_apply = unique_matches;
3192
+ if (match_mode === "strict" || match_mode === "first") {
3193
+ matches_to_apply = unique_matches.slice(0, 1);
3194
+ }
3195
+
3196
+ if (match_mode === "all" || matches_to_apply.length > 1) {
3197
+ // Create sub-edits for each match so that they are processed as independent operations,
3198
+ // and the occurrences_modified and applied_status are tracked correctly on the parent.
3199
+ for (const m of matches_to_apply) {
3200
+ const sub_edit = {
3201
+ ...edit,
3202
+ _resolved_start_idx: m[0],
3203
+ _active_mapper_ref: resolved_mapper,
3204
+ _parent_edit_ref: edit,
3205
+ };
3206
+ resolved_edits.push([sub_edit, null]);
3207
+ }
3208
+ } else {
3209
+ // Single match case for non-"all" modes
3210
+ edit._resolved_start_idx = matches_to_apply[0][0];
3211
+ edit._active_mapper_ref = resolved_mapper;
3212
+ resolved_edits.push([edit, null]);
3213
+ }
3214
+ } else {
3215
+ skipped++;
3216
+ edit._applied_status = false;
3217
+ const target_snippet = (edit.target_text || "")
3218
+ .trim()
3219
+ .substring(0, 40);
3220
+ const msg = `- Failed to locate row target: '${target_snippet}...'`;
3221
+ this.skipped_details.push(msg);
3222
+ edit._error_msg = msg;
3223
+ }
3080
3224
  } else {
3081
3225
  skipped++;
3082
3226
  edit._applied_status = false;
@@ -3289,11 +3433,179 @@ export class RedlineEngine {
3289
3433
  return [applied_logical, skipped_logical];
3290
3434
  }
3291
3435
 
3292
- public apply_review_actions(actions: any[]): [number, number] {
3436
+ /**
3437
+ * True when the paragraph still carries visible content (w:t text, w:tab,
3438
+ * w:br) that is NOT wrapped in a tracked deletion — i.e. the paragraph
3439
+ * would render non-empty in the accepted document.
3440
+ */
3441
+ private _paragraph_has_visible_content(p_elem: Element): boolean {
3442
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
3443
+ const nodes = findAllDescendants(p_elem, tag);
3444
+ for (const node of nodes) {
3445
+ let is_deleted = false;
3446
+ let curr = node.parentNode as Element | null;
3447
+ while (curr && curr !== p_elem.parentNode) {
3448
+ if (curr.tagName === "w:del") {
3449
+ is_deleted = true;
3450
+ break;
3451
+ }
3452
+ curr = curr.parentNode as Element | null;
3453
+ }
3454
+ if (!is_deleted) {
3455
+ if (tag === "w:t" && !node.textContent) continue;
3456
+ return true;
3457
+ }
3458
+ }
3459
+ }
3460
+ return false;
3461
+ }
3462
+
3463
+ /**
3464
+ * All contiguous same-author w:ins/w:del siblings that form one logical
3465
+ * modification block with `node` (a replacement's del+ins pair). Mirrors
3466
+ * the Python engine's _get_paired_nodes: comment range markers and
3467
+ * rPr/pPr are transparent; a different author or any other element breaks
3468
+ * the group.
3469
+ */
3470
+ private _get_paired_nodes(node: Element): Element[] {
3471
+ const pairs: Element[] = [];
3472
+ const author = node.getAttribute("w:author");
3473
+ const transparent = new Set([
3474
+ "w:commentRangeStart",
3475
+ "w:commentRangeEnd",
3476
+ "w:commentReference",
3477
+ "w:rPr",
3478
+ "w:pPr",
3479
+ ]);
3480
+
3481
+ const walk = (start: Element, dir: "next" | "prev") => {
3482
+ let cur: Node | null =
3483
+ dir === "next" ? start.nextSibling : start.previousSibling;
3484
+ while (cur) {
3485
+ if (cur.nodeType !== 1) {
3486
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
3487
+ continue;
3488
+ }
3489
+ const el = cur as Element;
3490
+ if (transparent.has(el.tagName)) {
3491
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
3492
+ continue;
3493
+ }
3494
+ if (
3495
+ (el.tagName === "w:ins" || el.tagName === "w:del") &&
3496
+ el.getAttribute("w:author") === author
3497
+ ) {
3498
+ pairs.push(el);
3499
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
3500
+ continue;
3501
+ }
3502
+ break;
3503
+ }
3504
+ };
3505
+
3506
+ walk(node, "next");
3507
+ walk(node, "prev");
3508
+ return pairs;
3509
+ }
3510
+
3511
+ /**
3512
+ * All revision ids that resolve as ONE unit with `target_id`: the ids of
3513
+ * every contiguous same-author w:ins/w:del sibling of its elements (a
3514
+ * replacement's del+ins pair), plus the id itself.
3515
+ */
3516
+ private _resolution_group_ids(target_id: string): Set<string> {
3517
+ const nodes = [
3518
+ ...findAllDescendants(this.doc.element, "w:ins"),
3519
+ ...findAllDescendants(this.doc.element, "w:del"),
3520
+ ].filter((n) => n.getAttribute("w:id") === target_id);
3521
+ const group = new Set<string>();
3522
+ if (nodes.length === 0) return group;
3523
+ group.add(target_id);
3524
+ for (const node of nodes) {
3525
+ for (const paired of this._get_paired_nodes(node)) {
3526
+ const pid = paired.getAttribute("w:id");
3527
+ if (pid) group.add(pid);
3528
+ }
3529
+ }
3530
+ return group;
3531
+ }
3532
+
3533
+ /**
3534
+ * Document-aware validation (QA 2026-07-19 ADEU-QA-004): a replacement's
3535
+ * del+ins pair carries two distinct ids but resolves as one unit, so a
3536
+ * batch that accepts one side and rejects the other is contradictory.
3537
+ * Rejecting it up front — before any action mutates the document — keeps
3538
+ * the batch transactional.
3539
+ */
3540
+ public validate_action_pairing(actions: any[]): string[] {
3541
+ const errors: string[] = [];
3542
+ const group_first = new Map<string, [number, string, string]>();
3543
+ for (let pos = 0; pos < actions.length; pos++) {
3544
+ const act = actions[pos];
3545
+ if (act.type !== "accept" && act.type !== "reject") continue;
3546
+ const raw_id = String(act.target_id ?? "");
3547
+ if (raw_id.startsWith("Com:")) continue;
3548
+ const target_id = raw_id.startsWith("Chg:") ? raw_id.slice(4) : raw_id;
3549
+ const group = this._resolution_group_ids(target_id);
3550
+ if (group.size === 0) continue; // unknown ids fail with their own not-found error
3551
+ let conflict: [number, string, string] | null = null;
3552
+ for (const gid of group) {
3553
+ const prior = group_first.get(gid);
3554
+ if (prior !== undefined && prior[1] !== act.type) {
3555
+ conflict = prior;
3556
+ break;
3557
+ }
3558
+ }
3559
+ if (conflict !== null) {
3560
+ const [first_pos, first_type, first_id] = conflict;
3561
+ errors.push(
3562
+ `- Action ${pos + 1} Failed: conflicting actions on one replacement — Action ` +
3563
+ `${first_pos + 1} applies '${first_type}' to Chg:${first_id}, and Chg:${target_id} is ` +
3564
+ `part of the same change (a replacement's contiguous del+ins pair resolves as one ` +
3565
+ `unit, so '${first_type}' already decides both sides). Accepting one side and ` +
3566
+ `rejecting the other is contradictory — decide the outcome and submit exactly one ` +
3567
+ `action for the pair.`,
3568
+ );
3569
+ continue;
3570
+ }
3571
+ for (const gid of group) {
3572
+ if (!group_first.has(gid)) {
3573
+ group_first.set(gid, [pos, act.type, target_id]);
3574
+ }
3575
+ }
3576
+ }
3577
+ return errors;
3578
+ }
3579
+
3580
+ /**
3581
+ * Returns [applied, skipped, already_resolved]. `applied` counts actions
3582
+ * that caused an observable state transition; an action naming an id an
3583
+ * earlier action of this batch already resolved (via its replacement pair)
3584
+ * is counted in `already_resolved` instead — never as applied
3585
+ * (QA 2026-07-19 ADEU-QA-004).
3586
+ */
3587
+ public apply_review_actions(actions: any[]): [number, number, number] {
3293
3588
  let applied = 0;
3294
3589
  let skipped = 0;
3590
+ let already_resolved = 0;
3591
+ const resolved_history = new Map<string, string>(); // id -> resolving action type
3592
+
3593
+ // Sort actions internally: non-destructive metadata operations (ReplyComment) first,
3594
+ // followed by destructive structural operations (AcceptChange, RejectChange).
3595
+ // Stable sort preserves the original relative ordering, and we preserve `pos`
3596
+ // so diagnostic messages refer to the original array indexes.
3597
+ const sortedActions = actions
3598
+ .map((action, pos) => ({ action, pos }))
3599
+ .sort((a, b) => {
3600
+ const aPri = a.action.type === "reply" ? 0 : 1;
3601
+ const bPri = b.action.type === "reply" ? 0 : 1;
3602
+ if (aPri !== bPri) {
3603
+ return aPri - bPri;
3604
+ }
3605
+ return a.pos - b.pos;
3606
+ });
3295
3607
 
3296
- for (const action of actions) {
3608
+ for (const { action, pos } of sortedActions) {
3297
3609
  const type = action.type;
3298
3610
  if (type === "reply") {
3299
3611
  const cid = action.target_id.replace("Com:", "");
@@ -3308,6 +3620,32 @@ export class RedlineEngine {
3308
3620
  }
3309
3621
 
3310
3622
  const target_id = action.target_id.replace("Chg:", "");
3623
+
3624
+ const prior_type = resolved_history.get(target_id);
3625
+ if (prior_type !== undefined) {
3626
+ if (prior_type === type) {
3627
+ // Consistent follow-up on the pair: legitimate agent workflow
3628
+ // ("accept both ids of the replacement"), but no state transition
3629
+ // happens — report it accurately (ADEU-QA-004).
3630
+ already_resolved++;
3631
+ this.skipped_details.push(
3632
+ `- Note: Action ${pos + 1} ('${type}' on ${action.target_id}) had no additional effect — ` +
3633
+ `the change was already resolved together with its replacement pair by an earlier ` +
3634
+ `action in this batch. Counted as already_resolved, not applied.`,
3635
+ );
3636
+ continue;
3637
+ }
3638
+ // Contradiction. validate_action_pairing rejects this shape before
3639
+ // anything mutates; this guard covers direct callers.
3640
+ this.skipped_details.push(
3641
+ `- Action ${pos + 1} Failed: contradictory action — '${type}' on ${action.target_id}, but ` +
3642
+ `the change was already resolved as '${prior_type}' together with its replacement ` +
3643
+ `pair by an earlier action in this batch.`,
3644
+ );
3645
+ skipped++;
3646
+ continue;
3647
+ }
3648
+
3311
3649
  const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
3312
3650
  (n) => n.getAttribute("w:id") === target_id,
3313
3651
  );
@@ -3347,7 +3685,25 @@ export class RedlineEngine {
3347
3685
  continue;
3348
3686
  }
3349
3687
 
3688
+ // A modification is one logical unit stored as a contiguous
3689
+ // same-author del+ins pair: resolving either side resolves BOTH —
3690
+ // Word's atomic replacement handling, and the Python engine's
3691
+ // long-standing behavior. Without this, accepting the deletion side
3692
+ // left the paired insertion pending (engine divergence,
3693
+ // QA 2026-07-19 ADEU-QA-004).
3694
+ const group_nodes = new Set<Element>(all_nodes);
3350
3695
  for (const node of all_nodes) {
3696
+ for (const paired of this._get_paired_nodes(node)) {
3697
+ group_nodes.add(paired);
3698
+ }
3699
+ }
3700
+ const resolved_now = new Set<string>();
3701
+ for (const node of group_nodes) {
3702
+ const rid = node.getAttribute("w:id");
3703
+ if (rid) resolved_now.add(rid);
3704
+ }
3705
+
3706
+ for (const node of group_nodes) {
3351
3707
  const is_ins = node.tagName === "w:ins";
3352
3708
  const parent_tag = node.parentNode
3353
3709
  ? (node.parentNode as Element).tagName
@@ -3402,9 +3758,12 @@ export class RedlineEngine {
3402
3758
  }
3403
3759
  }
3404
3760
  }
3761
+ for (const rid of resolved_now) {
3762
+ resolved_history.set(rid, type);
3763
+ }
3405
3764
  applied++;
3406
3765
  }
3407
- return [applied, skipped];
3766
+ return [applied, skipped, already_resolved];
3408
3767
  }
3409
3768
 
3410
3769
  private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
@@ -3486,18 +3845,16 @@ export class RedlineEngine {
3486
3845
  const is_regex = edit.regex || false;
3487
3846
  const match_mode = edit.match_mode || "strict";
3488
3847
 
3489
- let matches = this.mapper.find_all_match_indices(
3490
- edit.target_text,
3491
- is_regex,
3848
+ let matches = this.mapper.drop_virtual_only_matches(
3849
+ this.mapper.find_all_match_indices(edit.target_text, is_regex),
3492
3850
  );
3493
3851
  let use_clean_map = false;
3494
3852
 
3495
3853
  if (matches.length === 0) {
3496
3854
  if (!this.clean_mapper)
3497
3855
  this.clean_mapper = new DocumentMapper(this.doc, true);
3498
- matches = this.clean_mapper.find_all_match_indices(
3499
- edit.target_text,
3500
- is_regex,
3856
+ matches = this.clean_mapper.drop_virtual_only_matches(
3857
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
3501
3858
  );
3502
3859
  if (matches.length > 0) use_clean_map = true;
3503
3860
  else return null;
@@ -3511,6 +3868,8 @@ export class RedlineEngine {
3511
3868
  (span) =>
3512
3869
  span.run !== null && span.end > s && span.start < s + match_len,
3513
3870
  );
3871
+ // Virtual-only matches were already dropped above; here we only skip
3872
+ // matches buried entirely inside tracked deletions.
3514
3873
  if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
3515
3874
  live_matches.push([s, match_len]);
3516
3875
  }
@@ -4190,7 +4549,9 @@ export class RedlineEngine {
4190
4549
  this._set_paragraph_style(new_p, style_name);
4191
4550
  } else {
4192
4551
  const existing_pPr = findChild(_bug233_target_para, "w:pPr");
4193
- if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
4552
+ if (existing_pPr) {
4553
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
4554
+ }
4194
4555
  }
4195
4556
  let pPr = findChild(new_p, "w:pPr");
4196
4557
  if (!pPr) {
@@ -4534,7 +4895,41 @@ export class RedlineEngine {
4534
4895
  }
4535
4896
 
4536
4897
  if (p2_element && p2_element.tagName === "w:p") {
4898
+ // Decide the merged container's properties BEFORE p2's children
4899
+ // move in: when p1 keeps no visible content (a FULL paragraph
4900
+ // deletion), the only surviving text is p2's — the merged
4901
+ // paragraph must carry p2's properties (style, numbering).
4902
+ // Keeping p1's restyled the following paragraph: deleting a
4903
+ // heading turned the next body paragraph into a heading,
4904
+ // deleting a plain paragraph before a list item stripped the
4905
+ // item's numbering (QA 2026-07-19 ADEU-QA-002 B).
4906
+ const p1_fully_deleted =
4907
+ !this._paragraph_has_visible_content(p1_element);
4908
+
4537
4909
  let pPr = findChild(p1_element, "w:pPr");
4910
+ if (p1_fully_deleted) {
4911
+ const p2_pPr = findChild(p2_element, "w:pPr");
4912
+ const adopted = (
4913
+ p2_pPr
4914
+ ? p2_pPr.cloneNode(true)
4915
+ : p1_element.ownerDocument!.createElement("w:pPr")
4916
+ ) as Element;
4917
+ // Section properties belong to p1's position in the document
4918
+ // flow, never to p2's styling — carry them over so a section
4919
+ // boundary is not destroyed.
4920
+ if (pPr) {
4921
+ const sect = findChild(pPr, "w:sectPr");
4922
+ if (sect && !findChild(adopted, "w:sectPr")) {
4923
+ adopted.appendChild(sect.cloneNode(true));
4924
+ }
4925
+ p1_element.removeChild(pPr);
4926
+ }
4927
+ p1_element.insertBefore(
4928
+ adopted,
4929
+ p1_element.firstChild as Node | null,
4930
+ );
4931
+ pPr = adopted;
4932
+ }
4538
4933
  if (!pPr) {
4539
4934
  pPr = p1_element.ownerDocument!.createElement("w:pPr") as Element;
4540
4935
  p1_element.insertBefore(
@@ -4547,8 +4942,10 @@ export class RedlineEngine {
4547
4942
  rPr = p1_element.ownerDocument!.createElement("w:rPr") as Element;
4548
4943
  pPr!.appendChild(rPr);
4549
4944
  }
4550
- const del_mark = this._create_track_change_tag("w:del");
4551
- rPr!.appendChild(del_mark);
4945
+ if (!findChild(rPr!, "w:del")) {
4946
+ const del_mark = this._create_track_change_tag("w:del");
4947
+ rPr!.appendChild(del_mark);
4948
+ }
4552
4949
 
4553
4950
  const children = Array.from(p2_element.childNodes);
4554
4951
  for (const child of children) {
@@ -4618,27 +5015,7 @@ export class RedlineEngine {
4618
5015
 
4619
5016
  // PHASE 2: Check for orphaned paragraphs with zero visible content remaining
4620
5017
  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
- }
5018
+ const has_visible = this._paragraph_has_visible_content(p_elem);
4642
5019
 
4643
5020
  if (!has_visible) {
4644
5021
  let pPr = findChild(p_elem, "w:pPr");