@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.
- package/dist/index.cjs +354 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +352 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +129 -4
- package/src/engine.ts +228 -24
- package/src/index.ts +1 -1
- package/src/ingest.ts +11 -2
- package/src/mapper.ts +107 -47
- package/src/repro_qa_report_v7.test.ts +380 -0
- package/src/repro_qa_report_v8.test.ts +265 -0
- package/src/sanitize/core.ts +3 -0
- package/src/sanitize/transforms.ts +29 -0
- package/src/utils/docx.ts +30 -2
|
@@ -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
|
+
});
|
package/src/sanitize/core.ts
CHANGED
|
@@ -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
|
-
|
|
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 ?
|
|
601
|
+
return parts.map((p) => (p ? wrap(p) : "")).join("\n");
|
|
574
602
|
}
|
|
575
603
|
|
|
576
604
|
export function get_run_text(run: Run): string {
|