@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/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
- 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
- );
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
- const insertion = new_text ? `{++${new_text}++}` : "";
514
- const critic_markup = `${context_before}{--${target_text}--}${insertion}${context_after}`;
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
- const single_errors = this.validate_edits([edit], i);
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 as any).new_text || "",
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[0],
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 as any).new_text || "",
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 as any).new_text || "",
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
- const single_errors = this.validate_edits([edit], i);
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 as any).new_text || "",
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
- for (const cellText of edit.cells) {
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
- const anchor_parent = anchor_run._element
3249
- .parentNode as Element | null;
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
- anchor_run._element,
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, anchor_run._element);
3658
+ anchor_parent.insertBefore(result.first_node, anchor_el);
3268
3659
  } else {
3269
- insertAfter(result.first_node, anchor_run._element);
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