@adeu/core 1.27.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +563 -65
- 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 +563 -65
- 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 +323 -52
- package/src/ingest.test.ts +3 -1
- package/src/ingest.ts +16 -3
- package/src/mapper.ts +74 -9
- 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 +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,
|
|
@@ -1613,7 +1635,7 @@ export class RedlineEngine {
|
|
|
1613
1635
|
// Inherit pPr from the anchor paragraph (preserves list numbering).
|
|
1614
1636
|
const existing_pPr = findChild(current_p, "w:pPr");
|
|
1615
1637
|
if (existing_pPr) {
|
|
1616
|
-
new_p.appendChild(
|
|
1638
|
+
new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
|
|
1617
1639
|
}
|
|
1618
1640
|
}
|
|
1619
1641
|
|
|
@@ -2134,9 +2156,13 @@ export class RedlineEngine {
|
|
|
2134
2156
|
}
|
|
2135
2157
|
}
|
|
2136
2158
|
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2159
|
+
// Matches covering ONLY virtual projection text (meta bubbles,
|
|
2160
|
+
// timestamps, style markers) are phantoms: they can neither be edited
|
|
2161
|
+
// nor legitimately ambiguate a real match — a target of "4" was
|
|
2162
|
+
// rejected as "appears 8 times" because comment-bubble timestamps
|
|
2163
|
+
// matched (QA 2026-07-19 ADEU-QA-002 C).
|
|
2164
|
+
let matches = this.mapper.drop_virtual_only_matches(
|
|
2165
|
+
this.mapper.find_all_match_indices(edit.target_text, is_regex),
|
|
2140
2166
|
);
|
|
2141
2167
|
let activeText = this.mapper.full_text;
|
|
2142
2168
|
let target_mapper = this.mapper;
|
|
@@ -2144,9 +2170,8 @@ export class RedlineEngine {
|
|
|
2144
2170
|
if (matches.length === 0) {
|
|
2145
2171
|
if (!this.clean_mapper)
|
|
2146
2172
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
2147
|
-
matches = this.clean_mapper.
|
|
2148
|
-
edit.target_text,
|
|
2149
|
-
is_regex,
|
|
2173
|
+
matches = this.clean_mapper.drop_virtual_only_matches(
|
|
2174
|
+
this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
|
|
2150
2175
|
);
|
|
2151
2176
|
if (matches.length > 0) {
|
|
2152
2177
|
activeText = this.clean_mapper.full_text;
|
|
@@ -2164,9 +2189,9 @@ export class RedlineEngine {
|
|
|
2164
2189
|
const realSpans = this.mapper.spans.filter(
|
|
2165
2190
|
(s) => s.run !== null && s.end > start && s.start < start + length,
|
|
2166
2191
|
);
|
|
2167
|
-
|
|
2168
|
-
//
|
|
2169
|
-
|
|
2192
|
+
// Virtual-only matches were already dropped above; here we only
|
|
2193
|
+
// skip matches buried entirely inside tracked deletions.
|
|
2194
|
+
if (realSpans.length === 0) return true;
|
|
2170
2195
|
return realSpans.some((s) => !s.del_id);
|
|
2171
2196
|
});
|
|
2172
2197
|
matches = liveMatches;
|
|
@@ -2179,9 +2204,11 @@ export class RedlineEngine {
|
|
|
2179
2204
|
if (!this.original_mapper) {
|
|
2180
2205
|
this.original_mapper = new DocumentMapper(this.doc, false, true);
|
|
2181
2206
|
}
|
|
2182
|
-
const orig_matches = this.original_mapper.
|
|
2183
|
-
|
|
2184
|
-
|
|
2207
|
+
const orig_matches = this.original_mapper.drop_virtual_only_matches(
|
|
2208
|
+
this.original_mapper.find_all_match_indices(
|
|
2209
|
+
edit.target_text,
|
|
2210
|
+
is_regex,
|
|
2211
|
+
),
|
|
2185
2212
|
);
|
|
2186
2213
|
if (orig_matches.length > 0) {
|
|
2187
2214
|
is_deleted_text = true;
|
|
@@ -2535,10 +2562,15 @@ export class RedlineEngine {
|
|
|
2535
2562
|
length: number,
|
|
2536
2563
|
): number | null {
|
|
2537
2564
|
for (const s of mapper.spans) {
|
|
2538
|
-
if (s.
|
|
2565
|
+
if (s.end <= start || s.start >= start + length) {
|
|
2539
2566
|
continue;
|
|
2540
2567
|
}
|
|
2541
|
-
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
|
+
}
|
|
2542
2574
|
while (curr) {
|
|
2543
2575
|
if (curr.nodeType === 1 && (curr as Element).tagName === "w:tr") {
|
|
2544
2576
|
return findAllDescendants(curr as Element, "w:tc").filter(
|
|
@@ -2743,9 +2775,15 @@ export class RedlineEngine {
|
|
|
2743
2775
|
// _insert_and_split_ins).
|
|
2744
2776
|
|
|
2745
2777
|
// BUG-7: Unified single-pass validation in wet-run / standard mode.
|
|
2778
|
+
// The document-aware pairing check runs BEFORE any action mutates the
|
|
2779
|
+
// DOM: accept + reject across one replacement's del+ins pair is a
|
|
2780
|
+
// contradiction, not two independent operations (ADEU-QA-004).
|
|
2746
2781
|
if (!dry_run_mode) {
|
|
2747
|
-
|
|
2782
|
+
let action_errors =
|
|
2748
2783
|
actions.length > 0 ? this.validate_review_actions(actions) : [];
|
|
2784
|
+
if (actions.length > 0 && action_errors.length === 0) {
|
|
2785
|
+
action_errors = this.validate_action_pairing(actions);
|
|
2786
|
+
}
|
|
2749
2787
|
const validate_edits_now = edits.length > 0 && action_errors.length > 0;
|
|
2750
2788
|
const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
|
|
2751
2789
|
const all_errors = [...action_errors, ...edit_errors];
|
|
@@ -2754,7 +2792,10 @@ export class RedlineEngine {
|
|
|
2754
2792
|
}
|
|
2755
2793
|
} else {
|
|
2756
2794
|
if (actions.length > 0) {
|
|
2757
|
-
|
|
2795
|
+
let action_errors = this.validate_review_actions(actions);
|
|
2796
|
+
if (action_errors.length === 0) {
|
|
2797
|
+
action_errors = this.validate_action_pairing(actions);
|
|
2798
|
+
}
|
|
2758
2799
|
if (action_errors.length > 0) {
|
|
2759
2800
|
throw new BatchValidationError(action_errors);
|
|
2760
2801
|
}
|
|
@@ -2763,10 +2804,12 @@ export class RedlineEngine {
|
|
|
2763
2804
|
|
|
2764
2805
|
let applied_actions = 0;
|
|
2765
2806
|
let skipped_actions = 0;
|
|
2807
|
+
let already_resolved_actions = 0;
|
|
2766
2808
|
if (actions.length > 0) {
|
|
2767
2809
|
const res = this.apply_review_actions(actions);
|
|
2768
2810
|
applied_actions = res[0];
|
|
2769
2811
|
skipped_actions = res[1];
|
|
2812
|
+
already_resolved_actions = res[2];
|
|
2770
2813
|
if (skipped_actions > 0) {
|
|
2771
2814
|
throw new BatchValidationError(this.skipped_details);
|
|
2772
2815
|
}
|
|
@@ -3004,6 +3047,11 @@ export class RedlineEngine {
|
|
|
3004
3047
|
return {
|
|
3005
3048
|
actions_applied: applied_actions,
|
|
3006
3049
|
actions_skipped: skipped_actions,
|
|
3050
|
+
// Actions whose target was already resolved by an earlier action of
|
|
3051
|
+
// this batch (via its replacement pair): consistent no-ops, never
|
|
3052
|
+
// counted as applied — every reported "applied" action causes an
|
|
3053
|
+
// observable state transition (ADEU-QA-004).
|
|
3054
|
+
actions_already_resolved: already_resolved_actions,
|
|
3007
3055
|
edits_applied: applied_edits,
|
|
3008
3056
|
edits_skipped: skipped_edits,
|
|
3009
3057
|
// edits_applied counts change OBJECTS; this is the total number of
|
|
@@ -3059,13 +3107,17 @@ export class RedlineEngine {
|
|
|
3059
3107
|
edit._resolved_start_idx = edit._match_start_index;
|
|
3060
3108
|
resolved_edits.push([edit, edit.new_text || null]);
|
|
3061
3109
|
} else if (edit.type === "insert_row" || edit.type === "delete_row") {
|
|
3062
|
-
let matches = this.mapper.
|
|
3110
|
+
let matches = this.mapper.drop_virtual_only_matches(
|
|
3111
|
+
this.mapper.find_all_match_indices(edit.target_text),
|
|
3112
|
+
);
|
|
3063
3113
|
let resolved_mapper = this.mapper;
|
|
3064
3114
|
if (matches.length === 0) {
|
|
3065
3115
|
if (!this.clean_mapper) {
|
|
3066
3116
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
3067
3117
|
}
|
|
3068
|
-
matches = this.clean_mapper.
|
|
3118
|
+
matches = this.clean_mapper.drop_virtual_only_matches(
|
|
3119
|
+
this.clean_mapper.find_all_match_indices(edit.target_text),
|
|
3120
|
+
);
|
|
3069
3121
|
resolved_mapper = this.clean_mapper;
|
|
3070
3122
|
}
|
|
3071
3123
|
|
|
@@ -3289,11 +3341,165 @@ export class RedlineEngine {
|
|
|
3289
3341
|
return [applied_logical, skipped_logical];
|
|
3290
3342
|
}
|
|
3291
3343
|
|
|
3292
|
-
|
|
3344
|
+
/**
|
|
3345
|
+
* True when the paragraph still carries visible content (w:t text, w:tab,
|
|
3346
|
+
* w:br) that is NOT wrapped in a tracked deletion — i.e. the paragraph
|
|
3347
|
+
* would render non-empty in the accepted document.
|
|
3348
|
+
*/
|
|
3349
|
+
private _paragraph_has_visible_content(p_elem: Element): boolean {
|
|
3350
|
+
for (const tag of ["w:t", "w:tab", "w:br"]) {
|
|
3351
|
+
const nodes = findAllDescendants(p_elem, tag);
|
|
3352
|
+
for (const node of nodes) {
|
|
3353
|
+
let is_deleted = false;
|
|
3354
|
+
let curr = node.parentNode as Element | null;
|
|
3355
|
+
while (curr && curr !== p_elem.parentNode) {
|
|
3356
|
+
if (curr.tagName === "w:del") {
|
|
3357
|
+
is_deleted = true;
|
|
3358
|
+
break;
|
|
3359
|
+
}
|
|
3360
|
+
curr = curr.parentNode as Element | null;
|
|
3361
|
+
}
|
|
3362
|
+
if (!is_deleted) {
|
|
3363
|
+
if (tag === "w:t" && !node.textContent) continue;
|
|
3364
|
+
return true;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
return false;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/**
|
|
3372
|
+
* All contiguous same-author w:ins/w:del siblings that form one logical
|
|
3373
|
+
* modification block with `node` (a replacement's del+ins pair). Mirrors
|
|
3374
|
+
* the Python engine's _get_paired_nodes: comment range markers and
|
|
3375
|
+
* rPr/pPr are transparent; a different author or any other element breaks
|
|
3376
|
+
* the group.
|
|
3377
|
+
*/
|
|
3378
|
+
private _get_paired_nodes(node: Element): Element[] {
|
|
3379
|
+
const pairs: Element[] = [];
|
|
3380
|
+
const author = node.getAttribute("w:author");
|
|
3381
|
+
const transparent = new Set([
|
|
3382
|
+
"w:commentRangeStart",
|
|
3383
|
+
"w:commentRangeEnd",
|
|
3384
|
+
"w:commentReference",
|
|
3385
|
+
"w:rPr",
|
|
3386
|
+
"w:pPr",
|
|
3387
|
+
]);
|
|
3388
|
+
|
|
3389
|
+
const walk = (start: Element, dir: "next" | "prev") => {
|
|
3390
|
+
let cur: Node | null =
|
|
3391
|
+
dir === "next" ? start.nextSibling : start.previousSibling;
|
|
3392
|
+
while (cur) {
|
|
3393
|
+
if (cur.nodeType !== 1) {
|
|
3394
|
+
cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
|
|
3395
|
+
continue;
|
|
3396
|
+
}
|
|
3397
|
+
const el = cur as Element;
|
|
3398
|
+
if (transparent.has(el.tagName)) {
|
|
3399
|
+
cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
|
|
3400
|
+
continue;
|
|
3401
|
+
}
|
|
3402
|
+
if (
|
|
3403
|
+
(el.tagName === "w:ins" || el.tagName === "w:del") &&
|
|
3404
|
+
el.getAttribute("w:author") === author
|
|
3405
|
+
) {
|
|
3406
|
+
pairs.push(el);
|
|
3407
|
+
cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
|
|
3408
|
+
continue;
|
|
3409
|
+
}
|
|
3410
|
+
break;
|
|
3411
|
+
}
|
|
3412
|
+
};
|
|
3413
|
+
|
|
3414
|
+
walk(node, "next");
|
|
3415
|
+
walk(node, "prev");
|
|
3416
|
+
return pairs;
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
/**
|
|
3420
|
+
* All revision ids that resolve as ONE unit with `target_id`: the ids of
|
|
3421
|
+
* every contiguous same-author w:ins/w:del sibling of its elements (a
|
|
3422
|
+
* replacement's del+ins pair), plus the id itself.
|
|
3423
|
+
*/
|
|
3424
|
+
private _resolution_group_ids(target_id: string): Set<string> {
|
|
3425
|
+
const nodes = [
|
|
3426
|
+
...findAllDescendants(this.doc.element, "w:ins"),
|
|
3427
|
+
...findAllDescendants(this.doc.element, "w:del"),
|
|
3428
|
+
].filter((n) => n.getAttribute("w:id") === target_id);
|
|
3429
|
+
const group = new Set<string>();
|
|
3430
|
+
if (nodes.length === 0) return group;
|
|
3431
|
+
group.add(target_id);
|
|
3432
|
+
for (const node of nodes) {
|
|
3433
|
+
for (const paired of this._get_paired_nodes(node)) {
|
|
3434
|
+
const pid = paired.getAttribute("w:id");
|
|
3435
|
+
if (pid) group.add(pid);
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
return group;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* Document-aware validation (QA 2026-07-19 ADEU-QA-004): a replacement's
|
|
3443
|
+
* del+ins pair carries two distinct ids but resolves as one unit, so a
|
|
3444
|
+
* batch that accepts one side and rejects the other is contradictory.
|
|
3445
|
+
* Rejecting it up front — before any action mutates the document — keeps
|
|
3446
|
+
* the batch transactional.
|
|
3447
|
+
*/
|
|
3448
|
+
public validate_action_pairing(actions: any[]): string[] {
|
|
3449
|
+
const errors: string[] = [];
|
|
3450
|
+
const group_first = new Map<string, [number, string, string]>();
|
|
3451
|
+
for (let pos = 0; pos < actions.length; pos++) {
|
|
3452
|
+
const act = actions[pos];
|
|
3453
|
+
if (act.type !== "accept" && act.type !== "reject") continue;
|
|
3454
|
+
const raw_id = String(act.target_id ?? "");
|
|
3455
|
+
if (raw_id.startsWith("Com:")) continue;
|
|
3456
|
+
const target_id = raw_id.startsWith("Chg:") ? raw_id.slice(4) : raw_id;
|
|
3457
|
+
const group = this._resolution_group_ids(target_id);
|
|
3458
|
+
if (group.size === 0) continue; // unknown ids fail with their own not-found error
|
|
3459
|
+
let conflict: [number, string, string] | null = null;
|
|
3460
|
+
for (const gid of group) {
|
|
3461
|
+
const prior = group_first.get(gid);
|
|
3462
|
+
if (prior !== undefined && prior[1] !== act.type) {
|
|
3463
|
+
conflict = prior;
|
|
3464
|
+
break;
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
if (conflict !== null) {
|
|
3468
|
+
const [first_pos, first_type, first_id] = conflict;
|
|
3469
|
+
errors.push(
|
|
3470
|
+
`- Action ${pos + 1} Failed: conflicting actions on one replacement — Action ` +
|
|
3471
|
+
`${first_pos + 1} applies '${first_type}' to Chg:${first_id}, and Chg:${target_id} is ` +
|
|
3472
|
+
`part of the same change (a replacement's contiguous del+ins pair resolves as one ` +
|
|
3473
|
+
`unit, so '${first_type}' already decides both sides). Accepting one side and ` +
|
|
3474
|
+
`rejecting the other is contradictory — decide the outcome and submit exactly one ` +
|
|
3475
|
+
`action for the pair.`,
|
|
3476
|
+
);
|
|
3477
|
+
continue;
|
|
3478
|
+
}
|
|
3479
|
+
for (const gid of group) {
|
|
3480
|
+
if (!group_first.has(gid)) {
|
|
3481
|
+
group_first.set(gid, [pos, act.type, target_id]);
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
return errors;
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
/**
|
|
3489
|
+
* Returns [applied, skipped, already_resolved]. `applied` counts actions
|
|
3490
|
+
* that caused an observable state transition; an action naming an id an
|
|
3491
|
+
* earlier action of this batch already resolved (via its replacement pair)
|
|
3492
|
+
* is counted in `already_resolved` instead — never as applied
|
|
3493
|
+
* (QA 2026-07-19 ADEU-QA-004).
|
|
3494
|
+
*/
|
|
3495
|
+
public apply_review_actions(actions: any[]): [number, number, number] {
|
|
3293
3496
|
let applied = 0;
|
|
3294
3497
|
let skipped = 0;
|
|
3498
|
+
let already_resolved = 0;
|
|
3499
|
+
const resolved_history = new Map<string, string>(); // id -> resolving action type
|
|
3295
3500
|
|
|
3296
|
-
for (
|
|
3501
|
+
for (let pos = 0; pos < actions.length; pos++) {
|
|
3502
|
+
const action = actions[pos];
|
|
3297
3503
|
const type = action.type;
|
|
3298
3504
|
if (type === "reply") {
|
|
3299
3505
|
const cid = action.target_id.replace("Com:", "");
|
|
@@ -3308,6 +3514,32 @@ export class RedlineEngine {
|
|
|
3308
3514
|
}
|
|
3309
3515
|
|
|
3310
3516
|
const target_id = action.target_id.replace("Chg:", "");
|
|
3517
|
+
|
|
3518
|
+
const prior_type = resolved_history.get(target_id);
|
|
3519
|
+
if (prior_type !== undefined) {
|
|
3520
|
+
if (prior_type === type) {
|
|
3521
|
+
// Consistent follow-up on the pair: legitimate agent workflow
|
|
3522
|
+
// ("accept both ids of the replacement"), but no state transition
|
|
3523
|
+
// happens — report it accurately (ADEU-QA-004).
|
|
3524
|
+
already_resolved++;
|
|
3525
|
+
this.skipped_details.push(
|
|
3526
|
+
`- Note: Action ${pos + 1} ('${type}' on ${action.target_id}) had no additional effect — ` +
|
|
3527
|
+
`the change was already resolved together with its replacement pair by an earlier ` +
|
|
3528
|
+
`action in this batch. Counted as already_resolved, not applied.`,
|
|
3529
|
+
);
|
|
3530
|
+
continue;
|
|
3531
|
+
}
|
|
3532
|
+
// Contradiction. validate_action_pairing rejects this shape before
|
|
3533
|
+
// anything mutates; this guard covers direct callers.
|
|
3534
|
+
this.skipped_details.push(
|
|
3535
|
+
`- Action ${pos + 1} Failed: contradictory action — '${type}' on ${action.target_id}, but ` +
|
|
3536
|
+
`the change was already resolved as '${prior_type}' together with its replacement ` +
|
|
3537
|
+
`pair by an earlier action in this batch.`,
|
|
3538
|
+
);
|
|
3539
|
+
skipped++;
|
|
3540
|
+
continue;
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3311
3543
|
const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
|
|
3312
3544
|
(n) => n.getAttribute("w:id") === target_id,
|
|
3313
3545
|
);
|
|
@@ -3347,7 +3579,25 @@ export class RedlineEngine {
|
|
|
3347
3579
|
continue;
|
|
3348
3580
|
}
|
|
3349
3581
|
|
|
3582
|
+
// A modification is one logical unit stored as a contiguous
|
|
3583
|
+
// same-author del+ins pair: resolving either side resolves BOTH —
|
|
3584
|
+
// Word's atomic replacement handling, and the Python engine's
|
|
3585
|
+
// long-standing behavior. Without this, accepting the deletion side
|
|
3586
|
+
// left the paired insertion pending (engine divergence,
|
|
3587
|
+
// QA 2026-07-19 ADEU-QA-004).
|
|
3588
|
+
const group_nodes = new Set<Element>(all_nodes);
|
|
3350
3589
|
for (const node of all_nodes) {
|
|
3590
|
+
for (const paired of this._get_paired_nodes(node)) {
|
|
3591
|
+
group_nodes.add(paired);
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
const resolved_now = new Set<string>();
|
|
3595
|
+
for (const node of group_nodes) {
|
|
3596
|
+
const rid = node.getAttribute("w:id");
|
|
3597
|
+
if (rid) resolved_now.add(rid);
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
for (const node of group_nodes) {
|
|
3351
3601
|
const is_ins = node.tagName === "w:ins";
|
|
3352
3602
|
const parent_tag = node.parentNode
|
|
3353
3603
|
? (node.parentNode as Element).tagName
|
|
@@ -3402,9 +3652,12 @@ export class RedlineEngine {
|
|
|
3402
3652
|
}
|
|
3403
3653
|
}
|
|
3404
3654
|
}
|
|
3655
|
+
for (const rid of resolved_now) {
|
|
3656
|
+
resolved_history.set(rid, type);
|
|
3657
|
+
}
|
|
3405
3658
|
applied++;
|
|
3406
3659
|
}
|
|
3407
|
-
return [applied, skipped];
|
|
3660
|
+
return [applied, skipped, already_resolved];
|
|
3408
3661
|
}
|
|
3409
3662
|
|
|
3410
3663
|
private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
|
|
@@ -3486,18 +3739,16 @@ export class RedlineEngine {
|
|
|
3486
3739
|
const is_regex = edit.regex || false;
|
|
3487
3740
|
const match_mode = edit.match_mode || "strict";
|
|
3488
3741
|
|
|
3489
|
-
let matches = this.mapper.
|
|
3490
|
-
edit.target_text,
|
|
3491
|
-
is_regex,
|
|
3742
|
+
let matches = this.mapper.drop_virtual_only_matches(
|
|
3743
|
+
this.mapper.find_all_match_indices(edit.target_text, is_regex),
|
|
3492
3744
|
);
|
|
3493
3745
|
let use_clean_map = false;
|
|
3494
3746
|
|
|
3495
3747
|
if (matches.length === 0) {
|
|
3496
3748
|
if (!this.clean_mapper)
|
|
3497
3749
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
3498
|
-
matches = this.clean_mapper.
|
|
3499
|
-
edit.target_text,
|
|
3500
|
-
is_regex,
|
|
3750
|
+
matches = this.clean_mapper.drop_virtual_only_matches(
|
|
3751
|
+
this.clean_mapper.find_all_match_indices(edit.target_text, is_regex),
|
|
3501
3752
|
);
|
|
3502
3753
|
if (matches.length > 0) use_clean_map = true;
|
|
3503
3754
|
else return null;
|
|
@@ -3511,6 +3762,8 @@ export class RedlineEngine {
|
|
|
3511
3762
|
(span) =>
|
|
3512
3763
|
span.run !== null && span.end > s && span.start < s + match_len,
|
|
3513
3764
|
);
|
|
3765
|
+
// Virtual-only matches were already dropped above; here we only skip
|
|
3766
|
+
// matches buried entirely inside tracked deletions.
|
|
3514
3767
|
if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
|
|
3515
3768
|
live_matches.push([s, match_len]);
|
|
3516
3769
|
}
|
|
@@ -4190,7 +4443,9 @@ export class RedlineEngine {
|
|
|
4190
4443
|
this._set_paragraph_style(new_p, style_name);
|
|
4191
4444
|
} else {
|
|
4192
4445
|
const existing_pPr = findChild(_bug233_target_para, "w:pPr");
|
|
4193
|
-
if (existing_pPr)
|
|
4446
|
+
if (existing_pPr) {
|
|
4447
|
+
new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
|
|
4448
|
+
}
|
|
4194
4449
|
}
|
|
4195
4450
|
let pPr = findChild(new_p, "w:pPr");
|
|
4196
4451
|
if (!pPr) {
|
|
@@ -4534,7 +4789,41 @@ export class RedlineEngine {
|
|
|
4534
4789
|
}
|
|
4535
4790
|
|
|
4536
4791
|
if (p2_element && p2_element.tagName === "w:p") {
|
|
4792
|
+
// Decide the merged container's properties BEFORE p2's children
|
|
4793
|
+
// move in: when p1 keeps no visible content (a FULL paragraph
|
|
4794
|
+
// deletion), the only surviving text is p2's — the merged
|
|
4795
|
+
// paragraph must carry p2's properties (style, numbering).
|
|
4796
|
+
// Keeping p1's restyled the following paragraph: deleting a
|
|
4797
|
+
// heading turned the next body paragraph into a heading,
|
|
4798
|
+
// deleting a plain paragraph before a list item stripped the
|
|
4799
|
+
// item's numbering (QA 2026-07-19 ADEU-QA-002 B).
|
|
4800
|
+
const p1_fully_deleted =
|
|
4801
|
+
!this._paragraph_has_visible_content(p1_element);
|
|
4802
|
+
|
|
4537
4803
|
let pPr = findChild(p1_element, "w:pPr");
|
|
4804
|
+
if (p1_fully_deleted) {
|
|
4805
|
+
const p2_pPr = findChild(p2_element, "w:pPr");
|
|
4806
|
+
const adopted = (
|
|
4807
|
+
p2_pPr
|
|
4808
|
+
? p2_pPr.cloneNode(true)
|
|
4809
|
+
: p1_element.ownerDocument!.createElement("w:pPr")
|
|
4810
|
+
) as Element;
|
|
4811
|
+
// Section properties belong to p1's position in the document
|
|
4812
|
+
// flow, never to p2's styling — carry them over so a section
|
|
4813
|
+
// boundary is not destroyed.
|
|
4814
|
+
if (pPr) {
|
|
4815
|
+
const sect = findChild(pPr, "w:sectPr");
|
|
4816
|
+
if (sect && !findChild(adopted, "w:sectPr")) {
|
|
4817
|
+
adopted.appendChild(sect.cloneNode(true));
|
|
4818
|
+
}
|
|
4819
|
+
p1_element.removeChild(pPr);
|
|
4820
|
+
}
|
|
4821
|
+
p1_element.insertBefore(
|
|
4822
|
+
adopted,
|
|
4823
|
+
p1_element.firstChild as Node | null,
|
|
4824
|
+
);
|
|
4825
|
+
pPr = adopted;
|
|
4826
|
+
}
|
|
4538
4827
|
if (!pPr) {
|
|
4539
4828
|
pPr = p1_element.ownerDocument!.createElement("w:pPr") as Element;
|
|
4540
4829
|
p1_element.insertBefore(
|
|
@@ -4547,8 +4836,10 @@ export class RedlineEngine {
|
|
|
4547
4836
|
rPr = p1_element.ownerDocument!.createElement("w:rPr") as Element;
|
|
4548
4837
|
pPr!.appendChild(rPr);
|
|
4549
4838
|
}
|
|
4550
|
-
|
|
4551
|
-
|
|
4839
|
+
if (!findChild(rPr!, "w:del")) {
|
|
4840
|
+
const del_mark = this._create_track_change_tag("w:del");
|
|
4841
|
+
rPr!.appendChild(del_mark);
|
|
4842
|
+
}
|
|
4552
4843
|
|
|
4553
4844
|
const children = Array.from(p2_element.childNodes);
|
|
4554
4845
|
for (const child of children) {
|
|
@@ -4618,27 +4909,7 @@ export class RedlineEngine {
|
|
|
4618
4909
|
|
|
4619
4910
|
// PHASE 2: Check for orphaned paragraphs with zero visible content remaining
|
|
4620
4911
|
for (const p_elem of affected_ps) {
|
|
4621
|
-
|
|
4622
|
-
for (const tag of ["w:t", "w:tab", "w:br"]) {
|
|
4623
|
-
const nodes = findAllDescendants(p_elem, tag);
|
|
4624
|
-
for (const node of nodes) {
|
|
4625
|
-
let is_deleted = false;
|
|
4626
|
-
let curr = node.parentNode as Element | null;
|
|
4627
|
-
while (curr && curr !== p_elem.parentNode) {
|
|
4628
|
-
if (curr.tagName === "w:del") {
|
|
4629
|
-
is_deleted = true;
|
|
4630
|
-
break;
|
|
4631
|
-
}
|
|
4632
|
-
curr = curr.parentNode as Element | null;
|
|
4633
|
-
}
|
|
4634
|
-
if (!is_deleted) {
|
|
4635
|
-
if (tag === "w:t" && !node.textContent) continue;
|
|
4636
|
-
has_visible = true;
|
|
4637
|
-
break;
|
|
4638
|
-
}
|
|
4639
|
-
}
|
|
4640
|
-
if (has_visible) break;
|
|
4641
|
-
}
|
|
4912
|
+
const has_visible = this._paragraph_has_visible_content(p_elem);
|
|
4642
4913
|
|
|
4643
4914
|
if (!has_visible) {
|
|
4644
4915
|
let pPr = findChild(p_elem, "w:pPr");
|
package/src/ingest.test.ts
CHANGED
|
@@ -23,7 +23,9 @@ describe('Ingestion Engine (Node.js Port)', () => {
|
|
|
23
23
|
expect(markdown.length).toBeGreaterThan(0);
|
|
24
24
|
|
|
25
25
|
// Assert exact parity with the Python engine's CriticMarkup generation
|
|
26
|
-
|
|
26
|
+
// The del+ins pair of one modification is annotated as a resolution
|
|
27
|
+
// group (QA 2026-07-19 ADEU-QA-004).
|
|
28
|
+
expect(markdown).toBe('This is the {--initial --}{++golden ++}{>>[Chg:3 delete] Mikko Korpela (pairs with Chg:4)\n[Chg:4 insert] Mikko Korpela (pairs with Chg:3)\n[Com:0] Mikko Korpela @ 2026-01-23T07:25:00Z: Start of comment thread\n[Com:1] Mikko Korpela @ 2026-01-23T07:25:00Z: Second comment\n[Com:2] Mikko Korpela @ 2026-01-23T07:26:00Z: Third comment in the thread<<}document');
|
|
27
29
|
});
|
|
28
30
|
|
|
29
31
|
it('should execute in cleanView mode without failing', async () => {
|
package/src/ingest.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { DocumentObject } from "./docx/bridge.js";
|
|
|
2
2
|
import { Paragraph, Table, Run, DocxEvent } from "./docx/primitives.js";
|
|
3
3
|
import {
|
|
4
4
|
_get_style_cache,
|
|
5
|
+
compute_change_pair_map,
|
|
5
6
|
get_paragraph_prefix,
|
|
6
7
|
is_heading_paragraph,
|
|
7
8
|
is_native_heading,
|
|
@@ -258,7 +259,8 @@ export function extract_table(
|
|
|
258
259
|
| undefined;
|
|
259
260
|
const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
|
|
260
261
|
if (paraId) {
|
|
261
|
-
const
|
|
262
|
+
const space_pad = cell_content ? " " : "";
|
|
263
|
+
const anchor = `${space_pad}{#cell:${paraId}}`;
|
|
262
264
|
cell_content = cell_content + anchor;
|
|
263
265
|
}
|
|
264
266
|
}
|
|
@@ -603,13 +605,22 @@ function _build_merged_meta_block(
|
|
|
603
605
|
const comment_lines: string[] = [];
|
|
604
606
|
const seen_sigs = new Set<string>();
|
|
605
607
|
|
|
608
|
+
// Ids of one resolution group (a replacement's contiguous same-author
|
|
609
|
+
// del+ins pair) must not read as independently resolvable — either side
|
|
610
|
+
// resolves the whole group (QA 2026-07-19 ADEU-QA-004).
|
|
611
|
+
const pair_map = compute_change_pair_map(states_list);
|
|
612
|
+
const pairSuffix = (uid: string): string =>
|
|
613
|
+
pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
|
|
614
|
+
|
|
606
615
|
for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
|
|
607
616
|
for (const [uid, meta] of Object.entries(
|
|
608
617
|
ins_map as Record<string, DocxEvent>,
|
|
609
618
|
)) {
|
|
610
619
|
const sig = `Chg:${uid}`;
|
|
611
620
|
if (!seen_sigs.has(sig)) {
|
|
612
|
-
change_lines.push(
|
|
621
|
+
change_lines.push(
|
|
622
|
+
`[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
|
|
623
|
+
);
|
|
613
624
|
seen_sigs.add(sig);
|
|
614
625
|
}
|
|
615
626
|
}
|
|
@@ -618,7 +629,9 @@ function _build_merged_meta_block(
|
|
|
618
629
|
)) {
|
|
619
630
|
const sig = `Chg:${uid}`;
|
|
620
631
|
if (!seen_sigs.has(sig)) {
|
|
621
|
-
change_lines.push(
|
|
632
|
+
change_lines.push(
|
|
633
|
+
`[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
|
|
634
|
+
);
|
|
622
635
|
seen_sigs.add(sig);
|
|
623
636
|
}
|
|
624
637
|
}
|