@adeu/core 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/diff.ts CHANGED
@@ -436,6 +436,235 @@ function _sequence_opcodes(a: string[], b: string[]): Opcode[] {
436
436
  return ops;
437
437
  }
438
438
 
439
+ /**
440
+ * True when a "\n\n"-separated block reads as projected table rows: every
441
+ * line carries the " | " cell separator. Table rows are separated by single
442
+ * newlines, so a whole table is one block in the paragraph alignment.
443
+ */
444
+ function _is_table_blob(block: string): boolean {
445
+ const lines = block.split("\n");
446
+ return lines.length > 0 && lines.every((line) => line.includes(" | "));
447
+ }
448
+
449
+ /**
450
+ * Pairwise row-level edits between two aligned table blobs, or null when the
451
+ * blobs cannot be row-aligned (different row counts — a structural change the
452
+ * text path hands to the engine's row guards). Row edits are unpinned: the
453
+ * full row text is the anchor, and the engine's per-cell splitter resolves
454
+ * them — word-level hunks across " | " separators land in the wrong cell.
455
+ */
456
+ function _table_blob_row_edits(
457
+ orig_blob: string,
458
+ mod_blob: string,
459
+ ): ModifyText[] | null {
460
+ const rows_o = orig_blob.split("\n");
461
+ const rows_m = mod_blob.split("\n");
462
+ if (rows_o.length !== rows_m.length) return null;
463
+ const row_edits: ModifyText[] = [];
464
+ for (let k = 0; k < rows_o.length; k++) {
465
+ if (rows_o[k] === rows_m[k]) continue;
466
+ row_edits.push({
467
+ type: "modify",
468
+ target_text: rows_o[k],
469
+ new_text: rows_m[k],
470
+ comment: "Diff: Table row modified",
471
+ _is_table_edit: true,
472
+ });
473
+ }
474
+ return row_edits;
475
+ }
476
+
477
+ /**
478
+ * Splits generated hunks the engine would reject: an UNBALANCED target
479
+ * spanning a paragraph break with body text on both sides ("tail of A\n\n
480
+ * head of B" — the shape dmp produces when adjacent paragraphs share a
481
+ * prefix). Each leading paragraph piece becomes its own separator-carrying
482
+ * deletion (an allowed merge shape) and the final piece carries the whole
483
+ * replacement text; sequential application produces the identical merged
484
+ * result. Without this, one such hunk poisons the entire batch — apply is
485
+ * all-or-nothing (QA 2026-07-19 ADEU-QA-002 A).
486
+ */
487
+ function _split_cross_paragraph_hunks(edits: ModifyText[]): ModifyText[] {
488
+ const out: ModifyText[] = [];
489
+ for (const e of edits) {
490
+ const target = e.target_text || "";
491
+ const newText = e.new_text || "";
492
+ const idx = e._match_start_index;
493
+ if (
494
+ idx === undefined ||
495
+ idx === null ||
496
+ !target.includes("\n\n") ||
497
+ target.split("\n\n").length - 1 === newText.split("\n\n").length - 1
498
+ ) {
499
+ out.push(e);
500
+ continue;
501
+ }
502
+ const parts = target.split("\n\n");
503
+ if (parts.length < 2 || !parts[0].trim() || !parts[parts.length - 1].trim()) {
504
+ // One-sided shapes (separator-carrying deletions/insertions) are the
505
+ // engine's supported merge protocol — leave them intact.
506
+ out.push(e);
507
+ continue;
508
+ }
509
+ let offset = idx;
510
+ for (const piece of parts.slice(0, -1)) {
511
+ out.push({
512
+ type: "modify",
513
+ target_text: piece + "\n\n",
514
+ new_text: "",
515
+ comment: e.comment || "Diff: Text deleted",
516
+ _match_start_index: offset,
517
+ });
518
+ offset += piece.length + 2;
519
+ }
520
+ out.push({
521
+ type: "modify",
522
+ target_text: parts[parts.length - 1],
523
+ new_text: newText,
524
+ comment: e.comment,
525
+ _match_start_index: offset,
526
+ });
527
+ }
528
+ return out;
529
+ }
530
+
531
+ /**
532
+ * Aligns original and modified text by paragraph (using the difflib-style
533
+ * opcodes), then performs precise word-level diffing on replaced blocks.
534
+ * Whole-paragraph structural changes come out as engine-applicable shapes:
535
+ * a deleted paragraph is ONE deletion per paragraph carrying its "\n\n"
536
+ * separator, an inserted paragraph carries its separator — a raw word-level
537
+ * diff instead legally shifts hunk boundaries across paragraphs sharing a
538
+ * prefix ("...arrears.\n\nThe "), which the engine rightly rejects; every
539
+ * paragraph deletion/reordering in the QA corpus failed replay that way
540
+ * (QA 2026-07-19 ADEU-QA-002 A). Mirrors the Python engine's
541
+ * generate_edits_via_paragraph_alignment.
542
+ */
543
+ export function generate_edits_via_paragraph_alignment(
544
+ original_text: string,
545
+ modified_text: string,
546
+ ): ModifyText[] {
547
+ const orig_paragraphs = original_text.split("\n\n");
548
+ const mod_paragraphs = modified_text.split("\n\n");
549
+
550
+ const orig_offsets: number[] = [];
551
+ let current_offset = 0;
552
+ for (const p of orig_paragraphs) {
553
+ orig_offsets.push(current_offset);
554
+ current_offset += p.length + 2;
555
+ }
556
+
557
+ const opcodes = _sequence_opcodes(orig_paragraphs, mod_paragraphs);
558
+ const edits: ModifyText[] = [];
559
+
560
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
561
+ if (tag === "equal") continue;
562
+
563
+ const offset =
564
+ i1 < orig_offsets.length ? orig_offsets[i1] : original_text.length;
565
+
566
+ if (tag === "delete") {
567
+ // Multi-paragraph mid-document blocks are emitted as ONE deletion PER
568
+ // paragraph ("A\n\n", "B\n\n"), never "A\n\nB\n\n": the engine's merge
569
+ // protocol supports one deleted paragraph break per edit
570
+ // (QA 2026-07-19 ADEU-QA-002 A). The document's trailing block takes
571
+ // the LEADING separator instead (QA 2026-07-19 v8 F-12 fallout).
572
+ if (i2 < orig_paragraphs.length) {
573
+ let piece_offset = offset;
574
+ for (let k = i1; k < i2; k++) {
575
+ const piece = orig_paragraphs[k] + "\n\n";
576
+ edits.push({
577
+ type: "modify",
578
+ target_text: piece,
579
+ new_text: "",
580
+ comment: "Diff: Text deleted",
581
+ _match_start_index: piece_offset,
582
+ });
583
+ piece_offset += piece.length;
584
+ }
585
+ } else {
586
+ let deleted_text = orig_paragraphs.slice(i1, i2).join("\n\n");
587
+ let del_offset = offset;
588
+ if (i1 > 0) {
589
+ deleted_text = "\n\n" + deleted_text;
590
+ del_offset -= 2;
591
+ }
592
+ edits.push({
593
+ type: "modify",
594
+ target_text: deleted_text,
595
+ new_text: "",
596
+ comment: "Diff: Text deleted",
597
+ _match_start_index: del_offset,
598
+ });
599
+ }
600
+ } else if (tag === "insert") {
601
+ // An inserted paragraph must CARRY its paragraph separator, or the
602
+ // engine (rightly) treats the text as an inline insertion and glues it
603
+ // to the neighboring paragraph (QA 2026-07-18 v6 H2).
604
+ let inserted_text = mod_paragraphs.slice(j1, j2).join("\n\n");
605
+ if (i1 < orig_paragraphs.length) {
606
+ inserted_text = inserted_text + "\n\n";
607
+ } else {
608
+ inserted_text = "\n\n" + inserted_text;
609
+ }
610
+ edits.push({
611
+ type: "modify",
612
+ target_text: "",
613
+ new_text: inserted_text,
614
+ comment: "Diff: Text inserted",
615
+ _match_start_index: offset,
616
+ });
617
+ } else if (tag === "replace") {
618
+ // Table blobs in equal-count replace blocks pair up positionally and
619
+ // diff as ROW-LEVEL edits; word-level hunks over a table blob start or
620
+ // end inside " | " separators and land in the wrong cell. Prose pairs
621
+ // (and any block whose counts differ) keep the word-level chunk diff.
622
+ if (
623
+ i2 - i1 === j2 - j1 &&
624
+ Array.from({ length: i2 - i1 }).some(
625
+ (_, k) =>
626
+ _is_table_blob(orig_paragraphs[i1 + k]) ||
627
+ _is_table_blob(mod_paragraphs[j1 + k]),
628
+ )
629
+ ) {
630
+ for (let k = 0; k < i2 - i1; k++) {
631
+ const orig_p = orig_paragraphs[i1 + k];
632
+ const mod_p = mod_paragraphs[j1 + k];
633
+ if (orig_p === mod_p) continue;
634
+ const pair_offset = orig_offsets[i1 + k];
635
+ const row_edits =
636
+ _is_table_blob(orig_p) && _is_table_blob(mod_p)
637
+ ? _table_blob_row_edits(orig_p, mod_p)
638
+ : null;
639
+ if (row_edits !== null) {
640
+ edits.push(...row_edits);
641
+ continue;
642
+ }
643
+ const pair_edits = generate_edits_from_text(orig_p, mod_p);
644
+ for (const ce of pair_edits) {
645
+ ce._match_start_index = (ce._match_start_index || 0) + pair_offset;
646
+ edits.push(ce);
647
+ }
648
+ }
649
+ continue;
650
+ }
651
+
652
+ const orig_chunk = orig_paragraphs.slice(i1, i2).join("\n\n");
653
+ const mod_chunk = mod_paragraphs.slice(j1, j2).join("\n\n");
654
+ const chunk_edits = generate_edits_from_text(orig_chunk, mod_chunk);
655
+ for (const ce of chunk_edits) {
656
+ ce._match_start_index = (ce._match_start_index || 0) + offset;
657
+ edits.push(ce);
658
+ }
659
+ }
660
+ }
661
+
662
+ // Word-level diffs over unequal replace chunks can still emit hunks that
663
+ // straddle a paragraph break with body text on both sides; split them into
664
+ // engine-applicable pieces (ADEU-QA-002 A).
665
+ return _split_cross_paragraph_hunks(edits);
666
+ }
667
+
439
668
  const _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
