@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.18.2",
3
+ "version": "1.18.5",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -1735,30 +1735,35 @@ export class RedlineEngine {
1735
1735
  }
1736
1736
  }
1737
1737
  }
1738
- if (insAuthors.size > 0 || commentAuthors.size > 0) {
1738
+ if (insAuthors.size > 0) {
1739
1739
  // A single (strict/first) modification whose target lies ENTIRELY
1740
- // inside foreign-authored insertion(s), with no foreign comment
1741
- // overlap, is allowed: track_delete_run splits the enclosing <w:ins>
1742
- // and nests the change, producing valid tracked-change XML. Refuse the
1743
- // remaining cases — match_mode "all" fan-outs, partial overlaps that
1744
- // straddle the insertion boundary, and edits touching another author's
1745
- // comment range.
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
- (match_mode === "strict" || match_mode === "first") &&
1752
- fullyWithinForeignIns
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
- const nestedAuthors = new Set<string>([
1757
- ...insAuthors,
1758
- ...commentAuthors,
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: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
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
+ });