@adeu/core 1.27.0 → 1.29.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.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/diff.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { trim_common_context, generate_edits_from_text, create_word_patch_diff } from './diff.js';
2
+ import { trim_common_context, generate_edits_from_text, create_word_patch_diff, generate_edits_via_paragraph_alignment } from './diff.js';
3
3
 
4
4
  describe('Diff Logic & Context Trimming', () => {
5
5
  it('handles basic prefix and suffix', () => {
@@ -71,4 +71,11 @@ describe('Diff Logic & Context Trimming', () => {
71
71
  expect(diff).toContain("+ Corporation");
72
72
  expect(diff).toContain(" This agreement is made between the"); // Within 40-char context window so no truncation
73
73
  });
74
+
75
+ it('handles trailing newline and whitespace safely in paragraph alignment', () => {
76
+ const original = "This is a single paragraph of the draft.";
77
+ const modified = "This is a single paragraph of the draft.\n";
78
+ const edits = generate_edits_via_paragraph_alignment(original, modified);
79
+ expect(edits.length).toBe(0);
80
+ });
74
81
  });
package/src/diff.ts CHANGED
@@ -436,6 +436,241 @@ 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
+ // Normalize trailing whitespace/newlines in modified_text to match original_text
548
+ const orig_stripped = original_text.trimEnd();
549
+ const orig_ws = original_text.slice(orig_stripped.length);
550
+ const mod_stripped = modified_text.trimEnd();
551
+ modified_text = mod_stripped + orig_ws;
552
+
553
+ const orig_paragraphs = original_text.split("\n\n");
554
+ const mod_paragraphs = modified_text.split("\n\n");
555
+
556
+ const orig_offsets: number[] = [];
557
+ let current_offset = 0;
558
+ for (const p of orig_paragraphs) {
559
+ orig_offsets.push(current_offset);
560
+ current_offset += p.length + 2;
561
+ }
562
+
563
+ const opcodes = _sequence_opcodes(orig_paragraphs, mod_paragraphs);
564
+ const edits: ModifyText[] = [];
565
+
566
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
567
+ if (tag === "equal") continue;
568
+
569
+ const offset =
570
+ i1 < orig_offsets.length ? orig_offsets[i1] : original_text.length;
571
+
572
+ if (tag === "delete") {
573
+ // Multi-paragraph mid-document blocks are emitted as ONE deletion PER
574
+ // paragraph ("A\n\n", "B\n\n"), never "A\n\nB\n\n": the engine's merge
575
+ // protocol supports one deleted paragraph break per edit
576
+ // (QA 2026-07-19 ADEU-QA-002 A). The document's trailing block takes
577
+ // the LEADING separator instead (QA 2026-07-19 v8 F-12 fallout).
578
+ if (i2 < orig_paragraphs.length) {
579
+ let piece_offset = offset;
580
+ for (let k = i1; k < i2; k++) {
581
+ const piece = orig_paragraphs[k] + "\n\n";
582
+ edits.push({
583
+ type: "modify",
584
+ target_text: piece,
585
+ new_text: "",
586
+ comment: "Diff: Text deleted",
587
+ _match_start_index: piece_offset,
588
+ });
589
+ piece_offset += piece.length;
590
+ }
591
+ } else {
592
+ let deleted_text = orig_paragraphs.slice(i1, i2).join("\n\n");
593
+ let del_offset = offset;
594
+ if (i1 > 0) {
595
+ deleted_text = "\n\n" + deleted_text;
596
+ del_offset -= 2;
597
+ }
598
+ edits.push({
599
+ type: "modify",
600
+ target_text: deleted_text,
601
+ new_text: "",
602
+ comment: "Diff: Text deleted",
603
+ _match_start_index: del_offset,
604
+ });
605
+ }
606
+ } else if (tag === "insert") {
607
+ // An inserted paragraph must CARRY its paragraph separator, or the
608
+ // engine (rightly) treats the text as an inline insertion and glues it
609
+ // to the neighboring paragraph (QA 2026-07-18 v6 H2).
610
+ let inserted_text = mod_paragraphs.slice(j1, j2).join("\n\n");
611
+ if (i1 < orig_paragraphs.length) {
612
+ inserted_text = inserted_text + "\n\n";
613
+ } else {
614
+ inserted_text = "\n\n" + inserted_text;
615
+ }
616
+ edits.push({
617
+ type: "modify",
618
+ target_text: "",
619
+ new_text: inserted_text,
620
+ comment: "Diff: Text inserted",
621
+ _match_start_index: offset,
622
+ });
623
+ } else if (tag === "replace") {
624
+ // Table blobs in equal-count replace blocks pair up positionally and
625
+ // diff as ROW-LEVEL edits; word-level hunks over a table blob start or
626
+ // end inside " | " separators and land in the wrong cell. Prose pairs
627
+ // (and any block whose counts differ) keep the word-level chunk diff.
628
+ if (
629
+ i2 - i1 === j2 - j1 &&
630
+ Array.from({ length: i2 - i1 }).some(
631
+ (_, k) =>
632
+ _is_table_blob(orig_paragraphs[i1 + k]) ||
633
+ _is_table_blob(mod_paragraphs[j1 + k]),
634
+ )
635
+ ) {
636
+ for (let k = 0; k < i2 - i1; k++) {
637
+ const orig_p = orig_paragraphs[i1 + k];
638
+ const mod_p = mod_paragraphs[j1 + k];
639
+ if (orig_p === mod_p) continue;
640
+ const pair_offset = orig_offsets[i1 + k];
641
+ const row_edits =
642
+ _is_table_blob(orig_p) && _is_table_blob(mod_p)
643
+ ? _table_blob_row_edits(orig_p, mod_p)
644
+ : null;
645
+ if (row_edits !== null) {
646
+ edits.push(...row_edits);
647
+ continue;
648
+ }
649
+ const pair_edits = generate_edits_from_text(orig_p, mod_p);
650
+ for (const ce of pair_edits) {
651
+ ce._match_start_index = (ce._match_start_index || 0) + pair_offset;
652
+ edits.push(ce);
653
+ }
654
+ }
655
+ continue;
656
+ }
657
+
658
+ const orig_chunk = orig_paragraphs.slice(i1, i2).join("\n\n");
659
+ const mod_chunk = mod_paragraphs.slice(j1, j2).join("\n\n");
660
+ const chunk_edits = generate_edits_from_text(orig_chunk, mod_chunk);
661
+ for (const ce of chunk_edits) {
662
+ ce._match_start_index = (ce._match_start_index || 0) + offset;
663
+ edits.push(ce);
664
+ }
665
+ }
666
+ }
667
+
668
+ // Word-level diffs over unequal replace chunks can still emit hunks that
669
+ // straddle a paragraph break with body text on both sides; split them into
670
+ // engine-applicable pieces (ADEU-QA-002 A).
671
+ return _split_cross_paragraph_hunks(edits);
672
+ }
673
+
439
674
  const _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
