@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/dist/index.cjs +433 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +433 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.bugs.test.ts +1 -1
- package/src/engine.qa_v4_port.test.ts +304 -0
- package/src/engine.sequential.test.ts +140 -0
- package/src/engine.ts +421 -30
- package/src/mapper.ts +133 -20
- package/src/markup.test.ts +1 -1
- package/src/markup.ts +11 -5
- package/src/repro.insertion-anchor.test.ts +91 -0
- package/src/repro_qa_report_v3.test.ts +9 -3
- package/src/utils/text.ts +26 -0
package/src/mapper.ts
CHANGED
|
@@ -105,6 +105,12 @@ export function renumber_snapshot_ids(
|
|
|
105
105
|
return [chg_remap, com_remap];
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Markdown style delimiters the projection emits as VIRTUAL spans around
|
|
109
|
+
// formatted runs (see get_run_style_markers). Literal asterisks/underscores
|
|
110
|
+
// typed in the document live inside real (run-backed) spans and are never
|
|
111
|
+
// confused with these.
|
|
112
|
+
const STYLE_MARKER_TEXTS = new Set(["**", "__", "*", "_"]);
|
|
113
|
+
|
|
108
114
|
export class DocumentMapper {
|
|
109
115
|
public doc: DocumentObject;
|
|
110
116
|
public clean_view: boolean;
|
|
@@ -114,6 +120,7 @@ export class DocumentMapper {
|
|
|
114
120
|
public spans: TextSpan[] = [];
|
|
115
121
|
public appendix_start_index: number = -1;
|
|
116
122
|
private _text_chunks: string[] = [];
|
|
123
|
+
private _plain_projection: [string, number[]] | null = null;
|
|
117
124
|
|
|
118
125
|
constructor(
|
|
119
126
|
doc: DocumentObject,
|
|
@@ -132,6 +139,7 @@ export class DocumentMapper {
|
|
|
132
139
|
this.spans = [];
|
|
133
140
|
this._text_chunks = [];
|
|
134
141
|
this.full_text = "";
|
|
142
|
+
this._plain_projection = null;
|
|
135
143
|
|
|
136
144
|
for (const part of iter_document_parts(this.doc)) {
|
|
137
145
|
current_offset = this._map_blocks(part, current_offset);
|
|
@@ -800,6 +808,72 @@ export class DocumentMapper {
|
|
|
800
808
|
return parts.join("");
|
|
801
809
|
}
|
|
802
810
|
|
|
811
|
+
/**
|
|
812
|
+
* Returns [plain_text, offset_map] where plain_text is full_text with the
|
|
813
|
+
* VIRTUAL markdown style delimiters (bold/italic markers emitted around
|
|
814
|
+
* formatted runs) removed, and offset_map[i] is the full_text index of
|
|
815
|
+
* plain_text[i].
|
|
816
|
+
*
|
|
817
|
+
* Formatting run boundaries can fall mid-word (e.g. a paragraph projected
|
|
818
|
+
* as "**Al**pha"), where neither exact matching nor the whitespace-anchored
|
|
819
|
+
* fuzzy regex can find the plain target "Alpha". Matching against this
|
|
820
|
+
* projection and mapping the span back to full_text closes that gap (QA H2).
|
|
821
|
+
*
|
|
822
|
+
* Built lazily and invalidated by _build_map(): most batches never need it.
|
|
823
|
+
*/
|
|
824
|
+
private _get_plain_projection(): [string, number[]] {
|
|
825
|
+
if (this._plain_projection === null) {
|
|
826
|
+
const chunks: string[] = [];
|
|
827
|
+
const offsets: number[] = [];
|
|
828
|
+
for (const s of this.spans) {
|
|
829
|
+
if (
|
|
830
|
+
s.run === null &&
|
|
831
|
+
s.paragraph !== null &&
|
|
832
|
+
STYLE_MARKER_TEXTS.has(s.text)
|
|
833
|
+
) {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
chunks.push(s.text);
|
|
837
|
+
for (let k = s.start; k < s.end; k++) offsets.push(k);
|
|
838
|
+
}
|
|
839
|
+
this._plain_projection = [chunks.join(""), offsets];
|
|
840
|
+
}
|
|
841
|
+
return this._plain_projection;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Matches a markdown-stripped target against the plain projection and maps
|
|
846
|
+
* each hit back to a [start, length] span in full_text. Interior style
|
|
847
|
+
* markers end up inside the returned span (so "Alpha" over "**Al**pha"
|
|
848
|
+
* resolves to the "Al**pha" range); markers just outside the matched
|
|
849
|
+
* characters are excluded.
|
|
850
|
+
*/
|
|
851
|
+
private _find_plain_projection_matches(
|
|
852
|
+
target_text: string,
|
|
853
|
+
): [number, number][] {
|
|
854
|
+
const [plain_text, offsets] = this._get_plain_projection();
|
|
855
|
+
if (plain_text.length === this.full_text.length) {
|
|
856
|
+
return []; // No virtual style markers anywhere; nothing new to find.
|
|
857
|
+
}
|
|
858
|
+
const norm_target = this._replace_smart_quotes(
|
|
859
|
+
this._strip_markdown_formatting(target_text),
|
|
860
|
+
);
|
|
861
|
+
if (!norm_target) return [];
|
|
862
|
+
const norm_plain = this._replace_smart_quotes(plain_text);
|
|
863
|
+
const results: [number, number][] = [];
|
|
864
|
+
let from = 0;
|
|
865
|
+
while (true) {
|
|
866
|
+
const p_start = norm_plain.indexOf(norm_target, from);
|
|
867
|
+
if (p_start === -1) break;
|
|
868
|
+
const p_end = p_start + norm_target.length;
|
|
869
|
+
const raw_start = offsets[p_start];
|
|
870
|
+
const raw_end = offsets[p_end - 1] + 1;
|
|
871
|
+
results.push([raw_start, raw_end - raw_start]);
|
|
872
|
+
from = p_end;
|
|
873
|
+
}
|
|
874
|
+
return results;
|
|
875
|
+
}
|
|
876
|
+
|
|
803
877
|
public find_match_index(
|
|
804
878
|
target_text: string,
|
|
805
879
|
is_regex: boolean = false,
|
|
@@ -827,6 +901,12 @@ export class DocumentMapper {
|
|
|
827
901
|
return [start_idx, stripped_target.length];
|
|
828
902
|
}
|
|
829
903
|
|
|
904
|
+
// 3.5 Plain-projection match: the target crosses a formatting run
|
|
905
|
+
// boundary (possibly mid-word), so the projection carries style markers
|
|
906
|
+
// the plain target doesn't have (QA H2).
|
|
907
|
+
const plain_first = this._find_plain_projection_matches(target_text);
|
|
908
|
+
if (plain_first.length > 0) return plain_first[0];
|
|
909
|
+
|
|
830
910
|
try {
|
|
831
911
|
const pattern = new RegExp(this._make_fuzzy_regex(target_text));
|
|
832
912
|
const match = pattern.exec(this.full_text);
|
|
@@ -851,34 +931,47 @@ export class DocumentMapper {
|
|
|
851
931
|
} catch (e) {}
|
|
852
932
|
return [];
|
|
853
933
|
}
|
|
854
|
-
|
|
855
|
-
|
|
934
|
+
// Exact tiers use plain indexOf scans, NOT RegExp: building a RegExp from
|
|
935
|
+
// an arbitrarily long escaped target throws "regular expression too
|
|
936
|
+
// large" for oversized inputs, crashing validation instead of returning
|
|
937
|
+
// a clean not-found (QA C2 hardening).
|
|
938
|
+
const findAllLiteral = (
|
|
939
|
+
haystack: string,
|
|
940
|
+
needle: string,
|
|
941
|
+
): [number, number][] => {
|
|
942
|
+
const out: [number, number][] = [];
|
|
943
|
+
if (!needle) return out;
|
|
944
|
+
let from = 0;
|
|
945
|
+
while (true) {
|
|
946
|
+
const idx = haystack.indexOf(needle, from);
|
|
947
|
+
if (idx === -1) break;
|
|
948
|
+
out.push([idx, needle.length]);
|
|
949
|
+
from = idx + needle.length;
|
|
950
|
+
}
|
|
951
|
+
return out;
|
|
952
|
+
};
|
|
856
953
|
|
|
857
|
-
let matches =
|
|
858
|
-
|
|
859
|
-
];
|
|
860
|
-
if (matches.length > 0) return matches.map((m) => [m.index!, m[0].length]);
|
|
954
|
+
let matches = findAllLiteral(this.full_text, target_text);
|
|
955
|
+
if (matches.length > 0) return matches;
|
|
861
956
|
|
|
862
957
|
const norm_full = this._replace_smart_quotes(this.full_text);
|
|
863
958
|
const norm_target = this._replace_smart_quotes(target_text);
|
|
864
|
-
matches =
|
|
865
|
-
|
|
866
|
-
];
|
|
867
|
-
if (matches.length > 0) return matches.map((m) => [m.index!, m[0].length]);
|
|
959
|
+
matches = findAllLiteral(norm_full, norm_target);
|
|
960
|
+
if (matches.length > 0) return matches;
|
|
868
961
|
|
|
869
962
|
const stripped_target = this._strip_markdown_formatting(target_text);
|
|
870
|
-
matches =
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
963
|
+
matches = findAllLiteral(this.full_text, stripped_target);
|
|
964
|
+
if (matches.length > 0) return matches;
|
|
965
|
+
|
|
966
|
+
// 3.5 Plain-projection match (target spans a bold/italic run boundary,
|
|
967
|
+
// possibly mid-word). See _find_plain_projection_matches (QA H2).
|
|
968
|
+
const plain_matches = this._find_plain_projection_matches(target_text);
|
|
969
|
+
if (plain_matches.length > 0) return plain_matches;
|
|
876
970
|
|
|
877
971
|
try {
|
|
878
972
|
const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
|
|
879
|
-
|
|
880
|
-
if (
|
|
881
|
-
return matches.map((m) => [m.index!, m[0].length]);
|
|
973
|
+
const fuzzy = [...this.full_text.matchAll(pattern)];
|
|
974
|
+
if (fuzzy.length > 0) return fuzzy.map((m) => [m.index!, m[0].length]);
|
|
882
975
|
} catch (e) {}
|
|
883
976
|
|
|
884
977
|
return [];
|
|
@@ -989,7 +1082,27 @@ export class DocumentMapper {
|
|
|
989
1082
|
if (preceding[i].run) return [preceding[i].run, preceding[i].paragraph];
|
|
990
1083
|
}
|
|
991
1084
|
for (let i = preceding.length - 1; i >= 0; i--) {
|
|
992
|
-
|
|
1085
|
+
const para = preceding[i].paragraph;
|
|
1086
|
+
if (para) {
|
|
1087
|
+
// Every span ending exactly here is virtual (CriticMarkup
|
|
1088
|
+
// wrappers, {>>...<<} meta blocks, prefixes). If real text
|
|
1089
|
+
// precedes this index in the SAME paragraph, anchor after its
|
|
1090
|
+
// last run: falling back to the bare paragraph would drop the
|
|
1091
|
+
// insertion at paragraph start, ahead of the very redlines and
|
|
1092
|
+
// comment ranges that fence off the true position (mirrors the
|
|
1093
|
+
// Python mapper).
|
|
1094
|
+
for (let j = this.spans.length - 1; j >= 0; j--) {
|
|
1095
|
+
const prev = this.spans[j];
|
|
1096
|
+
if (
|
|
1097
|
+
prev.end <= index &&
|
|
1098
|
+
prev.run !== null &&
|
|
1099
|
+
prev.paragraph === para
|
|
1100
|
+
) {
|
|
1101
|
+
return [prev.run, prev.paragraph];
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return [null, para];
|
|
1105
|
+
}
|
|
993
1106
|
}
|
|
994
1107
|
}
|
|
995
1108
|
|
package/src/markup.test.ts
CHANGED
|
@@ -174,7 +174,7 @@ describe('format_ambiguity_error (Turn Loop Trap mitigation)', () => {
|
|
|
174
174
|
expect(msg).toContain('FIRST occurrence');
|
|
175
175
|
|
|
176
176
|
// The original "add more context" guidance is preserved as a third option.
|
|
177
|
-
expect(msg).toContain('
|
|
177
|
+
expect(msg).toContain('Provide more surrounding context');
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it('still throws for fewer than two matches', () => {
|
package/src/markup.ts
CHANGED
|
@@ -424,17 +424,23 @@ export function format_ambiguity_error(
|
|
|
424
424
|
|
|
425
425
|
// Tell the agent EXACTLY how to re-call. Without this, agents loop forever
|
|
426
426
|
// refining target_text/regex because they never learn that match_mode is the
|
|
427
|
-
// built-in escape hatch for genuine ambiguity.
|
|
427
|
+
// built-in escape hatch for genuine ambiguity. The safe strategy (more
|
|
428
|
+
// context) comes first: blindly switching to "first"/"all" has silently
|
|
429
|
+
// modified unrelated occurrences — dates, section numbers — in real use
|
|
430
|
+
// (QA C1), so those options carry an explicit verification warning.
|
|
431
|
+
// Wording mirrors the Python engine's format_ambiguity_error.
|
|
428
432
|
lines.push(" To resolve, re-send this edit using ONE of these strategies:");
|
|
429
433
|
lines.push(
|
|
430
|
-
|
|
434
|
+
" 1. RECOMMENDED: Provide more surrounding context in your target_text to uniquely " +
|
|
435
|
+
'identify a single location (keep the default "match_mode": "strict").',
|
|
431
436
|
);
|
|
432
437
|
lines.push(
|
|
433
|
-
|
|
438
|
+
` 2. Set "match_mode": "all" to modify ALL ${total} occurrences — only after verifying ` +
|
|
439
|
+
"from the occurrence list above that EVERY occurrence should change.",
|
|
434
440
|
);
|
|
435
441
|
lines.push(
|
|
436
|
-
' 3.
|
|
437
|
-
|
|
442
|
+
' 3. Set "match_mode": "first" to modify only the FIRST occurrence — only after verifying ' +
|
|
443
|
+
"the first occurrence above is the one you intend to change.",
|
|
438
444
|
);
|
|
439
445
|
|
|
440
446
|
return lines.join("\n");
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
6
|
+
import { RedlineEngine } from "./engine.js";
|
|
7
|
+
import { extractTextFromBuffer } from "./ingest.js";
|
|
8
|
+
import { createTestDocument, addParagraph } from "./test-utils.js";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
|
|
13
|
+
// Reproduction for the insertion-anchor bug (2026-07, golden.docx):
|
|
14
|
+
// ModifyText("document" -> "finalized document") is diff-minimized to a
|
|
15
|
+
// zero-width INSERTION of "finalized " immediately before "document". The
|
|
16
|
+
// target paragraph already carries tracked changes ({--initial --}
|
|
17
|
+
// {++golden ++}) and three comment ranges, so in the raw projection the
|
|
18
|
+
// insertion index sits right after a virtual {>>...<<} meta block.
|
|
19
|
+
// get_insertion_anchor only looked for a run-backed span ENDING exactly at
|
|
20
|
+
// that index; every span ending there is virtual, so it fell back to
|
|
21
|
+
// (null, paragraph) and the engine dropped the new <w:ins> at the START of
|
|
22
|
+
// the paragraph — "finalized This is the golden document" after accept-all.
|
|
23
|
+
// The fix anchors after the nearest preceding REAL run in the same
|
|
24
|
+
// paragraph (mirrors the Python engine/mapper fix).
|
|
25
|
+
describe("Zero-width insertion anchoring with preceding redlines/comments", () => {
|
|
26
|
+
it("lands the insertion before its target, not at paragraph start (golden.docx)", async () => {
|
|
27
|
+
const fixturePath = resolve(
|
|
28
|
+
__dirname,
|
|
29
|
+
"../../../../shared/fixtures/golden.docx",
|
|
30
|
+
);
|
|
31
|
+
const buf = readFileSync(fixturePath);
|
|
32
|
+
const doc = await DocumentObject.load(buf);
|
|
33
|
+
const engine = new RedlineEngine(doc, "T");
|
|
34
|
+
|
|
35
|
+
const stats = engine.process_batch([
|
|
36
|
+
{ type: "modify", target_text: "document", new_text: "finalized document" },
|
|
37
|
+
]);
|
|
38
|
+
expect(stats.edits_applied).toBe(1);
|
|
39
|
+
|
|
40
|
+
const out = await doc.save();
|
|
41
|
+
const clean = await extractTextFromBuffer(out, true);
|
|
42
|
+
expect(clean).toContain("golden finalized document");
|
|
43
|
+
expect(clean.startsWith("finalized This is")).toBe(false);
|
|
44
|
+
|
|
45
|
+
const red = await extractTextFromBuffer(out);
|
|
46
|
+
expect(red.startsWith("{++finalized")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not nest <w:ins> inside <w:del> when the anchor run is tracked-deleted", async () => {
|
|
50
|
+
const doc = await createTestDocument();
|
|
51
|
+
addParagraph(doc, "This is the initial document");
|
|
52
|
+
|
|
53
|
+
// Pass 1: track-delete "initial " so the raw projection becomes
|
|
54
|
+
// "This is the {--initial --}{>>[Chg:1 delete] T<<}document".
|
|
55
|
+
const engine1 = new RedlineEngine(doc, "T");
|
|
56
|
+
engine1.process_batch([
|
|
57
|
+
{ type: "modify", target_text: "initial ", new_text: "" },
|
|
58
|
+
]);
|
|
59
|
+
const deleted = await doc.save();
|
|
60
|
+
|
|
61
|
+
// Pass 2: zero-width insertion right before "document".
|
|
62
|
+
const doc2 = await DocumentObject.load(deleted);
|
|
63
|
+
const engine2 = new RedlineEngine(doc2, "T2");
|
|
64
|
+
engine2.process_batch([
|
|
65
|
+
{ type: "modify", target_text: "document", new_text: "finalized document" },
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const dels = Array.from(doc2.element.getElementsByTagName("w:del"));
|
|
69
|
+
for (const del of dels) {
|
|
70
|
+
expect(del.getElementsByTagName("w:ins").length).toBe(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const out = await doc2.save();
|
|
74
|
+
const clean = await extractTextFromBuffer(out, true);
|
|
75
|
+
expect(clean).toContain("This is the finalized document");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("keeps genuine paragraph-start insertions at the paragraph start", async () => {
|
|
79
|
+
const doc = await createTestDocument();
|
|
80
|
+
addParagraph(doc, "Alpha beta gamma");
|
|
81
|
+
const engine = new RedlineEngine(doc, "T");
|
|
82
|
+
|
|
83
|
+
engine.process_batch([
|
|
84
|
+
{ type: "modify", target_text: "Alpha", new_text: "Intro Alpha" },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
const out = await doc.save();
|
|
88
|
+
const clean = await extractTextFromBuffer(out, true);
|
|
89
|
+
expect(clean).toContain("Intro Alpha beta gamma");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -172,10 +172,16 @@ describe("QA Report V3 Defects Reproductions", () => {
|
|
|
172
172
|
} as any,
|
|
173
173
|
], true);
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
// Dry-run mirrors the real run's transactional semantics (QA 2026-07-17
|
|
176
|
+
// M1 parity): a batch with any invalid edit applies nothing. The valid
|
|
177
|
+
// edit is reported as blocked by the batch, the invalid one keeps its own
|
|
178
|
+
// error.
|
|
179
|
+
expect(res.edits_applied).toBe(0);
|
|
180
|
+
expect(res.edits_skipped).toBe(2);
|
|
181
|
+
expect(res.edits[0].status).toBe("failed");
|
|
182
|
+
expect(res.edits[0].error).toContain("transactional");
|
|
177
183
|
expect(res.edits[1].status).toBe("failed");
|
|
178
|
-
|
|
184
|
+
|
|
179
185
|
// Assert that the error message correctly labels it as Edit 2.
|
|
180
186
|
// The buggy unpatched codebase says "Edit 1 Failed:" inside the error message for Edit 2.
|
|
181
187
|
const errorMsg = res.edits[1].error;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// FILE: src/utils/text.ts
|
|
2
|
+
// Small text helpers shared by engine output paths. Mirrors the Python
|
|
3
|
+
// engine's adeu/utils/text.py so both engines bound their report output
|
|
4
|
+
// identically (QA C2).
|
|
5
|
+
|
|
6
|
+
// Default cap for echoing caller-supplied strings (target_text/new_text) back
|
|
7
|
+
// in batch reports and error messages. Reports feed straight into LLM context
|
|
8
|
+
// windows via MCP, so an oversized edit value must never be reflected in full
|
|
9
|
+
// (QA C2: a 2MB new_text was echoed twice, unbounded, in the apply report).
|
|
10
|
+
export const REPORT_ECHO_CAP = 500;
|
|
11
|
+
|
|
12
|
+
// Tighter cap for the inline redline preview snippets ({--...--}{++...++}),
|
|
13
|
+
// which additionally carry surrounding document context.
|
|
14
|
+
export const PREVIEW_TEXT_CAP = 200;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bounds `text` to roughly `cap` visible characters, keeping the head and
|
|
18
|
+
* tail and stating how much was omitted. Returns short strings unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export function truncate_middle(text: string, cap: number): string {
|
|
21
|
+
if (text === null || text === undefined || text.length <= cap) return text;
|
|
22
|
+
const head = Math.max(1, Math.floor((cap * 2) / 3));
|
|
23
|
+
const tail = Math.max(1, cap - head);
|
|
24
|
+
const omitted = text.length - head - tail;
|
|
25
|
+
return `${text.slice(0, head)}… [${omitted.toLocaleString("en-US")} chars omitted] …${text.slice(-tail)}`;
|
|
26
|
+
}
|