@adeu/core 1.27.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.
- package/dist/index.cjs +563 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +563 -65
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +236 -3
- package/src/engine.atomic.test.ts +5 -1
- package/src/engine.batch.test.ts +16 -6
- package/src/engine.ts +323 -52
- package/src/ingest.test.ts +3 -1
- package/src/ingest.ts +16 -3
- package/src/mapper.ts +74 -9
- package/src/repro_qa_mcp_issues_2026_07_20.test.ts +224 -0
- package/src/repro_qa_report_v9.test.ts +392 -0
- package/src/sanitize/core.ts +11 -0
- package/src/sanitize/report.ts +9 -7
- package/src/sanitize/transforms.ts +39 -0
- package/src/utils/docx.ts +87 -0
|
@@ -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
|
+
});
|
package/src/sanitize/core.ts
CHANGED
|
@@ -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(
|
package/src/sanitize/report.ts
CHANGED
|
@@ -31,20 +31,22 @@ export class SanitizeReport {
|
|
|
31
31
|
const lower = line.toLowerCase();
|
|
32
32
|
if (lower.includes("tracked change") || lower.includes("insertion") || lower.includes("deletion") || lower.includes("accepted")) {
|
|
33
33
|
this.change_lines.push(line);
|
|
34
|
-
} else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
|
|
35
|
-
if (lower.includes("kept") || lower.includes("visible")) {
|
|
36
|
-
this.kept_comment_lines.push(line);
|
|
37
|
-
} else {
|
|
38
|
-
this.removed_comment_lines.push(line);
|
|
39
|
-
}
|
|
40
34
|
} else if (
|
|
41
35
|
lower.includes("author") || lower.includes("template") || lower.includes("company") ||
|
|
42
36
|
lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") ||
|
|
43
37
|
lower.includes("custom xml") || lower.includes("custom propert") || lower.includes("identifier") ||
|
|
38
|
+
lower.includes("document variable") ||
|
|
44
39
|
lower.includes("language") || lower.includes("version") ||
|
|
45
|
-
lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed")
|
|
40
|
+
lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed") ||
|
|
41
|
+
lower.includes("description/comments")
|
|
46
42
|
) {
|
|
47
43
|
this.metadata_lines.push(line);
|
|
44
|
+
} else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
|
|
45
|
+
if (lower.includes("kept") || lower.includes("visible")) {
|
|
46
|
+
this.kept_comment_lines.push(line);
|
|
47
|
+
} else {
|
|
48
|
+
this.removed_comment_lines.push(line);
|
|
49
|
+
}
|
|
48
50
|
} else if (lower.includes("hyperlink") || lower.includes("warning")) {
|
|
49
51
|
this.warnings.push(line);
|
|
50
52
|
} else {
|
|
@@ -634,6 +634,45 @@ export function strip_custom_properties(doc: DocumentObject): string[] {
|
|
|
634
634
|
return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
+
/**
|
|
638
|
+
* Remove Word document variables (<w:docVars>/<w:docVar>) from
|
|
639
|
+
* word/settings.xml. Document variables are invisible in Word's UI but are a
|
|
640
|
+
* standard carrier for matter references, DMS identifiers, template state and
|
|
641
|
+
* integration tokens — a package that keeps them can never be reported CLEAN
|
|
642
|
+
* (QA 2026-07-19 ADEU-QA-001). Variables are disclosed by NAME only: their
|
|
643
|
+
* values are exactly the secrets a sanitize report must not echo.
|
|
644
|
+
*/
|
|
645
|
+
export function strip_document_variables(doc: DocumentObject): string[] {
|
|
646
|
+
const settingsPart = doc.pkg.getPartByPath('word/settings.xml');
|
|
647
|
+
if (!settingsPart) return [];
|
|
648
|
+
|
|
649
|
+
const names: string[] = [];
|
|
650
|
+
let removedAny = false;
|
|
651
|
+
|
|
652
|
+
for (const container of findDescendantsByLocalName(settingsPart._element, 'docVars')) {
|
|
653
|
+
for (const child of Array.from(container.childNodes)) {
|
|
654
|
+
const el = child as Element;
|
|
655
|
+
const tag = el.tagName || '';
|
|
656
|
+
if (tag === 'docVar' || tag.endsWith(':docVar')) {
|
|
657
|
+
names.push(el.getAttribute('w:name') || el.getAttribute('name') || '(unnamed)');
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
container.parentNode?.removeChild(container);
|
|
661
|
+
removedAny = true;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Defensive: stray <w:docVar> elements outside a <w:docVars> container.
|
|
665
|
+
for (const stray of findDescendantsByLocalName(settingsPart._element, 'docVar')) {
|
|
666
|
+
names.push(stray.getAttribute('w:name') || stray.getAttribute('name') || '(unnamed)');
|
|
667
|
+
stray.parentNode?.removeChild(stray);
|
|
668
|
+
removedAny = true;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (!removedAny) return [];
|
|
672
|
+
const shown = Array.from(new Set(names)).sort().join(', ') || '(unnamed)';
|
|
673
|
+
return [`Document variables: ${names.length} removed (${shown})`];
|
|
674
|
+
}
|
|
675
|
+
|
|
637
676
|
export function strip_image_alt_text(doc: DocumentObject): string[] {
|
|
638
677
|
let count = 0;
|
|
639
678
|
for (const docPr of findDescendantsByLocalName(doc.element, 'docPr')) {
|
package/src/utils/docx.ts
CHANGED
|
@@ -468,6 +468,19 @@ export function get_paragraph_prefix(
|
|
|
468
468
|
}
|
|
469
469
|
|
|
470
470
|
if (!style_name || style_name === "Normal") {
|
|
471
|
+
let is_inside_tc = false;
|
|
472
|
+
let curr: Element | null = paragraph._element;
|
|
473
|
+
while (curr) {
|
|
474
|
+
if (curr.tagName === "w:tc") {
|
|
475
|
+
is_inside_tc = true;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
curr = curr.parentNode as Element | null;
|
|
479
|
+
}
|
|
480
|
+
if (is_inside_tc) {
|
|
481
|
+
return "";
|
|
482
|
+
}
|
|
483
|
+
|
|
471
484
|
const text = paragraph.text.trim();
|
|
472
485
|
if (text && text.length < 100 && text === text.toUpperCase()) {
|
|
473
486
|
let is_bold = false;
|
|
@@ -581,6 +594,80 @@ export function split_boundary_whitespace(
|
|
|
581
594
|
];
|
|
582
595
|
}
|
|
583
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Maps each tracked-change id in a merged meta bubble to the OTHER ids of its
|
|
599
|
+
* resolution group, rendered ready for the bubble line suffix
|
|
600
|
+
* (`uid -> "Chg:2"` / `uid -> "Chg:2, Chg:3"`).
|
|
601
|
+
*
|
|
602
|
+
* A replacement is stored as a contiguous same-author <w:del> + <w:ins> pair
|
|
603
|
+
* with two distinct w:id values, but both engines resolve such a group as ONE
|
|
604
|
+
* unit — accepting or rejecting either side decides the whole replacement.
|
|
605
|
+
* Projecting the two ids side by side with no linkage implied they were
|
|
606
|
+
* independently resolvable (QA 2026-07-19 ADEU-QA-004); every meta-block
|
|
607
|
+
* builder (Python/Node ingest + mapper) uses this map to annotate grouped
|
|
608
|
+
* lines as `(pairs with Chg:N)`.
|
|
609
|
+
*
|
|
610
|
+
* Grouping mirrors the engines' `_get_paired_nodes` walk: consecutive ins/del
|
|
611
|
+
* ids in bubble order group while the author stays the same; any state
|
|
612
|
+
* carrying NO active ins/del (a comment- or format-only run between changes —
|
|
613
|
+
* physical text separating the elements) breaks the group. `states_list`
|
|
614
|
+
* entries are [ins_map, del_map, comments_set, fmt_map] snapshots in document
|
|
615
|
+
* order, as accumulated by the ingest/mapper state machines.
|
|
616
|
+
*/
|
|
617
|
+
export function compute_change_pair_map(
|
|
618
|
+
states_list: any[],
|
|
619
|
+
): Record<string, string> {
|
|
620
|
+
const groups: Array<Array<[string, string]>> = [];
|
|
621
|
+
let current: Array<[string, string]> = [];
|
|
622
|
+
const seen_ids = new Set<string>();
|
|
623
|
+
|
|
624
|
+
for (const [ins_map, del_map] of states_list) {
|
|
625
|
+
const ins_entries = Object.entries(ins_map as Record<string, any>);
|
|
626
|
+
const del_entries = Object.entries(del_map as Record<string, any>);
|
|
627
|
+
if (ins_entries.length === 0 && del_entries.length === 0) {
|
|
628
|
+
// A run with only comment/format meta sits between the tracked
|
|
629
|
+
// elements: they are not siblings, the engine will not group them.
|
|
630
|
+
if (current.length > 0) {
|
|
631
|
+
groups.push(current);
|
|
632
|
+
current = [];
|
|
633
|
+
}
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const state_new: Array<[string, string]> = [];
|
|
637
|
+
for (const [uid, meta] of ins_entries) {
|
|
638
|
+
if (!seen_ids.has(uid)) {
|
|
639
|
+
state_new.push([uid, (meta && meta.author) || "Unknown"]);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
for (const [uid, meta] of del_entries) {
|
|
643
|
+
if (!seen_ids.has(uid)) {
|
|
644
|
+
state_new.push([uid, (meta && meta.author) || "Unknown"]);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
for (const [uid, author] of state_new) {
|
|
648
|
+
seen_ids.add(uid);
|
|
649
|
+
if (current.length > 0 && current[current.length - 1][1] !== author) {
|
|
650
|
+
groups.push(current);
|
|
651
|
+
current = [];
|
|
652
|
+
}
|
|
653
|
+
current.push([uid, author]);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (current.length > 0) groups.push(current);
|
|
657
|
+
|
|
658
|
+
const pair_map: Record<string, string> = {};
|
|
659
|
+
for (const group of groups) {
|
|
660
|
+
if (group.length < 2) continue;
|
|
661
|
+
for (const [uid] of group) {
|
|
662
|
+
pair_map[uid] = group
|
|
663
|
+
.filter(([u]) => u !== uid)
|
|
664
|
+
.map(([u]) => `Chg:${u}`)
|
|
665
|
+
.join(", ");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return pair_map;
|
|
669
|
+
}
|
|
670
|
+
|
|
584
671
|
export function apply_formatting_to_segments(
|
|
585
672
|
text: string,
|
|
586
673
|
prefix: string,
|