@adeu/core 1.17.1 → 1.18.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 +514 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -1
- package/dist/index.d.ts +49 -1
- package/dist/index.js +514 -106
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.bugs.test.ts +12 -14
- package/src/engine.issue23.test.ts +23 -18
- package/src/engine.qa.test.ts +4 -4
- package/src/engine.tables.test.ts +138 -36
- package/src/engine.ts +700 -134
- package/src/ingest.ts +16 -1
- package/src/mapper.ts +23 -1
- package/src/markup.ts +1 -1
- package/src/reject_and_nested_redline.test.ts +137 -0
- package/src/repro.feedback.test.ts +160 -0
- package/src/repro.report.test.ts +132 -0
- package/src/repro_qa_report_v3.test.ts +186 -0
- package/src/repro_report_boundary_failures.test.ts +108 -0
package/src/ingest.ts
CHANGED
|
@@ -184,13 +184,28 @@ export function extract_table(
|
|
|
184
184
|
|
|
185
185
|
if (!first_cell) cell_cursor += 3;
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
let cell_content = _extract_blocks(
|
|
188
188
|
cell,
|
|
189
189
|
comments_map,
|
|
190
190
|
cleanView,
|
|
191
191
|
cell_cursor,
|
|
192
192
|
paragraph_offsets,
|
|
193
193
|
);
|
|
194
|
+
// Emit a stable, document-native anchor for this cell so empty/short
|
|
195
|
+
// value cells are addressable by the engine. Reuses the {#...} bookmark
|
|
196
|
+
// projection (already protected by validate_edit_strings and resolvable
|
|
197
|
+
// via the mapper). We key on the cell's first paragraph w14:paraId, which
|
|
198
|
+
// Word assigns and keeps stable across reads.
|
|
199
|
+
if (!cleanView) {
|
|
200
|
+
const firstP = cell._element.getElementsByTagName("w:p")[0] as
|
|
201
|
+
| Element
|
|
202
|
+
| undefined;
|
|
203
|
+
const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
|
|
204
|
+
if (paraId) {
|
|
205
|
+
const anchor = `{#cell:${paraId}}`;
|
|
206
|
+
cell_content = cell_content + anchor;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
194
209
|
cell_texts.push(cell_content);
|
|
195
210
|
cell_cursor += cell_content.length;
|
|
196
211
|
first_cell = false;
|
package/src/mapper.ts
CHANGED
|
@@ -260,7 +260,29 @@ export class DocumentMapper {
|
|
|
260
260
|
current += 3;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
const cell_start = current;
|
|
263
264
|
current = this._map_blocks(cell, current);
|
|
265
|
+
|
|
266
|
+
// Parity with ingest.extract_table: emit a {#cell:<paraId>} anchor and
|
|
267
|
+
// bind a zero-width span to the cell's first paragraph so the engine
|
|
268
|
+
// can resolve "write into this cell" even when the cell is empty
|
|
269
|
+
// (pPr-only paragraph with no run).
|
|
270
|
+
if (!this.clean_view && !this.original_view) {
|
|
271
|
+
const firstP = cell._element.getElementsByTagName("w:p")[0] as
|
|
272
|
+
| Element
|
|
273
|
+
| undefined;
|
|
274
|
+
const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
|
|
275
|
+
if (paraId && firstP) {
|
|
276
|
+
// Zero-width span bound to the empty cell paragraph: gives
|
|
277
|
+
// get_insertion_anchor a paragraph to land on. Placed at the anchor
|
|
278
|
+
// token offset so resolution targets THIS cell, not a neighbour.
|
|
279
|
+
const cellPara = new Paragraph(firstP, cell);
|
|
280
|
+
this._add_virtual_text("", current, cellPara);
|
|
281
|
+
const anchor = `{#cell:${paraId}}`;
|
|
282
|
+
this._add_virtual_text(anchor, current, cellPara);
|
|
283
|
+
current += anchor.length;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
264
286
|
cells_processed += 1;
|
|
265
287
|
}
|
|
266
288
|
|
|
@@ -738,7 +760,7 @@ export class DocumentMapper {
|
|
|
738
760
|
target_text = this._replace_smart_quotes(target_text);
|
|
739
761
|
|
|
740
762
|
const parts: string[] = [];
|
|
741
|
-
const token_pattern = /(\[_+\])|(\s+)|(['"])|([
|
|
763
|
+
const token_pattern = /(\[_+\])|(\s+)|(['"])|([.,;:\/\-\[\](){}+=$?*!|#^<>\\%&@~`_])/g;
|
|
742
764
|
|
|
743
765
|
let last_idx = 0;
|
|
744
766
|
let match;
|
package/src/markup.ts
CHANGED
|
@@ -138,7 +138,7 @@ export function _make_fuzzy_regex(target_text: string): string {
|
|
|
138
138
|
target_text = _replace_smart_quotes(target_text);
|
|
139
139
|
|
|
140
140
|
const parts: string[] = [];
|
|
141
|
-
const token_pattern = /(_+)|(\s+)|(['"])|([
|
|
141
|
+
const token_pattern = /(_+)|(\s+)|(['"])|([.,;:\/\-\[\](){}+=$?*!|#^<>\\%&@~`])/g;
|
|
142
142
|
|
|
143
143
|
// Note: JS does not support atomic groups (?>...).
|
|
144
144
|
// However, because we only match markdown characters * and _,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical track-change semantics for editing inside another author's pending
|
|
3
|
+
* insertion, plus reject_all_revisions round-trips. Mirrors the Python suite
|
|
4
|
+
* python/tests/test_reject_and_nested_redline.py (Python is canonical).
|
|
5
|
+
*
|
|
6
|
+
* When an author edits/deletes text inside another author's still-pending
|
|
7
|
+
* <w:ins>, the deletion is NESTED inside that <w:ins> and the replacement is a
|
|
8
|
+
* SIBLING after it (splitting the insertion if the edit is mid-insertion);
|
|
9
|
+
* <w:ins> is never nested in <w:ins>. This preserves authorship and makes
|
|
10
|
+
* reject-all revert the contingent text to nothing rather than promoting an
|
|
11
|
+
* unaccepted proposal to committed body text.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import { createTestDocument, addParagraph } from "./test-utils.js";
|
|
15
|
+
import { RedlineEngine } from "./engine.js";
|
|
16
|
+
import { extractTextFromBuffer } from "./ingest.js";
|
|
17
|
+
|
|
18
|
+
function foreignInsDoc(doc: any) {
|
|
19
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
20
|
+
const p = addParagraph(doc, "The party shall provide ");
|
|
21
|
+
const ins = xmlDoc.createElement("w:ins");
|
|
22
|
+
ins.setAttribute("w:id", "201");
|
|
23
|
+
ins.setAttribute("w:author", "Supplier's Counsel");
|
|
24
|
+
const r = xmlDoc.createElement("w:r");
|
|
25
|
+
const t = xmlDoc.createElement("w:t");
|
|
26
|
+
t.textContent = "written notice";
|
|
27
|
+
r.appendChild(t);
|
|
28
|
+
ins.appendChild(r);
|
|
29
|
+
p.appendChild(ins);
|
|
30
|
+
const r2 = xmlDoc.createElement("w:r");
|
|
31
|
+
const t2 = xmlDoc.createElement("w:t");
|
|
32
|
+
t2.textContent = " within 30 days.";
|
|
33
|
+
r2.appendChild(t2);
|
|
34
|
+
p.appendChild(r2);
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function cleanText(doc: any): Promise<string> {
|
|
39
|
+
return await extractTextFromBuffer(await doc.save());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("Canonical nesting + reject round-trips", () => {
|
|
43
|
+
it("modify inside a foreign insertion nests del and emits a sibling ins (no ins-in-ins)", async () => {
|
|
44
|
+
const doc = await createTestDocument();
|
|
45
|
+
const p = foreignInsDoc(doc);
|
|
46
|
+
const engine = new RedlineEngine(doc, "Reviewer AI");
|
|
47
|
+
const res = engine.process_batch(
|
|
48
|
+
[{ type: "modify", target_text: "written notice", new_text: "email notification" }] as any,
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
expect(res.edits_applied).toBe(1);
|
|
52
|
+
expect(res.edits_skipped).toBe(0);
|
|
53
|
+
|
|
54
|
+
// del nested inside Supplier's <w:ins>; no <w:ins> nested in <w:ins>.
|
|
55
|
+
const insNodes = Array.from(p.getElementsByTagName("w:ins"));
|
|
56
|
+
const supplierIns = insNodes.find(
|
|
57
|
+
(i) => i.getAttribute("w:author") === "Supplier's Counsel",
|
|
58
|
+
)!;
|
|
59
|
+
expect(supplierIns).toBeTruthy();
|
|
60
|
+
expect(supplierIns.getElementsByTagName("w:del").length).toBeGreaterThan(0);
|
|
61
|
+
for (const i of insNodes) {
|
|
62
|
+
expect(i.getElementsByTagName("w:ins").length).toBe(0);
|
|
63
|
+
}
|
|
64
|
+
// Reviewer's replacement insertion exists as a separate node.
|
|
65
|
+
expect(
|
|
66
|
+
insNodes.some(
|
|
67
|
+
(i) =>
|
|
68
|
+
i.getAttribute("w:author") === "Reviewer AI" &&
|
|
69
|
+
(i.textContent || "").includes("email notification"),
|
|
70
|
+
),
|
|
71
|
+
).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("accept-all and reject-all round-trip correctly for a foreign-ins modify", async () => {
|
|
75
|
+
const accDoc = await createTestDocument();
|
|
76
|
+
foreignInsDoc(accDoc);
|
|
77
|
+
const ae = new RedlineEngine(accDoc, "Reviewer AI");
|
|
78
|
+
ae.process_batch(
|
|
79
|
+
[{ type: "modify", target_text: "written notice", new_text: "email notification" }] as any,
|
|
80
|
+
false,
|
|
81
|
+
);
|
|
82
|
+
ae.accept_all_revisions();
|
|
83
|
+
expect(await cleanText(accDoc)).toContain(
|
|
84
|
+
"The party shall provide email notification within 30 days.",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const rejDoc = await createTestDocument();
|
|
88
|
+
foreignInsDoc(rejDoc);
|
|
89
|
+
const re = new RedlineEngine(rejDoc, "Reviewer AI");
|
|
90
|
+
re.process_batch(
|
|
91
|
+
[{ type: "modify", target_text: "written notice", new_text: "email notification" }] as any,
|
|
92
|
+
false,
|
|
93
|
+
);
|
|
94
|
+
re.reject_all_revisions();
|
|
95
|
+
const rejected = await cleanText(rejDoc);
|
|
96
|
+
// Reject reverts to the true baseline: the pending insertion vanishes.
|
|
97
|
+
expect(rejected).not.toContain("written notice");
|
|
98
|
+
expect(rejected).not.toContain("email notification");
|
|
99
|
+
expect(rejected).toContain("The party shall provide within 30 days.");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("insertion inside a foreign ins splits (no ins-in-ins) and round-trips", async () => {
|
|
103
|
+
const doc = await createTestDocument();
|
|
104
|
+
const p = foreignInsDoc(doc);
|
|
105
|
+
const e = new RedlineEngine(doc, "Reviewer AI");
|
|
106
|
+
// A modify that trims to a pure INSERTION inside the foreign <w:ins>.
|
|
107
|
+
const res = e.process_batch(
|
|
108
|
+
[{ type: "modify", target_text: "written notice", new_text: "written notice please" }] as any,
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
expect(res.edits_applied).toBe(1);
|
|
112
|
+
for (const i of Array.from(p.getElementsByTagName("w:ins"))) {
|
|
113
|
+
expect(i.getElementsByTagName("w:ins").length).toBe(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ad = await createTestDocument();
|
|
117
|
+
foreignInsDoc(ad);
|
|
118
|
+
const ae = new RedlineEngine(ad, "Reviewer AI");
|
|
119
|
+
ae.process_batch(
|
|
120
|
+
[{ type: "modify", target_text: "written notice", new_text: "written notice please" }] as any,
|
|
121
|
+
false,
|
|
122
|
+
);
|
|
123
|
+
ae.accept_all_revisions();
|
|
124
|
+
expect(await cleanText(ad)).toContain(
|
|
125
|
+
"The party shall provide written notice please within 30 days.",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("reject-all restores the original for an own-author edit", async () => {
|
|
130
|
+
const doc = await createTestDocument();
|
|
131
|
+
addParagraph(doc, "The cat sat.");
|
|
132
|
+
const e = new RedlineEngine(doc, "Z");
|
|
133
|
+
e.process_batch([{ type: "modify", target_text: "cat", new_text: "dog" }] as any, false);
|
|
134
|
+
e.reject_all_revisions();
|
|
135
|
+
expect(await cleanText(doc)).toContain("The cat sat.");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// FILE: node/packages/core/src/repro.feedback.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Regression repros for the field observations against the Node engine.
|
|
4
|
+
//
|
|
5
|
+
// STYLE: these assert the DESIRED (correct) behaviour, so a test is RED while
|
|
6
|
+
// the bug is present and turns GREEN once the engine is fixed. (This is the
|
|
7
|
+
// "isolate the bug before fixing" pattern from AI_CONTEXT.md > Testing.)
|
|
8
|
+
//
|
|
9
|
+
// Issue 1 — accept + modify the same span in one batch -> currently RED (bug)
|
|
10
|
+
// Issue 2 — comment-only run-shredding -> NOT a bug; see note
|
|
11
|
+
//
|
|
12
|
+
// (Issue 3 lives in node/packages/mcp-server/src/repro.feedback.test.ts.)
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import { RedlineEngine } from "./engine.js";
|
|
16
|
+
import { createTestDocument, addParagraph } from "./test-utils.js";
|
|
17
|
+
|
|
18
|
+
/** Append a paragraph that already contains a tracked insertion by `author`. */
|
|
19
|
+
function addParagraphWithForeignInsertion(
|
|
20
|
+
doc: any,
|
|
21
|
+
prefix: string,
|
|
22
|
+
insertedText: string,
|
|
23
|
+
insId: string,
|
|
24
|
+
author: string,
|
|
25
|
+
): Element {
|
|
26
|
+
const x = doc.element.ownerDocument!;
|
|
27
|
+
const p = x.createElement("w:p");
|
|
28
|
+
const r0 = x.createElement("w:r");
|
|
29
|
+
const t0 = x.createElement("w:t");
|
|
30
|
+
t0.textContent = prefix;
|
|
31
|
+
t0.setAttribute("xml:space", "preserve");
|
|
32
|
+
r0.appendChild(t0);
|
|
33
|
+
p.appendChild(r0);
|
|
34
|
+
|
|
35
|
+
const ins = x.createElement("w:ins");
|
|
36
|
+
ins.setAttribute("w:id", insId);
|
|
37
|
+
ins.setAttribute("w:author", author);
|
|
38
|
+
ins.setAttribute("w:date", "2024-01-01T00:00:00Z");
|
|
39
|
+
const r = x.createElement("w:r");
|
|
40
|
+
const t = x.createElement("w:t");
|
|
41
|
+
t.textContent = insertedText;
|
|
42
|
+
r.appendChild(t);
|
|
43
|
+
ins.appendChild(r);
|
|
44
|
+
p.appendChild(ins);
|
|
45
|
+
|
|
46
|
+
doc.element.appendChild(p);
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function countTag(el: any, tag: string): number {
|
|
51
|
+
let n = 0;
|
|
52
|
+
const walk = (node: any) => {
|
|
53
|
+
if (node.tagName === tag) n++;
|
|
54
|
+
let c = node.firstChild;
|
|
55
|
+
while (c) {
|
|
56
|
+
if (c.nodeType === 1) walk(c);
|
|
57
|
+
c = c.nextSibling;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(el);
|
|
61
|
+
return n;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("Field feedback repro — Node engine", () => {
|
|
65
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
66
|
+
// ISSUE 1 — RED until fixed.
|
|
67
|
+
//
|
|
68
|
+
// Accepting a foreign author's insertion and then editing that now-accepted
|
|
69
|
+
// text, in a SINGLE batch, MUST succeed: by the time the modify runs the span
|
|
70
|
+
// has been accepted and is no longer foreign. Node rejects it because the
|
|
71
|
+
// unified up-front validation pass (engine.ts `_process_batch_internal`, the
|
|
72
|
+
// `validate_edits(edits)` call BEFORE `apply_review_actions`) validates the
|
|
73
|
+
// modify against the PRE-accept document. This test fails today and will pass
|
|
74
|
+
// once validation runs after the accepts are applied.
|
|
75
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
76
|
+
it("Issue 1: accept + modify of the SAME foreign insertion in one batch should succeed", async () => {
|
|
77
|
+
const doc = await createTestDocument();
|
|
78
|
+
addParagraphWithForeignInsertion(
|
|
79
|
+
doc,
|
|
80
|
+
"The term is ",
|
|
81
|
+
"24 months",
|
|
82
|
+
"5",
|
|
83
|
+
"Supplier's Counsel",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const engine = new RedlineEngine(doc, "Acme's Counsel");
|
|
87
|
+
const batch = [
|
|
88
|
+
{ type: "accept", target_id: "Chg:5" },
|
|
89
|
+
{ type: "modify", target_text: "24 months", new_text: "36 months" },
|
|
90
|
+
] as any[];
|
|
91
|
+
|
|
92
|
+
// DESIRED behaviour — RED on current Node (throws "active insertion from
|
|
93
|
+
// another author"), GREEN after the fix.
|
|
94
|
+
const stats = engine.process_batch(batch, false);
|
|
95
|
+
expect(stats.actions_applied).toBe(1);
|
|
96
|
+
expect(stats.edits_applied).toBe(1);
|
|
97
|
+
expect(stats.edits_skipped).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("Issue 1 (control, GREEN): the same accept + modify succeeds across two batches", async () => {
|
|
101
|
+
// Proves the rejection above is purely a single-round-trip batching
|
|
102
|
+
// artifact — the content and intent are valid.
|
|
103
|
+
const doc = await createTestDocument();
|
|
104
|
+
addParagraphWithForeignInsertion(
|
|
105
|
+
doc,
|
|
106
|
+
"The term is ",
|
|
107
|
+
"24 months",
|
|
108
|
+
"5",
|
|
109
|
+
"Supplier's Counsel",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const engine = new RedlineEngine(doc, "Acme's Counsel");
|
|
113
|
+
const r1 = engine.process_batch([{ type: "accept", target_id: "Chg:5" }], false);
|
|
114
|
+
expect(r1.actions_applied).toBe(1);
|
|
115
|
+
const r2 = engine.process_batch(
|
|
116
|
+
[{ type: "modify", target_text: "24 months", new_text: "36 months" }],
|
|
117
|
+
false,
|
|
118
|
+
);
|
|
119
|
+
expect(r2.edits_applied).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
123
|
+
// ISSUE 2 — NOT a bug in the current engine, so there is nothing to fail on.
|
|
124
|
+
// The described mechanism (one match whose physical runs are shredded →
|
|
125
|
+
// comment id duplicated per run segment) does not occur: COMMENT_ONLY anchors
|
|
126
|
+
// a single range across the first→last run regardless of fragmentation. These
|
|
127
|
+
// assertions are GREEN BY DESIGN and exist as evidence for the "not
|
|
128
|
+
// replicable" conclusion. (The real gap — no dedicated comment-only change
|
|
129
|
+
// type, so comments must ride a self-replacing ModifyText — is a feature
|
|
130
|
+
// request, not a failing-test-able defect.)
|
|
131
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
132
|
+
it("Issue 2 (GREEN, evidence): self-replacement comment over 9 fragmented runs emits ONE comment", async () => {
|
|
133
|
+
const doc = await createTestDocument();
|
|
134
|
+
const x = doc.element.ownerDocument!;
|
|
135
|
+
const p = x.createElement("w:p");
|
|
136
|
+
const words = [
|
|
137
|
+
"The ", "Purchase ", "Price ", "is ", "[ ] ",
|
|
138
|
+
"dollars ", "per ", "unit ", "total.",
|
|
139
|
+
];
|
|
140
|
+
words.forEach((w, i) => {
|
|
141
|
+
if (i % 2 === 0) p.appendChild(x.createElement("w:proofErr"));
|
|
142
|
+
const r = x.createElement("w:r");
|
|
143
|
+
const t = x.createElement("w:t");
|
|
144
|
+
t.textContent = w;
|
|
145
|
+
t.setAttribute("xml:space", "preserve");
|
|
146
|
+
r.appendChild(t);
|
|
147
|
+
p.appendChild(r);
|
|
148
|
+
});
|
|
149
|
+
doc.element.appendChild(p);
|
|
150
|
+
|
|
151
|
+
const engine = new RedlineEngine(doc, "Reviewer");
|
|
152
|
+
const sentence = words.join("").trim();
|
|
153
|
+
engine.process_batch(
|
|
154
|
+
[{ type: "modify", target_text: sentence, new_text: sentence, comment: "Missing value" }],
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(countTag(doc.element, "w:commentReference")).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createTestDocument, addParagraph, addTable, setCellText } from "./test-utils.js";
|
|
3
|
+
import { RedlineEngine } from "./engine.js";
|
|
4
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
5
|
+
|
|
6
|
+
describe("Webinar Report Bug Reproductions", () => {
|
|
7
|
+
it("Bug 1: Diff Engine Fragility (Token-Splitting Punctuation)", async () => {
|
|
8
|
+
const doc = await createTestDocument();
|
|
9
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
10
|
+
const p = xmlDoc.createElement("w:p");
|
|
11
|
+
|
|
12
|
+
// Replicate MS Word fragmented runs for token-splitting punctuation: [Company Name]
|
|
13
|
+
// with style/formatting run fragmentation where only some runs are bold.
|
|
14
|
+
// This generates: **[**Company Name**]**
|
|
15
|
+
// The target "[Company Name]" will fail to be matched because brackets are not treated
|
|
16
|
+
// as fuzzy boundaries, so the fuzzy regex expects "[Company" directly without the intermediate "**".
|
|
17
|
+
const r1 = xmlDoc.createElement("w:r");
|
|
18
|
+
const rPr1 = xmlDoc.createElement("w:rPr");
|
|
19
|
+
rPr1.appendChild(xmlDoc.createElement("w:b"));
|
|
20
|
+
r1.appendChild(rPr1);
|
|
21
|
+
const t1 = xmlDoc.createElement("w:t");
|
|
22
|
+
t1.textContent = "[";
|
|
23
|
+
r1.appendChild(t1);
|
|
24
|
+
p.appendChild(r1);
|
|
25
|
+
|
|
26
|
+
const r2 = xmlDoc.createElement("w:r");
|
|
27
|
+
// Run 2 is NOT bold
|
|
28
|
+
const t2 = xmlDoc.createElement("w:t");
|
|
29
|
+
t2.textContent = "Company Name";
|
|
30
|
+
r2.appendChild(t2);
|
|
31
|
+
p.appendChild(r2);
|
|
32
|
+
|
|
33
|
+
const r3 = xmlDoc.createElement("w:r");
|
|
34
|
+
const rPr3 = xmlDoc.createElement("w:rPr");
|
|
35
|
+
rPr3.appendChild(xmlDoc.createElement("w:b"));
|
|
36
|
+
r3.appendChild(rPr3);
|
|
37
|
+
const t3 = xmlDoc.createElement("w:t");
|
|
38
|
+
t3.textContent = "]";
|
|
39
|
+
r3.appendChild(t3);
|
|
40
|
+
p.appendChild(r3);
|
|
41
|
+
|
|
42
|
+
doc.element.appendChild(p);
|
|
43
|
+
|
|
44
|
+
const engine = new RedlineEngine(doc);
|
|
45
|
+
|
|
46
|
+
// Under correct engine behavior, targeting "[Company Name]" should succeed.
|
|
47
|
+
// However, on the unpatched codebase, it throws a BatchValidationError (Target text not found).
|
|
48
|
+
const res = engine.process_batch([
|
|
49
|
+
{
|
|
50
|
+
type: "modify",
|
|
51
|
+
target_text: "[Company Name]",
|
|
52
|
+
new_text: "Google LLC",
|
|
53
|
+
} as any,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
expect(res.edits_applied).toBe(1);
|
|
57
|
+
expect(res.edits_skipped).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("Bug 2: editing across a foreign author's insertion boundary is refused (provenance-safe)", async () => {
|
|
61
|
+
const doc = await createTestDocument();
|
|
62
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
63
|
+
|
|
64
|
+
const p = addParagraph(doc, "The party shall provide ");
|
|
65
|
+
const ins = xmlDoc.createElement("w:ins");
|
|
66
|
+
ins.setAttribute("w:id", "123");
|
|
67
|
+
ins.setAttribute("w:author", "Supplier's Counsel");
|
|
68
|
+
const r = xmlDoc.createElement("w:r");
|
|
69
|
+
const t = xmlDoc.createElement("w:t");
|
|
70
|
+
t.textContent = "written notice";
|
|
71
|
+
r.appendChild(t);
|
|
72
|
+
ins.appendChild(r);
|
|
73
|
+
p.appendChild(ins);
|
|
74
|
+
|
|
75
|
+
const suffixRun = xmlDoc.createElement("w:r");
|
|
76
|
+
const suffixText = xmlDoc.createElement("w:t");
|
|
77
|
+
suffixText.textContent = " within 30 days.";
|
|
78
|
+
suffixRun.appendChild(suffixText);
|
|
79
|
+
p.appendChild(suffixRun);
|
|
80
|
+
|
|
81
|
+
const engine = new RedlineEngine(doc, "Reviewer");
|
|
82
|
+
|
|
83
|
+
// The target STRADDLES Supplier's Counsel's pending <w:ins> boundary
|
|
84
|
+
// (foreign-inserted "written notice" + plain " within 30 days."). Canonical
|
|
85
|
+
// behavior is to REFUSE rather than silently lift the foreign insertion into
|
|
86
|
+
// committed text (which would launder another author's pending proposal and
|
|
87
|
+
// erase their provenance). The agent must accept that change first, or scope
|
|
88
|
+
// the edit within / outside the insertion. (Matches the Python engine.)
|
|
89
|
+
const edit = {
|
|
90
|
+
type: "modify",
|
|
91
|
+
target_text: "written notice within 30 days.",
|
|
92
|
+
new_text: "notice within 15 business days.",
|
|
93
|
+
};
|
|
94
|
+
expect(() => engine.process_batch([edit as any], false)).toThrow(
|
|
95
|
+
/active insertion from another author/,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("Bug 3: Table Layout Ambiguity and Markdown Loss of Fidelity", async () => {
|
|
100
|
+
const doc = await createTestDocument();
|
|
101
|
+
|
|
102
|
+
// Create a table with 2 rows, 4 columns
|
|
103
|
+
const tbl = addTable(doc, 2, 4);
|
|
104
|
+
|
|
105
|
+
// Set cell text to match identical structure: Date | | |
|
|
106
|
+
setCellText(tbl, 0, 0, "Date");
|
|
107
|
+
setCellText(tbl, 0, 1, "");
|
|
108
|
+
setCellText(tbl, 0, 2, "");
|
|
109
|
+
setCellText(tbl, 0, 3, "");
|
|
110
|
+
|
|
111
|
+
setCellText(tbl, 1, 0, "Date");
|
|
112
|
+
setCellText(tbl, 1, 1, "");
|
|
113
|
+
setCellText(tbl, 1, 2, "");
|
|
114
|
+
setCellText(tbl, 1, 3, "");
|
|
115
|
+
|
|
116
|
+
const buf = await doc.save();
|
|
117
|
+
const midDoc = await DocumentObject.load(buf);
|
|
118
|
+
const engine = new RedlineEngine(midDoc);
|
|
119
|
+
|
|
120
|
+
// Target one of the empty rows to apply a date.
|
|
121
|
+
const edit = {
|
|
122
|
+
type: "modify",
|
|
123
|
+
target_text: "Date | | | ",
|
|
124
|
+
new_text: "Date | June 29, 2026 | | ",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Ideally, the system should allow distinct targeting of cells (e.g. preserving grid coordinates).
|
|
128
|
+
// But due to the coordinate-flattening design flaw, it throws "Ambiguous match".
|
|
129
|
+
const res = engine.process_batch([edit as any]);
|
|
130
|
+
expect(res.edits_applied).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
});
|