@adeu/core 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ // FILE: node/packages/core/src/repro_qa_report_v8.test.ts
2
+ /**
3
+ * Node-engine repro tests for the 2026-07-19 black-box QA and UX report on
4
+ * 1.26.0 (adeu 1.26.0+0741eaf). Mirrors python/tests/test_repro_qa_report_v8.py
5
+ * for the findings that live in the shared core engine:
6
+ *
7
+ * F-04 replacing a full sentence that crosses bold/italic formatting runs
8
+ * leaves a partial word bold (`**The Suppli** must perform ...`):
9
+ * the word-diff hunk absorbs the closing `**` marker, the resolved
10
+ * range starts on a virtual span, and the run-local split offset
11
+ * underflows into the preceding run's kept text
12
+ * F-07 review-action validation permits blank replies and duplicate /
13
+ * conflicting accept-reject pairs on one target_id
14
+ *
15
+ * Every test fails against the commit preceding its fix.
16
+ */
17
+
18
+ import { describe, it, expect } from "vitest";
19
+ import { createTestDocument, addParagraph } from "./test-utils.js";
20
+ import { DocumentObject } from "./docx/bridge.js";
21
+ import { RedlineEngine, BatchValidationError } from "./engine.js";
22
+ import { _extractTextFromDoc, extractTextFromBuffer } from "./ingest.js";
23
+
24
+ function addStyledRun(
25
+ p: Element,
26
+ text: string,
27
+ style: { bold?: boolean; italic?: boolean; underline?: boolean } = {},
28
+ ): Element {
29
+ const xmlDoc = p.ownerDocument!;
30
+ const r = xmlDoc.createElement("w:r");
31
+ if (style.bold || style.italic || style.underline) {
32
+ const rPr = xmlDoc.createElement("w:rPr");
33
+ if (style.bold) rPr.appendChild(xmlDoc.createElement("w:b"));
34
+ if (style.italic) rPr.appendChild(xmlDoc.createElement("w:i"));
35
+ if (style.underline) {
36
+ const u = xmlDoc.createElement("w:u");
37
+ u.setAttribute("w:val", "single");
38
+ rPr.appendChild(u);
39
+ }
40
+ r.appendChild(rPr);
41
+ }
42
+ const t = xmlDoc.createElement("w:t");
43
+ t.textContent = text;
44
+ if (text !== text.trim()) t.setAttribute("xml:space", "preserve");
45
+ r.appendChild(t);
46
+ p.appendChild(r);
47
+ return r;
48
+ }
49
+
50
+ function addEmptyParagraph(doc: DocumentObject): Element {
51
+ const xmlDoc = doc.element.ownerDocument!;
52
+ const p = xmlDoc.createElement("w:p");
53
+ doc.element.appendChild(p);
54
+ return p;
55
+ }
56
+
57
+ /** The QA report's F-04 fixture: bold "The Supplier ", italic
58
+ * "shall provide ", underlined remainder — one sentence, three runs. */
59
+ async function buildCrossRunDoc(): Promise<DocumentObject> {
60
+ const doc = await createTestDocument();
61
+ const p = addEmptyParagraph(doc);
62
+ addStyledRun(p, "The Supplier ", { bold: true });
63
+ addStyledRun(p, "shall provide ", { italic: true });
64
+ addStyledRun(p, "the Services with reasonable skill and care.", {
65
+ underline: true,
66
+ });
67
+ return doc;
68
+ }
69
+
70
+ function cleanText(doc: DocumentObject): string {
71
+ return _extractTextFromDoc(doc, {
72
+ cleanView: true,
73
+ includeAppendix: false,
74
+ }) as string;
75
+ }
76
+
77
+ /** A document carrying exactly one tracked modification (Chg:1 + Chg:2). */
78
+ async function buildTrackedChangeDoc(): Promise<DocumentObject> {
79
+ const doc = await createTestDocument();
80
+ addParagraph(doc, "Payment is due in 30 days.");
81
+ addParagraph(doc, "Second paragraph here.");
82
+ const engine = new RedlineEngine(doc);
83
+ engine.apply_edits([
84
+ { type: "modify", target_text: "30 days", new_text: "60 days" },
85
+ ]);
86
+ return DocumentObject.load(await doc.save());
87
+ }
88
+
89
+ /** A document carrying exactly one comment; returns [doc, "Com:<id>"]. */
90
+ async function buildCommentDoc(): Promise<[DocumentObject, string]> {
91
+ const doc = await createTestDocument();
92
+ addParagraph(doc, "Alpha beta gamma.");
93
+ addParagraph(doc, "Delta epsilon.");
94
+ const engine = new RedlineEngine(doc);
95
+ engine.process_batch([
96
+ {
97
+ type: "modify",
98
+ target_text: "Alpha beta gamma.",
99
+ new_text: "Alpha beta gamma.",
100
+ comment: "Please review.",
101
+ },
102
+ ]);
103
+ const buf = await doc.save();
104
+ const reloaded = await DocumentObject.load(buf);
105
+ const projected = await extractTextFromBuffer(buf);
106
+ const m = projected.match(/\[Com:(\d+)/);
107
+ expect(m, `expected a comment id in ${projected}`).toBeTruthy();
108
+ return [reloaded, `Com:${m![1]}`];
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // F-04: full-span replacements across formatting runs keep words whole
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("QA v8 F-04: cross-run full-span replacement fidelity", () => {
116
+ const TARGET =
117
+ "The Supplier shall provide the Services with reasonable skill and care.";
118
+ const NEW = "The Supplier must perform the Services professionally.";
119
+
120
+ it("leaves no partial-word bold after the replacement", async () => {
121
+ const doc = await buildCrossRunDoc();
122
+ const engine = new RedlineEngine(doc);
123
+ const stats = engine.process_batch([
124
+ { type: "modify", target_text: TARGET, new_text: NEW },
125
+ ]);
126
+ expect(stats.edits_applied).toBe(1);
127
+
128
+ const clean = cleanText(doc);
129
+ expect(clean).not.toContain("**The Suppli**");
130
+ expect(clean).not.toContain("Suppli**");
131
+ expect(clean.trim()).toBe(
132
+ "**The Supplier** must perform the Services professionally.",
133
+ );
134
+ });
135
+
136
+ it("accepted document matches the clean view", async () => {
137
+ const doc = await buildCrossRunDoc();
138
+ const engine = new RedlineEngine(doc);
139
+ engine.process_batch([
140
+ { type: "modify", target_text: TARGET, new_text: NEW },
141
+ ]);
142
+ engine.accept_all_revisions();
143
+ const accepted = _extractTextFromDoc(doc, {
144
+ cleanView: false,
145
+ includeAppendix: false,
146
+ }) as string;
147
+ expect(accepted.trim()).toBe(
148
+ "**The Supplier** must perform the Services professionally.",
149
+ );
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Hunt-profile counterexample (python property suite, 2026-07-19): a
155
+ // paragraph-splitting insertion at paragraph START must relocate the host
156
+ // paragraph's content into the LAST new paragraph. Deterministic pin,
157
+ // mirrored here because both engines shared the suffix-relocation logic.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe("QA v8: paragraph-start splitting insertion", () => {
161
+ it("relocates the host paragraph content into the last new paragraph", async () => {
162
+ const doc = await createTestDocument();
163
+ addParagraph(doc, "00.");
164
+ const engine = new RedlineEngine(doc);
165
+ const edit: any = {
166
+ type: "modify",
167
+ target_text: "",
168
+ new_text: "0.\n\n0 ",
169
+ _match_start_index: 0,
170
+ };
171
+ engine.apply_edits([edit]);
172
+ engine.accept_all_revisions();
173
+ const final = _extractTextFromDoc(doc, {
174
+ cleanView: true,
175
+ includeAppendix: false,
176
+ }) as string;
177
+ expect(final.trim()).toBe("0.\n\n0 00.");
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // F-07: blank replies and duplicate review actions are rejected
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe("QA v8 F-07: review-action validation", () => {
186
+ it("rejects a blank reply", async () => {
187
+ const [doc, comId] = await buildCommentDoc();
188
+ const engine = new RedlineEngine(doc);
189
+ expect(() =>
190
+ engine.process_batch([{ type: "reply", target_id: comId, text: " " }]),
191
+ ).toThrowError(BatchValidationError);
192
+ try {
193
+ engine.process_batch([{ type: "reply", target_id: comId, text: " " }]);
194
+ } catch (e: any) {
195
+ expect(e.errors.join("\n").toLowerCase()).toContain("empty");
196
+ }
197
+ });
198
+
199
+ it("rejects a duplicate accept of the same target_id", async () => {
200
+ const doc = await buildTrackedChangeDoc();
201
+ const engine = new RedlineEngine(doc);
202
+ try {
203
+ engine.process_batch([
204
+ { type: "accept", target_id: "Chg:1" },
205
+ { type: "accept", target_id: "Chg:1" },
206
+ ]);
207
+ expect.unreachable("duplicate accept must be rejected");
208
+ } catch (e: any) {
209
+ expect(e).toBeInstanceOf(BatchValidationError);
210
+ expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
211
+ }
212
+ });
213
+
214
+ it("rejects conflicting accept + reject on one target_id", async () => {
215
+ const doc = await buildTrackedChangeDoc();
216
+ const engine = new RedlineEngine(doc);
217
+ try {
218
+ engine.process_batch([
219
+ { type: "accept", target_id: "Chg:1" },
220
+ { type: "reject", target_id: "Chg:1" },
221
+ ]);
222
+ expect.unreachable("conflicting actions must be rejected");
223
+ } catch (e: any) {
224
+ expect(e).toBeInstanceOf(BatchValidationError);
225
+ expect(e.errors.join("\n").toLowerCase()).toContain("conflict");
226
+ }
227
+ });
228
+
229
+ it("rejects a duplicated identical reply", async () => {
230
+ const [doc, comId] = await buildCommentDoc();
231
+ const engine = new RedlineEngine(doc);
232
+ try {
233
+ engine.process_batch([
234
+ { type: "reply", target_id: comId, text: "Same reply." },
235
+ { type: "reply", target_id: comId, text: "Same reply." },
236
+ ]);
237
+ expect.unreachable("duplicate identical reply must be rejected");
238
+ } catch (e: any) {
239
+ expect(e).toBeInstanceOf(BatchValidationError);
240
+ expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
241
+ }
242
+ });
243
+
244
+ it("still allows distinct replies to the same comment", async () => {
245
+ const [doc, comId] = await buildCommentDoc();
246
+ const engine = new RedlineEngine(doc);
247
+ const stats = engine.process_batch([
248
+ { type: "reply", target_id: comId, text: "First reply." },
249
+ { type: "reply", target_id: comId, text: "Second reply." },
250
+ ]);
251
+ expect(stats.actions_applied).toBe(2);
252
+ expect(stats.actions_skipped).toBe(0);
253
+ });
254
+
255
+ it("still allows accepting the del+ins pair of one modification", async () => {
256
+ const doc = await buildTrackedChangeDoc();
257
+ const engine = new RedlineEngine(doc);
258
+ const stats = engine.process_batch([
259
+ { type: "accept", target_id: "Chg:1" },
260
+ { type: "accept", target_id: "Chg:2" },
261
+ ]);
262
+ expect(stats.actions_skipped).toBe(0);
263
+ expect(cleanText(doc)).toContain("60 days");
264
+ });
265
+ });
@@ -78,6 +78,9 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
78
78
  for (const w of transforms.detect_watermarks(doc)) report.warnings.push(w);
79
79
 
80
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));
81
84
 
82
85
  // Protection (Settings injection)
83
86
  if (options.protection_mode === 'read_only' || options.protection_mode === 'encrypt') {
@@ -417,6 +417,35 @@ export function normalize_change_dates(doc: DocumentObject): string[] {
417
417
  return count ? [`Track change timestamps: ${count} normalized`] : [];
418
418
  }
419
419
 
420
+ /**
421
+ * Normalize timestamps on RETAINED comments to the fixed sanitize date.
422
+ * Keep-markup sanitize normalized core and tracked-change timestamps but
423
+ * left the original comment timestamps in word/comments.xml (w:date) and
424
+ * word/commentsExtensible.xml (w16cex:dateUtc) — visible through any
425
+ * extraction and carrying exactly the when-did-they-work signal the other
426
+ * normalizations remove (QA 2026-07-19 F-09).
427
+ */
428
+ export function normalize_comment_dates(doc: DocumentObject): string[] {
429
+ let count = 0;
430
+ const fixed = "2025-01-01T00:00:00Z";
431
+ for (const part of doc.pkg.parts) {
432
+ if (!part.contentType.includes('comments')) continue;
433
+ for (const el of findAllDescendants(part._element, 'w:comment')) {
434
+ if (el.hasAttribute('w:date')) {
435
+ el.setAttribute('w:date', fixed);
436
+ count++;
437
+ }
438
+ }
439
+ for (const el of findAllDescendants(part._element, 'w16cex:commentExtensible')) {
440
+ if (el.hasAttribute('w16cex:dateUtc')) {
441
+ el.setAttribute('w16cex:dateUtc', fixed);
442
+ count++;
443
+ }
444
+ }
445
+ }
446
+ return count ? [`Comment timestamps: ${count} normalized`] : [];
447
+ }
448
+
420
449
  export function scrub_doc_properties(doc: DocumentObject): string[] {
421
450
  const lines: string[] = [];
422
451
  const corePart = doc.pkg.getPartByPath('docProps/core.xml');
package/src/utils/docx.ts CHANGED
@@ -560,6 +560,27 @@ export function get_run_style_markers(
560
560
  return [prefix, suffix];
561
561
  }
562
562
 
563
+ /**
564
+ * Splits `text` into [leading_ws, core, trailing_ws]. Emphasis markers must
565
+ * wrap only the core: `**The Supplier **` (a bold run with a trailing space)
566
+ * is malformed Markdown — CommonMark requires the closing delimiter to hug
567
+ * non-whitespace — and it poisons every downstream CriticMarkup consumer
568
+ * (QA 2026-07-19 F-03/F-10). Fully-whitespace text yields ["", "", text] so
569
+ * callers skip the markers entirely.
570
+ */
571
+ export function split_boundary_whitespace(
572
+ text: string,
573
+ ): [string, string, string] {
574
+ const core = text.trim();
575
+ if (!core) return ["", "", text];
576
+ const lead_len = text.length - text.trimStart().length;
577
+ return [
578
+ text.substring(0, lead_len),
579
+ text.substring(lead_len, lead_len + core.length),
580
+ text.substring(lead_len + core.length),
581
+ ];
582
+ }
583
+
563
584
  export function apply_formatting_to_segments(
564
585
  text: string,
565
586
  prefix: string,
@@ -567,10 +588,17 @@ export function apply_formatting_to_segments(
567
588
  ): string {
568
589
  if (!prefix && !suffix) return text;
569
590
  if (!text) return "";
570
- if (!text.includes("\n")) return `${prefix}${text}${suffix}`;
591
+
592
+ const wrap = (segment: string): string => {
593
+ const [lead, core, trail] = split_boundary_whitespace(segment);
594
+ if (!core) return segment;
595
+ return `${lead}${prefix}${core}${suffix}${trail}`;
596
+ };
597
+
598
+ if (!text.includes("\n")) return wrap(text);
571
599
 
572
600
  const parts = text.split("\n");
573
- return parts.map((p) => (p ? `${prefix}${p}${suffix}` : "")).join("\n");
601
+ return parts.map((p) => (p ? wrap(p) : "")).join("\n");
574
602
  }
575
603
 
576
604
  export function get_run_text(run: Run): string {