@adeu/core 1.27.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/dist/index.cjs +563 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +563 -65
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +236 -3
- package/src/engine.atomic.test.ts +5 -1
- package/src/engine.batch.test.ts +16 -6
- package/src/engine.ts +323 -52
- package/src/ingest.test.ts +3 -1
- package/src/ingest.ts +16 -3
- package/src/mapper.ts +74 -9
- package/src/repro_qa_mcp_issues_2026_07_20.test.ts +224 -0
- package/src/repro_qa_report_v9.test.ts +392 -0
- package/src/sanitize/core.ts +11 -0
- package/src/sanitize/report.ts +9 -7
- package/src/sanitize/transforms.ts +39 -0
- package/src/utils/docx.ts +87 -0
package/package.json
CHANGED
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/engine.batch.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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);
|