@adeu/core 1.18.2 → 1.18.5
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 +9 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +23 -18
- package/src/repro.comment-range-modify.test.ts +266 -0
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -1735,30 +1735,35 @@ export class RedlineEngine {
|
|
|
1735
1735
|
}
|
|
1736
1736
|
}
|
|
1737
1737
|
}
|
|
1738
|
-
if (insAuthors.size > 0
|
|
1738
|
+
if (insAuthors.size > 0) {
|
|
1739
1739
|
// A single (strict/first) modification whose target lies ENTIRELY
|
|
1740
|
-
// inside foreign-authored insertion(s)
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1745
|
-
|
|
1746
|
-
const fullyWithinForeignIns =
|
|
1747
|
-
insAuthors.size > 0 &&
|
|
1748
|
-
!hasNonForeignRealText &&
|
|
1749
|
-
commentAuthors.size === 0;
|
|
1740
|
+
// inside foreign-authored insertion(s) is allowed: track_delete_run
|
|
1741
|
+
// splits the enclosing <w:ins> and nests the change, producing valid
|
|
1742
|
+
// tracked-change XML. Refuse the remaining cases — match_mode "all"
|
|
1743
|
+
// fan-outs and partial overlaps that straddle the insertion
|
|
1744
|
+
// boundary.
|
|
1745
|
+
const fullyWithinForeignIns = !hasNonForeignRealText;
|
|
1750
1746
|
if (
|
|
1751
|
-
(
|
|
1752
|
-
|
|
1747
|
+
!(
|
|
1748
|
+
(match_mode === "strict" || match_mode === "first") &&
|
|
1749
|
+
fullyWithinForeignIns
|
|
1750
|
+
)
|
|
1753
1751
|
) {
|
|
1752
|
+
errors.push(
|
|
1753
|
+
`- Edit ${i + 1 + index_offset} Failed: Modification targets an active insertion from another author (${Array.from(insAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
|
|
1754
|
+
);
|
|
1754
1755
|
continue;
|
|
1755
1756
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1757
|
+
}
|
|
1758
|
+
// Foreign comment ranges do NOT block deliberate single-occurrence
|
|
1759
|
+
// edits: amending body text under a colleague's comment is a normal
|
|
1760
|
+
// review workflow, and the comment anchor survives the tracked change.
|
|
1761
|
+
// Only blind match_mode="all" fan-outs are refused, so a bulk
|
|
1762
|
+
// replacement cannot silently sweep through another author's
|
|
1763
|
+
// annotations (transactional rollback).
|
|
1764
|
+
if (commentAuthors.size > 0 && match_mode === "all") {
|
|
1760
1765
|
errors.push(
|
|
1761
|
-
`- Edit ${i + 1 + index_offset} Failed:
|
|
1766
|
+
`- Edit ${i + 1 + index_offset} Failed: match_mode="all" would sweep through a comment range from another author (${Array.from(commentAuthors).join(", ")}). Target the commented text deliberately with match_mode "strict" or "first", or scope your edit outside of it.`,
|
|
1762
1767
|
);
|
|
1763
1768
|
}
|
|
1764
1769
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// FILE: node/packages/core/src/repro.comment-range-modify.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Reproduction for the field report (2026-07-03, PII-Shield Desktop / memo
|
|
4
|
+
// review):
|
|
5
|
+
//
|
|
6
|
+
// "adeu's modify refuses to touch any text that sits inside another
|
|
7
|
+
// author's comment range, misclassifying it as 'an active insertion from
|
|
8
|
+
// another author.' This holds even when the target is fully inside the
|
|
9
|
+
// span (confirmed via dry-run)."
|
|
10
|
+
//
|
|
11
|
+
// Scenario: a memo arrives with a colleague's margin comments anchored to
|
|
12
|
+
// plain, untracked body text. The agent is asked to amend that body text.
|
|
13
|
+
// The engine refuses with "Modification targets an active insertion from
|
|
14
|
+
// another author" even though no <w:ins> exists anywhere in the document —
|
|
15
|
+
// only a foreign COMMENT_ONLY annotation. The user's forced workaround
|
|
16
|
+
// (strip all comments, edit, re-diff) defeats the point of redlining.
|
|
17
|
+
//
|
|
18
|
+
// Root cause (engine.ts validate_edits): the foreign <w:ins> overlap check
|
|
19
|
+
// and the foreign comment-range overlap check feed the same rejection
|
|
20
|
+
// branch. Any foreign comment overlap — with zero insertions — is rejected,
|
|
21
|
+
// and with the insertion-specific error message.
|
|
22
|
+
//
|
|
23
|
+
// Word's native behavior: editing text under someone else's comment is a
|
|
24
|
+
// normal review workflow. The comment anchor persists and the edit becomes a
|
|
25
|
+
// tracked change. Deleting/replying to the COMMENT ITSELF is a different
|
|
26
|
+
// operation and stays protected.
|
|
27
|
+
//
|
|
28
|
+
// STYLE: these tests assert the DESIRED behaviour, so they are RED while the
|
|
29
|
+
// bug is present and turn GREEN once the engine is fixed (the "isolate the
|
|
30
|
+
// bug before fixing" pattern from AI_CONTEXT.md > Testing). The GREEN
|
|
31
|
+
// controls pin down the boundary of the fix: same-author comments must keep
|
|
32
|
+
// working, and the foreign tracked-INSERTION straddle protection must NOT be
|
|
33
|
+
// loosened. The Python twin is
|
|
34
|
+
// python/tests/test_repro_foreign_comment_range_modify.py.
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from "vitest";
|
|
37
|
+
import { zipSync, strToU8 } from "fflate";
|
|
38
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
39
|
+
import { RedlineEngine } from "./engine.js";
|
|
40
|
+
import { extractTextFromBuffer } from "./ingest.js";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers (fixture builder mirrors comment_dedup.test.ts)
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const NS_W =
|
|
47
|
+
'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
|
48
|
+
const NS_W14 =
|
|
49
|
+
'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"';
|
|
50
|
+
const NS_R =
|
|
51
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
|
|
52
|
+
|
|
53
|
+
function buildDocx(bodyXml: string, commentsListXml: string = ""): Buffer {
|
|
54
|
+
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
55
|
+
<w:document ${NS_W} ${NS_W14} ${NS_R}>
|
|
56
|
+
<w:body>${bodyXml}</w:body>
|
|
57
|
+
</w:document>`;
|
|
58
|
+
|
|
59
|
+
const commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
60
|
+
<w:comments ${NS_W} ${NS_W14}>${commentsListXml}</w:comments>`;
|
|
61
|
+
|
|
62
|
+
const hasComments = commentsListXml.length > 0;
|
|
63
|
+
|
|
64
|
+
const contentTypesXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
65
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
66
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
67
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
68
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
69
|
+
${hasComments ? '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>' : ""}
|
|
70
|
+
</Types>`;
|
|
71
|
+
|
|
72
|
+
const rootRelsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
73
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
74
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
75
|
+
</Relationships>`;
|
|
76
|
+
|
|
77
|
+
const docRelsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
78
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
79
|
+
${hasComments ? '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>' : ""}
|
|
80
|
+
</Relationships>`;
|
|
81
|
+
|
|
82
|
+
const files: Record<string, Uint8Array> = {
|
|
83
|
+
"[Content_Types].xml": strToU8(contentTypesXml),
|
|
84
|
+
"_rels/.rels": strToU8(rootRelsXml),
|
|
85
|
+
"word/document.xml": strToU8(documentXml),
|
|
86
|
+
"word/_rels/document.xml.rels": strToU8(docRelsXml),
|
|
87
|
+
};
|
|
88
|
+
if (hasComments) {
|
|
89
|
+
files["word/comments.xml"] = strToU8(commentsXml);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Buffer.from(zipSync(files));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The reported document state: clean memo body text, one comment by
|
|
97
|
+
* "Colleague" anchored to 'the budget is 40,000 EUR'. Zero tracked changes.
|
|
98
|
+
*/
|
|
99
|
+
function buildMemoWithColleagueComment(): Buffer {
|
|
100
|
+
const body = `
|
|
101
|
+
<w:p><w:r><w:t>MEMO</w:t></w:r></w:p>
|
|
102
|
+
<w:p>
|
|
103
|
+
<w:r><w:t xml:space="preserve">The project deadline is 15 September 2026 and </w:t></w:r>
|
|
104
|
+
<w:commentRangeStart w:id="1"/>
|
|
105
|
+
<w:r><w:t xml:space="preserve">the budget is 40,000 EUR</w:t></w:r>
|
|
106
|
+
<w:commentRangeEnd w:id="1"/>
|
|
107
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
108
|
+
<w:r><w:t>.</w:t></w:r>
|
|
109
|
+
</w:p>
|
|
110
|
+
<w:p><w:r><w:t>Please review the terms above.</w:t></w:r></w:p>`;
|
|
111
|
+
|
|
112
|
+
const comments = `<w:comment w:id="1" w:author="Colleague" w:date="2026-07-01T10:00:00Z" w:initials="CO">
|
|
113
|
+
<w:p><w:r><w:t>Is this figure still correct?</w:t></w:r></w:p>
|
|
114
|
+
</w:comment>`;
|
|
115
|
+
|
|
116
|
+
return buildDocx(body, comments);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function countTag(el: any, tag: string): number {
|
|
120
|
+
let n = 0;
|
|
121
|
+
const walk = (node: any) => {
|
|
122
|
+
if (node.tagName === tag) n++;
|
|
123
|
+
let c = node.firstChild;
|
|
124
|
+
while (c) {
|
|
125
|
+
if (c.nodeType === 1) walk(c);
|
|
126
|
+
c = c.nextSibling;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
walk(el);
|
|
130
|
+
return n;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("Foreign comment-range modify repro — Node engine", () => {
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
135
|
+
// RED — the reported bug. Desired: the edit applies and the comment
|
|
136
|
+
// survives.
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
138
|
+
it("RED: modifying text strictly inside a foreign comment range applies as a tracked change", async () => {
|
|
139
|
+
const buf = buildMemoWithColleagueComment();
|
|
140
|
+
|
|
141
|
+
// Fixture sanity: one comment, zero redlines.
|
|
142
|
+
const before = await extractTextFromBuffer(buf, false);
|
|
143
|
+
expect(before).toContain("[Com:1]");
|
|
144
|
+
expect(before).not.toContain("{++");
|
|
145
|
+
expect(before).not.toContain("{--");
|
|
146
|
+
|
|
147
|
+
const doc = await DocumentObject.load(buf);
|
|
148
|
+
const engine = new RedlineEngine(doc, "Agent");
|
|
149
|
+
|
|
150
|
+
// Target '40,000 EUR' sits strictly inside Colleague's comment span.
|
|
151
|
+
// There is no <w:ins> anywhere in the document, so refusing this as "an
|
|
152
|
+
// active insertion from another author" is a misclassification.
|
|
153
|
+
const res = engine.process_batch(
|
|
154
|
+
[{ type: "modify", target_text: "40,000 EUR", new_text: "45,000 EUR" }] as any[],
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
expect(res.edits_applied).toBe(1);
|
|
158
|
+
expect(res.edits_skipped).toBe(0);
|
|
159
|
+
|
|
160
|
+
const outBuf = await doc.save();
|
|
161
|
+
const clean = await extractTextFromBuffer(outBuf, true);
|
|
162
|
+
expect(clean).toContain("45,000 EUR");
|
|
163
|
+
|
|
164
|
+
const marked = await extractTextFromBuffer(outBuf, false);
|
|
165
|
+
// Edit must land as a tracked change, not a silent rewrite.
|
|
166
|
+
expect(marked).toContain("{++");
|
|
167
|
+
expect(marked).toContain("{--");
|
|
168
|
+
expect(marked).toContain("Agent");
|
|
169
|
+
|
|
170
|
+
// The colleague's annotation must survive the edit underneath it.
|
|
171
|
+
expect(marked).toContain("Is this figure still correct?");
|
|
172
|
+
expect(countTag(doc.element, "w:commentRangeStart")).toBe(1);
|
|
173
|
+
expect(countTag(doc.element, "w:commentRangeEnd")).toBe(1);
|
|
174
|
+
expect(countTag(doc.element, "w:commentReference")).toBe(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("RED: modifying the entire foreign-commented span applies", async () => {
|
|
178
|
+
// Boundary variant: the target coincides exactly with the commented
|
|
179
|
+
// span, so the edit's edges touch the commentRangeStart/End markers.
|
|
180
|
+
const doc = await DocumentObject.load(buildMemoWithColleagueComment());
|
|
181
|
+
const engine = new RedlineEngine(doc, "Agent");
|
|
182
|
+
|
|
183
|
+
const res = engine.process_batch(
|
|
184
|
+
[
|
|
185
|
+
{
|
|
186
|
+
type: "modify",
|
|
187
|
+
target_text: "the budget is 40,000 EUR",
|
|
188
|
+
new_text: "the budget is 45,000 EUR excluding VAT",
|
|
189
|
+
},
|
|
190
|
+
] as any[],
|
|
191
|
+
false,
|
|
192
|
+
);
|
|
193
|
+
expect(res.edits_applied).toBe(1);
|
|
194
|
+
expect(res.edits_skipped).toBe(0);
|
|
195
|
+
|
|
196
|
+
const outBuf = await doc.save();
|
|
197
|
+
const clean = await extractTextFromBuffer(outBuf, true);
|
|
198
|
+
expect(clean).toContain("45,000 EUR excluding VAT");
|
|
199
|
+
|
|
200
|
+
const marked = await extractTextFromBuffer(outBuf, false);
|
|
201
|
+
expect(marked).toContain("Is this figure still correct?");
|
|
202
|
+
expect(countTag(doc.element, "w:commentRangeStart")).toBe(1);
|
|
203
|
+
expect(countTag(doc.element, "w:commentRangeEnd")).toBe(1);
|
|
204
|
+
expect(countTag(doc.element, "w:commentReference")).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("RED: dry-run reports the edit under a foreign comment as applicable", async () => {
|
|
208
|
+
// The field report explicitly says the refusal was "confirmed via
|
|
209
|
+
// dry-run". Desired: dry-run previews the edit as applicable instead of
|
|
210
|
+
// failing it with the insertion misclassification.
|
|
211
|
+
const doc = await DocumentObject.load(buildMemoWithColleagueComment());
|
|
212
|
+
const engine = new RedlineEngine(doc, "Agent");
|
|
213
|
+
|
|
214
|
+
const res = engine.process_batch(
|
|
215
|
+
[{ type: "modify", target_text: "40,000 EUR", new_text: "45,000 EUR" }] as any[],
|
|
216
|
+
true,
|
|
217
|
+
);
|
|
218
|
+
expect(res.edits_applied).toBe(1);
|
|
219
|
+
expect(res.edits_skipped).toBe(0);
|
|
220
|
+
expect(res.edits[0].status).toBe("applied");
|
|
221
|
+
expect(res.edits[0].error || "").not.toContain(
|
|
222
|
+
"active insertion from another author",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
227
|
+
// GREEN controls — pin the boundary of the fix.
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
229
|
+
it("GREEN control: the comment author editing their own commented text is not blocked", async () => {
|
|
230
|
+
// Proves the rejection above is keyed purely on foreign authorship of
|
|
231
|
+
// the comment, not on comment ranges per se.
|
|
232
|
+
const doc = await DocumentObject.load(buildMemoWithColleagueComment());
|
|
233
|
+
const engine = new RedlineEngine(doc, "Colleague");
|
|
234
|
+
|
|
235
|
+
const res = engine.process_batch(
|
|
236
|
+
[{ type: "modify", target_text: "40,000 EUR", new_text: "45,000 EUR" }] as any[],
|
|
237
|
+
false,
|
|
238
|
+
);
|
|
239
|
+
expect(res.edits_applied).toBe(1);
|
|
240
|
+
expect(res.edits_skipped).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("GREEN control: a straddle of a foreign tracked INSERTION stays refused", async () => {
|
|
244
|
+
// Must stay green after the fix: an edit partially straddling a foreign
|
|
245
|
+
// author's real <w:ins> is still refused — that protection is legitimate
|
|
246
|
+
// and must not be loosened while enabling comment-range edits.
|
|
247
|
+
const body = `
|
|
248
|
+
<w:p>
|
|
249
|
+
<w:r><w:t xml:space="preserve">The quick </w:t></w:r>
|
|
250
|
+
<w:ins w:id="100" w:author="Colleague" w:date="2026-07-01T10:00:00Z">
|
|
251
|
+
<w:r><w:t>red</w:t></w:r>
|
|
252
|
+
</w:ins>
|
|
253
|
+
<w:r><w:t xml:space="preserve"> fox jumps.</w:t></w:r>
|
|
254
|
+
</w:p>`;
|
|
255
|
+
const doc = await DocumentObject.load(buildDocx(body));
|
|
256
|
+
const engine = new RedlineEngine(doc, "Agent");
|
|
257
|
+
|
|
258
|
+
// 'red fox' = foreign inserted 'red' + untracked body ' fox' -> straddle.
|
|
259
|
+
expect(() =>
|
|
260
|
+
engine.process_batch(
|
|
261
|
+
[{ type: "modify", target_text: "red fox", new_text: "crimson wolf" }] as any[],
|
|
262
|
+
false,
|
|
263
|
+
),
|
|
264
|
+
).toThrow(/active insertion from another author/);
|
|
265
|
+
});
|
|
266
|
+
});
|