440
669
 
441
670
  /**
@@ -756,7 +985,7 @@ export function generate_structured_edits(
756
985
  );
757
986
  const flat = _drop_marker_interior_hunks(
758
987
  _drop_image_marker_hunks(
759
- generate_edits_from_text(text_orig, text_mod),
988
+ generate_edits_via_paragraph_alignment(text_orig, text_mod),
760
989
  warnings,
761
990
  ),
762
991
  text_orig,
@@ -796,7 +1025,7 @@ export function generate_structured_edits(
796
1025
  }
797
1026
 
798
1027
  if (!tables_alignable) {
799
- const part_edits = generate_edits_from_text(
1028
+ const part_edits = generate_edits_via_paragraph_alignment(
800
1029
  text_orig.substring(po_start, po_end),
801
1030
  text_mod.substring(pm_start, pm_end),
802
1031
  );
@@ -824,7 +1053,11 @@ export function generate_structured_edits(
824
1053
  const seg_o_end = boundaries_o[seg_idx + 1][0];
825
1054
  const seg_m_start = boundaries_m[seg_idx][1];
826
1055
  const seg_m_end = boundaries_m[seg_idx + 1][0];
827
- const seg_edits = generate_edits_from_text(
1056
+ // Paragraph alignment, never a raw word-level diff over the whole
1057
+ // segment: dmp legally shifts hunk boundaries across paragraphs
1058
+ // sharing a prefix, and the engine rightly rejects a deletion with
1059
+ // body text on both sides of a paragraph break (ADEU-QA-002 A).
1060
+ const seg_edits = generate_edits_via_paragraph_alignment(
828
1061
  text_orig.substring(seg_o_start, seg_o_end),
829
1062
  text_mod.substring(seg_m_start, seg_m_end),
830
1063
  );
@@ -40,7 +40,11 @@ describe('Atomic Batch Pipeline (Node.js Port)', () => {
40
40
  const stats = engine2.process_batch(changes);
41
41
 
42
42
  // 4. Assertions on the Tool Execution
43
- expect(stats.actions_applied).toBe(actions.length);
43
+ // The two ids form ONE replacement pair: the first accept resolves both,
44
+ // the second is an accurate no-op (QA 2026-07-19 ADEU-QA-004) — never a
45
+ // second "applied" state transition.
46
+ expect(stats.actions_applied).toBe(1);
47
+ expect(stats.actions_already_resolved).toBe(actions.length - 1);
44
48
  expect(stats.edits_applied).toBe(1);
45
49
 
46
50
  // 5. Assertions on the Final Document State
@@ -32,10 +32,15 @@ describe('Batch Reliability (Node.js Port)', () => {
32
32
 
33
33
  const actions: AcceptChange[] = [1, 2, 3, 4, 5, 6].map(id => ({ type: 'accept', target_id: `Chg:${id}` }));
34
34
 
35
- // Test direct apply_review_actions (Note: method missing in raw TS port right now, needs implementing)
36
- const [applied, skipped] = (engine2 as any).apply_review_actions(actions);
37
-
38
- expect(applied).toBe(6);
35
+ // Three replacements: each del+ins pair resolves as one unit on the
36
+ // first accept, so 3 actions transition state and 3 are accurate no-ops
37
+ // (QA 2026-07-19 ADEU-QA-004).
38
+ const [applied, skipped, already_resolved] = (
39
+ engine2 as any
40
+ ).apply_review_actions(actions);
41
+
42
+ expect(applied).toBe(3);
43
+ expect(already_resolved).toBe(3);
39
44
  expect(skipped).toBe(0);
40
45
 
41
46
  const finalBuf = await midDoc.save();
@@ -78,8 +83,13 @@ describe('Batch Reliability (Node.js Port)', () => {
78
83
  { type: 'reject', target_id: "Chg:2" } as RejectChange,
79
84
  ];
80
85
 
81
- const [applied] = (engine2 as any).apply_review_actions(actions);
82
- expect(applied).toBe(4);
86
+ // Two replacement pairs, each resolved by its first action; the paired
87
+ // follow-ups are accurate no-ops (QA 2026-07-19 ADEU-QA-004).
88
+ const [applied, , already_resolved] = (
89
+ engine2 as any
90
+ ).apply_review_actions(actions);
91
+ expect(applied).toBe(2);
92
+ expect(already_resolved).toBe(2);
83
93
 
84
94
  const finalBuf = await midDoc.save();
85
95
  const text_final = await extractTextFromBuffer(finalBuf);