440
675
 
441
676
  /**
@@ -756,7 +991,7 @@ export function generate_structured_edits(
756
991
  );
757
992
  const flat = _drop_marker_interior_hunks(
758
993
  _drop_image_marker_hunks(
759
- generate_edits_from_text(text_orig, text_mod),
994
+ generate_edits_via_paragraph_alignment(text_orig, text_mod),
760
995
  warnings,
761
996
  ),
762
997
  text_orig,
@@ -796,7 +1031,7 @@ export function generate_structured_edits(
796
1031
  }
797
1032
 
798
1033
  if (!tables_alignable) {
799
- const part_edits = generate_edits_from_text(
1034
+ const part_edits = generate_edits_via_paragraph_alignment(
800
1035
  text_orig.substring(po_start, po_end),
801
1036
  text_mod.substring(pm_start, pm_end),
802
1037
  );
@@ -824,7 +1059,11 @@ export function generate_structured_edits(
824
1059
  const seg_o_end = boundaries_o[seg_idx + 1][0];
825
1060
  const seg_m_start = boundaries_m[seg_idx][1];
826
1061
  const seg_m_end = boundaries_m[seg_idx + 1][0];
827
- const seg_edits = generate_edits_from_text(
1062
+ // Paragraph alignment, never a raw word-level diff over the whole
1063
+ // segment: dmp legally shifts hunk boundaries across paragraphs
1064
+ // sharing a prefix, and the engine rightly rejects a deletion with
1065
+ // body text on both sides of a paragraph break (ADEU-QA-002 A).
1066
+ const seg_edits = generate_edits_via_paragraph_alignment(
828
1067
  text_orig.substring(seg_o_start, seg_o_end),
829
1068
  text_mod.substring(seg_m_start, seg_m_end),
830
1069
  );
@@ -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);
@@ -568,4 +568,53 @@ describe("Resolved Bugs Core Engine Verification", () => {
568
568
  expect(caught.name).toBe("BatchValidationError");
569
569
  expect(caught.message).toContain("Invalid change format");
570
570
  });
571
+
572
+ it("BUG-REPRO: accept_all_revisions returns counts of accepted changes and removed comments", async () => {
573
+ const doc = await createTestDocument();
574
+ addParagraph(doc, "This is a test document.");
575
+ const engine = new RedlineEngine(doc);
576
+
577
+ engine.process_batch([
578
+ {
579
+ type: "modify",
580
+ target_text: "document",
581
+ new_text: "dossier",
582
+ comment: "Review note to be stripped",
583
+ },
584
+ ]);
585
+
586
+ const stats = engine.accept_all_revisions() as any;
587
+
588
+ expect(stats).toBeDefined();
589
+ expect(stats.accepted_insertions).toBe(1);
590
+ expect(stats.accepted_deletions).toBe(1);
591
+ expect(stats.accepted_formatting).toBe(0);
592
+ // Counted from comment bodies this call actually deleted, not from the
593
+ // document's comment total — the two coincide here, but see below.
594
+ expect(stats.removed_comments).toBe(1);
595
+ });
596
+
597
+ it("BUG-REPRO: removed_comments does not count comments it deliberately keeps", async () => {
598
+ const doc = await createTestDocument();
599
+ addParagraph(doc, "This is a test document.");
600
+ const engine = new RedlineEngine(doc);
601
+
602
+ engine.process_batch([
603
+ {
604
+ type: "modify",
605
+ target_text: "document",
606
+ new_text: "dossier",
607
+ comment: "Review note",
608
+ },
609
+ ]);
610
+
611
+ // A different reviewer now opens the redlined document. The existing
612
+ // comment is foreign to them, so it keeps its BODY by design (only the
613
+ // anchor is detached) and must not be reported as removed.
614
+ const other = new RedlineEngine(doc);
615
+ other.author = "Someone Else";
616
+ const stats = other.accept_all_revisions() as any;
617
+
618
+ expect(stats.removed_comments).toBe(0);
619
+ });
571
620
  });
@@ -265,4 +265,35 @@ describe("Table Interop & Engine (Node.js Port)", () => {
265
265
  expect(errors[0]).toContain('Did you mean the literal "( x )"');
266
266
  expect(errors[0]).toContain("drop the ^/$ anchors");
267
267
  });
268
+
269
+ it("compiled report includes type for structural table operations", async () => {
270
+ const doc = await createTestDocument();
271
+ const tbl = addTable(doc, 2, 2);
272
+ setCellText(tbl, 0, 0, "Row1 Col1");
273
+ setCellText(tbl, 0, 1, "Row1 Col2");
274
+ setCellText(tbl, 1, 0, "Row2 Col1");
275
+ setCellText(tbl, 1, 1, "Row2 Col2");
276
+
277
+ const buf = await doc.save();
278
+ const midDoc = await DocumentObject.load(buf);
279
+ const engine = new RedlineEngine(midDoc);
280
+
281
+ const stats = engine.process_batch([
282
+ {
283
+ type: "insert_row",
284
+ target_text: "Row1 Col1",
285
+ position: "below",
286
+ cells: ["NewRow Col1", "NewRow Col2"],
287
+ } as InsertTableRow,
288
+ {
289
+ type: "delete_row",
290
+ target_text: "Row2 Col1",
291
+ } as DeleteTableRow,
292
+ ]);
293
+
294
+ expect(stats.edits_applied).toBe(2);
295
+ expect(stats.edits).toHaveLength(2);
296
+ expect(stats.edits[0].type).toBe("insert_row");
297
+ expect(stats.edits[1].type).toBe("delete_row");
298
+ });
268
299
  });