@adeu/core 1.23.0 → 1.26.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,380 @@
1
+ // FILE: node/packages/core/src/repro_qa_report_v7.test.ts
2
+ /**
3
+ * Node-engine repro tests for the 2026-07-19 black-box QA and UX report
4
+ * (adeu 1.25.0+35c8bb4). Mirrors python/tests/test_repro_qa_report_v7.py
5
+ * for the findings that live in the shared core engine:
6
+ *
7
+ * F-02 a formatting-only replacement (bold -> italic) inherits the
8
+ * replaced span's bold instead of realizing exactly the markers
9
+ * F-03 a bold run with boundary whitespace projects as malformed
10
+ * Markdown (`**The Supplier **`) in both ingest and mapper
11
+ * F-04 an alt-text-only image difference becomes an unappliable edit
12
+ * F-06 match_mode=all rebuilds the document map once per occurrence
13
+ * F-09 sanitize leaves original comment timestamps un-normalized
14
+ * F-13 an invalid regex target is reported as "not found" instead of
15
+ * as a regex syntax error
16
+ * F-21 edits_applied counts occurrences instead of change objects
17
+ *
18
+ * Every test fails against the commit preceding its fix.
19
+ */
20
+
21
+ import { describe, it, expect } from "vitest";
22
+ import { readFileSync } from "node:fs";
23
+ import { resolve, dirname } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { createTestDocument, addParagraph } from "./test-utils.js";
26
+ import { DocumentObject } from "./docx/bridge.js";
27
+ import { DocumentMapper } from "./mapper.js";
28
+ import { RedlineEngine, BatchValidationError } from "./engine.js";
29
+ import { _extractTextFromDoc, ExtractStructure } from "./ingest.js";
30
+ import {
31
+ generate_structured_edits,
32
+ collect_media_difference_warnings,
33
+ } from "./diff.js";
34
+ import { zipSync } from "fflate";
35
+ import { finalize_document } from "./sanitize/core.js";
36
+ import { qn } from "./docx/dom.js";
37
+
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = dirname(__filename);
40
+
41
+ function addStyledRun(
42
+ p: Element,
43
+ text: string,
44
+ style: { bold?: boolean; italic?: boolean } = {},
45
+ ): Element {
46
+ const xmlDoc = p.ownerDocument!;
47
+ const r = xmlDoc.createElement("w:r");
48
+ if (style.bold || style.italic) {
49
+ const rPr = xmlDoc.createElement("w:rPr");
50
+ if (style.bold) rPr.appendChild(xmlDoc.createElement("w:b"));
51
+ if (style.italic) rPr.appendChild(xmlDoc.createElement("w:i"));
52
+ r.appendChild(rPr);
53
+ }
54
+ const t = xmlDoc.createElement("w:t");
55
+ t.textContent = text;
56
+ if (text !== text.trim()) t.setAttribute("xml:space", "preserve");
57
+ r.appendChild(t);
58
+ p.appendChild(r);
59
+ return r;
60
+ }
61
+
62
+ function addEmptyParagraph(doc: DocumentObject): Element {
63
+ const xmlDoc = doc.element.ownerDocument!;
64
+ const p = xmlDoc.createElement("w:p");
65
+ doc.element.appendChild(p);
66
+ return p;
67
+ }
68
+
69
+ /** Builds the QA report's F-02 fixture: "Normal before **Important phrase** normal after." */
70
+ async function buildBoldDoc(): Promise<DocumentObject> {
71
+ const doc = await createTestDocument();
72
+ const p = addEmptyParagraph(doc);
73
+ addStyledRun(p, "Normal before ");
74
+ addStyledRun(p, "Important phrase", { bold: true });
75
+ addStyledRun(p, " normal after.");
76
+ return doc;
77
+ }
78
+
79
+ function cleanText(doc: DocumentObject): string {
80
+ return _extractTextFromDoc(doc, { cleanView: true, includeAppendix: false }) as string;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // F-02: formatting-only replacements realize exactly the requested markers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe("QA v7 F-02: formatting-only replacement fidelity", () => {
88
+ it("clears inherited bold when the replacement carries explicit italic markers", async () => {
89
+ const doc = await buildBoldDoc();
90
+ const engine = new RedlineEngine(doc);
91
+ engine.process_batch([
92
+ {
93
+ type: "modify",
94
+ target_text: "**Important phrase**",
95
+ new_text: "_Important phrase_",
96
+ },
97
+ ]);
98
+ engine.accept_all_revisions();
99
+
100
+ const accepted = cleanText(doc);
101
+ expect(accepted).toContain("_Important phrase_");
102
+ expect(accepted).not.toContain("**");
103
+ });
104
+
105
+ it("clears bold when the replacement removes the markers entirely", async () => {
106
+ const doc = await buildBoldDoc();
107
+ const engine = new RedlineEngine(doc);
108
+ engine.process_batch([
109
+ {
110
+ type: "modify",
111
+ target_text: "**Important phrase**",
112
+ new_text: "Important phrase",
113
+ },
114
+ ]);
115
+ engine.accept_all_revisions();
116
+
117
+ const accepted = cleanText(doc);
118
+ expect(accepted).toContain("Normal before Important phrase normal after.");
119
+ expect(accepted).not.toContain("**");
120
+ });
121
+
122
+ it("still inherits bold for unmarked plain-text replacements", async () => {
123
+ const doc = await buildBoldDoc();
124
+ const engine = new RedlineEngine(doc);
125
+ engine.process_batch([
126
+ { type: "modify", target_text: "Important", new_text: "Critical" },
127
+ ]);
128
+ engine.accept_all_revisions();
129
+ expect(cleanText(doc)).toContain("**Critical phrase**");
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // F-03: styled-run boundary whitespace stays outside emphasis markers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("QA v7 F-03: styled-run boundary whitespace", () => {
138
+ async function buildBoundaryDoc(): Promise<DocumentObject> {
139
+ const doc = await createTestDocument();
140
+ const p = addEmptyParagraph(doc);
141
+ addStyledRun(p, "The Supplier ", { bold: true });
142
+ addStyledRun(p, "shall deliver");
143
+ addStyledRun(p, " the Services by 31 December 2026.", { italic: true });
144
+ return doc;
145
+ }
146
+
147
+ it("extraction emits valid markdown with whitespace outside the markers", async () => {
148
+ const doc = await buildBoundaryDoc();
149
+ const text = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
150
+ expect(text).toBe(
151
+ "**The Supplier** shall deliver _the Services by 31 December 2026._",
152
+ );
153
+ });
154
+
155
+ it("mapper projection matches ingest projection (virtual text contract)", async () => {
156
+ const doc = await buildBoundaryDoc();
157
+ const ingestText = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
158
+ const mapper = new DocumentMapper(doc);
159
+ expect(mapper.full_text.trim()).toBe(ingestText);
160
+ });
161
+
162
+ it("plain-text edits across the styled boundary still apply", async () => {
163
+ const doc = await buildBoundaryDoc();
164
+ const engine = new RedlineEngine(doc);
165
+ const stats = engine.process_batch([
166
+ { type: "modify", target_text: "shall deliver", new_text: "must deliver" },
167
+ ]);
168
+ expect(stats.edits_applied).toBe(1);
169
+ engine.accept_all_revisions();
170
+ expect(cleanText(doc)).toContain("must deliver");
171
+ });
172
+
173
+ it("whitespace-only styled runs are not wrapped", async () => {
174
+ const doc = await createTestDocument();
175
+ const p = addEmptyParagraph(doc);
176
+ addStyledRun(p, "Alpha");
177
+ addStyledRun(p, " ", { bold: true });
178
+ addStyledRun(p, "Omega");
179
+ const text = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
180
+ expect(text).toBe("Alpha Omega");
181
+ });
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // F-04: an alt-text-only image difference must not become an edit
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("QA v7 F-04: image alt-text diffs", () => {
189
+ it("drops hunks that reach inside a read-only image marker, with a warning", () => {
190
+ const text_orig = "Before image.\n\n![RED logo](docx-image:1)\n\nAfter image.";
191
+ const text_mod = "Before image.\n\n![BLUE logo](docx-image:1)\n\nAfter image.";
192
+ const struct = (text: string): ExtractStructure => ({
193
+ part_ranges: [[0, text.length, "body"]],
194
+ tables: [],
195
+ });
196
+
197
+ const { edits, warnings } = generate_structured_edits(
198
+ text_orig,
199
+ struct(text_orig),
200
+ text_mod,
201
+ struct(text_mod),
202
+ );
203
+
204
+ expect(edits).toEqual([]);
205
+ expect(
206
+ warnings.some((w) => /alternative text|image/i.test(w)),
207
+ ).toBe(true);
208
+ });
209
+ });
210
+
211
+ describe("QA v7 F-04: media byte differences", () => {
212
+ const makePkg = (mediaBytes: number[]): Uint8Array =>
213
+ zipSync({
214
+ "[Content_Types].xml": new TextEncoder().encode("<Types/>"),
215
+ "word/document.xml": new TextEncoder().encode("<w:document/>"),
216
+ "word/media/image1.png": new Uint8Array(mediaBytes),
217
+ });
218
+
219
+ it("warns when embedded media bytes differ", () => {
220
+ const warnings = collect_media_difference_warnings(
221
+ makePkg([1, 2, 3]),
222
+ makePkg([4, 5, 6]),
223
+ );
224
+ expect(warnings.length).toBe(1);
225
+ expect(warnings[0]).toMatch(/embedded media differ/);
226
+ });
227
+
228
+ it("stays silent when media are byte-identical", () => {
229
+ const warnings = collect_media_difference_warnings(
230
+ makePkg([1, 2, 3]),
231
+ makePkg([1, 2, 3]),
232
+ );
233
+ expect(warnings).toEqual([]);
234
+ });
235
+ });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // F-06: match_mode=all must not rebuild the map once per occurrence
239
+ // ---------------------------------------------------------------------------
240
+
241
+ describe("QA v7 F-06: all-match application scaling", () => {
242
+ it("keeps map rebuilds constant as occurrence count grows", async () => {
243
+ const n = 30;
244
+ const doc = await createTestDocument();
245
+ for (let i = 0; i < n; i++) {
246
+ addParagraph(
247
+ doc,
248
+ `Clause ${i}: The party PLACEHOLDER-${String(i).padStart(4, "0")} shall comply. REPLACEME token.`,
249
+ );
250
+ }
251
+ const engine = new RedlineEngine(doc);
252
+
253
+ const proto = DocumentMapper.prototype as any;
254
+ const originalBuild = proto._build_map;
255
+ let buildCount = 0;
256
+ proto._build_map = function (this: any) {
257
+ buildCount += 1;
258
+ return originalBuild.call(this);
259
+ };
260
+
261
+ let stats: any;
262
+ try {
263
+ stats = engine.process_batch([
264
+ {
265
+ type: "modify",
266
+ target_text: "REPLACEME",
267
+ new_text: "SWAPPED",
268
+ regex: true,
269
+ match_mode: "all",
270
+ },
271
+ ]);
272
+ } finally {
273
+ proto._build_map = originalBuild;
274
+ }
275
+
276
+ expect(stats.edits_skipped).toBe(0);
277
+ expect(buildCount).toBeLessThan(n);
278
+
279
+ engine.accept_all_revisions();
280
+ const final = cleanText(doc);
281
+ expect(final.match(/SWAPPED/g)?.length).toBe(n);
282
+ expect(final).not.toContain("REPLACEME");
283
+ });
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // F-09: comment timestamps must be normalized alongside change timestamps
288
+ // ---------------------------------------------------------------------------
289
+
290
+ describe("QA v7 F-09: comment timestamp normalization", () => {
291
+ it("normalizes w:date and w16cex:dateUtc in retained comment parts", async () => {
292
+ const fixturePath = resolve(
293
+ __dirname,
294
+ "../../../../shared/fixtures/golden.docx",
295
+ );
296
+ const doc = await DocumentObject.load(readFileSync(fixturePath));
297
+
298
+ await finalize_document(doc, {
299
+ filename: "golden.docx",
300
+ sanitize_mode: "keep-markup",
301
+ });
302
+
303
+ const commentParts = doc.pkg.parts.filter((p) =>
304
+ p.contentType.includes("comments"),
305
+ );
306
+ expect(commentParts.length).toBeGreaterThan(0);
307
+
308
+ const dates: string[] = [];
309
+ for (const part of commentParts) {
310
+ const xml = part._element.toString();
311
+ for (const m of xml.matchAll(/(?:w:date|w16cex:dateUtc)="([^"]+)"/g)) {
312
+ dates.push(m[1]);
313
+ }
314
+ }
315
+ expect(dates.length).toBeGreaterThan(0);
316
+ for (const d of dates) {
317
+ expect(d.startsWith("2025-01-01")).toBe(true);
318
+ }
319
+ });
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // F-13: invalid regex targets must be diagnosed as regex errors
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe("QA v7 F-13: invalid regex diagnosis", () => {
327
+ it("names the regex problem instead of 'not found'", async () => {
328
+ const doc = await createTestDocument();
329
+ addParagraph(doc, "alpha DUPTOKEN one.");
330
+ const engine = new RedlineEngine(doc);
331
+
332
+ let error: BatchValidationError | null = null;
333
+ try {
334
+ engine.process_batch([
335
+ { type: "modify", target_text: "[", new_text: "x", regex: true },
336
+ ]);
337
+ } catch (e) {
338
+ error = e as BatchValidationError;
339
+ }
340
+ expect(error).toBeInstanceOf(BatchValidationError);
341
+ const msg = (error as BatchValidationError).errors.join("\n");
342
+ expect(msg).toMatch(/regular expression/i);
343
+ expect(msg).not.toMatch(/not found/i);
344
+ });
345
+
346
+ it("valid regexes still apply", async () => {
347
+ const doc = await createTestDocument();
348
+ addParagraph(doc, "alpha DUPTOKEN one.");
349
+ const engine = new RedlineEngine(doc);
350
+ const stats = engine.process_batch([
351
+ { type: "modify", target_text: "DUP\\w+", new_text: "XTOKEN", regex: true },
352
+ ]);
353
+ expect(stats.edits_applied).toBe(1);
354
+ });
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // F-21: edits_applied counts change objects, not occurrences
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe("QA v7 F-21: edits_applied semantics", () => {
362
+ it("reports one applied edit with two modified occurrences", async () => {
363
+ const doc = await createTestDocument();
364
+ addParagraph(doc, "alpha DUPTOKEN one.");
365
+ addParagraph(doc, "beta DUPTOKEN two.");
366
+ const engine = new RedlineEngine(doc);
367
+ const stats = engine.process_batch([
368
+ {
369
+ type: "modify",
370
+ target_text: "DUPTOKEN",
371
+ new_text: "NEWTOKEN",
372
+ match_mode: "all",
373
+ },
374
+ ]);
375
+ expect(stats.edits.length).toBe(1);
376
+ expect(stats.edits_applied).toBe(1);
377
+ expect(stats.occurrences_modified).toBe(2);
378
+ expect(stats.edits[0].occurrences_modified).toBe(2);
379
+ });
380
+ });
@@ -1,7 +1,8 @@
1
+ import { strFromU8, unzipSync } from 'fflate';
1
2
  import { DocumentObject } from '../docx/bridge.js';
2
3
  import { SanitizeReport } from './report.js';
3
4
  import * as transforms from './transforms.js';
4
- import { findAllDescendants } from '../docx/dom.js';
5
+ import { findAllDescendants, parseXml } from '../docx/dom.js';
5
6
 
6
7
  export interface FinalizeOptions {
7
8
  filename: string;
@@ -67,6 +68,7 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
67
68
  report.add_transform_lines(transforms.scrub_doc_properties(doc));
68
69
  report.add_transform_lines(transforms.scrub_timestamps(doc));
69
70
  report.add_transform_lines(transforms.strip_custom_xml(doc));
71
+ report.add_transform_lines(transforms.strip_custom_properties(doc));
70
72
  report.add_transform_lines(transforms.strip_image_alt_text(doc));
71
73
 
72
74
  const warnings = transforms.audit_hyperlinks(doc);
@@ -76,6 +78,9 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
76
78
  for (const w of transforms.detect_watermarks(doc)) report.warnings.push(w);
77
79
 
78
80
  report.add_transform_lines(transforms.normalize_change_dates(doc));
81
+ // Retained comments carry the same when-did-they-work signal in
82
+ // word/comments.xml as tracked changes do in the body (QA 2026-07-19 F-09).
83
+ report.add_transform_lines(transforms.normalize_comment_dates(doc));
79
84
 
80
85
  // Protection (Settings injection)
81
86
  if (options.protection_mode === 'read_only' || options.protection_mode === 'encrypt') {
@@ -130,5 +135,59 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
130
135
  if (report.warnings.length > 0) report.status = 'clean_with_warnings';
131
136
 
132
137
  const outBuffer = await doc.save();
138
+ verifySanitizedPackage(outBuffer);
133
139
  return { reportText: report.render(), outBuffer };
140
+ }
141
+
142
+ // Core-property elements the pipeline claims to scrub; the post-sanitize
143
+ // verification re-checks each one in the SAVED bytes.
144
+ const VERIFIED_CORE_FIELDS: Array<[string, string]> = [
145
+ ['creator', 'author (dc:creator)'],
146
+ ['lastModifiedBy', 'last modified by (cp:lastModifiedBy)'],
147
+ ['identifier', 'identifier (dc:identifier)'],
148
+ ['description', 'description (dc:description)'],
149
+ ['keywords', 'keywords (cp:keywords)'],
150
+ ['category', 'category (cp:category)'],
151
+ ['subject', 'subject (dc:subject)'],
152
+ ['contentStatus', 'content status (cp:contentStatus)'],
153
+ ['language', 'language (dc:language)'],
154
+ ['version', 'version (cp:version)'],
155
+ ];
156
+
157
+ /**
158
+ * Post-sanitize package scan: re-open the SAVED bytes —
159
+ * bypassing every in-memory caching layer — and verify the claims the report
160
+ * is about to make. A "Result: CLEAN" verdict over a package that still
161
+ * carries custom properties or an identifier is worse than no sanitizer.
162
+ */
163
+ export function verifySanitizedPackage(outBuffer: Buffer): void {
164
+ const unzipped = unzipSync(new Uint8Array(outBuffer));
165
+ const names = Object.keys(unzipped);
166
+ const problems: string[] = [];
167
+
168
+ if (names.includes('docProps/custom.xml')) {
169
+ problems.push('docProps/custom.xml (custom document properties) is still in the package');
170
+ }
171
+ if (names.some(n => n.startsWith('customXml/'))) {
172
+ problems.push('customXml/* parts are still in the package');
173
+ }
174
+ if (names.includes('docProps/core.xml')) {
175
+ const core = parseXml(strFromU8(unzipped['docProps/core.xml']));
176
+ for (const [local, label] of VERIFIED_CORE_FIELDS) {
177
+ for (const el of transforms.findDescendantsByLocalName(core.documentElement! as unknown as Element, local)) {
178
+ if ((el.textContent || '').trim()) {
179
+ problems.push(`core property ${label} still contains a value`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ if (problems.length > 0) {
186
+ throw new Error(
187
+ 'Sanitize integrity check failed — the saved package still contains metadata this run ' +
188
+ 'claims to remove:\n - ' +
189
+ problems.join('\n - ') +
190
+ '\nRefusing to report a clean document.',
191
+ );
192
+ }
134
193
  }
@@ -38,9 +38,11 @@ export class SanitizeReport {
38
38
  this.removed_comment_lines.push(line);
39
39
  }
40
40
  } else if (
41
- lower.includes("author") || lower.includes("template") || lower.includes("company") ||
42
- lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") ||
43
- lower.includes("custom xml") || lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed")
41
+ lower.includes("author") || lower.includes("template") || lower.includes("company") ||
42
+ lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") ||
43
+ lower.includes("custom xml") || lower.includes("custom propert") || lower.includes("identifier") ||
44
+ lower.includes("language") || lower.includes("version") ||
45
+ lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed")
44
46
  ) {
45
47
  this.metadata_lines.push(line);
46
48
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
+ import { zipSync } from "fflate";
2
3
  import { DOMParser } from "@xmldom/xmldom";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { resolve, dirname } from "node:path";
@@ -186,7 +187,7 @@ describe("Finalize Document (Core)", () => {
186
187
  doc.pkg.parts.push(settingsPart);
187
188
 
188
189
  // Mock the doc.save buffer return
189
- doc.save = vi.fn().mockResolvedValue(Buffer.from("mock"));
190
+ doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
190
191
 
191
192
  const res = await finalize_document(doc, {
192
193
  filename: "test.docx",
@@ -231,7 +232,7 @@ describe("Finalize Document (Core)", () => {
231
232
  </w:p>
232
233
  `);
233
234
 
234
- doc.save = vi.fn().mockResolvedValue(Buffer.from("mock"));
235
+ doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
235
236
 
236
237
  await finalize_document(doc, {
237
238
  filename: "test.docx",
@@ -258,7 +259,7 @@ describe("Finalize Document (Core)", () => {
258
259
  "http://schemas.microsoft.com/office/word/2023/wordml/word16du",
259
260
  );
260
261
 
261
- doc.save = vi.fn().mockResolvedValue(Buffer.from("mock"));
262
+ doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
262
263
 
263
264
  await finalize_document(doc, {
264
265
  filename: "test.docx",
@@ -285,7 +286,7 @@ describe("Finalize Document (Core)", () => {
285
286
  expect(original_xml).toContain("w:commentReference");
286
287
 
287
288
  // Mock the doc.save buffer return
288
- doc.save = vi.fn().mockResolvedValue(Buffer.from("mock"));
289
+ doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
289
290
 
290
291
  // 2. Act: Finalize the document in full sanitize mode with accept_all: true
291
292
  await finalize_document(doc, {