@adeu/core 1.22.0 → 1.25.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,1244 @@
1
+ // FILE: src/repro_qa_report_2026_07_18.test.ts
2
+ //
3
+ // Node-side regression tests for the 2026-07-18 black-box QA report
4
+ // (adeu 1.22.0). The report was executed against the Python CLI; per the
5
+ // "Make Both Perfect" principle every engine-level finding is ported and
6
+ // verified here too (CLI-only findings H2/M2/M8/L2-L6 have no Node surface):
7
+ //
8
+ // C1 cross-part edits — the flattened projection let diff/apply write a
9
+ // new final body paragraph into word/footer1.xml
10
+ // C2 DOCX-to-DOCX table row changes became pipe-text writes into one cell
11
+ // H1 the read-only structural appendix leaked into diff extraction
12
+ // H4 comments anchored in footers/footnotes produce files Word/LibreOffice
13
+ // cannot open
14
+ // M1 markup did not honor apply's semantics (regex, match_mode, failures)
15
+ // M3 VML watermarks survived sanitize unreported (ported with H3)
16
+ // M4 style-based lists (List Bullet / List Number) lost list semantics
17
+ // M5 inline images disappeared from the projection
18
+ // M6 reserved separator footnotes with untyped ids surfaced as [^fn--1]
19
+ // M7 only the first quoted defined term per paragraph was captured
20
+ // L1 verified only: Node's no-match advice names real MCP parameters
21
+
22
+ import { describe, it, expect } from "vitest";
23
+ import {
24
+ createTestDocument,
25
+ addParagraph,
26
+ addTable,
27
+ setCellText,
28
+ } from "./test-utils.js";
29
+ import { DocumentObject } from "./docx/bridge.js";
30
+ import { serializeXml } from "./docx/dom.js";
31
+ import { RedlineEngine, BatchValidationError, validate_edit_strings } from "./engine.js";
32
+ import { DocumentMapper } from "./mapper.js";
33
+ import {
34
+ extractTextFromBuffer,
35
+ _extractTextFromDoc,
36
+ } from "./ingest.js";
37
+ import {
38
+ generate_edits_from_text,
39
+ generate_structured_edits,
40
+ } from "./diff.js";
41
+ import { apply_edits_to_markdown } from "./markup.js";
42
+ import {
43
+ extract_all_domain_metadata,
44
+ extract_terms_from_paragraph,
45
+ } from "./domain.js";
46
+ import { detect_watermarks } from "./sanitize/transforms.js";
47
+ import { finalize_document } from "./sanitize/core.js";
48
+
49
+ const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
50
+
51
+ const CT_HEADER =
52
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
53
+ const CT_FOOTER =
54
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml";
55
+ const CT_FOOTNOTES =
56
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml";
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Fixture builders (raw OOXML node injection into the initial.docx fixture,
60
+ // mirroring python/tests/test_repro_qa_2026_07_18.py builders)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Header + body + footer document. Mirrors Python's build_header_footer_doc. */
64
+ async function buildHeaderFooterDoc(
65
+ extraBodyParagraph = false,
66
+ ): Promise<DocumentObject> {
67
+ const doc = await createTestDocument();
68
+ doc.pkg.addPart(
69
+ "/word/header1.xml",
70
+ CT_HEADER,
71
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
72
+ `<w:hdr xmlns:w="${W_NS}"><w:p><w:r><w:t xml:space="preserve">HEADER MARKER</w:t></w:r></w:p></w:hdr>`,
73
+ );
74
+ doc.pkg.addPart(
75
+ "/word/footer1.xml",
76
+ CT_FOOTER,
77
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
78
+ `<w:ftr xmlns:w="${W_NS}"><w:p><w:r><w:t xml:space="preserve">FOOTER MARKER</w:t></w:r></w:p></w:ftr>`,
79
+ );
80
+ addParagraph(doc, "Body paragraph one.");
81
+ if (extraBodyParagraph) addParagraph(doc, "New final body paragraph.");
82
+ return doc;
83
+ }
84
+
85
+ function footerXml(doc: DocumentObject): string {
86
+ const part = doc.pkg.getPartByPath("word/footer1.xml");
87
+ return part ? serializeXml(part._element) : "";
88
+ }
89
+
90
+ function bodyXml(doc: DocumentObject): string {
91
+ return serializeXml(doc.pkg.mainDocumentPart._element);
92
+ }
93
+
94
+ const FOOTNOTE_SEP_TYPED =
95
+ '<w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote>';
96
+ const FOOTNOTE_CONT_TYPED =
97
+ '<w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:r><w:continuationSeparator/></w:r></w:p></w:footnote>';
98
+ // Reserved notes as emitted by some generators: ids -1/0 but NO w:type attribute.
99
+ const FOOTNOTE_SEP_UNTYPED =
100
+ '<w:footnote w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote>';
101
+ const FOOTNOTE_CONT_UNTYPED =
102
+ '<w:footnote w:id="0"><w:p><w:r><w:continuationSeparator/></w:r></w:p></w:footnote>';
103
+
104
+ /**
105
+ * A body paragraph carrying footnote 1, plus separator/continuation notes.
106
+ * Mirrors Python's build_footnote_doc.
107
+ */
108
+ async function buildFootnoteDoc(
109
+ typedSeparators = true,
110
+ noteText = "This is a QA footnote about governing law.",
111
+ ): Promise<DocumentObject> {
112
+ const doc = await createTestDocument();
113
+ const p = addParagraph(doc, "The governing law clause");
114
+ const xmlDoc = doc.element.ownerDocument!;
115
+ const r = xmlDoc.createElement("w:r");
116
+ const ref = xmlDoc.createElement("w:footnoteReference");
117
+ ref.setAttribute("w:id", "1");
118
+ r.appendChild(ref);
119
+ p.appendChild(r);
120
+ addParagraph(doc, "Second body paragraph.");
121
+
122
+ const sep = typedSeparators ? FOOTNOTE_SEP_TYPED : FOOTNOTE_SEP_UNTYPED;
123
+ const cont = typedSeparators ? FOOTNOTE_CONT_TYPED : FOOTNOTE_CONT_UNTYPED;
124
+ doc.pkg.addPart(
125
+ "/word/footnotes.xml",
126
+ CT_FOOTNOTES,
127
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
128
+ `<w:footnotes xmlns:w="${W_NS}">${sep}${cont}` +
129
+ `<w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>` +
130
+ `<w:r><w:t xml:space="preserve"> ${noteText}</w:t></w:r></w:p></w:footnote>` +
131
+ `</w:footnotes>`,
132
+ );
133
+ return doc;
134
+ }
135
+
136
+ function footnotesXml(doc: DocumentObject): string {
137
+ const part = doc.pkg.getPartByPath("word/footnotes.xml");
138
+ return part ? serializeXml(part._element) : "";
139
+ }
140
+
141
+ /** SLA table document. Mirrors Python's build_table_doc. */
142
+ async function buildTableDoc(
143
+ opts: { extraRow?: boolean; dropMiddleRow?: boolean } = {},
144
+ ): Promise<DocumentObject> {
145
+ const doc = await createTestDocument();
146
+ addParagraph(doc, "Service Level Agreement");
147
+ let rows = [
148
+ ["Service", "Uptime", "Price"],
149
+ ["Standard support", "99.0%", "EUR 1,000"],
150
+ ["Premium support", "99.9%", "EUR 2,000"],
151
+ ];
152
+ if (opts.dropMiddleRow) rows = [rows[0], rows[2]];
153
+ if (opts.extraRow) rows = [...rows, ["Backup service", "99.5%", "EUR 500"]];
154
+ const tbl = addTable(doc, rows.length, 3);
155
+ // addTable appends after the trailing paragraph; rebuild order: para, table, para.
156
+ for (let i = 0; i < rows.length; i++) {
157
+ for (let j = 0; j < 3; j++) setCellText(tbl, i, j, rows[i][j]);
158
+ }
159
+ addParagraph(doc, "Documentation: see appendix.");
160
+ return doc;
161
+ }
162
+
163
+ /** Extracts { text, structure } via the structured extraction API. */
164
+ function extractWithStructure(doc: DocumentObject): { text: string; structure: any } {
165
+ return (_extractTextFromDoc as any)(doc, true, false, false, true);
166
+ }
167
+
168
+ /** Body paragraph with an inline image (wp:docPr id=7, descr+title). */
169
+ async function buildImageDoc(): Promise<DocumentObject> {
170
+ const doc = await createTestDocument();
171
+ addParagraph(doc, "Before image.");
172
+ const xmlDoc = doc.element.ownerDocument!;
173
+ const p = xmlDoc.createElement("w:p");
174
+ const r = xmlDoc.createElement("w:r");
175
+ const drawing = xmlDoc.createElement("w:drawing");
176
+ const inline = xmlDoc.createElement("wp:inline");
177
+ const docPr = xmlDoc.createElement("wp:docPr");
178
+ docPr.setAttribute("id", "7");
179
+ docPr.setAttribute("name", "Picture 7");
180
+ docPr.setAttribute("descr", "Red rectangle QA diagram");
181
+ docPr.setAttribute("title", "Red rectangle QA diagram");
182
+ inline.appendChild(docPr);
183
+ drawing.appendChild(inline);
184
+ r.appendChild(drawing);
185
+ p.appendChild(r);
186
+ doc.element.appendChild(p);
187
+ addParagraph(doc, "After image.");
188
+ return doc;
189
+ }
190
+
191
+ /** List Bullet / List Number styles resolved via styles.xml + numbering.xml. */
192
+ async function buildStyleListDoc(): Promise<DocumentObject> {
193
+ const doc = await createTestDocument();
194
+ const stylesPart = doc.pkg.getPartByPath("word/styles.xml")!;
195
+ const sDoc = stylesPart._element.ownerDocument!;
196
+ const addListStyle = (styleId: string, name: string, numId: string) => {
197
+ const style = sDoc.createElement("w:style");
198
+ style.setAttribute("w:type", "paragraph");
199
+ style.setAttribute("w:styleId", styleId);
200
+ const nameEl = sDoc.createElement("w:name");
201
+ nameEl.setAttribute("w:val", name);
202
+ style.appendChild(nameEl);
203
+ const basedOn = sDoc.createElement("w:basedOn");
204
+ basedOn.setAttribute("w:val", "Normal");
205
+ style.appendChild(basedOn);
206
+ const pPr = sDoc.createElement("w:pPr");
207
+ const numPr = sDoc.createElement("w:numPr");
208
+ const ilvl = sDoc.createElement("w:ilvl");
209
+ ilvl.setAttribute("w:val", "0");
210
+ const numIdEl = sDoc.createElement("w:numId");
211
+ numIdEl.setAttribute("w:val", numId);
212
+ numPr.appendChild(ilvl);
213
+ numPr.appendChild(numIdEl);
214
+ pPr.appendChild(numPr);
215
+ style.appendChild(pPr);
216
+ stylesPart._element.appendChild(style);
217
+ };
218
+ addListStyle("ListBullet", "List Bullet", "1");
219
+ addListStyle("ListNumber", "List Number", "2");
220
+
221
+ doc.pkg.addPart(
222
+ "/word/numbering.xml",
223
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
224
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
225
+ `<w:numbering xmlns:w="${W_NS}">` +
226
+ `<w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/></w:lvl></w:abstractNum>` +
227
+ `<w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/></w:lvl></w:abstractNum>` +
228
+ `<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>` +
229
+ `<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>` +
230
+ `</w:numbering>`,
231
+ );
232
+
233
+ const addStyledParagraph = (text: string, styleId: string) => {
234
+ const p = addParagraph(doc, text);
235
+ const xmlDoc = doc.element.ownerDocument!;
236
+ const pPr = xmlDoc.createElement("w:pPr");
237
+ const pStyle = xmlDoc.createElement("w:pStyle");
238
+ pStyle.setAttribute("w:val", styleId);
239
+ pPr.appendChild(pStyle);
240
+ p.insertBefore(pPr, p.firstChild);
241
+ };
242
+
243
+ addParagraph(doc, "Requirements:");
244
+ addStyledParagraph("Maintain ISO 27001 controls", "ListBullet");
245
+ addStyledParagraph("Notify incidents without undue delay", "ListBullet");
246
+ addParagraph(doc, "Escalation:");
247
+ addStyledParagraph("First escalation", "ListNumber");
248
+ addStyledParagraph("Second escalation", "ListNumber");
249
+ return doc;
250
+ }
251
+
252
+ /** Header with a VML watermark shape (v:textpath). Mirrors build_watermark_doc. */
253
+ async function buildWatermarkDoc(): Promise<DocumentObject> {
254
+ const doc = await createTestDocument();
255
+ addParagraph(doc, "Confidential body.");
256
+ doc.pkg.addPart(
257
+ "/word/header1.xml",
258
+ CT_HEADER,
259
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
260
+ `<w:hdr xmlns:w="${W_NS}" xmlns:v="urn:schemas-microsoft-com:vml">` +
261
+ `<w:p><w:r><w:t>Header text</w:t></w:r></w:p>` +
262
+ `<w:p><w:r><w:pict>` +
263
+ `<v:shape id="PowerPlusWaterMarkObject" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt">` +
264
+ `<v:textpath style="font-family:Calibri" string="DRAFT ACME SECRET"/>` +
265
+ `</v:shape>` +
266
+ `</w:pict></w:r></w:p></w:hdr>`,
267
+ );
268
+ return doc;
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // C1 — cross-part (body/footer) edits
273
+ // ---------------------------------------------------------------------------
274
+
275
+ describe("C1: OPC part boundaries", () => {
276
+ it("mapper records part ranges, kinds and boundaries", async () => {
277
+ const doc = await buildHeaderFooterDoc();
278
+ const mapper = new DocumentMapper(doc);
279
+
280
+ const kinds = (mapper as any).part_ranges
281
+ .filter(([s, e]: [number, number, string]) => e > s)
282
+ .map(([, , k]: [number, number, string]) => k);
283
+ expect(kinds).toEqual(["header", "body", "footer"]);
284
+
285
+ const full = mapper.full_text;
286
+ expect(full).toBe("HEADER MARKER\n\nBody paragraph one.\n\nFOOTER MARKER");
287
+
288
+ expect((mapper as any).part_kind_at(full.indexOf("HEADER"))).toBe("header");
289
+ expect((mapper as any).part_kind_at(full.indexOf("Body"))).toBe("body");
290
+ expect((mapper as any).part_kind_at(full.indexOf("FOOTER"))).toBe("footer");
291
+
292
+ // Inside the separator between body and footer (prev_end < idx <= next_start).
293
+ const bodyEnd = full.indexOf("one.") + "one.".length;
294
+ const footerStart = full.indexOf("FOOTER MARKER");
295
+ expect((mapper as any).part_boundary_at(bodyEnd)).toBeNull();
296
+ const boundary = (mapper as any).part_boundary_at(footerStart);
297
+ expect(boundary).not.toBeNull();
298
+ const [prevIdx, nextIdx] = boundary!;
299
+ expect((mapper as any).part_kind_of(prevIdx)).toBe("body");
300
+ expect((mapper as any).part_kind_of(nextIdx)).toBe("footer");
301
+
302
+ // Every real span carries the part it was projected from.
303
+ for (const s of mapper.spans) {
304
+ if (s.run === null) continue;
305
+ if (s.text.includes("FOOTER")) {
306
+ expect((s as any).part_index).toBe(nextIdx);
307
+ }
308
+ if (s.text.includes("Body paragraph")) {
309
+ expect((s as any).part_index).toBe(prevIdx);
310
+ }
311
+ }
312
+ });
313
+
314
+ it("validate_edits rejects a replacement spanning the body/footer wall", async () => {
315
+ const doc = await buildHeaderFooterDoc();
316
+ const engine = new RedlineEngine(doc);
317
+ const errors = engine.validate_edits([
318
+ {
319
+ type: "modify",
320
+ target_text: "paragraph one.\n\nFOOTER MARKER",
321
+ new_text: "paragraph two.\n\nALTERED FOOTER",
322
+ },
323
+ ]);
324
+ expect(errors.length).toBeGreaterThan(0);
325
+ expect(errors.join("\n")).toContain(
326
+ "spans a structural document-part boundary (body → footer)",
327
+ );
328
+ });
329
+
330
+ it("structured diff + apply keeps the new final paragraph in the body", async () => {
331
+ const orig = await buildHeaderFooterDoc();
332
+ const mod = await buildHeaderFooterDoc(true);
333
+
334
+ const o = extractWithStructure(orig);
335
+ const m = extractWithStructure(mod);
336
+ const { edits, warnings } = generate_structured_edits(
337
+ o.text,
338
+ o.structure,
339
+ m.text,
340
+ m.structure,
341
+ );
342
+ expect(warnings).toEqual([]);
343
+ expect(edits.length).toBeGreaterThan(0);
344
+ // No edit may target text on both sides of the body/footer wall.
345
+ for (const e of edits as any[]) {
346
+ const tgt = e.target_text || "";
347
+ expect(tgt.includes("FOOTER") && tgt.includes("one.")).toBe(false);
348
+ }
349
+
350
+ const applyDoc = await buildHeaderFooterDoc();
351
+ const engine = new RedlineEngine(applyDoc);
352
+ engine.process_batch(edits as any[]);
353
+
354
+ expect(bodyXml(applyDoc)).toContain("New final body paragraph.");
355
+ expect(footerXml(applyDoc)).not.toContain("New final body paragraph.");
356
+
357
+ const clean = await extractTextFromBuffer(await applyDoc.save(), true);
358
+ expect(clean).toContain("Body paragraph one.\n\nNew final body paragraph.");
359
+ expect(clean).not.toContain("New final body paragraph.FOOTER");
360
+ });
361
+
362
+ it("a plain insertion pinned at the footer start re-anchors to the body", async () => {
363
+ const doc = await buildHeaderFooterDoc();
364
+ const engine = new RedlineEngine(doc);
365
+ const footerStart = engine.mapper.full_text.indexOf("FOOTER MARKER");
366
+
367
+ const [applied, skipped] = engine.apply_edits([
368
+ {
369
+ type: "modify",
370
+ target_text: "",
371
+ new_text: "New final body paragraph.",
372
+ _match_start_index: footerStart,
373
+ },
374
+ ]);
375
+ expect(applied).toBe(1);
376
+ expect(skipped).toBe(0);
377
+
378
+ expect(bodyXml(doc)).toContain("New final body paragraph.");
379
+ expect(footerXml(doc)).not.toContain("New final body paragraph.");
380
+
381
+ const clean = await extractTextFromBuffer(await doc.save(), true);
382
+ expect(clean).toContain("Body paragraph one.\n\nNew final body paragraph.");
383
+ expect(clean).toContain("FOOTER MARKER");
384
+ });
385
+
386
+ it("apply-level backstop: a pinned modification may never mutate two parts", async () => {
387
+ const doc = await buildHeaderFooterDoc();
388
+ const engine = new RedlineEngine(doc);
389
+ const full = engine.mapper.full_text;
390
+ const start = full.indexOf("one.");
391
+ const target = full.substring(start, full.indexOf("MARKER", start) + "MARKER".length);
392
+ expect(target).toContain("FOOTER"); // sanity: really crosses the wall
393
+
394
+ const [applied, skipped] = engine.apply_edits([
395
+ {
396
+ type: "modify",
397
+ target_text: target,
398
+ new_text: "",
399
+ _match_start_index: start,
400
+ },
401
+ ]);
402
+ expect(applied).toBe(0);
403
+ expect(skipped).toBe(1);
404
+ expect(footerXml(doc)).toContain("FOOTER MARKER");
405
+ });
406
+ });
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // C2 — structured table row operations
410
+ // ---------------------------------------------------------------------------
411
+
412
+ describe("C2: table row round trip", () => {
413
+ it("structured extraction reports part ranges and table row geometry", async () => {
414
+ const doc = await buildTableDoc();
415
+ const { text, structure } = extractWithStructure(doc);
416
+ expect(structure.part_ranges.length).toBe(1);
417
+ expect(structure.part_ranges[0][2]).toBe("body");
418
+ expect(structure.tables.length).toBe(1);
419
+ const table = structure.tables[0];
420
+ expect(table.rows.length).toBe(3);
421
+ expect(table.rows[0].cells).toEqual(["Service", "Uptime", "Price"]);
422
+ for (const row of table.rows) {
423
+ expect(text.substring(row.start, row.end)).toBe(row.cells.join(" | "));
424
+ }
425
+ });
426
+
427
+ it("diff emits insert_row for an added row (below anchor, no pipe smuggling)", async () => {
428
+ const orig = await buildTableDoc();
429
+ const mod = await buildTableDoc({ extraRow: true });
430
+ const o = extractWithStructure(orig);
431
+ const m = extractWithStructure(mod);
432
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
433
+
434
+ const rowOps = (edits as any[]).filter((e) => e.type === "insert_row");
435
+ expect(rowOps.length).toBe(1);
436
+ expect(rowOps[0].cells).toEqual(["Backup service", "99.5%", "EUR 500"]);
437
+ expect(rowOps[0].position).toBe("below");
438
+ expect(rowOps[0].target_text).toBe("Premium support | 99.9% | EUR 2,000");
439
+ for (const e of edits as any[]) {
440
+ if (e.type === "modify") {
441
+ expect(e.new_text || "").not.toContain("Backup service | 99.5%");
442
+ }
443
+ }
444
+ });
445
+
446
+ it("row addition round-trips through the engine", async () => {
447
+ const orig = await buildTableDoc();
448
+ const mod = await buildTableDoc({ extraRow: true });
449
+ const o = extractWithStructure(orig);
450
+ const m = extractWithStructure(mod);
451
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
452
+
453
+ const engine = new RedlineEngine(orig);
454
+ const res = engine.process_batch(edits as any[]);
455
+ expect(res.edits_applied).toBeGreaterThan(0);
456
+ expect(res.edits_skipped).toBe(0);
457
+
458
+ const buf = await orig.save();
459
+ const clean = await extractTextFromBuffer(buf, true, false);
460
+ expect(clean).toContain(
461
+ "Premium support | 99.9% | EUR 2,000\nBackup service | 99.5% | EUR 500",
462
+ );
463
+
464
+ // Follow-up structured diff must converge to zero edits.
465
+ const applied = await DocumentObject.load(buf);
466
+ const a = extractWithStructure(applied);
467
+ const again = generate_structured_edits(a.text, a.structure, m.text, m.structure);
468
+ expect(again.edits).toEqual([]);
469
+ });
470
+
471
+ it("diff emits delete_row for a removed row and round-trips", async () => {
472
+ const orig = await buildTableDoc();
473
+ const mod = await buildTableDoc({ dropMiddleRow: true });
474
+ const o = extractWithStructure(orig);
475
+ const m = extractWithStructure(mod);
476
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
477
+
478
+ const rowOps = (edits as any[]).filter((e) => e.type === "delete_row");
479
+ expect(rowOps.length).toBe(1);
480
+ expect(rowOps[0].target_text).toBe("Standard support | 99.0% | EUR 1,000");
481
+
482
+ const engine = new RedlineEngine(orig);
483
+ engine.process_batch(edits as any[]);
484
+ const buf = await orig.save();
485
+ const clean = await extractTextFromBuffer(buf, true, false);
486
+ expect(clean).not.toContain("Standard support");
487
+
488
+ const applied = await DocumentObject.load(buf);
489
+ const a = extractWithStructure(applied);
490
+ const again = generate_structured_edits(a.text, a.structure, m.text, m.structure);
491
+ expect(again.edits).toEqual([]);
492
+ });
493
+
494
+ it("a 1:1 modified row still diffs as a text edit, not a row op", async () => {
495
+ const orig = await buildTableDoc();
496
+ const mod = await buildTableDoc();
497
+ // EUR 1,000 -> EUR 1,250 in the modified doc.
498
+ const tbl = mod.element.getElementsByTagName("w:tbl")[0];
499
+ setCellText(tbl, 1, 2, "EUR 1,250");
500
+
501
+ const o = extractWithStructure(orig);
502
+ const m = extractWithStructure(mod);
503
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
504
+ expect((edits as any[]).every((e) => e.type === "modify")).toBe(true);
505
+
506
+ const engine = new RedlineEngine(orig);
507
+ engine.process_batch(edits as any[]);
508
+ const clean = await extractTextFromBuffer(await orig.save(), true, false);
509
+ expect(clean).toContain("Standard support | 99.0% | EUR 1,250");
510
+ });
511
+
512
+ it("validate_edits rejects a replacement smuggling a pipe row into a table", async () => {
513
+ const doc = await buildTableDoc();
514
+ const engine = new RedlineEngine(doc);
515
+ const errors = engine.validate_edits([
516
+ {
517
+ type: "modify",
518
+ target_text: "2,000\n\nDocumentation:",
519
+ new_text: "2,000\nBackup service | 99.5% | EUR 500\n\nDocumentation:",
520
+ },
521
+ ]);
522
+ expect(errors.length).toBeGreaterThan(0);
523
+ expect(errors.join("\n")).toContain("insert_row");
524
+ });
525
+
526
+ it("apply-level backstop: a pinned insertion of row-shaped text is refused", async () => {
527
+ const doc = await buildTableDoc();
528
+ const engine = new RedlineEngine(doc);
529
+ // Pinned inside the table (an insert-above shape from a stale diff).
530
+ const idx = engine.mapper.full_text.indexOf("Premium support");
531
+ const [applied, skipped] = engine.apply_edits([
532
+ {
533
+ type: "modify",
534
+ target_text: "",
535
+ new_text: "Backup service | 99.5% | EUR 500\n",
536
+ _match_start_index: idx,
537
+ },
538
+ ]);
539
+ expect(applied).toBe(0);
540
+ expect(skipped).toBe(1);
541
+ });
542
+ });
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // H1 — appendix leakage into diff extraction
546
+ // ---------------------------------------------------------------------------
547
+
548
+ async function buildDefinedTermsBuffer(extraUses = false): Promise<Buffer> {
549
+ const doc = await createTestDocument();
550
+ addParagraph(
551
+ doc,
552
+ '"Agreement" means this service agreement between the parties.',
553
+ );
554
+ addParagraph(doc, "The Agreement enters into force upon signature.");
555
+ if (extraUses) {
556
+ addParagraph(doc, "Termination of the Agreement requires notice.");
557
+ addParagraph(doc, "Amendments to the Agreement must be written.");
558
+ }
559
+ return doc.save();
560
+ }
561
+
562
+ describe("H1: the structural appendix must not leak into diff", () => {
563
+ it("extractTextFromBuffer(buf, clean, includeAppendix=false) omits the appendix", async () => {
564
+ const buf = await buildDefinedTermsBuffer();
565
+ const withAppendix = await extractTextFromBuffer(buf, true);
566
+ expect(withAppendix).toContain("Document Structure (Read-Only)");
567
+ const without = await extractTextFromBuffer(buf, true, false);
568
+ expect(without).not.toContain("Document Structure");
569
+ expect(without).not.toContain("— used ");
570
+ });
571
+
572
+ it("a usage-count-only change produces no appendix edits", async () => {
573
+ const origBuf = await buildDefinedTermsBuffer();
574
+ const modBuf = await buildDefinedTermsBuffer(true);
575
+ const textOrig = await extractTextFromBuffer(origBuf, true, false);
576
+ const textMod = await extractTextFromBuffer(modBuf, true, false);
577
+ const edits = generate_edits_from_text(textOrig, textMod);
578
+ expect(edits.length).toBeGreaterThan(0);
579
+ for (const e of edits) {
580
+ const joined = (e.target_text || "") + (e.new_text || "");
581
+ expect(joined).not.toContain("— used ");
582
+ expect(joined).not.toContain("Document Structure");
583
+ }
584
+ });
585
+ });
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // H4 — comments outside the main document story
589
+ // ---------------------------------------------------------------------------
590
+
591
+ describe("H4: comments outside the main story", () => {
592
+ it("a tracked footnote edit applies but drops the comment with a warning", async () => {
593
+ const doc = await buildFootnoteDoc();
594
+ const engine = new RedlineEngine(doc);
595
+ const res = engine.process_batch([
596
+ {
597
+ type: "modify",
598
+ target_text: "QA footnote about governing law",
599
+ new_text: "QA footnote about applicable law",
600
+ comment: "Diff: Replacement",
601
+ } as any,
602
+ ]);
603
+ expect(res.edits_applied).toBe(1);
604
+
605
+ const fnXml = footnotesXml(doc);
606
+ expect(fnXml).toContain("applicable");
607
+ expect(fnXml).not.toContain("commentReference");
608
+ expect(fnXml).not.toContain("commentRangeStart");
609
+
610
+ const details = engine.skipped_details.join("\n");
611
+ expect(details).toContain("was NOT attached");
612
+ expect(details).toContain("footnote");
613
+ });
614
+
615
+ it("a comment-only edit on footnote text fails validation", async () => {
616
+ const doc = await buildFootnoteDoc();
617
+ const engine = new RedlineEngine(doc);
618
+ const errors = engine.validate_edits([
619
+ {
620
+ type: "modify",
621
+ target_text: "QA footnote about governing law",
622
+ new_text: "QA footnote about governing law",
623
+ comment: "please review",
624
+ },
625
+ ]);
626
+ expect(errors.length).toBe(1);
627
+ expect(errors[0]).toContain("comments cannot be anchored inside a footnotes part");
628
+ });
629
+
630
+ it("a footer edit drops its comment while a body comment still attaches", async () => {
631
+ const doc = await buildHeaderFooterDoc();
632
+ const engine = new RedlineEngine(doc);
633
+ const res = engine.process_batch([
634
+ {
635
+ type: "modify",
636
+ target_text: "FOOTER MARKER",
637
+ new_text: "FOOTER MARK",
638
+ comment: "footer comment",
639
+ } as any,
640
+ {
641
+ type: "modify",
642
+ target_text: "Body paragraph one.",
643
+ new_text: "Body paragraph 1.",
644
+ comment: "body comment",
645
+ } as any,
646
+ ]);
647
+ expect(res.edits_applied).toBe(2);
648
+
649
+ expect(footerXml(doc)).not.toContain("commentReference");
650
+ expect(bodyXml(doc)).toContain("commentRangeStart");
651
+ expect(engine.skipped_details.join("\n")).toContain("footer");
652
+ });
653
+ });
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // M1 — markup parity with apply's edit semantics
657
+ // ---------------------------------------------------------------------------
658
+
659
+ describe("M1: markup honors apply's edit semantics", () => {
660
+ const md = "Alpha fee applies.\n\nBeta fee applies.\n\nGamma fee applies.";
661
+
662
+ it('match_mode "all" marks every occurrence', () => {
663
+ const result = apply_edits_to_markdown(md, [
664
+ { type: "modify", target_text: "fee", new_text: "charge", match_mode: "all" } as any,
665
+ ]);
666
+ expect((result.match(/\{--fee--\}\{\+\+charge\+\+\}/g) || []).length).toBe(3);
667
+ });
668
+
669
+ it("strict (default) refuses an ambiguous target, exactly like apply", () => {
670
+ const reports: any[] = [];
671
+ const result = apply_edits_to_markdown(
672
+ md,
673
+ [{ type: "modify", target_text: "fee", new_text: "charge" } as any],
674
+ false,
675
+ false,
676
+ reports,
677
+ );
678
+ expect(result).toBe(md);
679
+ expect(reports.length).toBe(1);
680
+ expect(reports[0].status).toBe("failed");
681
+ expect(reports[0].error).toContain("Ambiguous");
682
+ });
683
+
684
+ it('match_mode "first" gives the previous first-occurrence behavior', () => {
685
+ const result = apply_edits_to_markdown(md, [
686
+ { type: "modify", target_text: "fee", new_text: "charge", match_mode: "first" } as any,
687
+ ]);
688
+ expect((result.match(/\{--fee--\}/g) || []).length).toBe(1);
689
+ expect(result.startsWith("Alpha {--fee--}{++charge++}")).toBe(true);
690
+ });
691
+
692
+ it("regex targets are honored", () => {
693
+ const result = apply_edits_to_markdown(md, [
694
+ {
695
+ type: "modify",
696
+ target_text: "(?<=Alpha )fee",
697
+ new_text: "charge",
698
+ regex: true,
699
+ } as any,
700
+ ]);
701
+ expect(result).toContain("Alpha {--fee--}{++charge++}");
702
+ expect(result).toContain("Beta fee applies.");
703
+ });
704
+
705
+ it("a missing target is a per-edit failure, never a silent skip", () => {
706
+ const reports: any[] = [];
707
+ const result = apply_edits_to_markdown(
708
+ md,
709
+ [{ type: "modify", target_text: "does not exist", new_text: "x" } as any],
710
+ false,
711
+ false,
712
+ reports,
713
+ );
714
+ expect(result).toBe(md);
715
+ expect(reports[0].status).toBe("failed");
716
+ expect(reports[0].error).toContain("not found");
717
+ });
718
+
719
+ it("an empty target is recorded as a failure", () => {
720
+ const reports: any[] = [];
721
+ apply_edits_to_markdown(
722
+ md,
723
+ [{ type: "modify", target_text: "", new_text: "inserted" } as any],
724
+ false,
725
+ false,
726
+ reports,
727
+ );
728
+ expect(reports[0].status).toBe("failed");
729
+ expect(reports[0].error).toContain("target_text is empty");
730
+ });
731
+
732
+ it("edit reports carry truthful applied/occurrence stats", () => {
733
+ const reports: any[] = [];
734
+ apply_edits_to_markdown(
735
+ md,
736
+ [
737
+ { type: "modify", target_text: "Alpha fee", new_text: "Alpha charge" } as any,
738
+ { type: "modify", target_text: "fee", new_text: "charge", match_mode: "all" } as any,
739
+ ],
740
+ false,
741
+ false,
742
+ reports,
743
+ );
744
+ expect(reports.length).toBe(2);
745
+ expect(reports[0]).toMatchObject({ index: 0, status: "applied", occurrences: 1 });
746
+ // "Alpha fee" occupies the first "fee": the fan-out overlaps there and the
747
+ // edit is reported failed (mirrors Python's overlap accounting).
748
+ expect(reports[1].index).toBe(1);
749
+ });
750
+ });
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // M3 — VML watermark reporting (ported alongside H3's sanitize fixes)
754
+ // ---------------------------------------------------------------------------
755
+
756
+ describe("M3: sanitize reports watermark-like VML text objects", () => {
757
+ it("detect_watermarks finds the header textpath", async () => {
758
+ const doc = await buildWatermarkDoc();
759
+ const warnings = detect_watermarks(doc);
760
+ expect(warnings.length).toBe(1);
761
+ expect(warnings[0]).toContain("Watermark-like text object in header");
762
+ expect(warnings[0]).toContain("DRAFT ACME SECRET");
763
+ expect(warnings[0]).toContain("NOT removed");
764
+ });
765
+
766
+ it("finalize_document surfaces the watermark in its report", async () => {
767
+ const doc = await buildWatermarkDoc();
768
+ const { reportText } = await finalize_document(doc, {
769
+ filename: "wm.docx",
770
+ sanitize_mode: "full",
771
+ accept_all: true,
772
+ });
773
+ expect(reportText).toContain("DRAFT ACME SECRET");
774
+ expect(reportText.toLowerCase()).toContain("watermark");
775
+ });
776
+
777
+ it("a clean document reports no watermarks", async () => {
778
+ const doc = await createTestDocument();
779
+ addParagraph(doc, "No watermark here.");
780
+ expect(detect_watermarks(doc)).toEqual([]);
781
+ });
782
+ });
783
+
784
+ // ---------------------------------------------------------------------------
785
+ // M4 — style-based list semantics
786
+ // ---------------------------------------------------------------------------
787
+
788
+ describe("M4: style-based lists project list markers", () => {
789
+ it("List Bullet projects '* ' and List Number projects '1. '", async () => {
790
+ const doc = await buildStyleListDoc();
791
+ const text = await extractTextFromBuffer(await doc.save(), false, false);
792
+ expect(text).toContain("* Maintain ISO 27001 controls");
793
+ expect(text).toContain("* Notify incidents without undue delay");
794
+ expect(text).toContain("1. First escalation");
795
+ expect(text).toContain("1. Second escalation");
796
+ });
797
+
798
+ it("mapper and ingest stay in sync for style-based lists", async () => {
799
+ const buf = await (await buildStyleListDoc()).save();
800
+ const mapper = new DocumentMapper(await DocumentObject.load(buf));
801
+ const ingestText = _extractTextFromDoc(
802
+ await DocumentObject.load(buf),
803
+ false,
804
+ false,
805
+ ) as string;
806
+ expect(mapper.full_text).toBe(ingestText);
807
+ });
808
+
809
+ it("editing a style-based list item still applies", async () => {
810
+ const buf = await (await buildStyleListDoc()).save();
811
+ const doc = await DocumentObject.load(buf);
812
+ const engine = new RedlineEngine(doc);
813
+ const res = engine.process_batch([
814
+ {
815
+ type: "modify",
816
+ target_text: "ISO 27001 controls",
817
+ new_text: "ISO 27002 controls",
818
+ } as any,
819
+ ]);
820
+ expect(res.edits_applied).toBe(1);
821
+ const clean = await extractTextFromBuffer(await doc.save(), true, false);
822
+ expect(clean).toContain("ISO 27002 controls");
823
+ });
824
+ });
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // M5 — inline image markers
828
+ // ---------------------------------------------------------------------------
829
+
830
+ describe("M5: inline images project protected markers", () => {
831
+ it("an inline image projects ![alt](docx-image:id)", async () => {
832
+ const doc = await buildImageDoc();
833
+ const text = await extractTextFromBuffer(await doc.save(), false, false);
834
+ expect(text).toContain("![Red rectangle QA diagram](docx-image:7)");
835
+ });
836
+
837
+ it("mapper and ingest stay in sync for image markers", async () => {
838
+ const buf = await (await buildImageDoc()).save();
839
+ const mapper = new DocumentMapper(await DocumentObject.load(buf));
840
+ const ingestText = _extractTextFromDoc(
841
+ await DocumentObject.load(buf),
842
+ false,
843
+ false,
844
+ ) as string;
845
+ expect(mapper.full_text).toBe(ingestText);
846
+ const markerSpan = mapper.spans.find((s) =>
847
+ s.text.startsWith("![Red rectangle"),
848
+ );
849
+ expect(markerSpan).toBeTruthy();
850
+ expect((markerSpan as any).is_image_marker).toBe(true);
851
+ });
852
+
853
+ it("image markers cannot be fabricated in new_text", () => {
854
+ const errors = validate_edit_strings([
855
+ {
856
+ type: "modify",
857
+ target_text: "some prose",
858
+ new_text: "some prose ![fake](docx-image:9)",
859
+ },
860
+ ]);
861
+ // The generic hyperlink shape check also fires on ![...](...) — exactly
862
+ // like Python — so assert the image-specific error is present.
863
+ const imageErrors = errors.filter((e) => e.includes("docx-image"));
864
+ expect(imageErrors.length).toBe(1);
865
+ expect(imageErrors[0]).toContain("read-only");
866
+ });
867
+
868
+ it("image markers are write-protected in the document", async () => {
869
+ const doc = await buildImageDoc();
870
+ const engine = new RedlineEngine(doc);
871
+ const errors = engine.validate_edits([
872
+ {
873
+ type: "modify",
874
+ target_text: "![Red rectangle QA diagram](docx-image:7)",
875
+ new_text: "some replacement prose",
876
+ },
877
+ ]);
878
+ expect(errors.length).toBeGreaterThan(0);
879
+ expect(errors.join("\n")).toContain("image marker");
880
+ });
881
+ });
882
+
883
+ // ---------------------------------------------------------------------------
884
+ // M6 — reserved footnotes (untyped separator ids)
885
+ // ---------------------------------------------------------------------------
886
+
887
+ describe("M6: reserved separator footnotes stay hidden", () => {
888
+ it("untyped id=-1/0 notes are filtered from extraction", async () => {
889
+ const doc = await buildFootnoteDoc(false);
890
+ const text = await extractTextFromBuffer(await doc.save(), false, false);
891
+ expect(text).not.toContain("[^fn--1]");
892
+ expect(text).not.toContain("[^fn-0]");
893
+ expect(text).toContain("[^fn-1]:");
894
+ expect(text).toContain("QA footnote about governing law");
895
+ });
896
+ });
897
+
898
+ // ---------------------------------------------------------------------------
899
+ // M7 — every quoted defined term in a paragraph
900
+ // ---------------------------------------------------------------------------
901
+
902
+ describe("M7: every defined term in a paragraph is captured", () => {
903
+ it("extract_terms_from_paragraph finds leading, sentence-leading and inline terms", () => {
904
+ expect(
905
+ extract_terms_from_paragraph(
906
+ '"Alpha" means the first party. "Beta" means the second party. "Gamma" means the third party.',
907
+ ),
908
+ ).toEqual(["Alpha", "Beta", "Gamma"]);
909
+ expect(
910
+ extract_terms_from_paragraph(
911
+ 'This agreement is between Acme Oy (the "Customer") and Beta Oy ' +
912
+ '(the "Provider") for the provision of services (the "Services").',
913
+ ),
914
+ ).toEqual(["Customer", "Provider", "Services"]);
915
+ });
916
+
917
+ it("extract_all_domain_metadata sees all three sentence-leading terms", async () => {
918
+ const doc = await createTestDocument();
919
+ addParagraph(
920
+ doc,
921
+ '"Alpha" means the first party. "Beta" means the second party. "Gamma" means the third party.',
922
+ );
923
+ addParagraph(doc, "Alpha, Beta and Gamma perform the obligations.");
924
+ const baseText = _extractTextFromDoc(doc, false, false) as string;
925
+ const [defs] = extract_all_domain_metadata(doc, baseText);
926
+ expect(Object.keys(defs).sort()).toEqual(["Alpha", "Beta", "Gamma"]);
927
+ });
928
+ });
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // Post-review hardening (adversarial review of the fixes themselves; mirrors
932
+ // python commits 5b23b20 + b269427)
933
+ // ---------------------------------------------------------------------------
934
+
935
+ describe("Review hardening: C1 boundary re-anchor gate", () => {
936
+ it("a target-anchored footer prefix edit stays in the footer", async () => {
937
+ const doc = await buildHeaderFooterDoc();
938
+ const engine = new RedlineEngine(doc);
939
+ const res = engine.process_batch([
940
+ {
941
+ type: "modify",
942
+ target_text: "FOOTER MARKER",
943
+ new_text: "DRAFT FOOTER MARKER",
944
+ } as any,
945
+ ]);
946
+ expect(res.edits_applied).toBe(1);
947
+
948
+ expect(bodyXml(doc)).not.toContain("DRAFT");
949
+ expect(footerXml(doc)).toContain("DRAFT");
950
+
951
+ const clean = await extractTextFromBuffer(await doc.save(), true, false);
952
+ expect(clean).toContain("DRAFT FOOTER MARKER");
953
+ });
954
+
955
+ it("a machine-pinned pure insertion ending in a paragraph break still re-anchors to the body", async () => {
956
+ const doc = await buildHeaderFooterDoc();
957
+ const engine = new RedlineEngine(doc);
958
+ const footerStart = engine.mapper.full_text.indexOf("FOOTER MARKER");
959
+ const [applied, skipped] = engine.apply_edits([
960
+ {
961
+ type: "modify",
962
+ target_text: "",
963
+ new_text: "New final body paragraph.\n\n",
964
+ _match_start_index: footerStart,
965
+ },
966
+ ]);
967
+ expect(applied).toBe(1);
968
+ expect(skipped).toBe(0);
969
+ expect(bodyXml(doc)).toContain("New final body paragraph.");
970
+ expect(footerXml(doc)).not.toContain("New final body paragraph.");
971
+ });
972
+ });
973
+
974
+ describe("Review hardening: validation uses the RAW match range across parts", () => {
975
+ it("an insertion-shaped cross-part trim is rejected with single-part guidance", async () => {
976
+ const doc = await buildHeaderFooterDoc();
977
+ const engine = new RedlineEngine(doc);
978
+ // Trims to a pure insertion of "DRAFT " at the footer's first character,
979
+ // but the MATCH spans body + footer — inherently ambiguous.
980
+ const errors = engine.validate_edits([
981
+ {
982
+ type: "modify",
983
+ target_text: "one.\n\nFOOTER MARKER",
984
+ new_text: "one.\n\nDRAFT FOOTER MARKER",
985
+ },
986
+ ]);
987
+ expect(errors.length).toBeGreaterThan(0);
988
+ const joined = errors.join("\n");
989
+ expect(joined).toContain("spans a structural document-part boundary (body → footer)");
990
+ expect(joined).toContain("within a single part");
991
+ });
992
+
993
+ it("an image marker untouched in shared context does not block the edit", async () => {
994
+ const doc = await buildImageDoc();
995
+ const engine = new RedlineEngine(doc);
996
+ // The marker sits in the CONTEXT; the changed span is only the prose.
997
+ const errors = engine.validate_edits([
998
+ {
999
+ type: "modify",
1000
+ target_text:
1001
+ "Before image.\n\n![Red rectangle QA diagram](docx-image:7)\n\nAfter image.",
1002
+ new_text:
1003
+ "Before the image.\n\n![Red rectangle QA diagram](docx-image:7)\n\nAfter image.",
1004
+ },
1005
+ ]);
1006
+ expect(errors).toEqual([]);
1007
+ });
1008
+ });
1009
+
1010
+ describe("Review hardening: diff never emits image-marker edits", () => {
1011
+ it("an added inline image becomes a warning, not an edit", async () => {
1012
+ const orig = await createTestDocument();
1013
+ addParagraph(orig, "Before image.");
1014
+ addParagraph(orig, "After image.");
1015
+ const mod = await buildImageDoc();
1016
+
1017
+ const o = extractWithStructure(orig);
1018
+ const m = extractWithStructure(mod);
1019
+ const { edits, warnings } = generate_structured_edits(
1020
+ o.text,
1021
+ o.structure,
1022
+ m.text,
1023
+ m.structure,
1024
+ );
1025
+
1026
+ const markerRe = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
1027
+ for (const e of edits as any[]) {
1028
+ if (e.type !== "modify") continue;
1029
+ const t = ((e.target_text || "").match(markerRe) || []).sort();
1030
+ const n = ((e.new_text || "").match(markerRe) || []).sort();
1031
+ expect(t).toEqual(n);
1032
+ }
1033
+ expect(warnings.some((w) => w.toLowerCase().includes("image"))).toBe(true);
1034
+ });
1035
+ });
1036
+
1037
+ describe("Review hardening: structured row ops are pinned and warn on ambiguity", () => {
1038
+ it("insert_row/delete_row carry their source-row offsets", async () => {
1039
+ const orig = await buildTableDoc();
1040
+ const modAdd = await buildTableDoc({ extraRow: true });
1041
+ let o = extractWithStructure(orig);
1042
+ let m = extractWithStructure(modAdd);
1043
+ let res = generate_structured_edits(o.text, o.structure, m.text, m.structure);
1044
+ const insertOp: any = (res.edits as any[]).find((e) => e.type === "insert_row");
1045
+ expect(insertOp).toBeTruthy();
1046
+ // Pinned to the anchor row's ("Premium support …") start offset.
1047
+ expect(insertOp._match_start_index).toBe(o.structure.tables[0].rows[2].start);
1048
+
1049
+ const orig2 = await buildTableDoc();
1050
+ const modDrop = await buildTableDoc({ dropMiddleRow: true });
1051
+ o = extractWithStructure(orig2);
1052
+ m = extractWithStructure(modDrop);
1053
+ res = generate_structured_edits(o.text, o.structure, m.text, m.structure);
1054
+ const deleteOp: any = (res.edits as any[]).find((e) => e.type === "delete_row");
1055
+ expect(deleteOp).toBeTruthy();
1056
+ // Pinned to the deleted row's ("Standard support …") own start offset.
1057
+ expect(deleteOp._match_start_index).toBe(o.structure.tables[0].rows[1].start);
1058
+ });
1059
+
1060
+ it("warns once when a row anchor's text appears more than once", async () => {
1061
+ // Two identical tables; the second gains a row — its anchor text also
1062
+ // lives in the first table.
1063
+ const build = async (extra: boolean) => {
1064
+ const doc = await createTestDocument();
1065
+ for (let t = 0; t < 2; t++) {
1066
+ const rows = extra && t === 1
1067
+ ? [["Name", "Value"], ["A", "1"], ["B", "2"]]
1068
+ : [["Name", "Value"], ["A", "1"]];
1069
+ const tbl = addTable(doc, rows.length, 2);
1070
+ for (let i = 0; i < rows.length; i++) {
1071
+ for (let j = 0; j < 2; j++) setCellText(tbl, i, j, rows[i][j]);
1072
+ }
1073
+ addParagraph(doc, `Divider ${t}.`);
1074
+ }
1075
+ return doc;
1076
+ };
1077
+ const orig = await build(false);
1078
+ const mod = await build(true);
1079
+ const o = extractWithStructure(orig);
1080
+ const m = extractWithStructure(mod);
1081
+ const { edits, warnings } = generate_structured_edits(
1082
+ o.text,
1083
+ o.structure,
1084
+ m.text,
1085
+ m.structure,
1086
+ );
1087
+ expect((edits as any[]).some((e) => e.type === "insert_row")).toBe(true);
1088
+ expect(
1089
+ warnings.filter((w) => w.includes("appears more than once")).length,
1090
+ ).toBe(1);
1091
+ });
1092
+ });
1093
+
1094
+ describe("Review hardening: consecutive same-anchor row inserts", () => {
1095
+ it("two consecutive added rows apply in one sweep, in order", async () => {
1096
+ const build = async (extra: boolean) => {
1097
+ const doc = await createTestDocument();
1098
+ const rows = extra
1099
+ ? [["A", "B"], ["C", "D"], ["X1", "Y1"], ["X2", "Y2"]]
1100
+ : [["A", "B"], ["C", "D"]];
1101
+ const tbl = addTable(doc, rows.length, 2);
1102
+ for (let i = 0; i < rows.length; i++) {
1103
+ for (let j = 0; j < 2; j++) setCellText(tbl, i, j, rows[i][j]);
1104
+ }
1105
+ return doc;
1106
+ };
1107
+ const orig = await build(false);
1108
+ const mod = await build(true);
1109
+ const o = extractWithStructure(orig);
1110
+ const m = extractWithStructure(mod);
1111
+ const { edits } = generate_structured_edits(o.text, o.structure, m.text, m.structure);
1112
+ expect((edits as any[]).filter((e) => e.type === "insert_row").length).toBe(2);
1113
+
1114
+ // One apply_edits sweep: the zero-width insert ranges must not collide.
1115
+ const engine = new RedlineEngine(orig);
1116
+ const [applied, skipped] = engine.apply_edits(edits as any[]);
1117
+ expect(applied).toBe(2);
1118
+ expect(skipped).toBe(0);
1119
+
1120
+ const clean = await extractTextFromBuffer(await orig.save(), true, false);
1121
+ expect(clean).toContain("A | B\nC | D\nX1 | Y1\nX2 | Y2");
1122
+ });
1123
+ });
1124
+
1125
+ describe("Review hardening: numId=0 disables the style's numbering", () => {
1126
+ it("a direct numId=0 override suppresses the List Number marker", async () => {
1127
+ const doc = await buildStyleListDoc();
1128
+ // Append a ListNumber-styled paragraph carrying the ECMA-376 §17.9.15
1129
+ // "remove numbering" override.
1130
+ const xmlDoc = doc.element.ownerDocument!;
1131
+ const p = addParagraph(doc, "Not a list item");
1132
+ const pPr = xmlDoc.createElement("w:pPr");
1133
+ const pStyle = xmlDoc.createElement("w:pStyle");
1134
+ pStyle.setAttribute("w:val", "ListNumber");
1135
+ pPr.appendChild(pStyle);
1136
+ const numPr = xmlDoc.createElement("w:numPr");
1137
+ const numId = xmlDoc.createElement("w:numId");
1138
+ numId.setAttribute("w:val", "0");
1139
+ numPr.appendChild(numId);
1140
+ pPr.appendChild(numPr);
1141
+ p.insertBefore(pPr, p.firstChild);
1142
+
1143
+ const text = await extractTextFromBuffer(await doc.save(), false, false);
1144
+ expect(text).not.toContain("1. Not a list item");
1145
+ expect(text).toContain("Not a list item");
1146
+ expect(text).toContain("1. First escalation");
1147
+ });
1148
+ });
1149
+
1150
+ describe("Review hardening: legacy VML pictures project image markers", () => {
1151
+ async function buildVmlImageDoc(withTitle = true): Promise<DocumentObject> {
1152
+ const doc = await createTestDocument();
1153
+ addParagraph(doc, "Before legacy image.");
1154
+ const xmlDoc = doc.element.ownerDocument!;
1155
+ const p = xmlDoc.createElement("w:p");
1156
+ const r = xmlDoc.createElement("w:r");
1157
+ const pict = xmlDoc.createElement("w:pict");
1158
+ const shape = xmlDoc.createElement("v:shape");
1159
+ shape.setAttribute("id", "_x0000_i1025");
1160
+ const imagedata = xmlDoc.createElement("v:imagedata");
1161
+ imagedata.setAttribute("r:id", "rIdImg1");
1162
+ if (withTitle) imagedata.setAttribute("o:title", "Legacy logo");
1163
+ shape.appendChild(imagedata);
1164
+ pict.appendChild(shape);
1165
+ r.appendChild(pict);
1166
+ p.appendChild(r);
1167
+ doc.element.appendChild(p);
1168
+ addParagraph(doc, "After legacy image.");
1169
+ return doc;
1170
+ }
1171
+
1172
+ it("w:pict with v:imagedata projects ![title](docx-image:vml)", async () => {
1173
+ const doc = await buildVmlImageDoc();
1174
+ const text = await extractTextFromBuffer(await doc.save(), false, false);
1175
+ expect(text).toContain("![Legacy logo](docx-image:vml)");
1176
+ });
1177
+
1178
+ it("mapper and ingest stay in sync for VML image markers", async () => {
1179
+ const buf = await (await buildVmlImageDoc()).save();
1180
+ const mapper = new DocumentMapper(await DocumentObject.load(buf));
1181
+ const ingestText = _extractTextFromDoc(
1182
+ await DocumentObject.load(buf),
1183
+ false,
1184
+ false,
1185
+ ) as string;
1186
+ expect(mapper.full_text).toBe(ingestText);
1187
+ expect(mapper.full_text).toContain("![Legacy logo](docx-image:vml)");
1188
+ });
1189
+
1190
+ it("textpath-only watermark shapes stay out of the projection", async () => {
1191
+ const doc = await buildWatermarkDoc();
1192
+ const text = await extractTextFromBuffer(await doc.save(), false, false);
1193
+ expect(text).not.toContain("docx-image:vml");
1194
+ expect(text).not.toContain("DRAFT ACME SECRET");
1195
+ });
1196
+ });
1197
+
1198
+ describe("Review hardening: numbered sentence-leading defined terms", () => {
1199
+ it('captures "2.2 \\"Beta\\"" after a sentence delimiter', async () => {
1200
+ expect(
1201
+ extract_terms_from_paragraph(
1202
+ '2.1 "Alpha" means the product. 2.2 "Beta" means the service.',
1203
+ ),
1204
+ ).toEqual(["Alpha", "Beta"]);
1205
+
1206
+ const doc = await createTestDocument();
1207
+ addParagraph(
1208
+ doc,
1209
+ '2.1 "Alpha" means the product. 2.2 "Beta" means the service.',
1210
+ );
1211
+ addParagraph(doc, "Alpha and Beta apply to this order.");
1212
+ const baseText = _extractTextFromDoc(doc, false, false) as string;
1213
+ const [defs] = extract_all_domain_metadata(doc, baseText);
1214
+ expect(Object.keys(defs).sort()).toEqual(["Alpha", "Beta"]);
1215
+ });
1216
+ });
1217
+
1218
+ describe("Review hardening: markup markdown-strip matching rung", () => {
1219
+ it("a **marked** target resolves against plain text", () => {
1220
+ const reports: any[] = [];
1221
+ const result = apply_edits_to_markdown(
1222
+ "The Fee applies.",
1223
+ [{ type: "modify", target_text: "**Fee**", new_text: "**Charge**" } as any],
1224
+ false,
1225
+ false,
1226
+ reports,
1227
+ );
1228
+ expect(reports[0].status).toBe("applied");
1229
+ expect(result).toContain("{--Fee--}");
1230
+ });
1231
+
1232
+ it("a plain target resolves against text with mid-word markers", () => {
1233
+ const reports: any[] = [];
1234
+ const result = apply_edits_to_markdown(
1235
+ "The **Fe**e applies.",
1236
+ [{ type: "modify", target_text: "Fee", new_text: "Charge" } as any],
1237
+ false,
1238
+ false,
1239
+ reports,
1240
+ );
1241
+ expect(reports[0].status).toBe("applied");
1242
+ expect(result).toContain("{++Charge++}");
1243
+ });
1244
+ });