@adeu/core 1.26.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.
@@ -0,0 +1,265 @@
1
+ // FILE: node/packages/core/src/repro_qa_report_v8.test.ts
2
+ /**
3
+ * Node-engine repro tests for the 2026-07-19 black-box QA and UX report on
4
+ * 1.26.0 (adeu 1.26.0+0741eaf). Mirrors python/tests/test_repro_qa_report_v8.py
5
+ * for the findings that live in the shared core engine:
6
+ *
7
+ * F-04 replacing a full sentence that crosses bold/italic formatting runs
8
+ * leaves a partial word bold (`**The Suppli** must perform ...`):
9
+ * the word-diff hunk absorbs the closing `**` marker, the resolved
10
+ * range starts on a virtual span, and the run-local split offset
11
+ * underflows into the preceding run's kept text
12
+ * F-07 review-action validation permits blank replies and duplicate /
13
+ * conflicting accept-reject pairs on one target_id
14
+ *
15
+ * Every test fails against the commit preceding its fix.
16
+ */
17
+
18
+ import { describe, it, expect } from "vitest";
19
+ import { createTestDocument, addParagraph } from "./test-utils.js";
20
+ import { DocumentObject } from "./docx/bridge.js";
21
+ import { RedlineEngine, BatchValidationError } from "./engine.js";
22
+ import { _extractTextFromDoc, extractTextFromBuffer } from "./ingest.js";
23
+
24
+ function addStyledRun(
25
+ p: Element,
26
+ text: string,
27
+ style: { bold?: boolean; italic?: boolean; underline?: boolean } = {},
28
+ ): Element {
29
+ const xmlDoc = p.ownerDocument!;
30
+ const r = xmlDoc.createElement("w:r");
31
+ if (style.bold || style.italic || style.underline) {
32
+ const rPr = xmlDoc.createElement("w:rPr");
33
+ if (style.bold) rPr.appendChild(xmlDoc.createElement("w:b"));
34
+ if (style.italic) rPr.appendChild(xmlDoc.createElement("w:i"));
35
+ if (style.underline) {
36
+ const u = xmlDoc.createElement("w:u");
37
+ u.setAttribute("w:val", "single");
38
+ rPr.appendChild(u);
39
+ }
40
+ r.appendChild(rPr);
41
+ }
42
+ const t = xmlDoc.createElement("w:t");
43
+ t.textContent = text;
44
+ if (text !== text.trim()) t.setAttribute("xml:space", "preserve");
45
+ r.appendChild(t);
46
+ p.appendChild(r);
47
+ return r;
48
+ }
49
+
50
+ function addEmptyParagraph(doc: DocumentObject): Element {
51
+ const xmlDoc = doc.element.ownerDocument!;
52
+ const p = xmlDoc.createElement("w:p");
53
+ doc.element.appendChild(p);
54
+ return p;
55
+ }
56
+
57
+ /** The QA report's F-04 fixture: bold "The Supplier ", italic
58
+ * "shall provide ", underlined remainder — one sentence, three runs. */
59
+ async function buildCrossRunDoc(): Promise<DocumentObject> {
60
+ const doc = await createTestDocument();
61
+ const p = addEmptyParagraph(doc);
62
+ addStyledRun(p, "The Supplier ", { bold: true });
63
+ addStyledRun(p, "shall provide ", { italic: true });
64
+ addStyledRun(p, "the Services with reasonable skill and care.", {
65
+ underline: true,
66
+ });
67
+ return doc;
68
+ }
69
+
70
+ function cleanText(doc: DocumentObject): string {
71
+ return _extractTextFromDoc(doc, {
72
+ cleanView: true,
73
+ includeAppendix: false,
74
+ }) as string;
75
+ }
76
+
77
+ /** A document carrying exactly one tracked modification (Chg:1 + Chg:2). */
78
+ async function buildTrackedChangeDoc(): Promise<DocumentObject> {
79
+ const doc = await createTestDocument();
80
+ addParagraph(doc, "Payment is due in 30 days.");
81
+ addParagraph(doc, "Second paragraph here.");
82
+ const engine = new RedlineEngine(doc);
83
+ engine.apply_edits([
84
+ { type: "modify", target_text: "30 days", new_text: "60 days" },
85
+ ]);
86
+ return DocumentObject.load(await doc.save());
87
+ }
88
+
89
+ /** A document carrying exactly one comment; returns [doc, "Com:<id>"]. */
90
+ async function buildCommentDoc(): Promise<[DocumentObject, string]> {
91
+ const doc = await createTestDocument();
92
+ addParagraph(doc, "Alpha beta gamma.");
93
+ addParagraph(doc, "Delta epsilon.");
94
+ const engine = new RedlineEngine(doc);
95
+ engine.process_batch([
96
+ {
97
+ type: "modify",
98
+ target_text: "Alpha beta gamma.",
99
+ new_text: "Alpha beta gamma.",
100
+ comment: "Please review.",
101
+ },
102
+ ]);
103
+ const buf = await doc.save();
104
+ const reloaded = await DocumentObject.load(buf);
105
+ const projected = await extractTextFromBuffer(buf);
106
+ const m = projected.match(/\[Com:(\d+)/);
107
+ expect(m, `expected a comment id in ${projected}`).toBeTruthy();
108
+ return [reloaded, `Com:${m![1]}`];
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // F-04: full-span replacements across formatting runs keep words whole
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("QA v8 F-04: cross-run full-span replacement fidelity", () => {
116
+ const TARGET =
117
+ "The Supplier shall provide the Services with reasonable skill and care.";
118
+ const NEW = "The Supplier must perform the Services professionally.";
119
+
120
+ it("leaves no partial-word bold after the replacement", async () => {
121
+ const doc = await buildCrossRunDoc();
122
+ const engine = new RedlineEngine(doc);
123
+ const stats = engine.process_batch([
124
+ { type: "modify", target_text: TARGET, new_text: NEW },
125
+ ]);
126
+ expect(stats.edits_applied).toBe(1);
127
+
128
+ const clean = cleanText(doc);
129
+ expect(clean).not.toContain("**The Suppli**");
130
+ expect(clean).not.toContain("Suppli**");
131
+ expect(clean.trim()).toBe(
132
+ "**The Supplier** must perform the Services professionally.",
133
+ );
134
+ });
135
+
136
+ it("accepted document matches the clean view", async () => {
137
+ const doc = await buildCrossRunDoc();
138
+ const engine = new RedlineEngine(doc);
139
+ engine.process_batch([
140
+ { type: "modify", target_text: TARGET, new_text: NEW },
141
+ ]);
142
+ engine.accept_all_revisions();
143
+ const accepted = _extractTextFromDoc(doc, {
144
+ cleanView: false,
145
+ includeAppendix: false,
146
+ }) as string;
147
+ expect(accepted.trim()).toBe(
148
+ "**The Supplier** must perform the Services professionally.",
149
+ );
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Hunt-profile counterexample (python property suite, 2026-07-19): a
155
+ // paragraph-splitting insertion at paragraph START must relocate the host
156
+ // paragraph's content into the LAST new paragraph. Deterministic pin,
157
+ // mirrored here because both engines shared the suffix-relocation logic.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe("QA v8: paragraph-start splitting insertion", () => {
161
+ it("relocates the host paragraph content into the last new paragraph", async () => {
162
+ const doc = await createTestDocument();
163
+ addParagraph(doc, "00.");
164
+ const engine = new RedlineEngine(doc);
165
+ const edit: any = {
166
+ type: "modify",
167
+ target_text: "",
168
+ new_text: "0.\n\n0 ",
169
+ _match_start_index: 0,
170
+ };
171
+ engine.apply_edits([edit]);
172
+ engine.accept_all_revisions();
173
+ const final = _extractTextFromDoc(doc, {
174
+ cleanView: true,
175
+ includeAppendix: false,
176
+ }) as string;
177
+ expect(final.trim()).toBe("0.\n\n0 00.");
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // F-07: blank replies and duplicate review actions are rejected
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe("QA v8 F-07: review-action validation", () => {
186
+ it("rejects a blank reply", async () => {
187
+ const [doc, comId] = await buildCommentDoc();
188
+ const engine = new RedlineEngine(doc);
189
+ expect(() =>
190
+ engine.process_batch([{ type: "reply", target_id: comId, text: " " }]),
191
+ ).toThrowError(BatchValidationError);
192
+ try {
193
+ engine.process_batch([{ type: "reply", target_id: comId, text: " " }]);
194
+ } catch (e: any) {
195
+ expect(e.errors.join("\n").toLowerCase()).toContain("empty");
196
+ }
197
+ });
198
+
199
+ it("rejects a duplicate accept of the same target_id", async () => {
200
+ const doc = await buildTrackedChangeDoc();
201
+ const engine = new RedlineEngine(doc);
202
+ try {
203
+ engine.process_batch([
204
+ { type: "accept", target_id: "Chg:1" },
205
+ { type: "accept", target_id: "Chg:1" },
206
+ ]);
207
+ expect.unreachable("duplicate accept must be rejected");
208
+ } catch (e: any) {
209
+ expect(e).toBeInstanceOf(BatchValidationError);
210
+ expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
211
+ }
212
+ });
213
+
214
+ it("rejects conflicting accept + reject on one target_id", async () => {
215
+ const doc = await buildTrackedChangeDoc();
216
+ const engine = new RedlineEngine(doc);
217
+ try {
218
+ engine.process_batch([
219
+ { type: "accept", target_id: "Chg:1" },
220
+ { type: "reject", target_id: "Chg:1" },
221
+ ]);
222
+ expect.unreachable("conflicting actions must be rejected");
223
+ } catch (e: any) {
224
+ expect(e).toBeInstanceOf(BatchValidationError);
225
+ expect(e.errors.join("\n").toLowerCase()).toContain("conflict");
226
+ }
227
+ });
228
+
229
+ it("rejects a duplicated identical reply", async () => {
230
+ const [doc, comId] = await buildCommentDoc();
231
+ const engine = new RedlineEngine(doc);
232
+ try {
233
+ engine.process_batch([
234
+ { type: "reply", target_id: comId, text: "Same reply." },
235
+ { type: "reply", target_id: comId, text: "Same reply." },
236
+ ]);
237
+ expect.unreachable("duplicate identical reply must be rejected");
238
+ } catch (e: any) {
239
+ expect(e).toBeInstanceOf(BatchValidationError);
240
+ expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
241
+ }
242
+ });
243
+
244
+ it("still allows distinct replies to the same comment", async () => {
245
+ const [doc, comId] = await buildCommentDoc();
246
+ const engine = new RedlineEngine(doc);
247
+ const stats = engine.process_batch([
248
+ { type: "reply", target_id: comId, text: "First reply." },
249
+ { type: "reply", target_id: comId, text: "Second reply." },
250
+ ]);
251
+ expect(stats.actions_applied).toBe(2);
252
+ expect(stats.actions_skipped).toBe(0);
253
+ });
254
+
255
+ it("still allows accepting the del+ins pair of one modification", async () => {
256
+ const doc = await buildTrackedChangeDoc();
257
+ const engine = new RedlineEngine(doc);
258
+ const stats = engine.process_batch([
259
+ { type: "accept", target_id: "Chg:1" },
260
+ { type: "accept", target_id: "Chg:2" },
261
+ ]);
262
+ expect(stats.actions_skipped).toBe(0);
263
+ expect(cleanText(doc)).toContain("60 days");
264
+ });
265
+ });
@@ -0,0 +1,392 @@
1
+ // FILE: node/packages/core/src/repro_qa_report_v9.test.ts
2
+ /**
3
+ * Node-engine repro tests for the 2026-07-19 black-box QA and UX evaluation
4
+ * of 1.27.0 (adeu 1.27.0+7a0a821). Mirrors
5
+ * python/tests/test_repro_qa_report_v9.py for the findings that live in the
6
+ * shared core engine:
7
+ *
8
+ * ADEU-QA-001 sanitize reports CLEAN while a w:docVar secret survives in
9
+ * word/settings.xml
10
+ * ADEU-QA-002 structured diff output is not replayable for paragraph
11
+ * deletions/reorderings; three mechanisms: (A) word-level
12
+ * hunks crossing paragraph boundaries, (B) full-paragraph
13
+ * deletion bleeding the deleted paragraph's style onto the
14
+ * following one, (C) virtual meta-bubble text satisfying or
15
+ * ambiguating matches
16
+ * ADEU-QA-004 a replacement's del+ins pair reads as two independent IDs;
17
+ * the Node engine additionally did NOT group-resolve pairs at
18
+ * all (accepting the deletion left the insertion pending —
19
+ * an engine-parity divergence), and follow-up actions on the
20
+ * paired id were miscounted as applied
21
+ *
22
+ * Every test fails against the commit preceding its fix.
23
+ */
24
+
25
+ import { describe, it, expect } from "vitest";
26
+ import { strFromU8, unzipSync } from "fflate";
27
+ import { createTestDocument, addParagraph } from "./test-utils.js";
28
+ import { DocumentObject } from "./docx/bridge.js";
29
+ import { RedlineEngine, BatchValidationError } from "./engine.js";
30
+ import { DocumentMapper } from "./mapper.js";
31
+ import { _extractTextFromDoc, extractTextFromBuffer } from "./ingest.js";
32
+ import {
33
+ generate_structured_edits,
34
+ generate_edits_via_paragraph_alignment,
35
+ } from "./diff.js";
36
+ import { finalize_document } from "./sanitize/core.js";
37
+ import { findAllDescendants, findChild } from "./docx/dom.js";
38
+
39
+ const SECRET = "SECRET-MATTER-4711-CONFIDENTIAL";
40
+
41
+ function cleanText(doc: DocumentObject): string {
42
+ return _extractTextFromDoc(doc, true, false) as string;
43
+ }
44
+
45
+ function structured(doc: DocumentObject): { text: string; structure: any } {
46
+ return _extractTextFromDoc(doc, true, false, false, true) as {
47
+ text: string;
48
+ structure: any;
49
+ };
50
+ }
51
+
52
+ function addStyledParagraph(
53
+ doc: DocumentObject,
54
+ text: string,
55
+ styleId: string,
56
+ ): Element {
57
+ const p = addParagraph(doc, text);
58
+ const xmlDoc = p.ownerDocument!;
59
+ const pPr = xmlDoc.createElement("w:pPr");
60
+ const pStyle = xmlDoc.createElement("w:pStyle");
61
+ pStyle.setAttribute("w:val", styleId);
62
+ pPr.appendChild(pStyle);
63
+ p.insertBefore(pPr, p.firstChild);
64
+ return p;
65
+ }
66
+
67
+ /** A document carrying exactly one tracked modification (Chg:1 + Chg:2). */
68
+ async function buildTrackedChangeDoc(): Promise<DocumentObject> {
69
+ const doc = await createTestDocument();
70
+ addParagraph(doc, "Payment is due in 30 days.");
71
+ addParagraph(doc, "Second paragraph here.");
72
+ const engine = new RedlineEngine(doc, "Editor");
73
+ engine.apply_edits([
74
+ { type: "modify", target_text: "30 days", new_text: "60 days" },
75
+ ]);
76
+ return DocumentObject.load(await doc.save());
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // ADEU-QA-001: sanitizer must not report CLEAN while w:docVar secrets remain
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("ADEU-QA-001: sanitize strips document variables", () => {
84
+ async function buildDocVarDoc(): Promise<DocumentObject> {
85
+ const doc = await createTestDocument();
86
+ addParagraph(doc, "An ordinary contract paragraph.");
87
+ const settings = doc.pkg.getPartByPath("word/settings.xml");
88
+ expect(settings).toBeTruthy();
89
+ const xmlDoc = settings!._element.ownerDocument!;
90
+ const docVars = xmlDoc.createElement("w:docVars");
91
+ const docVar = xmlDoc.createElement("w:docVar");
92
+ docVar.setAttribute("w:name", "MatterRef");
93
+ docVar.setAttribute("w:val", SECRET);
94
+ docVars.appendChild(docVar);
95
+ settings!._element.appendChild(docVars);
96
+ return doc;
97
+ }
98
+
99
+ it("full sanitize removes w:docVar values from the saved package", async () => {
100
+ const doc = await buildDocVarDoc();
101
+ const pre = await doc.save();
102
+ expect(strFromU8(unzipSync(new Uint8Array(pre))["word/settings.xml"])).toContain(
103
+ SECRET,
104
+ );
105
+
106
+ const doc2 = await DocumentObject.load(pre);
107
+ const result = await finalize_document(doc2, {
108
+ filename: "docvar.docx",
109
+ sanitize_mode: "full",
110
+ });
111
+ expect(result.outBuffer).toBeTruthy();
112
+ const members = unzipSync(new Uint8Array(result.outBuffer!));
113
+ for (const [name, bytes] of Object.entries(members)) {
114
+ expect(
115
+ strFromU8(bytes).includes(SECRET),
116
+ `secret still present in ${name}`,
117
+ ).toBe(false);
118
+ }
119
+ });
120
+
121
+ it("report names the variable but never echoes its value", async () => {
122
+ const doc = await buildDocVarDoc();
123
+ const result = await finalize_document(doc, {
124
+ filename: "docvar.docx",
125
+ sanitize_mode: "full",
126
+ });
127
+ expect(result.reportText.toLowerCase()).toContain("document variable");
128
+ expect(result.reportText).toContain("MatterRef");
129
+ expect(result.reportText).not.toContain(SECRET);
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // ADEU-QA-002: structural diffs must replay; deletions must not restyle
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("ADEU-QA-002 B: full-paragraph deletion keeps the following style", () => {
138
+ it("deleting a styled paragraph does not restyle the following one", async () => {
139
+ const doc = await createTestDocument();
140
+ addStyledParagraph(doc, "Definitions", "Heading2");
141
+ addParagraph(doc, "The first body paragraph after the heading.");
142
+ addParagraph(doc, "A second body paragraph.");
143
+
144
+ const text_o = cleanText(doc);
145
+ // The projection may or may not render "## " depending on the fixture's
146
+ // styles.xml; delete the whole first paragraph block as it projects.
147
+ const first_block = text_o.split("\n\n")[0];
148
+ const text_m = text_o.replace(first_block + "\n\n", "");
149
+
150
+ const edits = generate_edits_via_paragraph_alignment(text_o, text_m);
151
+ const engine = new RedlineEngine(doc, "QA");
152
+ engine.process_batch(edits);
153
+
154
+ expect(cleanText(doc).trim()).toBe(text_m.trim());
155
+
156
+ // The merged container must carry the FOLLOWING paragraph's properties:
157
+ // no Heading2 pStyle may survive on a paragraph whose visible text is
158
+ // the body paragraph's.
159
+ for (const p of findAllDescendants(doc.element, "w:p")) {
160
+ const pPr = findChild(p, "w:pPr");
161
+ const pStyle = pPr ? findChild(pPr, "w:pStyle") : null;
162
+ if (pStyle && pStyle.getAttribute("w:val") === "Heading2") {
163
+ throw new Error(
164
+ "the deleted heading's style bled onto the surviving paragraph",
165
+ );
166
+ }
167
+ }
168
+ });
169
+
170
+ it("structured diff replays a first-paragraph deletion exactly", async () => {
171
+ const orig = await createTestDocument();
172
+ addStyledParagraph(orig, "Definitions", "Heading2");
173
+ addParagraph(orig, "The first body paragraph after the heading.");
174
+ addParagraph(orig, "A second body paragraph.");
175
+
176
+ const mod = await createTestDocument();
177
+ addParagraph(mod, "The first body paragraph after the heading.");
178
+ addParagraph(mod, "A second body paragraph.");
179
+
180
+ const { text: text_o, structure: struct_o } = structured(orig);
181
+ const { text: text_m, structure: struct_m } = structured(mod);
182
+ const { edits } = generate_structured_edits(
183
+ text_o,
184
+ struct_o,
185
+ text_m,
186
+ struct_m,
187
+ );
188
+
189
+ // Node diff output keeps pins through JSON (plain properties).
190
+ const replayed = JSON.parse(JSON.stringify(edits));
191
+ const engine = new RedlineEngine(orig, "QA");
192
+ engine.process_batch(replayed);
193
+ expect(cleanText(orig).trim()).toBe(text_m.trim());
194
+ });
195
+
196
+ it("structured diff replays an adjacent paragraph swap exactly", async () => {
197
+ const paras = [
198
+ "Clause one sets the scene for the agreement.",
199
+ "Clause two lists the payment obligations of the parties.",
200
+ "Clause three covers termination and remedies at law.",
201
+ "Clause four is the confidentiality clause.",
202
+ ];
203
+ const orig = await createTestDocument();
204
+ for (const p of paras) addParagraph(orig, p);
205
+ const swapped = [paras[0], paras[2], paras[1], paras[3]];
206
+ const mod = await createTestDocument();
207
+ for (const p of swapped) addParagraph(mod, p);
208
+
209
+ const { text: text_o, structure: struct_o } = structured(orig);
210
+ const { text: text_m, structure: struct_m } = structured(mod);
211
+ const { edits } = generate_structured_edits(
212
+ text_o,
213
+ struct_o,
214
+ text_m,
215
+ struct_m,
216
+ );
217
+
218
+ const engine = new RedlineEngine(orig, "QA");
219
+ engine.process_batch(JSON.parse(JSON.stringify(edits)));
220
+ expect(cleanText(orig).trim()).toBe(text_m.trim());
221
+ });
222
+ });
223
+
224
+ describe("ADEU-QA-002 C: virtual projection text never matches", () => {
225
+ it("a target existing only in a meta bubble is not found", async () => {
226
+ const doc = await buildTrackedChangeDoc();
227
+ const engine = new RedlineEngine(doc, "Reviewer");
228
+ // "Editor" appears ONLY inside the {>>[Chg:1 delete] Editor ...<<}
229
+ // bubble — never in document text.
230
+ expect(() =>
231
+ engine.process_batch([
232
+ { type: "modify", target_text: "Editor", new_text: "X" },
233
+ ]),
234
+ ).toThrow(/not found/i);
235
+ });
236
+
237
+ it("a digit unique in body text resolves despite bubble timestamps", async () => {
238
+ const doc = await createTestDocument();
239
+ addParagraph(doc, "Clause 7 applies to this agreement.");
240
+ addParagraph(doc, "Another paragraph.");
241
+ const engine = new RedlineEngine(doc, "Editor");
242
+ engine.process_batch([
243
+ {
244
+ type: "modify",
245
+ target_text: "Another",
246
+ new_text: "A different",
247
+ comment: "Reviewed on 2026-07-19",
248
+ },
249
+ ]);
250
+ const doc2 = await DocumentObject.load(await doc.save());
251
+
252
+ const engine2 = new RedlineEngine(doc2, "Reviewer");
253
+ const stats = engine2.process_batch([
254
+ { type: "modify", target_text: "7", new_text: "9" },
255
+ ]);
256
+ expect(stats.edits_applied).toBe(1);
257
+ expect(cleanText(doc2)).toContain("Clause 9 applies");
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // ADEU-QA-004: replacement del+ins pairs — parity, linkage, counts
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe("ADEU-QA-004: replacement pair semantics", () => {
266
+ it("projection annotates paired changes in both ingest and mapper", async () => {
267
+ const doc = await buildTrackedChangeDoc();
268
+ const raw = await extractTextFromBuffer(await doc.save());
269
+ expect(raw).toContain("[Chg:1 delete] Editor (pairs with Chg:2)");
270
+ expect(raw).toContain("[Chg:2 insert] Editor (pairs with Chg:1)");
271
+
272
+ const doc2 = await DocumentObject.load(await doc.save());
273
+ const mapper = new DocumentMapper(doc2);
274
+ const rawNoAppendix = _extractTextFromDoc(doc2, false, false) as string;
275
+ expect(mapper.full_text).toBe(rawNoAppendix);
276
+ });
277
+
278
+ it("standalone changes carry no pairing annotation", async () => {
279
+ const doc = await createTestDocument();
280
+ addParagraph(doc, "Payment is due in 30 days.");
281
+ const engine = new RedlineEngine(doc, "Editor");
282
+ engine.apply_edits([
283
+ { type: "modify", target_text: "Payment is due in 30 days.", new_text: "" },
284
+ ]);
285
+ const raw = await extractTextFromBuffer(await doc.save());
286
+ expect(raw).not.toContain("pairs with");
287
+ });
288
+
289
+ it("accepting the deletion side resolves the paired insertion (engine parity)", async () => {
290
+ const doc = await buildTrackedChangeDoc();
291
+ const engine = new RedlineEngine(doc, "Reviewer");
292
+ const stats = engine.process_batch([
293
+ { type: "accept", target_id: "Chg:1" },
294
+ ]);
295
+ expect(stats.actions_applied).toBe(1);
296
+
297
+ const final = cleanText(doc);
298
+ expect(final).toContain("60 days");
299
+ expect(final).not.toContain("30 days");
300
+ // The paired insertion must be fully resolved, not left pending.
301
+ expect(findAllDescendants(doc.element, "w:ins").length).toBe(0);
302
+ expect(findAllDescendants(doc.element, "w:del").length).toBe(0);
303
+ });
304
+
305
+ it("accepting both sides counts one state transition", async () => {
306
+ const doc = await buildTrackedChangeDoc();
307
+ const engine = new RedlineEngine(doc, "Reviewer");
308
+ const stats = engine.process_batch([
309
+ { type: "accept", target_id: "Chg:1" },
310
+ { type: "accept", target_id: "Chg:2" },
311
+ ]);
312
+ expect(stats.actions_applied).toBe(1);
313
+ expect(stats.actions_already_resolved).toBe(1);
314
+ expect(stats.actions_skipped).toBe(0);
315
+ const details = (stats.skipped_details || []).join("\n").toLowerCase();
316
+ expect(details).toContain("already resolved");
317
+
318
+ const final = cleanText(doc);
319
+ expect(final).toContain("60 days");
320
+ expect(final).not.toContain("30 days");
321
+ });
322
+
323
+ it("rejecting both sides counts one state transition", async () => {
324
+ const doc = await buildTrackedChangeDoc();
325
+ const engine = new RedlineEngine(doc, "Reviewer");
326
+ const stats = engine.process_batch([
327
+ { type: "reject", target_id: "Chg:1" },
328
+ { type: "reject", target_id: "Chg:2" },
329
+ ]);
330
+ expect(stats.actions_applied).toBe(1);
331
+ expect(stats.actions_already_resolved).toBe(1);
332
+
333
+ const final = cleanText(doc);
334
+ expect(final).toContain("30 days");
335
+ expect(final).not.toContain("60 days");
336
+ });
337
+
338
+ it("contradictory actions on one pair are rejected up front", async () => {
339
+ const doc = await buildTrackedChangeDoc();
340
+ const engine = new RedlineEngine(doc, "Reviewer");
341
+ let error: any = null;
342
+ try {
343
+ engine.process_batch([
344
+ { type: "accept", target_id: "Chg:1" },
345
+ { type: "reject", target_id: "Chg:2" },
346
+ ]);
347
+ } catch (e) {
348
+ error = e;
349
+ }
350
+ expect(error).toBeInstanceOf(BatchValidationError);
351
+ const msg = (error.errors || []).join("\n");
352
+ expect(msg).toContain("Chg:1");
353
+ expect(msg).toContain("Chg:2");
354
+ expect(msg.toLowerCase()).toContain("pair");
355
+ // Transactional: nothing may have been resolved.
356
+ expect(findAllDescendants(doc.element, "w:ins").length).toBeGreaterThan(0);
357
+ expect(findAllDescendants(doc.element, "w:del").length).toBeGreaterThan(0);
358
+ });
359
+
360
+ it("independent changes still resolve independently", async () => {
361
+ const doc = await createTestDocument();
362
+ addParagraph(doc, "First paragraph mentioning alpha.");
363
+ addParagraph(doc, "Second paragraph mentioning beta.");
364
+ const engine = new RedlineEngine(doc, "Editor");
365
+ engine.apply_edits([
366
+ { type: "modify", target_text: "alpha", new_text: "ALPHA" },
367
+ ]);
368
+ engine.apply_edits([
369
+ { type: "modify", target_text: "beta", new_text: "BETA" },
370
+ ]);
371
+ const doc2 = await DocumentObject.load(await doc.save());
372
+
373
+ const raw = await extractTextFromBuffer(await doc2.save());
374
+ const del_ids = [...raw.matchAll(/\[Chg:(\d+) delete\]/g)].map(
375
+ (m) => m[1],
376
+ );
377
+ expect(del_ids.length).toBe(2);
378
+
379
+ const engine2 = new RedlineEngine(doc2, "Reviewer");
380
+ const stats = engine2.process_batch([
381
+ { type: "accept", target_id: `Chg:${del_ids[0]}` },
382
+ { type: "reject", target_id: `Chg:${del_ids[1]}` },
383
+ ]);
384
+ expect(stats.actions_applied).toBe(2);
385
+ expect(stats.actions_already_resolved || 0).toBe(0);
386
+
387
+ const final = cleanText(doc2);
388
+ expect(final).toContain("ALPHA");
389
+ expect(final).toContain("beta");
390
+ expect(final).not.toContain("BETA");
391
+ });
392
+ });
@@ -69,6 +69,7 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
69
69
  report.add_transform_lines(transforms.scrub_timestamps(doc));
70
70
  report.add_transform_lines(transforms.strip_custom_xml(doc));
71
71
  report.add_transform_lines(transforms.strip_custom_properties(doc));
72
+ report.add_transform_lines(transforms.strip_document_variables(doc));
72
73
  report.add_transform_lines(transforms.strip_image_alt_text(doc));
73
74
 
74
75
  const warnings = transforms.audit_hyperlinks(doc);
@@ -181,6 +182,16 @@ export function verifySanitizedPackage(outBuffer: Buffer): void {
181
182
  }
182
183
  }
183
184
  }
185
+ if (names.includes('word/settings.xml')) {
186
+ // Document variables are invisible metadata (QA ADEU-QA-001): a surviving
187
+ // w:docVar means the strip transform silently failed.
188
+ const settings = parseXml(strFromU8(unzipped['word/settings.xml']));
189
+ if (
190
+ transforms.findDescendantsByLocalName(settings.documentElement! as unknown as Element, 'docVar').length > 0
191
+ ) {
192
+ problems.push('word/settings.xml still contains document variables (w:docVar)');
193
+ }
194
+ }
184
195
 
185
196
  if (problems.length > 0) {
186
197
  throw new Error(