@adeu/core 1.17.0 → 1.17.2
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/README.md +1 -0
- package/dist/index.cjs +353 -85
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +353 -85
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.qa.test.ts +4 -4
- package/src/engine.tables.test.ts +138 -36
- package/src/engine.ts +433 -105
- package/src/ingest.ts +16 -1
- package/src/mapper.ts +23 -1
- package/src/markup.test.ts +41 -6
- package/src/markup.ts +13 -2
- package/src/repro.feedback.test.ts +160 -0
- package/src/repro.report.test.ts +130 -0
- package/src/repro_qa_report_v3.test.ts +194 -0
- package/src/search_write_engine.test.ts +41 -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.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
_replace_smart_quotes,
|
|
4
|
-
_make_fuzzy_regex,
|
|
5
|
-
_find_match_in_text,
|
|
6
|
-
_build_critic_markup,
|
|
7
|
-
apply_edits_to_markdown
|
|
2
|
+
import {
|
|
3
|
+
_replace_smart_quotes,
|
|
4
|
+
_make_fuzzy_regex,
|
|
5
|
+
_find_match_in_text,
|
|
6
|
+
_build_critic_markup,
|
|
7
|
+
apply_edits_to_markdown,
|
|
8
|
+
format_ambiguity_error
|
|
8
9
|
} from './markup.js';
|
|
9
10
|
import { ModifyText } from './models.js';
|
|
10
11
|
|
|
@@ -147,4 +148,38 @@ describe('apply_edits_to_markdown', () => {
|
|
|
147
148
|
const result = apply_edits_to_markdown(text, [{ type: 'modify', target_text: target, new_text: newText }]);
|
|
148
149
|
expect(result).toContain(expectedSubstring);
|
|
149
150
|
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('format_ambiguity_error (Turn Loop Trap mitigation)', () => {
|
|
154
|
+
// Mirror the DPA scenario: the same placeholder appears in multiple clauses.
|
|
155
|
+
const haystack =
|
|
156
|
+
'PROVIDER: [official company name] shall process the data. ' +
|
|
157
|
+
'PROVIDER: [official company name] is the data processor.';
|
|
158
|
+
const target = 'PROVIDER: [official company name]';
|
|
159
|
+
const positions: [number, number][] = [
|
|
160
|
+
[0, target.length],
|
|
161
|
+
[58, 58 + target.length],
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
it('names BOTH match_mode escape hatches so the agent can re-call instead of looping', () => {
|
|
165
|
+
const msg = format_ambiguity_error(1, target, haystack, positions);
|
|
166
|
+
|
|
167
|
+
expect(msg).toContain('Edit 1 Failed: Ambiguous match');
|
|
168
|
+
expect(msg).toContain('appears 2 times');
|
|
169
|
+
|
|
170
|
+
// The actionable remediation that breaks the loop.
|
|
171
|
+
expect(msg).toContain('"match_mode": "all"');
|
|
172
|
+
expect(msg).toContain('"match_mode": "first"');
|
|
173
|
+
expect(msg).toContain('ALL 2 occurrences');
|
|
174
|
+
expect(msg).toContain('FIRST occurrence');
|
|
175
|
+
|
|
176
|
+
// The original "add more context" guidance is preserved as a third option.
|
|
177
|
+
expect(msg).toContain('Please provide more surrounding context');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('still throws for fewer than two matches', () => {
|
|
181
|
+
expect(() => format_ambiguity_error(1, target, haystack, [[0, 5]])).toThrow(
|
|
182
|
+
/requires at least 2 matches/,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
150
185
|
});
|
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 _,
|
|
@@ -422,8 +422,19 @@ export function format_ambiguity_error(
|
|
|
422
422
|
lines.push(` ... and ${remaining} more occurrence(s) not shown.`);
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
// Tell the agent EXACTLY how to re-call. Without this, agents loop forever
|
|
426
|
+
// refining target_text/regex because they never learn that match_mode is the
|
|
427
|
+
// built-in escape hatch for genuine ambiguity.
|
|
428
|
+
lines.push(" To resolve, re-send this edit using ONE of these strategies:");
|
|
425
429
|
lines.push(
|
|
426
|
-
|
|
430
|
+
` 1. Set "match_mode": "all" to modify ALL ${total} occurrences (same target_text).`,
|
|
431
|
+
);
|
|
432
|
+
lines.push(
|
|
433
|
+
' 2. Set "match_mode": "first" to modify only the FIRST occurrence (same target_text).',
|
|
434
|
+
);
|
|
435
|
+
lines.push(
|
|
436
|
+
' 3. Please provide more surrounding context in your target_text to uniquely ' +
|
|
437
|
+
'identify a single location (keep the default "match_mode": "strict").',
|
|
427
438
|
);
|
|
428
439
|
|
|
429
440
|
return lines.join("\n");
|
|
@@ -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,130 @@
|
|
|
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: Revision History Lockouts (Active Insertion Collisions)", 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
|
+
// Create an engine with a different user name to trigger co-authoring lockout
|
|
82
|
+
const engine = new RedlineEngine(doc, "Reviewer");
|
|
83
|
+
|
|
84
|
+
// Standard edit targeting the paragraph text containing the active insertion
|
|
85
|
+
const edit = {
|
|
86
|
+
type: "modify",
|
|
87
|
+
target_text: "written notice within 30 days.",
|
|
88
|
+
new_text: "notice within 15 business days.",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Under normal collaborative conditions, this edit should succeed.
|
|
92
|
+
// However, on the current unpatched codebase, the dry-run engine aborts with a BatchValidationError.
|
|
93
|
+
const res = engine.process_batch([edit as any], true);
|
|
94
|
+
expect(res.edits_applied).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("Bug 3: Table Layout Ambiguity and Markdown Loss of Fidelity", async () => {
|
|
98
|
+
const doc = await createTestDocument();
|
|
99
|
+
|
|
100
|
+
// Create a table with 2 rows, 4 columns
|
|
101
|
+
const tbl = addTable(doc, 2, 4);
|
|
102
|
+
|
|
103
|
+
// Set cell text to match identical structure: Date | | |
|
|
104
|
+
setCellText(tbl, 0, 0, "Date");
|
|
105
|
+
setCellText(tbl, 0, 1, "");
|
|
106
|
+
setCellText(tbl, 0, 2, "");
|
|
107
|
+
setCellText(tbl, 0, 3, "");
|
|
108
|
+
|
|
109
|
+
setCellText(tbl, 1, 0, "Date");
|
|
110
|
+
setCellText(tbl, 1, 1, "");
|
|
111
|
+
setCellText(tbl, 1, 2, "");
|
|
112
|
+
setCellText(tbl, 1, 3, "");
|
|
113
|
+
|
|
114
|
+
const buf = await doc.save();
|
|
115
|
+
const midDoc = await DocumentObject.load(buf);
|
|
116
|
+
const engine = new RedlineEngine(midDoc);
|
|
117
|
+
|
|
118
|
+
// Target one of the empty rows to apply a date.
|
|
119
|
+
const edit = {
|
|
120
|
+
type: "modify",
|
|
121
|
+
target_text: "Date | | | ",
|
|
122
|
+
new_text: "Date | June 29, 2026 | | ",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Ideally, the system should allow distinct targeting of cells (e.g. preserving grid coordinates).
|
|
126
|
+
// But due to the coordinate-flattening design flaw, it throws "Ambiguous match".
|
|
127
|
+
const res = engine.process_batch([edit as any]);
|
|
128
|
+
expect(res.edits_applied).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createTestDocument, addParagraph } from "./test-utils.js";
|
|
3
|
+
import { RedlineEngine } from "./engine.js";
|
|
4
|
+
|
|
5
|
+
describe("QA Report V3 Defects Reproductions", () => {
|
|
6
|
+
it("TC1: Sequential batch evaluation (modify->modify chaining) [report F1]", async () => {
|
|
7
|
+
const doc = await createTestDocument();
|
|
8
|
+
addParagraph(doc, "As defined in Section 1, the Recipient shall maintain confidentiality of all materials.");
|
|
9
|
+
const engine = new RedlineEngine(doc);
|
|
10
|
+
|
|
11
|
+
// If sequential evaluation works (like Python), the second edit will find "Receiving Party" (the output of the first edit).
|
|
12
|
+
// On the current unpatched Node engine, this will FAIL and throw "Target text not found in document: Receiving Party".
|
|
13
|
+
const res = engine.process_batch([
|
|
14
|
+
{
|
|
15
|
+
type: "modify",
|
|
16
|
+
target_text: "the Recipient",
|
|
17
|
+
new_text: "Receiving Party",
|
|
18
|
+
} as any,
|
|
19
|
+
{
|
|
20
|
+
type: "modify",
|
|
21
|
+
target_text: "Receiving Party",
|
|
22
|
+
new_text: "Disclosee",
|
|
23
|
+
} as any,
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
expect(res.edits_applied).toBe(2);
|
|
27
|
+
expect(res.edits_skipped).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("TC2: NODE dry-run ≠ real write (active-insertion guard) [report F2, F3]", async () => {
|
|
31
|
+
const doc = await createTestDocument();
|
|
32
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
33
|
+
|
|
34
|
+
// Create a paragraph with an active insertion by "Original Drafter"
|
|
35
|
+
const p = addParagraph(doc, "The party shall provide ");
|
|
36
|
+
const ins = xmlDoc.createElement("w:ins");
|
|
37
|
+
ins.setAttribute("w:id", "101");
|
|
38
|
+
ins.setAttribute("w:author", "Original Drafter");
|
|
39
|
+
ins.setAttribute("w:date", "2026-06-29T12:00:00Z");
|
|
40
|
+
|
|
41
|
+
const r = xmlDoc.createElement("w:r");
|
|
42
|
+
const t = xmlDoc.createElement("w:t");
|
|
43
|
+
t.textContent = "five (5)";
|
|
44
|
+
r.appendChild(t);
|
|
45
|
+
ins.appendChild(r);
|
|
46
|
+
p.appendChild(ins);
|
|
47
|
+
|
|
48
|
+
const suffixRun = xmlDoc.createElement("w:r");
|
|
49
|
+
const suffixText = xmlDoc.createElement("w:t");
|
|
50
|
+
suffixText.textContent = " years.";
|
|
51
|
+
suffixRun.appendChild(suffixText);
|
|
52
|
+
p.appendChild(suffixRun);
|
|
53
|
+
|
|
54
|
+
// Create an engine with a different user name ("QA Tester") to trigger lockout
|
|
55
|
+
const engine = new RedlineEngine(doc, "QA Tester");
|
|
56
|
+
|
|
57
|
+
const edit = {
|
|
58
|
+
type: "modify",
|
|
59
|
+
target_text: "five (5)",
|
|
60
|
+
new_text: "seven (7)",
|
|
61
|
+
} as any;
|
|
62
|
+
|
|
63
|
+
// Run A: dry_run = true
|
|
64
|
+
const resDry = engine.process_batch([edit], true);
|
|
65
|
+
expect(resDry.edits_applied).toBe(0);
|
|
66
|
+
expect(resDry.edits_skipped).toBe(1);
|
|
67
|
+
expect(resDry.edits[0].status).toBe("failed");
|
|
68
|
+
expect(resDry.edits[0].error).toContain("active insertion");
|
|
69
|
+
|
|
70
|
+
// Run B: dry_run = false
|
|
71
|
+
let wetRunError: any = null;
|
|
72
|
+
try {
|
|
73
|
+
engine.process_batch([edit], false);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
wetRunError = e;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(wetRunError).not.toBeNull();
|
|
79
|
+
expect(wetRunError.name).toBe("BatchValidationError");
|
|
80
|
+
expect(wetRunError.message).toContain("active insertion");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("TC3: Heading targeted by markdown '#' corrupts instead of failing [report F4, F5]", async () => {
|
|
84
|
+
const doc = await createTestDocument();
|
|
85
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
86
|
+
|
|
87
|
+
// Create a styled heading "3. Pending Review" (without literal "#" in the Word doc) using Heading1 style
|
|
88
|
+
const p = xmlDoc.createElement("w:p");
|
|
89
|
+
const pPr = xmlDoc.createElement("w:pPr");
|
|
90
|
+
const pStyle = xmlDoc.createElement("w:pStyle");
|
|
91
|
+
pStyle.setAttribute("w:val", "Heading1");
|
|
92
|
+
pPr.appendChild(pStyle);
|
|
93
|
+
p.appendChild(pPr);
|
|
94
|
+
|
|
95
|
+
const r = xmlDoc.createElement("w:r");
|
|
96
|
+
const t = xmlDoc.createElement("w:t");
|
|
97
|
+
t.textContent = "3. Pending Review";
|
|
98
|
+
r.appendChild(t);
|
|
99
|
+
p.appendChild(r);
|
|
100
|
+
doc.element.appendChild(p);
|
|
101
|
+
|
|
102
|
+
const engine = new RedlineEngine(doc);
|
|
103
|
+
|
|
104
|
+
// Target by its markdown representation
|
|
105
|
+
const res = engine.process_batch([
|
|
106
|
+
{
|
|
107
|
+
type: "modify",
|
|
108
|
+
target_text: "# 3. Pending Review",
|
|
109
|
+
new_text: "# 3. Final Review",
|
|
110
|
+
} as any,
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
expect(res.edits_applied).toBe(1);
|
|
114
|
+
|
|
115
|
+
// The correct behavior must NOT produce a redline containing a literal '#' character in the CriticMarkup preview
|
|
116
|
+
// or body text (such as `{--# 3. Pending--}{++# 3. Final++} Review`).
|
|
117
|
+
// Asserting that the critic_markup does NOT contain "{--#" or "{++#" will fail precisely due to this bug.
|
|
118
|
+
const criticMarkup = res.edits[0].critic_markup;
|
|
119
|
+
expect(criticMarkup).not.toContain("{--#");
|
|
120
|
+
expect(criticMarkup).not.toContain("{++#");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("TC5: w16du namespace stamped on untouched parts [report F9]", async () => {
|
|
124
|
+
const doc = await createTestDocument();
|
|
125
|
+
addParagraph(doc, "This is untouched body text.");
|
|
126
|
+
|
|
127
|
+
// Inject a header part without w16du namespace
|
|
128
|
+
const headerXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
129
|
+
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
130
|
+
<w:p><w:r><w:t>Header Text</w:t></w:r></w:p>
|
|
131
|
+
</w:hdr>`;
|
|
132
|
+
const headerPart = doc.pkg.addPart(
|
|
133
|
+
"/word/header1.xml",
|
|
134
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
|
|
135
|
+
headerXml,
|
|
136
|
+
);
|
|
137
|
+
doc.relateTo(
|
|
138
|
+
headerPart,
|
|
139
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const engine = new RedlineEngine(doc);
|
|
143
|
+
engine.process_batch([
|
|
144
|
+
{
|
|
145
|
+
type: "modify",
|
|
146
|
+
target_text: "untouched body",
|
|
147
|
+
new_text: "changed body",
|
|
148
|
+
} as any,
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
// Save the package to trigger serialization of all parts
|
|
152
|
+
await doc.save();
|
|
153
|
+
|
|
154
|
+
// Verify the header XML does not contain the word16du namespace
|
|
155
|
+
const savedHeaderXml = headerPart._element.toString();
|
|
156
|
+
expect(savedHeaderXml).not.toContain("word16du");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("TC8: Error message mislabels edit index [report F7]", async () => {
|
|
160
|
+
const doc = await createTestDocument();
|
|
161
|
+
addParagraph(doc, "First paragraph text.");
|
|
162
|
+
|
|
163
|
+
const engine = new RedlineEngine(doc);
|
|
164
|
+
|
|
165
|
+
// Run in dry_run mode where the bug manifests.
|
|
166
|
+
// Edit 1 succeeds, Edit 2 fails because "Non-existent text" is not found.
|
|
167
|
+
// On the unpatched codebase, individual validation of edit 2 passes single_errors = validate_edits([edit]),
|
|
168
|
+
// which hardcodes i = 0 internally. Thus, the error in res.edits[1].error is:
|
|
169
|
+
// "- Edit 1 Failed: Target text not found..." instead of "- Edit 2 Failed: ...".
|
|
170
|
+
const res = engine.process_batch([
|
|
171
|
+
{
|
|
172
|
+
type: "modify",
|
|
173
|
+
target_text: "First paragraph",
|
|
174
|
+
new_text: "Updated first paragraph",
|
|
175
|
+
} as any,
|
|
176
|
+
{
|
|
177
|
+
type: "modify",
|
|
178
|
+
target_text: "Non-existent text",
|
|
179
|
+
new_text: "Failed update",
|
|
180
|
+
} as any,
|
|
181
|
+
], true);
|
|
182
|
+
|
|
183
|
+
expect(res.edits_applied).toBe(1);
|
|
184
|
+
expect(res.edits_skipped).toBe(1);
|
|
185
|
+
expect(res.edits[1].status).toBe("failed");
|
|
186
|
+
|
|
187
|
+
// Assert that the error message correctly labels it as Edit 2.
|
|
188
|
+
// The buggy unpatched codebase says "Edit 1 Failed:" inside the error message for Edit 2.
|
|
189
|
+
const errorMsg = res.edits[1].error;
|
|
190
|
+
expect(errorMsg).toContain("Edit 2 Failed:");
|
|
191
|
+
expect(errorMsg).not.toContain("Edit 1 Failed:");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|