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