@adeu/core 1.14.0 → 1.15.1
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 +2198 -1788
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -12
- package/dist/index.d.ts +7 -12
- package/dist/index.js +2198 -1788
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/comment_dedup.test.ts +269 -0
- package/src/domain.test.ts +280 -77
- package/src/domain.ts +146 -46
- package/src/engine.qa.test.ts +162 -0
- package/src/engine.ts +274 -89
- package/src/ingest.ts +283 -133
- package/src/mapper.ts +425 -196
- package/src/models.ts +2 -0
- package/src/repro_heading_bug.test.ts +174 -0
- package/src/search_write_engine.test.ts +112 -0
package/package.json
CHANGED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// FILE: node/packages/core/src/comment_dedup.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { zipSync, strToU8 } from "fflate";
|
|
4
|
+
import { extractTextFromBuffer } from "./ingest.js";
|
|
5
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
6
|
+
import { DocumentMapper } from "./mapper.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const NS_W =
|
|
13
|
+
'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
|
14
|
+
const NS_W14 =
|
|
15
|
+
'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"';
|
|
16
|
+
const NS_R =
|
|
17
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds a minimal in-memory DOCX buffer from raw document body XML and a
|
|
21
|
+
* raw <w:comment> element list. We hand-assemble the OPC package to control
|
|
22
|
+
* exactly how runs are split inside comment ranges — python-docx-style
|
|
23
|
+
* builders would coalesce runs and hide the bug.
|
|
24
|
+
*/
|
|
25
|
+
function buildDocx(bodyXml: string, commentsListXml: string = ""): Buffer {
|
|
26
|
+
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
27
|
+
<w:document ${NS_W} ${NS_W14} ${NS_R}>
|
|
28
|
+
<w:body>${bodyXml}</w:body>
|
|
29
|
+
</w:document>`;
|
|
30
|
+
|
|
31
|
+
const commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
32
|
+
<w:comments ${NS_W} ${NS_W14}>${commentsListXml}</w:comments>`;
|
|
33
|
+
|
|
34
|
+
const hasComments = commentsListXml.length > 0;
|
|
35
|
+
|
|
36
|
+
const contentTypesXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
37
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
38
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
39
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
40
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
41
|
+
${hasComments ? '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>' : ""}
|
|
42
|
+
</Types>`;
|
|
43
|
+
|
|
44
|
+
const rootRelsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
45
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
46
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
47
|
+
</Relationships>`;
|
|
48
|
+
|
|
49
|
+
const docRelsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
50
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
51
|
+
${hasComments ? '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>' : ""}
|
|
52
|
+
</Relationships>`;
|
|
53
|
+
|
|
54
|
+
const files: Record<string, Uint8Array> = {
|
|
55
|
+
"[Content_Types].xml": strToU8(contentTypesXml),
|
|
56
|
+
"_rels/.rels": strToU8(rootRelsXml),
|
|
57
|
+
"word/document.xml": strToU8(documentXml),
|
|
58
|
+
"word/_rels/document.xml.rels": strToU8(docRelsXml),
|
|
59
|
+
};
|
|
60
|
+
if (hasComments) {
|
|
61
|
+
files["word/comments.xml"] = strToU8(commentsXml);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Buffer.from(zipSync(files));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function commentXml(
|
|
68
|
+
id: string,
|
|
69
|
+
author: string,
|
|
70
|
+
date: string,
|
|
71
|
+
text: string,
|
|
72
|
+
): string {
|
|
73
|
+
return `<w:comment w:id="${id}" w:author="${author}" w:date="${date}" w:initials="X">
|
|
74
|
+
<w:p w14:paraId="${id.padStart(8, "0")}" w14:textId="77777777">
|
|
75
|
+
<w:r><w:t>${text}</w:t></w:r>
|
|
76
|
+
</w:p>
|
|
77
|
+
</w:comment>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Regression tests for the multi-run comment duplication bug
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe("Multi-run comment deduplication (BUG: comment text repeated per run)", () => {
|
|
85
|
+
it("emits a single comment block when one comment spans many runs (the reported bug)", async () => {
|
|
86
|
+
// 8 runs (Word naturally fragments around numbers, punctuation, formatting).
|
|
87
|
+
// Before the fix this produced 8 duplicate {>>...<<} blocks.
|
|
88
|
+
const body = `
|
|
89
|
+
<w:p>
|
|
90
|
+
<w:commentRangeStart w:id="1"/>
|
|
91
|
+
<w:r><w:t xml:space="preserve">Party A shall pay </w:t></w:r>
|
|
92
|
+
<w:r><w:t>100</w:t></w:r>
|
|
93
|
+
<w:r><w:t>%</w:t></w:r>
|
|
94
|
+
<w:r><w:t xml:space="preserve"> of the total</w:t></w:r>
|
|
95
|
+
<w:r><w:t xml:space="preserve"> amount</w:t></w:r>
|
|
96
|
+
<w:r><w:t xml:space="preserve"> on </w:t></w:r>
|
|
97
|
+
<w:r><w:t>time</w:t></w:r>
|
|
98
|
+
<w:r><w:t>.</w:t></w:r>
|
|
99
|
+
<w:commentRangeEnd w:id="1"/>
|
|
100
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
101
|
+
</w:p>`;
|
|
102
|
+
|
|
103
|
+
const comments = commentXml(
|
|
104
|
+
"1",
|
|
105
|
+
"Reviewer",
|
|
106
|
+
"2026-06-15T10:00:00Z",
|
|
107
|
+
"Risk note: please confirm the payment percentage and timing.",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const buf = buildDocx(body, comments);
|
|
111
|
+
const md = await extractTextFromBuffer(buf, false);
|
|
112
|
+
|
|
113
|
+
// The full highlighted range must be a single contiguous {==...==} block.
|
|
114
|
+
expect(md).toContain(
|
|
115
|
+
"{==Party A shall pay 100% of the total amount on time.==}",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Exactly one comment meta block.
|
|
119
|
+
expect((md.match(/\[Com:1\]/g) || []).length).toBe(1);
|
|
120
|
+
expect((md.match(/\{==/g) || []).length).toBe(1);
|
|
121
|
+
expect((md.match(/\{>>/g) || []).length).toBe(1);
|
|
122
|
+
|
|
123
|
+
// And the comment payload appears exactly once.
|
|
124
|
+
const payload =
|
|
125
|
+
"Risk note: please confirm the payment percentage and timing.";
|
|
126
|
+
expect(md.split(payload).length - 1).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("keeps two separate comments on adjacent runs independent (must NOT merge)", async () => {
|
|
130
|
+
// Comment 1 wraps "First part", comment 2 wraps "second part".
|
|
131
|
+
// These are distinct ranges with no overlap and must stay distinct in output.
|
|
132
|
+
const body = `
|
|
133
|
+
<w:p>
|
|
134
|
+
<w:commentRangeStart w:id="1"/>
|
|
135
|
+
<w:r><w:t xml:space="preserve">First </w:t></w:r>
|
|
136
|
+
<w:r><w:t>part</w:t></w:r>
|
|
137
|
+
<w:commentRangeEnd w:id="1"/>
|
|
138
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
139
|
+
<w:r><w:t xml:space="preserve"> middle </w:t></w:r>
|
|
140
|
+
<w:commentRangeStart w:id="2"/>
|
|
141
|
+
<w:r><w:t xml:space="preserve">second </w:t></w:r>
|
|
142
|
+
<w:r><w:t>part</w:t></w:r>
|
|
143
|
+
<w:commentRangeEnd w:id="2"/>
|
|
144
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="2"/></w:r>
|
|
145
|
+
</w:p>`;
|
|
146
|
+
|
|
147
|
+
const comments =
|
|
148
|
+
commentXml("1", "A", "2026-06-15T10:00:00Z", "First annotation") +
|
|
149
|
+
commentXml("2", "B", "2026-06-15T11:00:00Z", "Second annotation");
|
|
150
|
+
|
|
151
|
+
const buf = buildDocx(body, comments);
|
|
152
|
+
const md = await extractTextFromBuffer(buf, false);
|
|
153
|
+
|
|
154
|
+
// Each comment appears exactly once, and they are distinct blocks.
|
|
155
|
+
expect((md.match(/\[Com:1\]/g) || []).length).toBe(1);
|
|
156
|
+
expect((md.match(/\[Com:2\]/g) || []).length).toBe(1);
|
|
157
|
+
expect((md.match(/\{>>/g) || []).length).toBe(2);
|
|
158
|
+
expect((md.match(/\{==/g) || []).length).toBe(2);
|
|
159
|
+
|
|
160
|
+
// Highlighted ranges must contain the right text on each side of the middle.
|
|
161
|
+
expect(md).toContain("{==First part==}");
|
|
162
|
+
expect(md).toContain("{==second part==}");
|
|
163
|
+
// The un-commented "middle" text must NOT be inside a highlight wrapper.
|
|
164
|
+
expect(md).toContain(" middle ");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("merges overlapping comments (same range, two comment IDs) into a single meta block", async () => {
|
|
168
|
+
// Both comment 1 and comment 2 span the same exact text. Per the existing
|
|
169
|
+
// state-machine semantics, both IDs are active simultaneously, so the meta
|
|
170
|
+
// block should list both — but only once.
|
|
171
|
+
const body = `
|
|
172
|
+
<w:p>
|
|
173
|
+
<w:commentRangeStart w:id="1"/>
|
|
174
|
+
<w:commentRangeStart w:id="2"/>
|
|
175
|
+
<w:r><w:t xml:space="preserve">Shared </w:t></w:r>
|
|
176
|
+
<w:r><w:t>highlighted</w:t></w:r>
|
|
177
|
+
<w:r><w:t xml:space="preserve"> text</w:t></w:r>
|
|
178
|
+
<w:commentRangeEnd w:id="1"/>
|
|
179
|
+
<w:commentRangeEnd w:id="2"/>
|
|
180
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
181
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="2"/></w:r>
|
|
182
|
+
</w:p>`;
|
|
183
|
+
|
|
184
|
+
const comments =
|
|
185
|
+
commentXml("1", "A", "2026-06-15T10:00:00Z", "Comment one") +
|
|
186
|
+
commentXml("2", "B", "2026-06-15T11:00:00Z", "Comment two");
|
|
187
|
+
|
|
188
|
+
const buf = buildDocx(body, comments);
|
|
189
|
+
const md = await extractTextFromBuffer(buf, false);
|
|
190
|
+
|
|
191
|
+
// Single highlight wrapper covering the full range.
|
|
192
|
+
expect(md).toContain("{==Shared highlighted text==}");
|
|
193
|
+
expect((md.match(/\{==/g) || []).length).toBe(1);
|
|
194
|
+
|
|
195
|
+
// Single meta block that mentions both comments.
|
|
196
|
+
expect((md.match(/\{>>/g) || []).length).toBe(1);
|
|
197
|
+
expect((md.match(/\[Com:1\]/g) || []).length).toBe(1);
|
|
198
|
+
expect((md.match(/\[Com:2\]/g) || []).length).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("handles a tracked insertion nested inside a comment range without duplication", async () => {
|
|
202
|
+
// Comment 1 spans 3 runs; one of those runs is wrapped in <w:ins>.
|
|
203
|
+
// The redline must produce its own {++...++} marker but the comment
|
|
204
|
+
// payload must still appear only once.
|
|
205
|
+
const body = `
|
|
206
|
+
<w:p>
|
|
207
|
+
<w:commentRangeStart w:id="1"/>
|
|
208
|
+
<w:r><w:t xml:space="preserve">Plain </w:t></w:r>
|
|
209
|
+
<w:ins w:id="100" w:author="Editor" w:date="2026-06-15T12:00:00Z">
|
|
210
|
+
<w:r><w:t>inserted</w:t></w:r>
|
|
211
|
+
</w:ins>
|
|
212
|
+
<w:r><w:t xml:space="preserve"> tail</w:t></w:r>
|
|
213
|
+
<w:commentRangeEnd w:id="1"/>
|
|
214
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
215
|
+
</w:p>`;
|
|
216
|
+
|
|
217
|
+
const comments = commentXml(
|
|
218
|
+
"1",
|
|
219
|
+
"Reviewer",
|
|
220
|
+
"2026-06-15T10:00:00Z",
|
|
221
|
+
"Mixed comment over a redline",
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const buf = buildDocx(body, comments);
|
|
225
|
+
const md = await extractTextFromBuffer(buf, false);
|
|
226
|
+
|
|
227
|
+
// The insertion is still surfaced as a redline.
|
|
228
|
+
expect(md).toContain("{++inserted++}");
|
|
229
|
+
|
|
230
|
+
// The comment payload appears exactly once.
|
|
231
|
+
expect((md.match(/\[Com:1\]/g) || []).length).toBe(1);
|
|
232
|
+
expect((md.match(/Mixed comment over a redline/g) || []).length).toBe(1);
|
|
233
|
+
|
|
234
|
+
// There should be the comment highlight pieces around the plain text,
|
|
235
|
+
// plus the {++ wrapper for the insertion — but the comment text itself
|
|
236
|
+
// must not be repeated.
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("DocumentMapper.full_text mirrors the projected output (parity with ingest)", async () => {
|
|
240
|
+
// If the mapper produces a different number of comment blocks than the
|
|
241
|
+
// projection layer, the engine's find/replace anchoring breaks. This guards
|
|
242
|
+
// mapper.ts against regressing independently of ingest.ts.
|
|
243
|
+
const body = `
|
|
244
|
+
<w:p>
|
|
245
|
+
<w:commentRangeStart w:id="1"/>
|
|
246
|
+
<w:r><w:t xml:space="preserve">A </w:t></w:r>
|
|
247
|
+
<w:r><w:t>B</w:t></w:r>
|
|
248
|
+
<w:r><w:t xml:space="preserve"> C</w:t></w:r>
|
|
249
|
+
<w:commentRangeEnd w:id="1"/>
|
|
250
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
251
|
+
</w:p>`;
|
|
252
|
+
|
|
253
|
+
const comments = commentXml(
|
|
254
|
+
"1",
|
|
255
|
+
"X",
|
|
256
|
+
"2026-06-15T10:00:00Z",
|
|
257
|
+
"Mapper parity check",
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const buf = buildDocx(body, comments);
|
|
261
|
+
const doc = await DocumentObject.load(buf);
|
|
262
|
+
const mapper = new DocumentMapper(doc);
|
|
263
|
+
|
|
264
|
+
expect((mapper.full_text.match(/\[Com:1\]/g) || []).length).toBe(1);
|
|
265
|
+
expect((mapper.full_text.match(/\{>>/g) || []).length).toBe(1);
|
|
266
|
+
expect((mapper.full_text.match(/\{==/g) || []).length).toBe(1);
|
|
267
|
+
expect(mapper.full_text).toContain("{==A B C==}");
|
|
268
|
+
});
|
|
269
|
+
});
|