@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/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
- const active_mapper = edit._active_mapper_ref || this.mapper;
503
- const full_text = active_mapper.full_text;
504
- if (!full_text) return [null, null];
505
-
506
- const before_start = Math.max(0, start_idx - 30);
507
- const context_before = full_text.substring(before_start, start_idx);
508
- const context_after = full_text.substring(
509
- start_idx + length,
510
- start_idx + length + 30,
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
- const insertion = new_text ? `{++${new_text}++}` : "";
514
- const critic_markup = `${context_before}{--${target_text}--}${insertion}${context_after}`;
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
- // NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
1997
- // foreign author's <w:ins> when a strict/first edit only partially straddled
1998
- // its boundary turning that author's tracked-inserted text into untracked
1999
- // committed body text before the edit applied, destroying their provenance.
2000
- // That is the same provenance-laundering failure mode the canonical engine
2001
- // refuses, so it has been removed: a partial straddle now surfaces the
2002
- // standard validation error ("Modification targets an active insertion from
2003
- // another author …") via validate_edits, matching the Python engine. An edit
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
- const single_errors = this.validate_edits([edit], i);
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 as any).new_text || "",
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[0],
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 as any).new_text || "",
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 as any).new_text || "",
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
- const single_errors = this.validate_edits([edit], i);
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 as any).new_text || "",
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
- const resolved = this._pre_resolve_heuristic_edit(edit);
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
- for (const cellText of edit.cells) {
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
  );