@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/dist/index.cjs +696 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -2
- package/dist/index.d.ts +63 -2
- package/dist/index.js +696 -78
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.test.ts +8 -1
- package/src/diff.ts +242 -3
- package/src/engine.atomic.test.ts +5 -1
- package/src/engine.batch.test.ts +16 -6
- package/src/engine.bugs.test.ts +49 -0
- package/src/engine.tables.test.ts +31 -0
- package/src/engine.ts +443 -66
- package/src/ingest.test.ts +3 -1
- package/src/ingest.ts +35 -5
- package/src/mapper.ts +94 -11
- package/src/repro_agy_bug.test.ts +108 -0
- package/src/repro_qa_mcp_issues_2026_07_20.test.ts +224 -0
- package/src/repro_qa_report_v9.test.ts +392 -0
- package/src/sanitize/core.ts +12 -1
- package/src/sanitize/report.ts +9 -7
- package/src/sanitize/sanitize.test.ts +1 -0
- package/src/sanitize/transforms.ts +39 -0
- package/src/utils/docx.ts +87 -0
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
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.
|
|
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
|
-
|
|
2168
|
-
//
|
|
2169
|
-
|
|
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.
|
|
2183
|
-
|
|
2184
|
-
|
|
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.
|
|
2601
|
+
if (s.end <= start || s.start >= start + length) {
|
|
2539
2602
|
continue;
|
|
2540
2603
|
}
|
|
2541
|
-
let curr: Node | null =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
3074
|
-
|
|
3075
|
-
//
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
4551
|
-
|
|
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
|
-
|
|
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");
|