@adeu/core 1.20.0 → 1.22.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 +577 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +105 -1
- package/dist/index.d.ts +105 -1
- package/dist/index.js +570 -57
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/domain.ts +10 -1
- package/src/engine.bugs.test.ts +1 -1
- package/src/engine.qa_v4_port.test.ts +304 -0
- package/src/engine.sequential.test.ts +140 -0
- package/src/engine.ts +501 -36
- package/src/index.ts +3 -2
- package/src/mapper.ts +128 -28
- package/src/markup.test.ts +3 -2
- package/src/markup.ts +14 -6
- package/src/repro_qa_report_v3.test.ts +9 -3
- package/src/repro_qa_report_v5.test.ts +399 -0
- package/src/sanitize/transforms.ts +26 -2
- package/src/utils/safe-regex.ts +90 -0
- package/src/utils/text.ts +26 -0
package/src/engine.ts
CHANGED
|
@@ -22,6 +22,15 @@ import {
|
|
|
22
22
|
apply_formatting_to_segments,
|
|
23
23
|
} from "./utils/docx.js";
|
|
24
24
|
import { format_ambiguity_error } from "./markup.js";
|
|
25
|
+
import {
|
|
26
|
+
PREVIEW_TEXT_CAP,
|
|
27
|
+
REPORT_ECHO_CAP,
|
|
28
|
+
truncate_middle,
|
|
29
|
+
} from "./utils/text.js";
|
|
30
|
+
import { RegexTimeoutError } from "./utils/safe-regex.js";
|
|
31
|
+
|
|
32
|
+
// Width of the surrounding-document window shown in redline previews.
|
|
33
|
+
const PREVIEW_CONTEXT_CHARS = 30;
|
|
25
34
|
|
|
26
35
|
// --- DOM Mutation Helpers for xmldom ---
|
|
27
36
|
function getNextElement(el: Element): Element | null {
|
|
@@ -152,6 +161,38 @@ export class BatchValidationError extends Error {
|
|
|
152
161
|
}
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
// Appended to a validation error when earlier edits in the same batch have
|
|
165
|
+
// already applied: the failing target may simply be stale under the
|
|
166
|
+
// sequential batch contract. Wording mirrors the Python engine exactly.
|
|
167
|
+
function sequential_context_hint(applied_so_far: number): string {
|
|
168
|
+
return (
|
|
169
|
+
`\n Note: ${applied_so_far} earlier edit(s) in this batch were already ` +
|
|
170
|
+
"applied. Batches apply sequentially — each edit must target the document " +
|
|
171
|
+
"text as it reads AFTER the preceding edits (e.g. target the replacement " +
|
|
172
|
+
"text an earlier edit introduced, not the original wording)."
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Report placeholder for edits blocked only by OTHER edits' validation
|
|
177
|
+
// failures under the transactional batch contract. Mirrors Python.
|
|
178
|
+
const TRANSACTIONAL_NOT_APPLIED_ERROR =
|
|
179
|
+
"Not applied: the batch is transactional and other edits failed " +
|
|
180
|
+
"validation (see their errors). Fix or remove those edits and re-run.";
|
|
181
|
+
|
|
182
|
+
// Characters XML 1.0 cannot represent: C0 controls except tab/newline/CR.
|
|
183
|
+
// Word refuses to open a package carrying them, and @xmldom serializes them
|
|
184
|
+
// silently, so they must be rejected before they reach the DOM
|
|
185
|
+
// (QA 2026-07-17 F11; mirrors Python's clean per-edit error).
|
|
186
|
+
const XML_ILLEGAL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
|
187
|
+
|
|
188
|
+
export function describe_illegal_control_chars(text: string): string | null {
|
|
189
|
+
if (!text) return null;
|
|
190
|
+
const found = text.match(XML_ILLEGAL_CHARS_RE);
|
|
191
|
+
if (!found) return null;
|
|
192
|
+
const codes = Array.from(new Set(found.map((c) => `0x${c.charCodeAt(0).toString(16).padStart(2, "0")}`))).sort();
|
|
193
|
+
return codes.join(", ");
|
|
194
|
+
}
|
|
195
|
+
|
|
155
196
|
export function validate_edit_strings(
|
|
156
197
|
edits: any[],
|
|
157
198
|
index_offset: number = 0,
|
|
@@ -163,6 +204,25 @@ export function validate_edit_strings(
|
|
|
163
204
|
const t_text = edit.target_text || "";
|
|
164
205
|
const n_text = edit.new_text || "";
|
|
165
206
|
|
|
207
|
+
// VAL-CRIT-8: XML-illegal control characters (QA 2026-07-17 F11).
|
|
208
|
+
const checked_fields: Array<[string, string]> = [
|
|
209
|
+
["target_text", t_text],
|
|
210
|
+
["new_text", n_text],
|
|
211
|
+
];
|
|
212
|
+
if (edit.comment) checked_fields.push(["comment", edit.comment]);
|
|
213
|
+
(edit.cells || []).forEach((cell: string, cell_idx: number) => {
|
|
214
|
+
checked_fields.push([`cells[${cell_idx}]`, cell || ""]);
|
|
215
|
+
});
|
|
216
|
+
for (const [field_name, field_value] of checked_fields) {
|
|
217
|
+
const described = describe_illegal_control_chars(field_value);
|
|
218
|
+
if (described) {
|
|
219
|
+
errors.push(
|
|
220
|
+
`- Edit ${i + 1 + index_offset} Failed: \`${field_name}\` contains control character(s) ` +
|
|
221
|
+
`(${described}) that cannot be stored in a DOCX. Remove them and re-submit.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
166
226
|
if (
|
|
167
227
|
n_text.includes("{++") ||
|
|
168
228
|
n_text.includes("{--") ||
|
|
@@ -473,10 +533,214 @@ export class RedlineEngine {
|
|
|
473
533
|
}
|
|
474
534
|
return "";
|
|
475
535
|
}
|
|
536
|
+
// CriticMarkup wrapper pairs used when tidying preview context windows.
|
|
537
|
+
private static readonly _PREVIEW_WRAPPER_PAIRS: [string, string][] = [
|
|
538
|
+
["{--", "--}"],
|
|
539
|
+
["{++", "++}"],
|
|
540
|
+
["{==", "==}"],
|
|
541
|
+
["{>>", "<<}"],
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Makes a fixed-width slice of the raw-view projection presentable: drops
|
|
546
|
+
* complete {>>...<<} meta blocks (annotations of pre-existing changes, not
|
|
547
|
+
* part of this edit) and any wrapper fragments the window boundary chopped
|
|
548
|
+
* in half. Without this, previews leak internal scaffolding like
|
|
549
|
+
* "[Chg:5 delete]" (QA H1). Mirrors the Python engine.
|
|
550
|
+
*/
|
|
551
|
+
private static _tidy_preview_context(
|
|
552
|
+
snippet: string,
|
|
553
|
+
side: "before" | "after",
|
|
554
|
+
): string {
|
|
555
|
+
snippet = snippet.replace(/\{>>[\s\S]*?<<\}/g, "");
|
|
556
|
+
|
|
557
|
+
for (const [open_tok, close_tok] of RedlineEngine._PREVIEW_WRAPPER_PAIRS) {
|
|
558
|
+
if (side === "before") {
|
|
559
|
+
// Cut through the last closer whose opener lies left of the window.
|
|
560
|
+
let depth = 0;
|
|
561
|
+
let cut = 0;
|
|
562
|
+
let i = 0;
|
|
563
|
+
while (i < snippet.length) {
|
|
564
|
+
if (snippet.startsWith(open_tok, i)) {
|
|
565
|
+
depth += 1;
|
|
566
|
+
i += open_tok.length;
|
|
567
|
+
} else if (snippet.startsWith(close_tok, i)) {
|
|
568
|
+
if (depth === 0) cut = i + close_tok.length;
|
|
569
|
+
else depth -= 1;
|
|
570
|
+
i += close_tok.length;
|
|
571
|
+
} else {
|
|
572
|
+
i += 1;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
snippet = snippet.substring(cut);
|
|
576
|
+
} else {
|
|
577
|
+
// Cut from the first opener whose closer lies right of the window.
|
|
578
|
+
const opens: number[] = [];
|
|
579
|
+
let i = 0;
|
|
580
|
+
while (i < snippet.length) {
|
|
581
|
+
if (snippet.startsWith(open_tok, i)) {
|
|
582
|
+
opens.push(i);
|
|
583
|
+
i += open_tok.length;
|
|
584
|
+
} else if (snippet.startsWith(close_tok, i)) {
|
|
585
|
+
if (opens.length > 0) opens.pop();
|
|
586
|
+
i += close_tok.length;
|
|
587
|
+
} else {
|
|
588
|
+
i += 1;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (opens.length > 0) snippet = snippet.substring(0, opens[0]);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 1-2 char remnants of a 3-char wrapper token chopped by the window edge.
|
|
596
|
+
if (side === "before") {
|
|
597
|
+
snippet = snippet.replace(/^[-+=<>]{0,2}\}/, "");
|
|
598
|
+
} else {
|
|
599
|
+
snippet = snippet.replace(/\{[-+=<>]{0,2}$/, "");
|
|
600
|
+
}
|
|
601
|
+
return snippet;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Snapshots the document text around a resolved edit BEFORE anything is
|
|
606
|
+
* applied. Previews rendered after the batch mutates the DOM cannot slice
|
|
607
|
+
* full_text at the stored offsets: applied edits shift offsets and inject
|
|
608
|
+
* tracked-change markup, garbling previews with unrelated edits and
|
|
609
|
+
* internal scaffolding (QA H1).
|
|
610
|
+
*/
|
|
611
|
+
private _capture_preview_context(edit: any): void {
|
|
612
|
+
if (edit.type !== "modify") return;
|
|
613
|
+
const start_idx = edit._resolved_start_idx;
|
|
614
|
+
if (start_idx === undefined || start_idx === null) return;
|
|
615
|
+
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
616
|
+
const full_text = active_mapper.full_text;
|
|
617
|
+
if (!full_text) return;
|
|
618
|
+
const length = (edit.target_text || "").length;
|
|
619
|
+
const before = full_text.substring(
|
|
620
|
+
Math.max(0, start_idx - PREVIEW_CONTEXT_CHARS),
|
|
621
|
+
start_idx,
|
|
622
|
+
);
|
|
623
|
+
const after = full_text.substring(
|
|
624
|
+
start_idx + length,
|
|
625
|
+
start_idx + length + PREVIEW_CONTEXT_CHARS,
|
|
626
|
+
);
|
|
627
|
+
edit._preview_context = [
|
|
628
|
+
RedlineEngine._tidy_preview_context(before, "before"),
|
|
629
|
+
RedlineEngine._tidy_preview_context(after, "after"),
|
|
630
|
+
];
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Like _capture_preview_context, but snapshots the context around the
|
|
635
|
+
* ORIGINAL edit's full matched span (stashed by _pre_resolve_heuristic_edit),
|
|
636
|
+
* so the report preview can present the complete logical change of a
|
|
637
|
+
* compound modification instead of its first sub-edit.
|
|
638
|
+
*/
|
|
639
|
+
private _capture_parent_preview_context(parent: any): void {
|
|
640
|
+
if (!parent || parent.type !== "modify") return;
|
|
641
|
+
if (parent._preview_context || !parent._preview_span) return;
|
|
642
|
+
const [start_idx, match_len] = parent._preview_span;
|
|
643
|
+
const active_mapper = parent._preview_mapper_ref || this.mapper;
|
|
644
|
+
const full_text = active_mapper.full_text;
|
|
645
|
+
if (!full_text) return;
|
|
646
|
+
const before = full_text.substring(
|
|
647
|
+
Math.max(0, start_idx - PREVIEW_CONTEXT_CHARS),
|
|
648
|
+
start_idx,
|
|
649
|
+
);
|
|
650
|
+
const after = full_text.substring(
|
|
651
|
+
start_idx + match_len,
|
|
652
|
+
start_idx + match_len + PREVIEW_CONTEXT_CHARS,
|
|
653
|
+
);
|
|
654
|
+
parent._preview_context = [
|
|
655
|
+
RedlineEngine._tidy_preview_context(before, "before"),
|
|
656
|
+
RedlineEngine._tidy_preview_context(after, "after"),
|
|
657
|
+
];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Renders the preview from the edit's full matched span. The common
|
|
662
|
+
* prefix/suffix between matched and replacement text is moved into the
|
|
663
|
+
* surrounding context so the {--...--}{++...++} block shows the minimal
|
|
664
|
+
* complete change.
|
|
665
|
+
*/
|
|
666
|
+
private _build_full_match_preview(edit: any): [string | null, string | null] {
|
|
667
|
+
let [context_before, context_after] = edit._preview_context as [
|
|
668
|
+
string,
|
|
669
|
+
string,
|
|
670
|
+
];
|
|
671
|
+
let matched: string = edit._preview_matched_text || "";
|
|
672
|
+
let new_text: string =
|
|
673
|
+
edit._preview_new_text !== undefined && edit._preview_new_text !== null
|
|
674
|
+
? edit._preview_new_text
|
|
675
|
+
: edit.new_text || "";
|
|
676
|
+
|
|
677
|
+
// Heading markdown prefixes are projection artifacts, not literal
|
|
678
|
+
// document text — keep them out of the {--...--}/{++...++} body.
|
|
679
|
+
const [matched_clean, matched_style] = this._parse_markdown_style(matched);
|
|
680
|
+
const [new_clean, new_style] = this._parse_markdown_style(new_text);
|
|
681
|
+
if (matched_style && matched_style.startsWith("Heading")) {
|
|
682
|
+
context_before = context_before + matched.substring(0, matched.length - matched_clean.length);
|
|
683
|
+
matched = matched_clean;
|
|
684
|
+
}
|
|
685
|
+
if (new_style && new_style.startsWith("Heading")) {
|
|
686
|
+
new_text = new_clean;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const [prefix_len, suffix_len] = trim_common_context(matched, new_text);
|
|
690
|
+
let display_target = matched.substring(
|
|
691
|
+
prefix_len,
|
|
692
|
+
matched.length - suffix_len,
|
|
693
|
+
);
|
|
694
|
+
let display_new = new_text.substring(
|
|
695
|
+
prefix_len,
|
|
696
|
+
new_text.length - suffix_len,
|
|
697
|
+
);
|
|
698
|
+
context_before = context_before + matched.substring(0, prefix_len);
|
|
699
|
+
if (suffix_len) {
|
|
700
|
+
context_after = matched.substring(matched.length - suffix_len) + context_after;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
display_target = truncate_middle(display_target, PREVIEW_TEXT_CAP);
|
|
704
|
+
display_new = truncate_middle(display_new, PREVIEW_TEXT_CAP);
|
|
705
|
+
let critic_markup: string;
|
|
706
|
+
if (!display_target && !display_new) {
|
|
707
|
+
// Comment-only edit (text unchanged): highlight the anchor instead of
|
|
708
|
+
// rendering an empty change.
|
|
709
|
+
const anchor = truncate_middle(matched, PREVIEW_TEXT_CAP);
|
|
710
|
+
const body = anchor ? `{==${anchor}==}` : "";
|
|
711
|
+
critic_markup = `${context_before.substring(0, context_before.length - matched.length)}${body}${context_after}`;
|
|
712
|
+
} else {
|
|
713
|
+
const deletion = display_target ? `{--${display_target}--}` : "";
|
|
714
|
+
const insertion = display_new ? `{++${display_new}++}` : "";
|
|
715
|
+
critic_markup = `${context_before}${deletion}${insertion}${context_after}`;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let clean_text = critic_markup;
|
|
719
|
+
clean_text = clean_text.replace(/\{>>[\s\S]*?<<\}/g, "");
|
|
720
|
+
clean_text = clean_text.replace(/\{--[\s\S]*?--\}/g, "");
|
|
721
|
+
clean_text = clean_text.replace(/\{\+\+([\s\S]*?)\+\+\}/g, "$1");
|
|
722
|
+
return [critic_markup, clean_text];
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* The "new text" a batch report should show for an edit. InsertTableRow has
|
|
727
|
+
* no new_text field — surface its cell contents rather than a misleading
|
|
728
|
+
* empty string (QA M4).
|
|
729
|
+
*/
|
|
730
|
+
private static _report_new_text(edit: any): string {
|
|
731
|
+
if (edit && edit.type === "insert_row" && Array.isArray(edit.cells)) {
|
|
732
|
+
return edit.cells.join(" | ");
|
|
733
|
+
}
|
|
734
|
+
return (edit && edit.new_text) || "";
|
|
735
|
+
}
|
|
736
|
+
|
|
476
737
|
private _build_edit_context_previews(
|
|
477
738
|
edit: any,
|
|
478
739
|
): [string | null, string | null] {
|
|
479
740
|
if (edit.type !== "modify") return [null, null];
|
|
741
|
+
if (edit._preview_span && edit._preview_context) {
|
|
742
|
+
return this._build_full_match_preview(edit);
|
|
743
|
+
}
|
|
480
744
|
if (edit._resolved_proxy_edit) {
|
|
481
745
|
edit = edit._resolved_proxy_edit;
|
|
482
746
|
}
|
|
@@ -499,19 +763,38 @@ export class RedlineEngine {
|
|
|
499
763
|
}
|
|
500
764
|
|
|
501
765
|
const length = target_text.length;
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
766
|
+
let context_before: string;
|
|
767
|
+
let context_after: string;
|
|
768
|
+
if (edit._preview_context) {
|
|
769
|
+
[context_before, context_after] = edit._preview_context;
|
|
770
|
+
} else {
|
|
771
|
+
// Fallback for callers that never went through apply_edits. Only safe
|
|
772
|
+
// while the mapper still reflects the pre-apply document.
|
|
773
|
+
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
774
|
+
const full_text = active_mapper.full_text;
|
|
775
|
+
if (!full_text) return [null, null];
|
|
776
|
+
context_before = RedlineEngine._tidy_preview_context(
|
|
777
|
+
full_text.substring(
|
|
778
|
+
Math.max(0, start_idx - PREVIEW_CONTEXT_CHARS),
|
|
779
|
+
start_idx,
|
|
780
|
+
),
|
|
781
|
+
"before",
|
|
782
|
+
);
|
|
783
|
+
context_after = RedlineEngine._tidy_preview_context(
|
|
784
|
+
full_text.substring(
|
|
785
|
+
start_idx + length,
|
|
786
|
+
start_idx + length + PREVIEW_CONTEXT_CHARS,
|
|
787
|
+
),
|
|
788
|
+
"after",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
512
791
|
|
|
513
|
-
|
|
514
|
-
|
|
792
|
+
// Bound the echoed edit values: previews flow into LLM context windows
|
|
793
|
+
// and must not multiply an oversized new_text/target_text (QA C2).
|
|
794
|
+
const display_target = truncate_middle(target_text, PREVIEW_TEXT_CAP);
|
|
795
|
+
const display_new = truncate_middle(new_text, PREVIEW_TEXT_CAP);
|
|
796
|
+
const insertion = display_new ? `{++${display_new}++}` : "";
|
|
797
|
+
const critic_markup = `${context_before}{--${display_target}--}${insertion}${context_after}`;
|
|
515
798
|
|
|
516
799
|
let clean_text = critic_markup;
|
|
517
800
|
clean_text = clean_text.replace(/\{>>.*?<<\}/gs, "");
|
|
@@ -1713,7 +1996,7 @@ export class RedlineEngine {
|
|
|
1713
1996
|
} else {
|
|
1714
1997
|
const hint = this._nearest_match_hint(edit.target_text, is_regex);
|
|
1715
1998
|
errors.push(
|
|
1716
|
-
`- Edit ${i + 1 + index_offset} Failed: Target text not found in document:\n "${edit.target_text}"${hint}`,
|
|
1999
|
+
`- Edit ${i + 1 + index_offset} Failed: Target text not found in document:\n "${truncate_middle(edit.target_text, REPORT_ECHO_CAP)}"${hint}`,
|
|
1717
2000
|
);
|
|
1718
2001
|
}
|
|
1719
2002
|
} else if (matches.length > 1 && match_mode === "strict") {
|
|
@@ -1862,10 +2145,65 @@ export class RedlineEngine {
|
|
|
1862
2145
|
);
|
|
1863
2146
|
}
|
|
1864
2147
|
}
|
|
2148
|
+
|
|
2149
|
+
// Structural table edits: verify the anchor really is a table row, and
|
|
2150
|
+
// that insert_row does not provide more cells than the row has columns —
|
|
2151
|
+
// extra cells must never produce a structurally inconsistent row (QA M3).
|
|
2152
|
+
if (
|
|
2153
|
+
(edit.type === "insert_row" || edit.type === "delete_row") &&
|
|
2154
|
+
matches.length > 0
|
|
2155
|
+
) {
|
|
2156
|
+
const [start, length] = matches[0];
|
|
2157
|
+
const n_cols = RedlineEngine._column_count_at(
|
|
2158
|
+
target_mapper,
|
|
2159
|
+
start,
|
|
2160
|
+
length,
|
|
2161
|
+
);
|
|
2162
|
+
if (n_cols === null) {
|
|
2163
|
+
errors.push(
|
|
2164
|
+
`- Edit ${i + 1 + index_offset} Failed: ${edit.type} target text was found, but it is not inside a table row. Anchor the operation on text that appears within the table.`,
|
|
2165
|
+
);
|
|
2166
|
+
} else if (
|
|
2167
|
+
edit.type === "insert_row" &&
|
|
2168
|
+
Array.isArray(edit.cells) &&
|
|
2169
|
+
edit.cells.length > n_cols
|
|
2170
|
+
) {
|
|
2171
|
+
errors.push(
|
|
2172
|
+
`- Edit ${i + 1 + index_offset} Failed: insert_row provides ${edit.cells.length} cells but the target table has ${n_cols} column(s). The extra cell(s) would be dropped. Provide at most ${n_cols} cells — rows given fewer cells are padded with empty ones.`,
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
1865
2176
|
}
|
|
1866
2177
|
return errors;
|
|
1867
2178
|
}
|
|
1868
2179
|
|
|
2180
|
+
/**
|
|
2181
|
+
* Number of columns (w:tc elements) in the table row containing the text at
|
|
2182
|
+
* [start, start+length) in `mapper`, or null if that text is not inside a
|
|
2183
|
+
* table row.
|
|
2184
|
+
*/
|
|
2185
|
+
private static _column_count_at(
|
|
2186
|
+
mapper: DocumentMapper,
|
|
2187
|
+
start: number,
|
|
2188
|
+
length: number,
|
|
2189
|
+
): number | null {
|
|
2190
|
+
for (const s of mapper.spans) {
|
|
2191
|
+
if (s.run === null || s.end <= start || s.start >= start + length) {
|
|
2192
|
+
continue;
|
|
2193
|
+
}
|
|
2194
|
+
let curr: Node | null = s.run._element;
|
|
2195
|
+
while (curr) {
|
|
2196
|
+
if (curr.nodeType === 1 && (curr as Element).tagName === "w:tr") {
|
|
2197
|
+
return findAllDescendants(curr as Element, "w:tc").filter(
|
|
2198
|
+
(tc) => tc.parentNode === curr,
|
|
2199
|
+
).length;
|
|
2200
|
+
}
|
|
2201
|
+
curr = curr.parentNode;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
1869
2207
|
public validate_review_actions(actions: any[]): string[] {
|
|
1870
2208
|
const errors: string[] = [];
|
|
1871
2209
|
for (let i = 0; i < actions.length; i++) {
|
|
@@ -1993,16 +2331,14 @@ export class RedlineEngine {
|
|
|
1993
2331
|
!["accept", "reject", "reply"].includes(c.type),
|
|
1994
2332
|
);
|
|
1995
2333
|
|
|
1996
|
-
//
|
|
1997
|
-
//
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2002
|
-
//
|
|
2003
|
-
//
|
|
2004
|
-
// fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
|
|
2005
|
-
// nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
|
|
2334
|
+
// Never pre-unwrap a foreign author's <w:ins> to make a partially
|
|
2335
|
+
// straddling edit fit: that turns their tracked-inserted text into
|
|
2336
|
+
// untracked committed body text before the edit applies, destroying
|
|
2337
|
+
// their provenance. A partial straddle surfaces the standard validation
|
|
2338
|
+
// error ("Modification targets an active insertion from another author
|
|
2339
|
+
// …") via validate_edits, matching the Python engine. An edit fully
|
|
2340
|
+
// CONTAINED inside a foreign <w:ins> is allowed and handled by nesting
|
|
2341
|
+
// the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
|
|
2006
2342
|
// _insert_and_split_ins).
|
|
2007
2343
|
|
|
2008
2344
|
// BUG-7: Unified single-pass validation in wet-run / standard mode.
|
|
@@ -2082,9 +2418,26 @@ export class RedlineEngine {
|
|
|
2082
2418
|
|
|
2083
2419
|
if (dry_run_mode) {
|
|
2084
2420
|
const reports_by_input: any[] = new Array(edits.length);
|
|
2421
|
+
// Indexes that failed VALIDATION (not runtime skips): if any exist,
|
|
2422
|
+
// the real run rejects the whole batch, so the dry-run report must
|
|
2423
|
+
// not claim any edit "applied" (transactional parity with Python).
|
|
2424
|
+
const validation_failed_idx = new Set<number>();
|
|
2085
2425
|
for (const { edit, i } of ordered_edits) {
|
|
2086
|
-
|
|
2426
|
+
let single_errors: string[];
|
|
2427
|
+
try {
|
|
2428
|
+
single_errors = this.validate_edits([edit], i);
|
|
2429
|
+
} catch (e) {
|
|
2430
|
+
// A pathological user pattern must fail as a clean per-edit
|
|
2431
|
+
// validation error, never a hang or crash (QA 2026-07-17 F5).
|
|
2432
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
2433
|
+
single_errors = [`- Edit ${i + 1} Failed: ${e.message}`];
|
|
2434
|
+
}
|
|
2087
2435
|
if (single_errors.length > 0) {
|
|
2436
|
+
if (applied_edits > 0) {
|
|
2437
|
+
const hint = sequential_context_hint(applied_edits);
|
|
2438
|
+
single_errors = single_errors.map((err) => err + hint);
|
|
2439
|
+
}
|
|
2440
|
+
validation_failed_idx.add(i);
|
|
2088
2441
|
skipped_edits++;
|
|
2089
2442
|
// Only surface the punctuation-anchor warning when the edit actually
|
|
2090
2443
|
// failed. A clean apply already returns the redline preview, so the
|
|
@@ -2096,10 +2449,10 @@ export class RedlineEngine {
|
|
|
2096
2449
|
);
|
|
2097
2450
|
reports_by_input[i] = {
|
|
2098
2451
|
status: "failed",
|
|
2099
|
-
target_text: (edit as any).target_text || "",
|
|
2100
|
-
new_text: (edit
|
|
2452
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2453
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2101
2454
|
warning: warning,
|
|
2102
|
-
error: single_errors
|
|
2455
|
+
error: single_errors.join("\n"),
|
|
2103
2456
|
critic_markup: null,
|
|
2104
2457
|
clean_text: null,
|
|
2105
2458
|
};
|
|
@@ -2111,8 +2464,8 @@ export class RedlineEngine {
|
|
|
2111
2464
|
const previews = this._build_edit_context_previews(edit);
|
|
2112
2465
|
reports_by_input[i] = {
|
|
2113
2466
|
status: "applied",
|
|
2114
|
-
target_text: (edit as any).target_text || "",
|
|
2115
|
-
new_text: (edit
|
|
2467
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2468
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2116
2469
|
warning: null,
|
|
2117
2470
|
error: null,
|
|
2118
2471
|
critic_markup: previews[0],
|
|
@@ -2135,8 +2488,8 @@ export class RedlineEngine {
|
|
|
2135
2488
|
);
|
|
2136
2489
|
reports_by_input[i] = {
|
|
2137
2490
|
status: "failed",
|
|
2138
|
-
target_text: (edit as any).target_text || "",
|
|
2139
|
-
new_text: (edit
|
|
2491
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2492
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2140
2493
|
warning: warning,
|
|
2141
2494
|
error: error_msg,
|
|
2142
2495
|
critic_markup: null,
|
|
@@ -2144,6 +2497,29 @@ export class RedlineEngine {
|
|
|
2144
2497
|
};
|
|
2145
2498
|
}
|
|
2146
2499
|
}
|
|
2500
|
+
if (validation_failed_idx.size > 0) {
|
|
2501
|
+
// Dry-run mirrors the real run's transactional rejection: no edit
|
|
2502
|
+
// will be applied by the real run, so none may be reported as
|
|
2503
|
+
// applied here. Edits that only failed at runtime keep their own
|
|
2504
|
+
// error; edits that would have applied get the transactional note.
|
|
2505
|
+
applied_edits = 0;
|
|
2506
|
+
skipped_edits = edits.length;
|
|
2507
|
+
for (let i = 0; i < reports_by_input.length; i++) {
|
|
2508
|
+
const report = reports_by_input[i];
|
|
2509
|
+
if (!report || validation_failed_idx.has(i)) continue;
|
|
2510
|
+
if (report.status === "applied") {
|
|
2511
|
+
reports_by_input[i] = {
|
|
2512
|
+
status: "failed",
|
|
2513
|
+
target_text: report.target_text,
|
|
2514
|
+
new_text: report.new_text,
|
|
2515
|
+
warning: null,
|
|
2516
|
+
error: TRANSACTIONAL_NOT_APPLIED_ERROR,
|
|
2517
|
+
critic_markup: null,
|
|
2518
|
+
clean_text: null,
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2147
2523
|
edits_reports.push(...reports_by_input);
|
|
2148
2524
|
} else {
|
|
2149
2525
|
// Simulated dry-run sequentially for wet-run validation parity
|
|
@@ -2151,12 +2527,27 @@ export class RedlineEngine {
|
|
|
2151
2527
|
const originalCurrentId = this.current_id;
|
|
2152
2528
|
try {
|
|
2153
2529
|
const sequential_errors: string[] = [];
|
|
2530
|
+
let applied_so_far = 0;
|
|
2154
2531
|
for (const { edit, i } of ordered_edits) {
|
|
2155
|
-
|
|
2532
|
+
let single_errors: string[];
|
|
2533
|
+
try {
|
|
2534
|
+
single_errors = this.validate_edits([edit], i);
|
|
2535
|
+
} catch (e) {
|
|
2536
|
+
// Clean per-edit failure for time-budget violations (QA F5).
|
|
2537
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
2538
|
+
single_errors = [`- Edit ${i + 1} Failed: ${e.message}`];
|
|
2539
|
+
}
|
|
2156
2540
|
if (single_errors.length > 0) {
|
|
2541
|
+
if (applied_so_far > 0) {
|
|
2542
|
+
const hint = sequential_context_hint(applied_so_far);
|
|
2543
|
+
single_errors = single_errors.map((err) => err + hint);
|
|
2544
|
+
}
|
|
2157
2545
|
sequential_errors.push(...single_errors);
|
|
2158
2546
|
} else {
|
|
2159
2547
|
this.apply_edits([edit], page_offsets);
|
|
2548
|
+
if ((edit as any)._applied_status) {
|
|
2549
|
+
applied_so_far++;
|
|
2550
|
+
}
|
|
2160
2551
|
this.mapper = new DocumentMapper(this.doc);
|
|
2161
2552
|
this.clean_mapper = null;
|
|
2162
2553
|
}
|
|
@@ -2195,8 +2586,8 @@ export class RedlineEngine {
|
|
|
2195
2586
|
}
|
|
2196
2587
|
edits_reports.push({
|
|
2197
2588
|
status: success ? "applied" : "failed",
|
|
2198
|
-
target_text: (edit as any).target_text || "",
|
|
2199
|
-
new_text: (edit
|
|
2589
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2590
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2200
2591
|
warning: warning,
|
|
2201
2592
|
error: error_msg,
|
|
2202
2593
|
critic_markup: critic_markup,
|
|
@@ -2280,7 +2671,20 @@ export class RedlineEngine {
|
|
|
2280
2671
|
edit._error_msg = msg;
|
|
2281
2672
|
}
|
|
2282
2673
|
} else {
|
|
2283
|
-
|
|
2674
|
+
let resolved: any;
|
|
2675
|
+
try {
|
|
2676
|
+
resolved = this._pre_resolve_heuristic_edit(edit);
|
|
2677
|
+
} catch (e) {
|
|
2678
|
+
// Direct apply_edits callers bypass validate_edits; the time
|
|
2679
|
+
// budget must still fail cleanly here (QA F5).
|
|
2680
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
2681
|
+
skipped++;
|
|
2682
|
+
edit._applied_status = false;
|
|
2683
|
+
const msg = `- Failed to apply edit targeting: '${(edit.target_text || "").substring(0, 40)}...' (${e.message})`;
|
|
2684
|
+
this.skipped_details.push(msg);
|
|
2685
|
+
edit._error_msg = msg;
|
|
2686
|
+
continue;
|
|
2687
|
+
}
|
|
2284
2688
|
if (resolved) {
|
|
2285
2689
|
if (Array.isArray(resolved)) {
|
|
2286
2690
|
for (const r of resolved) {
|
|
@@ -2320,6 +2724,18 @@ export class RedlineEngine {
|
|
|
2320
2724
|
(a, b) =>
|
|
2321
2725
|
(b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
|
|
2322
2726
|
);
|
|
2727
|
+
|
|
2728
|
+
// Snapshot preview context now, while every resolved offset still refers
|
|
2729
|
+
// to the untouched document. The sweep below mutates the DOM and rebuilds
|
|
2730
|
+
// the map, shifting offsets and injecting tracked-change markup —
|
|
2731
|
+
// slicing full_text at report time garbles previews (QA H1).
|
|
2732
|
+
for (const [res_edit] of resolved_edits) {
|
|
2733
|
+
this._capture_preview_context(res_edit);
|
|
2734
|
+
if (res_edit._parent_edit_ref) {
|
|
2735
|
+
this._capture_parent_preview_context(res_edit._parent_edit_ref);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2323
2739
|
const occupied_ranges: [number, number][] = [];
|
|
2324
2740
|
// Sub-edits split from one balanced multi-paragraph modification share a
|
|
2325
2741
|
// _split_group_id; count the group as a single applied edit (and a single
|
|
@@ -2458,6 +2874,29 @@ export class RedlineEngine {
|
|
|
2458
2874
|
continue;
|
|
2459
2875
|
}
|
|
2460
2876
|
|
|
2877
|
+
// Refuse accept/reject on a w:id shared by revisions from DIFFERENT
|
|
2878
|
+
// authors. Uniqueness of w:id is assumed but not guaranteed for
|
|
2879
|
+
// externally produced documents (merges, cross-document copy-paste),
|
|
2880
|
+
// where one action would silently resolve several unrelated changes
|
|
2881
|
+
// (QA 2026-07-17 F9). Same-author reuse is legitimate — this engine
|
|
2882
|
+
// itself mints one id across every element of a single logical edit —
|
|
2883
|
+
// so authorship is the discriminator.
|
|
2884
|
+
const dup_authors = Array.from(
|
|
2885
|
+
new Set(all_nodes.map((n) => n.getAttribute("w:author") || "Unknown")),
|
|
2886
|
+
).sort();
|
|
2887
|
+
if (dup_authors.length > 1) {
|
|
2888
|
+
skipped++;
|
|
2889
|
+
this.skipped_details.push(
|
|
2890
|
+
`- Failed to apply action: ${type} on Chg:${target_id} is ambiguous. The document ` +
|
|
2891
|
+
`contains ${all_nodes.length} tracked-change elements sharing w:id=${target_id} from ` +
|
|
2892
|
+
`different authors (${dup_authors.join(", ")}) — duplicate revision IDs produced ` +
|
|
2893
|
+
`outside this engine (e.g. by a document merge or copy-paste). Acting on this ID ` +
|
|
2894
|
+
`would resolve all of them at once. Resolve these changes individually in Word, or ` +
|
|
2895
|
+
`apply the intended outcome as an explicit text edit instead.`,
|
|
2896
|
+
);
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2461
2900
|
for (const node of all_nodes) {
|
|
2462
2901
|
const is_ins = node.tagName === "w:ins";
|
|
2463
2902
|
const parent_tag = node.parentNode
|
|
@@ -2552,7 +2991,22 @@ export class RedlineEngine {
|
|
|
2552
2991
|
const trPr = tr.ownerDocument!.createElement("w:trPr");
|
|
2553
2992
|
new_tr.appendChild(trPr);
|
|
2554
2993
|
trPr.appendChild(this._create_track_change_tag("w:ins"));
|
|
2555
|
-
|
|
2994
|
+
// The new row must carry exactly as many cells as the anchor row has
|
|
2995
|
+
// columns: pad missing cells with empty strings and drop extras
|
|
2996
|
+
// (validation already rejects overfilled batches upfront, QA M3) so a
|
|
2997
|
+
// mismatched `cells` list can never produce a structurally
|
|
2998
|
+
// inconsistent table row.
|
|
2999
|
+
const anchor_cols = findAllDescendants(tr, "w:tc").filter(
|
|
3000
|
+
(tc) => tc.parentNode === tr,
|
|
3001
|
+
).length;
|
|
3002
|
+
let cell_values: string[] = Array.isArray(edit.cells)
|
|
3003
|
+
? [...edit.cells]
|
|
3004
|
+
: [];
|
|
3005
|
+
if (anchor_cols > 0) {
|
|
3006
|
+
while (cell_values.length < anchor_cols) cell_values.push("");
|
|
3007
|
+
cell_values = cell_values.slice(0, anchor_cols);
|
|
3008
|
+
}
|
|
3009
|
+
for (const cellText of cell_values) {
|
|
2556
3010
|
const tc = tr.ownerDocument!.createElement("w:tc");
|
|
2557
3011
|
const p = tr.ownerDocument!.createElement("w:p");
|
|
2558
3012
|
const r = tr.ownerDocument!.createElement("w:r");
|
|
@@ -2671,6 +3125,17 @@ export class RedlineEngine {
|
|
|
2671
3125
|
} catch (e) {}
|
|
2672
3126
|
}
|
|
2673
3127
|
|
|
3128
|
+
// Stash the first occurrence's full match for the report preview, so it
|
|
3129
|
+
// can show the complete logical change rather than only the first
|
|
3130
|
+
// word-diff sub-edit (e.g. "{--two--}{++five++} (2) years" for a
|
|
3131
|
+
// "two (2) years" -> "five (5) years" edit). Mirrors Python (QA H1).
|
|
3132
|
+
if (!edit._preview_span) {
|
|
3133
|
+
edit._preview_span = [start_idx, match_len];
|
|
3134
|
+
edit._preview_matched_text = actual_doc_text;
|
|
3135
|
+
edit._preview_new_text = current_effective_new_text;
|
|
3136
|
+
edit._preview_mapper_ref = active_mapper;
|
|
3137
|
+
}
|
|
3138
|
+
|
|
2674
3139
|
const [edit_target_clean, edit_target_style] = this._parse_markdown_style(
|
|
2675
3140
|
edit.target_text,
|
|
2676
3141
|
);
|