@adeu/core 1.19.1 → 1.21.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 +433 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +433 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -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 +421 -30
- package/src/mapper.ts +133 -20
- package/src/markup.test.ts +1 -1
- package/src/markup.ts +11 -5
- package/src/repro.insertion-anchor.test.ts +91 -0
- package/src/repro_qa_report_v3.test.ts +9 -3
- package/src/utils/text.ts +26 -0
package/src/engine.ts
CHANGED
|
@@ -22,6 +22,14 @@ 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
|
+
|
|
31
|
+
// Width of the surrounding-document window shown in redline previews.
|
|
32
|
+
const PREVIEW_CONTEXT_CHARS = 30;
|
|
25
33
|
|
|
26
34
|
// --- DOM Mutation Helpers for xmldom ---
|
|
27
35
|
function getNextElement(el: Element): Element | null {
|
|
@@ -152,6 +160,24 @@ export class BatchValidationError extends Error {
|
|
|
152
160
|
}
|
|
153
161
|
}
|
|
154
162
|
|
|
163
|
+
// Appended to a validation error when earlier edits in the same batch have
|
|
164
|
+
// already applied: the failing target may simply be stale under the
|
|
165
|
+
// sequential batch contract. Wording mirrors the Python engine exactly.
|
|
166
|
+
function sequential_context_hint(applied_so_far: number): string {
|
|
167
|
+
return (
|
|
168
|
+
`\n Note: ${applied_so_far} earlier edit(s) in this batch were already ` +
|
|
169
|
+
"applied. Batches apply sequentially — each edit must target the document " +
|
|
170
|
+
"text as it reads AFTER the preceding edits (e.g. target the replacement " +
|
|
171
|
+
"text an earlier edit introduced, not the original wording)."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Report placeholder for edits blocked only by OTHER edits' validation
|
|
176
|
+
// failures under the transactional batch contract. Mirrors Python.
|
|
177
|
+
const TRANSACTIONAL_NOT_APPLIED_ERROR =
|
|
178
|
+
"Not applied: the batch is transactional and other edits failed " +
|
|
179
|
+
"validation (see their errors). Fix or remove those edits and re-run.";
|
|
180
|
+
|
|
155
181
|
export function validate_edit_strings(
|
|
156
182
|
edits: any[],
|
|
157
183
|
index_offset: number = 0,
|
|
@@ -473,10 +499,214 @@ export class RedlineEngine {
|
|
|
473
499
|
}
|
|
474
500
|
return "";
|
|
475
501
|
}
|
|
502
|
+
// CriticMarkup wrapper pairs used when tidying preview context windows.
|
|
503
|
+
private static readonly _PREVIEW_WRAPPER_PAIRS: [string, string][] = [
|
|
504
|
+
["{--", "--}"],
|
|
505
|
+
["{++", "++}"],
|
|
506
|
+
["{==", "==}"],
|
|
507
|
+
["{>>", "<<}"],
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Makes a fixed-width slice of the raw-view projection presentable: drops
|
|
512
|
+
* complete {>>...<<} meta blocks (annotations of pre-existing changes, not
|
|
513
|
+
* part of this edit) and any wrapper fragments the window boundary chopped
|
|
514
|
+
* in half. Without this, previews leak internal scaffolding like
|
|
515
|
+
* "[Chg:5 delete]" (QA H1). Mirrors the Python engine.
|
|
516
|
+
*/
|
|
517
|
+
private static _tidy_preview_context(
|
|
518
|
+
snippet: string,
|
|
519
|
+
side: "before" | "after",
|
|
520
|
+
): string {
|
|
521
|
+
snippet = snippet.replace(/\{>>[\s\S]*?<<\}/g, "");
|
|
522
|
+
|
|
523
|
+
for (const [open_tok, close_tok] of RedlineEngine._PREVIEW_WRAPPER_PAIRS) {
|
|
524
|
+
if (side === "before") {
|
|
525
|
+
// Cut through the last closer whose opener lies left of the window.
|
|
526
|
+
let depth = 0;
|
|
527
|
+
let cut = 0;
|
|
528
|
+
let i = 0;
|
|
529
|
+
while (i < snippet.length) {
|
|
530
|
+
if (snippet.startsWith(open_tok, i)) {
|
|
531
|
+
depth += 1;
|
|
532
|
+
i += open_tok.length;
|
|
533
|
+
} else if (snippet.startsWith(close_tok, i)) {
|
|
534
|
+
if (depth === 0) cut = i + close_tok.length;
|
|
535
|
+
else depth -= 1;
|
|
536
|
+
i += close_tok.length;
|
|
537
|
+
} else {
|
|
538
|
+
i += 1;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
snippet = snippet.substring(cut);
|
|
542
|
+
} else {
|
|
543
|
+
// Cut from the first opener whose closer lies right of the window.
|
|
544
|
+
const opens: number[] = [];
|
|
545
|
+
let i = 0;
|
|
546
|
+
while (i < snippet.length) {
|
|
547
|
+
if (snippet.startsWith(open_tok, i)) {
|
|
548
|
+
opens.push(i);
|
|
549
|
+
i += open_tok.length;
|
|
550
|
+
} else if (snippet.startsWith(close_tok, i)) {
|
|
551
|
+
if (opens.length > 0) opens.pop();
|
|
552
|
+
i += close_tok.length;
|
|
553
|
+
} else {
|
|
554
|
+
i += 1;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (opens.length > 0) snippet = snippet.substring(0, opens[0]);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 1-2 char remnants of a 3-char wrapper token chopped by the window edge.
|
|
562
|
+
if (side === "before") {
|
|
563
|
+
snippet = snippet.replace(/^[-+=<>]{0,2}\}/, "");
|
|
564
|
+
} else {
|
|
565
|
+
snippet = snippet.replace(/\{[-+=<>]{0,2}$/, "");
|
|
566
|
+
}
|
|
567
|
+
return snippet;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Snapshots the document text around a resolved edit BEFORE anything is
|
|
572
|
+
* applied. Previews rendered after the batch mutates the DOM cannot slice
|
|
573
|
+
* full_text at the stored offsets: applied edits shift offsets and inject
|
|
574
|
+
* tracked-change markup, which produced garbled previews mixing unrelated
|
|
575
|
+
* edits and internal scaffolding (QA H1).
|
|
576
|
+
*/
|
|
577
|
+
private _capture_preview_context(edit: any): void {
|
|
578
|
+
if (edit.type !== "modify") return;
|
|
579
|
+
const start_idx = edit._resolved_start_idx;
|
|
580
|
+
if (start_idx === undefined || start_idx === null) return;
|
|
581
|
+
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
582
|
+
const full_text = active_mapper.full_text;
|
|
583
|
+
if (!full_text) return;
|
|
584
|
+
const length = (edit.target_text || "").length;
|
|
585
|
+
const before = full_text.substring(
|
|
586
|
+
Math.max(0, start_idx - PREVIEW_CONTEXT_CHARS),
|
|
587
|
+
start_idx,
|
|
588
|
+
);
|
|
589
|
+
const after = full_text.substring(
|
|
590
|
+
start_idx + length,
|
|
591
|
+
start_idx + length + PREVIEW_CONTEXT_CHARS,
|
|
592
|
+
);
|
|
593
|
+
edit._preview_context = [
|
|
594
|
+
RedlineEngine._tidy_preview_context(before, "before"),
|
|
595
|
+
RedlineEngine._tidy_preview_context(after, "after"),
|
|
596
|
+
];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Like _capture_preview_context, but snapshots the context around the
|
|
601
|
+
* ORIGINAL edit's full matched span (stashed by _pre_resolve_heuristic_edit),
|
|
602
|
+
* so the report preview can present the complete logical change of a
|
|
603
|
+
* compound modification instead of its first sub-edit.
|
|
604
|
+
*/
|
|
605
|
+
private _capture_parent_preview_context(parent: any): void {
|
|
606
|
+
if (!parent || parent.type !== "modify") return;
|
|
607
|
+
if (parent._preview_context || !parent._preview_span) return;
|
|
608
|
+
const [start_idx, match_len] = parent._preview_span;
|
|
609
|
+
const active_mapper = parent._preview_mapper_ref || this.mapper;
|
|
610
|
+
const full_text = active_mapper.full_text;
|
|
611
|
+
if (!full_text) return;
|
|
612
|
+
const before = full_text.substring(
|
|
613
|
+
Math.max(0, start_idx - PREVIEW_CONTEXT_CHARS),
|
|
614
|
+
start_idx,
|
|
615
|
+
);
|
|
616
|
+
const after = full_text.substring(
|
|
617
|
+
start_idx + match_len,
|
|
618
|
+
start_idx + match_len + PREVIEW_CONTEXT_CHARS,
|
|
619
|
+
);
|
|
620
|
+
parent._preview_context = [
|
|
621
|
+
RedlineEngine._tidy_preview_context(before, "before"),
|
|
622
|
+
RedlineEngine._tidy_preview_context(after, "after"),
|
|
623
|
+
];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Renders the preview from the edit's full matched span. The common
|
|
628
|
+
* prefix/suffix between matched and replacement text is moved into the
|
|
629
|
+
* surrounding context so the {--...--}{++...++} block shows the minimal
|
|
630
|
+
* complete change.
|
|
631
|
+
*/
|
|
632
|
+
private _build_full_match_preview(edit: any): [string | null, string | null] {
|
|
633
|
+
let [context_before, context_after] = edit._preview_context as [
|
|
634
|
+
string,
|
|
635
|
+
string,
|
|
636
|
+
];
|
|
637
|
+
let matched: string = edit._preview_matched_text || "";
|
|
638
|
+
let new_text: string =
|
|
639
|
+
edit._preview_new_text !== undefined && edit._preview_new_text !== null
|
|
640
|
+
? edit._preview_new_text
|
|
641
|
+
: edit.new_text || "";
|
|
642
|
+
|
|
643
|
+
// Heading markdown prefixes are projection artifacts, not literal
|
|
644
|
+
// document text — keep them out of the {--...--}/{++...++} body.
|
|
645
|
+
const [matched_clean, matched_style] = this._parse_markdown_style(matched);
|
|
646
|
+
const [new_clean, new_style] = this._parse_markdown_style(new_text);
|
|
647
|
+
if (matched_style && matched_style.startsWith("Heading")) {
|
|
648
|
+
context_before = context_before + matched.substring(0, matched.length - matched_clean.length);
|
|
649
|
+
matched = matched_clean;
|
|
650
|
+
}
|
|
651
|
+
if (new_style && new_style.startsWith("Heading")) {
|
|
652
|
+
new_text = new_clean;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const [prefix_len, suffix_len] = trim_common_context(matched, new_text);
|
|
656
|
+
let display_target = matched.substring(
|
|
657
|
+
prefix_len,
|
|
658
|
+
matched.length - suffix_len,
|
|
659
|
+
);
|
|
660
|
+
let display_new = new_text.substring(
|
|
661
|
+
prefix_len,
|
|
662
|
+
new_text.length - suffix_len,
|
|
663
|
+
);
|
|
664
|
+
context_before = context_before + matched.substring(0, prefix_len);
|
|
665
|
+
if (suffix_len) {
|
|
666
|
+
context_after = matched.substring(matched.length - suffix_len) + context_after;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
display_target = truncate_middle(display_target, PREVIEW_TEXT_CAP);
|
|
670
|
+
display_new = truncate_middle(display_new, PREVIEW_TEXT_CAP);
|
|
671
|
+
let critic_markup: string;
|
|
672
|
+
if (!display_target && !display_new) {
|
|
673
|
+
// Comment-only edit (text unchanged): highlight the anchor instead of
|
|
674
|
+
// rendering an empty change.
|
|
675
|
+
const anchor = truncate_middle(matched, PREVIEW_TEXT_CAP);
|
|
676
|
+
const body = anchor ? `{==${anchor}==}` : "";
|
|
677
|
+
critic_markup = `${context_before.substring(0, context_before.length - matched.length)}${body}${context_after}`;
|
|
678
|
+
} else {
|
|
679
|
+
const deletion = display_target ? `{--${display_target}--}` : "";
|
|
680
|
+
const insertion = display_new ? `{++${display_new}++}` : "";
|
|
681
|
+
critic_markup = `${context_before}${deletion}${insertion}${context_after}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let clean_text = critic_markup;
|
|
685
|
+
clean_text = clean_text.replace(/\{>>[\s\S]*?<<\}/g, "");
|
|
686
|
+
clean_text = clean_text.replace(/\{--[\s\S]*?--\}/g, "");
|
|
687
|
+
clean_text = clean_text.replace(/\{\+\+([\s\S]*?)\+\+\}/g, "$1");
|
|
688
|
+
return [critic_markup, clean_text];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The "new text" a batch report should show for an edit. InsertTableRow has
|
|
693
|
+
* no new_text field — surface its cell contents instead of the misleading
|
|
694
|
+
* empty string the report used to print (QA M4).
|
|
695
|
+
*/
|
|
696
|
+
private static _report_new_text(edit: any): string {
|
|
697
|
+
if (edit && edit.type === "insert_row" && Array.isArray(edit.cells)) {
|
|
698
|
+
return edit.cells.join(" | ");
|
|
699
|
+
}
|
|
700
|
+
return (edit && edit.new_text) || "";
|
|
701
|
+
}
|
|
702
|
+
|
|
476
703
|
private _build_edit_context_previews(
|
|
477
704
|
edit: any,
|
|
478
705
|
): [string | null, string | null] {
|
|
479
706
|
if (edit.type !== "modify") return [null, null];
|
|
707
|
+
if (edit._preview_span && edit._preview_context) {
|
|
708
|
+
return this._build_full_match_preview(edit);
|
|
709
|
+
}
|
|
480
710
|
if (edit._resolved_proxy_edit) {
|
|
481
711
|
edit = edit._resolved_proxy_edit;
|
|
482
712
|
}
|
|
@@ -499,19 +729,38 @@ export class RedlineEngine {
|
|
|
499
729
|
}
|
|
500
730
|
|
|
501
731
|
const length = target_text.length;
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
732
|
+
let context_before: string;
|
|
733
|
+
let context_after: string;
|
|
734
|
+
if (edit._preview_context) {
|
|
735
|
+
[context_before, context_after] = edit._preview_context;
|
|
736
|
+
} else {
|
|
737
|
+
// Fallback for callers that never went through apply_edits. Only safe
|
|
738
|
+
// while the mapper still reflects the pre-apply document.
|
|
739
|
+
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
740
|
+
const full_text = active_mapper.full_text;
|
|
741
|
+
if (!full_text) return [null, null];
|
|
742
|
+
context_before = RedlineEngine._tidy_preview_context(
|
|
743
|
+
full_text.substring(
|
|
744
|
+
Math.max(0, start_idx - PREVIEW_CONTEXT_CHARS),
|
|
745
|
+
start_idx,
|
|
746
|
+
),
|
|
747
|
+
"before",
|
|
748
|
+
);
|
|
749
|
+
context_after = RedlineEngine._tidy_preview_context(
|
|
750
|
+
full_text.substring(
|
|
751
|
+
start_idx + length,
|
|
752
|
+
start_idx + length + PREVIEW_CONTEXT_CHARS,
|
|
753
|
+
),
|
|
754
|
+
"after",
|
|
755
|
+
);
|
|
756
|
+
}
|
|
512
757
|
|
|
513
|
-
|
|
514
|
-
|
|
758
|
+
// Bound the echoed edit values: previews flow into LLM context windows
|
|
759
|
+
// and must not multiply an oversized new_text/target_text (QA C2).
|
|
760
|
+
const display_target = truncate_middle(target_text, PREVIEW_TEXT_CAP);
|
|
761
|
+
const display_new = truncate_middle(new_text, PREVIEW_TEXT_CAP);
|
|
762
|
+
const insertion = display_new ? `{++${display_new}++}` : "";
|
|
763
|
+
const critic_markup = `${context_before}{--${display_target}--}${insertion}${context_after}`;
|
|
515
764
|
|
|
516
765
|
let clean_text = critic_markup;
|
|
517
766
|
clean_text = clean_text.replace(/\{>>.*?<<\}/gs, "");
|
|
@@ -1713,7 +1962,7 @@ export class RedlineEngine {
|
|
|
1713
1962
|
} else {
|
|
1714
1963
|
const hint = this._nearest_match_hint(edit.target_text, is_regex);
|
|
1715
1964
|
errors.push(
|
|
1716
|
-
`- Edit ${i + 1 + index_offset} Failed: Target text not found in document:\n "${edit.target_text}"${hint}`,
|
|
1965
|
+
`- Edit ${i + 1 + index_offset} Failed: Target text not found in document:\n "${truncate_middle(edit.target_text, REPORT_ECHO_CAP)}"${hint}`,
|
|
1717
1966
|
);
|
|
1718
1967
|
}
|
|
1719
1968
|
} else if (matches.length > 1 && match_mode === "strict") {
|
|
@@ -1862,10 +2111,65 @@ export class RedlineEngine {
|
|
|
1862
2111
|
);
|
|
1863
2112
|
}
|
|
1864
2113
|
}
|
|
2114
|
+
|
|
2115
|
+
// Structural table edits: verify the anchor really is a table row, and
|
|
2116
|
+
// that insert_row does not provide more cells than the row has columns —
|
|
2117
|
+
// extra cells used to produce a structurally inconsistent row (QA M3).
|
|
2118
|
+
if (
|
|
2119
|
+
(edit.type === "insert_row" || edit.type === "delete_row") &&
|
|
2120
|
+
matches.length > 0
|
|
2121
|
+
) {
|
|
2122
|
+
const [start, length] = matches[0];
|
|
2123
|
+
const n_cols = RedlineEngine._column_count_at(
|
|
2124
|
+
target_mapper,
|
|
2125
|
+
start,
|
|
2126
|
+
length,
|
|
2127
|
+
);
|
|
2128
|
+
if (n_cols === null) {
|
|
2129
|
+
errors.push(
|
|
2130
|
+
`- 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.`,
|
|
2131
|
+
);
|
|
2132
|
+
} else if (
|
|
2133
|
+
edit.type === "insert_row" &&
|
|
2134
|
+
Array.isArray(edit.cells) &&
|
|
2135
|
+
edit.cells.length > n_cols
|
|
2136
|
+
) {
|
|
2137
|
+
errors.push(
|
|
2138
|
+
`- 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.`,
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
1865
2142
|
}
|
|
1866
2143
|
return errors;
|
|
1867
2144
|
}
|
|
1868
2145
|
|
|
2146
|
+
/**
|
|
2147
|
+
* Number of columns (w:tc elements) in the table row containing the text at
|
|
2148
|
+
* [start, start+length) in `mapper`, or null if that text is not inside a
|
|
2149
|
+
* table row.
|
|
2150
|
+
*/
|
|
2151
|
+
private static _column_count_at(
|
|
2152
|
+
mapper: DocumentMapper,
|
|
2153
|
+
start: number,
|
|
2154
|
+
length: number,
|
|
2155
|
+
): number | null {
|
|
2156
|
+
for (const s of mapper.spans) {
|
|
2157
|
+
if (s.run === null || s.end <= start || s.start >= start + length) {
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
let curr: Node | null = s.run._element;
|
|
2161
|
+
while (curr) {
|
|
2162
|
+
if (curr.nodeType === 1 && (curr as Element).tagName === "w:tr") {
|
|
2163
|
+
return findAllDescendants(curr as Element, "w:tc").filter(
|
|
2164
|
+
(tc) => tc.parentNode === curr,
|
|
2165
|
+
).length;
|
|
2166
|
+
}
|
|
2167
|
+
curr = curr.parentNode;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
return null;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1869
2173
|
public validate_review_actions(actions: any[]): string[] {
|
|
1870
2174
|
const errors: string[] = [];
|
|
1871
2175
|
for (let i = 0; i < actions.length; i++) {
|
|
@@ -2082,9 +2386,18 @@ export class RedlineEngine {
|
|
|
2082
2386
|
|
|
2083
2387
|
if (dry_run_mode) {
|
|
2084
2388
|
const reports_by_input: any[] = new Array(edits.length);
|
|
2389
|
+
// Indexes that failed VALIDATION (not runtime skips): if any exist,
|
|
2390
|
+
// the real run rejects the whole batch, so the dry-run report must
|
|
2391
|
+
// not claim any edit "applied" (transactional parity with Python).
|
|
2392
|
+
const validation_failed_idx = new Set<number>();
|
|
2085
2393
|
for (const { edit, i } of ordered_edits) {
|
|
2086
|
-
|
|
2394
|
+
let single_errors = this.validate_edits([edit], i);
|
|
2087
2395
|
if (single_errors.length > 0) {
|
|
2396
|
+
if (applied_edits > 0) {
|
|
2397
|
+
const hint = sequential_context_hint(applied_edits);
|
|
2398
|
+
single_errors = single_errors.map((err) => err + hint);
|
|
2399
|
+
}
|
|
2400
|
+
validation_failed_idx.add(i);
|
|
2088
2401
|
skipped_edits++;
|
|
2089
2402
|
// Only surface the punctuation-anchor warning when the edit actually
|
|
2090
2403
|
// failed. A clean apply already returns the redline preview, so the
|
|
@@ -2096,10 +2409,10 @@ export class RedlineEngine {
|
|
|
2096
2409
|
);
|
|
2097
2410
|
reports_by_input[i] = {
|
|
2098
2411
|
status: "failed",
|
|
2099
|
-
target_text: (edit as any).target_text || "",
|
|
2100
|
-
new_text: (edit
|
|
2412
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2413
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2101
2414
|
warning: warning,
|
|
2102
|
-
error: single_errors
|
|
2415
|
+
error: single_errors.join("\n"),
|
|
2103
2416
|
critic_markup: null,
|
|
2104
2417
|
clean_text: null,
|
|
2105
2418
|
};
|
|
@@ -2111,8 +2424,8 @@ export class RedlineEngine {
|
|
|
2111
2424
|
const previews = this._build_edit_context_previews(edit);
|
|
2112
2425
|
reports_by_input[i] = {
|
|
2113
2426
|
status: "applied",
|
|
2114
|
-
target_text: (edit as any).target_text || "",
|
|
2115
|
-
new_text: (edit
|
|
2427
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2428
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2116
2429
|
warning: null,
|
|
2117
2430
|
error: null,
|
|
2118
2431
|
critic_markup: previews[0],
|
|
@@ -2135,8 +2448,8 @@ export class RedlineEngine {
|
|
|
2135
2448
|
);
|
|
2136
2449
|
reports_by_input[i] = {
|
|
2137
2450
|
status: "failed",
|
|
2138
|
-
target_text: (edit as any).target_text || "",
|
|
2139
|
-
new_text: (edit
|
|
2451
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2452
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2140
2453
|
warning: warning,
|
|
2141
2454
|
error: error_msg,
|
|
2142
2455
|
critic_markup: null,
|
|
@@ -2144,6 +2457,29 @@ export class RedlineEngine {
|
|
|
2144
2457
|
};
|
|
2145
2458
|
}
|
|
2146
2459
|
}
|
|
2460
|
+
if (validation_failed_idx.size > 0) {
|
|
2461
|
+
// Dry-run mirrors the real run's transactional rejection: no edit
|
|
2462
|
+
// will be applied by the real run, so none may be reported as
|
|
2463
|
+
// applied here. Edits that only failed at runtime keep their own
|
|
2464
|
+
// error; edits that would have applied get the transactional note.
|
|
2465
|
+
applied_edits = 0;
|
|
2466
|
+
skipped_edits = edits.length;
|
|
2467
|
+
for (let i = 0; i < reports_by_input.length; i++) {
|
|
2468
|
+
const report = reports_by_input[i];
|
|
2469
|
+
if (!report || validation_failed_idx.has(i)) continue;
|
|
2470
|
+
if (report.status === "applied") {
|
|
2471
|
+
reports_by_input[i] = {
|
|
2472
|
+
status: "failed",
|
|
2473
|
+
target_text: report.target_text,
|
|
2474
|
+
new_text: report.new_text,
|
|
2475
|
+
warning: null,
|
|
2476
|
+
error: TRANSACTIONAL_NOT_APPLIED_ERROR,
|
|
2477
|
+
critic_markup: null,
|
|
2478
|
+
clean_text: null,
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2147
2483
|
edits_reports.push(...reports_by_input);
|
|
2148
2484
|
} else {
|
|
2149
2485
|
// Simulated dry-run sequentially for wet-run validation parity
|
|
@@ -2151,12 +2487,20 @@ export class RedlineEngine {
|
|
|
2151
2487
|
const originalCurrentId = this.current_id;
|
|
2152
2488
|
try {
|
|
2153
2489
|
const sequential_errors: string[] = [];
|
|
2490
|
+
let applied_so_far = 0;
|
|
2154
2491
|
for (const { edit, i } of ordered_edits) {
|
|
2155
|
-
|
|
2492
|
+
let single_errors = this.validate_edits([edit], i);
|
|
2156
2493
|
if (single_errors.length > 0) {
|
|
2494
|
+
if (applied_so_far > 0) {
|
|
2495
|
+
const hint = sequential_context_hint(applied_so_far);
|
|
2496
|
+
single_errors = single_errors.map((err) => err + hint);
|
|
2497
|
+
}
|
|
2157
2498
|
sequential_errors.push(...single_errors);
|
|
2158
2499
|
} else {
|
|
2159
2500
|
this.apply_edits([edit], page_offsets);
|
|
2501
|
+
if ((edit as any)._applied_status) {
|
|
2502
|
+
applied_so_far++;
|
|
2503
|
+
}
|
|
2160
2504
|
this.mapper = new DocumentMapper(this.doc);
|
|
2161
2505
|
this.clean_mapper = null;
|
|
2162
2506
|
}
|
|
@@ -2195,8 +2539,8 @@ export class RedlineEngine {
|
|
|
2195
2539
|
}
|
|
2196
2540
|
edits_reports.push({
|
|
2197
2541
|
status: success ? "applied" : "failed",
|
|
2198
|
-
target_text: (edit as any).target_text || "",
|
|
2199
|
-
new_text: (edit
|
|
2542
|
+
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2543
|
+
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2200
2544
|
warning: warning,
|
|
2201
2545
|
error: error_msg,
|
|
2202
2546
|
critic_markup: critic_markup,
|
|
@@ -2320,6 +2664,18 @@ export class RedlineEngine {
|
|
|
2320
2664
|
(a, b) =>
|
|
2321
2665
|
(b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
|
|
2322
2666
|
);
|
|
2667
|
+
|
|
2668
|
+
// Snapshot preview context now, while every resolved offset still refers
|
|
2669
|
+
// to the untouched document. The sweep below mutates the DOM and rebuilds
|
|
2670
|
+
// the map, which shifts offsets and injects tracked-change markup —
|
|
2671
|
+
// slicing full_text at report time garbled previews (QA H1).
|
|
2672
|
+
for (const [res_edit] of resolved_edits) {
|
|
2673
|
+
this._capture_preview_context(res_edit);
|
|
2674
|
+
if (res_edit._parent_edit_ref) {
|
|
2675
|
+
this._capture_parent_preview_context(res_edit._parent_edit_ref);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2323
2679
|
const occupied_ranges: [number, number][] = [];
|
|
2324
2680
|
// Sub-edits split from one balanced multi-paragraph modification share a
|
|
2325
2681
|
// _split_group_id; count the group as a single applied edit (and a single
|
|
@@ -2552,7 +2908,22 @@ export class RedlineEngine {
|
|
|
2552
2908
|
const trPr = tr.ownerDocument!.createElement("w:trPr");
|
|
2553
2909
|
new_tr.appendChild(trPr);
|
|
2554
2910
|
trPr.appendChild(this._create_track_change_tag("w:ins"));
|
|
2555
|
-
|
|
2911
|
+
// The new row must carry exactly as many cells as the anchor row has
|
|
2912
|
+
// columns: pad missing cells with empty strings and drop extras
|
|
2913
|
+
// (validation already rejects overfilled batches upfront, QA M3) so a
|
|
2914
|
+
// mismatched `cells` list can never produce a structurally
|
|
2915
|
+
// inconsistent table row.
|
|
2916
|
+
const anchor_cols = findAllDescendants(tr, "w:tc").filter(
|
|
2917
|
+
(tc) => tc.parentNode === tr,
|
|
2918
|
+
).length;
|
|
2919
|
+
let cell_values: string[] = Array.isArray(edit.cells)
|
|
2920
|
+
? [...edit.cells]
|
|
2921
|
+
: [];
|
|
2922
|
+
if (anchor_cols > 0) {
|
|
2923
|
+
while (cell_values.length < anchor_cols) cell_values.push("");
|
|
2924
|
+
cell_values = cell_values.slice(0, anchor_cols);
|
|
2925
|
+
}
|
|
2926
|
+
for (const cellText of cell_values) {
|
|
2556
2927
|
const tc = tr.ownerDocument!.createElement("w:tc");
|
|
2557
2928
|
const p = tr.ownerDocument!.createElement("w:p");
|
|
2558
2929
|
const r = tr.ownerDocument!.createElement("w:r");
|
|
@@ -2671,6 +3042,17 @@ export class RedlineEngine {
|
|
|
2671
3042
|
} catch (e) {}
|
|
2672
3043
|
}
|
|
2673
3044
|
|
|
3045
|
+
// Stash the first occurrence's full match for the report preview, so it
|
|
3046
|
+
// can show the complete logical change rather than only the first
|
|
3047
|
+
// word-diff sub-edit (e.g. "{--two--}{++five++} (2) years" for a
|
|
3048
|
+
// "two (2) years" -> "five (5) years" edit). Mirrors Python (QA H1).
|
|
3049
|
+
if (!edit._preview_span) {
|
|
3050
|
+
edit._preview_span = [start_idx, match_len];
|
|
3051
|
+
edit._preview_matched_text = actual_doc_text;
|
|
3052
|
+
edit._preview_new_text = current_effective_new_text;
|
|
3053
|
+
edit._preview_mapper_ref = active_mapper;
|
|
3054
|
+
}
|
|
3055
|
+
|
|
2674
3056
|
const [edit_target_clean, edit_target_style] = this._parse_markdown_style(
|
|
2675
3057
|
edit.target_text,
|
|
2676
3058
|
);
|
|
@@ -3245,8 +3627,17 @@ export class RedlineEngine {
|
|
|
3245
3627
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
3246
3628
|
if (is_inline_first) {
|
|
3247
3629
|
if (anchor_run) {
|
|
3248
|
-
|
|
3249
|
-
|
|
3630
|
+
let anchor_el: Element = anchor_run._element;
|
|
3631
|
+
let anchor_parent = anchor_el.parentNode as Element | null;
|
|
3632
|
+
// A tracked-deleted anchor (run inside <w:del>) cannot host the
|
|
3633
|
+
// new <w:ins> as a child — an insertion nested inside a deletion
|
|
3634
|
+
// is invalid revision XML. Lift the anchor to the <w:del> wrapper
|
|
3635
|
+
// so the insert lands beside the whole block (mirrors the Python
|
|
3636
|
+
// engine).
|
|
3637
|
+
if (anchor_parent && anchor_parent.tagName === "w:del") {
|
|
3638
|
+
anchor_el = anchor_parent;
|
|
3639
|
+
anchor_parent = anchor_el.parentNode as Element | null;
|
|
3640
|
+
}
|
|
3250
3641
|
// get_insertion_anchor(0) resolves to the document's FIRST run: the
|
|
3251
3642
|
// insertion point precedes it, so the new <w:ins> must land before
|
|
3252
3643
|
// the anchor, not after (mirrors the Python engine's insert_before
|
|
@@ -3259,14 +3650,14 @@ export class RedlineEngine {
|
|
|
3259
3650
|
// Python engine).
|
|
3260
3651
|
this._insert_and_split_ins(
|
|
3261
3652
|
anchor_parent,
|
|
3262
|
-
|
|
3653
|
+
anchor_el,
|
|
3263
3654
|
result.first_node,
|
|
3264
3655
|
before_anchor,
|
|
3265
3656
|
);
|
|
3266
3657
|
} else if (before_anchor && anchor_parent) {
|
|
3267
|
-
anchor_parent.insertBefore(result.first_node,
|
|
3658
|
+
anchor_parent.insertBefore(result.first_node, anchor_el);
|
|
3268
3659
|
} else {
|
|
3269
|
-
insertAfter(result.first_node,
|
|
3660
|
+
insertAfter(result.first_node, anchor_el);
|
|
3270
3661
|
}
|
|
3271
3662
|
} else if (anchor_para) {
|
|
3272
3663
|
// Paragraph-anchored insertion: the anchor resolves to a paragraph
|